smoodly 0.0.35 → 0.0.37

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 (64) hide show
  1. package/README.md +24 -1611
  2. package/dist/admin/drift.js +3 -2
  3. package/dist/admin/editor/SeoSettings.d.ts +7 -1
  4. package/dist/admin/editor/SeoSettings.js +2 -2
  5. package/dist/admin/field-label.d.ts +2 -0
  6. package/dist/admin/field-label.js +2 -0
  7. package/dist/admin/forms/FieldWidget.js +2 -2
  8. package/dist/admin/next/create-admin.d.ts +4 -4
  9. package/dist/admin/next/create-admin.js +5 -5
  10. package/dist/admin/next/index.d.ts +1 -1
  11. package/dist/admin/serialize.d.ts +8 -1
  12. package/dist/admin/serialize.js +23 -7
  13. package/dist/admin/shell/AdminApp.d.ts +3 -3
  14. package/dist/admin/shell/AdminApp.js +2 -2
  15. package/dist/admin/shell/EntryForm.js +8 -1
  16. package/dist/admin/shell/presence-realtime.d.ts +2 -2
  17. package/dist/cli/space-io.js +1 -1
  18. package/dist/collections.d.ts +4 -0
  19. package/dist/collections.js +9 -1
  20. package/dist/entry-page.d.ts +12 -10
  21. package/dist/entry-store.d.ts +30 -1
  22. package/dist/entry-store.js +54 -1
  23. package/dist/env.d.ts +5 -5
  24. package/dist/env.js +2 -2
  25. package/dist/fields.d.ts +13 -7
  26. package/dist/fields.js +2 -2
  27. package/dist/global.d.ts +4 -7
  28. package/dist/index.d.ts +10 -39
  29. package/dist/index.js +1 -1
  30. package/dist/migrations.js +1 -1
  31. package/dist/next/index.d.ts +1 -1
  32. package/dist/next/index.js +1 -1
  33. package/dist/next/meta.d.ts +6 -4
  34. package/dist/next/meta.js +14 -17
  35. package/dist/next/page-renderer.js +6 -3
  36. package/dist/next/queries.d.ts +10 -11
  37. package/dist/next/queries.js +7 -2
  38. package/dist/page.d.ts +5 -2
  39. package/dist/page.js +3 -1
  40. package/dist/pairing.d.ts +10 -10
  41. package/dist/path-index.d.ts +3 -0
  42. package/dist/path-index.js +7 -0
  43. package/dist/read-shapes.d.ts +64 -0
  44. package/dist/read-shapes.js +52 -0
  45. package/dist/render.d.ts +3 -3
  46. package/dist/resolve.d.ts +12 -0
  47. package/dist/resolve.js +29 -7
  48. package/dist/schema-version.d.ts +1 -1
  49. package/dist/schema-version.js +1 -1
  50. package/dist/schema.d.ts +22 -1
  51. package/dist/seo.d.ts +18 -0
  52. package/dist/seo.js +53 -0
  53. package/dist/site.d.ts +15 -16
  54. package/dist/site.js +64 -58
  55. package/dist/sql-space.d.ts +6 -5
  56. package/dist/sql-space.js +11 -24
  57. package/dist/sql.d.ts +6 -1
  58. package/dist/sql.js +60 -23
  59. package/dist/supabase-entry-store.d.ts +3 -1
  60. package/dist/supabase-entry-store.js +39 -0
  61. package/dist/supabase-path-index.d.ts +1 -0
  62. package/dist/supabase-path-index.js +16 -0
  63. package/migrations/20260930040956_smoodly_v7.sql +84 -0
  64. package/package.json +1 -1
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. A table with a public read
67
- * has ONE select policy — its own space, not revoked, and a write key or
68
- * the published condition — and split write policies. A table without
69
- * one (refs, members) keeps its `for all` write policy, which is also its
70
- * only select. */
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
- const published = PUBLISHED_READ[table];
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 (${OWN} and ((select "smoodly_key_kind"()) = 'write' or ${published}));`);
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 { type CollectionSchema } from "./collections.ts";
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 guardrails — a published-only read view, created with
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 (PostgREST-native reads,
7
- // SQL-queryable eject story) — and a partial expression index for a
8
- // declared order field, on the jsonb VALUE (sibling slug uniqueness
9
- // is the `paths` primary key plus the stores' per-locale pre-check,
10
- // not an index). A view joins the locale rows, one row per published
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 { BUILT_IN_ORDER_FIELDS } from "./collections.js";
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 (unlike index expressions, which must be
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
- // Only a declared order field needs a per-collection expression index —
62
- // on the node's shared fields (an order field can be neither localized
63
- // nor `slug`, which collection() refuses), the jsonb value (`->`) the
64
- // adapter orders by, so the index serves the sort.
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: everything for a write key, published rows for
356
- -- a read key or the anon key (default space). Drafts stay unreachable
357
- -- to anything but a write key. The generated per-collection views
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
  }
@@ -0,0 +1,84 @@
1
+ -- Smoodly core schema, version 7 (docs/superpowers/specs/2026-09-29-content-api-contract-design.md §5).
2
+ -- Shipped in the smoodly package; `npx smoodly migrate` copies it into supabase/migrations/.
3
+ -- Never edit a published file: the next change is the next file.
4
+ -- The delta over version 6: smoodly_list_entries, which filters, orders and
5
+ -- limits a collection over the snapshot the reader is shown, and the read
6
+ -- policies of every content table admit a write key only (§8).
7
+
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): the published snapshot, or the live merge in preview. Invoker
11
+ -- rights, so RLS decides what a key sees; a preview read needs the
12
+ -- service role or a write key, anything else reads published.
13
+ create or replace function "smoodly_list_entries"(
14
+ p_collection text, p_locale text, p_draft boolean, p_where jsonb, p_order jsonb,
15
+ p_limit integer, p_parent uuid, p_has_parent boolean, p_ids uuid[]
16
+ ) returns table ("id" uuid, "fields" jsonb) language sql stable set search_path = public as $$
17
+ with mode as (
18
+ select p_draft and (current_user = 'service_role' or "smoodly_key_kind"() = 'write') as draft
19
+ ), shown as (
20
+ select e."id", e."sort", e."created_at", l."updated_at",
21
+ case when (select draft from mode) then e."fields" || l."fields" || jsonb_build_object('slug', l."slug")
22
+ else coalesce(v."fields", e."fields" || l."fields" || jsonb_build_object('slug', l."slug")) end as "fields"
23
+ from "entries" e
24
+ join "entry_locales" l on l."entry_id" = e."id" and l."locale" = p_locale
25
+ left join "entry_versions" v on v."id" = l."published_version_id"
26
+ where e."collection" = p_collection
27
+ and ((select draft from mode) or l."status" = 'published')
28
+ and (not p_has_parent or e."parent_id" is not distinct from p_parent)
29
+ and (p_ids is null or e."id" = any(p_ids))
30
+ )
31
+ select s."id", s."fields" from shown s
32
+ where not exists (
33
+ select 1 from jsonb_each(coalesce(p_where, '{}'::jsonb)) w
34
+ where not (case when w.value ? 'in'
35
+ then (s."fields" ->> w.key) = any (select jsonb_array_elements_text(w.value -> 'in'))
36
+ else s."fields" @> jsonb_build_object(w.key, w.value -> 'contains') end))
37
+ order by
38
+ case when p_order ->> 'by' = 'manual' then s."sort" end asc nulls last,
39
+ case when p_order ->> 'direction' = 'asc' then
40
+ case p_order ->> 'by' when 'createdAt' then to_jsonb(s."created_at") when 'updatedAt' then to_jsonb(s."updated_at")
41
+ when 'manual' then null else nullif(nullif(s."fields" -> (p_order ->> 'by'), 'null'::jsonb), '""'::jsonb) end end asc nulls last,
42
+ case when p_order ->> 'direction' = 'desc' then
43
+ case p_order ->> 'by' when 'createdAt' then to_jsonb(s."created_at") when 'updatedAt' then to_jsonb(s."updated_at")
44
+ when 'manual' then null else nullif(nullif(s."fields" -> (p_order ->> 'by'), 'null'::jsonb), '""'::jsonb) end end desc nulls last,
45
+ s."created_at" asc, s."id" asc
46
+ limit p_limit;
47
+ $$;
48
+
49
+ -- No public content reads (spec 2026-09-29 content API contract §8): the
50
+ -- publishable key and a read key read nothing; a write key reads its own space.
51
+ drop policy if exists "smoodly_read" on "pages";
52
+ create policy "smoodly_read" on "pages" for select
53
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
54
+
55
+ drop policy if exists "smoodly_read" on "page_locales";
56
+ create policy "smoodly_read" on "page_locales" for select
57
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
58
+
59
+ drop policy if exists "smoodly_read" on "page_versions";
60
+ create policy "smoodly_read" on "page_versions" for select
61
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
62
+
63
+ drop policy if exists "smoodly_read" on "entries";
64
+ create policy "smoodly_read" on "entries" for select
65
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
66
+
67
+ drop policy if exists "smoodly_read" on "entry_locales";
68
+ create policy "smoodly_read" on "entry_locales" for select
69
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
70
+
71
+ drop policy if exists "smoodly_read" on "entry_versions";
72
+ create policy "smoodly_read" on "entry_versions" for select
73
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
74
+
75
+ drop policy if exists "smoodly_read" on "paths";
76
+ create policy "smoodly_read" on "paths" for select
77
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
78
+
79
+ drop policy if exists "smoodly_read" on "assets";
80
+ create policy "smoodly_read" on "assets" for select
81
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
82
+
83
+ insert into "smoodly_meta" ("key", "value") values ('schema_version', '7')
84
+ on conflict ("key") do update set "value" = excluded."value";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smoodly",
3
- "version": "0.0.35",
3
+ "version": "0.0.37",
4
4
  "description": "Open-source, code-first CMS for Next.js on Supabase: define a component once, it becomes a typed, editable building block.",
5
5
  "license": "MIT",
6
6
  "repository": {