@tokenoftrust/storefront-runner 2.2.89 → 2.2.90

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,153 @@
1
+ /**
2
+ * `.tot/config.json` mapping resolution — the ONE reading of a store repo's
3
+ * declared layout, shared by every consumer: the hosted reconcile (preview,
4
+ * candidate, aggregate, publication), `tot dev`'s checkout graft, `tot validate`,
5
+ * the platform's materialize / asset-repair scripts, and this kit's
6
+ * `repair-tenant-config.mjs`. The platform imports this file from the kit.
7
+ *
8
+ * A mapping is `{ workspace, repo, kind }`:
9
+ * - `workspace` — where the files physically live in the store repo. It is the
10
+ * only source of truth for location; nothing assumes a `tenants/<id>/` layout
11
+ * inside the store's own repo.
12
+ * - `repo` — the storefront artifact target, `tenants/<segment>/<rel>`. Only
13
+ * `<rel>` (the tenant-relative path: `theme.json`, `content/`, `public/`, …)
14
+ * is read from it. The tenant namespace always comes from the CANONICAL
15
+ * tenant id — the store's registered id hosted, the config's `tenant` locally
16
+ * — never from `<segment>`, so a config whose segment disagrees with its
17
+ * tenant id resolves identically on every surface.
18
+ *
19
+ * Pure and dependency-free so kit tools, platform `.mjs` scripts and TypeScript
20
+ * consumers (types in the adjacent `.d.mts`) all import it. The published CLI
21
+ * installs without the kit, so it ships a byte-identical copy that its own test
22
+ * suite holds equal to this file.
23
+ */
24
+
25
+ /** Platform test tenants nest one level deeper: `tenants/e2e/<id>.e2e.test/`. */
26
+ const TARGET_RE = /^tenants\/(?:e2e\/([^/]+\.e2e\.test)|([^/]+))(?:\/(.*))?$/;
27
+
28
+ /**
29
+ * Split a declared mapping target into the tenant segment it names and the
30
+ * tenant-relative path it addresses. Null when the target is not under
31
+ * `tenants/<segment>/`, or when its relative part is not a clean path (empty,
32
+ * `.` or `..` segments) — such a target could address outside the tenant.
33
+ * A trailing `/` (tree target) is preserved on `rel`; the tenant root is `""`.
34
+ * @param {unknown} repo
35
+ * @returns {{ segment: string, rel: string } | null}
36
+ */
37
+ export function parseMappingTarget(repo) {
38
+ if (typeof repo !== "string") return null;
39
+ const m = TARGET_RE.exec(repo);
40
+ if (!m) return null;
41
+ const segment = m[1] ?? m[2];
42
+ const rel = m[3] ?? "";
43
+ const parts = rel.replace(/\/$/, "").split("/");
44
+ if (rel && parts.some((p) => !p || p === "." || p === "..")) return null;
45
+ return { segment, rel };
46
+ }
47
+
48
+ /** The canonical artifact root for a tenant: `tenants/<tenantId>/`. */
49
+ export function tenantArtifactRoot(tenantId) {
50
+ return `tenants/${tenantId}/`;
51
+ }
52
+
53
+ function asDir(path) {
54
+ return path === "" || path.endsWith("/") ? path : `${path}/`;
55
+ }
56
+
57
+ function mappingsOf(config) {
58
+ return Array.isArray(config?.mappings) ? config.mappings : [];
59
+ }
60
+
61
+ /**
62
+ * Map a tenant-repo path to its tenant-relative path (e.g. `content/home.json`).
63
+ * First match wins; a `file` mapping matches exactly, a `tree` mapping by prefix.
64
+ * Null when no mapping covers the path or the covering mapping's target is not
65
+ * a valid tenant target.
66
+ * @param {{ mappings?: unknown }} config
67
+ * @param {string} workspacePath
68
+ * @returns {string | null}
69
+ */
70
+ export function mapWorkspaceToTenantRelative(config, workspacePath) {
71
+ for (const m of mappingsOf(config)) {
72
+ if (!m || typeof m !== "object" || typeof m.workspace !== "string") continue;
73
+ if (m.kind === "file") {
74
+ if (workspacePath !== m.workspace) continue;
75
+ const target = parseMappingTarget(m.repo);
76
+ return target ? target.rel : null;
77
+ }
78
+ if (m.kind === "tree") {
79
+ const ws = asDir(m.workspace);
80
+ if (!workspacePath.startsWith(ws)) continue;
81
+ const target = parseMappingTarget(m.repo);
82
+ return target ? asDir(target.rel) + workspacePath.slice(ws.length) : null;
83
+ }
84
+ }
85
+ return null;
86
+ }
87
+
88
+ /**
89
+ * Map a tenant-repo path to its storefront artifact path under the CANONICAL
90
+ * tenant root (`tenants/<tenantId>/<rel>`). Null when unmapped.
91
+ * @param {{ mappings?: unknown }} config
92
+ * @param {string} tenantId
93
+ * @param {string} workspacePath
94
+ * @returns {string | null}
95
+ */
96
+ export function mapWorkspaceToArtifact(config, tenantId, workspacePath) {
97
+ const rel = mapWorkspaceToTenantRelative(config, workspacePath);
98
+ return rel === null ? null : tenantArtifactRoot(tenantId) + rel;
99
+ }
100
+
101
+ /**
102
+ * The inverse: where a tenant-relative path (`theme.json`, `content`, `public`,
103
+ * `content/home.json`) lives in the tenant repo, per the declared mappings.
104
+ * Unmapped paths resolve to themselves — the flat checkout layout — so a
105
+ * consumer still finds files the mappings do not cover (and the config-coverage
106
+ * check reports the gap).
107
+ * @param {{ mappings?: unknown }} config
108
+ * @param {string} relPath tenant-relative, no leading `/`
109
+ * @returns {string}
110
+ */
111
+ export function workspacePathForTenantRelative(config, relPath) {
112
+ const bare = relPath.replace(/\/$/, "");
113
+ for (const m of mappingsOf(config)) {
114
+ if (!m || typeof m !== "object" || typeof m.workspace !== "string") continue;
115
+ const target = parseMappingTarget(m.repo);
116
+ if (!target) continue;
117
+ const targetRel = target.rel.replace(/\/$/, "");
118
+ const ws = m.workspace.replace(/\/$/, "");
119
+ if (m.kind === "file") {
120
+ if (targetRel === bare) return ws;
121
+ continue;
122
+ }
123
+ if (m.kind !== "tree") continue;
124
+ if (targetRel === bare) return ws;
125
+ const prefix = asDir(targetRel);
126
+ if (bare.startsWith(prefix)) {
127
+ const rest = bare.slice(prefix.length);
128
+ return ws ? `${ws}/${rest}` : rest;
129
+ }
130
+ }
131
+ return bare;
132
+ }
133
+
134
+ /**
135
+ * Mappings whose declared target names a tenant segment other than the
136
+ * canonical tenant id. They still resolve correctly (the segment is never
137
+ * read); this exists so validators can tell the author the config disagrees
138
+ * with itself.
139
+ * @param {{ mappings?: unknown }} config
140
+ * @param {string} tenantId
141
+ * @returns {{ workspace: string, repo: string, segment: string }[]}
142
+ */
143
+ export function foreignTenantSegments(config, tenantId) {
144
+ const out = [];
145
+ for (const m of mappingsOf(config)) {
146
+ if (!m || typeof m !== "object") continue;
147
+ const target = parseMappingTarget(m.repo);
148
+ if (target && target.segment !== tenantId) {
149
+ out.push({ workspace: String(m.workspace ?? ""), repo: m.repo, segment: target.segment });
150
+ }
151
+ }
152
+ return out;
153
+ }
@@ -36,10 +36,10 @@ export interface DevTenantHints {
36
36
  */
37
37
  appDomain?: string;
38
38
  /**
39
- * True when catalog fixtures for this tenant's data scope are committed on
40
- * disk (the same `packages/migration/out/catalog/<scope>/` the FixtureToTClient
41
- * reads). When true we serve a real COMMERCE tenant so /collections + /products
42
- * render from them; when false we degrade to a marketing home (no catalog).
39
+ * True when the checkout has a catalog: its own `content/catalog/products/` files,
40
+ * or a grandfathered in-repo snapshot for its data scope. When true we serve a real
41
+ * COMMERCE tenant so /collections + /products render from it; when false we degrade
42
+ * to a marketing home (no catalog).
43
43
  */
44
44
  hasCatalog?: boolean;
45
45
  /**
@@ -117,8 +117,8 @@ export function synthesizeDevTenant(
117
117
  export interface DevTenantSeedOptions {
118
118
  /** Resolved appDomain for a slug tenant (from `.tot/config.json` scope). */
119
119
  scopeFor?: (id: string) => string | undefined;
120
- /** Whether catalog fixtures are committed on disk for a given data scope. */
121
- hasCatalog?: (scope: string) => boolean;
120
+ /** Whether the checkout `id` (data scope `scope`) has a catalog on disk. */
121
+ hasCatalog?: (scope: string, id: string) => boolean;
122
122
  /** The tenant's parsed `tenants/<id>/analytics.json`, or undefined when it has none. */
123
123
  analyticsFor?: (id: string) => unknown;
124
124
  }
