ysync 0009 phases 1-3: bump pcbjam-shared (y-sexpr v2 codec); SexprVersionError skew guard in kicad-binding

Refuse to bind docs written by an unsupported s-expr encoding (update
required); surfaced via WasmTool's fatal status path. Guard unit tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0142yAmMGZ3Ejrq3wwVr2Yq2
This commit is contained in:
Gergő Törcsvári 2026-07-10 10:29:56 +02:00
commit 33e59fde00
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
4 changed files with 47 additions and 3 deletions

@ -1 +1 @@
Subproject commit 1c4103c583b7326245a288a5ff63efe18dafcbf1
Subproject commit ce7e1e76464ee11bf3ef874016face590816e424

View file

@ -11,6 +11,7 @@ import type { CollabBridge } from "./types";
import {
bindKicadCollab,
moduleItemsBridge,
SexprVersionError,
type KicadBinding,
type KicadItemsModule,
type KicadItemsWindow,
@ -25,7 +26,7 @@ export {
type ProviderKind,
type YjsProvider,
} from "./provider";
export { bindKicadCollab, moduleItemsBridge };
export { bindKicadCollab, moduleItemsBridge, SexprVersionError };
export type { KicadBinding, KicadItemsModule, KicadItemsWindow };
export type { KicadItemsBridge } from "./kicad-binding";

View file

@ -7,11 +7,13 @@ import {
kicadLibSymbolsMap,
parseItemsWireDelta,
renderItem,
SEXPR_VERSION_CURRENT,
sexprToItems,
ydocSexprVersion,
yToDoc,
type KicadItem,
} from "@pcbjam/shared";
import { bindKicadCollab, type KicadItemsBridge } from "./kicad-binding";
import { bindKicadCollab, SexprVersionError, type KicadItemsBridge } from "./kicad-binding";
/**
* A fake editor implementing the v2 items bridge over an in-memory flattened
@ -342,3 +344,21 @@ describe("lib_symbols flow through the binding (miss 08A)", () => {
expect(symWire!.sexpr).toMatch(/^\(lib_symbols \(symbol "Device:R"/);
});
});
describe("sexprVersion skew guard (ysync 0009 §5)", () => {
it("binds a fresh (empty) room and a current-version doc", () => {
const { a, b } = pair();
const edA = new FakeEditor();
seedEditor(edA, FP);
bindKicadCollab(a, edA).seed(); // empty room: reads as v1, stamped CURRENT on write
expect(ydocSexprVersion(a)).toBe(SEXPR_VERSION_CURRENT);
expect(() => bindKicadCollab(b, new FakeEditor())).not.toThrow(); // peer joins the v2 doc
});
it("refuses to bind a doc written by a newer encoding (update required)", () => {
const doc = new Y.Doc();
doc.getMap("kdoc_meta").set("sexprVersion", SEXPR_VERSION_CURRENT + 1);
expect(() => bindKicadCollab(doc, new FakeEditor())).toThrow(SexprVersionError);
expect(() => bindKicadCollab(doc, new FakeEditor())).toThrow(/update required/);
});
});

View file

@ -10,12 +10,14 @@ import {
kicadLibSymbolsMap,
parseItemsWireDelta,
seedDocToY,
SEXPR_VERSION_SUPPORTED,
upsertLibSymbolsToY,
wireItemUuids,
wireLibSymbols,
Y_KDOC_META,
Y_KDOC_SEED_NONCE,
ydocHasState,
ydocSexprVersion,
yToItemUnchecked,
type ItemsWireDelta,
type KicadDoc,
@ -72,7 +74,28 @@ export interface KicadBinding {
readonly items: KicadYItems;
}
/**
* The doc uses an s-expr encoding this build cannot write (ysync 0009 §5's
* client skew guard). Binding anyway would mix versions in one doc a v1
* writer against a v2 doc corrupts the granularity contract so the bind is
* REFUSED; the app surfaces this as "update required".
*/
export class SexprVersionError extends Error {
constructor(readonly version: number) {
super(
`update required: document uses s-expr encoding v${version}; ` +
`this build supports v${SEXPR_VERSION_SUPPORTED.join(", v")}`,
);
this.name = "SexprVersionError";
}
}
export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBinding {
// Version skew guard — callers bind AFTER the provider's initial sync, so the
// doc's version is authoritative here (an empty room reads as v1 and is
// stamped CURRENT by the first write).
const version = ydocSexprVersion(doc);
if (!SEXPR_VERSION_SUPPORTED.includes(version)) throw new SexprVersionError(version);
const items = kicadItemsMap(doc);
// Opaque per-instance origin tag so we can distinguish our own writes from peers'.
const ORIGIN = { local: true };