smoodly 0.0.46 → 0.0.47

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.
@@ -13,11 +13,15 @@ export type HealthDocument = {
13
13
  collection: string | null;
14
14
  findings: HealthFinding[];
15
15
  };
16
+ import type { DeliveryStatus } from "../project.ts";
17
+ /** `delivery` (DESIGN §3, the document stamp): the delivery rows behind the current stamp, with the recorded one;
18
+ * absent only in a report built without a projector (static tests). */
16
19
  export type HealthReport = {
17
20
  documents: HealthDocument[];
18
21
  collections: {
19
22
  name: string;
20
23
  count: number;
21
24
  }[];
25
+ delivery?: DeliveryStatus;
22
26
  };
23
27
  export declare function buildHealthReport(registry: AdminRegistry, pages: PageScan[], entries: EntryScan[]): HealthReport;
@@ -1311,8 +1311,8 @@ export function createAdminOps(deps) {
1311
1311
  },
1312
1312
  health: {
1313
1313
  scan: () => guard(async () => {
1314
- const [trees, rows] = await Promise.all([pages.scanTrees(), entries.scanEntries()]);
1315
- return ok(buildHealthReport(adminRegistry(), trees, rows));
1314
+ const [trees, rows, delivery] = await Promise.all([pages.scanTrees(), entries.scanEntries(), projector.delivery()]);
1315
+ return ok({ ...buildHealthReport(adminRegistry(), trees, rows), delivery });
1316
1316
  }),
1317
1317
  },
1318
1318
  schema: {
@@ -26,7 +26,9 @@ export function ContentHealthPage({ ops, registry, initial = null }) {
26
26
  return _jsx(ErrorPane, { message: error, back: false, onRetry: scan });
27
27
  const sections = report ? healthSections(report, registry) : null;
28
28
  const brokenCount = sections ? sections.broken.length + sections.collections.length : 0;
29
- return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx("span", { className: "sm-title", children: "Content health" }), right: _jsxs(_Fragment, { children: [report && _jsx("span", { className: "sm-hint", children: "Checked just now" }), _jsx(Button, { variant: "secondary", onClick: scan, disabled: !report, children: "Rescan" })] }) }), _jsx("div", { className: "sm-listwrap", children: _jsxs("div", { className: "sm-table sm-health", children: [!sections && _jsx("div", { className: "sm-empty", children: "Checking content\u2026" }), sections && brokenCount === 0 && sections.orphan.length === 0 && _jsx("div", { className: "sm-empty", children: "Everything matches the code." }), sections && brokenCount > 0 && (_jsxs("section", { children: [_jsxs("h2", { className: "sm-health__section", children: ["Blocks publishing \u00B7 ", brokenCount] }), sections.collections.map((c) => _jsx("p", { className: "sm-health__headline", children: c.headline }, c.name)), sections.broken.map((g) => _jsx(Group, { group: g }, g.key))] })), sections && sections.orphan.length > 0 && (_jsxs("section", { children: [_jsxs("h2", { className: "sm-health__section", children: ["Left over \u00B7 ", sections.orphan.length] }), sections.orphan.map((g) => _jsx(Group, { group: g }, g.key))] }))] }) })] }));
29
+ return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx("span", { className: "sm-title", children: "Content health" }), right: _jsxs(_Fragment, { children: [report && _jsx("span", { className: "sm-hint", children: "Checked just now" }), _jsx(Button, { variant: "secondary", onClick: scan, disabled: !report, children: "Rescan" })] }) }), _jsx("div", { className: "sm-listwrap", children: _jsxs("div", { className: "sm-table sm-health", children: [!sections && _jsx("div", { className: "sm-empty", children: "Checking content\u2026" }), report?.delivery && (_jsx("p", { className: "sm-health__delivery", children: report.delivery.behind === 0
30
+ ? "Every published document is current."
31
+ : _jsxs(_Fragment, { children: [report.delivery.behind, " ", report.delivery.behind === 1 ? "document is" : "documents are", " behind the current stamp. Run ", _jsx("code", { children: "npx smoodly rebuild" }), " in the site's folder."] }) })), sections && brokenCount === 0 && sections.orphan.length === 0 && _jsx("div", { className: "sm-empty", children: "Everything matches the code." }), sections && brokenCount > 0 && (_jsxs("section", { children: [_jsxs("h2", { className: "sm-health__section", children: ["Blocks publishing \u00B7 ", brokenCount] }), sections.collections.map((c) => _jsx("p", { className: "sm-health__headline", children: c.headline }, c.name)), sections.broken.map((g) => _jsx(Group, { group: g }, g.key))] })), sections && sections.orphan.length > 0 && (_jsxs("section", { children: [_jsxs("h2", { className: "sm-health__section", children: ["Left over \u00B7 ", sections.orphan.length] }), sections.orphan.map((g) => _jsx(Group, { group: g }, g.key))] }))] }) })] }));
30
32
  }
