@tokenoftrust/storefront-runner 2.2.89 → 2.2.91
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.
- package/agency-kit/tools/lib/catalog-model/files.d.mts +48 -0
- package/agency-kit/tools/lib/catalog-model/files.mjs +263 -0
- package/agency-kit/tools/lib/catalog-model/model.mjs +282 -0
- package/agency-kit/tools/lib/tot-repo-mappings.mjs +153 -0
- package/apps/storefront/src/config/declaredTenantConfig.ts +9 -3
- package/apps/storefront/src/config/devTenantSeed.ts +7 -7
- package/apps/storefront/src/config/publicationSpaceRegistry.ts +1 -0
- package/apps/storefront/src/config/resolver.ts +19 -1
- package/apps/storefront/src/lib/storyblok/provider.ts +25 -1
- package/apps/storefront/src/lib/tot/ToTClient.ts +63 -18
- package/apps/storefront/src/lib/tot/tenantCatalog.ts +126 -0
- package/apps/storefront/src/middleware/controlPlane.ts +5 -5
- package/apps/storefront/src/middleware/renderChain.ts +24 -15
- package/package.json +1 -1
- package/packages/cli/src/declared-config.mjs +78 -0
- package/packages/public-runtime/package.json +1 -0
- package/packages/public-runtime/src/catalog-files.ts +51 -0
- package/packages/public-runtime/src/csp.ts +2 -2
- package/packages/public-runtime/src/tenant.ts +27 -2
- package/scripts/dev/standalone-graft-plan.mjs +34 -12
- package/scripts/tenant/materialize-tenant-content.mjs +8 -1
- package/scripts/tenant/validate.mjs +72 -31
- package/scripts/tot-dev.mjs +18 -18
|
@@ -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
|
+
}
|
|
@@ -80,15 +80,21 @@ export async function versionDeclaredTenantConfig(
|
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* The tenant as it renders: its record with every declaration applied. A render
|
|
85
|
+
* pinned to one version (a candidate under review) takes that version's
|
|
86
|
+
* declaration; otherwise the environment's channel supplies it.
|
|
87
|
+
*/
|
|
84
88
|
export async function withDeclaredTenantConfig(
|
|
85
89
|
tenant: TenantConfig,
|
|
86
90
|
kvGet: (key: string) => Promise<string | null>,
|
|
87
|
-
env: CustomizationEnv,
|
|
91
|
+
source: { env: CustomizationEnv; pinnedVersionId?: string },
|
|
88
92
|
): Promise<TenantConfig> {
|
|
89
93
|
const fromCheckout = applyDeclaredTenantConfig(tenant, checkoutDeclaredTenantConfig(tenant.tenant_id));
|
|
90
94
|
return applyDeclaredTenantConfig(
|
|
91
95
|
fromCheckout,
|
|
92
|
-
|
|
96
|
+
source.pinnedVersionId
|
|
97
|
+
? await versionDeclaredTenantConfig(kvGet, tenant.tenant_id, source.pinnedVersionId)
|
|
98
|
+
: await publishedDeclaredTenantConfig(kvGet, tenant.tenant_id, source.env),
|
|
93
99
|
);
|
|
94
100
|
}
|
|
@@ -36,10 +36,10 @@ export interface DevTenantHints {
|
|
|
36
36
|
*/
|
|
37
37
|
appDomain?: string;
|
|
38
38
|
/**
|
|
39
|
-
* True when
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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
|
|
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 =
|
|
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
|
|
6
|
-
*
|
|
7
|
-
* - FixtureToTClient —
|
|
8
|
-
*
|
|
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 —
|
|
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 —
|
|
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
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
267
|
-
// the chain's own catalog build
|
|
268
|
-
//
|
|
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
|
-
|
|
271
|
+
tenant.catalog?.serving === "d1" ? await readD1("CATALOG_DB") : undefined,
|
|
272
272
|
internalContentGrant: input.internalContentGrant,
|
|
273
273
|
});
|
|
274
274
|
}
|