feat(standalone): Slot-model collab binding runtime (ysync 0008 Stage B)

The thin runtime over @pcbjam/shared's transport-unaware blocks — this module
owns exactly what shared must not: the observeDeep subscription, local-origin
echo policy, and seed-once authority.

- wasm/collab/kicad-binding.ts: KicadItemsBridge (the v2 per-item s-expr bridge
  contract: snapshotItems/applyItems/onItems), bindKicadCollab (DOWN: onItems →
  itemsWireToDelta → applyDeltaToY origin-tagged; UP: observeDeep → skip own
  origin → deltaFromYEvents → deltaToItemsWire → applyItems; seed-once with doc
  authority on join, mirroring the scalar reconciler §2), moduleItemsBridge
  adapter over the future Stage C Module exports.
- index.ts: startKicadCollab — the Slot-model counterpart of startCollab (same
  provider + whenSynced + seed flow); legacy scalar path untouched.
- debug.ts: clog/cwarn quiet + crash-free outside a DOM context (vitest node).
- kicad-binding.test.ts: fake editor bridge + two relayed Y.Docs — seed → adopt
  → edits propagate both ways → footprint removal cascades; no self-echo;
  divergent local root dropped on adopt (doc authority); destroy() detaches.

Verified: 19 standalone unit tests, collab bundle rebuilds, pl_editor two-tab
spec still green (legacy path unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergő Törcsvári 2026-06-11 11:06:28 +02:00
commit 8bbab863da
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
5 changed files with 398 additions and 1 deletions

@ -1 +1 @@
Subproject commit 26d2d7951ba6a9574ff5149fb1a29a2a69d9176c
Subproject commit cc9c73dba95188ad4a9d9d406c4868ac4c9fae14

View file

@ -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 {

View file

@ -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<KicadCollabHandle> {
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();
},
};
}

View file

@ -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<string, KicadItem> = {};
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,
);
});
});

View file

@ -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,
* wiredelta 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<string, KicadItem> => {
const view: Record<string, KicadItem> = {};
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<Y.Map<unknown>>[], 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 };
},
};
}