smoodly 0.0.6 → 0.0.7
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 +231 -73
- package/dist/admin/client-ops.js +2 -2
- package/dist/admin/editor/EditorView.js +167 -24
- package/dist/admin/editor/PageSettings.d.ts +8 -4
- package/dist/admin/editor/PageSettings.js +25 -25
- package/dist/admin/fixed-nodes.d.ts +16 -10
- package/dist/admin/fixed-nodes.js +55 -41
- package/dist/admin/ops-impl.js +134 -35
- package/dist/admin/ops.d.ts +51 -11
- package/dist/admin/shell/AdminApp.js +3 -32
- package/dist/admin/shell/EntriesList.d.ts +7 -0
- package/dist/admin/shell/EntriesList.js +95 -0
- package/dist/admin/shell/EntryForm.js +167 -49
- package/dist/admin/shell/PagesList.js +86 -26
- package/dist/admin/shell/entries-list.d.ts +21 -0
- package/dist/admin/shell/entries-list.js +36 -0
- package/dist/admin/shell/pages-tree.d.ts +30 -12
- package/dist/admin/shell/pages-tree.js +48 -14
- package/dist/admin/ui/LocaleSwitcher.d.ts +11 -0
- package/dist/admin/ui/LocaleSwitcher.js +15 -0
- package/dist/collections.d.ts +6 -0
- package/dist/collections.js +16 -0
- package/dist/config.js +23 -3
- package/dist/entry-store.d.ts +132 -54
- package/dist/entry-store.js +294 -86
- package/dist/index.d.ts +6 -6
- package/dist/index.js +4 -4
- package/dist/localize.d.ts +0 -11
- package/dist/localize.js +11 -29
- package/dist/paths.d.ts +46 -27
- package/dist/paths.js +62 -29
- package/dist/revalidate.js +2 -1
- package/dist/site.d.ts +6 -2
- package/dist/site.js +65 -39
- package/dist/sql-space.d.ts +1 -1
- package/dist/sql-space.js +13 -5
- package/dist/sql.js +96 -53
- package/dist/store.d.ts +66 -32
- package/dist/store.js +205 -90
- package/dist/supabase-entry-store.d.ts +29 -8
- package/dist/supabase-entry-store.js +341 -177
- package/dist/supabase-store.d.ts +21 -9
- package/dist/supabase-store.js +202 -111
- package/package.json +1 -1
package/dist/store.js
CHANGED
|
@@ -2,10 +2,16 @@
|
|
|
2
2
|
// never raw table access — so validation, versioning and publish hooks
|
|
3
3
|
// can't be bypassed (DESIGN.md §1). MemoryPageStore is the reference
|
|
4
4
|
// implementation and the contract-test subject; the Supabase adapter
|
|
5
|
-
// implements the same interface over pages/page_versions.
|
|
5
|
+
// implements the same interface over pages/page_locales/page_versions.
|
|
6
|
+
//
|
|
7
|
+
// The model (spec 2026-09-06): a page is ONE node with ONE row per
|
|
8
|
+
// locale it exists in. The node holds identity and tree position; each
|
|
9
|
+
// locale row owns its slug, title, publish state and version pointers,
|
|
10
|
+
// and each locale has its own history. Nothing falls back to another
|
|
11
|
+
// locale — presence IS the row.
|
|
6
12
|
import { collectTreeRefs } from "./refs.js";
|
|
7
13
|
import { MemoryPathIndex } from "./path-index.js";
|
|
8
|
-
import { assertDepth, assertSegment, chainOf, pagePathRows, } from "./paths.js";
|
|
14
|
+
import { assertDepth, assertSegment, assertSupportedLocale, chainOf, isHomeNode, pagePathRows, unsupportedLocale, } from "./paths.js";
|
|
9
15
|
export const DEFAULT_LOCALES = { default: "en", supported: ["en"] };
|
|
10
16
|
/** The options every adapter resolves the same way. */
|
|
11
17
|
export function pathOptions(options) {
|
|
@@ -24,26 +30,36 @@ export const pageErrors = {
|
|
|
24
30
|
homeChildren: () => new Error("smoodly: the home page cannot have child pages."),
|
|
25
31
|
cycle: () => new Error("smoodly: a page cannot be moved under itself."),
|
|
26
32
|
hasChildren: (id) => new Error(`smoodly: "${id}" has child pages — move or delete them first.`),
|
|
33
|
+
unsupportedLocale,
|
|
34
|
+
notInLocale: (id, locale) => new Error(`smoodly: page "${id}" does not exist in locale "${locale}".`),
|
|
35
|
+
alreadyInLocale: (id, locale) => new Error(`smoodly: page "${id}" already exists in locale "${locale}".`),
|
|
36
|
+
parentNotInLocale: (locale) => new Error(`smoodly: the parent page does not exist in locale "${locale}" — add it there first.`),
|
|
37
|
+
lastLocale: (id) => new Error(`smoodly: page "${id}" must exist in at least one locale — delete the page instead.`),
|
|
38
|
+
localeInUse: (locale, titles) => new Error(`smoodly: child pages still exist in locale "${locale}" — remove it from ${titles.map((t) => `"${t}"`).join(", ")} first.`),
|
|
39
|
+
moveLocales: (locale) => new Error(`smoodly: the destination parent does not exist in locale "${locale}" — the page cannot move under it.`),
|
|
27
40
|
};
|
|
28
|
-
export
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
41
|
+
export { assertSupportedLocale } from "./paths.js";
|
|
42
|
+
/** The page's row for a locale — the "does not exist in locale" guard every per-locale write runs. */
|
|
43
|
+
export function localeRow(page, locale) {
|
|
44
|
+
const row = page.locales[locale];
|
|
45
|
+
if (!row)
|
|
46
|
+
throw pageErrors.notInLocale(page.id, locale);
|
|
47
|
+
return row;
|
|
33
48
|
}
|
|
34
|
-
/** A page's refs rows cover
|
|
35
|
-
*
|
|
49
|
+
/** A page's refs rows cover EVERY live tree — each locale's draft and
|
|
50
|
+
* published trees — so an entry stays delete-guarded while any of them
|
|
36
51
|
* shows it. Callers dedupe on write (the SQL PK does it for real). */
|
|
37
|
-
export function pageRefEdges(
|
|
38
|
-
const edges =
|
|
39
|
-
|
|
40
|
-
edges.push(...collectTreeRefs(
|
|
52
|
+
export function pageRefEdges(trees, sections) {
|
|
53
|
+
const edges = [];
|
|
54
|
+
for (const tree of trees)
|
|
55
|
+
edges.push(...collectTreeRefs(tree, sections));
|
|
41
56
|
return edges;
|
|
42
57
|
}
|
|
43
|
-
/** The publish-integrity gate (DESIGN.md §3): a page must not
|
|
44
|
-
* showing entries the published site
|
|
45
|
-
* entry must exist
|
|
46
|
-
|
|
58
|
+
/** The publish-integrity gate (DESIGN.md §3), per locale: a page must not
|
|
59
|
+
* go live in a locale showing entries the published site cannot resolve
|
|
60
|
+
* THERE — every referenced entry must exist, have a row in that locale,
|
|
61
|
+
* and be published in it. Throws naming the offenders. */
|
|
62
|
+
export async function assertPublishable(label, tree, sections, entries, locale) {
|
|
47
63
|
const targets = new Map();
|
|
48
64
|
for (const edge of collectTreeRefs(tree, sections)) {
|
|
49
65
|
if (!targets.has(edge.targetId))
|
|
@@ -52,8 +68,11 @@ export async function assertPublishable(label, tree, sections, entries) {
|
|
|
52
68
|
const offenders = [];
|
|
53
69
|
for (const [id, { collection, field }] of targets) {
|
|
54
70
|
const entry = await entries.get(collection, id);
|
|
55
|
-
|
|
56
|
-
|
|
71
|
+
const row = entry?.locales[locale];
|
|
72
|
+
if (row?.status !== "published") {
|
|
73
|
+
const why = !entry ? ", missing" : !row ? `, not in ${locale}` : "";
|
|
74
|
+
offenders.push(`${collection}/${id} (${field}${why})`);
|
|
75
|
+
}
|
|
57
76
|
}
|
|
58
77
|
if (offenders.length > 0) {
|
|
59
78
|
throw new Error(`smoodly: cannot publish "${label}" — it references entries that are not published: ${offenders.join(", ")}.`);
|
|
@@ -79,10 +98,16 @@ export class MemoryPageStore {
|
|
|
79
98
|
return page;
|
|
80
99
|
}
|
|
81
100
|
isHome(page) {
|
|
82
|
-
return page
|
|
101
|
+
return isHomeNode(page, this.opts);
|
|
102
|
+
}
|
|
103
|
+
snapshot(page) {
|
|
104
|
+
return { ...page, locales: structuredClone(page.locales) };
|
|
105
|
+
}
|
|
106
|
+
children(id) {
|
|
107
|
+
return [...this.pages.values()].filter((p) => p.parentId === id);
|
|
83
108
|
}
|
|
84
109
|
hasChildren(id) {
|
|
85
|
-
return
|
|
110
|
+
return this.children(id).length > 0;
|
|
86
111
|
}
|
|
87
112
|
siblings(parentId, except) {
|
|
88
113
|
return [...this.pages.values()]
|
|
@@ -90,8 +115,10 @@ export class MemoryPageStore {
|
|
|
90
115
|
// null sorts last; the SQL equivalent is ORDER BY sort ASC NULLS LAST, created_at ASC.
|
|
91
116
|
.sort((a, b) => (a.sort ?? Infinity) - (b.sort ?? Infinity) || this.seq.get(a.id) - this.seq.get(b.id));
|
|
92
117
|
}
|
|
93
|
-
|
|
94
|
-
|
|
118
|
+
/** Sibling slugs are unique per parent PER LOCALE — a friendlier error
|
|
119
|
+
* than the path index's, which stays the real invariant. */
|
|
120
|
+
assertSlugFree(parentId, locale, slug, except) {
|
|
121
|
+
if (this.siblings(parentId, except).some((p) => p.locales[locale]?.slug === slug))
|
|
95
122
|
throw pageErrors.slugTaken(slug);
|
|
96
123
|
}
|
|
97
124
|
subtree(id) {
|
|
@@ -111,44 +138,71 @@ export class MemoryPageStore {
|
|
|
111
138
|
async writePaths(nodes) {
|
|
112
139
|
await this.paths.rewrite(nodes.map((n) => ({ kind: "page", id: n.id })), pagePathRows(nodes, this.byId, this.opts));
|
|
113
140
|
}
|
|
114
|
-
|
|
141
|
+
/** Every locale's draft and published trees, deduped by version id. */
|
|
142
|
+
liveTrees(page) {
|
|
143
|
+
const ids = new Set();
|
|
144
|
+
for (const row of Object.values(page.locales)) {
|
|
145
|
+
if (row.draftVersionId)
|
|
146
|
+
ids.add(row.draftVersionId);
|
|
147
|
+
if (row.publishedVersionId)
|
|
148
|
+
ids.add(row.publishedVersionId);
|
|
149
|
+
}
|
|
150
|
+
const trees = [];
|
|
151
|
+
for (const id of ids) {
|
|
152
|
+
const tree = this.versions.get(id)?.tree;
|
|
153
|
+
if (tree)
|
|
154
|
+
trees.push(tree);
|
|
155
|
+
}
|
|
156
|
+
return trees;
|
|
157
|
+
}
|
|
158
|
+
writeRefs(page) {
|
|
115
159
|
const { sections, refs } = this.options;
|
|
116
160
|
if (!sections || !refs)
|
|
117
161
|
return;
|
|
118
|
-
refs.rewrite({ sourceKind: "page", sourceId:
|
|
162
|
+
refs.rewrite({ sourceKind: "page", sourceId: page.id }, pageRefEdges(this.liveTrees(page), sections));
|
|
119
163
|
}
|
|
120
|
-
newVersion(pageId, tree) {
|
|
121
|
-
const version = { id: uid(), pageId, tree: structuredClone(tree), createdAt: Date.now() };
|
|
164
|
+
newVersion(pageId, locale, tree) {
|
|
165
|
+
const version = { id: uid(), pageId, locale, tree: structuredClone(tree), createdAt: Date.now() };
|
|
122
166
|
this.versions.set(version.id, version);
|
|
123
167
|
return version;
|
|
124
168
|
}
|
|
169
|
+
dropVersions(pageId, locale) {
|
|
170
|
+
for (const [vid, v] of this.versions) {
|
|
171
|
+
if (v.pageId === pageId && (locale === undefined || v.locale === locale))
|
|
172
|
+
this.versions.delete(vid);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
125
175
|
async createPage(input) {
|
|
176
|
+
assertSupportedLocale(input.locale, this.opts.locales);
|
|
126
177
|
assertSegment(input.slug, "page slug");
|
|
127
|
-
assertPageI18n(input.i18n);
|
|
128
178
|
const parentId = input.parentId ?? null;
|
|
129
179
|
if (parentId !== null) {
|
|
130
180
|
const parent = this.must(parentId);
|
|
131
181
|
if (this.isHome(parent))
|
|
132
182
|
throw pageErrors.homeChildren();
|
|
183
|
+
if (!parent.locales[input.locale])
|
|
184
|
+
throw pageErrors.parentNotInLocale(input.locale);
|
|
133
185
|
assertDepth(chainOf(parent, this.byId).length + 1, this.opts.depth, "the page");
|
|
134
186
|
}
|
|
135
|
-
this.assertSlugFree(parentId, input.slug);
|
|
187
|
+
this.assertSlugFree(parentId, input.locale, input.slug);
|
|
136
188
|
const id = uid();
|
|
137
|
-
const version = input.template === null ? null : this.newVersion(id, { zones: {} });
|
|
189
|
+
const version = input.template === null ? null : this.newVersion(id, input.locale, { zones: {} });
|
|
138
190
|
const page = {
|
|
139
191
|
id,
|
|
140
192
|
parentId,
|
|
141
|
-
slug: input.slug,
|
|
142
|
-
title: input.title ?? input.slug,
|
|
143
|
-
i18n: input.i18n ?? null,
|
|
144
193
|
template: input.template,
|
|
145
|
-
status: "draft",
|
|
146
194
|
// An ordering hint, not a unique key: duplicates are possible after
|
|
147
195
|
// moves/deletes, and the creation-order tie-break settles them.
|
|
148
196
|
sort: this.siblings(parentId).length + 1,
|
|
149
|
-
locales:
|
|
150
|
-
|
|
151
|
-
|
|
197
|
+
locales: {
|
|
198
|
+
[input.locale]: {
|
|
199
|
+
slug: input.slug,
|
|
200
|
+
title: input.title ?? input.slug,
|
|
201
|
+
status: "draft",
|
|
202
|
+
draftVersionId: version?.id ?? null,
|
|
203
|
+
publishedVersionId: null,
|
|
204
|
+
},
|
|
205
|
+
},
|
|
152
206
|
};
|
|
153
207
|
this.pages.set(id, page);
|
|
154
208
|
this.seq.set(id, this.seq.size + 1);
|
|
@@ -161,17 +215,17 @@ export class MemoryPageStore {
|
|
|
161
215
|
this.versions.delete(version.id);
|
|
162
216
|
throw e;
|
|
163
217
|
}
|
|
164
|
-
return
|
|
218
|
+
return this.snapshot(page);
|
|
165
219
|
}
|
|
166
220
|
async getPage(id) {
|
|
167
221
|
const page = this.pages.get(id);
|
|
168
|
-
return page ?
|
|
222
|
+
return page ? this.snapshot(page) : null;
|
|
169
223
|
}
|
|
170
224
|
async listPages() {
|
|
171
225
|
return [...this.pages.values()]
|
|
172
226
|
// null sorts last; the SQL equivalent is ORDER BY sort ASC NULLS LAST, created_at ASC.
|
|
173
227
|
.sort((a, b) => (a.sort ?? Infinity) - (b.sort ?? Infinity) || this.seq.get(a.id) - this.seq.get(b.id))
|
|
174
|
-
.map((p) => (
|
|
228
|
+
.map((p) => this.snapshot(p));
|
|
175
229
|
}
|
|
176
230
|
resolvePath(locale, path) {
|
|
177
231
|
return this.paths.resolve(locale, path);
|
|
@@ -179,8 +233,67 @@ export class MemoryPageStore {
|
|
|
179
233
|
pathsOf(id) {
|
|
180
234
|
return this.paths.pathsOf({ kind: "page", id });
|
|
181
235
|
}
|
|
236
|
+
async addLocale(id, locale, options) {
|
|
237
|
+
assertSupportedLocale(locale, this.opts.locales);
|
|
238
|
+
const page = this.must(id);
|
|
239
|
+
if (page.locales[locale])
|
|
240
|
+
throw pageErrors.alreadyInLocale(id, locale);
|
|
241
|
+
const source = localeRow(page, options.from);
|
|
242
|
+
const slug = options.slug ?? source.slug;
|
|
243
|
+
if (options.slug !== undefined)
|
|
244
|
+
assertSegment(slug, `page slug (${locale})`);
|
|
245
|
+
if (page.parentId !== null && !this.must(page.parentId).locales[locale])
|
|
246
|
+
throw pageErrors.parentNotInLocale(locale);
|
|
247
|
+
this.assertSlugFree(page.parentId, locale, slug, id);
|
|
248
|
+
// The SOURCE'S DRAFT is the copy: what the editor sees on screen when
|
|
249
|
+
// they add the locale (spec §3). A folder has no tree to copy.
|
|
250
|
+
const version = page.template === null
|
|
251
|
+
? null
|
|
252
|
+
: this.newVersion(id, locale, (source.draftVersionId && this.versions.get(source.draftVersionId)?.tree) || { zones: {} });
|
|
253
|
+
page.locales[locale] = {
|
|
254
|
+
slug,
|
|
255
|
+
title: options.title ?? source.title,
|
|
256
|
+
status: "draft",
|
|
257
|
+
draftVersionId: version?.id ?? null,
|
|
258
|
+
publishedVersionId: null,
|
|
259
|
+
};
|
|
260
|
+
try {
|
|
261
|
+
await this.writePaths([page]);
|
|
262
|
+
}
|
|
263
|
+
catch (e) {
|
|
264
|
+
delete page.locales[locale];
|
|
265
|
+
if (version)
|
|
266
|
+
this.versions.delete(version.id);
|
|
267
|
+
throw e;
|
|
268
|
+
}
|
|
269
|
+
this.writeRefs(page);
|
|
270
|
+
return this.snapshot(page);
|
|
271
|
+
}
|
|
272
|
+
async removeLocale(id, locale) {
|
|
273
|
+
const page = this.must(id);
|
|
274
|
+
const removed = localeRow(page, locale);
|
|
275
|
+
if (Object.keys(page.locales).length === 1)
|
|
276
|
+
throw pageErrors.lastLocale(id);
|
|
277
|
+
// Under the parent rule only direct children can hold the locale, but
|
|
278
|
+
// the walk is cheap and the message must name every blocker.
|
|
279
|
+
const blockers = this.subtree(id).filter((n) => n.id !== id && n.locales[locale]);
|
|
280
|
+
if (blockers.length > 0)
|
|
281
|
+
throw pageErrors.localeInUse(locale, blockers.map((b) => b.locales[locale].title));
|
|
282
|
+
delete page.locales[locale];
|
|
283
|
+
try {
|
|
284
|
+
await this.writePaths([page]);
|
|
285
|
+
}
|
|
286
|
+
catch (e) {
|
|
287
|
+
page.locales[locale] = removed;
|
|
288
|
+
throw e;
|
|
289
|
+
}
|
|
290
|
+
this.dropVersions(id, locale);
|
|
291
|
+
this.writeRefs(page);
|
|
292
|
+
return this.snapshot(page);
|
|
293
|
+
}
|
|
182
294
|
async rename(id, patch) {
|
|
183
295
|
const page = this.must(id);
|
|
296
|
+
const row = localeRow(page, patch.locale);
|
|
184
297
|
if (patch.slug !== undefined)
|
|
185
298
|
assertSegment(patch.slug, `page slug (${patch.locale})`);
|
|
186
299
|
// A rename that turns this node INTO the home node (root, default-locale
|
|
@@ -193,28 +306,21 @@ export class MemoryPageStore {
|
|
|
193
306
|
this.hasChildren(id)) {
|
|
194
307
|
throw pageErrors.homeChildren();
|
|
195
308
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
page.title = patch.title;
|
|
204
|
-
}
|
|
205
|
-
else {
|
|
206
|
-
const i18n = { ...(page.i18n ?? {}) };
|
|
207
|
-
i18n[patch.locale] = { ...(i18n[patch.locale] ?? {}), ...(patch.slug !== undefined ? { slug: patch.slug } : {}), ...(patch.title !== undefined ? { title: patch.title } : {}) };
|
|
208
|
-
page.i18n = i18n;
|
|
209
|
-
}
|
|
309
|
+
if (patch.slug !== undefined)
|
|
310
|
+
this.assertSlugFree(page.parentId, patch.locale, patch.slug, id);
|
|
311
|
+
const before = { ...row };
|
|
312
|
+
if (patch.slug !== undefined)
|
|
313
|
+
row.slug = patch.slug;
|
|
314
|
+
if (patch.title !== undefined)
|
|
315
|
+
row.title = patch.title;
|
|
210
316
|
try {
|
|
211
317
|
await this.writePaths(this.subtree(id));
|
|
212
318
|
}
|
|
213
319
|
catch (e) {
|
|
214
|
-
Object.assign(
|
|
320
|
+
Object.assign(row, before);
|
|
215
321
|
throw e;
|
|
216
322
|
}
|
|
217
|
-
return
|
|
323
|
+
return this.snapshot(page);
|
|
218
324
|
}
|
|
219
325
|
async move(id, to) {
|
|
220
326
|
const page = this.must(id);
|
|
@@ -224,18 +330,25 @@ export class MemoryPageStore {
|
|
|
224
330
|
throw pageErrors.cycle();
|
|
225
331
|
if (this.isHome(parent))
|
|
226
332
|
throw pageErrors.homeChildren();
|
|
333
|
+
// The parent rule, on the move: every locale the page has must be
|
|
334
|
+
// present at the destination, or its chain would break there.
|
|
335
|
+
for (const locale of Object.keys(page.locales))
|
|
336
|
+
if (!parent.locales[locale])
|
|
337
|
+
throw pageErrors.moveLocales(locale);
|
|
227
338
|
const subtreeDepth = Math.max(...this.subtree(id).map((n) => chainOf(n, this.byId).length)) - chainOf(page, this.byId).length + 1;
|
|
228
339
|
assertDepth(chainOf(parent, this.byId).length + subtreeDepth, this.opts.depth, "the page");
|
|
229
340
|
}
|
|
230
|
-
else if (page.slug === this.opts.homePageSlug && this.hasChildren(id)) {
|
|
341
|
+
else if (page.locales[this.opts.locales.default]?.slug === this.opts.homePageSlug && this.hasChildren(id)) {
|
|
231
342
|
// Moving a home-slugged node TO the root is the mirror case of the
|
|
232
343
|
// rename guard above: it would make it the home node while it still
|
|
233
344
|
// has children.
|
|
234
345
|
throw pageErrors.homeChildren();
|
|
235
346
|
}
|
|
236
|
-
if (to.parentId !== page.parentId)
|
|
237
|
-
|
|
238
|
-
|
|
347
|
+
if (to.parentId !== page.parentId) {
|
|
348
|
+
for (const [locale, row] of Object.entries(page.locales))
|
|
349
|
+
this.assertSlugFree(to.parentId, locale, row.slug, id);
|
|
350
|
+
}
|
|
351
|
+
const before = { parentId: page.parentId, sort: page.sort };
|
|
239
352
|
page.parentId = to.parentId;
|
|
240
353
|
const siblings = this.siblings(to.parentId, id);
|
|
241
354
|
const index = Math.min(Math.max(to.index ?? siblings.length, 0), siblings.length);
|
|
@@ -251,54 +364,58 @@ export class MemoryPageStore {
|
|
|
251
364
|
s.sort = previousSort.get(s.id) ?? null;
|
|
252
365
|
throw e;
|
|
253
366
|
}
|
|
254
|
-
return
|
|
367
|
+
return this.snapshot(page);
|
|
255
368
|
}
|
|
256
369
|
async setTemplate(id, template) {
|
|
257
370
|
const page = this.must(id);
|
|
258
371
|
if (page.template !== null)
|
|
259
372
|
throw pageErrors.hasTemplate(id);
|
|
260
373
|
page.template = template;
|
|
261
|
-
|
|
262
|
-
|
|
374
|
+
for (const [locale, row] of Object.entries(page.locales))
|
|
375
|
+
row.draftVersionId = this.newVersion(id, locale, { zones: {} }).id;
|
|
376
|
+
return this.snapshot(page);
|
|
263
377
|
}
|
|
264
|
-
async saveDraft(id, tree) {
|
|
378
|
+
async saveDraft(id, locale, tree) {
|
|
265
379
|
const page = this.must(id);
|
|
266
380
|
if (page.template === null)
|
|
267
381
|
throw pageErrors.folder(id);
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
this.writeRefs(
|
|
272
|
-
return { ...version };
|
|
382
|
+
const row = localeRow(page, locale);
|
|
383
|
+
const version = this.newVersion(id, locale, tree);
|
|
384
|
+
row.draftVersionId = version.id;
|
|
385
|
+
this.writeRefs(page);
|
|
386
|
+
return { ...version, tree: structuredClone(version.tree) };
|
|
273
387
|
}
|
|
274
|
-
async publish(id) {
|
|
388
|
+
async publish(id, locale) {
|
|
275
389
|
const page = this.must(id);
|
|
276
|
-
if (page.template === null
|
|
390
|
+
if (page.template === null)
|
|
391
|
+
throw pageErrors.folder(id);
|
|
392
|
+
const row = localeRow(page, locale);
|
|
393
|
+
if (!row.draftVersionId)
|
|
277
394
|
throw pageErrors.folder(id);
|
|
278
395
|
const { sections, entries } = this.options;
|
|
279
|
-
const draft = this.versions.get(
|
|
396
|
+
const draft = this.versions.get(row.draftVersionId)?.tree ?? { zones: {} };
|
|
280
397
|
if (sections && entries)
|
|
281
|
-
await assertPublishable(
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
this.writeRefs(
|
|
285
|
-
return
|
|
398
|
+
await assertPublishable(row.slug, draft, sections, entries, locale);
|
|
399
|
+
row.publishedVersionId = row.draftVersionId;
|
|
400
|
+
row.status = "published";
|
|
401
|
+
this.writeRefs(page);
|
|
402
|
+
return this.snapshot(page);
|
|
286
403
|
}
|
|
287
|
-
async getDraftTree(id) {
|
|
288
|
-
const
|
|
289
|
-
if (!
|
|
404
|
+
async getDraftTree(id, locale) {
|
|
405
|
+
const row = this.pages.get(id)?.locales[locale];
|
|
406
|
+
if (!row?.draftVersionId)
|
|
290
407
|
return null;
|
|
291
|
-
return structuredClone(this.versions.get(
|
|
408
|
+
return structuredClone(this.versions.get(row.draftVersionId)?.tree ?? null);
|
|
292
409
|
}
|
|
293
|
-
async getPublishedTree(id) {
|
|
294
|
-
const
|
|
295
|
-
if (!
|
|
410
|
+
async getPublishedTree(id, locale) {
|
|
411
|
+
const row = this.pages.get(id)?.locales[locale];
|
|
412
|
+
if (!row?.publishedVersionId)
|
|
296
413
|
return null;
|
|
297
|
-
return structuredClone(this.versions.get(
|
|
414
|
+
return structuredClone(this.versions.get(row.publishedVersionId)?.tree ?? null);
|
|
298
415
|
}
|
|
299
|
-
async listVersions(id) {
|
|
416
|
+
async listVersions(id, locale) {
|
|
300
417
|
return [...this.versions.values()]
|
|
301
|
-
.filter((v) => v.pageId === id)
|
|
418
|
+
.filter((v) => v.pageId === id && v.locale === locale)
|
|
302
419
|
.sort((a, b) => b.createdAt - a.createdAt || b.id.localeCompare(a.id))
|
|
303
420
|
.map((v) => ({ ...v, tree: structuredClone(v.tree) }));
|
|
304
421
|
}
|
|
@@ -312,9 +429,7 @@ export class MemoryPageStore {
|
|
|
312
429
|
// call that can fail, and a failed rewrite must leave this store's own
|
|
313
430
|
// state (versions, refs, the page map) untouched.
|
|
314
431
|
await this.paths.remove([{ kind: "page", id }]);
|
|
315
|
-
|
|
316
|
-
if (v.pageId === id)
|
|
317
|
-
this.versions.delete(vid);
|
|
432
|
+
this.dropVersions(id);
|
|
318
433
|
this.options.refs?.remove({ sourceKind: "page", sourceId: id });
|
|
319
434
|
this.pages.delete(id);
|
|
320
435
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
2
2
|
import type { CollectionSchema } from "./collections.ts";
|
|
3
|
-
import type { CreateEntryOptions, EntryRecord, EntryStore, EntryStoreOptions, EntryUsage, ListOptions } from "./entry-store.ts";
|
|
3
|
+
import type { AddEntryLocaleOptions, CreateEntryOptions, EntryRecord, EntryStore, EntryStoreOptions, EntryUsage, EntryVersion, ListOptions } from "./entry-store.ts";
|
|
4
4
|
export declare class SupabaseEntryStore implements EntryStore {
|
|
5
5
|
private db;
|
|
6
6
|
private collections;
|
|
@@ -8,22 +8,43 @@ export declare class SupabaseEntryStore implements EntryStore {
|
|
|
8
8
|
private locales;
|
|
9
9
|
constructor(db: SupabaseClient, collections: CollectionSchema<any>[], options?: EntryStoreOptions);
|
|
10
10
|
private fail;
|
|
11
|
-
private
|
|
12
|
-
private
|
|
11
|
+
private schema;
|
|
12
|
+
private fetch;
|
|
13
|
+
private must;
|
|
14
|
+
private siblings;
|
|
13
15
|
private byIdMap;
|
|
14
16
|
private chain;
|
|
15
17
|
private subtree;
|
|
18
|
+
private versionFields;
|
|
16
19
|
private writePaths;
|
|
20
|
+
/** Every field set the site can render: each locale's live view, plus a
|
|
21
|
+
* published snapshot the draft has moved past (see entryRefEdges). */
|
|
22
|
+
private liveFieldSets;
|
|
23
|
+
private writeRefs;
|
|
24
|
+
/** The friendly pre-check per locale (spec 2026-09-06 §11); the paths
|
|
25
|
+
* primary key is the invariant. */
|
|
26
|
+
private assertSiblingSlug;
|
|
17
27
|
private assertParent;
|
|
18
|
-
|
|
28
|
+
private assertParentHasLocale;
|
|
29
|
+
private updateNode;
|
|
30
|
+
private insertLocale;
|
|
31
|
+
private updateLocale;
|
|
32
|
+
/** The row goes first (it is what makes the entry exist in the locale), then its versions. */
|
|
33
|
+
private deleteLocale;
|
|
34
|
+
/** A versioned collection snapshots the merged fields and points the
|
|
35
|
+
* locale's draft at the new row; others do nothing here. */
|
|
36
|
+
private recordVersion;
|
|
37
|
+
create(collection: string, fields: Record<string, unknown>, options: CreateEntryOptions): Promise<EntryRecord>;
|
|
19
38
|
get(collection: string, id: string): Promise<EntryRecord | null>;
|
|
20
39
|
list(collection: string, options?: ListOptions): Promise<EntryRecord[]>;
|
|
21
|
-
getMany(collection: string, ids: string[], options?: {
|
|
40
|
+
getMany(collection: string, ids: string[], locale: string, options?: {
|
|
22
41
|
status?: "published";
|
|
23
42
|
}): Promise<Record<string, Record<string, unknown>>>;
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
43
|
+
update(collection: string, id: string, locale: string, fields: Record<string, unknown>): Promise<EntryRecord>;
|
|
44
|
+
setStatus(collection: string, id: string, locale: string, status: "draft" | "published"): Promise<EntryRecord>;
|
|
45
|
+
addLocale(collection: string, id: string, locale: string, options: AddEntryLocaleOptions): Promise<EntryRecord>;
|
|
46
|
+
removeLocale(collection: string, id: string, locale: string): Promise<EntryRecord>;
|
|
47
|
+
listVersions(collection: string, id: string, locale: string): Promise<EntryVersion[]>;
|
|
27
48
|
move(collection: string, id: string, to: {
|
|
28
49
|
parentId: string | null;
|
|
29
50
|
index?: number;
|