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,5 +1,5 @@
1
1
  import type { SupabaseClient } from "@supabase/supabase-js";
2
- import { type CreatePageInput, type PageRecord, type PageStore, type PageStoreOptions, type PageVersion } from "./store.ts";
2
+ import { type AddLocaleOptions, type CreatePageInput, type PageRecord, type PageStore, type PageStoreOptions, type PageVersion } from "./store.ts";
3
3
  import type { PageTree } from "./render.tsx";
4
4
  import { type PathTarget } from "./paths.ts";
5
5
  export declare class SupabasePageStore implements PageStore {
@@ -15,6 +15,7 @@ export declare class SupabasePageStore implements PageStore {
15
15
  /** A one-row existence check — the shape `deletePage` needs too. */
16
16
  private hasChildren;
17
17
  private siblings;
18
+ private assertSlugFree;
18
19
  /** The node's ancestors (root first, node last), by walking parent_id. */
19
20
  private chain;
20
21
  /** The node and every descendant, breadth-first. */
@@ -24,19 +25,30 @@ export declare class SupabasePageStore implements PageStore {
24
25
  private writePaths;
25
26
  /** `touch` defaults to true (the normal edit path bumps updated_at).
26
27
  * A rollback restoring prior column values passes `{ touch: false }`,
27
- * which omits updated_at entirely — undoing a write is not an edit
28
- * (same pattern as SupabaseEntryStore's `patch`). */
29
- private update;
28
+ * which omits updated_at entirely — undoing a write is not an edit. */
29
+ private updateNode;
30
+ private updateLocale;
31
+ private insertLocale;
32
+ /** The locale's row and versions — the removeLocale and rollback shape.
33
+ * The row goes first: it is what makes the page exist in that locale,
34
+ * so a failure between the two statements leaves no locale row
35
+ * pointing at an already-deleted version. */
36
+ private deleteLocale;
30
37
  private insertVersion;
31
38
  private versionTree;
39
+ /** Every locale's draft and published trees in ONE query. */
40
+ private liveTrees;
32
41
  private writeRefs;
33
42
  private reorder;
43
+ /** page_locales and page_versions cascade from pages; refs do not. */
34
44
  private hardDelete;
35
45
  createPage(input: CreatePageInput): Promise<PageRecord>;
36
46
  getPage(id: string): Promise<PageRecord | null>;
37
47
  listPages(): Promise<PageRecord[]>;
38
48
  resolvePath(locale: string, path: string): Promise<PathTarget | null>;
39
49
  pathsOf(id: string): Promise<{}>;
50
+ addLocale(id: string, locale: string, options: AddLocaleOptions): Promise<PageRecord>;
51
+ removeLocale(id: string, locale: string): Promise<PageRecord>;
40
52
  rename(id: string, patch: {
41
53
  locale: string;
42
54
  slug?: string;
@@ -47,10 +59,10 @@ export declare class SupabasePageStore implements PageStore {
47
59
  index?: number;
48
60
  }): Promise<PageRecord>;
49
61
  setTemplate(id: string, template: string): Promise<PageRecord>;
50
- saveDraft(id: string, tree: PageTree): Promise<PageVersion>;
51
- publish(id: string): Promise<PageRecord>;
52
- getDraftTree(id: string): Promise<PageTree | null>;
53
- getPublishedTree(id: string): Promise<PageTree | null>;
54
- listVersions(id: string): Promise<PageVersion[]>;
62
+ saveDraft(id: string, locale: string, tree: PageTree): Promise<PageVersion>;
63
+ publish(id: string, locale: string): Promise<PageRecord>;
64
+ getDraftTree(id: string, locale: string): Promise<PageTree | null>;
65
+ getPublishedTree(id: string, locale: string): Promise<PageTree | null>;
66
+ listVersions(id: string, locale: string): Promise<PageVersion[]>;
55
67
  deletePage(id: string): Promise<void>;
56
68
  }
@@ -1,31 +1,38 @@
1
1
  // The Supabase adapter for the PageStore contract, over pages +
2
- // page_versions + paths. Server-side only (the service role in self-host,
3
- // a write key in the cloud); writes flow through Smoodly's API by design
4
- // (DESIGN.md §1). No FK links pages' version pointers to page_versions
5
- // (they'd be circular), so tree reads are two indexed lookups. Path rows
6
- // are rewritten through ONE RPC per write, so a collision rolls back.
7
- import { assertPageI18n, assertPublishable, pageErrors, pageRefEdges, pathOptions, } from "./store.js";
2
+ // page_locales + page_versions + paths. Server-side only (the service
3
+ // role in self-host, a write key in the cloud); writes flow through
4
+ // Smoodly's API by design (DESIGN.md §1). No FK links the locale rows'
5
+ // version pointers to page_versions (they'd be circular), so tree reads
6
+ // are two indexed lookups. Every read embeds the locale rows
7
+ // (`*, page_locales(*)`) so a record arrives whole. Path rows are
8
+ // rewritten through ONE RPC per write, so a collision rolls back.
9
+ import { assertPublishable, assertSupportedLocale, localeRow, pageErrors, pageRefEdges, pathOptions, } from "./store.js";
8
10
  import { SupabasePathIndex } from "./supabase-path-index.js";
9
- import { assertDepth, assertSegment, pagePathRows } from "./paths.js";
11
+ import { assertDepth, assertSegment, isHomeNode, pagePathRows } from "./paths.js";
12
+ const SELECT = "*, page_locales(*)";
10
13
  const UNIQUE_VIOLATION = "23505";
11
14
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
12
15
  function toRecord(row) {
16
+ const locales = {};
17
+ for (const l of row.page_locales ?? []) {
18
+ locales[l.locale] = {
19
+ slug: l.slug,
20
+ title: l.title,
21
+ status: l.status,
22
+ draftVersionId: l.draft_version_id,
23
+ publishedVersionId: l.published_version_id,
24
+ };
25
+ }
13
26
  return {
14
27
  id: row.id,
15
28
  parentId: row.parent_id,
16
- slug: row.slug,
17
- title: row.title,
18
- i18n: row.i18n,
19
29
  template: row.template,
20
- status: row.status,
21
30
  sort: row.sort === null ? null : Number(row.sort),
22
- locales: row.locales,
23
- draftVersionId: row.draft_version_id,
24
- publishedVersionId: row.published_version_id,
31
+ locales,
25
32
  };
26
33
  }
27
34
  function toVersion(row) {
28
- return { id: row.id, pageId: row.page_id, tree: row.tree, createdAt: Date.parse(row.created_at) };
35
+ return { id: row.id, pageId: row.page_id, locale: row.locale, tree: row.tree, createdAt: Date.parse(row.created_at) };
29
36
  }
30
37
  export class SupabasePageStore {
31
38
  constructor(db, options = {}) {
@@ -37,15 +44,14 @@ export class SupabasePageStore {
37
44
  fail(message) {
38
45
  throw new Error(`smoodly: ${message}`);
39
46
  }
40
- // Callers always pass a PageRecord (camelCase), never a raw row.
41
47
  isHome(page) {
42
- return page.parentId === null && page.slug === this.opts.homePageSlug;
48
+ return isHomeNode(page, this.opts);
43
49
  }
44
50
  // ── rows ──
45
51
  async row(id) {
46
52
  if (!UUID_RE.test(id))
47
53
  return null;
48
- const res = await this.db.from("pages").select("*").eq("id", id).maybeSingle();
54
+ const res = await this.db.from("pages").select(SELECT).eq("id", id).maybeSingle();
49
55
  if (res.error)
50
56
  this.fail(res.error.message);
51
57
  return res.data ? toRecord(res.data) : null;
@@ -64,7 +70,7 @@ export class SupabasePageStore {
64
70
  return res.data.length > 0;
65
71
  }
66
72
  async siblings(parentId, except) {
67
- let query = this.db.from("pages").select("*");
73
+ let query = this.db.from("pages").select(SELECT);
68
74
  query = parentId === null ? query.is("parent_id", null) : query.eq("parent_id", parentId);
69
75
  const res = await query
70
76
  .order("sort", { ascending: true, nullsFirst: false })
@@ -74,6 +80,10 @@ export class SupabasePageStore {
74
80
  this.fail(res.error.message);
75
81
  return res.data.map(toRecord).filter((p) => p.id !== except);
76
82
  }
83
+ async assertSlugFree(parentId, locale, slug, except) {
84
+ if ((await this.siblings(parentId, except)).some((s) => s.locales[locale]?.slug === slug))
85
+ throw pageErrors.slugTaken(slug);
86
+ }
77
87
  /** The node's ancestors (root first, node last), by walking parent_id. */
78
88
  async chain(page) {
79
89
  const chain = [page];
@@ -92,7 +102,7 @@ export class SupabasePageStore {
92
102
  const out = [page];
93
103
  let frontier = [page.id];
94
104
  while (frontier.length > 0) {
95
- const res = await this.db.from("pages").select("*").in("parent_id", frontier);
105
+ const res = await this.db.from("pages").select(SELECT).in("parent_id", frontier);
96
106
  if (res.error)
97
107
  this.fail(res.error.message);
98
108
  const rows = res.data.map(toRecord);
@@ -111,25 +121,43 @@ export class SupabasePageStore {
111
121
  }
112
122
  /** `touch` defaults to true (the normal edit path bumps updated_at).
113
123
  * A rollback restoring prior column values passes `{ touch: false }`,
114
- * which omits updated_at entirely — undoing a write is not an edit
115
- * (same pattern as SupabaseEntryStore's `patch`). */
116
- async update(id, patch, options = {}) {
124
+ * which omits updated_at entirely — undoing a write is not an edit. */
125
+ async updateNode(id, patch, options = {}) {
117
126
  const payload = (options.touch ?? true) ? { ...patch, updated_at: new Date().toISOString() } : { ...patch };
118
- const res = await this.db
119
- .from("pages")
120
- .update(payload)
121
- .eq("id", id)
122
- .select("*")
123
- .single();
127
+ const res = await this.db.from("pages").update(payload).eq("id", id).select(SELECT).single();
128
+ if (res.error)
129
+ this.fail(res.error.message);
130
+ return toRecord(res.data);
131
+ }
132
+ async updateLocale(id, locale, patch, options = {}) {
133
+ const payload = (options.touch ?? true) ? { ...patch, updated_at: new Date().toISOString() } : { ...patch };
134
+ const res = await this.db.from("page_locales").update(payload).eq("page_id", id).eq("locale", locale).select("page_id").single();
135
+ if (res.error)
136
+ this.fail(res.error.message);
137
+ return this.must(id);
138
+ }
139
+ async insertLocale(id, locale, values) {
140
+ const res = await this.db.from("page_locales").insert({ page_id: id, locale, ...values });
124
141
  if (res.error) {
125
142
  if (res.error.code === UNIQUE_VIOLATION)
126
- throw pageErrors.slugTaken(String(patch.slug ?? ""));
143
+ throw pageErrors.alreadyInLocale(id, locale);
127
144
  this.fail(res.error.message);
128
145
  }
129
- return toRecord(res.data);
130
146
  }
131
- async insertVersion(pageId, tree) {
132
- const res = await this.db.from("page_versions").insert({ page_id: pageId, tree }).select("*").single();
147
+ /** The locale's row and versions — the removeLocale and rollback shape.
148
+ * The row goes first: it is what makes the page exist in that locale,
149
+ * so a failure between the two statements leaves no locale row
150
+ * pointing at an already-deleted version. */
151
+ async deleteLocale(id, locale) {
152
+ const row = await this.db.from("page_locales").delete().eq("page_id", id).eq("locale", locale);
153
+ if (row.error)
154
+ this.fail(row.error.message);
155
+ const versions = await this.db.from("page_versions").delete().eq("page_id", id).eq("locale", locale);
156
+ if (versions.error)
157
+ this.fail(versions.error.message);
158
+ }
159
+ async insertVersion(pageId, locale, tree) {
160
+ const res = await this.db.from("page_versions").insert({ page_id: pageId, locale, tree }).select("*").single();
133
161
  if (res.error)
134
162
  this.fail(res.error.message);
135
163
  return res.data;
@@ -142,14 +170,33 @@ export class SupabasePageStore {
142
170
  this.fail(res.error.message);
143
171
  return res.data?.tree ?? null;
144
172
  }
145
- async writeRefs(pageId, edges) {
146
- const deleted = await this.db.from("refs").delete().eq("source_kind", "page").eq("source_id", pageId);
173
+ /** Every locale's draft and published trees in ONE query. */
174
+ async liveTrees(page) {
175
+ const ids = new Set();
176
+ for (const row of Object.values(page.locales)) {
177
+ if (row.draftVersionId)
178
+ ids.add(row.draftVersionId);
179
+ if (row.publishedVersionId)
180
+ ids.add(row.publishedVersionId);
181
+ }
182
+ if (ids.size === 0)
183
+ return [];
184
+ const res = await this.db.from("page_versions").select("tree").in("id", [...ids]);
185
+ if (res.error)
186
+ this.fail(res.error.message);
187
+ return res.data.map((r) => r.tree);
188
+ }
189
+ async writeRefs(page) {
190
+ if (!this.options.sections)
191
+ return;
192
+ const edges = pageRefEdges(await this.liveTrees(page), this.options.sections);
193
+ const deleted = await this.db.from("refs").delete().eq("source_kind", "page").eq("source_id", page.id);
147
194
  if (deleted.error)
148
195
  this.fail(deleted.error.message);
149
196
  const rows = new Map();
150
197
  for (const edge of edges) {
151
198
  rows.set(`${edge.field} ${edge.targetId}`, {
152
- source_kind: "page", source_id: pageId, source_field: edge.field,
199
+ source_kind: "page", source_id: page.id, source_field: edge.field,
153
200
  target_collection: edge.targetCollection, target_id: edge.targetId,
154
201
  });
155
202
  }
@@ -166,58 +213,52 @@ export class SupabasePageStore {
166
213
  if (res.error)
167
214
  this.fail(res.error.message);
168
215
  }
216
+ /** page_locales and page_versions cascade from pages; refs do not. */
169
217
  async hardDelete(id) {
170
- for (const [table, col] of [["refs", "source_id"], ["page_versions", "page_id"], ["pages", "id"]]) {
171
- const res = table === "refs"
172
- ? await this.db.from(table).delete().eq("source_kind", "page").eq(col, id)
173
- : await this.db.from(table).delete().eq(col, id);
174
- if (res.error)
175
- this.fail(res.error.message);
176
- }
218
+ const refs = await this.db.from("refs").delete().eq("source_kind", "page").eq("source_id", id);
219
+ if (refs.error)
220
+ this.fail(refs.error.message);
221
+ const page = await this.db.from("pages").delete().eq("id", id);
222
+ if (page.error)
223
+ this.fail(page.error.message);
177
224
  }
178
225
  // ── contract ──
179
226
  async createPage(input) {
227
+ assertSupportedLocale(input.locale, this.opts.locales);
180
228
  assertSegment(input.slug, "page slug");
181
- assertPageI18n(input.i18n);
182
229
  const parentId = input.parentId ?? null;
183
230
  if (parentId !== null) {
184
231
  const parent = await this.must(parentId);
185
232
  if (this.isHome(parent))
186
233
  throw pageErrors.homeChildren();
234
+ if (!parent.locales[input.locale])
235
+ throw pageErrors.parentNotInLocale(input.locale);
187
236
  assertDepth((await this.chain(parent)).length + 1, this.opts.depth, "the page");
188
237
  }
189
238
  const siblings = await this.siblings(parentId);
239
+ if (siblings.some((s) => s.locales[input.locale]?.slug === input.slug))
240
+ throw pageErrors.slugTaken(input.slug);
190
241
  const inserted = await this.db
191
242
  .from("pages")
192
- .insert({
193
- parent_id: parentId,
194
- slug: input.slug,
195
- title: input.title ?? input.slug,
196
- i18n: input.i18n ?? null,
197
- template: input.template,
198
- sort: siblings.length + 1,
199
- locales: input.locales ?? null,
200
- })
201
- .select("*")
243
+ .insert({ parent_id: parentId, template: input.template, sort: siblings.length + 1 })
244
+ .select("id")
202
245
  .single();
203
- if (inserted.error) {
204
- if (inserted.error.code === UNIQUE_VIOLATION)
205
- throw pageErrors.slugTaken(input.slug);
246
+ if (inserted.error)
206
247
  this.fail(inserted.error.message);
207
- }
208
- let page = toRecord(inserted.data);
248
+ const id = inserted.data.id;
209
249
  try {
210
- if (input.template !== null) {
211
- const version = await this.insertVersion(page.id, { zones: {} });
212
- page = await this.update(page.id, { draft_version_id: version.id });
213
- }
250
+ const version = input.template === null ? null : await this.insertVersion(id, input.locale, { zones: {} });
251
+ await this.insertLocale(id, input.locale, {
252
+ slug: input.slug, title: input.title ?? input.slug, draft_version_id: version?.id ?? null,
253
+ });
254
+ const page = await this.must(id);
214
255
  await this.writePaths(page);
256
+ return page;
215
257
  }
216
258
  catch (e) {
217
- await this.hardDelete(page.id);
259
+ await this.hardDelete(id);
218
260
  throw e;
219
261
  }
220
- return page;
221
262
  }
222
263
  getPage(id) {
223
264
  return this.row(id);
@@ -225,7 +266,7 @@ export class SupabasePageStore {
225
266
  async listPages() {
226
267
  const res = await this.db
227
268
  .from("pages")
228
- .select("*")
269
+ .select(SELECT)
229
270
  .order("sort", { ascending: true, nullsFirst: false })
230
271
  .order("created_at", { ascending: true })
231
272
  .order("id", { ascending: true });
@@ -239,8 +280,56 @@ export class SupabasePageStore {
239
280
  pathsOf(id) {
240
281
  return UUID_RE.test(id) ? this.paths.pathsOf({ kind: "page", id }) : Promise.resolve({});
241
282
  }
283
+ async addLocale(id, locale, options) {
284
+ assertSupportedLocale(locale, this.opts.locales);
285
+ const page = await this.must(id);
286
+ if (page.locales[locale])
287
+ throw pageErrors.alreadyInLocale(id, locale);
288
+ const source = localeRow(page, options.from);
289
+ const slug = options.slug ?? source.slug;
290
+ if (options.slug !== undefined)
291
+ assertSegment(slug, `page slug (${locale})`);
292
+ if (page.parentId !== null && !(await this.must(page.parentId)).locales[locale])
293
+ throw pageErrors.parentNotInLocale(locale);
294
+ await this.assertSlugFree(page.parentId, locale, slug, id);
295
+ let added;
296
+ try {
297
+ // The SOURCE'S DRAFT is the copy (spec §3); a folder has no tree.
298
+ const version = page.template === null
299
+ ? null
300
+ : await this.insertVersion(id, locale, (await this.versionTree(source.draftVersionId)) ?? { zones: {} });
301
+ await this.insertLocale(id, locale, { slug, title: options.title ?? source.title, draft_version_id: version?.id ?? null });
302
+ added = await this.must(id);
303
+ await this.writePaths(added);
304
+ }
305
+ catch (e) {
306
+ await this.deleteLocale(id, locale);
307
+ throw e;
308
+ }
309
+ await this.writeRefs(added);
310
+ return added;
311
+ }
312
+ async removeLocale(id, locale) {
313
+ const page = await this.must(id);
314
+ localeRow(page, locale);
315
+ if (Object.keys(page.locales).length === 1)
316
+ throw pageErrors.lastLocale(id);
317
+ const blockers = (await this.subtree(page)).filter((n) => n.id !== id && n.locales[locale]);
318
+ if (blockers.length > 0)
319
+ throw pageErrors.localeInUse(locale, blockers.map((b) => b.locales[locale].title));
320
+ // Fewer path rows can never collide, so the row goes first and a
321
+ // network failure on the rewrite leaves a stale path row that
322
+ // resolves to a page absent in that locale — a 404, never leaked
323
+ // content — until the next write of this node fixes it.
324
+ await this.deleteLocale(id, locale);
325
+ const remaining = await this.must(id);
326
+ await this.writePaths(remaining);
327
+ await this.writeRefs(remaining);
328
+ return remaining;
329
+ }
242
330
  async rename(id, patch) {
243
331
  const page = await this.must(id);
332
+ const row = localeRow(page, patch.locale);
244
333
  if (patch.slug !== undefined)
245
334
  assertSegment(patch.slug, `page slug (${patch.locale})`);
246
335
  // A rename that turns this node INTO the home node (root, default-locale
@@ -253,28 +342,19 @@ export class SupabasePageStore {
253
342
  (await this.hasChildren(id))) {
254
343
  throw pageErrors.homeChildren();
255
344
  }
345
+ if (patch.slug !== undefined)
346
+ await this.assertSlugFree(page.parentId, patch.locale, patch.slug, id);
256
347
  const columns = {};
257
- if (patch.locale === this.opts.locales.default) {
258
- if (patch.slug !== undefined)
259
- columns.slug = patch.slug;
260
- if (patch.title !== undefined)
261
- columns.title = patch.title;
262
- }
263
- else {
264
- const i18n = { ...(page.i18n ?? {}) };
265
- i18n[patch.locale] = {
266
- ...(i18n[patch.locale] ?? {}),
267
- ...(patch.slug !== undefined ? { slug: patch.slug } : {}),
268
- ...(patch.title !== undefined ? { title: patch.title } : {}),
269
- };
270
- columns.i18n = i18n;
271
- }
272
- const renamed = await this.update(id, columns);
348
+ if (patch.slug !== undefined)
349
+ columns.slug = patch.slug;
350
+ if (patch.title !== undefined)
351
+ columns.title = patch.title;
352
+ const renamed = await this.updateLocale(id, patch.locale, columns);
273
353
  try {
274
354
  await this.writePaths(renamed);
275
355
  }
276
356
  catch (e) {
277
- await this.update(id, { slug: page.slug, title: page.title, i18n: page.i18n }, { touch: false });
357
+ await this.updateLocale(id, patch.locale, { slug: row.slug, title: row.title }, { touch: false });
278
358
  throw e;
279
359
  }
280
360
  return renamed;
@@ -288,6 +368,9 @@ export class SupabasePageStore {
288
368
  const parent = await this.must(to.parentId);
289
369
  if (this.isHome(parent))
290
370
  throw pageErrors.homeChildren();
371
+ for (const locale of Object.keys(page.locales))
372
+ if (!parent.locales[locale])
373
+ throw pageErrors.moveLocales(locale);
291
374
  const byId = new Map(subtree.map((n) => [n.id, n]));
292
375
  // The subtree's own height: walk each node up to the moved node
293
376
  // itself (not to the tree root — its parentId points OUTSIDE this
@@ -297,32 +380,36 @@ export class SupabasePageStore {
297
380
  const height = Math.max(...subtree.map(heightOf));
298
381
  assertDepth((await this.chain(parent)).length + height, this.opts.depth, "the page");
299
382
  }
300
- else if (page.slug === this.opts.homePageSlug && (await this.hasChildren(id))) {
383
+ else if (page.locales[this.opts.locales.default]?.slug === this.opts.homePageSlug && (await this.hasChildren(id))) {
301
384
  // Moving a home-slugged node TO the root is the mirror case of the
302
385
  // rename guard above: it would make it the home node while it still
303
386
  // has children.
304
387
  throw pageErrors.homeChildren();
305
388
  }
306
389
  const siblings = await this.siblings(to.parentId, id);
307
- if (to.parentId !== page.parentId && siblings.some((s) => s.slug === page.slug))
308
- throw pageErrors.slugTaken(page.slug);
390
+ if (to.parentId !== page.parentId) {
391
+ for (const [locale, row] of Object.entries(page.locales)) {
392
+ if (siblings.some((s) => s.locales[locale]?.slug === row.slug))
393
+ throw pageErrors.slugTaken(row.slug);
394
+ }
395
+ }
309
396
  // The destination siblings' sorts as they stood BEFORE the reorder.
310
397
  // A rollback restores these exact values rather than re-running the
311
398
  // reorder RPC, which renumbers 1..N and would silently close gaps
312
399
  // left by earlier deletes and moves.
313
400
  const previousSorts = siblings.map((s) => ({ id: s.id, sort: s.sort }));
314
- const moved = await this.update(id, { parent_id: to.parentId });
401
+ const moved = await this.updateNode(id, { parent_id: to.parentId });
315
402
  const index = Math.min(Math.max(to.index ?? siblings.length, 0), siblings.length);
316
403
  const ordered = siblings.map((s) => s.id);
317
404
  ordered.splice(index, 0, id);
318
- await this.reorder(ordered);
319
405
  try {
406
+ await this.reorder(ordered);
320
407
  await this.writePaths(moved);
321
408
  }
322
409
  catch (e) {
323
- await this.update(id, { parent_id: page.parentId, sort: page.sort }, { touch: false });
410
+ await this.updateNode(id, { parent_id: page.parentId, sort: page.sort }, { touch: false });
324
411
  for (const s of previousSorts)
325
- await this.update(s.id, { sort: s.sort }, { touch: false });
412
+ await this.updateNode(s.id, { sort: s.sort }, { touch: false });
326
413
  throw e;
327
414
  }
328
415
  return { ...moved, sort: index + 1 };
@@ -331,49 +418,53 @@ export class SupabasePageStore {
331
418
  const page = await this.must(id);
332
419
  if (page.template !== null)
333
420
  throw pageErrors.hasTemplate(id);
334
- const version = await this.insertVersion(id, { zones: {} });
335
- return this.update(id, { template, draft_version_id: version.id });
421
+ for (const locale of Object.keys(page.locales)) {
422
+ const version = await this.insertVersion(id, locale, { zones: {} });
423
+ await this.updateLocale(id, locale, { draft_version_id: version.id });
424
+ }
425
+ return this.updateNode(id, { template });
336
426
  }
337
- async saveDraft(id, tree) {
427
+ async saveDraft(id, locale, tree) {
338
428
  const page = await this.must(id);
339
429
  if (page.template === null)
340
430
  throw pageErrors.folder(id);
341
- const version = await this.insertVersion(id, tree);
342
- await this.update(id, { draft_version_id: version.id });
343
- if (this.options.sections) {
344
- const published = await this.versionTree(page.publishedVersionId);
345
- await this.writeRefs(id, pageRefEdges(tree, published, this.options.sections));
346
- }
431
+ localeRow(page, locale);
432
+ const version = await this.insertVersion(id, locale, tree);
433
+ const saved = await this.updateLocale(id, locale, { draft_version_id: version.id });
434
+ await this.writeRefs(saved);
347
435
  return toVersion(version);
348
436
  }
349
- async publish(id) {
437
+ async publish(id, locale) {
350
438
  const page = await this.must(id);
351
- if (page.template === null || !page.draftVersionId)
439
+ if (page.template === null)
440
+ throw pageErrors.folder(id);
441
+ const row = localeRow(page, locale);
442
+ if (!row.draftVersionId)
352
443
  throw pageErrors.folder(id);
353
444
  const { sections, entries } = this.options;
354
- const draft = (await this.versionTree(page.draftVersionId)) ?? { zones: {} };
445
+ const draft = (await this.versionTree(row.draftVersionId)) ?? { zones: {} };
355
446
  if (sections && entries)
356
- await assertPublishable(page.slug, draft, sections, entries);
357
- const record = await this.update(id, { published_version_id: page.draftVersionId, status: "published" });
358
- if (sections)
359
- await this.writeRefs(id, pageRefEdges(draft, null, sections));
447
+ await assertPublishable(row.slug, draft, sections, entries, locale);
448
+ const record = await this.updateLocale(id, locale, { published_version_id: row.draftVersionId, status: "published" });
449
+ await this.writeRefs(record);
360
450
  return record;
361
451
  }
362
- async getDraftTree(id) {
452
+ async getDraftTree(id, locale) {
363
453
  const page = await this.row(id);
364
- return page ? this.versionTree(page.draftVersionId) : null;
454
+ return page ? this.versionTree(page.locales[locale]?.draftVersionId ?? null) : null;
365
455
  }
366
- async getPublishedTree(id) {
456
+ async getPublishedTree(id, locale) {
367
457
  const page = await this.row(id);
368
- return page ? this.versionTree(page.publishedVersionId) : null;
458
+ return page ? this.versionTree(page.locales[locale]?.publishedVersionId ?? null) : null;
369
459
  }
370
- async listVersions(id) {
460
+ async listVersions(id, locale) {
371
461
  if (!UUID_RE.test(id))
372
462
  return [];
373
463
  const res = await this.db
374
464
  .from("page_versions")
375
465
  .select("*")
376
466
  .eq("page_id", id)
467
+ .eq("locale", locale)
377
468
  .order("created_at", { ascending: false })
378
469
  .order("id", { ascending: false });
379
470
  if (res.error)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smoodly",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
4
4
  "description": "Open-source, code-first CMS for Next.js on Supabase: define a component once, it becomes a typed, editable building block.",
5
5
  "license": "MIT",
6
6
  "repository": {