smoodly 0.0.4 → 0.0.6

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 (89) hide show
  1. package/README.md +408 -34
  2. package/dist/admin/client-ops.js +2 -2
  3. package/dist/admin/editor/EditorView.d.ts +3 -2
  4. package/dist/admin/editor/EditorView.js +133 -80
  5. package/dist/admin/editor/PageSettings.d.ts +13 -0
  6. package/dist/admin/editor/PageSettings.js +40 -0
  7. package/dist/admin/fixed-nodes.d.ts +23 -0
  8. package/dist/admin/fixed-nodes.js +83 -0
  9. package/dist/admin/forms/FieldWidget.d.ts +2 -1
  10. package/dist/admin/forms/FieldWidget.js +16 -16
  11. package/dist/admin/index.d.ts +2 -1
  12. package/dist/admin/index.js +1 -0
  13. package/dist/admin/next/create-admin.js +7 -2
  14. package/dist/admin/next/op-handler.d.ts +1 -0
  15. package/dist/admin/next/op-handler.js +7 -2
  16. package/dist/admin/ops-impl.d.ts +4 -1
  17. package/dist/admin/ops-impl.js +190 -48
  18. package/dist/admin/ops.d.ts +39 -11
  19. package/dist/admin/serialize.d.ts +10 -1
  20. package/dist/admin/serialize.js +13 -2
  21. package/dist/admin/shell/AdminApp.js +36 -74
  22. package/dist/admin/shell/EntryForm.js +34 -10
  23. package/dist/admin/shell/ErrorPane.d.ts +4 -0
  24. package/dist/admin/shell/ErrorPane.js +9 -0
  25. package/dist/admin/shell/ListView.d.ts +8 -5
  26. package/dist/admin/shell/ListView.js +12 -11
  27. package/dist/admin/shell/LoginView.js +6 -2
  28. package/dist/admin/shell/PagesList.d.ts +6 -0
  29. package/dist/admin/shell/PagesList.js +148 -0
  30. package/dist/admin/shell/base.d.ts +1 -0
  31. package/dist/admin/shell/base.js +4 -0
  32. package/dist/admin/shell/pages-tree.d.ts +39 -0
  33. package/dist/admin/shell/pages-tree.js +65 -0
  34. package/dist/admin/ui/format.d.ts +1 -0
  35. package/dist/admin/ui/format.js +25 -0
  36. package/dist/admin/ui/primitives.d.ts +89 -0
  37. package/dist/admin/ui/primitives.js +115 -0
  38. package/dist/admin/ui/theme.d.ts +5 -0
  39. package/dist/admin/ui/theme.js +396 -0
  40. package/dist/collections.d.ts +23 -2
  41. package/dist/collections.js +19 -0
  42. package/dist/config.d.ts +11 -1
  43. package/dist/config.js +71 -13
  44. package/dist/entry-page.d.ts +16 -0
  45. package/dist/entry-page.js +6 -0
  46. package/dist/entry-store.d.ts +94 -7
  47. package/dist/entry-store.js +175 -26
  48. package/dist/index.d.ts +24 -8
  49. package/dist/index.js +14 -3
  50. package/dist/localize.d.ts +19 -0
  51. package/dist/localize.js +42 -0
  52. package/dist/next/index.d.ts +2 -2
  53. package/dist/next/index.js +1 -1
  54. package/dist/next/meta.d.ts +0 -6
  55. package/dist/next/meta.js +0 -11
  56. package/dist/next/page-renderer.d.ts +26 -18
  57. package/dist/next/page-renderer.js +79 -56
  58. package/dist/page.d.ts +10 -3
  59. package/dist/page.js +12 -2
  60. package/dist/pairing.d.ts +7 -6
  61. package/dist/path-index.d.ts +16 -0
  62. package/dist/path-index.js +33 -0
  63. package/dist/paths.d.ts +80 -0
  64. package/dist/paths.js +110 -0
  65. package/dist/render.d.ts +34 -5
  66. package/dist/render.js +9 -2
  67. package/dist/resolve.d.ts +8 -0
  68. package/dist/resolve.js +19 -7
  69. package/dist/revalidate.d.ts +10 -2
  70. package/dist/revalidate.js +13 -8
  71. package/dist/site.d.ts +85 -0
  72. package/dist/site.js +164 -0
  73. package/dist/sql-space.d.ts +1 -1
  74. package/dist/sql-space.js +2 -0
  75. package/dist/sql.d.ts +1 -1
  76. package/dist/sql.js +92 -15
  77. package/dist/store.d.ts +106 -30
  78. package/dist/store.js +242 -66
  79. package/dist/supabase-entry-store.d.ts +17 -3
  80. package/dist/supabase-entry-store.js +198 -22
  81. package/dist/supabase-path-index.d.ts +11 -0
  82. package/dist/supabase-path-index.js +52 -0
  83. package/dist/supabase-store.d.ts +46 -16
  84. package/dist/supabase-store.js +322 -110
  85. package/package.json +5 -4
  86. package/dist/admin/page-path.d.ts +0 -1
  87. package/dist/admin/page-path.js +0 -6
  88. package/dist/admin/shell/pages-list.d.ts +0 -22
  89. package/dist/admin/shell/pages-list.js +0 -31
