diff --git a/web/standalone/src/components/WasmTool.tsx b/web/standalone/src/components/WasmTool.tsx index 0ce6718..8b729cf 100644 --- a/web/standalone/src/components/WasmTool.tsx +++ b/web/standalone/src/components/WasmTool.tsx @@ -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; + /** + * 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 (File→Save 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) => diff --git a/web/standalone/src/lib/api.ts b/web/standalone/src/lib/api.ts index 8976705..591734a 100644 --- a/web/standalone/src/lib/api.ts +++ b/web/standalone/src/lib/api.ts @@ -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, diff --git a/web/standalone/src/lib/project-source-cache.test.ts b/web/standalone/src/lib/project-source-cache.test.ts index 57cba93..e3a7504 100644 --- a/web/standalone/src/lib/project-source-cache.test.ts +++ b/web/standalone/src/lib/project-source-cache.test.ts @@ -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; + }; + expect(init.method).toBe("PUT"); + const revisionHeader = Object.entries(init.headers).find( + ([k]) => k.toLowerCase().includes("revision"), + ); + expect(revisionHeader?.[1]).toBe("1"); + }); +}); diff --git a/web/standalone/src/lib/project-source.ts b/web/standalone/src/lib/project-source.ts index 9484a9c..e82a9ed 100644 --- a/web/standalone/src/lib/project-source.ts +++ b/web/standalone/src/lib/project-source.ts @@ -80,6 +80,17 @@ export interface ProjectSource { * becomes a legal write precondition. Optional for non-CAS sources. */ refreshFileRevision?(slug: string, relPath: string): Promise; + /** + * 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)); + }, }; } diff --git a/web/standalone/src/pages/ToolPage.tsx b/web/standalone/src/pages/ToolPage.tsx index ca4730f..d7fcf0d 100644 --- a/web/standalone/src/pages/ToolPage.tsx +++ b/web/standalone/src/pages/ToolPage.tsx @@ -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 diff --git a/web/standalone/src/wasm/kicad-runner.ts b/web/standalone/src/wasm/kicad-runner.ts index 205c91e..4904d5c 100644 --- a/web/standalone/src/wasm/kicad-runner.ts +++ b/web/standalone/src/wasm/kicad-runner.ts @@ -52,6 +52,13 @@ export interface DriveOptions { targetPath?: string; fetchBytes: (relPath: string) => Promise; 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 & { + 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( diff --git a/web/standalone/src/wasm/project-stage-sync.test.ts b/web/standalone/src/wasm/project-stage-sync.test.ts index f504ba5..8e7963b 100644 --- a/web/standalone/src/wasm/project-stage-sync.test.ts +++ b/web/standalone/src/wasm/project-stage-sync.test.ts @@ -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], + ]); + }); +});