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
@@ -0,0 +1,535 @@
1
+ -- Smoodly core schema, version 1 (docs/superpowers/specs/2026-09-26-schema-migrations-design.md).
2
+ -- Shipped in the smoodly package; `npx smoodly migrate` copies it into supabase/migrations/.
3
+ -- Never edit a published file: the next change is the next file.
4
+
5
+ set client_min_messages = warning;
6
+
7
+ -- The space a request acts in: the key's claim in Smoodly Cloud, the
8
+ -- default space in self-host (service role, anon key). Policies, the
9
+ -- insert trigger and the reorder RPC all read this ONE function.
10
+ create or replace function "smoodly_current_space"() returns uuid
11
+ language sql stable as $$
12
+ select coalesce((auth.jwt() ->> 'space_id')::uuid, '00000000-0000-0000-0000-000000000000');
13
+ $$;
14
+
15
+ create table if not exists "pages" (
16
+ "id" uuid primary key default gen_random_uuid(),
17
+ "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
18
+ "parent_id" uuid references "pages" ("id") on delete restrict,
19
+ "template" text,
20
+ "sort" numeric,
21
+ "created_at" timestamptz not null default now(),
22
+ "updated_at" timestamptz not null default now()
23
+ );
24
+
25
+ -- The tree is the URL (spec 2026-09-05 §1): a node stores its parent and
26
+ -- its position; template null = folder. Everything a locale owns — the
27
+ -- node's own segment, title, publish state and version pointers — lives
28
+ -- in page_locales, one row per locale the page exists in (spec 2026-09-06
29
+ -- §2). There is no sibling-slug index: URL uniqueness per locale is the
30
+ -- primary key of "paths", which the stores write inside the same operation.
31
+ create index if not exists "pages_parent_idx"
32
+ on "pages" ("space_id", "parent_id", "sort");
33
+
34
+ create table if not exists "page_locales" (
35
+ "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
36
+ "page_id" uuid not null references "pages" ("id") on delete cascade,
37
+ "locale" text not null,
38
+ "slug" text not null,
39
+ "title" text not null,
40
+ "status" text not null default 'draft',
41
+ "draft_version_id" uuid,
42
+ "published_version_id" uuid,
43
+ "updated_at" timestamptz not null default now(),
44
+ "updated_by" text,
45
+ primary key ("page_id", "locale")
46
+ );
47
+
48
+ -- Sibling reorder for pages: same shape as smoodly_reorder for entries.
49
+ create or replace function "smoodly_reorder_pages"(p_ids uuid[]) returns void language sql as $$
50
+ update "pages" pg set "sort" = u.ord
51
+ from unnest(p_ids) with ordinality as u(id, ord)
52
+ where pg."id" = u.id
53
+ and pg."space_id" = "smoodly_current_space"();
54
+ $$;
55
+
56
+ -- A version belongs to ONE locale of one page: each locale has its own
57
+ -- draft, its own publish and its own history. One row per publish plus
58
+ -- the live draft (spec 2026-09-13 history §1): the unpublished draft row
59
+ -- is rewritten in place and saved_at is when its content last changed.
60
+ create table if not exists "page_versions" (
61
+ "id" uuid primary key default gen_random_uuid(),
62
+ "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
63
+ "page_id" uuid not null references "pages" ("id") on delete cascade,
64
+ "locale" text not null,
65
+ "tree" jsonb not null,
66
+ "saved_at" timestamptz not null default now()
67
+ );
68
+
69
+ create index if not exists "page_versions_page_locale_idx"
70
+ on "page_versions" ("page_id", "locale", "saved_at");
71
+
72
+ create table if not exists "entries" (
73
+ "id" uuid primary key default gen_random_uuid(),
74
+ "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
75
+ "collection" text not null,
76
+ "parent_id" uuid references "entries" ("id") on delete restrict,
77
+ "fields" jsonb not null default '{}'::jsonb,
78
+ "sort" numeric,
79
+ "created_at" timestamptz not null default now(),
80
+ "updated_at" timestamptz not null default now()
81
+ );
82
+
83
+ -- The node holds identity, tree position and the SHARED fields — every
84
+ -- field without .localized() (spec 2026-09-06 §7): a Person's photo is
85
+ -- the same in every language. Everything a locale owns — its segment,
86
+ -- publish state, the .localized() fields and the version pointers —
87
+ -- lives in entry_locales, one row per locale the entry exists in. There
88
+ -- is no sibling-slug index any more: URL uniqueness per locale is the
89
+ -- primary key of "paths", which the stores write inside the same
90
+ -- operation, and both adapters pre-check siblings per locale in code.
91
+ create index if not exists "entries_collection_sort_idx"
92
+ on "entries" ("space_id", "collection", "sort");
93
+
94
+ create index if not exists "entries_parent_idx"
95
+ on "entries" ("space_id", "collection", "parent_id");
96
+
97
+ create table if not exists "entry_locales" (
98
+ "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
99
+ "entry_id" uuid not null references "entries" ("id") on delete cascade,
100
+ "locale" text not null,
101
+ "slug" text,
102
+ "status" text not null default 'draft',
103
+ "fields" jsonb not null default '{}'::jsonb,
104
+ "draft_version_id" uuid,
105
+ "published_version_id" uuid,
106
+ "updated_at" timestamptz not null default now(),
107
+ "updated_by" text,
108
+ primary key ("entry_id", "locale")
109
+ );
110
+
111
+ create index if not exists "entry_locales_status_idx"
112
+ on "entry_locales" ("space_id", "locale", "status");
113
+
114
+ -- A version belongs to ONE locale of one entry and stores the MERGED
115
+ -- fields (shared + localized + slug) as they were at that save, so
116
+ -- restoring an old Finnish version restores the shared fields too.
117
+ -- Rows for every collection and every global item (spec 2026-09-14 §1).
118
+ -- One row per publish plus the live draft (spec 2026-09-13 history §1).
119
+ create table if not exists "entry_versions" (
120
+ "id" uuid primary key default gen_random_uuid(),
121
+ "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
122
+ "entry_id" uuid not null references "entries" ("id") on delete cascade,
123
+ "locale" text not null,
124
+ "fields" jsonb not null,
125
+ "saved_at" timestamptz not null default now()
126
+ );
127
+
128
+ create index if not exists "entry_versions_entry_locale_idx"
129
+ on "entry_versions" ("entry_id", "locale", "saved_at");
130
+
131
+ -- v1 reorder (DESIGN.md §3 Manual ordering): rewrite sort 1..N in ONE
132
+ -- statement. Deliberately does not touch updated_at — a drag must not
133
+ -- make the whole collection look freshly edited. Scoped to the current
134
+ -- space by the same function the policies use.
135
+ create or replace function "smoodly_reorder"(
136
+ p_collection text,
137
+ p_ids uuid[]
138
+ ) returns void language sql as $$
139
+ update "entries" e set "sort" = u.ord
140
+ from unnest(p_ids) with ordinality as u(id, ord)
141
+ where e."id" = u.id
142
+ and e."collection" = p_collection
143
+ and e."space_id" = "smoodly_current_space"();
144
+ $$;
145
+
146
+ create table if not exists "refs" (
147
+ "source_kind" text not null,
148
+ "source_id" uuid not null,
149
+ "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
150
+ "source_field" text not null,
151
+ "target_collection" text not null,
152
+ "target_id" uuid not null,
153
+ primary key ("source_kind", "source_id", "source_field", "target_id")
154
+ );
155
+
156
+ -- The URL index (spec 2026-09-05 §4): the refs counterpart for paths.
157
+ -- Derived — the stores rewrite a target's rows on every create, rename,
158
+ -- move and delete — so one primary-key hit resolves any URL in any locale
159
+ -- and no page or entry can share a URL with another in any locale.
160
+ -- Rows exist for drafts too (the editor needs them); the read policy
161
+ -- admits a row only when its target is published.
162
+ create table if not exists "paths" (
163
+ "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
164
+ "locale" text not null,
165
+ "path" text not null,
166
+ "kind" text not null check ("kind" in ('page', 'entry')),
167
+ "target_id" uuid not null,
168
+ primary key ("space_id", "locale", "path")
169
+ );
170
+
171
+ create index if not exists "paths_target_idx"
172
+ on "paths" ("space_id", "kind", "target_id");
173
+
174
+ -- Media (spec 2026-09-08): one row per upload, bytes in the "media"
175
+ -- bucket at "path". Immediate — never draft-gated, never versioned —
176
+ -- so there is no locale row and no version table. status 'pending' is
177
+ -- a row whose signed upload has not been confirmed; list never shows it.
178
+ create table if not exists "assets" (
179
+ "id" uuid primary key default gen_random_uuid(),
180
+ "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
181
+ "kind" text not null check ("kind" in ('image', 'video', 'file')),
182
+ "status" text not null default 'pending' check ("status" in ('pending', 'ready')),
183
+ "path" text not null,
184
+ "mime" text not null,
185
+ "size" bigint not null,
186
+ "width" integer,
187
+ "height" integer,
188
+ "duration" numeric,
189
+ "poster_id" uuid references "assets" ("id") on delete set null,
190
+ "filename" text not null,
191
+ "title" text,
192
+ "alt" text,
193
+ "caption" text,
194
+ "credit" text,
195
+ "created_by" text,
196
+ "created_at" timestamptz not null default now(),
197
+ "updated_at" timestamptz not null default now()
198
+ );
199
+
200
+ create index if not exists "assets_space_created_idx"
201
+ on "assets" ("space_id", "status", "created_at" desc);
202
+
203
+ -- Replace the targets' rows with the given rows in ONE call: a SQL
204
+ -- function runs in the caller's transaction, so a primary-key collision
205
+ -- rolls the whole rewrite back. Inserted rows get their space from the
206
+ -- a_set_space trigger (the claim, or the default space).
207
+ create or replace function "smoodly_rewrite_paths"(p_targets jsonb, p_rows jsonb) returns void language sql as $$
208
+ delete from "paths" p
209
+ using jsonb_to_recordset(p_targets) as t(kind text, id uuid)
210
+ where p."kind" = t.kind and p."target_id" = t.id
211
+ and p."space_id" = "smoodly_current_space"();
212
+ insert into "paths" ("locale", "path", "kind", "target_id")
213
+ select r.locale, r.path, r.kind, r.id
214
+ from jsonb_to_recordset(p_rows) as r(locale text, path text, kind text, id uuid);
215
+ $$;
216
+
217
+ -- RLS on every core table. The service role bypasses it (self-host
218
+ -- adapters untouched). The policy set that follows (space layer) admits
219
+ -- a key to its own space: everything for a write key, published rows for
220
+ -- a read key or the anon key (default space). Drafts stay unreachable
221
+ -- to anything but a write key. The generated per-collection views
222
+ -- (collectionSQL) run with security_invoker = true, so they are subject
223
+ -- to this same smoodly_read policy rather than their owner's privileges.
224
+ -- smoodly_meta: which version of this schema the database holds (spec
225
+ -- 2026-09-26 §1). One row per database, not per space; every core
226
+ -- migration file upserts the version row as its last statement, and the
227
+ -- package compares it with SCHEMA_VERSION (behind blocks the admin).
228
+ create table if not exists "smoodly_meta" (
229
+ "key" text primary key,
230
+ "value" text not null
231
+ );
232
+
233
+ alter table "pages" enable row level security;
234
+ alter table "page_locales" enable row level security;
235
+ alter table "page_versions" enable row level security;
236
+ alter table "entries" enable row level security;
237
+ alter table "entry_locales" enable row level security;
238
+ alter table "entry_versions" enable row level security;
239
+ alter table "refs" enable row level security;
240
+ alter table "paths" enable row level security;
241
+ alter table "assets" enable row level security;
242
+ alter table "smoodly_meta" enable row level security;
243
+
244
+ -- ── Cloud state (spec 2026-09-04 §1, names 2026-09-25): three ordinary
245
+ -- tables, shipped for everyone with ONE definition. Self-host fills them
246
+ -- by hand or leaves them empty; smoodly.io writes rows with the service
247
+ -- role. There is never cloud-only SQL on a content database.
248
+ -- members: who may open /admin for a space (spec 2026-09-25 §1–§2). The
249
+ -- email is stored so the Members page needs no Auth lookup — the auth
250
+ -- project is not the content project in the cloud.
251
+ create table if not exists "members" (
252
+ "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
253
+ "user_id" uuid not null,
254
+ "email" text,
255
+ "role" text not null default 'editor',
256
+ primary key ("space_id", "user_id")
257
+ );
258
+
259
+ create table if not exists "space_keys" (
260
+ "id" uuid primary key,
261
+ "space_id" uuid not null,
262
+ "kind" text not null check ("kind" in ('read', 'write')),
263
+ "revoked_at" timestamptz
264
+ );
265
+
266
+ -- spaces: the control plane's row for a space (spec 2026-09-25 §3). No
267
+ -- row = self-host: no cap, members managed in /admin. console_url set =
268
+ -- smoodly.io manages the members and /admin links there.
269
+ create table if not exists "spaces" (
270
+ "space_id" uuid primary key,
271
+ "row_cap" integer,
272
+ "console_url" text
273
+ );
274
+
275
+ alter table "members" enable row level security;
276
+ alter table "space_keys" enable row level security;
277
+ alter table "spaces" enable row level security;
278
+
279
+ -- ── Claim readers. The two lookups run as definer so they can read the
280
+ -- service-role-only tables from inside a policy; search_path is pinned.
281
+ create or replace function "smoodly_key_kind"() returns text
282
+ language sql stable as $$
283
+ select auth.jwt() ->> 'kind';
284
+ $$;
285
+
286
+ create or replace function "smoodly_space_key_revoked"() returns boolean
287
+ language sql stable security definer set search_path = public as $$
288
+ select coalesce(
289
+ (select k."revoked_at" is not null from "space_keys" k
290
+ where k."id" = (auth.jwt() ->> 'key_id')::uuid),
291
+ false);
292
+ $$;
293
+
294
+ create or replace function "smoodly_space_row_cap"() returns integer
295
+ language sql stable security definer set search_path = public as $$
296
+ select c."row_cap" from "spaces" c where c."space_id" = "smoodly_current_space"();
297
+ $$;
298
+
299
+ -- ── Triggers. A key's claim wins over whatever the insert said, so an
300
+ -- adapter never has to know its space and cannot write into another.
301
+ -- No claim (service role, anon) leaves the given or default value —
302
+ -- that is how smoodly.io writes cloud-state rows for a chosen space.
303
+ create or replace function "smoodly_set_space"() returns trigger
304
+ language plpgsql as $$
305
+ begin
306
+ if auth.jwt() ->> 'space_id' is not null then
307
+ new."space_id" := (auth.jwt() ->> 'space_id')::uuid;
308
+ end if;
309
+ return new;
310
+ end;
311
+ $$;
312
+
313
+ -- Per table, per space. No spaces row, or a null cap = no cap (self-host). Trigger
314
+ -- a_ (smoodly_set_space) runs first, so new.space_id already equals the
315
+ -- current space for a keyed insert; smoodly_space_row_cap() reads the
316
+ -- current space itself, so this never needs the row's space_id as an
317
+ -- argument (which would let one space's insert probe another's cap).
318
+ -- A service-role insert that names another space explicitly is a
319
+ -- different case: there is no JWT claim, so smoodly_current_space()
320
+ -- still resolves to the default space, and the cap read is the default
321
+ -- space's (none) — the service role is never capped by another space's row.
322
+ -- search_path is pinned empty, so every reference below is schema-qualified.
323
+ create or replace function "smoodly_enforce_row_cap"() returns trigger
324
+ language plpgsql set search_path = '' as $$
325
+ declare
326
+ cap integer;
327
+ n bigint;
328
+ begin
329
+ cap := public."smoodly_space_row_cap"();
330
+ if cap is null then
331
+ return new;
332
+ end if;
333
+ -- Advisory only (free-tier abuse guard), not a hard invariant: two
334
+ -- concurrent inserts can both observe n = cap - 1 and both pass.
335
+ execute format('select count(*) from %I.%I where "space_id" = $1', tg_table_schema, tg_table_name)
336
+ into n using new."space_id";
337
+ if n >= cap then
338
+ raise exception 'smoodly: row cap (%) reached for this space in %', cap, tg_table_name
339
+ using errcode = 'check_violation';
340
+ end if;
341
+ return new;
342
+ end;
343
+ $$;
344
+
345
+ create index if not exists "page_locales_space_idx" on "page_locales" ("space_id");
346
+
347
+ create index if not exists "page_versions_space_idx" on "page_versions" ("space_id");
348
+
349
+ create index if not exists "entry_locales_space_idx" on "entry_locales" ("space_id");
350
+
351
+ create index if not exists "entry_versions_space_idx" on "entry_versions" ("space_id");
352
+
353
+ create index if not exists "refs_space_idx" on "refs" ("space_id");
354
+
355
+ drop trigger if exists "smoodly_a_set_space" on "pages";
356
+ create trigger "smoodly_a_set_space" before insert on "pages"
357
+ for each row execute function "smoodly_set_space"();
358
+
359
+ drop trigger if exists "smoodly_a_set_space" on "page_locales";
360
+ create trigger "smoodly_a_set_space" before insert on "page_locales"
361
+ for each row execute function "smoodly_set_space"();
362
+
363
+ drop trigger if exists "smoodly_a_set_space" on "page_versions";
364
+ create trigger "smoodly_a_set_space" before insert on "page_versions"
365
+ for each row execute function "smoodly_set_space"();
366
+
367
+ drop trigger if exists "smoodly_a_set_space" on "entries";
368
+ create trigger "smoodly_a_set_space" before insert on "entries"
369
+ for each row execute function "smoodly_set_space"();
370
+
371
+ drop trigger if exists "smoodly_a_set_space" on "entry_locales";
372
+ create trigger "smoodly_a_set_space" before insert on "entry_locales"
373
+ for each row execute function "smoodly_set_space"();
374
+
375
+ drop trigger if exists "smoodly_a_set_space" on "entry_versions";
376
+ create trigger "smoodly_a_set_space" before insert on "entry_versions"
377
+ for each row execute function "smoodly_set_space"();
378
+
379
+ drop trigger if exists "smoodly_a_set_space" on "refs";
380
+ create trigger "smoodly_a_set_space" before insert on "refs"
381
+ for each row execute function "smoodly_set_space"();
382
+
383
+ drop trigger if exists "smoodly_a_set_space" on "paths";
384
+ create trigger "smoodly_a_set_space" before insert on "paths"
385
+ for each row execute function "smoodly_set_space"();
386
+
387
+ drop trigger if exists "smoodly_a_set_space" on "assets";
388
+ create trigger "smoodly_a_set_space" before insert on "assets"
389
+ for each row execute function "smoodly_set_space"();
390
+
391
+ drop trigger if exists "smoodly_a_set_space" on "members";
392
+ create trigger "smoodly_a_set_space" before insert on "members"
393
+ for each row execute function "smoodly_set_space"();
394
+
395
+ drop trigger if exists "smoodly_b_row_cap" on "pages";
396
+ create trigger "smoodly_b_row_cap" before insert on "pages"
397
+ for each row execute function "smoodly_enforce_row_cap"();
398
+
399
+ drop trigger if exists "smoodly_b_row_cap" on "page_locales";
400
+ create trigger "smoodly_b_row_cap" before insert on "page_locales"
401
+ for each row execute function "smoodly_enforce_row_cap"();
402
+
403
+ drop trigger if exists "smoodly_b_row_cap" on "page_versions";
404
+ create trigger "smoodly_b_row_cap" before insert on "page_versions"
405
+ for each row execute function "smoodly_enforce_row_cap"();
406
+
407
+ drop trigger if exists "smoodly_b_row_cap" on "entries";
408
+ create trigger "smoodly_b_row_cap" before insert on "entries"
409
+ for each row execute function "smoodly_enforce_row_cap"();
410
+
411
+ drop trigger if exists "smoodly_b_row_cap" on "entry_locales";
412
+ create trigger "smoodly_b_row_cap" before insert on "entry_locales"
413
+ for each row execute function "smoodly_enforce_row_cap"();
414
+
415
+ drop trigger if exists "smoodly_b_row_cap" on "entry_versions";
416
+ create trigger "smoodly_b_row_cap" before insert on "entry_versions"
417
+ for each row execute function "smoodly_enforce_row_cap"();
418
+
419
+ drop trigger if exists "smoodly_b_row_cap" on "refs";
420
+ create trigger "smoodly_b_row_cap" before insert on "refs"
421
+ for each row execute function "smoodly_enforce_row_cap"();
422
+
423
+ drop trigger if exists "smoodly_b_row_cap" on "paths";
424
+ create trigger "smoodly_b_row_cap" before insert on "paths"
425
+ for each row execute function "smoodly_enforce_row_cap"();
426
+
427
+ drop trigger if exists "smoodly_b_row_cap" on "assets";
428
+ create trigger "smoodly_b_row_cap" before insert on "assets"
429
+ for each row execute function "smoodly_enforce_row_cap"();
430
+
431
+ drop policy if exists "smoodly_write" on "pages";
432
+ create policy "smoodly_write" on "pages" for all
433
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
434
+ with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
435
+
436
+ drop policy if exists "smoodly_read" on "pages";
437
+ create policy "smoodly_read" on "pages" for select
438
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and exists (select 1 from "page_locales" l where l."page_id" = "pages"."id" and l."status" = 'published'));
439
+
440
+ drop policy if exists "smoodly_write" on "page_locales";
441
+ create policy "smoodly_write" on "page_locales" for all
442
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
443
+ with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
444
+
445
+ drop policy if exists "smoodly_read" on "page_locales";
446
+ create policy "smoodly_read" on "page_locales" for select
447
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and "status" = 'published');
448
+
449
+ drop policy if exists "smoodly_write" on "page_versions";
450
+ create policy "smoodly_write" on "page_versions" for all
451
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
452
+ with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
453
+
454
+ drop policy if exists "smoodly_read" on "page_versions";
455
+ create policy "smoodly_read" on "page_versions" for select
456
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and exists (select 1 from "page_locales" l where l."published_version_id" = "page_versions"."id"));
457
+
458
+ drop policy if exists "smoodly_write" on "entries";
459
+ create policy "smoodly_write" on "entries" for all
460
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
461
+ with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
462
+
463
+ drop policy if exists "smoodly_read" on "entries";
464
+ create policy "smoodly_read" on "entries" for select
465
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and exists (select 1 from "entry_locales" l where l."entry_id" = "entries"."id" and l."status" = 'published'));
466
+
467
+ drop policy if exists "smoodly_write" on "entry_locales";
468
+ create policy "smoodly_write" on "entry_locales" for all
469
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
470
+ with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
471
+
472
+ drop policy if exists "smoodly_read" on "entry_locales";
473
+ create policy "smoodly_read" on "entry_locales" for select
474
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and "status" = 'published');
475
+
476
+ drop policy if exists "smoodly_write" on "entry_versions";
477
+ create policy "smoodly_write" on "entry_versions" for all
478
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
479
+ with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
480
+
481
+ drop policy if exists "smoodly_read" on "entry_versions";
482
+ create policy "smoodly_read" on "entry_versions" for select
483
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and exists (select 1 from "entry_locales" l where l."published_version_id" = "entry_versions"."id"));
484
+
485
+ drop policy if exists "smoodly_write" on "refs";
486
+ create policy "smoodly_write" on "refs" for all
487
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
488
+ with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
489
+
490
+ drop policy if exists "smoodly_write" on "paths";
491
+ create policy "smoodly_write" on "paths" for all
492
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
493
+ with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
494
+
495
+ drop policy if exists "smoodly_read" on "paths";
496
+ create policy "smoodly_read" on "paths" for select
497
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (("kind" = 'page' and exists (select 1 from "page_locales" l where l."page_id" = "paths"."target_id" and l."locale" = "paths"."locale" and l."status" = 'published')) or ("kind" = 'entry' and exists (select 1 from "entry_locales" l where l."entry_id" = "paths"."target_id" and l."locale" = "paths"."locale" and l."status" = 'published'))));
498
+
499
+ drop policy if exists "smoodly_write" on "assets";
500
+ create policy "smoodly_write" on "assets" for all
501
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
502
+ with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
503
+
504
+ drop policy if exists "smoodly_read" on "assets";
505
+ create policy "smoodly_read" on "assets" for select
506
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and "status" = 'ready');
507
+
508
+ drop policy if exists "smoodly_write" on "members";
509
+ create policy "smoodly_write" on "members" for all
510
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
511
+ with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
512
+
513
+ drop policy if exists "smoodly_read" on "spaces";
514
+ create policy "smoodly_read" on "spaces" for select
515
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
516
+
517
+ drop policy if exists "smoodly_read" on "smoodly_meta";
518
+ create policy "smoodly_read" on "smoodly_meta" for select
519
+ using ((select "smoodly_key_kind"()) = 'write' and not (select "smoodly_space_key_revoked"()));
520
+
521
+ insert into storage.buckets ("id", "name", "public", "file_size_limit")
522
+ values ('media', 'media', true, 104857600)
523
+ on conflict ("id") do update set "public" = excluded."public", "file_size_limit" = excluded."file_size_limit";
524
+
525
+ drop policy if exists "smoodly_media_read" on storage.objects;
526
+ create policy "smoodly_media_read" on storage.objects for select
527
+ using ("bucket_id" = 'media');
528
+
529
+ drop policy if exists "smoodly_media_write" on storage.objects;
530
+ create policy "smoodly_media_write" on storage.objects for all
531
+ using ("bucket_id" = 'media' and (select public."smoodly_key_kind"()) = 'write' and not (select public."smoodly_space_key_revoked"()) and split_part("name", '/', 1) = (select public."smoodly_current_space"())::text)
532
+ with check ("bucket_id" = 'media' and (select public."smoodly_key_kind"()) = 'write' and not (select public."smoodly_space_key_revoked"()) and split_part("name", '/', 1) = (select public."smoodly_current_space"())::text);
533
+
534
+ insert into "smoodly_meta" ("key", "value") values ('schema_version', '1')
535
+ on conflict ("key") do update set "value" = excluded."value";
@@ -0,0 +1,26 @@
1
+ -- Smoodly core schema, version 2 (docs/superpowers/specs/2026-09-26-concurrency-guard-presence-design.md).
2
+ -- Shipped in the smoodly package; `npx smoodly migrate` copies it into supabase/migrations/.
3
+ -- Never edit a published file: the next change is the next file.
4
+ -- The delta over version 1: the draft pointers become foreign keys (the
5
+ -- token rides every locale row read) and the editing-presence channel
6
+ -- gets its policy on realtime.messages.
7
+
8
+ set client_min_messages = warning;
9
+
10
+ alter table "page_locales" drop constraint if exists "page_locales_draft_version_fkey";
11
+ alter table "page_locales" add constraint "page_locales_draft_version_fkey"
12
+ foreign key ("draft_version_id") references "page_versions" ("id") on delete set null;
13
+ alter table "entry_locales" drop constraint if exists "entry_locales_draft_version_fkey";
14
+ alter table "entry_locales" add constraint "entry_locales_draft_version_fkey"
15
+ foreign key ("draft_version_id") references "entry_versions" ("id") on delete set null;
16
+
17
+ drop policy if exists "smoodly_presence_read" on "realtime"."messages";
18
+ create policy "smoodly_presence_read" on "realtime"."messages" for select
19
+ using ((select realtime.topic()) = 'space:' || (select public."smoodly_current_space"())::text);
20
+
21
+ drop policy if exists "smoodly_presence_write" on "realtime"."messages";
22
+ create policy "smoodly_presence_write" on "realtime"."messages" for insert
23
+ with check ((select realtime.topic()) = 'space:' || (select public."smoodly_current_space"())::text);
24
+
25
+ insert into "smoodly_meta" ("key", "value") values ('schema_version', '2')
26
+ on conflict ("key") do update set "value" = excluded."value";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smoodly",
3
- "version": "0.0.24",
3
+ "version": "0.0.26",
4
4
  "description": "Open-source, code-first CMS for Next.js on Supabase: define a component once, it becomes a typed, editable building block.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -11,6 +11,9 @@
