@iterant/site-runtime 3.3.0 → 3.5.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.
@@ -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.3.0._
53
+ _Generated from package.json by scripts/generate-kit-table.mjs. Runtime 3.5.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.0",
3
+ "version": "3.5.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,247 @@
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
+ /**
62
+ * Where a rendered row came from, carried on the row so the visual editor can
63
+ * find the element again (epic DBX, decision DBX-D20). It rides a NON-enumerable
64
+ * `__db` slot: invisible to `JSON.stringify`, to `Object.keys`, and to every
65
+ * template that spreads or iterates the row, so nothing accidentally renders it.
66
+ */
67
+ export type DbProvenance = {
68
+ table: string;
69
+ id: string;
70
+ /** Whether the published site shows this row. The preview channel projects it
71
+ * per row (`_live`); a draft reads `false`. */
72
+ live: boolean;
73
+ };
74
+
75
+ /** The property that carries {@link DbProvenance}, hidden from enumeration. */
76
+ const PROVENANCE_KEY = "__db";
77
+
78
+ /** The attributes {@link dbAttrs} writes onto a DB-bound element. */
79
+ export type DbAttrs = {
80
+ "data-db-table": string;
81
+ "data-db-row": string;
82
+ "data-db-col": string;
83
+ "data-db-draft"?: "";
84
+ };
85
+
86
+ export type DatabaseResult = {
87
+ rows: DatabaseRow[];
88
+ /** Every row matching the query, not just this page. */
89
+ total: number;
90
+ };
91
+
92
+ type SiteDatabaseHandoff = {
93
+ /** The complete read endpoint, brand and all. The site never assembles a
94
+ * platform URL of its own. */
95
+ url: string;
96
+ token: string;
97
+ };
98
+
99
+ const EMPTY: DatabaseResult = { rows: [], total: 0 };
100
+
101
+ function fromEnvironment(): SiteDatabaseHandoff | null {
102
+ const environment = (
103
+ globalThis as { process?: { env?: Record<string, string | undefined> } }
104
+ ).process?.env;
105
+ const url = environment?.[URL_ENV];
106
+ const token = environment?.[TOKEN_ENV];
107
+ return url && token ? { url, token } : null;
108
+ }
109
+
110
+ function handoff(): SiteDatabaseHandoff | null {
111
+ const baked = (globalThis as Record<string, unknown>)[GLOBAL_KEY];
112
+ if (baked && typeof baked === "object") {
113
+ const { url, token } = baked as Partial<SiteDatabaseHandoff>;
114
+ if (typeof url === "string" && typeof token === "string") {
115
+ return { url, token };
116
+ }
117
+ }
118
+ return fromEnvironment();
119
+ }
120
+
121
+ function warn(message: string, detail?: unknown): DatabaseResult {
122
+ console.warn(`[site-runtime] readDatabase: ${message}`, detail ?? "");
123
+ return EMPTY;
124
+ }
125
+
126
+ /**
127
+ * Read rows of one granted table.
128
+ *
129
+ * ```ts
130
+ * const { rows } = await readDatabase("articles", {
131
+ * sort: [{ column: "published_at", dir: "desc" }],
132
+ * limit: 20,
133
+ * });
134
+ * ```
135
+ *
136
+ * Call it in a page's frontmatter (server side). The rows are read on every
137
+ * request, so publishing a row shows it on the next page load with no rebuild.
138
+ */
139
+ export async function readDatabase(
140
+ table: string,
141
+ query: DatabaseQuery = {},
142
+ ): Promise<DatabaseResult> {
143
+ const pair = handoff();
144
+ if (!pair) {
145
+ return warn(
146
+ `no database is connected to this site, so "${table}" read as empty. ` +
147
+ `A published site gets its connection from the publish gate; ` +
148
+ `a dev server gets it from ${URL_ENV} and ${TOKEN_ENV}.`,
149
+ );
150
+ }
151
+ let response: Response;
152
+ try {
153
+ response = await fetch(pair.url, {
154
+ method: "POST",
155
+ headers: {
156
+ authorization: `Bearer ${pair.token}`,
157
+ "content-type": "application/json",
158
+ },
159
+ body: JSON.stringify({ table, ...query }),
160
+ signal: AbortSignal.timeout(TIMEOUT_MS),
161
+ });
162
+ } catch (error) {
163
+ return warn(`"${table}" could not be reached`, error);
164
+ }
165
+ if (!response.ok) {
166
+ const detail = await response.text().catch(() => "");
167
+ return warn(`"${table}" answered ${response.status}`, detail.slice(0, 400));
168
+ }
169
+ let body: unknown;
170
+ try {
171
+ body = await response.json();
172
+ } catch (error) {
173
+ return warn(`"${table}" answered with a body that is not JSON`, error);
174
+ }
175
+ const page = body as Partial<DatabaseResult>;
176
+ if (!Array.isArray(page.rows)) {
177
+ return warn(`"${table}" answered without rows`, body);
178
+ }
179
+ return {
180
+ rows: page.rows.map((row) => withProvenance(row, table)),
181
+ total: Number(page.total ?? page.rows.length),
182
+ };
183
+ }
184
+
185
+ /**
186
+ * Attach {@link DbProvenance} to a row that carries a system `_id`.
187
+ *
188
+ * The preview-read channel returns `_id` and a computed `_live` flag; the public
189
+ * production read (C6) is published-only and carries neither, so this leaves a
190
+ * production row untouched. The `_live` flag is lifted OFF the row into the
191
+ * hidden slot so no template can render it; `_id` stays where a `systemColumns`
192
+ * grant may legitimately want it. The slot is non-enumerable, so a row that
193
+ * spreads or serializes looks exactly as it did before.
194
+ */
195
+ function withProvenance(row: DatabaseRow, table: string): DatabaseRow {
196
+ const id = row._id;
197
+ if (id === undefined || id === null) return row;
198
+ const { _live, ...rest } = row;
199
+ const provenance: DbProvenance = {
200
+ table,
201
+ id: String(id),
202
+ live: _live === undefined ? true : _live !== 0,
203
+ };
204
+ Object.defineProperty(rest, PROVENANCE_KEY, {
205
+ value: provenance,
206
+ enumerable: false,
207
+ writable: false,
208
+ configurable: true,
209
+ });
210
+ return rest;
211
+ }
212
+
213
+ function provenanceOf(row: DatabaseRow): DbProvenance | undefined {
214
+ return (row as { [PROVENANCE_KEY]?: DbProvenance })[PROVENANCE_KEY];
215
+ }
216
+
217
+ /**
218
+ * The `data-db-*` attributes that make a DB-bound element self-describing, spread
219
+ * onto the element where the value is interpolated:
220
+ *
221
+ * ```astro
222
+ * <h1 {...dbAttrs(article, "title")}>{article.title}</h1>
223
+ * ```
224
+ *
225
+ * The visual editor reads these to map a click back to a table, row, and column
226
+ * (dbx-31). A draft row (its `_live` is false) also gets `data-db-draft`, which
227
+ * the preview marks so a screenshot cannot be mistaken for the live page.
228
+ *
229
+ * Returns `{}` in a production build (`import.meta.env.PROD`) and when the row
230
+ * carries no provenance, so published HTML ships with no annotations and no row
231
+ * ids. Preview only, by default (fork F1).
232
+ */
233
+ export function dbAttrs(
234
+ row: DatabaseRow,
235
+ column: string,
236
+ ): DbAttrs | Record<string, never> {
237
+ if (import.meta.env.PROD) return {};
238
+ const provenance = provenanceOf(row);
239
+ if (!provenance) return {};
240
+ const attrs: DbAttrs = {
241
+ "data-db-table": provenance.table,
242
+ "data-db-row": provenance.id,
243
+ "data-db-col": column,
244
+ };
245
+ if (provenance.live === false) attrs["data-db-draft"] = "";
246
+ return attrs;
247
+ }
@@ -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
- * KEEP IN SYNC with `apps/agent-mvp/src/locales.ts#parseEntryId` the agent
29
- * writes the sibling ids the site reads back (out-of-workspace duplication,
30
- * same pattern as the special-input mirrors).
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). KEEP IN
46
- * SYNC with `apps/agent-mvp/src/locales.ts#normalizeBcp47`.
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, "-");