create-smoodly-app 0.0.5 → 0.0.7

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 CHANGED
@@ -14,7 +14,10 @@ sign in. Spec: `docs/superpowers/specs/2026-09-04-create-smoodly-app-design.md`.
14
14
  scaffolds the next-intl layout: the site under `app/[locale]/`, `i18n/`,
15
15
  `proxy.ts`, translated URLs per locale. Flag: `--locales en,fi`.
16
16
  3. Where content lives: a **local Supabase stack** (`supabase init` +
17
- `supabase start` inside the app; needs the Supabase CLI and Docker; when
17
+ `supabase start` inside the app, with the services Smoodly never uses
18
+ excluded — storage, image proxy, edge functions, analytics, realtime,
19
+ the pooler — so the stack stays small beside any other you run; needs
20
+ the Supabase CLI and Docker; when
18
21
  another project holds the default `543xx` ports the new one moves to the
19
22
  next free block, `553xx` and so on, and says so) or a
20
23
  **hosted project** (URL, publishable key, secret key; then an offer to
@@ -61,6 +64,25 @@ multilingual one with `--locales en,de`.
61
64
 
62
65
  ## Follow-ups (logged 2026-09-06)
63
66
 
67
+ - Local mode on a fresh multilingual install (0.0.5, 2026-09-06) failed at
68
+ `supabase start` with `supabase_storage_<app> container is not ready:
69
+ unhealthy` — the storage container logged `Started Successfully` and the
70
+ CLI still stopped the stack. Five local stacks (57 containers) were
71
+ sharing 7.8 GB of Docker memory at the time, the same pressure that
72
+ made the repo's live suites flake on 2026-09-05. Smoodly never uses
73
+ storage, so the CLI could start the app's stack with
74
+ `supabase start -x storage-api,imgproxy,edge-runtime,logflare,vector,realtime`
75
+ (the container names `supabase start --help` lists; CI already excludes
76
+ most of these for the repo stack), and the "Finish by hand" text could
77
+ mention `supabase stop` on other projects. Retried the same day with
78
+ the other four stacks stopped (`supabase stop --project-id <name>`):
79
+ `npx create-smoodly-app@0.0.5 --supabase local --locales en,fi --yes`
80
+ came up with every container healthy, storage included, and the app
81
+ built and served `/admin` 200, `/` and `/fi` 404 — so the cause is
82
+ memory pressure, not the CLI. Fixed the same day: `run.ts` starts the
83
+ app's stack with `-x storage-api,imgproxy,edge-runtime,logflare,vector,realtime,supavisor`
84
+ (`LOCAL_START_ARGS`), and the "Finish by hand" text carries the same
85
+ flags. Kept: db, kong, auth, PostgREST, Studio and pg-meta, mailpit.
64
86
  - **A non-interactive run without `--locales` needs `--yes`.** The
65
87
  "Multilingual site?" prompt is asked whenever `--locales` is absent, and
66
88
  with no TTY (a CI step, stdin at EOF) the CLI exits 13 having created
package/dist/index.js CHANGED
@@ -378,6 +378,7 @@ function parseStatusEnv(output) {
378
378
  }
379
379
 
380
380
  // src/run.ts
381
+ var LOCAL_START_ARGS = ["start", "-x", "storage-api,imgproxy,edge-runtime,logflare,vector,realtime,supavisor"];
381
382
  async function run(answers2, deps) {
382
383
  if (answers2.supabase === "hosted" && !answers2.hosted) {
383
384
  throw new Error("hosted answers need the project's keys");
@@ -420,7 +421,7 @@ async function run(answers2, deps) {
420
421
  await deps.fs.writeFile(configPath, shiftBlock(toml, base));
421
422
  deps.log(`Ports ${DEFAULT_BASE / 100}xx are busy (another Supabase project running?), using ${base / 100}xx.`);
422
423
  }
423
- await sh("supabase", ["start"]);
424
+ await sh("supabase", LOCAL_START_ARGS);
424
425
  ctx.keys = parseStatusEnv((await sh("supabase", ["status", "-o", "env"], { capture: true })).stdout);
425
426
  break;
426
427
  }
@@ -466,7 +467,7 @@ ${renderEnv(keys).split("\n").filter((l) => !l.startsWith("#")).join("\n").trimE
466
467
  ].join("\n");
467
468
  switch (step) {
468
469
  case "supabaseLocal":
469
- return [`cd ${answers2.name} && supabase init && supabase start`, "", envLines, "", editorLines].join("\n");
470
+ return [`cd ${answers2.name} && supabase init && supabase ${LOCAL_START_ARGS.join(" ")}`, "", envLines, "", editorLines].join("\n");
470
471
  case "supabaseInit": {
471
472
  const commands = answers2.hosted?.linkAndPush ? [`cd ${answers2.name} && supabase init && ${linkCommand(answers2)} && supabase db push`] : [`cd ${answers2.name} && supabase init`, "", `When you are ready: ${linkCommand(answers2)} && supabase db push`];
472
473
  return [...commands, "", envLines, "", editorLines].join("\n");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-smoodly-app",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
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": {
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "type": "module",
12
12
  "bin": {
13
- "create-smoodly-app": "./dist/index.js"
13
+ "create-smoodly-app": "dist/index.js"
14
14
  },
15
15
  "files": [
16
16
  "dist",
@@ -13,7 +13,7 @@
13
13
  "next": "^16.3.4",
14
14
  "react": "^19",
15
15
  "react-dom": "^19",
16
- "smoodly": "0.0.5"
16
+ "smoodly": "0.0.7"
17
17
  },
18
18
  "devDependencies": {
19
19
  "@types/node": "^26.4.1",
@@ -1,24 +1,31 @@
1
1
  import { f, smoodly } from "smoodly";
2
2
 
3
+ // A person's name is the same in every language; the role is translated.
4
+ // Shared fields live on the entry, .localized() ones on its locale rows
5
+ // (spec 2026-09-06 §7).
3
6
  export const people = smoodly.collection({
4
7
  name: "people",
5
8
  title: "People",
6
9
  titleField: "name",
7
10
  fields: {
8
11
  name: f.text(),
9
- role: f.text().optional(),
12
+ role: f.text().optional().localized(),
10
13
  },
11
14
  });
12
15
 
16
+ // Articles keep a version history per locale (`versions: true`): every
17
+ // save is a snapshot, publish moves a pointer, and the published site
18
+ // keeps serving the published snapshot while a draft moves on.
13
19
  export const articles = smoodly.collection({
14
20
  name: "articles",
15
21
  title: "Articles",
16
22
  titleField: "title",
17
23
  path: "posts",
24
+ versions: true,
18
25
  fields: {
19
- title: f.text(),
26
+ title: f.text().localized(),
20
27
  slug: f.slug({ from: "title" }),
21
- excerpt: f.text().optional(),
28
+ excerpt: f.text().optional().localized(),
22
29
  author: f.ref(() => people),
23
30
  },
24
31
  });
@@ -12,28 +12,34 @@ create table if not exists "pages" (
12
12
  "id" uuid primary key default gen_random_uuid(),
13
13
  "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
14
14
  "parent_id" uuid references "pages" ("id") on delete restrict,
15
- "slug" text not null,
16
- "title" text not null,
17
- "i18n" jsonb,
18
15
  "template" text,
19
- "status" text not null default 'draft',
20
16
  "sort" numeric,
21
- "locales" text[],
22
- "draft_version_id" uuid,
23
- "published_version_id" uuid,
24
17
  "created_at" timestamptz not null default now(),
25
18
  "updated_at" timestamptz not null default now()
26
19
  );
27
20
 
28
- -- The tree is the URL (spec 2026-09-05 §1): a node stores its own segment
29
- -- and its parent. Siblings are unique per segment; NULLS NOT DISTINCT so
30
- -- two root nodes cannot share one either. template null = folder.
31
- create unique index if not exists "pages_sibling_slug_key"
32
- on "pages" ("space_id", "parent_id", "slug") nulls not distinct;
33
-
21
+ -- The tree is the URL (spec 2026-09-05 §1): a node stores its parent and
22
+ -- its position; template null = folder. Everything a locale owns — the
23
+ -- node's own segment, title, publish state and version pointers lives
24
+ -- in page_locales, one row per locale the page exists in (spec 2026-09-06
25
+ -- §2). There is no sibling-slug index: URL uniqueness per locale is the
26
+ -- primary key of "paths", which the stores write inside the same operation.
34
27
  create index if not exists "pages_parent_idx"
35
28
  on "pages" ("space_id", "parent_id", "sort");
36
29
 
30
+ create table if not exists "page_locales" (
31
+ "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
32
+ "page_id" uuid not null references "pages" ("id") on delete cascade,
33
+ "locale" text not null,
34
+ "slug" text not null,
35
+ "title" text not null,
36
+ "status" text not null default 'draft',
37
+ "draft_version_id" uuid,
38
+ "published_version_id" uuid,
39
+ "updated_at" timestamptz not null default now(),
40
+ primary key ("page_id", "locale")
41
+ );
42
+
37
43
  -- Sibling reorder for pages: same shape as smoodly_reorder for entries.
38
44
  create or replace function "smoodly_reorder_pages"(p_ids uuid[]) returns void language sql as $$
39
45
  update "pages" pg set "sort" = u.ord
@@ -42,15 +48,21 @@ create or replace function "smoodly_reorder_pages"(p_ids uuid[]) returns void la
42
48
  and pg."space_id" = "smoodly_current_space"();
43
49
  $$;
44
50
 
51
+ -- A version belongs to ONE locale of one page: each locale has its own
52
+ -- draft, its own publish and its own history.
45
53
  create table if not exists "page_versions" (
46
54
  "id" uuid primary key default gen_random_uuid(),
47
55
  "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
48
56
  "page_id" uuid not null references "pages" ("id") on delete cascade,
57
+ "locale" text not null,
49
58
  "tree" jsonb not null,
50
59
  "created_by" text,
51
60
  "created_at" timestamptz not null default now()
52
61
  );
53
62
 
63
+ create index if not exists "page_versions_page_locale_idx"
64
+ on "page_versions" ("page_id", "locale", "created_at");
65
+
54
66
  create table if not exists "saved_sections" (
55
67
  "id" uuid primary key default gen_random_uuid(),
56
68
  "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
@@ -73,40 +85,58 @@ create table if not exists "entries" (
73
85
  "collection" text not null,
74
86
  "parent_id" uuid references "entries" ("id") on delete restrict,
75
87
  "fields" jsonb not null default '{}'::jsonb,
76
- "i18n" jsonb,
77
- "status" text not null default 'draft',
78
88
  "sort" numeric,
79
- "locales" text[],
80
89
  "created_at" timestamptz not null default now(),
81
90
  "updated_at" timestamptz not null default now()
82
91
  );
83
92
 
84
- -- Entry slugs are unique among SIBLINGS, like pages: one rule for every
85
- -- node in the tree, and the URL is the tree. NULLS NOT DISTINCT so two
86
- -- ROOT entries (parent_id null) cannot share a slug either. The partial
87
- -- predicate keeps absent, null, empty or non-string slugs out of the
88
- -- index entirely: with NULLS NOT DISTINCT a null slug would otherwise
89
- -- collide with the next null slug, and a collection with no slug field
90
- -- at all could hold only one entry per parent. jsonb_typeof(...) =
91
- -- 'string' is required because ->> renders ANY JSON scalar as text —
92
- -- a number, boolean or null slug would otherwise print as non-empty
93
- -- text and claim the index; only a non-empty string slug claims a URL,
94
- -- absent, null, empty or non-string slugs never conflict — the same
95
- -- rule as slugOf in paths.ts. A flat collection (every entry a root)
96
- -- therefore behaves exactly as per-collection uniqueness did.
97
- create unique index if not exists "entries_sibling_slug_key"
98
- on "entries" ("space_id", "collection", "parent_id", ("fields"->>'slug')) nulls not distinct
99
- where (jsonb_typeof("fields"->'slug') = 'string' and "fields"->>'slug' <> '');
100
-
101
- create index if not exists "entries_collection_status_idx"
102
- on "entries" ("space_id", "collection", "status");
103
-
93
+ -- The node holds identity, tree position and the SHARED fields every
94
+ -- field without .localized() (spec 2026-09-06 §7): a Person's photo is
95
+ -- the same in every language. Everything a locale owns its segment,
96
+ -- publish state, the .localized() fields and the version pointers
97
+ -- lives in entry_locales, one row per locale the entry exists in. There
98
+ -- is no sibling-slug index any more: URL uniqueness per locale is the
99
+ -- primary key of "paths", which the stores write inside the same
100
+ -- operation, and both adapters pre-check siblings per locale in code.
104
101
  create index if not exists "entries_collection_sort_idx"
105
102
  on "entries" ("space_id", "collection", "sort");
106
103
 
107
104
  create index if not exists "entries_parent_idx"
108
105
  on "entries" ("space_id", "collection", "parent_id");
109
106
 
107
+ create table if not exists "entry_locales" (
108
+ "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
109
+ "entry_id" uuid not null references "entries" ("id") on delete cascade,
110
+ "locale" text not null,
111
+ "slug" text,
112
+ "status" text not null default 'draft',
113
+ "fields" jsonb not null default '{}'::jsonb,
114
+ "draft_version_id" uuid,
115
+ "published_version_id" uuid,
116
+ "updated_at" timestamptz not null default now(),
117
+ primary key ("entry_id", "locale")
118
+ );
119
+
120
+ create index if not exists "entry_locales_status_idx"
121
+ on "entry_locales" ("space_id", "locale", "status");
122
+
123
+ -- A version belongs to ONE locale of one entry and stores the MERGED
124
+ -- fields (shared + localized + slug) as they were at that save, so
125
+ -- restoring an old Finnish version restores the shared fields too.
126
+ -- Rows exist only for collections registered with `versions: true`.
127
+ create table if not exists "entry_versions" (
128
+ "id" uuid primary key default gen_random_uuid(),
129
+ "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
130
+ "entry_id" uuid not null references "entries" ("id") on delete cascade,
131
+ "locale" text not null,
132
+ "fields" jsonb not null,
133
+ "created_by" text,
134
+ "created_at" timestamptz not null default now()
135
+ );
136
+
137
+ create index if not exists "entry_versions_entry_locale_idx"
138
+ on "entry_versions" ("entry_id", "locale", "created_at");
139
+
110
140
  -- v1 reorder (DESIGN.md §3 Manual ordering): rewrite sort 1..N in ONE
111
141
  -- statement. Deliberately does not touch updated_at — a drag must not
112
142
  -- make the whole collection look freshly edited. Scoped to the current
@@ -172,10 +202,13 @@ $$;
172
202
  -- (collectionSQL) run with security_invoker = true, so they are subject
173
203
  -- to this same smoodly_read policy rather than their owner's privileges.
174
204
  alter table "pages" enable row level security;
205
+ alter table "page_locales" enable row level security;
175
206
  alter table "page_versions" enable row level security;
176
207
  alter table "saved_sections" enable row level security;
177
208
  alter table "global_sections" enable row level security;
178
209
  alter table "entries" enable row level security;
210
+ alter table "entry_locales" enable row level security;
211
+ alter table "entry_versions" enable row level security;
179
212
  alter table "refs" enable row level security;
180
213
  alter table "paths" enable row level security;
181
214
 
@@ -272,8 +305,14 @@ begin
272
305
  end;
273
306
  $$;
274
307
 
308
+ create index if not exists "page_locales_space_idx" on "page_locales" ("space_id");
309
+
275
310
  create index if not exists "page_versions_space_idx" on "page_versions" ("space_id");
276
311
 
312
+ create index if not exists "entry_locales_space_idx" on "entry_locales" ("space_id");
313
+
314
+ create index if not exists "entry_versions_space_idx" on "entry_versions" ("space_id");
315
+
277
316
  create index if not exists "saved_sections_space_idx" on "saved_sections" ("space_id");
278
317
 
279
318
  create index if not exists "global_sections_space_idx" on "global_sections" ("space_id");
@@ -284,6 +323,10 @@ drop trigger if exists "smoodly_a_set_space" on "pages";
284
323
  create trigger "smoodly_a_set_space" before insert on "pages"
285
324
  for each row execute function "smoodly_set_space"();
286
325
 
326
+ drop trigger if exists "smoodly_a_set_space" on "page_locales";
327
+ create trigger "smoodly_a_set_space" before insert on "page_locales"
328
+ for each row execute function "smoodly_set_space"();
329
+
287
330
  drop trigger if exists "smoodly_a_set_space" on "page_versions";
288
331
  create trigger "smoodly_a_set_space" before insert on "page_versions"
289
332
  for each row execute function "smoodly_set_space"();
@@ -300,6 +343,14 @@ drop trigger if exists "smoodly_a_set_space" on "entries";
300
343
  create trigger "smoodly_a_set_space" before insert on "entries"
301
344
  for each row execute function "smoodly_set_space"();
302
345
 
346
+ drop trigger if exists "smoodly_a_set_space" on "entry_locales";
347
+ create trigger "smoodly_a_set_space" before insert on "entry_locales"
348
+ for each row execute function "smoodly_set_space"();
349
+
350
+ drop trigger if exists "smoodly_a_set_space" on "entry_versions";
351
+ create trigger "smoodly_a_set_space" before insert on "entry_versions"
352
+ for each row execute function "smoodly_set_space"();
353
+
303
354
  drop trigger if exists "smoodly_a_set_space" on "refs";
304
355
  create trigger "smoodly_a_set_space" before insert on "refs"
305
356
  for each row execute function "smoodly_set_space"();
@@ -316,6 +367,10 @@ drop trigger if exists "smoodly_b_row_cap" on "pages";
316
367
  create trigger "smoodly_b_row_cap" before insert on "pages"
317
368
  for each row execute function "smoodly_enforce_row_cap"();
318
369
 
370
+ drop trigger if exists "smoodly_b_row_cap" on "page_locales";
371
+ create trigger "smoodly_b_row_cap" before insert on "page_locales"
372
+ for each row execute function "smoodly_enforce_row_cap"();
373
+
319
374
  drop trigger if exists "smoodly_b_row_cap" on "page_versions";
320
375
  create trigger "smoodly_b_row_cap" before insert on "page_versions"
321
376
  for each row execute function "smoodly_enforce_row_cap"();
@@ -332,6 +387,14 @@ drop trigger if exists "smoodly_b_row_cap" on "entries";
332
387
  create trigger "smoodly_b_row_cap" before insert on "entries"
333
388
  for each row execute function "smoodly_enforce_row_cap"();
334
389
 
390
+ drop trigger if exists "smoodly_b_row_cap" on "entry_locales";
391
+ create trigger "smoodly_b_row_cap" before insert on "entry_locales"
392
+ for each row execute function "smoodly_enforce_row_cap"();
393
+
394
+ drop trigger if exists "smoodly_b_row_cap" on "entry_versions";
395
+ create trigger "smoodly_b_row_cap" before insert on "entry_versions"
396
+ for each row execute function "smoodly_enforce_row_cap"();
397
+
335
398
  drop trigger if exists "smoodly_b_row_cap" on "refs";
336
399
  create trigger "smoodly_b_row_cap" before insert on "refs"
337
400
  for each row execute function "smoodly_enforce_row_cap"();
@@ -347,6 +410,15 @@ create policy "smoodly_write" on "pages" for all
347
410
 
348
411
  drop policy if exists "smoodly_read" on "pages";
349
412
  create policy "smoodly_read" on "pages" for select
413
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and exists (select 1 from "page_locales" l where l."page_id" = "pages"."id" and l."status" = 'published'));
414
+
415
+ drop policy if exists "smoodly_write" on "page_locales";
416
+ create policy "smoodly_write" on "page_locales" for all
417
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
418
+ with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
419
+
420
+ drop policy if exists "smoodly_read" on "page_locales";
421
+ create policy "smoodly_read" on "page_locales" for select
350
422
  using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and "status" = 'published');
351
423
 
352
424
  drop policy if exists "smoodly_write" on "page_versions";
@@ -356,7 +428,7 @@ create policy "smoodly_write" on "page_versions" for all
356
428
 
357
429
  drop policy if exists "smoodly_read" on "page_versions";
358
430
  create policy "smoodly_read" on "page_versions" for select
359
- using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and exists (select 1 from "pages" p where p."published_version_id" = "page_versions"."id"));
431
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and exists (select 1 from "page_locales" l where l."published_version_id" = "page_versions"."id"));
360
432
 
361
433
  drop policy if exists "smoodly_write" on "saved_sections";
362
434
  create policy "smoodly_write" on "saved_sections" for all
@@ -379,8 +451,26 @@ create policy "smoodly_write" on "entries" for all
379
451
 
380
452
  drop policy if exists "smoodly_read" on "entries";
381
453
  create policy "smoodly_read" on "entries" for select
454
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and exists (select 1 from "entry_locales" l where l."entry_id" = "entries"."id" and l."status" = 'published'));
455
+
456
+ drop policy if exists "smoodly_write" on "entry_locales";
457
+ create policy "smoodly_write" on "entry_locales" for all
458
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
459
+ with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
460
+
461
+ drop policy if exists "smoodly_read" on "entry_locales";
462
+ create policy "smoodly_read" on "entry_locales" for select
382
463
  using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and "status" = 'published');
383
464
 
465
+ drop policy if exists "smoodly_write" on "entry_versions";
466
+ create policy "smoodly_write" on "entry_versions" for all
467
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
468
+ with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
469
+
470
+ drop policy if exists "smoodly_read" on "entry_versions";
471
+ create policy "smoodly_read" on "entry_versions" for select
472
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and exists (select 1 from "entry_locales" l where l."published_version_id" = "entry_versions"."id"));
473
+
384
474
  drop policy if exists "smoodly_write" on "refs";
385
475
  create policy "smoodly_write" on "refs" for all
386
476
  using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
@@ -393,7 +483,7 @@ create policy "smoodly_write" on "paths" for all
393
483
 
394
484
  drop policy if exists "smoodly_read" on "paths";
395
485
  create policy "smoodly_read" on "paths" for select
396
- using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (("kind" = 'page' and exists (select 1 from "pages" p where p."id" = "paths"."target_id" and p."status" = 'published')) or ("kind" = 'entry' and exists (select 1 from "entries" e where e."id" = "paths"."target_id" and e."status" = 'published'))));
486
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (("kind" = 'page' and exists (select 1 from "page_locales" l where l."page_id" = "paths"."target_id" and l."locale" = "paths"."locale" and l."status" = 'published')) or ("kind" = 'entry' and exists (select 1 from "entry_locales" l where l."entry_id" = "paths"."target_id" and l."locale" = "paths"."locale" and l."status" = 'published'))));
397
487
 
