smoodly 0.0.34 → 0.0.36

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 (79) hide show
  1. package/README.md +807 -1326
  2. package/dist/admin/drift.js +3 -2
  3. package/dist/admin/editor/EditorView.js +10 -3
  4. package/dist/admin/editor/SeoSettings.d.ts +7 -1
  5. package/dist/admin/editor/SeoSettings.js +2 -2
  6. package/dist/admin/editor/autosave.d.ts +6 -1
  7. package/dist/admin/editor/autosave.js +43 -3
  8. package/dist/admin/field-label.d.ts +2 -0
  9. package/dist/admin/field-label.js +2 -0
  10. package/dist/admin/forms/FieldWidget.js +2 -2
  11. package/dist/admin/forms/list.js +10 -1
  12. package/dist/admin/next/create-admin.d.ts +4 -4
  13. package/dist/admin/next/create-admin.js +5 -5
  14. package/dist/admin/next/index.d.ts +1 -1
  15. package/dist/admin/serialize.d.ts +8 -1
  16. package/dist/admin/serialize.js +23 -7
  17. package/dist/admin/shell/AdminApp.d.ts +3 -3
  18. package/dist/admin/shell/AdminApp.js +2 -2
  19. package/dist/admin/shell/EntryForm.js +11 -3
  20. package/dist/admin/shell/GlobalForm.js +3 -2
  21. package/dist/admin/shell/presence-realtime.d.ts +2 -2
  22. package/dist/admin/shell/router.d.ts +5 -0
  23. package/dist/admin/shell/router.js +13 -0
  24. package/dist/cli/space-io.js +1 -1
  25. package/dist/collections.d.ts +4 -0
  26. package/dist/collections.js +9 -1
  27. package/dist/entry-page.d.ts +12 -10
  28. package/dist/entry-store.d.ts +29 -0
  29. package/dist/entry-store.js +53 -0
  30. package/dist/env.d.ts +5 -5
  31. package/dist/env.js +2 -2
  32. package/dist/fields.d.ts +13 -7
  33. package/dist/fields.js +2 -2
  34. package/dist/global.d.ts +4 -7
  35. package/dist/index.d.ts +10 -39
  36. package/dist/index.js +1 -1
  37. package/dist/migrations.js +1 -1
  38. package/dist/next/canvas-slot.d.ts +7 -0
  39. package/dist/next/canvas-slot.js +9 -0
  40. package/dist/next/index.d.ts +3 -1
  41. package/dist/next/index.js +3 -1
  42. package/dist/next/meta.d.ts +6 -4
  43. package/dist/next/meta.js +14 -17
  44. package/dist/next/page-renderer.d.ts +2 -0
  45. package/dist/next/page-renderer.js +18 -11
  46. package/dist/next/preview-pill.d.ts +9 -0
  47. package/dist/next/preview-pill.js +56 -0
  48. package/dist/next/preview-route.d.ts +1 -0
  49. package/dist/next/preview-route.js +30 -0
  50. package/dist/next/queries.d.ts +10 -11
  51. package/dist/next/queries.js +7 -2
  52. package/dist/page.d.ts +5 -2
  53. package/dist/page.js +3 -1
  54. package/dist/pairing.d.ts +10 -10
  55. package/dist/path-index.d.ts +3 -0
  56. package/dist/path-index.js +7 -0
  57. package/dist/read-shapes.d.ts +64 -0
  58. package/dist/read-shapes.js +52 -0
  59. package/dist/render.d.ts +3 -3
  60. package/dist/resolve.d.ts +12 -0
  61. package/dist/resolve.js +29 -7
  62. package/dist/schema-version.d.ts +1 -1
  63. package/dist/schema-version.js +1 -1
  64. package/dist/schema.d.ts +22 -1
  65. package/dist/seo.d.ts +18 -0
  66. package/dist/seo.js +53 -0
  67. package/dist/site.d.ts +15 -16
  68. package/dist/site.js +64 -58
  69. package/dist/sql-space.d.ts +15 -0
  70. package/dist/sql-space.js +54 -39
  71. package/dist/sql.d.ts +11 -1
  72. package/dist/sql.js +78 -25
  73. package/dist/supabase-entry-store.d.ts +3 -1
  74. package/dist/supabase-entry-store.js +39 -0
  75. package/dist/supabase-path-index.d.ts +1 -0
  76. package/dist/supabase-path-index.js +16 -0
  77. package/migrations/20260929135523_smoodly_v6.sql +200 -0
  78. package/migrations/20260930040956_smoodly_v7.sql +84 -0
  79. package/package.json +1 -5
