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
@@ -1,33 +1,46 @@
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, 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 } 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;
@@ -38,42 +51,31 @@ export class SupabaseEntryStore {
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.collections, 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,96 @@ 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
+ const parent = await this.assertParent(collection, parentId, 1);
244
+ this.assertParentHasLocale(parent, options.locale);
245
+ const { shared, localized, slug } = splitFields(fields, schema);
246
+ await this.assertSiblingSlug(collection, parentId, options.locale, slug);
247
+ const inserted = await this.db.from("entries").insert({ collection, parent_id: parentId, fields: shared }).select("id").single();
248
+ if (inserted.error)
249
+ this.fail(inserted.error.message);
250
+ const id = inserted.data.id;
140
251
  try {
141
- await this.writePaths(row);
252
+ await this.insertLocale(id, options.locale, { slug, fields: localized });
253
+ let record = await this.must(collection, id);
254
+ await this.recordVersion(record, options.locale);
255
+ record = await this.must(collection, id);
256
+ await this.writePaths(record);
257
+ await this.writeRefs(record);
258
+ return record;
142
259
  }
143
260
  catch (e) {
144
- await this.db.from("entries").delete().eq("id", row.id);
261
+ // Everything hangs off the node: locale rows and versions cascade.
262
+ // `paths` does not — it has no FK — and writeRefs runs AFTER the
263
+ // rewrite, so clear the rows first, the order `delete` uses.
264
+ await this.paths.remove([{ kind: "entry", id }]);
265
+ await this.db.from("entries").delete().eq("id", id);
145
266
  throw e;
146
267
  }
147
- await this.writeRefs(collection, row.id, fields);
148
- return row;
149
268
  }
150
269
  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;
270
+ return this.fetch(collection, id);
166
271
  }
167
272
  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);
273
+ const schema = this.schema(collection);
274
+ let query = this.db.from("entries").select(SELECT).eq("collection", collection);
172
275
  if (options?.parent !== undefined) {
173
276
  query = options.parent === null ? query.is("parent_id", null) : query.eq("parent_id", options.parent);
174
277
  }
@@ -177,131 +280,197 @@ export class SupabaseEntryStore {
177
280
  query = query.order("sort", { ascending: true, nullsFirst: false }).order("created_at", { ascending: true });
178
281
  }
179
282
  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.
283
+ // `fields->x`, not `->>x`: ordering by the jsonb VALUE of the SHARED
284
+ // field (an order field is never localized), matching the memory comparator.
183
285
  const column = order.by === "createdAt" ? "created_at" : order.by === "updatedAt" ? "updated_at" : `fields->${order.by}`;
184
286
  query = query.order(column, { ascending: order.direction === "asc", nullsFirst: false }).order("id", { ascending: true });
185
287
  }
186
288
  const res = await query;
187
289
  if (res.error)
188
290
  this.fail(res.error.message);
189
- return res.data.map(toRecord);
291
+ let rows = res.data.map(toRecord);
292
+ // The status filter reads the locale row; collections are small enough
293
+ // to filter here rather than lose the other rows to an !inner embed.
294
+ if (options?.status) {
295
+ const locale = options.locale ?? this.locales.default;
296
+ rows = rows.filter((e) => e.locales[locale]?.status === options.status);
297
+ }
298
+ return rows;
190
299
  }
191
- async getMany(collection, ids, options) {
192
- schemaOf(this.collections, collection);
300
+ async getMany(collection, ids, locale, options) {
301
+ this.schema(collection);
193
302
  const uuidIds = ids.filter((id) => UUID_RE.test(id));
194
303
  if (uuidIds.length === 0)
195
304
  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;
305
+ const res = await this.db.from("entries").select(SELECT).eq("collection", collection).in("id", uuidIds);
204
306
  if (res.error)
205
307
  this.fail(res.error.message);
308
+ const records = res.data.map(toRecord);
206
309
  const out = {};
207
- for (const row of res.data) {
208
- out[row.id] = row.fields;
310
+ if (!options?.status) {
311
+ for (const record of records)
312
+ if (record.locales[locale])
313
+ out[record.id] = entryFieldsIn(record, locale);
314
+ return out;
315
+ }
316
+ const published = records.filter((r) => r.locales[locale]?.status === options.status);
317
+ // A versioned collection serves what was published, not the live row.
318
+ const snapshots = await this.versionFields(published.map((r) => r.locales[locale].publishedVersionId).filter((v) => v !== null));
319
+ for (const record of published) {
320
+ const pointer = record.locales[locale].publishedVersionId;
321
+ out[record.id] = (pointer !== null ? snapshots.get(pointer) : undefined) ?? entryFieldsIn(record, locale);
209
322
  }
210
323
  return out;
211
324
  }
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 });
325
+ async update(collection, id, locale, fields) {
326
+ const schema = this.schema(collection);
327
+ const before = await this.must(collection, id);
328
+ const row = entryLocaleRow(before, locale);
329
+ const { shared, localized, slug } = splitFields(fields, schema);
330
+ await this.assertSiblingSlug(collection, before.parentId, locale, slug, id);
331
+ await this.updateNode(id, { fields: shared });
332
+ await this.updateLocale(id, locale, { slug, fields: localized });
333
+ const record = await this.must(collection, id);
241
334
  try {
242
335
  await this.writePaths(record);
243
336
  }
244
337
  catch (e) {
245
- await this.patch(collection, id, { fields: before.fields, updated_at: new Date(before.updatedAt).toISOString() }, { touch: false });
338
+ await this.updateNode(id, { fields: before.fields, updated_at: new Date(before.updatedAt).toISOString() }, { touch: false });
339
+ await this.updateLocale(id, locale, { slug: row.slug, fields: row.fields, updated_at: new Date(row.updatedAt).toISOString() }, { touch: false });
246
340
  throw e;
247
341
  }
248
- await this.writeRefs(collection, id, fields);
249
- return record;
342
+ await this.recordVersion(record, locale);
343
+ const final = await this.must(collection, id);
344
+ await this.writeRefs(final);
345
+ return final;
250
346
  }