398
488
  drop policy if exists "smoodly_write" on "editors";
399
489
  create policy "smoodly_write" on "editors" for all
@@ -402,26 +492,30 @@ create policy "smoodly_write" on "editors" for all
402
492
 
403
493
  create or replace view "people" with (security_invoker = true) as
404
494
  select
405
- "id",
406
- "sort",
407
- "locales",
408
- "created_at",
409
- "updated_at",
410
- "fields"->>'name' as "name",
411
- "fields"->>'role' as "role"
412
- from "entries"
413
- where "collection" = 'people' and "status" = 'published';
495
+ e."id",
496
+ l."locale",
497
+ l."slug",
498
+ e."sort",
499
+ e."created_at",
500
+ l."updated_at",
501
+ e."fields"->>'name' as "name",
502
+ l."fields"->>'role' as "role"
503
+ from "entries" e
504
+ join "entry_locales" l on l."entry_id" = e."id"
505
+ where e."collection" = 'people' and l."status" = 'published';
414
506
 
415
507
  create or replace view "articles" with (security_invoker = true) as
416
508
  select
417
- "id",
418
- "sort",
419
- "locales",
420
- "created_at",
421
- "updated_at",
422
- "fields"->>'title' as "title",
423
- "fields"->>'slug' as "slug",
424
- "fields"->>'excerpt' as "excerpt",
425
- "fields"->>'author' as "author"
426
- from "entries"
427
- where "collection" = 'articles' and "status" = 'published';
509
+ e."id",
510
+ l."locale",
511
+ l."slug",
512
+ e."sort",
513
+ e."created_at",
514
+ l."updated_at",
515
+ v."fields"->>'title' as "title",
516
+ v."fields"->>'excerpt' as "excerpt",
517
+ v."fields"->>'author' as "author"
518
+ from "entries" e
519
+ join "entry_locales" l on l."entry_id" = e."id"
520
+ join "entry_versions" v on v."id" = l."published_version_id"
521
+ where e."collection" = 'articles' and l."status" = 'published';
@@ -13,7 +13,7 @@
13
13
  "next": "^16.3.4",
