@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,48 @@
|
|
|
1
|
+
// Types for files.mjs. Records are typed structurally here; the platform narrows them to its
|
|
2
|
+
// canonical product types at its own boundary.
|
|
3
|
+
|
|
4
|
+
export type CatalogFileKind = "product" | "collection";
|
|
5
|
+
export type CatalogRecord = Record<string, unknown>;
|
|
6
|
+
export type InventoryProvider = "mirror" | "live-hydrate" | "tot-authoritative";
|
|
7
|
+
|
|
8
|
+
export const CATALOG_CONTENT_DIRS: { readonly products: string; readonly collections: string };
|
|
9
|
+
export const CATALOG_ARTIFACT_PREFIX: string;
|
|
10
|
+
export const CATALOG_PRODUCTS_PREFIX: string;
|
|
11
|
+
export const CATALOG_COLLECTIONS_PREFIX: string;
|
|
12
|
+
export const CATALOG_HANDLE_RE: RegExp;
|
|
13
|
+
export const INVENTORY_PROVIDERS: readonly InventoryProvider[];
|
|
14
|
+
|
|
15
|
+
export function classifyCatalogPath(
|
|
16
|
+
relative: string,
|
|
17
|
+
): { kind: CatalogFileKind; handle: string } | { kind: "unexpected" } | null;
|
|
18
|
+
|
|
19
|
+
export function catalogFileRecord<T>(kind: CatalogFileKind, record: T): T;
|
|
20
|
+
export function serializeCatalogRecord(record: unknown): string;
|
|
21
|
+
export function planCatalogFiles(catalog: { products: readonly unknown[]; collections: readonly unknown[] }): {
|
|
22
|
+
files: Array<{ kind: CatalogFileKind; handle: string; path: string; content: string }>;
|
|
23
|
+
errors: string[];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function declaredAvailability(provider: InventoryProvider | undefined): { available: boolean };
|
|
27
|
+
export function catalogFileIssues(relative: string, raw: string): string[];
|
|
28
|
+
|
|
29
|
+
export class CatalogFileError extends Error {
|
|
30
|
+
constructor(failures: ReadonlyArray<{ path: string; issues: readonly string[] }>);
|
|
31
|
+
readonly failures: ReadonlyArray<{ path: string; issues: readonly string[] }>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface CatalogStore {
|
|
35
|
+
tenantScope: string;
|
|
36
|
+
inventoryProvider?: InventoryProvider;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function readCatalogFile(
|
|
40
|
+
relative: string,
|
|
41
|
+
raw: string,
|
|
42
|
+
store: CatalogStore,
|
|
43
|
+
): { kind: CatalogFileKind; record: CatalogRecord };
|
|
44
|
+
|
|
45
|
+
export function readCatalogFiles(
|
|
46
|
+
entries: Iterable<{ path: string; raw: string | null }>,
|
|
47
|
+
store: CatalogStore,
|
|
48
|
+
): { products: CatalogRecord[]; collections: CatalogRecord[] };
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A store's catalog files: where they live, how a record becomes a file, and how a file becomes a
|
|
3
|
+
* record. The one implementation every reader and writer uses: the kit's tools, the platform's
|
|
4
|
+
* renderer, its reconcile validator and its migration engine.
|
|
5
|
+
*
|
|
6
|
+
* Pure: no filesystem, no network, no dependencies, so it runs in Node, a browser and a Worker.
|
|
7
|
+
*
|
|
8
|
+
* Layout, relative to the store checkout:
|
|
9
|
+
*
|
|
10
|
+
* content/catalog/products/<handle>.json one CatalogProduct
|
|
11
|
+
* content/catalog/collections/<handle>.json one CatalogCollection
|
|
12
|
+
*
|
|
13
|
+
* One JSON object per file; the file stem IS the record's `handle`. Each record is the canonical
|
|
14
|
+
* shape in `./model.mjs`, with three file-level relaxations:
|
|
15
|
+
*
|
|
16
|
+
* - `tenant_id` (product and variant) and `variants[].product_id` are optional and ignored. The
|
|
17
|
+
* reader stamps them from the store being read: a file's location is its identity.
|
|
18
|
+
* - `created_at` / `updated_at` / `migrated_at` / `source_checksum` are optional.
|
|
19
|
+
* - `variants[].inventory` is never read, and writers leave it out. Availability is hot data and
|
|
20
|
+
* lives behind the store's declared `inventory.provider`, never in a content file.
|
|
21
|
+
*/
|
|
22
|
+
import { validateRecord } from "./model.mjs";
|
|
23
|
+
|
|
24
|
+
/** Store-relative directories, one per record kind. */
|
|
25
|
+
export const CATALOG_CONTENT_DIRS = {
|
|
26
|
+
products: "content/catalog/products",
|
|
27
|
+
collections: "content/catalog/collections",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** The same directories relative to the store's `content/` (the platform's artifact paths). */
|
|
31
|
+
export const CATALOG_ARTIFACT_PREFIX = "catalog/";
|
|
32
|
+
export const CATALOG_PRODUCTS_PREFIX = "catalog/products/";
|
|
33
|
+
export const CATALOG_COLLECTIONS_PREFIX = "catalog/collections/";
|
|
34
|
+
|
|
35
|
+
/** A safe handle, which is also the file stem. */
|
|
36
|
+
export const CATALOG_HANDLE_RE = /^[a-z0-9][a-z0-9-]{0,119}$/;
|
|
37
|
+
|
|
38
|
+
const PROVENANCE_KEYS = ["created_at", "updated_at", "migrated_at", "source_checksum"];
|
|
39
|
+
|
|
40
|
+
/** @type {(v: unknown) => v is Record<string, any>} */
|
|
41
|
+
const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* What a `content/`-relative path is to the catalog: a product or collection file, `unexpected`
|
|
45
|
+
* (under `catalog/` but not a record file this layout defines), or null (not a catalog path).
|
|
46
|
+
*
|
|
47
|
+
* @returns {{ kind: "product" | "collection", handle: string } | { kind: "unexpected" } | null}
|
|
48
|
+
*/
|
|
49
|
+
export function classifyCatalogPath(relative) {
|
|
50
|
+
const path = String(relative).replace(/^\/+/, "");
|
|
51
|
+
if (!path.startsWith(CATALOG_ARTIFACT_PREFIX)) return null;
|
|
52
|
+
/** @type {Array<[string, "product" | "collection"]>} */
|
|
53
|
+
const kinds = [
|
|
54
|
+
[CATALOG_PRODUCTS_PREFIX, "product"],
|
|
55
|
+
[CATALOG_COLLECTIONS_PREFIX, "collection"],
|
|
56
|
+
];
|
|
57
|
+
for (const [prefix, kind] of kinds) {
|
|
58
|
+
if (!path.startsWith(prefix)) continue;
|
|
59
|
+
const name = path.slice(prefix.length);
|
|
60
|
+
if (!name.endsWith(".json")) return { kind: "unexpected" };
|
|
61
|
+
const handle = name.slice(0, -".json".length);
|
|
62
|
+
return CATALOG_HANDLE_RE.test(handle) ? { kind, handle } : { kind: "unexpected" };
|
|
63
|
+
}
|
|
64
|
+
return { kind: "unexpected" };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// --- Writing ----------------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
/** A record as its file holds it: every variant's `inventory` left out. */
|
|
70
|
+
export function catalogFileRecord(kind, record) {
|
|
71
|
+
if (kind !== "product" || !isObject(record) || !Array.isArray(record.variants)) return record;
|
|
72
|
+
return {
|
|
73
|
+
...record,
|
|
74
|
+
variants: record.variants.map((variant) => {
|
|
75
|
+
if (!isObject(variant)) return variant;
|
|
76
|
+
const { inventory: _inventory, ...rest } = variant;
|
|
77
|
+
return rest;
|
|
78
|
+
}),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A file's bytes: two-space JSON and a trailing newline. */
|
|
83
|
+
export function serializeCatalogRecord(record) {
|
|
84
|
+
return JSON.stringify(record, null, 2) + "\n";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Plan a catalog's files, sorted by path so the same catalog always yields the same bytes. Every
|
|
89
|
+
* planned file passes `catalogFileIssues`; `errors` names each record that cannot become a file
|
|
90
|
+
* (an unsafe or duplicate handle, or a record the reader would refuse). Non-empty `errors` means
|
|
91
|
+
* the write must not happen.
|
|
92
|
+
*
|
|
93
|
+
* @param {{ products: object[], collections: object[] }} catalog
|
|
94
|
+
* @returns {{ files: Array<{ kind: "product" | "collection", handle: string, path: string, content: string }>, errors: string[] }}
|
|
95
|
+
*/
|
|
96
|
+
export function planCatalogFiles({ products, collections }) {
|
|
97
|
+
/** @type {Array<{ kind: "product" | "collection", handle: string, path: string, content: string }>} */
|
|
98
|
+
const files = [];
|
|
99
|
+
/** @type {string[]} */
|
|
100
|
+
const errors = [];
|
|
101
|
+
/** @type {Array<["product" | "collection", string, readonly unknown[]]>} */
|
|
102
|
+
const kinds = [
|
|
103
|
+
["product", "products", products],
|
|
104
|
+
["collection", "collections", collections],
|
|
105
|
+
];
|
|
106
|
+
for (const [kind, dir, records] of kinds) {
|
|
107
|
+
const seen = new Set();
|
|
108
|
+
for (const record of records) {
|
|
109
|
+
const handle = isObject(record) ? record.handle : undefined;
|
|
110
|
+
if (typeof handle !== "string" || !CATALOG_HANDLE_RE.test(handle)) {
|
|
111
|
+
errors.push(`${dir}: handle ${JSON.stringify(handle)} is not a safe file name (lowercase letters, digits and "-")`);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (seen.has(handle)) {
|
|
115
|
+
errors.push(`${dir}: handle ${handle} appears twice`);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
seen.add(handle);
|
|
119
|
+
const content = serializeCatalogRecord(catalogFileRecord(kind, record));
|
|
120
|
+
const relative = `catalog/${dir}/${handle}.json`;
|
|
121
|
+
for (const issue of catalogFileIssues(relative, content)) errors.push(`content/${relative}: ${issue}`);
|
|
122
|
+
files.push({ kind, handle, path: `content/${relative}`, content });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
126
|
+
return { files, errors };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// --- Reading ----------------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
/** ADR 0033's declared availability providers. Only `mirror` is implemented. */
|
|
132
|
+
export const INVENTORY_PROVIDERS = ["mirror", "live-hydrate", "tot-authoritative"];
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The availability a variant carries when its catalog comes from content files: the `mirror`
|
|
136
|
+
* provider at location `unspecified` with no movement record, which is purchasable with stock
|
|
137
|
+
* confirmed at checkout. No quantity is claimed, because none is known.
|
|
138
|
+
*/
|
|
139
|
+
export function declaredAvailability(provider) {
|
|
140
|
+
const effective = provider ?? "mirror";
|
|
141
|
+
if (effective !== "mirror") {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`inventory.provider "${effective}" is declared, but only "mirror" is implemented ` +
|
|
144
|
+
`(ADR 0033). Declare "mirror" or remove the declaration.`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
return { available: true };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** A file's record with its identity, provenance and availability stamped by the reader. */
|
|
151
|
+
function stamp(kind, doc, tenantScope, availability) {
|
|
152
|
+
if (!isObject(doc)) return doc;
|
|
153
|
+
/** @type {Record<string, any>} */
|
|
154
|
+
const out = { ...doc, tenant_id: tenantScope };
|
|
155
|
+
for (const key of PROVENANCE_KEYS) out[key] = doc[key] ?? "";
|
|
156
|
+
if (kind === "product" && Array.isArray(doc.variants)) {
|
|
157
|
+
const productId = typeof doc.id === "string" ? doc.id : "";
|
|
158
|
+
out.variants = doc.variants.map((variant) =>
|
|
159
|
+
isObject(variant)
|
|
160
|
+
? { ...variant, tenant_id: tenantScope, product_id: productId, inventory: { ...availability } }
|
|
161
|
+
: variant,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function parse(relative, raw, tenantScope, availability) {
|
|
168
|
+
const where = classifyCatalogPath(relative);
|
|
169
|
+
if (where === null) return { issues: [] };
|
|
170
|
+
if (where.kind === "unexpected") {
|
|
171
|
+
return {
|
|
172
|
+
issues: [
|
|
173
|
+
`not a catalog record file: catalog files are ${CATALOG_PRODUCTS_PREFIX}<handle>.json ` +
|
|
174
|
+
`or ${CATALOG_COLLECTIONS_PREFIX}<handle>.json, with <handle> matching ${CATALOG_HANDLE_RE}`,
|
|
175
|
+
],
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
let doc;
|
|
179
|
+
try {
|
|
180
|
+
doc = JSON.parse(raw);
|
|
181
|
+
} catch (e) {
|
|
182
|
+
return { issues: [`not valid JSON: ${e instanceof Error ? e.message : e}`] };
|
|
183
|
+
}
|
|
184
|
+
const record = stamp(where.kind, doc, tenantScope, availability);
|
|
185
|
+
const issues = validateRecord(where.kind === "product" ? "CatalogProduct" : "CatalogCollection", record).map(
|
|
186
|
+
(i) => (i.path ? `${i.path}: ${i.message}` : i.message),
|
|
187
|
+
);
|
|
188
|
+
if (isObject(doc) && typeof doc.handle === "string" && doc.handle !== where.handle) {
|
|
189
|
+
issues.push(`handle: "${doc.handle}" does not match the file name "${where.handle}.json"`);
|
|
190
|
+
}
|
|
191
|
+
return { kind: where.kind, record, issues };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The structural problems with one catalog file's text (empty = valid). */
|
|
195
|
+
export function catalogFileIssues(relative, raw) {
|
|
196
|
+
return parse(relative, raw, "", { available: true }).issues;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** A catalog file the reader refuses, with every reason it names. */
|
|
200
|
+
export class CatalogFileError extends Error {
|
|
201
|
+
/** @param {ReadonlyArray<{ path: string, issues: readonly string[] }>} failures */
|
|
202
|
+
constructor(failures) {
|
|
203
|
+
super(
|
|
204
|
+
`catalog: ${failures.length} file(s) in content/catalog/ cannot be served:\n` +
|
|
205
|
+
failures
|
|
206
|
+
.map(
|
|
207
|
+
(f) =>
|
|
208
|
+
` content/${f.path}: ${f.issues.slice(0, 5).join("; ")}` +
|
|
209
|
+
(f.issues.length > 5 ? `; …and ${f.issues.length - 5} more` : ""),
|
|
210
|
+
)
|
|
211
|
+
.join("\n"),
|
|
212
|
+
);
|
|
213
|
+
this.name = "CatalogFileError";
|
|
214
|
+
this.failures = failures;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Parse one catalog file into its canonical record, stamped for the store being read. Throws
|
|
220
|
+
* `CatalogFileError` naming every issue when the file fails validation.
|
|
221
|
+
*
|
|
222
|
+
* @param {string} relative `content/`-relative path
|
|
223
|
+
* @param {string} raw the file's text
|
|
224
|
+
* @param {{ tenantScope: string, inventoryProvider?: string }} store
|
|
225
|
+
*/
|
|
226
|
+
export function readCatalogFile(relative, raw, { tenantScope, inventoryProvider }) {
|
|
227
|
+
const parsed = parse(relative, raw, tenantScope, declaredAvailability(inventoryProvider));
|
|
228
|
+
if (parsed.issues.length > 0 || !parsed.kind) {
|
|
229
|
+
throw new CatalogFileError([
|
|
230
|
+
{ path: relative, issues: parsed.issues.length ? parsed.issues : ["not a catalog record file"] },
|
|
231
|
+
]);
|
|
232
|
+
}
|
|
233
|
+
return { kind: parsed.kind, record: parsed.record };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* A whole catalog from its files: every file validated, one bad file refuses the catalog with
|
|
238
|
+
* every failing path named (never a partial catalog with a product silently missing). Non-catalog
|
|
239
|
+
* paths are ignored; an unreadable entry (`raw: null`) is a failure.
|
|
240
|
+
*
|
|
241
|
+
* @param {Iterable<{ path: string, raw: string | null }>} entries `content/`-relative paths
|
|
242
|
+
* @param {{ tenantScope: string, inventoryProvider?: string }} store
|
|
243
|
+
* @returns {{ products: object[], collections: object[] }}
|
|
244
|
+
*/
|
|
245
|
+
export function readCatalogFiles(entries, { tenantScope, inventoryProvider }) {
|
|
246
|
+
const availability = declaredAvailability(inventoryProvider);
|
|
247
|
+
const products = [];
|
|
248
|
+
const collections = [];
|
|
249
|
+
const failures = [];
|
|
250
|
+
for (const { path, raw } of entries) {
|
|
251
|
+
if (classifyCatalogPath(path) === null) continue;
|
|
252
|
+
if (raw === null) {
|
|
253
|
+
failures.push({ path, issues: ["listed in the catalog but unreadable"] });
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
const parsed = parse(path, raw, tenantScope, availability);
|
|
257
|
+
if (parsed.issues.length > 0) failures.push({ path, issues: parsed.issues });
|
|
258
|
+
else if (parsed.kind === "product") products.push(parsed.record);
|
|
259
|
+
else collections.push(parsed.record);
|
|
260
|
+
}
|
|
261
|
+
if (failures.length > 0) throw new CatalogFileError(failures);
|
|
262
|
+
return { products, collections };
|
|
263
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canonical catalog record shape, vendored into the kit.
|
|
3
|
+
*
|
|
4
|
+
* This is a mirror of the platform's product model (`packages/public-runtime/src/product.ts` in the
|
|
5
|
+
* Storefront repository), written as data so the kit can validate records with no dependency and
|
|
6
|
+
* no platform checkout. The Storefront repository pins it field for field: its drift test compares
|
|
7
|
+
* this model to `product.ts` and fails on any added, removed, renamed, retyped, or re-optionalised
|
|
8
|
+
* field, and on any product-model type this file does not mirror. Change both together.
|
|
9
|
+
*
|
|
10
|
+
* Grammar. Each entry is one named type:
|
|
11
|
+
* { alias: "<type>" } a type alias
|
|
12
|
+
* { fields: { "<name>[?]": "<type>" } } an object; a trailing `?` marks the field optional
|
|
13
|
+
* { union: [ { "<name>[?]": "<type>" } ] } a union of inline object shapes
|
|
14
|
+
* A `<type>` is written exactly as in TypeScript: `string`, `number`, `boolean`, `unknown`,
|
|
15
|
+
* a string literal (`"csv"`), a named type from this model, `T[]`, `Record<string, T>`, or a
|
|
16
|
+
* `|`-separated union of those.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const CATALOG_MODEL = {
|
|
20
|
+
ProductStatus: { alias: '"active" | "draft" | "archived"' },
|
|
21
|
+
Currency: { alias: "string" },
|
|
22
|
+
SourcePlatform: { alias: '"shopify" | "directory" | "csv" | "manual" | "synthesized"' },
|
|
23
|
+
SourceRef: {
|
|
24
|
+
fields: { platform: "SourcePlatform", source_id: "string", "source_handle?": "string" },
|
|
25
|
+
},
|
|
26
|
+
SeoFields: { fields: { "title?": "string", "description?": "string" } },
|
|
27
|
+
PriceRange: { fields: { min: "number", max: "number", currency: "Currency" } },
|
|
28
|
+
ProductOption: { fields: { name: "string", position: "number", values: "string[]" } },
|
|
29
|
+
PurchaseOptionKind: { alias: '"one_time" | "subscription"' },
|
|
30
|
+
OneTimePurchaseOption: {
|
|
31
|
+
fields: { id: "string", kind: '"one_time"', label: "string", "description?": "string" },
|
|
32
|
+
},
|
|
33
|
+
PurchaseOptionPriceAdjustment: {
|
|
34
|
+
union: [
|
|
35
|
+
{ type: '"percent_off"', value: "number" },
|
|
36
|
+
{ type: '"amount_off"', value: "number" },
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
SubscriptionPurchaseOption: {
|
|
40
|
+
fields: {
|
|
41
|
+
id: "string",
|
|
42
|
+
kind: '"subscription"',
|
|
43
|
+
label: "string",
|
|
44
|
+
"description?": "string",
|
|
45
|
+
"savingsLabel?": "string",
|
|
46
|
+
"priceAdjustment?": "PurchaseOptionPriceAdjustment",
|
|
47
|
+
frequency: "string",
|
|
48
|
+
"startDate?": "string",
|
|
49
|
+
"endDate?": "string",
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
ProductPurchaseOption: { alias: "OneTimePurchaseOption | SubscriptionPurchaseOption" },
|
|
53
|
+
CatalogImage: {
|
|
54
|
+
fields: {
|
|
55
|
+
id: "string",
|
|
56
|
+
url: "string",
|
|
57
|
+
"alt?": "string",
|
|
58
|
+
position: "number",
|
|
59
|
+
"width?": "number",
|
|
60
|
+
"height?": "number",
|
|
61
|
+
"variant_ids?": "string[]",
|
|
62
|
+
"rehosted?": "boolean",
|
|
63
|
+
"source_url?": "string",
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
Metafield: {
|
|
67
|
+
fields: { namespace: "string", key: "string", value: "string", "type?": "string" },
|
|
68
|
+
},
|
|
69
|
+
InventoryState: {
|
|
70
|
+
fields: { "quantity?": "number", "policy?": "string", available: "boolean" },
|
|
71
|
+
},
|
|
72
|
+
CatalogVariant: {
|
|
73
|
+
fields: {
|
|
74
|
+
tenant_id: "string",
|
|
75
|
+
id: "string",
|
|
76
|
+
product_id: "string",
|
|
77
|
+
source: "SourceRef",
|
|
78
|
+
"sku?": "string",
|
|
79
|
+
title: "string",
|
|
80
|
+
price: "number",
|
|
81
|
+
"compare_at_price?": "number",
|
|
82
|
+
currency: "Currency",
|
|
83
|
+
option_values: "Record<string, string>",
|
|
84
|
+
inventory: "InventoryState",
|
|
85
|
+
"barcode?": "string",
|
|
86
|
+
"weight?": "number",
|
|
87
|
+
"weight_unit?": "string",
|
|
88
|
+
position: "number",
|
|
89
|
+
"image_id?": "string",
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
CatalogProduct: {
|
|
93
|
+
fields: {
|
|
94
|
+
tenant_id: "string",
|
|
95
|
+
id: "string",
|
|
96
|
+
source: "SourceRef",
|
|
97
|
+
handle: "string",
|
|
98
|
+
title: "string",
|
|
99
|
+
description_html: "string",
|
|
100
|
+
description_text: "string",
|
|
101
|
+
"vendor?": "string",
|
|
102
|
+
"product_type?": "string",
|
|
103
|
+
tags: "string[]",
|
|
104
|
+
status: "ProductStatus",
|
|
105
|
+
seo: "SeoFields",
|
|
106
|
+
options: "ProductOption[]",
|
|
107
|
+
images: "CatalogImage[]",
|
|
108
|
+
variants: "CatalogVariant[]",
|
|
109
|
+
"purchase_options?": "ProductPurchaseOption[]",
|
|
110
|
+
price_range: "PriceRange",
|
|
111
|
+
collections: "string[]",
|
|
112
|
+
metafields: "Metafield[]",
|
|
113
|
+
created_at: "string",
|
|
114
|
+
updated_at: "string",
|
|
115
|
+
migrated_at: "string",
|
|
116
|
+
source_checksum: "string",
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
CatalogCollection: {
|
|
120
|
+
fields: {
|
|
121
|
+
tenant_id: "string",
|
|
122
|
+
id: "string",
|
|
123
|
+
source: "SourceRef",
|
|
124
|
+
handle: "string",
|
|
125
|
+
title: "string",
|
|
126
|
+
"description_html?": "string",
|
|
127
|
+
"description_text?": "string",
|
|
128
|
+
"image?": "CatalogImage",
|
|
129
|
+
seo: "SeoFields",
|
|
130
|
+
product_handles: "string[]",
|
|
131
|
+
"rule?": "Record<string, unknown>",
|
|
132
|
+
"position?": "number",
|
|
133
|
+
created_at: "string",
|
|
134
|
+
updated_at: "string",
|
|
135
|
+
migrated_at: "string",
|
|
136
|
+
source_checksum: "string",
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/** Split a type on its top-level `|`, ignoring pipes nested in `<>`, `{}`, `()` or `[]`. */
|
|
142
|
+
export function splitUnion(type) {
|
|
143
|
+
const parts = [];
|
|
144
|
+
let depth = 0;
|
|
145
|
+
let cur = "";
|
|
146
|
+
let quote = null;
|
|
147
|
+
for (const ch of type) {
|
|
148
|
+
if (quote) {
|
|
149
|
+
cur += ch;
|
|
150
|
+
if (ch === quote) quote = null;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (ch === '"' || ch === "'") quote = ch;
|
|
154
|
+
else if ("<{([".includes(ch)) depth++;
|
|
155
|
+
else if (">})]".includes(ch)) depth--;
|
|
156
|
+
if (ch === "|" && depth === 0) {
|
|
157
|
+
parts.push(cur);
|
|
158
|
+
cur = "";
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
cur += ch;
|
|
162
|
+
}
|
|
163
|
+
parts.push(cur);
|
|
164
|
+
return parts.map((p) => p.trim()).filter(Boolean);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Canonical spelling of a type: single spaces, `'` quotes as `"`, unions joined by ` | `. */
|
|
168
|
+
export function normType(type) {
|
|
169
|
+
return splitUnion(String(type).replace(/\s+/g, " ").replace(/'/g, '"').replace(/;\s*$/, ""))
|
|
170
|
+
.map((p) => p.replace(/\s*,\s*/g, ", ").replace(/\s*<\s*/g, "<").replace(/\s*>/g, ">"))
|
|
171
|
+
.join(" | ");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function normFields(fields) {
|
|
175
|
+
const out = {};
|
|
176
|
+
for (const [raw, type] of Object.entries(fields)) {
|
|
177
|
+
const optional = raw.endsWith("?");
|
|
178
|
+
out[optional ? raw.slice(0, -1) : raw] = { type: normType(type), optional };
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The comparable form of a model: `{ name: { kind: "alias", type } | { kind: "object", fields }
|
|
185
|
+
* | { kind: "union", variants: fields[] } }`, where `fields` is `{ name: { type, optional } }`.
|
|
186
|
+
* The drift test reduces the TypeScript source to the same form and compares the two.
|
|
187
|
+
*/
|
|
188
|
+
export function normalizeModel(model = CATALOG_MODEL) {
|
|
189
|
+
const out = {};
|
|
190
|
+
for (const [name, def] of Object.entries(model)) {
|
|
191
|
+
if ("alias" in def) out[name] = { kind: "alias", type: normType(def.alias) };
|
|
192
|
+
else if ("fields" in def) out[name] = { kind: "object", fields: normFields(def.fields) };
|
|
193
|
+
else if ("union" in def) out[name] = { kind: "union", variants: def.union.map(normFields) };
|
|
194
|
+
else throw new Error(`catalog model entry ${name} has no alias, fields or union`);
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const NORMALIZED = normalizeModel(CATALOG_MODEL);
|
|
200
|
+
|
|
201
|
+
const isPlainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
202
|
+
|
|
203
|
+
function checkObject(fields, value, path, issues) {
|
|
204
|
+
if (!isPlainObject(value)) {
|
|
205
|
+
issues.push({ path, message: "expected an object" });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
for (const [key, spec] of Object.entries(fields)) {
|
|
209
|
+
const at = path ? `${path}.${key}` : key;
|
|
210
|
+
if (value[key] === undefined) {
|
|
211
|
+
if (!spec.optional) issues.push({ path: at, message: "required" });
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
checkType(spec.type, value[key], at, issues);
|
|
215
|
+
}
|
|
216
|
+
for (const key of Object.keys(value)) {
|
|
217
|
+
if (!(key in fields)) issues.push({ path: path ? `${path}.${key}` : key, message: "unknown field" });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function checkType(type, value, path, issues) {
|
|
222
|
+
const alts = splitUnion(type);
|
|
223
|
+
if (alts.length > 1) {
|
|
224
|
+
if (alts.some((alt) => typeIssues(alt, value, path).length === 0)) return;
|
|
225
|
+
issues.push({ path, message: `expected ${type}` });
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const t = alts[0];
|
|
229
|
+
const literal = /^"(.*)"$/.exec(t);
|
|
230
|
+
if (literal) {
|
|
231
|
+
if (value !== literal[1]) issues.push({ path, message: `expected ${t}` });
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (t.endsWith("[]")) {
|
|
235
|
+
if (!Array.isArray(value)) issues.push({ path, message: `expected ${t}` });
|
|
236
|
+
else value.forEach((v, i) => checkType(t.slice(0, -2), v, `${path}.${i}`, issues));
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const record = /^Record<string, (.+)>$/.exec(t);
|
|
240
|
+
if (record) {
|
|
241
|
+
if (!isPlainObject(value)) issues.push({ path, message: `expected ${t}` });
|
|
242
|
+
else for (const [k, v] of Object.entries(value)) checkType(record[1], v, `${path}.${k}`, issues);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
switch (t) {
|
|
246
|
+
case "unknown":
|
|
247
|
+
return;
|
|
248
|
+
case "string":
|
|
249
|
+
case "boolean":
|
|
250
|
+
if (typeof value !== t) issues.push({ path, message: `expected ${t}` });
|
|
251
|
+
return;
|
|
252
|
+
case "number":
|
|
253
|
+
if (typeof value !== "number" || !Number.isFinite(value)) issues.push({ path, message: "expected number" });
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const def = NORMALIZED[t];
|
|
257
|
+
if (!def) throw new Error(`catalog model has no type ${t}`);
|
|
258
|
+
if (def.kind === "alias") checkType(def.type, value, path, issues);
|
|
259
|
+
else if (def.kind === "object") checkObject(def.fields, value, path, issues);
|
|
260
|
+
else if (!def.variants.some((fields) => objectIssues(fields, value, path).length === 0))
|
|
261
|
+
issues.push({ path, message: `expected ${t}` });
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function typeIssues(type, value, path) {
|
|
265
|
+
const issues = [];
|
|
266
|
+
checkType(type, value, path, issues);
|
|
267
|
+
return issues;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function objectIssues(fields, value, path) {
|
|
271
|
+
const issues = [];
|
|
272
|
+
checkObject(fields, value, path, issues);
|
|
273
|
+
return issues;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Structural issues for one record against a named model type. Never throws on bad data; returns
|
|
278
|
+
* `[{ path, message }]`, empty when the record is valid. Unknown fields are issues.
|
|
279
|
+
*/
|
|
280
|
+
export function validateRecord(typeName, value) {
|
|
281
|
+
return typeIssues(typeName, value, "");
|
|
282
|
+
}
|