@iterant/site-runtime 3.5.0 → 3.6.1
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/scripts/scan-bespoke-siblings.mjs +86 -33
- package/src/lib/database.ts +119 -11
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.1._
|
|
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.1",
|
|
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": {
|
|
@@ -19,12 +19,29 @@
|
|
|
19
19
|
* the shape the catch-all's pickPageExport (src/lib/bespoke-pages.ts)
|
|
20
20
|
* resolves.
|
|
21
21
|
*
|
|
22
|
-
* For every NON-DRAFT bespoke base entry
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
22
|
+
* For every NON-DRAFT bespoke base entry THAT HAS a locale sibling file
|
|
23
|
+
* (`<base>.<locale>.json`, draft or not: a draft sibling still renders in a
|
|
24
|
+
* preview), this asserts (1) against the built manifest and (2) by importing
|
|
25
|
+
* the built client entry itself. Draft bespoke pages are exempt: drafts never
|
|
26
|
+
* publish, their siblings are never advertised (hreflang/sitemap), and the
|
|
27
|
+
* stock `not-found` page is a draft that deliberately lives outside the
|
|
28
|
+
* `<base>/page.tsx` convention (404.astro renders it). Publishing a bespoke
|
|
29
|
+
* page puts it in scope on the next verify.
|
|
30
|
+
*
|
|
31
|
+
* Scoping to bases-with-siblings (3.6.1) loses nothing: the hazard cannot
|
|
32
|
+
* materialize without a sibling, a sibling only comes into existence through
|
|
33
|
+
* a save, and every save runs this gate, so a base picks up the full check
|
|
34
|
+
* the moment its first sibling lands. Before that, a pre-convention bespoke
|
|
35
|
+
* page (every pre-3.6 blog) must not block a same-major runtime bump over a
|
|
36
|
+
* feature the repo does not use.
|
|
37
|
+
*
|
|
38
|
+
* A base whose sibling route is SHADOWED is also out of scope (3.6.1): when
|
|
39
|
+
* `src/pages/[locale]/<base>.astro` exists (`index.astro` for the `home`
|
|
40
|
+
* base), Astro's route priority sends `/es` to that explicit route, never to
|
|
41
|
+
* the catch-all, so the catch-all hydration contract this gate asserts does
|
|
42
|
+
* not serve the sibling at all. Replica-era repos localize exactly this way.
|
|
43
|
+
* The shadowing route's own hydration is `astro build`'s concern, like every
|
|
44
|
+
* other explicit route.
|
|
28
45
|
*
|
|
29
46
|
* It also pins the Astro runtime contract the catch-all relies on: the island
|
|
30
47
|
* renderer must keep accepting `client:component-path` /
|
|
@@ -37,7 +54,7 @@
|
|
|
37
54
|
* weaken this script.
|
|
38
55
|
*/
|
|
39
56
|
|
|
40
|
-
import { readdir, readFile } from "node:fs/promises";
|
|
57
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
41
58
|
import { join } from "node:path";
|
|
42
59
|
import { pathToFileURL } from "node:url";
|
|
43
60
|
import { exit } from "node:process";
|
|
@@ -51,41 +68,34 @@ const CONVENTIONS =
|
|
|
51
68
|
/** @type {string[]} */
|
|
52
69
|
const problems = [];
|
|
53
70
|
|
|
54
|
-
// ----
|
|
55
|
-
|
|
56
|
-
const hydrationJs = join(
|
|
57
|
-
process.cwd(),
|
|
58
|
-
"node_modules/astro/dist/runtime/server/hydration.js",
|
|
59
|
-
);
|
|
60
|
-
const hydrationSrc = await readFile(hydrationJs, "utf8").catch(() => "");
|
|
61
|
-
for (const directive of ["client:component-path", "client:component-export"]) {
|
|
62
|
-
if (!hydrationSrc.includes(`case "${directive}"`)) {
|
|
63
|
-
problems.push(
|
|
64
|
-
`astro runtime contract changed: ${hydrationJs} no longer handles ` +
|
|
65
|
-
`"${directive}" as a prop. Bespoke locale siblings hydrate through ` +
|
|
66
|
-
`exactly that escape hatch (src/pages/[...slug].astro) — re-verify ` +
|
|
67
|
-
`sibling hydration against this Astro version before shipping.`,
|
|
68
|
-
);
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// ---- SF1+SF2: every non-draft bespoke base ---------------------------------
|
|
71
|
+
// ---- SF1+SF2: every non-draft bespoke base with a sibling ------------------
|
|
73
72
|
|
|
74
|
-
/**
|
|
75
|
-
|
|
73
|
+
/**
|
|
74
|
+
* Non-draft bespoke base entry ids (`home`), never locale siblings, plus the
|
|
75
|
+
* set of base ids that have at least one sibling file. Sibling presence
|
|
76
|
+
* counts by filename alone (`home.es.json` -> `home`): a sibling's own draft
|
|
77
|
+
* flag or parse failure does not excuse its base, because the sibling still
|
|
78
|
+
* renders in a preview.
|
|
79
|
+
*/
|
|
80
|
+
async function bespokeInventory() {
|
|
76
81
|
const pagesDir = join(process.cwd(), "src/content/pages");
|
|
77
82
|
/** @type {string[]} */
|
|
78
83
|
const bases = [];
|
|
84
|
+
/** @type {Set<string>} */
|
|
85
|
+
const siblingBases = new Set();
|
|
79
86
|
/** @type {string[]} */
|
|
80
87
|
let names = [];
|
|
81
88
|
try {
|
|
82
89
|
names = await readdir(pagesDir);
|
|
83
90
|
} catch {
|
|
84
|
-
return bases; // no pages dir
|
|
91
|
+
return { bases, siblingBases }; // no pages dir: astro check owns that failure
|
|
85
92
|
}
|
|
86
93
|
for (const name of names.filter((n) => n.endsWith(".json")).sort()) {
|
|
87
94
|
const id = name.replace(/\.json$/, "");
|
|
88
|
-
if (id.includes("."))
|
|
95
|
+
if (id.includes(".")) {
|
|
96
|
+
siblingBases.add(id.slice(0, id.indexOf(".")));
|
|
97
|
+
continue; // locale sibling: guarded via its base
|
|
98
|
+
}
|
|
89
99
|
/** @type {{ mode?: string; draft?: boolean }} */
|
|
90
100
|
let entry;
|
|
91
101
|
try {
|
|
@@ -95,7 +105,21 @@ async function bespokeBases() {
|
|
|
95
105
|
}
|
|
96
106
|
if (entry.mode === "bespoke" && entry.draft !== true) bases.push(id);
|
|
97
107
|
}
|
|
98
|
-
return bases;
|
|
108
|
+
return { bases, siblingBases };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Whether an explicit locale route shadows this base's siblings. Astro sends
|
|
113
|
+
* `/es` (and `/es/<base>`) to `src/pages/[locale]/index.astro` (respectively
|
|
114
|
+
* `[locale]/<base>.astro`) before any rest-param catch-all, so when that file
|
|
115
|
+
* exists the sibling never renders through the catch-all this gate asserts.
|
|
116
|
+
*/
|
|
117
|
+
async function siblingRouteShadowed(base) {
|
|
118
|
+
const file = base === "home" ? "index.astro" : `${base}.astro`;
|
|
119
|
+
return stat(join(process.cwd(), "src/pages/[locale]", file)).then(
|
|
120
|
+
(s) => s.isFile(),
|
|
121
|
+
() => false,
|
|
122
|
+
);
|
|
99
123
|
}
|
|
100
124
|
|
|
101
125
|
/**
|
|
@@ -133,8 +157,37 @@ async function builtEntryModules() {
|
|
|
133
157
|
return null;
|
|
134
158
|
}
|
|
135
159
|
|
|
136
|
-
const
|
|
137
|
-
|
|
160
|
+
const inventory = await bespokeInventory();
|
|
161
|
+
/** @type {string[]} */
|
|
162
|
+
const bases = [];
|
|
163
|
+
for (const base of inventory.bases) {
|
|
164
|
+
if (!inventory.siblingBases.has(base)) continue;
|
|
165
|
+
if (await siblingRouteShadowed(base)) continue;
|
|
166
|
+
bases.push(base);
|
|
167
|
+
}
|
|
168
|
+
// Nothing can hydrate through the escape hatch: the first sibling arrives via
|
|
169
|
+
// a save, and every save re-runs this gate, so exiting here defers nothing.
|
|
170
|
+
if (bases.length === 0) exit(0);
|
|
171
|
+
|
|
172
|
+
// ---- SF-pin: the Astro runtime directive contract --------------------------
|
|
173
|
+
|
|
174
|
+
const hydrationJs = join(
|
|
175
|
+
process.cwd(),
|
|
176
|
+
"node_modules/astro/dist/runtime/server/hydration.js",
|
|
177
|
+
);
|
|
178
|
+
const hydrationSrc = await readFile(hydrationJs, "utf8").catch(() => "");
|
|
179
|
+
for (const directive of ["client:component-path", "client:component-export"]) {
|
|
180
|
+
if (!hydrationSrc.includes(`case "${directive}"`)) {
|
|
181
|
+
problems.push(
|
|
182
|
+
`astro runtime contract changed: ${hydrationJs} no longer handles ` +
|
|
183
|
+
`"${directive}" as a prop. Bespoke locale siblings hydrate through ` +
|
|
184
|
+
`exactly that escape hatch (src/pages/[...slug].astro): re-verify ` +
|
|
185
|
+
`sibling hydration against this Astro version before shipping.`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (problems.length === 0) {
|
|
138
191
|
const entryModules = await builtEntryModules();
|
|
139
192
|
if (!entryModules) {
|
|
140
193
|
problems.push(
|
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"
|
|
@@ -98,15 +117,29 @@ type SiteDatabaseHandoff = {
|
|
|
98
117
|
|
|
99
118
|
const EMPTY: DatabaseResult = { rows: [], total: 0 };
|
|
100
119
|
|
|
101
|
-
function
|
|
102
|
-
|
|
120
|
+
function environment(): Record<string, string | undefined> | undefined {
|
|
121
|
+
return (
|
|
103
122
|
globalThis as { process?: { env?: Record<string, string | undefined> } }
|
|
104
123
|
).process?.env;
|
|
105
|
-
|
|
106
|
-
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function fromEnvironment(): SiteDatabaseHandoff | null {
|
|
127
|
+
const url = environment()?.[URL_ENV];
|
|
128
|
+
const token = environment()?.[TOKEN_ENV];
|
|
107
129
|
return url && token ? { url, token } : null;
|
|
108
130
|
}
|
|
109
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
|
+
|
|
110
143
|
function handoff(): SiteDatabaseHandoff | null {
|
|
111
144
|
const baked = (globalThis as Record<string, unknown>)[GLOBAL_KEY];
|
|
112
145
|
if (baked && typeof baked === "object") {
|
|
@@ -118,8 +151,25 @@ function handoff(): SiteDatabaseHandoff | null {
|
|
|
118
151
|
return fromEnvironment();
|
|
119
152
|
}
|
|
120
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
|
+
|
|
121
171
|
function warn(message: string, detail?: unknown): DatabaseResult {
|
|
122
|
-
|
|
172
|
+
report(`readDatabase: ${message}`, detail);
|
|
123
173
|
return EMPTY;
|
|
124
174
|
}
|
|
125
175
|
|
|
@@ -133,8 +183,9 @@ function warn(message: string, detail?: unknown): DatabaseResult {
|
|
|
133
183
|
* });
|
|
134
184
|
* ```
|
|
135
185
|
*
|
|
136
|
-
* Call it in a page's frontmatter (server side).
|
|
137
|
-
*
|
|
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.
|
|
138
189
|
*/
|
|
139
190
|
export async function readDatabase(
|
|
140
191
|
table: string,
|
|
@@ -182,6 +233,63 @@ export async function readDatabase(
|
|
|
182
233
|
};
|
|
183
234
|
}
|
|
184
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
|
+
|
|
185
293
|
/**
|
|
186
294
|
* Attach {@link DbProvenance} to a row that carries a system `_id`.
|
|
187
295
|
*
|