@velastack/cms 0.1.0 → 0.2.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.
Files changed (95) hide show
  1. package/README.md +1 -1
  2. package/dist/backend/auth/editors.d.ts +51 -0
  3. package/dist/backend/auth/editors.js +157 -0
  4. package/dist/backend/auth/local-editors.d.ts +22 -0
  5. package/dist/backend/auth/local-editors.js +44 -0
  6. package/dist/backend/auth/scrypt.d.ts +20 -0
  7. package/dist/backend/auth/scrypt.js +80 -0
  8. package/dist/backend/factory.d.ts +25 -0
  9. package/dist/backend/factory.js +202 -0
  10. package/dist/backend/http.d.ts +34 -0
  11. package/dist/backend/http.js +79 -0
  12. package/dist/backend/index.d.ts +45 -0
  13. package/dist/backend/index.js +39 -0
  14. package/dist/backend/router.d.ts +52 -0
  15. package/dist/backend/router.js +57 -0
  16. package/dist/backend/routes/context.d.ts +46 -0
  17. package/dist/backend/routes/context.js +30 -0
  18. package/dist/backend/routes/docs.d.ts +2 -0
  19. package/dist/backend/routes/docs.js +150 -0
  20. package/dist/backend/routes/login.d.ts +19 -0
  21. package/dist/backend/routes/login.js +155 -0
  22. package/dist/backend/routes/media.d.ts +22 -0
  23. package/dist/backend/routes/media.js +119 -0
  24. package/dist/backend/routes/pages.d.ts +19 -0
  25. package/dist/backend/routes/pages.js +221 -0
  26. package/dist/backend/routes/release.d.ts +10 -0
  27. package/dist/backend/routes/release.js +147 -0
  28. package/dist/backend/routes/site.d.ts +2 -0
  29. package/dist/backend/routes/site.js +40 -0
  30. package/dist/backend/routes/table.d.ts +11 -0
  31. package/dist/backend/routes/table.js +72 -0
  32. package/dist/backend/routes/test-reset.d.ts +15 -0
  33. package/dist/backend/routes/test-reset.js +112 -0
  34. package/dist/backend/routes/user.d.ts +14 -0
  35. package/dist/backend/routes/user.js +21 -0
  36. package/dist/backend/storage.d.ts +18 -0
  37. package/dist/backend/storage.js +45 -0
  38. package/dist/backend/store/cache.d.ts +34 -0
  39. package/dist/backend/store/cache.js +38 -0
  40. package/dist/backend/store/db.d.ts +10 -0
  41. package/dist/backend/store/db.js +29 -0
  42. package/dist/backend/store/migrate.d.ts +10 -0
  43. package/dist/backend/store/migrate.js +30 -0
  44. package/dist/backend/store/migrations/0001-init.d.ts +1 -0
  45. package/dist/backend/store/migrations/0001-init.js +129 -0
  46. package/dist/backend/store/migrations/0002-site.d.ts +1 -0
  47. package/dist/backend/store/migrations/0002-site.js +86 -0
  48. package/dist/backend/store/migrations/0003-editors.d.ts +1 -0
  49. package/dist/backend/store/migrations/0003-editors.js +44 -0
  50. package/dist/backend/store/migrations/index.d.ts +15 -0
  51. package/dist/backend/store/migrations/index.js +18 -0
  52. package/dist/backend/store/queries.d.ts +67 -0
  53. package/dist/backend/store/queries.js +1207 -0
  54. package/dist/backend/store/sqlite.d.ts +22 -0
  55. package/dist/backend/store/sqlite.js +29 -0
  56. package/dist/backend/testing/fixtures.d.ts +39 -0
  57. package/dist/backend/testing/fixtures.js +61 -0
  58. package/dist/backend/testing/harness.d.ts +35 -0
  59. package/dist/backend/testing/harness.js +149 -0
  60. package/dist/backend/types.d.ts +102 -0
  61. package/dist/cms.d.ts +3 -3
  62. package/dist/cms.js +1 -1
  63. package/dist/components/admin-bar/publish-dialog.svelte +1 -1
  64. package/dist/components/cms/cms-image-editable.svelte +1 -1
  65. package/dist/components/cms/cms-store.svelte.d.ts +3 -68
  66. package/dist/components/cms/cms-store.svelte.js +25 -10
  67. package/dist/components/cms/locale-merge.d.ts +1 -1
  68. package/dist/components/cms/locale-merge.js +1 -1
  69. package/dist/components/cms/scope.d.ts +1 -1
  70. package/dist/core/page-entry.d.ts +43 -0
  71. package/dist/core/page-entry.js +21 -0
  72. package/dist/{components/cms → core}/path.d.ts +13 -0
  73. package/dist/{components/cms → core}/path.js +36 -0
  74. package/dist/core/wire.d.ts +182 -0
  75. package/dist/core/wire.js +1 -0
  76. package/dist/{srv → server}/api-adapter.js +2 -2
  77. package/dist/{srv → server}/load-cms.js +1 -1
  78. package/dist/{srv → server}/mock-adapter.d.ts +3 -24
  79. package/dist/{srv → server}/mock-adapter.js +5 -22
  80. package/dist/{srv → server}/types.d.ts +2 -6
  81. package/dist/server/types.js +1 -0
  82. package/package.json +13 -4
  83. package/plugin/dist/index.js +7 -0
  84. package/plugin/dist/index.js.map +1 -1
  85. package/plugin/dist/media.d.ts +16 -7
  86. package/plugin/dist/media.js +23 -10
  87. package/plugin/dist/media.js.map +1 -1
  88. /package/dist/{srv → backend}/types.js +0 -0
  89. /package/dist/{srv → server}/README.md +0 -0
  90. /package/dist/{srv → server}/api-adapter.d.ts +0 -0
  91. /package/dist/{srv → server}/cms.d.ts +0 -0
  92. /package/dist/{srv → server}/cms.js +0 -0
  93. /package/dist/{srv → server}/index.d.ts +0 -0
  94. /package/dist/{srv → server}/index.js +0 -0
  95. /package/dist/{srv → server}/load-cms.d.ts +0 -0