@@ -147,7 +147,7 @@ export function devTenantsToSeed(
147
147
  seen.add(id);
148
148
  const appDomain = opts.scopeFor?.(id);
149
149
  const scope = appDomain ?? id;
150
- const hasCatalog = opts.hasCatalog?.(scope) ?? false;
150
+ const hasCatalog = opts.hasCatalog?.(scope, id) ?? false;
151
151
  const analyticsRaw = opts.analyticsFor?.(id);
152
152
  out.push(synthesizeDevTenant(id, themeFor(id), { appDomain, hasCatalog, analyticsRaw }));
153
153
  }
@@ -14,6 +14,7 @@ const ROOT_SOURCES: Readonly<Record<string, PublicationSpaceSourceV1>> = {
14
14
  "verify.e2e.test": { forge: "fixture", repo: "verify.e2e.test", defaultBranch: "main" },
15
15
  "fixture.e2e.test": { forge: "fixture", repo: "fixture.e2e.test", defaultBranch: "main" },
16
16
  "chrome.e2e.test": { forge: "fixture", repo: "chrome.e2e.test", defaultBranch: "main" },
17
+ "catalog.e2e.test": { forge: "fixture", repo: "catalog.e2e.test", defaultBranch: "main" },
17
18
  };
18
19
 
19
20
  /** Node-safe publication topology authority shared by runtime config and CI composition. */
