From 5650e6193fea0b57089a7997f77a267ee79737a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Tue, 25 Aug 2026 13:22:51 +0200 Subject: [PATCH] standalone: room-backed files skip the save upload (save-flow uploadPolicy) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In ydoc mode the collab room owns a document's state — items reach it at commit time, layout heads at save time (syncLayoutFromSave). The raw CAS PUT on Ctrl+S was only ever the file-list registration + fallback copy, but for a file the listing already marks hasYdoc/isLive it bumps the revision for nothing, can 409 the target into the durable save-blocked banner, and leaves a shadow row the ydoc supersedes on every read. registerSaveHook gains `uploadPolicy(relPath) → "upload" | "room"`; WasmTool marks the boot listing's ydoc/live files "room" (ydoc mode only). Files with no row yet (created sheets, the synthesized .kicad_pro) still upload; onSavedText still runs for room-backed saves. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012Wd1r3ewftpV1DBSEArpRa --- web/standalone/src/components/WasmTool.tsx | 11 ++++++++ web/standalone/src/wasm/save-flow.test.ts | 30 ++++++++++++++++++++++ web/standalone/src/wasm/save-flow.ts | 19 ++++++++++++++ 3 files changed, 60 insertions(+) diff --git a/web/standalone/src/components/WasmTool.tsx b/web/standalone/src/components/WasmTool.tsx index 8b729cf..5323708 100644 --- a/web/standalone/src/components/WasmTool.tsx +++ b/web/standalone/src/components/WasmTool.tsx @@ -1991,9 +1991,20 @@ export function WasmTool({ // Read-only sessions register neither upload nor the save-driven room // writers (onSaved onboarding, onSavedText layout sync) — saves, were // any reachable past the wasm lock, stay MEMFS-only. + // Room-backed files (ydoc/live in the boot listing) never upload on + // save in ydoc mode: the room owns their state (save-flow uploadPolicy). + // A file created this session, or one whose room is first seeded now, + // has no ydoc row yet and still uploads — that is the registration + + // first fallback copy the backend file list needs. + const roomBacked = new Set( + docSource === "ydoc" + ? files.filter((f) => f.hasYdoc || f.isLive).map((f) => f.path) + : [], + ); const saveHookHandle = registerSaveHook(win, { slug, saveBytes: readOnly ? undefined : saveBytes, + uploadPolicy: (relPath) => (roomBacked.has(relPath) ? "room" : "upload"), log: append, onStatus: setStatus, ...(readOnly diff --git a/web/standalone/src/wasm/save-flow.test.ts b/web/standalone/src/wasm/save-flow.test.ts index c375bd8..62d8ab8 100644 --- a/web/standalone/src/wasm/save-flow.test.ts +++ b/web/standalone/src/wasm/save-flow.test.ts @@ -552,3 +552,33 @@ describe("registerSaveHook lifetime", () => { expect(statuses.at(-1)).toBe("Saved board.kicad_pcb ✓"); }); }); + +describe("registerSaveHook uploadPolicy", () => { + it("a room-backed path is NOT uploaded; other paths still are", () => { + const saveBytes = vi.fn(async (_p: string, _b: Uint8Array) => SAVE_COMMITTED); + const onSavedText = vi.fn(); + const status: string[] = []; + const win: SaveHookWindow = { + FS: { readFile: () => new Uint8Array([40, 41]) } as unknown as SaveHookWindow["FS"], + kicadCollab: {}, + }; + registerSaveHook(win, { + slug: SLUG, + saveBytes, + onSavedText, + uploadPolicy: (relPath) => (relPath === "root.kicad_sch" ? "room" : "upload"), + log: () => {}, + onStatus: (t) => status.push(t), + }); + win.kicadCollab!.onSave!(`${PROJ}/root.kicad_sch`); + win.kicadCollab!.onSave!(`${PROJ}/root.kicad_pro`); + // Layout save-sync still observed the room-backed save. + expect(onSavedText.mock.calls.map((c) => c[0])).toEqual([ + "root.kicad_sch", + "root.kicad_pro", + ]); + expect(saveBytes).toHaveBeenCalledTimes(1); + expect(saveBytes.mock.calls[0]?.[0]).toBe("root.kicad_pro"); + expect(status.some((s) => /root\.kicad_sch.*collab room/.test(s))).toBe(true); + }); +}); diff --git a/web/standalone/src/wasm/save-flow.ts b/web/standalone/src/wasm/save-flow.ts index ad6f15b..96ed22c 100644 --- a/web/standalone/src/wasm/save-flow.ts +++ b/web/standalone/src/wasm/save-flow.ts @@ -113,6 +113,15 @@ export function registerSaveHook( maxPaths?: number; /** Durable per-path safety block; only hook retirement clears it. */ onBlocked?: (block: SaveBlock) => void; + /** + * Per-path upload policy. "room" ⇒ the file's live state is owned by its + * collab room (ydoc-backed in the listing): the save stays MEMFS-only and + * is NOT uploaded — the items already reached the room at commit time and + * `onSavedText` reconciled the layout heads, so a raw PUT would only bump + * the revision, risk a spurious CAS block on the target, and leave a stale + * shadow row the ydoc supersedes on every read. Absent/"upload" ⇒ upload. + */ + uploadPolicy?: (relPath: string) => "upload" | "room"; }, ): SaveHookHandle { const maxRetainedBytes = opts.maxRetainedBytes ?? MAX_RETAINED_SAVE_BYTES; @@ -323,6 +332,16 @@ export function registerSaveHook( statusClearTimer = undefined; } + if (opts.uploadPolicy?.(relPath) === "room") { + opts.log(`[save] ${relPath} is room-backed — synced via collab room, not uploaded`); + opts.onStatus(`Saved ${relPath} ✓ (collab room)`); + statusClearTimer = setTimeout(() => { + statusClearTimer = undefined; + if (generation === statusGeneration) opts.onStatus(""); + }, 2500); + return; + } + let data: Uint8Array; try { data = win.FS?.readFile(absPath) as Uint8Array;