14
14
  "react": "^19",
15
15
  "react-dom": "^19",
16
- "smoodly": "0.0.5",
16
+ "smoodly": "0.0.7",
17
17
  "next-intl": "^4.14.2"
18
18
  },
19
19
  "devDependencies": {
@@ -1,24 +1,31 @@
1
1
  import { f, smoodly } from "smoodly";
2
2
 
3
+ // A person's name is the same in every language; the role is translated.
4
+ // Shared fields live on the entry, .localized() ones on its locale rows
5
+ // (spec 2026-09-06 §7).
3
6
  export const people = smoodly.collection({
4
7
  name: "people",
5
8
  title: "People",
6
9
  titleField: "name",
7
10
  fields: {
8
11
  name: f.text(),
9
- role: f.text().optional(),
12
+ role: f.text().optional().localized(),
10
13
  },
11
14
  });
12
15
 
16
+ // Articles keep a version history per locale (`versions: true`): every
17
+ // save is a snapshot, publish moves a pointer, and the published site
18
+ // keeps serving the published snapshot while a draft moves on.
13
19
  export const articles = smoodly.collection({
14
20
  name: "articles",
15
21
  title: "Articles",
16
22
  titleField: "title",
17
23
  path: "posts",
24
+ versions: true,
18
25
  fields: {
19
- title: f.text(),
26
+ title: f.text().localized(),
20
27
  slug: f.slug({ from: "title" }),
21
- excerpt: f.text().optional(),
28
+ excerpt: f.text().optional().localized(),
22
29
  author: f.ref(() => people),
23
30
  },
24
31
  });
@@ -12,28 +12,34 @@ create table if not exists "pages" (
12
12
  "id" uuid primary key default gen_random_uuid(),
13
13
  "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
14
14
  "parent_id" uuid references "pages" ("id") on delete restrict,
15
- "slug" text not null,
16
- "title" text not null,
17
- "i18n" jsonb,
18
15
  "template" text,
19
- "status" text not null default 'draft',
20
16
  "sort" numeric,
21
- "locales" text[],
22
- "draft_version_id" uuid,
23
- "published_version_id" uuid,
24
17
  "created_at" timestamptz not null default now(),
25
18
  "updated_at" timestamptz not null default now()
26
19
  );
27
20
 
28
- -- The tree is the URL (spec 2026-09-05 §1): a node stores its own segment
29
- -- and its parent. Siblings are unique per segment; NULLS NOT DISTINCT so
30
- -- two root nodes cannot share one either. template null = folder.
31
- create unique index if not exists "pages_sibling_slug_key"
32
- on "pages" ("space_id", "parent_id", "slug") nulls not distinct;
33
-
21
+ -- The tree is the URL (spec 2026-09-05 §1): a node stores its parent and
22
+ -- its position; template null = folder. Everything a locale owns — the
23
+ -- node's own segment, title, publish state and version pointers lives
24
+ -- in page_locales, one row per locale the page exists in (spec 2026-09-06
25
+ -- §2). There is no sibling-slug index: URL uniqueness per locale is the
26
+ -- primary key of "paths", which the stores write inside the same operation.
34
27
  create index if not exists "pages_parent_idx"
35
28
  on "pages" ("space_id", "parent_id", "sort");
36
29
 
30
+ create table if not exists "page_locales" (
31
+ "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
32
+ "page_id" uuid not null references "pages" ("id") on delete cascade,
33
+ "locale" text not null,
34
+ "slug" text not null,
35
+ "title" text not null,
36
+ "status" text not null default 'draft',
37
+ "draft_version_id" uuid,
38
+ "published_version_id" uuid,
39
+ "updated_at" timestamptz not null default now(),
40
+ primary key ("page_id", "locale")
41
+ );
42
+
37
43
  -- Sibling reorder for pages: same shape as smoodly_reorder for entries.
38
44
  create or replace function "smoodly_reorder_pages"(p_ids uuid[]) returns void language sql as $$
39
45
  update "pages" pg set "sort" = u.ord
@@ -42,15 +48,21 @@ create or replace function "smoodly_reorder_pages"(p_ids uuid[]) returns void la
42
48
  and pg."space_id" = "smoodly_current_space"();
43
49
  $$;
44
50
 
51
+ -- A version belongs to ONE locale of one page: each locale has its own
52
+ -- draft, its own publish and its own history.
45
53
  create table if not exists "page_versions" (
46
54
  "id" uuid primary key default gen_random_uuid(),
47
55
  "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
48
56
  "page_id" uuid not null references "pages" ("id") on delete cascade,
57
+ "locale" text not null,
49
58
  "tree" jsonb not null,
50
59
  "created_by" text,
51
60
  "created_at" timestamptz not null default now()
52
61
  );
53
62
 
63
+ create index if not exists "page_versions_page_locale_idx"
64
+ on "page_versions" ("page_id", "locale", "created_at");
65
+
54
66
  create table if not exists "saved_sections" (
55
67
  "id" uuid primary key default gen_random_uuid(),
56
68
  "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
@@ -73,40 +85,58 @@ create table if not exists "entries" (
73
85
  "collection" text not null,
74
86
  "parent_id" uuid references "entries" ("id") on delete restrict,
75
87
  "fields" jsonb not null default '{}'::jsonb,
76
- "i18n" jsonb,
77
- "status" text not null default 'draft',
78
88
  "sort" numeric,
79
- "locales" text[],
80
89
  "created_at" timestamptz not null default now(),
81
90
  "updated_at" timestamptz not null default now()
82
91
  );
83
92
 
84
- -- Entry slugs are unique among SIBLINGS, like pages: one rule for every
85
- -- node in the tree, and the URL is the tree. NULLS NOT DISTINCT so two
86
- -- ROOT entries (parent_id null) cannot share a slug either. The partial
87
- -- predicate keeps absent, null, empty or non-string slugs out of the
88
- -- index entirely: with NULLS NOT DISTINCT a null slug would otherwise
89
- -- collide with the next null slug, and a collection with no slug field
90
- -- at all could hold only one entry per parent. jsonb_typeof(...) =
91
- -- 'string' is required because ->> renders ANY JSON scalar as text —
92
- -- a number, boolean or null slug would otherwise print as non-empty
93
- -- text and claim the index; only a non-empty string slug claims a URL,
94
- -- absent, null, empty or non-string slugs never conflict — the same
95
- -- rule as slugOf in paths.ts. A flat collection (every entry a root)
96
- -- therefore behaves exactly as per-collection uniqueness did.
97
- create unique index if not exists "entries_sibling_slug_key"
98
- on "entries" ("space_id", "collection", "parent_id", ("fields"->>'slug')) nulls not distinct
99
- where (jsonb_typeof("fields"->'slug') = 'string' and "fields"->>'slug' <> '');
100
-
101
- create index if not exists "entries_collection_status_idx"
102
- on "entries" ("space_id", "collection", "status");
103
-
93
+ -- The node holds identity, tree position and the SHARED fields every
94
+ -- field without .localized() (spec 2026-09-06 §7): a Person's photo is
95
+ -- the same in every language. Everything a locale owns its segment,
96
+ -- publish state, the .localized() fields and the version pointers
97
+ -- lives in entry_locales, one row per locale the entry exists in. There
98
+ -- is no sibling-slug index any more: URL uniqueness per locale is the
99
+ -- primary key of "paths", which the stores write inside the same
100
+ -- operation, and both adapters pre-check siblings per locale in code.
104
101
  create index if not exists "entries_collection_sort_idx"
105
102
  on "entries" ("space_id", "collection", "sort");
106
103
 
107
104
  create index if not exists "entries_parent_idx"
108
105
  on "entries" ("space_id", "collection", "parent_id");
109
106
 
107
+ create table if not exists "entry_locales" (
108
+ "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
109
+ "entry_id" uuid not null references "entries" ("id") on delete cascade,
110
+ "locale" text not null,
111
+ "slug" text,
112
+ "status" text not null default 'draft',
113
+ "fields" jsonb not null default '{}'::jsonb,
114
+ "draft_version_id" uuid,
115
+ "published_version_id" uuid,
116
+ "updated_at" timestamptz not null default now(),
117
+ primary key ("entry_id", "locale")
118
+ );
119
+
120
+ create index if not exists "entry_locales_status_idx"
121
+ on "entry_locales" ("space_id", "locale", "status");
122
+
123
+ -- A version belongs to ONE locale of one entry and stores the MERGED
124
+ -- fields (shared + localized + slug) as they were at that save, so
125
+ -- restoring an old Finnish version restores the shared fields too.
126
+ -- Rows exist only for collections registered with `versions: true`.
127
+ create table if not exists "entry_versions" (
128
+ "id" uuid primary key default gen_random_uuid(),
129
+ "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
130
+ "entry_id" uuid not null references "entries" ("id") on delete cascade,
131
+ "locale" text not null,
132
+ "fields" jsonb not null,
133
+ "created_by" text,
134
+ "created_at" timestamptz not null default now()
135
+ );
136
+
137
+ create index if not exists "entry_versions_entry_locale_idx"
138
+ on "entry_versions" ("entry_id", "locale", "created_at");
139
+
110
140
  -- v1 reorder (DESIGN.md §3 Manual ordering): rewrite sort 1..N in ONE
111
141
  -- statement. Deliberately does not touch updated_at — a drag must not
112
142
  -- make the whole collection look freshly edited. Scoped to the current
@@ -172,10 +202,13 @@ $$;
172
202
  -- (collectionSQL) run with security_invoker = true, so they are subject
173
203
  -- to this same smoodly_read policy rather than their owner's privileges.
174
204
  alter table "pages" enable row level security;
205
+ alter table "page_locales" enable row level security;
175
206
  alter table "page_versions" enable row level security;
176
207
  alter table "saved_sections" enable row level security;
177
208
  alter table "global_sections" enable row level security;
178
209
  alter table "entries" enable row level security;
210
+ alter table "entry_locales" enable row level security;
211
+ alter table "entry_versions" enable row level security;
179
212
  alter table "refs" enable row level security;
180
213
  alter table "paths" enable row level security;
181
214
 
@@ -272,8 +305,14 @@ begin
272
305
  end;
273
306
  $$;
274
307
 
308
+ create index if not exists "page_locales_space_idx" on "page_locales" ("space_id");
309
+
275
310
  create index if not exists "page_versions_space_idx" on "page_versions" ("space_id");
276
311
 
312
+ create index if not exists "entry_locales_space_idx" on "entry_locales" ("space_id");
313
+
314
+ create index if not exists "entry_versions_space_idx" on "entry_versions" ("space_id");
315
+
277
316
  create index if not exists "saved_sections_space_idx" on "saved_sections" ("space_id");
278
317
 
279
318
  create index if not exists "global_sections_space_idx" on "global_sections" ("space_id");
@@ -284,6 +323,10 @@ drop trigger if exists "smoodly_a_set_space" on "pages";
284
323
  create trigger "smoodly_a_set_space" before insert on "pages"
285
324
  for each row execute function "smoodly_set_space"();
286
325
 
326
+ drop trigger if exists "smoodly_a_set_space" on "page_locales";
327
+ create trigger "smoodly_a_set_space" before insert on "page_locales"
328
+ for each row execute function "smoodly_set_space"();
329
+
287
330
  drop trigger if exists "smoodly_a_set_space" on "page_versions";
288
331
  create trigger "smoodly_a_set_space" before insert on "page_versions"
289
332
  for each row execute function "smoodly_set_space"();
@@ -300,6 +343,14 @@ drop trigger if exists "smoodly_a_set_space" on "entries";
300
343
  create trigger "smoodly_a_set_space" before insert on "entries"
301
344
  for each row execute function "smoodly_set_space"();
302
345
 
346
+ drop trigger if exists "smoodly_a_set_space" on "entry_locales";
347
+ create trigger "smoodly_a_set_space" before insert on "entry_locales"
348
+ for each row execute function "smoodly_set_space"();
349
+
350
+ drop trigger if exists "smoodly_a_set_space" on "entry_versions";
351
+ create trigger "smoodly_a_set_space" before insert on "entry_versions"
352
+ for each row execute function "smoodly_set_space"();
353
+
303
354
  drop trigger if exists "smoodly_a_set_space" on "refs";
304
355
  create trigger "smoodly_a_set_space" before insert on "refs"
305
356
  for each row execute function "smoodly_set_space"();
@@ -316,6 +367,10 @@ drop trigger if exists "smoodly_b_row_cap" on "pages";
316
367
  create trigger "smoodly_b_row_cap" before insert on "pages"
317
368
  for each row execute function "smoodly_enforce_row_cap"();
318
369
 
370
+ drop trigger if exists "smoodly_b_row_cap" on "page_locales";
371
+ create trigger "smoodly_b_row_cap" before insert on "page_locales"
372
+ for each row execute function "smoodly_enforce_row_cap"();
373
+
319
374
  drop trigger if exists "smoodly_b_row_cap" on "page_versions";
320
375
  create trigger "smoodly_b_row_cap" before insert on "page_versions"
321
376
  for each row execute function "smoodly_enforce_row_cap"();
@@ -332,6 +387,14 @@ drop trigger if exists "smoodly_b_row_cap" on "entries";
332
387
  create trigger "smoodly_b_row_cap" before insert on "entries"
333
388
  for each row execute function "smoodly_enforce_row_cap"();
334
389
 
390
+ drop trigger if exists "smoodly_b_row_cap" on "entry_locales";
391
+ create trigger "smoodly_b_row_cap" before insert on "entry_locales"
392
+ for each row execute function "smoodly_enforce_row_cap"();
393
+
394
+ drop trigger if exists "smoodly_b_row_cap" on "entry_versions";
395
+ create trigger "smoodly_b_row_cap" before insert on "entry_versions"
396
+ for each row execute function "smoodly_enforce_row_cap"();
397
+
335
398
  drop trigger if exists "smoodly_b_row_cap" on "refs";
336
399
  create trigger "smoodly_b_row_cap" before insert on "refs"
337
400
  for each row execute function "smoodly_enforce_row_cap"();
@@ -347,6 +410,15 @@ create policy "smoodly_write" on "pages" for all
347
410
 
348
411
  drop policy if exists "smoodly_read" on "pages";
349
412
  create policy "smoodly_read" on "pages" for select
413
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and exists (select 1 from "page_locales" l where l."page_id" = "pages"."id" and l."status" = 'published'));
414
+
415
+ drop policy if exists "smoodly_write" on "page_locales";
416
+ create policy "smoodly_write" on "page_locales" for all
417
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
418
+ with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
419
+
420
+ drop policy if exists "smoodly_read" on "page_locales";
421
+ create policy "smoodly_read" on "page_locales" for select
350
422
  using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and "status" = 'published');
