smoodly 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.
Files changed (70) hide show
  1. package/README.md +318 -74
  2. package/dist/admin/client-ops.js +3 -2
  3. package/dist/admin/editor/EditorView.js +229 -30
  4. package/dist/admin/editor/PageSettings.d.ts +8 -4
  5. package/dist/admin/editor/PageSettings.js +25 -25
  6. package/dist/admin/fixed-nodes.d.ts +16 -10
  7. package/dist/admin/fixed-nodes.js +55 -41
  8. package/dist/admin/index.d.ts +3 -2
  9. package/dist/admin/index.js +1 -0
  10. package/dist/admin/ops-impl.js +218 -36
  11. package/dist/admin/ops.d.ts +73 -12
  12. package/dist/admin/serialize.d.ts +11 -0
  13. package/dist/admin/serialize.js +8 -0
  14. package/dist/admin/shared-items.d.ts +9 -0
  15. package/dist/admin/shared-items.js +61 -0
  16. package/dist/admin/shell/AdminApp.js +11 -34
  17. package/dist/admin/shell/EntriesList.d.ts +7 -0
  18. package/dist/admin/shell/EntriesList.js +95 -0
  19. package/dist/admin/shell/EntryForm.js +167 -49
  20. package/dist/admin/shell/PagesList.js +86 -26
  21. package/dist/admin/shell/SharedForm.d.ts +7 -0
  22. package/dist/admin/shell/SharedForm.js +146 -0
  23. package/dist/admin/shell/SharedList.d.ts +6 -0
  24. package/dist/admin/shell/SharedList.js +62 -0
  25. package/dist/admin/shell/entries-list.d.ts +21 -0
  26. package/dist/admin/shell/entries-list.js +36 -0
  27. package/dist/admin/shell/pages-tree.d.ts +30 -12
  28. package/dist/admin/shell/pages-tree.js +48 -14
  29. package/dist/admin/shell/shared-list.d.ts +14 -0
  30. package/dist/admin/shell/shared-list.js +17 -0
  31. package/dist/admin/tree-ops.d.ts +12 -5
  32. package/dist/admin/tree-ops.js +39 -8
  33. package/dist/admin/ui/LocaleSwitcher.d.ts +11 -0
  34. package/dist/admin/ui/LocaleSwitcher.js +15 -0
  35. package/dist/collections.d.ts +10 -1
  36. package/dist/collections.js +16 -0
  37. package/dist/config.d.ts +3 -0
  38. package/dist/config.js +58 -4
  39. package/dist/entry-store.d.ts +141 -55
  40. package/dist/entry-store.js +317 -87
  41. package/dist/index.d.ts +11 -7
  42. package/dist/index.js +8 -5
  43. package/dist/localize.d.ts +0 -11
  44. package/dist/localize.js +11 -29
  45. package/dist/next/page-renderer.d.ts +4 -0
  46. package/dist/next/page-renderer.js +14 -1
  47. package/dist/page.js +5 -1
  48. package/dist/paths.d.ts +46 -27
  49. package/dist/paths.js +62 -29
  50. package/dist/refs.js +8 -0
  51. package/dist/resolve.d.ts +3 -0
  52. package/dist/resolve.js +38 -0
  53. package/dist/revalidate.d.ts +4 -0
  54. package/dist/revalidate.js +6 -1
  55. package/dist/shared.d.ts +46 -0
  56. package/dist/shared.js +36 -0
  57. package/dist/site.d.ts +16 -2
  58. package/dist/site.js +92 -39
  59. package/dist/sql-space.d.ts +1 -1
  60. package/dist/sql-space.js +16 -13
  61. package/dist/sql.js +95 -70
  62. package/dist/store.d.ts +66 -32
  63. package/dist/store.js +205 -90
  64. package/dist/supabase-entry-store.d.ts +30 -9
  65. package/dist/supabase-entry-store.js +360 -178
  66. package/dist/supabase-store.d.ts +21 -9
  67. package/dist/supabase-store.js +202 -111
  68. package/dist/zones.d.ts +6 -2
  69. package/dist/zones.js +8 -2
  70. package/package.json +1 -1
package/dist/site.d.ts CHANGED
@@ -3,6 +3,7 @@ import { type EntryRecord, type EntryStore } from "./entry-store.ts";
3
3
  import { type LocaleSet, type PathTarget } from "./paths.ts";
4
4
  import type { SectionLike } from "./refs.ts";
5
5
  import type { PageContext, PageLink, PageTree } from "./render.tsx";
6
+ import { type SharedSchema } from "./shared.ts";
6
7
  import type { PageRecord, PageStore } from "./store.ts";
