smoodly 0.0.33 → 0.0.35
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 +845 -1301
- package/dist/addresses.d.ts +42 -0
- package/dist/addresses.js +51 -0
- package/dist/admin/editor/EditorView.js +14 -5
- package/dist/admin/editor/PageSettings.js +2 -1
- package/dist/admin/editor/autosave.d.ts +6 -1
- package/dist/admin/editor/autosave.js +43 -3
- package/dist/admin/editor/useCanvasBridge.js +25 -2
- package/dist/admin/fixed-nodes.d.ts +9 -24
- package/dist/admin/fixed-nodes.js +36 -72
- package/dist/admin/forms/list.js +10 -1
- package/dist/admin/health.js +9 -0
- package/dist/admin/next/create-admin.d.ts +4 -3
- package/dist/admin/next/create-admin.js +14 -3
- package/dist/admin/ops-impl.js +121 -12
- package/dist/admin/redirect-path.d.ts +3 -0
- package/dist/admin/redirect-path.js +9 -0
- package/dist/admin/serialize.d.ts +3 -0
- package/dist/admin/serialize.js +1 -0
- package/dist/admin/shell/AddRedirectModal.js +2 -2
- package/dist/admin/shell/EntryForm.js +3 -2
- package/dist/admin/shell/GlobalForm.js +3 -2
- package/dist/admin/shell/PagesList.js +29 -12
- package/dist/admin/shell/live-url.d.ts +3 -1
- package/dist/admin/shell/live-url.js +5 -2
- package/dist/admin/shell/pages-tree.d.ts +19 -14
- package/dist/admin/shell/pages-tree.js +49 -16
- package/dist/admin/shell/router.d.ts +5 -0
- package/dist/admin/shell/router.js +13 -0
- package/dist/admin/ui/theme.js +1 -0
- package/dist/anchors.d.ts +42 -0
- package/dist/anchors.js +72 -0
- package/dist/cli/export.js +3 -1
- package/dist/cli/import.d.ts +1 -0
- package/dist/cli/import.js +13 -2
- package/dist/cli/space-io.d.ts +1 -2
- package/dist/cli/space-io.js +5 -5
- package/dist/collections.js +5 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.js +53 -18
- package/dist/current-space.d.ts +4 -0
- package/dist/current-space.js +9 -0
- package/dist/entry-store.js +4 -2
- package/dist/env.d.ts +4 -3
- package/dist/env.js +15 -17
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/next/canvas-slot.d.ts +7 -0
- package/dist/next/canvas-slot.js +9 -0
- package/dist/next/index.d.ts +2 -0
- package/dist/next/index.js +2 -0
- package/dist/next/page-renderer.d.ts +2 -0
- package/dist/next/page-renderer.js +14 -10
- package/dist/next/preview-pill.d.ts +9 -0
- package/dist/next/preview-pill.js +56 -0
- package/dist/next/preview-route.d.ts +1 -0
- package/dist/next/preview-route.js +30 -0
- package/dist/path-index.d.ts +11 -0
- package/dist/path-index.js +52 -25
- package/dist/paths.d.ts +25 -12
- package/dist/paths.js +59 -21
- package/dist/schema-version.d.ts +1 -1
- package/dist/schema-version.js +1 -1
- package/dist/site.d.ts +7 -3
- package/dist/site.js +26 -15
- package/dist/space-transfer.d.ts +2 -0
- package/dist/space-transfer.js +8 -1
- package/dist/sql-space.d.ts +14 -0
- package/dist/sql-space.js +51 -23
- package/dist/sql.d.ts +5 -0
- package/dist/sql.js +51 -9
- package/dist/store.d.ts +15 -1
- package/dist/store.js +27 -6
- package/dist/supabase-entry-store.d.ts +0 -1
- package/dist/supabase-entry-store.js +6 -11
- package/dist/supabase-path-index.d.ts +12 -1
- package/dist/supabase-path-index.js +56 -19
- package/dist/supabase-store.d.ts +2 -0
- package/dist/supabase-store.js +30 -9
- package/migrations/20260929120000_smoodly_v5.sql +56 -0
- package/migrations/20260929135523_smoodly_v6.sql +200 -0
- package/package.json +1 -5
package/dist/paths.js
CHANGED
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
// The one place a URL is derived (spec 2026-09-05 §1, §4; spec
|
|
2
|
-
// 2026-09-06 §3, §7). Pure and framework-free: both store adapters and
|
|
3
|
-
// the tree query call these, so the `paths` index and every derived
|
|
4
|
-
// `path` value agree by construction.
|
|
5
1
|
export const SEGMENT_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
6
2
|
export const isSegment = (s) => SEGMENT_RE.test(s);
|
|
7
3
|
export function assertSegment(s, what) {
|
|
@@ -47,13 +43,31 @@ export function perLocaleSegments(value, supported) {
|
|
|
47
43
|
* (spec 2026-09-06 §3). The boot collision check (config.ts), the
|
|
48
44
|
* materializer (admin/fixed-nodes.ts) and the fixed path derivation
|
|
49
45
|
* (localize.ts) all fill through here, or a collision passes boot and
|
|
50
|
-
* only surfaces at materialization. Collection
|
|
51
|
-
*
|
|
46
|
+
* only surfaces at materialization. Collection paths fill the same way
|
|
47
|
+
* (addresses.ts): their entries take the default path in a locale the
|
|
48
|
+
* path omits (collectionSegment), so the address is claimed there too. */
|
|
52
49
|
export function fixedPageSegments(slug, locales) {
|
|
53
50
|
const raw = perLocaleSegments(slug, locales.supported);
|
|
54
51
|
return Object.fromEntries(locales.supported.map((l) => [l, raw[l] || raw[locales.default]]));
|
|
55
52
|
}
|
|
56
53
|
export const joinPath = (segments) => (segments.length === 0 ? "/" : "/" + segments.join("/"));
|
|
54
|
+
/** A request's segment or a typed address's, as stored: percent-decoded and NFC-composed, so
|
|
55
|
+
* a pasted `%C3%A4`, a typed `ä` and a decomposed `a\u0308` are one address. Next hands the
|
|
56
|
+
* catch-all its segments still encoded. A stray `%` that is no escape stays as it is, and so
|
|
57
|
+
* does a segment holding an encoded slash: decoded, it would become two segments and make
|
|
58
|
+
* `/a%2Fb` an alias of `/a/b`. Slugs are ASCII, so this changes nothing a slug path holds. */
|
|
59
|
+
export function decodeSegment(segment) {
|
|
60
|
+
let out = segment;
|
|
61
|
+
try {
|
|
62
|
+
const decoded = decodeURIComponent(segment);
|
|
63
|
+
if (!decoded.includes("/"))
|
|
64
|
+
out = decoded;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// not an escape sequence: looked up as typed
|
|
68
|
+
}
|
|
69
|
+
return out.normalize("NFC");
|
|
70
|
+
}
|
|
57
71
|
export const splitPath = (path) => path.split("/").filter((s) => s.length > 0);
|
|
58
72
|
/** The node's segment in one locale. An absent locale is an error, never
|
|
59
73
|
* a fallback: the stores' parent rule guarantees a present node has a
|
|
@@ -83,28 +97,56 @@ export function assertDepth(depth, max, what) {
|
|
|
83
97
|
/** Home is judged by identity: the root node whose DEFAULT-locale slug is
|
|
84
98
|
* homePageSlug serves "/" in every locale it exists in. */
|
|
85
99
|
export const isHomeNode = (node, opts) => node.parentId === null && node.locales[opts.locales.default]?.slug === opts.homePageSlug;
|
|
100
|
+
/** A code-owned root's address parts in the locale from the config; null
|
|
101
|
+
* when no registration claims it there any more (an orphan). */
|
|
102
|
+
function addressParts(root, locale, opts) {
|
|
103
|
+
const address = opts.addresses?.[root.fixed]?.[locale];
|
|
104
|
+
return address === undefined ? null : address.split("/");
|
|
105
|
+
}
|
|
86
106
|
/** The node's path in one locale: `/` for the home node, else the
|
|
87
|
-
* locale's ancestor chain joined.
|
|
107
|
+
* locale's ancestor chain joined. A code-owned root's path is its
|
|
108
|
+
* address (spec 2026-09-29). THE derivation — pagePathRows,
|
|
88
109
|
* buildPageTree and the site layer all call this. */
|
|
89
110
|
export function pagePath(node, byId, locale, opts) {
|
|
90
|
-
|
|
111
|
+
if (isHomeNode(node, opts))
|
|
112
|
+
return "/";
|
|
113
|
+
const [root, ...rest] = chainOf(node, byId);
|
|
114
|
+
const own = segmentFor(root, locale); // an absent root is a broken invariant, code-owned or not
|
|
115
|
+
const head = root.fixed ? addressParts(root, locale, opts) : [own];
|
|
116
|
+
if (head === null)
|
|
117
|
+
throw new Error(`smoodly: page "${node.id}" has no address in locale "${locale}".`);
|
|
118
|
+
return joinPath([...head, ...rest.map((n) => segmentFor(n, locale))]);
|
|
91
119
|
}
|
|
92
120
|
/** The same path, or null when the node or an ancestor is absent from the
|
|
93
|
-
* locale — what the Pages list shows as a dimmed "not in fi" row
|
|
121
|
+
* locale — what the Pages list shows as a dimmed "not in fi" row — or
|
|
122
|
+
* when its code-owned root has no address there. */
|
|
94
123
|
export function pagePathIn(node, byId, locale, opts) {
|
|
95
124
|
if (!node.locales[locale])
|
|
96
125
|
return null;
|
|
97
126
|
if (isHomeNode(node, opts))
|
|
98
127
|
return "/";
|
|
99
128
|
const chain = chainOf(node, byId);
|
|
100
|
-
|
|
129
|
+
if (!chain.every((n) => n.locales[locale] !== undefined))
|
|
130
|
+
return null;
|
|
131
|
+
const [root, ...rest] = chain;
|
|
132
|
+
const head = root.fixed ? addressParts(root, locale, opts) : [root.locales[locale].slug];
|
|
133
|
+
return head === null ? null : joinPath([...head, ...rest.map((n) => n.locales[locale].slug)]);
|
|
101
134
|
}
|
|
102
|
-
/** One row per locale the target EXISTS in
|
|
135
|
+
/** One row per locale the target EXISTS in (spec 2026-09-29 anchored paths §3): under a code-owned root, the root's
|
|
136
|
+
* owner key and the chain below it (`''` for the root itself); in the editors' tree, no anchor and the full path. */
|
|
103
137
|
export function pagePathRows(targets, byId, opts) {
|
|
104
138
|
const rows = [];
|
|
105
139
|
for (const target of targets) {
|
|
140
|
+
const chain = chainOf(target, byId);
|
|
141
|
+
const root = chain[0];
|
|
106
142
|
for (const locale of Object.keys(target.locales)) {
|
|
107
|
-
|
|
143
|
+
if (root.fixed) {
|
|
144
|
+
const below = chain.slice(1).map((n) => segmentFor(n, locale));
|
|
145
|
+
rows.push({ kind: "page", id: target.id, locale, anchor: root.fixed, path: below.length === 0 ? "" : joinPath(below) });
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
rows.push({ kind: "page", id: target.id, locale, anchor: "", path: pagePath(target, byId, locale, opts) });
|
|
149
|
+
}
|
|
108
150
|
}
|
|
109
151
|
}
|
|
110
152
|
return rows;
|
|
@@ -131,22 +173,18 @@ export const slugOf = (fields) => {
|
|
|
131
173
|
export function entrySegment(entry, locale) {
|
|
132
174
|
return entry.locales[locale]?.slug ?? null;
|
|
133
175
|
}
|
|
134
|
-
/** One row per locale the target EXISTS in, where the whole chain has a
|
|
135
|
-
*
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
export function entryPathRows(targets, byId, collection, set) {
|
|
176
|
+
/** One row per locale the target EXISTS in, where the whole chain has a slug there, under the collection's anchor
|
|
177
|
+
* (spec 2026-09-29 anchored paths §1): written whether or not the collection has a `path`, which the index reads. */
|
|
178
|
+
export function entryPathRows(targets, byId, collection) {
|
|
179
|
+
// `collection:<name>` is addresses.ts's collectionKey, spelled here: addresses.ts imports this module.
|
|
139
180
|
const rows = [];
|
|
140
181
|
for (const target of targets) {
|
|
141
182
|
const chain = chainOf(target, byId);
|
|
142
183
|
for (const locale of Object.keys(target.locales)) {
|
|
143
|
-
const base = collectionSegment(collection.path, locale, set);
|
|
144
|
-
if (base === null)
|
|
145
|
-
continue;
|
|
146
184
|
const segments = chain.map((e) => entrySegment(e, locale));
|
|
147
185
|
if (segments.some((s) => s === null))
|
|
148
186
|
continue;
|
|
149
|
-
rows.push({ kind: "entry", id: target.id, locale, path: joinPath(
|
|
187
|
+
rows.push({ kind: "entry", id: target.id, locale, anchor: `collection:${collection}`, path: joinPath(segments) });
|
|
150
188
|
}
|
|
151
189
|
}
|
|
152
190
|
return rows;
|
package/dist/schema-version.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** The minimum schema version the package needs. Moves ONLY in a release
|
|
2
2
|
* that adds a table, column, function or policy the package reads; a
|
|
3
3
|
* unit test holds it equal to the highest file in `migrations/`. */
|
|
4
|
-
export declare const SCHEMA_VERSION =
|
|
4
|
+
export declare const SCHEMA_VERSION = 6;
|
|
5
5
|
export declare const SCHEMA_VERSION_KEY = "schema_version";
|
|
6
6
|
export declare const CORE_FILE_RE: RegExp;
|
|
7
7
|
export declare const VIEWS_FILE_RE: RegExp;
|
package/dist/schema-version.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
/** The minimum schema version the package needs. Moves ONLY in a release
|
|
8
8
|
* that adds a table, column, function or policy the package reads; a
|
|
9
9
|
* unit test holds it equal to the highest file in `migrations/`. */
|
|
10
|
-
export const SCHEMA_VERSION =
|
|
10
|
+
export const SCHEMA_VERSION = 6;
|
|
11
11
|
export const SCHEMA_VERSION_KEY = "schema_version";
|
|
12
12
|
export const CORE_FILE_RE = /^(\d{14})_smoodly_v(\d+)\.sql$/;
|
|
13
13
|
export const VIEWS_FILE_RE = /^(\d{14})_smoodly_views\.sql$/;
|
package/dist/site.d.ts
CHANGED
|
@@ -27,14 +27,18 @@ export type SiteOptions = {
|
|
|
27
27
|
styles?: StyleValuesInput;
|
|
28
28
|
/** The custom section's options (spec 2026-09-14 §6); the defaults when absent. */
|
|
29
29
|
custom?: CustomConfigInput;
|
|
30
|
-
/** Page shapes (entry pages are ignored): the source of each template's page-field descriptors (spec 2026-09-10 page fields §3)
|
|
31
|
-
|
|
30
|
+
/** Page shapes (entry pages are ignored): the source of each template's page-field descriptors (spec 2026-09-10 page fields §3)
|
|
31
|
+
* and of the fixed pages' addresses (spec 2026-09-29 nested paths §3) — `config.registry.pages`. Required: a site that
|
|
32
|
+
* missed one would take its code-owned record for an orphan and drop its URLs at the first lookup. */
|
|
33
|
+
pages: ReadonlyArray<SitePageSchema>;
|
|
32
34
|
};
|
|
33
|
-
/** A page registration as the site needs it: the template's page fields. */
|
|
35
|
+
/** A page registration as the site needs it: the template's page fields, and a fixed page's address. */
|
|
34
36
|
export type SitePageSchema = {
|
|
35
37
|
schema: {
|
|
36
38
|
kind: string;
|
|
37
39
|
name: string;
|
|
40
|
+
title?: string;
|
|
41
|
+
slug?: string | Record<string, string>;
|
|
38
42
|
fields?: Record<string, Descriptor>;
|
|
39
43
|
};
|
|
40
44
|
};
|
package/dist/site.js
CHANGED
|
@@ -6,6 +6,8 @@ import { globalNodeId, SETTINGS_KEY, STYLES_KEY, CSS_KEY } from "./global.js";
|
|
|
6
6
|
import { SMD_KEY } from "./section-wrapper.js";
|
|
7
7
|
import { resolveStyleValues } from "./styles.js";
|
|
8
8
|
import { resolveCustom } from "./custom/index.js";
|
|
9
|
+
import { addressMap, codeAddresses } from "./addresses.js";
|
|
10
|
+
import { fullPath, servedAnchor } from "./anchors.js";
|
|
9
11
|
import { withDefaults } from "./field-defaults.js";
|
|
10
12
|
/** The `page` prop for a loaded page. `href` is the app's seam from a
|
|
11
13
|
* locale-relative path to a site URL; the default keeps CMS paths as URLs. */
|
|
@@ -29,7 +31,11 @@ export function createSmoodlySite(options) {
|
|
|
29
31
|
const styles = resolveStyleValues(options.styles);
|
|
30
32
|
const custom = resolveCustom(options.custom);
|
|
31
33
|
const elements = elementSchemas(options.elements);
|
|
32
|
-
|
|
34
|
+
// Code-owned addresses from the site's own registry (spec 2026-09-29 nested paths §3): a code-owned root's path.
|
|
35
|
+
const addresses = addressMap(codeAddresses({ locales, pages: options.pages, collections: options.collections }));
|
|
36
|
+
const pathOpts = { locales, homePageSlug, addresses };
|
|
37
|
+
/** The same addresses as the path index sees them: an entry's stored part joined to its collection's (spec 2026-09-29 anchored paths §2). */
|
|
38
|
+
const anchors = { addresses, homePageSlug, defaultLocale: locales.default };
|
|
33
39
|
const mounted = options.collections.filter((c) => c.path !== undefined);
|
|
34
40
|
/** The resolver's fetcher, bound to a locale: the merged fields of that
|
|
35
41
|
* locale — published ones (a versioned collection's snapshot) on the
|
|
@@ -184,8 +190,11 @@ export function createSmoodlySite(options) {
|
|
|
184
190
|
return wanted
|
|
185
191
|
.filter((record) => fieldsById[record.id] !== undefined)
|
|
186
192
|
.map((record) => {
|
|
187
|
-
const rows = entryPathRows([record], byId, schema
|
|
188
|
-
const pathIn = (l) =>
|
|
193
|
+
const rows = entryPathRows([record], byId, schema.name);
|
|
194
|
+
const pathIn = (l) => {
|
|
195
|
+
const row = rows.find((r) => r.locale === l);
|
|
196
|
+
return row ? fullPath(anchors, row.anchor ?? "", row.path, l) : null;
|
|
197
|
+
};
|
|
189
198
|
return {
|
|
190
199
|
entry: { ...record, fields: fieldsById[record.id] },
|
|
191
200
|
path: pathIn(locale),
|
|
@@ -243,18 +252,20 @@ export function createSmoodlySite(options) {
|
|
|
243
252
|
const shown = all.filter((n) => visible(n, locale, draft));
|
|
244
253
|
const index = new Map(shown.map((n) => [n.id, n]));
|
|
245
254
|
const fields = await linkFields(shown, locale, draft);
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
255
|
+
// Every node the walk reaches is present with a present chain. Left
|
|
256
|
+
// out, with its subtree: a record no longer in code (no path, spec
|
|
257
|
+
// 2026-09-29 nested paths §5), and a page where another code address
|
|
258
|
+
// wins, which visitors never reach (anchored paths §4).
|
|
259
|
+
const hidden = (node) => node.path === null || servedAnchor(anchors, locale, node.path) !== (chainOf(node.record, (x) => index.get(x))[0].fixed ?? "");
|
|
260
|
+
const toItems = (nodes) => nodes.flatMap((node) => hidden(node) ? [] : [{
|
|
261
|
+
id: node.record.id,
|
|
262
|
+
title: node.record.locales[locale].title,
|
|
263
|
+
path: node.path,
|
|
264
|
+
hasPage: hasPage(node.record, locale, draft),
|
|
265
|
+
fields: fields.get(node.record.id) ?? {},
|
|
266
|
+
children: toItems(node.children),
|
|
267
|
+
}]);
|
|
268
|
+
return toItems(buildPageTree(shown, locale, pathOpts));
|
|
258
269
|
},
|
|
259
270
|
async getPage(pathOrId, o) {
|
|
260
271
|
const locale = localeOf(o);
|
package/dist/space-transfer.d.ts
CHANGED
|
@@ -46,6 +46,8 @@ export declare function insertPlan(rows: TableRows): {
|
|
|
46
46
|
export declare function brokenReferences(rows: TableRows): string[];
|
|
47
47
|
/** The import's target check (spec §9.1): per table, how many of the target's row keys this export does not hold. All zero = empty, or an earlier run. */
|
|
48
48
|
export declare function foreignRows(target: Record<ContentTable, string[]>, exported: TableRows): Record<ContentTable, number>;
|
|
49
|
+
/** The export's rows the target does not hold yet: zero on a re-run of a finished import. */
|
|
50
|
+
export declare function missingRows(target: Record<ContentTable, string[]>, exported: TableRows): number;
|
|
49
51
|
export declare const countRows: (rows: TableRows) => Record<ContentTable, number>;
|
|
50
52
|
export declare function formatBytes(n: number): string;
|
|
51
53
|
/** "14 pages (41 versions), 32 entries (118 versions), 51 assets (38.2 MB), 9 redirects" (spec §8.5). */
|
package/dist/space-transfer.js
CHANGED
|
@@ -23,7 +23,7 @@ export const ROW_KEY = {
|
|
|
23
23
|
entry_locales: ["entry_id", "locale"],
|
|
24
24
|
entry_versions: ["id"],
|
|
25
25
|
refs: ["source_kind", "source_id", "source_field", "target_id"],
|
|
26
|
-
paths: ["locale", "path"],
|
|
26
|
+
paths: ["locale", "anchor", "path"],
|
|
27
27
|
assets: ["id"],
|
|
28
28
|
};
|
|
29
29
|
/** The primary key as Postgres declares it — what "on conflict do nothing" names. */
|
|
@@ -116,6 +116,13 @@ export function foreignRows(target, exported) {
|
|
|
116
116
|
return [table, target[table].filter((key) => !own.has(key)).length];
|
|
117
117
|
}));
|
|
118
118
|
}
|
|
119
|
+
/** The export's rows the target does not hold yet: zero on a re-run of a finished import. */
|
|
120
|
+
export function missingRows(target, exported) {
|
|
121
|
+
return CONTENT_TABLES.reduce((n, table) => {
|
|
122
|
+
const held = new Set(target[table]);
|
|
123
|
+
return n + exported[table].filter((row) => !held.has(rowKey(table, row))).length;
|
|
124
|
+
}, 0);
|
|
125
|
+
}
|
|
119
126
|
export const countRows = (rows) => Object.fromEntries(CONTENT_TABLES.map((t) => [t, rows[t].length]));
|
|
120
127
|
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
121
128
|
const listJoin = (items) => (items.length <= 1 ? items.join("") : `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`);
|
package/dist/sql-space.d.ts
CHANGED
|
@@ -7,6 +7,17 @@ export declare const SPACE_COL = "\"space_id\" uuid not null default '00000000-0
|
|
|
7
7
|
export declare const CONTENT_TABLES: readonly ["pages", "page_locales", "page_versions", "entries", "entry_locales", "entry_versions", "refs", "paths", "assets"];
|
|
8
8
|
export type ContentTable = (typeof CONTENT_TABLES)[number];
|
|
9
9
|
export declare function currentSpaceSQL(): string;
|
|
10
|
+
/** One permissive policy per action (spec 2026-09-29 pre-install fixes §3):
|
|
11
|
+
* the write key's insert, update and delete, each alone for its action; its
|
|
12
|
+
* reads go through the table's one SELECT policy. `table` is as SQL names
|
|
13
|
+
* it (`"pages"`, `storage.objects`). Drops the old `for all` policy first. */
|
|
14
|
+
export declare function writePoliciesSQL(table: string, write: string, prefix: string): string;
|
|
15
|
+
/** The content tables' and members' policies. A table with a public read
|
|
16
|
+
* has ONE select policy — its own space, not revoked, and a write key or
|
|
17
|
+
* the published condition — and split write policies. A table without
|
|
18
|
+
* one (refs, members) keeps its `for all` write policy, which is also its
|
|
19
|
+
* only select. */
|
|
20
|
+
export declare function contentPoliciesSQL(): string;
|
|
10
21
|
export declare function spaceLayerSQL(): string;
|
|
11
22
|
/** The media bucket and its object policies (spec 2026-09-08 media §3,
|
|
12
23
|
* §12). Public read: the CDN and next/image fetch without tokens. Writes:
|
|
@@ -15,6 +26,9 @@ export declare function spaceLayerSQL(): string;
|
|
|
15
26
|
* Schema-qualified calls: storage policies do not run with public on
|
|
16
27
|
* the search path. Last in the migration, after the functions exist. */
|
|
17
28
|
export declare function storageSQL(): string;
|
|
29
|
+
/** The media bucket's write policies: insert, update, delete for a write
|
|
30
|
+
* key under its own space's prefix; reads are the public select above. */
|
|
31
|
+
export declare function mediaPoliciesSQL(): string;
|
|
18
32
|
/** The three mutation RPCs are the stores' verbs, never a browser's (spec
|
|
19
33
|
* 2026-09-26 security pass §2): executable by the service role and by
|
|
20
34
|
* `authenticated` (the role a cloud write key carries), not by anon or
|
package/dist/sql-space.js
CHANGED
|
@@ -49,6 +49,43 @@ const PUBLISHED_READ = {
|
|
|
49
49
|
const NEEDS_SPACE_INDEX = [
|
|
50
50
|
"page_locales", "page_versions", "entry_locales", "entry_versions", "refs",
|
|
51
51
|
];
|
|
52
|
+
/** One permissive policy per action (spec 2026-09-29 pre-install fixes §3):
|
|
53
|
+
* the write key's insert, update and delete, each alone for its action; its
|
|
54
|
+
* reads go through the table's one SELECT policy. `table` is as SQL names
|
|
55
|
+
* it (`"pages"`, `storage.objects`). Drops the old `for all` policy first. */
|
|
56
|
+
export function writePoliciesSQL(table, write, prefix) {
|
|
57
|
+
return [
|
|
58
|
+
`drop policy if exists "${prefix}_write" on ${table};`,
|
|
59
|
+
`drop policy if exists "${prefix}_insert" on ${table};\ncreate policy "${prefix}_insert" on ${table} for insert\n with check (${write});`,
|
|
60
|
+
`drop policy if exists "${prefix}_update" on ${table};\ncreate policy "${prefix}_update" on ${table} for update\n using (${write})\n with check (${write});`,
|
|
61
|
+
`drop policy if exists "${prefix}_delete" on ${table};\ncreate policy "${prefix}_delete" on ${table} for delete\n using (${write});`,
|
|
62
|
+
].join("\n\n");
|
|
63
|
+
}
|
|
64
|
+
const OWN = `"space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"())`;
|
|
65
|
+
const WRITE = `${OWN} and (select "smoodly_key_kind"()) = 'write'`;
|
|
66
|
+
/** The content tables' and members' policies. A table with a public read
|
|
67
|
+
* has ONE select policy — its own space, not revoked, and a write key or
|
|
68
|
+
* the published condition — and split write policies. A table without
|
|
69
|
+
* one (refs, members) keeps its `for all` write policy, which is also its
|
|
70
|
+
* only select. */
|
|
71
|
+
export function contentPoliciesSQL() {
|
|
72
|
+
const statements = [];
|
|
73
|
+
for (const table of [...CONTENT_TABLES, "members"]) {
|
|
74
|
+
const published = PUBLISHED_READ[table];
|
|
75
|
+
if (published === null) {
|
|
76
|
+
statements.push(`drop policy if exists "smoodly_write" on "${table}";\n` +
|
|
77
|
+
`create policy "smoodly_write" on "${table}" for all\n` +
|
|
78
|
+
` using (${WRITE})\n` +
|
|
79
|
+
` with check (${WRITE});`);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
statements.push(writePoliciesSQL(`"${table}"`, WRITE, "smoodly"));
|
|
83
|
+
statements.push(`drop policy if exists "smoodly_read" on "${table}";\n` +
|
|
84
|
+
`create policy "smoodly_read" on "${table}" for select\n` +
|
|
85
|
+
` using (${OWN} and ((select "smoodly_key_kind"()) = 'write' or ${published}));`);
|
|
86
|
+
}
|
|
87
|
+
return statements.join("\n\n");
|
|
88
|
+
}
|
|
52
89
|
export function spaceLayerSQL() {
|
|
53
90
|
const statements = [];
|
|
54
91
|
statements.push(`
|
|
@@ -185,21 +222,9 @@ $$;`.trim());
|
|
|
185
222
|
// an InitPlan, evaluated once per statement rather than once per row
|
|
186
223
|
// — needed for smoodly_space_key_revoked, a security definer function
|
|
187
224
|
// Postgres never inlines; applied to all three calls for the same win.
|
|
188
|
-
const own =
|
|
189
|
-
const write =
|
|
190
|
-
|
|
191
|
-
statements.push(`drop policy if exists "smoodly_write" on "${table}";\n` +
|
|
192
|
-
`create policy "smoodly_write" on "${table}" for all\n` +
|
|
193
|
-
` using (${write})\n` +
|
|
194
|
-
` with check (${write});`);
|
|
195
|
-
const published = PUBLISHED_READ[table];
|
|
196
|
-
if (published !== null) {
|
|
197
|
-
const predicate = `${own}${published ? ` and ${published}` : ""}`;
|
|
198
|
-
statements.push(`drop policy if exists "smoodly_read" on "${table}";\n` +
|
|
199
|
-
`create policy "smoodly_read" on "${table}" for select\n` +
|
|
200
|
-
` using (${predicate});`);
|
|
201
|
-
}
|
|
202
|
-
}
|
|
225
|
+
const own = OWN;
|
|
226
|
+
const write = WRITE;
|
|
227
|
+
statements.push(contentPoliciesSQL());
|
|
203
228
|
// A write key reads its own space's row — the console URL the Members
|
|
204
229
|
// page links to (spec 2026-09-25 §3). Nothing else touches spaces or
|
|
205
230
|
// space_keys but the two definer functions and the service role.
|
|
@@ -235,10 +260,6 @@ $$;`.trim());
|
|
|
235
260
|
* Schema-qualified calls: storage policies do not run with public on
|
|
236
261
|
* the search path. Last in the migration, after the functions exist. */
|
|
237
262
|
export function storageSQL() {
|
|
238
|
-
const write = `"bucket_id" = 'media'` +
|
|
239
|
-
` and (select public."smoodly_key_kind"()) = 'write'` +
|
|
240
|
-
` and not (select public."smoodly_space_key_revoked"())` +
|
|
241
|
-
` and split_part("name", '/', 1) = (select public."smoodly_current_space"())::text`;
|
|
242
263
|
return `
|
|
243
264
|
insert into storage.buckets ("id", "name", "public", "file_size_limit")
|
|
244
265
|
values ('media', 'media', true, 104857600)
|
|
@@ -248,10 +269,16 @@ drop policy if exists "smoodly_media_read" on storage.objects;
|
|
|
248
269
|
create policy "smoodly_media_read" on storage.objects for select
|
|
249
270
|
using ("bucket_id" = 'media');
|
|
250
271
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
272
|
+
${mediaPoliciesSQL()}`.trim();
|
|
273
|
+
}
|
|
274
|
+
/** The media bucket's write policies: insert, update, delete for a write
|
|
275
|
+
* key under its own space's prefix; reads are the public select above. */
|
|
276
|
+
export function mediaPoliciesSQL() {
|
|
277
|
+
const write = `"bucket_id" = 'media'` +
|
|
278
|
+
` and (select public."smoodly_key_kind"()) = 'write'` +
|
|
279
|
+
` and not (select public."smoodly_space_key_revoked"())` +
|
|
280
|
+
` and split_part("name", '/', 1) = (select public."smoodly_current_space"())::text`;
|
|
281
|
+
return writePoliciesSQL("storage.objects", write, "smoodly_media");
|
|
255
282
|
}
|
|
256
283
|
/** The three mutation RPCs are the stores' verbs, never a browser's (spec
|
|
257
284
|
* 2026-09-26 security pass §2): executable by the service role and by
|
|
@@ -263,5 +290,6 @@ export function grantsSQL() {
|
|
|
263
290
|
`revoke execute on function "smoodly_reorder"(text, uuid[]) from public, anon;`,
|
|
264
291
|
`revoke execute on function "smoodly_reorder_pages"(uuid[]) from public, anon;`,
|
|
265
292
|
`revoke execute on function "smoodly_rewrite_paths"(jsonb, jsonb) from public, anon;`,
|
|
293
|
+
`revoke execute on function "smoodly_space_row_cap"() from public, anon;`,
|
|
266
294
|
].join("\n");
|
|
267
295
|
}
|
package/dist/sql.d.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
1
|
import { type CollectionSchema } from "./collections.ts";
|
|
2
2
|
export declare function collectionSQL(collection: CollectionSchema<any>): string;
|
|
3
|
+
/** Foreign keys the planner needs an index for (spec 2026-09-29 pre-install
|
|
4
|
+
* fixes §3): a restrict or set-null check scans the referencing table
|
|
5
|
+
* without one, and the versions' read policies probe published_version_id
|
|
6
|
+
* on every public read. The composite indexes that exist lead with space_id. */
|
|
7
|
+
export declare function foreignKeyIndexesSQL(): string;
|
|
3
8
|
export declare function coreTablesSQL(): string;
|
package/dist/sql.js
CHANGED
|
@@ -69,6 +69,21 @@ export function collectionSQL(collection) {
|
|
|
69
69
|
}
|
|
70
70
|
return statements.join("\n\n");
|
|
71
71
|
}
|
|
72
|
+
/** Foreign keys the planner needs an index for (spec 2026-09-29 pre-install
|
|
73
|
+
* fixes §3): a restrict or set-null check scans the referencing table
|
|
74
|
+
* without one, and the versions' read policies probe published_version_id
|
|
75
|
+
* on every public read. The composite indexes that exist lead with space_id. */
|
|
76
|
+
export function foreignKeyIndexesSQL() {
|
|
77
|
+
return [
|
|
78
|
+
`create index if not exists "page_locales_draft_version_idx" on "page_locales" ("draft_version_id");`,
|
|
79
|
+
`create index if not exists "entry_locales_draft_version_idx" on "entry_locales" ("draft_version_id");`,
|
|
80
|
+
`create index if not exists "page_locales_published_version_idx" on "page_locales" ("published_version_id");`,
|
|
81
|
+
`create index if not exists "entry_locales_published_version_idx" on "entry_locales" ("published_version_id");`,
|
|
82
|
+
`create index if not exists "pages_parent_id_idx" on "pages" ("parent_id");`,
|
|
83
|
+
`create index if not exists "entries_parent_id_idx" on "entries" ("parent_id");`,
|
|
84
|
+
`create index if not exists "assets_poster_idx" on "assets" ("poster_id");`,
|
|
85
|
+
].join("\n");
|
|
86
|
+
}
|
|
72
87
|
export function coreTablesSQL() {
|
|
73
88
|
return [
|
|
74
89
|
// The trigger/policy blocks below drop-before-create so the migration is
|
|
@@ -83,6 +98,7 @@ create table if not exists "pages" (
|
|
|
83
98
|
"parent_id" uuid references "pages" ("id") on delete restrict,
|
|
84
99
|
"template" text,
|
|
85
100
|
"sort" numeric,
|
|
101
|
+
"fixed" text,
|
|
86
102
|
"created_at" timestamptz not null default now(),
|
|
87
103
|
"updated_at" timestamptz not null default now()
|
|
88
104
|
);
|
|
@@ -96,6 +112,12 @@ create table if not exists "pages" (
|
|
|
96
112
|
create index if not exists "pages_parent_idx"
|
|
97
113
|
on "pages" ("space_id", "parent_id", "sort");
|
|
98
114
|
|
|
115
|
+
-- The registration that owns a code-owned record (spec 2026-09-29 nested
|
|
116
|
+
-- paths §3): "collection:<name>" or "page:<name>"; null = an editor's
|
|
117
|
+
-- page. Its path is its address from the config, not its slug chain.
|
|
118
|
+
create unique index if not exists "pages_fixed_idx"
|
|
119
|
+
on "pages" ("space_id", "fixed") where "fixed" is not null;
|
|
120
|
+
|
|
99
121
|
create table if not exists "page_locales" (
|
|
100
122
|
${SPACE_COL},
|
|
101
123
|
"page_id" uuid not null references "pages" ("id") on delete cascade,
|
|
@@ -238,14 +260,18 @@ create table if not exists "refs" (
|
|
|
238
260
|
-- A row with "redirect" set is an old address of its target (spec
|
|
239
261
|
-- 2026-09-26 redirects §2): the same primary key, so a redirect can never
|
|
240
262
|
-- sit where a live URL is and a live row taking the address replaces it.
|
|
263
|
+
-- "anchor" names the code address a row hangs under (spec 2026-09-29
|
|
264
|
+
-- anchored paths): "path" is only the part editors made, and the address
|
|
265
|
+
-- itself lives in the config; '' is the editors' own tree.
|
|
241
266
|
create table if not exists "paths" (
|
|
242
267
|
${SPACE_COL},
|
|
243
268
|
"locale" text not null,
|
|
269
|
+
"anchor" text not null default '',
|
|
244
270
|
"path" text not null,
|
|
245
271
|
"kind" text not null check ("kind" in ('page', 'entry')),
|
|
246
272
|
"target_id" uuid not null,
|
|
247
273
|
"redirect" boolean not null default false,
|
|
248
|
-
primary key ("space_id", "locale", "path")
|
|
274
|
+
primary key ("space_id", "locale", "anchor", "path")
|
|
249
275
|
);
|
|
250
276
|
|
|
251
277
|
create index if not exists "paths_target_idx"
|
|
@@ -285,29 +311,45 @@ create index if not exists "assets_space_created_idx"
|
|
|
285
311
|
-- rolls the whole rewrite back. Inserted rows get their space from the
|
|
286
312
|
-- a_set_space trigger (the claim, or the default space).
|
|
287
313
|
-- A target may name redirect_locales: its old live rows there stay as redirects.
|
|
314
|
+
-- A row carries "keys": every (anchor, path) that yields its full URL, from
|
|
315
|
+
-- the config's addresses (spec 2026-09-29 anchored paths §2). A redirect at
|
|
316
|
+
-- any of them goes (live wins, a redirect by hand has no anchor), and an old
|
|
317
|
+
-- row at one is the same address moving between anchors, never a redirect.
|
|
288
318
|
create or replace function "smoodly_rewrite_paths"(p_targets jsonb, p_rows jsonb) returns void language sql set search_path = public as $$
|
|
289
319
|
delete from "paths" p
|
|
290
|
-
using jsonb_to_recordset(p_rows) as r(locale text, path text, kind text, id uuid)
|
|
291
|
-
where p."redirect" and p."locale" = r.locale
|
|
320
|
+
using jsonb_to_recordset(p_rows) as r(locale text, anchor text, path text, kind text, id uuid, keys jsonb)
|
|
321
|
+
where p."redirect" and p."locale" = r.locale
|
|
322
|
+
and exists (select 1 from jsonb_to_recordset(r.keys) as k(anchor text, path text) where k.anchor = p."anchor" and k.path = p."path")
|
|
292
323
|
and p."space_id" = "smoodly_current_space"();
|
|
293
324
|
update "paths" p set "redirect" = true
|
|
294
325
|
from jsonb_to_recordset(p_targets) as t(kind text, id uuid, redirect_locales text[])
|
|
295
326
|
where not p."redirect" and p."kind" = t.kind and p."target_id" = t.id
|
|
296
327
|
and p."locale" = any (coalesce(t.redirect_locales, '{}'::text[]))
|
|
297
|
-
and not exists (select 1 from jsonb_to_recordset(p_rows) as r(locale text, path text, kind text, id uuid)
|
|
298
|
-
|
|
328
|
+
and not exists (select 1 from jsonb_to_recordset(p_rows) as r(locale text, anchor text, path text, kind text, id uuid, keys jsonb)
|
|
329
|
+
cross join lateral jsonb_to_recordset(r.keys) as k(anchor text, path text)
|
|
330
|
+
where r.locale = p."locale" and k.anchor = p."anchor" and k.path = p."path")
|
|
299
331
|
and p."space_id" = "smoodly_current_space"();
|
|
300
332
|
delete from "paths" p
|
|
301
333
|
using jsonb_to_recordset(p_targets) as t(kind text, id uuid, redirect_locales text[])
|
|
302
334
|
where p."kind" = t.kind and p."target_id" = t.id
|
|
303
|
-
and (not p."redirect" or not exists (select 1 from jsonb_to_recordset(p_rows) as r(locale text, path text, kind text, id uuid)
|
|
335
|
+
and (not p."redirect" or not exists (select 1 from jsonb_to_recordset(p_rows) as r(locale text, anchor text, path text, kind text, id uuid)
|
|
304
336
|
where r.kind = p."kind" and r.id = p."target_id" and r.locale = p."locale"))
|
|
305
337
|
and p."space_id" = "smoodly_current_space"();
|
|
306
|
-
insert into "paths" ("locale", "path", "kind", "target_id")
|
|
307
|
-
select r.locale, r.path, r.kind, r.id
|
|
308
|
-
from jsonb_to_recordset(p_rows) as r(locale text, path text, kind text, id uuid);
|
|
338
|
+
insert into "paths" ("locale", "anchor", "path", "kind", "target_id")
|
|
339
|
+
select r.locale, r.anchor, r.path, r.kind, r.id
|
|
340
|
+
from jsonb_to_recordset(p_rows) as r(locale text, anchor text, path text, kind text, id uuid);
|
|
309
341
|
$$;
|
|
310
342
|
|
|
343
|
+
-- One call reads every candidate (anchor, path) of a request (spec
|
|
344
|
+
-- 2026-09-29 anchored paths §2); the caller picks by precedence. Invoker
|
|
345
|
+
-- rights: the paths read policy applies, as to a plain select.
|
|
346
|
+
create or replace function "smoodly_resolve_path"(p_locale text, p_keys jsonb) returns setof "paths" language sql stable set search_path = public as $$
|
|
347
|
+
select p.* from "paths" p
|
|
348
|
+
join jsonb_to_recordset(p_keys) as k(anchor text, path text) on p."anchor" = k.anchor and p."path" = k.path
|
|
349
|
+
where p."locale" = p_locale;
|
|
350
|
+
$$;`.trim(),
|
|
351
|
+
foreignKeyIndexesSQL(),
|
|
352
|
+
`
|
|
311
353
|
-- RLS on every core table. The service role bypasses it (self-host
|
|
312
354
|
-- adapters untouched). The policy set that follows (space layer) admits
|
|
313
355
|
-- a key to its own space: everything for a write key, published rows for
|
package/dist/store.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { PageTree } from "./render.tsx";
|
|
2
2
|
import type { EntryStore } from "./entry-store.ts";
|
|
3
|
+
import type { Addresses } from "./addresses.ts";
|
|
4
|
+
import type { Anchors } from "./anchors.ts";
|
|
3
5
|
import { type RefEdge, type SectionLike } from "./refs.ts";
|
|
4
6
|
import type { Toolset } from "./toolset.ts";
|
|
5
7
|
import type { MemoryRefIndex } from "./ref-index.ts";
|
|
@@ -25,6 +27,8 @@ export type PageRecord = {
|
|
|
25
27
|
/** null = folder: a node with a title and a position but no page behind it. */
|
|
26
28
|
template: string | null;
|
|
27
29
|
sort: number | null;
|
|
30
|
+
/** The owner key when code owns the record (spec 2026-09-29 nested paths §3); absent/null = an editor's page. */
|
|
31
|
+
fixed?: string | null;
|
|
28
32
|
/** One entry per locale the page exists in. */
|
|
29
33
|
locales: Record<string, PageLocale>;
|
|
30
34
|
};
|
|
@@ -35,6 +39,8 @@ export type CreatePageInput = {
|
|
|
35
39
|
template: string | null;
|
|
36
40
|
title?: string;
|
|
37
41
|
parentId?: string | null;
|
|
42
|
+
/** The owner key; only the materializer and seeds pass it. The record must be a root. */
|
|
43
|
+
fixed?: string;
|
|
38
44
|
};
|
|
39
45
|
/** `from` names the locale whose draft tree, title and slug are copied;
|
|
40
46
|
* `slug` and `title` override the copies (the materializer's per-locale
|
|
@@ -146,12 +152,16 @@ export type PageStoreOptions = {
|
|
|
146
152
|
tree?: {
|
|
147
153
|
depth: number;
|
|
148
154
|
};
|
|
155
|
+
/** Code-owned addresses by owner key (config.addresses): a code-owned record's path. */
|
|
156
|
+
addresses?: Addresses;
|
|
149
157
|
};
|
|
150
158
|
export declare const DEFAULT_LOCALES: LocaleSet;
|
|
151
159
|
/** The section list every adapter walks refs with: the registered ones plus the custom section's schema (spec 2026-09-14 §6). */
|
|
152
160
|
/** Group an index's redirect rows by locale, paths sorted. */
|
|
153
161
|
export declare function groupRedirects(rows: PathRow[]): Record<string, string[]>;
|
|
154
162
|
export declare function refSections(options: PageStoreOptions): SectionLike[] | undefined;
|
|
163
|
+
/** The anchors a store's own index reads when none is shared (spec 2026-09-29 anchored paths §2). */
|
|
164
|
+
export declare function storeAnchors(options: PageStoreOptions): Anchors;
|
|
155
165
|
/** The options every adapter resolves the same way. */
|
|
156
166
|
export declare function pathOptions(options: PageStoreOptions): PagePathOptions & {
|
|
157
167
|
depth: number;
|
|
@@ -174,6 +184,8 @@ export declare const pageErrors: {
|
|
|
174
184
|
localeInUse: (locale: string, titles: string[]) => Error;
|
|
175
185
|
moveLocales: (locale: string) => Error;
|
|
176
186
|
foreignVersion: (id: string, locale: string, versionId: string) => Error;
|
|
187
|
+
fixedTaken: (key: string) => Error;
|
|
188
|
+
fixedAtRoot: () => Error;
|
|
177
189
|
};
|
|
178
190
|
export { assertSupportedLocale } from "./paths.ts";
|
|
179
191
|
/** The page's row for a locale — the "does not exist in locale" guard every per-locale write runs. */
|
|
@@ -206,7 +218,9 @@ export declare class MemoryPageStore implements PageStore {
|
|
|
206
218
|
private hasChildren;
|
|
207
219
|
private siblings;
|
|
208
220
|
/** Sibling slugs are unique per parent PER LOCALE — a friendlier error
|
|
209
|
-
* than the path index's, which stays the real invariant.
|
|
221
|
+
* than the path index's, which stays the real invariant. Code-owned
|
|
222
|
+
* roots are left out: their path is their address, and the path index
|
|
223
|
+
* guards it (spec 2026-09-29 nested paths §3). */
|
|
210
224
|
private assertSlugFree;
|
|
211
225
|
private subtree;
|
|
212
226
|
private writePaths;
|