feat: consume the boot payload (load-path-rework 0001 §6, client)
useProjectBoot: the tool page tries the ONE composed boot round-trip first — its project half is the getProject shape — and falls back to the active source's getProject on any miss (older backend, local-store slug, static gallery), so downstream behavior without a payload is exactly pre-boot. With a payload: seedSessionIdentity makes the /api/me flight a resolved no-op; the synced libs source is constructed with the preloaded listing (kind-filtered client-side by kindCounts — the same org/mirror-always + libHasKind rule the server applies) and the batch-resolved stacks, and every INTERNAL listing (syncState, presync, enableRealtime name-mapping) rides the preload too; the fresh project-sync digest threads into staging so a warm restage affirms with zero HTTP. Measured warm load (Arduino repo-as-project): total API traffic = 1 boot request + the ydoc-backed per-file set — /api/me, /libs, sync-stacks and /sync/manifest all at zero. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VLSht9cadprtT2mhynawWu
This commit is contained in:
parent
314986ad36
commit
7c15f14bb8
11 changed files with 366 additions and 29 deletions
|
|
@ -30,7 +30,7 @@ import {
|
||||||
} from "@/lib/config";
|
} from "@/lib/config";
|
||||||
import { defaultFileName, newFileTemplate, withExtension } from "@/lib/new-file";
|
import { defaultFileName, newFileTemplate, withExtension } from "@/lib/new-file";
|
||||||
import { redirectTargetFor } from "@/lib/redirect";
|
import { redirectTargetFor } from "@/lib/redirect";
|
||||||
import { loadSessionIdentity } from "@/lib/session-identity";
|
import { loadSessionIdentity, seedSessionIdentity } from "@/lib/session-identity";
|
||||||
import { setTheme, useThemeValue } from "@/lib/theme";
|
import { setTheme, useThemeValue } from "@/lib/theme";
|
||||||
import { bootKicadTool } from "@/wasm/boot";
|
import { bootKicadTool } from "@/wasm/boot";
|
||||||
import {
|
import {
|
||||||
|
|
@ -954,6 +954,7 @@ export function WasmTool({
|
||||||
libsSource,
|
libsSource,
|
||||||
sourceDescriptor,
|
sourceDescriptor,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
|
boot = null,
|
||||||
}: {
|
}: {
|
||||||
tool: Tool;
|
tool: Tool;
|
||||||
slug: string;
|
slug: string;
|
||||||
|
|
@ -972,6 +973,13 @@ export function WasmTool({
|
||||||
* — a specific backend lib, or a local `.kicad_sym`/`.kicad_mod` file.
|
* — a specific backend lib, or a local `.kicad_sym`/`.kicad_mod` file.
|
||||||
*/
|
*/
|
||||||
libsSource?: LibsSource | null;
|
libsSource?: LibsSource | null;
|
||||||
|
/**
|
||||||
|
* The composed boot payload (load-path-rework 0001 §6), when the page's ONE
|
||||||
|
* boot round-trip answered: seeds identity, the lib listing + stack
|
||||||
|
* resolves, and the project sync digest. Null ⇒ each consumer uses its
|
||||||
|
* individual endpoint — the pre-boot behavior.
|
||||||
|
*/
|
||||||
|
boot?: import("@/lib/boot-payload").BootPayload | null;
|
||||||
/** Fetch one project-relative file's bytes (contract loader or local folder). */
|
/** Fetch one project-relative file's bytes (contract loader or local folder). */
|
||||||
fetchBytes: (relPath: string) => Promise<Uint8Array>;
|
fetchBytes: (relPath: string) => Promise<Uint8Array>;
|
||||||
/**
|
/**
|
||||||
|
|
@ -1639,7 +1647,9 @@ export function WasmTool({
|
||||||
// parallel with the WASM download; awaited after boot, before anything
|
// parallel with the WASM download; awaited after boot, before anything
|
||||||
// binds presence/comments, so presenceUser()/userSlug() speak for the
|
// binds presence/comments, so presenceUser()/userSlug() speak for the
|
||||||
// authenticated user (anonymous/example backends resolve to null and
|
// authenticated user (anonymous/example backends resolve to null and
|
||||||
// the pre-auth slug fallback stays).
|
// the pre-auth slug fallback stays). A boot payload already carries
|
||||||
|
// the /api/me shape — seeding it makes this a resolved no-op flight.
|
||||||
|
if (boot?.me) seedSessionIdentity(boot.me);
|
||||||
const identityReady = loadSessionIdentity(API_BASE_URL);
|
const identityReady = loadSessionIdentity(API_BASE_URL);
|
||||||
// Resolve the per-tool asset base at runtime (CDN manifest → versioned
|
// Resolve the per-tool asset base at runtime (CDN manifest → versioned
|
||||||
// folder, or the flat local /wasm in dev). See wasm/wasm-assets.ts.
|
// folder, or the flat local /wasm in dev). See wasm/wasm-assets.ts.
|
||||||
|
|
@ -1647,9 +1657,15 @@ export function WasmTool({
|
||||||
const base = meta.base;
|
const base = meta.base;
|
||||||
// One source instance, shared by the wasm provider AND the pre-sync below
|
// One source instance, shared by the wasm provider AND the pre-sync below
|
||||||
// (libsSourceConfig builds a fresh one each call — their SyncStack caches
|
// (libsSourceConfig builds a fresh one each call — their SyncStack caches
|
||||||
// must be the same object for the warm-up to benefit the editor).
|
// must be the same object for the warm-up to benefit the editor). A boot
|
||||||
|
// payload pre-seeds its lib listing + stack resolves (zero lib HTTP).
|
||||||
const source =
|
const source =
|
||||||
libsSource !== undefined ? libsSource : libsSourceConfig(projectId);
|
libsSource !== undefined
|
||||||
|
? libsSource
|
||||||
|
: libsSourceConfig(
|
||||||
|
projectId,
|
||||||
|
boot ? { libs: boot.libs, stacks: boot.stacks } : undefined,
|
||||||
|
);
|
||||||
if (libsSource === undefined) ownedLibsSource = source;
|
if (libsSource === undefined) ownedLibsSource = source;
|
||||||
// Download-consent gate (standalone-load-ux 0001): before pulling the
|
// Download-consent gate (standalone-load-ux 0001): before pulling the
|
||||||
// (large) cold wasm + lib bundles, say how many MB and wait for the OK.
|
// (large) cold wasm + lib bundles, say how many MB and wait for the OK.
|
||||||
|
|
@ -1897,6 +1913,8 @@ export function WasmTool({
|
||||||
scope: currentScope(),
|
scope: currentScope(),
|
||||||
scopeId,
|
scopeId,
|
||||||
projectId,
|
projectId,
|
||||||
|
// Boot's fresh digest: a warm match stages with ZERO HTTP.
|
||||||
|
digest: boot?.projectSync.digest,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
log: append,
|
log: append,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,17 @@
|
||||||
import type { DriftReportBody, Project, ProjectFile } from "@pcbjam/shared";
|
import type {
|
||||||
|
DriftReportBody,
|
||||||
|
Project,
|
||||||
|
ProjectFile,
|
||||||
|
ProjectWithFiles,
|
||||||
|
} from "@pcbjam/shared";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { API_BASE_URL, currentScope, libsSourceConfig } from "./config";
|
import {
|
||||||
|
API_BASE_URL,
|
||||||
|
currentScope,
|
||||||
|
libsSourceConfig,
|
||||||
|
PROJECT_SOURCE_KIND,
|
||||||
|
} from "./config";
|
||||||
|
import { fetchBootPayload, type BootPayload } from "./boot-payload";
|
||||||
import { client } from "./contract-client";
|
import { client } from "./contract-client";
|
||||||
import type { LibInfo } from "@/wasm/libs/source";
|
import type { LibInfo } from "@/wasm/libs/source";
|
||||||
import { downloadBytes } from "./download";
|
import { downloadBytes } from "./download";
|
||||||
|
|
@ -72,6 +83,31 @@ export function useProject(slug: string) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tool page's project query, boot-endpoint first (load-path-rework 0001
|
||||||
|
* §6): remote deployments try the ONE composed boot round-trip — its project
|
||||||
|
* half is exactly the getProject shape, and the extras (identity, libs,
|
||||||
|
* stacks, project sync digest) ride along for WasmTool. Any miss (older
|
||||||
|
* backend, a local-store slug answering 404, the static gallery) falls back
|
||||||
|
* to the active source's getProject with `boot: null`, which is exactly the
|
||||||
|
* pre-boot behavior everywhere downstream.
|
||||||
|
*/
|
||||||
|
export function useProjectBoot(slug: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["project-boot", slug],
|
||||||
|
queryFn: async (): Promise<{
|
||||||
|
data: ProjectWithFiles & { access?: "read" | "write" };
|
||||||
|
boot: BootPayload | null;
|
||||||
|
}> => {
|
||||||
|
if (PROJECT_SOURCE_KIND !== "static") {
|
||||||
|
const boot = await fetchBootPayload(currentScope(), slug);
|
||||||
|
if (boot) return { data: boot, boot };
|
||||||
|
}
|
||||||
|
return { data: await projectSource().getProject(slug), boot: null };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* File bytes from the active source (backend stream, or the static CDN
|
* File bytes from the active source (backend stream, or the static CDN
|
||||||
* gallery). Pass the file's row from the current listing as `meta` when you
|
* gallery). Pass the file's row from the current listing as `meta` when you
|
||||||
|
|
|
||||||
65
web/standalone/src/lib/boot-payload.ts
Normal file
65
web/standalone/src/lib/boot-payload.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import {
|
||||||
|
USER_HEADER,
|
||||||
|
type ProjectWithFiles,
|
||||||
|
type SyncManifest,
|
||||||
|
type SyncStackDescriptor,
|
||||||
|
} from "@pcbjam/shared";
|
||||||
|
import { API_BASE_URL, userSlug } from "./config";
|
||||||
|
import { fileCacheValidator, pruneProjectFileCache } from "./project-file-cache";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ONE editor boot round-trip (load-path-rework 0001 §6):
|
||||||
|
* `GET /api/scopes/:scope/projects/:project/boot` composes everything the
|
||||||
|
* editor otherwise collects across four serial API calls — identity, the
|
||||||
|
* project + files + access, the visible-lib DTOs, the batch-resolved sync
|
||||||
|
* stacks, and the project sync-namespace manifest with its digest. Every
|
||||||
|
* piece is OPTIONAL downstream: an absent payload (older backend, demo
|
||||||
|
* gallery, local store) leaves each consumer on its individual endpoint,
|
||||||
|
* so this can only ever remove requests, never break a boot.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Wire lib DTO — the `GET /libs` shape, structurally (no contract import). */
|
||||||
|
export interface BootLibDto {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
type: string;
|
||||||
|
itemCount?: number;
|
||||||
|
sync?: { namespace: string; bytes: number | null } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BootPayload extends ProjectWithFiles {
|
||||||
|
me: unknown;
|
||||||
|
access?: "read" | "write";
|
||||||
|
libs: BootLibDto[];
|
||||||
|
stacks: Record<string, SyncStackDescriptor | null>;
|
||||||
|
projectSync: { manifest: SyncManifest; digest: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Null on ANY failure (404 = older backend / non-boot source) — callers fall
|
||||||
|
* back to the individual endpoints. */
|
||||||
|
export async function fetchBootPayload(
|
||||||
|
scope: string,
|
||||||
|
slug: string,
|
||||||
|
): Promise<BootPayload | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE_URL}/api/scopes/${encodeURIComponent(scope)}/projects/${encodeURIComponent(slug)}/boot`,
|
||||||
|
{ credentials: "include", headers: { [USER_HEADER]: userSlug() } },
|
||||||
|
);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const body = (await res.json()) as BootPayload;
|
||||||
|
if (!body?.project || !Array.isArray(body.files)) return null;
|
||||||
|
// Fresh listing = fresh cache truth (same prune the remote source's
|
||||||
|
// getProject performs) — the boot payload replaces that call.
|
||||||
|
const valid = new Map<string, string>();
|
||||||
|
for (const f of body.files) {
|
||||||
|
const v = fileCacheValidator(f);
|
||||||
|
if (v) valid.set(f.path, v);
|
||||||
|
}
|
||||||
|
void pruneProjectFileCache(body.project.id, valid);
|
||||||
|
return body;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -311,7 +311,19 @@ export function modelsSourceConfig(): Model3dSource | null {
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function libsSourceConfig(projectId?: string): LibsSource | null {
|
/** The boot payload's lib hand-over (see wasm/libs/synced-source.ts). */
|
||||||
|
export interface LibsBootPreload {
|
||||||
|
libs: import("@/wasm/libs/synced-source").PreloadedLibDto[];
|
||||||
|
stacks: Record<
|
||||||
|
string,
|
||||||
|
import("@pcbjam/shared").SyncStackDescriptor | null
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function libsSourceConfig(
|
||||||
|
projectId?: string,
|
||||||
|
preload?: LibsBootPreload,
|
||||||
|
): LibsSource | null {
|
||||||
const kind = import.meta.env.VITE_LIBS_SOURCE ?? "remote";
|
const kind = import.meta.env.VITE_LIBS_SOURCE ?? "remote";
|
||||||
// "local" is the placeholder id for launches with no real backend project
|
// "local" is the placeholder id for launches with no real backend project
|
||||||
// (local folder, tool grid, lib-scoped open). It is NOT a project on the
|
// (local folder, tool grid, lib-scoped open). It is NOT a project on the
|
||||||
|
|
@ -338,6 +350,7 @@ export function libsSourceConfig(projectId?: string): LibsSource | null {
|
||||||
scope: currentScope(),
|
scope: currentScope(),
|
||||||
user: userSlug(),
|
user: userSlug(),
|
||||||
project,
|
project,
|
||||||
|
preloaded: preload,
|
||||||
log: (m) => console.log(m),
|
log: (m) => console.log(m),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
loadSessionIdentity,
|
loadSessionIdentity,
|
||||||
|
seedSessionIdentity,
|
||||||
resetSessionIdentityForTest,
|
resetSessionIdentityForTest,
|
||||||
sessionIdentity,
|
sessionIdentity,
|
||||||
} from "./session-identity";
|
} from "./session-identity";
|
||||||
|
|
@ -69,4 +70,18 @@ describe("loadSessionIdentity", () => {
|
||||||
await loadSessionIdentity("http://api");
|
await loadSessionIdentity("http://api");
|
||||||
expect(f).toHaveBeenCalledTimes(1);
|
expect(f).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("a seeded boot payload makes loadSessionIdentity a no-fetch resolved flight", async () => {
|
||||||
|
const f = vi
|
||||||
|
.spyOn(globalThis, "fetch")
|
||||||
|
.mockRejectedValue(new Error("must not fetch"));
|
||||||
|
seedSessionIdentity({ user: { slug: "boots", name: "Boot User" } });
|
||||||
|
expect(await loadSessionIdentity("http://api")).toEqual({
|
||||||
|
slug: "boots",
|
||||||
|
name: "Boot User",
|
||||||
|
email: undefined,
|
||||||
|
});
|
||||||
|
expect(sessionIdentity()?.slug).toBe("boots");
|
||||||
|
expect(f).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -34,19 +34,7 @@ export function loadSessionIdentity(
|
||||||
pending = fetch(`${apiBase}/api/me`, { credentials: "include" })
|
pending = fetch(`${apiBase}/api/me`, { credentials: "include" })
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
.then((body: unknown) => {
|
.then((body: unknown) => {
|
||||||
const u = (
|
adoptMePayload(body);
|
||||||
body as {
|
|
||||||
user?: { slug?: unknown; name?: unknown; email?: unknown } | null;
|
|
||||||
} | null
|
|
||||||
)?.user;
|
|
||||||
if (u && typeof u.slug === "string" && u.slug) {
|
|
||||||
const email = typeof u.email === "string" && u.email ? u.email : undefined;
|
|
||||||
identity = {
|
|
||||||
slug: u.slug,
|
|
||||||
name: (typeof u.name === "string" && u.name) || email || u.slug,
|
|
||||||
email,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return identity;
|
return identity;
|
||||||
})
|
})
|
||||||
.catch(() => null);
|
.catch(() => null);
|
||||||
|
|
@ -54,6 +42,32 @@ export function loadSessionIdentity(
|
||||||
return pending;
|
return pending;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed the identity from an already-fetched `/api/me`-shaped payload (the boot
|
||||||
|
* endpoint's `me` — load-path-rework 0001 §6), making `loadSessionIdentity`'s
|
||||||
|
* own fetch a no-op resolved flight. Must run BEFORE it is first called.
|
||||||
|
*/
|
||||||
|
export function seedSessionIdentity(me: unknown): void {
|
||||||
|
adoptMePayload(me);
|
||||||
|
pending = Promise.resolve(identity);
|
||||||
|
}
|
||||||
|
|
||||||
|
function adoptMePayload(body: unknown): void {
|
||||||
|
const u = (
|
||||||
|
body as {
|
||||||
|
user?: { slug?: unknown; name?: unknown; email?: unknown } | null;
|
||||||
|
} | null
|
||||||
|
)?.user;
|
||||||
|
if (u && typeof u.slug === "string" && u.slug) {
|
||||||
|
const email = typeof u.email === "string" && u.email ? u.email : undefined;
|
||||||
|
identity = {
|
||||||
|
slug: u.slug,
|
||||||
|
name: (typeof u.name === "string" && u.name) || email || u.slug,
|
||||||
|
email,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Test-only: forget the cached identity + in-flight fetch. */
|
/** Test-only: forget the cached identity + in-flight fetch. */
|
||||||
export function resetSessionIdentityForTest(): void {
|
export function resetSessionIdentityForTest(): void {
|
||||||
identity = null;
|
identity = null;
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import {
|
||||||
createProjectFileIfMissing,
|
createProjectFileIfMissing,
|
||||||
fetchFileBytes,
|
fetchFileBytes,
|
||||||
uploadFileBytes,
|
uploadFileBytes,
|
||||||
useProject,
|
useProjectBoot,
|
||||||
useSourceDescriptor,
|
useSourceDescriptor,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { docSourceConfig } from "@/lib/config";
|
import { docSourceConfig } from "@/lib/config";
|
||||||
|
|
@ -30,7 +30,9 @@ export function ToolPage() {
|
||||||
: (parseToolParam(search.get("tool")) ?? (splat ? toolForFile(splat) : null));
|
: (parseToolParam(search.get("tool")) ?? (splat ? toolForFile(splat) : null));
|
||||||
const targetPath = params.tool ? undefined : splat;
|
const targetPath = params.tool ? undefined : splat;
|
||||||
|
|
||||||
const { data, isLoading, error } = useProject(slug);
|
// Boot-endpoint first (one composed round-trip); getProject fallback inside.
|
||||||
|
const { data: bootData, isLoading, error } = useProjectBoot(slug);
|
||||||
|
const data = bootData?.data;
|
||||||
const { data: sourceDescriptor } = useSourceDescriptor(slug);
|
const { data: sourceDescriptor } = useSourceDescriptor(slug);
|
||||||
// Listing rows by path, handed to fetchFileBytes so the remote source can
|
// Listing rows by path, handed to fetchFileBytes so the remote source can
|
||||||
// serve unchanged files from the local body cache (project-file-cache.ts).
|
// serve unchanged files from the local body cache (project-file-cache.ts).
|
||||||
|
|
@ -102,6 +104,7 @@ export function ToolPage() {
|
||||||
docSource={docSource}
|
docSource={docSource}
|
||||||
sourceDescriptor={sourceDescriptor}
|
sourceDescriptor={sourceDescriptor}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
|
boot={bootData?.boot ?? null}
|
||||||
/>
|
/>
|
||||||
</PreflightGate>
|
</PreflightGate>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,13 @@ export interface ProjectSyncConfig {
|
||||||
scope: string;
|
scope: string;
|
||||||
scopeId: string;
|
scopeId: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
/**
|
||||||
|
* The boot payload's fresh manifest digest (load-path-rework 0001 §6). It
|
||||||
|
* was computed from the same listing this boot just fetched, so trusting it
|
||||||
|
* is exactly as current as GETting /sync/manifest ourselves — a warm match
|
||||||
|
* makes project staging ZERO-request. Absent ⇒ one manifest GET, as before.
|
||||||
|
*/
|
||||||
|
digest?: string;
|
||||||
/** Test seams (default: credentialed global fetch / IndexedDB stores). */
|
/** Test seams (default: credentialed global fetch / IndexedDB stores). */
|
||||||
fetchImpl?: typeof fetch;
|
fetchImpl?: typeof fetch;
|
||||||
storeFactory?: ConstructorParameters<typeof SyncStack>[0]["storeFactory"];
|
storeFactory?: ConstructorParameters<typeof SyncStack>[0]["storeFactory"];
|
||||||
|
|
@ -220,6 +227,7 @@ export async function stageViaProjectSync(
|
||||||
namespace: `project:${sync.scopeId}:${sync.projectId}`,
|
namespace: `project:${sync.scopeId}:${sync.projectId}`,
|
||||||
kind: "static",
|
kind: "static",
|
||||||
url: `${sync.apiBase}/api/scopes/${encodeURIComponent(sync.scope)}/projects/${encodeURIComponent(opts.slug)}/sync`,
|
url: `${sync.apiBase}/api/scopes/${encodeURIComponent(sync.scope)}/projects/${encodeURIComponent(opts.slug)}/sync`,
|
||||||
|
digest: sync.digest,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
fetchImpl: credentialed,
|
fetchImpl: credentialed,
|
||||||
|
|
|
||||||
|
|
@ -310,6 +310,77 @@ describe("cutover 409 → re-resolve + retry (load-path-rework 0002)", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("boot preload (load-path-rework 0001 §6)", () => {
|
||||||
|
it("serves listLibs locally (server's kind rule) and skips the stack resolve", async () => {
|
||||||
|
const server = await fakeServer({ "symbol/R": "(r)" });
|
||||||
|
const failingResolve = ((input: unknown, init?: RequestInit) => {
|
||||||
|
if (String(input).includes("sync-stack")) {
|
||||||
|
throw new Error("resolve must not be called with a preloaded stack");
|
||||||
|
}
|
||||||
|
return (server.fetchImpl as (i: unknown, x?: RequestInit) => unknown)(
|
||||||
|
input,
|
||||||
|
init,
|
||||||
|
);
|
||||||
|
}) as typeof fetch;
|
||||||
|
const remoteStub = {
|
||||||
|
listLibs: () => {
|
||||||
|
throw new Error("remote listLibs must not be called");
|
||||||
|
},
|
||||||
|
} as unknown as LibsSource;
|
||||||
|
|
||||||
|
const source = syncedScopeLibsSource(remoteStub, {
|
||||||
|
apiBase: API,
|
||||||
|
scope: "s",
|
||||||
|
user: "u",
|
||||||
|
fetchImpl: failingResolve,
|
||||||
|
storeFactory: () => memStore(),
|
||||||
|
channelFactory: () => server.channel,
|
||||||
|
preloaded: {
|
||||||
|
libs: [
|
||||||
|
{ id: LIB_ID, name: "My Lib", type: "org" },
|
||||||
|
{
|
||||||
|
id: "lib-sym",
|
||||||
|
name: "Symbols Only",
|
||||||
|
type: "origin",
|
||||||
|
kindCounts: { symbol: 12 },
|
||||||
|
sync: { namespace: "origin:lib-sym@v1", bytes: 42 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "lib-fp",
|
||||||
|
name: "Footprints Only",
|
||||||
|
type: "origin",
|
||||||
|
kindCounts: { footprint: 7 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
stacks: {
|
||||||
|
[LIB_ID]: {
|
||||||
|
lib: { id: LIB_ID, name: "My Lib" },
|
||||||
|
layers: [
|
||||||
|
{ namespace: `org:${LIB_ID}`, kind: "live", url: ROOM, writable: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Kind filter matches the server: org libs always; origins by kindCounts.
|
||||||
|
const symbols = await source.listLibs!("symbol");
|
||||||
|
expect(symbols.map((l) => l.id).sort()).toEqual([LIB_ID, "lib-sym"]);
|
||||||
|
expect(symbols.find((l) => l.id === "lib-sym")!.sync).toEqual({
|
||||||
|
namespace: "origin:lib-sym@v1",
|
||||||
|
bytes: 42,
|
||||||
|
});
|
||||||
|
const footprints = await source.listLibs!("footprint");
|
||||||
|
expect(footprints.map((l) => l.id).sort()).toEqual([LIB_ID, "lib-fp"]);
|
||||||
|
|
||||||
|
// The preloaded stack answers the per-lib resolve: a save works with the
|
||||||
|
// resolve endpoint hard-failing.
|
||||||
|
const ok = await source.saveItemBody!(LIB_ID, "symbol", "Mine", "(body)");
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
source.dispose?.();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("syncedScopeLibsSource.syncState", () => {
|
describe("syncedScopeLibsSource.syncState", () => {
|
||||||
function scoped(libs: LibInfo[]) {
|
function scoped(libs: LibInfo[]) {
|
||||||
const remote: LibsSource = {
|
const remote: LibsSource = {
|
||||||
|
|
|
||||||
|
|
@ -333,6 +333,20 @@ function splitPath(path: string): LibItemInfo {
|
||||||
* "lazy materialization" deferred note) — same trade the lib-editor pages
|
* "lazy materialization" deferred note) — same trade the lib-editor pages
|
||||||
* already make.
|
* already make.
|
||||||
*/
|
*/
|
||||||
|
/** A lib DTO handed over from the boot payload (structural — the wire shape
|
||||||
|
* of `GET /libs`, which the boot endpoint embeds unfiltered). */
|
||||||
|
export interface PreloadedLibDto {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
type: string;
|
||||||
|
itemCount?: number;
|
||||||
|
/** Per-kind item counts — the client-side equivalent of the server's
|
||||||
|
* `libHasKind` filter for `listLibs(kind)`. */
|
||||||
|
kindCounts?: Record<string, number>;
|
||||||
|
sync?: { namespace: string; bytes: number | null } | null;
|
||||||
|
}
|
||||||
|
|
||||||
export function syncedScopeLibsSource(
|
export function syncedScopeLibsSource(
|
||||||
remote: LibsSource,
|
remote: LibsSource,
|
||||||
opts: {
|
opts: {
|
||||||
|
|
@ -341,6 +355,17 @@ export function syncedScopeLibsSource(
|
||||||
user?: string;
|
user?: string;
|
||||||
project?: string;
|
project?: string;
|
||||||
log?: (msg: string) => void;
|
log?: (msg: string) => void;
|
||||||
|
/**
|
||||||
|
* The boot payload's lib listing + batch-resolved stacks (load-path-rework
|
||||||
|
* 0001 §6): `listLibs` answers locally (kind-filtered the same way the
|
||||||
|
* server would) and the stack prefetch is already satisfied — a boot with
|
||||||
|
* this present makes ZERO lib listing/resolve requests. Absent ⇒ the
|
||||||
|
* individual endpoints, exactly as before.
|
||||||
|
*/
|
||||||
|
preloaded?: {
|
||||||
|
libs: PreloadedLibDto[];
|
||||||
|
stacks: Record<string, SyncStackDescriptor | null>;
|
||||||
|
};
|
||||||
fetchImpl?: typeof fetch;
|
fetchImpl?: typeof fetch;
|
||||||
storeFactory?: (namespace: string) => LayerStore;
|
storeFactory?: (namespace: string) => LayerStore;
|
||||||
channelFactory?: ChannelFactory;
|
channelFactory?: ChannelFactory;
|
||||||
|
|
@ -352,6 +377,30 @@ export function syncedScopeLibsSource(
|
||||||
// stale pin isn't retried one-by-one. Misses simply fall back per-lib, which
|
// stale pin isn't retried one-by-one. Misses simply fall back per-lib, which
|
||||||
// is also what happens against a backend predating the batch route.
|
// is also what happens against a backend predating the batch route.
|
||||||
const batchedStacks = new Map<string, SyncStackDescriptor | null>();
|
const batchedStacks = new Map<string, SyncStackDescriptor | null>();
|
||||||
|
if (opts.preloaded) {
|
||||||
|
for (const [id, stack] of Object.entries(opts.preloaded.stacks)) {
|
||||||
|
batchedStacks.set(id, stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const preloadedLibs = (kind?: string): LibInfo[] =>
|
||||||
|
(opts.preloaded?.libs ?? [])
|
||||||
|
.filter(
|
||||||
|
(l) =>
|
||||||
|
!kind ||
|
||||||
|
// Same rule the server applies: org libs and mirrors are
|
||||||
|
// kind-agnostic containers; origins/pins need >=1 item of the kind.
|
||||||
|
l.type === "org" ||
|
||||||
|
l.type === "mirror" ||
|
||||||
|
(l.kindCounts?.[kind] ?? 0) > 0,
|
||||||
|
)
|
||||||
|
.map((l) => ({
|
||||||
|
id: l.id,
|
||||||
|
name: l.name,
|
||||||
|
description: l.description ?? null,
|
||||||
|
type: l.type,
|
||||||
|
itemCount: l.itemCount,
|
||||||
|
sync: l.sync ?? null,
|
||||||
|
}));
|
||||||
const forLib = (libId: string): LibsSource => {
|
const forLib = (libId: string): LibsSource => {
|
||||||
let src = perLib.get(libId);
|
let src = perLib.get(libId);
|
||||||
if (!src) {
|
if (!src) {
|
||||||
|
|
@ -408,8 +457,14 @@ export function syncedScopeLibsSource(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every internal listing goes through the preload too — syncState, presync
|
||||||
|
// and the realtime name-mapping otherwise each re-fetch the listing the
|
||||||
|
// boot payload already carried.
|
||||||
|
const listLibsPreferred = (kind?: string): Promise<LibInfo[]> =>
|
||||||
|
opts.preloaded ? Promise.resolve(preloadedLibs(kind)) : remote.listLibs(kind);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
listLibs: (kind) => remote.listLibs(kind),
|
listLibs: (kind) => listLibsPreferred(kind),
|
||||||
createLib: remote.createLib?.bind(remote),
|
createLib: remote.createLib?.bind(remote),
|
||||||
getFpIndex: remote.getFpIndex?.bind(remote),
|
getFpIndex: remote.getFpIndex?.bind(remote),
|
||||||
async syncState(kind): Promise<LibsSyncState | null> {
|
async syncState(kind): Promise<LibsSyncState | null> {
|
||||||
|
|
@ -419,7 +474,7 @@ export function syncedScopeLibsSource(
|
||||||
// is answerable from the local cache alone — no stack resolves, no
|
// is answerable from the local cache alone — no stack resolves, no
|
||||||
// bundle fetches. A backend predating the `sync` field yields no
|
// bundle fetches. A backend predating the `sync` field yields no
|
||||||
// namespaces at all → null (unknown), never a fake all-cold answer.
|
// namespaces at all → null (unknown), never a fake all-cold answer.
|
||||||
const libs = await remote.listLibs(kind);
|
const libs = await listLibsPreferred(kind);
|
||||||
const withNs = libs.filter(
|
const withNs = libs.filter(
|
||||||
(l): l is LibInfo & { sync: { namespace: string; bytes?: number | null } } =>
|
(l): l is LibInfo & { sync: { namespace: string; bytes?: number | null } } =>
|
||||||
!!l.sync?.namespace,
|
!!l.sync?.namespace,
|
||||||
|
|
@ -457,7 +512,7 @@ export function syncedScopeLibsSource(
|
||||||
saveItemBody: (libId, kind, name, body) =>
|
saveItemBody: (libId, kind, name, body) =>
|
||||||
forLib(libId).saveItemBody!(libId, kind, name, body),
|
forLib(libId).saveItemBody!(libId, kind, name, body),
|
||||||
async presync(presyncOpts): Promise<void> {
|
async presync(presyncOpts): Promise<void> {
|
||||||
const libs = await remote.listLibs(presyncOpts?.kind);
|
const libs = await listLibsPreferred(presyncOpts?.kind);
|
||||||
const total = libs.length;
|
const total = libs.length;
|
||||||
let done = 0;
|
let done = 0;
|
||||||
presyncOpts?.onProgress?.({ done, total, current: "libraries" });
|
presyncOpts?.onProgress?.({ done, total, current: "libraries" });
|
||||||
|
|
@ -493,7 +548,7 @@ export function syncedScopeLibsSource(
|
||||||
// has usually made the same call already). Names that don't resolve —
|
// has usually made the same call already). Names that don't resolve —
|
||||||
// project-local table rows, stale nicknames — are simply not ours.
|
// project-local table rows, stale nicknames — are simply not ours.
|
||||||
const wanted = new Set(libNames);
|
const wanted = new Set(libNames);
|
||||||
const libs = (await remote.listLibs()).filter((l) => wanted.has(l.name));
|
const libs = (await listLibsPreferred()).filter((l) => wanted.has(l.name));
|
||||||
opts.log?.(
|
opts.log?.(
|
||||||
`[synced] realtime upgrade for ${libs.length}/${libNames.length} referenced lib(s)`,
|
`[synced] realtime upgrade for ${libs.length}/${libNames.length} referenced lib(s)`,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { encodeBundle, type SyncManifest } from "@pcbjam/shared";
|
import { encodeBundle, manifestDigest, type SyncManifest } from "@pcbjam/shared";
|
||||||
import { memStore } from "@pcbjam/sync-client";
|
import { memStore } from "@pcbjam/sync-client";
|
||||||
import {
|
import {
|
||||||
stageViaProjectSync,
|
stageViaProjectSync,
|
||||||
|
|
@ -25,16 +25,27 @@ function fakeSyncServer(bodies: Record<string, string>) {
|
||||||
frames.push([path, b]);
|
frames.push([path, b]);
|
||||||
}
|
}
|
||||||
let bundleFetches = 0;
|
let bundleFetches = 0;
|
||||||
|
let manifestFetches = 0;
|
||||||
const fetchImpl = (async (input: unknown) => {
|
const fetchImpl = (async (input: unknown) => {
|
||||||
const url = String(input);
|
const url = String(input);
|
||||||
if (url.endsWith("/sync/manifest")) return Response.json(manifest);
|
if (url.endsWith("/sync/manifest")) {
|
||||||
|
manifestFetches += 1;
|
||||||
|
return Response.json(manifest);
|
||||||
|
}
|
||||||
if (url.endsWith("/sync/bundle")) {
|
if (url.endsWith("/sync/bundle")) {
|
||||||
bundleFetches += 1;
|
bundleFetches += 1;
|
||||||
return new Response(encodeBundle(manifest, frames) as BodyInit);
|
return new Response(encodeBundle(manifest, frames) as BodyInit);
|
||||||
}
|
}
|
||||||
return new Response(null, { status: 404 });
|
return new Response(null, { status: 404 });
|
||||||
}) as typeof fetch;
|
}) as typeof fetch;
|
||||||
return { fetchImpl, counters: { get bundleFetches() { return bundleFetches; } } };
|
return {
|
||||||
|
fetchImpl,
|
||||||
|
manifest,
|
||||||
|
counters: {
|
||||||
|
get bundleFetches() { return bundleFetches; },
|
||||||
|
get manifestFetches() { return manifestFetches; },
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncConfig(fetchImpl: typeof fetch): ProjectSyncConfig {
|
function syncConfig(fetchImpl: typeof fetch): ProjectSyncConfig {
|
||||||
|
|
@ -118,6 +129,34 @@ describe("stageViaProjectSync", () => {
|
||||||
expect(rest).toEqual(files);
|
expect(rest).toEqual(files);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("a boot digest makes the WARM restage zero-request (load-path 0001 §6)", async () => {
|
||||||
|
const server = fakeSyncServer({ "a.txt": "A", "b.txt": "BB" });
|
||||||
|
const files: ToolFile[] = [
|
||||||
|
{ path: "a.txt", revision: 1 },
|
||||||
|
{ path: "b.txt", revision: 1 },
|
||||||
|
];
|
||||||
|
const config = syncConfig(server.fetchImpl); // shared stores across runs
|
||||||
|
const cold: string[] = [];
|
||||||
|
await stageViaProjectSync(
|
||||||
|
{ slug: "p", files, projectSync: config, log: () => {} },
|
||||||
|
(p) => cold.push(p),
|
||||||
|
);
|
||||||
|
expect(cold.sort()).toEqual(["a.txt", "b.txt"]);
|
||||||
|
expect(server.counters.bundleFetches).toBe(1);
|
||||||
|
|
||||||
|
// Warm, with the boot payload's fresh digest: the stored snapshot is
|
||||||
|
// affirmed without even the manifest GET.
|
||||||
|
const digest = await manifestDigest(server.manifest);
|
||||||
|
const warm: string[] = [];
|
||||||
|
await stageViaProjectSync(
|
||||||
|
{ slug: "p", files, projectSync: { ...config, digest }, log: () => {} },
|
||||||
|
(p) => warm.push(p),
|
||||||
|
);
|
||||||
|
expect(warm.sort()).toEqual(["a.txt", "b.txt"]);
|
||||||
|
expect(server.counters.bundleFetches).toBe(1);
|
||||||
|
expect(server.counters.manifestFetches).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("no sync config means the whole set is per-file (demo/local sources)", async () => {
|
it("no sync config means the whole set is per-file (demo/local sources)", async () => {
|
||||||
const files: ToolFile[] = [{ path: "a.txt", revision: 1 }];
|
const files: ToolFile[] = [{ path: "a.txt", revision: 1 }];
|
||||||
const rest = await stageViaProjectSync(
|
const rest = await stageViaProjectSync(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue