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.
Files changed (35) hide show
  1. package/README.md +2 -2
  2. package/dist/index.js +4 -4
  3. package/package.json +1 -1
  4. package/templates/blank/AGENTS.md +6 -5
  5. package/templates/blank/README.md +4 -6
  6. package/templates/blank/package.json +1 -1
  7. package/templates/blank/smoodly/collections.ts +3 -4
  8. package/templates/blank/supabase/migrations/{00000000000000_smoodly.sql → 20260926120000_smoodly_v1.sql} +20 -18
  9. package/templates/blank/supabase/migrations/20260926120100_smoodly_views.sql +23 -0
  10. package/templates/blank-intl/AGENTS.md +6 -5
  11. package/templates/blank-intl/README.md +4 -6
  12. package/templates/blank-intl/package.json +1 -1
  13. package/templates/blank-intl/smoodly/collections.ts +3 -4
  14. package/templates/blank-intl/supabase/migrations/{00000000000000_smoodly.sql → 20260926120000_smoodly_v1.sql} +20 -18
  15. package/templates/blank-intl/supabase/migrations/20260926120100_smoodly_views.sql +23 -0
  16. package/templates/lorem/AGENTS.md +6 -5
  17. package/templates/lorem/README.md +1 -1
  18. package/templates/lorem/components/PageFrame.tsx +3 -2
  19. package/templates/lorem/components/articles.ts +4 -0
  20. package/templates/lorem/components/sections/Header.tsx +1 -2
  21. package/templates/lorem/package.json +1 -1
  22. package/templates/lorem/scripts/seed.ts +6 -6
  23. package/templates/lorem/smoodly/config.ts +2 -0
  24. package/templates/lorem/supabase/migrations/{00000000000000_smoodly.sql → 20260926120000_smoodly_v1.sql} +20 -37
  25. package/templates/lorem/supabase/migrations/20260926120100_smoodly_views.sql +43 -0
  26. package/templates/lorem-intl/AGENTS.md +6 -5
  27. package/templates/lorem-intl/README.md +1 -1
  28. package/templates/lorem-intl/components/PageFrame.tsx +3 -2
  29. package/templates/lorem-intl/components/articles.ts +4 -0
  30. package/templates/lorem-intl/components/sections/Header.tsx +1 -2
  31. package/templates/lorem-intl/package.json +1 -1
  32. package/templates/lorem-intl/scripts/seed.ts +6 -6
  33. package/templates/lorem-intl/smoodly/config.ts +2 -0
  34. package/templates/lorem-intl/supabase/migrations/{00000000000000_smoodly.sql → 20260926120000_smoodly_v1.sql} +20 -37
  35. 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 `owner`, the email
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 `owner`, `next
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: "owner" }, { onConflict: "space_id,user_id" });
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>', 'owner');"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-smoodly-app",
3
- "version": "0.0.23",
3
+ "version": "0.0.25",
4
4
  "description": "Create a Smoodly site: a fresh Next.js app with the admin mounted, a Supabase schema, an env file and a first editor.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
- - **A new collection is a new migration.** Write its view with
16
- `collectionSQL` from `smoodly` into a new file under
17
- `supabase/migrations/`, then apply it: `supabase db push` (hosted) or
18
- `supabase db reset` (local). The admin cannot list a collection whose
19
- view is missing.
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 create a migration with its view:
43
+ Then write its read view and order index and apply them:
44
44
 
45
- import { collectionSQL } from "smoodly";
46
- import { people } from "./smoodly/collections";
47
- console.log(collectionSQL(people));
45
+ npx smoodly migrate
46
+ supabase db push # hosted; `supabase db reset` for the local stack
48
47
 
49
- Save the output as `supabase/migrations/<timestamp>_people.sql` and apply
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
 
@@ -14,7 +14,7 @@
14
14
  "next": "^16.3.4",
15
15
  "react": "^19",
16
16
  "react-dom": "^19",
17
- "smoodly": "0.0.23"
17
+ "smoodly": "0.0.25"
18
18
  },
19
19
  "devDependencies": {
20
20
  "@next/env": "^16.3.4",
@@ -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
- // collection: copy this block, add it to `collections`, write its view
9
- // into a new migration with `collectionSQL` from "smoodly", and apply
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
- create or replace view "posts" with (security_invoker = true) as
517
- select
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
- - **A new collection is a new migration.** Write its view with
16
- `collectionSQL` from `smoodly` into a new file under
17
- `supabase/migrations/`, then apply it: `supabase db push` (hosted) or
18
- `supabase db reset` (local). The admin cannot list a collection whose
19
- view is missing.
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 create a migration with its view:
44
+ Then write its read view and order index and apply them:
45
45
 
46
- import { collectionSQL } from "smoodly";
47
- import { people } from "./smoodly/collections";
48
- console.log(collectionSQL(people));
46
+ npx smoodly migrate
47
+ supabase db push # hosted; `supabase db reset` for the local stack
49
48
 
50
- Save the output as `supabase/migrations/<timestamp>_people.sql` and apply
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
 
@@ -14,7 +14,7 @@
14
14
  "next": "^16.3.4",
15
15
  "react": "^19",
16
16
  "react-dom": "^19",
17
- "smoodly": "0.0.23",
17
+ "smoodly": "0.0.25",
18
18
  "next-intl": "^4.14.2"
19
19
  },
20
20
  "devDependencies": {
@@ -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
- // collection: copy this block, add it to `collections`, write its view
9
- // into a new migration with `collectionSQL` from "smoodly", and apply
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
- create or replace view "posts" with (security_invoker = true) as
517
- select
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
- - **A new collection is a new migration.** Write its view with
16
- `collectionSQL` from `smoodly` into a new file under
17
- `supabase/migrations/`, then apply it: `supabase db push` (hosted) or
18
- `supabase db reset` (local). The admin cannot list a collection whose
19
- view is missing.
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, then add its view to a new migration with `collectionSQL` from `smoodly`.
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">
@@ -14,7 +14,7 @@
14
14
  "next": "^16.3.4",
15
15
  "react": "^19",
16
16
  "react-dom": "^19",
17
- "smoodly": "0.0.23"
17
+ "smoodly": "0.0.25"
18
18
  },
19
19
  "devDependencies": {
20
20
  "@next/env": "^16.3.4",
@@ -175,17 +175,17 @@ const article = await seedEntry("articles", {
175
175
 
176
176
  // --- global items ---------------------------------------------------
177
177
 
178
- const nav = (locale: string, labels: [string, string, string]) => {
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.home },
182
- { label: labels[1], href: links.articles },
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, ["Home", "Articles", "About"]) },
188
- FI ? { nav: nav(FI, ["Etusivu", "Artikkelit", "Tietoa meistä"]) } : undefined,
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
- create or replace view "people" with (security_invoker = true) as
517
- select
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
- - **A new collection is a new migration.** Write its view with
16
- `collectionSQL` from `smoodly` into a new file under
17
- `supabase/migrations/`, then apply it: `supabase db push` (hosted) or
18
- `supabase db reset` (local). The admin cannot list a collection whose
19
- view is missing.
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, then add its view to a new migration with `collectionSQL` from `smoodly`.
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">
@@ -14,7 +14,7 @@
14
14
  "next": "^16.3.4",
15
15
  "react": "^19",
16
16
  "react-dom": "^19",
17
- "smoodly": "0.0.23",
17
+ "smoodly": "0.0.25",
18
18
  "next-intl": "^4.14.2"
19
19
  },
20
20
  "devDependencies": {
@@ -175,17 +175,17 @@ const article = await seedEntry("articles", {
175
175
 
176
176
  // --- global items ---------------------------------------------------
177
177
 
178
- const nav = (locale: string, labels: [string, string, string]) => {
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.home },
182
- { label: labels[1], href: links.articles },
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, ["Home", "Articles", "About"]) },
188
- FI ? { nav: nav(FI, ["Etusivu", "Artikkelit", "Tietoa meistä"]) } : undefined,
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
- create or replace view "people" with (security_invoker = true) as
517
- select
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';