smoodly 0.0.7 → 0.0.9

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 +224 -4
  2. package/dist/admin/client-ops.js +1 -0
  3. package/dist/admin/editor/EditorView.js +64 -8
  4. package/dist/admin/forms/FieldWidget.d.ts +7 -3
  5. package/dist/admin/forms/FieldWidget.js +85 -7
  6. package/dist/admin/forms/list.d.ts +18 -0
  7. package/dist/admin/forms/list.js +81 -0
  8. package/dist/admin/index.d.ts +3 -2
  9. package/dist/admin/index.js +1 -0
  10. package/dist/admin/ops-impl.js +84 -1
  11. package/dist/admin/ops.d.ts +22 -1
  12. package/dist/admin/richtext.d.ts +13 -11
  13. package/dist/admin/richtext.js +94 -13
  14. package/dist/admin/serialize.d.ts +15 -0
  15. package/dist/admin/serialize.js +11 -0
  16. package/dist/admin/shared-items.d.ts +9 -0
  17. package/dist/admin/shared-items.js +61 -0
  18. package/dist/admin/shell/AdminApp.js +8 -2
  19. package/dist/admin/shell/EntryForm.js +1 -1
  20. package/dist/admin/shell/SharedForm.d.ts +7 -0
  21. package/dist/admin/shell/SharedForm.js +146 -0
  22. package/dist/admin/shell/SharedList.d.ts +6 -0
  23. package/dist/admin/shell/SharedList.js +62 -0
  24. package/dist/admin/shell/shared-list.d.ts +14 -0
  25. package/dist/admin/shell/shared-list.js +17 -0
  26. package/dist/admin/tree-ops.d.ts +12 -5
  27. package/dist/admin/tree-ops.js +39 -8
  28. package/dist/admin/ui/theme.d.ts +1 -1
  29. package/dist/admin/ui/theme.js +13 -0
  30. package/dist/admin/validate.d.ts +7 -2
  31. package/dist/admin/validate.js +43 -13
  32. package/dist/collections.d.ts +4 -1
  33. package/dist/config.d.ts +3 -0
  34. package/dist/config.js +35 -1
  35. package/dist/entry-store.d.ts +9 -1
  36. package/dist/entry-store.js +32 -4
  37. package/dist/fields.d.ts +16 -1
  38. package/dist/fields.js +49 -4
  39. package/dist/index.d.ts +8 -1
  40. package/dist/index.js +7 -1
  41. package/dist/next/page-renderer.d.ts +4 -0
  42. package/dist/next/page-renderer.js +14 -1
  43. package/dist/page.js +5 -1
  44. package/dist/refs.js +13 -0
  45. package/dist/resolve.d.ts +3 -0
  46. package/dist/resolve.js +41 -0
  47. package/dist/revalidate.d.ts +4 -0
  48. package/dist/revalidate.js +4 -0
  49. package/dist/richtext-render.d.ts +3 -0
  50. package/dist/richtext-render.js +62 -0
  51. package/dist/shared-id.d.ts +3 -0
  52. package/dist/shared-id.js +36 -0
  53. package/dist/shared.d.ts +46 -0
  54. package/dist/shared.js +36 -0
  55. package/dist/site.d.ts +10 -0
  56. package/dist/site.js +28 -1
  57. package/dist/sql-space.d.ts +5 -1
  58. package/dist/sql-space.js +9 -10
  59. package/dist/sql.js +1 -19
  60. package/dist/supabase-entry-store.d.ts +9 -1
  61. package/dist/supabase-entry-store.js +60 -7
  62. package/dist/zones.d.ts +6 -2
  63. package/dist/zones.js +8 -2
  64. package/package.json +1 -1