@@ -0,0 +1,1207 @@
1
+ import { randomBytes, randomUUID } from 'node:crypto';
2
+ import { findPageEntry } from '../../core/page-entry.js';
3
+ import { get, leafPaths, mergeTree, set } from '../../core/path.js';
4
+ /** The locale a request falls back to when it names none. */
5
+ export const DEFAULT_LOCALE = 'en';
6
+ /** An opaque, unguessable key for a release's preview URL. */
7
+ export const generatePreviewKey = () => randomBytes(8).toString('hex');
8
+ /**
9
+ * Every SQL statement and every piece of CMS domain logic, bound to one
10
+ * database connection.
11
+ *
12
+ * This was a module of top-level functions reading a process-wide `getDb()`
13
+ * singleton. Closing over `db` instead is what lets a test file hold its own
14
+ * `':memory:'` database (so the suites run in parallel rather than serialised
15
+ * behind a shared file) and what stops a host from being limited to one CMS per
16
+ * process. The statement helpers below are memoised per instance as a side
17
+ * effect — they previously re-`prepare`d on every single call.
18
+ *
19
+ * The body is otherwise a verbatim move.
20
+ */
21
+ export const createStore = (db) => {
22
+ const nowIso = () => new Date().toISOString();
23
+ /** Stable canonical JSON for a params record. Empty params → '{}'. Used as
24
+ * the partition key for page rows; never exposed to the API. */
25
+ const paramsHash = (params) => {
26
+ const keys = Object.keys(params).sort();
27
+ const out = {};
28
+ for (const k of keys)
29
+ out[k] = params[k];
30
+ return JSON.stringify(out);
31
+ };
32
+ const parseParams = (raw) => {
33
+ if (!raw)
34
+ return {};
35
+ const v = JSON.parse(raw);
36
+ if (!v || typeof v !== 'object' || Array.isArray(v))
37
+ return {};
38
+ return v;
39
+ };
40
+ const parseTree = (raw) => {
41
+ if (!raw)
42
+ return null;
43
+ const v = JSON.parse(raw);
44
+ if (!v || typeof v !== 'object' || Array.isArray(v))
45
+ return null;
46
+ return v;
47
+ };
48
+ /**
49
+ * `priorTree` rows can contain `undefined` leaf values, which JSON.stringify
50
+ * silently drops. The leaf paths matter for revert (undefined → delete the
51
+ * leaf, anything else → overwrite). We round-trip via a sentinel so the leaf
52
+ * keys survive serialization.
53
+ */
54
+ const UNDEFINED_SENTINEL = '__cms_undef__';
55
+ const replaceUndef = (v) => {
56
+ if (v === undefined)
57
+ return UNDEFINED_SENTINEL;
58
+ if (Array.isArray(v))
59
+ return v.map(replaceUndef);
60
+ if (v && typeof v === 'object') {
61
+ const out = {};
62
+ for (const [k, val] of Object.entries(v)) {
63
+ out[k] = replaceUndef(val);
64
+ }
65
+ return out;
66
+ }
67
+ return v;
68
+ };
69
+ const restoreUndef = (v) => {
70
+ if (v === UNDEFINED_SENTINEL)
71
+ return undefined;
72
+ if (Array.isArray(v))
73
+ return v.map(restoreUndef);
74
+ if (v && typeof v === 'object') {
75
+ const out = {};
76
+ for (const [k, val] of Object.entries(v)) {
77
+ out[k] = restoreUndef(val);
78
+ }
79
+ return out;
80
+ }
81
+ return v;
82
+ };
83
+ const serializePriorTree = (tree) => tree === null ? null : JSON.stringify(replaceUndef(tree));
84
+ const parsePriorTree = (raw) => {
85
+ if (!raw)
86
+ return null;
87
+ const v = restoreUndef(JSON.parse(raw));
88
+ if (!v || typeof v !== 'object' || Array.isArray(v))
89
+ return null;
90
+ return v;
91
+ };
92
+ const parseOutcome = (raw) => {
93
+ if (!raw)
94
+ return undefined;
95
+ const v = JSON.parse(raw);
96
+ if (!v || typeof v !== 'object' || Array.isArray(v))
97
+ return undefined;
98
+ const o = v;
99
+ if (o.kind === 'gone')
100
+ return { kind: 'gone' };
101
+ if (o.kind === 'redirect' && typeof o.to === 'string')
102
+ return { kind: 'redirect', to: o.to };
103
+ return undefined;
104
+ };
105
+ const paramsEqual = (a, b) => {
106
+ const aKeys = Object.keys(a);
107
+ if (aKeys.length !== Object.keys(b).length)
108
+ return false;
109
+ for (const k of aKeys)
110
+ if (a[k] !== b[k])
111
+ return false;
112
+ return true;
113
+ };
114
+ /**
115
+ * Capture the values of every leaf path in `incoming` as they appear in
116
+ * `target` right now. Used by publish + revert so the inverse patch tree
117
+ * restores the same shape that the original patch touched.
118
+ */
119
+ const capturePriorTree = (target, incoming) => {
120
+ const prior = {};
121
+ for (const path of leafPaths(incoming)) {
122
+ const v = get(target, path);
123
+ set(prior, path, v === undefined ? undefined : structuredClone(v));
124
+ }
125
+ return prior;
126
+ };
127
+ /** Apply restoration values to a target tree. `undefined` deletes the leaf,
128
+ * other values overwrite. Returns a new tree (does not mutate). */
129
+ const applyRestore = (target, restore) => {
130
+ const out = structuredClone(target);
131
+ for (const path of leafPaths(restore)) {
132
+ const v = get(restore, path);
133
+ if (v === undefined) {
134
+ unsetPath(out, path);
135
+ }
136
+ else {
137
+ set(out, path, v);
138
+ }
139
+ }
140
+ return out;
141
+ };
142
+ const unsetPath = (tree, path) => {
143
+ const segs = path.split('.');
144
+ if (segs.length === 0)
145
+ return;
146
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
147
+ let cur = tree;
148
+ for (let i = 0; i < segs.length - 1; i++) {
149
+ if (cur == null || typeof cur !== 'object')
150
+ return;
151
+ cur = cur[segs[i]];
152
+ }
153
+ if (cur == null || typeof cur !== 'object')
154
+ return;
155
+ delete cur[segs[segs.length - 1]];
156
+ };
157
+ const treeHasAnyLeaf = (tree) => {
158
+ for (const v of Object.values(tree))
159
+ if (v !== undefined)
160
+ return true;
161
+ return false;
162
+ };
163
+ // ---------------------------------------------------------------------------
164
+ // Project state
165
+ // ---------------------------------------------------------------------------
166
+ const ensureProjectStateStmt = () => db.prepare('INSERT INTO project_state (project_id) VALUES (?) ON CONFLICT DO NOTHING');
167
+ const bumpProjectVersionStmt = () => db.prepare('UPDATE project_state SET cms_version = cms_version + 1 WHERE project_id = ?');
168
+ const nextSeqStmt = () => db.prepare('SELECT last_release_seq + 1 AS seq FROM project_state WHERE project_id = ?');
169
+ const setLastSeqStmt = () => db.prepare('UPDATE project_state SET last_release_seq = ?, cms_version = cms_version + 1 WHERE project_id = ?');
170
+ const ensureProjectState = (projectId) => {
171
+ ensureProjectStateStmt().run(projectId);
172
+ };
173
+ const getCmsVersionStmt = () => db.prepare('SELECT cms_version FROM project_state WHERE project_id = ?');
174
+ /**
175
+ * Per-project monotonic version stamp. Bumped on every publish (and revert).
176
+ * Used as the ETag basis for HTTP caching and to invalidate the in-process
177
+ * LRU. Returns 0 for projects that haven't published anything yet.
178
+ */
179
+ const getCmsVersion = (projectId) => {
180
+ const row = getCmsVersionStmt().get(projectId);
181
+ return row?.cms_version ?? 0;
182
+ };
183
+ const getPublishedPageRowStmt = () => db.prepare('SELECT params, tree, tombstone FROM published_pages WHERE project_id = ? AND locale = ? AND route_id = ? AND params_hash = ?');
184
+ const listPublishedPageRowsStmt = () => db.prepare('SELECT params, tree, tombstone FROM published_pages WHERE project_id = ? AND locale = ? AND route_id = ? ORDER BY params_hash');
185
+ const getPublishedLayoutTreeStmt = () => db.prepare('SELECT tree FROM published_layouts WHERE project_id = ? AND locale = ? AND route_id = ?');
186
+ const allPageRouteIdsStmt = () => db.prepare('SELECT DISTINCT route_id FROM published_pages WHERE project_id = ?');
187
+ const allPagesByLocaleStmt = () => db.prepare('SELECT route_id, params, tree, tombstone FROM published_pages WHERE project_id = ? AND locale = ? ORDER BY route_id, params_hash');
188
+ const allLayoutsByLocaleStmt = () => db.prepare('SELECT route_id, tree FROM published_layouts WHERE project_id = ? AND locale = ? ORDER BY route_id');
189
+ const allLocalesForProjectStmt = () => db.prepare(`SELECT DISTINCT locale FROM published_pages WHERE project_id = ?
190
+ UNION
191
+ SELECT DISTINCT locale FROM published_layouts WHERE project_id = ?`);
192
+ const distinctParamValuesStmt = () => db.prepare('SELECT DISTINCT params FROM published_pages WHERE project_id = ? AND route_id = ?');
193
+ /** Return the published `PageEntry` for a single scope, or null. */
194
+ const getPublishedPageEntry = (projectId, locale, routeId, params) => {
195
+ const row = getPublishedPageRowStmt().get(projectId, locale, routeId, paramsHash(params));
196
+ if (!row)
197
+ return null;
198
+ const published = parseTree(row.tree) ?? {};
199
+ const tombstone = parseOutcome(row.tombstone);
200
+ const entry = { params: parseParams(row.params), published };
201
+ if (tombstone)
202
+ entry.tombstone = tombstone;
203
+ return entry;
204
+ };
205
+ /** Return all published `PageEntry` rows for a route+locale (one per param set). */
206
+ const getPublishedPageEntries = (projectId, locale, routeId) => {
207
+ const rows = listPublishedPageRowsStmt().all(projectId, locale, routeId);
208
+ return rows.map((r) => {
209
+ const entry = {
210
+ params: parseParams(r.params),
211
+ published: parseTree(r.tree) ?? {}
212
+ };
213
+ const tombstone = parseOutcome(r.tombstone);
214
+ if (tombstone)
215
+ entry.tombstone = tombstone;
216
+ return entry;
217
+ });
218
+ };
219
+ /** Return the published layout tree for a single scope, or null. */
220
+ const getPublishedLayoutTree = (projectId, locale, routeId) => {
221
+ const row = getPublishedLayoutTreeStmt().get(projectId, locale, routeId);
222
+ return row ? parseTree(row.tree) : null;
223
+ };
224
+ /** Distinct route IDs that have at least one published page row in any locale. */
225
+ const allPageRouteIds = (projectId) => allPageRouteIdsStmt()
226
+ .all(projectId)
227
+ .map((r) => r.route_id);
228
+ // ---------------------------------------------------------------------------
229
+ // Published site reads
230
+ // ---------------------------------------------------------------------------
231
+ const SITE_SENTINEL = '__site__';
232
+ const getPublishedSiteTreeStmt = () => db.prepare('SELECT tree FROM published_site WHERE project_id = ?');
233
+ /** Return the published site tree for a project, or `{}` if none yet. */
234
+ const getPublishedSiteTree = (projectId) => {
235
+ const row = getPublishedSiteTreeStmt().get(projectId);
236
+ return row ? (parseTree(row.tree) ?? {}) : {};
237
+ };
238
+ const upsertPublishedSiteStmt = () => db.prepare(`INSERT INTO published_site (project_id, tree, updated_at, updated_by_release)
239
+ VALUES (?, ?, ?, ?)
240
+ ON CONFLICT(project_id) DO UPDATE SET
241
+ tree = excluded.tree,
242
+ updated_at = excluded.updated_at,
243
+ updated_by_release = excluded.updated_by_release`);
244
+ /**
245
+ * Distinct values of one `[param]` segment seen across every locale's
246
+ * published pages for the route. Mirrors `_store.ts`'s `listPageParamValues`.
247
+ */
248
+ const listPageParamValues = (projectId, routeId, param) => {
249
+ const seen = new Set();
250
+ const out = [];
251
+ for (const r of distinctParamValuesStmt().all(projectId, routeId)) {
252
+ const params = parseParams(r.params);
253
+ const v = params[param];
254
+ if (typeof v !== 'string' || seen.has(v))
255
+ continue;
256
+ seen.add(v);
257
+ out.push(v);
258
+ }
259
+ return out;
260
+ };
261
+ const getOpenReleaseStmt = () => db.prepare('SELECT user_id, name, created_at, preview_key FROM open_releases WHERE project_id = ? AND user_id = ?');
262
+ const listOpenReleaseItemsStmt = () => db.prepare('SELECT kind, route_id, locale, params, tree, outcome, added_at FROM open_release_items WHERE project_id = ? AND user_id = ? ORDER BY id');
263
+ const findOpenReleaseByPreviewKeyStmt = () => db.prepare('SELECT user_id, name, created_at, preview_key FROM open_releases WHERE project_id = ? AND preview_key = ?');
264
+ const rowToReleaseItem = (r) => {
265
+ if (r.kind === 'layout') {
266
+ return {
267
+ kind: 'layout',
268
+ routeId: r.route_id,
269
+ locale: r.locale,
270
+ tree: parseTree(r.tree) ?? {},
271
+ addedAt: r.added_at
272
+ };
273
+ }
274
+ if (r.kind === 'page') {
275
+ return {
276
+ kind: 'page',
277
+ routeId: r.route_id,
278
+ locale: r.locale,
279
+ params: parseParams(r.params),
280
+ tree: parseTree(r.tree) ?? {},
281
+ addedAt: r.added_at
282
+ };
283
+ }
284
+ if (r.kind === 'site') {
285
+ return {
286
+ kind: 'site',
287
+ tree: parseTree(r.tree) ?? {},
288
+ addedAt: r.added_at
289
+ };
290
+ }
291
+ const outcome = parseOutcome(r.outcome);
292
+ const item = {
293
+ kind: 'page-delete',
294
+ routeId: r.route_id,
295
+ locale: r.locale,
296
+ params: parseParams(r.params),
297
+ addedAt: r.added_at
298
+ };
299
+ if (outcome)
300
+ item.outcome = outcome;
301
+ return item;
302
+ };
303
+ const buildOpenRelease = (projectId, row) => {
304
+ const items = listOpenReleaseItemsStmt().all(projectId, row.user_id).map(rowToReleaseItem);
305
+ const release = {
306
+ userId: row.user_id,
307
+ createdAt: row.created_at,
308
+ preview_key: row.preview_key,
309
+ items
310
+ };
311
+ if (row.name != null)
312
+ release.name = row.name;
313
+ return release;
314
+ };
315
+ const getOpenRelease = (projectId, userId) => {
316
+ const row = getOpenReleaseStmt().get(projectId, userId);
317
+ return row ? buildOpenRelease(projectId, row) : null;
318
+ };
319
+ const findOpenReleaseByPreviewKey = (projectId, key) => {
320
+ const row = findOpenReleaseByPreviewKeyStmt().get(projectId, key);
321
+ return row ? buildOpenRelease(projectId, row) : null;
322
+ };
323
+ /** Snapshot of an open release for the api-adapter overlay path. */
324
+ const lookupReleaseByPreviewKey = (projectId, key) => {
325
+ const release = findOpenReleaseByPreviewKey(projectId, key);
326
+ if (!release)
327
+ return null;
328
+ return { id: release.userId, items: release.items };
329
+ };
330
+ // ---------------------------------------------------------------------------
331
+ // Open release writes
332
+ // ---------------------------------------------------------------------------
333
+ const ensureOpenReleaseStmt = () => db.prepare('INSERT INTO open_releases (project_id, user_id, created_at, preview_key) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING');
334
+ const findOpenItemStmt = () => db.prepare('SELECT id, tree, outcome FROM open_release_items WHERE project_id = ? AND user_id = ? AND kind = ? AND route_id = ? AND locale = ? AND params_hash = ?');
335
+ const updateOpenItemTreeStmt = () => db.prepare('UPDATE open_release_items SET tree = ?, added_at = ? WHERE id = ?');
336
+ const updateOpenItemOutcomeStmt = () => db.prepare('UPDATE open_release_items SET outcome = ?, added_at = ? WHERE id = ?');
337
+ const insertOpenItemStmt = () => db.prepare('INSERT INTO open_release_items (project_id, user_id, kind, route_id, locale, params_hash, params, tree, outcome, added_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
338
+ const deleteOpenReleaseStmt = () => db.prepare('DELETE FROM open_releases WHERE project_id = ? AND user_id = ?');
339
+ const deleteOpenItemStmt = () => db.prepare('DELETE FROM open_release_items WHERE project_id = ? AND user_id = ? AND kind = ? AND route_id = ? AND locale = ? AND params_hash = ? RETURNING id');
340
+ const updateOpenReleasePreviewKeyStmt = () => db.prepare('UPDATE open_releases SET preview_key = ? WHERE project_id = ? AND user_id = ?');
341
+ const ensureOpenRelease = (projectId, userId) => {
342
+ ensureProjectState(projectId);
343
+ ensureOpenReleaseStmt().run(projectId, userId, nowIso(), generatePreviewKey());
344
+ };
345
+ /**
346
+ * Add or merge items into the user's open release. Existing scopes get their
347
+ * tree deep-merged via `mergeTree` (last-write-wins at the leaf, arrays
348
+ * replace wholesale). Same scope+routeId in two locales is two distinct rows.
349
+ */
350
+ const addReleaseItems = (projectId, userId, inputs) => {
351
+ const tx = db.transaction(() => {
352
+ ensureOpenRelease(projectId, userId);
353
+ const now = nowIso();
354
+ for (const input of inputs) {
355
+ if (!treeHasAnyLeaf(input.tree))
356
+ continue;
357
+ const routeId = input.kind === 'site' ? SITE_SENTINEL : input.routeId;
358
+ const locale = input.kind === 'site' ? SITE_SENTINEL : input.locale;
359
+ const params = input.kind === 'page' ? input.params : {};
360
+ const hash = input.kind === 'page' ? paramsHash(params) : '';
361
+ const existing = findOpenItemStmt().get(projectId, userId, input.kind, routeId, locale, hash);
362
+ if (existing) {
363
+ const prevTree = parseTree(existing.tree) ?? {};
364
+ const merged = mergeTree(prevTree, input.tree);
365
+ updateOpenItemTreeStmt().run(JSON.stringify(merged), now, existing.id);
366
+ }
367
+ else {
368
+ insertOpenItemStmt().run(projectId, userId, input.kind, routeId, locale, hash, input.kind === 'page' ? JSON.stringify(params) : null, JSON.stringify(structuredClone(input.tree)), null, now);
369
+ }
370
+ }
371
+ });
372
+ tx();
373
+ return getOpenRelease(projectId, userId);
374
+ };
375
+ const discardReleaseItem = (projectId, userId, target) => {
376
+ if (target.kind === 'site') {
377
+ const removed = deleteOpenItemStmt().get(projectId, userId, 'site', SITE_SENTINEL, SITE_SENTINEL, '');
378
+ return !!removed;
379
+ }
380
+ const params = 'params' in target ? target.params : {};
381
+ const hash = target.kind === 'layout' ? '' : paramsHash(params);
382
+ const removed = deleteOpenItemStmt().get(projectId, userId, target.kind, target.routeId, target.locale, hash);
383
+ return !!removed;
384
+ };
385
+ const discardOpenRelease = (projectId, userId) => {
386
+ const result = deleteOpenReleaseStmt().run(projectId, userId);
387
+ return result.changes > 0;
388
+ };
389
+ const regeneratePreviewKey = (projectId, userId) => {
390
+ const row = getOpenReleaseStmt().get(projectId, userId);
391
+ if (!row)
392
+ return null;
393
+ const key = generatePreviewKey();
394
+ updateOpenReleasePreviewKeyStmt().run(key, projectId, userId);
395
+ return key;
396
+ };
397
+ // ---------------------------------------------------------------------------
398
+ // Page lifecycle (create / stage delete)
399
+ // ---------------------------------------------------------------------------
400
+ const findPendingPageItemAcrossUsersStmt = () => db.prepare("SELECT user_id FROM open_release_items WHERE project_id = ? AND kind = 'page' AND route_id = ? AND locale = ? AND params_hash = ? LIMIT 1");
401
+ const createPage = (projectId, userId, routeId, locale, params, metadata = {}) => {
402
+ const hash = paramsHash(params);
403
+ const tx = db.transaction(() => {
404
+ const existing = getPublishedPageRowStmt().get(projectId, locale, routeId, hash);
405
+ if (existing)
406
+ return { ok: false, reason: 'exists' };
407
+ const pending = findPendingPageItemAcrossUsersStmt().get(projectId, routeId, locale, hash);
408
+ if (pending)
409
+ return { ok: false, reason: 'exists' };
410
+ ensureOpenRelease(projectId, userId);
411
+ const now = nowIso();
412
+ insertOpenItemStmt().run(projectId, userId, 'page', routeId, locale, hash, JSON.stringify(params), JSON.stringify({ metadata }), null, now);
413
+ return { ok: true };
414
+ });
415
+ return tx();
416
+ };
417
+ /**
418
+ * Move a published or drafted page from one set of params to another — a
419
+ * slug rename.
420
+ *
421
+ * The admin bar has posted to `/pages/rename` since it shipped and nothing
422
+ * answered, so renaming silently did nothing. Two cases:
423
+ *
424
+ * - **Draft only** (nothing published at `fromParams`): re-key the open
425
+ * release item. There is no published URL to redirect from, so no
426
+ * tombstone is staged.
427
+ * - **Published**: stage a page item at `toParams` carrying the merged
428
+ * published + drafted tree, and stage a `page-delete` at `fromParams`
429
+ * whose outcome redirects to `toUrl`, so the old address keeps working
430
+ * once the release is published.
431
+ *
432
+ * The draft at `fromParams` must be removed *before* staging the delete:
433
+ * `stagePageDelete` refuses with `has-draft` while a page item exists at the
434
+ * same scope.
435
+ */
436
+ const renamePage = (projectId, userId, args) => {
437
+ const { routeId, locale, fromParams, toParams, toUrl } = args;
438
+ const fromHash = paramsHash(fromParams);
439
+ const toHash = paramsHash(toParams);
440
+ if (fromHash === toHash)
441
+ return { ok: false, reason: 'same-params' };
442
+ const tx = db.transaction(() => {
443
+ const publishedFrom = getPublishedPageRowStmt().get(projectId, locale, routeId, fromHash);
444
+ const draftFrom = findOpenItemStmt().get(projectId, userId, 'page', routeId, locale, fromHash);
445
+ if (!publishedFrom && !draftFrom)
446
+ return { ok: false, reason: 'not-found' };
447
+ // The destination must be free, both published and across everyone's
448
+ // pending drafts — two editors must not race onto the same slug.
449
+ if (getPublishedPageRowStmt().get(projectId, locale, routeId, toHash)) {
450
+ return { ok: false, reason: 'exists' };
451
+ }
452
+ if (findPendingPageItemAcrossUsersStmt().get(projectId, routeId, locale, toHash)) {
453
+ return { ok: false, reason: 'exists' };
454
+ }
455
+ // The new page carries everything the old one resolved to: there is
456
+ // no published row behind `toParams` to merge onto later.
457
+ const tree = mergeTree(parseTree(publishedFrom?.tree ?? null) ?? {}, parseTree(draftFrom?.tree ?? null) ?? {});
458
+ ensureOpenRelease(projectId, userId);
459
+ const now = nowIso();
460
+ if (draftFrom) {
461
+ deleteOpenItemStmt().get(projectId, userId, 'page', routeId, locale, fromHash);
462
+ }
463
+ insertOpenItemStmt().run(projectId, userId, 'page', routeId, locale, toHash, JSON.stringify(toParams), JSON.stringify(tree), null, now);
464
+ if (publishedFrom) {
465
+ const outcome = { kind: 'redirect', to: toUrl };
466
+ const pending = findOpenItemStmt().get(projectId, userId, 'page-delete', routeId, locale, fromHash);
467
+ if (pending) {
468
+ updateOpenItemOutcomeStmt().run(JSON.stringify(outcome), now, pending.id);
469
+ }
470
+ else {
471
+ insertOpenItemStmt().run(projectId, userId, 'page-delete', routeId, locale, fromHash, JSON.stringify(fromParams), null, JSON.stringify(outcome), now);
472
+ }
473
+ }
474
+ const release = getOpenRelease(projectId, userId);
475
+ if (!release)
476
+ return { ok: false, reason: 'not-found' };
477
+ return { ok: true, release };
478
+ });
479
+ return tx();
480
+ };
481
+ const stagePageDelete = (projectId, userId, routeId, locale, params, outcome) => {
482
+ const hash = paramsHash(params);
483
+ const tx = db.transaction(() => {
484
+ const published = getPublishedPageRowStmt().get(projectId, locale, routeId, hash);
485
+ if (!published)
486
+ return { ok: false, reason: 'not-published' };
487
+ const draft = findOpenItemStmt().get(projectId, userId, 'page', routeId, locale, hash);
488
+ if (draft)
489
+ return { ok: false, reason: 'has-draft' };
490
+ const pending = findOpenItemStmt().get(projectId, userId, 'page-delete', routeId, locale, hash);
491
+ const now = nowIso();
492
+ if (pending) {
493
+ updateOpenItemOutcomeStmt().run(outcome ? JSON.stringify(outcome) : null, now, pending.id);
494
+ return { ok: true, alreadyStaged: true };
495
+ }
496
+ ensureOpenRelease(projectId, userId);
497
+ insertOpenItemStmt().run(projectId, userId, 'page-delete', routeId, locale, hash, JSON.stringify(params), null, outcome ? JSON.stringify(outcome) : null, now);
498
+ return { ok: true, alreadyStaged: false };
499
+ });
500
+ return tx();
501
+ };
502
+ // ---------------------------------------------------------------------------
503
+ // Publish
504
+ // ---------------------------------------------------------------------------
505
+ const upsertPublishedPageStmt = () => db.prepare(`INSERT INTO published_pages (project_id, locale, route_id, params_hash, params, tree, tombstone, updated_at, updated_by_release)
506
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
507
+ ON CONFLICT(project_id, locale, route_id, params_hash) DO UPDATE SET
508
+ tree = excluded.tree,
509
+ tombstone = excluded.tombstone,
510
+ updated_at = excluded.updated_at,
511
+ updated_by_release = excluded.updated_by_release`);
512
+ const upsertPublishedLayoutStmt = () => db.prepare(`INSERT INTO published_layouts (project_id, locale, route_id, tree, updated_at, updated_by_release)
513
+ VALUES (?, ?, ?, ?, ?, ?)
514
+ ON CONFLICT(project_id, locale, route_id) DO UPDATE SET
515
+ tree = excluded.tree,
516
+ updated_at = excluded.updated_at,
517
+ updated_by_release = excluded.updated_by_release`);
518
+ const updatePublishedPageTombstoneStmt = () => db.prepare('UPDATE published_pages SET tombstone = ?, updated_at = ?, updated_by_release = ? WHERE project_id = ? AND locale = ? AND route_id = ? AND params_hash = ?');
519
+ const deletePublishedPageStmt = () => db.prepare('DELETE FROM published_pages WHERE project_id = ? AND locale = ? AND route_id = ? AND params_hash = ?');
520
+ const insertReleaseStmt = () => db.prepare('INSERT INTO releases (id, project_id, seq, name, published_by, published_at, preview_key) VALUES (?, ?, ?, ?, ?, ?, ?)');
521
+ const insertReleaseItemStmt = () => db.prepare('INSERT INTO release_items (release_id, project_id, seq, kind, route_id, locale, params_hash, params, tree, prior_tree, outcome, prior_tombstone, added_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
522
+ const deleteOpenItemsForUserStmt = () => db.prepare('DELETE FROM open_release_items WHERE project_id = ? AND user_id = ?');
523
+ /**
524
+ * Apply every item in the user's open release atomically: capture each item's
525
+ * prior leaf values, write the merged tree to `published_pages`/`published_layouts`,
526
+ * insert the release header + items, bump the project version, and clear the
527
+ * open release. Returns the new `PublishedRelease`, or null if there's nothing
528
+ * to publish.
529
+ */
530
+ const publishRelease = (projectId, userId, name) => {
531
+ const tx = db.transaction(() => {
532
+ ensureProjectState(projectId);
533
+ const releaseRow = getOpenReleaseStmt().get(projectId, userId);
534
+ if (!releaseRow)
535
+ return null;
536
+ const items = listOpenReleaseItemsStmt().all(projectId, userId).map(rowToReleaseItem);
537
+ if (items.length === 0)
538
+ return null;
539
+ const seqRow = nextSeqStmt().get(projectId);
540
+ const seq = seqRow?.seq ?? 1;
541
+ const releaseId = randomUUID();
542
+ const publishedAt = nowIso();
543
+ const previewKey = generatePreviewKey();
544
+ const publishedItems = [];
545
+ // Insert the release header up front so release_items.release_id FK
546
+ // resolves on every per-item insert below. We delete the header at the
547
+ // end if the body turned out to be empty.
548
+ const finalName = name ?? releaseRow.name ?? null;
549
+ insertReleaseStmt().run(releaseId, projectId, seq, finalName, userId, publishedAt, previewKey);
550
+ for (const item of items) {
551
+ if (item.kind === 'site') {
552
+ const target = getPublishedSiteTree(projectId);
553
+ const priorTree = capturePriorTree(target, item.tree);
554
+ const merged = mergeTree(target, item.tree);
555
+ upsertPublishedSiteStmt().run(projectId, JSON.stringify(merged), publishedAt, releaseId);
556
+ const published = {
557
+ kind: 'site',
558
+ tree: structuredClone(item.tree),
559
+ priorTree
560
+ };
561
+ publishedItems.push(published);
562
+ insertReleaseItemStmt().run(releaseId, projectId, seq, 'site', SITE_SENTINEL, SITE_SENTINEL, null, null, JSON.stringify(item.tree), serializePriorTree(priorTree), null, null, publishedAt);
563
+ }
564
+ else if (item.kind === 'layout') {
565
+ const existing = getPublishedLayoutTreeStmt().get(projectId, item.locale, item.routeId);
566
+ const target = existing ? (parseTree(existing.tree) ?? {}) : {};
567
+ const priorTree = capturePriorTree(target, item.tree);
568
+ const merged = mergeTree(target, item.tree);
569
+ upsertPublishedLayoutStmt().run(projectId, item.locale, item.routeId, JSON.stringify(merged), publishedAt, releaseId);
570
+ const published = {
571
+ kind: 'layout',
572
+ routeId: item.routeId,
573
+ locale: item.locale,
574
+ tree: structuredClone(item.tree),
575
+ priorTree
576
+ };
577
+ publishedItems.push(published);
578
+ insertReleaseItemStmt().run(releaseId, projectId, seq, 'layout', item.routeId, item.locale, null, null, JSON.stringify(item.tree), serializePriorTree(priorTree), null, null, publishedAt);
579
+ }
580
+ else if (item.kind === 'page') {
581
+ const hash = paramsHash(item.params);
582
+ const existing = getPublishedPageRowStmt().get(projectId, item.locale, item.routeId, hash);
583
+ const target = existing ? (parseTree(existing.tree) ?? {}) : {};
584
+ const priorTree = existing ? capturePriorTree(target, item.tree) : null;
585
+ const merged = mergeTree(target, item.tree);
586
+ const tombstone = existing?.tombstone ?? null;
587
+ upsertPublishedPageStmt().run(projectId, item.locale, item.routeId, hash, JSON.stringify(item.params), JSON.stringify(merged), tombstone, publishedAt, releaseId);
588
+ const published = {
589
+ kind: 'page',
590
+ routeId: item.routeId,
591
+ locale: item.locale,
592
+ params: { ...item.params },
593
+ tree: structuredClone(item.tree),
594
+ priorTree
595
+ };
596
+ publishedItems.push(published);
597
+ insertReleaseItemStmt().run(releaseId, projectId, seq, 'page', item.routeId, item.locale, hash, JSON.stringify(item.params), JSON.stringify(item.tree), serializePriorTree(priorTree), null, null, publishedAt);
598
+ }
599
+ else {
600
+ // page-delete: outcome present → set tombstone + keep tree; absent → delete row.
601
+ const hash = paramsHash(item.params);
602
+ const existing = getPublishedPageRowStmt().get(projectId, item.locale, item.routeId, hash);
603
+ if (!existing)
604
+ continue;
605
+ const priorTree = parseTree(existing.tree) ?? {};
606
+ const priorTombstone = parseOutcome(existing.tombstone);
607
+ if (item.outcome) {
608
+ updatePublishedPageTombstoneStmt().run(JSON.stringify(item.outcome), publishedAt, releaseId, projectId, item.locale, item.routeId, hash);
609
+ }
610
+ else {
611
+ deletePublishedPageStmt().run(projectId, item.locale, item.routeId, hash);
612
+ }
613
+ const published = {
614
+ kind: 'page-delete',
615
+ routeId: item.routeId,
616
+ locale: item.locale,
617
+ params: { ...item.params },
618
+ priorTree
619
+ };
620
+ if (item.outcome)
621
+ published.outcome = item.outcome;
622
+ if (priorTombstone)
623
+ published.priorTombstone = priorTombstone;
624
+ publishedItems.push(published);
625
+ insertReleaseItemStmt().run(releaseId, projectId, seq, 'page-delete', item.routeId, item.locale, hash, JSON.stringify(item.params), null, serializePriorTree(priorTree), item.outcome ? JSON.stringify(item.outcome) : null, priorTombstone ? JSON.stringify(priorTombstone) : null, publishedAt);
626
+ }
627
+ }
628
+ if (publishedItems.length === 0) {
629
+ // Roll back the speculative header insert from above.
630
+ db.prepare('DELETE FROM releases WHERE id = ?').run(releaseId);
631
+ return null;
632
+ }
633
+ setLastSeqStmt().run(seq, projectId);
634
+ deleteOpenItemsForUserStmt().run(projectId, userId);
635
+ deleteOpenReleaseStmt().run(projectId, userId);
636
+ const result = {
637
+ id: releaseId,
638
+ publishedBy: userId,
639
+ publishedAt,
640
+ preview_key: previewKey,
641
+ items: publishedItems
642
+ };
643
+ if (finalName != null)
644
+ result.name = finalName;
645
+ return result;
646
+ });
647
+ return tx();
648
+ };
649
+ const listReleaseHistoryStmt = () => db.prepare('SELECT id, name, published_by, published_at, preview_key, reverted_at FROM releases WHERE project_id = ? ORDER BY seq ASC');
650
+ const findReleaseByPreviewKeyStmt = () => db.prepare('SELECT id, name, published_by, published_at, preview_key, reverted_at, seq FROM releases WHERE project_id = ? AND preview_key = ?');
651
+ const getReleaseByIdStmt = () => db.prepare('SELECT id, name, published_by, published_at, preview_key, reverted_at, seq FROM releases WHERE project_id = ? AND id = ?');
652
+ const listReleaseItemsByReleaseIdStmt = () => db.prepare('SELECT kind, route_id, locale, params, tree, prior_tree, outcome, prior_tombstone FROM release_items WHERE release_id = ? ORDER BY id');
653
+ const listReleaseItemsAfterSeqStmt = () => db.prepare('SELECT seq, kind, route_id, locale, params, tree, prior_tree, outcome, prior_tombstone FROM release_items WHERE project_id = ? AND seq > ? ORDER BY seq DESC, id DESC');
654
+ const updateReleasePreviewKeyStmt = () => db.prepare('UPDATE releases SET preview_key = ? WHERE id = ?');
655
+ const setReleaseRevertedAtStmt = () => db.prepare('UPDATE releases SET reverted_at = ? WHERE id = ?');
656
+ const rowToPublishedItem = (r) => {
657
+ const priorTree = parsePriorTree(r.prior_tree);
658
+ const priorTombstone = parseOutcome(r.prior_tombstone);
659
+ if (r.kind === 'layout') {
660
+ const item = {
661
+ kind: 'layout',
662
+ routeId: r.route_id,
663
+ locale: r.locale,
664
+ tree: parseTree(r.tree) ?? {},
665
+ priorTree: priorTree ?? {}
666
+ };
667
+ return item;
668
+ }
669
+ if (r.kind === 'page') {
670
+ const item = {
671
+ kind: 'page',
672
+ routeId: r.route_id,
673
+ locale: r.locale,
674
+ params: parseParams(r.params),
675
+ tree: parseTree(r.tree) ?? {},
676
+ priorTree
677
+ };
678
+ return item;
679
+ }
680
+ if (r.kind === 'site') {
681
+ return {
682
+ kind: 'site',
683
+ tree: parseTree(r.tree) ?? {},
684
+ priorTree: priorTree ?? {}
685
+ };
686
+ }
687
+ const outcome = parseOutcome(r.outcome);
688
+ const item = {
689
+ kind: 'page-delete',
690
+ routeId: r.route_id,
691
+ locale: r.locale,
692
+ params: parseParams(r.params),
693
+ priorTree
694
+ };
695
+ if (outcome)
696
+ item.outcome = outcome;
697
+ if (priorTombstone)
698
+ item.priorTombstone = priorTombstone;
699
+ return item;
700
+ };
701
+ const buildPublishedRelease = (projectId, row) => {
702
+ const items = listReleaseItemsByReleaseIdStmt().all(row.id).map(rowToPublishedItem);
703
+ const r = {
704
+ id: row.id,
705
+ publishedBy: row.published_by,
706
+ publishedAt: row.published_at,
707
+ preview_key: row.preview_key,
708
+ items
709
+ };
710
+ if (row.name != null)
711
+ r.name = row.name;
712
+ if (row.reverted_at != null)
713
+ r.revertedAt = row.reverted_at;
714
+ return r;
715
+ };
716
+ const getReleaseHistory = (projectId) => listReleaseHistoryStmt()
717
+ .all(projectId)
718
+ .map((r) => buildPublishedRelease(projectId, r));
719
+ const findPublishedReleaseByPreviewKey = (projectId, key) => {
720
+ const row = findReleaseByPreviewKeyStmt().get(projectId, key);
721
+ return row ? buildPublishedRelease(projectId, row) : null;
722
+ };
723
+ const regeneratePublishedPreviewKey = (projectId, releaseId) => {
724
+ const row = getReleaseByIdStmt().get(projectId, releaseId);
725
+ if (!row)
726
+ return null;
727
+ const key = generatePreviewKey();
728
+ updateReleasePreviewKeyStmt().run(key, releaseId);
729
+ return key;
730
+ };
731
+ const cloneCurrentState = (projectId) => {
732
+ const layoutSnap = {};
733
+ const pageSnap = {};
734
+ const locales = allLocalesForProjectStmt()
735
+ .all(projectId, projectId)
736
+ .map((r) => r.locale);
737
+ for (const locale of locales) {
738
+ const layoutBucket = {};
739
+ for (const r of allLayoutsByLocaleStmt().all(projectId, locale)) {
740
+ layoutBucket[r.route_id] = parseTree(r.tree) ?? {};
741
+ }
742
+ layoutSnap[locale] = layoutBucket;
743
+ const pageBucket = {};
744
+ for (const r of allPagesByLocaleStmt().all(projectId, locale)) {
745
+ const params = parseParams(r.params);
746
+ const entry = { params, published: parseTree(r.tree) ?? {} };
747
+ const tombstone = parseOutcome(r.tombstone);
748
+ if (tombstone)
749
+ entry.tombstone = tombstone;
750
+ (pageBucket[r.route_id] ??= []).push(entry);
751
+ }
752
+ pageSnap[locale] = pageBucket;
753
+ }
754
+ return { layoutSnap, pageSnap, siteSnap: structuredClone(getPublishedSiteTree(projectId)) };
755
+ };
756
+ /**
757
+ * Reconstruct the live state at the moment a release shipped: clone current
758
+ * materialized state, then walk every release published *after* the target
759
+ * newest-to-oldest, applying each item's `priorTree` to undo it. The target
760
+ * release itself stays applied. Pure on the cloned trees.
761
+ */
762
+ const buildStateAtRelease = (projectId, releaseId) => {
763
+ const target = getReleaseByIdStmt().get(projectId, releaseId);
764
+ if (!target)
765
+ return null;
766
+ const snap = cloneCurrentState(projectId);
767
+ for (const r of listReleaseItemsAfterSeqStmt().all(projectId, target.seq)) {
768
+ const item = rowToPublishedItem(r);
769
+ if (item.kind === 'site') {
770
+ snap.siteSnap = applyRestore(snap.siteSnap, item.priorTree ?? {});
771
+ }
772
+ else if (item.kind === 'layout') {
773
+ const localeBucket = (snap.layoutSnap[item.locale] ??= {});
774
+ const tree = localeBucket[item.routeId] ?? {};
775
+ localeBucket[item.routeId] = applyRestore(tree, item.priorTree ?? {});
776
+ }
777
+ else if (item.kind === 'page') {
778
+ const localeBucket = (snap.pageSnap[item.locale] ??= {});
779
+ if (item.priorTree == null) {
780
+ const entries = localeBucket[item.routeId];
781
+ if (!entries)
782
+ continue;
783
+ const idx = entries.findIndex((e) => paramsEqual(e.params, item.params));
784
+ if (idx < 0)
785
+ continue;
786
+ entries.splice(idx, 1);
787
+ }
788
+ else {
789
+ const entries = (localeBucket[item.routeId] ??= []);
790
+ let entry = findPageEntry(entries, item.params);
791
+ if (!entry) {
792
+ entry = { params: { ...item.params }, published: {} };
793
+ entries.push(entry);
794
+ }
795
+ entry.published = applyRestore(entry.published, item.priorTree);
796
+ }
797
+ }
798
+ else {
799
+ const localeBucket = (snap.pageSnap[item.locale] ??= {});
800
+ const entries = (localeBucket[item.routeId] ??= []);
801
+ if (!item.outcome) {
802
+ if (findPageEntry(entries, item.params))
803
+ continue;
804
+ entries.push({
805
+ params: { ...item.params },
806
+ published: structuredClone(item.priorTree ?? {})
807
+ });
808
+ }
809
+ else {
810
+ const entry = findPageEntry(entries, item.params);
811
+ if (!entry)
812
+ continue;
813
+ if (item.priorTombstone)
814
+ entry.tombstone = item.priorTombstone;
815
+ else
816
+ delete entry.tombstone;
817
+ }
818
+ }
819
+ }
820
+ return snap;
821
+ };
822
+ // ---------------------------------------------------------------------------
823
+ // Revert
824
+ // ---------------------------------------------------------------------------
825
+ const revertRelease = (projectId, releaseId, userId) => {
826
+ const tx = db.transaction(() => {
827
+ ensureProjectState(projectId);
828
+ const sourceRow = getReleaseByIdStmt().get(projectId, releaseId);
829
+ if (!sourceRow)
830
+ return null;
831
+ const sourceItems = listReleaseItemsByReleaseIdStmt().all(releaseId).map(rowToPublishedItem);
832
+ const seqRow = nextSeqStmt().get(projectId);
833
+ const seq = seqRow?.seq ?? 1;
834
+ const inverseId = randomUUID();
835
+ const publishedAt = nowIso();
836
+ const previewKey = generatePreviewKey();
837
+ const inverseItems = [];
838
+ // Header up front so release_items.release_id FK resolves below.
839
+ const inverseName = sourceRow.name
840
+ ? `Revert "${sourceRow.name}"`
841
+ : `Revert ${sourceRow.id.slice(0, 8)}`;
842
+ insertReleaseStmt().run(inverseId, projectId, seq, inverseName, userId, publishedAt, previewKey);
843
+ for (const item of sourceItems) {
844
+ if (item.kind === 'site') {
845
+ const restoreTree = item.priorTree ?? {};
846
+ const target = getPublishedSiteTree(projectId);
847
+ const priorTree = capturePriorTree(target, restoreTree);
848
+ const next = applyRestore(target, restoreTree);
849
+ upsertPublishedSiteStmt().run(projectId, JSON.stringify(next), publishedAt, inverseId);
850
+ const inverse = {
851
+ kind: 'site',
852
+ tree: structuredClone(restoreTree),
853
+ priorTree
854
+ };
855
+ inverseItems.push(inverse);
856
+ insertReleaseItemStmt().run(inverseId, projectId, seq, 'site', SITE_SENTINEL, SITE_SENTINEL, null, null, serializePriorTree(restoreTree), serializePriorTree(priorTree), null, null, publishedAt);
857
+ }
858
+ else if (item.kind === 'layout') {
859
+ const restoreTree = item.priorTree ?? {};
860
+ const existing = getPublishedLayoutTreeStmt().get(projectId, item.locale, item.routeId);
861
+ const target = existing ? (parseTree(existing.tree) ?? {}) : {};
862
+ const priorTree = capturePriorTree(target, restoreTree);
863
+ const next = applyRestore(target, restoreTree);
864
+ upsertPublishedLayoutStmt().run(projectId, item.locale, item.routeId, JSON.stringify(next), publishedAt, inverseId);
865
+ const inverse = {
866
+ kind: 'layout',
867
+ routeId: item.routeId,
868
+ locale: item.locale,
869
+ tree: structuredClone(restoreTree),
870
+ priorTree
871
+ };
872
+ inverseItems.push(inverse);
873
+ insertReleaseItemStmt().run(inverseId, projectId, seq, 'layout', item.routeId, item.locale, null, null, serializePriorTree(restoreTree), serializePriorTree(priorTree), null, null, publishedAt);
874
+ }
875
+ else if (item.kind === 'page-delete') {
876
+ const hash = paramsHash(item.params);
877
+ if (!item.outcome) {
878
+ // Hard delete: source spliced the entry; inverse re-inserts.
879
+ const existing = getPublishedPageRowStmt().get(projectId, item.locale, item.routeId, hash);
880
+ if (existing)
881
+ continue;
882
+ const restored = item.priorTree ?? {};
883
+ upsertPublishedPageStmt().run(projectId, item.locale, item.routeId, hash, JSON.stringify(item.params), JSON.stringify(restored), null, publishedAt, inverseId);
884
+ const inverse = {
885
+ kind: 'page',
886
+ routeId: item.routeId,
887
+ locale: item.locale,
888
+ params: { ...item.params },
889
+ tree: structuredClone(restored),
890
+ priorTree: null
891
+ };
892
+ inverseItems.push(inverse);
893
+ insertReleaseItemStmt().run(inverseId, projectId, seq, 'page', item.routeId, item.locale, hash, JSON.stringify(item.params), JSON.stringify(restored), null, null, null, publishedAt);
894
+ }
895
+ else {
896
+ // Tombstone delete: restore prior tombstone (or clear).
897
+ const existing = getPublishedPageRowStmt().get(projectId, item.locale, item.routeId, hash);
898
+ if (!existing)
899
+ continue;
900
+ const currentTombstone = parseOutcome(existing.tombstone);
901
+ updatePublishedPageTombstoneStmt().run(item.priorTombstone ? JSON.stringify(item.priorTombstone) : null, publishedAt, inverseId, projectId, item.locale, item.routeId, hash);
902
+ const inverse = {
903
+ kind: 'page-delete',
904
+ routeId: item.routeId,
905
+ locale: item.locale,
906
+ params: { ...item.params },
907
+ outcome: item.outcome,
908
+ priorTree: parseTree(existing.tree) ?? {}
909
+ };
910
+ if (item.priorTombstone)
911
+ inverse.priorTombstone = item.priorTombstone;
912
+ inverseItems.push(inverse);
913
+ insertReleaseItemStmt().run(inverseId, projectId, seq, 'page-delete', item.routeId, item.locale, hash, JSON.stringify(item.params), null, JSON.stringify(parseTree(existing.tree) ?? {}), JSON.stringify(item.outcome), item.priorTombstone
914
+ ? JSON.stringify(item.priorTombstone)
915
+ : currentTombstone
916
+ ? JSON.stringify(currentTombstone)
917
+ : null, publishedAt);
918
+ }
919
+ }
920
+ else if (item.priorTree == null) {
921
+ // Source created the page; revert deletes it.
922
+ const hash = paramsHash(item.params);
923
+ const existing = getPublishedPageRowStmt().get(projectId, item.locale, item.routeId, hash);
924
+ if (!existing)
925
+ continue;
926
+ const removedTree = parseTree(existing.tree) ?? {};
927
+ deletePublishedPageStmt().run(projectId, item.locale, item.routeId, hash);
928
+ const inverse = {
929
+ kind: 'page-delete',
930
+ routeId: item.routeId,
931
+ locale: item.locale,
932
+ params: { ...item.params },
933
+ priorTree: removedTree
934
+ };
935
+ inverseItems.push(inverse);
936
+ insertReleaseItemStmt().run(inverseId, projectId, seq, 'page-delete', item.routeId, item.locale, hash, JSON.stringify(item.params), null, JSON.stringify(removedTree), null, null, publishedAt);
937
+ }
938
+ else {
939
+ const restoreTree = item.priorTree;
940
+ const hash = paramsHash(item.params);
941
+ const existing = getPublishedPageRowStmt().get(projectId, item.locale, item.routeId, hash);
942
+ if (!existing)
943
+ continue;
944
+ const target = parseTree(existing.tree) ?? {};
945
+ const priorTree = capturePriorTree(target, restoreTree);
946
+ const next = applyRestore(target, restoreTree);
947
+ upsertPublishedPageStmt().run(projectId, item.locale, item.routeId, hash, existing.params, JSON.stringify(next), existing.tombstone, publishedAt, inverseId);
948
+ const inverse = {
949
+ kind: 'page',
950
+ routeId: item.routeId,
951
+ locale: item.locale,
952
+ params: { ...item.params },
953
+ tree: structuredClone(restoreTree),
954
+ priorTree
955
+ };
956
+ inverseItems.push(inverse);
957
+ insertReleaseItemStmt().run(inverseId, projectId, seq, 'page', item.routeId, item.locale, hash, JSON.stringify(item.params), serializePriorTree(restoreTree), serializePriorTree(priorTree), null, null, publishedAt);
958
+ }
959
+ }
960
+ setReleaseRevertedAtStmt().run(publishedAt, releaseId);
961
+ setLastSeqStmt().run(seq, projectId);
962
+ return {
963
+ id: inverseId,
964
+ name: inverseName,
965
+ publishedBy: userId,
966
+ publishedAt,
967
+ preview_key: previewKey,
968
+ items: inverseItems
969
+ };
970
+ });
971
+ return tx();
972
+ };
973
+ // ---------------------------------------------------------------------------
974
+ // Page listings (for the editor admin bar)
975
+ // ---------------------------------------------------------------------------
976
+ const readMetadataBranch = (tree) => {
977
+ const m = tree?.metadata;
978
+ return m && typeof m === 'object' && !Array.isArray(m) ? m : {};
979
+ };
980
+ const paramsKey = (params) => {
981
+ const keys = Object.keys(params).sort();
982
+ return keys.map((k) => `${k}=${params[k]}`).join('&');
983
+ };
984
+ /**
985
+ * Build the editor's per-route page map for one locale: every published page
986
+ * + every same-locale `page` / `page-delete` item in the user's open release.
987
+ * `isDraft` flags entries that exist only in the draft; `isDeletePending`
988
+ * flags published entries with a staged removal. Routes alphabetical, entries
989
+ * within a route alphabetical by serialized params.
990
+ */
991
+ const listAllPages = (projectId, userId, locale) => {
992
+ const map = new Map();
993
+ const ensureRoute = (routeId) => {
994
+ let r = map.get(routeId);
995
+ if (!r) {
996
+ r = new Map();
997
+ map.set(routeId, r);
998
+ }
999
+ return r;
1000
+ };
1001
+ for (const r of allPagesByLocaleStmt().all(projectId, locale)) {
1002
+ const route = ensureRoute(r.route_id);
1003
+ const tree = parseTree(r.tree) ?? {};
1004
+ const tombstone = parseOutcome(r.tombstone);
1005
+ const params = parseParams(r.params);
1006
+ const entry = {
1007
+ params,
1008
+ metadata: { ...readMetadataBranch(tree) },
1009
+ isDraft: false,
1010
+ isDeletePending: false
1011
+ };
1012
+ if (tombstone?.kind === 'redirect')
1013
+ entry.redirectTo = tombstone.to;
1014
+ else if (tombstone?.kind === 'gone')
1015
+ entry.gone = true;
1016
+ route.set(paramsKey(params), entry);
1017
+ }
1018
+ if (userId) {
1019
+ const items = listOpenReleaseItemsStmt().all(projectId, userId).map(rowToReleaseItem);
1020
+ for (const item of items) {
1021
+ if (item.kind === 'layout' || item.kind === 'site')
1022
+ continue;
1023
+ if (item.locale !== locale)
1024
+ continue;
1025
+ const route = ensureRoute(item.routeId);
1026
+ const key = paramsKey(item.params);
1027
+ const existing = route.get(key);
1028
+ if (item.kind === 'page') {
1029
+ const itemMeta = readMetadataBranch(item.tree);
1030
+ if (existing) {
1031
+ existing.metadata = { ...existing.metadata, ...itemMeta };
1032
+ continue;
1033
+ }
1034
+ route.set(key, {
1035
+ params: { ...item.params },
1036
+ metadata: { ...itemMeta },
1037
+ isDraft: true,
1038
+ isDeletePending: false
1039
+ });
1040
+ }
1041
+ else {
1042
+ if (!existing)
1043
+ continue;
1044
+ existing.isDeletePending = true;
1045
+ if (item.outcome?.kind === 'redirect') {
1046
+ existing.redirectTo = item.outcome.to;
1047
+ delete existing.gone;
1048
+ }
1049
+ else if (item.outcome?.kind === 'gone') {
1050
+ existing.gone = true;
1051
+ delete existing.redirectTo;
1052
+ }
1053
+ else {
1054
+ delete existing.redirectTo;
1055
+ delete existing.gone;
1056
+ }
1057
+ }
1058
+ }
1059
+ }
1060
+ const out = [];
1061
+ for (const routeId of [...map.keys()].sort()) {
1062
+ const inner = map.get(routeId);
1063
+ const entries = [...inner.entries()]
1064
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
1065
+ .map(([, v]) => v);
1066
+ out.push({ routeId, entries });
1067
+ }
1068
+ return out;
1069
+ };
1070
+ const insertMediaStmt = () => db.prepare('INSERT INTO media_items (id, project_id, filename, original_name, mime, size, url, uploaded_at, uploaded_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)');
1071
+ /**
1072
+ * Newest first. The tiebreaker is `rowid`, not `id`: `uploaded_at` has
1073
+ * millisecond resolution and `id` is a random UUID, so several uploads inside
1074
+ * one millisecond used to come back in arbitrary order. `rowid` is insertion
1075
+ * order, which is what "newest first" is supposed to mean.
1076
+ */
1077
+ const listMediaStmt = () => db.prepare('SELECT id, filename, original_name, mime, size, url, uploaded_at, uploaded_by FROM media_items WHERE project_id = ? ORDER BY uploaded_at DESC, rowid DESC LIMIT ? OFFSET ?');
1078
+ const countMediaStmt = () => db.prepare('SELECT COUNT(*) AS count FROM media_items WHERE project_id = ?');
1079
+ const findMediaStmt = () => db.prepare('SELECT id, filename, original_name, mime, size, url, uploaded_at, uploaded_by FROM media_items WHERE project_id = ? AND id = ?');
1080
+ const deleteMediaStmt = () => db.prepare('DELETE FROM media_items WHERE project_id = ? AND id = ? RETURNING id, filename, original_name, mime, size, url, uploaded_at, uploaded_by');
1081
+ const rowToMedia = (r) => ({
1082
+ id: r.id,
1083
+ filename: r.filename,
1084
+ originalName: r.original_name,
1085
+ mime: r.mime,
1086
+ size: r.size,
1087
+ url: r.url,
1088
+ uploadedAt: r.uploaded_at,
1089
+ uploadedBy: r.uploaded_by
1090
+ });
1091
+ const createMediaItem = (projectId, input) => {
1092
+ const item = {
1093
+ id: randomUUID(),
1094
+ uploadedAt: nowIso(),
1095
+ ...input
1096
+ };
1097
+ insertMediaStmt().run(item.id, projectId, item.filename, item.originalName, item.mime, item.size, item.url, item.uploadedAt, item.uploadedBy);
1098
+ return item;
1099
+ };
1100
+ const listMediaItems = (projectId, { offset = 0, limit = 50 } = {}) => {
1101
+ const items = listMediaStmt().all(projectId, limit, offset).map(rowToMedia);
1102
+ const total = countMediaStmt().get(projectId)?.count ?? 0;
1103
+ return { items, total };
1104
+ };
1105
+ const findMediaItem = (projectId, id) => {
1106
+ const row = findMediaStmt().get(projectId, id);
1107
+ return row ? rowToMedia(row) : null;
1108
+ };
1109
+ const deleteMediaItem = (projectId, id) => {
1110
+ const row = deleteMediaStmt().get(projectId, id);
1111
+ return row ? rowToMedia(row) : null;
1112
+ };
1113
+ // ---------------------------------------------------------------------------
1114
+ // Test reset / seed
1115
+ // ---------------------------------------------------------------------------
1116
+ const truncateProjectStmts = () => {
1117
+ const tables = [
1118
+ 'open_release_items',
1119
+ 'open_releases',
1120
+ 'release_items',
1121
+ 'releases',
1122
+ 'published_pages',
1123
+ 'published_layouts',
1124
+ 'published_site',
1125
+ 'media_items',
1126
+ 'project_state'
1127
+ ];
1128
+ return tables.map((t) => {
1129
+ const stmt = db.prepare(`DELETE FROM ${t} WHERE project_id = ?`);
1130
+ return (projectId) => {
1131
+ stmt.run(projectId);
1132
+ };
1133
+ });
1134
+ };
1135
+ let _truncators = null;
1136
+ /** Wipe every CMS table for one project. Used by `__test_reset__`.
1137
+ * Also clears the in-process LRU — without this, the next test's first publish
1138
+ * would re-mint `cms_version=1` and collide with cached entries from the
1139
+ * previous test run (production never resets, so version is monotonic). */
1140
+ const __resetProjectForTests = (projectId) => {
1141
+ if (!_truncators)
1142
+ _truncators = truncateProjectStmts();
1143
+ const tx = db.transaction(() => {
1144
+ for (const fn of _truncators)
1145
+ fn(projectId);
1146
+ });
1147
+ tx();
1148
+ };
1149
+ /**
1150
+ * Seed published page/layout content for a project. Bypasses the release
1151
+ * flow — used by tests that want a known fixture without going through
1152
+ * publish. Each call replaces existing rows for the same scope.
1153
+ */
1154
+ const seedPublishedDocs = (projectId, layouts, pages) => {
1155
+ const tx = db.transaction(() => {
1156
+ ensureProjectState(projectId);
1157
+ const seedReleaseId = `seed-${projectId}`;
1158
+ const at = nowIso();
1159
+ for (const [locale, byRoute] of Object.entries(layouts)) {
1160
+ for (const [routeId, tree] of Object.entries(byRoute)) {
1161
+ upsertPublishedLayoutStmt().run(projectId, locale, routeId, JSON.stringify(tree), at, seedReleaseId);
1162
+ }
1163
+ }
1164
+ for (const [locale, byRoute] of Object.entries(pages)) {
1165
+ for (const [routeId, entries] of Object.entries(byRoute)) {
1166
+ for (const entry of entries) {
1167
+ const hash = paramsHash(entry.params);
1168
+ upsertPublishedPageStmt().run(projectId, locale, routeId, hash, JSON.stringify(entry.params), JSON.stringify(entry.published), entry.tombstone ? JSON.stringify(entry.tombstone) : null, at, seedReleaseId);
1169
+ }
1170
+ }
1171
+ }
1172
+ });
1173
+ tx();
1174
+ };
1175
+ return {
1176
+ __resetProjectForTests,
1177
+ addReleaseItems,
1178
+ allPageRouteIds,
1179
+ buildStateAtRelease,
1180
+ createMediaItem,
1181
+ createPage,
1182
+ deleteMediaItem,
1183
+ discardOpenRelease,
1184
+ discardReleaseItem,
1185
+ findMediaItem,
1186
+ findOpenReleaseByPreviewKey,
1187
+ findPublishedReleaseByPreviewKey,
1188
+ getCmsVersion,
1189
+ getOpenRelease,
1190
+ getPublishedLayoutTree,
1191
+ getPublishedPageEntries,
1192
+ getPublishedPageEntry,
1193
+ getPublishedSiteTree,
1194
+ getReleaseHistory,
1195
+ listAllPages,
1196
+ listMediaItems,
1197
+ listPageParamValues,
1198
+ lookupReleaseByPreviewKey,
1199
+ publishRelease,
1200
+ regeneratePreviewKey,
1201
+ regeneratePublishedPreviewKey,
1202
+ renamePage,
1203
+ revertRelease,
1204
+ seedPublishedDocs,
1205
+ stagePageDelete
1206
+ };
1207
+ };