251
- async setStatus(collection, id, status) {
252
- return this.patch(collection, id, { status });
347
+ async setStatus(collection, id, locale, status) {
348
+ let record = await this.must(collection, id);
349
+ let row = entryLocaleRow(record, locale);
350
+ if (status === "published") {
351
+ if (this.schema(collection).versions && !row.draftVersionId) {
352
+ await this.recordVersion(record, locale);
353
+ record = await this.must(collection, id);
354
+ row = entryLocaleRow(record, locale);
355
+ }
356
+ await this.updateLocale(id, locale, { status, published_version_id: row.draftVersionId });
357
+ }
358
+ else {
359
+ // Unpublish keeps the pointer for a later republish (spec §2).
360
+ await this.updateLocale(id, locale, { status });
361
+ }
362
+ await this.updateNode(id, {});
363
+ const final = await this.must(collection, id);
364
+ await this.writeRefs(final);
365
+ return final;
366
+ }
367
+ async addLocale(collection, id, locale, options) {
368
+ assertSupportedLocale(locale, this.locales);
369
+ const entry = await this.must(collection, id);
370
+ // The source is read FIRST (the memory adapter's order): "copy from a
371
+ // locale that isn't there" is the more specific complaint, even when
372
+ // the target locale already exists.
373
+ const source = entryLocaleRow(entry, options.from);
374
+ if (entry.locales[locale])
375
+ throw entryErrors.alreadyInLocale(id, locale);
376
+ const parent = entry.parentId === null ? null : await this.fetch(collection, entry.parentId);
377
+ this.assertParentHasLocale(parent, locale);
378
+ await this.assertSiblingSlug(collection, entry.parentId, locale, source.slug, id);
379
+ await this.insertLocale(id, locale, { slug: source.slug, fields: source.fields });
380
+ let added;
381
+ try {
382
+ added = await this.must(collection, id);
383
+ await this.recordVersion(added, locale);
384
+ added = await this.must(collection, id);
385
+ await this.writePaths(added);
386
+ }
387
+ catch (e) {
388
+ await this.deleteLocale(id, locale);
389
+ throw e;
390
+ }
391
+ await this.writeRefs(added);
392
+ return added;
393
+ }
394
+ async removeLocale(collection, id, locale) {
395
+ const schema = this.schema(collection);
396
+ const entry = await this.must(collection, id);
397
+ entryLocaleRow(entry, locale);
398
+ if (Object.keys(entry.locales).length === 1)
399
+ throw entryErrors.lastLocale(id);
400
+ const children = await this.db.from("entries").select(SELECT).eq("parent_id", id);
401
+ if (children.error)
402
+ this.fail(children.error.message);
403
+ const blockers = children.data.map(toRecord).filter((c) => c.locales[locale] !== undefined).map((c) => entryTitleIn(c, locale, schema));
404
+ if (blockers.length > 0)
405
+ throw entryErrors.localeInUse(locale, blockers);
406
+ // Not atomic across the two statements: a failure between them leaves
407
+ // a present node without that locale's path row — a 404 for that
408
+ // locale until the next write, never leaked content.
409
+ await this.deleteLocale(id, locale);
410
+ const remaining = await this.must(collection, id);
411
+ await this.writePaths(remaining);
412
+ await this.updateNode(id, {});
413
+ const final = await this.must(collection, id);
414
+ await this.writeRefs(final);
415
+ return final;
416
+ }
417
+ async listVersions(collection, id, locale) {
418
+ await this.must(collection, id);
419
+ const res = await this.db
420
+ .from("entry_versions")
421
+ .select("*")
422
+ .eq("entry_id", id)
423
+ .eq("locale", locale)
424
+ .order("created_at", { ascending: false })
425
+ .order("id", { ascending: false });
426
+ if (res.error)
427
+ this.fail(res.error.message);
428
+ return res.data.map(toVersion);
253
429
  }
254
430
  async move(collection, id, to) {
255
- const entry = await this.get(collection, id);
256
- if (!entry)
257
- throw entryErrors.notFound(collection, id);
431
+ const entry = await this.must(collection, id);
258
432
  const subtree = await this.subtree(entry);
259
433
  if (to.parentId !== null && (to.parentId === id || subtree.some((e) => e.id === to.parentId)))
260
434
  throw entryErrors.cycle();
261
435
  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.
436
+ // `entry`'s own parentId points OUTSIDE this map (its pre-move parent):
437
+ // stop at `entry` itself, the map's root for this computation.
267
438
  const heightOf = (e) => (e.id === entry.id ? 1 : 1 + heightOf(byId(e.parentId)));
268
439
  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.
440
+ const parent = await this.assertParent(collection, to.parentId, height);
441
+ if (parent)
442
+ for (const locale of Object.keys(entry.locales))
443
+ if (!parent.locales[locale])
444
+ throw entryErrors.moveLocales(locale);
445
+ const siblings = await this.siblings(collection, to.parentId, id);
275
446
  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);
447
+ for (const [locale, row] of Object.entries(entry.locales)) {
448
+ if (row.slug !== null && siblings.some((s) => s.locales[locale]?.slug === row.slug))
449
+ throw entryErrors.slugTaken(collection);
279
450
  }
280
451
  }
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.
452
+ // The destination siblings' sorts as they stood BEFORE the reorder: a
453
+ // rollback restores these exact values rather than renumbering 1..N.
285
454
  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