7
8
  export type SiteOptions = {
8
9
  stores: () => {
@@ -11,6 +12,7 @@ export type SiteOptions = {
11
12
  };
12
13
  collections: CollectionSchema<any>[];
13
14
  sections: SectionLike[];
15
+ shared?: SharedSchema[];
14
16
  locales: LocaleSet;
15
17
  homePageSlug?: string;
16
18
  };
@@ -23,13 +25,17 @@ export type SitePage = {
23
25
  path: string;
24
26
  ancestors: PageLink[];
25
27
  children: PageLink[];
26
- /** locale → path, for metadata alternates. */
28
+ /** locale → path, for metadata alternates: the locales the page is
29
+ * PUBLISHED in, since that is what may be advertised — draft mode
30
+ * carries every locale the page exists in. */
27
31
  paths: Record<string, string>;
28
32
  };
29
33
  export type SiteEntry = {
30
34
  kind: "entry";
31
35
  collection: string;
32
- /** `fields` are localized for the requested locale with refs resolved. */
36
+ /** `fields` are the locale's MERGED fields — shared + localized + slug —
37
+ * with refs resolved: the published snapshot on the public path, the
38
+ * live row in draft mode. */
33
39
  entry: EntryRecord;
34
40
  path: string;
35
41
  paths: Record<string, string>;
@@ -78,6 +84,14 @@ export interface SmoodlySite {
78
84
  getEntriesTree(collection: string, options?: {
79
85
  locale?: string;
80
86
  }): Promise<EntryTreeItem[]>;
87
+ /** A shared item's content in a locale (spec 2026-09-07 §4): an object
88
+ * item's merged fields, or a visual item's section props (fields plus
89
+ * `styles`), refs hydrated; null when the item has no row there or is
90
+ * unpublished (outside draft mode). */
91
+ getShared<V>(item: SharedSchema<V>, options?: {
92
+ locale?: string;
93
+ draft?: boolean;
94
+ }): Promise<V | null>;
81
95
  }
82
96
  /** The `page` prop for a loaded page. `toUrl` is the app's `href` seam
83
97
  * bound to the render locale; the default keeps CMS paths as URLs. */
package/dist/site.js CHANGED
@@ -1,7 +1,7 @@
1
- import { schemaOf } from "./entry-store.js";
2
- import { localizeFields, titleFor } from "./localize.js";
3
- import { buildPageTree, chainOf, entryPathRows, entrySegment, localesOf, pagePath, } from "./paths.js";
1
+ import { entryFieldsIn, schemaOf } from "./entry-store.js";
2
+ import { buildPageTree, chainOf, entryPathRows, entrySegment, pagePath, } from "./paths.js";
4
3
  import { resolveFields, resolveTree } from "./resolve.js";
4
+ import { STYLES_KEY } from "./shared.js";
5
5
  /** The `page` prop for a loaded page. `toUrl` is the app's `href` seam
6
6
  * bound to the render locale; the default keeps CMS paths as URLs. */
7
7
  export function pageContextOf(hit, toUrl = (p) => p) {
@@ -20,42 +20,64 @@ export function createSmoodlySite(options) {
20
20
  const homePageSlug = options.homePageSlug ?? "home";
21
21
  const pathOpts = { locales, homePageSlug };
22
22
  const mounted = options.collections.filter((c) => c.path !== undefined);
23
- const fetcher = (draft) => (collection, ids) => options.stores().entries.getMany(collection, ids, draft ? undefined : { status: "published" });
24
- /** Visible to the public: folders and published pages. Draft mode sees every node. */
25
- const visible = (node, draft) => draft || node.template === null || node.publishedVersionId !== null;
26
- const hasPage = (node, draft) => node.template !== null && (draft || node.publishedVersionId !== null);
23
+ /** The resolver's fetcher, bound to a locale: the merged fields of that
24
+ * locale — published ones (a versioned collection's snapshot) on the
25
+ * public path, the live rows in draft mode. */
26
+ const fetcher = (draft, locale) => (collection, ids) => options.stores().entries.getMany(collection, ids, locale, draft ? undefined : { status: "published" });
27
+ /** Visible to the public in a locale: present there, and a folder or a
28
+ * published page. Draft mode sees every present node. Presence is the
29
+ * locale row — nothing falls back (spec 2026-09-06 §3). */
30
+ const visible = (node, locale, draft) => {
31
+ const row = node.locales[locale];
32
+ return row !== undefined && (draft || node.template === null || row.status === "published");
33
+ };
34
+ const hasPage = (node, locale, draft) => {
35
+ const row = node.locales[locale];
36
+ return node.template !== null && row !== undefined && (draft || row.status === "published");
37
+ };
38
+ /** Only ever called for a node PRESENT in the locale (visible() or an ancestor of one). */
27
39
  const link = (node, byId, locale, draft) => ({
28
- title: titleFor(node, locale, locales),
40
+ title: node.locales[locale].title,
29
41
  path: pagePath(node, byId, locale, pathOpts),
30
- hasPage: hasPage(node, draft),
42
+ hasPage: hasPage(node, locale, draft),
31
43
  });
32
44
  async function loadPage(id, locale, path, draft) {
33
45
  const { pages } = options.stores();
34
46
  const record = await pages.getPage(id);
35
47
  if (!record || record.template === null)
36
48
  return null;
37
- const raw = draft ? await pages.getDraftTree(id) : record.publishedVersionId ? await pages.getPublishedTree(id) : null;
49
+ const row = record.locales[locale];
50
+ if (!row)
51
+ return null;
52
+ const raw = draft ? await pages.getDraftTree(id, locale) : row.status === "published" ? await pages.getPublishedTree(id, locale) : null;
38
53
  if (!raw)
39
54
  return null;
40
- const tree = await resolveTree(raw, { sections: options.sections, collections: options.collections, fetch: fetcher(draft) });
55
+ const tree = await resolveTree(raw, {
56
+ sections: options.sections,
57
+ collections: options.collections,
58
+ shared: options.shared,
59
+ fetch: fetcher(draft, locale),
60
+ });
41
61
  const all = await pages.listPages();
42
62
  const index = new Map(all.map((n) => [n.id, n]));
43
63
  const byId = (x) => index.get(x);
44
64
  const chain = chainOf(record, byId);
45
65
  const ancestors = chain.slice(0, -1).map((n) => link(n, byId, locale, draft));
46
66
  const children = all
47
- .filter((n) => n.parentId === id && visible(n, draft) && localesOf(n, locales).includes(locale))
67
+ .filter((n) => n.parentId === id && visible(n, locale, draft))
48
68
  .map((n) => link(n, byId, locale, draft));
69
+ // Alternates advertise PUBLISHED locales only; the editor (draft) sees every present one.
70
+ const paths = Object.fromEntries(Object.entries(await pages.pathsOf(id)).filter(([l]) => draft || record.locales[l]?.status === "published"));
49
71
  return {
50
72
  kind: "page",
51
73
  record,
52
74
  template: record.template,
53
75
  tree,
54
- title: titleFor(record, locale, locales),
76
+ title: row.title,
55
77
  path,
56
78
  ancestors,
57
79
  children,
58
- paths: await pages.pathsOf(id),
80
+ paths,
59
81
  };
60
82
  }
61
83
  async function loadEntry(id, locale, path, draft) {
@@ -64,53 +86,63 @@ export function createSmoodlySite(options) {
64
86
  const record = await entries.get(schema.name, id);
65
87
  if (!record)
66
88
  continue;
67
- if (!draft && record.status !== "published")
89
+ // No row, or a draft row outside draft mode, is a 404 for THIS locale.
90
+ const row = record.locales[locale];
91
+ if (!row)
68
92
  return null;
69
- const fields = await resolveFields(localizeFields(record.fields, record.i18n, locale, locales), schema.fields, {
70
- collections: options.collections,
71
- fetch: fetcher(draft),
72
- });
73
- return {
74
- kind: "entry",
75
- collection: schema.name,
76
- entry: { ...record, fields },
77
- path,
78
- paths: await entries.pathsOf(schema.name, id),
79
- };
93
+ if (!draft && row.status !== "published")
94
+ return null;
95
+ const raw = draft
96
+ ? entryFieldsIn(record, locale)
97
+ : (await entries.getMany(schema.name, [id], locale, { status: "published" }))[id];
98
+ if (!raw)
99
+ return null;
100
+ const fields = await resolveFields(raw, schema.fields, { collections: options.collections, fetch: fetcher(draft, locale) });
101
+ // Alternates advertise PUBLISHED locales only; the editor (draft) sees every present one.
102
+ const paths = Object.fromEntries(Object.entries(await entries.pathsOf(schema.name, id)).filter(([l]) => draft || record.locales[l]?.status === "published"));
103
+ return { kind: "entry", collection: schema.name, entry: { ...record, fields }, path, paths };
80
104
  }
81
105
  return null;
82
106
  }
83
107
  const localeOf = (o) => o?.locale ?? locales.default;
84
- /** Localized fields and the locale's path for entries of one collection;
85
- * `all` is the whole collection (drafts included: a draft ancestor
86
- * still contributes its segment). */
87
- function entryItems(schema, wanted, all, locale) {
108
+ /** One locale's published fields and path for entries of one collection;
109
+ * `all` is the whole collection (a draft ancestor still contributes
110
+ * its segment). `fieldsById` is the published read for the locale. */
111
+ function entryItems(schema, wanted, all, locale, fieldsById) {
88
112
  const index = new Map(all.map((e) => [e.id, e]));
89
113
  const byId = (x) => index.get(x);
90
- return wanted.map((record) => ({
91
- entry: { ...record, fields: localizeFields(record.fields, record.i18n, locale, locales) },
114
+ return wanted
115
+ .filter((record) => fieldsById[record.id] !== undefined)
116
+ .map((record) => ({
117
+ entry: { ...record, fields: fieldsById[record.id] },
92
118
  path: entryPathRows([record], byId, schema, locales).find((r) => r.locale === locale)?.path ?? null,
93
119
  }));
94
120
  }
95
- const publicEntry = (e, locale) => e.status === "published" && localesOf(e, locales).includes(locale);
121
+ /** Public in a locale: its row exists there and is published. */
122
+ const publicEntry = (e, locale) => e.locales[locale]?.status === "published";
96
123
  async function getEntries(collection, o = {}) {
97
124
  const schema = schemaOf(options.collections, collection);
98
125
  const locale = localeOf(o);
99
126
  const { entries } = options.stores();
100
127
  const all = await entries.list(collection, { order: o.order });
101
128
  const wanted = all.filter((e) => publicEntry(e, locale) && (o.parent === undefined || e.parentId === o.parent));
102
- return entryItems(schema, wanted, all, locale);
129
+ const published = await entries.getMany(collection, wanted.map((e) => e.id), locale, { status: "published" });
130
+ return entryItems(schema, wanted, all, locale, published);
103
131
  }
104
132
  const api = {
105
133
  async getPagesTree(o) {
106
134
  const locale = localeOf(o);
107
135
  const all = await options.stores().pages.listPages();
108
- const shown = all.filter((n) => visible(n, false) && localesOf(n, locales).includes(locale));
136
+ const shown = all.filter((n) => visible(n, locale, false));
137
+ const index = new Map(shown.map((n) => [n.id, n]));
109
138
  const toItem = (node) => ({
110
139
  id: node.record.id,
111
- title: titleFor(node.record, locale, locales),
112
- path: node.path,
113
- hasPage: hasPage(node.record, false),
140
+ title: node.record.locales[locale].title,
141
+ // Every node the walk reaches is present with a present chain, so
142
+ // buildPageTree's nullable path is never null here; pagePath is
143
+ // the same derivation and keeps the type honest.
144
+ path: node.path ?? pagePath(node.record, (x) => index.get(x), locale, pathOpts),
145
+ hasPage: hasPage(node.record, locale, false),
114
146
  children: node.children.map(toItem),
115
147
  });
116
148
  return buildPageTree(shown, locale, pathOpts).map(toItem);
@@ -138,13 +170,34 @@ export function createSmoodlySite(options) {
138
170
  // A bare slug names a ROOT entry only (sibling-scoped slugs, spec §4):
139
171
  // match the localized slug directly (paths.ts owns slug derivation),
140
172
  // regardless of whether the collection even has a `path`.
141
- return items.find((i) => i.entry.parentId === null && entrySegment(i.entry, locale, locales) === idOrPath) ?? null;
173
+ return items.find((i) => i.entry.parentId === null && entrySegment(i.entry, locale) === idOrPath) ?? null;
142
174
  },
143
175
  async getEntriesTree(collection, o) {
144
176
  const items = await getEntries(collection, { locale: localeOf(o) });
145
177
  const build = (parentId) => items.filter((i) => i.entry.parentId === parentId).map((i) => ({ ...i, children: build(i.entry.id) }));
146
178
  return build(null);
147
179
  },
180
+ async getShared(item, o) {
181
+ const locale = localeOf(o);
182
+ const draft = o?.draft ?? false;
183
+ const { entries } = options.stores();
184
+ const [record] = await entries.list(item.name);
185
+ if (!record)
186
+ return null;
187
+ const row = record.locales[locale];
188
+ if (!row || (!draft && row.status !== "published"))
189
+ return null;
190
+ const raw = draft
191
+ ? entryFieldsIn(record, locale)
192
+ : (await entries.getMany(item.name, [record.id], locale, { status: "published" }))[record.id];
193
+ if (!raw)
194
+ return null;
195
+ const fields = await resolveFields(raw, item.fields, { collections: options.collections, fetch: fetcher(draft, locale) });
196
+ if (!item.section)
197
+ return fields;
198
+ const { [STYLES_KEY]: styles, ...rest } = fields;
199
+ return { ...rest, styles: styles !== null && typeof styles === "object" ? styles : {} };
200
+ },
148
201
  };
149
202
  const site = {
150
203
  locales,
@@ -1,6 +1,6 @@
1
1
  export declare const DEFAULT_SPACE = "'00000000-0000-0000-0000-000000000000'";
2
2
  export declare const SPACE_COL = "\"space_id\" uuid not null default '00000000-0000-0000-0000-000000000000'";
3
- export declare const CONTENT_TABLES: readonly ["pages", "page_versions", "saved_sections", "global_sections", "entries", "refs", "paths"];
3
+ export declare const CONTENT_TABLES: readonly ["pages", "page_locales", "page_versions", "entries", "entry_locales", "entry_versions", "refs", "paths"];
4
4
  export type ContentTable = (typeof CONTENT_TABLES)[number];
5
5
  export declare function currentSpaceSQL(): string;
6
6
  export declare function spaceLayerSQL(): string;
package/dist/sql-space.js CHANGED
@@ -6,10 +6,11 @@ export const DEFAULT_SPACE = `'00000000-0000-0000-0000-000000000000'`;
6
6
  export const SPACE_COL = `"space_id" uuid not null default ${DEFAULT_SPACE}`;
7
7
  export const CONTENT_TABLES = [
8
8
  "pages",
9
+ "page_locales",
9
10
  "page_versions",
10
- "saved_sections",
11
- "global_sections",
12
11
  "entries",
12
+ "entry_locales",
13
+ "entry_versions",
13
14
  "refs",
14
15
  "paths",
15
16
  ];
@@ -24,22 +25,24 @@ language sql stable as $$
24
25
  $$;`.trim();
25
26
  }
26
27
  /** Which rows a read key (or the anon key, which has no claim) may see.
27
- * null = write keys only — refs and the section library are editor
28
- * tooling; editors is admission data; nothing a browser needs. The
29
- * empty string means "read policy, no extra predicate" (global_sections
30
- * is fully readable once it's in-space) — distinct from null. */
28
+ * null = write keys only — refs is editor tooling; editors is admission
29
+ * data; nothing a browser needs. Shared content (spec 2026-09-07) rides
30
+ * the three entry predicates: it is stored as entries. */
31
31
  const PUBLISHED_READ = {
32
- pages: `"status" = 'published'`,
33
- entries: `"status" = 'published'`,
34
- page_versions: `exists (select 1 from "pages" p where p."published_version_id" = "page_versions"."id")`,
35
- global_sections: ``,
36
- saved_sections: null,
32
+ pages: `exists (select 1 from "page_locales" l where l."page_id" = "pages"."id" and l."status" = 'published')`,
33
+ page_locales: `"status" = 'published'`,
34
+ page_versions: `exists (select 1 from "page_locales" l where l."published_version_id" = "page_versions"."id")`,
35
+ entries: `exists (select 1 from "entry_locales" l where l."entry_id" = "entries"."id" and l."status" = 'published')`,
36
+ entry_locales: `"status" = 'published'`,
37
+ entry_versions: `exists (select 1 from "entry_locales" l where l."published_version_id" = "entry_versions"."id")`,
37
38
  refs: null,
38
39
  editors: null,
39
- paths: `(("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')))`,
40
+ paths: `(("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')))`,
40
41
  };
41
42
  /** Tables whose existing indexes do not lead with space_id. */
42
- const NEEDS_SPACE_INDEX = ["page_versions", "saved_sections", "global_sections", "refs"];
43
+ const NEEDS_SPACE_INDEX = [
44
+ "page_locales", "page_versions", "entry_locales", "entry_versions", "refs",
45
+ ];
43
46
  export function spaceLayerSQL() {
44
47
  const statements = [];
45
48
  statements.push(`
package/dist/sql.js CHANGED
@@ -5,9 +5,11 @@
5
5
  // security_invoker so it runs under the caller's own RLS policies
6
6
  // rather than the view owner's privileges (PostgREST-native reads,
7
7
  // SQL-queryable eject story) — and a partial expression index for a
8
- // declared order field, on the jsonb VALUE (slug uniqueness is a core
9
- // index, scoped to siblings, not per collection). Views project the
10
- // default locale; the `i18n` overlay is the resolver/API's business.
8
+ // declared order field, on the jsonb VALUE (sibling slug uniqueness
9
+ // is the `paths` primary key plus the stores' per-locale pre-check,
10
+ // not an index). A view joins the locale rows, one row per published
11
+ // locale (spec 2026-09-06 §7); a versioned collection reads its
12
+ // fields from the published snapshot.
11
13
  import { BUILT_IN_ORDER_FIELDS } from "./collections.js";
12
14
  import { currentSpaceSQL, SPACE_COL, spaceLayerSQL } from "./sql-space.js";
13
15
  const snake = (s) => s.replace(/[A-Z]/g, (c) => "_" + c.toLowerCase());
@@ -16,42 +18,50 @@ const q = (s) => `"${s}"`;
16
18
  // immutable — the order field's index stays on the raw jsonb
17
19
  // expression: jsonb ordering compares numbers numerically and strings,
18
20
  // ISO dates included, alphabetically, matching the memory comparator).
19
- function fieldExpr(key, d) {
21
+ function fieldExpr(key, d, source) {
20
22
  switch (d.type) {
21
23
  case "text":
22
24
  case "link":
23
25
  case "slug":
24
26
  case "select":
25
27
  case "ref": // stored as the target entry's id string
26
- return `"fields"->>'${key}'`;
28
+ return `${source}->>'${key}'`;
27
29
  case "number":
28
- return `("fields"->>'${key}')::numeric`;
30
+ return `(${source}->>'${key}')::numeric`;
29
31
  case "date":
30
- return `("fields"->>'${key}')::timestamptz`;
32
+ return `(${source}->>'${key}')::timestamptz`;
31
33
  default:
32
- return `"fields"->'${key}'`; // image, video, file, object, richtext, dataAttributes, refList
34
+ return `${source}->'${key}'`; // image, video, file, object, richtext, dataAttributes, refList
33
35
  }
34
36
  }
35
37
  export function collectionSQL(collection) {
36
38
  const name = collection.name;
37
- const columns = ['"id"', '"sort"', '"locales"', '"created_at"', '"updated_at"'];
39
+ const versioned = collection.versions === true;
40
+ // Where a field's value lives: a versioned collection's view reads the
41
+ // published snapshot (merged at publish time); otherwise shared fields
42
+ // come from the node and localized ones from the locale row.
43
+ const sourceOf = (d) => (versioned ? 'v."fields"' : d.localized === true ? 'l."fields"' : 'e."fields"');
44
+ const columns = ['e."id"', 'l."locale"', 'l."slug"', 'e."sort"', 'e."created_at"', 'l."updated_at"'];
38
45
  for (const [key, d] of Object.entries(collection.fields)) {
39
- columns.push(`${fieldExpr(key, d)} as ${q(snake(key))}`);
46
+ if (key === "slug")
47
+ continue; // the segment is the row's column, never a field
48
+ columns.push(`${fieldExpr(key, d, sourceOf(d))} as ${q(snake(key))}`);
40
49
  }
41
50
  const statements = [
42
51
  [
43
52
  `create or replace view ${q(name)} with (security_invoker = true) as`,
44
53
  `select`,
45
54
  columns.map((c) => " " + c).join(",\n"),
46
- `from "entries"`,
47
- `where "collection" = '${name}' and "status" = 'published';`,
55
+ `from "entries" e`,
56
+ `join "entry_locales" l on l."entry_id" = e."id"`,
57
+ ...(versioned ? [`join "entry_versions" v on v."id" = l."published_version_id"`] : []),
58
+ `where e."collection" = '${name}' and l."status" = 'published';`,
48
59
  ].join("\n"),
49
60
  ];
50
- // slug uniqueness is the generic core index, scoped to siblings
51
- // (space_id, collection, parent_id, slug); only a declared order field
52
- // needs a per-collection expression index — the built-in timestamps are
53
- // real columns already. The expression is the jsonb value (`->`), the
54
- // same one the adapter orders by, so the index actually serves the sort.
61
+ // Only a declared order field needs a per-collection expression index —
62
+ // on the node's shared fields (an order field can be neither localized
63
+ // nor `slug`, which collection() refuses), the jsonb value (`->`) the
64
+ // adapter orders by, so the index serves the sort.
55
65
  const order = collection.order;
56
66
  if (order !== undefined && order !== "manual" && !BUILT_IN_ORDER_FIELDS.includes(order.by)) {
57
67
  statements.push(`create index if not exists ${q(`entries_${name}_${snake(order.by)}_idx`)}\n` +
@@ -71,28 +81,34 @@ create table if not exists "pages" (
71
81
  "id" uuid primary key default gen_random_uuid(),
72
82
  ${SPACE_COL},
73
83
  "parent_id" uuid references "pages" ("id") on delete restrict,
74
- "slug" text not null,
75
- "title" text not null,
76
- "i18n" jsonb,
77
84
  "template" text,
78
- "status" text not null default 'draft',
79
85
  "sort" numeric,
80
- "locales" text[],
81
- "draft_version_id" uuid,
82
- "published_version_id" uuid,
83
86
  "created_at" timestamptz not null default now(),
84
87
  "updated_at" timestamptz not null default now()
85
88
  );
86
89
 
87
- -- The tree is the URL (spec 2026-09-05 §1): a node stores its own segment
88
- -- and its parent. Siblings are unique per segment; NULLS NOT DISTINCT so
89
- -- two root nodes cannot share one either. template null = folder.
90
- create unique index if not exists "pages_sibling_slug_key"
91
- on "pages" ("space_id", "parent_id", "slug") nulls not distinct;
92
-
90
+ -- The tree is the URL (spec 2026-09-05 §1): a node stores its parent and
91
+ -- its position; template null = folder. Everything a locale owns — the
92
+ -- node's own segment, title, publish state and version pointers — lives
93
+ -- in page_locales, one row per locale the page exists in (spec 2026-09-06
94
+ -- §2). There is no sibling-slug index: URL uniqueness per locale is the
95
+ -- primary key of "paths", which the stores write inside the same operation.
93
96
  create index if not exists "pages_parent_idx"
94
97
  on "pages" ("space_id", "parent_id", "sort");
95
98
 
99
+ create table if not exists "page_locales" (
100
+ ${SPACE_COL},
101
+ "page_id" uuid not null references "pages" ("id") on delete cascade,
102
+ "locale" text not null,
103
+ "slug" text not null,
104
+ "title" text not null,
105
+ "status" text not null default 'draft',
106
+ "draft_version_id" uuid,
107
+ "published_version_id" uuid,
108
+ "updated_at" timestamptz not null default now(),
109
+ primary key ("page_id", "locale")
110
+ );
111
+
96
112
  -- Sibling reorder for pages: same shape as smoodly_reorder for entries.
97
113
  create or replace function "smoodly_reorder_pages"(p_ids uuid[]) returns void language sql as $$
98
114
  update "pages" pg set "sort" = u.ord
@@ -101,30 +117,20 @@ create or replace function "smoodly_reorder_pages"(p_ids uuid[]) returns void la
101
117
  and pg."space_id" = "smoodly_current_space"();
102
118
  $$;
103
119
 
120
+ -- A version belongs to ONE locale of one page: each locale has its own
121
+ -- draft, its own publish and its own history.
104
122
  create table if not exists "page_versions" (
105
123
  "id" uuid primary key default gen_random_uuid(),
106
124
  ${SPACE_COL},
107
125
  "page_id" uuid not null references "pages" ("id") on delete cascade,
126
+ "locale" text not null,
108
127
  "tree" jsonb not null,
109
128
  "created_by" text,
110
129
  "created_at" timestamptz not null default now()
111
130
  );
112
131
 
113
- create table if not exists "saved_sections" (
114
- "id" uuid primary key default gen_random_uuid(),
115
- ${SPACE_COL},
116
- "title" text not null,
117
- "fragment" jsonb not null,
118
- "created_at" timestamptz not null default now()
119
- );
120
-
121
- create table if not exists "global_sections" (
122
- "id" uuid primary key default gen_random_uuid(),
123
- ${SPACE_COL},
124
- "type" text not null,
125
- "content" jsonb not null,
126
- "updated_at" timestamptz not null default now()
127
- );
132
+ create index if not exists "page_versions_page_locale_idx"
133
+ on "page_versions" ("page_id", "locale", "created_at");
128
134
 
129
135
  create table if not exists "entries" (
130
136
  "id" uuid primary key default gen_random_uuid(),
@@ -132,40 +138,58 @@ create table if not exists "entries" (
132
138
  "collection" text not null,
133
139
  "parent_id" uuid references "entries" ("id") on delete restrict,
134
140
  "fields" jsonb not null default '{}'::jsonb,
135
- "i18n" jsonb,
136
- "status" text not null default 'draft',
137
141
  "sort" numeric,
138
- "locales" text[],
139
142
  "created_at" timestamptz not null default now(),
140
143
  "updated_at" timestamptz not null default now()
141
144
  );
142
145
 
143
- -- Entry slugs are unique among SIBLINGS, like pages: one rule for every
144
- -- node in the tree, and the URL is the tree. NULLS NOT DISTINCT so two
145
- -- ROOT entries (parent_id null) cannot share a slug either. The partial
146
- -- predicate keeps absent, null, empty or non-string slugs out of the
147
- -- index entirely: with NULLS NOT DISTINCT a null slug would otherwise
148
- -- collide with the next null slug, and a collection with no slug field
149
- -- at all could hold only one entry per parent. jsonb_typeof(...) =
150
- -- 'string' is required because ->> renders ANY JSON scalar as text —
151
- -- a number, boolean or null slug would otherwise print as non-empty
152
- -- text and claim the index; only a non-empty string slug claims a URL,
153
- -- absent, null, empty or non-string slugs never conflict — the same
154
- -- rule as slugOf in paths.ts. A flat collection (every entry a root)
155
- -- therefore behaves exactly as per-collection uniqueness did.
156
- create unique index if not exists "entries_sibling_slug_key"
157
- on "entries" ("space_id", "collection", "parent_id", ("fields"->>'slug')) nulls not distinct
158
- where (jsonb_typeof("fields"->'slug') = 'string' and "fields"->>'slug' <> '');
159
-
160
- create index if not exists "entries_collection_status_idx"
161
- on "entries" ("space_id", "collection", "status");
162
-
146
+ -- The node holds identity, tree position and the SHARED fields — every
147
+ -- field without .localized() (spec 2026-09-06 §7): a Person's photo is
148
+ -- the same in every language. Everything a locale owns — its segment,
149
+ -- publish state, the .localized() fields and the version pointers —
150
+ -- lives in entry_locales, one row per locale the entry exists in. There
151
+ -- is no sibling-slug index any more: URL uniqueness per locale is the
152
+ -- primary key of "paths", which the stores write inside the same
153
+ -- operation, and both adapters pre-check siblings per locale in code.
163
154
  create index if not exists "entries_collection_sort_idx"
164
155
  on "entries" ("space_id", "collection", "sort");
165
156
 
166
157
  create index if not exists "entries_parent_idx"
167
158
  on "entries" ("space_id", "collection", "parent_id");
168
159
 
160
+ create table if not exists "entry_locales" (
161
+ ${SPACE_COL},
162
+ "entry_id" uuid not null references "entries" ("id") on delete cascade,
163
+ "locale" text not null,
164
+ "slug" text,
165
+ "status" text not null default 'draft',
166
+ "fields" jsonb not null default '{}'::jsonb,
167
+ "draft_version_id" uuid,
168
+ "published_version_id" uuid,
169
+ "updated_at" timestamptz not null default now(),
170
+ primary key ("entry_id", "locale")
171
+ );
172
+
173
+ create index if not exists "entry_locales_status_idx"
174
+ on "entry_locales" ("space_id", "locale", "status");
175
+
176
+ -- A version belongs to ONE locale of one entry and stores the MERGED
177
+ -- fields (shared + localized + slug) as they were at that save, so
178
+ -- restoring an old Finnish version restores the shared fields too.
179
+ -- Rows exist only for collections registered with \`versions: true\`.
180
+ create table if not exists "entry_versions" (
181
+ "id" uuid primary key default gen_random_uuid(),
182
+ ${SPACE_COL},
183
+ "entry_id" uuid not null references "entries" ("id") on delete cascade,
184
+ "locale" text not null,
185
+ "fields" jsonb not null,
186
+ "created_by" text,
187
+ "created_at" timestamptz not null default now()
188
+ );
189
+
190
+ create index if not exists "entry_versions_entry_locale_idx"
191
+ on "entry_versions" ("entry_id", "locale", "created_at");
192
+
169
193
  -- v1 reorder (DESIGN.md §3 Manual ordering): rewrite sort 1..N in ONE
170
194
  -- statement. Deliberately does not touch updated_at — a drag must not
171
195
  -- make the whole collection look freshly edited. Scoped to the current
@@ -231,10 +255,11 @@ $$;
231
255
  -- (collectionSQL) run with security_invoker = true, so they are subject
232
256
  -- to this same smoodly_read policy rather than their owner's privileges.
233
257
  alter table "pages" enable row level security;
258
+ alter table "page_locales" enable row level security;
234
259
  alter table "page_versions" enable row level security;
235
- alter table "saved_sections" enable row level security;
236
- alter table "global_sections" enable row level security;
237
260
  alter table "entries" enable row level security;
261
+ alter table "entry_locales" enable row level security;
262
+ alter table "entry_versions" enable row level security;
238
263
  alter table "refs" enable row level security;
239
264
  alter table "paths" enable row level security;
240
265
  `.trim(),