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.
- package/README.md +318 -74
- package/dist/admin/client-ops.js +3 -2
- package/dist/admin/editor/EditorView.js +229 -30
- package/dist/admin/editor/PageSettings.d.ts +8 -4
- package/dist/admin/editor/PageSettings.js +25 -25
- package/dist/admin/fixed-nodes.d.ts +16 -10
- package/dist/admin/fixed-nodes.js +55 -41
- package/dist/admin/index.d.ts +3 -2
- package/dist/admin/index.js +1 -0
- package/dist/admin/ops-impl.js +218 -36
- package/dist/admin/ops.d.ts +73 -12
- package/dist/admin/serialize.d.ts +11 -0
- package/dist/admin/serialize.js +8 -0
- package/dist/admin/shared-items.d.ts +9 -0
- package/dist/admin/shared-items.js +61 -0
- package/dist/admin/shell/AdminApp.js +11 -34
- package/dist/admin/shell/EntriesList.d.ts +7 -0
- package/dist/admin/shell/EntriesList.js +95 -0
- package/dist/admin/shell/EntryForm.js +167 -49
- package/dist/admin/shell/PagesList.js +86 -26
- package/dist/admin/shell/SharedForm.d.ts +7 -0
- package/dist/admin/shell/SharedForm.js +146 -0
- package/dist/admin/shell/SharedList.d.ts +6 -0
- package/dist/admin/shell/SharedList.js +62 -0
- package/dist/admin/shell/entries-list.d.ts +21 -0
- package/dist/admin/shell/entries-list.js +36 -0
- package/dist/admin/shell/pages-tree.d.ts +30 -12
- package/dist/admin/shell/pages-tree.js +48 -14
- package/dist/admin/shell/shared-list.d.ts +14 -0
- package/dist/admin/shell/shared-list.js +17 -0
- package/dist/admin/tree-ops.d.ts +12 -5
- package/dist/admin/tree-ops.js +39 -8
- package/dist/admin/ui/LocaleSwitcher.d.ts +11 -0
- package/dist/admin/ui/LocaleSwitcher.js +15 -0
- package/dist/collections.d.ts +10 -1
- package/dist/collections.js +16 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.js +58 -4
- package/dist/entry-store.d.ts +141 -55
- package/dist/entry-store.js +317 -87
- package/dist/index.d.ts +11 -7
- package/dist/index.js +8 -5
- package/dist/localize.d.ts +0 -11
- package/dist/localize.js +11 -29
- package/dist/next/page-renderer.d.ts +4 -0
- package/dist/next/page-renderer.js +14 -1
- package/dist/page.js +5 -1
- package/dist/paths.d.ts +46 -27
- package/dist/paths.js +62 -29
- package/dist/refs.js +8 -0
- package/dist/resolve.d.ts +3 -0
- package/dist/resolve.js +38 -0
- package/dist/revalidate.d.ts +4 -0
- package/dist/revalidate.js +6 -1
- package/dist/shared.d.ts +46 -0
- package/dist/shared.js +36 -0
- package/dist/site.d.ts +16 -2
- package/dist/site.js +92 -39
- package/dist/sql-space.d.ts +1 -1
- package/dist/sql-space.js +16 -13
- package/dist/sql.js +95 -70
- package/dist/store.d.ts +66 -32
- package/dist/store.js +205 -90
- package/dist/supabase-entry-store.d.ts +30 -9
- package/dist/supabase-entry-store.js +360 -178
- package/dist/supabase-store.d.ts +21 -9
- package/dist/supabase-store.js +202 -111
- package/dist/zones.d.ts +6 -2
- package/dist/zones.js +8 -2
- 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
|
|
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 {
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const
|
|
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:
|
|
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
|
|
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, {
|
|
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,
|
|
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:
|
|
76
|
+
title: row.title,
|
|
55
77
|
path,
|
|
56
78
|
ancestors,
|
|
57
79
|
children,
|
|
58
|
-
paths
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
/**
|
|
85
|
-
* `all` is the whole collection (
|
|
86
|
-
*
|
|
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
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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:
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
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,
|
package/dist/sql-space.d.ts
CHANGED
|
@@ -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", "
|
|
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
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
-
|
|
34
|
-
page_versions: `exists (select 1 from "
|
|
35
|
-
|
|
36
|
-
|
|
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 "
|
|
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 = [
|
|
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
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
|
28
|
+
return `${source}->>'${key}'`;
|
|
27
29
|
case "number":
|
|
28
|
-
return `(
|
|
30
|
+
return `(${source}->>'${key}')::numeric`;
|
|
29
31
|
case "date":
|
|
30
|
-
return `(
|
|
32
|
+
return `(${source}->>'${key}')::timestamptz`;
|
|
31
33
|
default:
|
|
32
|
-
return
|
|
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
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
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
|
|
88
|
-
--
|
|
89
|
-
--
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
114
|
-
"
|
|
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
|
-
--
|
|
144
|
-
--
|
|
145
|
-
--
|
|
146
|
-
--
|
|
147
|
-
--
|
|
148
|
-
--
|
|
149
|
-
--
|
|
150
|
-
--
|
|
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(),
|