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.
Files changed (56) hide show
  1. package/README.md +32 -0
  2. package/dist/admin/editor/EditorView.js +81 -17
  3. package/dist/admin/editor/autosave.d.ts +4 -1
  4. package/dist/admin/editor/autosave.js +24 -1
  5. package/dist/admin/editor/global-edit.d.ts +5 -1
  6. package/dist/admin/editor/global-edit.js +15 -5
  7. package/dist/admin/editor/useGlobalEdit.d.ts +1 -1
  8. package/dist/admin/editor/useGlobalEdit.js +1 -1
  9. package/dist/admin/next/create-admin.d.ts +4 -0
  10. package/dist/admin/next/create-admin.js +3 -1
  11. package/dist/admin/next/index.d.ts +1 -0
  12. package/dist/admin/ops-impl.js +70 -24
  13. package/dist/admin/ops.d.ts +22 -12
  14. package/dist/admin/shell/AdminApp.d.ts +3 -1
  15. package/dist/admin/shell/AdminApp.js +20 -2
  16. package/dist/admin/shell/EntriesList.js +5 -1
  17. package/dist/admin/shell/EntryForm.js +44 -7
  18. package/dist/admin/shell/GlobalForm.js +46 -7
  19. package/dist/admin/shell/GlobalList.js +6 -2
  20. package/dist/admin/shell/PagesList.js +5 -1
  21. package/dist/admin/shell/presence-context.d.ts +9 -0
  22. package/dist/admin/shell/presence-context.js +35 -0
  23. package/dist/admin/shell/presence-realtime.d.ts +3 -0
  24. package/dist/admin/shell/presence-realtime.js +22 -0
  25. package/dist/admin/shell/presence.d.ts +51 -0
  26. package/dist/admin/shell/presence.js +90 -0
  27. package/dist/admin/shell/stale.d.ts +28 -0
  28. package/dist/admin/shell/stale.js +17 -0
  29. package/dist/admin/ui/LockIcon.d.ts +4 -0
  30. package/dist/admin/ui/LockIcon.js +12 -0
  31. package/dist/admin/ui/PresenceLine.d.ts +6 -0
  32. package/dist/admin/ui/PresenceLine.js +11 -0
  33. package/dist/admin/ui/StaleBanner.d.ts +5 -0
  34. package/dist/admin/ui/StaleBanner.js +9 -0
  35. package/dist/admin/ui/primitives.js +1 -0
  36. package/dist/admin/ui/theme.js +4 -0
  37. package/dist/draft-token.d.ts +31 -0
  38. package/dist/draft-token.js +41 -0
  39. package/dist/entry-store.d.ts +11 -8
  40. package/dist/entry-store.js +24 -10
  41. package/dist/env.d.ts +9 -0
  42. package/dist/env.js +19 -0
  43. package/dist/index.d.ts +4 -2
  44. package/dist/index.js +2 -1
  45. package/dist/schema-version.d.ts +1 -1
  46. package/dist/schema-version.js +1 -1
  47. package/dist/sql-space.js +11 -0
  48. package/dist/sql.js +11 -0
  49. package/dist/store.d.ts +11 -8
  50. package/dist/store.js +26 -8
  51. package/dist/supabase-entry-store.d.ts +15 -5
  52. package/dist/supabase-entry-store.js +87 -22
  53. package/dist/supabase-store.d.ts +15 -5
  54. package/dist/supabase-store.js +60 -20
  55. package/migrations/20260926130000_smoodly_v2.sql +26 -0
  56. package/package.json +1 -1
@@ -3,6 +3,7 @@ import type { GlobalSchema } from "./global.ts";
3
3
  import { type RefEdge, type SectionLike } from "./refs.ts";
4
4
  import { MemoryRefIndex } from "./ref-index.ts";
5
5
  import { type PathIndex } from "./path-index.ts";
6
+ import { type DraftToken } from "./draft-token.ts";
6
7
  import { type LocaleSet, type PathTarget } from "./paths.ts";