@@ -0,0 +1,62 @@
1
+ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
+ // The site-side richtext renderer (spec 2026-09-07, list field §5):
3
+ // TipTap JSON → React, a fragment with no wrapper so the developer owns
4
+ // the surrounding element and its class. Pure React, no Node imports, so
5
+ // it works in shared, server and client components alike. Unknown nodes
6
+ // render their children — a document from a later, richer editor degrades
7
+ // instead of crashing. Elements as node views are Phase 3.
8
+ import { Fragment, createElement } from "react";
9
+ /** Only schemes an editor's link may carry reach the page: http(s), mailto, tel,
10
+ * and site-relative targets. Anything else (javascript:, data:, an empty href,
11
+ * a protocol-relative `//host` or a backslash form `/\host` that a browser
12
+ * treats the same way) renders the text without an anchor. */
13
+ const SAFE_HREF = /^(https?:|mailto:|tel:|\/(?![/\\])|#|\?|\.)/i;
14
+ function safeHref(raw) {
15
+ if (typeof raw !== "string")
16
+ return null;
17
+ // URL parsers drop tab, LF and CR, so normalise before testing to see what the parser sees.
18
+ const href = raw.replace(/[\t\n\r]/g, "").trim();
19
+ return href !== "" && SAFE_HREF.test(href) ? href : null;
20
+ }
21
+ export function RichText({ doc }) {
22
+ const d = doc;
23
+ if (!d || typeof d !== "object" || d.type !== "doc" || !Array.isArray(d.content))
24
+ return null;
25
+ return _jsx(_Fragment, { children: renderNodes(d.content) });
26
+ }
27
+ function renderNodes(nodes) {
28
+ return nodes.map((n, i) => _jsx(Fragment, { children: renderNode(n) }, i));
29
+ }
30
+ function renderNode(node) {
31
+ if (!node || typeof node !== "object")
32
+ return null;
33
+ const children = Array.isArray(node.content) ? renderNodes(node.content) : null;
34
+ switch (node.type) {
35
+ case "text": return withMarks(node.text ?? "", node.marks ?? []);
36
+ case "paragraph": return _jsx("p", { children: children });
37
+ case "heading": {
38
+ const level = Math.min(6, Math.max(1, Math.round(Number(node.attrs?.level ?? 1) || 1)));
39
+ return createElement(`h${level}`, null, children);
40
+ }
41
+ case "bulletList": return _jsx("ul", { children: children });
42
+ case "orderedList": return _jsx("ol", { children: children });
43
+ case "listItem": return _jsx("li", { children: children });
44
+ case "blockquote": return _jsx("blockquote", { children: children });
45
+ case "hardBreak": return _jsx("br", {});
46
+ default: return children;
47
+ }
48
+ }
49
+ /** Marks wrap from the first outward: [bold, italic] → <em><strong>…</strong></em>. */
50
+ function withMarks(text, marks) {
51
+ return marks.reduce((inner, m) => {
52
+ switch (m.type) {
53
+ case "bold": return _jsx("strong", { children: inner });
54
+ case "italic": return _jsx("em", { children: inner });
55
+ case "link": {
56
+ const href = safeHref(m.attrs?.href);
57
+ return href === null ? inner : _jsx("a", { href: href, children: inner });
58
+ }
59
+ default: return inner;
60
+ }
61
+ }, text);
62
+ }
@@ -0,0 +1,3 @@
1
+ /** RFC 4122 §4.3 uuid v5: sha1(namespace bytes ‖ name bytes), version 5,
2
+ * variant 10xx, first 16 bytes of the digest. */
3
+ export declare function sharedEntryId(space: string, name: string): Promise<string>;
@@ -0,0 +1,36 @@
1
+ // The row id of a shared item (spec 2026-09-07 §10 "§3 row id"): uuid v5
2
+ // of (space id as the namespace, item name as the name). Deterministic in
3
+ // both adapters, so a second concurrent materialization of the same item
4
+ // hits the `entries` primary key instead of adding an unremovable
5
+ // duplicate — `delete` refuses every shared row. The space is part of it
6
+ // because the primary key is `id` alone and, in the cloud, many spaces
7
+ // share one table: a name-only id would make space B's footer collide
8
+ // with space A's.
9
+ //
10
+ // No imports — Web Crypto (`globalThis.crypto.subtle`) exists in Node
11
+ // >= 20 and in every browser, so this module carries no node builtin
12
+ // into the package root's graph. It matters because every scaffolded
13
+ // section does `import { f, smoodly } from "smoodly"`, and a "use client"
14
+ // section's browser build would fail on `node:crypto`.
15
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
16
+ /** The namespace's 16 raw bytes — RFC 4122 hashes the bytes, not the text. */
17
+ function uuidBytes(uuid) {
18
+ if (!UUID_RE.test(uuid))
19
+ throw new Error(`smoodly: "${uuid}" is not a uuid — a shared item's namespace is the space id.`);
20
+ const hex = uuid.replace(/-/g, "");
21
+ const bytes = new Uint8Array(16);
22
+ for (let i = 0; i < 16; i++)
23
+ bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16);
24
+ return bytes;
25
+ }
26
+ /** RFC 4122 §4.3 uuid v5: sha1(namespace bytes ‖ name bytes), version 5,
27
+ * variant 10xx, first 16 bytes of the digest. */
28
+ export async function sharedEntryId(space, name) {
29
+ const input = new Uint8Array([...uuidBytes(space), ...new TextEncoder().encode(name)]);
30
+ const digest = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-1", input));
31
+ const bytes = digest.subarray(0, 16);
32
+ bytes[6] = (bytes[6] & 0x0f) | 0x50; // version 5 in the high nibble of octet 6
33
+ bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10xx in the top bits of octet 8
34
+ const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
35
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
36
+ }
@@ -0,0 +1,46 @@
1
+ import type { CollectionSchema } from "./collections.ts";
2
+ import type { Descriptor } from "./fields.ts";
3
+ import type { PairedComponent } from "./pairing.tsx";
4
+ import type { BuilderMap, NamespaceValues, SectionSchema } from "./schema.ts";
5
+ /** The reserved tree node type of a placement. */
6
+ export declare const SHARED_NODE_TYPE = "shared";
7
+ /** The reserved key a visual item's styles live under in the node's shared JSONB. */
8
+ export declare const STYLES_KEY = "$styles";
9
+ export type SharedSchema<V = Record<string, unknown>> = CollectionSchema<any> & {
10
+ kind: "shared";
11
+ /** The registered section a visual item renders through; absent = an object item. */
12
+ section?: string;
13
+ /** The section's style descriptors (empty for an object item). */
14
+ styles: Record<string, Descriptor>;
15
+ sample?: Record<string, unknown>;
16
+ /** phantom: what getShared returns */
17
+ readonly __value?: V;
18
+ };
19
+ export type SharedLink = {
20
+ item: string;
21
+ ref: string;
22
+ };
23
+ /** The placement a tree node is, or null for any other node. Accepts a
24
+ * bare `{ type, fields }` shape or a full tree node — `id` (and any
25
+ * other tree-node property) is ignored. */
26
+ export declare function sharedLink(node: {
27
+ id?: string;
28
+ type: string;
29
+ fields?: Record<string, unknown>;
30
+ }): SharedLink | null;
31
+ type SharedInput = {
32
+ name: string;
33
+ title: string;
34
+ versions?: boolean;
35
+ };
36
+ export declare function shared<F extends BuilderMap>(input: SharedInput & {
37
+ fields: F;
38
+ section?: undefined;
39
+ }): SharedSchema<NamespaceValues<F>>;
40
+ export declare function shared<F extends BuilderMap, St extends BuilderMap>(input: SharedInput & {
41
+ section: PairedComponent<SectionSchema<F, St>, F, St>;
42
+ fields?: undefined;
43
+ }): SharedSchema<NamespaceValues<F> & {
44
+ styles: NamespaceValues<St>;
45
+ }>;
46
+ export {};
package/dist/shared.js ADDED
@@ -0,0 +1,36 @@
1
+ /** The reserved tree node type of a placement. */
2
+ export const SHARED_NODE_TYPE = "shared";
3
+ /** The reserved key a visual item's styles live under in the node's shared JSONB. */
4
+ export const STYLES_KEY = "$styles";
5
+ /** The placement a tree node is, or null for any other node. Accepts a
6
+ * bare `{ type, fields }` shape or a full tree node — `id` (and any
7
+ * other tree-node property) is ignored. */
8
+ export function sharedLink(node) {
9
+ if (node.type !== SHARED_NODE_TYPE)
10
+ return null;
11
+ const { item, ref } = node.fields ?? {};
12
+ return typeof item === "string" && typeof ref === "string" ? { item, ref } : null;
13
+ }
14
+ export function shared(input) {
15
+ if (!input?.name)
16
+ throw new Error("smoodly: a shared item requires a name.");
17
+ if ((input.fields === undefined) === (input.section === undefined)) {
18
+ throw new Error(`smoodly: shared item "${input.name}" must declare exactly one of \`section\` or \`fields\`.`);
19
+ }
20
+ const fields = input.section
21
+ ? input.section.schema.fields
22
+ : Object.fromEntries(Object.entries(input.fields).map(([k, b]) => [k, b.descriptor]));
23
+ if (Object.prototype.hasOwnProperty.call(fields, STYLES_KEY)) {
24
+ throw new Error(`smoodly: shared item "${input.name}" has a field named "${STYLES_KEY}" — that key is reserved for a visual item's styles.`);
25
+ }
26
+ return {
27
+ kind: "shared",
28
+ name: input.name,
29
+ title: input.title,
30
+ fields,
31
+ styles: input.section ? input.section.schema.styles : {},
32
+ ...(input.section ? { section: input.section.schema.name } : {}),
33
+ ...(input.section?.schema.sample ? { sample: input.section.schema.sample } : {}),
34
+ ...(input.versions ? { versions: true } : {}),
35
+ };
36
+ }
package/dist/site.d.ts CHANGED
@@ -3,6 +3,7 @@ import { type EntryRecord, type EntryStore } from "./entry-store.ts";
3
3
  import { type LocaleSet, type PathTarget } from "./paths.ts";
