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:
parent
cf28e3be82
commit
b824eb007a
7 changed files with 121 additions and 3 deletions
|
|
@ -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 (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) =>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue