standalone: record CAS base revision for sync-bundle-staged files

Files staged from the project sync namespace bundle (stageViaProjectSync)
never passed through fetchFileBytes, the only place baseRevisions was set,
so their first save PUT carried expected revision 0 and 409'd against any
row ever re-saved — e.g. assigning a footprint (CvPcb → eeschema root save
also writes .kicad_pro) failed with "Save conflict … (local base 0,
server 1)".

Add ProjectSource.rememberBaseRevision + api.rememberFileBaseRevision,
DriveOptions.onStagedRevision reported by stageViaProjectSync with the
listing revision, and wire it through WasmTool from ToolPage.

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-24 19:36:51 +02:00
commit b824eb007a
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
7 changed files with 121 additions and 3 deletions

View file

@ -973,6 +973,7 @@ export function WasmTool({
files,
targetPath,
fetchBytes,
onStagedRevision,
saveBytes,
createFile,
docSource,
@ -1008,6 +1009,12 @@ export function WasmTool({
boot?: import("@/lib/boot-payload").BootPayload | null;
/** Fetch one project-relative file's bytes (contract loader or local folder). */
fetchBytes: (relPath: string) => Promise<Uint8Array>;
/**
* Files staged from the project sync namespace bundle never pass through
* `fetchBytes`; this reports their listing revision so the source can record
* the CAS ancestry `saveBytes` must publish against (see DriveOptions).
*/
onStagedRevision?: (relPath: string, revision: number) => void;
/**
* Persist one file the user saved in the editor (FileSave writes MEMFS, then
* the wasm fires window.kicadCollab.onSave this). API upload for backend
@ -2055,6 +2062,7 @@ export function WasmTool({
digest: boot?.projectSync.digest,
}
: null,
onStagedRevision,
log: append,
onStatus: setStatus,
onFileProgress: (done, total) =>

View file

@ -152,6 +152,19 @@ export async function uploadFileBytes(
}
}
/**
* Record a file's CAS ancestry when its bytes were staged WITHOUT going through
* `fetchFileBytes` (project sync namespace bundle). See
* ProjectSource.rememberBaseRevision.
*/
export function rememberFileBaseRevision(
slug: string,
relPath: string,
revision: number,
): void {
projectSource().rememberBaseRevision?.(slug, relPath, revision);
}
/** Observe the server revision after an ambiguous save; never rebases this model. */
export async function refreshFileRevision(
slug: string,

View file

@ -249,3 +249,33 @@ describe("remote source file-body cache", () => {
);
});
});
describe("remote source CAS ancestry for bundle-staged files", () => {
it("rememberBaseRevision sets the expected revision of the next PUT", async () => {
// A file staged from the project sync namespace never passes through
// fetchFileBytes; without rememberBaseRevision its first save would carry
// expected revision 0 and 409 against the real row (assign-footprints
// .kicad_pro conflict: "local base 0, server 1").
const source = await loadRemote(cacheMock());
const fetchMock = vi.fn(async () => ({
ok: true,
status: 200,
headers: { get: (h: string) => (h === "x-pcbjam-file-revision" ? "2" : null) },
json: async () => ({ revision: 2 }),
}));
vi.stubGlobal("fetch", fetchMock);
const relPath = "Leonardo/Arduino Leonardo.kicad_pro";
source().rememberBaseRevision?.("leo", relPath, 1);
await source().uploadFileBytes!("leo", relPath, new Uint8Array([1]));
expect(fetchMock).toHaveBeenCalledTimes(1);
const init = (fetchMock.mock.calls[0] as unknown[])[1] as {
method: string;
headers: Record<string, string>;
};
expect(init.method).toBe("PUT");
const revisionHeader = Object.entries(init.headers).find(
([k]) => k.toLowerCase().includes("revision"),
);
expect(revisionHeader?.[1]).toBe("1");
});
});

View file

@ -80,6 +80,17 @@ export interface ProjectSource {
* becomes a legal write precondition. Optional for non-CAS sources.
*/
refreshFileRevision?(slug: string, relPath: string): Promise<void>;
/**
* Record that the in-memory model of `relPath` was built from `revision`
* WITHOUT this source having served the bytes the project sync namespace
* stages plain files from one bundle (kicad-runner stageViaProjectSync), so
* `fetchFileBytes` never sees them. Without this the first CAS PUT of such
* a file goes out with expected revision 0 and 409s against any row that
* was ever re-saved (e.g. `.kicad_pro` at revision 1 "local base 0,
* server 1"). The revision comes from the listing the namespace snapshot
* was vouched against, so it IS the model's ancestry, not merely observed.
*/
rememberBaseRevision?(slug: string, relPath: string, revision: number): void;
}
// --- remote (REST backend over the shared contract) ---------------------------
@ -254,6 +265,11 @@ function remoteProjectSource(): ProjectSource {
const file = res.body.find((candidate) => candidate.path === relPath);
rememberObservedRevision(slug, relPath, file?.revision);
},
rememberBaseRevision(slug, relPath, revision) {
if (!Number.isSafeInteger(revision) || revision < 0) return;
rememberObservedRevision(slug, relPath, revision);
baseRevisions.set(revisionKey(slug, relPath), revision);
},
// Editor saves publish through the CAS PUT (findings D-3 client half): the
// expected revision is the model's ANCESTRY (baseRevisions), never the
// latest observed metadata — so a save issued after a conflict cannot
@ -490,6 +506,9 @@ function compositeProjectSource(
const s = await route(slug);
await s.refreshFileRevision?.(slug, p);
},
rememberBaseRevision: (slug, p, revision) => {
void route(slug).then((s) => s.rememberBaseRevision?.(slug, p, revision));
},
};
}