31
33
  function Group({ group }) {
32
34
  const adminHref = useAdminHref();
@@ -29,5 +29,15 @@ export type MigrateDeps = {
29
29
  env?: Record<string, string | undefined>;
30
30
  /** Test seam: replaces the database read behind the key check. */
31
31
  readKey?: (env: Record<string, string | undefined>) => Promise<SchemaRead>;
32
+ /** Test seam: the delivery rows behind the site's document stamp (DESIGN §3); default: the app's config, loaded, against the key's space. */
33
+ delivery?: (env: Record<string, string | undefined>, cwd: string) => Promise<{
34
+ behind: number;
35
+ total: number;
36
+ }>;
32
37
  };
38
+ /** The delivery line of `--check` (DESIGN §3, the document stamp): a count with the one command to run, never a verdict. */
39
+ export declare const deliveryLine: (d: {
40
+ behind: number;
41
+ total: number;
42
+ }) => string;
33
43
  export declare function runMigrate(args: MigrateArgs, out: (line: string) => void, deps?: MigrateDeps): Promise<number>;
@@ -15,6 +15,10 @@ import { resolveSmoodlyEnv } from "../env.js";
15
15
  import { loadAppEnv } from "./space-io.js";
16
16
  import { planIsEmpty, planMigrate } from "../migrations.js";
17
17
  import { loadCollections } from "./load-collections.js";
18
+ import { loadConfig } from "./load-config.js";
19
+ import { siteOptions } from "../bind.js";
20
+ import { documentStamp } from "../document-stamp.js";
21
+ import { SupabasePublishedStore } from "../supabase-published-store.js";
18
22
  export const DEFAULT_COLLECTIONS = "smoodly/collections.ts";
19
23
  /** The Astro starter keeps the setup under src/ (plan 2026-10-06 astro adapter 3); the default follows it when the Next path is absent. */
20
24
  export function collectionsFile(cwd, given, exists = existsSync) {
@@ -47,6 +51,16 @@ export function describePlan(plan, check) {
47
51
  }
48
52
  return lines;
49
53
  }
54
+ /** The delivery line of `--check` (DESIGN §3, the document stamp): a count with the one command to run, never a verdict. */
55
+ export const deliveryLine = (d) => d.behind === 0
56
+ ? `Delivery: every published document is current (${d.total}).`
57
+ : `Delivery: ${d.behind} of ${d.total} documents are behind the current stamp — run npx smoodly rebuild.`;
58
+ async function countDelivery(env, cwd) {
59
+ const config = await loadConfig(cwd);
60
+ const stamp = documentStamp(siteOptions(config, () => { throw new Error("smoodly: the stamp reads no store"); }));
61
+ const db = createSmoodlyClient(resolveSmoodlyEnv(env));
62
+ return new SupabasePublishedStore(db, config.registry.collections ?? []).countBehind(stamp);
63
+ }
50
64
  /** Errors that are a verdict on the env itself, not on the network: they fail the build. */
51
65
  const ENV_VERDICT = /SMOODLY_(SECRET|PUBLISHABLE)_KEY|SMOODLY_URL "/;
52
66
  /** The build's key check (decided 2026-10-02): a key the site would be refused with fails the
@@ -72,8 +86,15 @@ async function checkKey(out, cwd, deps) {
72
86
  out(`Key not checked: ${message}`);
73
87
  return 0;
74
88
  }
75
- if ("version" in read)
89
+ if ("version" in read) {
76
90
  out(keyChecked(read.version));
91
+ try {
92
+ out(deliveryLine(await (deps.delivery ?? countDelivery)(env, cwd)));
93
+ }
94
+ catch (e) {
95
+ out(`Delivery: not checked: ${e instanceof Error ? e.message : String(e)}`);
96
+ }
97
+ }
77
98
  else if (read.missing === "key") {
78
99
  out(secretKeyRefusedMessage);
79
100
  return 1;
@@ -45,10 +45,10 @@ export { readSchemaVersion } from "../schema-read.js";
45
45
  * the site built from the config as the app's glue builds them, then every page and entry. */
46
46
  export async function rebuildSpace(db, config) {
47
47
  const { pages, entries, assets, published } = createSupabaseStores(db, config);
48
- const { projector, cacheKey } = createSmoodlySite(siteOptions(config, () => ({ pages, entries, assets, published })));
48
+ const { projector, stamp } = createSmoodlySite(siteOptions(config, () => ({ pages, entries, assets, published })));
49
49
  const names = [...(config.registry.collections ?? []), ...(config.registry.globals ?? [])].map((c) => c.name);
50
- // The model fingerprint every row now carries: what a rebuild after a deploy wrote.
51
- return { ...(await projectAll({ stores: { pages, entries }, projector, names })), model: cacheKey };
50
+ // The document stamp every row now carries, recorded as the space's current one (DESIGN §3).
51
+ return { ...(await projectAll({ stores: { pages, entries }, projector, names })), model: stamp };
52
52
  }
53
53
  /** The line a rebuild ends with. */
54
54
  export const rebuiltMessage = (counts) => `Rebuilt the delivery tables: ${counts.pages} ${counts.pages === 1 ? "page" : "pages"}, ${counts.entries} ${counts.entries === 1 ? "entry" : "entries"}.`;
@@ -0,0 +1,7 @@
1
+ import type { SiteOptions } from "./site.ts";
2
+ /** Bumped by hand when the assembler's output changes shape; the upgrading guide names each bump. */
3
+ export declare const DOCUMENT_FORMAT = 1;
4
+ /** The parts of the registry that reach a document: what `cacheKey` hashes, minus the presentation. */
5
+ export declare function documentModel(options: SiteOptions): unknown;
6
+ /** `<format>:<model hash>` — what a delivery row's `model` column holds. */
7
+ export declare function documentStamp(options: SiteOptions): string;
@@ -0,0 +1,48 @@
1
+ // The stamp a delivery row carries (DESIGN §3, the document stamp, 2026-10-08): which document format and
2
+ // which content model built it. Not the cache key: the package version is in that one, not here, because a
3
+ // release that leaves documents alone must leave them alone.
4
+ import { modelKey } from "./model-key.js";
5
+ import { schemaOf } from "./registration.js";
6
+ /** Bumped by hand when the assembler's output changes shape; the upgrading guide names each bump. */
7
+ export const DOCUMENT_FORMAT = 1;
8
+ /** Schema keys that reach the admin only: a change to one leaves every document as it was. */
9
+ const PRESENTATION = new Set(["title", "description", "sample", "icon", "preview", "group", "admin"]);
10
+ /** A field descriptor without the parts that reach the admin only (a hidden condition decides what the editor shows). */
11
+ function fieldModel(field) {
12
+ if (!field || typeof field !== "object")
13
+ return field;
14
+ const { hidden: _hidden, ...rest } = field;
15
+ return Object.fromEntries(Object.entries(rest).map(([k, v]) => [k, k === "fields" ? fieldsModel(v) : k === "of" ? fieldModel(v) : v]));
16
+ }
17
+ function fieldsModel(fields) {
18
+ if (!fields || typeof fields !== "object")
19
+ return fields;
20
+ return Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, fieldModel(v)]));
21
+ }
22
+ /** A schema without its presentation. */
23
+ function schemaModel(schema) {
24
+ if (!schema || typeof schema !== "object")
25
+ return schema;
26
+ return Object.fromEntries(Object.entries(schema)
27
+ .filter(([k]) => !PRESENTATION.has(k))
28
+ .map(([k, v]) => [k, k === "fields" || k === "settings" ? fieldsModel(v) : v]));
29
+ }
30
+ /** The parts of the registry that reach a document: what `cacheKey` hashes, minus the presentation. */
31
+ export function documentModel(options) {
32
+ return {
33
+ collections: options.collections.map(schemaModel),
34
+ // A global whole, its presentation dropped: its settings' defaults reach a visual item's document.
35
+ globals: (options.globals ?? []).map(schemaModel),
36
+ sections: options.sections.map((x) => schemaModel(schemaOf(x))),
37
+ elements: (options.elements ?? []).map((x) => schemaModel(schemaOf(x))),
38
+ pages: options.pages.map((x) => schemaModel(schemaOf(x))),
39
+ locales: options.locales,
40
+ homePageSlug: options.homePageSlug,
41
+ styles: options.styles,
42
+ custom: options.custom,
43
+ };
44
+ }
45
+ /** `<format>:<model hash>` — what a delivery row's `model` column holds. */
46
+ export function documentStamp(options) {
47
+ return `${DOCUMENT_FORMAT}:${modelKey(documentModel(options))}`;
48
+ }
package/dist/project.d.ts CHANGED
@@ -9,9 +9,12 @@ import type { SiteEntry, SitePage } from "./site.ts";
9
9
  import type { PageStore } from "./store.ts";