package/dist/seo.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ import type { Descriptor } from "./fields.ts";
2
+ import type { Seo } from "./read-shapes.ts";
3
+ /** The reserved key an entry's SEO values live under; a collection may not declare a field of this name. */
4
+ export declare const SEO_KEY = "$seo";
5
+ /** `seo: true` falls back on the title only; the object names the fields the description and the image fall back on. */
6
+ export type CollectionSeo = true | {
7
+ description?: string;
8
+ image?: string;
9
+ };
10
+ export declare const SEO_DESCRIPTOR: Descriptor;
11
+ export declare function validateCollectionSeo(name: string, seo: CollectionSeo, fields: Record<string, Descriptor>): void;
12
+ /** An entry's effective SEO: the editor's `$seo` values where set, the fallbacks the collection names otherwise. */
13
+ export declare function entrySeo(fields: Record<string, unknown>, schema: {
14
+ titleField?: string;
15
+ seo?: CollectionSeo;
16
+ }): Seo;
17
+ /** A page's effective SEO from its stored meta; the title falls back to the page title. */
18
+ export declare function pageSeo(meta: Record<string, unknown> | undefined, title: string): Seo;
package/dist/seo.js ADDED
@@ -0,0 +1,53 @@
1
+ /** The reserved key an entry's SEO values live under; a collection may not declare a field of this name. */
2
+ export const SEO_KEY = "$seo";
3
+ export const SEO_DESCRIPTOR = {
4
+ type: "object",
5
+ optional: true,
6
+ localized: true,
7
+ fields: {
8
+ title: { type: "text", optional: true },
9
+ description: { type: "text", optional: true },
10
+ ogImage: { type: "image", optional: true },
11
+ noindex: { type: "boolean", optional: true },
12
+ },
13
+ };
14
+ export function validateCollectionSeo(name, seo, fields) {
15
+ if (seo === true)
16
+ return;
17
+ for (const [role, key] of Object.entries(seo)) {
18
+ if (key === undefined)
19
+ continue;
20
+ const d = fields[key];
21
+ if (!d)
22
+ throw new Error(`smoodly: collection "${name}" seo.${role} names "${key}", which is not a field.`);
23
+ if (role === "description" && d.type !== "text")
24
+ throw new Error(`smoodly: collection "${name}" seo.description "${key}" must be a text field, not ${d.type}.`);
25
+ if (role === "image" && d.type !== "image")
26
+ throw new Error(`smoodly: collection "${name}" seo.image "${key}" must be an image field, not ${d.type}.`);
27
+ }
28
+ }
29
+ const str = (v) => (typeof v === "string" && v !== "" ? v : undefined);
30
+ const image = (v) => v !== null && typeof v === "object" && typeof v.url === "string" ? v : undefined;
31
+ const compact = (s) => Object.fromEntries(Object.entries(s).filter(([, v]) => v !== undefined));
32
+ /** An entry's effective SEO: the editor's `$seo` values where set, the fallbacks the collection names otherwise. */
33
+ export function entrySeo(fields, schema) {
34
+ const raw = fields[SEO_KEY];
35
+ const own = raw !== null && typeof raw === "object" ? raw : {};
36
+ const map = schema.seo === true || schema.seo === undefined ? {} : schema.seo;
37
+ return compact({
38
+ title: str(own.title) ?? (schema.titleField ? str(fields[schema.titleField]) : undefined),
39
+ description: str(own.description) ?? (map.description ? str(fields[map.description]) : undefined),
40
+ image: image(own.ogImage) ?? (map.image ? image(fields[map.image]) : undefined),
41
+ noindex: own.noindex === true ? true : undefined,
42
+ });
43
+ }
44
+ /** A page's effective SEO from its stored meta; the title falls back to the page title. */
45
+ export function pageSeo(meta, title) {
46
+ const m = meta ?? {};
47
+ return compact({
48
+ title: str(m.title) ?? str(title),
49
+ description: str(m.description),
50
+ image: image(m.ogImage),
51
+ noindex: m.noindex === true ? true : undefined,
52
+ });
53
+ }
package/dist/site.d.ts CHANGED
@@ -10,6 +10,7 @@ import { type StyleValues, type StyleValuesInput } from "./styles.ts";
10
10
  import { type CustomConfigInput, type ResolvedCustom } from "./custom/index.ts";
11
11
  import type { PageRecord, PageStore } from "./store.ts";
12
12
  import type { PathHit } from "./path-index.ts";