View file

@ -5,6 +5,7 @@ import { Loader2 } from "lucide-react";
import {
createProjectFileIfMissing,
fetchFileBytes,
rememberFileBaseRevision,
uploadFileBytes,
useProjectBoot,
useSourceDescriptor,
@ -100,6 +101,9 @@ export function ToolPage() {
fetchBytes={(relPath) =>
fetchFileBytes(slug, relPath, filesByPath.get(relPath))
}
onStagedRevision={(relPath, revision) =>
rememberFileBaseRevision(slug, relPath, revision)
}
saveBytes={
readOnly
? undefined

View file

@ -52,6 +52,13 @@ export interface DriveOptions {
targetPath?: string;
fetchBytes: (relPath: string) => Promise<Uint8Array>;
projectSync?: ProjectSyncConfig | null;
/**
* A file staged from the sync namespace bundle bypassed `fetchBytes`, so the
* project source never learned which revision the model was built from.
* Called with the listing's revision for every such file so the CAS save
* path (uploadFileBytes) publishes against the right ancestry instead of 0.
*/
onStagedRevision?: (relPath: string, revision: number) => void;
log: (msg: string) => void;
onStatus: (text: string) => void;
/** Per-file staging progress (files fetched+written so far, total) drives
@ -200,7 +207,10 @@ async function syncProjectToMemfs(win: ToolWindow, opts: DriveOptions): Promise<
* the files that still need the per-file path. Exported for tests.
*/
export async function stageViaProjectSync(
opts: Pick<DriveOptions, "slug" | "files" | "targetPath" | "log"> & {
opts: Pick<
DriveOptions,
"slug" | "files" | "targetPath" | "log" | "onStagedRevision"
> & {
projectSync?: ProjectSyncConfig | null;
},
stageOne: (path: string, bytes: Uint8Array) => void,
@ -242,8 +252,13 @@ export async function stageViaProjectSync(
// The namespace manifest raced the project listing (file changed to
// ydoc-backed, or was just deleted/renamed): let the per-file path
// answer authoritatively.
if (!bytes) missed.push(f);
else stageOne(f.path, bytes);
if (!bytes) {
missed.push(f);
continue;
}
stageOne(f.path, bytes);
// `eligible` requires revision > 0, so this is always a real row.
if (f.revision !== undefined) opts.onStagedRevision?.(f.path, f.revision);
}
if (missed.length) {
opts.log(

View file

@ -168,3 +168,32 @@ describe("stageViaProjectSync", () => {
expect(rest).toEqual(files);
});
});
describe("stageViaProjectSync CAS ancestry", () => {
it("reports the listing revision for every namespace-staged file", async () => {
// A bundle-staged file bypasses fetchFileBytes, so the source would
// otherwise publish its first save against revision 0 → 409 "local base 0,
// server N" (the .kicad_pro assign-footprints conflict).
const server = fakeSyncServer({ "a.kicad_pro": "(pro)", "b.txt": "B" });
const files: ToolFile[] = [
{ path: "a.kicad_pro", revision: 1 },
{ path: "b.txt", revision: 4 },
{ path: "missing.txt", revision: 2 },
];
const seen: Array<[string, number]> = [];
await stageViaProjectSync(
{
slug: "p",
files,
projectSync: syncConfig(server.fetchImpl),
log: () => {},
onStagedRevision: (path, revision) => seen.push([path, revision]),
},
() => {},
);
expect(seen.sort()).toEqual([
["a.kicad_pro", 1],
["b.txt", 4],
]);
});
});