351
423
 
352
424
  drop policy if exists "smoodly_write" on "page_versions";
@@ -356,7 +428,7 @@ create policy "smoodly_write" on "page_versions" for all
356
428
 
357
429
  drop policy if exists "smoodly_read" on "page_versions";
358
430
  create policy "smoodly_read" on "page_versions" for select
359
- using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and exists (select 1 from "pages" p where p."published_version_id" = "page_versions"."id"));
431
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and exists (select 1 from "page_locales" l where l."published_version_id" = "page_versions"."id"));
360
432
 
361
433
  drop policy if exists "smoodly_write" on "saved_sections";
362
434
  create policy "smoodly_write" on "saved_sections" for all
@@ -379,8 +451,26 @@ create policy "smoodly_write" on "entries" for all
379
451
 
380
452
  drop policy if exists "smoodly_read" on "entries";
381
453
  create policy "smoodly_read" on "entries" for select
454
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and exists (select 1 from "entry_locales" l where l."entry_id" = "entries"."id" and l."status" = 'published'));
455
+
456
+ drop policy if exists "smoodly_write" on "entry_locales";
457
+ create policy "smoodly_write" on "entry_locales" for all
458
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
459
+ with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
460
+
461
+ drop policy if exists "smoodly_read" on "entry_locales";
462
+ create policy "smoodly_read" on "entry_locales" for select
382
463
  using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and "status" = 'published');