10
10
  /** The documents one projection writes before the rest are left to heal on read (§2). */
11
11
  export declare const PROJECTION_CAP = 100;
12
- /** `cap` overrides the projector's own for one call: a rebuild passes Infinity, a test a small number. */
12
+ /** `cap` overrides the projector's own for one call: a rebuild passes Infinity, a test a small number. `write`
13
+ * false (DESIGN §3, the document stamp): the rows are built but nothing is written, removed or marked — a reader
14
+ * whose stamp the space did not record. Default true. */
13
15
  export type ProjectOptions = {
14
16
  cap?: number;
17
+ write?: boolean;
15
18
  };
16
19
  export type Projector = {
17
20
  /** Writes the delivery rows of these pages in every supported locale (a row per visible locale, removed where not visible). Returns the keys it could not fit under the cap, already marked stale. */
@@ -26,9 +29,22 @@ export type Projector = {
26
29
  healGlobal<V>(item: GlobalSchema<V>, locale: string, options?: HealOptions): Promise<V | null>;
27
30
  /** Flags rows to rebuild on their next read: what an op does with the targets of a projection that threw. */
28
31
  markStale(keys: PublishedKey[]): Promise<void>;
32
+ /** Records this projector's stamp as the space's current one (DESIGN §3): what a rebuild ends with. */
33
+ record(): Promise<void>;
34
+ /** The delivery tables against this stamp: how many rows are behind it, and which stamp the space recorded. */
35
+ delivery(): Promise<DeliveryStatus>;
29
36
  };
37
+ /** What Content health and `migrate --check` show of the delivery tables (DESIGN §3, the document stamp). */
38
+ export type DeliveryStatus = {
39
+ total: number;
40
+ behind: number;
41
+ recorded: string | null;
42
+ stamp: string;
43
+ };
44
+ /** `write` false: serve the assembled document, write and remove nothing (a reader at another stamp than the recorded one). */
30
45
  export type HealOptions = {
31
46
  seen?: boolean;
47
+ write?: boolean;
32
48
  };
33
49
  export type ProjectorDeps = {
34
50
  assembler: Assembler;
@@ -43,7 +59,7 @@ export type ProjectorDeps = {
43
59
  sections: SectionLike[];
44
60
  elements?: SectionLike[];
45
61
  locales: LocaleSet;
46
- /** The fingerprint a row is current against: the site's cacheKey (§4). */
62
+ /** The stamp a row is current against: the site's document stamp (§4; DESIGN §3). */
47
63
  model: string;
48
64
  cap?: number;
49
65
  };
@@ -60,7 +76,7 @@ export type ProjectAllDeps = {
60
76
  cap?: number;
61
77
  };
62
78
  /** The rebuild (spec 2026-10-07 delivery model §2): every page and entry of the space projected, in batches of
63
- * `cap` ids, none left stale. Import ends with it, and `smoodly rebuild` is it. The counts are the pages and the
79
+ * `cap` ids, none left stale, and the stamp recorded as the space's current one. Import ends with it, and `smoodly rebuild` is it. The counts are the pages and the
64
80
  * entries (globals included) published in some locale; `stale` is what a batch could not write, 0 by design. */
65
81
  export declare function projectAll(deps: ProjectAllDeps): Promise<{
66
82
  pages: number;
package/dist/project.js CHANGED
@@ -89,7 +89,7 @@ export function createProjector(deps) {
89
89
  };
90
90
  }
91
91
  /** Builds rows for each (id, locale) until the cap; the rest are marked stale and returned. */
92
- async function project(cap, kind, targets, idOf, build, put) {
92
+ async function project(cap, kind, targets, idOf, build, put, write) {
93
93
  const { published } = deps.stores();
94
94
  const rows = [];
95
95
  const gone = [];
@@ -107,6 +107,8 @@ export function createProjector(deps) {
107
107
  gone.push({ kind, id: idOf(t), locale });
108
108
  }
109
109
  }
110
+ if (!write)
111
+ return over;
110
112
  if (rows.length > 0)
111
113
  await put(rows);
112
114
  if (gone.length > 0)
@@ -117,14 +119,15 @@ export function createProjector(deps) {
117
119
  }
118
120
  return {
119
121
  async pages(ids, options) {
122
+ const write = options?.write !== false;
120
123
  const all = await deps.stores().pages.listPages();
121
124
  const a = assembler.batch({ pages: all });
122
125
  const byId = new Map(all.map((p) => [p.id, p]));
123
126
  // A page no longer in the tree has no row in any locale.
124
127
  const missing = ids.filter((id) => !byId.has(id));
125
- if (missing.length > 0)
128
+ if (write && missing.length > 0)
126
129
  await deps.stores().published.remove(missing.map((id) => ({ kind: "page", id })));
127
- return project(options?.cap ?? cap, "page", ids.filter((id) => byId.has(id)).map((id) => byId.get(id)), (p) => p.id, (p, locale) => pageRow(a, p, all, locale), (rows) => deps.stores().published.putPages(rows));
130
+ return project(options?.cap ?? cap, "page", ids.filter((id) => byId.has(id)).map((id) => byId.get(id)), (p) => p.id, (p, locale) => pageRow(a, p, all, locale), (rows) => deps.stores().published.putPages(rows), write);
128
131
  },
129
132
  async entries(ids, options) {
130
133
  const a = assembler.batch();
@@ -137,9 +140,10 @@ export function createProjector(deps) {
137
140
  else
138
141
  missing.push({ kind: "entry", id });
139
142
  }
140
- if (missing.length > 0)
143
+ const write = options?.write !== false;
144
+ if (write && missing.length > 0)
141
145
  await deps.stores().published.remove(missing);
142
- return project(options?.cap ?? cap, "entry", found, (h) => h.record.id, (h, locale) => entryRow(a, h.schema, h.record, locale), (rows) => deps.stores().published.putEntries(rows));
146
+ return project(options?.cap ?? cap, "entry", found, (h) => h.record.id, (h, locale) => entryRow(a, h.schema, h.record, locale), (rows) => deps.stores().published.putEntries(rows), write);
143
147
  },
144
148
  async healPage(id, locale, options) {
145
149
  const { pages, published } = deps.stores();
@@ -149,24 +153,28 @@ export function createProjector(deps) {
149
153
  const visible = row !== undefined && (record.template === null || row.status === "published");
150
154
  const all = visible ? await pages.listPages() : [];
151
155
  const projected = visible ? await pageRow(assembler.batch({ pages: all }), record, all, locale) : null;
156
+ const write = options?.write !== false;
152
157
  if (!projected) {
153
- if (options?.seen !== false)
158
+ if (write && options?.seen !== false)
154
159
  await bestEffort(`removing page ${id} (${locale})`, () => published.remove([{ kind: "page", id, locale }]));
155
160
  return null;
156
161
  }
157
- await bestEffort(`writing page ${id} (${locale})`, () => published.putPages([projected]));
162
+ if (write)
163
+ await bestEffort(`writing page ${id} (${locale})`, () => published.putPages([projected]));
158
164
  return projected.document;
159
165
  },
160
166
  async healEntry(id, locale, options) {
161
167
  const { published } = deps.stores();
162
168
  const hit = await findEntry(id);
163
169
  const row = hit ? await entryRow(assembler.batch(), hit.schema, hit.record, locale) : null;
170
+ const write = options?.write !== false;
164
171
  if (!row) {
165
- if (options?.seen !== false)
172
+ if (write && options?.seen !== false)
166
173
  await bestEffort(`removing entry ${id} (${locale})`, () => published.remove([{ kind: "entry", id, locale }]));
167
174
  return null;
168
175
  }
169
- await bestEffort(`writing entry ${id} (${locale})`, () => published.putEntries([row]));
176
+ if (write)
177
+ await bestEffort(`writing entry ${id} (${locale})`, () => published.putEntries([row]));
170
178
  return row.document;
171
179
  },
172
180
  async healGlobal(item, locale, options) {
@@ -175,19 +183,27 @@ export function createProjector(deps) {
175
183
  if (!record)
176
184
  return null;
177
185
  const row = await entryRow(assembler.batch(), item, record, locale);
186
+ const write = options?.write !== false;
178
187
  if (!row) {
179
- if (options?.seen !== false)
188
+ if (write && options?.seen !== false)
180
189
  await bestEffort(`removing global ${item.name} (${locale})`, () => published.remove([{ kind: "entry", id: record.id, locale }]));
181
190
  return null;
182
191
  }
183
- await bestEffort(`writing global ${item.name} (${locale})`, () => published.putEntries([row]));
192
+ if (write)
193
+ await bestEffort(`writing global ${item.name} (${locale})`, () => published.putEntries([row]));
184
194
  return row.document;
185
195
  },
186
196
  markStale: (keys) => deps.stores().published.markStale(keys),
197
+ record: () => deps.stores().published.recordModel(model),
198
+ async delivery() {
199
+ const { published } = deps.stores();
200
+ const [counts, recorded] = await Promise.all([published.countBehind(model), published.recordedModel()]);
201
+ return { ...counts, recorded, stamp: model };
202
+ },
187
203
  };
188
204
  }
189
205
  /** The rebuild (spec 2026-10-07 delivery model §2): every page and entry of the space projected, in batches of
190
- * `cap` ids, none left stale. Import ends with it, and `smoodly rebuild` is it. The counts are the pages and the
206
+ * `cap` ids, none left stale, and the stamp recorded as the space's current one. Import ends with it, and `smoodly rebuild` is it. The counts are the pages and the
191
207
  * entries (globals included) published in some locale; `stale` is what a batch could not write, 0 by design. */
192
208
  export async function projectAll(deps) {
193
209
  const size = deps.cap ?? PROJECTION_CAP;
@@ -202,6 +218,8 @@ export async function projectAll(deps) {
202
218
  for (let i = 0; i < entries.length; i += size) {
203
219
  stale += (await deps.projector.entries(entries.slice(i, i + size).map((e) => e.id), whole)).length;
204
220
  }
221
+ // Every row now carries this stamp: it is the space's current one, and this deployment the writer of record (DESIGN §3).
222
+ await deps.projector.record();
205
223
  return {
206
224
  pages: pages.filter((p) => p.template !== null && published(p)).length,
207
225
  entries: entries.filter(published).length,
@@ -15,6 +15,7 @@ export type PublishedPageRow = {
15
15
  linkFields: Record<string, unknown>;
16
16
  document: SitePage | null;
17
17
  tags: string[];
18
+ /** The document stamp that built the row (document-stamp.ts); the column name predates the name. */
18
19
  model: string;
19
20
  stale: boolean;
20
21
  publishedAt: number;
@@ -34,6 +35,7 @@ export type PublishedEntryRow = {
34
35
  fields: Record<string, unknown>;
35
36
  document: SiteEntry | unknown;
36
37
  tags: string[];
38
+ /** The document stamp that built the row (document-stamp.ts); the column name predates the name. */
37
39
  model: string;
38
40
  stale: boolean;
39
41
  publishedAt: number;
@@ -80,10 +82,27 @@ export interface PublishedStore {
80
82
  /** The light columns of every row in the locale, with the model and stale flag, in tree order (sort asc nulls last, published, id). */
81
83
  tree(locale: string): Promise<PublishedTreeRow[]>;
82
84
  markStale(keys: PublishedKey[]): Promise<void>;
85
+ /** The document stamp this space recorded as current (DESIGN §3): what `rebuild` last wrote, or the first
86
+ * writer's; null until then. Only a reader at this stamp rewrites rows. */
87
+ recordedModel(): Promise<string | null>;
88
+ /** Record `model` as the space's current stamp (upsert by space). */
89
+ recordModel(model: string): Promise<void>;
90
+ /** Rows of both tables, and how many of them carry a stamp other than `model`: what `migrate --check` and Content health show. */
91
+ countBehind(model: string): Promise<{
92
+ behind: number;
93
+ total: number;
94
+ }>;
83
95
  }
84
96
  export declare class MemoryPublishedStore implements PublishedStore {
85
97
  private pages;
86
98
  private entryRows;
99
+ private recorded;
100
+ recordedModel(): Promise<string | null>;
101
+ recordModel(model: string): Promise<void>;
102
+ countBehind(model: string): Promise<{
103
+ behind: number;
104
+ total: number;
105
+ }>;
87
106
  putPages(rows: PublishedPageRow[]): Promise<void>;
88
107
  putEntries(rows: PublishedEntryRow[]): Promise<void>;
89
108
  remove(keys: PublishedKey[]): Promise<void>;
package/dist/published.js CHANGED
@@ -49,6 +49,17 @@ export class MemoryPublishedStore {
49
49
  constructor() {
50
50
  this.pages = new Map();
51
51
  this.entryRows = new Map();
52
+ this.recorded = null;
53
+ }
54
+ async recordedModel() {
55
+ return this.recorded;
56
+ }
57
+ async recordModel(model) {
58
+ this.recorded = model;
59
+ }
60
+ async countBehind(model) {
61
+ const rows = [...this.pages.values(), ...this.entryRows.values()];
62
+ return { behind: rows.filter((r) => r.model !== model).length, total: rows.length };
52
63
  }
53
64
  async putPages(rows) {
54
65
  vacate(this.pages, rows);
@@ -1,7 +1,7 @@
1
1
  /** The minimum schema version the package needs. Moves ONLY in a release
2
2
  * that adds a table, column, function or policy the package reads; a
3
3
  * unit test holds it equal to the highest file in `migrations/`. */
4
- export declare const SCHEMA_VERSION = 9;
4
+ export declare const SCHEMA_VERSION = 10;
5
5
  export declare const SCHEMA_VERSION_KEY = "schema_version";
6
6
  export declare const CORE_FILE_RE: RegExp;
7
7
  export declare const VIEWS_FILE_RE: RegExp;
@@ -7,7 +7,7 @@
7
7
  /** The minimum schema version the package needs. Moves ONLY in a release
8
8
  * that adds a table, column, function or policy the package reads; a
9
9
  * unit test holds it equal to the highest file in `migrations/`. */
10
- export const SCHEMA_VERSION = 9;
10
+ export const SCHEMA_VERSION = 10;
11
11
  export const SCHEMA_VERSION_KEY = "schema_version";
12
12
  export const CORE_FILE_RE = /^(\d{14})_smoodly_v(\d+)\.sql$/;
13
13
  export const VIEWS_FILE_RE = /^(\d{14})_smoodly_views\.sql$/;
package/dist/site.d.ts CHANGED
@@ -122,6 +122,8 @@ export interface SmoodlySite {
122
122
  /** The package version and a hash of the registered model: a part of every cache key, so an upgrade or a
123
123
  * model change never serves a unit cached in the old shape (spec 2026-09-30 tester findings §3). */
124
124
  readonly cacheKey: string;
125
+ /** The document stamp a delivery row carries (DESIGN §3): the format number and the model hash, never the version. */
126
+ readonly stamp: string;
125
127
  /** The resolved styles values the renderer and `getGlobal` hand the wrapper. */
126
128
  readonly styles: StyleValues;
127
129
  /** The custom section's resolved options the renderer hands renderPage. */
package/dist/site.js CHANGED
@@ -3,6 +3,7 @@ import { entrySegment } from "./paths.js";
3
3
  import { lastPart } from "./addresses.js";
4
4
  import { withDefaults } from "./field-defaults.js";
5
5
  import { modelKey } from "./model-key.js";
6
+ import { documentStamp } from "./document-stamp.js";
6
7
  import { SMOODLY_VERSION } from "./version.js";
7
8
  import { toEntryItem, toPageItem } from "./read-shapes.js";
8
9
  import { pageSeo } from "./seo.js";
@@ -33,30 +34,63 @@ export function createSmoodlySite(options) {
33
34
  const cacheKey = `${SMOODLY_VERSION}:${modelKey({
34
35
  // Whole schemas: a collection's order, tree or title field changes a read as its fields do (functions drop out in the hash).
35
36
  collections: options.collections,
36
- globals: (options.globals ?? []).map((g) => ({ name: g.name, fields: g.fields, section: g.section })),
37
+ globals: (options.globals ?? []).map((g) => ({ name: g.name, fields: g.fields, settings: g.settings, section: g.section })),
37
38
  sections: options.sections.map((x) => ("schema" in x ? x.schema : x)),
38
39
  elements: (options.elements ?? []).map((x) => ("schema" in x ? x.schema : x)),
39
40
  pages: pageSchemas,
40
41
  locales, homePageSlug, styles, custom,
41
42
  })}`;
43
+ // What a delivery row is stamped with (DESIGN §3, the document stamp): the document format and the schema parts
44
+ // that reach a document, never the package version, so a release leaves current documents current.
45
+ const stamp = documentStamp(options);
42
46
  const localeOf = (o) => o?.locale ?? locales.default;
43
47
  const published = () => options.stores().published;
44
48
  const projector = createProjector({
45
49
  assembler,
46
50
  stores: () => { const s = options.stores(); return { pages: s.pages, entries: s.entries, published: s.published }; },
47
51
  collections: options.collections, globals: options.globals ?? [], sections: options.sections, elements: options.elements,
48
- locales, model: cacheKey,
52
+ locales, model: stamp,
49
53
  });
50
54
  // Delivery (spec 2026-10-07 delivery model §3–§4): outside draft mode a read takes the stored document when it is
51
55
  // current — built by this model and not marked stale — and otherwise assembles that one document, stores it and serves it.
52
- const current = (row) => row.model === cacheKey && !row.stale;
56
+ const current = (row) => row.model === stamp && !row.stale;
57
+ /** One writer of record (DESIGN §3, the document stamp): only the deployment whose stamp the space recorded rewrites
58
+ * rows; the first writer into an empty record records itself; every other deployment serves assembled documents
59
+ * and writes nothing. Read only when a heal is about to write, so a current page stays one call. A "no" is kept for
60
+ * the process (a later rebuild at this stamp is seen by the next process, which costs assembled reads, never wrong
61
+ * ones); a "yes" is read again on each heal, so a rebuild elsewhere stops this writer at its next heal. */
62
+ let yields = false;
63
+ async function mayWrite() {
64
+ if (yields)
65
+ return false;
66
+ // The record is derived like the rows: a read or write of it that fails is logged, and the page is served
67
+ // assembled with nothing written (spec 2026-10-07 §2 Failure), never failed on it.
68
+ try {
69
+ const recorded = await published().recordedModel();
70
+ if (recorded === null) {
71
+ await published().recordModel(stamp);
72
+ return true;
73
+ }
74
+ yields = recorded !== stamp;
75
+ return !yields;
76
+ }
77
+ catch (e) {
78
+ console.error("smoodly: reading the space's recorded document stamp failed; served assembled, nothing written.", e);
79
+ return false;
80
+ }
81
+ }
53
82
  const fresh = (row) => current(row) && row.document != null;
54
83
  const itemOf = (doc) => toEntryItem(doc.entry, doc.entry.fields, { path: doc.path, locale: doc.locale, locales: doc.locales, ...(doc.seo ? { seo: doc.seo } : {}) });
55
84
  /** A page row's answer: its document when current (null for a folder or a hidden node), else the healed one. */
56
- const pageOf = (row, id, locale) => row && current(row) ? Promise.resolve(row.document) : projector.healPage(id, locale, { seen: row !== null });
85
+ const pageOf = async (row, id, locale) => row && current(row) ? row.document : projector.healPage(id, locale, { seen: row !== null, write: await mayWrite() });
57
86
  /** An entry document is served at an address only when its collection has addresses. */
58
87
  const served = (doc) => (doc && doc.path !== null ? doc : null);
59
- const entryOf = async (row, id, locale) => row && fresh(row) ? row.document : projector.healEntry(id, locale, { seen: row !== null });
88
+ const entryOf = async (row, id, locale) => row && fresh(row) ? row.document : projector.healEntry(id, locale, { seen: row !== null, write: await mayWrite() });
89
+ /** An entry document as the projector would write it, from an assembled read (the address-less shape, path from the item). */
90
+ const assembledEntry = (collection, locale) => (read) => ({
91
+ kind: "entry", locale, collection, entry: { ...read.record, fields: read.item.fields },
92
+ path: read.item.path, locales: read.item.locales, ...(read.item.seo ? { seo: read.item.seo } : {}),
93
+ });
60
94
  /** The published documents of a collection's query. Stale rows are rebuilt in one batched projection and the
61
95
  * query read once more (spec §4): a rebuild that finds an entry unpublished removes its row, so it drops out. */
62
96
  async function deliveredEntries(collection, o) {
@@ -70,6 +104,9 @@ export function createSmoodlySite(options) {
70
104
  let rows = await read();
71
105
  const behind = rows.filter((r) => !fresh(r)).map((r) => r.id);
72
106
  if (behind.length > 0) {
107
+ // Not the writer of record: the list assembled now, nothing written (DESIGN §3).
108
+ if (!(await mayWrite()))
109
+ return (await assembler.entries(collection, { ...o, locale, draft: false })).map(assembledEntry(collection, locale));
73
110
  // A failed rebuild never fails the read: the rows as read are served, and the next read tries again.
74
111
  try {
75
112
  await projector.entries(behind, { cap: Infinity });
@@ -96,6 +133,9 @@ export function createSmoodlySite(options) {
96
133
  let rows = await published().tree(locale);
97
134
  const behind = rows.filter((r) => !current(r)).map((r) => r.id);
98
135
  if (behind.length > 0) {
136
+ // Not the writer of record: the tree assembled now, nothing written (DESIGN §3).
137
+ if (!(await mayWrite()))
138
+ return assembler.tree(locale, { draft: false });
99
139
  // One batched projection for every row behind (one tree read in the projector), then one more tree read.
100
140
  // A failed rebuild never fails the read: the rows as read are served, and the next read tries again.
101
141
  try {
@@ -147,7 +187,7 @@ export function createSmoodlySite(options) {
147
187
  return itemOf(row.document);
148
188
  // A miss is a record no projection wrote yet, or a slug: one `get` tells them apart.
149
189
  if (row?.collection === collection || (await options.stores().entries.get(collection, idOrPath))) {
150
- const doc = await projector.healEntry(idOrPath, locale, { seen: row !== null });
190
+ const doc = await projector.healEntry(idOrPath, locale, { seen: row !== null, write: await mayWrite() });
151
191
  if (doc)
152
192
  return itemOf(doc);
153
193
  }
@@ -169,7 +209,7 @@ export function createSmoodlySite(options) {
169
209
  return assembler.global(item, locale, { draft: true });
170
210
  // The item's row by its collection, in one read: a global is a one-record collection.
171
211
  const [row] = await published().entries(item.name, { locale, limit: 1 });
172
- return row && fresh(row) ? row.document : projector.healGlobal(item, locale, { seen: row !== undefined });
212
+ return row && fresh(row) ? row.document : projector.healGlobal(item, locale, { seen: row !== undefined, write: await mayWrite() });
173
213
  },
174
214
  };
175
215
  /** The published read of an address; `code`: the code-owned page answers where the index has no row. */
@@ -182,7 +222,8 @@ export function createSmoodlySite(options) {
182
222
  let healed = null;
183
223
  if (route) {
184
224
  // Stale: rebuilt by id. Unpublished or moved since the row was written, the address is answered as a miss.
185
- healed = route.kind === "page" ? await projector.healPage(route.row.id, locale) : served(await projector.healEntry(route.row.id, locale));
225
+ const write = await mayWrite();
226
+ healed = route.kind === "page" ? await projector.healPage(route.row.id, locale, { write }) : served(await projector.healEntry(route.row.id, locale, { write }));
186
227
  if (healed && healed.path === path)
187
228
  return { hit: healed, rows };
188
229
  }
@@ -225,6 +266,7 @@ export function createSmoodlySite(options) {
225
266
  locales,
226
267
  homePageSlug,
227
268
  cacheKey,
269
+ stamp,
228
270
  styles,
229
271
  custom,
230
272
  locate: (locale, path) => options.stores().pages.resolvePath(locale, path),
@@ -251,7 +293,7 @@ export function createSmoodlySite(options) {
251
293
  // pays the call where a page renders it. A stale row heals once and is written.
252
294
  if (!row)
253
295
  continue;
254
- globals.set(item.name, fresh(row) ? row.document : await projector.healGlobal(item, locale, { seen: true }));
296
+ globals.set(item.name, fresh(row) ? row.document : await projector.healGlobal(item, locale, { seen: true, write: await mayWrite() }));
255
297
  }
256
298
  }
257
299
  return { hit, globals };
@@ -8,8 +8,9 @@ export declare const CONTENT_TABLES: readonly ["pages", "page_locales", "page_ve
8
8
  export type ContentTable = (typeof CONTENT_TABLES)[number];
9
9
  /** The delivery model's derived tables (spec 2026-10-07 §1): policy-covered and
10
10
  * space-stamped like content, but not transferred (export skips them, import
11
- * rebuilds them) and not counted toward the row cap, since they are derived. */
12
- export declare const DELIVERY_TABLES: readonly ["published_pages", "published_entries"];
11
+ * rebuilds them) and not counted toward the row cap, since they are derived.
12
+ * `published_models` (schema v10) holds one row per space: the document stamp the space recorded as current. */
13
+ export declare const DELIVERY_TABLES: readonly ["published_pages", "published_entries", "published_models"];
13
14
  export declare function currentSpaceSQL(): string;
14
15
  /** One permissive policy per action (spec 2026-09-29 pre-install fixes §3):
15
16
  * the write key's insert, update and delete, each alone for its action; its
package/dist/sql-space.js CHANGED
@@ -21,8 +21,9 @@ export const CONTENT_TABLES = [
21
21
  ];
22
22
  /** The delivery model's derived tables (spec 2026-10-07 §1): policy-covered and
23
23
  * space-stamped like content, but not transferred (export skips them, import
24
- * rebuilds them) and not counted toward the row cap, since they are derived. */
25
- export const DELIVERY_TABLES = ["published_pages", "published_entries"];
24
+ * rebuilds them) and not counted toward the row cap, since they are derived.
25
+ * `published_models` (schema v10) holds one row per space: the document stamp the space recorded as current. */
26
+ export const DELIVERY_TABLES = ["published_pages", "published_entries", "published_models"];
26
27
  export function currentSpaceSQL() {
27
28
  return `
28
29
  -- The space a request acts in: the key's claim in Smoodly Cloud, the