smoodly 0.0.51 → 0.0.52

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 (285) hide show
  1. package/README.md +14 -13
  2. package/dist/admin/auth-config.d.ts +6 -5
  3. package/dist/admin/auth.d.ts +3 -11
  4. package/dist/admin/auth.js +1 -30
  5. package/dist/admin/browser/fetch-op.js +3 -2
  6. package/dist/admin/browser/main.js +6 -2
  7. package/dist/admin/client-ops.d.ts +5 -4
  8. package/dist/admin/client-ops.js +14 -11
  9. package/dist/admin/delivery-mode.d.ts +1 -1
  10. package/dist/admin/delivery-mode.js +2 -2
  11. package/dist/admin/drift.d.ts +0 -2
  12. package/dist/admin/drift.js +1 -1
  13. package/dist/admin/editor/EditorView.d.ts +0 -3
  14. package/dist/admin/editor/EditorView.js +11 -16
  15. package/dist/admin/editor/FloatingToolbar.d.ts +0 -1
  16. package/dist/admin/editor/FloatingToolbar.js +1 -1
  17. package/dist/admin/editor/SeoSettings.d.ts +0 -2
  18. package/dist/admin/editor/SeoSettings.js +2 -2
  19. package/dist/admin/editor/canvas-address.d.ts +8 -0
  20. package/dist/admin/editor/canvas-address.js +18 -2
  21. package/dist/admin/editor/canvas-diagnosis.d.ts +2 -0
  22. package/dist/admin/editor/canvas-diagnosis.js +3 -0
  23. package/dist/admin/editor/inline-dom.d.ts +0 -4
  24. package/dist/admin/editor/inline-dom.js +1 -1
  25. package/dist/admin/editor/preview-grant.d.ts +6 -0
  26. package/dist/admin/editor/preview-grant.js +25 -0
  27. package/dist/admin/editor/useCanvasDiagnosis.js +2 -2
  28. package/dist/admin/files.generated.js +1 -1
  29. package/dist/admin/fixed-nodes.d.ts +0 -6
  30. package/dist/admin/fixed-nodes.js +1 -81
  31. package/dist/admin/forms/CropEditor.d.ts +4 -2
  32. package/dist/admin/forms/CropEditor.js +58 -5
  33. package/dist/admin/forms/ImagePreview.d.ts +3 -1
  34. package/dist/admin/forms/ImagePreview.js +9 -3
  35. package/dist/admin/forms/MediaGrid.js +2 -1
  36. package/dist/admin/forms/MediaPicker.js +9 -6
  37. package/dist/admin/forms/MediaWidget.js +11 -9
  38. package/dist/admin/forms/assets-context.d.ts +7 -3
  39. package/dist/admin/forms/assets-context.js +5 -3
  40. package/dist/admin/forms/crop-editor.d.ts +24 -2
  41. package/dist/admin/forms/crop-editor.js +36 -2
  42. package/dist/admin/forms/date-picker.d.ts +0 -1
  43. package/dist/admin/forms/date-picker.js +1 -1
  44. package/dist/admin/forms/tabs.d.ts +0 -1
  45. package/dist/admin/forms/tabs.js +1 -1
  46. package/dist/admin/forms/upload.d.ts +18 -11
  47. package/dist/admin/forms/upload.js +62 -24
  48. package/dist/admin/global-items.d.ts +0 -3
  49. package/dist/admin/global-items.js +0 -54
  50. package/dist/admin/handler.d.ts +22 -28
  51. package/dist/admin/handler.js +146 -47
  52. package/dist/admin/health.d.ts +0 -4
  53. package/dist/admin/index.d.ts +5 -8
  54. package/dist/admin/index.js +3 -6
  55. package/dist/admin/ops.d.ts +36 -22
  56. package/dist/admin/page-data.d.ts +14 -6
  57. package/dist/admin/page-data.js +1 -1
  58. package/dist/admin/session-flow.d.ts +43 -0
  59. package/dist/admin/session-flow.js +94 -0
  60. package/dist/admin/shell/AdminApp.d.ts +8 -3
  61. package/dist/admin/shell/AdminApp.js +37 -58
  62. package/dist/admin/shell/ContentHealthPage.js +1 -3
  63. package/dist/admin/shell/GlobalFields.d.ts +0 -3
  64. package/dist/admin/shell/GlobalFields.js +1 -1
  65. package/dist/admin/shell/IconRail.js +5 -4
  66. package/dist/admin/shell/MediaScreen.js +9 -6
  67. package/dist/admin/shell/MembersPage.d.ts +1 -2
  68. package/dist/admin/shell/MembersPage.js +19 -90
  69. package/dist/admin/shell/SettingsView.d.ts +1 -2
  70. package/dist/admin/shell/SettingsView.js +2 -2
  71. package/dist/admin/shell/SignedOutView.d.ts +5 -0
  72. package/dist/admin/shell/SignedOutView.js +9 -0
  73. package/dist/admin/shell/console-links.d.ts +5 -0
  74. package/dist/admin/shell/console-links.js +11 -0
  75. package/dist/admin/shell/members-list.d.ts +1 -14
  76. package/dist/admin/shell/members-list.js +2 -23
  77. package/dist/admin/shell/pages-tree.d.ts +0 -2
  78. package/dist/admin/shell/pages-tree.js +1 -1
  79. package/dist/admin/shell/presence-socket.d.ts +10 -0
  80. package/dist/admin/shell/presence-socket.js +191 -0
  81. package/dist/admin/shell/presence.d.ts +6 -18
  82. package/dist/admin/shell/presence.js +4 -11
  83. package/dist/admin/shell/session.d.ts +20 -12
  84. package/dist/admin/shell/session.js +113 -47
  85. package/dist/admin/ui/LocaleSwitcher.d.ts +0 -4
  86. package/dist/admin/ui/LocaleSwitcher.js +2 -2
  87. package/dist/admin/ui/primitives.d.ts +4 -14
  88. package/dist/admin/ui/primitives.js +5 -8
  89. package/dist/admin/ui/theme.js +13 -2
  90. package/dist/admin/validate.js +10 -1
  91. package/dist/api-client.d.ts +26 -0
  92. package/dist/api-client.js +107 -0
  93. package/dist/assemble.d.ts +4 -41
  94. package/dist/assemble.js +4 -302
  95. package/dist/asset-store.d.ts +28 -30
  96. package/dist/asset-store.js +26 -121
  97. package/dist/astro/components/Head.astro +4 -1
  98. package/dist/astro/components/Image.astro +12 -17
  99. package/dist/astro/components/Video.astro +5 -2
  100. package/dist/astro/head.d.ts +3 -0
  101. package/dist/astro/head.js +24 -0
  102. package/dist/astro/index.js +54 -25
  103. package/dist/astro/locale-paths.d.ts +5 -2
  104. package/dist/astro/locale-paths.js +10 -2
  105. package/dist/astro/routes/admin.js +9 -7
  106. package/dist/astro/runtime.d.ts +2 -1
  107. package/dist/astro/runtime.js +4 -3
  108. package/dist/astro/server.d.ts +7 -11
  109. package/dist/astro/server.js +22 -28
  110. package/dist/astro/shell.d.ts +2 -2
  111. package/dist/astro/static-media-node.d.ts +4 -0
  112. package/dist/astro/static-media-node.js +25 -0
  113. package/dist/astro/static-media.d.ts +17 -0
  114. package/dist/astro/static-media.js +55 -0
  115. package/dist/astro/static.d.ts +5 -10
  116. package/dist/astro/static.js +13 -29
  117. package/dist/canvas-marker.d.ts +14 -0
  118. package/dist/canvas-marker.js +36 -0
  119. package/dist/cli/api.d.ts +14 -0
  120. package/dist/cli/api.js +48 -0
  121. package/dist/cli/app-env.d.ts +2 -0
  122. package/dist/cli/app-env.js +20 -0
  123. package/dist/cli/export.d.ts +16 -2
  124. package/dist/cli/export.js +52 -35
  125. package/dist/cli/import.d.ts +7 -11
  126. package/dist/cli/import.js +50 -54
  127. package/dist/cli/load-config.d.ts +0 -1
  128. package/dist/cli/load-config.js +1 -1
  129. package/dist/cli/main.d.ts +4 -12
  130. package/dist/cli/main.js +26 -72
  131. package/dist/cli/push.d.ts +6 -0
  132. package/dist/cli/push.js +31 -0
  133. package/dist/collections.d.ts +0 -1
  134. package/dist/collections.js +1 -1
  135. package/dist/config.d.ts +20 -5
  136. package/dist/config.js +18 -1
  137. package/dist/content/content-api.d.ts +22 -23
  138. package/dist/content/content-api.js +46 -56
  139. package/dist/content/mime.d.ts +1 -0
  140. package/dist/content/mime.js +6 -0
  141. package/dist/deploy-store.d.ts +19 -0
  142. package/dist/deploy-store.js +1 -0
  143. package/dist/entry-store.d.ts +3 -89
  144. package/dist/entry-store.js +2 -550
  145. package/dist/env.d.ts +27 -20
  146. package/dist/env.js +38 -50
  147. package/dist/fields.d.ts +11 -3
  148. package/dist/fields.js +33 -2
  149. package/dist/global-id.d.ts +2 -0
  150. package/dist/global-id.js +4 -2
  151. package/dist/image/SmoodlyImage.d.ts +9 -5
  152. package/dist/image/SmoodlyImage.js +29 -15
  153. package/dist/image/SmoodlyVideo.d.ts +1 -1
  154. package/dist/image/SmoodlyVideo.js +3 -2
  155. package/dist/image/index.d.ts +2 -3
  156. package/dist/image/index.js +0 -2
  157. package/dist/image/markup.d.ts +53 -0
  158. package/dist/image/markup.js +111 -0
  159. package/dist/index.d.ts +12 -22
  160. package/dist/index.js +7 -20
  161. package/dist/media-cut.d.ts +44 -0
  162. package/dist/media-cut.js +70 -0
  163. package/dist/media.d.ts +57 -3
  164. package/dist/media.js +125 -15
  165. package/dist/member-store.d.ts +3 -22
  166. package/dist/member-store.js +1 -53
  167. package/dist/next/admin-route.d.ts +1 -1
  168. package/dist/next/admin-route.js +5 -3
  169. package/dist/next/meta.js +2 -3
  170. package/dist/next/page-renderer.d.ts +2 -2
  171. package/dist/next/page-renderer.js +22 -11
  172. package/dist/next/queries.d.ts +2 -2
  173. package/dist/path-index.d.ts +0 -24
  174. package/dist/path-index.js +0 -98
  175. package/dist/push.d.ts +30 -0
  176. package/dist/push.js +52 -0
  177. package/dist/read-counter.d.ts +2 -0
  178. package/dist/read-counter.js +6 -0
  179. package/dist/reads.d.ts +7 -7
  180. package/dist/reads.js +3 -0
  181. package/dist/registry-json.d.ts +11 -0
  182. package/dist/registry-json.js +81 -0
  183. package/dist/render.d.ts +0 -1
  184. package/dist/render.js +1 -1
  185. package/dist/resolve.d.ts +12 -6
  186. package/dist/resolve.js +11 -8
  187. package/dist/revalidate.d.ts +8 -1
  188. package/dist/revalidate.js +8 -1
  189. package/dist/richtext/index.d.ts +1 -1
  190. package/dist/section-wrapper.d.ts +1 -1
  191. package/dist/section-wrapper.js +0 -9
  192. package/dist/signed-message.d.ts +26 -0
  193. package/dist/signed-message.js +103 -0
  194. package/dist/site.d.ts +59 -18
  195. package/dist/site.js +21 -252
  196. package/dist/space-transfer.d.ts +5 -2
  197. package/dist/space-transfer.js +17 -5
  198. package/dist/store.d.ts +2 -80
  199. package/dist/store.js +3 -489
  200. package/dist/toolset.d.ts +2 -1
  201. package/dist/toolset.js +1 -1
  202. package/dist/version.d.ts +1 -1
  203. package/dist/version.js +1 -1
  204. package/package.json +4 -15
  205. package/dist/admin/ops-impl.d.ts +0 -51
  206. package/dist/admin/ops-impl.js +0 -1403
  207. package/dist/admin/shell/InviteModal.d.ts +0 -6
  208. package/dist/admin/shell/InviteModal.js +0 -28
  209. package/dist/admin/shell/LoginView.d.ts +0 -5
  210. package/dist/admin/shell/LoginView.js +0 -80
  211. package/dist/admin/shell/SchemaNotice.d.ts +0 -4
  212. package/dist/admin/shell/SchemaNotice.js +0 -4
  213. package/dist/admin/shell/SetPasswordView.d.ts +0 -7
  214. package/dist/admin/shell/SetPasswordView.js +0 -41
  215. package/dist/admin/shell/presence-realtime.d.ts +0 -3
  216. package/dist/admin/shell/presence-realtime.js +0 -32
  217. package/dist/admin/supabase-auth.d.ts +0 -7
  218. package/dist/admin/supabase-auth.js +0 -30
  219. package/dist/astro/deploy-deps.d.ts +0 -10
  220. package/dist/astro/deploy-deps.js +0 -8
  221. package/dist/astro/image.d.ts +0 -27
  222. package/dist/astro/image.js +0 -29
  223. package/dist/bind.d.ts +0 -19
  224. package/dist/bind.js +0 -34
  225. package/dist/cli/add-admin.d.ts +0 -11
  226. package/dist/cli/add-admin.js +0 -25
  227. package/dist/cli/load-collections.d.ts +0 -2
  228. package/dist/cli/load-collections.js +0 -27
  229. package/dist/cli/migrate.d.ts +0 -43
  230. package/dist/cli/migrate.js +0 -138
  231. package/dist/cli/rebuild.d.ts +0 -23
  232. package/dist/cli/rebuild.js +0 -22
  233. package/dist/cli/space-io.d.ts +0 -41
  234. package/dist/cli/space-io.js +0 -84
  235. package/dist/client.d.ts +0 -11
  236. package/dist/client.js +0 -54
  237. package/dist/current-space.d.ts +0 -4
  238. package/dist/current-space.js +0 -9
  239. package/dist/document-stamp.d.ts +0 -7
  240. package/dist/document-stamp.js +0 -48
  241. package/dist/image/loaders.d.ts +0 -15
  242. package/dist/image/loaders.js +0 -5
  243. package/dist/image/next-config.d.ts +0 -18
  244. package/dist/image/next-config.js +0 -32
  245. package/dist/image/remote-pattern.d.ts +0 -1
  246. package/dist/image/remote-pattern.js +0 -5
  247. package/dist/migrations.d.ts +0 -37
  248. package/dist/migrations.js +0 -73
  249. package/dist/project.d.ts +0 -88
  250. package/dist/project.js +0 -231
  251. package/dist/published.d.ts +0 -142
  252. package/dist/published.js +0 -164
  253. package/dist/ref-index.d.ts +0 -19
  254. package/dist/ref-index.js +0 -0
  255. package/dist/schema-read.d.ts +0 -23
  256. package/dist/schema-read.js +0 -50
  257. package/dist/schema-version.d.ts +0 -19
  258. package/dist/schema-version.js +0 -34
  259. package/dist/sql-space.d.ts +0 -44
  260. package/dist/sql-space.js +0 -289
  261. package/dist/sql.d.ts +0 -15
  262. package/dist/sql.js +0 -556
  263. package/dist/supabase-asset-store.d.ts +0 -21
  264. package/dist/supabase-asset-store.js +0 -170
  265. package/dist/supabase-entry-store.d.ts +0 -92
  266. package/dist/supabase-entry-store.js +0 -824
  267. package/dist/supabase-member-store.d.ts +0 -21
  268. package/dist/supabase-member-store.js +0 -96
  269. package/dist/supabase-path-index.d.ts +0 -30
  270. package/dist/supabase-path-index.js +0 -138
  271. package/dist/supabase-published-store.d.ts +0 -56
  272. package/dist/supabase-published-store.js +0 -314
  273. package/dist/supabase-store.d.ts +0 -103
  274. package/dist/supabase-store.js +0 -678
  275. package/migrations/20260926120000_smoodly_v1.sql +0 -535
  276. package/migrations/20260926130000_smoodly_v2.sql +0 -26
  277. package/migrations/20260926140000_smoodly_v3.sql +0 -38
  278. package/migrations/20260926150000_smoodly_v4.sql +0 -41
  279. package/migrations/20260929120000_smoodly_v5.sql +0 -56
  280. package/migrations/20260929135523_smoodly_v6.sql +0 -200
  281. package/migrations/20260930040956_smoodly_v7.sql +0 -84
  282. package/migrations/20261007120000_smoodly_v8.sql +0 -154
  283. package/migrations/20261007200000_smoodly_v9.sql +0 -30
  284. package/migrations/20261008120000_smoodly_v10.sql +0 -47
  285. package/migrations/20261009120000_smoodly_v11.sql +0 -49
