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:
parent
b69ba09ebd
commit
8bbab863da
5 changed files with 398 additions and 1 deletions
|
|
@ -1 +1 @@
|
|||
Subproject commit 26d2d7951ba6a9574ff5149fb1a29a2a69d9176c
|
||||
Subproject commit cc9c73dba95188ad4a9d9d406c4868ac4c9fae14
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
167
web/standalone/src/wasm/collab/kicad-binding.test.ts
Normal file
167
web/standalone/src/wasm/collab/kicad-binding.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
177
web/standalone/src/wasm/collab/kicad-binding.ts
Normal file
177
web/standalone/src/wasm/collab/kicad-binding.ts
Normal 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,
|
||||
* 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<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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue