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.
Files changed (148) hide show
  1. package/README.md +214 -40
  2. package/bin/smoodly.js +6 -0
  3. package/dist/admin/client-ops.js +1 -0
  4. package/dist/admin/editor/EditorView.js +100 -22
  5. package/dist/admin/editor/InsertPicker.d.ts +14 -0
  6. package/dist/admin/editor/InsertPicker.js +37 -7
  7. package/dist/admin/editor/InspectorFields.js +1 -1
  8. package/dist/admin/editor/Outline.js +2 -2
  9. package/dist/admin/editor/PageSettings.js +1 -1
  10. package/dist/admin/editor/SeoSettings.js +1 -1
  11. package/dist/admin/editor/autosave.d.ts +4 -1
  12. package/dist/admin/editor/autosave.js +24 -1
  13. package/dist/admin/editor/global-edit.d.ts +5 -1
  14. package/dist/admin/editor/global-edit.js +15 -5
  15. package/dist/admin/editor/locale-url.d.ts +7 -4
  16. package/dist/admin/editor/locale-url.js +14 -7
  17. package/dist/admin/editor/useGlobalEdit.d.ts +1 -1
  18. package/dist/admin/editor/useGlobalEdit.js +1 -1
  19. package/dist/admin/editor/usePageLocale.js +9 -1
  20. package/dist/admin/forms/CropEditor.d.ts +4 -2
  21. package/dist/admin/forms/CropEditor.js +6 -3
  22. package/dist/admin/forms/DatePicker.d.ts +9 -0
  23. package/dist/admin/forms/DatePicker.js +133 -0
  24. package/dist/admin/forms/FieldWidget.js +11 -5
  25. package/dist/admin/forms/ImagePreview.d.ts +9 -0
  26. package/dist/admin/forms/ImagePreview.js +11 -0
  27. package/dist/admin/forms/MediaWidget.d.ts +8 -2
  28. package/dist/admin/forms/MediaWidget.js +13 -13
  29. package/dist/admin/forms/RichtextElementCard.js +1 -1
  30. package/dist/admin/forms/RichtextImageCard.js +14 -9
  31. package/dist/admin/forms/date-picker.d.ts +44 -0
  32. package/dist/admin/forms/date-picker.js +117 -0
  33. package/dist/admin/forms/elements-context.d.ts +3 -0
  34. package/dist/admin/forms/elements-context.js +7 -2
  35. package/dist/admin/forms/time-zone-context.d.ts +7 -0
  36. package/dist/admin/forms/time-zone-context.js +15 -0
  37. package/dist/admin/forms/useRichtextEditor.js +10 -1
  38. package/dist/admin/next/bridge.d.ts +2 -1
  39. package/dist/admin/next/bridge.js +35 -5
  40. package/dist/admin/next/create-admin.d.ts +4 -0
  41. package/dist/admin/next/create-admin.js +3 -1
  42. package/dist/admin/next/index.d.ts +1 -0
  43. package/dist/admin/ops-impl.js +74 -24
  44. package/dist/admin/ops.d.ts +31 -12
  45. package/dist/admin/serialize.d.ts +2 -0
  46. package/dist/admin/serialize.js +1 -0
  47. package/dist/admin/shell/AdminApp.d.ts +3 -1
  48. package/dist/admin/shell/AdminApp.js +42 -2
  49. package/dist/admin/shell/EntriesList.js +17 -22
  50. package/dist/admin/shell/EntryForm.js +91 -13
  51. package/dist/admin/shell/GlobalForm.js +68 -9
  52. package/dist/admin/shell/GlobalList.js +10 -20
  53. package/dist/admin/shell/IconRail.js +14 -5
  54. package/dist/admin/shell/InviteModal.js +9 -5
  55. package/dist/admin/shell/ListView.js +2 -2
  56. package/dist/admin/shell/MediaScreen.js +11 -1
  57. package/dist/admin/shell/MembersPage.js +33 -15
  58. package/dist/admin/shell/PageDialogs.d.ts +26 -0
  59. package/dist/admin/shell/PageDialogs.js +38 -0
  60. package/dist/admin/shell/PagesList.js +101 -74
  61. package/dist/admin/shell/RouteLink.js +1 -1
  62. package/dist/admin/shell/SchemaNotice.d.ts +4 -0
  63. package/dist/admin/shell/SchemaNotice.js +4 -0
  64. package/dist/admin/shell/SettingsView.js +11 -26
  65. package/dist/admin/shell/live-url.d.ts +4 -3
  66. package/dist/admin/shell/live-url.js +12 -3
  67. package/dist/admin/shell/locale-memory.d.ts +8 -0
  68. package/dist/admin/shell/locale-memory.js +23 -0
  69. package/dist/admin/shell/members-list.d.ts +2 -0
  70. package/dist/admin/shell/members-list.js +5 -0
  71. package/dist/admin/shell/page-dialogs.d.ts +9 -0
  72. package/dist/admin/shell/page-dialogs.js +19 -0
  73. package/dist/admin/shell/presence-context.d.ts +9 -0
  74. package/dist/admin/shell/presence-context.js +35 -0
  75. package/dist/admin/shell/presence-realtime.d.ts +3 -0
  76. package/dist/admin/shell/presence-realtime.js +22 -0
  77. package/dist/admin/shell/presence.d.ts +51 -0
  78. package/dist/admin/shell/presence.js +90 -0
  79. package/dist/admin/shell/router.d.ts +10 -7
  80. package/dist/admin/shell/router.js +18 -9
  81. package/dist/admin/shell/settings.d.ts +0 -6
  82. package/dist/admin/shell/settings.js +4 -5
  83. package/dist/admin/shell/stale.d.ts +28 -0
  84. package/dist/admin/shell/stale.js +17 -0
  85. package/dist/admin/tree-ops.js +3 -0
  86. package/dist/admin/ui/ActionMenu.js +26 -6
  87. package/dist/admin/ui/Combobox.d.ts +5 -0
  88. package/dist/admin/ui/Combobox.js +1 -1
  89. package/dist/admin/ui/ConfirmDialog.d.ts +8 -0
  90. package/dist/admin/ui/ConfirmDialog.js +30 -0
  91. package/dist/admin/ui/Dialog.d.ts +13 -0
  92. package/dist/admin/ui/Dialog.js +54 -0
  93. package/dist/admin/ui/HistoryModal.d.ts +2 -1
  94. package/dist/admin/ui/HistoryModal.js +7 -2
  95. package/dist/admin/ui/LockIcon.d.ts +4 -0
  96. package/dist/admin/ui/LockIcon.js +12 -0
  97. package/dist/admin/ui/PresenceLine.d.ts +6 -0
  98. package/dist/admin/ui/PresenceLine.js +11 -0
  99. package/dist/admin/ui/ResizeHandle.js +4 -4
  100. package/dist/admin/ui/StaleBanner.d.ts +5 -0
  101. package/dist/admin/ui/StaleBanner.js +9 -0
  102. package/dist/admin/ui/confirm.d.ts +35 -0
  103. package/dist/admin/ui/confirm.js +57 -0
  104. package/dist/admin/ui/primitives.d.ts +19 -4
  105. package/dist/admin/ui/primitives.js +12 -5
  106. package/dist/admin/ui/resize.d.ts +7 -4
  107. package/dist/admin/ui/resize.js +7 -4
  108. package/dist/admin/ui/theme.d.ts +1 -1
  109. package/dist/admin/ui/theme.js +373 -204
  110. package/dist/admin/validate.d.ts +4 -0
  111. package/dist/admin/validate.js +28 -0
  112. package/dist/cli/load-collections.d.ts +2 -0
  113. package/dist/cli/load-collections.js +26 -0
  114. package/dist/cli/main.d.ts +13 -0
  115. package/dist/cli/main.js +62 -0
  116. package/dist/cli/migrate.d.ts +23 -0
  117. package/dist/cli/migrate.js +64 -0
  118. package/dist/config.d.ts +4 -0
  119. package/dist/config.js +9 -0
  120. package/dist/draft-token.d.ts +31 -0
  121. package/dist/draft-token.js +41 -0
  122. package/dist/entry-store.d.ts +11 -8
  123. package/dist/entry-store.js +24 -10
  124. package/dist/env.d.ts +9 -0
  125. package/dist/env.js +19 -0
  126. package/dist/fields.d.ts +2 -0
  127. package/dist/fields.js +2 -0
  128. package/dist/index.d.ts +6 -3
  129. package/dist/index.js +4 -2
  130. package/dist/marks.d.ts +3 -0
  131. package/dist/marks.js +3 -0
  132. package/dist/migrations.d.ts +37 -0
  133. package/dist/migrations.js +73 -0
  134. package/dist/schema-version.d.ts +17 -0
  135. package/dist/schema-version.js +32 -0
  136. package/dist/sql-space.js +17 -0
  137. package/dist/sql.js +21 -0
  138. package/dist/store.d.ts +14 -8
  139. package/dist/store.js +30 -8
  140. package/dist/supabase-entry-store.d.ts +15 -5
  141. package/dist/supabase-entry-store.js +87 -22
  142. package/dist/supabase-store.d.ts +18 -5
  143. package/dist/supabase-store.js +83 -20
  144. package/dist/time-zone.d.ts +6 -0
  145. package/dist/time-zone.js +27 -0
  146. package/migrations/20260926120000_smoodly_v1.sql +535 -0
  147. package/migrations/20260926130000_smoodly_v2.sql +26 -0
  148. 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
- async recordVersion(entry, locale) {
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
- const res = await this.db.from("entry_versions").update({ fields, saved_at: new Date().toISOString() }).eq("id", row.draftVersionId);
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
- return;
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
- await this.updateLocale(entry.id, locale, { draft_version_id: res.data.id }, { touch: false });
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
- await this.ensureLiveSnapshot(before, locale);
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.updateLocale(id, locale, { status, published_version_id: row.draftVersionId }, { by });
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;
@@ -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>>>;
@@ -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
- async writeDraftVersion(page, locale, tree, by) {
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
- const res = await this.db
176
- .from("page_versions")
177
- .update({ tree, saved_at: new Date().toISOString() })
178
- .eq("id", row.draftVersionId)
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 record = await this.updateLocale(page.id, locale, { draft_version_id: version.id }, { by });
188
- return { version, record };
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
- const { version, record } = await this.writeDraftVersion(page, locale, tree, by);
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 record = await this.updateLocale(id, locale, { published_version_id: row.draftVersionId, status: "published" }, { by });
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 record = await this.updateLocale(id, locale, { draft_version_id: row.publishedVersionId }, { by });
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
+ }