smoodly 0.0.14 → 0.0.16

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 (67) hide show
  1. package/README.md +94 -15
  2. package/dist/admin/auth.d.ts +7 -4
  3. package/dist/admin/auth.js +1 -1
  4. package/dist/admin/client-ops.js +3 -3
  5. package/dist/admin/editor/CanvasOverlay.d.ts +5 -1
  6. package/dist/admin/editor/CanvasOverlay.js +5 -5
  7. package/dist/admin/editor/EditorView.d.ts +2 -0
  8. package/dist/admin/editor/EditorView.js +251 -23
  9. package/dist/admin/editor/PageSettings.js +3 -2
  10. package/dist/admin/editor/SharedInspector.d.ts +23 -0
  11. package/dist/admin/editor/SharedInspector.js +13 -0
  12. package/dist/admin/editor/shared-edit.d.ts +39 -0
  13. package/dist/admin/editor/shared-edit.js +124 -0
  14. package/dist/admin/editor/useSharedEdit.d.ts +6 -0
  15. package/dist/admin/editor/useSharedEdit.js +18 -0
  16. package/dist/admin/forms/tabs.d.ts +9 -3
  17. package/dist/admin/next/op-handler.js +4 -3
  18. package/dist/admin/ops-impl.d.ts +3 -0
  19. package/dist/admin/ops-impl.js +198 -23
  20. package/dist/admin/ops.d.ts +32 -0
  21. package/dist/admin/serialize.d.ts +0 -1
  22. package/dist/admin/serialize.js +0 -1
  23. package/dist/admin/shared-items.js +14 -0
  24. package/dist/admin/shell/AdminApp.js +1 -1
  25. package/dist/admin/shell/EntriesList.js +19 -3
  26. package/dist/admin/shell/EntryForm.js +183 -77
  27. package/dist/admin/shell/IconRail.js +3 -3
  28. package/dist/admin/shell/PagesList.js +3 -2
  29. package/dist/admin/shell/SharedFields.d.ts +21 -0
  30. package/dist/admin/shell/SharedFields.js +15 -0
  31. package/dist/admin/shell/SharedForm.js +166 -52
  32. package/dist/admin/shell/SharedList.js +3 -2
  33. package/dist/admin/shell/entries-list.d.ts +2 -2
  34. package/dist/admin/shell/entries-list.js +3 -3
  35. package/dist/admin/supabase-auth.js +1 -1
  36. package/dist/admin/ui/ActionMenu.d.ts +11 -0
  37. package/dist/admin/ui/ActionMenu.js +25 -0
  38. package/dist/admin/ui/HistoryModal.d.ts +23 -0
  39. package/dist/admin/ui/HistoryModal.js +40 -0
  40. package/dist/admin/ui/Modal.d.ts +5 -1
  41. package/dist/admin/ui/Modal.js +3 -3
  42. package/dist/admin/ui/PublishControl.d.ts +31 -0
  43. package/dist/admin/ui/PublishControl.js +24 -0
  44. package/dist/admin/ui/format.d.ts +4 -0
  45. package/dist/admin/ui/format.js +9 -0
  46. package/dist/admin/ui/primitives.d.ts +13 -0
  47. package/dist/admin/ui/primitives.js +18 -2
  48. package/dist/admin/ui/status-label.d.ts +10 -0
  49. package/dist/admin/ui/status-label.js +14 -0
  50. package/dist/admin/ui/theme.js +46 -6
  51. package/dist/collections.d.ts +0 -6
  52. package/dist/collections.js +0 -1
  53. package/dist/entry-store.d.ts +38 -13
  54. package/dist/entry-store.js +90 -23
  55. package/dist/next/page-renderer.js +8 -4
  56. package/dist/shared.d.ts +8 -1
  57. package/dist/shared.js +10 -1
  58. package/dist/site.d.ts +2 -0
  59. package/dist/site.js +17 -7
  60. package/dist/sql.js +18 -15
  61. package/dist/store.d.ts +38 -13
  62. package/dist/store.js +82 -12
  63. package/dist/supabase-entry-store.d.ts +11 -6
  64. package/dist/supabase-entry-store.js +101 -25
  65. package/dist/supabase-store.d.ts +13 -6
  66. package/dist/supabase-store.js +88 -19
  67. package/package.json +1 -1
@@ -25,6 +25,7 @@ function toLocale(row) {
25
25
  draftVersionId: row.draft_version_id,
26
26
  publishedVersionId: row.published_version_id,
27
27
  updatedAt: Date.parse(row.updated_at),
28
+ updatedBy: row.updated_by,
28
29
  };
29
30
  }
30
31
  function toRecord(row) {
@@ -40,7 +41,7 @@ function toRecord(row) {
40
41
  };
41
42
  }
42
43
  function toVersion(row) {
43
- return { id: row.id, entryId: row.entry_id, locale: row.locale, fields: row.fields, createdAt: Date.parse(row.created_at) };
44
+ return { id: row.id, entryId: row.entry_id, locale: row.locale, fields: row.fields, savedAt: Date.parse(row.saved_at) };
44
45
  }
45
46
  export class SupabaseEntryStore {
46
47
  constructor(db, collections, options = {}) {
@@ -83,7 +84,15 @@ export class SupabaseEntryStore {
83
84
  async fetch(collection, id) {
84
85
  if (!UUID_RE.test(id))
85
86
  return null;
86
- const res = await this.db.from("entries").select(SELECT).eq("collection", collection).eq("id", id).maybeSingle();
87
+ // Locale rows ordered by locale, as in `list`: the embed otherwise arrives in
88
+ // physical row order, which shifts with every update to the table.
89
+ const res = await this.db
90
+ .from("entries")
91
+ .select(SELECT)
92
+ .eq("collection", collection)
93
+ .eq("id", id)
94
+ .order("locale", { referencedTable: "entry_locales", ascending: true })
95
+ .maybeSingle();
87
96
  if (res.error)
88
97
  this.fail(res.error.message);
89
98
  return res.data ? toRecord(res.data) : null;
@@ -223,16 +232,21 @@ export class SupabaseEntryStore {
223
232
  if (res.error)
224
233
  this.fail(res.error.message);
225
234
  }
226
- async insertLocale(entryId, locale, row) {
227
- const res = await this.db.from("entry_locales").insert({ entry_id: entryId, locale, slug: row.slug, fields: row.fields });
235
+ async insertLocale(entryId, locale, row, by) {
236
+ const res = await this.db
237
+ .from("entry_locales")
238
+ .insert({ entry_id: entryId, locale, slug: row.slug, fields: row.fields, updated_by: by ?? null });
228
239
  if (res.error) {
229
240
  if (res.error.code === UNIQUE_VIOLATION)
230
241
  throw entryErrors.alreadyInLocale(entryId, locale);
231
242
  this.fail(res.error.message);
232
243
  }
233
244
  }
245
+ /** A touch writes the audit pair — `updated_at` and `updated_by` (spec 2026-09-13 history §2). */
234
246
  async updateLocale(entryId, locale, patch, options = {}) {
235
- const payload = (options.touch ?? true) ? { ...patch, updated_at: new Date().toISOString() } : { ...patch };
247
+ const payload = (options.touch ?? true)
248
+ ? { ...patch, updated_at: new Date().toISOString(), updated_by: options.by ?? null }
249
+ : { ...patch };
236
250
  const res = await this.db.from("entry_locales").update(payload).eq("entry_id", entryId).eq("locale", locale);
237
251
  if (res.error)
238
252
  this.fail(res.error.message);
@@ -246,22 +260,36 @@ export class SupabaseEntryStore {
246
260
  if (versions.error)
247
261
  this.fail(versions.error.message);
248
262
  }
249
- /** A versioned collection snapshots the merged fields and points the
250
- * locale's draft at the new row; others do nothing here. */
263
+ /** The draft rule for every collection (spec 2026-09-14 §1), see MemoryEntryStore.recordVersion. */
251
264
  async recordVersion(entry, locale) {
252
- if (!this.schema(entry.collection).versions)
265
+ const row = entry.locales[locale];
266
+ const fields = entryFieldsIn(entry, locale);
267
+ if (row.draftVersionId && row.draftVersionId !== row.publishedVersionId) {
268
+ const res = await this.db.from("entry_versions").update({ fields, saved_at: new Date().toISOString() }).eq("id", row.draftVersionId);
269
+ if (res.error)
270
+ this.fail(res.error.message);
253
271
  return;
254
- const res = await this.db
255
- .from("entry_versions")
256
- .insert({ entry_id: entry.id, locale, fields: entryFieldsIn(entry, locale) })
257
- .select("id")
258
- .single();
272
+ }
273
+ const res = await this.db.from("entry_versions").insert({ entry_id: entry.id, locale, fields }).select("id").single();
259
274
  if (res.error)
260
275
  this.fail(res.error.message);
261
276
  await this.updateLocale(entry.id, locale, { draft_version_id: res.data.id }, { touch: false });
262
277
  }
278
+ /** See MemoryEntryStore.ensureLiveSnapshot: the snapshot of what is live, both pointers on it. */
279
+ async ensureLiveSnapshot(entry, locale) {
280
+ const row = entry.locales[locale];
281
+ if (row.status !== "published" || row.publishedVersionId !== null)
282
+ return;
283
+ const res = await this.db.from("entry_versions").insert({ entry_id: entry.id, locale, fields: entryFieldsIn(entry, locale) }).select("id").single();
284
+ if (res.error)
285
+ this.fail(res.error.message);
286
+ const id = res.data.id;
287
+ await this.updateLocale(entry.id, locale, { draft_version_id: id, published_version_id: id }, { touch: false });
288
+ row.draftVersionId = id;
289
+ row.publishedVersionId = id;
290
+ }
263
291
  // ── the contract ──
264
- async create(collection, fields, options) {
292
+ async create(collection, fields, options, by) {
265
293
  const schema = this.schema(collection);
266
294
  assertSupportedLocale(options.locale, this.locales);
267
295
  const parentId = options.parentId ?? null;
@@ -294,7 +322,7 @@ export class SupabaseEntryStore {
294
322
  }
295
323
  const id = inserted.data.id;
296
324
  try {
297
- await this.insertLocale(id, options.locale, { slug, fields: localized });
325
+ await this.insertLocale(id, options.locale, { slug, fields: localized }, by);
298
326
  let record = await this.must(collection, id);
299
327
  await this.recordVersion(record, options.locale);
300
328
  record = await this.must(collection, id);
@@ -389,23 +417,24 @@ export class SupabaseEntryStore {
389
417
  }
390
418
  return out;
391
419
  }
392
- async update(collection, id, locale, fields) {
420
+ async update(collection, id, locale, fields, by) {
393
421
  const schema = this.schema(collection);
394
422
  const before = await this.must(collection, id);
395
423
  const row = entryLocaleRow(before, locale);
424
+ await this.ensureLiveSnapshot(before, locale);
396
425
  const { shared, localized, slug } = splitFields(fields, schema);
397
426
  if (isSharedSchema(schema) && slug !== null)
398
427
  throw entryErrors.noSlug(collection);
399
428
  await this.assertSiblingSlug(collection, before.parentId, locale, slug, id);
400
429
  await this.updateNode(id, { fields: shared });
401
- await this.updateLocale(id, locale, { slug, fields: localized });
430
+ await this.updateLocale(id, locale, { slug, fields: localized }, { by });
402
431
  const record = await this.must(collection, id);
403
432
  try {
404
433
  await this.writePaths(record);
405
434
  }
406
435
  catch (e) {
407
436
  await this.updateNode(id, { fields: before.fields, updated_at: new Date(before.updatedAt).toISOString() }, { touch: false });
408
- await this.updateLocale(id, locale, { slug: row.slug, fields: row.fields, updated_at: new Date(row.updatedAt).toISOString() }, { touch: false });
437
+ await this.updateLocale(id, locale, { slug: row.slug, fields: row.fields, updated_at: new Date(row.updatedAt).toISOString(), updated_by: row.updatedBy }, { touch: false });
409
438
  throw e;
410
439
  }
411
440
  await this.recordVersion(record, locale);
@@ -413,27 +442,47 @@ export class SupabaseEntryStore {
413
442
  await this.writeRefs(final);
414
443
  return final;
415
444
  }
416
- async setStatus(collection, id, locale, status) {
445
+ async setStatus(collection, id, locale, status, by) {
417
446
  let record = await this.must(collection, id);
418
447
  let row = entryLocaleRow(record, locale);
419
448
  if (status === "published") {
420
- if (this.schema(collection).versions && !row.draftVersionId) {
449
+ if (!row.draftVersionId) {
421
450
  await this.recordVersion(record, locale);
422
451
  record = await this.must(collection, id);
423
452
  row = entryLocaleRow(record, locale);
424
453
  }
425
- await this.updateLocale(id, locale, { status, published_version_id: row.draftVersionId });
454
+ await this.updateLocale(id, locale, { status, published_version_id: row.draftVersionId }, { by });
426
455
  }
427
456
  else {
428
457
  // Unpublish keeps the pointer for a later republish (spec §2).
429
- await this.updateLocale(id, locale, { status });
458
+ await this.updateLocale(id, locale, { status }, { by });
430
459
  }
431
460
  await this.updateNode(id, {});
432
461
  const final = await this.must(collection, id);
433
462
  await this.writeRefs(final);
434
463
  return final;
435
464
  }
436
- async addLocale(collection, id, locale, options) {
465
+ async discardDraft(collection, id, locale, by) {
466
+ const schema = this.schema(collection);
467
+ const record = await this.must(collection, id);
468
+ const row = entryLocaleRow(record, locale);
469
+ const snapshot = row.publishedVersionId ? (await this.versionFields([row.publishedVersionId])).get(row.publishedVersionId) : undefined;
470
+ if (!snapshot)
471
+ throw entryErrors.nothingPublished(collection, id, locale);
472
+ const { shared, localized } = splitFields(snapshot, schema);
473
+ await this.updateNode(id, { fields: shared });
474
+ await this.updateLocale(id, locale, { fields: localized, draft_version_id: row.publishedVersionId }, { by });
475
+ if (row.draftVersionId && row.draftVersionId !== row.publishedVersionId) {
476
+ // The pointer moved first, so a failure here leaves a stray row, never a dangling pointer.
477
+ const gone = await this.db.from("entry_versions").delete().eq("id", row.draftVersionId);
478
+ if (gone.error)
479
+ this.fail(gone.error.message);
480
+ }
481
+ const final = await this.must(collection, id);
482
+ await this.writeRefs(final);
483
+ return final;
484
+ }
485
+ async addLocale(collection, id, locale, options, by) {
437
486
  assertSupportedLocale(locale, this.locales);
438
487
  const entry = await this.must(collection, id);
439
488
  // The source is read FIRST (the memory adapter's order): "copy from a
@@ -445,7 +494,7 @@ export class SupabaseEntryStore {
445
494
  const parent = entry.parentId === null ? null : await this.fetch(collection, entry.parentId);
446
495
  this.assertParentHasLocale(parent, locale);
447
496
  await this.assertSiblingSlug(collection, entry.parentId, locale, source.slug, id);
448
- await this.insertLocale(id, locale, { slug: source.slug, fields: source.fields });
497
+ await this.insertLocale(id, locale, { slug: source.slug, fields: source.fields }, by);
449
498
  let added;
450
499
  try {
451
500
  added = await this.must(collection, id);
@@ -492,12 +541,39 @@ export class SupabaseEntryStore {
492
541
  .select("*")
493
542
  .eq("entry_id", id)
494
543
  .eq("locale", locale)
495
- .order("created_at", { ascending: false })
544
+ .order("saved_at", { ascending: false })
496
545
  .order("id", { ascending: false });
497
546
  if (res.error)
498
547
  this.fail(res.error.message);
499
548
  return res.data.map(toVersion);
500
549
  }
550
+ async restore(collection, id, locale, versionId, by) {
551
+ const schema = this.schema(collection);
552
+ const entry = await this.must(collection, id);
553
+ const row = entryLocaleRow(entry, locale);
554
+ const version = await this.getVersion(collection, versionId);
555
+ if (!version || version.entryId !== id || version.locale !== locale)
556
+ throw entryErrors.foreignVersion(collection, id, locale, versionId);
557
+ if (versionId === row.publishedVersionId)
558
+ return this.discardDraft(collection, id, locale, by);
559
+ const { shared, localized } = splitFields(version.fields, schema);
560
+ await this.updateNode(id, { fields: shared });
561
+ await this.updateLocale(id, locale, { fields: localized }, { by }); // the slug is the row's own and never versioned
562
+ const record = await this.must(collection, id);
563
+ await this.recordVersion(record, locale);
564
+ const final = await this.must(collection, id);
565
+ await this.writeRefs(final);
566
+ return final;
567
+ }
568
+ async getVersion(collection, versionId) {
569
+ this.schema(collection);
570
+ if (!UUID_RE.test(versionId))
571
+ return null;
572
+ const res = await this.db.from("entry_versions").select("*").eq("id", versionId).maybeSingle();
573
+ if (res.error)
574
+ this.fail(res.error.message);
575
+ return res.data ? toVersion(res.data) : null;
576
+ }
501
577
  async move(collection, id, to) {
502
578
  if (isSharedSchema(this.schema(collection)))
503
579
  throw entryErrors.owned(collection, "it has no tree position");
@@ -27,6 +27,7 @@ export declare class SupabasePageStore implements PageStore {
27
27
  * A rollback restoring prior column values passes `{ touch: false }`,
28
28
  * which omits updated_at entirely — undoing a write is not an edit. */
29
29
  private updateNode;
30
+ /** A touch writes the audit pair — `updated_at` and `updated_by` (spec 2026-09-13 history §2). */
30
31
  private updateLocale;
31
32
  private insertLocale;
32
33
  /** The locale's row and versions — the removeLocale and rollback shape.
@@ -35,6 +36,8 @@ export declare class SupabasePageStore implements PageStore {
35
36
  * pointing at an already-deleted version. */
36
37
  private deleteLocale;
37
38
  private insertVersion;
39
+ /** The draft rule (spec 2026-09-13 history §1), see MemoryPageStore.writeDraftVersion. */
40
+ private writeDraftVersion;
38
41
  private versionTree;
39
42
  /** Every locale's draft and published trees in ONE query. */
40
43
  private liveTrees;
@@ -42,25 +45,29 @@ export declare class SupabasePageStore implements PageStore {
42
45
  private reorder;
43
46
  /** page_locales and page_versions cascade from pages; refs do not. */
44
47
  private hardDelete;
45
- createPage(input: CreatePageInput): Promise<PageRecord>;
48
+ createPage(input: CreatePageInput, by?: string): Promise<PageRecord>;
46
49
  getPage(id: string): Promise<PageRecord | null>;
47
50
  listPages(): Promise<PageRecord[]>;
48
51
  resolvePath(locale: string, path: string): Promise<PathTarget | null>;
49
52
  pathsOf(id: string): Promise<{}>;
50
- addLocale(id: string, locale: string, options: AddLocaleOptions): Promise<PageRecord>;
53
+ addLocale(id: string, locale: string, options: AddLocaleOptions, by?: string): Promise<PageRecord>;
51
54
  removeLocale(id: string, locale: string): Promise<PageRecord>;
52
55
  rename(id: string, patch: {
53
56
  locale: string;
54
57
  slug?: string;
55
58
  title?: string;
56
- }): Promise<PageRecord>;
59
+ }, by?: string): Promise<PageRecord>;
57
60
  move(id: string, to: {
58
61
  parentId: string | null;
59
62
  index?: number;
60
63
  }): Promise<PageRecord>;
61
- setTemplate(id: string, template: string): Promise<PageRecord>;
62
- saveDraft(id: string, locale: string, tree: PageTree): Promise<PageVersion>;
63
- publish(id: string, locale: string): Promise<PageRecord>;
64
+ setTemplate(id: string, template: string, by?: string): Promise<PageRecord>;
65
+ saveDraft(id: string, locale: string, tree: PageTree, by?: string): Promise<PageVersion>;
66
+ publish(id: string, locale: string, by?: string): Promise<PageRecord>;
67
+ discardDraft(id: string, locale: string, by?: string): Promise<PageRecord>;
68
+ unpublish(id: string, locale: string, by?: string): Promise<PageRecord>;
69
+ restore(id: string, locale: string, versionId: string, by?: string): Promise<PageRecord>;
70
+ getVersion(versionId: string): Promise<PageVersion | null>;
64
71
  getDraftTree(id: string, locale: string): Promise<PageTree | null>;
65
72
  fieldsOf(versionIds: string[]): Promise<Record<string, Record<string, unknown>>>;
66
73
  getPublishedTree(id: string, locale: string): Promise<PageTree | null>;
@@ -21,6 +21,8 @@ function toRecord(row) {
21
21
  status: l.status,
22
22
  draftVersionId: l.draft_version_id,
23
23
  publishedVersionId: l.published_version_id,
24
+ updatedAt: Date.parse(l.updated_at),
25
+ updatedBy: l.updated_by,
24
26
  };
25
27
  }
26
28
  return {
@@ -32,7 +34,7 @@ function toRecord(row) {
32
34
  };
33
35
  }
34
36
  function toVersion(row) {
35
- return { id: row.id, pageId: row.page_id, locale: row.locale, tree: row.tree, createdAt: Date.parse(row.created_at) };
37
+ return { id: row.id, pageId: row.page_id, locale: row.locale, tree: row.tree, savedAt: Date.parse(row.saved_at) };
36
38
  }
37
39
  export class SupabasePageStore {
38
40
  constructor(db, options = {}) {
@@ -129,8 +131,11 @@ export class SupabasePageStore {
129
131
  this.fail(res.error.message);
130
132
  return toRecord(res.data);
131
133
  }
134
+ /** A touch writes the audit pair — `updated_at` and `updated_by` (spec 2026-09-13 history §2). */
132
135
  async updateLocale(id, locale, patch, options = {}) {
133
- const payload = (options.touch ?? true) ? { ...patch, updated_at: new Date().toISOString() } : { ...patch };
136
+ const payload = (options.touch ?? true)
137
+ ? { ...patch, updated_at: new Date().toISOString(), updated_by: options.by ?? null }
138
+ : { ...patch };
134
139
  const res = await this.db.from("page_locales").update(payload).eq("page_id", id).eq("locale", locale).select("page_id").single();
135
140
  if (res.error)
136
141
  this.fail(res.error.message);
@@ -162,6 +167,25 @@ export class SupabasePageStore {
162
167
  this.fail(res.error.message);
163
168
  return res.data;
164
169
  }
170
+ /** The draft rule (spec 2026-09-13 history §1), see MemoryPageStore.writeDraftVersion. */
171
+ async writeDraftVersion(page, locale, tree, by) {
172
+ const row = localeRow(page, locale);
173
+ if (row.draftVersionId && row.draftVersionId !== row.publishedVersionId) {
174
+ const res = await this.db
175
+ .from("page_versions")
176
+ .update({ tree, saved_at: new Date().toISOString() })
177
+ .eq("id", row.draftVersionId)
178
+ .select("*")
179
+ .single();
180
+ if (res.error)
181
+ this.fail(res.error.message);
182
+ const record = await this.updateLocale(page.id, locale, {}, { by });
183
+ return { version: res.data, record };
184
+ }
185
+ const version = await this.insertVersion(page.id, locale, tree);
186
+ const record = await this.updateLocale(page.id, locale, { draft_version_id: version.id }, { by });
187
+ return { version, record };
188
+ }
165
189
  async versionTree(versionId) {
166
190
  if (!versionId)
167
191
  return null;
@@ -223,7 +247,7 @@ export class SupabasePageStore {
223
247
  this.fail(page.error.message);
224
248
  }
225
249
  // ── contract ──
226
- async createPage(input) {
250
+ async createPage(input, by) {
227
251
  assertSupportedLocale(input.locale, this.opts.locales);
228
252
  assertSegment(input.slug, "page slug");
229
253
  const parentId = input.parentId ?? null;
@@ -249,7 +273,7 @@ export class SupabasePageStore {
249
273
  try {
250
274
  const version = input.template === null ? null : await this.insertVersion(id, input.locale, { zones: {} });
251
275
  await this.insertLocale(id, input.locale, {
252
- slug: input.slug, title: input.title ?? input.slug, draft_version_id: version?.id ?? null,
276
+ slug: input.slug, title: input.title ?? input.slug, draft_version_id: version?.id ?? null, updated_by: by ?? null,
253
277
  });
254
278
  const page = await this.must(id);
255
279
  await this.writePaths(page);
@@ -280,7 +304,7 @@ export class SupabasePageStore {
280
304
  pathsOf(id) {
281
305
  return UUID_RE.test(id) ? this.paths.pathsOf({ kind: "page", id }) : Promise.resolve({});
282
306
  }
283
- async addLocale(id, locale, options) {
307
+ async addLocale(id, locale, options, by) {
284
308
  assertSupportedLocale(locale, this.opts.locales);
285
309
  const page = await this.must(id);
286
310
  if (page.locales[locale])
@@ -298,7 +322,9 @@ export class SupabasePageStore {
298
322
  const version = page.template === null
299
323
  ? null
300
324
  : 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 });
325
+ await this.insertLocale(id, locale, {
326
+ slug, title: options.title ?? source.title, draft_version_id: version?.id ?? null, updated_by: by ?? null,
327
+ });
302
328
  added = await this.must(id);
303
329
  await this.writePaths(added);
304
330
  }
@@ -327,7 +353,7 @@ export class SupabasePageStore {
327
353
  await this.writeRefs(remaining);
328
354
  return remaining;
329
355
  }
330
- async rename(id, patch) {
356
+ async rename(id, patch, by) {
331
357
  const page = await this.must(id);
332
358
  const row = localeRow(page, patch.locale);
333
359
  if (patch.slug !== undefined)
@@ -349,12 +375,13 @@ export class SupabasePageStore {
349
375
  columns.slug = patch.slug;
350
376
  if (patch.title !== undefined)
351
377
  columns.title = patch.title;
352
- const renamed = await this.updateLocale(id, patch.locale, columns);
378
+ const renamed = await this.updateLocale(id, patch.locale, columns, { by });
353
379
  try {
354
380
  await this.writePaths(renamed);
355
381
  }
356
382
  catch (e) {
357
- await this.updateLocale(id, patch.locale, { slug: row.slug, title: row.title }, { touch: false });
383
+ // The audit pair goes back too: a rejected rename is not an edit.
384
+ await this.updateLocale(id, patch.locale, { slug: row.slug, title: row.title, updated_at: new Date(row.updatedAt).toISOString(), updated_by: row.updatedBy }, { touch: false });
358
385
  throw e;
359
386
  }
360
387
  return renamed;
@@ -414,27 +441,25 @@ export class SupabasePageStore {
414
441
  }
415
442
  return { ...moved, sort: index + 1 };
416
443
  }
417
- async setTemplate(id, template) {
444
+ async setTemplate(id, template, by) {
418
445
  const page = await this.must(id);
419
446
  if (page.template !== null)
420
447
  throw pageErrors.hasTemplate(id);
421
448
  for (const locale of Object.keys(page.locales)) {
422
449
  const version = await this.insertVersion(id, locale, { zones: {} });
423
- await this.updateLocale(id, locale, { draft_version_id: version.id });
450
+ await this.updateLocale(id, locale, { draft_version_id: version.id }, { by });
424
451
  }
425
452
  return this.updateNode(id, { template });
426
453
  }
427
- async saveDraft(id, locale, tree) {
454
+ async saveDraft(id, locale, tree, by) {
428
455
  const page = await this.must(id);
429
456
  if (page.template === null)
430
457
  throw pageErrors.folder(id);
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);
458
+ const { version, record } = await this.writeDraftVersion(page, locale, tree, by);
459
+ await this.writeRefs(record);
435
460
  return toVersion(version);
436
461
  }
437
- async publish(id, locale) {
462
+ async publish(id, locale, by) {
438
463
  const page = await this.must(id);
439
464
  if (page.template === null)
440
465
  throw pageErrors.folder(id);
@@ -445,10 +470,54 @@ export class SupabasePageStore {
445
470
  const draft = (await this.versionTree(row.draftVersionId)) ?? { zones: {} };
446
471
  if (sections && entries)
447
472
  await assertPublishable(row.slug, draft, sections, entries, locale);
448
- const record = await this.updateLocale(id, locale, { published_version_id: row.draftVersionId, status: "published" });
473
+ const record = await this.updateLocale(id, locale, { published_version_id: row.draftVersionId, status: "published" }, { by });
474
+ await this.writeRefs(record);
475
+ return record;
476
+ }
477
+ async discardDraft(id, locale, by) {
478
+ const page = await this.must(id);
479
+ const row = localeRow(page, locale);
480
+ if (!row.publishedVersionId)
481
+ throw pageErrors.nothingPublished(id, locale);
482
+ const record = await this.updateLocale(id, locale, { draft_version_id: row.publishedVersionId }, { by });
483
+ if (row.draftVersionId && row.draftVersionId !== row.publishedVersionId) {
484
+ // The pointer moved first, so a failure here leaves a stray row, never a dangling pointer.
485
+ const gone = await this.db.from("page_versions").delete().eq("id", row.draftVersionId);
486
+ if (gone.error)
487
+ this.fail(gone.error.message);
488
+ }
449
489
  await this.writeRefs(record);
450
490
  return record;
451
491
  }
492
+ async unpublish(id, locale, by) {
493
+ const page = await this.must(id);
494
+ localeRow(page, locale); // the "does not exist in locale" guard
495
+ const record = await this.updateLocale(id, locale, { status: "draft" }, { by });
496
+ await this.writeRefs(record);
497
+ return record;
498
+ }
499
+ async restore(id, locale, versionId, by) {
500
+ const page = await this.must(id);
501
+ if (page.template === null)
502
+ throw pageErrors.folder(id);
503
+ const row = localeRow(page, locale);
504
+ const version = await this.getVersion(versionId);
505
+ if (!version || version.pageId !== id || version.locale !== locale)
506
+ throw pageErrors.foreignVersion(id, locale, versionId);
507
+ if (versionId === row.publishedVersionId)
508
+ return this.discardDraft(id, locale, by);
509
+ const { record } = await this.writeDraftVersion(page, locale, version.tree, by);
510
+ await this.writeRefs(record);
511
+ return record;
512
+ }
513
+ async getVersion(versionId) {
514
+ if (!UUID_RE.test(versionId))
515
+ return null;
516
+ const res = await this.db.from("page_versions").select("*").eq("id", versionId).maybeSingle();
517
+ if (res.error)
518
+ this.fail(res.error.message);
519
+ return res.data ? toVersion(res.data) : null;
520
+ }
452
521
  async getDraftTree(id, locale) {
453
522
  const page = await this.row(id);
454
523
  return page ? this.versionTree(page.locales[locale]?.draftVersionId ?? null) : null;
@@ -478,7 +547,7 @@ export class SupabasePageStore {
478
547
  .select("*")
479
548
  .eq("page_id", id)
480
549
  .eq("locale", locale)
481
- .order("created_at", { ascending: false })
550
+ .order("saved_at", { ascending: false })
482
551
  .order("id", { ascending: false });
483
552
  if (res.error)
484
553
  this.fail(res.error.message);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smoodly",
3
- "version": "0.0.14",
3
+ "version": "0.0.16",
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": {