4
4
  import type { SectionLike } from "./refs.ts";
5
5
  import type { PageContext, PageLink, PageTree } from "./render.tsx";
6
+ import { type SharedSchema } from "./shared.ts";
6
7
  import type { PageRecord, PageStore } from "./store.ts";
7
8
  export type SiteOptions = {
8
9
  stores: () => {
@@ -11,6 +12,7 @@ export type SiteOptions = {
11
12
  };
12
13
  collections: CollectionSchema<any>[];
13
14
  sections: SectionLike[];
15
+ shared?: SharedSchema[];
14
16
  locales: LocaleSet;
15
17
  homePageSlug?: string;
16
18
  };
@@ -82,6 +84,14 @@ export interface SmoodlySite {
82
84
  getEntriesTree(collection: string, options?: {
83
85
  locale?: string;
84
86
  }): Promise<EntryTreeItem[]>;
87
+ /** A shared item's content in a locale (spec 2026-09-07 §4): an object
88
+ * item's merged fields, or a visual item's section props (fields plus
89
+ * `styles`), refs hydrated; null when the item has no row there or is
90
+ * unpublished (outside draft mode). */
91
+ getShared<V>(item: SharedSchema<V>, options?: {
92
+ locale?: string;
93
+ draft?: boolean;
94
+ }): Promise<V | null>;
85
95
  }
86
96
  /** The `page` prop for a loaded page. `toUrl` is the app's `href` seam
87
97
  * bound to the render locale; the default keeps CMS paths as URLs. */
package/dist/site.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { entryFieldsIn, schemaOf } from "./entry-store.js";
2
2
  import { buildPageTree, chainOf, entryPathRows, entrySegment, pagePath, } from "./paths.js";
3
3
  import { resolveFields, resolveTree } from "./resolve.js";
4
+ import { STYLES_KEY } from "./shared.js";
4
5
  /** The `page` prop for a loaded page. `toUrl` is the app's `href` seam
5
6
  * bound to the render locale; the default keeps CMS paths as URLs. */
6
7
  export function pageContextOf(hit, toUrl = (p) => p) {
@@ -51,7 +52,12 @@ export function createSmoodlySite(options) {
51
52
  const raw = draft ? await pages.getDraftTree(id, locale) : row.status === "published" ? await pages.getPublishedTree(id, locale) : null;
52
53
  if (!raw)
53
54
  return null;
54
- const tree = await resolveTree(raw, { sections: options.sections, collections: options.collections, fetch: fetcher(draft, locale) });
55
+ const tree = await resolveTree(raw, {
56
+ sections: options.sections,
57
+ collections: options.collections,
58
+ shared: options.shared,
59
+ fetch: fetcher(draft, locale),
60
+ });
55
61
  const all = await pages.listPages();
56
62
  const index = new Map(all.map((n) => [n.id, n]));
57
63
  const byId = (x) => index.get(x);
@@ -171,6 +177,27 @@ export function createSmoodlySite(options) {
171
177
  const build = (parentId) => items.filter((i) => i.entry.parentId === parentId).map((i) => ({ ...i, children: build(i.entry.id) }));
172
178
  return build(null);
173
179
  },
180
+ async getShared(item, o) {
181
+ const locale = localeOf(o);
182
+ const draft = o?.draft ?? false;
183
+ const { entries } = options.stores();
184
+ const [record] = await entries.list(item.name);
185
+ if (!record)
186
+ return null;
187
+ const row = record.locales[locale];
188
+ if (!row || (!draft && row.status !== "published"))
189
+ return null;
190
+ const raw = draft
191
+ ? entryFieldsIn(record, locale)
192
+ : (await entries.getMany(item.name, [record.id], locale, { status: "published" }))[record.id];
193
+ if (!raw)
194
+ return null;
195
+ const fields = await resolveFields(raw, item.fields, { collections: options.collections, fetch: fetcher(draft, locale) });
196
+ if (!item.section)
197
+ return fields;
198
+ const { [STYLES_KEY]: styles, ...rest } = fields;
199
+ return { ...rest, styles: styles !== null && typeof styles === "object" ? styles : {} };
200
+ },
174
201
  };
175
202
  const site = {
176
203
  locales,
@@ -1,6 +1,10 @@
1
+ /** The one space a self-host install has; the space a request with no
2
+ * claim resolves to. The bare uuid — code needs the value (a shared
3
+ * item's id namespace), SQL needs it quoted. */
4
+ export declare const DEFAULT_SPACE_ID = "00000000-0000-0000-0000-000000000000";
1
5
  export declare const DEFAULT_SPACE = "'00000000-0000-0000-0000-000000000000'";
2
6
  export declare const SPACE_COL = "\"space_id\" uuid not null default '00000000-0000-0000-0000-000000000000'";
3
- export declare const CONTENT_TABLES: readonly ["pages", "page_locales", "page_versions", "saved_sections", "global_sections", "entries", "entry_locales", "entry_versions", "refs", "paths"];
7
+ export declare const CONTENT_TABLES: readonly ["pages", "page_locales", "page_versions", "entries", "entry_locales", "entry_versions", "refs", "paths"];
4
8
  export type ContentTable = (typeof CONTENT_TABLES)[number];
5
9
  export declare function currentSpaceSQL(): string;
6
10
  export declare function spaceLayerSQL(): string;
package/dist/sql-space.js CHANGED
@@ -2,14 +2,16 @@
2
2
  // is one cloud environment; a self-host install is one space with the
3
3
  // default id. Everything that needs "which space is this request in"
4
4
  // reads smoodly_current_space() — the adapters never mention spaces.
5
- export const DEFAULT_SPACE = `'00000000-0000-0000-0000-000000000000'`;
5
+ /** The one space a self-host install has; the space a request with no
6
+ * claim resolves to. The bare uuid — code needs the value (a shared
7
+ * item's id namespace), SQL needs it quoted. */
8
+ export const DEFAULT_SPACE_ID = "00000000-0000-0000-0000-000000000000";
9
+ export const DEFAULT_SPACE = `'${DEFAULT_SPACE_ID}'`;
6
10
  export const SPACE_COL = `"space_id" uuid not null default ${DEFAULT_SPACE}`;
7
11
  export const CONTENT_TABLES = [
8
12
  "pages",
9
13
  "page_locales",
10
14
  "page_versions",
11
- "saved_sections",
12
- "global_sections",
13
15
  "entries",
14
16
  "entry_locales",
15
17
  "entry_versions",
@@ -27,10 +29,9 @@ language sql stable as $$
27
29
  $$;`.trim();
28
30
  }
29
31
  /** Which rows a read key (or the anon key, which has no claim) may see.
30
- * null = write keys only — refs and the section library are editor
31
- * tooling; editors is admission data; nothing a browser needs. The
32
- * empty string means "read policy, no extra predicate" (global_sections
33
- * is fully readable once it's in-space) — distinct from null. */
32
+ * null = write keys only — refs is editor tooling; editors is admission
33
+ * data; nothing a browser needs. Shared content (spec 2026-09-07) rides
34
+ * the three entry predicates: it is stored as entries. */
34
35
  const PUBLISHED_READ = {
35
36
  pages: `exists (select 1 from "page_locales" l where l."page_id" = "pages"."id" and l."status" = 'published')`,
36
37
  page_locales: `"status" = 'published'`,
@@ -38,15 +39,13 @@ const PUBLISHED_READ = {
38
39
  entries: `exists (select 1 from "entry_locales" l where l."entry_id" = "entries"."id" and l."status" = 'published')`,
39
40
  entry_locales: `"status" = 'published'`,
40
41
  entry_versions: `exists (select 1 from "entry_locales" l where l."published_version_id" = "entry_versions"."id")`,
41
- global_sections: ``,
42
- saved_sections: null,
43
42
  refs: null,
44
43
  editors: null,
45
44
  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')))`,
46
45
  };
47
46
  /** Tables whose existing indexes do not lead with space_id. */
48
47
  const NEEDS_SPACE_INDEX = [
49
- "page_locales", "page_versions", "entry_locales", "entry_versions", "saved_sections", "global_sections", "refs",
48
+ "page_locales", "page_versions", "entry_locales", "entry_versions", "refs",
50
49
  ];
51
50
  export function spaceLayerSQL() {
52
51
  const statements = [];
package/dist/sql.js CHANGED
@@ -31,7 +31,7 @@ function fieldExpr(key, d, source) {
31
31
  case "date":
32
32
  return `(${source}->>'${key}')::timestamptz`;
33
33
  default:
34
- return `${source}->'${key}'`; // image, video, file, object, richtext, dataAttributes, refList
34
+ return `${source}->'${key}'`; // image, video, file, object, list, richtext, dataAttributes, refList
35
35
  }
36
36
  }
37
37
  export function collectionSQL(collection) {
@@ -132,22 +132,6 @@ create table if not exists "page_versions" (
132
132
  create index if not exists "page_versions_page_locale_idx"
133
133
  on "page_versions" ("page_id", "locale", "created_at");
134
134
 
135
- create table if not exists "saved_sections" (
136
- "id" uuid primary key default gen_random_uuid(),
137
- ${SPACE_COL},
138
- "title" text not null,
139
- "fragment" jsonb not null,
140
- "created_at" timestamptz not null default now()
141
- );
142
-
143
- create table if not exists "global_sections" (
144
- "id" uuid primary key default gen_random_uuid(),
145
- ${SPACE_COL},
146
- "type" text not null,
147
- "content" jsonb not null,
148
- "updated_at" timestamptz not null default now()
149
- );
150
-
151
135
  create table if not exists "entries" (
152
136
  "id" uuid primary key default gen_random_uuid(),
153
137
  ${SPACE_COL},
@@ -273,8 +257,6 @@ $$;
273
257
  alter table "pages" enable row level security;
274
258
  alter table "page_locales" enable row level security;
275
259
  alter table "page_versions" enable row level security;
276
- alter table "saved_sections" enable row level security;
277
- alter table "global_sections" enable row level security;
278
260
  alter table "entries" enable row level security;
279
261
  alter table "entry_locales" enable row level security;
280
262
  alter table "entry_versions" enable row level security;
@@ -3,12 +3,20 @@ import type { CollectionSchema } from "./collections.ts";
3
3
  import type { AddEntryLocaleOptions, CreateEntryOptions, EntryRecord, EntryStore, EntryStoreOptions, EntryUsage, EntryVersion, ListOptions } from "./entry-store.ts";
4
4
  export declare class SupabaseEntryStore implements EntryStore {
5
5
  private db;
6
- private collections;
7
6
  private paths;
8
7
  private locales;
8
+ private schemas;
9
+ private spaceId?;
9
10
  constructor(db: SupabaseClient, collections: CollectionSchema<any>[], options?: EntryStoreOptions);
10
11
  private fail;
11
12
  private schema;
13
+ /** The space this client acts in — the ONE function the policies and
14
+ * the insert trigger read (src/sql-space.ts): a key's claim in the
15
+ * cloud, the default space under the service role. Constant for the
16
+ * life of the client, so the PROMISE is cached and concurrent creates
17
+ * share one round trip; a failed lookup is not cached. */
18
+ private space;
19
+ private fetchSpace;
12
20
  private fetch;
13
21
  private must;
14
22
  private siblings;
@@ -7,9 +7,10 @@
7
7
  // calls the smoodly_reorder SQL function: one statement, updated_at
8
8
  // untouched.
9
9
  import { collectionDepth, collectionOrder } from "./collections.js";
10
- import { DEFAULT_ENTRY_LOCALES, entryErrors, entryFieldsIn, entryLocaleRow, entryRefEdges, entryTitleIn, schemaOf, splitFields, } from "./entry-store.js";
10
+ import { DEFAULT_ENTRY_LOCALES, entryErrors, entryFieldsIn, entryLocaleRow, entryRefEdges, entryTitleIn, isSharedSchema, schemaOf, splitFields, } from "./entry-store.js";
11
+ import { sharedEntryId } from "./shared-id.js";
11
12
  import { SupabasePathIndex } from "./supabase-path-index.js";
12
- import { assertDepth, assertSupportedLocale, entryPathRows } from "./paths.js";
13
+ import { assertDepth, assertSupportedLocale, entryPathRows, slugOf } from "./paths.js";
13
14
  // entries.id is a Postgres uuid column: filtering on a non-UUID string
14
15
  // throws invalid_text_representation. A non-UUID id can't exist in the
15
16
  // table, so treat it the same as a missing one.
@@ -44,15 +45,38 @@ function toVersion(row) {
44
45
  export class SupabaseEntryStore {
45
46
  constructor(db, collections, options = {}) {
46
47
  this.db = db;
47
- this.collections = collections;
48
48
  this.paths = options.paths ?? new SupabasePathIndex(db);
49
49
  this.locales = options.locales ?? DEFAULT_ENTRY_LOCALES;
50
+ this.schemas = [...collections, ...(options.shared ?? [])];
50
51
  }
51
52
  fail(message) {
52
53
  throw new Error(`smoodly: ${message}`);
53
54
  }
54
55
  schema(collection) {
55
- return schemaOf(this.collections, collection);
56
+ return schemaOf(this.schemas, collection);
57
+ }
58
+ /** The space this client acts in — the ONE function the policies and
59
+ * the insert trigger read (src/sql-space.ts): a key's claim in the
60
+ * cloud, the default space under the service role. Constant for the
61
+ * life of the client, so the PROMISE is cached and concurrent creates
62
+ * share one round trip; a failed lookup is not cached. */
63
+ space() {
64
+ if (!this.spaceId) {
65
+ this.spaceId = this.fetchSpace().catch((e) => {
66
+ // A failed lookup is not cached: the next write retries it.
67
+ this.spaceId = undefined;
68
+ throw e;
69
+ });
70
+ }
71
+ return this.spaceId;
72
+ }
73
+ async fetchSpace() {
74
+ const res = await this.db.rpc("smoodly_current_space");
75
+ if (res.error)
76
+ this.fail(res.error.message);
77
+ if (typeof res.data !== "string")
78
+ this.fail("smoodly_current_space() returned no space — is the schema up to date?");
79
+ return res.data;
56
80
  }
57
81
  // ── reads ──
58
82
  async fetch(collection, id) {
@@ -240,13 +264,33 @@ export class SupabaseEntryStore {
240
264
  const schema = this.schema(collection);
241
265
  assertSupportedLocale(options.locale, this.locales);
242
266
  const parentId = options.parentId ?? null;
267
+ if (isSharedSchema(schema)) {
268
+ const existing = await this.db.from("entries").select("id").eq("collection", collection).limit(1);
269
+ if (existing.error)
270
+ this.fail(existing.error.message);
271
+ if (existing.data.length > 0)
272
+ throw entryErrors.singleton(collection);
273
+ if (slugOf(fields) !== null)
274
+ throw entryErrors.noSlug(collection);
275
+ }
243
276
  const parent = await this.assertParent(collection, parentId, 1);
244
277
  this.assertParentHasLocale(parent, options.locale);
245
278
  const { shared, localized, slug } = splitFields(fields, schema);
246
279
  await this.assertSiblingSlug(collection, parentId, options.locale, slug);
247
- const inserted = await this.db.from("entries").insert({ collection, parent_id: parentId, fields: shared }).select("id").single();
248
- if (inserted.error)
280
+ // A shared item's row id is DERIVED from (space, name), not generated
281
+ // (spec 2026-09-07 §10): the SELECT above is a fast path two racing
282
+ // materializations can both pass, so the primary key is the backstop
283
+ // — a duplicate would be unremovable, since `delete` refuses every
284
+ // shared row.
285
+ const row = { collection, parent_id: parentId, fields: shared };
286
+ if (isSharedSchema(schema))
287
+ row.id = await sharedEntryId(await this.space(), collection);
288
+ const inserted = await this.db.from("entries").insert(row).select("id").single();
289
+ if (inserted.error) {
290
+ if (isSharedSchema(schema) && inserted.error.code === UNIQUE_VIOLATION)
291
+ throw entryErrors.singleton(collection);
249
292
  this.fail(inserted.error.message);
293
+ }
250
294
  const id = inserted.data.id;
251
295
  try {
252
296
  await this.insertLocale(id, options.locale, { slug, fields: localized });
@@ -327,6 +371,8 @@ export class SupabaseEntryStore {
327
371
  const before = await this.must(collection, id);
328
372
  const row = entryLocaleRow(before, locale);
329
373
  const { shared, localized, slug } = splitFields(fields, schema);
374
+ if (isSharedSchema(schema) && slug !== null)
375
+ throw entryErrors.noSlug(collection);
330
376
  await this.assertSiblingSlug(collection, before.parentId, locale, slug, id);
331
377
  await this.updateNode(id, { fields: shared });
332
378
  await this.updateLocale(id, locale, { slug, fields: localized });
@@ -393,6 +439,8 @@ export class SupabaseEntryStore {
393
439
  }
394
440
  async removeLocale(collection, id, locale) {
395
441
  const schema = this.schema(collection);
442
+ if (isSharedSchema(schema))
443
+ throw entryErrors.everyLocale(collection);
396
444
  const entry = await this.must(collection, id);
397
445
  entryLocaleRow(entry, locale);
398
446
  if (Object.keys(entry.locales).length === 1)
@@ -428,6 +476,8 @@ export class SupabaseEntryStore {
428
476
  return res.data.map(toVersion);
429
477
  }
430
478
  async move(collection, id, to) {
479
+ if (isSharedSchema(this.schema(collection)))
480
+ throw entryErrors.owned(collection, "it has no tree position");
431
481
  const entry = await this.must(collection, id);
432
482
  const subtree = await this.subtree(entry);
433
483
  if (to.parentId !== null && (to.parentId === id || subtree.some((e) => e.id === to.parentId)))
@@ -473,6 +523,8 @@ export class SupabaseEntryStore {
473
523
  const entry = await this.fetch(collection, id);
474
524
  if (!entry)
475
525
  return;
526
+ if (isSharedSchema(this.schema(collection)))
527
+ throw entryErrors.owned(collection, "remove the registration to remove it");
476
528
  const children = await this.db.from("entries").select("id").eq("parent_id", id).limit(1);
477
529
  if (children.error)
478
530
  this.fail(children.error.message);
@@ -498,7 +550,8 @@ export class SupabaseEntryStore {
498
550
  return (await this.fetch(collection, id)) ? this.paths.pathsOf({ kind: "entry", id }) : {};
499
551
  }
500
552
  async reorder(collection, orderedIds) {
501
- this.schema(collection);
553
+ if (isSharedSchema(this.schema(collection)))
554
+ throw entryErrors.owned(collection, "it has no order");
502
555
  const res = await this.db.rpc("smoodly_reorder", { p_collection: collection, p_ids: orderedIds });
503
556
  if (res.error)
504
557
  this.fail(res.error.message);
package/dist/zones.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { SharedSchema } from "./shared.ts";
1
2
  type WithSchema = {
2
3
  schema: {
3
4
  name: string;
@@ -11,9 +12,12 @@ export type ZonePolicy = {
11
12
  kind: "freeform";
12
13
  allow: string[] | "*";
13
14
  rows: boolean;
14
- } | {
15
+ }
16
+ /** `shared` names the item a bound zone shows (spec 2026-09-07 §4); `component` is its section either way. */
17
+ | {
15
18
  kind: "locked";
16
19
  component: string;
20
+ shared?: string;
17
21
  };
18
22
  export declare const z: {
19
23
  sections(components: WithSchema[]): {
@@ -26,6 +30,6 @@ export declare const z: {
26
30
  sections: WithSchema[] | "*";
27
31
  rows?: boolean;
28
32
  }): ZonePolicy;
29
- locked(component: WithSchema): ZonePolicy;
33
+ locked(target: WithSchema | SharedSchema): ZonePolicy;
30
34
  };
31
35
  export {};
package/dist/zones.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // Zone policies — the freedom dial. Policies reference paired components
2
2
  // directly; only their registered names are stored (plain data).
3
+ const isShared = (target) => "kind" in target && target.kind === "shared";
3
4
  const names = (components) => components.map((c) => c.schema.name);
4
5
  export const z = {
5
6
  sections(components) {
@@ -17,7 +18,12 @@ export const z = {
17
18
  rows: opts.rows ?? false,
18
19
  };
19
20
  },
20
- locked(component) {
21
- return { kind: "locked", component: component.schema.name };
21
+ locked(target) {
22
+ if (isShared(target)) {
23
+ if (!target.section)
24
+ throw new Error(`smoodly: shared item "${target.name}" has no section — a locked zone needs a visual item.`);
25
+ return { kind: "locked", component: target.section, shared: target.name };
26
+ }
27
+ return { kind: "locked", component: target.schema.name };
22
28
  },
23
29
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smoodly",
3
- "version": "0.0.7",
3
+ "version": "0.0.9",
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": {