@iterant/site-runtime 3.4.0 → 3.6.0
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/docs/runtime-contract.md +1 -1
- package/package.json +1 -1
- package/src/lib/database.ts +212 -12
- package/src/lib/locales.ts +7 -5
package/docs/runtime-contract.md
CHANGED
|
@@ -50,7 +50,7 @@ runtime and says so.
|
|
|
50
50
|
|
|
51
51
|
<!-- generated: available libraries -->
|
|
52
52
|
|
|
53
|
-
_Generated from package.json by scripts/generate-kit-table.mjs. Runtime 3.
|
|
53
|
+
_Generated from package.json by scripts/generate-kit-table.mjs. Runtime 3.6.0._
|
|
54
54
|
|
|
55
55
|
**Toolchain** (this package owns the version; do NOT declare these):
|
|
56
56
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iterant/site-runtime",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The platform layer every Iterant brand site runs on: content grammar, collection schemas, SEO head and JSON-LD, layout core, Astro config preset, dev integrations and the verify gates.",
|
|
6
6
|
"scripts": {
|
package/src/lib/database.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
// Reading the brand's Database from a page
|
|
2
|
-
// DBX-D11).
|
|
1
|
+
// Reading the brand's Database from a page (epic DBX, decision DBX-D11).
|
|
3
2
|
//
|
|
4
3
|
// A published site holds no database binding and composes no SQL. It asks the
|
|
5
4
|
// platform's public read route for rows of ONE table, and the platform decides
|
|
@@ -12,10 +11,21 @@
|
|
|
12
11
|
// platform generates on every publish and puts in front of Astro's entry. It
|
|
13
12
|
// assigns them to `globalThis.__ITERANT_SITE_DB__` at module scope. In `astro
|
|
14
13
|
// dev` there is no gate, so the pair arrives as environment variables instead.
|
|
14
|
+
// A prerendered route reads while the site BUILDS, before that gate is even
|
|
15
|
+
// generated, so the publish pipeline puts the pair in `.dev.vars` beside the
|
|
16
|
+
// wrangler config and it reaches the render as environment variables too.
|
|
15
17
|
//
|
|
16
18
|
// Every failure answers with no rows and a console warning, never a throw: a
|
|
17
|
-
// listing that renders empty is a page the customer can still see, and a
|
|
18
|
-
// that fails on a database blip is not.
|
|
19
|
+
// listing that renders empty is a page the customer can still see, and a
|
|
20
|
+
// request that fails on a database blip is not.
|
|
21
|
+
//
|
|
22
|
+
// That contract inverts at BAKE time. A prerendered route reads once, during
|
|
23
|
+
// the publish build, and whatever it read is served until the next publish, so
|
|
24
|
+
// an empty answer there is not a transient blank list but a blog that stays
|
|
25
|
+
// empty for days. The publish build therefore sets `ITERANT_SITE_DB_STRICT=1`
|
|
26
|
+
// and every failure below throws instead, killing the build at its cause. The
|
|
27
|
+
// deployed runtime and the dev server never set it, so their behavior is
|
|
28
|
+
// unchanged.
|
|
19
29
|
|
|
20
30
|
const GLOBAL_KEY = "__ITERANT_SITE_DB__";
|
|
21
31
|
|
|
@@ -23,9 +33,18 @@ const URL_ENV = "ITERANT_SITE_DB_URL";
|
|
|
23
33
|
|
|
24
34
|
const TOKEN_ENV = "ITERANT_SITE_DB_TOKEN";
|
|
25
35
|
|
|
36
|
+
const STRICT_ENV = "ITERANT_SITE_DB_STRICT";
|
|
37
|
+
|
|
26
38
|
/** How long one read may take before the page renders without it. */
|
|
27
39
|
const TIMEOUT_MS = 10_000;
|
|
28
40
|
|
|
41
|
+
/** The platform's own ceiling on `limit`, and so the pager's page size. */
|
|
42
|
+
const MAX_LIMIT = 200;
|
|
43
|
+
|
|
44
|
+
/** The platform refuses an `offset` past this, so `MAX_LIMIT` rows after it is
|
|
45
|
+
* the last row {@link readDatabaseAll} can reach. */
|
|
46
|
+
const MAX_OFFSET = 100_000;
|
|
47
|
+
|
|
29
48
|
export type DatabaseFilterOp =
|
|
30
49
|
| "eq"
|
|
31
50
|
| "ne"
|
|
@@ -58,6 +77,31 @@ export type DatabaseQuery = {
|
|
|
58
77
|
|
|
59
78
|
export type DatabaseRow = Record<string, string | number | null>;
|
|
60
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Where a rendered row came from, carried on the row so the visual editor can
|
|
82
|
+
* find the element again (epic DBX, decision DBX-D20). It rides a NON-enumerable
|
|
83
|
+
* `__db` slot: invisible to `JSON.stringify`, to `Object.keys`, and to every
|
|
84
|
+
* template that spreads or iterates the row, so nothing accidentally renders it.
|
|
85
|
+
*/
|
|
86
|
+
export type DbProvenance = {
|
|
87
|
+
table: string;
|
|
88
|
+
id: string;
|
|
89
|
+
/** Whether the published site shows this row. The preview channel projects it
|
|
90
|
+
* per row (`_live`); a draft reads `false`. */
|
|
91
|
+
live: boolean;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/** The property that carries {@link DbProvenance}, hidden from enumeration. */
|
|
95
|
+
const PROVENANCE_KEY = "__db";
|
|
96
|
+
|
|
97
|
+
/** The attributes {@link dbAttrs} writes onto a DB-bound element. */
|
|
98
|
+
export type DbAttrs = {
|
|
99
|
+
"data-db-table": string;
|
|
100
|
+
"data-db-row": string;
|
|
101
|
+
"data-db-col": string;
|
|
102
|
+
"data-db-draft"?: "";
|
|
103
|
+
};
|
|
104
|
+
|
|
61
105
|
export type DatabaseResult = {
|
|
62
106
|
rows: DatabaseRow[];
|
|
63
107
|
/** Every row matching the query, not just this page. */
|
|
@@ -73,15 +117,29 @@ type SiteDatabaseHandoff = {
|
|
|
73
117
|
|
|
74
118
|
const EMPTY: DatabaseResult = { rows: [], total: 0 };
|
|
75
119
|
|
|
76
|
-
function
|
|
77
|
-
|
|
120
|
+
function environment(): Record<string, string | undefined> | undefined {
|
|
121
|
+
return (
|
|
78
122
|
globalThis as { process?: { env?: Record<string, string | undefined> } }
|
|
79
123
|
).process?.env;
|
|
80
|
-
|
|
81
|
-
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function fromEnvironment(): SiteDatabaseHandoff | null {
|
|
127
|
+
const url = environment()?.[URL_ENV];
|
|
128
|
+
const token = environment()?.[TOKEN_ENV];
|
|
82
129
|
return url && token ? { url, token } : null;
|
|
83
130
|
}
|
|
84
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Whether a failed read must kill the process instead of rendering empty.
|
|
134
|
+
*
|
|
135
|
+
* Read from the environment only, never from the global handoff: the handoff is
|
|
136
|
+
* what a DEPLOYED site carries, and a deployed site is exactly where the
|
|
137
|
+
* never-throw contract still holds.
|
|
138
|
+
*/
|
|
139
|
+
function strict(): boolean {
|
|
140
|
+
return environment()?.[STRICT_ENV] === "1";
|
|
141
|
+
}
|
|
142
|
+
|
|
85
143
|
function handoff(): SiteDatabaseHandoff | null {
|
|
86
144
|
const baked = (globalThis as Record<string, unknown>)[GLOBAL_KEY];
|
|
87
145
|
if (baked && typeof baked === "object") {
|
|
@@ -93,8 +151,25 @@ function handoff(): SiteDatabaseHandoff | null {
|
|
|
93
151
|
return fromEnvironment();
|
|
94
152
|
}
|
|
95
153
|
|
|
154
|
+
function detailOf(detail: unknown): string {
|
|
155
|
+
if (detail instanceof Error) return detail.message;
|
|
156
|
+
if (typeof detail === "string") return detail;
|
|
157
|
+
return JSON.stringify(detail) ?? String(detail);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Say a read did not work, the way this environment wants it said. */
|
|
161
|
+
function report(message: string, detail?: unknown): void {
|
|
162
|
+
const line = `[site-runtime] ${message}`;
|
|
163
|
+
if (strict()) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
detail === undefined ? line : `${line} ${detailOf(detail)}`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
console.warn(line, detail ?? "");
|
|
169
|
+
}
|
|
170
|
+
|
|
96
171
|
function warn(message: string, detail?: unknown): DatabaseResult {
|
|
97
|
-
|
|
172
|
+
report(`readDatabase: ${message}`, detail);
|
|
98
173
|
return EMPTY;
|
|
99
174
|
}
|
|
100
175
|
|
|
@@ -108,8 +183,9 @@ function warn(message: string, detail?: unknown): DatabaseResult {
|
|
|
108
183
|
* });
|
|
109
184
|
* ```
|
|
110
185
|
*
|
|
111
|
-
* Call it in a page's frontmatter (server side).
|
|
112
|
-
*
|
|
186
|
+
* Call it in a page's frontmatter (server side). On a prerendered route the read
|
|
187
|
+
* happens once, at publish time, and the site shows those rows until the next
|
|
188
|
+
* publish; on an SSR route it happens per request.
|
|
113
189
|
*/
|
|
114
190
|
export async function readDatabase(
|
|
115
191
|
table: string,
|
|
@@ -151,5 +227,129 @@ export async function readDatabase(
|
|
|
151
227
|
if (!Array.isArray(page.rows)) {
|
|
152
228
|
return warn(`"${table}" answered without rows`, body);
|
|
153
229
|
}
|
|
154
|
-
return {
|
|
230
|
+
return {
|
|
231
|
+
rows: page.rows.map((row) => withProvenance(row, table)),
|
|
232
|
+
total: Number(page.total ?? page.rows.length),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Every row a query matches, not just the first page of it.
|
|
238
|
+
*
|
|
239
|
+
* ```ts
|
|
240
|
+
* export async function getStaticPaths() {
|
|
241
|
+
* const { rows } = await readDatabaseAll("articles", { select: ["slug"] });
|
|
242
|
+
* return rows.map((row) => ({ params: { slug: String(row.slug) } }));
|
|
243
|
+
* }
|
|
244
|
+
* ```
|
|
245
|
+
*
|
|
246
|
+
* This is the enumeration a `getStaticPaths` needs, and the reason it exists is
|
|
247
|
+
* that a plain {@link readDatabase} answers at most {@link MAX_LIMIT} rows and
|
|
248
|
+
* says so only in `total`. A blog of 300 articles would bake its first 200 and
|
|
249
|
+
* look complete. Paging here is not an optimization; it is the difference
|
|
250
|
+
* between a whole blog and a silently truncated one.
|
|
251
|
+
*
|
|
252
|
+
* Any `limit` or `offset` on the query is ignored; `filter`, `sort` and `select`
|
|
253
|
+
* are carried on every page.
|
|
254
|
+
*/
|
|
255
|
+
export async function readDatabaseAll(
|
|
256
|
+
table: string,
|
|
257
|
+
query: DatabaseQuery = {},
|
|
258
|
+
): Promise<DatabaseResult> {
|
|
259
|
+
const { limit: _limit, offset: _offset, ...shared } = query;
|
|
260
|
+
const rows: DatabaseRow[] = [];
|
|
261
|
+
let total = 0;
|
|
262
|
+
for (let offset = 0; ; offset += MAX_LIMIT) {
|
|
263
|
+
const page = await readDatabase(table, {
|
|
264
|
+
...shared,
|
|
265
|
+
limit: MAX_LIMIT,
|
|
266
|
+
offset,
|
|
267
|
+
});
|
|
268
|
+
// No rows means the end of the table, or a page that failed and rendered
|
|
269
|
+
// empty under the never-throw contract. Its `total` is not the table's, and
|
|
270
|
+
// adopting it would answer with rows already read alongside a total of
|
|
271
|
+
// none, which is a shape no caller can act on.
|
|
272
|
+
if (page.rows.length === 0) break;
|
|
273
|
+
rows.push(...page.rows);
|
|
274
|
+
// The first page's count is the snapshot the rest of the walk is measured
|
|
275
|
+
// against. A total that is not a real number (a route answering `"many"`)
|
|
276
|
+
// would compare false forever and walk to the offset ceiling, so an
|
|
277
|
+
// unusable one falls back to what actually arrived.
|
|
278
|
+
if (offset === 0) {
|
|
279
|
+
total = Number.isFinite(page.total) ? page.total : rows.length;
|
|
280
|
+
}
|
|
281
|
+
if (rows.length >= total) break;
|
|
282
|
+
if (offset + MAX_LIMIT > MAX_OFFSET) {
|
|
283
|
+
report(
|
|
284
|
+
`readDatabaseAll: "${table}" holds ${total} rows, past the ${MAX_OFFSET + MAX_LIMIT} ` +
|
|
285
|
+
`that offset paging reaches, so ${rows.length} of them were read.`,
|
|
286
|
+
);
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return { rows, total };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Attach {@link DbProvenance} to a row that carries a system `_id`.
|
|
295
|
+
*
|
|
296
|
+
* The preview-read channel returns `_id` and a computed `_live` flag; the public
|
|
297
|
+
* production read (C6) is published-only and carries neither, so this leaves a
|
|
298
|
+
* production row untouched. The `_live` flag is lifted OFF the row into the
|
|
299
|
+
* hidden slot so no template can render it; `_id` stays where a `systemColumns`
|
|
300
|
+
* grant may legitimately want it. The slot is non-enumerable, so a row that
|
|
301
|
+
* spreads or serializes looks exactly as it did before.
|
|
302
|
+
*/
|
|
303
|
+
function withProvenance(row: DatabaseRow, table: string): DatabaseRow {
|
|
304
|
+
const id = row._id;
|
|
305
|
+
if (id === undefined || id === null) return row;
|
|
306
|
+
const { _live, ...rest } = row;
|
|
307
|
+
const provenance: DbProvenance = {
|
|
308
|
+
table,
|
|
309
|
+
id: String(id),
|
|
310
|
+
live: _live === undefined ? true : _live !== 0,
|
|
311
|
+
};
|
|
312
|
+
Object.defineProperty(rest, PROVENANCE_KEY, {
|
|
313
|
+
value: provenance,
|
|
314
|
+
enumerable: false,
|
|
315
|
+
writable: false,
|
|
316
|
+
configurable: true,
|
|
317
|
+
});
|
|
318
|
+
return rest;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function provenanceOf(row: DatabaseRow): DbProvenance | undefined {
|
|
322
|
+
return (row as { [PROVENANCE_KEY]?: DbProvenance })[PROVENANCE_KEY];
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* The `data-db-*` attributes that make a DB-bound element self-describing, spread
|
|
327
|
+
* onto the element where the value is interpolated:
|
|
328
|
+
*
|
|
329
|
+
* ```astro
|
|
330
|
+
* <h1 {...dbAttrs(article, "title")}>{article.title}</h1>
|
|
331
|
+
* ```
|
|
332
|
+
*
|
|
333
|
+
* The visual editor reads these to map a click back to a table, row, and column
|
|
334
|
+
* (dbx-31). A draft row (its `_live` is false) also gets `data-db-draft`, which
|
|
335
|
+
* the preview marks so a screenshot cannot be mistaken for the live page.
|
|
336
|
+
*
|
|
337
|
+
* Returns `{}` in a production build (`import.meta.env.PROD`) and when the row
|
|
338
|
+
* carries no provenance, so published HTML ships with no annotations and no row
|
|
339
|
+
* ids. Preview only, by default (fork F1).
|
|
340
|
+
*/
|
|
341
|
+
export function dbAttrs(
|
|
342
|
+
row: DatabaseRow,
|
|
343
|
+
column: string,
|
|
344
|
+
): DbAttrs | Record<string, never> {
|
|
345
|
+
if (import.meta.env.PROD) return {};
|
|
346
|
+
const provenance = provenanceOf(row);
|
|
347
|
+
if (!provenance) return {};
|
|
348
|
+
const attrs: DbAttrs = {
|
|
349
|
+
"data-db-table": provenance.table,
|
|
350
|
+
"data-db-row": provenance.id,
|
|
351
|
+
"data-db-col": column,
|
|
352
|
+
};
|
|
353
|
+
if (provenance.live === false) attrs["data-db-draft"] = "";
|
|
354
|
+
return attrs;
|
|
155
355
|
}
|
package/src/lib/locales.ts
CHANGED
|
@@ -25,9 +25,11 @@ export const DEFAULT_LOCALE = "en";
|
|
|
25
25
|
* `chrome.pt-br` → {base:"chrome", locale:"pt-br"}. Base names never contain
|
|
26
26
|
* dots, so any well-formed lowercased locale suffix IS a sibling id.
|
|
27
27
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
28
|
+
* The agent-side counterpart is
|
|
29
|
+
* `apps/agent-flue/src/product/localization.ts#parseEntryId`: the agent writes
|
|
30
|
+
* the sibling ids the site reads back (out-of-workspace duplication). KEEP THE
|
|
31
|
+
* LOCALE SEGMENT GRAMMAR IN SYNC with its LOCALE_SEGMENT_PATTERN; the two
|
|
32
|
+
* differ only on ids whose suffix fails that grammar.
|
|
31
33
|
*/
|
|
32
34
|
export function parseEntryId(id: string): { base: string; locale?: string } {
|
|
33
35
|
const dot = id.indexOf(".");
|
|
@@ -42,8 +44,8 @@ export function parseEntryId(id: string): { base: string; locale?: string } {
|
|
|
42
44
|
* Canonical BCP-47 casing for a locale (`pt-br` → `pt-BR`, `ES` → `es`):
|
|
43
45
|
* language subtag lowercased, 4-alpha script subtags Titlecased, other
|
|
44
46
|
* subtags uppercased. Files/routes use the lowercased segment; this casing is
|
|
45
|
-
* for the `<html lang>` attribute and hreflang values (starter 2.9.0).
|
|
46
|
-
*
|
|
47
|
+
* for the `<html lang>` attribute and hreflang values (starter 2.9.0). This is
|
|
48
|
+
* the single implementation; the agent side never renders lang attributes.
|
|
47
49
|
*/
|
|
48
50
|
export function normalizeBcp47(locale: string): string {
|
|
49
51
|
const raw = locale.trim().replace(/_/g, "-");
|