smoodly 0.0.24 → 0.0.26
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 +214 -40
- package/bin/smoodly.js +6 -0
- package/dist/admin/client-ops.js +1 -0
- package/dist/admin/editor/EditorView.js +100 -22
- package/dist/admin/editor/InsertPicker.d.ts +14 -0
- package/dist/admin/editor/InsertPicker.js +37 -7
- package/dist/admin/editor/InspectorFields.js +1 -1
- package/dist/admin/editor/Outline.js +2 -2
- package/dist/admin/editor/PageSettings.js +1 -1
- package/dist/admin/editor/SeoSettings.js +1 -1
- package/dist/admin/editor/autosave.d.ts +4 -1
- package/dist/admin/editor/autosave.js +24 -1
- package/dist/admin/editor/global-edit.d.ts +5 -1
- package/dist/admin/editor/global-edit.js +15 -5
- package/dist/admin/editor/locale-url.d.ts +7 -4
- package/dist/admin/editor/locale-url.js +14 -7
- package/dist/admin/editor/useGlobalEdit.d.ts +1 -1
- package/dist/admin/editor/useGlobalEdit.js +1 -1
- package/dist/admin/editor/usePageLocale.js +9 -1
- package/dist/admin/forms/CropEditor.d.ts +4 -2
- package/dist/admin/forms/CropEditor.js +6 -3
- package/dist/admin/forms/DatePicker.d.ts +9 -0
- package/dist/admin/forms/DatePicker.js +133 -0
- package/dist/admin/forms/FieldWidget.js +11 -5
- package/dist/admin/forms/ImagePreview.d.ts +9 -0
- package/dist/admin/forms/ImagePreview.js +11 -0
- package/dist/admin/forms/MediaWidget.d.ts +8 -2
- package/dist/admin/forms/MediaWidget.js +13 -13
- package/dist/admin/forms/RichtextElementCard.js +1 -1
- package/dist/admin/forms/RichtextImageCard.js +14 -9
- package/dist/admin/forms/date-picker.d.ts +44 -0
- package/dist/admin/forms/date-picker.js +117 -0
- package/dist/admin/forms/elements-context.d.ts +3 -0
- package/dist/admin/forms/elements-context.js +7 -2
- package/dist/admin/forms/time-zone-context.d.ts +7 -0
- package/dist/admin/forms/time-zone-context.js +15 -0
- package/dist/admin/forms/useRichtextEditor.js +10 -1
- package/dist/admin/next/bridge.d.ts +2 -1
- package/dist/admin/next/bridge.js +35 -5
- package/dist/admin/next/create-admin.d.ts +4 -0
- package/dist/admin/next/create-admin.js +3 -1
- package/dist/admin/next/index.d.ts +1 -0
- package/dist/admin/ops-impl.js +74 -24
- package/dist/admin/ops.d.ts +31 -12
- package/dist/admin/serialize.d.ts +2 -0
- package/dist/admin/serialize.js +1 -0
- package/dist/admin/shell/AdminApp.d.ts +3 -1
- package/dist/admin/shell/AdminApp.js +42 -2
- package/dist/admin/shell/EntriesList.js +17 -22
- package/dist/admin/shell/EntryForm.js +91 -13
- package/dist/admin/shell/GlobalForm.js +68 -9
- package/dist/admin/shell/GlobalList.js +10 -20
- package/dist/admin/shell/IconRail.js +14 -5
- package/dist/admin/shell/InviteModal.js +9 -5
- package/dist/admin/shell/ListView.js +2 -2
- package/dist/admin/shell/MediaScreen.js +11 -1
- package/dist/admin/shell/MembersPage.js +33 -15
- package/dist/admin/shell/PageDialogs.d.ts +26 -0
- package/dist/admin/shell/PageDialogs.js +38 -0
- package/dist/admin/shell/PagesList.js +101 -74
- package/dist/admin/shell/RouteLink.js +1 -1
- package/dist/admin/shell/SchemaNotice.d.ts +4 -0
- package/dist/admin/shell/SchemaNotice.js +4 -0
- package/dist/admin/shell/SettingsView.js +11 -26
- package/dist/admin/shell/live-url.d.ts +4 -3
- package/dist/admin/shell/live-url.js +12 -3
- package/dist/admin/shell/locale-memory.d.ts +8 -0
- package/dist/admin/shell/locale-memory.js +23 -0
- package/dist/admin/shell/members-list.d.ts +2 -0
- package/dist/admin/shell/members-list.js +5 -0
- package/dist/admin/shell/page-dialogs.d.ts +9 -0
- package/dist/admin/shell/page-dialogs.js +19 -0
- package/dist/admin/shell/presence-context.d.ts +9 -0
- package/dist/admin/shell/presence-context.js +35 -0
- package/dist/admin/shell/presence-realtime.d.ts +3 -0
- package/dist/admin/shell/presence-realtime.js +22 -0
- package/dist/admin/shell/presence.d.ts +51 -0
- package/dist/admin/shell/presence.js +90 -0
- package/dist/admin/shell/router.d.ts +10 -7
- package/dist/admin/shell/router.js +18 -9
- package/dist/admin/shell/settings.d.ts +0 -6
- package/dist/admin/shell/settings.js +4 -5
- package/dist/admin/shell/stale.d.ts +28 -0
- package/dist/admin/shell/stale.js +17 -0
- package/dist/admin/tree-ops.js +3 -0
- package/dist/admin/ui/ActionMenu.js +26 -6
- package/dist/admin/ui/Combobox.d.ts +5 -0
- package/dist/admin/ui/Combobox.js +1 -1
- package/dist/admin/ui/ConfirmDialog.d.ts +8 -0
- package/dist/admin/ui/ConfirmDialog.js +30 -0
- package/dist/admin/ui/Dialog.d.ts +13 -0
- package/dist/admin/ui/Dialog.js +54 -0
- package/dist/admin/ui/HistoryModal.d.ts +2 -1
- package/dist/admin/ui/HistoryModal.js +7 -2
- package/dist/admin/ui/LockIcon.d.ts +4 -0
- package/dist/admin/ui/LockIcon.js +12 -0
- package/dist/admin/ui/PresenceLine.d.ts +6 -0
- package/dist/admin/ui/PresenceLine.js +11 -0
- package/dist/admin/ui/ResizeHandle.js +4 -4
- package/dist/admin/ui/StaleBanner.d.ts +5 -0
- package/dist/admin/ui/StaleBanner.js +9 -0
- package/dist/admin/ui/confirm.d.ts +35 -0
- package/dist/admin/ui/confirm.js +57 -0
- package/dist/admin/ui/primitives.d.ts +19 -4
- package/dist/admin/ui/primitives.js +12 -5
- package/dist/admin/ui/resize.d.ts +7 -4
- package/dist/admin/ui/resize.js +7 -4
- package/dist/admin/ui/theme.d.ts +1 -1
- package/dist/admin/ui/theme.js +373 -204
- package/dist/admin/validate.d.ts +4 -0
- package/dist/admin/validate.js +28 -0
- package/dist/cli/load-collections.d.ts +2 -0
- package/dist/cli/load-collections.js +26 -0
- package/dist/cli/main.d.ts +13 -0
- package/dist/cli/main.js +62 -0
- package/dist/cli/migrate.d.ts +23 -0
- package/dist/cli/migrate.js +64 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.js +9 -0
- package/dist/draft-token.d.ts +31 -0
- package/dist/draft-token.js +41 -0
- package/dist/entry-store.d.ts +11 -8
- package/dist/entry-store.js +24 -10
- package/dist/env.d.ts +9 -0
- package/dist/env.js +19 -0
- package/dist/fields.d.ts +2 -0
- package/dist/fields.js +2 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.js +4 -2
- package/dist/marks.d.ts +3 -0
- package/dist/marks.js +3 -0
- package/dist/migrations.d.ts +37 -0
- package/dist/migrations.js +73 -0
- package/dist/schema-version.d.ts +17 -0
- package/dist/schema-version.js +32 -0
- package/dist/sql-space.js +17 -0
- package/dist/sql.js +21 -0
- package/dist/store.d.ts +14 -8
- package/dist/store.js +30 -8
- package/dist/supabase-entry-store.d.ts +15 -5
- package/dist/supabase-entry-store.js +87 -22
- package/dist/supabase-store.d.ts +18 -5
- package/dist/supabase-store.js +83 -20
- package/dist/time-zone.d.ts +6 -0
- package/dist/time-zone.js +27 -0
- package/migrations/20260926120000_smoodly_v1.sql +535 -0
- package/migrations/20260926130000_smoodly_v2.sql +26 -0
- package/package.json +13 -3
|
@@ -10,19 +10,21 @@ import { collectionDepth, collectionOrder } from "./collections.js";
|
|
|
10
10
|
import { ARRAY_FIELD_TYPES, DEFAULT_ENTRY_LOCALES, entryErrors, entryFieldsIn, entryLocaleRow, entryRefEdges, entryTitleIn, isGlobalSchema, schemaOf, splitFields, } from "./entry-store.js";
|
|
11
11
|
import { globalEntryId } from "./global-id.js";
|
|
12
12
|
import { SupabasePathIndex } from "./supabase-path-index.js";
|
|
13
|
+
import { assertFresh, draftToken, StaleDraftError } from "./draft-token.js";
|
|
13
14
|
import { assertDepth, assertSupportedLocale, entryPathRows, slugOf } from "./paths.js";
|
|
14
15
|
// entries.id is a Postgres uuid column: filtering on a non-UUID string
|
|
15
16
|
// throws invalid_text_representation. A non-UUID id can't exist in the
|
|
16
17
|
// table, so treat it the same as a missing one.
|
|
17
18
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
18
19
|
const UNIQUE_VIOLATION = "23505";
|
|
19
|
-
const SELECT = "*, entry_locales(
|
|
20
|
+
const SELECT = "*, entry_locales(*, draft:entry_versions!entry_locales_draft_version_fkey(saved_at))";
|
|
20
21
|
function toLocale(row) {
|
|
21
22
|
return {
|
|
22
23
|
slug: row.slug,
|
|
23
24
|
status: row.status,
|
|
24
25
|
fields: row.fields,
|
|
25
26
|
draftVersionId: row.draft_version_id,
|
|
27
|
+
draftSavedAt: row.draft?.saved_at ?? null,
|
|
26
28
|
publishedVersionId: row.published_version_id,
|
|
27
29
|
updatedAt: Date.parse(row.updated_at),
|
|
28
30
|
updatedBy: row.updated_by,
|
|
@@ -251,6 +253,26 @@ export class SupabaseEntryStore {
|
|
|
251
253
|
if (res.error)
|
|
252
254
|
this.fail(res.error.message);
|
|
253
255
|
}
|
|
256
|
+
/** The row's stale error, built from a fresh read: what a reload would get, and who wrote last. */
|
|
257
|
+
async stale(collection, id, locale) {
|
|
258
|
+
const row = entryLocaleRow(await this.must(collection, id), locale);
|
|
259
|
+
return new StaleDraftError(draftToken(row), row.updatedBy, row.updatedAt);
|
|
260
|
+
}
|
|
261
|
+
/** `updateLocale` with the pointer as a filter (spec 2026-09-26 concurrency §3): the row is
|
|
262
|
+
* written only while `draft_version_id` still equals `guard` (null = still no pointer).
|
|
263
|
+
* `undefined` guard = unconditional. Resolves false when no row matched. */
|
|
264
|
+
async guardedUpdateLocale(entryId, locale, patch, options) {
|
|
265
|
+
const payload = (options.touch ?? true)
|
|
266
|
+
? { ...patch, updated_at: new Date().toISOString(), updated_by: options.by ?? null }
|
|
267
|
+
: { ...patch };
|
|
268
|
+
let query = this.db.from("entry_locales").update(payload).eq("entry_id", entryId).eq("locale", locale);
|
|
269
|
+
if (options.guard !== undefined)
|
|
270
|
+
query = options.guard === null ? query.is("draft_version_id", null) : query.eq("draft_version_id", options.guard);
|
|
271
|
+
const res = await query.select("entry_id").maybeSingle();
|
|
272
|
+
if (res.error)
|
|
273
|
+
this.fail(res.error.message);
|
|
274
|
+
return res.data !== null;
|
|
275
|
+
}
|
|
254
276
|
/** The row goes first (it is what makes the entry exist in the locale), then its versions. */
|
|
255
277
|
async deleteLocale(entryId, locale) {
|
|
256
278
|
const row = await this.db.from("entry_locales").delete().eq("entry_id", entryId).eq("locale", locale);
|
|
@@ -260,20 +282,43 @@ export class SupabaseEntryStore {
|
|
|
260
282
|
if (versions.error)
|
|
261
283
|
this.fail(versions.error.message);
|
|
262
284
|
}
|
|
263
|
-
/** The draft rule for every collection (spec 2026-09-14 §1), see MemoryEntryStore.recordVersion
|
|
264
|
-
|
|
285
|
+
/** The draft rule for every collection (spec 2026-09-14 §1), see MemoryEntryStore.recordVersion —
|
|
286
|
+
* guarded (spec 2026-09-26 concurrency §3) when the caller verified `row` against the token:
|
|
287
|
+
* the rewrite filters on saved_at, the first draft on the pointer. Returns the version row it
|
|
288
|
+
* created, or null when it rewrote in place, so a failed later step can undo it. */
|
|
289
|
+
async recordVersion(entry, locale, guarded) {
|
|
265
290
|
const row = entry.locales[locale];
|
|
266
291
|
const fields = entryFieldsIn(entry, locale);
|
|
267
292
|
if (row.draftVersionId && row.draftVersionId !== row.publishedVersionId) {
|
|
268
|
-
|
|
293
|
+
let query = this.db.from("entry_versions").update({ fields, saved_at: new Date().toISOString() }).eq("id", row.draftVersionId);
|
|
294
|
+
if (guarded)
|
|
295
|
+
query = query.eq("saved_at", row.draftSavedAt ?? "");
|
|
296
|
+
const res = await query.select("id").maybeSingle();
|
|
269
297
|
if (res.error)
|
|
270
298
|
this.fail(res.error.message);
|
|
271
|
-
|
|
299
|
+
if (res.data === null)
|
|
300
|
+
throw await this.stale(entry.collection, entry.id, locale);
|
|
301
|
+
return { created: null };
|
|
272
302
|
}
|
|
273
303
|
const res = await this.db.from("entry_versions").insert({ entry_id: entry.id, locale, fields }).select("id").single();
|
|
274
304
|
if (res.error)
|
|
275
305
|
this.fail(res.error.message);
|
|
276
|
-
|
|
306
|
+
const id = res.data.id;
|
|
307
|
+
let matched;
|
|
308
|
+
if (guarded)
|
|
309
|
+
matched = await this.guardedUpdateLocale(entry.id, locale, { draft_version_id: id }, { guard: row.draftVersionId, touch: false });
|
|
310
|
+
else {
|
|
311
|
+
await this.updateLocale(entry.id, locale, { draft_version_id: id }, { touch: false });
|
|
312
|
+
matched = true;
|
|
313
|
+
}
|
|
314
|
+
if (!matched) {
|
|
315
|
+
// The other writer took the pointer first: this row must never show in history.
|
|
316
|
+
const gone = await this.db.from("entry_versions").delete().eq("id", id);
|
|
317
|
+
if (gone.error)
|
|
318
|
+
this.fail(gone.error.message);
|
|
319
|
+
throw await this.stale(entry.collection, entry.id, locale);
|
|
320
|
+
}
|
|
321
|
+
return { created: id };
|
|
277
322
|
}
|
|
278
323
|
/** See MemoryEntryStore.ensureLiveSnapshot: the snapshot of what is live, both pointers on it. */
|
|
279
324
|
async ensureLiveSnapshot(entry, locale) {
|
|
@@ -324,7 +369,7 @@ export class SupabaseEntryStore {
|
|
|
324
369
|
try {
|
|
325
370
|
await this.insertLocale(id, options.locale, { slug, fields: localized }, by);
|
|
326
371
|
let record = await this.must(collection, id);
|
|
327
|
-
await this.recordVersion(record, options.locale);
|
|
372
|
+
await this.recordVersion(record, options.locale, false);
|
|
328
373
|
record = await this.must(collection, id);
|
|
329
374
|
await this.writePaths(record);
|
|
330
375
|
await this.writeRefs(record);
|
|
@@ -417,15 +462,20 @@ export class SupabaseEntryStore {
|
|
|
417
462
|
}
|
|
418
463
|
return out;
|
|
419
464
|
}
|
|
420
|
-
async update(collection, id, locale, fields, by) {
|
|
465
|
+
async update(collection, id, locale, fields, by, expected) {
|
|
421
466
|
const schema = this.schema(collection);
|
|
422
467
|
const before = await this.must(collection, id);
|
|
423
468
|
const row = entryLocaleRow(before, locale);
|
|
424
|
-
|
|
469
|
+
assertFresh(row, expected);
|
|
470
|
+
await this.ensureLiveSnapshot(before, locale); // mutates `row`'s pointers for a legacy row
|
|
425
471
|
const { shared, localized, slug } = splitFields(fields, schema);
|
|
426
472
|
if (isGlobalSchema(schema) && slug !== null)
|
|
427
473
|
throw entryErrors.noSlug(collection);
|
|
428
474
|
await this.assertSiblingSlug(collection, before.parentId, locale, slug, id);
|
|
475
|
+
// The snapshot of what the row is about to hold, written under the guard BEFORE the live
|
|
476
|
+
// columns change (spec 2026-09-26 concurrency §3): a stale save leaves nothing behind.
|
|
477
|
+
const next = { ...before, fields: shared, locales: { ...before.locales, [locale]: { ...row, fields: localized, slug } } };
|
|
478
|
+
const { created } = await this.recordVersion(next, locale, expected !== undefined);
|
|
429
479
|
await this.updateNode(id, { fields: shared });
|
|
430
480
|
await this.updateLocale(id, locale, { slug, fields: localized }, { by });
|
|
431
481
|
const record = await this.must(collection, id);
|
|
@@ -434,27 +484,36 @@ export class SupabaseEntryStore {
|
|
|
434
484
|
}
|
|
435
485
|
catch (e) {
|
|
436
486
|
await this.updateNode(id, { fields: before.fields, updated_at: new Date(before.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 });
|
|
487
|
+
await this.updateLocale(id, locale, { slug: row.slug, fields: row.fields, updated_at: new Date(row.updatedAt).toISOString(), updated_by: row.updatedBy, ...(created ? { draft_version_id: row.draftVersionId } : {}) }, { touch: false });
|
|
488
|
+
if (created)
|
|
489
|
+
await this.db.from("entry_versions").delete().eq("id", created);
|
|
490
|
+
else {
|
|
491
|
+
// The in-place rewrite moved saved_at too: put the token back as it was read.
|
|
492
|
+
const undo = { fields: entryFieldsIn(before, locale), ...(row.draftSavedAt ? { saved_at: row.draftSavedAt } : {}) };
|
|
493
|
+
await this.db.from("entry_versions").update(undo).eq("id", row.draftVersionId);
|
|
494
|
+
}
|
|
438
495
|
throw e;
|
|
439
496
|
}
|
|
440
|
-
await this.recordVersion(record, locale);
|
|
441
497
|
const final = await this.must(collection, id);
|
|
442
498
|
await this.writeRefs(final);
|
|
443
499
|
return final;
|
|
444
500
|
}
|
|
445
|
-
async setStatus(collection, id, locale, status, by) {
|
|
501
|
+
async setStatus(collection, id, locale, status, by, expected) {
|
|
446
502
|
let record = await this.must(collection, id);
|
|
447
503
|
let row = entryLocaleRow(record, locale);
|
|
448
504
|
if (status === "published") {
|
|
505
|
+
assertFresh(row, expected);
|
|
449
506
|
if (!row.draftVersionId) {
|
|
450
|
-
await this.recordVersion(record, locale);
|
|
507
|
+
await this.recordVersion(record, locale, false);
|
|
451
508
|
record = await this.must(collection, id);
|
|
452
509
|
row = entryLocaleRow(record, locale);
|
|
453
510
|
}
|
|
454
|
-
await this.
|
|
511
|
+
const matched = await this.guardedUpdateLocale(id, locale, { status, published_version_id: row.draftVersionId }, { by, guard: expected !== undefined ? row.draftVersionId : undefined });
|
|
512
|
+
if (!matched)
|
|
513
|
+
throw await this.stale(collection, id, locale);
|
|
455
514
|
}
|
|
456
515
|
else {
|
|
457
|
-
// Unpublish keeps the pointer for a later republish (spec §2).
|
|
516
|
+
// Unpublish keeps the pointer for a later republish (spec §2) and is not guarded.
|
|
458
517
|
await this.updateLocale(id, locale, { status }, { by });
|
|
459
518
|
}
|
|
460
519
|
await this.updateNode(id, {});
|
|
@@ -462,16 +521,20 @@ export class SupabaseEntryStore {
|
|
|
462
521
|
await this.writeRefs(final);
|
|
463
522
|
return final;
|
|
464
523
|
}
|
|
465
|
-
async discardDraft(collection, id, locale, by) {
|
|
524
|
+
async discardDraft(collection, id, locale, by, expected) {
|
|
466
525
|
const schema = this.schema(collection);
|
|
467
526
|
const record = await this.must(collection, id);
|
|
468
527
|
const row = entryLocaleRow(record, locale);
|
|
528
|
+
assertFresh(row, expected);
|
|
469
529
|
const snapshot = row.publishedVersionId ? (await this.versionFields([row.publishedVersionId])).get(row.publishedVersionId) : undefined;
|
|
470
530
|
if (!snapshot)
|
|
471
531
|
throw entryErrors.nothingPublished(collection, id, locale);
|
|
472
532
|
const { shared, localized } = splitFields(snapshot, schema);
|
|
533
|
+
// The pointer moves under the guard before the fields (spec 2026-09-26 concurrency §3).
|
|
534
|
+
const matched = await this.guardedUpdateLocale(id, locale, { fields: localized, draft_version_id: row.publishedVersionId }, { by, guard: expected !== undefined ? row.draftVersionId : undefined });
|
|
535
|
+
if (!matched)
|
|
536
|
+
throw await this.stale(collection, id, locale);
|
|
473
537
|
await this.updateNode(id, { fields: shared });
|
|
474
|
-
await this.updateLocale(id, locale, { fields: localized, draft_version_id: row.publishedVersionId }, { by });
|
|
475
538
|
if (row.draftVersionId && row.draftVersionId !== row.publishedVersionId) {
|
|
476
539
|
// The pointer moved first, so a failure here leaves a stray row, never a dangling pointer.
|
|
477
540
|
const gone = await this.db.from("entry_versions").delete().eq("id", row.draftVersionId);
|
|
@@ -498,7 +561,7 @@ export class SupabaseEntryStore {
|
|
|
498
561
|
let added;
|
|
499
562
|
try {
|
|
500
563
|
added = await this.must(collection, id);
|
|
501
|
-
await this.recordVersion(added, locale);
|
|
564
|
+
await this.recordVersion(added, locale, false);
|
|
502
565
|
added = await this.must(collection, id);
|
|
503
566
|
await this.writePaths(added);
|
|
504
567
|
}
|
|
@@ -547,20 +610,22 @@ export class SupabaseEntryStore {
|
|
|
547
610
|
this.fail(res.error.message);
|
|
548
611
|
return res.data.map(toVersion);
|
|
549
612
|
}
|
|
550
|
-
async restore(collection, id, locale, versionId, by) {
|
|
613
|
+
async restore(collection, id, locale, versionId, by, expected) {
|
|
551
614
|
const schema = this.schema(collection);
|
|
552
615
|
const entry = await this.must(collection, id);
|
|
553
616
|
const row = entryLocaleRow(entry, locale);
|
|
554
617
|
const version = await this.getVersion(collection, versionId);
|
|
555
618
|
if (!version || version.entryId !== id || version.locale !== locale)
|
|
556
619
|
throw entryErrors.foreignVersion(collection, id, locale, versionId);
|
|
620
|
+
assertFresh(row, expected);
|
|
557
621
|
if (versionId === row.publishedVersionId)
|
|
558
|
-
return this.discardDraft(collection, id, locale, by);
|
|
622
|
+
return this.discardDraft(collection, id, locale, by, expected);
|
|
559
623
|
const { shared, localized } = splitFields(version.fields, schema);
|
|
624
|
+
// The snapshot first, under the guard, as in update.
|
|
625
|
+
const next = { ...entry, fields: shared, locales: { ...entry.locales, [locale]: { ...row, fields: localized } } };
|
|
626
|
+
await this.recordVersion(next, locale, expected !== undefined);
|
|
560
627
|
await this.updateNode(id, { fields: shared });
|
|
561
628
|
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
629
|
const final = await this.must(collection, id);
|
|
565
630
|
await this.writeRefs(final);
|
|
566
631
|
return final;
|
package/dist/supabase-store.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
2
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
|
+
import { type DraftToken } from "./draft-token.ts";
|
|
4
5
|
import { type PathTarget } from "./paths.ts";
|
|
5
6
|
export declare class SupabasePageStore implements PageStore {
|
|
6
7
|
private db;
|
|
@@ -29,6 +30,12 @@ export declare class SupabasePageStore implements PageStore {
|
|
|
29
30
|
private updateNode;
|
|
30
31
|
/** A touch writes the audit pair — `updated_at` and `updated_by` (spec 2026-09-13 history §2). */
|
|
31
32
|
private updateLocale;
|
|
33
|
+
/** The row's stale error, built from a fresh read: what a reload would get, and who wrote last. */
|
|
34
|
+
private stale;
|
|
35
|
+
/** `updateLocale` with the pointer as a filter (spec 2026-09-26 concurrency §3): the row is
|
|
36
|
+
* written only while `draft_version_id` still equals `guard` (null = still no pointer).
|
|
37
|
+
* `undefined` guard = unconditional. Resolves false when no row matched. */
|
|
38
|
+
private guardedUpdateLocale;
|
|
32
39
|
private insertLocale;
|
|
33
40
|
/** The locale's row and versions — the removeLocale and rollback shape.
|
|
34
41
|
* The row goes first: it is what makes the page exist in that locale,
|
|
@@ -36,7 +43,10 @@ export declare class SupabasePageStore implements PageStore {
|
|
|
36
43
|
* pointing at an already-deleted version. */
|
|
37
44
|
private deleteLocale;
|
|
38
45
|
private insertVersion;
|
|
39
|
-
/** The draft rule (spec 2026-09-13 history §1), see MemoryPageStore.writeDraftVersion
|
|
46
|
+
/** The draft rule (spec 2026-09-13 history §1), see MemoryPageStore.writeDraftVersion —
|
|
47
|
+
* and the guard (spec 2026-09-26 concurrency §3): the caller ran assertFresh on the row it
|
|
48
|
+
* holds, so `row` IS the expected state; the write itself filters on it, closing the
|
|
49
|
+
* read-then-write window. `guarded` false = a script, no filters. */
|
|
40
50
|
private writeDraftVersion;
|
|
41
51
|
private versionTree;
|
|
42
52
|
/** Every locale's draft and published trees in ONE query. */
|
|
@@ -48,7 +58,10 @@ export declare class SupabasePageStore implements PageStore {
|
|
|
48
58
|
createPage(input: CreatePageInput, by?: string): Promise<PageRecord>;
|
|
49
59
|
getPage(id: string): Promise<PageRecord | null>;
|
|
50
60
|
listPages(): Promise<PageRecord[]>;
|
|
61
|
+
/** The site's one warning per process (spec 2026-09-26 §5): fired by the first locate, never blocking. */
|
|
62
|
+
private schemaChecked;
|
|
51
63
|
resolvePath(locale: string, path: string): Promise<PathTarget | null>;
|
|
64
|
+
schemaVersion(): Promise<number | null>;
|
|
52
65
|
pathsOf(id: string): Promise<{}>;
|
|
53
66
|
addLocale(id: string, locale: string, options: AddLocaleOptions, by?: string): Promise<PageRecord>;
|
|
54
67
|
removeLocale(id: string, locale: string): Promise<PageRecord>;
|
|
@@ -62,11 +75,11 @@ export declare class SupabasePageStore implements PageStore {
|
|
|
62
75
|
index?: number;
|
|
63
76
|
}): Promise<PageRecord>;
|
|
64
77
|
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>;
|
|
78
|
+
saveDraft(id: string, locale: string, tree: PageTree, by?: string, expected?: DraftToken | null): Promise<PageVersion>;
|
|
79
|
+
publish(id: string, locale: string, by?: string, expected?: DraftToken | null): Promise<PageRecord>;
|
|
80
|
+
discardDraft(id: string, locale: string, by?: string, expected?: DraftToken | null): Promise<PageRecord>;
|
|
68
81
|
unpublish(id: string, locale: string, by?: string): Promise<PageRecord>;
|
|
69
|
-
restore(id: string, locale: string, versionId: string, by?: string): Promise<PageRecord>;
|
|
82
|
+
restore(id: string, locale: string, versionId: string, by?: string, expected?: DraftToken | null): Promise<PageRecord>;
|
|
70
83
|
getVersion(versionId: string): Promise<PageVersion | null>;
|
|
71
84
|
getDraftTree(id: string, locale: string): Promise<PageTree | null>;
|
|
72
85
|
fieldsOf(versionIds: string[]): Promise<Record<string, Record<string, unknown>>>;
|
package/dist/supabase-store.js
CHANGED
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
// rewritten through ONE RPC per write, so a collision rolls back.
|
|
9
9
|
import { assertPublishable, assertSupportedLocale, localeRow, pageErrors, pageRefEdges, pathOptions, refSections, } from "./store.js";
|
|
10
10
|
import { SupabasePathIndex } from "./supabase-path-index.js";
|
|
11
|
+
import { SCHEMA_VERSION_KEY, schemaBehind, schemaBehindMessage } from "./schema-version.js";
|
|
12
|
+
import { assertFresh, draftToken, StaleDraftError } from "./draft-token.js";
|
|
11
13
|
import { assertDepth, assertSegment, isHomeNode, pagePathRows } from "./paths.js";
|
|
12
|
-
const SELECT = "*, page_locales(
|
|
14
|
+
const SELECT = "*, page_locales(*, draft:page_versions!page_locales_draft_version_fkey(saved_at))";
|
|
13
15
|
const UNIQUE_VIOLATION = "23505";
|
|
14
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;
|
|
15
17
|
function toRecord(row) {
|
|
@@ -20,6 +22,7 @@ function toRecord(row) {
|
|
|
20
22
|
title: l.title,
|
|
21
23
|
status: l.status,
|
|
22
24
|
draftVersionId: l.draft_version_id,
|
|
25
|
+
draftSavedAt: l.draft?.saved_at ?? null,
|
|
23
26
|
publishedVersionId: l.published_version_id,
|
|
24
27
|
updatedAt: Date.parse(l.updated_at),
|
|
25
28
|
updatedBy: l.updated_by,
|
|
@@ -40,6 +43,8 @@ export class SupabasePageStore {
|
|
|
40
43
|
constructor(db, options = {}) {
|
|
41
44
|
this.db = db;
|
|
42
45
|
this.options = options;
|
|
46
|
+
/** The site's one warning per process (spec 2026-09-26 §5): fired by the first locate, never blocking. */
|
|
47
|
+
this.schemaChecked = false;
|
|
43
48
|
this.options = { ...options, sections: refSections(options) };
|
|
44
49
|
this.paths = options.paths ?? new SupabasePathIndex(db);
|
|
45
50
|
this.opts = pathOptions(options);
|
|
@@ -142,6 +147,24 @@ export class SupabasePageStore {
|
|
|
142
147
|
this.fail(res.error.message);
|
|
143
148
|
return this.must(id);
|
|
144
149
|
}
|
|
150
|
+
/** The row's stale error, built from a fresh read: what a reload would get, and who wrote last. */
|
|
151
|
+
async stale(id, locale) {
|
|
152
|
+
const row = localeRow(await this.must(id), locale);
|
|
153
|
+
return new StaleDraftError(draftToken(row), row.updatedBy, row.updatedAt);
|
|
154
|
+
}
|
|
155
|
+
/** `updateLocale` with the pointer as a filter (spec 2026-09-26 concurrency §3): the row is
|
|
156
|
+
* written only while `draft_version_id` still equals `guard` (null = still no pointer).
|
|
157
|
+
* `undefined` guard = unconditional. Resolves false when no row matched. */
|
|
158
|
+
async guardedUpdateLocale(id, locale, patch, options) {
|
|
159
|
+
const payload = { ...patch, updated_at: new Date().toISOString(), updated_by: options.by ?? null };
|
|
160
|
+
let query = this.db.from("page_locales").update(payload).eq("page_id", id).eq("locale", locale);
|
|
161
|
+
if (options.guard !== undefined)
|
|
162
|
+
query = options.guard === null ? query.is("draft_version_id", null) : query.eq("draft_version_id", options.guard);
|
|
163
|
+
const res = await query.select("page_id").maybeSingle();
|
|
164
|
+
if (res.error)
|
|
165
|
+
this.fail(res.error.message);
|
|
166
|
+
return res.data !== null;
|
|
167
|
+
}
|
|
145
168
|
async insertLocale(id, locale, values) {
|
|
146
169
|
const res = await this.db.from("page_locales").insert({ page_id: id, locale, ...values });
|
|
147
170
|
if (res.error) {
|
|
@@ -168,24 +191,34 @@ export class SupabasePageStore {
|
|
|
168
191
|
this.fail(res.error.message);
|
|
169
192
|
return res.data;
|
|
170
193
|
}
|
|
171
|
-
/** The draft rule (spec 2026-09-13 history §1), see MemoryPageStore.writeDraftVersion
|
|
172
|
-
|
|
194
|
+
/** The draft rule (spec 2026-09-13 history §1), see MemoryPageStore.writeDraftVersion —
|
|
195
|
+
* and the guard (spec 2026-09-26 concurrency §3): the caller ran assertFresh on the row it
|
|
196
|
+
* holds, so `row` IS the expected state; the write itself filters on it, closing the
|
|
197
|
+
* read-then-write window. `guarded` false = a script, no filters. */
|
|
198
|
+
async writeDraftVersion(page, locale, tree, by, guarded) {
|
|
173
199
|
const row = localeRow(page, locale);
|
|
174
200
|
if (row.draftVersionId && row.draftVersionId !== row.publishedVersionId) {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
.
|
|
178
|
-
|
|
179
|
-
.select("*")
|
|
180
|
-
.single();
|
|
201
|
+
let query = this.db.from("page_versions").update({ tree, saved_at: new Date().toISOString() }).eq("id", row.draftVersionId);
|
|
202
|
+
if (guarded)
|
|
203
|
+
query = query.eq("saved_at", row.draftSavedAt ?? "");
|
|
204
|
+
const res = await query.select("*").maybeSingle();
|
|
181
205
|
if (res.error)
|
|
182
206
|
this.fail(res.error.message);
|
|
207
|
+
if (res.data === null)
|
|
208
|
+
throw await this.stale(page.id, locale);
|
|
183
209
|
const record = await this.updateLocale(page.id, locale, {}, { by });
|
|
184
210
|
return { version: res.data, record };
|
|
185
211
|
}
|
|
186
212
|
const version = await this.insertVersion(page.id, locale, tree);
|
|
187
|
-
const
|
|
188
|
-
|
|
213
|
+
const matched = await this.guardedUpdateLocale(page.id, locale, { draft_version_id: version.id }, { by, guard: guarded ? row.draftVersionId : undefined });
|
|
214
|
+
if (!matched) {
|
|
215
|
+
// The other writer took the pointer first: this row must never show in history.
|
|
216
|
+
const gone = await this.db.from("page_versions").delete().eq("id", version.id);
|
|
217
|
+
if (gone.error)
|
|
218
|
+
this.fail(gone.error.message);
|
|
219
|
+
throw await this.stale(page.id, locale);
|
|
220
|
+
}
|
|
221
|
+
return { version, record: await this.must(page.id) };
|
|
189
222
|
}
|
|
190
223
|
async versionTree(versionId) {
|
|
191
224
|
if (!versionId)
|
|
@@ -300,8 +333,28 @@ export class SupabasePageStore {
|
|
|
300
333
|
return res.data.map(toRecord);
|
|
301
334
|
}
|
|
302
335
|
resolvePath(locale, path) {
|
|
336
|
+
if (!this.schemaChecked) {
|
|
337
|
+
this.schemaChecked = true;
|
|
338
|
+
void this.schemaVersion()
|
|
339
|
+
.then((v) => {
|
|
340
|
+
if (schemaBehind(v))
|
|
341
|
+
console.warn(schemaBehindMessage(v));
|
|
342
|
+
})
|
|
343
|
+
.catch(() => { });
|
|
344
|
+
}
|
|
303
345
|
return this.paths.resolve(locale, path);
|
|
304
346
|
}
|
|
347
|
+
async schemaVersion() {
|
|
348
|
+
const res = await this.db.from("smoodly_meta").select("value").eq("key", SCHEMA_VERSION_KEY).maybeSingle();
|
|
349
|
+
if (res.error) {
|
|
350
|
+
// PGRST205: not in PostgREST's schema cache; 42P01: undefined table.
|
|
351
|
+
if (res.error.code === "PGRST205" || res.error.code === "42P01")
|
|
352
|
+
return null;
|
|
353
|
+
this.fail(res.error.message);
|
|
354
|
+
}
|
|
355
|
+
const n = Number(res.data?.value);
|
|
356
|
+
return Number.isInteger(n) ? n : null;
|
|
357
|
+
}
|
|
305
358
|
pathsOf(id) {
|
|
306
359
|
return UUID_RE.test(id) ? this.paths.pathsOf({ kind: "page", id }) : Promise.resolve({});
|
|
307
360
|
}
|
|
@@ -452,41 +505,50 @@ export class SupabasePageStore {
|
|
|
452
505
|
}
|
|
453
506
|
return this.updateNode(id, { template });
|
|
454
507
|
}
|
|
455
|
-
async saveDraft(id, locale, tree, by) {
|
|
508
|
+
async saveDraft(id, locale, tree, by, expected) {
|
|
456
509
|
const page = await this.must(id);
|
|
457
510
|
if (page.template === null)
|
|
458
511
|
throw pageErrors.folder(id);
|
|
459
|
-
|
|
512
|
+
assertFresh(localeRow(page, locale), expected);
|
|
513
|
+
const { version, record } = await this.writeDraftVersion(page, locale, tree, by, expected !== undefined);
|
|
460
514
|
await this.writeRefs(record);
|
|
461
515
|
return toVersion(version);
|
|
462
516
|
}
|
|
463
|
-
async publish(id, locale, by) {
|
|
517
|
+
async publish(id, locale, by, expected) {
|
|
464
518
|
const page = await this.must(id);
|
|
465
519
|
if (page.template === null)
|
|
466
520
|
throw pageErrors.folder(id);
|
|
467
521
|
const row = localeRow(page, locale);
|
|
522
|
+
assertFresh(row, expected);
|
|
468
523
|
if (!row.draftVersionId)
|
|
469
524
|
throw pageErrors.folder(id);
|
|
470
525
|
const { sections, entries } = this.options;
|
|
471
526
|
const draft = (await this.versionTree(row.draftVersionId)) ?? { zones: {} };
|
|
472
527
|
if (sections && entries)
|
|
473
528
|
await assertPublishable(row.slug, draft, sections, entries, locale);
|
|
474
|
-
const
|
|
529
|
+
const matched = await this.guardedUpdateLocale(id, locale, { published_version_id: row.draftVersionId, status: "published" }, { by, guard: expected !== undefined ? row.draftVersionId : undefined });
|
|
530
|
+
if (!matched)
|
|
531
|
+
throw await this.stale(id, locale);
|
|
532
|
+
const record = await this.must(id);
|
|
475
533
|
await this.writeRefs(record);
|
|
476
534
|
return record;
|
|
477
535
|
}
|
|
478
|
-
async discardDraft(id, locale, by) {
|
|
536
|
+
async discardDraft(id, locale, by, expected) {
|
|
479
537
|
const page = await this.must(id);
|
|
480
538
|
const row = localeRow(page, locale);
|
|
539
|
+
assertFresh(row, expected);
|
|
481
540
|
if (!row.publishedVersionId)
|
|
482
541
|
throw pageErrors.nothingPublished(id, locale);
|
|
483
|
-
const
|
|
542
|
+
const matched = await this.guardedUpdateLocale(id, locale, { draft_version_id: row.publishedVersionId }, { by, guard: expected !== undefined ? row.draftVersionId : undefined });
|
|
543
|
+
if (!matched)
|
|
544
|
+
throw await this.stale(id, locale);
|
|
484
545
|
if (row.draftVersionId && row.draftVersionId !== row.publishedVersionId) {
|
|
485
546
|
// The pointer moved first, so a failure here leaves a stray row, never a dangling pointer.
|
|
486
547
|
const gone = await this.db.from("page_versions").delete().eq("id", row.draftVersionId);
|
|
487
548
|
if (gone.error)
|
|
488
549
|
this.fail(gone.error.message);
|
|
489
550
|
}
|
|
551
|
+
const record = await this.must(id);
|
|
490
552
|
await this.writeRefs(record);
|
|
491
553
|
return record;
|
|
492
554
|
}
|
|
@@ -497,7 +559,7 @@ export class SupabasePageStore {
|
|
|
497
559
|
await this.writeRefs(record);
|
|
498
560
|
return record;
|
|
499
561
|
}
|
|
500
|
-
async restore(id, locale, versionId, by) {
|
|
562
|
+
async restore(id, locale, versionId, by, expected) {
|
|
501
563
|
const page = await this.must(id);
|
|
502
564
|
if (page.template === null)
|
|
503
565
|
throw pageErrors.folder(id);
|
|
@@ -505,9 +567,10 @@ export class SupabasePageStore {
|
|
|
505
567
|
const version = await this.getVersion(versionId);
|
|
506
568
|
if (!version || version.pageId !== id || version.locale !== locale)
|
|
507
569
|
throw pageErrors.foreignVersion(id, locale, versionId);
|
|
570
|
+
assertFresh(row, expected);
|
|
508
571
|
if (versionId === row.publishedVersionId)
|
|
509
|
-
return this.discardDraft(id, locale, by);
|
|
510
|
-
const { record } = await this.writeDraftVersion(page, locale, version.tree, by);
|
|
572
|
+
return this.discardDraft(id, locale, by, expected);
|
|
573
|
+
const { record } = await this.writeDraftVersion(page, locale, version.tree, by, expected !== undefined);
|
|
511
574
|
await this.writeRefs(record);
|
|
512
575
|
return record;
|
|
513
576
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Whether this runtime's Intl knows the zone: canonical names, the
|
|
2
|
+
* aliases its database still carries ("Europe/Kiev"), "UTC". An offset
|
|
3
|
+
* like "+3" or a bare city is refused. */
|
|
4
|
+
export declare function isValidZone(zone: string): boolean;
|
|
5
|
+
/** The zone the runtime resolves for itself — the editor's browser in the admin; "UTC" when it has none. */
|
|
6
|
+
export declare function browserZone(): string;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// The site's time zone (spec 2026-09-25 date pickers §5): an IANA name,
|
|
2
|
+
// never an offset, so daylight-saving rules come from the runtime's own
|
|
3
|
+
// zone database. Core and pure: defineConfig validates with it, the
|
|
4
|
+
// admin's picker falls back to the browser's zone through it.
|
|
5
|
+
/** Whether this runtime's Intl knows the zone: canonical names, the
|
|
6
|
+
* aliases its database still carries ("Europe/Kiev"), "UTC". An offset
|
|
7
|
+
* like "+3" or a bare city is refused. */
|
|
8
|
+
export function isValidZone(zone) {
|
|
9
|
+
if (zone === "")
|
|
10
|
+
return false;
|
|
11
|
+
try {
|
|
12
|
+
new Intl.DateTimeFormat("en", { timeZone: zone });
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** The zone the runtime resolves for itself — the editor's browser in the admin; "UTC" when it has none. */
|
|
20
|
+
export function browserZone() {
|
|
21
|
+
try {
|
|
22
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return "UTC";
|
|
26
|
+
}
|
|
27
|
+
}
|