455
  try {
291
- await this.writePaths(moved);
456
+ await this.updateNode(id, { parent_id: to.parentId }, { touch: false });
457
+ const ordered = siblings.map((s) => s.id);
458
+ ordered.splice(Math.min(Math.max(to.index ?? siblings.length, 0), siblings.length), 0, id);
459
+ await this.reorder(collection, ordered);
460
+ await this.writePaths(await this.must(collection, id));
292
461
  }
293
462
  catch (e) {
294
- await this.patch(collection, id, { parent_id: entry.parentId, sort: entry.sort }, { touch: false });
463
+ await this.updateNode(id, { parent_id: entry.parentId, sort: entry.sort }, { touch: false });
295
464
  for (const s of previousSorts)
296
- await this.patch(collection, s.id, { sort: s.sort }, { touch: false });
465
+ await this.updateNode(s.id, { sort: s.sort }, { touch: false });
297
466
  throw e;
298
467
  }
299
- return (await this.get(collection, id));
468
+ return this.must(collection, id);
300
469
  }
301
470
  async delete(collection, id) {
302
471
  if (!UUID_RE.test(id))
303
472
  return;
304
- const entry = await this.get(collection, id);
473
+ const entry = await this.fetch(collection, id);
305
474
  if (!entry)
306
475
  return;
307
476
  const children = await this.db.from("entries").select("id").eq("parent_id", id).limit(1);
@@ -317,6 +486,7 @@ export class SupabaseEntryStore {
317
486
  const refs = await this.db.from("refs").delete().eq("source_kind", "entry").eq("source_id", id);
318
487
  if (refs.error)
319
488
  this.fail(refs.error.message);
489
+ // locale rows and versions cascade from the node
320
490
  const res = await this.db.from("entries").delete().eq("collection", collection).eq("id", id);
321
491
  if (res.error)
322
492
  this.fail(res.error.message);
@@ -325,22 +495,16 @@ export class SupabaseEntryStore {
325
495
  return this.paths.resolve(locale, path);
326
496
  }
327
497
  async pathsOf(collection, id) {
328
- return (await this.get(collection, id)) ? this.paths.pathsOf({ kind: "entry", id }) : {};
498
+ return (await this.fetch(collection, id)) ? this.paths.pathsOf({ kind: "entry", id }) : {};
329
499
  }
330
500
  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
- });
501
+ this.schema(collection);
502
+ const res = await this.db.rpc("smoodly_reorder", { p_collection: collection, p_ids: orderedIds });
336
503
  if (res.error)
337
504
  this.fail(res.error.message);
338
505
  }
339
506
  async incoming(targetId) {
340
- const res = await this.db
341
- .from("refs")
342
- .select("source_kind, source_id")
343
- .eq("target_id", targetId);
507
+ const res = await this.db.from("refs").select("source_kind, source_id").eq("target_id", targetId);
344
508
  if (res.error)
345
509
  this.fail(res.error.message);
346
510
  const seen = new Set(); // one row per source (multiple fields collapse)