create-smoodly-app 0.0.6 → 0.0.8

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.
@@ -0,0 +1,22 @@
1
+ // Shared content (spec 2026-09-07): code-declared singletons stored as
2
+ // entries. `footer` is visual — the layout renders it; `contact` is an
3
+ // object the layout reads; `testimonials` is visual and placed on pages.
4
+ import { f, smoodly } from "smoodly";
5
+ import { Footer } from "@/components/sections/Footer";
6
+ import { Testimonials } from "@/components/sections/Testimonials";
7
+
8
+ export const footer = smoodly.shared({ name: "footer", title: "Footer", section: Footer, versions: true });
9
+
10
+ export const contact = smoodly.shared({
11
+ name: "contact",
12
+ title: "Contact details",
13
+ fields: {
14
+ email: f.text(),
15
+ phone: f.text().optional(),
16
+ address: f.text().localized(),
17
+ },
18
+ });
19
+
20
+ export const testimonials = smoodly.shared({ name: "testimonials", title: "Testimonials", section: Testimonials });
21
+
22
+ export const shared = [footer, contact, testimonials];
@@ -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,30 +48,20 @@ 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
 
54
- create table if not exists "saved_sections" (
55
- "id" uuid primary key default gen_random_uuid(),
56
- "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
57
- "title" text not null,
58
- "fragment" jsonb not null,
59
- "created_at" timestamptz not null default now()
60
- );
61
-
62
- create table if not exists "global_sections" (
63
- "id" uuid primary key default gen_random_uuid(),
64
- "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
65
- "type" text not null,
66
- "content" jsonb not null,
67
- "updated_at" timestamptz not null default now()
68
- );
63
+ create index if not exists "page_versions_page_locale_idx"
64
+ on "page_versions" ("page_id", "locale", "created_at");
69
65
 
