pcbjam/features/yjs-bridge/0001-general-bridge-design.md
Gergő Törcsvári f94114efb8
docs(yjs-bridge): add collaborative Yjs bridge design
Approved design for porting KiCad-wasm editors to collaborative editing via Yjs:
- 0001 general (tool-agnostic) bridge contract: structured CRDT, unified
  ChangeSource + apply, the 3 serialization mechanisms, 4-commit phasing
- 0002 pl_editor (first PoC): identity, snapshot-differ adapter, field mapping
- 0003 eeschema, 0004 pcbnew: native-listener/COMMIT adapters

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:56:41 +02:00

228 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Yjs collaborative bridge — general design (tool-agnostic)
Branch: `feature/yjs-bridge` (root + kicad + wxwidgets).
Goal: a bidirectional bridge between a KiCad editor's in-memory document model and a Yjs
`Y.Doc`, so local UI edits become CRDT ops and remote CRDT ops become model mutations.
This file is the **shared architecture**. Per-tool specifics live in:
- `0002-pl-editor-bridge.md` — drawing-sheet editor (first PoC).
- `0003-eeschema-bridge.md` — schematic editor.
- `0004-pcbnew-bridge.md` — PCB editor.
Each tool doc states **only its differences** from this file.
Decisions taken (2026-06-03):
- **CRDT representation: structured** — `Y.Array` of one `Y.Map` per item (stable id + fields).
- **First PoC transport: in-browser, no server** — two tabs share a `Y.Doc` via
`BroadcastChannel`/`y-webrtc`. Server persistence is a later milestone.
- **First PoC target: pl_editor.**
- **One unified bridge contract for all three tools — no profile split** (see §1).
---
## 1. The unified bridge contract (one shape, per-tool adapters)
There is **one** bridge contract, identical for every tool. The C++ side exposes exactly two
things, and the JS side is fully generic:
```
┌───────────────────────── identical across all tools ─────────────────────────┐
C++ editor → ChangeSource → per-item delta JSON → reconciler.ts → Y.Doc(items: Y.Array<Y.Map>)
C++ editor ← apply(delta) ← per-item delta JSON ← reconciler.ts ← Y.Doc ← provider ← peer
└─ per-tool adapter ─┘ └──────────── generic, schema-agnostic ──┘
```
- **`ChangeSource` (emit):** produces a stream of **per-item change events** — `added` /
`removed` / `changed`, each carrying `{id, type, …fields}`. Emitted to JS as delta JSON.
- **`apply(delta)`:** consumes the same per-item delta JSON and applies it to the model.
Both directions speak the **same per-item vocabulary** for every tool. What differs is only the
**per-tool adapter** that *implements* `ChangeSource`/`apply` — an internal detail invisible to
the wire format, the JS reconciler, and the other tools:
| Tool | `ChangeSource` implementation | `apply` implementation | Identity |
|------|-------------------------------|------------------------|----------|
| eeschema | native `SCHEMATIC_LISTENER` | `SCH_COMMIT` Add/Modify/Remove + Push | native `KIID` |
| pcbnew | native `BOARD_LISTENER` (`OnBoardCompositeUpdate`) | `BOARD_COMMIT` Add/Modify/Remove + Push | native `KIID` |
| pl_editor | **in-bridge snapshot-differ** fired from `OnModify` | per-item mutate `DS_DATA_MODEL` by uuid + `SyncDrawItems` | **added** `KIID` (`0002`) |
eeschema/pcbnew get per-item events *natively* from their commit/listener systems. pl_editor
has none, so its adapter **derives** the same per-item events by uuid-diffing successive
whole-model snapshots inside the bridge (trivial at its scale) — and applies per-item by uuid.
The derivation is encapsulated in pl_editor's adapter; nothing downstream can tell the
difference. (We deliberately do **not** rewrite pl_editor's edit/undo core to a real `COMMIT` —
that would diverge heavily from upstream for no runtime benefit; decided 2026-06-03.)
So the only real per-tool work is: (1) pl_editor must **add** `KIID`+`(uuid)` to its format
(§2, `0002`); (2) eeschema/pcbnew must run the correct **recompute** on apply (connectivity /
ERC / ratsnest / hierarchy) by going through their native `COMMIT`.
---
## 2. Item identity (shared semantics)
A structured CRDT needs a per-item key that is (a) **identical on every client** and
(b) **stable across undo/redo**. The only way to guarantee both is for the id to live in the
**serialized form** of the item (so re-parsing — which undo does — preserves it, and two
clients opening the same file derive the same ids):
- **eeschema/pcbnew:** satisfied natively. Items derive from `EDA_ITEM`
(`include/eda_item.h:516` → `const KIID m_Uuid`) and every item writes `(uuid …)`. Nothing to
do; the uuid flows through KiCad ↔ JSON ↔ Y.Doc as the single source of truth, with **no
cross-layer identity assumptions**.
- **pl_editor:** *not* satisfied natively — items have no `KIID` and `.kicad_wks` has no uuid
token. We add **the same `KIID` + `(uuid …)`** the others use (not a bespoke id), so the three
tools share one identity scheme (`0002`). Once added, identity behaves identically.
New items minted locally get a fresh `KIID`; it appears in the next emit, the reconciler keys
a new `Y.Map` by it, and remote peers create the item with that same uuid on apply.
**Backfill caveat for files lacking uuids.** When a tool *adds* uuids to a format (pl_editor),
legacy/upstream files have none, so they're backfilled on load. `KIID` backfill is **random,
not a deterministic content-hash** — so two clients cold-opening the *same never-saved* file
would disagree. Resolve via the **seed-once rule**: the client that finds the `Y.Doc` empty
seeds it and its backfilled uuids win; joiners adopt the `Y.Doc` instead of their own parse.
The first save then persists uuids, making all later opens consistent. (Deterministic
content-seed backfill is an optional later hardening; see `0002`.)
---
## 3. Wire schema + representation granularity
**The contract is minimal and schema-agnostic:** an array of objects, each with a string `id`
and `type`, plus arbitrary additional fields. The JS reconciler hardcodes **no** field names.
Each item's fields can be represented at one of two granularities — **chosen per item-type on
the C++ side; the JS reconciler is identical either way** (it just diffs values keyed by field
name):
- **Decomposed scalars** — `{id, type, x, y, text, …}`. Enables *field-level* merge (two
people editing different fields of the same item never conflict). Best for small/hot types.
- **Opaque blob** — `{id, type, sexpr: "(segment …)"}` reusing the tool's native per-item
serializer. *Item-level* merge granularity (concurrent edits to the same item conflict).
Near-zero C++ mapping. Best for the long tail of complex types.
### Where the field values come from (don't hand-write N serializers)
There are three C++ mechanisms to produce the JSON for an item, in decreasing genericity. Pick
per tool; a tool may mix them and the JS reconciler is unaffected:
1. **`PROPERTY_MANAGER` reflection — write-once, all `EDA_ITEM` types (the "do it once" lever).**
`EDA_ITEM`-based items register their fields generically (`REGISTER_TYPE` + `AddProperty`,
e.g. `pcbnew/board_item.cpp:427+` — *"Position X"*, *"Layer"*, *"Locked"*, …). Enumerate
`propMgr.GetProperties(item->GetTypeId())`, read each via `PROPERTY_BASE::GetValue → wxAny`
→ JSON; write back via `PROPERTY_BASE::Set`. **One converter covers every registered type**
with field-level merge. Cost paid once: a `wxAny↔JSON` type bridge (ints/strings/bools,
enums, `EDA_ANGLE`/`VECTOR2I` units). Coverage isn't 100% — a few unregistered fields still
need (3). Primary path for **pcbnew** (broad coverage) and **eeschema** (partial).
2. **Native per-item S-expr blob — write-once *per tool that has one*.** pcbnew
`PCB_IO_KICAD_SEXPR::Format(BOARD_ITEM*)`; pl_editor `DS_DATA_MODEL_IO::Format(model, item)`
/ `SaveInString({item}, &str)`. Serializes *any* of that tool's items with no per-type code;
item-level merge only. (eeschema has no clean per-item formatter — document-level only.)
3. **Hand-mapped per-type fields.** Explicit, total control, works everywhere (incl. pl_editor,
which has neither reflection nor `EDA_ITEM`). Most code — reserve for tools with few types
(pl_editor's 5) or the hot fields you want field-level merge on.
(There is also `SERIALIZABLE`/`Serialize→protobuf::Any` on `EDA_ITEM`, but it targets the IPC
API — protobuf, partial coverage — wrong fit for JSON; not used here.)
A tool may mix granularities/mechanisms and refine over time **without any JS change**. Nested
data (polygon corner lists, bitmap blobs) is an opaque blob field or a nested `Y.Array`/`Y.Map`.
Numeric coordinates use the model's native units; the reconciler **quantizes before comparing**
to avoid spurious float diffs.
Optional **Zod schema as a validation guard only** at the apply boundary (reject garbage,
coerce number/bool/string) — never as a structural requirement.
---
## 4. The JS reconciler (`web/.../collab/`, identical for all tools)
A single generic module, parameterized only by the `Y.Doc` and the two bridge entry points:
- **Down (model → Y):** keep the last-seen item-set keyed by `id`. On each emitted per-item
delta, write **only** the changes into the `Y.Array<Y.Map>`, inside one `Y.transaction`
tagged with a local origin. (If an adapter ever emits a whole snapshot instead of a delta —
it shouldn't, but pl_editor's differ could — the reconciler still diffs it to minimal ops, so
the wire stays granular regardless.)
- **Up (Y → model):** observe the `Y.Array`; on remote-origin events, build the changed-item
delta JSON and call the C++ `apply` hook. Ignore events whose origin is our own tag
(standard Yjs echo-suppression).
- **Generic mapping:** an item ⇆ `Y.Map` is a flat copy of scalar fields; the diff is
value-equality per key. Adding a C++ field needs zero JS change.
The reconciler is **adapter-blind** — it only ever sees per-item deltas keyed by uuid.
---
## 5. Loop / re-entrancy safety (shared)
- **`s_applyingRemote` guard** in the C++ bridge: set around `apply`; emit is suppressed while
set. Prevents apply → (OnModify | listener fire) → emit → Y write → observe → apply … loops.
Needed by every adapter (a native listener would otherwise re-fire on our own apply; the
pl_editor differ would otherwise re-diff our own apply).
- **Asyncify timing:** apply is invoked from a JS callback and runs synchronously on the main
thread between frames (same context as the existing `OnFileDropped`/`OpenFileCallback`
hooks). It must not run mid-Asyncify-unwind — gate applies on the tool's ready/idle state and
queue any that arrive during a suspension.
- **Yjs origin tags** on all reconciler transactions; observers skip their own origin.
---
## 6. Transport & persistence (shared, phased)
- **PoC:** two tabs, one `Y.Doc`, `BroadcastChannel` (or `y-webrtc`). Zero backend work.
- **Later (M-server):** `y-websocket` provider (standalone or folded into the Fastify server in
`web/apps/server`) for networked sync + persistence; periodically materialize the Y.Doc back
to the canonical `.kicad_*` file via the tool's normal save path.
---
## 7. Shared C++ bridge interface
A thin, `#ifdef __EMSCRIPTEN__`-guarded interface each tool implements, so JS is uniform:
- **Emit → JS:** `EM_ASM({ window.kicadCollab.onDelta(UTF8ToString($0)) }, json)` — `json` is a
per-item delta. Same JS entry point for every tool/adapter.
- **Apply ← JS:** `EMSCRIPTEN_KEEPALIVE void kicadCollabApply(const char* json)` — dispatches to
the active tool's adapter; wrapped in the `s_applyingRemote` guard.
- Copy the proven wx-layer pattern (`EM_ASM` out, `ccall`→`EMSCRIPTEN_KEEPALIVE` in).
- The collab code that touches tool models lives in the **kicad** fork; keep it tightly guarded
and run `kicad-diff-stats.sh`. eeschema/pcbnew adapters add almost no surface (listener
subclass + COMMIT calls). pl_editor adds the most: the `(uuid)` format token + the snapshot-
differ adapter — but still leaves its edit/undo core untouched.
---
## 8. Phasing — one commit per step
1. **pl_editor interface/identity change.** `KIID m_Uuid` on `DS_DATA_ITEM` + `(uuid …)`
keyword/parse(backfill)/save via the shared `FormatUuid`. Independently testable (uuid
round-trip + legacy-file backfill-on-save). Commit. (`0002` §identity)
2. **pl_editor bridge + JSON converters (all 5 item types) + tests.** The snapshot-differ
`ChangeSource`, per-item `apply` by uuid, the generic JS reconciler, `BroadcastChannel`
transport, two-tab demo. Text/line/rect as scalars, poly/bitmap as blobs (`0002`). Commit.
3. **eeschema JSON converters + bridge + tests.** Adapter = `SCHEMATIC_LISTENER` + `SCH_COMMIT`;
converters reflection-first (§3 mechanism 1) + hand-map gaps; multi-sheet hierarchy + symbol
instances. Commit. (`0003`)
4. **pcbnew JSON converters + bridge + tests.** Adapter = `BOARD_LISTENER` + `BOARD_COMMIT`;
converters = reflection (1) for the bulk + native blob (2) for the long tail; mandatory
connectivity/ratsnest recompute; large-board batched initial-sync. Commit. (`0004`)
5. **Later — y-websocket + persistence** across all tools.
pl_editor goes first (steps 1–2) because its adapter is the only one we write from scratch and
it carries the least that can break (no connectivity, tiny docs); once the contract holds
there, the eeschema/pcbnew adapters are a thin re-use over native machinery.
---
## 9. Cross-cutting risks
- **Asyncify timing** of apply (§5) — needs a real under-load test.
- **Representation-granularity drift** — mixing blob and scalar per type must stay consistent
across clients (the `type`→granularity decision lives in C++ and must be deterministic).
- **Coordinate precision** — quantize before diffing (§3).
- **Recompute side-effects (eeschema/pcbnew)** — applying outside the normal UI flow must still
trigger connectivity/ERC/ratsnest/hierarchy; always go through the native COMMIT.
- **Initial sync cost (pcbnew)** — seeding a 100k-item board into a fresh `Y.Doc` is itself
heavy; needs a batched/own-phase strategy (see `0004`).
- **Fork divergence** — pl_editor changes the `.kicad_wks` file format (fork-only files; see
`0002`). eeschema/pcbnew adapters are near-zero divergence.