feat(save): D group — client save lane + CAS PUT re-created from codex reference
The standalone's save path was a bare multipart POST: no per-path lane, no revision tracking, 'Saved ✓' on any 2xx (findings D-1/D-2/D-3 client half — the 8e3a886 server CAS + smoke oracle landed without it). Re-created fresh: - save-flow.ts: full persistence lane — per-path active+pending snapshots, capacity admission before the byte copy, committed-ONLY promotion, conflict/unknown outcomes install a durable path block, status generations, SaveHookHandle.stop() aborts transports (D-9 teardown superseding the interim unregister). +17 unit tests (codex suite, green unmodified). - project-source.ts: D-1 two-map revision tracking (baseRevisions = model ancestry and the only legal write precondition; observedRevisions = metadata), seeded from listing rows, download headers, and cache hits; uploadFileBytes is now the CAS PUT with x-pcbjam-file-revision and 409 / pre-publish / ambiguous-outcome classification; refreshFileRevision. - api.ts / idb-project-store / HomePage local sources / ToolPage / NewFileDialog / persistCreatedSheet: SaveOutcome contract threaded through. Validated end-to-end by apps/tests editor-save-lane.spec.ts against the live stack (revision 1 learned on load, PUT base 1 → 200 → server revision 2). Standalone units 365/365, tsc clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UZJ1pUePb4W47hGoLMYTw4
This commit is contained in:
parent
453df4fb9c
commit
4e089ae455
9 changed files with 1037 additions and 50 deletions
|
|
@ -111,7 +111,14 @@ export function NewFileDialog({
|
|||
slug = created.slug;
|
||||
} else {
|
||||
slug = project ? project.slug : target;
|
||||
for (const f of files) await uploadFileBytes(slug, f.path, f.bytes);
|
||||
for (const f of files) {
|
||||
const outcome = await uploadFileBytes(slug, f.path, f.bytes);
|
||||
if (outcome.kind !== "committed") {
|
||||
throw new Error(
|
||||
outcome.message ?? `upload did not commit: ${f.path}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Full navigation so Emscripten boots into a clean page opening the file.
|
||||
// A home-created project is browser-local (@local scope); an in-project new
|
||||
|
|
|
|||
|
|
@ -879,7 +879,16 @@ function persistCreatedSheet(
|
|||
const bytes = win.FS?.readFile(memfsFilePath(slug, relPath));
|
||||
if (!(bytes instanceof Uint8Array)) return;
|
||||
void saveBytes(relPath, bytes)
|
||||
.then(() => log(`[sheet] registered created subsheet ${relPath} (${bytes.length} bytes)`))
|
||||
.then((outcome) => {
|
||||
if (outcome.kind === "committed") {
|
||||
log(`[sheet] registered created subsheet ${relPath} (${bytes.length} bytes)`);
|
||||
} else {
|
||||
cwarn(
|
||||
`[sheet] upload of created subsheet ${relPath} did not commit`,
|
||||
outcome,
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err) => cwarn(`[sheet] upload of created subsheet ${relPath} failed`, err));
|
||||
} catch (err) {
|
||||
cwarn(`[sheet] read of created subsheet ${relPath} failed`, err);
|
||||
|
|
@ -1636,6 +1645,9 @@ export function WasmTool({
|
|||
// cleanup below closes over it — and never rejected, so aborting mid-sync
|
||||
// leaks nothing and throws nothing.
|
||||
const presyncAbort = new AbortController();
|
||||
// The save sink is a global slot (D-9): keep its teardown edge so a
|
||||
// remount can't leave the dead mount uploading + publishing status.
|
||||
let unregisterSaveHook: (() => void) | null = null;
|
||||
// The libs source THIS boot created (vs. one injected via props, which the
|
||||
// caller owns) — cleanup disposes it so its SyncStack sockets don't outlive
|
||||
// the editor.
|
||||
|
|
@ -1850,7 +1862,7 @@ 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.
|
||||
registerSaveHook(win, {
|
||||
const saveHookHandle = registerSaveHook(win, {
|
||||
slug,
|
||||
saveBytes: readOnly ? undefined : saveBytes,
|
||||
log: append,
|
||||
|
|
@ -1881,6 +1893,7 @@ export function WasmTool({
|
|||
},
|
||||
}),
|
||||
});
|
||||
unregisterSaveHook = () => saveHookHandle.stop();
|
||||
// The room connect started in the fan-out above — settle it before
|
||||
// staging so the target file materializes from the doc when it has one.
|
||||
const docResult = await docSessionReady;
|
||||
|
|
@ -2233,6 +2246,9 @@ export function WasmTool({
|
|||
presyncAbort.abort();
|
||||
win.removeEventListener("keydown", swallowBrowserSave, true);
|
||||
win.removeEventListener("keydown", chromeHotkey, true);
|
||||
// The global save sink must not outlive this mount (D-9).
|
||||
unregisterSaveHook?.();
|
||||
unregisterSaveHook = null;
|
||||
// Every collab surface + any not-yet-adopted doc session (C-1/C-7).
|
||||
teardownCollab();
|
||||
// Close the lib SyncStacks this boot opened (mirror mux + any dedicated
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
projectSource,
|
||||
} from "./project-source";
|
||||
import type { SourceDescriptor } from "./project-source-shared";
|
||||
import { SAVE_COMMITTED, type SaveOutcome } from "../wasm/save-flow";
|
||||
|
||||
/**
|
||||
* Project/file reads go through the active PROJECT SOURCE (lib/project-source.ts):
|
||||
|
|
@ -132,24 +133,33 @@ export async function uploadFileBytes(
|
|||
slug: string,
|
||||
relPath: string,
|
||||
bytes: Uint8Array,
|
||||
): Promise<void> {
|
||||
signal?: AbortSignal,
|
||||
): Promise<SaveOutcome> {
|
||||
const source = projectSource();
|
||||
if (!source.uploadFileBytes) {
|
||||
downloadBytes(relPath, bytes);
|
||||
return;
|
||||
return SAVE_COMMITTED;
|
||||
}
|
||||
try {
|
||||
await source.uploadFileBytes(slug, relPath, bytes);
|
||||
return await source.uploadFileBytes(slug, relPath, bytes, signal);
|
||||
} catch (e) {
|
||||
// Composite write to a read-only (gallery) project → fall back to download.
|
||||
if (e instanceof ReadOnlyProjectError) {
|
||||
downloadBytes(relPath, bytes);
|
||||
return;
|
||||
return SAVE_COMMITTED;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** Observe the server revision after an ambiguous save; never rebases this model. */
|
||||
export async function refreshFileRevision(
|
||||
slug: string,
|
||||
relPath: string,
|
||||
): Promise<void> {
|
||||
await projectSource().refreshFileRevision?.(slug, relPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a project file that a tool switch found missing (WasmTool's nav
|
||||
* hook): write `bytes` at `relPath` unless the file already exists on the
|
||||
|
|
@ -168,7 +178,10 @@ export async function createProjectFileIfMissing(
|
|||
if (!source.uploadFileBytes) throw new ReadOnlyProjectError(slug);
|
||||
const { files } = await source.getProject(slug);
|
||||
if (files.some((file) => file.path === relPath)) return;
|
||||
await source.uploadFileBytes(slug, relPath, bytes);
|
||||
const outcome = await source.uploadFileBytes(slug, relPath, bytes);
|
||||
if (outcome.kind !== "committed") {
|
||||
throw new Error(outcome.message ?? `file creation did not commit: ${relPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- collaboration drift reporting (ysync; backend-only) ---
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
type ProjectWithFiles,
|
||||
} from "@pcbjam/shared";
|
||||
import type { ProjectSource } from "./project-source";
|
||||
import { SAVE_COMMITTED } from "../wasm/save-flow";
|
||||
import {
|
||||
SOURCE_DESCRIPTORS,
|
||||
deterministicUuid,
|
||||
|
|
@ -198,13 +199,16 @@ export function idbProjectStore(): LocalProjectStore {
|
|||
return v.bytes;
|
||||
},
|
||||
|
||||
async uploadFileBytes(slug, relPath, bytes): Promise<void> {
|
||||
async uploadFileBytes(slug, relPath, bytes) {
|
||||
const d = await db();
|
||||
const tx = d.transaction(FILES, "readwrite");
|
||||
const rec: FileRecord = { slug, path: relPath, size: bytes.length, bytes };
|
||||
tx.objectStore(FILES).put(rec, fileKey(slug, relPath));
|
||||
await txDone(tx);
|
||||
await touch(d, slug);
|
||||
// Local IDB is single-writer per browser profile — an awaited put IS the
|
||||
// commit (SaveOutcome contract, findings D-2).
|
||||
return SAVE_COMMITTED;
|
||||
},
|
||||
|
||||
async hasProject(slug: string): Promise<boolean> {
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@ import {
|
|||
type Project,
|
||||
type ProjectFile,
|
||||
type ProjectWithFiles,
|
||||
PROJECT_FILE_REVISION_HEADER,
|
||||
YDOC_CONTENT_TYPE,
|
||||
ydocUpdateToKicadDoc,
|
||||
} from "@pcbjam/shared";
|
||||
import { SAVE_COMMITTED, type SaveOutcome } from "../wasm/save-flow";
|
||||
import {
|
||||
API_BASE_URL,
|
||||
LOCAL_PROJECTS_ENABLED,
|
||||
|
|
@ -60,12 +62,24 @@ export interface ProjectSource {
|
|||
relPath: string,
|
||||
meta?: ProjectFile,
|
||||
): Promise<Uint8Array>;
|
||||
/** Present only on writable sources; absent ⇒ read-only (download on save). */
|
||||
/**
|
||||
* Present only on writable sources; absent ⇒ read-only (download on save).
|
||||
* The outcome is the save lane's contract (findings D-1/D-2): only
|
||||
* `committed` promotes a queued successor; `conflict`/`unknown` block the
|
||||
* path until an authoritative reload.
|
||||
*/
|
||||
uploadFileBytes?(
|
||||
slug: string,
|
||||
relPath: string,
|
||||
bytes: Uint8Array,
|
||||
): Promise<void>;
|
||||
signal?: AbortSignal,
|
||||
): Promise<SaveOutcome>;
|
||||
/**
|
||||
* Observe one path's latest server revision without downloading its body.
|
||||
* This is diagnostic metadata only: it never rebases the in-memory model or
|
||||
* becomes a legal write precondition. Optional for non-CAS sources.
|
||||
*/
|
||||
refreshFileRevision?(slug: string, relPath: string): Promise<void>;
|
||||
}
|
||||
|
||||
// --- remote (REST backend over the shared contract) ---------------------------
|
||||
|
|
@ -84,6 +98,42 @@ function remoteProjectSource(): ProjectSource {
|
|||
`${API_BASE_URL}/api/scopes/${encodeURIComponent(currentScope())}/projects`;
|
||||
const fileUrl = (slug: string, relPath: string) =>
|
||||
`${projectsBase()}/${encodeURIComponent(slug)}/files/${encodePath(relPath)}`;
|
||||
// CAS revision tracking (findings D-1): TWO maps because "revision I saw on
|
||||
// the server" and "revision my in-memory model was built from" are different
|
||||
// facts — conflating them let a fresh Ctrl+S after a conflict silently
|
||||
// overwrite the winner. Only `baseRevisions` is a legal write precondition.
|
||||
const baseRevisions = new Map<string, number>();
|
||||
const observedRevisions = new Map<string, number>();
|
||||
const revisionKey = (slug: string, relPath: string) =>
|
||||
`${currentScope()}\u0000${slug}\u0000${relPath}`;
|
||||
const rememberObservedRevision = (
|
||||
slug: string,
|
||||
relPath: string,
|
||||
revision: number | undefined,
|
||||
): void => {
|
||||
if (revision !== undefined && Number.isSafeInteger(revision) && revision >= 0) {
|
||||
observedRevisions.set(revisionKey(slug, relPath), revision);
|
||||
}
|
||||
};
|
||||
const rememberFiles = (slug: string, files: ProjectFile[]): void => {
|
||||
for (const file of files) {
|
||||
rememberObservedRevision(slug, file.path, file.revision);
|
||||
}
|
||||
};
|
||||
const rememberResponseRevision = (
|
||||
slug: string,
|
||||
relPath: string,
|
||||
response: Response,
|
||||
): number | undefined => {
|
||||
const text = response.headers.get(PROJECT_FILE_REVISION_HEADER);
|
||||
if (text === null || !/^\d+$/.test(text)) return undefined;
|
||||
const value = Number(text);
|
||||
if (Number.isSafeInteger(value)) {
|
||||
observedRevisions.set(revisionKey(slug, relPath), value);
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
return {
|
||||
descriptor: SOURCE_DESCRIPTORS["remote-rw"],
|
||||
readOnly: false,
|
||||
|
|
@ -98,6 +148,7 @@ function remoteProjectSource(): ProjectSource {
|
|||
});
|
||||
if (res.status === 404) throw new Error("project not found");
|
||||
if (res.status !== 200) throw new Error("failed to load project");
|
||||
rememberFiles(slug, res.body.files);
|
||||
// Fresh listing = fresh cache truth: drop cached bodies this listing no
|
||||
// longer vouches for (deleted paths, superseded revisions). Best-effort.
|
||||
const valid = new Map<string, string>();
|
||||
|
|
@ -114,7 +165,16 @@ function remoteProjectSource(): ProjectSource {
|
|||
const validator = meta ? fileCacheValidator(meta) : null;
|
||||
if (validator && meta) {
|
||||
const hit = await readCachedFileBytes(meta.projectId, relPath, validator);
|
||||
if (hit) return hit;
|
||||
if (hit) {
|
||||
// The cached body IS the listed row's body — its revision is the
|
||||
// ancestry the model is about to be built from.
|
||||
const listed = meta.revision;
|
||||
rememberObservedRevision(slug, relPath, listed);
|
||||
if (listed !== undefined && Number.isSafeInteger(listed) && listed >= 0) {
|
||||
baseRevisions.set(revisionKey(slug, relPath), listed);
|
||||
}
|
||||
return hit;
|
||||
}
|
||||
}
|
||||
// credentials: session-cookie auth (see contract-client.ts). The static
|
||||
// gallery fetches below stay credential-less — a CDN's wildcard CORS
|
||||
|
|
@ -132,6 +192,14 @@ function remoteProjectSource(): ProjectSource {
|
|||
headers: { accept: `${YDOC_CONTENT_TYPE}, */*` },
|
||||
});
|
||||
if (!res.ok) throw new Error(`download failed (${res.status}): ${relPath}`);
|
||||
// These bytes become the in-memory model — their revision is the CAS
|
||||
// ancestry every later Ctrl+S publishes against (D-1: base, not merely
|
||||
// observed). Holds for the ydoc-materialized form too: the header still
|
||||
// names the row the doc supersedes, which is the row CAS guards.
|
||||
const responseRevision = rememberResponseRevision(slug, relPath, res);
|
||||
if (responseRevision !== undefined) {
|
||||
baseRevisions.set(revisionKey(slug, relPath), responseRevision);
|
||||
}
|
||||
const bytes = new Uint8Array(await res.arrayBuffer());
|
||||
// A converted ydoc body may be cached ONLY under the ydoc-form validator
|
||||
// (blob fingerprint) — never under `revision:updatedAt`, whose row does
|
||||
|
|
@ -165,6 +233,10 @@ function remoteProjectSource(): ProjectSource {
|
|||
if (!plain.ok) {
|
||||
throw new Error(`download failed (${plain.status}): ${relPath} (${String(err)})`);
|
||||
}
|
||||
const plainRevision = rememberResponseRevision(slug, relPath, plain);
|
||||
if (plainRevision !== undefined) {
|
||||
baseRevisions.set(revisionKey(slug, relPath), plainRevision);
|
||||
}
|
||||
const materialized = new Uint8Array(await plain.arrayBuffer());
|
||||
// Caching the fallback under the blob tag ends the double-fetch for
|
||||
// stale unconvertible ydocs — one per tag instead of two per load.
|
||||
|
|
@ -174,18 +246,118 @@ function remoteProjectSource(): ProjectSource {
|
|||
return materialized;
|
||||
}
|
||||
},
|
||||
async uploadFileBytes(slug, relPath, bytes) {
|
||||
const name = relPath.split("/").pop() ?? relPath;
|
||||
const form = new FormData();
|
||||
// The form FIELD NAME carries the project-relative path (upsert by
|
||||
// (project, path)) — same convention as the management app's folder upload.
|
||||
form.append(relPath, new File([bytes as BlobPart], name));
|
||||
const res = await fetch(`${projectsBase()}/${encodeURIComponent(slug)}/files`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
credentials: "include",
|
||||
async refreshFileRevision(slug, relPath) {
|
||||
const res = await client.listFiles({
|
||||
params: { scope: currentScope(), project: slug },
|
||||
});
|
||||
if (!res.ok) throw new Error(`upload failed (${res.status}): ${relPath}`);
|
||||
if (res.status !== 200) throw new Error("failed to refresh file revision");
|
||||
const file = res.body.find((candidate) => candidate.path === relPath);
|
||||
rememberObservedRevision(slug, relPath, file?.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
|
||||
// silently overwrite the winner. The multipart POST remains the bulk
|
||||
// import path only (deliberately unconditional; see files.ts route note).
|
||||
async uploadFileBytes(slug, relPath, bytes, signal) {
|
||||
const key = revisionKey(slug, relPath);
|
||||
const expectedRevision = baseRevisions.get(key) ?? 0;
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(fileUrl(slug, relPath), {
|
||||
method: "PUT",
|
||||
body: bytes as BodyInit,
|
||||
credentials: "include",
|
||||
signal,
|
||||
headers: {
|
||||
"content-type": "application/octet-stream",
|
||||
[PROJECT_FILE_REVISION_HEADER]: String(expectedRevision),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Even an AbortError can arrive after the server published the CAS but
|
||||
// before this client received its acknowledgement. Hook retirement
|
||||
// ignores the result; a live caller must treat it as ambiguous.
|
||||
return {
|
||||
kind: "unknown",
|
||||
message: `Save state unknown: ${relPath} — reload or save a copy`,
|
||||
};
|
||||
}
|
||||
const responseRevision = rememberResponseRevision(slug, relPath, res);
|
||||
if (res.status === 409) {
|
||||
const conflict = (await res.json().catch(() => null)) as {
|
||||
current?: ProjectFile | null;
|
||||
} | null;
|
||||
rememberObservedRevision(slug, relPath, conflict?.current?.revision);
|
||||
const headerText = res.headers.get(PROJECT_FILE_REVISION_HEADER);
|
||||
const headerRevision =
|
||||
headerText !== null && /^\d+$/.test(headerText)
|
||||
? Number(headerText)
|
||||
: NaN;
|
||||
const currentRevision =
|
||||
conflict?.current?.revision ??
|
||||
(Number.isSafeInteger(headerRevision) && headerRevision >= 0
|
||||
? headerRevision
|
||||
: 0);
|
||||
return {
|
||||
kind: "conflict",
|
||||
message: `Save conflict: ${relPath} (local base ${expectedRevision}, server ${currentRevision}) — reload or merge, or save a copy`,
|
||||
};
|
||||
}
|
||||
if (!res.ok) {
|
||||
// These answers are produced before body publication. A generic 5xx
|
||||
// can happen after publication but before the response is encoded, so
|
||||
// its commit state is unknown and the hook must block the path.
|
||||
const prePublish = new Set([400, 401, 403, 404, 413, 415, 428]);
|
||||
return prePublish.has(res.status)
|
||||
? {
|
||||
kind: "not-committed",
|
||||
message: `Save failed (${res.status}): ${relPath}`,
|
||||
}
|
||||
: {
|
||||
kind: "unknown",
|
||||
message: `Save state unknown (${res.status}): ${relPath} — reload or save a copy`,
|
||||
};
|
||||
}
|
||||
let savedRevision: number | undefined;
|
||||
try {
|
||||
const saved = (await res.json()) as Partial<ProjectFile>;
|
||||
if (
|
||||
Number.isSafeInteger(saved.revision) &&
|
||||
(saved.revision as number) >= 0
|
||||
) {
|
||||
savedRevision = saved.revision;
|
||||
rememberObservedRevision(slug, relPath, savedRevision);
|
||||
}
|
||||
} catch {
|
||||
// A conforming response also carries the revision header, so malformed
|
||||
// JSON need not make an otherwise acknowledged commit ambiguous.
|
||||
}
|
||||
if (
|
||||
savedRevision !== undefined &&
|
||||
responseRevision !== undefined &&
|
||||
savedRevision !== responseRevision
|
||||
) {
|
||||
return {
|
||||
kind: "unknown",
|
||||
message: `Save returned inconsistent revisions for ${relPath} — reload or save a copy`,
|
||||
};
|
||||
}
|
||||
const committedRevision = savedRevision ?? responseRevision;
|
||||
if (committedRevision === undefined) {
|
||||
return {
|
||||
kind: "unknown",
|
||||
message: `Save committed without a revision: ${relPath} — reload or save a copy`,
|
||||
};
|
||||
}
|
||||
if (committedRevision <= expectedRevision) {
|
||||
return {
|
||||
kind: "unknown",
|
||||
message: `Save returned a non-advancing revision for ${relPath} — reload or save a copy`,
|
||||
};
|
||||
}
|
||||
baseRevisions.set(key, committedRevision);
|
||||
return SAVE_COMMITTED;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -308,12 +480,16 @@ function compositeProjectSource(
|
|||
getProject: (slug) => route(slug).then((s) => s.getProject(slug)),
|
||||
fetchFileBytes: (slug, p, meta) =>
|
||||
route(slug).then((s) => s.fetchFileBytes(slug, p, meta)),
|
||||
uploadFileBytes: async (slug, p, bytes) => {
|
||||
uploadFileBytes: async (slug, p, bytes, signal) => {
|
||||
const s = await route(slug);
|
||||
if (s.uploadFileBytes) return s.uploadFileBytes(slug, p, bytes);
|
||||
if (s.uploadFileBytes) return s.uploadFileBytes(slug, p, bytes, signal);
|
||||
// Read-only gallery project being edited → download (api.ts also guards).
|
||||
throw new ReadOnlyProjectError(slug);
|
||||
},
|
||||
refreshFileRevision: async (slug, p) => {
|
||||
const s = await route(slug);
|
||||
await s.refreshFileRevision?.(slug, p);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import { ToolGrid } from "@/components/ToolGrid";
|
|||
import { ProjectsSection } from "@/components/ProjectsSection";
|
||||
import { WaitlistForm } from "@/components/WaitlistForm";
|
||||
import { NewFileDialog } from "@/components/NewFileDialog";
|
||||
import type { SaveBytes } from "@/wasm/save-flow";
|
||||
import { SAVE_COMMITTED, type SaveBytes } from "@/wasm/save-flow";
|
||||
import { LocalProjectView, type LocalFile } from "@/components/LocalProjectView";
|
||||
import { StorageUsageCard } from "@/components/StorageUsageCard";
|
||||
import { WasmTool } from "@/components/WasmTool";
|
||||
|
|
@ -82,6 +82,8 @@ async function buildFsaProject(root: FileSystemDirectoryHandle): Promise<LocalPr
|
|||
const writable = await handle.createWritable();
|
||||
await writable.write(bytes as unknown as FileSystemWriteChunkType);
|
||||
await writable.close();
|
||||
// An awaited close IS the local-disk commit (SaveOutcome contract).
|
||||
return SAVE_COMMITTED;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -111,7 +113,10 @@ function buildLocalProject(fileList: FileList): LocalProject {
|
|||
return new Uint8Array(await f.arrayBuffer());
|
||||
},
|
||||
// A webkitdirectory FileList is read-only — saves become downloads.
|
||||
saveBytes: async (relPath, bytes) => downloadBytes(relPath, bytes),
|
||||
saveBytes: async (relPath, bytes) => {
|
||||
downloadBytes(relPath, bytes);
|
||||
return SAVE_COMMITTED;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -103,7 +103,8 @@ export function ToolPage() {
|
|||
saveBytes={
|
||||
readOnly
|
||||
? undefined
|
||||
: (relPath, bytes) => uploadFileBytes(slug, relPath, bytes)
|
||||
: (relPath, bytes, signal) =>
|
||||
uploadFileBytes(slug, relPath, bytes, signal)
|
||||
}
|
||||
createFile={
|
||||
readOnly
|
||||
|
|
|
|||
|
|
@ -1,13 +1,35 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { MEMFS_PROJECTS_DIR } from "./constants";
|
||||
import { registerSaveHook, type SaveHookWindow } from "./save-flow";
|
||||
import {
|
||||
registerSaveHook,
|
||||
SAVE_COMMITTED,
|
||||
type SaveBlock,
|
||||
type SaveHookWindow,
|
||||
type SaveOutcome,
|
||||
} from "./save-flow";
|
||||
|
||||
const SLUG = "myproj";
|
||||
const HOME = MEMFS_PROJECTS_DIR; // …/projects (editor's default "projects home")
|
||||
const PROJ = `${HOME}/${SLUG}`; // …/projects/myproj (this project's own folder)
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason: unknown) => void;
|
||||
const promise = new Promise<T>((yes, no) => {
|
||||
resolve = yes;
|
||||
reject = no;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function flushMicrotasks(): Promise<void> {
|
||||
for (let i = 0; i < 8; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
function setup() {
|
||||
const saveBytes = vi.fn(async () => {});
|
||||
const saveBytes = vi.fn(async () => SAVE_COMMITTED);
|
||||
const onSaved = vi.fn();
|
||||
const win: SaveHookWindow = {
|
||||
FS: { readFile: () => new Uint8Array([1, 2, 3]) } as unknown as SaveHookWindow["FS"],
|
||||
|
|
@ -22,14 +44,22 @@ describe("registerSaveHook path routing", () => {
|
|||
const { fire, saveBytes, onSaved } = setup();
|
||||
fire(`${PROJ}/sub/sheet.kicad_sch`);
|
||||
expect(onSaved).toHaveBeenCalledWith("sub/sheet.kicad_sch");
|
||||
expect(saveBytes).toHaveBeenCalledWith("sub/sheet.kicad_sch", expect.any(Uint8Array));
|
||||
expect(saveBytes).toHaveBeenCalledWith(
|
||||
"sub/sheet.kicad_sch",
|
||||
expect.any(Uint8Array),
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
});
|
||||
|
||||
it("routes a bare file saved in the editor's default projects home to the project root", () => {
|
||||
const { fire, saveBytes, onSaved } = setup();
|
||||
fire(`${HOME}/main.kicad_sch`);
|
||||
expect(onSaved).toHaveBeenCalledWith("main.kicad_sch");
|
||||
expect(saveBytes).toHaveBeenCalledWith("main.kicad_sch", expect.any(Uint8Array));
|
||||
expect(saveBytes).toHaveBeenCalledWith(
|
||||
"main.kicad_sch",
|
||||
expect.any(Uint8Array),
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores a save outside the projects tree", () => {
|
||||
|
|
@ -81,3 +111,444 @@ describe("registerSaveHook path routing", () => {
|
|||
expect(log).toHaveBeenCalledWith(expect.stringContaining("onSavedText read failed"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("registerSaveHook persistence ordering", () => {
|
||||
it("coalesces 1,000 same-path saves to one active and the immutable latest", async () => {
|
||||
const source = new Uint8Array([0, 0]);
|
||||
const writes: Array<{
|
||||
path: string;
|
||||
bytes: Uint8Array;
|
||||
completion: ReturnType<typeof deferred<SaveOutcome>>;
|
||||
}> = [];
|
||||
const saveBytes = vi.fn((path: string, bytes: Uint8Array) => {
|
||||
const completion = deferred<SaveOutcome>();
|
||||
writes.push({ path, bytes, completion });
|
||||
return completion.promise;
|
||||
});
|
||||
const win: SaveHookWindow = {
|
||||
FS: { readFile: () => source } as unknown as SaveHookWindow["FS"],
|
||||
kicadCollab: {},
|
||||
};
|
||||
registerSaveHook(win, {
|
||||
slug: SLUG,
|
||||
saveBytes,
|
||||
log: () => {},
|
||||
onStatus: () => {},
|
||||
});
|
||||
|
||||
for (let revision = 0; revision < 1_000; revision++) {
|
||||
source[0] = revision & 0xff;
|
||||
source[1] = revision >>> 8;
|
||||
win.kicadCollab!.onSave!(`${PROJ}/board.kicad_pcb`);
|
||||
}
|
||||
|
||||
expect(saveBytes).toHaveBeenCalledTimes(1);
|
||||
expect([...writes[0]!.bytes]).toEqual([0, 0]);
|
||||
writes[0]!.completion.resolve(SAVE_COMMITTED);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(saveBytes).toHaveBeenCalledTimes(2);
|
||||
expect(writes.map((write) => write.path)).toEqual([
|
||||
"board.kicad_pcb",
|
||||
"board.kicad_pcb",
|
||||
]);
|
||||
expect([...writes[1]!.bytes]).toEqual([999 & 0xff, 999 >>> 8]);
|
||||
writes[1]!.completion.resolve(SAVE_COMMITTED);
|
||||
await flushMicrotasks();
|
||||
expect(saveBytes).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("drops a not-committed latest, releases capacity, and permits an explicit retry", async () => {
|
||||
const source = new Uint8Array([1]);
|
||||
const writes: Array<{
|
||||
bytes: Uint8Array;
|
||||
completion: ReturnType<typeof deferred<SaveOutcome>>;
|
||||
}> = [];
|
||||
const saveBytes = vi.fn((_path: string, bytes: Uint8Array) => {
|
||||
const completion = deferred<SaveOutcome>();
|
||||
writes.push({ bytes, completion });
|
||||
return completion.promise;
|
||||
});
|
||||
const statuses: string[] = [];
|
||||
const win: SaveHookWindow = {
|
||||
FS: { readFile: () => source } as unknown as SaveHookWindow["FS"],
|
||||
kicadCollab: {},
|
||||
};
|
||||
registerSaveHook(win, {
|
||||
slug: SLUG,
|
||||
saveBytes,
|
||||
log: () => {},
|
||||
onStatus: (status) => statuses.push(status),
|
||||
maxRetainedBytes: 2,
|
||||
});
|
||||
|
||||
win.kicadCollab!.onSave!(`${PROJ}/board.kicad_pcb`);
|
||||
source[0] = 2;
|
||||
win.kicadCollab!.onSave!(`${PROJ}/board.kicad_pcb`);
|
||||
writes[0]!.completion.resolve({
|
||||
kind: "not-committed",
|
||||
message: "Save rejected before publication",
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
// The newest status generation belongs to the pending snapshot, but it
|
||||
// cannot be promoted without proof that its predecessor committed.
|
||||
expect(saveBytes).toHaveBeenCalledTimes(1);
|
||||
expect(statuses.at(-1)).toBe("Save rejected before publication");
|
||||
|
||||
// A not-committed result is safe to retry explicitly, and both retained
|
||||
// bytes were released before this new snapshot is admitted.
|
||||
source[0] = 3;
|
||||
win.kicadCollab!.onSave!(`${PROJ}/board.kicad_pcb`);
|
||||
expect(saveBytes).toHaveBeenCalledTimes(2);
|
||||
expect([...writes[1]!.bytes]).toEqual([3]);
|
||||
writes[1]!.completion.resolve(SAVE_COMMITTED);
|
||||
await flushMicrotasks();
|
||||
expect(statuses.at(-1)).toBe("Saved board.kicad_pcb ✓");
|
||||
});
|
||||
|
||||
it("absorbs a conflict, keeps callbacks alive, and leaves other paths usable", async () => {
|
||||
const source = new Uint8Array([1]);
|
||||
const writes: Array<ReturnType<typeof deferred<SaveOutcome>>> = [];
|
||||
const saveBytes = vi.fn((_path: string, _bytes: Uint8Array) => {
|
||||
const completion = deferred<SaveOutcome>();
|
||||
writes.push(completion);
|
||||
return completion.promise;
|
||||
});
|
||||
const statuses: string[] = [];
|
||||
const blocks: SaveBlock[] = [];
|
||||
const onSaved = vi.fn();
|
||||
const onSavedText = vi.fn();
|
||||
const readFile = vi.fn(() => source);
|
||||
const win: SaveHookWindow = {
|
||||
FS: { readFile } as unknown as SaveHookWindow["FS"],
|
||||
kicadCollab: {},
|
||||
};
|
||||
registerSaveHook(win, {
|
||||
slug: SLUG,
|
||||
saveBytes,
|
||||
onSaved,
|
||||
onSavedText,
|
||||
log: () => {},
|
||||
onStatus: (status) => statuses.push(status),
|
||||
onBlocked: (block) => blocks.push(block),
|
||||
// Active + pending fill the cap. Starting a later explicit save proves
|
||||
// conflict retirement released both snapshots exactly once.
|
||||
maxRetainedBytes: 2,
|
||||
});
|
||||
|
||||
win.kicadCollab!.onSave!(`${PROJ}/board.kicad_pcb`); // active A
|
||||
source[0] = 2;
|
||||
win.kicadCollab!.onSave!(`${PROJ}/board.kicad_pcb`); // captured B
|
||||
writes[0]!.resolve({
|
||||
kind: "conflict",
|
||||
message:
|
||||
"Save conflict: board.kicad_pcb (local base 4, server 5) — reload or merge, or save a copy",
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
// B was captured under revision 4. It must never be promoted or retried
|
||||
// against a merely observed remote revision.
|
||||
expect(saveBytes).toHaveBeenCalledTimes(1);
|
||||
expect(blocks).toEqual([
|
||||
{
|
||||
relPath: "board.kicad_pcb",
|
||||
kind: "conflict",
|
||||
message:
|
||||
"Save conflict: board.kicad_pcb (local base 4, server 5) — reload or merge, or save a copy",
|
||||
},
|
||||
]);
|
||||
expect(statuses.at(-1)).toContain("reload or merge");
|
||||
|
||||
// A third native Save notification still feeds collaboration callbacks,
|
||||
// but the poisoned path does not read a persistence snapshot or call PUT.
|
||||
source[0] = 3;
|
||||
win.kicadCollab!.onSave!(`${PROJ}/board.kicad_pcb`);
|
||||
expect(onSaved).toHaveBeenCalledTimes(3);
|
||||
expect(onSavedText).toHaveBeenCalledTimes(3);
|
||||
expect(saveBytes).toHaveBeenCalledTimes(1);
|
||||
// A and B each read once for text and once for persistence. C reads only
|
||||
// for the still-live text callback; it creates no byte snapshot.
|
||||
expect(readFile).toHaveBeenCalledTimes(5);
|
||||
|
||||
// Conflict retirement released active + pending exactly once. Another
|
||||
// path can use the full one-byte capacity and remains independent.
|
||||
win.kicadCollab!.onSave!(`${PROJ}/other.kicad_sch`);
|
||||
expect(saveBytes).toHaveBeenCalledTimes(2);
|
||||
expect(saveBytes.mock.calls[1]![0]).toBe("other.kicad_sch");
|
||||
writes[1]!.resolve(SAVE_COMMITTED);
|
||||
});
|
||||
|
||||
it("turns an unexpected throw into an absorbing unknown block", async () => {
|
||||
const completion = deferred<SaveOutcome>();
|
||||
const saveBytes = vi.fn(() => completion.promise);
|
||||
const onBlocked = vi.fn();
|
||||
const onSaved = vi.fn();
|
||||
const win: SaveHookWindow = {
|
||||
FS: { readFile: () => new Uint8Array([1]) } as unknown as SaveHookWindow["FS"],
|
||||
kicadCollab: {},
|
||||
};
|
||||
registerSaveHook(win, {
|
||||
slug: SLUG,
|
||||
saveBytes,
|
||||
onSaved,
|
||||
onBlocked,
|
||||
log: () => {},
|
||||
onStatus: () => {},
|
||||
});
|
||||
|
||||
win.kicadCollab!.onSave!(`${PROJ}/board.kicad_pcb`);
|
||||
completion.reject(new Error("connection disappeared"));
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(onBlocked).toHaveBeenCalledWith({
|
||||
relPath: "board.kicad_pcb",
|
||||
kind: "unknown",
|
||||
message: "Save state unknown: board.kicad_pcb — reload or save a copy",
|
||||
});
|
||||
win.kicadCollab!.onSave!(`${PROJ}/board.kicad_pcb`);
|
||||
expect(onSaved).toHaveBeenCalledTimes(2);
|
||||
expect(saveBytes).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps different paths concurrent", () => {
|
||||
const completions: Array<ReturnType<typeof deferred<SaveOutcome>>> = [];
|
||||
const saveBytes = vi.fn((_path: string, _bytes: Uint8Array) => {
|
||||
const completion = deferred<SaveOutcome>();
|
||||
completions.push(completion);
|
||||
return completion.promise;
|
||||
});
|
||||
const win: SaveHookWindow = {
|
||||
FS: { readFile: () => new Uint8Array([1]) } as unknown as SaveHookWindow["FS"],
|
||||
kicadCollab: {},
|
||||
};
|
||||
registerSaveHook(win, {
|
||||
slug: SLUG,
|
||||
saveBytes,
|
||||
log: () => {},
|
||||
onStatus: () => {},
|
||||
});
|
||||
|
||||
win.kicadCollab!.onSave!(`${PROJ}/a.kicad_sch`);
|
||||
win.kicadCollab!.onSave!(`${PROJ}/b.kicad_sch`);
|
||||
|
||||
expect(saveBytes).toHaveBeenCalledTimes(2);
|
||||
expect(saveBytes.mock.calls.map(([path]) => path)).toEqual([
|
||||
"a.kicad_sch",
|
||||
"b.kicad_sch",
|
||||
]);
|
||||
for (const completion of completions) completion.resolve(SAVE_COMMITTED);
|
||||
});
|
||||
|
||||
it("does not let an older completion or clear timer overwrite newer status", async () => {
|
||||
vi.useFakeTimers();
|
||||
const completions = new Map<string, ReturnType<typeof deferred<SaveOutcome>>>();
|
||||
const saveBytes = vi.fn((path: string) => {
|
||||
const completion = deferred<SaveOutcome>();
|
||||
completions.set(path, completion);
|
||||
return completion.promise;
|
||||
});
|
||||
const statuses: string[] = [];
|
||||
const win: SaveHookWindow = {
|
||||
FS: { readFile: () => new Uint8Array([1]) } as unknown as SaveHookWindow["FS"],
|
||||
kicadCollab: {},
|
||||
};
|
||||
registerSaveHook(win, {
|
||||
slug: SLUG,
|
||||
saveBytes,
|
||||
log: () => {},
|
||||
onStatus: (status) => statuses.push(status),
|
||||
});
|
||||
|
||||
win.kicadCollab!.onSave!(`${PROJ}/old.kicad_sch`);
|
||||
win.kicadCollab!.onSave!(`${PROJ}/new.kicad_sch`);
|
||||
completions.get("new.kicad_sch")!.resolve(SAVE_COMMITTED);
|
||||
await flushMicrotasks();
|
||||
expect(statuses.at(-1)).toBe("Saved new.kicad_sch ✓");
|
||||
|
||||
completions.get("old.kicad_sch")!.resolve(SAVE_COMMITTED);
|
||||
await flushMicrotasks();
|
||||
expect(statuses.at(-1)).toBe("Saved new.kicad_sch ✓");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2500);
|
||||
expect(statuses.at(-1)).toBe("");
|
||||
});
|
||||
|
||||
it("refuses excess retained bytes and excess distinct paths before copying", async () => {
|
||||
const source = new Uint8Array([1, 2]);
|
||||
const completions: Array<ReturnType<typeof deferred<SaveOutcome>>> = [];
|
||||
const saveBytes = vi.fn(() => {
|
||||
const completion = deferred<SaveOutcome>();
|
||||
completions.push(completion);
|
||||
return completion.promise;
|
||||
});
|
||||
const logs: string[] = [];
|
||||
const statuses: string[] = [];
|
||||
const win: SaveHookWindow = {
|
||||
FS: { readFile: () => source } as unknown as SaveHookWindow["FS"],
|
||||
kicadCollab: {},
|
||||
};
|
||||
registerSaveHook(win, {
|
||||
slug: SLUG,
|
||||
saveBytes,
|
||||
log: (line) => logs.push(line),
|
||||
onStatus: (status) => statuses.push(status),
|
||||
maxRetainedBytes: 2,
|
||||
maxPaths: 1,
|
||||
});
|
||||
|
||||
win.kicadCollab!.onSave!(`${PROJ}/a.kicad_sch`);
|
||||
win.kicadCollab!.onSave!(`${PROJ}/b.kicad_sch`);
|
||||
expect(saveBytes).toHaveBeenCalledTimes(1);
|
||||
expect(logs.at(-1)).toContain("1 active paths");
|
||||
expect(statuses.at(-1)).toContain("queue is full");
|
||||
|
||||
// A newer same-path revision replaces the pending slot, but the active
|
||||
// two-byte snapshot already owns the complete byte budget.
|
||||
source[0] = 3;
|
||||
win.kicadCollab!.onSave!(`${PROJ}/a.kicad_sch`);
|
||||
expect(saveBytes).toHaveBeenCalledTimes(1);
|
||||
expect(logs.at(-1)).toContain("retained bytes");
|
||||
|
||||
completions[0]!.resolve(SAVE_COMMITTED);
|
||||
await flushMicrotasks();
|
||||
win.kicadCollab!.onSave!(`${PROJ}/b.kicad_sch`);
|
||||
expect(saveBytes).toHaveBeenCalledTimes(2);
|
||||
completions[1]!.resolve(SAVE_COMMITTED);
|
||||
});
|
||||
});
|
||||
|
||||
describe("registerSaveHook lifetime", () => {
|
||||
it("aborts the active transport, drops pending and makes completion inert", async () => {
|
||||
const source = new Uint8Array([1]);
|
||||
const active = deferred<SaveOutcome>();
|
||||
let activeSignal: AbortSignal | undefined;
|
||||
const saveBytes = vi.fn(
|
||||
(_path: string, _bytes: Uint8Array, signal?: AbortSignal) => {
|
||||
activeSignal = signal;
|
||||
return active.promise;
|
||||
},
|
||||
);
|
||||
const statuses: string[] = [];
|
||||
const logs: string[] = [];
|
||||
const win: SaveHookWindow = {
|
||||
FS: { readFile: () => source } as unknown as SaveHookWindow["FS"],
|
||||
kicadCollab: {},
|
||||
};
|
||||
const handle = registerSaveHook(win, {
|
||||
slug: SLUG,
|
||||
saveBytes,
|
||||
log: (line) => logs.push(line),
|
||||
onStatus: (status) => statuses.push(status),
|
||||
});
|
||||
|
||||
const installed = win.kicadCollab!.onSave!;
|
||||
installed(`${PROJ}/board.kicad_pcb`);
|
||||
source[0] = 2;
|
||||
installed(`${PROJ}/board.kicad_pcb`);
|
||||
expect(saveBytes).toHaveBeenCalledTimes(1);
|
||||
|
||||
handle.stop();
|
||||
expect(activeSignal?.aborted).toBe(true);
|
||||
expect(win.kicadCollab!.onSave).toBeUndefined();
|
||||
installed(`${PROJ}/ignored.kicad_pcb`);
|
||||
active.resolve(SAVE_COMMITTED);
|
||||
await flushMicrotasks();
|
||||
|
||||
// The pending revision never starts, and the already-running revision does
|
||||
// not report through the retired component lifetime when it settles.
|
||||
expect(saveBytes).toHaveBeenCalledTimes(1);
|
||||
expect(statuses).toEqual([
|
||||
"Saving board.kicad_pcb…",
|
||||
"Saving board.kicad_pcb…",
|
||||
]);
|
||||
expect(logs).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not start a queued latest write when stop aborts the active one", async () => {
|
||||
const source = new Uint8Array([1]);
|
||||
const active = deferred<SaveOutcome>();
|
||||
const saveBytes = vi.fn(() => active.promise);
|
||||
const win: SaveHookWindow = {
|
||||
FS: { readFile: () => source } as unknown as SaveHookWindow["FS"],
|
||||
kicadCollab: {},
|
||||
};
|
||||
const handle = registerSaveHook(win, {
|
||||
slug: SLUG,
|
||||
saveBytes,
|
||||
log: () => {},
|
||||
onStatus: () => {},
|
||||
});
|
||||
|
||||
win.kicadCollab!.onSave!(`${PROJ}/board.kicad_pcb`);
|
||||
source[0] = 2;
|
||||
win.kicadCollab!.onSave!(`${PROJ}/board.kicad_pcb`);
|
||||
handle.stop();
|
||||
active.reject(new DOMException("Aborted", "AbortError"));
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(saveBytes).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not detach a replacement hook and gives the remount a fresh cap", () => {
|
||||
const oldWrite = deferred<SaveOutcome>();
|
||||
const oldSave = vi.fn(() => oldWrite.promise);
|
||||
const newSave = vi.fn(async () => SAVE_COMMITTED);
|
||||
const win: SaveHookWindow = {
|
||||
FS: { readFile: () => new Uint8Array([1, 2]) } as unknown as SaveHookWindow["FS"],
|
||||
kicadCollab: {},
|
||||
};
|
||||
const oldHandle = registerSaveHook(win, {
|
||||
slug: SLUG,
|
||||
saveBytes: oldSave,
|
||||
log: () => {},
|
||||
onStatus: () => {},
|
||||
maxRetainedBytes: 2,
|
||||
maxPaths: 1,
|
||||
});
|
||||
win.kicadCollab!.onSave!(`${PROJ}/old.kicad_sch`);
|
||||
|
||||
const newHandle = registerSaveHook(win, {
|
||||
slug: SLUG,
|
||||
saveBytes: newSave,
|
||||
log: () => {},
|
||||
onStatus: () => {},
|
||||
maxRetainedBytes: 2,
|
||||
maxPaths: 1,
|
||||
});
|
||||
const replacement = win.kicadCollab!.onSave;
|
||||
oldHandle.stop();
|
||||
|
||||
expect(win.kicadCollab!.onSave).toBe(replacement);
|
||||
win.kicadCollab!.onSave!(`${PROJ}/new.kicad_sch`);
|
||||
expect(newSave).toHaveBeenCalledOnce();
|
||||
newHandle.stop();
|
||||
expect(win.kicadCollab!.onSave).toBeUndefined();
|
||||
oldWrite.resolve(SAVE_COMMITTED);
|
||||
});
|
||||
|
||||
it("cancels a pending success-clear timer on stop", async () => {
|
||||
vi.useFakeTimers();
|
||||
const statuses: string[] = [];
|
||||
const win: SaveHookWindow = {
|
||||
FS: { readFile: () => new Uint8Array([1]) } as unknown as SaveHookWindow["FS"],
|
||||
kicadCollab: {},
|
||||
};
|
||||
const handle = registerSaveHook(win, {
|
||||
slug: SLUG,
|
||||
saveBytes: async () => SAVE_COMMITTED,
|
||||
log: () => {},
|
||||
onStatus: (status) => statuses.push(status),
|
||||
});
|
||||
|
||||
win.kicadCollab!.onSave!(`${PROJ}/board.kicad_pcb`);
|
||||
await flushMicrotasks();
|
||||
expect(statuses.at(-1)).toBe("Saved board.kicad_pcb ✓");
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
|
||||
handle.stop();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
await vi.advanceTimersByTimeAsync(2500);
|
||||
expect(statuses.at(-1)).toBe("Saved board.kicad_pcb ✓");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,21 @@
|
|||
import { MEMFS_PROJECTS_DIR, memfsProjectDir } from "./constants";
|
||||
|
||||
/** Transport-independent result of one persistence attempt. */
|
||||
export type SaveOutcome =
|
||||
| { kind: "committed" }
|
||||
| { kind: "not-committed"; message?: string }
|
||||
| { kind: "conflict"; message?: string }
|
||||
| { kind: "unknown"; message?: string };
|
||||
|
||||
export const SAVE_COMMITTED = Object.freeze({ kind: "committed" } as const);
|
||||
|
||||
/** A path whose in-memory ancestry is no longer safe to publish. */
|
||||
export interface SaveBlock {
|
||||
relPath: string;
|
||||
kind: "conflict" | "unknown";
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist one saved file's bytes outside MEMFS. The counterpart of
|
||||
* `fetchBytes` on the load side: each page decides the destination —
|
||||
|
|
@ -7,13 +23,63 @@ import { MEMFS_PROJECTS_DIR, memfsProjectDir } from "./constants";
|
|||
* folders). Absent ⇒ saves stay MEMFS-only (e.g. Y.Doc-backed sessions,
|
||||
* where the provider already persists the document).
|
||||
*/
|
||||
export type SaveBytes = (relPath: string, bytes: Uint8Array) => Promise<void>;
|
||||
export type SaveBytes = (
|
||||
relPath: string,
|
||||
bytes: Uint8Array,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<SaveOutcome>;
|
||||
|
||||
/** Active plus latest save snapshots retained by one registered hook. */
|
||||
export const MAX_RETAINED_SAVE_BYTES = 64 * 1024 * 1024;
|
||||
/** One active lane per distinct path; each lane retains at most two snapshots. */
|
||||
export const MAX_SAVE_PATHS = 256;
|
||||
|
||||
interface SaveSnapshot {
|
||||
bytes: Uint8Array;
|
||||
generation: number;
|
||||
}
|
||||
|
||||
interface SaveLane {
|
||||
active?: SaveSnapshot;
|
||||
pending?: SaveSnapshot;
|
||||
activeController?: AbortController;
|
||||
}
|
||||
|
||||
function normalizeSaveOutcome(outcome: unknown, relPath: string): SaveOutcome {
|
||||
if (typeof outcome === "object" && outcome !== null && "kind" in outcome) {
|
||||
const candidate = outcome as { kind?: unknown; message?: unknown };
|
||||
if (
|
||||
candidate.kind === "committed" ||
|
||||
candidate.kind === "not-committed" ||
|
||||
candidate.kind === "conflict" ||
|
||||
candidate.kind === "unknown"
|
||||
) {
|
||||
return typeof candidate.message === "string"
|
||||
? { kind: candidate.kind, message: candidate.message }
|
||||
: { kind: candidate.kind };
|
||||
}
|
||||
}
|
||||
return {
|
||||
kind: "unknown",
|
||||
message: `Save state unknown: ${relPath} — reload or save a copy`,
|
||||
};
|
||||
}
|
||||
|
||||
export interface SaveHookWindow {
|
||||
FS?: EmscriptenFS;
|
||||
kicadCollab?: { onSave?: (absPath: string) => void };
|
||||
}
|
||||
|
||||
export interface SaveHookHandle {
|
||||
/**
|
||||
* Retire this exact hook lifetime.
|
||||
*
|
||||
* Aborts every active transport, releases every queued latest snapshot and
|
||||
* removes this hook only when it still owns the global callback slot.
|
||||
*/
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the C++ → JS save notification sink (`window.kicadCollab.onSave`).
|
||||
* The kicad fork fires it from each tool's save chokepoint (SaveDrawingSheetFile /
|
||||
|
|
@ -41,8 +107,22 @@ export function registerSaveHook(
|
|||
* document state (title block, paper, setup…) into the room doc.
|
||||
*/
|
||||
onSavedText?: (relPath: string, text: string) => void;
|
||||
/** Test/host override. Admission fails before copying above this total. */
|
||||
maxRetainedBytes?: number;
|
||||
/** Test/host override for concurrently active distinct paths. */
|
||||
maxPaths?: number;
|
||||
/** Durable per-path safety block; only hook retirement clears it. */
|
||||
onBlocked?: (block: SaveBlock) => void;
|
||||
},
|
||||
): void {
|
||||
): SaveHookHandle {
|
||||
const maxRetainedBytes = opts.maxRetainedBytes ?? MAX_RETAINED_SAVE_BYTES;
|
||||
const maxPaths = opts.maxPaths ?? MAX_SAVE_PATHS;
|
||||
if (!Number.isSafeInteger(maxRetainedBytes) || maxRetainedBytes < 0) {
|
||||
throw new RangeError(`maxRetainedBytes must be a non-negative safe integer`);
|
||||
}
|
||||
if (!Number.isSafeInteger(maxPaths) || maxPaths < 1) {
|
||||
throw new RangeError(`maxPaths must be a positive safe integer`);
|
||||
}
|
||||
const projectPrefix = `${memfsProjectDir(opts.slug)}/`;
|
||||
// The editor's default "projects" home (KiCad's GetDefaultUserProjectsPath) — one
|
||||
// level above this project's own folder. A blank editor's Save-As lands HERE, not in
|
||||
|
|
@ -50,6 +130,16 @@ export function registerSaveHook(
|
|||
// holds exactly one project in MEMFS, so such a file belongs to it. (Files under the
|
||||
// project's own folder still take the first branch, with their full relative path.)
|
||||
const projectsHome = `${MEMFS_PROJECTS_DIR}/`;
|
||||
// Each file has one active write and one replaceable latest snapshot. This
|
||||
// keeps call order without retaining every intermediate Ctrl+S payload.
|
||||
const persistenceLanes = new Map<string, SaveLane>();
|
||||
const blockedPaths = new Map<string, SaveBlock>();
|
||||
let retainedBytes = 0;
|
||||
// The status surface is global. Only the newest save notification may
|
||||
// publish an asynchronous result or clear a newer result.
|
||||
let statusGeneration = 0;
|
||||
let statusClearTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let stopped = false;
|
||||
|
||||
/** Saved MEMFS path → project-relative path, or null if it's outside the project. */
|
||||
const toRelPath = (absPath: string): string | null => {
|
||||
|
|
@ -61,7 +151,144 @@ export function registerSaveHook(
|
|||
return null;
|
||||
};
|
||||
|
||||
const releaseSnapshot = (snapshot: SaveSnapshot | undefined): void => {
|
||||
if (!snapshot) return;
|
||||
retainedBytes -= snapshot.bytes.byteLength;
|
||||
};
|
||||
|
||||
const reportCapacityFailure = (
|
||||
relPath: string,
|
||||
generation: number,
|
||||
reason: "paths" | "bytes",
|
||||
bytes: number,
|
||||
): void => {
|
||||
const detail =
|
||||
reason === "paths"
|
||||
? `${maxPaths} active paths`
|
||||
: `${retainedBytes + bytes} > ${maxRetainedBytes} retained bytes`;
|
||||
opts.log(`[save] FAILED to queue ${relPath}: save queue capacity (${detail})`);
|
||||
if (generation === statusGeneration) {
|
||||
opts.onStatus(`Save failed: ${relPath} — save queue is full`);
|
||||
}
|
||||
};
|
||||
|
||||
const retireUncommittedLane = (
|
||||
relPath: string,
|
||||
lane: SaveLane,
|
||||
snapshot: SaveSnapshot,
|
||||
controller: AbortController,
|
||||
): number => {
|
||||
const terminalGeneration = lane.pending?.generation ?? snapshot.generation;
|
||||
releaseSnapshot(lane.pending);
|
||||
lane.pending = undefined;
|
||||
releaseSnapshot(snapshot);
|
||||
lane.active = undefined;
|
||||
if (lane.activeController === controller) lane.activeController = undefined;
|
||||
persistenceLanes.delete(relPath);
|
||||
return terminalGeneration;
|
||||
};
|
||||
|
||||
const startPersistence = (relPath: string, lane: SaveLane): void => {
|
||||
const snapshot = lane.active;
|
||||
if (!snapshot) return;
|
||||
const controller = new AbortController();
|
||||
lane.activeController = controller;
|
||||
|
||||
let persistence: Promise<SaveOutcome>;
|
||||
try {
|
||||
persistence = Promise.resolve(
|
||||
opts.saveBytes!(relPath, snapshot.bytes, controller.signal),
|
||||
);
|
||||
} catch (error) {
|
||||
persistence = Promise.reject(error);
|
||||
}
|
||||
|
||||
void persistence
|
||||
.then(
|
||||
(rawOutcome) => {
|
||||
if (stopped) return;
|
||||
const outcome = normalizeSaveOutcome(rawOutcome, relPath);
|
||||
if (outcome.kind !== "committed") {
|
||||
const terminalGeneration = retireUncommittedLane(
|
||||
relPath,
|
||||
lane,
|
||||
snapshot,
|
||||
controller,
|
||||
);
|
||||
|
||||
if (outcome.kind === "conflict" || outcome.kind === "unknown") {
|
||||
const block: SaveBlock = {
|
||||
relPath,
|
||||
kind: outcome.kind,
|
||||
message:
|
||||
outcome.message ??
|
||||
(outcome.kind === "conflict"
|
||||
? `Save conflict: ${relPath} — reload or merge, or save a copy`
|
||||
: `Save state unknown: ${relPath} — reload or save a copy`),
|
||||
};
|
||||
blockedPaths.set(relPath, block);
|
||||
opts.log(`[save] BLOCKED ${relPath}: ${block.message}`);
|
||||
opts.onBlocked?.(block);
|
||||
if (terminalGeneration === statusGeneration) {
|
||||
opts.onStatus(block.message);
|
||||
}
|
||||
} else if (terminalGeneration === statusGeneration) {
|
||||
opts.onStatus(
|
||||
outcome.message ?? `Save failed: ${relPath} — see console`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
opts.log(
|
||||
`[save] ${relPath} persisted (${snapshot.bytes.length} bytes)`,
|
||||
);
|
||||
if (snapshot.generation !== statusGeneration) return;
|
||||
opts.onStatus(`Saved ${relPath} ✓`);
|
||||
statusClearTimer = setTimeout(() => {
|
||||
statusClearTimer = undefined;
|
||||
if (snapshot.generation === statusGeneration) opts.onStatus("");
|
||||
}, 2500);
|
||||
},
|
||||
(error) => {
|
||||
if (stopped) return;
|
||||
opts.log(`[save] FAILED to persist ${relPath}: ${String(error)}`);
|
||||
// An unexpected throw has unknown commit state. It is equivalent to
|
||||
// an explicit unknown outcome and permanently blocks this path.
|
||||
const terminalGeneration = retireUncommittedLane(
|
||||
relPath,
|
||||
lane,
|
||||
snapshot,
|
||||
controller,
|
||||
);
|
||||
const block: SaveBlock = {
|
||||
relPath,
|
||||
kind: "unknown",
|
||||
message: `Save state unknown: ${relPath} — reload or save a copy`,
|
||||
};
|
||||
blockedPaths.set(relPath, block);
|
||||
opts.onBlocked?.(block);
|
||||
if (terminalGeneration === statusGeneration) {
|
||||
opts.onStatus(block.message);
|
||||
}
|
||||
},
|
||||
)
|
||||
.finally(() => {
|
||||
if (stopped) return;
|
||||
if (lane.active !== snapshot) return;
|
||||
if (lane.activeController === controller) lane.activeController = undefined;
|
||||
releaseSnapshot(snapshot);
|
||||
// Rejections retire the lane in the handler above and return at the
|
||||
// guard. Only a positively acknowledged save may promote its latest
|
||||
// captured successor.
|
||||
lane.active = lane.pending;
|
||||
lane.pending = undefined;
|
||||
if (lane.active) startPersistence(relPath, lane);
|
||||
else persistenceLanes.delete(relPath);
|
||||
});
|
||||
};
|
||||
|
||||
const onSave = (absPath: string) => {
|
||||
if (stopped) return;
|
||||
const relPath = toRelPath(absPath);
|
||||
if (relPath === null) {
|
||||
opts.log(`[save] ignoring save outside project dir: ${absPath}`);
|
||||
|
|
@ -81,37 +308,104 @@ export function registerSaveHook(
|
|||
}
|
||||
}
|
||||
|
||||
// The callbacks above still observe native saves for collab/layout state,
|
||||
// but a poisoned file ancestry cannot publish bytes in this hook lifetime.
|
||||
if (blockedPaths.has(relPath)) return;
|
||||
|
||||
if (!opts.saveBytes) {
|
||||
opts.log(`[save] ${relPath} saved in MEMFS (no external save target)`);
|
||||
return;
|
||||
}
|
||||
|
||||
let bytes: Uint8Array;
|
||||
const generation = ++statusGeneration;
|
||||
if (statusClearTimer) {
|
||||
clearTimeout(statusClearTimer);
|
||||
statusClearTimer = undefined;
|
||||
}
|
||||
|
||||
let data: Uint8Array;
|
||||
try {
|
||||
const data = win.FS?.readFile(absPath);
|
||||
data = win.FS?.readFile(absPath) as Uint8Array;
|
||||
if (!(data instanceof Uint8Array)) throw new Error("FS.readFile returned no bytes");
|
||||
bytes = data;
|
||||
} catch (err) {
|
||||
opts.log(`[save] FAILED to read ${absPath} back from MEMFS: ${String(err)}`);
|
||||
opts.onStatus(`Save failed: ${relPath}`);
|
||||
if (generation === statusGeneration) opts.onStatus(`Save failed: ${relPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
opts.onStatus(`Saving ${relPath}…`);
|
||||
void opts
|
||||
.saveBytes(relPath, bytes)
|
||||
.then(() => {
|
||||
opts.log(`[save] ${relPath} persisted (${bytes.length} bytes)`);
|
||||
opts.onStatus(`Saved ${relPath} ✓`);
|
||||
setTimeout(() => opts.onStatus(""), 2500);
|
||||
})
|
||||
.catch((err) => {
|
||||
opts.log(`[save] FAILED to persist ${relPath}: ${String(err)}`);
|
||||
opts.onStatus(`Save failed: ${relPath} — see console`);
|
||||
});
|
||||
let lane = persistenceLanes.get(relPath);
|
||||
if (!lane && persistenceLanes.size >= maxPaths) {
|
||||
reportCapacityFailure(relPath, generation, "paths", data.byteLength);
|
||||
return;
|
||||
}
|
||||
|
||||
// A newer notification makes the old pending snapshot obsolete even when
|
||||
// the new payload cannot be admitted. Never persist an intermediate state
|
||||
// after reporting that the latest save failed admission.
|
||||
if (lane?.pending) {
|
||||
releaseSnapshot(lane.pending);
|
||||
lane.pending = undefined;
|
||||
}
|
||||
if (data.byteLength > maxRetainedBytes - retainedBytes) {
|
||||
reportCapacityFailure(relPath, generation, "bytes", data.byteLength);
|
||||
return;
|
||||
}
|
||||
|
||||
// Copy only after capacity admission. FS adapters may return a mutable view
|
||||
// which a later native save reuses.
|
||||
let snapshot: SaveSnapshot;
|
||||
try {
|
||||
snapshot = { bytes: data.slice(), generation };
|
||||
} catch (error) {
|
||||
opts.log(`[save] FAILED to snapshot ${relPath}: ${String(error)}`);
|
||||
if (generation === statusGeneration) {
|
||||
opts.onStatus(`Save failed: ${relPath} — not enough memory`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
retainedBytes += snapshot.bytes.byteLength;
|
||||
if (!lane) {
|
||||
lane = { active: snapshot };
|
||||
persistenceLanes.set(relPath, lane);
|
||||
startPersistence(relPath, lane);
|
||||
} else if (!lane.active) {
|
||||
lane.active = snapshot;
|
||||
startPersistence(relPath, lane);
|
||||
} else {
|
||||
lane.pending = snapshot;
|
||||
}
|
||||
};
|
||||
|
||||
// Spread-merge like moduleItemsBridge does, so sibling hooks (onItems/onDelta)
|
||||
// registered before or after survive.
|
||||
win.kicadCollab = { ...win.kicadCollab, onSave };
|
||||
|
||||
return {
|
||||
stop() {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
// Invalidate every asynchronous completion before releasing state. An
|
||||
// already-started SaveBytes Promise still owns its immutable argument,
|
||||
// but it cannot publish status or advance to the queued snapshot.
|
||||
statusGeneration++;
|
||||
if (statusClearTimer) clearTimeout(statusClearTimer);
|
||||
statusClearTimer = undefined;
|
||||
|
||||
for (const lane of persistenceLanes.values()) {
|
||||
lane.activeController?.abort();
|
||||
lane.activeController = undefined;
|
||||
lane.active = undefined;
|
||||
lane.pending = undefined;
|
||||
}
|
||||
persistenceLanes.clear();
|
||||
retainedBytes = 0;
|
||||
|
||||
// A newer registration may have replaced the global slot. Never remove
|
||||
// another lifetime's hook when this older handle retires late.
|
||||
if (win.kicadCollab?.onSave === onSave) {
|
||||
delete win.kicadCollab.onSave;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue