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.
Files changed (44) hide show
  1. package/README.md +231 -73
  2. package/dist/admin/client-ops.js +2 -2
  3. package/dist/admin/editor/EditorView.js +167 -24
  4. package/dist/admin/editor/PageSettings.d.ts +8 -4
  5. package/dist/admin/editor/PageSettings.js +25 -25
  6. package/dist/admin/fixed-nodes.d.ts +16 -10
  7. package/dist/admin/fixed-nodes.js +55 -41
  8. package/dist/admin/ops-impl.js +134 -35
  9. package/dist/admin/ops.d.ts +51 -11
  10. package/dist/admin/shell/AdminApp.js +3 -32
  11. package/dist/admin/shell/EntriesList.d.ts +7 -0
  12. package/dist/admin/shell/EntriesList.js +95 -0
  13. package/dist/admin/shell/EntryForm.js +167 -49
  14. package/dist/admin/shell/PagesList.js +86 -26
  15. package/dist/admin/shell/entries-list.d.ts +21 -0
  16. package/dist/admin/shell/entries-list.js +36 -0
  17. package/dist/admin/shell/pages-tree.d.ts +30 -12
  18. package/dist/admin/shell/pages-tree.js +48 -14
  19. package/dist/admin/ui/LocaleSwitcher.d.ts +11 -0
  20. package/dist/admin/ui/LocaleSwitcher.js +15 -0
  21. package/dist/collections.d.ts +6 -0
  22. package/dist/collections.js +16 -0
  23. package/dist/config.js +23 -3
  24. package/dist/entry-store.d.ts +132 -54
  25. package/dist/entry-store.js +294 -86
  26. package/dist/index.d.ts +6 -6
  27. package/dist/index.js +4 -4
  28. package/dist/localize.d.ts +0 -11
  29. package/dist/localize.js +11 -29
  30. package/dist/paths.d.ts +46 -27
  31. package/dist/paths.js +62 -29
  32. package/dist/revalidate.js +2 -1
  33. package/dist/site.d.ts +6 -2
  34. package/dist/site.js +65 -39
  35. package/dist/sql-space.d.ts +1 -1
  36. package/dist/sql-space.js +13 -5
  37. package/dist/sql.js +96 -53
  38. package/dist/store.d.ts +66 -32
  39. package/dist/store.js +205 -90
  40. package/dist/supabase-entry-store.d.ts +29 -8
  41. package/dist/supabase-entry-store.js +341 -177
  42. package/dist/supabase-store.d.ts +21 -9
  43. package/dist/supabase-store.js +202 -111
  44. 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 function assertPageI18n(i18n) {
29
- for (const [locale, v] of Object.entries(i18n ?? {})) {
30
- if (v.slug !== undefined)
31
- assertSegment(v.slug, `page slug (${locale})`);
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 BOTH live treesedges(draft)
35
- * edges(published) — so an entry stays delete-guarded while either tree
49
+ /** A page's refs rows cover EVERY live treeeach 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(draft, published, sections) {
38
- const edges = collectTreeRefs(draft, sections);
39
- if (published)
40
- edges.push(...collectTreeRefs(published, sections));
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 go live
44
- * showing entries the published site can't resolve — every referenced
45
- * entry must exist and be published. Throws naming the offenders. */
46
- export async function assertPublishable(label, tree, sections, entries) {
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
- if (entry?.status !== "published")
56
- offenders.push(`${collection}/${id} (${field}${entry ? "" : ", missing"})`);
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.parentId === null && page.slug === this.opts.homePageSlug;
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 [...this.pages.values()].some((p) => p.parentId === id);
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
- assertSlugFree(parentId, slug, except) {
94
- if (this.siblings(parentId, except).some((p) => p.slug === slug))
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
- writeRefs(pageId, draft, published) {
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: pageId }, pageRefEdges(draft, published, sections));
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: input.locales ?? null,
150
- draftVersionId: version?.id ?? null,
151
- publishedVersionId: null,
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 { ...page };
218
+ return this.snapshot(page);
165
219
  }
166
220
  async getPage(id) {
167
221
  const page = this.pages.get(id);
168
- return page ? { ...page } : null;
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) => ({ ...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
- const before = { ...page, i18n: page.i18n ? structuredClone(page.i18n) : null };
197
- if (patch.locale === this.opts.locales.default) {
198
- if (patch.slug !== undefined) {
199
- this.assertSlugFree(page.parentId, patch.slug, id);
200
- page.slug = patch.slug;
201
- }
202
- if (patch.title !== undefined)
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(page, before);
320
+ Object.assign(row, before);
215
321
  throw e;
216
322
  }
217
- return { ...page };
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
- this.assertSlugFree(to.parentId, page.slug, id);
238
- const before = { ...page };
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 { ...page };
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
- page.draftVersionId = this.newVersion(id, { zones: {} }).id;
262
- return { ...page };
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 version = this.newVersion(id, tree);
269
- page.draftVersionId = version.id;
270
- const published = page.publishedVersionId ? (this.versions.get(page.publishedVersionId)?.tree ?? null) : null;
271
- this.writeRefs(id, version.tree, published);
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 || !page.draftVersionId)
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(page.draftVersionId)?.tree ?? { zones: {} };
396
+ const draft = this.versions.get(row.draftVersionId)?.tree ?? { zones: {} };
280
397
  if (sections && entries)
281
- await assertPublishable(page.slug, draft, sections, entries);
282
- page.publishedVersionId = page.draftVersionId;
283
- page.status = "published";
284
- this.writeRefs(id, draft, null);
285
- return { ...page };
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 page = this.pages.get(id);
289
- if (!page?.draftVersionId)
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(page.draftVersionId)?.tree ?? null);
408
+ return structuredClone(this.versions.get(row.draftVersionId)?.tree ?? null);
292
409
  }
293
- async getPublishedTree(id) {
294
- const page = this.pages.get(id);
295
- if (!page?.publishedVersionId)
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(page.publishedVersionId)?.tree ?? null);
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
- for (const [vid, v] of this.versions)
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 failWriteError;
12
- private writeRefs;
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
- create(collection: string, fields: Record<string, unknown>, options?: CreateEntryOptions): Promise<EntryRecord>;
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
- private patch;
25
- update(collection: string, id: string, fields: Record<string, unknown>): Promise<EntryRecord>;
26
- setStatus(collection: string, id: string, status: "draft" | "published"): Promise<EntryRecord>;
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;