383
464
 
465
+ drop policy if exists "smoodly_write" on "entry_versions";
466
+ create policy "smoodly_write" on "entry_versions" for all
467
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
468
+ with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
469
+
470
+ drop policy if exists "smoodly_read" on "entry_versions";
471
+ create policy "smoodly_read" on "entry_versions" for select
472
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and exists (select 1 from "entry_locales" l where l."published_version_id" = "entry_versions"."id"));
473
+
384
474
  drop policy if exists "smoodly_write" on "refs";
385
475
  create policy "smoodly_write" on "refs" for all
386
476
  using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
@@ -393,7 +483,7 @@ create policy "smoodly_write" on "paths" for all
393
483
 
394
484
  drop policy if exists "smoodly_read" on "paths";
395
485
  create policy "smoodly_read" on "paths" for select
396
- using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (("kind" = 'page' and exists (select 1 from "pages" p where p."id" = "paths"."target_id" and p."status" = 'published')) or ("kind" = 'entry' and exists (select 1 from "entries" e where e."id" = "paths"."target_id" and e."status" = 'published'))));
486
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (("kind" = 'page' and exists (select 1 from "page_locales" l where l."page_id" = "paths"."target_id" and l."locale" = "paths"."locale" and l."status" = 'published')) or ("kind" = 'entry' and exists (select 1 from "entry_locales" l where l."entry_id" = "paths"."target_id" and l."locale" = "paths"."locale" and l."status" = 'published'))));
397
487
 
