smoodly 0.0.46 → 0.0.48

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.
Files changed (64) hide show
  1. package/dist/admin/browser/main.js +2 -0
  2. package/dist/admin/client-ops.js +1 -0
  3. package/dist/admin/delivery-mode.d.ts +5 -0
  4. package/dist/admin/delivery-mode.js +20 -0
  5. package/dist/admin/editor/EditorView.js +3 -0
  6. package/dist/admin/files.generated.js +1 -1
  7. package/dist/admin/handler.d.ts +5 -1
  8. package/dist/admin/handler.js +2 -1
  9. package/dist/admin/health.d.ts +4 -0
  10. package/dist/admin/ops-impl.d.ts +13 -0
  11. package/dist/admin/ops-impl.js +48 -2
  12. package/dist/admin/ops.d.ts +11 -0
  13. package/dist/admin/page-data.d.ts +2 -0
  14. package/dist/admin/shell/AdminApp.js +47 -1
  15. package/dist/admin/shell/ContentHealthPage.js +3 -1
  16. package/dist/admin/shell/EntryForm.js +3 -0
  17. package/dist/admin/shell/GlobalForm.js +5 -1
  18. package/dist/admin/shell/IconRail.d.ts +8 -1
  19. package/dist/admin/shell/IconRail.js +8 -3
  20. package/dist/admin/shell/deploy-tile.d.ts +20 -0
  21. package/dist/admin/shell/deploy-tile.js +35 -0
  22. package/dist/admin/ui/PublishControl.js +3 -1
  23. package/dist/admin/ui/theme.js +12 -0
  24. package/dist/astro/deploy-deps.d.ts +10 -0
  25. package/dist/astro/deploy-deps.js +8 -0
  26. package/dist/astro/index.d.ts +9 -4
  27. package/dist/astro/index.js +75 -15
  28. package/dist/astro/middleware.js +4 -0
  29. package/dist/astro/routes/admin.js +2 -1
  30. package/dist/astro/routes/preview.astro +17 -5
  31. package/dist/astro/routes/static-page.astro +21 -0
  32. package/dist/astro/runtime.d.ts +6 -0
  33. package/dist/astro/runtime.js +4 -1
  34. package/dist/astro/server.d.ts +6 -0
  35. package/dist/astro/server.js +7 -1
  36. package/dist/astro/static.d.ts +36 -0
  37. package/dist/astro/static.js +59 -0
  38. package/dist/cli/load-config.d.ts +3 -0
  39. package/dist/cli/load-config.js +8 -1
  40. package/dist/cli/migrate.d.ts +10 -0
  41. package/dist/cli/migrate.js +22 -1
  42. package/dist/cli/space-io.js +3 -3
  43. package/dist/deploy-state.d.ts +28 -0
  44. package/dist/deploy-state.js +31 -0
  45. package/dist/document-stamp.d.ts +7 -0
  46. package/dist/document-stamp.js +48 -0
  47. package/dist/project.d.ts +22 -3
  48. package/dist/project.js +33 -12
  49. package/dist/published.d.ts +50 -4
  50. package/dist/published.js +46 -7
  51. package/dist/schema-version.d.ts +1 -1
  52. package/dist/schema-version.js +1 -1
  53. package/dist/site.d.ts +2 -0
  54. package/dist/site.js +53 -11
  55. package/dist/sql-space.d.ts +4 -2
  56. package/dist/sql-space.js +4 -2
  57. package/dist/sql.js +20 -1
  58. package/dist/supabase-published-store.d.ts +24 -3
  59. package/dist/supabase-published-store.js +104 -5
  60. package/dist/version.d.ts +1 -1
  61. package/dist/version.js +1 -1
  62. package/migrations/20261008120000_smoodly_v10.sql +47 -0
  63. package/migrations/20261009120000_smoodly_v11.sql +49 -0
  64. package/package.json +1 -1
@@ -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,15 @@ 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;
18
+ /** `false` (a behind rebuild on a read, spec 2026-10-09 static mode §5): an existing row keeps its publishedAt, so
19
+ * the rebuild is not counted as a publish the static site has not got. Default true: a publish, `npx smoodly rebuild`. */
20
+ stamp?: boolean;
15
21
  };
