feat: stage plain project files via the sync namespace (load-path-rework 0001 §4 full, client)
stageViaProjectSync: plain uploaded files (revision>0, not ydoc-backed, not the target) stage from a one-layer static SyncStack over the backend's project sync surface — ONE bundle GET cold, a manifest diff warm, bodies IDB-mirrored like a library. The target keeps its room-materialized wrapper, ydoc-backed files keep the negotiated per-file fetch, a namespace miss (a write raced the listing) falls back per-file, and ANY namespace failure (older backend, decode error) falls back wholesale — exactly the previous behavior. Demo/local sources pass no config and are untouched. Measured (Arduino repo-as-project, 108 files = 76 plain + 32 ydoc-backed): cold 76 requests → 1 bundle; warm 1 manifest GET, 0 bodies. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VLSht9cadprtT2mhynawWu
This commit is contained in:
parent
f9deee60a7
commit
314986ad36
3 changed files with 261 additions and 15 deletions
|
|
@ -1887,6 +1887,18 @@ export function WasmTool({
|
||||||
? (relPath) =>
|
? (relPath) =>
|
||||||
relPath === targetPath ? Promise.resolve(targetBytes) : fetchBytes(relPath)
|
relPath === targetPath ? Promise.resolve(targetBytes) : fetchBytes(relPath)
|
||||||
: fetchBytes,
|
: fetchBytes,
|
||||||
|
// Plain files stage via the project sync namespace — one bundle GET
|
||||||
|
// cold, a manifest diff warm (0001 §4 full). Backend projects only:
|
||||||
|
// the gallery/local sources have no sync routes and no CAS rows.
|
||||||
|
projectSync:
|
||||||
|
sourceDescriptor?.kind === "remote-rw" && scopeId !== "local"
|
||||||
|
? {
|
||||||
|
apiBase: API_BASE_URL,
|
||||||
|
scope: currentScope(),
|
||||||
|
scopeId,
|
||||||
|
projectId,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
log: append,
|
log: append,
|
||||||
onStatus: setStatus,
|
onStatus: setStatus,
|
||||||
onFileProgress: (done, total) =>
|
onFileProgress: (done, total) =>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { Tool } from "@pcbjam/shared";
|
import type { Tool } from "@pcbjam/shared";
|
||||||
import { FILELESS_TOOLS, toolForFile } from "@pcbjam/shared";
|
import { FILELESS_TOOLS, toolForFile } from "@pcbjam/shared";
|
||||||
|
import { SyncStack } from "@pcbjam/sync-client";
|
||||||
import { defaultKicadPro } from "../lib/new-file";
|
import { defaultKicadPro } from "../lib/new-file";
|
||||||
import { memfsFilePath, memfsProjectDir } from "./constants";
|
import { memfsFilePath, memfsProjectDir } from "./constants";
|
||||||
import { mark } from "./load-trace";
|
import { mark } from "./load-trace";
|
||||||
|
|
@ -9,10 +10,32 @@ import { openFileInTool } from "./open-flow";
|
||||||
/**
|
/**
|
||||||
* The only thing the editor needs to know about a file to sync it into MEMFS:
|
* The only thing the editor needs to know about a file to sync it into MEMFS:
|
||||||
* its project-relative POSIX path. Both the contract loader (whose ProjectFile
|
* its project-relative POSIX path. Both the contract loader (whose ProjectFile
|
||||||
* is a superset of this) and the local-folder loader satisfy it.
|
* is a superset of this) and the local-folder loader satisfy it. The optional
|
||||||
|
* fields are the contract loader's collab/CAS overlay — when present they let
|
||||||
|
* staging route the file through the project sync namespace (plain uploaded
|
||||||
|
* files) vs the per-file negotiated fetch (ydoc-backed files).
|
||||||
*/
|
*/
|
||||||
export interface ToolFile {
|
export interface ToolFile {
|
||||||
path: string;
|
path: string;
|
||||||
|
revision?: number;
|
||||||
|
hasYdoc?: boolean;
|
||||||
|
isLive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The project-as-sync-namespace endpoints (load-path-rework 0001 §4 full):
|
||||||
|
* plain files stage through ONE bundle GET cold / a manifest diff warm,
|
||||||
|
* IDB-mirrored like a library. Absent (demo gallery, local folders, older
|
||||||
|
* backends) ⇒ every file takes the per-file path, exactly as before.
|
||||||
|
*/
|
||||||
|
export interface ProjectSyncConfig {
|
||||||
|
apiBase: string;
|
||||||
|
scope: string;
|
||||||
|
scopeId: string;
|
||||||
|
projectId: string;
|
||||||
|
/** Test seams (default: credentialed global fetch / IndexedDB stores). */
|
||||||
|
fetchImpl?: typeof fetch;
|
||||||
|
storeFactory?: ConstructorParameters<typeof SyncStack>[0]["storeFactory"];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DriveOptions {
|
export interface DriveOptions {
|
||||||
|
|
@ -21,6 +44,7 @@ export interface DriveOptions {
|
||||||
files: ToolFile[];
|
files: ToolFile[];
|
||||||
targetPath?: string;
|
targetPath?: string;
|
||||||
fetchBytes: (relPath: string) => Promise<Uint8Array>;
|
fetchBytes: (relPath: string) => Promise<Uint8Array>;
|
||||||
|
projectSync?: ProjectSyncConfig | null;
|
||||||
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
|
||||||
|
|
@ -122,34 +146,113 @@ async function syncProjectToMemfs(win: ToolWindow, opts: DriveOptions): Promise<
|
||||||
|
|
||||||
let staged = 0;
|
let staged = 0;
|
||||||
opts.onFileProgress?.(0, opts.files.length);
|
opts.onFileProgress?.(0, opts.files.length);
|
||||||
const queue = [...opts.files];
|
const stageOne = (path: string, bytes: Uint8Array): void => {
|
||||||
|
restageFile(win, opts.slug, path, bytes, opts.log);
|
||||||
|
opts.onFileProgress?.(++staged, opts.files.length);
|
||||||
|
// 3D models: prefetch every model this board references (R2 → IDB → MEMFS)
|
||||||
|
// so the 3D viewer's first open resolves locally. Fire-and-forget — project
|
||||||
|
// open never waits on it; a ref that misses falls back to the C++ per-model
|
||||||
|
// ensure. No-op unless a model source is installed (bootKicadTool).
|
||||||
|
if (path.endsWith(".kicad_pcb")) {
|
||||||
|
const text = new TextDecoder().decode(bytes);
|
||||||
|
void prescanBoardModels(text).catch((e) =>
|
||||||
|
opts.log(`[3d] prescan failed: ${String(e)}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Plain uploaded files stage through the project sync namespace when the
|
||||||
|
// backend offers one — one bundle GET cold, a manifest diff warm — and any
|
||||||
|
// file the namespace cannot vouch for falls back to the per-file path:
|
||||||
|
// ydoc-backed files (their bytes ride room materialization), the TARGET
|
||||||
|
// (its bytes may come from the connected doc room via the fetchBytes
|
||||||
|
// wrapper), and everything on any namespace failure at all.
|
||||||
|
const perFile = await (opts.projectSync
|
||||||
|
? stageViaProjectSync(opts, stageOne)
|
||||||
|
: Promise.resolve(opts.files));
|
||||||
|
|
||||||
|
const queue = [...perFile];
|
||||||
const worker = async (): Promise<void> => {
|
const worker = async (): Promise<void> => {
|
||||||
for (let file = queue.shift(); file; file = queue.shift()) {
|
for (let file = queue.shift(); file; file = queue.shift()) {
|
||||||
const bytes = await opts.fetchBytes(file.path);
|
const bytes = await opts.fetchBytes(file.path);
|
||||||
restageFile(win, opts.slug, file.path, bytes, opts.log);
|
stageOne(file.path, bytes);
|
||||||
opts.onFileProgress?.(++staged, opts.files.length);
|
|
||||||
// 3D models: prefetch every model this board references (R2 → IDB → MEMFS)
|
|
||||||
// so the 3D viewer's first open resolves locally. Fire-and-forget — project
|
|
||||||
// open never waits on it; a ref that misses falls back to the C++ per-model
|
|
||||||
// ensure. No-op unless a model source is installed (bootKicadTool).
|
|
||||||
if (file.path.endsWith(".kicad_pcb")) {
|
|
||||||
const text = new TextDecoder().decode(bytes);
|
|
||||||
void prescanBoardModels(text).catch((e) =>
|
|
||||||
opts.log(`[3d] prescan failed: ${String(e)}`),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// One rejection fails the stage (same as the serial loop did), but let the
|
// One rejection fails the stage (same as the serial loop did), but let the
|
||||||
// in-flight siblings settle first so a failure can't leave a fetch writing
|
// in-flight siblings settle first so a failure can't leave a fetch writing
|
||||||
// into MEMFS after the caller has moved on.
|
// into MEMFS after the caller has moved on.
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
Array.from({ length: Math.min(STAGE_CONCURRENCY, opts.files.length) }, worker),
|
Array.from({ length: Math.min(STAGE_CONCURRENCY, queue.length) }, worker),
|
||||||
);
|
);
|
||||||
const failed = results.find((r) => r.status === "rejected");
|
const failed = results.find((r) => r.status === "rejected");
|
||||||
if (failed) throw (failed as PromiseRejectedResult).reason;
|
if (failed) throw (failed as PromiseRejectedResult).reason;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stage every namespace-eligible file from the project sync stack, returning
|
||||||
|
* the files that still need the per-file path. Exported for tests.
|
||||||
|
*/
|
||||||
|
export async function stageViaProjectSync(
|
||||||
|
opts: Pick<DriveOptions, "slug" | "files" | "targetPath" | "log"> & {
|
||||||
|
projectSync?: ProjectSyncConfig | null;
|
||||||
|
},
|
||||||
|
stageOne: (path: string, bytes: Uint8Array) => void,
|
||||||
|
): Promise<ToolFile[]> {
|
||||||
|
const sync = opts.projectSync;
|
||||||
|
if (!sync) return opts.files;
|
||||||
|
const eligible = opts.files.filter(
|
||||||
|
(f) =>
|
||||||
|
f.path !== opts.targetPath &&
|
||||||
|
!f.hasYdoc &&
|
||||||
|
!f.isLive &&
|
||||||
|
(f.revision ?? 0) > 0,
|
||||||
|
);
|
||||||
|
const rest = opts.files.filter((f) => !eligible.includes(f));
|
||||||
|
if (eligible.length === 0) return rest;
|
||||||
|
|
||||||
|
const baseFetch = sync.fetchImpl ?? fetch;
|
||||||
|
const credentialed: typeof fetch = (input, init) =>
|
||||||
|
baseFetch(input, { ...init, credentials: "include" });
|
||||||
|
const stack = new SyncStack({
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
// Ids (stable) key the IDB cache; slugs (renameable) only address HTTP.
|
||||||
|
namespace: `project:${sync.scopeId}:${sync.projectId}`,
|
||||||
|
kind: "static",
|
||||||
|
url: `${sync.apiBase}/api/scopes/${encodeURIComponent(sync.scope)}/projects/${encodeURIComponent(opts.slug)}/sync`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
fetchImpl: credentialed,
|
||||||
|
storeFactory: sync.storeFactory,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await stack.open();
|
||||||
|
const all = await stack.readAll();
|
||||||
|
const missed: ToolFile[] = [];
|
||||||
|
for (const f of eligible) {
|
||||||
|
const bytes = all.get(f.path);
|
||||||
|
// 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 (missed.length) {
|
||||||
|
opts.log(
|
||||||
|
`[stage] ${missed.length} file(s) missed the sync namespace — per-file fallback`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return [...rest, ...missed];
|
||||||
|
} catch (e) {
|
||||||
|
// Older backend (no sync routes), quota, decode failure — the whole set
|
||||||
|
// falls back to per-file staging, which is exactly the previous behavior.
|
||||||
|
opts.log(`[stage] project sync namespace unavailable (${String(e)}) — per-file staging`);
|
||||||
|
return opts.files;
|
||||||
|
} finally {
|
||||||
|
stack.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* KiCad expects a `<stem>.kicad_pro` next to a board/schematic; without one it
|
* KiCad expects a `<stem>.kicad_pro` next to a board/schematic; without one it
|
||||||
* runs on an in-memory defaults project, so nothing project-scoped (netclasses,
|
* runs on an in-memory defaults project, so nothing project-scoped (netclasses,
|
||||||
|
|
|
||||||
131
web/standalone/src/wasm/project-stage-sync.test.ts
Normal file
131
web/standalone/src/wasm/project-stage-sync.test.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { encodeBundle, type SyncManifest } from "@pcbjam/shared";
|
||||||
|
import { memStore } from "@pcbjam/sync-client";
|
||||||
|
import {
|
||||||
|
stageViaProjectSync,
|
||||||
|
type ProjectSyncConfig,
|
||||||
|
type ToolFile,
|
||||||
|
} from "./kicad-runner";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plain files stage from the project sync namespace (one bundle GET cold);
|
||||||
|
* ydoc-backed files, the target, and anything the namespace can't vouch for
|
||||||
|
* stay on the per-file path — and ANY namespace failure falls back wholesale
|
||||||
|
* (older backend without the routes = exactly the previous behavior).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
|
||||||
|
function fakeSyncServer(bodies: Record<string, string>) {
|
||||||
|
const manifest: SyncManifest = { version: 1, entries: {} };
|
||||||
|
const frames: Array<[string, Uint8Array]> = [];
|
||||||
|
for (const [path, text] of Object.entries(bodies)) {
|
||||||
|
const b = enc.encode(text);
|
||||||
|
manifest.entries[path] = { hash: `r1:u${b.length}`, size: b.length, mtime: 0 };
|
||||||
|
frames.push([path, b]);
|
||||||
|
}
|
||||||
|
let bundleFetches = 0;
|
||||||
|
const fetchImpl = (async (input: unknown) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url.endsWith("/sync/manifest")) return Response.json(manifest);
|
||||||
|
if (url.endsWith("/sync/bundle")) {
|
||||||
|
bundleFetches += 1;
|
||||||
|
return new Response(encodeBundle(manifest, frames) as BodyInit);
|
||||||
|
}
|
||||||
|
return new Response(null, { status: 404 });
|
||||||
|
}) as typeof fetch;
|
||||||
|
return { fetchImpl, counters: { get bundleFetches() { return bundleFetches; } } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncConfig(fetchImpl: typeof fetch): ProjectSyncConfig {
|
||||||
|
const stores = new Map<string, ReturnType<typeof memStore>>();
|
||||||
|
return {
|
||||||
|
apiBase: "https://api.test",
|
||||||
|
scope: "team",
|
||||||
|
scopeId: "scope-1",
|
||||||
|
projectId: "proj-1",
|
||||||
|
fetchImpl,
|
||||||
|
storeFactory: (ns) => stores.get(ns) ?? stores.set(ns, memStore()).get(ns)!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("stageViaProjectSync", () => {
|
||||||
|
it("stages plain files from ONE bundle; special files stay per-file", async () => {
|
||||||
|
const server = fakeSyncServer({
|
||||||
|
"a.kicad_pcb": "(pcb)",
|
||||||
|
"docs/readme.md": "hi",
|
||||||
|
});
|
||||||
|
const files: ToolFile[] = [
|
||||||
|
{ path: "a.kicad_pcb", revision: 1 },
|
||||||
|
{ path: "docs/readme.md", revision: 1 },
|
||||||
|
{ path: "root.kicad_sch", revision: 3, hasYdoc: true },
|
||||||
|
{ path: "live.kicad_sch", revision: 2, isLive: true },
|
||||||
|
{ path: "subsheet.kicad_sch", revision: 0 },
|
||||||
|
{ path: "target.kicad_pcb", revision: 5 },
|
||||||
|
];
|
||||||
|
const stagedPaths: string[] = [];
|
||||||
|
const rest = await stageViaProjectSync(
|
||||||
|
{
|
||||||
|
slug: "proj",
|
||||||
|
files,
|
||||||
|
targetPath: "target.kicad_pcb",
|
||||||
|
projectSync: syncConfig(server.fetchImpl),
|
||||||
|
log: () => {},
|
||||||
|
},
|
||||||
|
(path, bytes) => stagedPaths.push(`${path}:${bytes.length}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(stagedPaths.sort()).toEqual(["a.kicad_pcb:5", "docs/readme.md:2"]);
|
||||||
|
expect(server.counters.bundleFetches).toBe(1);
|
||||||
|
expect(rest.map((f) => f.path).sort()).toEqual([
|
||||||
|
"live.kicad_sch",
|
||||||
|
"root.kicad_sch",
|
||||||
|
"subsheet.kicad_sch",
|
||||||
|
"target.kicad_pcb",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a file the namespace manifest missed falls back to per-file", async () => {
|
||||||
|
// The listing knows b.txt, the namespace snapshot doesn't (raced write,
|
||||||
|
// just-turned-ydoc): the per-file path must answer authoritatively.
|
||||||
|
const server = fakeSyncServer({ "a.txt": "A" });
|
||||||
|
const files: ToolFile[] = [
|
||||||
|
{ path: "a.txt", revision: 1 },
|
||||||
|
{ path: "b.txt", revision: 1 },
|
||||||
|
];
|
||||||
|
const staged: string[] = [];
|
||||||
|
const rest = await stageViaProjectSync(
|
||||||
|
{ slug: "p", files, projectSync: syncConfig(server.fetchImpl), log: () => {} },
|
||||||
|
(path) => staged.push(path),
|
||||||
|
);
|
||||||
|
expect(staged).toEqual(["a.txt"]);
|
||||||
|
expect(rest.map((f) => f.path)).toEqual(["b.txt"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("any namespace failure falls back WHOLESALE to per-file staging", async () => {
|
||||||
|
const fetchImpl = (async () =>
|
||||||
|
new Response(null, { status: 404 })) as typeof fetch; // older backend
|
||||||
|
const files: ToolFile[] = [
|
||||||
|
{ path: "a.txt", revision: 1 },
|
||||||
|
{ path: "b.txt", revision: 1 },
|
||||||
|
];
|
||||||
|
const staged: string[] = [];
|
||||||
|
const rest = await stageViaProjectSync(
|
||||||
|
{ slug: "p", files, projectSync: syncConfig(fetchImpl), log: () => {} },
|
||||||
|
(path) => staged.push(path),
|
||||||
|
);
|
||||||
|
expect(staged).toEqual([]);
|
||||||
|
expect(rest).toEqual(files);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no sync config means the whole set is per-file (demo/local sources)", async () => {
|
||||||
|
const files: ToolFile[] = [{ path: "a.txt", revision: 1 }];
|
||||||
|
const rest = await stageViaProjectSync(
|
||||||
|
{ slug: "p", files, projectSync: null, log: () => {} },
|
||||||
|
() => {
|
||||||
|
throw new Error("must not stage");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(rest).toEqual(files);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue