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.
- package/README.md +94 -15
- package/dist/admin/auth.d.ts +7 -4
- package/dist/admin/auth.js +1 -1
- package/dist/admin/client-ops.js +3 -3
- package/dist/admin/editor/CanvasOverlay.d.ts +5 -1
- package/dist/admin/editor/CanvasOverlay.js +5 -5
- package/dist/admin/editor/EditorView.d.ts +2 -0
- package/dist/admin/editor/EditorView.js +251 -23
- package/dist/admin/editor/PageSettings.js +3 -2
- package/dist/admin/editor/SharedInspector.d.ts +23 -0
- package/dist/admin/editor/SharedInspector.js +13 -0
- package/dist/admin/editor/shared-edit.d.ts +39 -0
- package/dist/admin/editor/shared-edit.js +124 -0
- package/dist/admin/editor/useSharedEdit.d.ts +6 -0
- package/dist/admin/editor/useSharedEdit.js +18 -0
- package/dist/admin/forms/tabs.d.ts +9 -3
- package/dist/admin/next/op-handler.js +4 -3
- package/dist/admin/ops-impl.d.ts +3 -0
- package/dist/admin/ops-impl.js +198 -23
- package/dist/admin/ops.d.ts +32 -0
- package/dist/admin/serialize.d.ts +0 -1
- package/dist/admin/serialize.js +0 -1
- package/dist/admin/shared-items.js +14 -0
- package/dist/admin/shell/AdminApp.js +1 -1
- package/dist/admin/shell/EntriesList.js +19 -3
- package/dist/admin/shell/EntryForm.js +183 -77
- package/dist/admin/shell/IconRail.js +3 -3
- package/dist/admin/shell/PagesList.js +3 -2
- package/dist/admin/shell/SharedFields.d.ts +21 -0
- package/dist/admin/shell/SharedFields.js +15 -0
- package/dist/admin/shell/SharedForm.js +166 -52
- package/dist/admin/shell/SharedList.js +3 -2
- package/dist/admin/shell/entries-list.d.ts +2 -2
- package/dist/admin/shell/entries-list.js +3 -3
- package/dist/admin/supabase-auth.js +1 -1
- package/dist/admin/ui/ActionMenu.d.ts +11 -0
- package/dist/admin/ui/ActionMenu.js +25 -0
- package/dist/admin/ui/HistoryModal.d.ts +23 -0
- package/dist/admin/ui/HistoryModal.js +40 -0
- package/dist/admin/ui/Modal.d.ts +5 -1
- package/dist/admin/ui/Modal.js +3 -3
- package/dist/admin/ui/PublishControl.d.ts +31 -0
- package/dist/admin/ui/PublishControl.js +24 -0
- package/dist/admin/ui/format.d.ts +4 -0
- package/dist/admin/ui/format.js +9 -0
- package/dist/admin/ui/primitives.d.ts +13 -0
- package/dist/admin/ui/primitives.js +18 -2
- package/dist/admin/ui/status-label.d.ts +10 -0
- package/dist/admin/ui/status-label.js +14 -0
- package/dist/admin/ui/theme.js +46 -6
- package/dist/collections.d.ts +0 -6
- package/dist/collections.js +0 -1
- package/dist/entry-store.d.ts +38 -13
- package/dist/entry-store.js +90 -23
- package/dist/next/page-renderer.js +8 -4
- package/dist/shared.d.ts +8 -1
- package/dist/shared.js +10 -1
- package/dist/site.d.ts +2 -0
- package/dist/site.js +17 -7
- package/dist/sql.js +18 -15
- package/dist/store.d.ts +38 -13
- package/dist/store.js +82 -12
- package/dist/supabase-entry-store.d.ts +11 -6
- package/dist/supabase-entry-store.js +101 -25
- package/dist/supabase-store.d.ts +13 -6
- package/dist/supabase-store.js +88 -19
- 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,
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
255
|
-
|
|
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 (
|
|
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
|
|
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("
|
|
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");
|
package/dist/supabase-store.d.ts
CHANGED
|
@@ -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>;
|
package/dist/supabase-store.js
CHANGED
|
@@ -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,
|
|
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)
|
|
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, {
|
|
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
|
-
|
|
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
|
-
|
|
432
|
-
|
|
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("
|
|
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