@iterant/site-runtime 3.3.0 → 3.4.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 +32 -1
- package/package.json +3 -3
- package/src/index.ts +1 -0
- package/src/lib/database.ts +155 -0
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.4.0._
|
|
54
54
|
|
|
55
55
|
**Toolchain** (this package owns the version; do NOT declare these):
|
|
56
56
|
|
|
@@ -259,6 +259,37 @@ additive platform-side field can never fail an already-pinned repo's build.
|
|
|
259
259
|
"The one `@source` line" below does not grow: the component ships no utility
|
|
260
260
|
classes, only a scoped style block on the data attribute.
|
|
261
261
|
|
|
262
|
+
### Reading the brand Database (3.4.0)
|
|
263
|
+
|
|
264
|
+
A page can render rows the customer maintains in the brand Database, read at
|
|
265
|
+
request time:
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
import { readDatabase } from "@iterant/site-runtime/database";
|
|
269
|
+
|
|
270
|
+
const { rows, total } = await readDatabase("articles", {
|
|
271
|
+
sort: [{ column: "published_at", dir: "desc" }],
|
|
272
|
+
limit: 20,
|
|
273
|
+
});
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
Server side only (a page's frontmatter, an SSR route). The site holds no
|
|
277
|
+
database binding and writes no SQL: it names a table and the platform decides
|
|
278
|
+
what that means. A table is invisible until the platform grants it site read,
|
|
279
|
+
and the grant carries the filter that says which rows are public (a blog
|
|
280
|
+
publishes `status = published`) plus an optional list of readable fields. A
|
|
281
|
+
narrower `filter` here can only narrow that further. The platform's own row
|
|
282
|
+
columns (`_id`, `_created_at`, `_updated_at`, `_v`) are not part of a grant
|
|
283
|
+
unless it says so, so a page that keys rows by `_id` needs the grant to publish
|
|
284
|
+
it; without that they are neither readable, filterable nor sortable.
|
|
285
|
+
|
|
286
|
+
Nothing is configured in the repo. A published site gets the endpoint and the
|
|
287
|
+
token from the platform-generated publish gate; `astro dev` gets them from
|
|
288
|
+
`ITERANT_SITE_DB_URL` and `ITERANT_SITE_DB_TOKEN`, which the platform sets on
|
|
289
|
+
the dev server. A site with neither, an ungranted table and an unreachable
|
|
290
|
+
platform all read as `{rows: [], total: 0}` with a console warning: a listing
|
|
291
|
+
that renders empty beats a build that dies on a database blip.
|
|
292
|
+
|
|
262
293
|
### Choosing a shell (3.2.0)
|
|
263
294
|
|
|
264
295
|
A repo may own more than one frame. A replicated site whose second page carried
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iterant/site-runtime",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.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": {
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"!scripts/check-fixture.mjs",
|
|
32
32
|
"!scripts/check-packed.mjs",
|
|
33
33
|
"!scripts/generate-kit-table.mjs",
|
|
34
|
+
"!scripts/packed-content.mjs",
|
|
34
35
|
"!src/lib/__fixtures__"
|
|
35
36
|
],
|
|
36
37
|
"exports": {
|
|
@@ -38,6 +39,7 @@
|
|
|
38
39
|
"./content": "./src/content/collections.ts",
|
|
39
40
|
"./content/schema": "./src/content/schema.ts",
|
|
40
41
|
"./content-values": "./src/lib/content-values.ts",
|
|
42
|
+
"./database": "./src/lib/database.ts",
|
|
41
43
|
"./markdown": "./src/lib/markdown.ts",
|
|
42
44
|
"./locales": "./src/lib/locales.ts",
|
|
43
45
|
"./hreflang": "./src/lib/hreflang.ts",
|
|
@@ -112,8 +114,6 @@
|
|
|
112
114
|
"@types/node": "24.3.1",
|
|
113
115
|
"@types/react": "19.2.14",
|
|
114
116
|
"@types/react-dom": "19.2.3",
|
|
115
|
-
"@workspace/eslint-config": "workspace:*",
|
|
116
|
-
"@workspace/typescript-config": "workspace:*",
|
|
117
117
|
"typescript": "5.9.2",
|
|
118
118
|
"vitest": "^4.0.13",
|
|
119
119
|
"wrangler": "^4.107.0"
|
package/src/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ export * from "./lib/bespoke-pages";
|
|
|
12
12
|
export * from "./lib/chrome-schemas";
|
|
13
13
|
export * from "./lib/content-paths";
|
|
14
14
|
export * from "./lib/content-values";
|
|
15
|
+
export * from "./lib/database";
|
|
15
16
|
export * from "./lib/hreflang";
|
|
16
17
|
export * from "./lib/locales";
|
|
17
18
|
export * from "./version";
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Reading the brand's Database from a page, at request time (epic DBX, decision
|
|
2
|
+
// DBX-D11).
|
|
3
|
+
//
|
|
4
|
+
// A published site holds no database binding and composes no SQL. It asks the
|
|
5
|
+
// platform's public read route for rows of ONE table, and the platform decides
|
|
6
|
+
// what that means: the table has to carry a `site_read` grant, the grant's
|
|
7
|
+
// filters are added server side, and its column allowlist is what comes back.
|
|
8
|
+
// So a page cannot read a draft by asking differently, and nothing here needs to
|
|
9
|
+
// know how the data is stored.
|
|
10
|
+
//
|
|
11
|
+
// Where the endpoint and token come from: the publish gate, a module the
|
|
12
|
+
// platform generates on every publish and puts in front of Astro's entry. It
|
|
13
|
+
// assigns them to `globalThis.__ITERANT_SITE_DB__` at module scope. In `astro
|
|
14
|
+
// dev` there is no gate, so the pair arrives as environment variables instead.
|
|
15
|
+
//
|
|
16
|
+
// 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 build
|
|
18
|
+
// that fails on a database blip is not.
|
|
19
|
+
|
|
20
|
+
const GLOBAL_KEY = "__ITERANT_SITE_DB__";
|
|
21
|
+
|
|
22
|
+
const URL_ENV = "ITERANT_SITE_DB_URL";
|
|
23
|
+
|
|
24
|
+
const TOKEN_ENV = "ITERANT_SITE_DB_TOKEN";
|
|
25
|
+
|
|
26
|
+
/** How long one read may take before the page renders without it. */
|
|
27
|
+
const TIMEOUT_MS = 10_000;
|
|
28
|
+
|
|
29
|
+
export type DatabaseFilterOp =
|
|
30
|
+
| "eq"
|
|
31
|
+
| "ne"
|
|
32
|
+
| "contains"
|
|
33
|
+
| "gt"
|
|
34
|
+
| "lt"
|
|
35
|
+
| "isnull";
|
|
36
|
+
|
|
37
|
+
export type DatabaseFilter = {
|
|
38
|
+
column: string;
|
|
39
|
+
op: DatabaseFilterOp;
|
|
40
|
+
value?: string | number | boolean | null;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type DatabaseSort = {
|
|
44
|
+
column: string;
|
|
45
|
+
dir: "asc" | "desc";
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type DatabaseQuery = {
|
|
49
|
+
/** Columns to read. Omitted means every column the grant allows. */
|
|
50
|
+
select?: string[];
|
|
51
|
+
/** Narrows the granted rows further. It can never widen them. */
|
|
52
|
+
filter?: DatabaseFilter[];
|
|
53
|
+
sort?: DatabaseSort[];
|
|
54
|
+
/** Rows per page, up to the platform's own ceiling of 200. */
|
|
55
|
+
limit?: number;
|
|
56
|
+
offset?: number;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type DatabaseRow = Record<string, string | number | null>;
|
|
60
|
+
|
|
61
|
+
export type DatabaseResult = {
|
|
62
|
+
rows: DatabaseRow[];
|
|
63
|
+
/** Every row matching the query, not just this page. */
|
|
64
|
+
total: number;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
type SiteDatabaseHandoff = {
|
|
68
|
+
/** The complete read endpoint, brand and all. The site never assembles a
|
|
69
|
+
* platform URL of its own. */
|
|
70
|
+
url: string;
|
|
71
|
+
token: string;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const EMPTY: DatabaseResult = { rows: [], total: 0 };
|
|
75
|
+
|
|
76
|
+
function fromEnvironment(): SiteDatabaseHandoff | null {
|
|
77
|
+
const environment = (
|
|
78
|
+
globalThis as { process?: { env?: Record<string, string | undefined> } }
|
|
79
|
+
).process?.env;
|
|
80
|
+
const url = environment?.[URL_ENV];
|
|
81
|
+
const token = environment?.[TOKEN_ENV];
|
|
82
|
+
return url && token ? { url, token } : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function handoff(): SiteDatabaseHandoff | null {
|
|
86
|
+
const baked = (globalThis as Record<string, unknown>)[GLOBAL_KEY];
|
|
87
|
+
if (baked && typeof baked === "object") {
|
|
88
|
+
const { url, token } = baked as Partial<SiteDatabaseHandoff>;
|
|
89
|
+
if (typeof url === "string" && typeof token === "string") {
|
|
90
|
+
return { url, token };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return fromEnvironment();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function warn(message: string, detail?: unknown): DatabaseResult {
|
|
97
|
+
console.warn(`[site-runtime] readDatabase: ${message}`, detail ?? "");
|
|
98
|
+
return EMPTY;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Read rows of one granted table.
|
|
103
|
+
*
|
|
104
|
+
* ```ts
|
|
105
|
+
* const { rows } = await readDatabase("articles", {
|
|
106
|
+
* sort: [{ column: "published_at", dir: "desc" }],
|
|
107
|
+
* limit: 20,
|
|
108
|
+
* });
|
|
109
|
+
* ```
|
|
110
|
+
*
|
|
111
|
+
* Call it in a page's frontmatter (server side). The rows are read on every
|
|
112
|
+
* request, so publishing a row shows it on the next page load with no rebuild.
|
|
113
|
+
*/
|
|
114
|
+
export async function readDatabase(
|
|
115
|
+
table: string,
|
|
116
|
+
query: DatabaseQuery = {},
|
|
117
|
+
): Promise<DatabaseResult> {
|
|
118
|
+
const pair = handoff();
|
|
119
|
+
if (!pair) {
|
|
120
|
+
return warn(
|
|
121
|
+
`no database is connected to this site, so "${table}" read as empty. ` +
|
|
122
|
+
`A published site gets its connection from the publish gate; ` +
|
|
123
|
+
`a dev server gets it from ${URL_ENV} and ${TOKEN_ENV}.`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
let response: Response;
|
|
127
|
+
try {
|
|
128
|
+
response = await fetch(pair.url, {
|
|
129
|
+
method: "POST",
|
|
130
|
+
headers: {
|
|
131
|
+
authorization: `Bearer ${pair.token}`,
|
|
132
|
+
"content-type": "application/json",
|
|
133
|
+
},
|
|
134
|
+
body: JSON.stringify({ table, ...query }),
|
|
135
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
136
|
+
});
|
|
137
|
+
} catch (error) {
|
|
138
|
+
return warn(`"${table}" could not be reached`, error);
|
|
139
|
+
}
|
|
140
|
+
if (!response.ok) {
|
|
141
|
+
const detail = await response.text().catch(() => "");
|
|
142
|
+
return warn(`"${table}" answered ${response.status}`, detail.slice(0, 400));
|
|
143
|
+
}
|
|
144
|
+
let body: unknown;
|
|
145
|
+
try {
|
|
146
|
+
body = await response.json();
|
|
147
|
+
} catch (error) {
|
|
148
|
+
return warn(`"${table}" answered with a body that is not JSON`, error);
|
|
149
|
+
}
|
|
150
|
+
const page = body as Partial<DatabaseResult>;
|
|
151
|
+
if (!Array.isArray(page.rows)) {
|
|
152
|
+
return warn(`"${table}" answered without rows`, body);
|
|
153
|
+
}
|
|
154
|
+
return { rows: page.rows, total: Number(page.total ?? page.rows.length) };
|
|
155
|
+
}
|