smoodly 0.0.6 → 0.0.8

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 (70) hide show
  1. package/README.md +318 -74
  2. package/dist/admin/client-ops.js +3 -2
  3. package/dist/admin/editor/EditorView.js +229 -30
  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/index.d.ts +3 -2
  9. package/dist/admin/index.js +1 -0
  10. package/dist/admin/ops-impl.js +218 -36
  11. package/dist/admin/ops.d.ts +73 -12
  12. package/dist/admin/serialize.d.ts +11 -0
  13. package/dist/admin/serialize.js +8 -0
  14. package/dist/admin/shared-items.d.ts +9 -0
  15. package/dist/admin/shared-items.js +61 -0
  16. package/dist/admin/shell/AdminApp.js +11 -34
  17. package/dist/admin/shell/EntriesList.d.ts +7 -0
  18. package/dist/admin/shell/EntriesList.js +95 -0
  19. package/dist/admin/shell/EntryForm.js +167 -49
  20. package/dist/admin/shell/PagesList.js +86 -26
  21. package/dist/admin/shell/SharedForm.d.ts +7 -0
  22. package/dist/admin/shell/SharedForm.js +146 -0
  23. package/dist/admin/shell/SharedList.d.ts +6 -0
  24. package/dist/admin/shell/SharedList.js +62 -0
  25. package/dist/admin/shell/entries-list.d.ts +21 -0
  26. package/dist/admin/shell/entries-list.js +36 -0
  27. package/dist/admin/shell/pages-tree.d.ts +30 -12
  28. package/dist/admin/shell/pages-tree.js +48 -14
  29. package/dist/admin/shell/shared-list.d.ts +14 -0
  30. package/dist/admin/shell/shared-list.js +17 -0
  31. package/dist/admin/tree-ops.d.ts +12 -5
  32. package/dist/admin/tree-ops.js +39 -8
  33. package/dist/admin/ui/LocaleSwitcher.d.ts +11 -0
  34. package/dist/admin/ui/LocaleSwitcher.js +15 -0
  35. package/dist/collections.d.ts +10 -1
  36. package/dist/collections.js +16 -0
  37. package/dist/config.d.ts +3 -0
  38. package/dist/config.js +58 -4
  39. package/dist/entry-store.d.ts +141 -55
  40. package/dist/entry-store.js +317 -87
  41. package/dist/index.d.ts +11 -7
  42. package/dist/index.js +8 -5
  43. package/dist/localize.d.ts +0 -11
  44. package/dist/localize.js +11 -29
  45. package/dist/next/page-renderer.d.ts +4 -0
  46. package/dist/next/page-renderer.js +14 -1
  47. package/dist/page.js +5 -1
  48. package/dist/paths.d.ts +46 -27
  49. package/dist/paths.js +62 -29
  50. package/dist/refs.js +8 -0
  51. package/dist/resolve.d.ts +3 -0
  52. package/dist/resolve.js +38 -0
  53. package/dist/revalidate.d.ts +4 -0
  54. package/dist/revalidate.js +6 -1
  55. package/dist/shared.d.ts +46 -0
  56. package/dist/shared.js +36 -0
  57. package/dist/site.d.ts +16 -2
  58. package/dist/site.js +92 -39
  59. package/dist/sql-space.d.ts +1 -1
  60. package/dist/sql-space.js +16 -13
  61. package/dist/sql.js +95 -70
  62. package/dist/store.d.ts +66 -32
  63. package/dist/store.js +205 -90
  64. package/dist/supabase-entry-store.d.ts +30 -9
  65. package/dist/supabase-entry-store.js +360 -178
  66. package/dist/supabase-store.d.ts +21 -9
  67. package/dist/supabase-store.js +202 -111
  68. package/dist/zones.d.ts +6 -2
  69. package/dist/zones.js +8 -2
  70. package/package.json +1 -1
@@ -1,79 +1,81 @@
1
- // The Supabase adapter for the EntryStore contract, over entries + refs.
2
- // Service-role client, server-side only (writes bypass RLS by design —
3
- // DESIGN.md §1). Reorder calls the smoodly_reorder SQL function: one
4
- // statement for the whole collection, updated_at untouched.
1
+ // The Supabase adapter for the EntryStore contract, over entries +
2
+ // entry_locales + entry_versions + refs + paths. Service-role client (or
3
+ // a cloud write key), server-side only. Every read embeds the locale
4
+ // rows (`*, entry_locales(*)`) so a record arrives whole; a versioned
5
+ // collection's published read goes through entry_versions. Path rows are
6
+ // rewritten through ONE RPC per write, so a collision rolls back. Reorder
7
+ // calls the smoodly_reorder SQL function: one statement, updated_at
8
+ // untouched.
5
9
  import { collectionDepth, collectionOrder } from "./collections.js";
