standalone: room-backed files skip the save upload (save-flow uploadPolicy)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Wd1r3ewftpV1DBSEArpRa
This commit is contained in:
Gergő Törcsvári 2026-08-25 13:22:51 +02:00
commit 5650e6193f
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
3 changed files with 60 additions and 0 deletions

View file

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

View file

@ -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);
});
});

View file

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