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:
Gergő Törcsvári 2026-08-18 11:45:23 +02:00
commit 314986ad36
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
3 changed files with 261 additions and 15 deletions

View file

@ -1887,6 +1887,18 @@ export function WasmTool({
? (relPath) =>
relPath === targetPath ? Promise.resolve(targetBytes) : fetchBytes(relPath)
: 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,
onStatus: setStatus,
onFileProgress: (done, total) =>

View file

@ -1,5 +1,6 @@
import type { Tool } from "@pcbjam/shared";
import { FILELESS_TOOLS, toolForFile } from "@pcbjam/shared";
import { SyncStack } from "@pcbjam/sync-client";
import { defaultKicadPro } from "../lib/new-file";
import { memfsFilePath, memfsProjectDir } from "./constants";
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:
* 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 {
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 {
@ -21,6 +44,7 @@ export interface DriveOptions {
files: ToolFile[];
targetPath?: string;
fetchBytes: (relPath: string) => Promise<Uint8Array>;
projectSync?: ProjectSyncConfig | null;
log: (msg: string) => void;
onStatus: (text: string) => void;
/** 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;
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> => {
for (let file = queue.shift(); file; file = queue.shift()) {
const bytes = await opts.fetchBytes(file.path);
restageFile(win, opts.slug, file.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 (file.path.endsWith(".kicad_pcb")) {
const text = new TextDecoder().decode(bytes);
void prescanBoardModels(text).catch((e) =>
opts.log(`[3d] prescan failed: ${String(e)}`),
);
}
stageOne(file.path, bytes);
}
};
// 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
// into MEMFS after the caller has moved on.
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");
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
* runs on an in-memory defaults project, so nothing project-scoped (netclasses,

View 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);
});
});