16
22
  export type Projector = {
17
23
  /** 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 +32,22 @@ export type Projector = {
26
32
  healGlobal<V>(item: GlobalSchema<V>, locale: string, options?: HealOptions): Promise<V | null>;
27
33
  /** Flags rows to rebuild on their next read: what an op does with the targets of a projection that threw. */
28
34
  markStale(keys: PublishedKey[]): Promise<void>;
35
+ /** Records this projector's stamp as the space's current one (DESIGN §3): what a rebuild ends with. */
36
+ record(): Promise<void>;
37
+ /** The delivery tables against this stamp: how many rows are behind it, and which stamp the space recorded. */
38
+ delivery(): Promise<DeliveryStatus>;
29
39
  };
40
+ /** What Content health and `migrate --check` show of the delivery tables (DESIGN §3, the document stamp). */
41
+ export type DeliveryStatus = {
42
+ total: number;
43
+ behind: number;
44
+ recorded: string | null;
45
+ stamp: string;
46
+ };
47
+ /** `write` false: serve the assembled document, write and remove nothing (a reader at another stamp than the recorded one). */
30
48
  export type HealOptions = {
31
49
  seen?: boolean;
50
+ write?: boolean;
32
51
  };
33
52
  export type ProjectorDeps = {
34
53
  assembler: Assembler;
@@ -43,7 +62,7 @@ export type ProjectorDeps = {
43
62
  sections: SectionLike[];
44
63
  elements?: SectionLike[];
45
64
  locales: LocaleSet;
46
- /** The fingerprint a row is current against: the site's cacheKey (§4). */
65
+ /** The stamp a row is current against: the site's document stamp (§4; DESIGN §3). */
47
66
  model: string;
48
67
  cap?: number;
49
68
  };
@@ -60,7 +79,7 @@ export type ProjectAllDeps = {
60
79
  cap?: number;
61
80
  };
62
81
  /** 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
82
+ * `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
83
  * entries (globals included) published in some locale; `stale` is what a batch could not write, 0 by design. */
65
84
  export declare function projectAll(deps: ProjectAllDeps): Promise<{
66
85
  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,16 @@ 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
+ const keep = options?.stamp === false;
131
+ 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, { keep }), write);
128
132
  },
129
133
  async entries(ids, options) {
130
134
  const a = assembler.batch();
@@ -137,9 +141,11 @@ export function createProjector(deps) {
137
141
  else
138
142
  missing.push({ kind: "entry", id });
139
143
  }
140
- if (missing.length > 0)
144
+ const write = options?.write !== false;
145
+ if (write && missing.length > 0)
141
146
  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));
147
+ const keep = options?.stamp === false;
148
+ 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, { keep }), write);
143
149
  },
144
150
  async healPage(id, locale, options) {
145
151
  const { pages, published } = deps.stores();
@@ -149,24 +155,29 @@ export function createProjector(deps) {
149
155
  const visible = row !== undefined && (record.template === null || row.status === "published");
150
156
  const all = visible ? await pages.listPages() : [];
151
157
  const projected = visible ? await pageRow(assembler.batch({ pages: all }), record, all, locale) : null;
158
+ const write = options?.write !== false;
152
159
  if (!projected) {
153
- if (options?.seen !== false)
160
+ if (write && options?.seen !== false)
154
161
  await bestEffort(`removing page ${id} (${locale})`, () => published.remove([{ kind: "page", id, locale }]));
155
162
  return null;
156
163
  }
157
- await bestEffort(`writing page ${id} (${locale})`, () => published.putPages([projected]));
164
+ // A heal keeps the row's publishedAt: it rebuilds what a publish wrote, it is not one (spec 2026-10-09 static mode §5).
165
+ if (write)
166
+ await bestEffort(`writing page ${id} (${locale})`, () => published.putPages([projected], { keep: true }));
158
167
  return projected.document;
159
168
  },
160
169
  async healEntry(id, locale, options) {
161
170
  const { published } = deps.stores();
162
171
  const hit = await findEntry(id);
163
172
  const row = hit ? await entryRow(assembler.batch(), hit.schema, hit.record, locale) : null;
173
+ const write = options?.write !== false;
164
174
  if (!row) {
165
- if (options?.seen !== false)
175
+ if (write && options?.seen !== false)
166
176
  await bestEffort(`removing entry ${id} (${locale})`, () => published.remove([{ kind: "entry", id, locale }]));
167
177
  return null;
168
178
  }
169
- await bestEffort(`writing entry ${id} (${locale})`, () => published.putEntries([row]));
179
+ if (write)
180
+ await bestEffort(`writing entry ${id} (${locale})`, () => published.putEntries([row], { keep: true }));
170
181
  return row.document;
171
182
  },
172
183
  async healGlobal(item, locale, options) {
@@ -175,19 +186,27 @@ export function createProjector(deps) {
175
186
  if (!record)
176
187
  return null;
177
188
  const row = await entryRow(assembler.batch(), item, record, locale);
189
+ const write = options?.write !== false;
178
190
  if (!row) {
179
- if (options?.seen !== false)
191
+ if (write && options?.seen !== false)
180
192
  await bestEffort(`removing global ${item.name} (${locale})`, () => published.remove([{ kind: "entry", id: record.id, locale }]));
181
193
  return null;
182
194
  }
183
- await bestEffort(`writing global ${item.name} (${locale})`, () => published.putEntries([row]));
195
+ if (write)
196
+ await bestEffort(`writing global ${item.name} (${locale})`, () => published.putEntries([row], { keep: true }));
184
197
  return row.document;
185
198
  },
186
199
  markStale: (keys) => deps.stores().published.markStale(keys),
200
+ record: () => deps.stores().published.recordModel(model),
201
+ async delivery() {
202
+ const { published } = deps.stores();
203
+ const [counts, recorded] = await Promise.all([published.countBehind(model), published.recordedModel()]);
204
+ return { ...counts, recorded, stamp: model };
205
+ },
187
206
  };
188
207
  }
