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";
|
||||
import { defaultFileName, newFileTemplate, withExtension } from "@/lib/new-file";
|
||||
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 { bootKicadTool } from "@/wasm/boot";
|
||||
import {
|
||||
|
|
@ -954,6 +954,7 @@ export function WasmTool({
|
|||
libsSource,
|
||||
sourceDescriptor,
|
||||
readOnly = false,
|
||||
boot = null,
|
||||
}: {
|
||||
tool: Tool;
|
||||
slug: string;
|
||||
|
|
@ -972,6 +973,13 @@ export function WasmTool({
|
|||
* — a specific backend lib, or a local `.kicad_sym`/`.kicad_mod` file.
|
||||
*/
|
||||
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). */
|
||||
fetchBytes: (relPath: string) => Promise<Uint8Array>;
|
||||
/**
|
||||
|
|
@ -1639,7 +1647,9 @@ export function WasmTool({
|
|||
// parallel with the WASM download; awaited after boot, before anything
|
||||
// binds presence/comments, so presenceUser()/userSlug() speak for the
|
||||
// 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);
|
||||
// Resolve the per-tool asset base at runtime (CDN manifest → versioned
|
||||
// folder, or the flat local /wasm in dev). See wasm/wasm-assets.ts.
|
||||
|
|
@ -1647,9 +1657,15 @@ export function WasmTool({
|
|||
const base = meta.base;
|
||||
// One source instance, shared by the wasm provider AND the pre-sync below
|
||||
// (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 =
|
||||
libsSource !== undefined ? libsSource : libsSourceConfig(projectId);
|
||||
libsSource !== undefined
|
||||
? libsSource
|
||||
: libsSourceConfig(
|
||||
projectId,
|
||||
boot ? { libs: boot.libs, stacks: boot.stacks } : undefined,
|
||||
);
|
||||
if (libsSource === undefined) ownedLibsSource = source;
|
||||
// Download-consent gate (standalone-load-ux 0001): before pulling the
|
||||
// (large) cold wasm + lib bundles, say how many MB and wait for the OK.
|
||||
|
|
@ -1897,6 +1913,8 @@ export function WasmTool({
|
|||
scope: currentScope(),
|
||||
scopeId,
|
||||
projectId,
|
||||
// Boot's fresh digest: a warm match stages with ZERO HTTP.
|
||||
digest: boot?.projectSync.digest,
|
||||
}
|
||||
: null,
|
||||
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 { 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 type { LibInfo } from "@/wasm/libs/source";
|
||||
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
|
||||
* 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;
|
||||
}
|
||||
|
||||
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";
|
||||
// "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
|
||||
|
|
@ -338,6 +350,7 @@ export function libsSourceConfig(projectId?: string): LibsSource | null {
|
|||
scope: currentScope(),
|
||||
user: userSlug(),
|
||||
project,
|
||||
preloaded: preload,
|
||||
log: (m) => console.log(m),
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
loadSessionIdentity,
|
||||
seedSessionIdentity,
|
||||
resetSessionIdentityForTest,
|
||||
sessionIdentity,
|
||||
} from "./session-identity";
|
||||
|
|
@ -69,4 +70,18 @@ describe("loadSessionIdentity", () => {
|
|||
await loadSessionIdentity("http://api");
|
||||
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" })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((body: unknown) => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
adoptMePayload(body);
|
||||
return identity;
|
||||
})
|
||||
.catch(() => null);
|
||||
|
|
@ -54,6 +42,32 @@ export function loadSessionIdentity(
|
|||
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. */
|
||||
export function resetSessionIdentityForTest(): void {
|
||||
identity = null;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import {
|
|||
createProjectFileIfMissing,
|
||||
fetchFileBytes,
|
||||
uploadFileBytes,
|
||||
useProject,
|
||||
useProjectBoot,
|
||||
useSourceDescriptor,
|
||||
} from "@/lib/api";
|
||||
import { docSourceConfig } from "@/lib/config";
|
||||
|
|
@ -30,7 +30,9 @@ export function ToolPage() {
|
|||
: (parseToolParam(search.get("tool")) ?? (splat ? toolForFile(splat) : null));
|
||||
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);
|
||||
// Listing rows by path, handed to fetchFileBytes so the remote source can
|
||||
// serve unchanged files from the local body cache (project-file-cache.ts).
|
||||
|
|
@ -102,6 +104,7 @@ export function ToolPage() {
|
|||
docSource={docSource}
|
||||
sourceDescriptor={sourceDescriptor}
|
||||
readOnly={readOnly}
|
||||
boot={bootData?.boot ?? null}
|
||||
/>
|
||||
</PreflightGate>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -33,6 +33,13 @@ export interface ProjectSyncConfig {
|
|||
scope: string;
|
||||
scopeId: 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). */
|
||||
fetchImpl?: typeof fetch;
|
||||
storeFactory?: ConstructorParameters<typeof SyncStack>[0]["storeFactory"];
|
||||
|
|
@ -220,6 +227,7 @@ export async function stageViaProjectSync(
|
|||
namespace: `project:${sync.scopeId}:${sync.projectId}`,
|
||||
kind: "static",
|
||||
url: `${sync.apiBase}/api/scopes/${encodeURIComponent(sync.scope)}/projects/${encodeURIComponent(opts.slug)}/sync`,
|
||||
digest: sync.digest,
|
||||
},
|
||||
],
|
||||
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", () => {
|
||||
function scoped(libs: LibInfo[]) {
|
||||
const remote: LibsSource = {
|
||||
|
|
|
|||
|
|
@ -333,6 +333,20 @@ function splitPath(path: string): LibItemInfo {
|
|||
* "lazy materialization" deferred note) — same trade the lib-editor pages
|
||||
* 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(
|
||||
remote: LibsSource,
|
||||
opts: {
|
||||
|
|
@ -341,6 +355,17 @@ export function syncedScopeLibsSource(
|
|||
user?: string;
|
||||
project?: string;
|
||||
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;
|
||||
storeFactory?: (namespace: string) => LayerStore;
|
||||
channelFactory?: ChannelFactory;
|
||||
|
|
@ -352,6 +377,30 @@ export function syncedScopeLibsSource(
|
|||
// 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.
|
||||
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 => {
|
||||
let src = perLib.get(libId);
|
||||
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 {
|
||||
listLibs: (kind) => remote.listLibs(kind),
|
||||
listLibs: (kind) => listLibsPreferred(kind),
|
||||
createLib: remote.createLib?.bind(remote),
|
||||
getFpIndex: remote.getFpIndex?.bind(remote),
|
||||
async syncState(kind): Promise<LibsSyncState | null> {
|
||||
|
|
@ -419,7 +474,7 @@ export function syncedScopeLibsSource(
|
|||
// is answerable from the local cache alone — no stack resolves, no
|
||||
// bundle fetches. A backend predating the `sync` field yields no
|
||||
// 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(
|
||||
(l): l is LibInfo & { sync: { namespace: string; bytes?: number | null } } =>
|
||||
!!l.sync?.namespace,
|
||||
|
|
@ -457,7 +512,7 @@ export function syncedScopeLibsSource(
|
|||
saveItemBody: (libId, kind, name, body) =>
|
||||
forLib(libId).saveItemBody!(libId, kind, name, body),
|
||||
async presync(presyncOpts): Promise<void> {
|
||||
const libs = await remote.listLibs(presyncOpts?.kind);
|
||||
const libs = await listLibsPreferred(presyncOpts?.kind);
|
||||
const total = libs.length;
|
||||
let done = 0;
|
||||
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 —
|
||||
// project-local table rows, stale nicknames — are simply not ours.
|
||||
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?.(
|
||||
`[synced] realtime upgrade for ${libs.length}/${libNames.length} referenced lib(s)`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 {
|
||||
stageViaProjectSync,
|
||||
|
|
@ -25,16 +25,27 @@ function fakeSyncServer(bodies: Record<string, string>) {
|
|||
frames.push([path, b]);
|
||||
}
|
||||
let bundleFetches = 0;
|
||||
let manifestFetches = 0;
|
||||
const fetchImpl = (async (input: unknown) => {
|
||||
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")) {
|
||||
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; } } };
|
||||
return {
|
||||
fetchImpl,
|
||||
manifest,
|
||||
counters: {
|
||||
get bundleFetches() { return bundleFetches; },
|
||||
get manifestFetches() { return manifestFetches; },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function syncConfig(fetchImpl: typeof fetch): ProjectSyncConfig {
|
||||
|
|
@ -118,6 +129,34 @@ describe("stageViaProjectSync", () => {
|
|||
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 () => {
|
||||
const files: ToolFile[] = [{ path: "a.txt", revision: 1 }];
|
||||
const rest = await stageViaProjectSync(
|
||||
|
|
|
|||
Loading…
Reference in a new issue