@@ -1,678 +0,0 @@
1
- // The Supabase adapter for the PageStore contract, over pages +
2
- // page_locales + page_versions + paths. Server-side only (the service
3
- // role in self-host, a write key in the cloud); writes flow through
4
- // Smoodly's API by design (DESIGN.md §1). No FK links the locale rows'
5
- // version pointers to page_versions (they'd be circular), so tree reads
6
- // are two indexed lookups. Every read embeds the locale rows
7
- // (`*, page_locales(*)`) so a record arrives whole. Path rows are
8
- // rewritten through ONE RPC per write, so a collision rolls back.
9
- import { assertPublishable, assertSupportedLocale, localeRow, pageErrors, pageRefEdges, pathOptions, refSections, storeAnchors, } from "./store.js";
10
- import { SupabasePathIndex } from "./supabase-path-index.js";
11
- import { secretKeyRefusedMessage } from "./schema-version.js";
12
- import { readSchemaVersion, SecretKeyCheck } from "./schema-read.js";
13
- import { groupRedirects } from "./store.js";
14
- import { assertFresh, draftToken, StaleDraftError } from "./draft-token.js";
15
- import { assertDepth, assertSegment, isHomeNode, pagePathRows, rewriteTargets } from "./paths.js";
16
- const SELECT = "*, page_locales(*, draft:page_versions!page_locales_draft_version_fkey(saved_at))";
17
- const UNIQUE_VIOLATION = "23505";
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;
19
- function toRecord(row) {
20
- const locales = {};
21
- for (const l of row.page_locales ?? []) {
22
- locales[l.locale] = {
23
- slug: l.slug,
24
- title: l.title,
25
- status: l.status,
26
- draftVersionId: l.draft_version_id,
27
- draftSavedAt: l.draft?.saved_at ?? null,
28
- publishedVersionId: l.published_version_id,
29
- updatedAt: Date.parse(l.updated_at),
30
- updatedBy: l.updated_by,
31
- };
32
- }
33
- return {
34
- id: row.id,
35
- parentId: row.parent_id,
36
- template: row.template,
37
- fixed: row.fixed ?? null,
38
- sort: row.sort === null ? null : Number(row.sort),
39
- locales,
40
- };
41
- }
42
- function toVersion(row) {
43
- return { id: row.id, pageId: row.page_id, locale: row.locale, tree: row.tree, savedAt: Date.parse(row.saved_at) };
44
- }
45
- export class SupabasePageStore {
46
- constructor(db, options = {}) {
47
- this.db = db;
48
- this.options = options;
49
- /** Once per store; a refused key fails lookups for 30 s at a time (schema-read.ts). */
50
- this.keyCheck = new SecretKeyCheck(() => readSchemaVersion(this.db));
51
- this.options = { ...options, sections: refSections(options) };
52
- this.opts = pathOptions(options);
53
- // Alone, the store's own index reads the addresses it was given (spec 2026-09-29 anchored paths §2).
54
- this.paths = options.paths ?? new SupabasePathIndex(db, storeAnchors(options));
55
- }
56
- fail(message) {
57
- throw new Error(`smoodly: ${message}`);
58
- }
59
- isHome(page) {
60
- return isHomeNode(page, this.opts);
61
- }
62
- // ── rows ──
63
- async row(id) {
64
- if (!UUID_RE.test(id))
65
- return null;
66
- const res = await this.db.from("pages").select(SELECT).eq("id", id).maybeSingle();
67
- if (res.error)
68
- this.fail(res.error.message);
69
- return res.data ? toRecord(res.data) : null;
70
- }
71
- async must(id) {
72
- const page = await this.row(id);
73
- if (!page)
74
- throw pageErrors.notFound(id);
75
- return page;
76
- }
77
- /** A one-row existence check — the shape `deletePage` needs too. */
78
- async hasChildren(id) {
79
- const res = await this.db.from("pages").select("id").eq("parent_id", id).limit(1);
80
- if (res.error)
81
- this.fail(res.error.message);
82
- return res.data.length > 0;
83
- }
84
- async siblings(parentId, except) {
85
- let query = this.db.from("pages").select(SELECT);
86
- query = parentId === null ? query.is("parent_id", null) : query.eq("parent_id", parentId);
87
- const res = await query
88
- .order("sort", { ascending: true, nullsFirst: false })
89
- .order("created_at", { ascending: true })
90
- .order("id", { ascending: true });
91
- if (res.error)
92
- this.fail(res.error.message);
93
- return res.data.map(toRecord).filter((p) => p.id !== except);
94
- }
95
- /** Code-owned roots are left out: their path is their address, and the
96
- * path index guards it (spec 2026-09-29 nested paths §3). */
97
- async assertSlugFree(parentId, locale, slug, except) {
98
- if ((await this.siblings(parentId, except)).some((s) => !s.fixed && s.locales[locale]?.slug === slug))
99
- throw pageErrors.slugTaken(slug);
100
- }
101
- /** The node's ancestors (root first, node last), by walking parent_id. */
102
- async chain(page) {
103
- const chain = [page];
104
- let current = page;
105
- while (current.parentId !== null) {
106
- const parent = await this.row(current.parentId);
107
- if (!parent)
108
- this.fail(`broken parent chain at "${page.id}".`);
109
- chain.unshift(parent);
110
- current = parent;
111
- }
112
- return chain;
113
- }
114
- /** The node and every descendant, breadth-first. */
115
- async subtree(page) {
116
- const out = [page];
117
- let frontier = [page.id];
118
- while (frontier.length > 0) {
119
- const res = await this.db.from("pages").select(SELECT).in("parent_id", frontier);
120
- if (res.error)
121
- this.fail(res.error.message);
122
- const rows = res.data.map(toRecord);
123
- out.push(...rows);
124
- frontier = rows.map((r) => r.id);
125
- }
126
- return out;
127
- }
128
- /** Rewrite path rows for a subtree: ancestors are loaded once so the
129
- * chain lookup is a map, then ONE RPC replaces every target's rows. */
130
- async writePaths(top, redirect = true) {
131
- const ancestors = await this.chain(top);
132
- const nodes = await this.subtree(top);
133
- const byId = new Map([...ancestors, ...nodes].map((n) => [n.id, n]));
134
- await this.paths.rewrite(rewriteTargets("page", nodes, redirect), pagePathRows(nodes, (id) => byId.get(id), this.opts));
135
- }
136
- /** `touch` defaults to true (the normal edit path bumps updated_at).
137
- * A rollback restoring prior column values passes `{ touch: false }`,
138
- * which omits updated_at entirely — undoing a write is not an edit. */
139
- async updateNode(id, patch, options = {}) {
140
- const payload = (options.touch ?? true) ? { ...patch, updated_at: new Date().toISOString() } : { ...patch };
141
- const res = await this.db.from("pages").update(payload).eq("id", id).select(SELECT).single();
142
- if (res.error)
143
- this.fail(res.error.message);
144
- return toRecord(res.data);
145
- }
146
- /** A touch writes the audit pair — `updated_at` and `updated_by` (spec 2026-09-13 history §2). */
147
- async updateLocale(id, locale, patch, options = {}) {
148
- const payload = (options.touch ?? true)
149
- ? { ...patch, updated_at: new Date().toISOString(), updated_by: options.by ?? null }
150
- : { ...patch };
151
- const res = await this.db.from("page_locales").update(payload).eq("page_id", id).eq("locale", locale).select("page_id").single();
152
- if (res.error)
153
- this.fail(res.error.message);
154
- return this.must(id);
155
- }
156
- /** The row's stale error, built from a fresh read: what a reload would get, and who wrote last. */
157
- async stale(id, locale) {
158
- const row = localeRow(await this.must(id), locale);
159
- return new StaleDraftError(draftToken(row), row.updatedBy, row.updatedAt);
160
- }
161
- /** `updateLocale` with the pointer as a filter (spec 2026-09-26 concurrency §3): the row is
162
- * written only while `draft_version_id` still equals `guard` (null = still no pointer).
163
- * `undefined` guard = unconditional. Resolves false when no row matched. */
164
- async guardedUpdateLocale(id, locale, patch, options) {
165
- const payload = { ...patch, updated_at: new Date().toISOString(), updated_by: options.by ?? null };
166
- let query = this.db.from("page_locales").update(payload).eq("page_id", id).eq("locale", locale);
167
- if (options.guard !== undefined)
168
- query = options.guard === null ? query.is("draft_version_id", null) : query.eq("draft_version_id", options.guard);
169
- const res = await query.select("page_id").maybeSingle();
170
- if (res.error)
171
- this.fail(res.error.message);
172
- return res.data !== null;
173
- }
174
- async insertLocale(id, locale, values) {
175
- const res = await this.db.from("page_locales").insert({ page_id: id, locale, ...values });
176
- if (res.error) {
177
- if (res.error.code === UNIQUE_VIOLATION)
178
- throw pageErrors.alreadyInLocale(id, locale);
179
- this.fail(res.error.message);
180
- }
181
- }
182
- /** The locale's row and versions — the removeLocale and rollback shape.
183
- * The row goes first: it is what makes the page exist in that locale,
184
- * so a failure between the two statements leaves no locale row
185
- * pointing at an already-deleted version. */
186
- async deleteLocale(id, locale) {
187
- const row = await this.db.from("page_locales").delete().eq("page_id", id).eq("locale", locale);
188
- if (row.error)
189
- this.fail(row.error.message);
190
- const versions = await this.db.from("page_versions").delete().eq("page_id", id).eq("locale", locale);
191
- if (versions.error)
192
- this.fail(versions.error.message);
193
- }
194
- async insertVersion(pageId, locale, tree) {
195
- const res = await this.db.from("page_versions").insert({ page_id: pageId, locale, tree }).select("*").single();
196
- if (res.error)
197
- this.fail(res.error.message);
198
- return res.data;
199
- }
200
- /** The draft rule (spec 2026-09-13 history §1), see MemoryPageStore.writeDraftVersion —
201
- * and the guard (spec 2026-09-26 concurrency §3): the caller ran assertFresh on the row it
202
- * holds, so `row` IS the expected state; the write itself filters on it, closing the
203
- * read-then-write window. `guarded` false = a script, no filters. */
204
- async writeDraftVersion(page, locale, tree, by, guarded) {
205
- const row = localeRow(page, locale);
206
- if (row.draftVersionId && row.draftVersionId !== row.publishedVersionId) {
207
- let query = this.db.from("page_versions").update({ tree, saved_at: new Date().toISOString() }).eq("id", row.draftVersionId);
208
- if (guarded)
209
- query = query.eq("saved_at", row.draftSavedAt ?? "");
210
- const res = await query.select("*").maybeSingle();
211
- if (res.error)
212
- this.fail(res.error.message);
213
- if (res.data === null)
214
- throw await this.stale(page.id, locale);
215
- const record = await this.updateLocale(page.id, locale, {}, { by });
216
- return { version: res.data, record };
217
- }
218
- const version = await this.insertVersion(page.id, locale, tree);
219
- const matched = await this.guardedUpdateLocale(page.id, locale, { draft_version_id: version.id }, { by, guard: guarded ? row.draftVersionId : undefined });
220
- if (!matched) {
221
- // The other writer took the pointer first: this row must never show in history.
222
- const gone = await this.db.from("page_versions").delete().eq("id", version.id);
223
- if (gone.error)
224
- this.fail(gone.error.message);
225
- throw await this.stale(page.id, locale);
226
- }
227
- return { version, record: await this.must(page.id) };
228
- }
229
- async versionTree(versionId) {
230
- if (!versionId)
231
- return null;
232
- const res = await this.db.from("page_versions").select("tree").eq("id", versionId).maybeSingle();
233
- if (res.error)
234
- this.fail(res.error.message);
235
- return res.data?.tree ?? null;
236
- }
237
- /** Every locale's draft and published trees in ONE query. */
238
- async liveTrees(page) {
239
- const ids = new Set();
240
- for (const row of Object.values(page.locales)) {
241
- if (row.draftVersionId)
242
- ids.add(row.draftVersionId);
243
- if (row.publishedVersionId)
244
- ids.add(row.publishedVersionId);
245
- }
246
- if (ids.size === 0)
247
- return [];
248
- const res = await this.db.from("page_versions").select("tree").in("id", [...ids]);
249
- if (res.error)
250
- this.fail(res.error.message);
251
- return res.data.map((r) => r.tree);
252
- }
253
- async writeRefs(page) {
254
- if (!this.options.sections)
255
- return;
256
- const edges = pageRefEdges(await this.liveTrees(page), this.options.sections, this.options.elements);
257
- const deleted = await this.db.from("refs").delete().eq("source_kind", "page").eq("source_id", page.id);
258
- if (deleted.error)
259
- this.fail(deleted.error.message);
260
- const rows = new Map();
261
- for (const edge of edges) {
262
- rows.set(`${edge.field} ${edge.targetId}`, {
263
- source_kind: "page", source_id: page.id, source_field: edge.field,
264
- target_collection: edge.targetCollection, target_id: edge.targetId,
265
- });
266
- }
267
- if (rows.size > 0) {
268
- const inserted = await this.db.from("refs").insert([...rows.values()]);
269
- if (inserted.error)
270
- this.fail(inserted.error.message);
271
- }
272
- }
273
- async reorder(ids) {
274
- if (ids.length === 0)
275
- return;
276
- const res = await this.db.rpc("smoodly_reorder_pages", { p_ids: ids });
277
- if (res.error)
278
- this.fail(res.error.message);
279
- }
280
- /** page_locales and page_versions cascade from pages; refs do not. */
281
- async hardDelete(id) {
282
- const refs = await this.db.from("refs").delete().eq("source_kind", "page").eq("source_id", id);
283
- if (refs.error)
284
- this.fail(refs.error.message);
285
- const page = await this.db.from("pages").delete().eq("id", id);
286
- if (page.error)
287
- this.fail(page.error.message);
288
- }
289
- // ── contract ──
290
- async createPage(input, by) {
291
- assertSupportedLocale(input.locale, this.opts.locales);
292
- assertSegment(input.slug, "page slug");
293
- const parentId = input.parentId ?? null;
294
- if (input.fixed !== undefined) {
295
- if (parentId !== null)
296
- throw pageErrors.fixedAtRoot();
297
- const owned = await this.db.from("pages").select("id").eq("fixed", input.fixed).limit(1);
298
- if (owned.error)
299
- this.fail(owned.error.message);
300
- if (owned.data.length > 0)
301
- throw pageErrors.fixedTaken(input.fixed);
302
- }
303
- if (parentId !== null) {
304
- const parent = await this.must(parentId);
305
- if (this.isHome(parent))
306
- throw pageErrors.homeChildren();
307
- if (!parent.locales[input.locale])
308
- throw pageErrors.parentNotInLocale(input.locale);
309
- assertDepth((await this.chain(parent)).length + 1, this.opts.depth, "the page");
310
- }
311
- const siblings = await this.siblings(parentId);
312
- if (input.fixed === undefined && siblings.some((s) => !s.fixed && s.locales[input.locale]?.slug === input.slug)) {
313
- throw pageErrors.slugTaken(input.slug);
314
- }
315
- const inserted = await this.db
316
- .from("pages")
317
- .insert({ parent_id: parentId, template: input.template, sort: siblings.length + 1, fixed: input.fixed ?? null })
318
- .select("id")
319
- .single();
320
- if (inserted.error) {
321
- // A racing materializer inserted the owner first: the unique index is the backstop.
322
- if (inserted.error.code === UNIQUE_VIOLATION && input.fixed !== undefined)
323
- throw pageErrors.fixedTaken(input.fixed);
324
- this.fail(inserted.error.message);
325
- }
326
- const id = inserted.data.id;
327
- try {
328
- const version = input.template === null ? null : await this.insertVersion(id, input.locale, { zones: {} });
329
- await this.insertLocale(id, input.locale, {
330
- slug: input.slug, title: input.title ?? input.slug, draft_version_id: version?.id ?? null, updated_by: by ?? null,
331
- });
332
- const page = await this.must(id);
333
- await this.writePaths(page);
334
- return page;
335
- }
336
- catch (e) {
337
- await this.hardDelete(id);
338
- throw e;
339
- }
340
- }
341
- getPage(id) {
342
- return this.row(id);
343
- }
344
- async scanTrees() {
345
- const records = await this.listPages();
346
- const ids = new Set();
347
- for (const r of records)
348
- for (const row of Object.values(r.locales)) {
349
- if (row.draftVersionId)
350
- ids.add(row.draftVersionId);
351
- if (row.publishedVersionId)
352
- ids.add(row.publishedVersionId);
353
- }
354
- // In chunks: one in() over hundreds of ids would overflow the request URL.
355
- const trees = new Map();
356
- const all = [...ids];
357
- for (let i = 0; i < all.length; i += 100) {
358
- const res = await this.db.from("page_versions").select("id, tree").in("id", all.slice(i, i + 100));
359
- if (res.error)
360
- this.fail(res.error.message);
361
- for (const v of res.data)
362
- trees.set(v.id, v.tree);
363
- }
364
- const tree = (id) => (id ? trees.get(id) ?? null : null);
365
- return records.map((record) => ({
366
- record,
367
- draft: Object.fromEntries(Object.entries(record.locales).map(([l, row]) => [l, tree(row.draftVersionId)])),
368
- live: Object.fromEntries(Object.entries(record.locales).map(([l, row]) => [l, tree(row.publishedVersionId)])),
369
- }));
370
- }
371
- async listPages() {
372
- const res = await this.db
373
- .from("pages")
374
- .select(SELECT)
375
- .order("sort", { ascending: true, nullsFirst: false })
376
- .order("created_at", { ascending: true })
377
- .order("id", { ascending: true });
378
- if (res.error)
379
- this.fail(res.error.message);
380
- return res.data.map(toRecord);
381
- }
382
- async resolvePath(locale, path) {
383
- // In parallel: the check costs the first request no extra round trip.
384
- const [refused, hit] = await Promise.all([this.keyCheck.refused(), this.paths.resolve(locale, path)]);
385
- if (refused)
386
- throw new Error(secretKeyRefusedMessage);
387
- return hit;
388
- }
389
- async schemaVersion() {
390
- const read = await readSchemaVersion(this.db);
391
- if ("version" in read)
392
- return read.version;
393
- // The table exists and the row is hidden: the key is not a secret key.
394
- if (read.missing === "key")
395
- throw new Error(secretKeyRefusedMessage);
396
- return null;
397
- }
398
- pathsOf(id) {
399
- return UUID_RE.test(id) ? this.paths.pathsOf({ kind: "page", id }) : Promise.resolve({});
400
- }
401
- targetPaths(target) { return this.paths.pathsOf(target); }
402
- async redirectsOf(id) { return UUID_RE.test(id) ? groupRedirects(await this.paths.redirectsOf({ kind: "page", id })) : {}; }
403
- listLive(locale) { return this.paths.listLive(locale); }
404
- listRedirects(locale) { return this.paths.listRedirects(locale); }
405
- addRedirect(row) { return this.paths.addRedirect(row); }
406
- removeRedirect(locale, path) { return this.paths.removeRedirect(locale, path); }
407
- async addLocale(id, locale, options, by) {
408
- assertSupportedLocale(locale, this.opts.locales);
409
- const page = await this.must(id);
410
- if (page.locales[locale])
411
- throw pageErrors.alreadyInLocale(id, locale);
412
- const source = localeRow(page, options.from);
413
- const slug = options.slug ?? source.slug;
414
- if (options.slug !== undefined)
415
- assertSegment(slug, `page slug (${locale})`);
416
- if (page.parentId !== null && !(await this.must(page.parentId)).locales[locale])
417
- throw pageErrors.parentNotInLocale(locale);
418
- if (!page.fixed)
419
- await this.assertSlugFree(page.parentId, locale, slug, id);
420
- let added;
421
- try {
422
- // The SOURCE'S DRAFT is the copy (spec §3); a folder has no tree.
423
- const version = page.template === null
424
- ? null
425
- : await this.insertVersion(id, locale, (await this.versionTree(source.draftVersionId)) ?? { zones: {} });
426
- await this.insertLocale(id, locale, {
427
- slug, title: options.title ?? source.title, draft_version_id: version?.id ?? null, updated_by: by ?? null,
428
- });
429
- added = await this.must(id);
430
- await this.writePaths(added);
431
- }
432
- catch (e) {
433
- await this.deleteLocale(id, locale);
434
- throw e;
435
- }
436
- await this.writeRefs(added);
437
- return added;
438
- }
439
- async removeLocale(id, locale) {
440
- const page = await this.must(id);
441
- localeRow(page, locale);
442
- if (Object.keys(page.locales).length === 1)
443
- throw pageErrors.lastLocale(id);
444
- const blockers = (await this.subtree(page)).filter((n) => n.id !== id && n.locales[locale]);
445
- if (blockers.length > 0)
446
- throw pageErrors.localeInUse(locale, blockers.map((b) => b.locales[locale].title));
447
- // Fewer path rows can never collide, so the row goes first and a
448
- // network failure on the rewrite leaves a stale path row that
449
- // resolves to a page absent in that locale — a 404, never leaked
450
- // content — until the next write of this node fixes it.
451
- await this.deleteLocale(id, locale);
452
- const remaining = await this.must(id);
453
- await this.writePaths(remaining, false);
454
- await this.writeRefs(remaining);
455
- return remaining;
456
- }
457
- async rename(id, patch, by, options) {
458
- const page = await this.must(id);
459
- const row = localeRow(page, patch.locale);
460
- if (patch.slug !== undefined)
461
- assertSegment(patch.slug, `page slug (${patch.locale})`);
462
- // A rename that turns this node INTO the home node (root, default-locale
463
- // slug === homePageSlug) is subject to the same invariant as createPage:
464
- // the home page cannot have child pages.
465
- if (patch.locale === this.opts.locales.default &&
466
- patch.slug !== undefined &&
467
- page.parentId === null &&
468
- patch.slug === this.opts.homePageSlug &&
469
- (await this.hasChildren(id))) {
470
- throw pageErrors.homeChildren();
471
- }
472
- if (patch.slug !== undefined && !page.fixed)
473
- await this.assertSlugFree(page.parentId, patch.locale, patch.slug, id);
474
- const columns = {};
475
- if (patch.slug !== undefined)
476
- columns.slug = patch.slug;
477
- if (patch.title !== undefined)
478
- columns.title = patch.title;
479
- const renamed = await this.updateLocale(id, patch.locale, columns, { by });
480
- try {
481
- await this.writePaths(renamed, options?.redirect ?? true);
482
- }
483
- catch (e) {
484
- // The audit pair goes back too: a rejected rename is not an edit.
485
- await this.updateLocale(id, patch.locale, { slug: row.slug, title: row.title, updated_at: new Date(row.updatedAt).toISOString(), updated_by: row.updatedBy }, { touch: false });
486
- throw e;
487
- }
488
- return renamed;
489
- }
490
- async move(id, to, options) {
491
- const page = await this.must(id);
492
- if (page.fixed && to.parentId !== null)
493
- throw pageErrors.fixedAtRoot();
494
- const subtree = await this.subtree(page);
495
- if (to.parentId !== null) {
496
- if (to.parentId === id || subtree.some((n) => n.id === to.parentId))
497
- throw pageErrors.cycle();
498
- const parent = await this.must(to.parentId);
499
- if (this.isHome(parent))
500
- throw pageErrors.homeChildren();
501
- for (const locale of Object.keys(page.locales))
502
- if (!parent.locales[locale])
503
- throw pageErrors.moveLocales(locale);
504
- const byId = new Map(subtree.map((n) => [n.id, n]));
505
- // The subtree's own height: walk each node up to the moved node
506
- // itself (not to the tree root — its parentId points OUTSIDE this
507
- // subtree map, at its current, pre-move parent, so chainOf would
508
- // throw "broken parent chain" there instead of stopping).
509
- const heightOf = (n) => (n.id === page.id ? 1 : 1 + heightOf(byId.get(n.parentId)));
510
- const height = Math.max(...subtree.map(heightOf));
511
- assertDepth((await this.chain(parent)).length + height, this.opts.depth, "the page");
512
- }
513
- else if (page.locales[this.opts.locales.default]?.slug === this.opts.homePageSlug && (await this.hasChildren(id))) {
514
- // Moving a home-slugged node TO the root is the mirror case of the
515
- // rename guard above: it would make it the home node while it still
516
- // has children.
517
- throw pageErrors.homeChildren();
518
- }
519
- const siblings = await this.siblings(to.parentId, id);
520
- if (to.parentId !== page.parentId) {
521
- for (const [locale, row] of Object.entries(page.locales)) {
522
- if (siblings.some((s) => !s.fixed && s.locales[locale]?.slug === row.slug))
523
- throw pageErrors.slugTaken(row.slug);
524
- }
525
- }
526
- // The destination siblings' sorts as they stood BEFORE the reorder.
527
- // A rollback restores these exact values rather than re-running the
528
- // reorder RPC, which renumbers 1..N and would silently close gaps
529
- // left by earlier deletes and moves.
530
- const previousSorts = siblings.map((s) => ({ id: s.id, sort: s.sort }));
531
- const moved = await this.updateNode(id, { parent_id: to.parentId });
532
- const index = Math.min(Math.max(to.index ?? siblings.length, 0), siblings.length);
533
- const ordered = siblings.map((s) => s.id);
534
- ordered.splice(index, 0, id);
535
- try {
536
- await this.reorder(ordered);
537
- await this.writePaths(moved, options?.redirect ?? true);
538
- }
539
- catch (e) {
540
- await this.updateNode(id, { parent_id: page.parentId, sort: page.sort }, { touch: false });
541
- for (const s of previousSorts)
542
- await this.updateNode(s.id, { sort: s.sort }, { touch: false });
543
- throw e;
544
- }
545
- return { ...moved, sort: index + 1 };
546
- }
547
- async setTemplate(id, template, by) {
548
- const page = await this.must(id);
549
- if (page.template !== null)
550
- throw pageErrors.hasTemplate(id);
551
- for (const locale of Object.keys(page.locales)) {
552
- const version = await this.insertVersion(id, locale, { zones: {} });
553
- await this.updateLocale(id, locale, { draft_version_id: version.id }, { by });
554
- }
555
- return this.updateNode(id, { template });
556
- }
557
- async saveDraft(id, locale, tree, by, expected) {
558
- const page = await this.must(id);
559
- if (page.template === null)
560
- throw pageErrors.folder(id);
561
- assertFresh(localeRow(page, locale), expected);
562
- const { version, record } = await this.writeDraftVersion(page, locale, tree, by, expected !== undefined);
563
- await this.writeRefs(record);
564
- return toVersion(version);
565
- }
566
- async publish(id, locale, by, expected) {
567
- const page = await this.must(id);
568
- if (page.template === null)
569
- throw pageErrors.folder(id);
570
- const row = localeRow(page, locale);
571
- assertFresh(row, expected);
572
- if (!row.draftVersionId)
573
- throw pageErrors.folder(id);
574
- const { sections, entries } = this.options;
575
- const draft = (await this.versionTree(row.draftVersionId)) ?? { zones: {} };
576
- if (sections && entries)
577
- await assertPublishable(row.slug, draft, sections, entries, locale);
578
- const matched = await this.guardedUpdateLocale(id, locale, { published_version_id: row.draftVersionId, status: "published" }, { by, guard: expected !== undefined ? row.draftVersionId : undefined });
579
- if (!matched)
580
- throw await this.stale(id, locale);
581
- const record = await this.must(id);
582
- await this.writeRefs(record);
583
- return record;
584
- }
585
- async discardDraft(id, locale, by, expected) {
586
- const page = await this.must(id);
587
- const row = localeRow(page, locale);
588
- assertFresh(row, expected);
589
- if (!row.publishedVersionId)
590
- throw pageErrors.nothingPublished(id, locale);
591
- const matched = await this.guardedUpdateLocale(id, locale, { draft_version_id: row.publishedVersionId }, { by, guard: expected !== undefined ? row.draftVersionId : undefined });
592
- if (!matched)
593
- throw await this.stale(id, locale);
594
- if (row.draftVersionId && row.draftVersionId !== row.publishedVersionId) {
595
- // The pointer moved first, so a failure here leaves a stray row, never a dangling pointer.
596
- const gone = await this.db.from("page_versions").delete().eq("id", row.draftVersionId);
597
- if (gone.error)
598
- this.fail(gone.error.message);
599
- }
600
- const record = await this.must(id);
601
- await this.writeRefs(record);
602
- return record;
603
- }
604
- async unpublish(id, locale, by) {
605
- const page = await this.must(id);
606
- localeRow(page, locale); // the "does not exist in locale" guard
607
- const record = await this.updateLocale(id, locale, { status: "draft" }, { by });
608
- await this.writeRefs(record);
609
- return record;
610
- }
611
- async restore(id, locale, versionId, by, expected) {
612
- const page = await this.must(id);
613
- if (page.template === null)
614
- throw pageErrors.folder(id);
615
- const row = localeRow(page, locale);
616
- const version = await this.getVersion(versionId);
617
- if (!version || version.pageId !== id || version.locale !== locale)
618
- throw pageErrors.foreignVersion(id, locale, versionId);
619
- assertFresh(row, expected);
620
- if (versionId === row.publishedVersionId)
621
- return this.discardDraft(id, locale, by, expected);
622
- const { record } = await this.writeDraftVersion(page, locale, version.tree, by, expected !== undefined);
623
- await this.writeRefs(record);
624
- return record;
625
- }
626
- async getVersion(versionId) {
627
- if (!UUID_RE.test(versionId))
628
- return null;
629
- const res = await this.db.from("page_versions").select("*").eq("id", versionId).maybeSingle();
630
- if (res.error)
631
- this.fail(res.error.message);
632
- return res.data ? toVersion(res.data) : null;
633
- }
634
- async getDraftTree(id, locale) {
635
- const page = await this.row(id);
636
- return page ? this.versionTree(page.locales[locale]?.draftVersionId ?? null) : null;
637
- }
638
- async fieldsOf(versionIds) {
639
- const ids = versionIds.filter((id) => UUID_RE.test(id));
640
- if (ids.length === 0)
641
- return {};
642
- // PostgREST's JSON arrow selects the `fields` key alone — the rest of the tree never leaves the database.
643
- const res = await this.db.from("page_versions").select("id, fields:tree->fields").in("id", ids);
644
- if (res.error)
645
- this.fail(res.error.message);
646
- const out = {};
647
- for (const row of res.data)
648
- out[row.id] = row.fields ?? {};
649
- return out;
650
- }
651
- async getPublishedTree(id, locale) {
652
- const page = await this.row(id);
653
- return page ? this.versionTree(page.locales[locale]?.publishedVersionId ?? null) : null;
654
- }
655
- async listVersions(id, locale) {
656
- if (!UUID_RE.test(id))
657
- return [];
658
- const res = await this.db
659
- .from("page_versions")
660
- .select("*")
661
- .eq("page_id", id)
662
- .eq("locale", locale)
663
- .order("saved_at", { ascending: false })
664
- .order("id", { ascending: false });
665
- if (res.error)
666
- this.fail(res.error.message);
667
- return res.data.map(toVersion);
668
- }
669
- async deletePage(id) {
670
- const page = await this.row(id);
671
- if (!page)
672
- return;
673
- if (await this.hasChildren(id))
674
- throw pageErrors.hasChildren(id);
675
- await this.paths.remove([{ kind: "page", id }]);
676
- await this.hardDelete(id);
677
- }
678
- }