13
+ import { type EntryItem, type PageItem, type Seo } from "./read-shapes.ts";
13
14
  export type SiteOptions = {
14
15
  stores: () => {
15
16
  pages: PageStore;
@@ -40,6 +41,7 @@ export type SitePageSchema = {
40
41
  title?: string;
41
42
  slug?: string | Record<string, string>;
42
43
  fields?: Record<string, Descriptor>;
44
+ seo?: true;
43
45
  };
44
46
  };
45
47
  export type SitePage = {
@@ -58,6 +60,8 @@ export type SitePage = {
58
60
  /** One key per supported locale: the page's `{ slug, path }` there, or null where it is
59
61
  * absent or, outside draft mode, unpublished (spec 2026-09-15 §1). */
60
62
  locales: Record<string, PageAlternate | null>;
63
+ /** The effective SEO values, for a template with `seo: true` (spec 2026-09-29 content API contract §7). */
64
+ seo?: Seo;
61
65
  };
62
66
  export type SiteEntry = {
63
67
  kind: "entry";
@@ -70,6 +74,8 @@ export type SiteEntry = {
70
74
  entry: EntryRecord;
71
75
  path: string;
72
76
  locales: Record<string, LocaleAlternate | null>;
77
+ /** The effective SEO values, for a collection with `seo` (§7). */
78
+ seo?: Seo;
73
79
  };
74
80
  export type SiteHit = SitePage | SiteEntry;
75
81
  export type PageTreeItem = {
@@ -80,13 +86,7 @@ export type PageTreeItem = {
80
86
  fields: Record<string, unknown>;
81
87
  children: PageTreeItem[];
82
88
  };
83
- export type SiteEntryItem = {
84
- entry: EntryRecord;
85
- path: string | null;
86
- locale: string;
87
- locales: Record<string, LocaleAlternate | null>;
88
- };
89
- export type EntryTreeItem = SiteEntryItem & {
89
+ export type EntryTreeItem = EntryItem & {
90
90
  children: EntryTreeItem[];
91
91
  };
92
92
  /** `where`, `limit`, `parent` and `order` run in the store (spec 2026-09-11 §3); `draft` reads live rows inside the editor preview. */
@@ -97,6 +97,8 @@ export type EntryQuery = {
97
97
  where?: Where;
98
98
  limit?: number;
99
99
  draft?: boolean;
100
+ /** Only these entries (`getEntry` by id). */
101
+ ids?: string[];
100
102
  };
101
103
  /** `version`: a stored page version to render instead of the draft — History (spec 2026-09-13 §4), draft mode only. */
102
104
  export type LoadOptions = {
@@ -124,26 +126,23 @@ export interface SmoodlySite {
124
126
  getPage(pathOrId: string, options?: {
125
127
  locale?: string;
126
128
  draft?: boolean;
127
- }): Promise<SitePage | null>;
128
- getEntries(collection: string, options?: EntryQuery): Promise<SiteEntryItem[]>;
129
+ }): Promise<PageItem | null>;
130
+ getEntries(collection: string, options?: EntryQuery): Promise<EntryItem[]>;
129
131
  getEntry(collection: string, idOrPath: string, options?: {
130
132
  locale?: string;
131
133
  draft?: boolean;
132
- }): Promise<SiteEntryItem | null>;
134
+ }): Promise<EntryItem | null>;
133
135
  getEntriesTree(collection: string, options?: {
134
136
  locale?: string;
135
137
  draft?: boolean;
136
138
  }): Promise<EntryTreeItem[]>;
137
- /** A global item's content in a locale (spec 2026-09-07 §4): an object
138
- * item's merged fields, or a visual item's section props (fields plus
139
- * `settings`), refs hydrated; null when the item has no row there or is
140
- * unpublished (outside draft mode). */
139
+ /** A global item's content in a locale (spec 2026-09-07 §4): `{ locale, fields }`,
140
+ * plus `settings` and `styles` for a visual item, refs hydrated; null when the
141
+ * item has no row there or is unpublished (outside draft mode). */
141
142
  getGlobal<V>(item: GlobalSchema<V>, options?: {
142
143
  locale?: string;
143
144
  draft?: boolean;
144
145
  }): Promise<V | null>;
145
146
  }
146
- /** The `page` prop for a loaded page. `href` is the app's seam from a
147
- * locale-relative path to a site URL; the default keeps CMS paths as URLs. */
148
147
  export declare function pageContextOf(hit: SitePage, href?: (path: string, locale: string) => string): PageContext;
149
148
  export declare function createSmoodlySite(options: SiteOptions): SmoodlySite;
package/dist/site.js CHANGED
@@ -1,6 +1,6 @@
1
- import { ARRAY_FIELD_TYPES, entryFieldsIn, localizedKeys, schemaOf } from "./entry-store.js";
1
+ import { ARRAY_FIELD_TYPES, entryFieldsIn, schemaOf } from "./entry-store.js";
2
2
  import { buildPageTree, chainOf, entryAlternates, entryPathRows, entrySegment, pageAlternates, pagePath, } from "./paths.js";
3
- import { resolveFields, resolveMedia, resolveMediaSets, resolveTree } from "./resolve.js";
3
+ import { resolveFields, resolveFieldSets, resolveMedia, resolveTree } from "./resolve.js";
4
4
  import { elementSchemas } from "./richtext-walk.js";
5
5
  import { globalNodeId, SETTINGS_KEY, STYLES_KEY, CSS_KEY } from "./global.js";
6
6
  import { SMD_KEY } from "./section-wrapper.js";
@@ -9,8 +9,12 @@ import { resolveCustom } from "./custom/index.js";
9
9
  import { addressMap, codeAddresses } from "./addresses.js";
10
10
  import { fullPath, servedAnchor } from "./anchors.js";
11
11
  import { withDefaults } from "./field-defaults.js";
12
+ import { toEntryItem, toPageItem } from "./read-shapes.js";
13
+ import { entrySeo, pageSeo, SEO_KEY } from "./seo.js";
12
14
  /** The `page` prop for a loaded page. `href` is the app's seam from a
13
15
  * locale-relative path to a site URL; the default keeps CMS paths as URLs. */
16
+ /** Field types whose stored value is an object: not filterable (memory and SQL would disagree). */
17
+ const OBJECT_FIELD_TYPES = new Set(["image", "video", "file", "richtext", "object"]);
14
18
  export function pageContextOf(hit, href = (p) => p) {
15
19
  const withUrl = (l) => ({ ...l, url: href(l.path, hit.locale) });
16
20
  return {
@@ -41,6 +45,8 @@ export function createSmoodlySite(options) {
41
45
  * locale — published ones (a versioned collection's snapshot) on the
42
46
  * public path, the live rows in draft mode. */
43
47
  const fetcher = (draft, locale) => (collection, ids) => options.stores().entries.getMany(collection, ids, locale, draft ? undefined : { status: "published" });
48
+ /** The resolver's path lookup, bound to a locale: each EntryRef's address (spec 2026-09-29 content API contract §2). */
49
+ const pathLookup = (locale) => (collection, ids) => options.stores().entries.pathsIn(collection, ids, locale);
44
50
  /** The media fetcher (spec 2026-09-08 §9) — absent when the site runs without an asset store. */
45
51
  const assetFetcher = () => {
46
52
  const assets = options.stores().assets;
@@ -123,7 +129,7 @@ export function createSmoodlySite(options) {
123
129
  collections: options.collections,
124
130
  globals: options.globals,
125
131
  elements: options.elements,
126
- fetch: fetcher(draft, locale),
132
+ fetch: fetcher(draft, locale), paths: pathLookup(locale),
127
133
  assets: assetFetcher(),
128
134
  page: { fields: pageFields(record.template) },
129
135
  custom,
@@ -139,6 +145,7 @@ export function createSmoodlySite(options) {
139
145
  // Alternates advertise PUBLISHED locales only; the editor (draft) sees every present
140
146
  // one. Derived from the tree already listed — no index lookup (spec 2026-09-15 §2).
141
147
  const alternates = pageAlternates(record, byId, pathOpts, (l) => draft || record.locales[l]?.status === "published");
148
+ const withSeo = (options.pages ?? []).some((x) => x.schema.kind === "page" && x.schema.name === record.template && x.schema.seo === true);
142
149
  return {
143
150
  kind: "page",
144
151
  locale,
@@ -151,6 +158,7 @@ export function createSmoodlySite(options) {
151
158
  ancestors,
152
159
  children,
153
160
  locales: alternates,
161
+ ...(withSeo ? { seo: pageSeo(tree.meta, row.title) } : {}),
154
162
  };
155
163
  }
156
164
  async function loadEntry(id, locale, path, draft) {
@@ -170,51 +178,38 @@ export function createSmoodlySite(options) {
170
178
  : (await entries.getMany(schema.name, [id], locale, { status: "published" }))[id];
171
179
  if (!raw)
172
180
  return null;
173
- const fields = await resolveFields(raw, schema.fields, { collections: options.collections, elements: options.elements, fetch: fetcher(draft, locale), assets: assetFetcher() });
181
+ const fields = await resolveFields(raw, schema.fields, { collections: options.collections, elements: options.elements, fetch: fetcher(draft, locale), paths: pathLookup(locale), assets: assetFetcher() });
174
182
  // Alternates advertise PUBLISHED locales only; the editor (draft) sees every present one.
175
183
  // One record is in memory (a nested entry's parents are not), so the `paths` index
176
184
  // supplies the paths — the same single lookup as before (spec 2026-09-15 §2).
177
185
  const known = await entries.pathsOf(schema.name, id);
178
186
  const alternates = entryAlternates(record, locales, (l) => draft || record.locales[l]?.status === "published", (l) => known[l] ?? null);
179
- return { kind: "entry", locale, collection: schema.name, entry: { ...record, fields }, path, locales: alternates };
187
+ return {
188
+ kind: "entry", locale, collection: schema.name, entry: { ...record, fields }, path, locales: alternates,
189
+ ...(schema.seo ? { seo: entrySeo(fields, schema) } : {}),
190
+ };
180
191
  }
181
192
  return null;
182
193
  }
183
194
  const localeOf = (o) => o?.locale ?? locales.default;
184
- /** One locale's published fields and path for entries of one collection;
185
- * `all` is the whole collection (a draft ancestor still contributes
186
- * its segment). `fieldsById` is the published read for the locale. */
187
- function entryItems(schema, wanted, all, locale, draft, fieldsById) {
188
- const index = new Map(all.map((e) => [e.id, e]));
189
- const byId = (x) => index.get(x);
190
- return wanted
191
- .filter((record) => fieldsById[record.id] !== undefined)
192
- .map((record) => {
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
- };
198
- return {
199
- entry: { ...record, fields: fieldsById[record.id] },
200
- path: pathIn(locale),
201
- locale,
202
- locales: entryAlternates(record, locales, (l) => draft || record.locales[l]?.status === "published", pathIn),
203
- };
204
- });
205
- }
206
- async function getEntries(collection, o = {}) {
195
+ async function readEntries(collection, o = {}) {
207
196
  const schema = schemaOf(options.collections, collection);
208
197
  const locale = localeOf(o);
209
198
  const draft = o.draft ?? false;
210
199
  const { entries } = options.stores();
211
200
  // A filter that silently matched nothing would be worse than an error (spec 2026-09-11 §3).
212
201
  for (const key of Object.keys(o.where ?? {})) {
213
- if (key === "slug" || localizedKeys(schema).includes(key)) {
214
- throw new Error(`smoodly: getEntries("${collection}"): "${key}" is localized — where filters shared fields only (spec 2026-09-11 §3).`);
202
+ if (key === "slug") {
203
+ throw new Error(`smoodly: getEntries("${collection}"): "slug" is not filterable — where filters fields; find an entry by its path instead.`);
215
204
  }
205
+ if (key === SEO_KEY)
206
+ throw new Error(`smoodly: getEntries("${collection}"): "${SEO_KEY}" is reserved — the SEO panel's values are not filterable.`);
216
207
  if (!(key in schema.fields))
217
208
  throw new Error(`smoodly: getEntries("${collection}"): "${key}" is not a field of the collection.`);
209
+ // The memory adapter's `===` never matches an object while SQL's `@>` matches a subset, so the two would disagree.
210
+ if (OBJECT_FIELD_TYPES.has(schema.fields[key].type ?? "")) {
211
+ throw new Error(`smoodly: getEntries("${collection}"): "${key}" holds an object — filter on a scalar field instead.`);
212
+ }
218
213
  // Any-of reads the text form of the whole field (PostgREST's `->>`), which for
219
214
  // an array is its JSON — `["a"]` never matches "a" — so refuse rather than match nothing.
220
215
  if (Array.isArray(o.where[key]) && ARRAY_FIELD_TYPES.has(schema.fields[key].type ?? "")) {
@@ -224,26 +219,35 @@ export function createSmoodlySite(options) {
224
219
  if (o.limit !== undefined && (!Number.isInteger(o.limit) || o.limit < 0)) {
225
220
  throw new Error(`smoodly: getEntries("${collection}"): limit must be a non-negative integer.`);
226
221
  }
227
- // The store filters, orders and limits (spec 2026-09-11 §3): the locale
228
- // gate — presence in draft mode, published otherwise — is part of the
229
- // query, so `limit` counts rows that are really there.
230
- const inLocale = await entries.list(collection, {
231
- order: o.order, where: o.where, limit: o.limit,
222
+ // The store filters, orders and limits over the snapshot shown (spec 2026-09-29 content API contract §5): the
223
+ // locale gate — presence in draft mode, published otherwise — is part of the query, so `limit` counts rows that are really there.
224
+ const rows = await entries.query(collection, {
225
+ locale, draft, where: o.where, order: o.order, limit: o.limit, ids: o.ids,
232
226
  ...(o.parent !== undefined ? { parent: o.parent } : {}),
233
- ...(draft ? { locale } : { status: "published", locale }),
234
227
  });
235
228
  // Paths need every ancestor: a tree collection reads the whole collection for that, a flat one does not.
236
- const all = schema.tree ? await entries.list(collection) : inLocale;
237
- const fieldsById = draft
238
- ? Object.fromEntries(inLocale.map((e) => [e.id, entryFieldsIn(e, locale)]))
239
- : await entries.getMany(collection, inLocale.map((e) => e.id), locale, { status: "published" });
240
- // Refs stay ids on the content API; media resolves (one asset round for
241
- // the whole list), because a list page's cards need their image URLs.
242
- const fetchAssets = assetFetcher();
243
- if (fetchAssets)
244
- await resolveMediaSets(Object.values(fieldsById).map((values) => ({ values, descriptors: schema.fields })), fetchAssets, elements);
245
- return entryItems(schema, inLocale, all, locale, draft, fieldsById);
229
+ const all = schema.tree ? await entries.list(collection) : rows.map((r) => r.record);
230
+ // References and media resolve for the whole list in one pass (§2, §6).
231
+ const sets = rows.map((r) => ({ values: structuredClone(r.fields), descriptors: schema.fields }));
232
+ await resolveFieldSets(sets, { collections: options.collections, elements: options.elements, fetch: fetcher(draft, locale), paths: pathLookup(locale), assets: assetFetcher() });
233
+ const index = new Map(all.map((e) => [e.id, e]));
234
+ const byId = (x) => index.get(x);
235
+ return rows.map(({ record }, i) => {
236
+ const paths = entryPathRows([record], byId, schema.name);
237
+ const pathIn = (l) => {
238
+ const row = paths.find((r) => r.locale === l);
239
+ return row ? fullPath(anchors, row.anchor ?? "", row.path, l) : null;
240
+ };
241
+ const item = toEntryItem(record, sets[i].values, {
242
+ path: pathIn(locale),
243
+ locale,
244
+ locales: entryAlternates(record, locales, (l) => draft || record.locales[l]?.status === "published", pathIn),
245
+ ...(schema.seo ? { seo: entrySeo(sets[i].values, schema) } : {}),
246
+ });
247
+ return { record, item };
248
+ });
246
249
  }
250
+ const getEntries = async (collection, o = {}) => (await readEntries(collection, o)).map((r) => r.item);
247
251
  const api = {
248
252
  async getPagesTree(o) {
249
253
  const locale = localeOf(o);
@@ -273,28 +277,29 @@ export function createSmoodlySite(options) {
273
277
  if (!path)
274
278
  return null;
275
279
  const hit = await site.resolve(locale, path, { draft: o?.draft ?? false });
276
- return hit?.kind === "page" ? hit : null;
280
+ return hit?.kind === "page" ? toPageItem(hit) : null;
277
281
  },
278
282
  getEntries,
279
283
  async getEntry(collection, idOrPath, o) {
280
284
  schemaOf(options.collections, collection); // an unknown collection is a wiring error on every branch
281
285
  const locale = localeOf(o);
286
+ const draft = o?.draft ?? false;
282
287
  if (idOrPath.startsWith("/")) {
283
- const hit = await site.resolve(locale, idOrPath, { draft: o?.draft ?? false });
284
- return hit?.kind === "entry" && hit.collection === collection ? { entry: hit.entry, path: hit.path, locale: hit.locale, locales: hit.locales } : null;
288
+ const hit = await site.resolve(locale, idOrPath, { draft });
289
+ return hit?.kind === "entry" && hit.collection === collection ? toEntryItem(hit.entry, hit.entry.fields, hit) : null;
285
290
  }
286
- const items = await getEntries(collection, { locale, draft: o?.draft ?? false });
287
- const byId = items.find((i) => i.entry.id === idOrPath);
291
+ const [byId] = await readEntries(collection, { locale, draft, ids: [idOrPath] });
288
292
  if (byId)
289
- return byId;
293
+ return byId.item;
290
294
  // A bare slug names a ROOT entry only (sibling-scoped slugs, spec §4):
291
295
  // match the localized slug directly (paths.ts owns slug derivation),
292
296
  // regardless of whether the collection even has a `path`.
293
- return items.find((i) => i.entry.parentId === null && entrySegment(i.entry, locale) === idOrPath) ?? null;
297
+ const all = await readEntries(collection, { locale, draft });
298
+ return all.find((r) => r.record.parentId === null && entrySegment(r.record, locale) === idOrPath)?.item ?? null;
294
299
  },
295
300
  async getEntriesTree(collection, o) {
296
- const items = await getEntries(collection, { locale: localeOf(o), draft: o?.draft ?? false });
297
- const build = (parentId) => items.filter((i) => i.entry.parentId === parentId).map((i) => ({ ...i, children: build(i.entry.id) }));
301
+ const rows = await readEntries(collection, { locale: localeOf(o), draft: o?.draft ?? false });
302
+ const build = (parentId) => rows.filter((r) => r.record.parentId === parentId).map((r) => ({ ...r.item, children: build(r.record.id) }));
298
303
  return build(null);
299
304
  },
300
305
  async getGlobal(item, o) {
@@ -312,9 +317,9 @@ export function createSmoodlySite(options) {
312
317
  : (await entries.getMany(item.name, [record.id], locale, { status: "published" }))[record.id];
313
318
  if (!raw)
314
319
  return null;
315
- const fields = await resolveFields(raw, item.fields, { collections: options.collections, elements: options.elements, fetch: fetcher(draft, locale), assets: assetFetcher() });
320
+ const fields = await resolveFields(raw, item.fields, { collections: options.collections, elements: options.elements, fetch: fetcher(draft, locale), paths: pathLookup(locale), assets: assetFetcher() });
316
321
  if (!item.section)
317
- return fields;
322
+ return { locale, fields };
318
323
  const { [SETTINGS_KEY]: settings, [STYLES_KEY]: steps, [CSS_KEY]: css, ...rest } = fields;
319
324
  const fetch = assetFetcher();
320
325
  const own = settings !== null && typeof settings === "object" ? settings : {};
@@ -324,7 +329,8 @@ export function createSmoodlySite(options) {
324
329
  // the wrapper strips it and emits data-smd-node, so the canvas selects the item and the css reaches it.
325
330
  const stepsObject = steps !== null && typeof steps === "object" ? steps : {};
326
331
  return {
327
- ...rest,
332
+ locale,
333
+ fields: rest,
328
334
  settings: own,
329
335
  styles: { ...stepsObject, [SMD_KEY]: { node: globalNodeId(item.name), css: typeof css === "string" ? css : undefined, values: styles } },
330
336
  };
@@ -7,6 +7,18 @@ 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. Each content table has ONE
16
+ * select policy — its own space, not revoked, and a write key (spec
17
+ * 2026-09-29 content API contract §8): no key but a write key reads
18
+ * content; the publishable key serves sign-in and presence. Split write
19
+ * policies beside it. `refs` and `members` keep their `for all` write
20
+ * policy, which is also their only select. */
21
+ export declare function contentPoliciesSQL(): string;
10
22
  export declare function spaceLayerSQL(): string;
11
23
  /** The media bucket and its object policies (spec 2026-09-08 media §3,
12
24
  * §12). Public read: the CDN and next/image fetch without tokens. Writes:
@@ -15,6 +27,9 @@ export declare function spaceLayerSQL(): string;
15
27
  * Schema-qualified calls: storage policies do not run with public on
16
28
  * the search path. Last in the migration, after the functions exist. */
17
29
  export declare function storageSQL(): string;
30
+ /** The media bucket's write policies: insert, update, delete for a write
31
+ * key under its own space's prefix; reads are the public select above. */
32
+ export declare function mediaPoliciesSQL(): string;
18
33
  /** The three mutation RPCs are the stores' verbs, never a browser's (spec
19
34
  * 2026-09-26 security pass §2): executable by the service role and by
20
35
  * `authenticated` (the role a cloud write key carries), not by anon or
package/dist/sql-space.js CHANGED
@@ -29,26 +29,47 @@ language sql stable set search_path = '' as $$
29
29
  select coalesce((auth.jwt() ->> 'space_id')::uuid, ${DEFAULT_SPACE});
30
30
  $$;`.trim();
31
31
  }
32
- /** Which rows a read key (or the anon key, which has no claim) may see.
33
- * null = write keys only — refs is editor tooling; members is admission
34
- * data; nothing a browser needs. Global content (spec 2026-09-07) rides
35
- * the three entry predicates: it is stored as entries. */
36
- const PUBLISHED_READ = {
37
- pages: `exists (select 1 from "page_locales" l where l."page_id" = "pages"."id" and l."status" = 'published')`,
38
- page_locales: `"status" = 'published'`,
39
- page_versions: `exists (select 1 from "page_locales" l where l."published_version_id" = "page_versions"."id")`,
40
- entries: `exists (select 1 from "entry_locales" l where l."entry_id" = "entries"."id" and l."status" = 'published')`,
41
- entry_locales: `"status" = 'published'`,
42
- entry_versions: `exists (select 1 from "entry_locales" l where l."published_version_id" = "entry_versions"."id")`,
43
- refs: null,
44
- members: null,
45
- assets: `"status" = 'ready'`,
46
- 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')))`,
47
- };
48
32
  /** Tables whose existing indexes do not lead with space_id. */
49
33
  const NEEDS_SPACE_INDEX = [
50
34
  "page_locales", "page_versions", "entry_locales", "entry_versions", "refs",
51
35
  ];
36
+ /** One permissive policy per action (spec 2026-09-29 pre-install fixes §3):
37
+ * the write key's insert, update and delete, each alone for its action; its
38
+ * reads go through the table's one SELECT policy. `table` is as SQL names
39
+ * it (`"pages"`, `storage.objects`). Drops the old `for all` policy first. */
40
+ export function writePoliciesSQL(table, write, prefix) {
41
+ return [
42
+ `drop policy if exists "${prefix}_write" on ${table};`,
43
+ `drop policy if exists "${prefix}_insert" on ${table};\ncreate policy "${prefix}_insert" on ${table} for insert\n with check (${write});`,
44
+ `drop policy if exists "${prefix}_update" on ${table};\ncreate policy "${prefix}_update" on ${table} for update\n using (${write})\n with check (${write});`,
45
+ `drop policy if exists "${prefix}_delete" on ${table};\ncreate policy "${prefix}_delete" on ${table} for delete\n using (${write});`,
46
+ ].join("\n\n");
47
+ }
48
+ const OWN = `"space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"())`;
49
+ const WRITE = `${OWN} and (select "smoodly_key_kind"()) = 'write'`;
50
+ /** The content tables' and members' policies. Each content table has ONE
51
+ * select policy — its own space, not revoked, and a write key (spec
52
+ * 2026-09-29 content API contract §8): no key but a write key reads
53
+ * content; the publishable key serves sign-in and presence. Split write
54
+ * policies beside it. `refs` and `members` keep their `for all` write
55
+ * policy, which is also their only select. */
56
+ export function contentPoliciesSQL() {
57
+ const statements = [];
58
+ for (const table of [...CONTENT_TABLES, "members"]) {
59
+ if (table === "refs" || table === "members") {
60
+ statements.push(`drop policy if exists "smoodly_write" on "${table}";\n` +
61
+ `create policy "smoodly_write" on "${table}" for all\n` +
62
+ ` using (${WRITE})\n` +
63
+ ` with check (${WRITE});`);
64
+ continue;
65
+ }
66
+ statements.push(writePoliciesSQL(`"${table}"`, WRITE, "smoodly"));
67
+ statements.push(`drop policy if exists "smoodly_read" on "${table}";\n` +
68
+ `create policy "smoodly_read" on "${table}" for select\n` +
69
+ ` using (${WRITE});`);
70
+ }
71
+ return statements.join("\n\n");
72
+ }
52
73
  export function spaceLayerSQL() {
53
74
  const statements = [];
54
75
  statements.push(`
@@ -185,21 +206,9 @@ $$;`.trim());
185
206
  // an InitPlan, evaluated once per statement rather than once per row
186
207
  // — needed for smoodly_space_key_revoked, a security definer function
187
208
  // Postgres never inlines; applied to all three calls for the same win.
188
- const own = `"space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"())`;
189
- const write = `${own} and (select "smoodly_key_kind"()) = 'write'`;
190
- for (const table of [...CONTENT_TABLES, "members"]) {
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
- }
209
+ const own = OWN;
210
+ const write = WRITE;
211
+ statements.push(contentPoliciesSQL());
203
212
  // A write key reads its own space's row — the console URL the Members
204
213
  // page links to (spec 2026-09-25 §3). Nothing else touches spaces or
205
214
  // space_keys but the two definer functions and the service role.
@@ -235,10 +244,6 @@ $$;`.trim());
235
244
  * Schema-qualified calls: storage policies do not run with public on
236
245
  * the search path. Last in the migration, after the functions exist. */
237
246
  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
247
  return `
243
248
  insert into storage.buckets ("id", "name", "public", "file_size_limit")
244
249
  values ('media', 'media', true, 104857600)
@@ -248,10 +253,16 @@ drop policy if exists "smoodly_media_read" on storage.objects;
248
253
  create policy "smoodly_media_read" on storage.objects for select
249
254
  using ("bucket_id" = 'media');
250
255
 
251
- drop policy if exists "smoodly_media_write" on storage.objects;
252
- create policy "smoodly_media_write" on storage.objects for all
253
- using (${write})
254
- with check (${write});`.trim();
256
+ ${mediaPoliciesSQL()}`.trim();
257
+ }
258
+ /** The media bucket's write policies: insert, update, delete for a write
259
+ * key under its own space's prefix; reads are the public select above. */
260
+ export function mediaPoliciesSQL() {
261
+ const write = `"bucket_id" = 'media'` +
262
+ ` and (select public."smoodly_key_kind"()) = 'write'` +
263
+ ` and not (select public."smoodly_space_key_revoked"())` +
264
+ ` and split_part("name", '/', 1) = (select public."smoodly_current_space"())::text`;
265
+ return writePoliciesSQL("storage.objects", write, "smoodly_media");
255
266
  }
256
267
  /** The three mutation RPCs are the stores' verbs, never a browser's (spec
257
268
  * 2026-09-26 security pass §2): executable by the service role and by
@@ -259,9 +270,13 @@ create policy "smoodly_media_write" on storage.objects for all
259
270
  * public. `smoodly_current_space` is NOT here — the stores read it through
260
271
  * rpc(). Last in the schema, after every definition exists. */
261
272
  export function grantsSQL() {
273
+ // smoodly_list_entries is callable by every role on purpose: invoker
274
+ // rights and the read policies decide what a key sees (spec 2026-09-29
275
+ // content API contract §5), so there is nothing to revoke.
262
276
  return [
263
277
  `revoke execute on function "smoodly_reorder"(text, uuid[]) from public, anon;`,
264
278
  `revoke execute on function "smoodly_reorder_pages"(uuid[]) from public, anon;`,
265
279
  `revoke execute on function "smoodly_rewrite_paths"(jsonb, jsonb) from public, anon;`,
280
+ `revoke execute on function "smoodly_space_row_cap"() from public, anon;`,
266
281
  ].join("\n");
267
282
  }
package/dist/sql.d.ts CHANGED
@@ -1,3 +1,13 @@
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
+ /** 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;
8
+ /** The shown snapshot of one collection's entries in a locale, filtered,
9
+ * ordered and limited in one query (spec 2026-09-29 content API contract
10
+ * §5). Invoker rights, so RLS decides what a key sees. Defined after the
11
+ * space layer, whose `smoodly_key_kind` it calls. */
12
+ export declare function listEntriesSQL(): string;
3
13
  export declare function coreTablesSQL(): string;