smoodly 0.0.25 → 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 +32 -0
- package/dist/admin/editor/EditorView.js +81 -17
- 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/useGlobalEdit.d.ts +1 -1
- package/dist/admin/editor/useGlobalEdit.js +1 -1
- 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 +70 -24
- package/dist/admin/ops.d.ts +22 -12
- package/dist/admin/shell/AdminApp.d.ts +3 -1
- package/dist/admin/shell/AdminApp.js +20 -2
- package/dist/admin/shell/EntriesList.js +5 -1
- package/dist/admin/shell/EntryForm.js +44 -7
- package/dist/admin/shell/GlobalForm.js +46 -7
- package/dist/admin/shell/GlobalList.js +6 -2
- package/dist/admin/shell/PagesList.js +5 -1
- 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/stale.d.ts +28 -0
- package/dist/admin/shell/stale.js +17 -0
- 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/StaleBanner.d.ts +5 -0
- package/dist/admin/ui/StaleBanner.js +9 -0
- package/dist/admin/ui/primitives.js +1 -0
- package/dist/admin/ui/theme.js +4 -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/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/schema-version.d.ts +1 -1
- package/dist/schema-version.js +1 -1
- package/dist/sql-space.js +11 -0
- package/dist/sql.js +11 -0
- package/dist/store.d.ts +11 -8
- package/dist/store.js +26 -8
- package/dist/supabase-entry-store.d.ts +15 -5
- package/dist/supabase-entry-store.js +87 -22
- package/dist/supabase-store.d.ts +15 -5
- package/dist/supabase-store.js +60 -20
- package/migrations/20260926130000_smoodly_v2.sql +26 -0
- package/package.json +1 -1
|
@@ -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. */
|
|
@@ -65,11 +75,11 @@ export declare class SupabasePageStore implements PageStore {
|
|
|
65
75
|
index?: number;
|
|
66
76
|
}): Promise<PageRecord>;
|
|
67
77
|
setTemplate(id: string, template: string, by?: string): Promise<PageRecord>;
|
|
68
|
-
saveDraft(id: string, locale: string, tree: PageTree, by?: string): Promise<PageVersion>;
|
|
69
|
-
publish(id: string, locale: string, by?: string): Promise<PageRecord>;
|
|
70
|
-
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>;
|
|
71
81
|
unpublish(id: string, locale: string, by?: string): Promise<PageRecord>;
|
|
72
|
-
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>;
|
|
73
83
|
getVersion(versionId: string): Promise<PageVersion | null>;
|
|
74
84
|
getDraftTree(id: string, locale: string): Promise<PageTree | null>;
|
|
75
85
|
fieldsOf(versionIds: string[]): Promise<Record<string, Record<string, unknown>>>;
|
package/dist/supabase-store.js
CHANGED
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
import { assertPublishable, assertSupportedLocale, localeRow, pageErrors, pageRefEdges, pathOptions, refSections, } from "./store.js";
|
|
10
10
|
import { SupabasePathIndex } from "./supabase-path-index.js";
|
|
11
11
|
import { SCHEMA_VERSION_KEY, schemaBehind, schemaBehindMessage } from "./schema-version.js";
|
|
12
|
+
import { assertFresh, draftToken, StaleDraftError } from "./draft-token.js";
|
|
12
13
|
import { assertDepth, assertSegment, isHomeNode, pagePathRows } from "./paths.js";
|
|
13
|
-
const SELECT = "*, page_locales(
|
|
14
|
+
const SELECT = "*, page_locales(*, draft:page_versions!page_locales_draft_version_fkey(saved_at))";
|
|
14
15
|
const UNIQUE_VIOLATION = "23505";
|
|
15
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;
|
|
16
17
|
function toRecord(row) {
|
|
@@ -21,6 +22,7 @@ function toRecord(row) {
|
|
|
21
22
|
title: l.title,
|
|
22
23
|
status: l.status,
|
|
23
24
|
draftVersionId: l.draft_version_id,
|
|
25
|
+
draftSavedAt: l.draft?.saved_at ?? null,
|
|
24
26
|
publishedVersionId: l.published_version_id,
|
|
25
27
|
updatedAt: Date.parse(l.updated_at),
|
|
26
28
|
updatedBy: l.updated_by,
|
|
@@ -145,6 +147,24 @@ export class SupabasePageStore {
|
|
|
145
147
|
this.fail(res.error.message);
|
|
146
148
|
return this.must(id);
|
|
147
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
|
+
}
|
|
148
168
|
async insertLocale(id, locale, values) {
|
|
149
169
|
const res = await this.db.from("page_locales").insert({ page_id: id, locale, ...values });
|
|
150
170
|
if (res.error) {
|
|
@@ -171,24 +191,34 @@ export class SupabasePageStore {
|
|
|
171
191
|
this.fail(res.error.message);
|
|
172
192
|
return res.data;
|
|
173
193
|
}
|
|
174
|
-
/** The draft rule (spec 2026-09-13 history §1), see MemoryPageStore.writeDraftVersion
|
|
175
|
-
|
|
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) {
|
|
176
199
|
const row = localeRow(page, locale);
|
|
177
200
|
if (row.draftVersionId && row.draftVersionId !== row.publishedVersionId) {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
.
|
|
181
|
-
|
|
182
|
-
.select("*")
|
|
183
|
-
.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();
|
|
184
205
|
if (res.error)
|
|
185
206
|
this.fail(res.error.message);
|
|
207
|
+
if (res.data === null)
|
|
208
|
+
throw await this.stale(page.id, locale);
|
|
186
209
|
const record = await this.updateLocale(page.id, locale, {}, { by });
|
|
187
210
|
return { version: res.data, record };
|
|
188
211
|
}
|
|
189
212
|
const version = await this.insertVersion(page.id, locale, tree);
|
|
190
|
-
const
|
|
191
|
-
|
|
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) };
|
|
192
222
|
}
|
|
193
223
|
async versionTree(versionId) {
|
|
194
224
|
if (!versionId)
|
|
@@ -475,41 +505,50 @@ export class SupabasePageStore {
|
|
|
475
505
|
}
|
|
476
506
|
return this.updateNode(id, { template });
|
|
477
507
|
}
|
|
478
|
-
async saveDraft(id, locale, tree, by) {
|
|
508
|
+
async saveDraft(id, locale, tree, by, expected) {
|
|
479
509
|
const page = await this.must(id);
|
|
480
510
|
if (page.template === null)
|
|
481
511
|
throw pageErrors.folder(id);
|
|
482
|
-
|
|
512
|
+
assertFresh(localeRow(page, locale), expected);
|
|
513
|
+
const { version, record } = await this.writeDraftVersion(page, locale, tree, by, expected !== undefined);
|
|
483
514
|
await this.writeRefs(record);
|
|
484
515
|
return toVersion(version);
|
|
485
516
|
}
|
|
486
|
-
async publish(id, locale, by) {
|
|
517
|
+
async publish(id, locale, by, expected) {
|
|
487
518
|
const page = await this.must(id);
|
|
488
519
|
if (page.template === null)
|
|
489
520
|
throw pageErrors.folder(id);
|
|
490
521
|
const row = localeRow(page, locale);
|
|
522
|
+
assertFresh(row, expected);
|
|
491
523
|
if (!row.draftVersionId)
|
|
492
524
|
throw pageErrors.folder(id);
|
|
493
525
|
const { sections, entries } = this.options;
|
|
494
526
|
const draft = (await this.versionTree(row.draftVersionId)) ?? { zones: {} };
|
|
495
527
|
if (sections && entries)
|
|
496
528
|
await assertPublishable(row.slug, draft, sections, entries, locale);
|
|
497
|
-
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);
|
|
498
533
|
await this.writeRefs(record);
|
|
499
534
|
return record;
|
|
500
535
|
}
|
|
501
|
-
async discardDraft(id, locale, by) {
|
|
536
|
+
async discardDraft(id, locale, by, expected) {
|
|
502
537
|
const page = await this.must(id);
|
|
503
538
|
const row = localeRow(page, locale);
|
|
539
|
+
assertFresh(row, expected);
|
|
504
540
|
if (!row.publishedVersionId)
|
|
505
541
|
throw pageErrors.nothingPublished(id, locale);
|
|
506
|
-
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);
|
|
507
545
|
if (row.draftVersionId && row.draftVersionId !== row.publishedVersionId) {
|
|
508
546
|
// The pointer moved first, so a failure here leaves a stray row, never a dangling pointer.
|
|
509
547
|
const gone = await this.db.from("page_versions").delete().eq("id", row.draftVersionId);
|
|
510
548
|
if (gone.error)
|
|
511
549
|
this.fail(gone.error.message);
|
|
512
550
|
}
|
|
551
|
+
const record = await this.must(id);
|
|
513
552
|
await this.writeRefs(record);
|
|
514
553
|
return record;
|
|
515
554
|
}
|
|
@@ -520,7 +559,7 @@ export class SupabasePageStore {
|
|
|
520
559
|
await this.writeRefs(record);
|
|
521
560
|
return record;
|
|
522
561
|
}
|
|
523
|
-
async restore(id, locale, versionId, by) {
|
|
562
|
+
async restore(id, locale, versionId, by, expected) {
|
|
524
563
|
const page = await this.must(id);
|
|
525
564
|
if (page.template === null)
|
|
526
565
|
throw pageErrors.folder(id);
|
|
@@ -528,9 +567,10 @@ export class SupabasePageStore {
|
|
|
528
567
|
const version = await this.getVersion(versionId);
|
|
529
568
|
if (!version || version.pageId !== id || version.locale !== locale)
|
|
530
569
|
throw pageErrors.foreignVersion(id, locale, versionId);
|
|
570
|
+
assertFresh(row, expected);
|
|
531
571
|
if (versionId === row.publishedVersionId)
|
|
532
|
-
return this.discardDraft(id, locale, by);
|
|
533
|
-
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);
|
|
534
574
|
await this.writeRefs(record);
|
|
535
575
|
return record;
|
|
536
576
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
-- Smoodly core schema, version 2 (docs/superpowers/specs/2026-09-26-concurrency-guard-presence-design.md).
|
|
2
|
+
-- Shipped in the smoodly package; `npx smoodly migrate` copies it into supabase/migrations/.
|
|
3
|
+
-- Never edit a published file: the next change is the next file.
|
|
4
|
+
-- The delta over version 1: the draft pointers become foreign keys (the
|
|
5
|
+
-- token rides every locale row read) and the editing-presence channel
|
|
6
|
+
-- gets its policy on realtime.messages.
|
|
7
|
+
|
|
8
|
+
set client_min_messages = warning;
|
|
9
|
+
|
|
10
|
+
alter table "page_locales" drop constraint if exists "page_locales_draft_version_fkey";
|
|
11
|
+
alter table "page_locales" add constraint "page_locales_draft_version_fkey"
|
|
12
|
+
foreign key ("draft_version_id") references "page_versions" ("id") on delete set null;
|
|
13
|
+
alter table "entry_locales" drop constraint if exists "entry_locales_draft_version_fkey";
|
|
14
|
+
alter table "entry_locales" add constraint "entry_locales_draft_version_fkey"
|
|
15
|
+
foreign key ("draft_version_id") references "entry_versions" ("id") on delete set null;
|
|
16
|
+
|
|
17
|
+
drop policy if exists "smoodly_presence_read" on "realtime"."messages";
|
|
18
|
+
create policy "smoodly_presence_read" on "realtime"."messages" for select
|
|
19
|
+
using ((select realtime.topic()) = 'space:' || (select public."smoodly_current_space"())::text);
|
|
20
|
+
|
|
21
|
+
drop policy if exists "smoodly_presence_write" on "realtime"."messages";
|
|
22
|
+
create policy "smoodly_presence_write" on "realtime"."messages" for insert
|
|
23
|
+
with check ((select realtime.topic()) = 'space:' || (select public."smoodly_current_space"())::text);
|
|
24
|
+
|
|
25
|
+
insert into "smoodly_meta" ("key", "value") values ('schema_version', '2')
|
|
26
|
+
on conflict ("key") do update set "value" = excluded."value";
|
package/package.json
CHANGED