70
66
  create table if not exists "entries" (
71
67
  "id" uuid primary key default gen_random_uuid(),
@@ -73,40 +69,58 @@ create table if not exists "entries" (
73
69
  "collection" text not null,
74
70
  "parent_id" uuid references "entries" ("id") on delete restrict,
75
71
  "fields" jsonb not null default '{}'::jsonb,
76
- "i18n" jsonb,
77
- "status" text not null default 'draft',
78
72
  "sort" numeric,
79
- "locales" text[],
80
73
  "created_at" timestamptz not null default now(),
81
74
  "updated_at" timestamptz not null default now()
82
75
  );
83
76
 
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
-
77
+ -- The node holds identity, tree position and the SHARED fields — every
78
+ -- field without .localized() (spec 2026-09-06 §7): a Person's photo is
79
+ -- the same in every language. Everything a locale owns — its segment,
80
+ -- publish state, the .localized() fields and the version pointers —
81
+ -- lives in entry_locales, one row per locale the entry exists in. There
82
+ -- is no sibling-slug index any more: URL uniqueness per locale is the
83
+ -- primary key of "paths", which the stores write inside the same
84
+ -- operation, and both adapters pre-check siblings per locale in code.
104
85
  create index if not exists "entries_collection_sort_idx"
105
86
  on "entries" ("space_id", "collection", "sort");
106
87
 
107
88
  create index if not exists "entries_parent_idx"
108
89
  on "entries" ("space_id", "collection", "parent_id");
109
90
 
91
+ create table if not exists "entry_locales" (
92
+ "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
93
+ "entry_id" uuid not null references "entries" ("id") on delete cascade,
94
+ "locale" text not null,
95
+ "slug" text,
96
+ "status" text not null default 'draft',
97
+ "fields" jsonb not null default '{}'::jsonb,
98
+ "draft_version_id" uuid,
99
+ "published_version_id" uuid,
100
+ "updated_at" timestamptz not null default now(),
101
+ primary key ("entry_id", "locale")
102
+ );
103
+
104
+ create index if not exists "entry_locales_status_idx"
105
+ on "entry_locales" ("space_id", "locale", "status");
106
+
107
+ -- A version belongs to ONE locale of one entry and stores the MERGED
108
+ -- fields (shared + localized + slug) as they were at that save, so
109
+ -- restoring an old Finnish version restores the shared fields too.
110
+ -- Rows exist only for collections registered with `versions: true`.
111
+ create table if not exists "entry_versions" (
112
+ "id" uuid primary key default gen_random_uuid(),
113
+ "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
114
+ "entry_id" uuid not null references "entries" ("id") on delete cascade,
115
+ "locale" text not null,
116
+ "fields" jsonb not null,
117
+ "created_by" text,
118
+ "created_at" timestamptz not null default now()
119
+ );
120
+
121
+ create index if not exists "entry_versions_entry_locale_idx"
122
+ on "entry_versions" ("entry_id", "locale", "created_at");
123
+
110
124
  -- v1 reorder (DESIGN.md §3 Manual ordering): rewrite sort 1..N in ONE
111
125
  -- statement. Deliberately does not touch updated_at — a drag must not
112
126
  -- make the whole collection look freshly edited. Scoped to the current
@@ -172,10 +186,11 @@ $$;
172
186
  -- (collectionSQL) run with security_invoker = true, so they are subject
173
187
  -- to this same smoodly_read policy rather than their owner's privileges.
174
188
  alter table "pages" enable row level security;
189
+ alter table "page_locales" enable row level security;
175
190
  alter table "page_versions" enable row level security;
176
- alter table "saved_sections" enable row level security;
177
- alter table "global_sections" enable row level security;
178
191
  alter table "entries" enable row level security;
192
+ alter table "entry_locales" enable row level security;
193
+ alter table "entry_versions" enable row level security;
179
194
  alter table "refs" enable row level security;
180
195
  alter table "paths" enable row level security;
181
196
 
@@ -272,11 +287,13 @@ begin
272
287
  end;
273
288
  $$;
274
289
 
290
+ create index if not exists "page_locales_space_idx" on "page_locales" ("space_id");
291
+
275
292
  create index if not exists "page_versions_space_idx" on "page_versions" ("space_id");
276
293
 
277
- create index if not exists "saved_sections_space_idx" on "saved_sections" ("space_id");
294
+ create index if not exists "entry_locales_space_idx" on "entry_locales" ("space_id");
278
295
 
279
- create index if not exists "global_sections_space_idx" on "global_sections" ("space_id");
296
+ create index if not exists "entry_versions_space_idx" on "entry_versions" ("space_id");
280
297
 
281
298
  create index if not exists "refs_space_idx" on "refs" ("space_id");
282
299
 
@@ -284,20 +301,24 @@ drop trigger if exists "smoodly_a_set_space" on "pages";
284
301
  create trigger "smoodly_a_set_space" before insert on "pages"
285
302
  for each row execute function "smoodly_set_space"();
286
303
 
304
+ drop trigger if exists "smoodly_a_set_space" on "page_locales";
305
+ create trigger "smoodly_a_set_space" before insert on "page_locales"
306
+ for each row execute function "smoodly_set_space"();
307
+
287
308
  drop trigger if exists "smoodly_a_set_space" on "page_versions";
288
309
  create trigger "smoodly_a_set_space" before insert on "page_versions"
289
310
  for each row execute function "smoodly_set_space"();
290
311
 
291
- drop trigger if exists "smoodly_a_set_space" on "saved_sections";
292
- create trigger "smoodly_a_set_space" before insert on "saved_sections"
312
+ drop trigger if exists "smoodly_a_set_space" on "entries";
313
+ create trigger "smoodly_a_set_space" before insert on "entries"
293
314
  for each row execute function "smoodly_set_space"();
294
315
 
295
- drop trigger if exists "smoodly_a_set_space" on "global_sections";
296
- create trigger "smoodly_a_set_space" before insert on "global_sections"
316
+ drop trigger if exists "smoodly_a_set_space" on "entry_locales";
317
+ create trigger "smoodly_a_set_space" before insert on "entry_locales"
297
318
  for each row execute function "smoodly_set_space"();
298
319
 
299
- drop trigger if exists "smoodly_a_set_space" on "entries";
300
- create trigger "smoodly_a_set_space" before insert on "entries"
320
+ drop trigger if exists "smoodly_a_set_space" on "entry_versions";
321
+ create trigger "smoodly_a_set_space" before insert on "entry_versions"
301
322
  for each row execute function "smoodly_set_space"();
302
323
 
303
324
  drop trigger if exists "smoodly_a_set_space" on "refs";
@@ -316,20 +337,24 @@ drop trigger if exists "smoodly_b_row_cap" on "pages";
316
337
  create trigger "smoodly_b_row_cap" before insert on "pages"
317
338
  for each row execute function "smoodly_enforce_row_cap"();
318
339
 
340
+ drop trigger if exists "smoodly_b_row_cap" on "page_locales";
341
+ create trigger "smoodly_b_row_cap" before insert on "page_locales"
342
+ for each row execute function "smoodly_enforce_row_cap"();
343
+
319
344
  drop trigger if exists "smoodly_b_row_cap" on "page_versions";
320
345
  create trigger "smoodly_b_row_cap" before insert on "page_versions"
321
346
  for each row execute function "smoodly_enforce_row_cap"();
322
347
 
323
- drop trigger if exists "smoodly_b_row_cap" on "saved_sections";
324
- create trigger "smoodly_b_row_cap" before insert on "saved_sections"
348
+ drop trigger if exists "smoodly_b_row_cap" on "entries";
349
+ create trigger "smoodly_b_row_cap" before insert on "entries"
325
350
  for each row execute function "smoodly_enforce_row_cap"();
326
351
 
327
- drop trigger if exists "smoodly_b_row_cap" on "global_sections";
328
- create trigger "smoodly_b_row_cap" before insert on "global_sections"
352
+ drop trigger if exists "smoodly_b_row_cap" on "entry_locales";
353
+ create trigger "smoodly_b_row_cap" before insert on "entry_locales"
329
354
  for each row execute function "smoodly_enforce_row_cap"();
330
355
 
331
- drop trigger if exists "smoodly_b_row_cap" on "entries";
332
- create trigger "smoodly_b_row_cap" before insert on "entries"
356
+ drop trigger if exists "smoodly_b_row_cap" on "entry_versions";
357
+ create trigger "smoodly_b_row_cap" before insert on "entry_versions"
333
358
  for each row execute function "smoodly_enforce_row_cap"();
334
359
 
335
360
  drop trigger if exists "smoodly_b_row_cap" on "refs";
@@ -347,6 +372,15 @@ create policy "smoodly_write" on "pages" for all
347
372
 
348
373
  drop policy if exists "smoodly_read" on "pages";
349
374
  create policy "smoodly_read" on "pages" for select
375
+ 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'));
376
+
377
+ drop policy if exists "smoodly_write" on "page_locales";
378
+ create policy "smoodly_write" on "page_locales" for all
379
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
380
+ with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
381
+
382
+ drop policy if exists "smoodly_read" on "page_locales";
383
+ create policy "smoodly_read" on "page_locales" for select
350
384
  using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and "status" = 'published');