7
8
  export type EntryLocale = {
8
9
  /** The entry's segment in this locale; null claims no URL. */
@@ -11,6 +12,8 @@ export type EntryLocale = {
11
12
  /** ONLY the .localized() fields, for this locale. */
12
13
  fields: Record<string, unknown>;
13
14
  draftVersionId: string | null;
15
+ /** The draft version's saved time as the store's own text (spec 2026-09-26 concurrency §1). Null with no pointer. */
16
+ draftSavedAt: string | null;
14
17
  publishedVersionId: string | null;
15
18
  updatedAt: number;
16
19
  /** The editor's email behind the last write that touched this row; null for the seed and scripts (spec 2026-09-13 history §2). */
@@ -104,14 +107,14 @@ export interface EntryStore {
104
107
  }): Promise<Record<string, Record<string, unknown>>>;
105
108
  /** Replaces the merged fields for `locale`: shared keys on the node
106
109
  * (every locale sees them), localized keys and the slug on the row. */
107
- update(collection: string, id: string, locale: string, fields: Record<string, unknown>, by?: string): Promise<EntryRecord>;
108
- setStatus(collection: string, id: string, locale: string, status: "draft" | "published", by?: string): Promise<EntryRecord>;
110
+ update(collection: string, id: string, locale: string, fields: Record<string, unknown>, by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
111
+ setStatus(collection: string, id: string, locale: string, status: "draft" | "published", by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
109
112
  /** The live row goes back to what is published: the published snapshot's fields
110
113
  * (shared and localized, split as on save — the slug is never versioned) written over
111
114
  * the row, the draft pointer set to the published one, the abandoned draft row deleted
112
115
  * (spec 2026-09-13 history §1). Refused when the locale has no published version (a
113
116
  * non-versioned collection never has one). */
114
- discardDraft(collection: string, id: string, locale: string, by?: string): Promise<EntryRecord>;
117
+ discardDraft(collection: string, id: string, locale: string, by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
115
118
  /** Copies `from`'s localized fields and slug as the new row (shared
116
119
  * fields need no copy); refused when the entry has the locale or its
117
120
  * parent lacks it. */
@@ -124,7 +127,7 @@ export interface EntryStore {
124
127
  /** The snapshot's shared and localized fields become the draft (the slug is never
125
128
  * versioned) through the draft rule; the live version restores as a discard. Refused
126
129
  * when the version is not this entry's in this locale (spec 2026-09-13 history §6). */
127
- restore(collection: string, id: string, locale: string, versionId: string, by?: string): Promise<EntryRecord>;
130
+ restore(collection: string, id: string, locale: string, versionId: string, by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
128
131
  /** One stored version by id — callers check `entryId` and `locale`. */
129
132
  getVersion(collection: string, versionId: string): Promise<EntryVersion | null>;
130
133
  /** Blocked while anything references the entry (the safe-delete guard),
@@ -268,13 +271,13 @@ export declare class MemoryEntryStore implements EntryStore {
268
271
  getMany(collection: string, ids: string[], locale: string, options?: {
269
272
  status?: "published";
270
273
  }): Promise<Record<string, Record<string, unknown>>>;
271
- update(collection: string, id: string, locale: string, fields: Record<string, unknown>, by?: string): Promise<EntryRecord>;
272
- setStatus(collection: string, id: string, locale: string, status: "draft" | "published", by?: string): Promise<EntryRecord>;
273
- discardDraft(collection: string, id: string, locale: string, by?: string): Promise<EntryRecord>;
274
+ update(collection: string, id: string, locale: string, fields: Record<string, unknown>, by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
275
+ setStatus(collection: string, id: string, locale: string, status: "draft" | "published", by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
276
+ discardDraft(collection: string, id: string, locale: string, by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
274
277
  addLocale(collection: string, id: string, locale: string, options: AddEntryLocaleOptions, by?: string): Promise<EntryRecord>;
275
278
  removeLocale(collection: string, id: string, locale: string): Promise<EntryRecord>;
276
279
  listVersions(collection: string, id: string, locale: string): Promise<EntryVersion[]>;
277
- restore(collection: string, id: string, locale: string, versionId: string, by?: string): Promise<EntryRecord>;
280
+ restore(collection: string, id: string, locale: string, versionId: string, by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
278
281
  getVersion(collection: string, versionId: string): Promise<EntryVersion | null>;
279
282
  move(collection: string, id: string, to: {
280
283
  parentId: string | null;
@@ -13,6 +13,7 @@ import { collectionDepth, collectionOrder } from "./collections.js";
13
13
  import { collectEntryRefs } from "./refs.js";
14
14
  import { MemoryRefIndex } from "./ref-index.js";
15
15
  import { MemoryPathIndex } from "./path-index.js";
16
+ import { assertFresh } from "./draft-token.js";
16
17
  import { globalEntryId } from "./global-id.js";
17
18
  import { DEFAULT_SPACE_ID } from "./sql-space.js";
18
19
  import { assertDepth, assertSupportedLocale, chainOf, entryPathRows, slugOf, unsupportedLocale, } from "./paths.js";
@@ -44,6 +45,11 @@ export function schemaOf(collections, name) {
44
45
  export const isGlobalSchema = (schema) => schema.kind === "global";
45
46
  let counter = 0;
46
47
  const uid = (prefix) => `${prefix}-${++counter}-${Date.now().toString(36)}`;
48
+ /** A version's saved time: wall-clock, but strictly increasing, so two saves in one
49
+ * millisecond still move the draft token (spec 2026-09-26 concurrency §1) the way
50
+ * Postgres's microsecond `now()` does. */
51
+ let lastSavedAt = 0;
52
+ const savedClock = () => (lastSavedAt = Math.max(Date.now(), lastSavedAt + 1));
47
53
  export const DEFAULT_ENTRY_LOCALES = { default: "en", supported: ["en"] };
48
54
  export const entryErrors = {
49
55
  notFound: (collection, id) => new Error(`smoodly: no entry "${id}" in "${collection}".`),
@@ -216,14 +222,16 @@ export class MemoryEntryStore {
216
222
  const current = row.draftVersionId ? this.versions.get(row.draftVersionId) : undefined;
217
223
  if (current && row.draftVersionId !== row.publishedVersionId) {
218
224
  current.fields = fields;
219
- current.savedAt = Date.now();
225
+ current.savedAt = savedClock();
226
+ row.draftSavedAt = String(current.savedAt);
220
227
  this.versionSeq.set(current.id, ++this.seq); // newest again
221
228
  return;
222
229
  }
223
- const version = { id: uid("version"), entryId: entry.id, locale, fields, savedAt: Date.now() };
230
+ const version = { id: uid("version"), entryId: entry.id, locale, fields, savedAt: savedClock() };
224
231
  this.versions.set(version.id, version);
225
232
  this.versionSeq.set(version.id, ++this.seq);
226
233
  row.draftVersionId = version.id;
234
+ row.draftSavedAt = String(version.savedAt);
227
235
  }
228
236
  /** A published row from before every collection was versioned has no snapshot; give it
229
237
  * one from what is live BEFORE a write changes the fields, so the site keeps serving it
@@ -232,11 +240,12 @@ export class MemoryEntryStore {
232
240
  const row = entry.locales[locale];
233
241
  if (row.status !== "published" || row.publishedVersionId !== null)
234
242
  return;
235
- const version = { id: uid("version"), entryId: entry.id, locale, fields: entryFieldsIn(entry, locale), savedAt: Date.now() };
243
+ const version = { id: uid("version"), entryId: entry.id, locale, fields: entryFieldsIn(entry, locale), savedAt: savedClock() };
236
244
  this.versions.set(version.id, version);
237
245
  this.versionSeq.set(version.id, ++this.seq);
238
246
  row.draftVersionId = version.id;
239
247
  row.publishedVersionId = version.id;
248
+ row.draftSavedAt = String(version.savedAt);
240
249
  }
241
250
  dropVersions(entryId, locale) {
242
251
  for (const [id, v] of this.versions) {
@@ -323,7 +332,7 @@ export class MemoryEntryStore {
323
332
  collection, parentId, fields: shared,
324
333
  locales: {
325
334
  [options.locale]: {
326
- slug, status: "draft", fields: localized, draftVersionId: null, publishedVersionId: null, updatedAt: now, updatedBy: by ?? null,
335
+ slug, status: "draft", fields: localized, draftVersionId: null, draftSavedAt: null, publishedVersionId: null, updatedAt: now, updatedBy: by ?? null,
327
336
  },
328
337
  },
329
338
  sort: null, createdAt: now, updatedAt: now,
@@ -387,10 +396,11 @@ export class MemoryEntryStore {
387
396
  }
388
397
  return out;
389
398
  }
390
- async update(collection, id, locale, fields, by) {
399
+ async update(collection, id, locale, fields, by, expected) {
391
400
  const schema = this.schema(collection);
392
401
  const entry = this.must(collection, id);
393
402
  const row = entryLocaleRow(entry, locale);
403
+ assertFresh(row, expected);
394
404
  this.ensureLiveSnapshot(entry, locale);
395
405
  const { shared, localized, slug } = splitFields(fields, schema);
396
406
  if (isGlobalSchema(schema) && slug !== null)
@@ -413,10 +423,11 @@ export class MemoryEntryStore {
413
423
  this.writeRefs(entry);
414
424
  return this.snapshot(entry);
415
425
  }
416
- async setStatus(collection, id, locale, status, by) {
426
+ async setStatus(collection, id, locale, status, by, expected) {
417
427
  const entry = this.must(collection, id);
418
428
  const row = entryLocaleRow(entry, locale);
419
429
  if (status === "published") {
430
+ assertFresh(row, expected);
420
431
  // Publish points at the current draft; a row always has one after its first write, but never trust that from here.
421
432
  if (!row.draftVersionId)
422
433
  this.recordVersion(entry, locale);
@@ -428,10 +439,11 @@ export class MemoryEntryStore {
428
439
  this.writeRefs(entry); // the published snapshot just became, or stopped being, a live set
429
440
  return this.snapshot(entry);
430
441
  }
431
- async discardDraft(collection, id, locale, by) {
442
+ async discardDraft(collection, id, locale, by, expected) {
432
443
  const schema = this.schema(collection);
433
444
  const entry = this.must(collection, id);
434
445
  const row = entryLocaleRow(entry, locale);
446
+ assertFresh(row, expected);
435
447
  const snapshot = row.publishedVersionId ? this.versions.get(row.publishedVersionId) : undefined;
436
448
  if (!snapshot)
437
449
  throw entryErrors.nothingPublished(collection, id, locale);
@@ -440,6 +452,7 @@ export class MemoryEntryStore {
440
452
  row.fields = localized;
441
453
  const abandoned = row.draftVersionId;
442
454
  row.draftVersionId = row.publishedVersionId;
455
+ row.draftSavedAt = String(snapshot.savedAt);
443
456
  if (abandoned && abandoned !== row.publishedVersionId) {
444
457
  this.versions.delete(abandoned);
445
458
  this.versionSeq.delete(abandoned);
@@ -462,7 +475,7 @@ export class MemoryEntryStore {
462
475
  const now = Date.now();
463
476
  entry.locales[locale] = {
464
477
  slug: source.slug, status: "draft", fields: structuredClone(source.fields),
465
- draftVersionId: null, publishedVersionId: null, updatedAt: now, updatedBy: by ?? null,
478
+ draftVersionId: null, draftSavedAt: null, publishedVersionId: null, updatedAt: now, updatedBy: by ?? null,
466
479
  };
467
480
  this.recordVersion(entry, locale);
468
481
  // Only this node's rows change: a child cannot have a locale its parent lacked.
@@ -510,15 +523,16 @@ export class MemoryEntryStore {
510
523
  .sort((a, b) => b.savedAt - a.savedAt || this.versionSeq.get(b.id) - this.versionSeq.get(a.id))
511
524
  .map((v) => structuredClone(v));
512
525
  }
513
- async restore(collection, id, locale, versionId, by) {
526
+ async restore(collection, id, locale, versionId, by, expected) {
514
527
  const schema = this.schema(collection);
515
528
  const entry = this.must(collection, id);
516
529
  const row = entryLocaleRow(entry, locale);
517
530
  const version = this.versions.get(versionId);
518
531
  if (!version || version.entryId !== id || version.locale !== locale)
519
532
  throw entryErrors.foreignVersion(collection, id, locale, versionId);
533
+ assertFresh(row, expected);
520
534
  if (versionId === row.publishedVersionId)
521
- return this.discardDraft(collection, id, locale, by);
535
+ return this.discardDraft(collection, id, locale, by, expected);
522
536
  const { shared, localized } = splitFields(version.fields, schema);
523
537
  entry.fields = shared;
524
538
  row.fields = localized; // the slug is the row's own and never versioned
package/dist/env.d.ts CHANGED
@@ -14,3 +14,12 @@ export declare function resolveSmoodlyEnv(env: Record<string, string | undefined
14
14
  /** `resolveSmoodlyEnv(process.env)` — call lazily, never at module load,
15
15
  * so `next build` without env still succeeds. */
16
16
  export declare const smoodlyEnv: () => SmoodlyEnv;
17
+ /** What the browser needs to join the content project's presence channel (spec 2026-09-26 concurrency §5): public values only. */
18
+ export type ContentConfig = {
19
+ url: string;
20
+ key: string;
21
+ spaceId: string;
22
+ };
23
+ /** The space a key acts in: its `space_id` claim when it is a JWT that carries one, else the default space. */
24
+ export declare function spaceIdOfKey(key: string): string;
25
+ export declare function publicContentConfig(env: SmoodlyEnv): ContentConfig;
package/dist/env.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // The one config shape (spec 2026-09-04 §4/§6). Self-host sets three
2
2
  // names; the cloud sets five. The two defaults are what keep a single
3
3
  // code path: a default for an absent value is not a mode branch.
4
+ import { DEFAULT_SPACE_ID } from "./sql-space.js";
4
5
  const REQUIRED = ["SMOODLY_URL", "SMOODLY_SECRET_KEY", "SMOODLY_PUBLISHABLE_KEY"];
5
6
  export function resolveSmoodlyEnv(env) {
6
7
  const missing = REQUIRED.filter((name) => !env[name]);
@@ -20,3 +21,21 @@ export function resolveSmoodlyEnv(env) {
20
21
  /** `resolveSmoodlyEnv(process.env)` — call lazily, never at module load,
21
22
  * so `next build` without env still succeeds. */
22
23
  export const smoodlyEnv = () => resolveSmoodlyEnv(process.env);
24
+ /** The space a key acts in: its `space_id` claim when it is a JWT that carries one, else the default space. */
25
+ export function spaceIdOfKey(key) {
26
+ const parts = key.split(".");
27
+ if (parts.length !== 3)
28
+ return DEFAULT_SPACE_ID;
29
+ try {
30
+ // base64url → base64, padded; atob exists in Node 22 and every browser, so no Buffer here.
31
+ const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(parts[1].length / 4) * 4, "=");
32
+ const claims = JSON.parse(atob(b64));
33
+ return typeof claims.space_id === "string" && claims.space_id ? claims.space_id : DEFAULT_SPACE_ID;
34
+ }
35
+ catch {
36
+ return DEFAULT_SPACE_ID;
37
+ }
38
+ }
39
+ export function publicContentConfig(env) {
40
+ return { url: env.url, key: env.publishableKey, spaceId: spaceIdOfKey(env.secretKey) };
41
+ }
package/dist/index.d.ts CHANGED
@@ -28,6 +28,8 @@ export { z } from "./zones.ts";
28
28
  export { defineConfig } from "./config.ts";
29
29
  export { collectionSQL, coreTablesSQL } from "./sql.ts";
30
30
  export { SCHEMA_VERSION, SCHEMA_VERSION_KEY, schemaVersionSQL, coreFileVersion, schemaBehind, schemaBehindMessage } from "./schema-version.ts";
31
+ export { draftToken, sameToken, isDraftToken, assertFresh, StaleDraftError } from "./draft-token.ts";
32
+ export type { DraftToken, DraftPointerRow } from "./draft-token.ts";
31
33
  export { MemoryPageStore } from "./store.ts";
32
34
  export { MemoryAssetStore, assetErrors, kindOf, extensionFor, objectPath, withDownload, PAGE_SIZE as ASSET_PAGE_SIZE } from "./asset-store.ts";
33
35
  export type { AssetStore, UploadTicket, FinishInput, AssetQuery, AssetPage, BeginUploadInput, CreateAssetInput } from "./asset-store.ts";
@@ -153,8 +155,8 @@ export declare const smoodly: {
153
155
  };
154
156
  export { NODE_ATTR, ZONE_ATTR, EMPTY_ATTR, BLANK_ATTR, FIELD_ATTR, ITEM_ATTR, EDITOR_ATTR, resolveMarkPath } from "./marks.ts";
155
157
  export type { MarkNode, MarkPath, Marks, Scope } from "./marks.ts";
156
- export { resolveSmoodlyEnv, smoodlyEnv } from "./env.ts";
157
- export type { SmoodlyEnv } from "./env.ts";
158
+ export { resolveSmoodlyEnv, smoodlyEnv, publicContentConfig, spaceIdOfKey } from "./env.ts";
159
+ export type { SmoodlyEnv, ContentConfig } from "./env.ts";
158
160
  export { clientOptions, createSmoodlyClient } from "./client.ts";
159
161
  export { createSmoodlySite, pageContextOf } from "./site.ts";
160
162
  export type { SiteOptions, SitePage, SitePageSchema, SiteEntry, SiteHit, SmoodlySite, LoadOptions, PageTreeItem, SiteEntryItem, EntryTreeItem, EntryQuery, } from "./site.ts";
package/dist/index.js CHANGED
@@ -22,6 +22,7 @@ export { z } from "./zones.js";
22
22
  export { defineConfig } from "./config.js";
23
23
  export { collectionSQL, coreTablesSQL } from "./sql.js";
24
24
  export { SCHEMA_VERSION, SCHEMA_VERSION_KEY, schemaVersionSQL, coreFileVersion, schemaBehind, schemaBehindMessage } from "./schema-version.js";
25
+ export { draftToken, sameToken, isDraftToken, assertFresh, StaleDraftError } from "./draft-token.js";
25
26
  export { MemoryPageStore } from "./store.js";
26
27
  export { MemoryAssetStore, assetErrors, kindOf, extensionFor, objectPath, withDownload, PAGE_SIZE as ASSET_PAGE_SIZE } from "./asset-store.js";
27
28
  export { imageDimensions } from "./image-dimensions.js";
@@ -63,6 +64,6 @@ export const smoodly = {
63
64
  marks,
64
65
  };
65
66
  export { NODE_ATTR, ZONE_ATTR, EMPTY_ATTR, BLANK_ATTR, FIELD_ATTR, ITEM_ATTR, EDITOR_ATTR, resolveMarkPath } from "./marks.js";
66
- export { resolveSmoodlyEnv, smoodlyEnv } from "./env.js";
67
+ export { resolveSmoodlyEnv, smoodlyEnv, publicContentConfig, spaceIdOfKey } from "./env.js";
67
68
  export { clientOptions, createSmoodlyClient } from "./client.js";
68
69
  export { createSmoodlySite, pageContextOf } from "./site.js";
@@ -1,7 +1,7 @@
1
1
  /** The minimum schema version the package needs. Moves ONLY in a release
2
2
  * that adds a table, column, function or policy the package reads; a
3
3
  * unit test holds it equal to the highest file in `migrations/`. */
4
- export declare const SCHEMA_VERSION = 1;
4
+ export declare const SCHEMA_VERSION = 2;
5
5
  export declare const SCHEMA_VERSION_KEY = "schema_version";
6
6
  export declare const CORE_FILE_RE: RegExp;
7
7
  export declare const VIEWS_FILE_RE: RegExp;
@@ -7,7 +7,7 @@
7
7
  /** The minimum schema version the package needs. Moves ONLY in a release
8
8
  * that adds a table, column, function or policy the package reads; a
9
9
  * unit test holds it equal to the highest file in `migrations/`. */
10
- export const SCHEMA_VERSION = 1;
10
+ export const SCHEMA_VERSION = 2;
11
11
  export const SCHEMA_VERSION_KEY = "schema_version";
12
12
  export const CORE_FILE_RE = /^(\d{14})_smoodly_v(\d+)\.sql$/;
13
13
  export const VIEWS_FILE_RE = /^(\d{14})_smoodly_views\.sql$/;
package/dist/sql-space.js CHANGED
@@ -199,6 +199,17 @@ $$;`.trim());
199
199
  statements.push(`drop policy if exists "smoodly_read" on "smoodly_meta";\n` +
200
200
  `create policy "smoodly_read" on "smoodly_meta" for select\n` +
201
201
  ` using ((select "smoodly_key_kind"()) = 'write' and not (select "smoodly_space_key_revoked"()));`);
202
+ // The editing-presence channel (spec 2026-09-26 concurrency §6): one
203
+ // private Realtime topic per space, `space:<id>`. A caller receives on
204
+ // and sends to its own space's topic only — the anon key resolves to the
205
+ // default space, a cloud read key to its claim. Identical everywhere.
206
+ const topic = `(select realtime.topic()) = 'space:' || (select public."smoodly_current_space"())::text`;
207
+ statements.push(`drop policy if exists "smoodly_presence_read" on "realtime"."messages";\n` +
208
+ `create policy "smoodly_presence_read" on "realtime"."messages" for select\n` +
209
+ ` using (${topic});`);
210
+ statements.push(`drop policy if exists "smoodly_presence_write" on "realtime"."messages";\n` +
211
+ `create policy "smoodly_presence_write" on "realtime"."messages" for insert\n` +
212
+ ` with check (${topic});`);
202
213
  statements.push(storageSQL());
203
214
  return statements.join("\n\n");
204
215
  }
package/dist/sql.js CHANGED
@@ -193,6 +193,17 @@ create table if not exists "entry_versions" (
193
193
  create index if not exists "entry_versions_entry_locale_idx"
194
194
  on "entry_versions" ("entry_id", "locale", "saved_at");
195
195
 
196
+ -- ── Draft pointers as foreign keys (schema v2, spec 2026-09-26 concurrency §1):
197
+ -- PostgREST embeds the draft version's saved_at through them, so every
198
+ -- locale row read carries the token. Set null on delete: discard moves
199
+ -- the pointer before it deletes the abandoned row, so nothing dangles.
200
+ alter table "page_locales" drop constraint if exists "page_locales_draft_version_fkey";
201
+ alter table "page_locales" add constraint "page_locales_draft_version_fkey"
202
+ foreign key ("draft_version_id") references "page_versions" ("id") on delete set null;
203
+ alter table "entry_locales" drop constraint if exists "entry_locales_draft_version_fkey";
204
+ alter table "entry_locales" add constraint "entry_locales_draft_version_fkey"
205
+ foreign key ("draft_version_id") references "entry_versions" ("id") on delete set null;
206
+
196
207
  -- v1 reorder (DESIGN.md §3 Manual ordering): rewrite sort 1..N in ONE
197
208
  -- statement. Deliberately does not touch updated_at — a drag must not
198
209
  -- make the whole collection look freshly edited. Scoped to the current
package/dist/store.d.ts CHANGED
@@ -4,6 +4,7 @@ import { type RefEdge, type SectionLike } from "./refs.ts";
4
4
  import type { Toolset } from "./toolset.ts";
5
5
  import type { MemoryRefIndex } from "./ref-index.ts";
6
6
  import { type PathIndex } from "./path-index.ts";
7
+ import { type DraftToken } from "./draft-token.ts";
7
8
  import { type LocaleSet, type PagePathOptions, type PathTarget } from "./paths.ts";
8
9
  export type PageLocale = {
9
10
  /** The node's OWN segment in this locale; the URL is derived (paths.ts). */
@@ -11,6 +12,8 @@ export type PageLocale = {
11
12
  title: string;
12
13
  status: "draft" | "published";
13
14
  draftVersionId: string | null;
15
+ /** The draft version's saved time as the store's own text — the token's second half (spec 2026-09-26 concurrency §1). Null with no pointer. */
16
+ draftSavedAt: string | null;
14
17
  publishedVersionId: string | null;
15
18
  updatedAt: number;
16
19
  /** The editor's email behind the last write that touched this row; null for the seed and scripts (spec 2026-09-13 history §2). */
@@ -81,18 +84,18 @@ export interface PageStore {
81
84
  }): Promise<PageRecord>;
82
85
  /** A folder becomes a page: an empty draft version per present locale. */
83
86
  setTemplate(id: string, template: string, by?: string): Promise<PageRecord>;
84
- saveDraft(id: string, locale: string, tree: PageTree, by?: string): Promise<PageVersion>;
85
- publish(id: string, locale: string, by?: string): Promise<PageRecord>;
87
+ saveDraft(id: string, locale: string, tree: PageTree, by?: string, expected?: DraftToken | null): Promise<PageVersion>;
88
+ publish(id: string, locale: string, by?: string, expected?: DraftToken | null): Promise<PageRecord>;
86
89
  /** The draft goes back to what is live: the locale's draft pointer set to its
87
90
  * published pointer and the abandoned draft row deleted (spec 2026-09-13 history §1).
88
91
  * Refused when nothing is published in the locale (spec 2026-09-13 publish flow §4). */
89
- discardDraft(id: string, locale: string, by?: string): Promise<PageRecord>;
92
+ discardDraft(id: string, locale: string, by?: string, expected?: DraftToken | null): Promise<PageRecord>;
90
93
  /** Takes ONE locale off the site: status draft, both pointers kept so a later
91
94
  * publish republishes the current draft (spec §5). */
92
95
  unpublish(id: string, locale: string, by?: string): Promise<PageRecord>;
93
96
  /** The version's tree becomes the draft through the draft rule (spec 2026-09-13 history §6); the
94
97
  * published version restores as a discard. Refused when the version is not this page's in this locale. */
95
- restore(id: string, locale: string, versionId: string, by?: string): Promise<PageRecord>;
98
+ restore(id: string, locale: string, versionId: string, by?: string, expected?: DraftToken | null): Promise<PageRecord>;
96
99
  /** One stored version by id, whoever it belongs to — callers check `pageId` and `locale`. */
97
100
  getVersion(versionId: string): Promise<PageVersion | null>;
98
101
  getDraftTree(id: string, locale: string): Promise<PageTree | null>;
@@ -210,11 +213,11 @@ export declare class MemoryPageStore implements PageStore {
210
213
  index?: number;
211
214
  }): Promise<PageRecord>;
212
215
  setTemplate(id: string, template: string, by?: string): Promise<PageRecord>;
213
- saveDraft(id: string, locale: string, tree: PageTree, by?: string): Promise<PageVersion>;
214
- publish(id: string, locale: string, by?: string): Promise<PageRecord>;
215
- discardDraft(id: string, locale: string, by?: string): Promise<PageRecord>;
216
+ saveDraft(id: string, locale: string, tree: PageTree, by?: string, expected?: DraftToken | null): Promise<PageVersion>;
217
+ publish(id: string, locale: string, by?: string, expected?: DraftToken | null): Promise<PageRecord>;
218
+ discardDraft(id: string, locale: string, by?: string, expected?: DraftToken | null): Promise<PageRecord>;
216
219
  unpublish(id: string, locale: string, by?: string): Promise<PageRecord>;
217
- restore(id: string, locale: string, versionId: string, by?: string): Promise<PageRecord>;
220
+ restore(id: string, locale: string, versionId: string, by?: string, expected?: DraftToken | null): Promise<PageRecord>;
218
221
  getVersion(versionId: string): Promise<PageVersion | null>;
219
222
  getDraftTree(id: string, locale: string): Promise<PageTree | null>;
220
223
  fieldsOf(versionIds: string[]): Promise<Record<string, Record<string, unknown>>>;
package/dist/store.js CHANGED
@@ -13,6 +13,7 @@ import { ASSET_COLLECTION } from "./media.js";
13
13
  import { collectTreeRefs } from "./refs.js";
14
14
  import { customToolset, withCustomSchema } from "./custom/index.js";
15
15
  import { MemoryPathIndex } from "./path-index.js";
16
+ import { assertFresh } from "./draft-token.js";
16
17
  import { SCHEMA_VERSION } from "./schema-version.js";
17
18
  import { assertDepth, assertSegment, assertSupportedLocale, chainOf, isHomeNode, pagePathRows, unsupportedLocale, } from "./paths.js";
18
19
  export const DEFAULT_LOCALES = { default: "en", supported: ["en"] };
@@ -91,6 +92,11 @@ export async function assertPublishable(label, tree, sections, entries, locale)
91
92
  }
92
93
  let counter = 0;
93
94
  const uid = () => `id-${++counter}-${Date.now().toString(36)}`;
95
+ /** A version's saved time: wall-clock, but strictly increasing, so two saves in one
96
+ * millisecond still move the draft token (spec 2026-09-26 concurrency §1) the way
97
+ * Postgres's microsecond `now()` does. */
98
+ let lastSavedAt = 0;
99
+ const savedClock = () => (lastSavedAt = Math.max(Date.now(), lastSavedAt + 1));
94
100
  /** The row's audit pair: every write that touches a locale row sets both (spec 2026-09-13 history §2). */
95
101
  function touchRow(row, by) {
96
102
  row.updatedAt = Date.now();
@@ -179,7 +185,7 @@ export class MemoryPageStore {
179
185
  refs.rewrite({ sourceKind: "page", sourceId: page.id }, pageRefEdges(this.liveTrees(page), sections, elements));
180
186
  }
181
187
  newVersion(pageId, locale, tree) {
182
- const version = { id: uid(), pageId, locale, tree: structuredClone(tree), savedAt: Date.now() };
188
+ const version = { id: uid(), pageId, locale, tree: structuredClone(tree), savedAt: savedClock() };
183
189
  this.versions.set(version.id, version);
184
190
  return version;
185
191
  }
@@ -191,12 +197,14 @@ export class MemoryPageStore {
191
197
  let version;
192
198
  if (current && row.draftVersionId !== row.publishedVersionId) {
193
199
  current.tree = structuredClone(tree);
194
- current.savedAt = Date.now();
200
+ current.savedAt = savedClock();
201
+ row.draftSavedAt = String(current.savedAt);
195
202
  version = current;
196
203
  }
197
204
  else {
198
205
  version = this.newVersion(page.id, locale, tree);
199
206
  row.draftVersionId = version.id;
207
+ row.draftSavedAt = String(version.savedAt);
200
208
  }
201
209
  touchRow(row, by);
202
210
  return version;
@@ -235,6 +243,7 @@ export class MemoryPageStore {
235
243
  title: input.title ?? input.slug,
236
244
  status: "draft",
237
245
  draftVersionId: version?.id ?? null,
246
+ draftSavedAt: version ? String(version.savedAt) : null,
238
247
  publishedVersionId: null,
239
248
  updatedAt: Date.now(),
240
249
  updatedBy: by ?? null,
@@ -295,6 +304,7 @@ export class MemoryPageStore {
295
304
  title: options.title ?? source.title,
296
305
  status: "draft",
297
306
  draftVersionId: version?.id ?? null,
307
+ draftSavedAt: version ? String(version.savedAt) : null,
298
308
  publishedVersionId: null,
299
309
  updatedAt: Date.now(),
300
310
  updatedBy: by ?? null,
@@ -415,24 +425,28 @@ export class MemoryPageStore {
415
425
  throw pageErrors.hasTemplate(id);
416
426
  page.template = template;
417
427
  for (const [locale, row] of Object.entries(page.locales)) {
418
- row.draftVersionId = this.newVersion(id, locale, { zones: {} }).id;
428
+ const version = this.newVersion(id, locale, { zones: {} });
429
+ row.draftVersionId = version.id;
430
+ row.draftSavedAt = String(version.savedAt);
419
431
  touchRow(row, by);
420
432
  }
421
433
  return this.snapshot(page);
422
434
  }
423
- async saveDraft(id, locale, tree, by) {
435
+ async saveDraft(id, locale, tree, by, expected) {
424
436
  const page = this.must(id);
425
437
  if (page.template === null)
426
438
  throw pageErrors.folder(id);
439
+ assertFresh(localeRow(page, locale), expected);
427
440
  const version = this.writeDraftVersion(page, locale, tree, by);
428
441
  this.writeRefs(page);
429
442
  return { ...version, tree: structuredClone(version.tree) };
430
443
  }
431
- async publish(id, locale, by) {
444
+ async publish(id, locale, by, expected) {
432
445
  const page = this.must(id);
433
446
  if (page.template === null)
434
447
  throw pageErrors.folder(id);
435
448
  const row = localeRow(page, locale);
449
+ assertFresh(row, expected);
436
450
  if (!row.draftVersionId)
437
451
  throw pageErrors.folder(id);
438
452
  const { sections, entries } = this.options;
@@ -445,13 +459,15 @@ export class MemoryPageStore {
445
459
  this.writeRefs(page);
446
460
  return this.snapshot(page);
447
461
  }
448
- async discardDraft(id, locale, by) {
462
+ async discardDraft(id, locale, by, expected) {
449
463
  const page = this.must(id);
450
464
  const row = localeRow(page, locale);
465
+ assertFresh(row, expected);
451
466
  if (!row.publishedVersionId)
452
467
  throw pageErrors.nothingPublished(id, locale);
453
468
  const abandoned = row.draftVersionId;
454
469
  row.draftVersionId = row.publishedVersionId;
470
+ row.draftSavedAt = String(this.versions.get(row.publishedVersionId).savedAt);
455
471
  if (abandoned && abandoned !== row.publishedVersionId)
456
472
  this.versions.delete(abandoned); // never shown as a stray draft
457
473
  touchRow(row, by);
@@ -466,7 +482,7 @@ export class MemoryPageStore {
466
482
  this.writeRefs(page);
467
483
  return this.snapshot(page);
468
484
  }
469
- async restore(id, locale, versionId, by) {
485
+ async restore(id, locale, versionId, by, expected) {
470
486
  const page = this.must(id);
471
487
  if (page.template === null)
472
488
  throw pageErrors.folder(id);
@@ -474,8 +490,10 @@ export class MemoryPageStore {
474
490
  const version = this.versions.get(versionId);
475
491
  if (!version || version.pageId !== id || version.locale !== locale)
476
492
  throw pageErrors.foreignVersion(id, locale, versionId);
493
+ // The memory store is single-threaded: the read-side check IS the atomic check.
494
+ assertFresh(row, expected);
477
495
  if (versionId === row.publishedVersionId)
478
- return this.discardDraft(id, locale, by);
496
+ return this.discardDraft(id, locale, by, expected);
479
497
  this.writeDraftVersion(page, locale, version.tree, by);
480
498
  this.writeRefs(page);
481
499
  return this.snapshot(page);
@@ -1,6 +1,7 @@
1
1
  import type { SupabaseClient } from "@supabase/supabase-js";
2
2
  import type { CollectionSchema } from "./collections.ts";
3
3
  import type { AddEntryLocaleOptions, CreateEntryOptions, EntryRecord, EntryStore, EntryStoreOptions, EntryUsage, EntryVersion, ListOptions } from "./entry-store.ts";
4
+ import { type DraftToken } from "./draft-token.ts";
4
5
  export declare class SupabaseEntryStore implements EntryStore {
5
6
  private db;
6
7
  private paths;
@@ -39,9 +40,18 @@ export declare class SupabaseEntryStore implements EntryStore {
39
40
  private insertLocale;
40
41
  /** A touch writes the audit pair — `updated_at` and `updated_by` (spec 2026-09-13 history §2). */
41
42
  private updateLocale;
43
+ /** The row's stale error, built from a fresh read: what a reload would get, and who wrote last. */
44
+ private stale;
45
+ /** `updateLocale` with the pointer as a filter (spec 2026-09-26 concurrency §3): the row is
46
+ * written only while `draft_version_id` still equals `guard` (null = still no pointer).
47
+ * `undefined` guard = unconditional. Resolves false when no row matched. */
48
+ private guardedUpdateLocale;
42
49
  /** The row goes first (it is what makes the entry exist in the locale), then its versions. */
43
50
  private deleteLocale;
44
- /** The draft rule for every collection (spec 2026-09-14 §1), see MemoryEntryStore.recordVersion. */
51
+ /** The draft rule for every collection (spec 2026-09-14 §1), see MemoryEntryStore.recordVersion —
52
+ * guarded (spec 2026-09-26 concurrency §3) when the caller verified `row` against the token:
53
+ * the rewrite filters on saved_at, the first draft on the pointer. Returns the version row it
54
+ * created, or null when it rewrote in place, so a failed later step can undo it. */
45
55
  private recordVersion;
46
56
  /** See MemoryEntryStore.ensureLiveSnapshot: the snapshot of what is live, both pointers on it. */
47
57
  private ensureLiveSnapshot;
@@ -51,13 +61,13 @@ export declare class SupabaseEntryStore implements EntryStore {
51
61
  getMany(collection: string, ids: string[], locale: string, options?: {
52
62
  status?: "published";
53
63
  }): Promise<Record<string, Record<string, unknown>>>;
54
- update(collection: string, id: string, locale: string, fields: Record<string, unknown>, by?: string): Promise<EntryRecord>;
55
- setStatus(collection: string, id: string, locale: string, status: "draft" | "published", by?: string): Promise<EntryRecord>;
56
- discardDraft(collection: string, id: string, locale: string, by?: string): Promise<EntryRecord>;
64
+ update(collection: string, id: string, locale: string, fields: Record<string, unknown>, by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
65
+ setStatus(collection: string, id: string, locale: string, status: "draft" | "published", by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
66
+ discardDraft(collection: string, id: string, locale: string, by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
57
67
  addLocale(collection: string, id: string, locale: string, options: AddEntryLocaleOptions, by?: string): Promise<EntryRecord>;
58
68
  removeLocale(collection: string, id: string, locale: string): Promise<EntryRecord>;
59
69
  listVersions(collection: string, id: string, locale: string): Promise<EntryVersion[]>;
60
- restore(collection: string, id: string, locale: string, versionId: string, by?: string): Promise<EntryRecord>;
70
+ restore(collection: string, id: string, locale: string, versionId: string, by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
61
71
  getVersion(collection: string, versionId: string): Promise<EntryVersion | null>;
62
72
  move(collection: string, id: string, to: {
63
73
  parentId: string | null;