diff --git a/web/pcbjam-shared b/web/pcbjam-shared index 26d2d79..cc9c73d 160000 --- a/web/pcbjam-shared +++ b/web/pcbjam-shared @@ -1 +1 @@ -Subproject commit 26d2d7951ba6a9574ff5149fb1a29a2a69d9176c +Subproject commit cc9c73dba95188ad4a9d9d406c4868ac4c9fae14 diff --git a/web/standalone/src/wasm/collab/debug.ts b/web/standalone/src/wasm/collab/debug.ts index aeb68d3..c5b3494 100644 --- a/web/standalone/src/wasm/collab/debug.ts +++ b/web/standalone/src/wasm/collab/debug.ts @@ -1,7 +1,9 @@ // Lightweight collab debug logging to the BROWSER DEVTOOLS console (not the in-app // log panel). On by default while we bring the bridge up; silence with // `window.__COLLAB_DEBUG = false` (or `localStorage.collabDebug = "0"`). +// Quiet (and crash-free) outside a DOM context — e.g. the vitest node env. function on(): boolean { + if (typeof window === "undefined") return false; const w = window as unknown as { __COLLAB_DEBUG?: boolean }; if (typeof w.__COLLAB_DEBUG === "boolean") return w.__COLLAB_DEBUG; try { diff --git a/web/standalone/src/wasm/collab/index.ts b/web/standalone/src/wasm/collab/index.ts index 325921c..44d8d9a 100644 --- a/web/standalone/src/wasm/collab/index.ts +++ b/web/standalone/src/wasm/collab/index.ts @@ -7,6 +7,13 @@ import { } from "./provider"; import { createReconciler, type Reconciler } from "./reconciler"; import type { CollabBridge } from "./types"; +import { + bindKicadCollab, + moduleItemsBridge, + type KicadBinding, + type KicadItemsModule, + type KicadItemsWindow, +} from "./kicad-binding"; export type { CollabBridge, CollabDelta, CollabItem } from "./types"; export { createReconciler } from "./reconciler"; @@ -17,6 +24,9 @@ export { type ProviderKind, type YjsProvider, } from "./provider"; +export { bindKicadCollab, moduleItemsBridge }; +export type { KicadBinding, KicadItemsModule, KicadItemsWindow }; +export type { KicadItemsBridge } from "./kicad-binding"; /** The subset of the Emscripten Module the collab bridge needs (embind functions). */ export interface CollabModule { @@ -90,3 +100,44 @@ export async function startCollab( }, }; } + +export interface KicadCollabHandle { + doc: Y.Doc; + binding: KicadBinding; + provider: YjsProvider; + destroy(): void; +} + +/** + * The Slot-model counterpart of `startCollab` (ysync 0008): wires the v2 items + * bridge (kicadCollabSnapshotItems / ApplyItems / onItems — Stage C exports) into + * a Y.Doc holding the canonical `KicadDoc` representation. Same provider + + * seed-once flow as the legacy path; supersedes it once the wasm speaks the + * items wire (Stage D). + */ +export async function startKicadCollab( + mod: KicadItemsModule, + win: KicadItemsWindow, + opts: StartCollabOptions, +): Promise { + clog("startKicadCollab:", opts.provider.kind, "room =", opts.room); + const doc = new Y.Doc(); + const bridge = moduleItemsBridge(mod, win); + const binding = bindKicadCollab(doc, bridge); + const provider = await connectProvider(doc, opts.provider, { room: opts.room }); + + await provider.whenSynced(); + binding.seed(); + clog("startKicadCollab: ready; doc items =", binding.items.size); + + return { + doc, + binding, + provider, + destroy() { + binding.destroy(); + provider.destroy(); + doc.destroy(); + }, + }; +} diff --git a/web/standalone/src/wasm/collab/kicad-binding.test.ts b/web/standalone/src/wasm/collab/kicad-binding.test.ts new file mode 100644 index 0000000..82d888e --- /dev/null +++ b/web/standalone/src/wasm/collab/kicad-binding.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from "vitest"; +import * as Y from "yjs"; +import { + itemsWireToDelta, + parseItemsWireDelta, + renderItem, + sexprToItems, + type KicadItem, +} from "@pcbjam/shared"; +import { bindKicadCollab, type KicadItemsBridge } from "./kicad-binding"; + +/** + * A fake editor implementing the v2 items bridge over an in-memory flattened + * item store — the same semantics the Stage C C++ side will have: apply() is an + * idempotent per-item upsert/remove that does NOT re-emit (s_applyingRemote + * analogue); local edits mutate the store AND emit the items wire. + */ +class FakeEditor implements KicadItemsBridge { + store: Record = {}; + applied: string[] = []; // raw JSON of every applyItems call (echo assertions) + private emit: ((json: string) => void) | null = null; + + snapshotItems(): string { + const roots = Object.entries(this.store) + .filter(([, it]) => it.parent === null) + .map(([uuid]) => ({ + sexpr: renderItem({ items: this.store }, uuid), + parent: null, + })); + return JSON.stringify({ added: roots, changed: [], removed: [] }); + } + + applyItems(json: string): void { + this.applied.push(json); + this.applyToStore(json); // no emit — remote applies must not echo + } + + onItems(cb: (json: string) => void): void { + this.emit = cb; + } + + /** A local user edit: mutate the store, then emit (like OnModify → Format). */ + localUpsert(sexpr: string, parent: string | null = null, kind: "added" | "changed" = "changed"): void { + const json = JSON.stringify({ [kind]: [{ sexpr, parent }] }); + this.applyToStore(json); + this.emit?.(json); + } + + localRemove(uuid: string): void { + const json = JSON.stringify({ removed: [uuid] }); + this.applyToStore(json); + this.emit?.(json); + } + + private applyToStore(json: string): void { + const delta = itemsWireToDelta(parseItemsWireDelta(json), this.store); + for (const it of [...delta.added, ...delta.updated]) { + const { uuid, ...item } = it; + this.store[uuid] = item; + } + for (const uuid of delta.removed) delete this.store[uuid]; + } +} + +/** Two Y.Docs joined by relaying updates (stand-in for any provider). */ +function pair(): { a: Y.Doc; b: Y.Doc } { + const a = new Y.Doc(); + const b = new Y.Doc(); + a.on("update", (u: Uint8Array) => Y.applyUpdate(b, u, "relay")); + b.on("update", (u: Uint8Array) => Y.applyUpdate(a, u, "relay")); + return { a, b }; +} + +const FP = `(footprint "lib:R" (layer "F.Cu") (uuid "fp-1") (at 10 10) + (property "Reference" "R1" (at 0 -2) (uuid "fld-1")) + (pad "1" smd (at 0 0) (uuid "pad-1")))`; + +function seedEditor(ed: FakeEditor, sexpr: string): void { + const { uuid, items } = sexprToItems(sexpr); + void uuid; + Object.assign(ed.store, items); +} + +describe("bindKicadCollab — two editors over relayed Y.Docs", () => { + function setup() { + const { a, b } = pair(); + const edA = new FakeEditor(); + const edB = new FakeEditor(); + const bindA = bindKicadCollab(a, edA); + const bindB = bindKicadCollab(b, edB); + return { a, b, edA, edB, bindA, bindB }; + } + + it("seed → add → edit → remove propagates both ways; no self-echo", () => { + const { edA, edB, bindA, bindB } = setup(); + seedEditor(edA, FP); + bindA.seed(); // A is first: seeds the doc + bindB.seed(); // B joins: adopts the doc + + // B's editor received the footprint subtree via adopt. + expect(Object.keys(edB.store).sort()).toEqual(["fld-1", "fp-1", "pad-1"]); + + // B edits the pad locally → A's editor sees it. + edB.localUpsert(`(pad "1" smd (at 7 7) (uuid "pad-1"))`, "fp-1"); + expect(edA.store["pad-1"]!.body).toEqual( + sexprToItems(`(pad "1" smd (at 7 7) (uuid "pad-1"))`, "fp-1").items["pad-1"]!.body, + ); + + // A adds a free segment → B gets it. + edA.localUpsert(`(segment (start 0 0) (end 1 1) (uuid "seg-1"))`, null, "added"); + expect(edB.store["seg-1"]).toBeDefined(); + + // A removes the footprint → cascades to B's whole subtree. + edA.localRemove("fp-1"); + expect(Object.keys(edB.store).sort()).toEqual(["seg-1"]); + + // Echo suppression: every applyItems an editor received came from the PEER's + // edits (adopt + peer changes), never from its own emits bouncing back. + for (const json of edA.applied) { + const wire = parseItemsWireDelta(json); + // A's own edits were seg-1 add + fp-1 remove; they must not appear. + expect(wire.added.map((w) => w.sexpr).join()).not.toContain("seg-1"); + expect(wire.removed).not.toContain("fp-1"); + } + }); + + it("a remote apply does not bounce back to the originator", () => { + const { edA, edB, bindA, bindB } = setup(); + seedEditor(edA, FP); + bindA.seed(); + bindB.seed(); + const appliedOnB = edB.applied.length; + + edB.localUpsert(`(pad "1" smd (at 3 3) (uuid "pad-1"))`, "fp-1"); + // B's own edit: nothing new applied on B (only A receives an apply). + expect(edB.applied.length).toBe(appliedOnB); + expect(edA.applied.length).toBeGreaterThan(0); + }); + + it("adopt removes divergent local-only roots (doc authority)", () => { + const { edA, edB, bindA, bindB } = setup(); + seedEditor(edA, FP); + bindA.seed(); + + // B cold-opened the same file unsaved → its local model has a DIFFERENT uuid. + seedEditor(edB, `(footprint "lib:R" (layer "F.Cu") (uuid "fp-DIVERGENT") (at 10 10))`); + bindB.seed(); + + expect(edB.store["fp-DIVERGENT"]).toBeUndefined(); // dropped + expect(edB.store["fp-1"]).toBeDefined(); // adopted + }); + + it("destroy() detaches the editor from further remote changes", () => { + const { edA, edB, bindA, bindB } = setup(); + seedEditor(edA, FP); + bindA.seed(); + bindB.seed(); + + bindB.destroy(); + edA.localUpsert(`(pad "1" smd (at 9 9) (uuid "pad-1"))`, "fp-1"); + + // B's Y.Doc still received the update (provider-level), but its editor didn't. + expect(edB.store["pad-1"]!.body).toEqual( + sexprToItems(`(pad "1" smd (at 0 0) (uuid "pad-1"))`, "fp-1").items["pad-1"]!.body, + ); + }); +}); diff --git a/web/standalone/src/wasm/collab/kicad-binding.ts b/web/standalone/src/wasm/collab/kicad-binding.ts new file mode 100644 index 0000000..4ecdae6 --- /dev/null +++ b/web/standalone/src/wasm/collab/kicad-binding.ts @@ -0,0 +1,177 @@ +import * as Y from "yjs"; +import { + applyDeltaToY, + deltaFromYEvents, + deltaToItemsWire, + isEmptyItemsWireDelta, + isEmptyKicadDelta, + itemsWireToDelta, + kicadItemsMap, + parseItemsWireDelta, + renderItem, + yToItem, + type ItemsWireDelta, + type KicadItem, + type KicadYItems, +} from "@pcbjam/shared"; +import { clog, cwarn } from "./debug"; + +/** + * The Slot-model collab binding (ysync 0008 Stage B) — the THIN RUNTIME over the + * shared, transport-unaware building blocks. This module owns exactly what + * `@pcbjam/shared` must not: the `observeDeep` subscription, the local-origin + * echo policy, and seed-once authority. Everything data-shaped — wire schemas, + * wire⇄delta conversion, Y reads/writes — is the shared lib. + * + * DOWN (editor → Y): bridge.onItems(json) → itemsWireToDelta(wire, Y items) + * → applyDeltaToY (transaction tagged with our origin). + * UP (Y → editor): items.observeDeep → skip own origin → deltaFromYEvents + * → deltaToItemsWire (full subtree sexprs) → bridge.applyItems. + * + * The bridge speaks the v2 "items" wire: per-item s-expr + parent uuid — the C++ + * exports kicadCollabSnapshotItems / kicadCollabApplyItems / onItems (Stage C). + * Until those land in the wasm, the binding is exercised by unit tests with a + * fake editor bridge (kicad-binding.test.ts). + */ + +/** The v2 per-item s-expr bridge (Stage C C++ contract), runtime-adapted. */ +export interface KicadItemsBridge { + /** Full current model as an all-`added` ItemsWireDelta JSON. */ + snapshotItems(): string; + /** Apply a remote ItemsWireDelta JSON (per-item Parse + splice by uuid). */ + applyItems(json: string): void; + /** Register the local-edit emit hook (Format changed items → JSON). */ + onItems(cb: (json: string) => void): void; +} + +export interface KicadBinding { + /** + * Seed-once join: if the shared doc holds no items this client seeds it from + * the editor snapshot; otherwise the editor adopts the doc (doc authority — + * local-only roots are removed, doc roots applied). Call once after the + * doc/provider are connected. + */ + seed(): void; + destroy(): void; + /** The underlying kdoc items map (exposed for tests/inspection). */ + readonly items: KicadYItems; +} + +export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBinding { + const items = kicadItemsMap(doc); + // Opaque per-instance origin tag so we can distinguish our own writes from peers'. + const ORIGIN = { local: true }; + + /** Plain snapshot of the Y items (the `current`/`view` the conversions need). */ + const itemsView = (): Record => { + const view: Record = {}; + items.forEach((ym, uuid) => { + view[uuid] = yToItem(ym); + }); + return view; + }; + + // DOWN: local editor change → Y.Doc + bridge.onItems((json: string) => { + let wire: ItemsWireDelta; + try { + wire = parseItemsWireDelta(json); + } catch (err) { + cwarn("⬇ onItems from wasm: UNPARSEABLE", err, json); + return; + } + const delta = itemsWireToDelta(wire, itemsView()); + if (isEmptyKicadDelta(delta)) return; + clog("⬇ onItems (local edit):", { + added: delta.added.length, + updated: delta.updated.length, + removed: delta.removed.length, + }); + applyDeltaToY(doc, delta, ORIGIN); + }); + + // UP: remote Y change → editor. The subscription + origin policy live HERE + // (the runtime); the event→delta computation is the shared default impl. + const observer = (events: Y.YEvent>[], txn: Y.Transaction) => { + if (txn.origin === ORIGIN) return; // our own echo — ignore + const delta = deltaFromYEvents(items, events); + if (isEmptyKicadDelta(delta)) return; + const wire = deltaToItemsWire(delta, itemsView()); + if (isEmptyItemsWireDelta(wire)) return; + clog("⬆ remote Y change → apply to editor:", { + added: wire.added.length, + changed: wire.changed.length, + removed: wire.removed.length, + }); + bridge.applyItems(JSON.stringify(wire)); + }; + items.observeDeep(observer); + + function seed(): void { + let wire: ItemsWireDelta; + try { + wire = parseItemsWireDelta(bridge.snapshotItems()); + } catch (err) { + cwarn("seed: snapshotItems unparseable", err); + return; + } + const local = itemsWireToDelta(wire, {}); + + clog( + `seed: doc has ${items.size} item(s), editor has ${local.added.length} →`, + items.size === 0 ? "SEEDING doc (first tab)" : "ADOPTING doc (joining)", + ); + + if (items.size === 0) { + // First tab: seed the shared doc from the editor model. + applyDeltaToY(doc, local, ORIGIN); + return; + } + + // Joining a populated doc: the editor adopts it (seed-once authority, same + // rationale as the scalar reconciler §2 — divergent local uuids from a + // never-saved cold open must yield to the doc's identity). Apply the doc's + // ROOT items (their sexprs embed all descendants) and remove local-only roots. + const view = itemsView(); + const docRoots = Object.entries(view) + .filter(([, item]) => item.parent === null) + .map(([uuid]) => ({ sexpr: renderItem({ items: view }, uuid), parent: null })); + const removed = local.added + .filter((it) => it.parent === null && !(it.uuid in view)) + .map((it) => it.uuid); + bridge.applyItems(JSON.stringify({ added: docRoots, changed: [], removed })); + } + + return { + seed, + destroy: () => items.unobserveDeep(observer), + items, + }; +} + +// ── Live wasm adapter ───────────────────────────────────────────────────────── + +/** The Stage C Module exports + window hook, as the browser exposes them. */ +export interface KicadItemsModule { + kicadCollabSnapshotItems(): string; + kicadCollabApplyItems(json: string): void; +} + +export interface KicadItemsWindow { + kicadCollab?: { onItems?: (json: string) => void }; +} + +/** Adapt a live wasm Module + window to the bridge interface. */ +export function moduleItemsBridge( + mod: KicadItemsModule, + win: KicadItemsWindow, +): KicadItemsBridge { + return { + snapshotItems: () => mod.kicadCollabSnapshotItems(), + applyItems: (json) => mod.kicadCollabApplyItems(json), + onItems: (cb) => { + // Preserve any sibling hooks (e.g. the legacy onDelta) on the global. + win.kicadCollab = { ...win.kicadCollab, onItems: cb }; + }, + }; +}