6
- import { DEFAULT_ENTRY_LOCALES, entryErrors, schemaOf } from "./entry-store.js";
7
- import { collectEntryRefs } from "./refs.js";
10
+ import { DEFAULT_ENTRY_LOCALES, entryErrors, entryFieldsIn, entryLocaleRow, entryRefEdges, entryTitleIn, isSharedSchema, schemaOf, splitFields, } from "./entry-store.js";
8
11
  import { SupabasePathIndex } from "./supabase-path-index.js";
9
- import { assertDepth, entryPathRows, slugOf } from "./paths.js";
12
+ import { assertDepth, assertSupportedLocale, entryPathRows, slugOf } from "./paths.js";
10
13
  // entries.id is a Postgres uuid column: filtering on a non-UUID string
11
14
  // throws invalid_text_representation. A non-UUID id can't exist in the
12
- // table, so treat it the same as a missing one — getMany silently omits
13
- // it, patch raises the contract's "no entry" error — rather than letting
14
- // the cast error leak through.
15
+ // table, so treat it the same as a missing one.
15
16
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
16
17
  const UNIQUE_VIOLATION = "23505";
18
+ const SELECT = "*, entry_locales(*)";
19
+ function toLocale(row) {
20
+ return {
21
+ slug: row.slug,
22
+ status: row.status,
23
+ fields: row.fields,
24
+ draftVersionId: row.draft_version_id,
25
+ publishedVersionId: row.published_version_id,
26
+ updatedAt: Date.parse(row.updated_at),
27
+ };
28
+ }
17
29
  function toRecord(row) {
18
30
  return {
19
31
  id: row.id,
20
32
  collection: row.collection,
21
33
  parentId: row.parent_id,
22
34
  fields: row.fields,
23
- i18n: row.i18n,
24
- status: row.status,
35
+ locales: Object.fromEntries((row.entry_locales ?? []).map((l) => [l.locale, toLocale(l)])),
25
36
  sort: row.sort,
26
- locales: row.locales,
27
37
  createdAt: Date.parse(row.created_at),
28
38
  updatedAt: Date.parse(row.updated_at),
29
39
  };
30
40
  }
