@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.
- package/README.md +1 -1
- package/dist/backend/auth/editors.d.ts +51 -0
- package/dist/backend/auth/editors.js +157 -0
- package/dist/backend/auth/local-editors.d.ts +22 -0
- package/dist/backend/auth/local-editors.js +44 -0
- package/dist/backend/auth/scrypt.d.ts +20 -0
- package/dist/backend/auth/scrypt.js +80 -0
- package/dist/backend/factory.d.ts +25 -0
- package/dist/backend/factory.js +202 -0
- package/dist/backend/http.d.ts +34 -0
- package/dist/backend/http.js +79 -0
- package/dist/backend/index.d.ts +45 -0
- package/dist/backend/index.js +39 -0
- package/dist/backend/router.d.ts +52 -0
- package/dist/backend/router.js +57 -0
- package/dist/backend/routes/context.d.ts +46 -0
- package/dist/backend/routes/context.js +30 -0
- package/dist/backend/routes/docs.d.ts +2 -0
- package/dist/backend/routes/docs.js +150 -0
- package/dist/backend/routes/login.d.ts +19 -0
- package/dist/backend/routes/login.js +155 -0
- package/dist/backend/routes/media.d.ts +22 -0
- package/dist/backend/routes/media.js +119 -0
- package/dist/backend/routes/pages.d.ts +19 -0
- package/dist/backend/routes/pages.js +221 -0
- package/dist/backend/routes/release.d.ts +10 -0
- package/dist/backend/routes/release.js +147 -0
- package/dist/backend/routes/site.d.ts +2 -0
- package/dist/backend/routes/site.js +40 -0
- package/dist/backend/routes/table.d.ts +11 -0
- package/dist/backend/routes/table.js +72 -0
- package/dist/backend/routes/test-reset.d.ts +15 -0
- package/dist/backend/routes/test-reset.js +112 -0
- package/dist/backend/routes/user.d.ts +14 -0
- package/dist/backend/routes/user.js +21 -0
- package/dist/backend/storage.d.ts +18 -0
- package/dist/backend/storage.js +45 -0
- package/dist/backend/store/cache.d.ts +34 -0
- package/dist/backend/store/cache.js +38 -0
- package/dist/backend/store/db.d.ts +10 -0
- package/dist/backend/store/db.js +29 -0
- package/dist/backend/store/migrate.d.ts +10 -0
- package/dist/backend/store/migrate.js +30 -0
- package/dist/backend/store/migrations/0001-init.d.ts +1 -0
- package/dist/backend/store/migrations/0001-init.js +129 -0
- package/dist/backend/store/migrations/0002-site.d.ts +1 -0
- package/dist/backend/store/migrations/0002-site.js +86 -0
- package/dist/backend/store/migrations/0003-editors.d.ts +1 -0
- package/dist/backend/store/migrations/0003-editors.js +44 -0
- package/dist/backend/store/migrations/index.d.ts +15 -0
- package/dist/backend/store/migrations/index.js +18 -0
- package/dist/backend/store/queries.d.ts +67 -0
- package/dist/backend/store/queries.js +1207 -0
- package/dist/backend/store/sqlite.d.ts +22 -0
- package/dist/backend/store/sqlite.js +29 -0
- package/dist/backend/testing/fixtures.d.ts +39 -0
- package/dist/backend/testing/fixtures.js +61 -0
- package/dist/backend/testing/harness.d.ts +35 -0
- package/dist/backend/testing/harness.js +149 -0
- package/dist/backend/types.d.ts +102 -0
- package/dist/cms.d.ts +3 -3
- package/dist/cms.js +1 -1
- package/dist/components/admin-bar/publish-dialog.svelte +1 -1
- package/dist/components/cms/cms-image-editable.svelte +1 -1
- package/dist/components/cms/cms-store.svelte.d.ts +3 -68
- package/dist/components/cms/cms-store.svelte.js +25 -10
- package/dist/components/cms/locale-merge.d.ts +1 -1
- package/dist/components/cms/locale-merge.js +1 -1
- package/dist/components/cms/scope.d.ts +1 -1
- package/dist/core/page-entry.d.ts +43 -0
- package/dist/core/page-entry.js +21 -0
- package/dist/{components/cms → core}/path.d.ts +13 -0
- package/dist/{components/cms → core}/path.js +36 -0
- package/dist/core/wire.d.ts +182 -0
- package/dist/core/wire.js +1 -0
- package/dist/{srv → server}/api-adapter.js +2 -2
- package/dist/{srv → server}/load-cms.js +1 -1
- package/dist/{srv → server}/mock-adapter.d.ts +3 -24
- package/dist/{srv → server}/mock-adapter.js +5 -22
- package/dist/{srv → server}/types.d.ts +2 -6
- package/dist/server/types.js +1 -0
- package/package.json +13 -4
- package/plugin/dist/index.js +7 -0
- package/plugin/dist/index.js.map +1 -1
- package/plugin/dist/media.d.ts +16 -7
- package/plugin/dist/media.js +23 -10
- package/plugin/dist/media.js.map +1 -1
- /package/dist/{srv → backend}/types.js +0 -0
- /package/dist/{srv → server}/README.md +0 -0
- /package/dist/{srv → server}/api-adapter.d.ts +0 -0
- /package/dist/{srv → server}/cms.d.ts +0 -0
- /package/dist/{srv → server}/cms.js +0 -0
- /package/dist/{srv → server}/index.d.ts +0 -0
- /package/dist/{srv → server}/index.js +0 -0
- /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
|
+
};
|