11
11
  "type": "module",
12
12
  "main": "./dist/index.js",
13
13
  "types": "./dist/index.d.ts",
14
+ "bin": {
15
+ "smoodly": "bin/smoodly.js"
16
+ },
14
17
  "exports": {
15
18
  ".": {
16
19
  "types": "./dist/index.d.ts",
@@ -43,10 +46,16 @@
43
46
  "./bridge": {
44
47
  "types": "./dist/admin/next/bridge.d.ts",
45
48
  "default": "./dist/admin/next/bridge.js"
49
+ },
50
+ "./migrations": {
51
+ "types": "./dist/migrations.d.ts",
52
+ "default": "./dist/migrations.js"
46
53
  }
47
54
  },
48
55
  "files": [
49
- "dist"
56
+ "dist",
57
+ "migrations",
58
+ "bin"
50
59
  ],
51
60
  "scripts": {
52
61
  "build": "rm -rf dist && tsc -p tsconfig.build.json",
@@ -88,6 +97,7 @@
88
97
  "@tiptap/react": "3.31.3",
89
98
  "@tiptap/starter-kit": "3.31.3",
90
99
  "@tiptap/suggestion": "3.31.3",
91
- "lucide-react": "^1.41.0"
100
+ "lucide-react": "^1.41.0",
101
+ "tsx": "^4.23.15"
92
102
  }
93
103
  }