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,
|
files,
|
||||||
targetPath,
|
targetPath,
|
||||||
fetchBytes,
|
fetchBytes,
|
||||||
|
onStagedRevision,
|
||||||
saveBytes,
|
saveBytes,
|
||||||
createFile,
|
createFile,
|
||||||
docSource,
|
docSource,
|
||||||
|
|
@ -1008,6 +1009,12 @@ export function WasmTool({
|
||||||
boot?: import("@/lib/boot-payload").BootPayload | null;
|
boot?: import("@/lib/boot-payload").BootPayload | null;
|
||||||
/** Fetch one project-relative file's bytes (contract loader or local folder). */
|
/** Fetch one project-relative file's bytes (contract loader or local folder). */
|
||||||
fetchBytes: (relPath: string) => Promise<Uint8Array>;
|
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
|
* 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
|
* the wasm fires window.kicadCollab.onSave → this). API upload for backend
|
||||||
|
|
@ -2055,6 +2062,7 @@ export function WasmTool({
|
||||||
digest: boot?.projectSync.digest,
|
digest: boot?.projectSync.digest,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
onStagedRevision,
|
||||||
log: append,
|
log: append,
|
||||||
onStatus: setStatus,
|
onStatus: setStatus,
|
||||||
onFileProgress: (done, total) =>
|
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. */
|
/** Observe the server revision after an ambiguous save; never rebases this model. */
|
||||||
export async function refreshFileRevision(
|
export async function refreshFileRevision(
|
||||||
slug: string,
|
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.
|
* becomes a legal write precondition. Optional for non-CAS sources.
|
||||||
*/
|
*/
|
||||||
refreshFileRevision?(slug: string, relPath: string): Promise<void>;
|
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) ---------------------------
|
// --- remote (REST backend over the shared contract) ---------------------------
|
||||||
|
|
@ -254,6 +265,11 @@ function remoteProjectSource(): ProjectSource {
|
||||||
const file = res.body.find((candidate) => candidate.path === relPath);
|
const file = res.body.find((candidate) => candidate.path === relPath);
|
||||||
rememberObservedRevision(slug, relPath, file?.revision);
|
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
|
// Editor saves publish through the CAS PUT (findings D-3 client half): the
|
||||||
// expected revision is the model's ANCESTRY (baseRevisions), never the
|
// expected revision is the model's ANCESTRY (baseRevisions), never the
|
||||||
// latest observed metadata — so a save issued after a conflict cannot
|
// latest observed metadata — so a save issued after a conflict cannot
|
||||||
|
|
@ -490,6 +506,9 @@ function compositeProjectSource(
|
||||||
const s = await route(slug);
|
const s = await route(slug);
|
||||||
await s.refreshFileRevision?.(slug, p);
|
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 {
|
import {
|
||||||
createProjectFileIfMissing,
|
createProjectFileIfMissing,
|
||||||
fetchFileBytes,
|
fetchFileBytes,
|
||||||
|
rememberFileBaseRevision,
|
||||||
uploadFileBytes,
|
uploadFileBytes,
|
||||||
useProjectBoot,
|
useProjectBoot,
|
||||||
useSourceDescriptor,
|
useSourceDescriptor,
|
||||||
|
|
@ -100,6 +101,9 @@ export function ToolPage() {
|
||||||
fetchBytes={(relPath) =>
|
fetchBytes={(relPath) =>
|
||||||
fetchFileBytes(slug, relPath, filesByPath.get(relPath))
|
fetchFileBytes(slug, relPath, filesByPath.get(relPath))
|
||||||
}
|
}
|
||||||
|
onStagedRevision={(relPath, revision) =>
|
||||||
|
rememberFileBaseRevision(slug, relPath, revision)
|
||||||
|
}
|
||||||
saveBytes={
|
saveBytes={
|
||||||
readOnly
|
readOnly
|
||||||
? undefined
|
? undefined
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,13 @@ export interface DriveOptions {
|
||||||
targetPath?: string;
|
targetPath?: string;
|
||||||
fetchBytes: (relPath: string) => Promise<Uint8Array>;
|
fetchBytes: (relPath: string) => Promise<Uint8Array>;
|
||||||
projectSync?: ProjectSyncConfig | null;
|
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;
|
log: (msg: string) => void;
|
||||||
onStatus: (text: string) => void;
|
onStatus: (text: string) => void;
|
||||||
/** Per-file staging progress (files fetched+written so far, total) — drives
|
/** 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.
|
* the files that still need the per-file path. Exported for tests.
|
||||||
*/
|
*/
|
||||||
export async function stageViaProjectSync(
|
export async function stageViaProjectSync(
|
||||||
opts: Pick<DriveOptions, "slug" | "files" | "targetPath" | "log"> & {
|
opts: Pick<
|
||||||
|
DriveOptions,
|
||||||
|
"slug" | "files" | "targetPath" | "log" | "onStagedRevision"
|
||||||
|
> & {
|
||||||
projectSync?: ProjectSyncConfig | null;
|
projectSync?: ProjectSyncConfig | null;
|
||||||
},
|
},
|
||||||
stageOne: (path: string, bytes: Uint8Array) => void,
|
stageOne: (path: string, bytes: Uint8Array) => void,
|
||||||
|
|
@ -242,8 +252,13 @@ export async function stageViaProjectSync(
|
||||||
// The namespace manifest raced the project listing (file changed to
|
// The namespace manifest raced the project listing (file changed to
|
||||||
// ydoc-backed, or was just deleted/renamed): let the per-file path
|
// ydoc-backed, or was just deleted/renamed): let the per-file path
|
||||||
// answer authoritatively.
|
// answer authoritatively.
|
||||||
if (!bytes) missed.push(f);
|
if (!bytes) {
|
||||||
else stageOne(f.path, 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) {
|
if (missed.length) {
|
||||||
opts.log(
|
opts.log(
|
||||||
|
|
|
||||||
|
|
@ -168,3 +168,32 @@ describe("stageViaProjectSync", () => {
|
||||||
expect(rest).toEqual(files);
|
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