189
208
  /** 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
209
+ * `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
210
  * entries (globals included) published in some locale; `stale` is what a batch could not write, 0 by design. */
192
211
  export async function projectAll(deps) {
193
212
  const size = deps.cap ?? PROJECTION_CAP;
@@ -202,6 +221,8 @@ export async function projectAll(deps) {
202
221
  for (let i = 0; i < entries.length; i += size) {
203
222
  stale += (await deps.projector.entries(entries.slice(i, i + size).map((e) => e.id), whole)).length;
204
223
  }
224
+ // Every row now carries this stamp: it is the space's current one, and this deployment the writer of record (DESIGN §3).
225
+ await deps.projector.record();
205
226
  return {
206
227
  pages: pages.filter((p) => p.template !== null && published(p)).length,
207
228
  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;
@@ -64,10 +66,15 @@ export type PublishedKey = {
64
66
  id: string;
65
67
  locale?: string;
66
68
  };
69
+ export type PutOptions = {
70
+ keep?: boolean;
71
+ };
67
72
  export interface PublishedStore {
68
73
  /** Upsert by (id, locale). */
69
- putPages(rows: PublishedPageRow[]): Promise<void>;
70
- putEntries(rows: PublishedEntryRow[]): Promise<void>;
74
+ /** `keep` (a heal, a behind rebuild on read): an existing row keeps its publishedAt, a new row is dated now; without it
75
+ * every row is dated now (a publish, `npx smoodly rebuild`). The static build's count reads publishedAt (spec 2026-10-09 §5). */
76
+ putPages(rows: PublishedPageRow[], options?: PutOptions): Promise<void>;
77
+ putEntries(rows: PublishedEntryRow[], options?: PutOptions): Promise<void>;
71
78
  /** Every locale of the id when `locale` is absent. */
72
79
  remove(keys: PublishedKey[]): Promise<void>;
73
80
  /** The one-call lookup: a reachable page or entry at the address, else null. With `globals` (collection names of
@@ -80,13 +87,52 @@ export interface PublishedStore {
80
87
  /** 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
88
  tree(locale: string): Promise<PublishedTreeRow[]>;
82
89
  markStale(keys: PublishedKey[]): Promise<void>;
90
+ /** The document stamp this space recorded as current (DESIGN §3): what `rebuild` last wrote, or the first
91
+ * writer's; null until then. Only a reader at this stamp rewrites rows. */
92
+ recordedModel(): Promise<string | null>;
93
+ /** Record `model` as the space's current stamp (upsert by space). */
94
+ recordModel(model: string): Promise<void>;
95
+ /** Rows of both tables, and how many of them carry a stamp other than `model`: what `migrate --check` and Content health show. */
96
+ countBehind(model: string): Promise<{
97
+ behind: number;
98
+ total: number;
99
+ }>;
100
+ /** The latest published_at over the space's pages and entries (ms); null when nothing is published. The static build's watermark (spec 2026-10-09 static mode §5). */
101
+ newestPublishedAt(): Promise<number | null>;
102
+ /** Rows published strictly after `since` (ms), pages plus entries: the Deploy tile's "changes waiting". */
103
+ countPublishedAfter(since: number): Promise<number>;
104
+ /** The space's last Deploy press (schema v11, space_deploys); null until the first press. */
105
+ deployRequest(): Promise<{
106
+ at: number;
107
+ by: string | null;
108
+ } | null>;
109
+ requestDeploy(by: string | null, at?: number): Promise<void>;
110
+ /** When a row last went (an unpublish, a delete): a change the count of newer rows cannot see (same row, `removed_at`). */
111
+ lastRemovalAt(): Promise<number | null>;
83
112
  }
84
113
  export declare class MemoryPublishedStore implements PublishedStore {
85
114
  private pages;
86
115
  private entryRows;
87
- putPages(rows: PublishedPageRow[]): Promise<void>;
88
- putEntries(rows: PublishedEntryRow[]): Promise<void>;
116
+ private recorded;
117
+ recordedModel(): Promise<string | null>;
118
+ recordModel(model: string): Promise<void>;
119
+ private deploy;
120
+ newestPublishedAt(): Promise<number | null>;
121
+ countPublishedAfter(since: number): Promise<number>;
122
+ deployRequest(): Promise<{
123
+ at: number;
124
+ by: string | null;
125
+ } | null>;
126
+ requestDeploy(by: string | null, at?: number): Promise<void>;
127
+ countBehind(model: string): Promise<{
128
+ behind: number;
129
+ total: number;
130
+ }>;
131
+ putPages(rows: PublishedPageRow[], options?: PutOptions): Promise<void>;
132
+ putEntries(rows: PublishedEntryRow[], options?: PutOptions): Promise<void>;
133
+ private removedAt;
89
134
  remove(keys: PublishedKey[]): Promise<void>;
135
+ lastRemovalAt(): Promise<number | null>;
90
136
  byAddress(locale: string, path: string, globals?: readonly string[]): Promise<PublishedRoute | null>;
91
137
  page(id: string, locale: string): Promise<PublishedPageRow | null>;
92
138
  entry(id: string, locale: string): Promise<PublishedEntryRow | null>;
package/dist/published.js CHANGED
@@ -49,24 +49,63 @@ export class MemoryPublishedStore {
49
49
  constructor() {
50
50
  this.pages = new Map();
51
51
  this.entryRows = new Map();
52
+ this.recorded = null;
53
+ this.deploy = null;
54
+ this.removedAt = null;
52
55
  }
53
- async putPages(rows) {
56
+ async recordedModel() {
57
+ return this.recorded;
58
+ }
59
+ async recordModel(model) {
60
+ this.recorded = model;
61
+ }
62
+ async newestPublishedAt() {
63
+ const all = [...this.pages.values(), ...this.entryRows.values()].map((r) => r.publishedAt);
64
+ return all.length === 0 ? null : Math.max(...all);
65
+ }
66
+ async countPublishedAfter(since) {
67
+ return [...this.pages.values(), ...this.entryRows.values()].filter((r) => r.publishedAt > since).length;
68
+ }
69
+ async deployRequest() {
70
+ return this.deploy;
71
+ }
72
+ async requestDeploy(by, at = Date.now()) {
73
+ this.deploy = { at, by };
74
+ }
75
+ async countBehind(model) {
76
+ const rows = [...this.pages.values(), ...this.entryRows.values()];
77
+ return { behind: rows.filter((r) => r.model !== model).length, total: rows.length };
78
+ }
79
+ async putPages(rows, options) {
54
80
  vacate(this.pages, rows);
55
- for (const row of rows)
56
- this.pages.set(keyOf(row.id, row.locale), structuredClone(row));
81
+ for (const row of rows) {
82
+ const kept = options?.keep ? this.pages.get(keyOf(row.id, row.locale))?.publishedAt : undefined;
83
+ this.pages.set(keyOf(row.id, row.locale), structuredClone(kept === undefined ? row : { ...row, publishedAt: kept }));
84
+ }
57
85
  }
58
- async putEntries(rows) {
86
+ async putEntries(rows, options) {
59
87
  vacate(this.entryRows, rows);
60
- for (const row of rows)
61
- this.entryRows.set(keyOf(row.id, row.locale), structuredClone(row));
88
+ for (const row of rows) {
89
+ const kept = options?.keep ? this.entryRows.get(keyOf(row.id, row.locale))?.publishedAt : undefined;
90
+ this.entryRows.set(keyOf(row.id, row.locale), structuredClone(kept === undefined ? row : { ...row, publishedAt: kept }));
91
+ }
62
92
  }
63
93
  async remove(keys) {
94
+ let deleted = 0;
64
95
  for (const key of keys) {
65
96
  const map = key.kind === "page" ? this.pages : this.entryRows;
66
97
  for (const [k, row] of map)
67
- if (row.id === key.id && (key.locale === undefined || row.locale === key.locale))
98
+ if (row.id === key.id && (key.locale === undefined || row.locale === key.locale)) {
68
99
  map.delete(k);
100
+ deleted += 1;
101
+ }
69
102
  }
103
+ // Only a row that went is a change (a key with no row is not): the Deploy tile counts it (spec 2026-10-09 §5).
104
+ if (deleted > 0)
105
+ this.removedAt = Date.now();
106
+ }
107
+ async lastRemovalAt() {
108
+ return this.removedAt;
70
109
  }
71
110
  async byAddress(locale, path, globals) {
72
111
  const found = () => {
@@ -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 = 11;
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 = 11;
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,9 +104,12 @@ 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
- await projector.entries(behind, { cap: Infinity });
112
+ await projector.entries(behind, { cap: Infinity, stamp: false });
76
113
  rows = await read();
77
114
  }
78
115
  catch (e) {
@@ -96,10 +133,13 @@ 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 {
102
- await projector.pages(behind, { cap: Infinity });
142
+ await projector.pages(behind, { cap: Infinity, stamp: false });
103
143
  rows = await published().tree(locale);
104
144
  }
105
145
  catch (e) {
@@ -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,10 @@ 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
+ * `space_deploys` (schema v11) one row per space: the last Deploy press of static mode. */
14
+ export declare const DELIVERY_TABLES: readonly ["published_pages", "published_entries", "published_models", "space_deploys"];
13
15
  export declare function currentSpaceSQL(): string;
14
16
  /** One permissive policy per action (spec 2026-09-29 pre-install fixes §3):
15
17
  * the write key's insert, update and delete, each alone for its action; its
package/dist/sql-space.js CHANGED
@@ -21,8 +21,10 @@ 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
+ * `space_deploys` (schema v11) one row per space: the last Deploy press of static mode. */
27
+ export const DELIVERY_TABLES = ["published_pages", "published_entries", "published_models", "space_deploys"];
26
28
  export function currentSpaceSQL() {
27
29
  return `
28
30
  -- The space a request acts in: the key's claim in Smoodly Cloud, the
package/dist/sql.js CHANGED
@@ -9,7 +9,7 @@
9
9
  // locale (spec 2026-09-06 §7); a versioned collection reads its
10
10
  // fields from the published snapshot.
11
11
  import { SEO_KEY } from "./seo.js";
12
- import { currentSpaceSQL, grantsSQL, SPACE_COL, spaceLayerSQL } from "./sql-space.js";
12
+ import { currentSpaceSQL, grantsSQL, SPACE_COL, spaceLayerSQL, DEFAULT_SPACE } from "./sql-space.js";
13
13
  const snake = (s) => s.replace(/[A-Z]/g, (c) => "_" + c.toLowerCase());
14
14
  const q = (s) => `"${s}"`;
15
15
  // Casts are fine in views.
@@ -173,8 +173,27 @@ create unique index if not exists "published_entries_address_idx"
173
173
  create index if not exists "published_entries_fields_idx"
174
174
  on "published_entries" using gin ("fields");
175
175
 
176
+ -- The document stamp a space recorded as current (DESIGN §3, schema v10): set by \`npx smoodly rebuild\`
177
+ -- and by the first writer into an empty record; only a reader at this stamp rewrites the rows above.
178
+ create table if not exists "published_models" (
179
+ "space_id" uuid primary key default ${DEFAULT_SPACE},
180
+ "model" text not null,
181
+ "recorded_at" timestamptz not null default now()
182
+ );
183
+
184
+ -- The last Deploy press of a space (spec 2026-10-09 static mode §7, schema v11): who pressed and when;
185
+ -- the admin compares it with the build marker the live site serves.
186
+ create table if not exists "space_deploys" (
187
+ "space_id" uuid primary key default ${DEFAULT_SPACE},
188
+ "requested_at" timestamptz,
189
+ "requested_by" text,
190
+ "removed_at" timestamptz
191
+ );
192
+
176
193
  alter table "published_pages" enable row level security;
177
194
  alter table "published_entries" enable row level security;
195
+ alter table "published_models" enable row level security;
196
+ alter table "space_deploys" enable row level security;
178
197
 
179
198
  -- smoodly_list_entries without the draft branch, over the delivery table (spec 2026-10-07 §3):
180
199
  -- \`where\` and \`order\` read the raw published snapshot, the row answers with its document.