@@ -72,6 +72,23 @@ const catalogFixtureFiles = import.meta.glob(
72
72
  "../../../../packages/migration/out/catalog/*/products.json",
73
73
  );
74
74
 
75
+ // A checkout's own catalog files (ADR 0033): `tot dev` grafts the store's
76
+ // `content/catalog/` here, and the catalog reader serves them.
77
+ const storeCatalogFiles = import.meta.glob([
78
+ "../../../../tenants/*/content/catalog/products/*.json",
79
+ "../../../../tenants/e2e/*/content/catalog/products/*.json",
80
+ ]);
81
+
82
+ /** Tenant dir ids whose checkout carries its own catalog files. */
83
+ function storeCatalogIds(): Set<string> {
84
+ const ids = new Set<string>();
85
+ for (const key of Object.keys(storeCatalogFiles)) {
86
+ const id = tenantIdFromPath(key);
87
+ if (id) ids.add(id);
88
+ }
89
+ return ids;
90
+ }
91
+
75
92
  /** Data scopes (== out/catalog dir names) that have committed catalog fixtures. */
76
93
  function catalogScopes(): Set<string> {
77
94
  const scopes = new Set<string>();
@@ -120,13 +137,14 @@ function devFallbackResolver(): TenantResolver {
120
137
  if (!import.meta.env.DEV) return staticResolver;
121
138
  const staticIds = new Set(staticTenants.map((t) => t.tenant_id));
122
139
  const withCatalog = catalogScopes();
140
+ const withStoreCatalog = storeCatalogIds();
123
141
  const scopes = slugScopes();
124
142
  // The SAME tenant-owned analytics read the static registry uses; here it serves only
125
143
  // the tenants that have NO registry entry (see config/tenantAnalyticsFiles.ts).
126
144
  const analytics = tenantAnalyticsRawById();
127
145
  const dev = devTenantsToSeed(localTenantDirIds(), staticIds, getTenantTheme, {
128
146
  scopeFor: (id) => scopes.get(id),
129
- hasCatalog: (scope) => withCatalog.has(scope),
147
+ hasCatalog: (scope, id) => withStoreCatalog.has(id) || withCatalog.has(scope),
130
148
  analyticsFor: (id) => analytics.get(id),
131
149
  });
132
150
  if (dev.length === 0) return staticResolver;
@@ -81,6 +81,11 @@ export interface ContentProvider {
81
81
  readArtifact(relative: string): Promise<string | null>;
82
82
  /** Artifact paths relative to `content/`; null means use a legacy fallback. */
83
83
  listArtifactPaths(prefix?: string): Promise<string[] | null>;
84
+ /**
85
+ * True when each `readArtifact` is its own request-time store read (the KV-backed version
86
+ * providers), so a caller that reads many artifacts in one request budgets against it.
87
+ */
88
+ readonly perRequestArtifactReads?: boolean;
84
89
  }
85
90
 
86
91
  // ---------------------------------------------------------------------------
@@ -155,6 +160,23 @@ const artifactGlobs = import.meta.glob<string>([
155
160
  import: "default",
156
161
  });
157
162
 
163
+ /**
164
+ * Glob keys by their `/tenants/…/<id>/content/<path>` tail, the form `readArtifact` asks for.
165
+ * A store's catalog is one artifact per product, so a page reads thousands of them; a linear
166
+ * scan of every key per read made a 3k-product page cost seconds.
167
+ */
168
+ let artifactKeysByTail: Map<string, string> | undefined;
169
+ function artifactKeyFor(tail: string): string | undefined {
170
+ if (!artifactKeysByTail) {
171
+ artifactKeysByTail = new Map();
172
+ for (const key of Object.keys(artifactGlobs)) {
173
+ const at = key.indexOf("/tenants/");
174
+ if (at >= 0) artifactKeysByTail.set(key.slice(at), key);
175
+ }
176
+ }
177
+ return artifactKeysByTail.get(tail);
178
+ }
179
+
158
180
  const DEFAULT_SCOPE = "home";
159
181
 
160
182
  function pickContent<T>(
@@ -337,7 +359,7 @@ export class LocalContentProvider implements ContentProvider {
337
359
  async readArtifact(relative: string): Promise<string | null> {
338
360
  const normalized = relative.replace(/^\/+/, "");
339
361
  const marker = `${tenantDirSegment(this.scope)}content/${normalized}`;
340
- const key = Object.keys(artifactGlobs).find((candidate) => candidate.endsWith(marker));
362
+ const key = artifactKeyFor(marker);
341
363
  return key ? artifactGlobs[key]!() : null;
342
364
  }
343
365
 
@@ -571,6 +593,7 @@ export interface ContentCustomization {
571
593
  }
572
594
 
573
595
  export class PublishedContentProvider implements ContentProvider {
596
+ readonly perRequestArtifactReads = true;
574
597
  constructor(
575
598
  private readonly base: ContentProvider,
576
599
  private readonly tenantId: string,
@@ -695,6 +718,7 @@ export class PublishedContentProvider implements ContentProvider {
695
718
  // ---------------------------------------------------------------------------
696
719
 
697
720
  export class VersionPinnedContentProvider implements ContentProvider {
721
+ readonly perRequestArtifactReads = true;
698
722
  constructor(
699
723
  private readonly base: ContentProvider,
700
724
  private readonly tenantId: string,
@@ -2,10 +2,11 @@
2
2
  * ToTClient — the storefront's SINGLE source of product truth.
3
3
  *
4
4
  * Two backends behind one interface:
5
- * - D1ToTClient — the Cloudflare D1 edge read replica (FTS5 search + SQL
6
- * facets), used when a CATALOG_DB binding is wired.
7
- * - FixtureToTClient — reads packages/migration/out/catalog/*.json so the
8
- * whole site renders offline with no creds.
5
+ * - D1ToTClient — the D1 serving projection (FTS5 search + SQL facets), for a
6
+ * tenant that declares `catalog.serving: "d1"`.
7
+ * - FixtureToTClient — an in-memory catalog: the store's own `content/catalog/`
8
+ * files (tenantCatalog.ts), or the grandfathered in-repo
9
+ * snapshot under packages/migration/out/catalog/<scope>/.
9
10
  *
10
11
  * Public listings (PLP/search/featured) MUST exclude non-active products. The
11
12
  * `trail-cap` fixture is a `draft` and is the canary: it must NOT appear on any
@@ -26,6 +27,7 @@ import { D1ToTClient } from "./d1Client.js";
26
27
  import { fromD1, type D1Like } from "../d1/catalog.js";
27
28
  import { buildSearchIndex, type SearchIndex } from "../search/index.js";
28
29
  import type { ToTClient } from "./totClientInterface.js";
30
+ import { readTenantCatalogFiles, type CatalogArtifactSource } from "./tenantCatalog.js";
29
31
 
30
32
  // The contract lives in its own module so d1Client can implement it without a
31
33
  // circular edge back to this file (which imports D1ToTClient for the factory).
@@ -35,7 +37,7 @@ export type { ToTClient } from "./totClientInterface.js";
35
37
  const isActive = (p: CatalogProduct) => p.status === "active";
36
38
 
37
39
  // ---------------------------------------------------------------------------
38
- // FixtureToTClient — local JSON, used when no live catalog creds are present.
40
+ // FixtureToTClient — an in-memory catalog (store files or the in-repo snapshot).
39
41
  // ---------------------------------------------------------------------------
40
42
 
41
43
  export class FixtureToTClient implements ToTClient {
@@ -109,7 +111,7 @@ export class FixtureToTClient implements ToTClient {
109
111
  }
110
112
 
111
113
  // ---------------------------------------------------------------------------
112
- // Factory — D1 edge replica when bound, else the bundled fixture snapshot.
114
+ // Factory — the declared catalog serving artifact (ADR 0033).
113
115
  // ---------------------------------------------------------------------------
114
116
 
115
117
  type FixtureBundle = {
@@ -118,10 +120,9 @@ type FixtureBundle = {
118
120
  reviews: Record<string, Review[]>;
119
121
  };
120
122
 
121
- // Per-tenant fixtures live under out/catalog/<tot_data_scope>/. Vite resolves
122
- // these globs at build time and bundles every tenant's JSON, so the worker has
123
- // no runtime filesystem dependency. reviews.json is OPTIONAL (only present after
124
- // the migration's reviews step) — a tenant without it just gets {}.
123
+ // The grandfathered in-repo snapshot: packages/migration/out/catalog/<tot_data_scope>/,
124
+ // tracked only for the tenants in scripts/tenant/catalog-in-repo-exceptions.json (plus
125
+ // the e2e scopes). Vite resolves these globs at build time. reviews.json is optional.
125
126
  const productGlobs = import.meta.glob<CatalogProduct[]>(
126
127
  "../../../../../packages/migration/out/catalog/*/products.json",
127
128
  { import: "default" },
@@ -162,16 +163,60 @@ async function loadFixtures(scope: string): Promise<FixtureBundle> {
162
163
  return bundle;
163
164
  }
164
165
 
166
+ /**
167
+ * Resolves its backend on the first read, so a page that never touches the catalog
168
+ * (a blog post, an editorial page) never pays for — or fails on — reading it.
169
+ */
170
+ class DeferredToTClient implements ToTClient {
171
+ private client?: Promise<ToTClient>;
172
+ constructor(private readonly load: () => Promise<ToTClient>) {}
173
+ private resolved(): Promise<ToTClient> {
174
+ this.client ??= this.load();
175
+ return this.client;
176
+ }
177
+ async listProducts(query?: ListProductsQuery) { return (await this.resolved()).listProducts(query); }
178
+ async getProductByHandle(handle: string) { return (await this.resolved()).getProductByHandle(handle); }
179
+ async listCollections() { return (await this.resolved()).listCollections(); }
180
+ async getCollectionByHandle(handle: string) { return (await this.resolved()).getCollectionByHandle(handle); }
181
+ async search(q: string, limit?: number) { return (await this.resolved()).search(q, limit); }
182
+ async getFeatured(limit?: number) { return (await this.resolved()).getFeatured(limit); }
183
+ async getAllActiveProducts() { return (await this.resolved()).getAllActiveProducts(); }
184
+ async getReviews(handle: string) { return (await this.resolved()).getReviews(handle); }
185
+ }
186
+
187
+ /**
188
+ * The tenant's catalog, selected by its declared `catalog.serving` (ADR 0033):
189
+ *
190
+ * "d1" the D1 serving projection. The caller supplies it only where a
191
+ * per-request D1 read is allowed (the control plane); a public or
192
+ * static render has none, so a d1 tenant refuses there rather than
193
+ * serving a different artifact.
194
+ * "files" (default) in order:
195
+ * 1. the store's own `content/catalog/` files, read through `content`
196
+ * (the request's tenant content source);
197
+ * 2. the grandfathered in-repo snapshot for `tot_data_scope`;
198
+ * 3. an empty catalog.
199
+ */
165
200
  export async function createToTClient(
166
201
  tenant: TenantConfig,
167
202
  d1?: D1Like,
203
+ content?: CatalogArtifactSource,
168
204
  ): Promise<ToTClient> {
169
- // Edge read replica: when a D1 binding is wired,
170
- // serve reads from it (FTS5 search + SQL facets). Otherwise the bundled fixture
171
- // snapshot renders the site offline. The tot20 HTTP catalog API has been retired,
172
- // so the read path is fully edge-native.
173
- if (d1) return new D1ToTClient(fromD1(d1));
174
-
175
- const { products, collections, reviews } = await loadFixtures(tenant.tot_data_scope);
176
- return new FixtureToTClient(products, collections, tenant.tot_data_scope, reviews);
205
+ if (tenant.catalog?.serving === "d1") {
206
+ if (d1) return new D1ToTClient(fromD1(d1));
207
+ return new DeferredToTClient(async () => {
208
+ throw new Error(
209
+ `catalog: ${tenant.tenant_id} declares catalog.serving: "d1", and this render has no ` +
210
+ `D1 serving projection (public and static renders never read D1 per request, ADR 0013).`,
211
+ );
212
+ });
213
+ }
214
+ return new DeferredToTClient(async () => {
215
+ const files = await readTenantCatalogFiles(tenant, content);
216
+ if (files) {
217
+ return new FixtureToTClient(files.products, files.collections, tenant.tot_data_scope);
218
+ }
219
+ const { products, collections, reviews } = await loadFixtures(tenant.tot_data_scope);
220
+ return new FixtureToTClient(products, collections, tenant.tot_data_scope, reviews);
221
+ });
177
222
  }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The catalog reader for a store's own `content/catalog/` files (ADR 0033 "content,
3
+ * cold"). Reads through the SAME artifact source every other tenant content surface
4
+ * uses (`ContentProvider.readArtifact` / `listArtifactPaths`), so it follows the store's
5
+ * `.tot/config.json` content mapping and whichever version the request selected:
6
+ *
7
+ * - hosted preview (Worker): the reconciled version manifest in KV, one read per file;
8
+ * - static publish (both CDNs) and `tot dev`: the store's checkout as materialized
9
+ * into the build (`LocalContentProvider`'s build-time globs).
10
+ *
11
+ * Parsing and validation are the kit's shared catalog module: one bad file refuses the
12
+ * whole catalog with every failing path named, never a partial catalog with a product
13
+ * silently missing.
14
+ */
15
+ import type { CatalogCollection, CatalogProduct, TenantConfig } from "@tot/public-runtime";
16
+ import {
17
+ CATALOG_ARTIFACT_PREFIX,
18
+ CatalogFileError,
19
+ readCatalogFiles,
20
+ type CatalogStore,
21
+ } from "@tot/public-runtime/catalog-files";
22
+ import { LocalContentProvider, type ContentProvider } from "../storyblok/provider.js";
23
+
24
+ export type CatalogArtifactSource = Pick<
25
+ ContentProvider,
26
+ "readArtifact" | "listArtifactPaths" | "perRequestArtifactReads"
27
+ >;
28
+
29
+ export interface TenantCatalogFiles {
30
+ products: CatalogProduct[];
31
+ collections: CatalogCollection[];
32
+ }
33
+
34
+ /**
35
+ * The most catalog files one request may read from a per-request source. A Worker
36
+ * invocation may make 1,000 calls to KV and other bindings, and each artifact read is
37
+ * one of them; the rest of the budget is left to the page's other reads. Measured
38
+ * against a 2,959-product, 357-collection catalog (3,316 files, 44.6 MB at 13.4 KB per
39
+ * product, ~290 ms to validate): that store cannot be read file by file per request on
40
+ * the hosted preview, while `tot dev` and the static publish read it from the checkout.
41
+ */
42
+ export const CATALOG_REQUEST_READ_BUDGET = 800;
43
+
44
+ /**
45
+ * The selected source and its catalog paths. A source that cannot enumerate (`null`:
46
+ * no versioned manifest selected) yields to the store's build-time content, the same
47
+ * rule the blog and page indexes apply.
48
+ */
49
+ async function catalogPaths(
50
+ tenant: TenantConfig,
51
+ content: CatalogArtifactSource | undefined,
52
+ ): Promise<{ source: CatalogArtifactSource; paths: string[] }> {
53
+ const listed = content ? await content.listArtifactPaths(CATALOG_ARTIFACT_PREFIX) : null;
54
+ if (content && listed !== null) return { source: content, paths: listed };
55
+ const local = new LocalContentProvider(tenant.tenant_id);
56
+ return { source: local, paths: await local.listArtifactPaths(CATALOG_ARTIFACT_PREFIX) };
57
+ }
58
+
59
+ /**
60
+ * A build-time source serves the same bytes to every page a build renders, so a parsed
61
+ * file is reused while its text is unchanged (a `tot dev` save changes the text). A
62
+ * static publish renders a page per product; without this each page re-validated the
63
+ * whole catalog.
64
+ */
65
+ const parsedBuildFiles = new Map<string, { raw: string; catalog: TenantCatalogFiles }>();
66
+
67
+ function readBuildCatalog(
68
+ tenantId: string,
69
+ entries: Array<{ path: string; raw: string | null }>,
70
+ store: CatalogStore,
71
+ ): TenantCatalogFiles {
72
+ const products: CatalogProduct[] = [];
73
+ const collections: CatalogCollection[] = [];
74
+ const failures: Array<{ path: string; issues: readonly string[] }> = [];
75
+ for (const entry of entries) {
76
+ const key = `${tenantId}\0${entry.path}`;
77
+ let catalog = parsedBuildFiles.get(key);
78
+ if (!catalog || catalog.raw !== entry.raw) {
79
+ try {
80
+ const parsed = readCatalogFiles([entry], store);
81
+ catalog = entry.raw === null ? undefined : { raw: entry.raw, catalog: parsed };
82
+ if (catalog) parsedBuildFiles.set(key, catalog);
83
+ } catch (error) {
84
+ if (!(error instanceof CatalogFileError)) throw error;
85
+ failures.push(...error.failures);
86
+ continue;
87
+ }
88
+ }
89
+ if (!catalog) continue;
90
+ products.push(...catalog.catalog.products);
91
+ collections.push(...catalog.catalog.collections);
92
+ }
93
+ if (failures.length > 0) throw new CatalogFileError(failures);
94
+ return { products, collections };
95
+ }
96
+
97
+ /**
98
+ * The store's catalog files, or null when it has none. Throws `CatalogFileError` naming
99
+ * every file that fails validation, and refuses a per-request source holding more files
100
+ * than `CATALOG_REQUEST_READ_BUDGET` before reading any of them.
101
+ */
102
+ export async function readTenantCatalogFiles(
103
+ tenant: TenantConfig,
104
+ content: CatalogArtifactSource | undefined,
105
+ ): Promise<TenantCatalogFiles | null> {
106
+ const { source, paths } = await catalogPaths(tenant, content);
107
+ if (paths.length === 0) return null;
108
+
109
+ if (source.perRequestArtifactReads && paths.length > CATALOG_REQUEST_READ_BUDGET) {
110
+ throw new Error(
111
+ `catalog: ${tenant.tenant_id} has ${paths.length} files in content/catalog/, and the hosted ` +
112
+ `preview reads at most ${CATALOG_REQUEST_READ_BUDGET} catalog files per request (one read ` +
113
+ `each, inside the Worker's per-request limit). \`tot dev\` and the published site render ` +
114
+ `this catalog from the checkout.`,
115
+ );
116
+ }
117
+
118
+ const store: CatalogStore = {
119
+ tenantScope: tenant.tot_data_scope,
120
+ inventoryProvider: tenant.inventory?.provider,
121
+ };
122
+ const entries = await Promise.all(paths.map(async (path) => ({ path, raw: await source.readArtifact(path) })));
123
+ return source.perRequestArtifactReads
124
+ ? readCatalogFiles(entries, store)
125
+ : readBuildCatalog(tenant.tenant_id, entries, store);
126
+ }
@@ -263,12 +263,12 @@ export async function serveControlPlane(input: ControlPlaneInput): Promise<Respo
263
263
  publicationBucket: input.publicationBucket,
264
264
  managedIntegrationDeclarations,
265
265
  readTenantConsentSnapshot: () => readSavedConsentSnapshot(tenant.tenant_id),
266
- // The D1 catalog edge read replica, when this deploy opts into it. Deferred to
267
- // the chain's own catalog build so a request the static bundle or the edge
268
- // cache answers never pays for it. The public branch never resolves it at all
269
- // — see `publicRender`.
266
+ // The D1 catalog serving projection, for a tenant that declares
267
+ // `catalog.serving: "d1"` (ADR 0033). Deferred to the chain's own catalog build
268
+ // so a request the static bundle or the edge cache answers never pays for it.
269
+ // The public branch never resolves it at all — see `publicRender`.
270
270
  readCatalogD1: async (): Promise<D1Like | undefined> =>
271
- (await readEnv("USE_D1_CATALOG")) === "1" ? await readD1("CATALOG_DB") : undefined,
271
+ tenant.catalog?.serving === "d1" ? await readD1("CATALOG_DB") : undefined,
272
272
  internalContentGrant: input.internalContentGrant,
273
273
  });
274
274
  }
@@ -441,16 +441,6 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
441
441
  if (hit) return await stampResponseNonce(hit, freshNonce());
442
442
  }
443
443
 
444
- // Catalog source selection (docs/architecture/product-catalog.md).
445
- // A store's product source graduates over its life: JSON fixtures (dev default) ->
446
- // D1 edge replica (staging) -> hosted ToT catalog (go-live). The read backend is
447
- // chosen by the CALLER: the control plane resolves the D1 edge replica when
448
- // USE_D1_CATALOG=1; the public branch never does, so `createToTClient` falls back
449
- // to the bundled JSON fixture snapshot. The hosted catalog graduates the SAME
450
- // canonical records into the D1 read model (emitCatalogSql), so JSON/D1/hosted
451
- // render identically — pinned by lib/tot/sourceSelection.test.ts.
452
- const tot = await createToTClient(tenant, await input.readCatalogD1?.());
453
-
454
444
  const proto = url.protocol === "http:" ? "http" : "https";
455
445
  // Canonical base uses the tenant's canonical domain in prod; for local dev we
456
446
  // keep the actual host (plus any /<domain> base path) so links resolve.
@@ -468,7 +458,19 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
468
458
  // pointer, the artifact is resolved through the immutable site version
469
459
  // (channel -> versionId -> manifest -> content). Tenants with no channel
470
460
  // pointer fall back to the legacy per-path selection — no behavior change.
471
- const kvGet = kv ? (key: string) => kv.get(key) : async () => null;
461
+ // Request-scoped read memo: every content read resolves the same pointer and
462
+ // version manifest, and a catalog render reads one artifact per catalog file.
463
+ const kvReads = new Map<string, Promise<string | null>>();
464
+ const kvGet = kv
465
+ ? (key: string) => {
466
+ let read = kvReads.get(key);
467
+ if (!read) {
468
+ read = kv.get(key);
469
+ kvReads.set(key, read);
470
+ }
471
+ return read;
472
+ }
473
+ : async () => null;
472
474
 
473
475
  // Self-heal a DANGLING channel pointer: the pointer for this env's channel may
474
476
  // name a version that was never built (or whose manifest was evicted).
@@ -600,7 +602,6 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
600
602
  ? await resolveTenantCommerce(tenant)
601
603
  : null;
602
604
  locals.commerce = commerce ?? undefined;
603
- locals.tot = tot;
604
605
  // Resolve the shared root adapter first. An opened publication version then
605
606
  // becomes authoritative for selected content while a mounted space may still
606
607
  // reuse root-owned chrome. Neither source falls through to another space.
@@ -624,6 +625,12 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
624
625
  kvGet,
625
626
  );
626
627
  }
628
+ // Catalog serving is declared per tenant (`catalog.serving`, ADR 0033 and
629
+ // docs/architecture/product-catalog.md). The store's own `content/catalog/` files
630
+ // are read through the SAME content source just resolved, so they follow the
631
+ // request's selected version exactly as pages and the blog do. Only the control
632
+ // plane supplies the D1 projection; the public branch never reads D1.
633
+ locals.tot = await createToTClient(tenant, await input.readCatalogD1?.(), locals.content);
627
634
  locals.themeStyle = themeToCssVars(theme);
628
635
  locals.canonicalBase = canonicalBase;
629
636
  locals.customizationEnv = customization.env;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.2.89",
3
+ "version": "2.2.90",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
6
6
  "packageManager": "pnpm@11.9.0",
@@ -17,6 +17,7 @@
17
17
  "./image-preview-rewrite-overlay": "./src/image-preview-rewrite-overlay.ts",
18
18
  "./hosted-image-media-types": "./src/hosted-image-media-types.ts",
19
19
  "./durable-assets": "./src/durable-assets.ts",
20
+ "./catalog-files": "./src/catalog-files.ts",
20
21
  "./retention-holds": "./src/retention-holds.ts",
21
22
  "./tenant-embed-defaults": "./src/tenant-embed-defaults.mjs",
22
23
  "./declared-tenant-config": "./src/declared-tenant-config.mjs",