41
+ function toVersion(row) {
42
+ return { id: row.id, entryId: row.entry_id, locale: row.locale, fields: row.fields, createdAt: Date.parse(row.created_at) };
43
+ }
31
44
  export class SupabaseEntryStore {
32
45
  constructor(db, collections, options = {}) {
33
46
  this.db = db;
34
- this.collections = collections;
35
47
  this.paths = options.paths ?? new SupabasePathIndex(db);
36
48
  this.locales = options.locales ?? DEFAULT_ENTRY_LOCALES;
49
+ this.schemas = [...collections, ...(options.shared ?? [])];
37
50
  }
38
51
  fail(message) {
39
52
  throw new Error(`smoodly: ${message}`);
40
53
  }
41
- // entries_sibling_slug_key (space_id, collection, parent_id,
42
- // fields->>'slug') enforces slug uniqueness among SIBLINGS at the DB
43
- // level, ahead of the paths rewrite — a collision here never reaches
44
- // writePaths, so the insert, the update and move's re-parent all need
45
- // this translation. The text comes from entryErrors so the memory
46
- // adapter's pre-check and this one read identically.
47
- failWriteError(error, collection) {
48
- if (error.code === UNIQUE_VIOLATION)
49
- throw entryErrors.slugTaken(collection);
50
- this.fail(error.message);
51
- }
52
- async writeRefs(collection, id, fields) {
53
- const schema = schemaOf(this.collections, collection);
54
- const deleted = await this.db
55
- .from("refs")
56
- .delete()
57
- .eq("source_kind", "entry")
58
- .eq("source_id", id);
59
- if (deleted.error)
60
- this.fail(deleted.error.message);
61
- // dedupe: the refs PK is (source_kind, source_id, source_field, target_id)
62
- const rows = new Map();
63
- for (const edge of collectEntryRefs(fields, schema)) {
64
- rows.set(`${edge.field}\x00${edge.targetId}`, {
65
- source_kind: "entry",
66
- source_id: id,
67
- source_field: edge.field,
68
- target_collection: edge.targetCollection,
69
- target_id: edge.targetId,
70
- });
71
- }
72
- if (rows.size > 0) {
73
- const inserted = await this.db.from("refs").insert([...rows.values()]);
74
- if (inserted.error)
75
- this.fail(inserted.error.message);
76
- }
54
+ schema(collection) {
55
+ return schemaOf(this.schemas, collection);
56
+ }
57
+ // ── reads ──
58
+ async fetch(collection, id) {
59
+ if (!UUID_RE.test(id))
60
+ return null;
61
+ const res = await this.db.from("entries").select(SELECT).eq("collection", collection).eq("id", id).maybeSingle();
62
+ if (res.error)
63
+ this.fail(res.error.message);
64
+ return res.data ? toRecord(res.data) : null;
65
+ }
66
+ async must(collection, id) {
67
+ const entry = await this.fetch(collection, id);
68
+ if (!entry)
69
+ throw entryErrors.notFound(collection, id);
70
+ return entry;
71
+ }
72
+ async siblings(collection, parentId, except) {
73
+ let query = this.db.from("entries").select(SELECT).eq("collection", collection);
74
+ query = parentId === null ? query.is("parent_id", null) : query.eq("parent_id", parentId);
75
+ const res = await query.order("sort", { ascending: true, nullsFirst: false }).order("created_at", { ascending: true });
76
+ if (res.error)
77
+ this.fail(res.error.message);
78
+ return res.data.map(toRecord).filter((e) => e.id !== except);
77
79
  }
78
80
  byIdMap(rows) {
79
81
  const map = new Map(rows.map((r) => [r.id, r]));
@@ -83,7 +85,7 @@ export class SupabaseEntryStore {
83
85
  const chain = [entry];
84
86
  let current = entry;
85
87
  while (current.parentId !== null) {
86
- const parent = await this.get(current.collection, current.parentId);
88
+ const parent = await this.fetch(current.collection, current.parentId);
87
89
  if (!parent)
88
90
  this.fail(`broken parent chain at "${entry.id}".`);
89
91
  chain.unshift(parent);
@@ -95,7 +97,7 @@ export class SupabaseEntryStore {
95
97
  const out = [entry];
96
98
  let frontier = [entry.id];
97
99
  while (frontier.length > 0) {
98
- const res = await this.db.from("entries").select("*").eq("collection", entry.collection).in("parent_id", frontier);
100
+ const res = await this.db.from("entries").select(SELECT).eq("collection", entry.collection).in("parent_id", frontier);
99
101
  if (res.error)
100
102
  this.fail(res.error.message);
101
103
  const rows = res.data.map(toRecord);
@@ -104,18 +106,75 @@ export class SupabaseEntryStore {
104
106
  }
105
107
  return out;
106
108
  }
109
+ async versionFields(ids) {
110
+ if (ids.length === 0)
111
+ return new Map();
112
+ const res = await this.db.from("entry_versions").select("id, fields").in("id", ids);
113
+ if (res.error)
114
+ this.fail(res.error.message);
115
+ return new Map(res.data.map((r) => [r.id, r.fields]));
116
+ }
117
+ // ── the derived indexes ──
107
118
  async writePaths(top) {
108
- const schema = schemaOf(this.collections, top.collection);
119
+ const schema = this.schema(top.collection);
109
120
  const ancestors = await this.chain(top);
110
121
  const nodes = await this.subtree(top);
111
122
  await this.paths.rewrite(nodes.map((n) => ({ kind: "entry", id: n.id })), entryPathRows(nodes, this.byIdMap([...ancestors, ...nodes]), schema, this.locales));
112
123
  }
124
+ /** Every field set the site can render: each locale's live view, plus a
125
+ * published snapshot the draft has moved past (see entryRefEdges). */
126
+ async liveFieldSets(entry) {
127
+ const sets = [];
128
+ const snapshotIds = [];
129
+ for (const [locale, row] of Object.entries(entry.locales)) {
130
+ sets.push(entryFieldsIn(entry, locale));
131
+ if (row.status === "published" && row.publishedVersionId && row.publishedVersionId !== row.draftVersionId) {
132
+ snapshotIds.push(row.publishedVersionId);
133
+ }
134
+ }
135
+ for (const fields of (await this.versionFields(snapshotIds)).values())
136
+ sets.push(fields);
137
+ return sets;
138
+ }
139
+ async writeRefs(entry) {
140
+ const schema = this.schema(entry.collection);
141
+ const deleted = await this.db.from("refs").delete().eq("source_kind", "entry").eq("source_id", entry.id);
142
+ if (deleted.error)
143
+ this.fail(deleted.error.message);
144
+ // dedupe: the refs PK is (source_kind, source_id, source_field, target_id)
145
+ const rows = new Map();
146
+ for (const edge of entryRefEdges(await this.liveFieldSets(entry), schema)) {
147
+ rows.set(`${edge.field}\x00${edge.targetId}`, {
148
+ source_kind: "entry",
149
+ source_id: entry.id,
150
+ source_field: edge.field,
151
+ target_collection: edge.targetCollection,
152
+ target_id: edge.targetId,
153
+ });
154
+ }
155
+ if (rows.size > 0) {
156
+ const inserted = await this.db.from("refs").insert([...rows.values()]);
157
+ if (inserted.error)
158
+ this.fail(inserted.error.message);
159
+ }
160
+ }
161
+ // ── guards ──
162
+ /** The friendly pre-check per locale (spec 2026-09-06 §11); the paths
163
+ * primary key is the invariant. */
164
+ async assertSiblingSlug(collection, parentId, locale, slug, except) {
165
+ if (slug === null)
166
+ return;
167
+ for (const s of await this.siblings(collection, parentId, except)) {
168
+ if (s.locales[locale]?.slug === slug)
169
+ throw entryErrors.slugTaken(collection);
170
+ }
171
+ }
113
172
  async assertParent(collection, parentId, subtreeHeight) {
114
173
  if (parentId === null)
115
- return;
174
+ return null;
116
175
  if (!UUID_RE.test(parentId))
117
176
  throw entryErrors.notFound(collection, parentId);
118
- const res = await this.db.from("entries").select("*").eq("id", parentId).maybeSingle();
177
+ const res = await this.db.from("entries").select(SELECT).eq("id", parentId).maybeSingle();
119
178
  if (res.error)
120
179
  this.fail(res.error.message);
121
180
  if (!res.data)
@@ -123,52 +182,105 @@ export class SupabaseEntryStore {
123
182
  const parent = toRecord(res.data);
124
183
  if (parent.collection !== collection)
125
184
  throw entryErrors.foreignParent();
126
- assertDepth((await this.chain(parent)).length + subtreeHeight, collectionDepth(schemaOf(this.collections, collection)), "the entry");
185
+ assertDepth((await this.chain(parent)).length + subtreeHeight, collectionDepth(this.schema(collection)), "the entry");
186
+ return parent;
127
187
  }
128
- async create(collection, fields, options = {}) {
129
- schemaOf(this.collections, collection);
130
- const parentId = options.parentId ?? null;
131
- await this.assertParent(collection, parentId, 1);
188
+ assertParentHasLocale(parent, locale) {
189
+ if (parent && !parent.locales[locale])
190
+ throw entryErrors.parentNotInLocale(locale);
191
+ }
192
+ // ── row writers ──
193
+ // `touch` defaults to true (an edit bumps updated_at). A rollback or a
194
+ // structure change passes { touch: false } so the column is left alone.
195
+ async updateNode(id, patch, options = {}) {
196
+ const payload = (options.touch ?? true) ? { ...patch, updated_at: new Date().toISOString() } : { ...patch };
197
+ const res = await this.db.from("entries").update(payload).eq("id", id);
198
+ if (res.error)
199
+ this.fail(res.error.message);
200
+ }
201
+ async insertLocale(entryId, locale, row) {
202
+ const res = await this.db.from("entry_locales").insert({ entry_id: entryId, locale, slug: row.slug, fields: row.fields });
203
+ if (res.error) {
204
+ if (res.error.code === UNIQUE_VIOLATION)
205
+ throw entryErrors.alreadyInLocale(entryId, locale);
206
+ this.fail(res.error.message);
207
+ }
208
+ }
209
+ async updateLocale(entryId, locale, patch, options = {}) {
210
+ const payload = (options.touch ?? true) ? { ...patch, updated_at: new Date().toISOString() } : { ...patch };
211
+ const res = await this.db.from("entry_locales").update(payload).eq("entry_id", entryId).eq("locale", locale);
212
+ if (res.error)
213
+ this.fail(res.error.message);
214
+ }
215
+ /** The row goes first (it is what makes the entry exist in the locale), then its versions. */
216
+ async deleteLocale(entryId, locale) {
217
+ const row = await this.db.from("entry_locales").delete().eq("entry_id", entryId).eq("locale", locale);
218
+ if (row.error)
219
+ this.fail(row.error.message);
220
+ const versions = await this.db.from("entry_versions").delete().eq("entry_id", entryId).eq("locale", locale);
221
+ if (versions.error)
222
+ this.fail(versions.error.message);
223
+ }
224
+ /** A versioned collection snapshots the merged fields and points the
225
+ * locale's draft at the new row; others do nothing here. */
226
+ async recordVersion(entry, locale) {
227
+ if (!this.schema(entry.collection).versions)
228
+ return;
132
229
  const res = await this.db
133
- .from("entries")
134
- .insert({ collection, fields, parent_id: parentId, locales: options.locales ?? null })
135
- .select("*")
230
+ .from("entry_versions")
231
+ .insert({ entry_id: entry.id, locale, fields: entryFieldsIn(entry, locale) })
232
+ .select("id")
136
233
  .single();
137
234
  if (res.error)
138
- this.failWriteError(res.error, collection);
139
- const row = toRecord(res.data);
235
+ this.fail(res.error.message);
236
+ await this.updateLocale(entry.id, locale, { draft_version_id: res.data.id }, { touch: false });
237
+ }
238
+ // ── the contract ──
239
+ async create(collection, fields, options) {
240
+ const schema = this.schema(collection);
241
+ assertSupportedLocale(options.locale, this.locales);
242
+ const parentId = options.parentId ?? null;
243
+ if (isSharedSchema(schema)) {
244
+ const existing = await this.db.from("entries").select("id").eq("collection", collection).limit(1);
245
+ if (existing.error)
246
+ this.fail(existing.error.message);
247
+ if (existing.data.length > 0)
248
+ throw entryErrors.singleton(collection);
249
+ if (slugOf(fields) !== null)
250
+ throw entryErrors.noSlug(collection);
251
+ }
252
+ const parent = await this.assertParent(collection, parentId, 1);
253
+ this.assertParentHasLocale(parent, options.locale);
254
+ const { shared, localized, slug } = splitFields(fields, schema);
255
+ await this.assertSiblingSlug(collection, parentId, options.locale, slug);
256
+ const inserted = await this.db.from("entries").insert({ collection, parent_id: parentId, fields: shared }).select("id").single();
257
+ if (inserted.error)
258
+ this.fail(inserted.error.message);
259
+ const id = inserted.data.id;
140
260
  try {
141
- await this.writePaths(row);
261
+ await this.insertLocale(id, options.locale, { slug, fields: localized });
262
+ let record = await this.must(collection, id);
263
+ await this.recordVersion(record, options.locale);
264
+ record = await this.must(collection, id);
265
+ await this.writePaths(record);
266
+ await this.writeRefs(record);
267
+ return record;
142
268
  }
143
269
  catch (e) {
144
- await this.db.from("entries").delete().eq("id", row.id);
270
+ // Everything hangs off the node: locale rows and versions cascade.
271
+ // `paths` does not — it has no FK — and writeRefs runs AFTER the
272
+ // rewrite, so clear the rows first, the order `delete` uses.
273
+ await this.paths.remove([{ kind: "entry", id }]);
274
+ await this.db.from("entries").delete().eq("id", id);
145
275
  throw e;
146
276
  }
147
- await this.writeRefs(collection, row.id, fields);
148
- return row;
149
277
  }
150
278
  async get(collection, id) {
151
- // Same short-circuit as UUID_RE elsewhere: a non-UUID id can't exist
152
- // in the table, and querying it as one throws invalid_text_representation
153
- // instead of returning "not found" (which `update`'s rollback read and
154
- // `chain`'s parent walk both rely on).
155
- if (!UUID_RE.test(id))
156
- return null;
157
- const res = await this.db
158
- .from("entries")
159
- .select("*")
160
- .eq("collection", collection)
161
- .eq("id", id)
162
- .maybeSingle();
163
- if (res.error)
164
- this.fail(res.error.message);
165
- return res.data ? toRecord(res.data) : null;
279
+ return this.fetch(collection, id);
166
280
  }
167
281
  async list(collection, options) {
168
- const schema = schemaOf(this.collections, collection);
169
- let query = this.db.from("entries").select("*").eq("collection", collection);
170
- if (options?.status)
171
- query = query.eq("status", options.status);
282
+ const schema = this.schema(collection);
283
+ let query = this.db.from("entries").select(SELECT).eq("collection", collection);
172
284
  if (options?.parent !== undefined) {
173
285
  query = options.parent === null ? query.is("parent_id", null) : query.eq("parent_id", options.parent);
174
286
  }
@@ -177,133 +289,207 @@ export class SupabaseEntryStore {
177
289
  query = query.order("sort", { ascending: true, nullsFirst: false }).order("created_at", { ascending: true });
178
290
  }
179
291
  else {
180
- // `fields->x`, not `->>x`: ordering by the jsonb VALUE, so numbers
181
- // compare numerically (rank 10 after rank 2) and strings — ISO dates
182
- // included — alphabetically, matching the memory comparator.
292
+ // `fields->x`, not `->>x`: ordering by the jsonb VALUE of the SHARED
293
+ // field (an order field is never localized), matching the memory comparator.
183
294
  const column = order.by === "createdAt" ? "created_at" : order.by === "updatedAt" ? "updated_at" : `fields->${order.by}`;
184
295
  query = query.order(column, { ascending: order.direction === "asc", nullsFirst: false }).order("id", { ascending: true });
185
296
  }
186
297
  const res = await query;
187
298
  if (res.error)
188
299
  this.fail(res.error.message);
189
- return res.data.map(toRecord);
300
+ let rows = res.data.map(toRecord);
301
+ // The status filter reads the locale row; collections are small enough
302
+ // to filter here rather than lose the other rows to an !inner embed.
303
+ if (options?.status) {
304
+ const locale = options.locale ?? this.locales.default;
305
+ rows = rows.filter((e) => e.locales[locale]?.status === options.status);
306
+ }
307
+ return rows;
190
308
  }
191
- async getMany(collection, ids, options) {
192
- schemaOf(this.collections, collection);
309
+ async getMany(collection, ids, locale, options) {
310
+ this.schema(collection);
193
311
  const uuidIds = ids.filter((id) => UUID_RE.test(id));
194
312
  if (uuidIds.length === 0)
195
313
  return {};
196
- let query = this.db
197
- .from("entries")
198
- .select("id, fields")
199
- .eq("collection", collection)
200
- .in("id", uuidIds);
201
- if (options?.status)
202
- query = query.eq("status", options.status);
203
- const res = await query;
314
+ const res = await this.db.from("entries").select(SELECT).eq("collection", collection).in("id", uuidIds);
204
315
  if (res.error)
205
316
  this.fail(res.error.message);
317
+ const records = res.data.map(toRecord);
206
318
  const out = {};
207
- for (const row of res.data) {
208
- out[row.id] = row.fields;
319
+ if (!options?.status) {
320
+ for (const record of records)
321
+ if (record.locales[locale])
322
+ out[record.id] = entryFieldsIn(record, locale);
323
+ return out;
324
+ }
325
+ const published = records.filter((r) => r.locales[locale]?.status === options.status);
326
+ // A versioned collection serves what was published, not the live row.
327
+ const snapshots = await this.versionFields(published.map((r) => r.locales[locale].publishedVersionId).filter((v) => v !== null));
328
+ for (const record of published) {
329
+ const pointer = record.locales[locale].publishedVersionId;
330
+ out[record.id] = (pointer !== null ? snapshots.get(pointer) : undefined) ?? entryFieldsIn(record, locale);
209
331
  }
210
332
  return out;
211
333
  }
212
- // `touch` defaults to true (the normal edit path always bumps
213
- // updated_at). Callers that must leave the timestamp alone — a
214
- // rollback restoring a prior value, or move's re-parent, which the
215
- // contract says must not count as an edit — pass `{ touch: false }`,
216
- // which omits updated_at from the payload entirely so the column is
217
- // left untouched (or keeps whatever value the caller put in `patch`).
218
- async patch(collection, id, patch, options = {}) {
219
- if (!UUID_RE.test(id))
220
- throw entryErrors.notFound(collection, id);
221
- const touch = options.touch ?? true;
222
- const payload = touch ? { ...patch, updated_at: new Date().toISOString() } : { ...patch };
223
- const res = await this.db
224
- .from("entries")
225
- .update(payload)
226
- .eq("collection", collection)
227
- .eq("id", id)
228
- .select("*")
229
- .maybeSingle();
230
- if (res.error)
231
- this.failWriteError(res.error, collection);
232
- if (!res.data)
233
- throw entryErrors.notFound(collection, id);
234
- return toRecord(res.data);
235
- }
236
- async update(collection, id, fields) {
237
- const before = await this.get(collection, id);
238
- if (!before)
239
- throw entryErrors.notFound(collection, id);
240
- const record = await this.patch(collection, id, { fields });
334
+ async update(collection, id, locale, fields) {
335
+ const schema = this.schema(collection);
336
+ const before = await this.must(collection, id);
337
+ const row = entryLocaleRow(before, locale);
338
+ const { shared, localized, slug } = splitFields(fields, schema);
339
+ if (isSharedSchema(schema) && slug !== null)
340
+ throw entryErrors.noSlug(collection);
341
+ await this.assertSiblingSlug(collection, before.parentId, locale, slug, id);
342
+ await this.updateNode(id, { fields: shared });
343
+ await this.updateLocale(id, locale, { slug, fields: localized });
344
+ const record = await this.must(collection, id);
241
345
  try {
242
346
  await this.writePaths(record);
243
347
  }
244
348
  catch (e) {
245
- await this.patch(collection, id, { fields: before.fields, updated_at: new Date(before.updatedAt).toISOString() }, { touch: false });
349
+ await this.updateNode(id, { fields: before.fields, updated_at: new Date(before.updatedAt).toISOString() }, { touch: false });
350
+ await this.updateLocale(id, locale, { slug: row.slug, fields: row.fields, updated_at: new Date(row.updatedAt).toISOString() }, { touch: false });
246
351
  throw e;
247
352
  }
248
- await this.writeRefs(collection, id, fields);
249
- return record;
353
+ await this.recordVersion(record, locale);
354
+ const final = await this.must(collection, id);
355
+ await this.writeRefs(final);
356
+ return final;
250
357
  }
251
- async setStatus(collection, id, status) {
252
- return this.patch(collection, id, { status });
358
+ async setStatus(collection, id, locale, status) {
359
+ let record = await this.must(collection, id);
360
+ let row = entryLocaleRow(record, locale);
361
+ if (status === "published") {
362
+ if (this.schema(collection).versions && !row.draftVersionId) {
363
+ await this.recordVersion(record, locale);
364
+ record = await this.must(collection, id);
365
+ row = entryLocaleRow(record, locale);
366
+ }
367
+ await this.updateLocale(id, locale, { status, published_version_id: row.draftVersionId });
368
+ }
369
+ else {
370
+ // Unpublish keeps the pointer for a later republish (spec §2).
371
+ await this.updateLocale(id, locale, { status });
372
+ }
373
+ await this.updateNode(id, {});
374
+ const final = await this.must(collection, id);
375
+ await this.writeRefs(final);
376
+ return final;
377
+ }
378
+ async addLocale(collection, id, locale, options) {
379
+ assertSupportedLocale(locale, this.locales);
380
+ const entry = await this.must(collection, id);
381
+ // The source is read FIRST (the memory adapter's order): "copy from a
382
+ // locale that isn't there" is the more specific complaint, even when
383
+ // the target locale already exists.
384
+ const source = entryLocaleRow(entry, options.from);
385
+ if (entry.locales[locale])
386
+ throw entryErrors.alreadyInLocale(id, locale);
387
+ const parent = entry.parentId === null ? null : await this.fetch(collection, entry.parentId);
388
+ this.assertParentHasLocale(parent, locale);
389
+ await this.assertSiblingSlug(collection, entry.parentId, locale, source.slug, id);
390
+ await this.insertLocale(id, locale, { slug: source.slug, fields: source.fields });
391
+ let added;
392
+ try {
393
+ added = await this.must(collection, id);
394
+ await this.recordVersion(added, locale);
395
+ added = await this.must(collection, id);
396
+ await this.writePaths(added);
397
+ }
398
+ catch (e) {
399
+ await this.deleteLocale(id, locale);
400
+ throw e;
401
+ }
402
+ await this.writeRefs(added);
403
+ return added;
404
+ }
405
+ async removeLocale(collection, id, locale) {
406
+ const schema = this.schema(collection);
407
+ if (isSharedSchema(schema))
408
+ throw entryErrors.everyLocale(collection);
409
+ const entry = await this.must(collection, id);
410
+ entryLocaleRow(entry, locale);
411
+ if (Object.keys(entry.locales).length === 1)
412
+ throw entryErrors.lastLocale(id);
413
+ const children = await this.db.from("entries").select(SELECT).eq("parent_id", id);
414
+ if (children.error)
415
+ this.fail(children.error.message);
416
+ const blockers = children.data.map(toRecord).filter((c) => c.locales[locale] !== undefined).map((c) => entryTitleIn(c, locale, schema));
417
+ if (blockers.length > 0)
418
+ throw entryErrors.localeInUse(locale, blockers);
419
+ // Not atomic across the two statements: a failure between them leaves
420
+ // a present node without that locale's path row — a 404 for that
421
+ // locale until the next write, never leaked content.
422
+ await this.deleteLocale(id, locale);
423
+ const remaining = await this.must(collection, id);
424
+ await this.writePaths(remaining);
425
+ await this.updateNode(id, {});
426
+ const final = await this.must(collection, id);
427
+ await this.writeRefs(final);
428
+ return final;
429
+ }
430
+ async listVersions(collection, id, locale) {
431
+ await this.must(collection, id);
432
+ const res = await this.db
433
+ .from("entry_versions")
434
+ .select("*")
435
+ .eq("entry_id", id)
436
+ .eq("locale", locale)
437
+ .order("created_at", { ascending: false })
438
+ .order("id", { ascending: false });
439
+ if (res.error)
440
+ this.fail(res.error.message);
441
+ return res.data.map(toVersion);
253
442
  }
254
443
  async move(collection, id, to) {
255
- const entry = await this.get(collection, id);
256
- if (!entry)
257
- throw entryErrors.notFound(collection, id);
444
+ if (isSharedSchema(this.schema(collection)))
445
+ throw entryErrors.owned(collection, "it has no tree position");
446
+ const entry = await this.must(collection, id);
258
447
  const subtree = await this.subtree(entry);
259
448
  if (to.parentId !== null && (to.parentId === id || subtree.some((e) => e.id === to.parentId)))
260
449
  throw entryErrors.cycle();
261
450
  const byId = this.byIdMap(subtree);
262
- // chainOf walks all the way to a null parentId, but `entry`'s own
263
- // parentId still points OUTSIDE this subtree map (its pre-move
264
- // parent) — so it can't be used here directly (page store's `move`
265
- // hits the same shape and solves it the same way): stop at `entry`
266
- // itself instead, since that's the map's root for this computation.
451
+ // `entry`'s own parentId points OUTSIDE this map (its pre-move parent):
452
+ // stop at `entry` itself, the map's root for this computation.
267
453
  const heightOf = (e) => (e.id === entry.id ? 1 : 1 + heightOf(byId(e.parentId)));
268
454
  const height = Math.max(...subtree.map(heightOf));
269
- await this.assertParent(collection, to.parentId, height);
270
- const siblings = (await this.list(collection, { parent: to.parentId, order: "manual" })).filter((s) => s.id !== id);
271
- // Mirror of the memory adapter's pre-check. The index below would
272
- // catch it on the parent_id update anyway, but raising here keeps the
273
- // error identical across adapters and leaves the destination
274
- // siblings' sorts untouched.
455
+ const parent = await this.assertParent(collection, to.parentId, height);
456
+ if (parent)
457
+ for (const locale of Object.keys(entry.locales))
458
+ if (!parent.locales[locale])
459
+ throw entryErrors.moveLocales(locale);
460
+ const siblings = await this.siblings(collection, to.parentId, id);
275
461
  if (to.parentId !== entry.parentId) {
276
- const slug = slugOf(entry.fields);
277
- if (slug !== null && siblings.some((s) => slugOf(s.fields) === slug)) {
278
- throw entryErrors.slugTaken(collection);
462
+ for (const [locale, row] of Object.entries(entry.locales)) {
463
+ if (row.slug !== null && siblings.some((s) => s.locales[locale]?.slug === row.slug))
464
+ throw entryErrors.slugTaken(collection);
279
465
  }
280
466
  }
281
- // The destination siblings' sorts as they stood BEFORE the reorder.
282
- // A rollback restores these exact values rather than re-running the
283
- // reorder RPC, which renumbers 1..N and would silently close gaps
284
- // left by earlier deletes and moves.
467
+ // The destination siblings' sorts as they stood BEFORE the reorder: a
468
+ // rollback restores these exact values rather than renumbering 1..N.
285
469
  const previousSorts = siblings.map((s) => ({ id: s.id, sort: s.sort }));
286
- const moved = await this.patch(collection, id, { parent_id: to.parentId }, { touch: false });
287
- const ordered = siblings.map((s) => s.id);
288
- ordered.splice(Math.min(Math.max(to.index ?? siblings.length, 0), siblings.length), 0, id);
289
- await this.reorder(collection, ordered);
290
470
  try {
291
- await this.writePaths(moved);
471
+ await this.updateNode(id, { parent_id: to.parentId }, { touch: false });
472
+ const ordered = siblings.map((s) => s.id);
473
+ ordered.splice(Math.min(Math.max(to.index ?? siblings.length, 0), siblings.length), 0, id);
474
+ await this.reorder(collection, ordered);
475
+ await this.writePaths(await this.must(collection, id));
292
476
  }
293
477
  catch (e) {
294
- await this.patch(collection, id, { parent_id: entry.parentId, sort: entry.sort }, { touch: false });
478
+ await this.updateNode(id, { parent_id: entry.parentId, sort: entry.sort }, { touch: false });
295
479
  for (const s of previousSorts)
296
- await this.patch(collection, s.id, { sort: s.sort }, { touch: false });
480
+ await this.updateNode(s.id, { sort: s.sort }, { touch: false });
297
481
  throw e;
298
482
  }
299
- return (await this.get(collection, id));
483
+ return this.must(collection, id);
300
484
  }
301
485
  async delete(collection, id) {
302
486
  if (!UUID_RE.test(id))
303
487
  return;
304
- const entry = await this.get(collection, id);
488
+ const entry = await this.fetch(collection, id);
305
489
  if (!entry)
306
490
  return;
491
+ if (isSharedSchema(this.schema(collection)))
492
+ throw entryErrors.owned(collection, "remove the registration to remove it");
307
493
  const children = await this.db.from("entries").select("id").eq("parent_id", id).limit(1);
308
494
  if (children.error)
309
495
  this.fail(children.error.message);
@@ -317,6 +503,7 @@ export class SupabaseEntryStore {
317
503
  const refs = await this.db.from("refs").delete().eq("source_kind", "entry").eq("source_id", id);
318
504
  if (refs.error)
319
505
  this.fail(refs.error.message);
506
+ // locale rows and versions cascade from the node
320
507
  const res = await this.db.from("entries").delete().eq("collection", collection).eq("id", id);
321
508
  if (res.error)
322
509
  this.fail(res.error.message);
@@ -325,22 +512,17 @@ export class SupabaseEntryStore {
325
512
  return this.paths.resolve(locale, path);
326
513
  }
327
514
  async pathsOf(collection, id) {
328
- return (await this.get(collection, id)) ? this.paths.pathsOf({ kind: "entry", id }) : {};
515
+ return (await this.fetch(collection, id)) ? this.paths.pathsOf({ kind: "entry", id }) : {};
329
516
  }
330
517
  async reorder(collection, orderedIds) {
331
- schemaOf(this.collections, collection);
332
- const res = await this.db.rpc("smoodly_reorder", {
333
- p_collection: collection,
334
- p_ids: orderedIds,
335
- });
518
+ if (isSharedSchema(this.schema(collection)))
519
+ throw entryErrors.owned(collection, "it has no order");
520
+ const res = await this.db.rpc("smoodly_reorder", { p_collection: collection, p_ids: orderedIds });
336
521
  if (res.error)
337
522
  this.fail(res.error.message);
338
523
  }
339
524
  async incoming(targetId) {
340
- const res = await this.db
341
- .from("refs")
342
- .select("source_kind, source_id")
343
- .eq("target_id", targetId);
525
+ const res = await this.db.from("refs").select("source_kind, source_id").eq("target_id", targetId);
344
526
  if (res.error)
345
527
  this.fail(res.error.message);
346
528
  const seen = new Set(); // one row per source (multiple fields collapse)