smoodly 0.0.35 → 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.
- package/README.md +29 -38
- package/dist/admin/drift.js +3 -2
- package/dist/admin/editor/SeoSettings.d.ts +7 -1
- package/dist/admin/editor/SeoSettings.js +2 -2
- package/dist/admin/field-label.d.ts +2 -0
- package/dist/admin/field-label.js +2 -0
- package/dist/admin/forms/FieldWidget.js +2 -2
- package/dist/admin/next/create-admin.d.ts +4 -4
- package/dist/admin/next/create-admin.js +5 -5
- package/dist/admin/next/index.d.ts +1 -1
- package/dist/admin/serialize.d.ts +8 -1
- package/dist/admin/serialize.js +23 -7
- package/dist/admin/shell/AdminApp.d.ts +3 -3
- package/dist/admin/shell/AdminApp.js +2 -2
- package/dist/admin/shell/EntryForm.js +8 -1
- package/dist/admin/shell/presence-realtime.d.ts +2 -2
- package/dist/cli/space-io.js +1 -1
- package/dist/collections.d.ts +4 -0
- package/dist/collections.js +9 -1
- package/dist/entry-page.d.ts +12 -10
- package/dist/entry-store.d.ts +29 -0
- package/dist/entry-store.js +53 -0
- package/dist/env.d.ts +5 -5
- package/dist/env.js +2 -2
- package/dist/fields.d.ts +13 -7
- package/dist/fields.js +2 -2
- package/dist/global.d.ts +4 -7
- package/dist/index.d.ts +10 -39
- package/dist/index.js +1 -1
- package/dist/migrations.js +1 -1
- package/dist/next/index.d.ts +1 -1
- package/dist/next/index.js +1 -1
- package/dist/next/meta.d.ts +6 -4
- package/dist/next/meta.js +14 -17
- package/dist/next/page-renderer.js +6 -3
- package/dist/next/queries.d.ts +10 -11
- package/dist/next/queries.js +7 -2
- package/dist/page.d.ts +5 -2
- package/dist/page.js +3 -1
- package/dist/pairing.d.ts +10 -10
- package/dist/path-index.d.ts +3 -0
- package/dist/path-index.js +7 -0
- package/dist/read-shapes.d.ts +64 -0
- package/dist/read-shapes.js +52 -0
- package/dist/render.d.ts +3 -3
- package/dist/resolve.d.ts +12 -0
- package/dist/resolve.js +29 -7
- package/dist/schema-version.d.ts +1 -1
- package/dist/schema-version.js +1 -1
- package/dist/schema.d.ts +22 -1
- package/dist/seo.d.ts +18 -0
- package/dist/seo.js +53 -0
- package/dist/site.d.ts +15 -16
- package/dist/site.js +64 -58
- package/dist/sql-space.d.ts +6 -5
- package/dist/sql-space.js +11 -24
- package/dist/sql.d.ts +6 -1
- package/dist/sql.js +60 -23
- package/dist/supabase-entry-store.d.ts +3 -1
- package/dist/supabase-entry-store.js +39 -0
- package/dist/supabase-path-index.d.ts +1 -0
- package/dist/supabase-path-index.js +16 -0
- package/migrations/20260930040956_smoodly_v7.sql +84 -0
- package/package.json +1 -1
package/dist/site.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { ARRAY_FIELD_TYPES, entryFieldsIn,
|
|
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,
|
|
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 {
|
|
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
|
-
|
|
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"
|
|
214
|
-
throw new Error(`smoodly: getEntries("${collection}"): "
|
|
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-
|
|
228
|
-
// gate — presence in draft mode, published otherwise — is part of the
|
|
229
|
-
|
|
230
|
-
|
|
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) :
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
|
284
|
-
return hit?.kind === "entry" && hit.collection === collection ?
|
|
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
|
|
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
|
-
|
|
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
|
|
297
|
-
const build = (parentId) =>
|
|
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
|
-
|
|
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
|
};
|
package/dist/sql-space.d.ts
CHANGED
|
@@ -12,11 +12,12 @@ export declare function currentSpaceSQL(): string;
|
|
|
12
12
|
* reads go through the table's one SELECT policy. `table` is as SQL names
|
|
13
13
|
* it (`"pages"`, `storage.objects`). Drops the old `for all` policy first. */
|
|
14
14
|
export declare function writePoliciesSQL(table: string, write: string, prefix: string): string;
|
|
15
|
-
/** The content tables' and members' policies.
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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. */
|
|
20
21
|
export declare function contentPoliciesSQL(): string;
|
|
21
22
|
export declare function spaceLayerSQL(): string;
|
|
22
23
|
/** The media bucket and its object policies (spec 2026-09-08 media §3,
|
package/dist/sql-space.js
CHANGED
|
@@ -29,22 +29,6 @@ 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",
|
|
@@ -63,16 +47,16 @@ export function writePoliciesSQL(table, write, prefix) {
|
|
|
63
47
|
}
|
|
64
48
|
const OWN = `"space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"())`;
|
|
65
49
|
const WRITE = `${OWN} and (select "smoodly_key_kind"()) = 'write'`;
|
|
66
|
-
/** The content tables' and members' policies.
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
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. */
|
|
71
56
|
export function contentPoliciesSQL() {
|
|
72
57
|
const statements = [];
|
|
73
58
|
for (const table of [...CONTENT_TABLES, "members"]) {
|
|
74
|
-
|
|
75
|
-
if (published === null) {
|
|
59
|
+
if (table === "refs" || table === "members") {
|
|
76
60
|
statements.push(`drop policy if exists "smoodly_write" on "${table}";\n` +
|
|
77
61
|
`create policy "smoodly_write" on "${table}" for all\n` +
|
|
78
62
|
` using (${WRITE})\n` +
|
|
@@ -82,7 +66,7 @@ export function contentPoliciesSQL() {
|
|
|
82
66
|
statements.push(writePoliciesSQL(`"${table}"`, WRITE, "smoodly"));
|
|
83
67
|
statements.push(`drop policy if exists "smoodly_read" on "${table}";\n` +
|
|
84
68
|
`create policy "smoodly_read" on "${table}" for select\n` +
|
|
85
|
-
` using (${
|
|
69
|
+
` using (${WRITE});`);
|
|
86
70
|
}
|
|
87
71
|
return statements.join("\n\n");
|
|
88
72
|
}
|
|
@@ -286,6 +270,9 @@ export function mediaPoliciesSQL() {
|
|
|
286
270
|
* public. `smoodly_current_space` is NOT here — the stores read it through
|
|
287
271
|
* rpc(). Last in the schema, after every definition exists. */
|
|
288
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.
|
|
289
276
|
return [
|
|
290
277
|
`revoke execute on function "smoodly_reorder"(text, uuid[]) from public, anon;`,
|
|
291
278
|
`revoke execute on function "smoodly_reorder_pages"(uuid[]) from public, anon;`,
|
package/dist/sql.d.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { CollectionSchema } from "./collections.ts";
|
|
2
2
|
export declare function collectionSQL(collection: CollectionSchema<any>): string;
|
|
3
3
|
/** Foreign keys the planner needs an index for (spec 2026-09-29 pre-install
|
|
4
4
|
* fixes §3): a restrict or set-null check scans the referencing table
|
|
5
5
|
* without one, and the versions' read policies probe published_version_id
|
|
6
6
|
* on every public read. The composite indexes that exist lead with space_id. */
|
|
7
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;
|
|
8
13
|
export declare function coreTablesSQL(): string;
|
package/dist/sql.js
CHANGED
|
@@ -1,23 +1,18 @@
|
|
|
1
1
|
// Schema → SQL generation (Postgres/Supabase) for the stable schema
|
|
2
2
|
// (DESIGN.md §3): all collection content lives in the one `entries`
|
|
3
3
|
// table, applied once and never migrated. Per collection we emit only
|
|
4
|
-
// disposable
|
|
4
|
+
// a disposable guardrail — a published-only read view, created with
|
|
5
5
|
// security_invoker so it runs under the caller's own RLS policies
|
|
6
|
-
// rather than the view owner's privileges (
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// is the `paths` primary key plus the stores' per-locale pre-check,
|
|
10
|
-
// not an index). A view joins the locale rows, one row per published
|
|
6
|
+
// rather than the view owner's privileges (SQL-queryable eject story;
|
|
7
|
+
// sibling slug uniqueness is the `paths` primary key plus the stores'
|
|
8
|
+
// per-locale pre-check, not an index). A view joins the locale rows, one row per published
|
|
11
9
|
// locale (spec 2026-09-06 §7); a versioned collection reads its
|
|
12
10
|
// fields from the published snapshot.
|
|
13
|
-
import {
|
|
11
|
+
import { SEO_KEY } from "./seo.js";
|
|
14
12
|
import { currentSpaceSQL, grantsSQL, SPACE_COL, spaceLayerSQL } from "./sql-space.js";
|
|
15
13
|
const snake = (s) => s.replace(/[A-Z]/g, (c) => "_" + c.toLowerCase());
|
|
16
14
|
const q = (s) => `"${s}"`;
|
|
17
|
-
// Casts are fine in views
|
|
18
|
-
// immutable — the order field's index stays on the raw jsonb
|
|
19
|
-
// expression: jsonb ordering compares numbers numerically and strings,
|
|
20
|
-
// ISO dates included, alphabetically, matching the memory comparator).
|
|
15
|
+
// Casts are fine in views.
|
|
21
16
|
function fieldExpr(key, d, source) {
|
|
22
17
|
switch (d.type) {
|
|
23
18
|
case "text":
|
|
@@ -45,6 +40,8 @@ export function collectionSQL(collection) {
|
|
|
45
40
|
for (const [key, d] of Object.entries(collection.fields)) {
|
|
46
41
|
if (key === "slug")
|
|
47
42
|
continue; // the segment is the row's column, never a field
|
|
43
|
+
if (key === SEO_KEY)
|
|
44
|
+
continue; // the panel's values are Smoodly-internal, not a view column
|
|
48
45
|
columns.push(`${fieldExpr(key, d, source)} as ${q(snake(key))}`);
|
|
49
46
|
}
|
|
50
47
|
const statements = [
|
|
@@ -58,15 +55,7 @@ export function collectionSQL(collection) {
|
|
|
58
55
|
`where e."collection" = '${name}' and l."status" = 'published';`,
|
|
59
56
|
].join("\n"),
|
|
60
57
|
];
|
|
61
|
-
//
|
|
62
|
-
// on the node's shared fields (an order field can be neither localized
|
|
63
|
-
// nor `slug`, which collection() refuses), the jsonb value (`->`) the
|
|
64
|
-
// adapter orders by, so the index serves the sort.
|
|
65
|
-
const order = collection.order;
|
|
66
|
-
if (order !== undefined && order !== "manual" && !BUILT_IN_ORDER_FIELDS.includes(order.by)) {
|
|
67
|
-
statements.push(`create index if not exists ${q(`entries_${name}_${snake(order.by)}_idx`)}\n` +
|
|
68
|
-
` on "entries" (("fields"->'${order.by}')) where "collection" = '${name}';`);
|
|
69
|
-
}
|
|
58
|
+
// Ordering reads the shown snapshot (smoodly_list_entries), so no index on the live row serves it (spec 2026-09-29 content API contract §5).
|
|
70
59
|
return statements.join("\n\n");
|
|
71
60
|
}
|
|
72
61
|
/** Foreign keys the planner needs an index for (spec 2026-09-29 pre-install
|
|
@@ -84,6 +73,53 @@ export function foreignKeyIndexesSQL() {
|
|
|
84
73
|
`create index if not exists "assets_poster_idx" on "assets" ("poster_id");`,
|
|
85
74
|
].join("\n");
|
|
86
75
|
}
|
|
76
|
+
/** The shown snapshot of one collection's entries in a locale, filtered,
|
|
77
|
+
* ordered and limited in one query (spec 2026-09-29 content API contract
|
|
78
|
+
* §5). Invoker rights, so RLS decides what a key sees. Defined after the
|
|
79
|
+
* space layer, whose `smoodly_key_kind` it calls. */
|
|
80
|
+
export function listEntriesSQL() {
|
|
81
|
+
return `
|
|
82
|
+
-- The shown snapshot of one collection's entries in a locale, filtered,
|
|
83
|
+
-- ordered and limited in one query (spec 2026-09-29 content API contract
|
|
84
|
+
-- §5): the published snapshot, or the live merge in preview. Invoker
|
|
85
|
+
-- rights, so RLS decides what a key sees; a preview read needs the
|
|
86
|
+
-- service role or a write key, anything else reads published.
|
|
87
|
+
create or replace function "smoodly_list_entries"(
|
|
88
|
+
p_collection text, p_locale text, p_draft boolean, p_where jsonb, p_order jsonb,
|
|
89
|
+
p_limit integer, p_parent uuid, p_has_parent boolean, p_ids uuid[]
|
|
90
|
+
) returns table ("id" uuid, "fields" jsonb) language sql stable set search_path = public as $$
|
|
91
|
+
with mode as (
|
|
92
|
+
select p_draft and (current_user = 'service_role' or "smoodly_key_kind"() = 'write') as draft
|
|
93
|
+
), shown as (
|
|
94
|
+
select e."id", e."sort", e."created_at", l."updated_at",
|
|
95
|
+
case when (select draft from mode) then e."fields" || l."fields" || jsonb_build_object('slug', l."slug")
|
|
96
|
+
else coalesce(v."fields", e."fields" || l."fields" || jsonb_build_object('slug', l."slug")) end as "fields"
|
|
97
|
+
from "entries" e
|
|
98
|
+
join "entry_locales" l on l."entry_id" = e."id" and l."locale" = p_locale
|
|
99
|
+
left join "entry_versions" v on v."id" = l."published_version_id"
|
|
100
|
+
where e."collection" = p_collection
|
|
101
|
+
and ((select draft from mode) or l."status" = 'published')
|
|
102
|
+
and (not p_has_parent or e."parent_id" is not distinct from p_parent)
|
|
103
|
+
and (p_ids is null or e."id" = any(p_ids))
|
|
104
|
+
)
|
|
105
|
+
select s."id", s."fields" from shown s
|
|
106
|
+
where not exists (
|
|
107
|
+
select 1 from jsonb_each(coalesce(p_where, '{}'::jsonb)) w
|
|
108
|
+
where not (case when w.value ? 'in'
|
|
109
|
+
then (s."fields" ->> w.key) = any (select jsonb_array_elements_text(w.value -> 'in'))
|
|
110
|
+
else s."fields" @> jsonb_build_object(w.key, w.value -> 'contains') end))
|
|
111
|
+
order by
|
|
112
|
+
case when p_order ->> 'by' = 'manual' then s."sort" end asc nulls last,
|
|
113
|
+
case when p_order ->> 'direction' = 'asc' then
|
|
114
|
+
case p_order ->> 'by' when 'createdAt' then to_jsonb(s."created_at") when 'updatedAt' then to_jsonb(s."updated_at")
|
|
115
|
+
when 'manual' then null else nullif(nullif(s."fields" -> (p_order ->> 'by'), 'null'::jsonb), '""'::jsonb) end end asc nulls last,
|
|
116
|
+
case when p_order ->> 'direction' = 'desc' then
|
|
117
|
+
case p_order ->> 'by' when 'createdAt' then to_jsonb(s."created_at") when 'updatedAt' then to_jsonb(s."updated_at")
|
|
118
|
+
when 'manual' then null else nullif(nullif(s."fields" -> (p_order ->> 'by'), 'null'::jsonb), '""'::jsonb) end end desc nulls last,
|
|
119
|
+
s."created_at" asc, s."id" asc
|
|
120
|
+
limit p_limit;
|
|
121
|
+
$$;`.trim();
|
|
122
|
+
}
|
|
87
123
|
export function coreTablesSQL() {
|
|
88
124
|
return [
|
|
89
125
|
// The trigger/policy blocks below drop-before-create so the migration is
|
|
@@ -352,9 +388,9 @@ $$;`.trim(),
|
|
|
352
388
|
`
|
|
353
389
|
-- RLS on every core table. The service role bypasses it (self-host
|
|
354
390
|
-- adapters untouched). The policy set that follows (space layer) admits
|
|
355
|
-
-- a key to its own space
|
|
356
|
-
--
|
|
357
|
-
--
|
|
391
|
+
-- a write key to its own space and no other key to any content (spec
|
|
392
|
+
-- 2026-09-29 content API contract §8): the publishable key serves
|
|
393
|
+
-- sign-in and presence, and the generated per-collection views
|
|
358
394
|
-- (collectionSQL) run with security_invoker = true, so they are subject
|
|
359
395
|
-- to this same smoodly_read policy rather than their owner's privileges.
|
|
360
396
|
-- smoodly_meta: which version of this schema the database holds (spec
|
|
@@ -378,6 +414,7 @@ alter table "assets" enable row level security;
|
|
|
378
414
|
alter table "smoodly_meta" enable row level security;
|
|
379
415
|
`.trim(),
|
|
380
416
|
spaceLayerSQL(),
|
|
417
|
+
listEntriesSQL(),
|
|
381
418
|
grantsSQL(),
|
|
382
419
|
].join("\n\n");
|
|
383
420
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
2
2
|
import type { CollectionSchema } from "./collections.ts";
|
|
3
|
-
import type { AddEntryLocaleOptions, CreateEntryOptions, EntryRecord, EntryStore, EntryStoreOptions, EntryScan, EntryUsage, EntryVersion, ListOptions } from "./entry-store.ts";
|
|
3
|
+
import type { AddEntryLocaleOptions, CreateEntryOptions, EntryRecord, EntryStore, EntryStoreOptions, EntryScan, EntryUsage, EntryVersion, ListOptions, SnapshotQuery, SnapshotRow } from "./entry-store.ts";
|
|
4
4
|
import { type DraftToken } from "./draft-token.ts";
|
|
5
5
|
export declare class SupabaseEntryStore implements EntryStore {
|
|
6
6
|
private db;
|
|
@@ -57,6 +57,7 @@ export declare class SupabaseEntryStore implements EntryStore {
|
|
|
57
57
|
create(collection: string, fields: Record<string, unknown>, options: CreateEntryOptions, by?: string): Promise<EntryRecord>;
|
|
58
58
|
get(collection: string, id: string): Promise<EntryRecord | null>;
|
|
59
59
|
list(collection: string, options?: ListOptions): Promise<EntryRecord[]>;
|
|
60
|
+
query(collection: string, q: SnapshotQuery): Promise<SnapshotRow[]>;
|
|
60
61
|
getMany(collection: string, ids: string[], locale: string, options?: {
|
|
61
62
|
status?: "published";
|
|
62
63
|
}): Promise<Record<string, Record<string, unknown>>>;
|
|
@@ -79,6 +80,7 @@ export declare class SupabaseEntryStore implements EntryStore {
|
|
|
79
80
|
}): Promise<EntryRecord>;
|
|
80
81
|
delete(collection: string, id: string): Promise<void>;
|
|
81
82
|
resolvePath(locale: string, path: string): Promise<import("./path-index.ts").PathHit | null>;
|
|
83
|
+
pathsIn(collection: string, ids: string[], locale: string): Promise<Record<string, string>>;
|
|
82
84
|
pathsOf(collection: string, id: string): Promise<Record<string, string>>;
|
|
83
85
|
redirectsOf(collection: string, id: string): Promise<Record<string, string[]>>;
|
|
84
86
|
reorder(collection: string, orderedIds: string[]): Promise<void>;
|
|
@@ -433,6 +433,41 @@ export class SupabaseEntryStore {
|
|
|
433
433
|
this.fail(res.error.message);
|
|
434
434
|
return res.data.map(({ gate: _gate, ...row }) => toRecord(row));
|
|
435
435
|
}
|
|
436
|
+
async query(collection, q) {
|
|
437
|
+
const schema = this.schema(collection);
|
|
438
|
+
const where = {};
|
|
439
|
+
for (const [key, want] of Object.entries(q.where ?? {})) {
|
|
440
|
+
const inArray = ARRAY_FIELD_TYPES.has(schema.fields[key]?.type ?? "");
|
|
441
|
+
where[key] = Array.isArray(want) ? { in: want.map(String) } : { contains: inArray ? [want] : want };
|
|
442
|
+
}
|
|
443
|
+
const order = q.order ?? collectionOrder(schema);
|
|
444
|
+
const res = await this.db.rpc("smoodly_list_entries", {
|
|
445
|
+
p_collection: collection,
|
|
446
|
+
p_locale: q.locale,
|
|
447
|
+
p_draft: q.draft,
|
|
448
|
+
p_where: where,
|
|
449
|
+
p_order: order === "manual" ? { by: "manual" } : order,
|
|
450
|
+
p_limit: q.limit ?? null,
|
|
451
|
+
p_parent: q.parent ?? null,
|
|
452
|
+
p_has_parent: q.parent !== undefined,
|
|
453
|
+
p_ids: q.ids ? q.ids.filter((id) => UUID_RE.test(id)) : null,
|
|
454
|
+
});
|
|
455
|
+
if (res.error)
|
|
456
|
+
this.fail(res.error.message);
|
|
457
|
+
const shown = res.data;
|
|
458
|
+
if (shown.length === 0)
|
|
459
|
+
return [];
|
|
460
|
+
// In chunks: one in() over hundreds of ids would overflow the request URL.
|
|
461
|
+
const byId = new Map();
|
|
462
|
+
for (let i = 0; i < shown.length; i += 100) {
|
|
463
|
+
const recs = await this.db.from("entries").select(SELECT).in("id", shown.slice(i, i + 100).map((r) => r.id));
|
|
464
|
+
if (recs.error)
|
|
465
|
+
this.fail(recs.error.message);
|
|
466
|
+
for (const r of recs.data)
|
|
467
|
+
byId.set(r.id, toRecord(r));
|
|
468
|
+
}
|
|
469
|
+
return shown.flatMap((r) => (byId.has(r.id) ? [{ record: byId.get(r.id), fields: r.fields }] : []));
|
|
470
|
+
}
|
|
436
471
|
async getMany(collection, ids, locale, options) {
|
|
437
472
|
this.schema(collection);
|
|
438
473
|
const uuidIds = ids.filter((id) => UUID_RE.test(id));
|
|
@@ -728,6 +763,10 @@ export class SupabaseEntryStore {
|
|
|
728
763
|
resolvePath(locale, path) {
|
|
729
764
|
return this.paths.resolve(locale, path);
|
|
730
765
|
}
|
|
766
|
+
pathsIn(collection, ids, locale) {
|
|
767
|
+
this.schema(collection);
|
|
768
|
+
return this.paths.livePathsIn("entry", ids, locale);
|
|
769
|
+
}
|
|
731
770
|
async pathsOf(collection, id) {
|
|
732
771
|
return (await this.fetch(collection, id)) ? this.paths.pathsOf({ kind: "entry", id }) : {};
|
|
733
772
|
}
|
|
@@ -19,6 +19,7 @@ export declare class SupabasePathIndex implements PathIndex {
|
|
|
19
19
|
/** A locale's rows, sorted by URL in JS: the stored path is not the URL. */
|
|
20
20
|
private rowsIn;
|
|
21
21
|
pathsOf(target: PathTarget): Promise<Record<string, string>>;
|
|
22
|
+
livePathsIn(kind: "page" | "entry", ids: string[], locale: string): Promise<Record<string, string>>;
|
|
22
23
|
redirectsOf(target: PathTarget): Promise<PathRow[]>;
|
|
23
24
|
listLive(locale: string): Promise<PathRow[]>;
|
|
24
25
|
listRedirects(locale: string): Promise<PathRow[]>;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { candidates, fullPath, pickHit, sameUrlKeys } from "./anchors.js";
|
|
2
2
|
import { pathLiveError, pathTakenError } from "./path-index.js";
|
|
3
3
|
const UNIQUE_VIOLATION = "23505";
|
|
4
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
4
5
|
const COLS = "locale, anchor, path, kind, target_id, redirect";
|
|
5
6
|
export class SupabasePathIndex {
|
|
6
7
|
/** Held by reference: the config's addresses (spec 2026-09-29 anchored paths §2). */
|
|
@@ -84,6 +85,21 @@ export class SupabasePathIndex {
|
|
|
84
85
|
out[r.locale] = r.path;
|
|
85
86
|
return out;
|
|
86
87
|
}
|
|
88
|
+
async livePathsIn(kind, ids, locale) {
|
|
89
|
+
const uuids = ids.filter((id) => UUID_RE.test(id));
|
|
90
|
+
if (uuids.length === 0)
|
|
91
|
+
return {};
|
|
92
|
+
const out = {};
|
|
93
|
+
// In chunks: one in() over hundreds of ids would overflow the request URL.
|
|
94
|
+
for (let i = 0; i < uuids.length; i += 100) {
|
|
95
|
+
const res = await this.db.from("paths").select(COLS).eq("kind", kind).eq("locale", locale).eq("redirect", false).in("target_id", uuids.slice(i, i + 100));
|
|
96
|
+
if (res.error)
|
|
97
|
+
this.fail(res.error.message);
|
|
98
|
+
for (const r of this.shown(res.data))
|
|
99
|
+
out[r.id] = r.path;
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
87
103
|
redirectsOf(target) {
|
|
88
104
|
return this.rowsOf(target, true);
|
|
89
105
|
}
|