package/dist/resolve.js CHANGED
@@ -26,12 +26,8 @@ export async function resolveTree(tree, options) {
26
26
  const schema = "schema" in s ? s.schema : s;
27
27
  return [schema.name, schema];
28
28
  }));
29
- const collectionSchemas = new Map(options.collections.map((c) => [c.name, c]));
30
29
  const resolved = structuredClone(tree);
31
- // every entry is fetched once; cycle cuts reuse cached ancestors
32
- const cache = new Map();
33
- // seed tasks from every node's ref-typed fields
34
- let tasks = [];
30
+ const tasks = [];
35
31
  for (const nodes of Object.values(resolved.zones ?? {})) {
36
32
  for (const node of nodes) {
37
33
  const schema = sectionSchemas.get(node.type);
@@ -40,7 +36,24 @@ export async function resolveTree(tree, options) {
40
36
  collectTasks(node.fields, schema.fields, tasks);
41
37
  }
42
38
  }
43
- for (let round = 0; round < maxDepth && tasks.length > 0; round++) {
39
+ await runResolution(tasks, { collections: options.collections, fetch: options.fetch, maxDepth });
40
+ return resolved;
41
+ }
42
+ /** One entry's fields, refs hydrated — the entry page's counterpart of
43
+ * resolveTree. Same rules, same batching, same cycle cut. */
44
+ export async function resolveFields(fields, descriptors, options) {
45
+ const resolved = structuredClone(fields);
46
+ const tasks = [];
47
+ collectTasks(resolved, descriptors, tasks);
48
+ await runResolution(tasks, { ...options, maxDepth: options.maxDepth ?? Infinity });
49
+ return resolved;
50
+ }
51
+ async function runResolution(seeds, options) {
52
+ const collectionSchemas = new Map(options.collections.map((c) => [c.name, c]));
53
+ // every entry is fetched once; cycle cuts reuse cached ancestors
54
+ const cache = new Map();
55
+ let tasks = seeds;
56
+ for (let round = 0; round < options.maxDepth && tasks.length > 0; round++) {
44
57
  // batch: one fetch per collection with unique uncached ids
45
58
  const wanted = new Map();
46
59
  for (const task of tasks) {
@@ -111,7 +124,6 @@ export async function resolveTree(tree, options) {
111
124
  }
112
125
  tasks = next;
113
126
  }
114
- return resolved;
115
127
  }
116
128
  function collectTasks(values, descriptors, out) {
117
129
  for (const [key, d] of Object.entries(descriptors)) {
@@ -1,8 +1,16 @@
1
1
  import type { EntryStore } from "./entry-store.ts";
2
2
  import type { PageStore } from "./store.ts";
3
- export declare function affectedPageSlugs(stores: {
3
+ export type AffectedTargets = {
4
+ /** Every PUBLISHED page whose tree references the entry, directly or through other entries. */
5
+ pageIds: string[];
6
+ /** The entry itself, then every entry that references it, transitively — the entry-page cache units. */
7
+ entryIds: string[];
8
+ };
9
+ export declare const pageTag: (id: string) => string;
10
+ export declare const entryTag: (id: string) => string;
11
+ export declare function affectedTargets(stores: {
4
12
  pages: PageStore;
5
13
  entries: EntryStore;
6
14
  }, entryId: string, options?: {
7
15
  maxDepth?: number;
8
- }): Promise<string[]>;
16
+ }): Promise<AffectedTargets>;
@@ -1,12 +1,16 @@
1
1
  // The revalidation fan-out (DESIGN.md §3): an entry changed — which
2
- // published pages must re-render? Walks the refs index in REVERSE
2
+ // cached units must re-render? Walks the refs index in REVERSE
3
3
  // (entry ← entry ← page), breadth-first with a visited set so cycles
4
4
  // terminate; `maxDepth` is an optional safety valve like resolveTree's.
5
- // Framework-free on purpose: returns slugs, and the Next.js glue maps
6
- // them to `revalidateTag("page:" + slug)`.
7
- export async function affectedPageSlugs(stores, entryId, options = {}) {
5
+ // Framework-free on purpose: returns ids, and the Next glue maps them to
6
+ // cache tags (`pageTag`/`entryTag`) by id, because slugs and paths
7
+ // change (spec 2026-09-05 §5).
8
+ export const pageTag = (id) => `page:${id}`;
9
+ export const entryTag = (id) => `entry:${id}`;
10
+ export async function affectedTargets(stores, entryId, options = {}) {
8
11
  const maxDepth = options.maxDepth ?? Infinity;
9
12
  const seen = new Set([entryId]);
13
+ const entryIds = [entryId];
10
14
  const pageIds = new Set();
11
15
  let frontier = [entryId];
12
16
  for (let depth = 0; depth < maxDepth && frontier.length > 0; depth++) {
@@ -18,17 +22,18 @@ export async function affectedPageSlugs(stores, entryId, options = {}) {
18
22
  }
19
23
  else if (source.sourceKind === "entry" && !seen.has(source.sourceId)) {
20
24
  seen.add(source.sourceId);
25
+ entryIds.push(source.sourceId);
21
26
  next.push(source.sourceId);
22
27
  }
23
28
  }
24
29
  }
25
30
  frontier = next;
26
31
  }
27
- const slugs = [];
32
+ const published = [];
28
33
  for (const id of pageIds) {
29
- const page = await stores.pages.getPageById(id);
34
+ const page = await stores.pages.getPage(id);
30
35
  if (page?.publishedVersionId)
31
- slugs.push(page.slug);
36
+ published.push(id);
32
37
  }
33
- return slugs;
38
+ return { pageIds: published, entryIds };
34
39
  }
package/dist/site.d.ts ADDED
@@ -0,0 +1,85 @@
1
+ import type { CollectionSchema, EntryOrder } from "./collections.ts";
2
+ import { type EntryRecord, type EntryStore } from "./entry-store.ts";
3
+ import { type LocaleSet, type PathTarget } from "./paths.ts";
4
+ import type { SectionLike } from "./refs.ts";
5
+ import type { PageContext, PageLink, PageTree } from "./render.tsx";
6
+ import type { PageRecord, PageStore } from "./store.ts";
7
+ export type SiteOptions = {
8
+ stores: () => {
9
+ pages: PageStore;
10
+ entries: EntryStore;
11
+ };
12
+ collections: CollectionSchema<any>[];
13
+ sections: SectionLike[];
14
+ locales: LocaleSet;
15
+ homePageSlug?: string;
16
+ };
17
+ export type SitePage = {
18
+ kind: "page";
19
+ record: PageRecord;
20
+ template: string;
21
+ tree: PageTree;
22
+ title: string;
23
+ path: string;
24
+ ancestors: PageLink[];
25
+ children: PageLink[];
26
+ /** locale → path, for metadata alternates. */
27
+ paths: Record<string, string>;
28
+ };
29
+ export type SiteEntry = {
30
+ kind: "entry";
31
+ collection: string;
32
+ /** `fields` are localized for the requested locale with refs resolved. */
33
+ entry: EntryRecord;
34
+ path: string;
35
+ paths: Record<string, string>;
36
+ };
37
+ export type SiteHit = SitePage | SiteEntry;
38
+ export type PageTreeItem = {
39
+ id: string;
40
+ title: string;
41
+ path: string;
42
+ hasPage: boolean;
43
+ children: PageTreeItem[];
44
+ };
45
+ export type SiteEntryItem = {
46
+ entry: EntryRecord;
47
+ path: string | null;
48
+ };
49
+ export type EntryTreeItem = SiteEntryItem & {
50
+ children: EntryTreeItem[];
51
+ };
52
+ export type EntryQuery = {
53
+ locale?: string;
54
+ parent?: string | null;
55
+ order?: EntryOrder;
56
+ };
57
+ export type LoadOptions = {
58
+ draft?: boolean;
59
+ };
60
+ export interface SmoodlySite {
61
+ readonly locales: LocaleSet;
62
+ readonly homePageSlug: string;
63
+ /** One index hit: what lives at (locale, path). Uncached by design — the id it returns is the cache tag. */
64
+ locate(locale: string, path: string): Promise<PathTarget | null>;
65
+ /** Everything the renderer needs for one target: null when it is a folder, unpublished (outside draft), or gone. */
66
+ load(target: PathTarget, locale: string, path: string, options?: LoadOptions): Promise<SiteHit | null>;
67
+ resolve(locale: string, path: string, options?: LoadOptions): Promise<SiteHit | null>;
68
+ getPagesTree(options?: {
69
+ locale?: string;
70
+ }): Promise<PageTreeItem[]>;
71
+ getPage(pathOrId: string, options?: {
72
+ locale?: string;
73
+ }): Promise<SitePage | null>;
74
+ getEntries(collection: string, options?: EntryQuery): Promise<SiteEntryItem[]>;
75
+ getEntry(collection: string, idOrPath: string, options?: {
76
+ locale?: string;
77
+ }): Promise<SiteEntryItem | null>;
78
+ getEntriesTree(collection: string, options?: {
79
+ locale?: string;
80
+ }): Promise<EntryTreeItem[]>;
81
+ }
82
+ /** The `page` prop for a loaded page. `toUrl` is the app's `href` seam
83
+ * bound to the render locale; the default keeps CMS paths as URLs. */
84
+ export declare function pageContextOf(hit: SitePage, toUrl?: (path: string) => string): PageContext;
85
+ export declare function createSmoodlySite(options: SiteOptions): SmoodlySite;
package/dist/site.js ADDED
@@ -0,0 +1,164 @@
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";
4
+ import { resolveFields, resolveTree } from "./resolve.js";
5
+ /** The `page` prop for a loaded page. `toUrl` is the app's `href` seam
6
+ * bound to the render locale; the default keeps CMS paths as URLs. */
7
+ export function pageContextOf(hit, toUrl = (p) => p) {
8
+ const withUrl = (l) => ({ ...l, url: toUrl(l.path) });
9
+ return {
10
+ id: hit.record.id,
11
+ title: hit.title,
12
+ path: hit.path,
13
+ url: toUrl(hit.path),
14
+ ancestors: hit.ancestors.map(withUrl),
15
+ children: hit.children.map(withUrl),
16
+ };
17
+ }
18
+ export function createSmoodlySite(options) {
19
+ const locales = options.locales;
20
+ const homePageSlug = options.homePageSlug ?? "home";
21
+ const pathOpts = { locales, homePageSlug };
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);
27
+ const link = (node, byId, locale, draft) => ({
28
+ title: titleFor(node, locale, locales),
29
+ path: pagePath(node, byId, locale, pathOpts),
30
+ hasPage: hasPage(node, draft),
31
+ });
32
+ async function loadPage(id, locale, path, draft) {
33
+ const { pages } = options.stores();
34
+ const record = await pages.getPage(id);
35
+ if (!record || record.template === null)
36
+ return null;
37
+ const raw = draft ? await pages.getDraftTree(id) : record.publishedVersionId ? await pages.getPublishedTree(id) : null;
38
+ if (!raw)
39
+ return null;
40
+ const tree = await resolveTree(raw, { sections: options.sections, collections: options.collections, fetch: fetcher(draft) });
41
+ const all = await pages.listPages();
42
+ const index = new Map(all.map((n) => [n.id, n]));
43
+ const byId = (x) => index.get(x);
44
+ const chain = chainOf(record, byId);
45
+ const ancestors = chain.slice(0, -1).map((n) => link(n, byId, locale, draft));
46
+ const children = all
47
+ .filter((n) => n.parentId === id && visible(n, draft) && localesOf(n, locales).includes(locale))
48
+ .map((n) => link(n, byId, locale, draft));
49
+ return {
50
+ kind: "page",
51
+ record,
52
+ template: record.template,
53
+ tree,
54
+ title: titleFor(record, locale, locales),
55
+ path,
56
+ ancestors,
57
+ children,
58
+ paths: await pages.pathsOf(id),
59
+ };
60
+ }
61
+ async function loadEntry(id, locale, path, draft) {
62
+ const { entries } = options.stores();
63
+ for (const schema of mounted) {
64
+ const record = await entries.get(schema.name, id);
65
+ if (!record)
66
+ continue;
67
+ if (!draft && record.status !== "published")
68
+ 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
+ };
80
+ }
81
+ return null;
82
+ }
83
+ 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) {
88
+ const index = new Map(all.map((e) => [e.id, e]));
89
+ const byId = (x) => index.get(x);
90
+ return wanted.map((record) => ({
91
+ entry: { ...record, fields: localizeFields(record.fields, record.i18n, locale, locales) },
92
+ path: entryPathRows([record], byId, schema, locales).find((r) => r.locale === locale)?.path ?? null,
93
+ }));
94
+ }
95
+ const publicEntry = (e, locale) => e.status === "published" && localesOf(e, locales).includes(locale);
96
+ async function getEntries(collection, o = {}) {
97
+ const schema = schemaOf(options.collections, collection);
98
+ const locale = localeOf(o);
99
+ const { entries } = options.stores();
100
+ const all = await entries.list(collection, { order: o.order });
101
+ const wanted = all.filter((e) => publicEntry(e, locale) && (o.parent === undefined || e.parentId === o.parent));
102
+ return entryItems(schema, wanted, all, locale);
103
+ }
104
+ const api = {
105
+ async getPagesTree(o) {
106
+ const locale = localeOf(o);
107
+ const all = await options.stores().pages.listPages();
108
+ const shown = all.filter((n) => visible(n, false) && localesOf(n, locales).includes(locale));
109
+ const toItem = (node) => ({
110
+ id: node.record.id,
111
+ title: titleFor(node.record, locale, locales),
112
+ path: node.path,
113
+ hasPage: hasPage(node.record, false),
114
+ children: node.children.map(toItem),
115
+ });
116
+ return buildPageTree(shown, locale, pathOpts).map(toItem);
117
+ },
118
+ async getPage(pathOrId, o) {
119
+ const locale = localeOf(o);
120
+ const path = pathOrId.startsWith("/") ? pathOrId : (await options.stores().pages.pathsOf(pathOrId))[locale];
121
+ if (!path)
122
+ return null;
123
+ const hit = await site.resolve(locale, path);
124
+ return hit?.kind === "page" ? hit : null;
125
+ },
126
+ getEntries,
127
+ async getEntry(collection, idOrPath, o) {
128
+ schemaOf(options.collections, collection); // an unknown collection is a wiring error on every branch
129
+ const locale = localeOf(o);
130
+ if (idOrPath.startsWith("/")) {
131
+ const hit = await site.resolve(locale, idOrPath);
132
+ return hit?.kind === "entry" && hit.collection === collection ? { entry: hit.entry, path: hit.path } : null;
133
+ }
134
+ const items = await getEntries(collection, { locale });
135
+ const byId = items.find((i) => i.entry.id === idOrPath);
136
+ if (byId)
137
+ return byId;
138
+ // A bare slug names a ROOT entry only (sibling-scoped slugs, spec §4):
139
+ // match the localized slug directly (paths.ts owns slug derivation),
140
+ // 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;
142
+ },
143
+ async getEntriesTree(collection, o) {
144
+ const items = await getEntries(collection, { locale: localeOf(o) });
145
+ const build = (parentId) => items.filter((i) => i.entry.parentId === parentId).map((i) => ({ ...i, children: build(i.entry.id) }));
146
+ return build(null);
147
+ },
148
+ };
149
+ const site = {
150
+ locales,
151
+ homePageSlug,
152
+ locate: (locale, path) => options.stores().pages.resolvePath(locale, path),
153
+ load(target, locale, path, opts = {}) {
154
+ const draft = opts.draft ?? false;
155
+ return target.kind === "page" ? loadPage(target.id, locale, path, draft) : loadEntry(target.id, locale, path, draft);
156
+ },
157
+ async resolve(locale, path, opts) {
158
+ const target = await site.locate(locale, path);
159
+ return target ? site.load(target, locale, path, opts) : null;
160
+ },
161
+ ...api,
162
+ };
163
+ return site;
164
+ }
@@ -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"];
3
+ export declare const CONTENT_TABLES: readonly ["pages", "page_versions", "saved_sections", "global_sections", "entries", "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
@@ -11,6 +11,7 @@ export const CONTENT_TABLES = [
11
11
  "global_sections",
12
12
  "entries",
13
13
  "refs",
14
+ "paths",
14
15
  ];
15
16
  export function currentSpaceSQL() {
16
17
  return `
@@ -35,6 +36,7 @@ const PUBLISHED_READ = {
35
36
  saved_sections: null,
36
37
  refs: null,
37
38
  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')))`,
38
40
  };
39
41
  /** Tables whose existing indexes do not lead with space_id. */
40
42
  const NEEDS_SPACE_INDEX = ["page_versions", "saved_sections", "global_sections", "refs"];
package/dist/sql.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- import type { CollectionSchema } from "./collections.ts";
1
+ import { type CollectionSchema } from "./collections.ts";
2
2
  export declare function collectionSQL(collection: CollectionSchema<any>): string;
3
3
  export declare function coreTablesSQL(): string;
package/dist/sql.js CHANGED
@@ -4,15 +4,18 @@
4
4
  // disposable guardrails — a published-only read view, created with
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
- // SQL-queryable eject story) — and partial expression indexes (slug
8
- // uniqueness, declared orderBy fields). Views project the default
9
- // locale; the `i18n` overlay is the resolver/API's business.
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.
11
+ import { BUILT_IN_ORDER_FIELDS } from "./collections.js";
10
12
  import { currentSpaceSQL, SPACE_COL, spaceLayerSQL } from "./sql-space.js";
11
13
  const snake = (s) => s.replace(/[A-Z]/g, (c) => "_" + c.toLowerCase());
12
14
  const q = (s) => `"${s}"`;
13
15
  // Casts are fine in views (unlike index expressions, which must be
14
- // immutable — orderBy indexes stay on the text value; ISO dates sort
15
- // correctly as text).
16
+ // immutable — the order field's index stays on the raw jsonb
17
+ // expression: jsonb ordering compares numbers numerically and strings,
18
+ // ISO dates included, alphabetically, matching the memory comparator).
16
19
  function fieldExpr(key, d) {
17
20
  switch (d.type) {
18
21
  case "text":
@@ -44,11 +47,15 @@ export function collectionSQL(collection) {
44
47
  `where "collection" = '${name}' and "status" = 'published';`,
45
48
  ].join("\n"),
46
49
  ];
47
- // slug uniqueness is the generic core index (space_id, collection, slug);
48
- // only declared orderBy fields need per-collection expression indexes
49
- for (const key of Object.keys(collection.admin?.orderBy ?? {})) {
50
- statements.push(`create index if not exists ${q(`entries_${name}_${snake(key)}_idx`)}\n` +
51
- ` on "entries" (("fields"->>'${key}')) where "collection" = '${name}';`);
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.
55
+ const order = collection.order;
56
+ if (order !== undefined && order !== "manual" && !BUILT_IN_ORDER_FIELDS.includes(order.by)) {
57
+ statements.push(`create index if not exists ${q(`entries_${name}_${snake(order.by)}_idx`)}\n` +
58
+ ` on "entries" (("fields"->'${order.by}')) where "collection" = '${name}';`);
52
59
  }
53
60
  return statements.join("\n\n");
54
61
  }
@@ -63,18 +70,37 @@ export function coreTablesSQL() {
63
70
  create table if not exists "pages" (
64
71
  "id" uuid primary key default gen_random_uuid(),
65
72
  ${SPACE_COL},
73
+ "parent_id" uuid references "pages" ("id") on delete restrict,
66
74
  "slug" text not null,
67
- "template" text not null,
75
+ "title" text not null,
76
+ "i18n" jsonb,
77
+ "template" text,
68
78
  "status" text not null default 'draft',
69
79
  "sort" numeric,
70
80
  "locales" text[],
71
81
  "draft_version_id" uuid,
72
82
  "published_version_id" uuid,
73
83
  "created_at" timestamptz not null default now(),
74
- "updated_at" timestamptz not null default now(),
75
- unique ("space_id", "slug")
84
+ "updated_at" timestamptz not null default now()
76
85
  );
77
86
 
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
+
93
+ create index if not exists "pages_parent_idx"
94
+ on "pages" ("space_id", "parent_id", "sort");
95
+
96
+ -- Sibling reorder for pages: same shape as smoodly_reorder for entries.
97
+ create or replace function "smoodly_reorder_pages"(p_ids uuid[]) returns void language sql as $$
98
+ update "pages" pg set "sort" = u.ord
99
+ from unnest(p_ids) with ordinality as u(id, ord)
100
+ where pg."id" = u.id
101
+ and pg."space_id" = "smoodly_current_space"();
102
+ $$;
103
+
78
104
  create table if not exists "page_versions" (
79
105
  "id" uuid primary key default gen_random_uuid(),
80
106
  ${SPACE_COL},
@@ -104,6 +130,7 @@ create table if not exists "entries" (
104
130
  "id" uuid primary key default gen_random_uuid(),
105
131
  ${SPACE_COL},
106
132
  "collection" text not null,
133
+ "parent_id" uuid references "entries" ("id") on delete restrict,
107
134
  "fields" jsonb not null default '{}'::jsonb,
108
135
  "i18n" jsonb,
109
136
  "status" text not null default 'draft',
@@ -113,8 +140,22 @@ create table if not exists "entries" (
113
140
  "updated_at" timestamptz not null default now()
114
141
  );
115
142
 
116
- create unique index if not exists "entries_slug_key"
117
- on "entries" ("space_id", "collection", ("fields"->>'slug'));
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' <> '');
118
159
 
119
160
  create index if not exists "entries_collection_status_idx"
120
161
  on "entries" ("space_id", "collection", "status");
@@ -122,6 +163,9 @@ create index if not exists "entries_collection_status_idx"
122
163
  create index if not exists "entries_collection_sort_idx"
123
164
  on "entries" ("space_id", "collection", "sort");
124
165
 
166
+ create index if not exists "entries_parent_idx"
167
+ on "entries" ("space_id", "collection", "parent_id");
168
+
125
169
  -- v1 reorder (DESIGN.md §3 Manual ordering): rewrite sort 1..N in ONE
126
170
  -- statement. Deliberately does not touch updated_at — a drag must not
127
171
  -- make the whole collection look freshly edited. Scoped to the current
@@ -147,6 +191,38 @@ create table if not exists "refs" (
147
191
  primary key ("source_kind", "source_id", "source_field", "target_id")
148
192
  );
149
193
 
194
+ -- The URL index (spec 2026-09-05 §4): the refs counterpart for paths.
195
+ -- Derived — the stores rewrite a target's rows on every create, rename,
196
+ -- move and delete — so one primary-key hit resolves any URL in any locale
197
+ -- and no page or entry can share a URL with another in any locale.
198
+ -- Rows exist for drafts too (the editor needs them); the read policy
199
+ -- admits a row only when its target is published.
200
+ create table if not exists "paths" (
201
+ ${SPACE_COL},
202
+ "locale" text not null,
203
+ "path" text not null,
204
+ "kind" text not null check ("kind" in ('page', 'entry')),
205
+ "target_id" uuid not null,
206
+ primary key ("space_id", "locale", "path")
207
+ );
208
+
209
+ create index if not exists "paths_target_idx"
210
+ on "paths" ("space_id", "kind", "target_id");
211
+
212
+ -- Replace the targets' rows with the given rows in ONE call: a SQL
213
+ -- function runs in the caller's transaction, so a primary-key collision
214
+ -- rolls the whole rewrite back. Inserted rows get their space from the
215
+ -- a_set_space trigger (the claim, or the default space).
216
+ create or replace function "smoodly_rewrite_paths"(p_targets jsonb, p_rows jsonb) returns void language sql as $$
217
+ delete from "paths" p
218
+ using jsonb_to_recordset(p_targets) as t(kind text, id uuid)
219
+ where p."kind" = t.kind and p."target_id" = t.id
220
+ and p."space_id" = "smoodly_current_space"();
221
+ insert into "paths" ("locale", "path", "kind", "target_id")
222
+ select r.locale, r.path, r.kind, r.id
223
+ from jsonb_to_recordset(p_rows) as r(locale text, path text, kind text, id uuid);
224
+ $$;
225
+
150
226
  -- RLS on every core table. The service role bypasses it (self-host
151
227
  -- adapters untouched). The policy set that follows (space layer) admits
152
228
  -- a key to its own space: everything for a write key, published rows for
@@ -160,6 +236,7 @@ alter table "saved_sections" enable row level security;
160
236
  alter table "global_sections" enable row level security;
161
237
  alter table "entries" enable row level security;
162
238
  alter table "refs" enable row level security;
239
+ alter table "paths" enable row level security;
163
240
  `.trim(),
164
241
  spaceLayerSQL(),
165
242
  ].join("\n\n");