create-smoodly-app 0.0.23 → 0.0.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/index.js +4 -4
- package/package.json +1 -1
- package/templates/blank/AGENTS.md +6 -5
- package/templates/blank/README.md +4 -6
- package/templates/blank/package.json +1 -1
- package/templates/blank/smoodly/collections.ts +3 -4
- package/templates/blank/supabase/migrations/{00000000000000_smoodly.sql → 20260926120000_smoodly_v1.sql} +20 -18
- package/templates/blank/supabase/migrations/20260926120100_smoodly_views.sql +23 -0
- package/templates/blank-intl/AGENTS.md +6 -5
- package/templates/blank-intl/README.md +4 -6
- package/templates/blank-intl/package.json +1 -1
- package/templates/blank-intl/smoodly/collections.ts +3 -4
- package/templates/blank-intl/supabase/migrations/{00000000000000_smoodly.sql → 20260926120000_smoodly_v1.sql} +20 -18
- package/templates/blank-intl/supabase/migrations/20260926120100_smoodly_views.sql +23 -0
- package/templates/lorem/AGENTS.md +6 -5
- package/templates/lorem/README.md +1 -1
- package/templates/lorem/components/PageFrame.tsx +3 -2
- package/templates/lorem/components/articles.ts +4 -0
- package/templates/lorem/components/sections/Header.tsx +1 -2
- package/templates/lorem/package.json +1 -1
- package/templates/lorem/scripts/seed.ts +6 -6
- package/templates/lorem/smoodly/config.ts +2 -0
- package/templates/lorem/supabase/migrations/{00000000000000_smoodly.sql → 20260926120000_smoodly_v1.sql} +20 -37
- package/templates/lorem/supabase/migrations/20260926120100_smoodly_views.sql +43 -0
- package/templates/lorem-intl/AGENTS.md +6 -5
- package/templates/lorem-intl/README.md +1 -1
- package/templates/lorem-intl/components/PageFrame.tsx +3 -2
- package/templates/lorem-intl/components/articles.ts +4 -0
- package/templates/lorem-intl/components/sections/Header.tsx +1 -2
- package/templates/lorem-intl/package.json +1 -1
- package/templates/lorem-intl/scripts/seed.ts +6 -6
- package/templates/lorem-intl/smoodly/config.ts +2 -0
- package/templates/lorem-intl/supabase/migrations/{00000000000000_smoodly.sql → 20260926120000_smoodly_v1.sql} +20 -37
- package/templates/lorem-intl/supabase/migrations/20260926120100_smoodly_views.sql +43 -0
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ sign in. Spec: `docs/superpowers/specs/2026-09-04-create-smoodly-app-design.md`.
|
|
|
30
30
|
so an app that keeps its own tables in Supabase gives them a project of
|
|
31
31
|
their own, or takes Smoodly Cloud for content (DESIGN.md §1).
|
|
32
32
|
4. The first editor: pick an existing Auth user or create one (email +
|
|
33
|
-
password). The `members` row is written with role `
|
|
33
|
+
password). The `members` row is written with role `admin`, the email
|
|
34
34
|
beside the id.
|
|
35
35
|
If `@` does not appear when you type it (macOS, `@` on Option+2 with the
|
|
36
36
|
terminal's "Option as Meta" setting on), paste the address or pass
|
|
@@ -175,7 +175,7 @@ scaffolded in hosted mode against the repo stack and `next build` only.
|
|
|
175
175
|
(done 2026-09-05, throwaway project, `sb_secret_` + `sb_publishable_`):
|
|
176
176
|
`db push` of the template migration printed no NOTICE, the CLI listed and
|
|
177
177
|
created the first editor through the GoTrue admin API with the secret key
|
|
178
|
-
as the sole `apikey`, the `members` row landed with role `
|
|
178
|
+
as the sole `apikey`, the `members` row landed with role `admin`, `next
|
|
179
179
|
build` passed, `/admin` served the shell, and a password sign-in with the
|
|
180
180
|
publishable key returned a session. Caveat: the link step ran by hand with
|
|
181
181
|
an explicit `--project-ref` (see the next-but-one item); the CLI's own
|
package/dist/index.js
CHANGED
|
@@ -82,7 +82,7 @@ async function ensureEditor(client, ask) {
|
|
|
82
82
|
const existing = await client.listUsers();
|
|
83
83
|
const choice = await ask(existing);
|
|
84
84
|
const user = choice.kind === "pick" ? choice.user : await client.createUser(choice.email, choice.password);
|
|
85
|
-
await client.upsertEditor(user.id, user.email);
|
|
85
|
+
await client.upsertEditor(user.id, user.email, "admin");
|
|
86
86
|
return user;
|
|
87
87
|
}
|
|
88
88
|
function clientFor(keys) {
|
|
@@ -106,8 +106,8 @@ function supabaseEditorClient(keys) {
|
|
|
106
106
|
if (res.error) throw fail("creating the Auth user", res.error.message);
|
|
107
107
|
return { id: res.data.user.id, email: res.data.user.email ?? null };
|
|
108
108
|
},
|
|
109
|
-
async upsertEditor(userId, email) {
|
|
110
|
-
const res = await db.from("members").upsert({ user_id: userId, email, role
|
|
109
|
+
async upsertEditor(userId, email, role) {
|
|
110
|
+
const res = await db.from("members").upsert({ user_id: userId, email, role }, { onConflict: "space_id,user_id" });
|
|
111
111
|
if (res.error) throw fail("inserting the members row", res.error.message);
|
|
112
112
|
}
|
|
113
113
|
};
|
|
@@ -503,7 +503,7 @@ ${renderEnv(keys).split("\n").filter((l) => !l.startsWith("#")).join("\n").trimE
|
|
|
503
503
|
const editorLines = [
|
|
504
504
|
"Create the first editor: add a user in Supabase Studio (Authentication \u2192 Users), then in the SQL editor:",
|
|
505
505
|
"",
|
|
506
|
-
" insert into members (user_id, email, role) values ('<the user id>', '<the email>', '
|
|
506
|
+
" insert into members (user_id, email, role) values ('<the user id>', '<the email>', 'admin');"
|
|
507
507
|
].join("\n");
|
|
508
508
|
switch (step) {
|
|
509
509
|
case "supabaseLocal":
|
package/package.json
CHANGED
|
@@ -12,11 +12,12 @@ where things are.
|
|
|
12
12
|
`elements.ts`, `collections.ts`, `globals.ts`) and `smoodly/config.ts`
|
|
13
13
|
is the one registry. A component that is not registered does not exist
|
|
14
14
|
for the admin.
|
|
15
|
-
- **
|
|
16
|
-
`
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
- **After a new collection or a `smoodly` upgrade, run `npx smoodly migrate`**
|
|
16
|
+
and apply what it writes (`supabase db push` hosted, `supabase db reset`
|
|
17
|
+
local). It copies the core schema files the package ships and writes a
|
|
18
|
+
read view and order index per collection. Never edit a file it wrote.
|
|
19
|
+
The admin reads the base tables, so it lists a collection either way;
|
|
20
|
+
the view is for SQL and PostgREST readers, the index for `order`.
|
|
20
21
|
- **Seed through the stores.** `scripts/seed.ts` is starter content,
|
|
21
22
|
written through the page and entry stores, never raw SQL. It is
|
|
22
23
|
idempotent; run it with `npm run seed`.
|
|
@@ -40,14 +40,12 @@ editor's "/" menu.
|
|
|
40
40
|
Copy the `posts` block in `smoodly/collections.ts`, add the new
|
|
41
41
|
collection to `collections`, and write its entry page by copying
|
|
42
42
|
`components/pages/Post.tsx` (register it in `config.ts` under `pages`).
|
|
43
|
-
Then
|
|
43
|
+
Then write its read view and order index and apply them:
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
console.log(collectionSQL(people));
|
|
45
|
+
npx smoodly migrate
|
|
46
|
+
supabase db push # hosted; `supabase db reset` for the local stack
|
|
48
47
|
|
|
49
|
-
|
|
50
|
-
it with `supabase db push` (hosted) or `supabase db reset` (local).
|
|
48
|
+
The same command brings the core schema forward after a `smoodly` upgrade.
|
|
51
49
|
|
|
52
50
|
## Add a global item
|
|
53
51
|
|
|
@@ -4,10 +4,9 @@ import { text } from "./toolsets";
|
|
|
4
4
|
// A collection: structured content with its own admin list, served at
|
|
5
5
|
// `path` by its entry page (components/pages/Post.tsx). Fields marked
|
|
6
6
|
// .localized() are stored per locale; the rest are shared across
|
|
7
|
-
// locales. To add a
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// it (supabase db push, or supabase db reset locally).
|
|
7
|
+
// locales. To add a collection: copy this block, add it to
|
|
8
|
+
// `collections`, run `npx smoodly migrate` and apply the file it writes
|
|
9
|
+
// (supabase db push, or supabase db reset locally).
|
|
11
10
|
export const posts = smoodly.collection({
|
|
12
11
|
name: "posts",
|
|
13
12
|
title: "Posts",
|
|
@@ -1,3 +1,7 @@
|
|
|
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
|
+
|
|
1
5
|
set client_min_messages = warning;
|
|
2
6
|
|
|
3
7
|
-- The space a request acts in: the key's claim in Smoodly Cloud, the
|
|
@@ -217,6 +221,15 @@ $$;
|
|
|
217
221
|
-- to anything but a write key. The generated per-collection views
|
|
218
222
|
-- (collectionSQL) run with security_invoker = true, so they are subject
|
|
219
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
|
+
|
|
220
233
|
alter table "pages" enable row level security;
|
|
221
234
|
alter table "page_locales" enable row level security;
|
|
222
235
|
alter table "page_versions" enable row level security;
|
|
@@ -226,6 +239,7 @@ alter table "entry_versions" enable row level security;
|
|
|
226
239
|
alter table "refs" enable row level security;
|
|
227
240
|
alter table "paths" enable row level security;
|
|
228
241
|
alter table "assets" enable row level security;
|
|
242
|
+
alter table "smoodly_meta" enable row level security;
|
|
229
243
|
|
|
230
244
|
-- ── Cloud state (spec 2026-09-04 §1, names 2026-09-25): three ordinary
|
|
231
245
|
-- tables, shipped for everyone with ONE definition. Self-host fills them
|
|
@@ -500,6 +514,10 @@ drop policy if exists "smoodly_read" on "spaces";
|
|
|
500
514
|
create policy "smoodly_read" on "spaces" for select
|
|
501
515
|
using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
|
|
502
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
|
+
|
|
503
521
|
insert into storage.buckets ("id", "name", "public", "file_size_limit")
|
|
504
522
|
values ('media', 'media', true, 104857600)
|
|
505
523
|
on conflict ("id") do update set "public" = excluded."public", "file_size_limit" = excluded."file_size_limit";
|
|
@@ -513,21 +531,5 @@ create policy "smoodly_media_write" on storage.objects for all
|
|
|
513
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)
|
|
514
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);
|
|
515
533
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
e."id",
|
|
519
|
-
l."locale",
|
|
520
|
-
l."slug",
|
|
521
|
-
e."sort",
|
|
522
|
-
e."created_at",
|
|
523
|
-
l."updated_at",
|
|
524
|
-
coalesce(v."fields", e."fields" || l."fields")->>'title' as "title",
|
|
525
|
-
(coalesce(v."fields", e."fields" || l."fields")->>'date')::timestamptz as "date",
|
|
526
|
-
coalesce(v."fields", e."fields" || l."fields")->'body' as "body"
|
|
527
|
-
from "entries" e
|
|
528
|
-
join "entry_locales" l on l."entry_id" = e."id"
|
|
529
|
-
left join "entry_versions" v on v."id" = l."published_version_id"
|
|
530
|
-
where e."collection" = 'posts' and l."status" = 'published';
|
|
531
|
-
|
|
532
|
-
create index if not exists "entries_posts_date_idx"
|
|
533
|
-
on "entries" (("fields"->'date')) where "collection" = 'posts';
|
|
534
|
+
insert into "smoodly_meta" ("key", "value") values ('schema_version', '1')
|
|
535
|
+
on conflict ("key") do update set "value" = excluded."value";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
-- Smoodly: read views and order indexes for this app's collections, written by `npx smoodly migrate`
|
|
2
|
+
-- (2026-09-26T12:01:00.000Z). One block per collection; the marker's hash is the block's SQL, compared on the next run.
|
|
3
|
+
-- Never edit a written file: change the collection and run migrate again.
|
|
4
|
+
|
|
5
|
+
-- smoodly:collection posts sha256:2e648b887b8e7146
|
|
6
|
+
create or replace view "posts" with (security_invoker = true) as
|
|
7
|
+
select
|
|
8
|
+
e."id",
|
|
9
|
+
l."locale",
|
|
10
|
+
l."slug",
|
|
11
|
+
e."sort",
|
|
12
|
+
e."created_at",
|
|
13
|
+
l."updated_at",
|
|
14
|
+
coalesce(v."fields", e."fields" || l."fields")->>'title' as "title",
|
|
15
|
+
(coalesce(v."fields", e."fields" || l."fields")->>'date')::timestamptz as "date",
|
|
16
|
+
coalesce(v."fields", e."fields" || l."fields")->'body' as "body"
|
|
17
|
+
from "entries" e
|
|
18
|
+
join "entry_locales" l on l."entry_id" = e."id"
|
|
19
|
+
left join "entry_versions" v on v."id" = l."published_version_id"
|
|
20
|
+
where e."collection" = 'posts' and l."status" = 'published';
|
|
21
|
+
|
|
22
|
+
create index if not exists "entries_posts_date_idx"
|
|
23
|
+
on "entries" (("fields"->'date')) where "collection" = 'posts';
|
|
@@ -12,11 +12,12 @@ where things are.
|
|
|
12
12
|
`elements.ts`, `collections.ts`, `globals.ts`) and `smoodly/config.ts`
|
|
13
13
|
is the one registry. A component that is not registered does not exist
|
|
14
14
|
for the admin.
|
|
15
|
-
- **
|
|
16
|
-
`
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
- **After a new collection or a `smoodly` upgrade, run `npx smoodly migrate`**
|
|
16
|
+
and apply what it writes (`supabase db push` hosted, `supabase db reset`
|
|
17
|
+
local). It copies the core schema files the package ships and writes a
|
|
18
|
+
read view and order index per collection. Never edit a file it wrote.
|
|
19
|
+
The admin reads the base tables, so it lists a collection either way;
|
|
20
|
+
the view is for SQL and PostgREST readers, the index for `order`.
|
|
20
21
|
- **Seed through the stores.** `scripts/seed.ts` is starter content,
|
|
21
22
|
written through the page and entry stores, never raw SQL. It is
|
|
22
23
|
idempotent; run it with `npm run seed`.
|
|
@@ -41,14 +41,12 @@ editor's "/" menu.
|
|
|
41
41
|
Copy the `posts` block in `smoodly/collections.ts`, add the new
|
|
42
42
|
collection to `collections`, and write its entry page by copying
|
|
43
43
|
`components/pages/Post.tsx` (register it in `config.ts` under `pages`).
|
|
44
|
-
Then
|
|
44
|
+
Then write its read view and order index and apply them:
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
console.log(collectionSQL(people));
|
|
46
|
+
npx smoodly migrate
|
|
47
|
+
supabase db push # hosted; `supabase db reset` for the local stack
|
|
49
48
|
|
|
50
|
-
|
|
51
|
-
it with `supabase db push` (hosted) or `supabase db reset` (local).
|
|
49
|
+
The same command brings the core schema forward after a `smoodly` upgrade.
|
|
52
50
|
|
|
53
51
|
## Add a global item
|
|
54
52
|
|
|
@@ -4,10 +4,9 @@ import { text } from "./toolsets";
|
|
|
4
4
|
// A collection: structured content with its own admin list, served at
|
|
5
5
|
// `path` by its entry page (components/pages/Post.tsx). Fields marked
|
|
6
6
|
// .localized() are stored per locale; the rest are shared across
|
|
7
|
-
// locales. To add a
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// it (supabase db push, or supabase db reset locally).
|
|
7
|
+
// locales. To add a collection: copy this block, add it to
|
|
8
|
+
// `collections`, run `npx smoodly migrate` and apply the file it writes
|
|
9
|
+
// (supabase db push, or supabase db reset locally).
|
|
11
10
|
export const posts = smoodly.collection({
|
|
12
11
|
name: "posts",
|
|
13
12
|
title: "Posts",
|
|
@@ -1,3 +1,7 @@
|
|
|
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
|
+
|
|
1
5
|
set client_min_messages = warning;
|
|
2
6
|
|
|
3
7
|
-- The space a request acts in: the key's claim in Smoodly Cloud, the
|
|
@@ -217,6 +221,15 @@ $$;
|
|
|
217
221
|
-- to anything but a write key. The generated per-collection views
|
|
218
222
|
-- (collectionSQL) run with security_invoker = true, so they are subject
|
|
219
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
|
+
|
|
220
233
|
alter table "pages" enable row level security;
|
|
221
234
|
alter table "page_locales" enable row level security;
|
|
222
235
|
alter table "page_versions" enable row level security;
|
|
@@ -226,6 +239,7 @@ alter table "entry_versions" enable row level security;
|
|
|
226
239
|
alter table "refs" enable row level security;
|
|
227
240
|
alter table "paths" enable row level security;
|
|
228
241
|
alter table "assets" enable row level security;
|
|
242
|
+
alter table "smoodly_meta" enable row level security;
|
|
229
243
|
|
|
230
244
|
-- ── Cloud state (spec 2026-09-04 §1, names 2026-09-25): three ordinary
|
|
231
245
|
-- tables, shipped for everyone with ONE definition. Self-host fills them
|
|
@@ -500,6 +514,10 @@ drop policy if exists "smoodly_read" on "spaces";
|
|
|
500
514
|
create policy "smoodly_read" on "spaces" for select
|
|
501
515
|
using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
|
|
502
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
|
+
|
|
503
521
|
insert into storage.buckets ("id", "name", "public", "file_size_limit")
|
|
504
522
|
values ('media', 'media', true, 104857600)
|
|
505
523
|
on conflict ("id") do update set "public" = excluded."public", "file_size_limit" = excluded."file_size_limit";
|
|
@@ -513,21 +531,5 @@ create policy "smoodly_media_write" on storage.objects for all
|
|
|
513
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)
|
|
514
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);
|
|
515
533
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
e."id",
|
|
519
|
-
l."locale",
|
|
520
|
-
l."slug",
|
|
521
|
-
e."sort",
|
|
522
|
-
e."created_at",
|
|
523
|
-
l."updated_at",
|
|
524
|
-
coalesce(v."fields", e."fields" || l."fields")->>'title' as "title",
|
|
525
|
-
(coalesce(v."fields", e."fields" || l."fields")->>'date')::timestamptz as "date",
|
|
526
|
-
coalesce(v."fields", e."fields" || l."fields")->'body' as "body"
|
|
527
|
-
from "entries" e
|
|
528
|
-
join "entry_locales" l on l."entry_id" = e."id"
|
|
529
|
-
left join "entry_versions" v on v."id" = l."published_version_id"
|
|
530
|
-
where e."collection" = 'posts' and l."status" = 'published';
|
|
531
|
-
|
|
532
|
-
create index if not exists "entries_posts_date_idx"
|
|
533
|
-
on "entries" (("fields"->'date')) where "collection" = 'posts';
|
|
534
|
+
insert into "smoodly_meta" ("key", "value") values ('schema_version', '1')
|
|
535
|
+
on conflict ("key") do update set "value" = excluded."value";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
-- Smoodly: read views and order indexes for this app's collections, written by `npx smoodly migrate`
|
|
2
|
+
-- (2026-09-26T12:01:00.000Z). One block per collection; the marker's hash is the block's SQL, compared on the next run.
|
|
3
|
+
-- Never edit a written file: change the collection and run migrate again.
|
|
4
|
+
|
|
5
|
+
-- smoodly:collection posts sha256:2e648b887b8e7146
|
|
6
|
+
create or replace view "posts" with (security_invoker = true) as
|
|
7
|
+
select
|
|
8
|
+
e."id",
|
|
9
|
+
l."locale",
|
|
10
|
+
l."slug",
|
|
11
|
+
e."sort",
|
|
12
|
+
e."created_at",
|
|
13
|
+
l."updated_at",
|
|
14
|
+
coalesce(v."fields", e."fields" || l."fields")->>'title' as "title",
|
|
15
|
+
(coalesce(v."fields", e."fields" || l."fields")->>'date')::timestamptz as "date",
|
|
16
|
+
coalesce(v."fields", e."fields" || l."fields")->'body' as "body"
|
|
17
|
+
from "entries" e
|
|
18
|
+
join "entry_locales" l on l."entry_id" = e."id"
|
|
19
|
+
left join "entry_versions" v on v."id" = l."published_version_id"
|
|
20
|
+
where e."collection" = 'posts' and l."status" = 'published';
|
|
21
|
+
|
|
22
|
+
create index if not exists "entries_posts_date_idx"
|
|
23
|
+
on "entries" (("fields"->'date')) where "collection" = 'posts';
|
|
@@ -12,11 +12,12 @@ where things are.
|
|
|
12
12
|
`elements.ts`, `collections.ts`, `globals.ts`) and `smoodly/config.ts`
|
|
13
13
|
is the one registry. A component that is not registered does not exist
|
|
14
14
|
for the admin.
|
|
15
|
-
- **
|
|
16
|
-
`
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
- **After a new collection or a `smoodly` upgrade, run `npx smoodly migrate`**
|
|
16
|
+
and apply what it writes (`supabase db push` hosted, `supabase db reset`
|
|
17
|
+
local). It copies the core schema files the package ships and writes a
|
|
18
|
+
read view and order index per collection. Never edit a file it wrote.
|
|
19
|
+
The admin reads the base tables, so it lists a collection either way;
|
|
20
|
+
the view is for SQL and PostgREST readers, the index for `order`.
|
|
20
21
|
- **Seed through the stores.** `scripts/seed.ts` is starter content,
|
|
21
22
|
written through the page and entry stores, never raw SQL. It is
|
|
22
23
|
idempotent; run it with `npm run seed`.
|
|
@@ -17,7 +17,7 @@ for an agent (or a person) building the site.
|
|
|
17
17
|
- `components/strings.ts`, `messages/` — the site's own UI strings and the request locale, one seam.
|
|
18
18
|
- `public/images/` — the starter's photos; image fields hold URLs like `/images/hero_image.jpg`.
|
|
19
19
|
- `scripts/seed.ts` — the starter content, written through the stores. `npm run seed` is idempotent.
|
|
20
|
-
- `smoodly/collections.ts` — structured content (people, articles). Add a collection,
|
|
20
|
+
- `smoodly/collections.ts` — structured content (people, articles). Add a collection, run `npx smoodly migrate`, apply the file it writes.
|
|
21
21
|
- `smoodly/` — the wiring: `config.ts` (the registry and settings), `collections.ts`, `sections.ts`, `server.ts` (stores, site layer, renderer), `ops.ts` (the admin's one server action), `admin.tsx` (the admin mount).
|
|
22
22
|
- `.env.local` — `SMOODLY_URL`, `SMOODLY_SECRET_KEY`, `SMOODLY_PUBLISHABLE_KEY`.
|
|
23
23
|
|
|
@@ -10,7 +10,8 @@ import { HeaderView, type LocaleLink } from "./sections/Header";
|
|
|
10
10
|
// entry's map (spec 2026-09-15): the other locale's path where the page
|
|
11
11
|
// exists and is published there, that locale's home otherwise. With one
|
|
12
12
|
// locale (the single-language scaffold) there are no links at all. The
|
|
13
|
-
// header itself is the global item, read through `q` per locale
|
|
13
|
+
// header itself is the global item, read through `q` per locale, and its
|
|
14
|
+
// brand links to the locale's home — so the nav needs no Home item.
|
|
14
15
|
export async function PageFrame({ locale, locales, children }: {
|
|
15
16
|
locale: string;
|
|
16
17
|
locales: Record<string, LocaleAlternate | null>;
|
|
@@ -23,7 +24,7 @@ export async function PageFrame({ locale, locales, children }: {
|
|
|
23
24
|
: undefined;
|
|
24
25
|
return (
|
|
25
26
|
<>
|
|
26
|
-
{header && <HeaderView {...header} locales={links} />}
|
|
27
|
+
{header && <HeaderView {...header} home={href("/", locale)} locales={links} />}
|
|
27
28
|
<main>{children}</main>
|
|
28
29
|
</>
|
|
29
30
|
);
|
|
@@ -28,6 +28,10 @@ export const articlePath = (slug: string, locale: string) => `/${segment(locale)
|
|
|
28
28
|
export const formatDate = (iso: string, locale: string) =>
|
|
29
29
|
new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeZone: "UTC" }).format(new Date(iso));
|
|
30
30
|
|
|
31
|
+
/** A datetime field's UTC instant, shown in the site's zone (config.timeZone). */
|
|
32
|
+
export const formatDateTime = (iso: string, locale: string, timeZone: string) =>
|
|
33
|
+
new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short", timeZone }).format(new Date(iso));
|
|
34
|
+
|
|
31
35
|
type Node = { text?: string; content?: Node[] };
|
|
32
36
|
export function richtextText(doc: unknown): string {
|
|
33
37
|
const walk = (n: Node): string => n.text ?? (n.content ?? []).map(walk).join(" ");
|
|
@@ -24,8 +24,7 @@ export type LocaleLink = { code: string; url: string; current: boolean };
|
|
|
24
24
|
|
|
25
25
|
const m = smoodly.marks(headerSchema);
|
|
26
26
|
|
|
27
|
-
export function HeaderView({ brand, nav, locales, styles }: Props<typeof headerSchema> & { locales?: LocaleLink[] }) {
|
|
28
|
-
const home = locales?.find((l) => l.current)?.url ?? "/";
|
|
27
|
+
export function HeaderView({ brand, nav, home = "/", locales, styles }: Props<typeof headerSchema> & { home?: string; locales?: LocaleLink[] }) {
|
|
29
28
|
return (
|
|
30
29
|
<smoodly.Section as="header" styles={styles} className="site-header">
|
|
31
30
|
<div className="container site-header__bar">
|
|
@@ -175,17 +175,17 @@ const article = await seedEntry("articles", {
|
|
|
175
175
|
|
|
176
176
|
// --- global items ---------------------------------------------------
|
|
177
177
|
|
|
178
|
-
|
|
178
|
+
// No Home item: the brand in the header is the way home.
|
|
179
|
+
const nav = (locale: string, labels: [string, string]) => {
|
|
179
180
|
const links = paths(locale);
|
|
180
181
|
return [
|
|
181
|
-
{ label: labels[0], href: links.
|
|
182
|
-
{ label: labels[1], href: links.
|
|
183
|
-
{ label: labels[2], href: links.about },
|
|
182
|
+
{ label: labels[0], href: links.articles },
|
|
183
|
+
{ label: labels[1], href: links.about },
|
|
184
184
|
];
|
|
185
185
|
};
|
|
186
186
|
await seedEntry("header",
|
|
187
|
-
{ brand: "Lorem Co.", nav: nav(EN, ["
|
|
188
|
-
FI ? { nav: nav(FI, ["
|
|
187
|
+
{ brand: "Lorem Co.", nav: nav(EN, ["Articles", "About"]) },
|
|
188
|
+
FI ? { nav: nav(FI, ["Artikkelit", "Tietoa meistä"]) } : undefined,
|
|
189
189
|
);
|
|
190
190
|
|
|
191
191
|
await seedEntry("footer", {
|
|
@@ -25,6 +25,8 @@ export const config = defineConfig({
|
|
|
25
25
|
pages: [HomePage, ArticlesPage, StandardPage, ArticlePage],
|
|
26
26
|
},
|
|
27
27
|
locales: { default: "en", supported: ["en"] },
|
|
28
|
+
// Editors pick dates and times in this zone; the views format with it (an IANA name, never an offset).
|
|
29
|
+
timeZone: "Europe/Helsinki",
|
|
28
30
|
// Smoodly's own default, spelled out: the record the site root serves.
|
|
29
31
|
homePageSlug: "home",
|
|
30
32
|
// Nesting on: an editor can put pages three deep.
|
|
@@ -1,3 +1,7 @@
|
|
|
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
|
+
|
|
1
5
|
set client_min_messages = warning;
|
|
2
6
|
|
|
3
7
|
-- The space a request acts in: the key's claim in Smoodly Cloud, the
|
|
@@ -217,6 +221,15 @@ $$;
|
|
|
217
221
|
-- to anything but a write key. The generated per-collection views
|
|
218
222
|
-- (collectionSQL) run with security_invoker = true, so they are subject
|
|
219
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
|
+
|
|
220
233
|
alter table "pages" enable row level security;
|
|
221
234
|
alter table "page_locales" enable row level security;
|
|
222
235
|
alter table "page_versions" enable row level security;
|
|
@@ -226,6 +239,7 @@ alter table "entry_versions" enable row level security;
|
|
|
226
239
|
alter table "refs" enable row level security;
|
|
227
240
|
alter table "paths" enable row level security;
|
|
228
241
|
alter table "assets" enable row level security;
|
|
242
|
+
alter table "smoodly_meta" enable row level security;
|
|
229
243
|
|
|
230
244
|
-- ── Cloud state (spec 2026-09-04 §1, names 2026-09-25): three ordinary
|
|
231
245
|
-- tables, shipped for everyone with ONE definition. Self-host fills them
|
|
@@ -500,6 +514,10 @@ drop policy if exists "smoodly_read" on "spaces";
|
|
|
500
514
|
create policy "smoodly_read" on "spaces" for select
|
|
501
515
|
using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
|
|
502
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
|
+
|
|
503
521
|
insert into storage.buckets ("id", "name", "public", "file_size_limit")
|
|
504
522
|
values ('media', 'media', true, 104857600)
|
|
505
523
|
on conflict ("id") do update set "public" = excluded."public", "file_size_limit" = excluded."file_size_limit";
|
|
@@ -513,40 +531,5 @@ create policy "smoodly_media_write" on storage.objects for all
|
|
|
513
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)
|
|
514
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);
|
|
515
533
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
e."id",
|
|
519
|
-
l."locale",
|
|
520
|
-
l."slug",
|
|
521
|
-
e."sort",
|
|
522
|
-
e."created_at",
|
|
523
|
-
l."updated_at",
|
|
524
|
-
coalesce(v."fields", e."fields" || l."fields")->>'name' as "name",
|
|
525
|
-
coalesce(v."fields", e."fields" || l."fields")->>'role' as "role"
|
|
526
|
-
from "entries" e
|
|
527
|
-
join "entry_locales" l on l."entry_id" = e."id"
|
|
528
|
-
left join "entry_versions" v on v."id" = l."published_version_id"
|
|
529
|
-
where e."collection" = 'people' and l."status" = 'published';
|
|
530
|
-
|
|
531
|
-
create or replace view "articles" with (security_invoker = true) as
|
|
532
|
-
select
|
|
533
|
-
e."id",
|
|
534
|
-
l."locale",
|
|
535
|
-
l."slug",
|
|
536
|
-
e."sort",
|
|
537
|
-
e."created_at",
|
|
538
|
-
l."updated_at",
|
|
539
|
-
coalesce(v."fields", e."fields" || l."fields")->>'title' as "title",
|
|
540
|
-
coalesce(v."fields", e."fields" || l."fields")->'image' as "image",
|
|
541
|
-
(coalesce(v."fields", e."fields" || l."fields")->>'date')::timestamptz as "date",
|
|
542
|
-
coalesce(v."fields", e."fields" || l."fields")->>'author' as "author",
|
|
543
|
-
coalesce(v."fields", e."fields" || l."fields")->>'excerpt' as "excerpt",
|
|
544
|
-
coalesce(v."fields", e."fields" || l."fields")->'body' as "body",
|
|
545
|
-
coalesce(v."fields", e."fields" || l."fields")->'tags' as "tags"
|
|
546
|
-
from "entries" e
|
|
547
|
-
join "entry_locales" l on l."entry_id" = e."id"
|
|
548
|
-
left join "entry_versions" v on v."id" = l."published_version_id"
|
|
549
|
-
where e."collection" = 'articles' and l."status" = 'published';
|
|
550
|
-
|
|
551
|
-
create index if not exists "entries_articles_date_idx"
|
|
552
|
-
on "entries" (("fields"->'date')) where "collection" = 'articles';
|
|
534
|
+
insert into "smoodly_meta" ("key", "value") values ('schema_version', '1')
|
|
535
|
+
on conflict ("key") do update set "value" = excluded."value";
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
-- Smoodly: read views and order indexes for this app's collections, written by `npx smoodly migrate`
|
|
2
|
+
-- (2026-09-26T12:01:00.000Z). One block per collection; the marker's hash is the block's SQL, compared on the next run.
|
|
3
|
+
-- Never edit a written file: change the collection and run migrate again.
|
|
4
|
+
|
|
5
|
+
-- smoodly:collection people sha256:929798ebb4fa2bb6
|
|
6
|
+
create or replace view "people" with (security_invoker = true) as
|
|
7
|
+
select
|
|
8
|
+
e."id",
|
|
9
|
+
l."locale",
|
|
10
|
+
l."slug",
|
|
11
|
+
e."sort",
|
|
12
|
+
e."created_at",
|
|
13
|
+
l."updated_at",
|
|
14
|
+
coalesce(v."fields", e."fields" || l."fields")->>'name' as "name",
|
|
15
|
+
coalesce(v."fields", e."fields" || l."fields")->>'role' as "role"
|
|
16
|
+
from "entries" e
|
|
17
|
+
join "entry_locales" l on l."entry_id" = e."id"
|
|
18
|
+
left join "entry_versions" v on v."id" = l."published_version_id"
|
|
19
|
+
where e."collection" = 'people' and l."status" = 'published';
|
|
20
|
+
|
|
21
|
+
-- smoodly:collection articles sha256:f0257bf8fe462954
|
|
22
|
+
create or replace view "articles" with (security_invoker = true) as
|
|
23
|
+
select
|
|
24
|
+
e."id",
|
|
25
|
+
l."locale",
|
|
26
|
+
l."slug",
|
|
27
|
+
e."sort",
|
|
28
|
+
e."created_at",
|
|
29
|
+
l."updated_at",
|
|
30
|
+
coalesce(v."fields", e."fields" || l."fields")->>'title' as "title",
|
|
31
|
+
coalesce(v."fields", e."fields" || l."fields")->'image' as "image",
|
|
32
|
+
(coalesce(v."fields", e."fields" || l."fields")->>'date')::timestamptz as "date",
|
|
33
|
+
coalesce(v."fields", e."fields" || l."fields")->>'author' as "author",
|
|
34
|
+
coalesce(v."fields", e."fields" || l."fields")->>'excerpt' as "excerpt",
|
|
35
|
+
coalesce(v."fields", e."fields" || l."fields")->'body' as "body",
|
|
36
|
+
coalesce(v."fields", e."fields" || l."fields")->'tags' as "tags"
|
|
37
|
+
from "entries" e
|
|
38
|
+
join "entry_locales" l on l."entry_id" = e."id"
|
|
39
|
+
left join "entry_versions" v on v."id" = l."published_version_id"
|
|
40
|
+
where e."collection" = 'articles' and l."status" = 'published';
|
|
41
|
+
|
|
42
|
+
create index if not exists "entries_articles_date_idx"
|
|
43
|
+
on "entries" (("fields"->'date')) where "collection" = 'articles';
|
|
@@ -12,11 +12,12 @@ where things are.
|
|
|
12
12
|
`elements.ts`, `collections.ts`, `globals.ts`) and `smoodly/config.ts`
|
|
13
13
|
is the one registry. A component that is not registered does not exist
|
|
14
14
|
for the admin.
|
|
15
|
-
- **
|
|
16
|
-
`
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
- **After a new collection or a `smoodly` upgrade, run `npx smoodly migrate`**
|
|
16
|
+
and apply what it writes (`supabase db push` hosted, `supabase db reset`
|
|
17
|
+
local). It copies the core schema files the package ships and writes a
|
|
18
|
+
read view and order index per collection. Never edit a file it wrote.
|
|
19
|
+
The admin reads the base tables, so it lists a collection either way;
|
|
20
|
+
the view is for SQL and PostgREST readers, the index for `order`.
|
|
20
21
|
- **Seed through the stores.** `scripts/seed.ts` is starter content,
|
|
21
22
|
written through the page and entry stores, never raw SQL. It is
|
|
22
23
|
idempotent; run it with `npm run seed`.
|
|
@@ -18,7 +18,7 @@ for an agent (or a person) building the site.
|
|
|
18
18
|
- `components/strings.ts`, `messages/` — the site's own UI strings and the request locale, one seam.
|
|
19
19
|
- `public/images/` — the starter's photos; image fields hold URLs like `/images/hero_image.jpg`.
|
|
20
20
|
- `scripts/seed.ts` — the starter content, written through the stores. `npm run seed` is idempotent.
|
|
21
|
-
- `smoodly/collections.ts` — structured content (people, articles). Add a collection,
|
|
21
|
+
- `smoodly/collections.ts` — structured content (people, articles). Add a collection, run `npx smoodly migrate`, apply the file it writes.
|
|
22
22
|
- `smoodly/` — the wiring: `config.ts` (the registry and settings), `collections.ts`, `sections.ts`, `server.ts` (stores, site layer, renderer), `ops.ts` (the admin's one server action), `admin.tsx` (the admin mount).
|
|
23
23
|
- `.env.local` — `SMOODLY_URL`, `SMOODLY_SECRET_KEY`, `SMOODLY_PUBLISHABLE_KEY`.
|
|
24
24
|
|
|
@@ -10,7 +10,8 @@ import { HeaderView, type LocaleLink } from "./sections/Header";
|
|
|
10
10
|
// entry's map (spec 2026-09-15): the other locale's path where the page
|
|
11
11
|
// exists and is published there, that locale's home otherwise. With one
|
|
12
12
|
// locale (the single-language scaffold) there are no links at all. The
|
|
13
|
-
// header itself is the global item, read through `q` per locale
|
|
13
|
+
// header itself is the global item, read through `q` per locale, and its
|
|
14
|
+
// brand links to the locale's home — so the nav needs no Home item.
|
|
14
15
|
export async function PageFrame({ locale, locales, children }: {
|
|
15
16
|
locale: string;
|
|
16
17
|
locales: Record<string, LocaleAlternate | null>;
|
|
@@ -23,7 +24,7 @@ export async function PageFrame({ locale, locales, children }: {
|
|
|
23
24
|
: undefined;
|
|
24
25
|
return (
|
|
25
26
|
<>
|
|
26
|
-
{header && <HeaderView {...header} locales={links} />}
|
|
27
|
+
{header && <HeaderView {...header} home={href("/", locale)} locales={links} />}
|
|
27
28
|
<main>{children}</main>
|
|
28
29
|
</>
|
|
29
30
|
);
|
|
@@ -28,6 +28,10 @@ export const articlePath = (slug: string, locale: string) => `/${segment(locale)
|
|
|
28
28
|
export const formatDate = (iso: string, locale: string) =>
|
|
29
29
|
new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeZone: "UTC" }).format(new Date(iso));
|
|
30
30
|
|
|
31
|
+
/** A datetime field's UTC instant, shown in the site's zone (config.timeZone). */
|
|
32
|
+
export const formatDateTime = (iso: string, locale: string, timeZone: string) =>
|
|
33
|
+
new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short", timeZone }).format(new Date(iso));
|
|
34
|
+
|
|
31
35
|
type Node = { text?: string; content?: Node[] };
|
|
32
36
|
export function richtextText(doc: unknown): string {
|
|
33
37
|
const walk = (n: Node): string => n.text ?? (n.content ?? []).map(walk).join(" ");
|
|
@@ -24,8 +24,7 @@ export type LocaleLink = { code: string; url: string; current: boolean };
|
|
|
24
24
|
|
|
25
25
|
const m = smoodly.marks(headerSchema);
|
|
26
26
|
|
|
27
|
-
export function HeaderView({ brand, nav, locales, styles }: Props<typeof headerSchema> & { locales?: LocaleLink[] }) {
|
|
28
|
-
const home = locales?.find((l) => l.current)?.url ?? "/";
|
|
27
|
+
export function HeaderView({ brand, nav, home = "/", locales, styles }: Props<typeof headerSchema> & { home?: string; locales?: LocaleLink[] }) {
|
|
29
28
|
return (
|
|
30
29
|
<smoodly.Section as="header" styles={styles} className="site-header">
|
|
31
30
|
<div className="container site-header__bar">
|
|
@@ -175,17 +175,17 @@ const article = await seedEntry("articles", {
|
|
|
175
175
|
|
|
176
176
|
// --- global items ---------------------------------------------------
|
|
177
177
|
|
|
178
|
-
|
|
178
|
+
// No Home item: the brand in the header is the way home.
|
|
179
|
+
const nav = (locale: string, labels: [string, string]) => {
|
|
179
180
|
const links = paths(locale);
|
|
180
181
|
return [
|
|
181
|
-
{ label: labels[0], href: links.
|
|
182
|
-
{ label: labels[1], href: links.
|
|
183
|
-
{ label: labels[2], href: links.about },
|
|
182
|
+
{ label: labels[0], href: links.articles },
|
|
183
|
+
{ label: labels[1], href: links.about },
|
|
184
184
|
];
|
|
185
185
|
};
|
|
186
186
|
await seedEntry("header",
|
|
187
|
-
{ brand: "Lorem Co.", nav: nav(EN, ["
|
|
188
|
-
FI ? { nav: nav(FI, ["
|
|
187
|
+
{ brand: "Lorem Co.", nav: nav(EN, ["Articles", "About"]) },
|
|
188
|
+
FI ? { nav: nav(FI, ["Artikkelit", "Tietoa meistä"]) } : undefined,
|
|
189
189
|
);
|
|
190
190
|
|
|
191
191
|
await seedEntry("footer", {
|
|
@@ -29,6 +29,8 @@ export const config = defineConfig({
|
|
|
29
29
|
pages: [HomePage, ArticlesPage, StandardPage, ArticlePage],
|
|
30
30
|
},
|
|
31
31
|
locales: { default: routing.defaultLocale, supported: [...routing.locales] },
|
|
32
|
+
// Editors pick dates and times in this zone; the views format with it (an IANA name, never an offset).
|
|
33
|
+
timeZone: "Europe/Helsinki",
|
|
32
34
|
// Smoodly's own default, spelled out: the record the site root serves.
|
|
33
35
|
homePageSlug: "home",
|
|
34
36
|
// Nesting on: an editor can put pages three deep.
|
|
@@ -1,3 +1,7 @@
|
|
|
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
|
+
|
|
1
5
|
set client_min_messages = warning;
|
|
2
6
|
|
|
3
7
|
-- The space a request acts in: the key's claim in Smoodly Cloud, the
|
|
@@ -217,6 +221,15 @@ $$;
|
|
|
217
221
|
-- to anything but a write key. The generated per-collection views
|
|
218
222
|
-- (collectionSQL) run with security_invoker = true, so they are subject
|
|
219
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
|
+
|
|
220
233
|
alter table "pages" enable row level security;
|
|
221
234
|
alter table "page_locales" enable row level security;
|
|
222
235
|
alter table "page_versions" enable row level security;
|
|
@@ -226,6 +239,7 @@ alter table "entry_versions" enable row level security;
|
|
|
226
239
|
alter table "refs" enable row level security;
|
|
227
240
|
alter table "paths" enable row level security;
|
|
228
241
|
alter table "assets" enable row level security;
|
|
242
|
+
alter table "smoodly_meta" enable row level security;
|
|
229
243
|
|
|
230
244
|
-- ── Cloud state (spec 2026-09-04 §1, names 2026-09-25): three ordinary
|
|
231
245
|
-- tables, shipped for everyone with ONE definition. Self-host fills them
|
|
@@ -500,6 +514,10 @@ drop policy if exists "smoodly_read" on "spaces";
|
|
|
500
514
|
create policy "smoodly_read" on "spaces" for select
|
|
501
515
|
using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
|
|
502
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
|
+
|
|
503
521
|
insert into storage.buckets ("id", "name", "public", "file_size_limit")
|
|
504
522
|
values ('media', 'media', true, 104857600)
|
|
505
523
|
on conflict ("id") do update set "public" = excluded."public", "file_size_limit" = excluded."file_size_limit";
|
|
@@ -513,40 +531,5 @@ create policy "smoodly_media_write" on storage.objects for all
|
|
|
513
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)
|
|
514
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);
|
|
515
533
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
e."id",
|
|
519
|
-
l."locale",
|
|
520
|
-
l."slug",
|
|
521
|
-
e."sort",
|
|
522
|
-
e."created_at",
|
|
523
|
-
l."updated_at",
|
|
524
|
-
coalesce(v."fields", e."fields" || l."fields")->>'name' as "name",
|
|
525
|
-
coalesce(v."fields", e."fields" || l."fields")->>'role' as "role"
|
|
526
|
-
from "entries" e
|
|
527
|
-
join "entry_locales" l on l."entry_id" = e."id"
|
|
528
|
-
left join "entry_versions" v on v."id" = l."published_version_id"
|
|
529
|
-
where e."collection" = 'people' and l."status" = 'published';
|
|
530
|
-
|
|
531
|
-
create or replace view "articles" with (security_invoker = true) as
|
|
532
|
-
select
|
|
533
|
-
e."id",
|
|
534
|
-
l."locale",
|
|
535
|
-
l."slug",
|
|
536
|
-
e."sort",
|
|
537
|
-
e."created_at",
|
|
538
|
-
l."updated_at",
|
|
539
|
-
coalesce(v."fields", e."fields" || l."fields")->>'title' as "title",
|
|
540
|
-
coalesce(v."fields", e."fields" || l."fields")->'image' as "image",
|
|
541
|
-
(coalesce(v."fields", e."fields" || l."fields")->>'date')::timestamptz as "date",
|
|
542
|
-
coalesce(v."fields", e."fields" || l."fields")->>'author' as "author",
|
|
543
|
-
coalesce(v."fields", e."fields" || l."fields")->>'excerpt' as "excerpt",
|
|
544
|
-
coalesce(v."fields", e."fields" || l."fields")->'body' as "body",
|
|
545
|
-
coalesce(v."fields", e."fields" || l."fields")->'tags' as "tags"
|
|
546
|
-
from "entries" e
|
|
547
|
-
join "entry_locales" l on l."entry_id" = e."id"
|
|
548
|
-
left join "entry_versions" v on v."id" = l."published_version_id"
|
|
549
|
-
where e."collection" = 'articles' and l."status" = 'published';
|
|
550
|
-
|
|
551
|
-
create index if not exists "entries_articles_date_idx"
|
|
552
|
-
on "entries" (("fields"->'date')) where "collection" = 'articles';
|
|
534
|
+
insert into "smoodly_meta" ("key", "value") values ('schema_version', '1')
|
|
535
|
+
on conflict ("key") do update set "value" = excluded."value";
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
-- Smoodly: read views and order indexes for this app's collections, written by `npx smoodly migrate`
|
|
2
|
+
-- (2026-09-26T12:01:00.000Z). One block per collection; the marker's hash is the block's SQL, compared on the next run.
|
|
3
|
+
-- Never edit a written file: change the collection and run migrate again.
|
|
4
|
+
|
|
5
|
+
-- smoodly:collection people sha256:929798ebb4fa2bb6
|
|
6
|
+
create or replace view "people" with (security_invoker = true) as
|
|
7
|
+
select
|
|
8
|
+
e."id",
|
|
9
|
+
l."locale",
|
|
10
|
+
l."slug",
|
|
11
|
+
e."sort",
|
|
12
|
+
e."created_at",
|
|
13
|
+
l."updated_at",
|
|
14
|
+
coalesce(v."fields", e."fields" || l."fields")->>'name' as "name",
|
|
15
|
+
coalesce(v."fields", e."fields" || l."fields")->>'role' as "role"
|
|
16
|
+
from "entries" e
|
|
17
|
+
join "entry_locales" l on l."entry_id" = e."id"
|
|
18
|
+
left join "entry_versions" v on v."id" = l."published_version_id"
|
|
19
|
+
where e."collection" = 'people' and l."status" = 'published';
|
|
20
|
+
|
|
21
|
+
-- smoodly:collection articles sha256:f0257bf8fe462954
|
|
22
|
+
create or replace view "articles" with (security_invoker = true) as
|
|
23
|
+
select
|
|
24
|
+
e."id",
|
|
25
|
+
l."locale",
|
|
26
|
+
l."slug",
|
|
27
|
+
e."sort",
|
|
28
|
+
e."created_at",
|
|
29
|
+
l."updated_at",
|
|
30
|
+
coalesce(v."fields", e."fields" || l."fields")->>'title' as "title",
|
|
31
|
+
coalesce(v."fields", e."fields" || l."fields")->'image' as "image",
|
|
32
|
+
(coalesce(v."fields", e."fields" || l."fields")->>'date')::timestamptz as "date",
|
|
33
|
+
coalesce(v."fields", e."fields" || l."fields")->>'author' as "author",
|
|
34
|
+
coalesce(v."fields", e."fields" || l."fields")->>'excerpt' as "excerpt",
|
|
35
|
+
coalesce(v."fields", e."fields" || l."fields")->'body' as "body",
|
|
36
|
+
coalesce(v."fields", e."fields" || l."fields")->'tags' as "tags"
|
|
37
|
+
from "entries" e
|
|
38
|
+
join "entry_locales" l on l."entry_id" = e."id"
|
|
39
|
+
left join "entry_versions" v on v."id" = l."published_version_id"
|
|
40
|
+
where e."collection" = 'articles' and l."status" = 'published';
|
|
41
|
+
|
|
42
|
+
create index if not exists "entries_articles_date_idx"
|
|
43
|
+
on "entries" (("fields"->'date')) where "collection" = 'articles';
|