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 === 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) =>
|
||||
|
|
|
|||
|
|
@ -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 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);
|
||||
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 (file.path.endsWith(".kicad_pcb")) {
|
||||
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);
|
||||
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,
|
||||
|
|
|
|||
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