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
package/dist/index.d.ts CHANGED
@@ -27,6 +27,9 @@ export type { Crop, Hotspot, AssetKind, ImageValue, VideoValue, FileValue, Asset
27
27
  export { z } from "./zones.ts";
28
28
  export { defineConfig } from "./config.ts";
29
29
  export { collectionSQL, coreTablesSQL } from "./sql.ts";
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";
30
33
  export { MemoryPageStore } from "./store.ts";
31
34
  export { MemoryAssetStore, assetErrors, kindOf, extensionFor, objectPath, withDownload, PAGE_SIZE as ASSET_PAGE_SIZE } from "./asset-store.ts";
32
35
  export type { AssetStore, UploadTicket, FinishInput, AssetQuery, AssetPage, BeginUploadInput, CreateAssetInput } from "./asset-store.ts";
@@ -150,10 +153,10 @@ export declare const smoodly: {
150
153
  Section: typeof Section;
151
154
  marks: typeof marks;
152
155
  };
153
- export { NODE_ATTR, ZONE_ATTR, EMPTY_ATTR, FIELD_ATTR, ITEM_ATTR, EDITOR_ATTR, resolveMarkPath } from "./marks.ts";
156
+ export { NODE_ATTR, ZONE_ATTR, EMPTY_ATTR, BLANK_ATTR, FIELD_ATTR, ITEM_ATTR, EDITOR_ATTR, resolveMarkPath } from "./marks.ts";
154
157
  export type { MarkNode, MarkPath, Marks, Scope } from "./marks.ts";
155
- export { resolveSmoodlyEnv, smoodlyEnv } from "./env.ts";
156
- export type { SmoodlyEnv } from "./env.ts";
158
+ export { resolveSmoodlyEnv, smoodlyEnv, publicContentConfig, spaceIdOfKey } from "./env.ts";
159
+ export type { SmoodlyEnv, ContentConfig } from "./env.ts";
157
160
  export { clientOptions, createSmoodlyClient } from "./client.ts";
158
161
  export { createSmoodlySite, pageContextOf } from "./site.ts";
159
162
  export type { SiteOptions, SitePage, SitePageSchema, SiteEntry, SiteHit, SmoodlySite, LoadOptions, PageTreeItem, SiteEntryItem, EntryTreeItem, EntryQuery, } from "./site.ts";
package/dist/index.js CHANGED
@@ -21,6 +21,8 @@ export { ASSET_COLLECTION, DEFAULT_MEDIA, MEDIA_TYPES, FULL_CROP, CENTER_HOTSPOT
21
21
  export { z } from "./zones.js";
22
22
  export { defineConfig } from "./config.js";
23
23
  export { collectionSQL, coreTablesSQL } from "./sql.js";
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";
24
26
  export { MemoryPageStore } from "./store.js";
25
27
  export { MemoryAssetStore, assetErrors, kindOf, extensionFor, objectPath, withDownload, PAGE_SIZE as ASSET_PAGE_SIZE } from "./asset-store.js";
26
28
  export { imageDimensions } from "./image-dimensions.js";
@@ -61,7 +63,7 @@ export const smoodly = {
61
63
  Section,
62
64
  marks,
63
65
  };
64
- export { NODE_ATTR, ZONE_ATTR, EMPTY_ATTR, FIELD_ATTR, ITEM_ATTR, EDITOR_ATTR, resolveMarkPath } from "./marks.js";
65
- export { resolveSmoodlyEnv, smoodlyEnv } from "./env.js";
66
+ export { NODE_ATTR, ZONE_ATTR, EMPTY_ATTR, BLANK_ATTR, FIELD_ATTR, ITEM_ATTR, EDITOR_ATTR, resolveMarkPath } from "./marks.js";
67
+ export { resolveSmoodlyEnv, smoodlyEnv, publicContentConfig, spaceIdOfKey } from "./env.js";
66
68
  export { clientOptions, createSmoodlyClient } from "./client.js";
67
69
  export { createSmoodlySite, pageContextOf } from "./site.js";
package/dist/marks.d.ts CHANGED
@@ -4,6 +4,9 @@ export declare const NODE_ATTR = "data-smd-node";
4
4
  /** An empty zone's placeholder box, from `<Zone>` in editor mode. */
5
5
  export declare const ZONE_ATTR = "data-smd-zone";
6
6
  export declare const EMPTY_ATTR = "data-smd-empty";
7
+ /** A section that renders no text and no media, stamped by the bridge in
8
+ * editor mode so the canvas can give it a height to see and select. */
9
+ export declare const BLANK_ATTR = "data-smd-blank";
7
10
  /** The element that renders a field's value verbatim. */
8
11
  export declare const FIELD_ATTR = "data-smd-field";
9
12
  /** A list item's or object field's root: scopes the field names inside it. */
package/dist/marks.js CHANGED
@@ -3,6 +3,9 @@ export const NODE_ATTR = "data-smd-node";
3
3
  /** An empty zone's placeholder box, from `<Zone>` in editor mode. */
4
4
  export const ZONE_ATTR = "data-smd-zone";
5
5
  export const EMPTY_ATTR = "data-smd-empty";
6
+ /** A section that renders no text and no media, stamped by the bridge in
7
+ * editor mode so the canvas can give it a height to see and select. */
8
+ export const BLANK_ATTR = "data-smd-blank";
6
9
  /** The element that renders a field's value verbatim. */
7
10
  export const FIELD_ATTR = "data-smd-field";
8
11
  /** A list item's or object field's root: scopes the field names inside it. */
@@ -0,0 +1,37 @@
1
+ export type MigrationFile = {
2
+ name: string;
3
+ text: string;
4
+ };
5
+ export type CollectionSQL = {
6
+ name: string;
7
+ sql: string;
8
+ };
9
+ export declare const MARKER_PREFIX = "-- smoodly:collection ";
10
+ /** The first 16 hex chars of sha256 over the exact emitted SQL. */
11
+ export declare const collectionHash: (sql: string) => string;
12
+ export declare const collectionMarker: (c: CollectionSQL) => string;
13
+ /** name → hash, the LAST marker per collection across the files in name order. */
14
+ export declare function parseMarkers(files: MigrationFile[]): Map<string, string>;
15
+ /** UTC yyyyMMddHHmmss — the Supabase CLI's file-name prefix. */
16
+ export declare const stamp: (at: Date) => string;
17
+ /** A views file must sort after every core file it may depend on and
18
+ * after any earlier views file it supersedes: the later of `now` and the
19
+ * newest own stamp plus a minute. */
20
+ export declare function viewsStamp(names: string[], now: Date): Date;
21
+ /** One file with a block per new or changed collection; null when none changed. */
22
+ export declare function viewsFile(collections: CollectionSQL[], existing: Map<string, string>, at: Date): MigrationFile | null;
23
+ export type CorePending = {
24
+ copy: MigrationFile[];
25
+ modified: string[];
26
+ };
27
+ /** Package files the app lacks (copy) and same-named files whose text differs (modified — never overwritten). */
28
+ export declare function pendingCoreFiles(packageFiles: MigrationFile[], appFiles: MigrationFile[]): CorePending;
29
+ export type MigratePlan = CorePending & {
30
+ views: MigrationFile | null;
31
+ };
32
+ export declare function planMigrate(input: {
33
+ packageFiles: MigrationFile[];
34
+ appFiles: MigrationFile[];
35
+ collections: CollectionSQL[];
36
+ }, now: Date): MigratePlan;
37
+ export declare const planIsEmpty: (plan: MigratePlan) => boolean;
@@ -0,0 +1,73 @@
1
+ // packages/smoodly/src/migrations.ts
2
+ // What `npx smoodly migrate` decides, as pure functions over file lists
3
+ // (spec 2026-09-26 §2–§3): which package files the app lacks, which
4
+ // collections need a fresh view block, and the one views file to write.
5
+ // Node-only (crypto): exposed at `smoodly/migrations`, never from the
6
+ // root index, which the client bundle reaches through the config.
7
+ import { createHash } from "node:crypto";
8
+ export const MARKER_PREFIX = "-- smoodly:collection ";
9
+ const MARKER_RE = /^-- smoodly:collection (\S+) sha256:([0-9a-f]+)\s*$/gm;
10
+ const OWN_FILE_RE = /^(\d{14})_smoodly_(v\d+|views)\.sql$/;
11
+ /** The first 16 hex chars of sha256 over the exact emitted SQL. */
12
+ export const collectionHash = (sql) => createHash("sha256").update(sql).digest("hex").slice(0, 16);
13
+ export const collectionMarker = (c) => `${MARKER_PREFIX}${c.name} sha256:${collectionHash(c.sql)}`;
14
+ /** name → hash, the LAST marker per collection across the files in name order. */
15
+ export function parseMarkers(files) {
16
+ const out = new Map();
17
+ for (const file of [...files].sort((a, b) => a.name.localeCompare(b.name))) {
18
+ for (const m of file.text.matchAll(MARKER_RE))
19
+ out.set(m[1], m[2]);
20
+ }
21
+ return out;
22
+ }
23
+ /** UTC yyyyMMddHHmmss — the Supabase CLI's file-name prefix. */
24
+ export const stamp = (at) => at.toISOString().replace(/\D/g, "").slice(0, 14);
25
+ const stampDate = (s) => new Date(Date.UTC(+s.slice(0, 4), +s.slice(4, 6) - 1, +s.slice(6, 8), +s.slice(8, 10), +s.slice(10, 12), +s.slice(12, 14)));
26
+ /** A views file must sort after every core file it may depend on and
27
+ * after any earlier views file it supersedes: the later of `now` and the
28
+ * newest own stamp plus a minute. */
29
+ export function viewsStamp(names, now) {
30
+ let at = now;
31
+ for (const name of names) {
32
+ const m = OWN_FILE_RE.exec(name);
33
+ if (!m)
34
+ continue;
35
+ const after = new Date(stampDate(m[1]).getTime() + 60000);
36
+ if (after > at)
37
+ at = after;
38
+ }
39
+ return at;
40
+ }
41
+ /** One file with a block per new or changed collection; null when none changed. */
42
+ export function viewsFile(collections, existing, at) {
43
+ const changed = collections.filter((c) => existing.get(c.name) !== collectionHash(c.sql));
44
+ if (changed.length === 0)
45
+ return null;
46
+ const text = "-- Smoodly: read views and order indexes for this app's collections, written by `npx smoodly migrate`\n" +
47
+ `-- (${at.toISOString()}). One block per collection; the marker's hash is the block's SQL, compared on the next run.\n` +
48
+ "-- Never edit a written file: change the collection and run migrate again.\n\n" +
49
+ changed.map((c) => `${collectionMarker(c)}\n${c.sql.trim()}`).join("\n\n") +
50
+ "\n";
51
+ return { name: `${stamp(at)}_smoodly_views.sql`, text };
52
+ }
53
+ /** Package files the app lacks (copy) and same-named files whose text differs (modified — never overwritten). */
54
+ export function pendingCoreFiles(packageFiles, appFiles) {
55
+ const app = new Map(appFiles.map((f) => [f.name, f.text]));
56
+ const copy = [];
57
+ const modified = [];
58
+ for (const file of [...packageFiles].sort((a, b) => a.name.localeCompare(b.name))) {
59
+ const have = app.get(file.name);
60
+ if (have === undefined)
61
+ copy.push(file);
62
+ else if (have !== file.text)
63
+ modified.push(file.name);
64
+ }
65
+ return { copy, modified };
66
+ }
67
+ export function planMigrate(input, now) {
68
+ const core = pendingCoreFiles(input.packageFiles, input.appFiles);
69
+ const names = [...input.packageFiles, ...input.appFiles].map((f) => f.name);
70
+ const views = viewsFile(input.collections, parseMarkers(input.appFiles), viewsStamp(names, now));
71
+ return { ...core, views };
72
+ }
73
+ export const planIsEmpty = (plan) => plan.copy.length === 0 && plan.modified.length === 0 && plan.views === null;
@@ -0,0 +1,17 @@
1
+ /** The minimum schema version the package needs. Moves ONLY in a release
2
+ * that adds a table, column, function or policy the package reads; a
3
+ * unit test holds it equal to the highest file in `migrations/`. */
4
+ export declare const SCHEMA_VERSION = 2;
5
+ export declare const SCHEMA_VERSION_KEY = "schema_version";
6
+ export declare const CORE_FILE_RE: RegExp;
7
+ export declare const VIEWS_FILE_RE: RegExp;
8
+ /** The version a core file leaves behind, from its name; null for anything else. */
9
+ export declare function coreFileVersion(name: string): number | null;
10
+ /** The last statement of every core file. */
11
+ export declare function schemaVersionSQL(version: number): string;
12
+ /** Behind blocks the admin and warns on the site (§5). Null = no
13
+ * `smoodly_meta` table, a database older than version 1. Ahead is the
14
+ * normal state of a cloud app between our migration and its package
15
+ * bump, and is fine. */
16
+ export declare const schemaBehind: (database: number | null, required?: number) => boolean;
17
+ export declare function schemaBehindMessage(database: number | null, required?: number): string;
@@ -0,0 +1,32 @@
1
+ // packages/smoodly/src/schema-version.ts
2
+ // The schema version this package needs (spec 2026-09-26 §1) and the two
3
+ // things every side agrees on: the `smoodly_meta` row that records a
4
+ // database's version, and the names of the files the package ships. Pure
5
+ // and framework-free — the SQL emitters, the CLI, the stores, the admin
6
+ // op and the shell all read it from here.
7
+ /** The minimum schema version the package needs. Moves ONLY in a release
8
+ * that adds a table, column, function or policy the package reads; a
9
+ * unit test holds it equal to the highest file in `migrations/`. */
10
+ export const SCHEMA_VERSION = 2;
11
+ export const SCHEMA_VERSION_KEY = "schema_version";
12
+ export const CORE_FILE_RE = /^(\d{14})_smoodly_v(\d+)\.sql$/;
13
+ export const VIEWS_FILE_RE = /^(\d{14})_smoodly_views\.sql$/;
14
+ /** The version a core file leaves behind, from its name; null for anything else. */
15
+ export function coreFileVersion(name) {
16
+ const m = CORE_FILE_RE.exec(name);
17
+ return m ? Number(m[2]) : null;
18
+ }
19
+ /** The last statement of every core file. */
20
+ export function schemaVersionSQL(version) {
21
+ return (`insert into "smoodly_meta" ("key", "value") values ('${SCHEMA_VERSION_KEY}', '${version}')\n` +
22
+ ` on conflict ("key") do update set "value" = excluded."value";`);
23
+ }
24
+ /** Behind blocks the admin and warns on the site (§5). Null = no
25
+ * `smoodly_meta` table, a database older than version 1. Ahead is the
26
+ * normal state of a cloud app between our migration and its package
27
+ * bump, and is fine. */
28
+ export const schemaBehind = (database, required = SCHEMA_VERSION) => database === null || database < required;
29
+ export function schemaBehindMessage(database, required = SCHEMA_VERSION) {
30
+ return (`smoodly: the database schema is at version ${database ?? "none"}; this Smoodly needs version ${required}. ` +
31
+ "Run `npx smoodly migrate`, then `supabase db push` (hosted) or `supabase db reset` (local).");
32
+ }
package/dist/sql-space.js CHANGED
@@ -193,6 +193,23 @@ $$;`.trim());
193
193
  statements.push(`drop policy if exists "smoodly_read" on "spaces";\n` +
194
194
  `create policy "smoodly_read" on "spaces" for select\n` +
195
195
  ` using (${write});`);
196
+ // The package reads its schema version with the write key (the
197
+ // service role bypasses anyway); nothing else sees the table. No
198
+ // space_id: the schema is one per database (spec 2026-09-26 §1).
199
+ statements.push(`drop policy if exists "smoodly_read" on "smoodly_meta";\n` +
200
+ `create policy "smoodly_read" on "smoodly_meta" for select\n` +
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});`);
196
213
  statements.push(storageSQL());
197
214
  return statements.join("\n\n");
198
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
@@ -286,6 +297,15 @@ $$;
286
297
  -- to anything but a write key. The generated per-collection views
287
298
  -- (collectionSQL) run with security_invoker = true, so they are subject
288
299
  -- to this same smoodly_read policy rather than their owner's privileges.
300
+ -- smoodly_meta: which version of this schema the database holds (spec
301
+ -- 2026-09-26 §1). One row per database, not per space; every core
302
+ -- migration file upserts the version row as its last statement, and the
303
+ -- package compares it with SCHEMA_VERSION (behind blocks the admin).
304
+ create table if not exists "smoodly_meta" (
305
+ "key" text primary key,
306
+ "value" text not null
307
+ );
308
+
289
309
  alter table "pages" enable row level security;
290
310
  alter table "page_locales" enable row level security;
291
311
  alter table "page_versions" enable row level security;
@@ -295,6 +315,7 @@ alter table "entry_versions" enable row level security;
295
315
  alter table "refs" enable row level security;
296
316
  alter table "paths" enable row level security;
297
317
  alter table "assets" enable row level security;
318
+ alter table "smoodly_meta" enable row level security;
298
319
  `.trim(),
299
320
  spaceLayerSQL(),
300
321
  ].join("\n\n");
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). */
@@ -54,6 +57,8 @@ export interface PageStore {
54
57
  getPage(id: string): Promise<PageRecord | null>;
55
58
  listPages(): Promise<PageRecord[]>;
56
59
  resolvePath(locale: string, path: string): Promise<PathTarget | null>;
60
+ /** The database's schema version from `smoodly_meta`; null when the table is missing (older than v1). Memory: always current (spec 2026-09-26 §5). */
61
+ schemaVersion(): Promise<number | null>;
57
62
  /** locale → path, for the locales the page exists in. */
58
63
  pathsOf(id: string): Promise<Record<string, string>>;
59
64
  /** Refused when the page already has the locale or its parent lacks it (the parent rule). */
@@ -79,18 +84,18 @@ export interface PageStore {
79
84
  }): Promise<PageRecord>;
80
85
  /** A folder becomes a page: an empty draft version per present locale. */
81
86
  setTemplate(id: string, template: string, by?: string): Promise<PageRecord>;
82
- saveDraft(id: string, locale: string, tree: PageTree, by?: string): Promise<PageVersion>;
83
- 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>;
84
89
  /** The draft goes back to what is live: the locale's draft pointer set to its
85
90
  * published pointer and the abandoned draft row deleted (spec 2026-09-13 history §1).
86
91
  * Refused when nothing is published in the locale (spec 2026-09-13 publish flow §4). */
87
- discardDraft(id: string, locale: string, by?: string): Promise<PageRecord>;
92
+ discardDraft(id: string, locale: string, by?: string, expected?: DraftToken | null): Promise<PageRecord>;
88
93
  /** Takes ONE locale off the site: status draft, both pointers kept so a later
89
94
  * publish republishes the current draft (spec §5). */
90
95
  unpublish(id: string, locale: string, by?: string): Promise<PageRecord>;
91
96
  /** The version's tree becomes the draft through the draft rule (spec 2026-09-13 history §6); the
92
97
  * published version restores as a discard. Refused when the version is not this page's in this locale. */
93
- 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>;
94
99
  /** One stored version by id, whoever it belongs to — callers check `pageId` and `locale`. */
95
100
  getVersion(versionId: string): Promise<PageVersion | null>;
96
101
  getDraftTree(id: string, locale: string): Promise<PageTree | null>;
@@ -194,6 +199,7 @@ export declare class MemoryPageStore implements PageStore {
194
199
  getPage(id: string): Promise<PageRecord | null>;
195
200
  listPages(): Promise<PageRecord[]>;
196
201
  resolvePath(locale: string, path: string): Promise<PathTarget | null>;
202
+ schemaVersion(): Promise<number>;
197
203
  pathsOf(id: string): Promise<Record<string, string>>;
198
204
  addLocale(id: string, locale: string, options: AddLocaleOptions, by?: string): Promise<PageRecord>;
199
205
  removeLocale(id: string, locale: string): Promise<PageRecord>;
@@ -207,11 +213,11 @@ export declare class MemoryPageStore implements PageStore {
207
213
  index?: number;
208
214
  }): Promise<PageRecord>;
209
215
  setTemplate(id: string, template: string, by?: string): Promise<PageRecord>;
210
- saveDraft(id: string, locale: string, tree: PageTree, by?: string): Promise<PageVersion>;
211
- publish(id: string, locale: string, by?: string): Promise<PageRecord>;
212
- 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>;
213
219
  unpublish(id: string, locale: string, by?: string): Promise<PageRecord>;
214
- 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>;
215
221
  getVersion(versionId: string): Promise<PageVersion | null>;
216
222
  getDraftTree(id: string, locale: string): Promise<PageTree | null>;
217
223
  fieldsOf(versionIds: string[]): Promise<Record<string, Record<string, unknown>>>;
package/dist/store.js CHANGED
@@ -13,6 +13,8 @@ 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";
17
+ import { SCHEMA_VERSION } from "./schema-version.js";
16
18
  import { assertDepth, assertSegment, assertSupportedLocale, chainOf, isHomeNode, pagePathRows, unsupportedLocale, } from "./paths.js";
17
19
  export const DEFAULT_LOCALES = { default: "en", supported: ["en"] };
18
20
  /** The section list every adapter walks refs with: the registered ones plus the custom section's schema (spec 2026-09-14 §6). */
@@ -90,6 +92,11 @@ export async function assertPublishable(label, tree, sections, entries, locale)
90
92
  }
91
93
  let counter = 0;
92
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));
93
100
  /** The row's audit pair: every write that touches a locale row sets both (spec 2026-09-13 history §2). */
94
101
  function touchRow(row, by) {
95
102
  row.updatedAt = Date.now();
@@ -178,7 +185,7 @@ export class MemoryPageStore {
178
185
  refs.rewrite({ sourceKind: "page", sourceId: page.id }, pageRefEdges(this.liveTrees(page), sections, elements));
179
186
  }
180
187
  newVersion(pageId, locale, tree) {
181
- const version = { id: uid(), pageId, locale, tree: structuredClone(tree), savedAt: Date.now() };
188
+ const version = { id: uid(), pageId, locale, tree: structuredClone(tree), savedAt: savedClock() };
182
189
  this.versions.set(version.id, version);
183
190
  return version;
184
191
  }
@@ -190,12 +197,14 @@ export class MemoryPageStore {
190
197
  let version;
191
198
  if (current && row.draftVersionId !== row.publishedVersionId) {
192
199
  current.tree = structuredClone(tree);
193
- current.savedAt = Date.now();
200
+ current.savedAt = savedClock();
201
+ row.draftSavedAt = String(current.savedAt);
194
202
  version = current;
195
203
  }
196
204
  else {
197
205
  version = this.newVersion(page.id, locale, tree);
198
206
  row.draftVersionId = version.id;
207
+ row.draftSavedAt = String(version.savedAt);
199
208
  }
200
209
  touchRow(row, by);
201
210
  return version;
@@ -234,6 +243,7 @@ export class MemoryPageStore {
234
243
  title: input.title ?? input.slug,
235
244
  status: "draft",
236
245
  draftVersionId: version?.id ?? null,
246
+ draftSavedAt: version ? String(version.savedAt) : null,
237
247
  publishedVersionId: null,
238
248
  updatedAt: Date.now(),
239
249
  updatedBy: by ?? null,
@@ -266,6 +276,9 @@ export class MemoryPageStore {
266
276
  resolvePath(locale, path) {
267
277
  return this.paths.resolve(locale, path);
268
278
  }
279
+ schemaVersion() {
280
+ return Promise.resolve(SCHEMA_VERSION);
281
+ }
269
282
  pathsOf(id) {
270
283
  return this.paths.pathsOf({ kind: "page", id });
271
284
  }
@@ -291,6 +304,7 @@ export class MemoryPageStore {
291
304
  title: options.title ?? source.title,
292
305
  status: "draft",
293
306
  draftVersionId: version?.id ?? null,
307
+ draftSavedAt: version ? String(version.savedAt) : null,
294
308
  publishedVersionId: null,
295
309
  updatedAt: Date.now(),
296
310
  updatedBy: by ?? null,
@@ -411,24 +425,28 @@ export class MemoryPageStore {
411
425
  throw pageErrors.hasTemplate(id);
412
426
  page.template = template;
413
427
  for (const [locale, row] of Object.entries(page.locales)) {
414
- 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);
415
431
  touchRow(row, by);
416
432
  }
417
433
  return this.snapshot(page);
418
434
  }
419
- async saveDraft(id, locale, tree, by) {
435
+ async saveDraft(id, locale, tree, by, expected) {
420
436
  const page = this.must(id);
421
437
  if (page.template === null)
422
438
  throw pageErrors.folder(id);
439
+ assertFresh(localeRow(page, locale), expected);
423
440
  const version = this.writeDraftVersion(page, locale, tree, by);
424
441
  this.writeRefs(page);
425
442
  return { ...version, tree: structuredClone(version.tree) };
426
443
  }
427
- async publish(id, locale, by) {
444
+ async publish(id, locale, by, expected) {
428
445
  const page = this.must(id);
429
446
  if (page.template === null)
430
447
  throw pageErrors.folder(id);
431
448
  const row = localeRow(page, locale);
449
+ assertFresh(row, expected);
432
450
  if (!row.draftVersionId)
433
451
  throw pageErrors.folder(id);
434
452
  const { sections, entries } = this.options;
@@ -441,13 +459,15 @@ export class MemoryPageStore {
441
459
  this.writeRefs(page);
442
460
  return this.snapshot(page);
443
461
  }
444
- async discardDraft(id, locale, by) {
462
+ async discardDraft(id, locale, by, expected) {
445
463
  const page = this.must(id);
446
464
  const row = localeRow(page, locale);
465
+ assertFresh(row, expected);
447
466
  if (!row.publishedVersionId)
448
467
  throw pageErrors.nothingPublished(id, locale);
449
468
  const abandoned = row.draftVersionId;
450
469
  row.draftVersionId = row.publishedVersionId;
470
+ row.draftSavedAt = String(this.versions.get(row.publishedVersionId).savedAt);
451
471
  if (abandoned && abandoned !== row.publishedVersionId)
452
472
  this.versions.delete(abandoned); // never shown as a stray draft
453
473
  touchRow(row, by);
@@ -462,7 +482,7 @@ export class MemoryPageStore {
462
482
  this.writeRefs(page);
463
483
  return this.snapshot(page);
464
484
  }
465
- async restore(id, locale, versionId, by) {
485
+ async restore(id, locale, versionId, by, expected) {
466
486
  const page = this.must(id);
467
487
  if (page.template === null)
468
488
  throw pageErrors.folder(id);
@@ -470,8 +490,10 @@ export class MemoryPageStore {
470
490
  const version = this.versions.get(versionId);
471
491
  if (!version || version.pageId !== id || version.locale !== locale)
472
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);
473
495
  if (versionId === row.publishedVersionId)
474
- return this.discardDraft(id, locale, by);
496
+ return this.discardDraft(id, locale, by, expected);
475
497
  this.writeDraftVersion(page, locale, version.tree, by);
476
498
  this.writeRefs(page);
477
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;