351
385
 
352
386
  drop policy if exists "smoodly_write" on "page_versions";
@@ -356,30 +390,34 @@ create policy "smoodly_write" on "page_versions" for all
356
390
 
357
391
  drop policy if exists "smoodly_read" on "page_versions";
358
392
  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"));
393
+ 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
394
 
361
- drop policy if exists "smoodly_write" on "saved_sections";
362
- create policy "smoodly_write" on "saved_sections" for all
395
+ drop policy if exists "smoodly_write" on "entries";
396
+ create policy "smoodly_write" on "entries" for all
363
397
  using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
364
398
  with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
365
399
 
366
- drop policy if exists "smoodly_write" on "global_sections";
367
- create policy "smoodly_write" on "global_sections" for all
400
+ drop policy if exists "smoodly_read" on "entries";
401
+ create policy "smoodly_read" on "entries" for select
402
+ 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'));
403
+
404
+ drop policy if exists "smoodly_write" on "entry_locales";
405
+ create policy "smoodly_write" on "entry_locales" for all
368
406
  using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
369
407
  with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
370
408
 
371
- drop policy if exists "smoodly_read" on "global_sections";
372
- create policy "smoodly_read" on "global_sections" for select
373
- using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()));
409
+ drop policy if exists "smoodly_read" on "entry_locales";
410
+ create policy "smoodly_read" on "entry_locales" for select
411
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and "status" = 'published');
374
412
 
375
- drop policy if exists "smoodly_write" on "entries";
376
- create policy "smoodly_write" on "entries" for all
413
+ drop policy if exists "smoodly_write" on "entry_versions";
414
+ create policy "smoodly_write" on "entry_versions" for all
377
415
  using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
378
416
  with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
379
417
 
380
- drop policy if exists "smoodly_read" on "entries";
381
- create policy "smoodly_read" on "entries" for select
382
- using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and "status" = 'published');
418
+ drop policy if exists "smoodly_read" on "entry_versions";
419
+ create policy "smoodly_read" on "entry_versions" for select
420
+ 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"));
383
421
 
384
422
  drop policy if exists "smoodly_write" on "refs";
385
423
  create policy "smoodly_write" on "refs" for all
@@ -393,7 +431,7 @@ create policy "smoodly_write" on "paths" for all
393
431
 
394
432
  drop policy if exists "smoodly_read" on "paths";
395
433
  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'))));
434
+ 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
435
 
398
436
  drop policy if exists "smoodly_write" on "editors";
399
437
  create policy "smoodly_write" on "editors" for all
@@ -402,26 +440,30 @@ create policy "smoodly_write" on "editors" for all
402
440
 
403
441
  create or replace view "people" with (security_invoker = true) as
404
442
  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';
443
+ e."id",
444
+ l."locale",
445
+ l."slug",
446
+ e."sort",
447
+ e."created_at",
448
+ l."updated_at",
449
+ e."fields"->>'name' as "name",
450
+ l."fields"->>'role' as "role"
451
+ from "entries" e
452
+ join "entry_locales" l on l."entry_id" = e."id"
453
+ where e."collection" = 'people' and l."status" = 'published';
414
454
 
415
455
  create or replace view "articles" with (security_invoker = true) as
416
456
  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';
457
+ e."id",
458
+ l."locale",
459
+ l."slug",
460
+ e."sort",
461
+ e."created_at",
462
+ l."updated_at",
463
+ v."fields"->>'title' as "title",
464
+ v."fields"->>'excerpt' as "excerpt",
465
+ v."fields"->>'author' as "author"
466
+ from "entries" e
467
+ join "entry_locales" l on l."entry_id" = e."id"
468
+ join "entry_versions" v on v."id" = l."published_version_id"
469
+ where e."collection" = 'articles' and l."status" = 'published';