398
488
  drop policy if exists "smoodly_write" on "editors";
399
489
  create policy "smoodly_write" on "editors" for all
@@ -402,26 +492,30 @@ create policy "smoodly_write" on "editors" for all
402
492
 
403
493
  create or replace view "people" with (security_invoker = true) as
404
494
  select
405
- "id",
406
- "sort",
407
- "locales",
408
- "created_at",
409
- "updated_at",
410
- "fields"->>'name' as "name",
411
- "fields"->>'role' as "role"
412
- from "entries"
413
- where "collection" = 'people' and "status" = 'published';
495
+ e."id",
496
+ l."locale",
497
+ l."slug",
498
+ e."sort",
499
+ e."created_at",
500
+ l."updated_at",
501
+ e."fields"->>'name' as "name",
502
+ l."fields"->>'role' as "role"
503
+ from "entries" e
504
+ join "entry_locales" l on l."entry_id" = e."id"
505
+ where e."collection" = 'people' and l."status" = 'published';
414
506
 
415
507
  create or replace view "articles" with (security_invoker = true) as
416
508
  select
417
- "id",
418
- "sort",
419
- "locales",
420
- "created_at",
421
- "updated_at",
422
- "fields"->>'title' as "title",
423
- "fields"->>'slug' as "slug",
424
- "fields"->>'excerpt' as "excerpt",
425
- "fields"->>'author' as "author"
426
- from "entries"
427
- where "collection" = 'articles' and "status" = 'published';
509
+ e."id",
510
+ l."locale",
511
+ l."slug",
512
+ e."sort",
513
+ e."created_at",
514
+ l."updated_at",
515
+ v."fields"->>'title' as "title",
516
+ v."fields"->>'excerpt' as "excerpt",
517
+ v."fields"->>'author' as "author"
518
+ from "entries" e
519
+ join "entry_locales" l on l."entry_id" = e."id"
520
+ join "entry_versions" v on v."id" = l."published_version_id"
521
+ where e."collection" = 'articles' and l."status" = 'published';