smoodly 0.0.36 → 0.0.38

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 (48) hide show
  1. package/README.md +24 -1602
  2. package/dist/admin/fixed-nodes.d.ts +1 -1
  3. package/dist/admin/fixed-nodes.js +26 -1
  4. package/dist/admin/index.d.ts +1 -0
  5. package/dist/admin/index.js +1 -0
  6. package/dist/admin/ops-impl.js +21 -8
  7. package/dist/admin/shell/SchemaNotice.js +1 -1
  8. package/dist/admin/supabase-auth.js +10 -4
  9. package/dist/cli/add-admin.d.ts +11 -0
  10. package/dist/cli/add-admin.js +25 -0
  11. package/dist/cli/main.d.ts +5 -1
  12. package/dist/cli/main.js +23 -3
  13. package/dist/cli/migrate.d.ts +1 -1
  14. package/dist/cli/migrate.js +6 -1
  15. package/dist/cli/space-io.d.ts +1 -7
  16. package/dist/cli/space-io.js +2 -16
  17. package/dist/client.js +3 -1
  18. package/dist/content/content-api.d.ts +80 -0
  19. package/dist/content/content-api.js +152 -0
  20. package/dist/content/node.d.ts +7 -0
  21. package/dist/content/node.js +10 -0
  22. package/dist/entry-store.d.ts +1 -1
  23. package/dist/entry-store.js +1 -1
  24. package/dist/env.d.ts +6 -3
  25. package/dist/env.js +39 -15
  26. package/dist/index.d.ts +4 -3
  27. package/dist/index.js +1 -0
  28. package/dist/member-store.d.ts +12 -0
  29. package/dist/member-store.js +8 -0
  30. package/dist/model-key.d.ts +1 -0
  31. package/dist/model-key.js +36 -0
  32. package/dist/next/page-renderer.js +6 -5
  33. package/dist/next/queries.js +1 -1
  34. package/dist/schema-read.d.ts +7 -0
  35. package/dist/schema-read.js +15 -0
  36. package/dist/schema-version.d.ts +2 -0
  37. package/dist/schema-version.js +3 -1
  38. package/dist/schema.d.ts +10 -4
  39. package/dist/site.d.ts +8 -0
  40. package/dist/site.js +40 -2
  41. package/dist/supabase-member-store.d.ts +6 -0
  42. package/dist/supabase-member-store.js +20 -2
  43. package/dist/supabase-store.js +16 -14
  44. package/dist/tags.d.ts +3 -0
  45. package/dist/tags.js +16 -0
  46. package/dist/version.d.ts +1 -0
  47. package/dist/version.js +2 -0
  48. package/package.json +16 -13
@@ -8,7 +8,7 @@ export declare function fixedNodeOf(record: {
8
8
  fixed?: string | null;
9
9
  }, config: ResolvedConfig): FixedNode | null;
10
10
  /** Create every code-owned record that has none yet and give each the
11
- * locales it lacks, then list. Code wins an address (spec 2026-09-29
11
+ * locales it lacks, publishing what it creates, then list. Code wins an address (spec 2026-09-29
12
12
  * anchored paths §4): an editor's page already there changes nothing
13
13
  * here — the index serves the record, the Pages list flags the page. */
14
14
  export declare function ensureFixedNodes(pages: PageStore, config: ResolvedConfig): Promise<PageRecord[]>;
@@ -4,8 +4,31 @@ export const fixedNodeSpecs = (config) => codeAddresses({ locales: config.locale
4
4
  export function fixedNodeOf(record, config) {
5
5
  return record.fixed ? fixedNodeSpecs(config).find((s) => s.key === record.fixed) ?? null : null;
6
6
  }
7
+ /** Publish a row this call just created (spec 2026-09-30 tester findings §2): the site served the address
8
+ * from code until now, so the empty page goes live as it was. A row that already existed is never
9
+ * published here — an editor's unpublish stands. A folder has no tree to publish. */
10
+ async function publishCreated(pages, record, locale, address) {
11
+ if (record.template === null)
12
+ return record;
13
+ try {
14
+ // A locale added to an older record starts as a copy of the default locale's DRAFT. What goes live
15
+ // is the empty page code was serving; the copy stays the new locale's draft, for an editor to publish.
16
+ const copy = await pages.getDraftTree(record.id, locale);
17
+ const empty = !copy || (Object.values(copy.zones).every((nodes) => nodes.length === 0) && !copy.fields && !copy.meta);
18
+ if (empty)
19
+ return await pages.publish(record.id, locale);
20
+ await pages.saveDraft(record.id, locale, { zones: {} });
21
+ await pages.publish(record.id, locale);
22
+ await pages.saveDraft(record.id, locale, copy);
23
+ return (await pages.getPage(record.id)) ?? record;
24
+ }
25
+ catch (e) {
26
+ console.warn(`smoodly: could not publish "${address}" in "${locale}": ${e instanceof Error ? e.message : String(e)}`);
27
+ return record;
28
+ }
29
+ }
7
30
  /** Create every code-owned record that has none yet and give each the
8
- * locales it lacks, then list. Code wins an address (spec 2026-09-29
31
+ * locales it lacks, publishing what it creates, then list. Code wins an address (spec 2026-09-29
9
32
  * anchored paths §4): an editor's page already there changes nothing
10
33
  * here — the index serves the record, the Pages list flags the page. */
11
34
  export async function ensureFixedNodes(pages, config) {
@@ -28,6 +51,7 @@ export async function ensureFixedNodes(pages, config) {
28
51
  console.warn(`smoodly: could not materialize "${defAddress}": ${message}`);
29
52
  continue;
30
53
  }
54
+ record = await publishCreated(pages, record, def, defAddress);
31
55
  }
32
56
  else if (record.template === null && spec.template !== null) {
33
57
  // The collection gained its index page. A racing admin's hasTemplate is that admin winning.
@@ -48,6 +72,7 @@ export async function ensureFixedNodes(pages, config) {
48
72
  wrote = true;
49
73
  try {
50
74
  record = await pages.addLocale(record.id, locale, { from: def, slug: lastPart(address) });
75
+ record = await publishCreated(pages, record, locale, address);
51
76
  }
52
77
  catch (e) {
53
78
  // Only a concurrent add of the SAME locale is a race; it skips silently.
@@ -12,3 +12,4 @@ export { serializeAdminConfig } from "./serialize.ts";
12
12
  export type { AdminRegistry, AdminSection, AdminCollection, AdminPageTemplate, AdminGlobal, AdminField } from "./serialize.ts";
13
13
  export * from "./tree-ops.ts";
14
14
  export { validateFields, type FieldError } from "./validate.ts";
15
+ export { createSmoodlyContent, SmoodlyContentError, type SmoodlyContent, type ContentDeps } from "../content/content-api.ts";
@@ -7,3 +7,4 @@ export { supabaseAdminAuth } from "./supabase-auth.js";
7
7
  export { serializeAdminConfig } from "./serialize.js";
8
8
  export * from "./tree-ops.js";
9
9
  export { validateFields } from "./validate.js";
10
+ export { createSmoodlyContent, SmoodlyContentError } from "../content/content-api.js";
@@ -1,7 +1,7 @@
1
1
  import { entryErrors, entryFieldsIn, entryTitleIn } from "../entry-store.js";
2
2
  import { chainOf, isSegment, pagePathIn } from "../paths.js";
3
3
  import { servedAnchor } from "../anchors.js";
4
- import { distinctTags } from "../tags.js";
4
+ import { canonicalTags, distinctTags } from "../tags.js";
5
5
  import { can, isMemberRole, normalizeRole } from "../permissions.js";
6
6
  import { ASSET_COLLECTION } from "../media.js";
7
7
  import { adminHref, adminSlugTaken, isAdminSlug } from "../admin-path.js";
@@ -117,6 +117,23 @@ export function createAdminOps(deps) {
117
117
  const href = deps.href ?? ((path) => path);
118
118
  const by = deps.actor?.email ?? undefined;
119
119
  const collectionFields = (name) => config.registry.collections?.find((c) => c.name === name)?.fields ?? null;
120
+ /** The distinct tags a field holds across the collection: a localized field reads `locale`'s rows, a shared one every entry. */
121
+ const tagsIn = (records, field, localized, locale, except) => distinctTags(localized
122
+ ? records.flatMap((r) => (r.id !== except && r.locales[locale] ? [r.locales[locale].fields[field]] : []))
123
+ : records.flatMap((r) => (r.id !== except ? [r.fields[field]] : [])));
124
+ /** Every f.tags() value takes the collection's existing spelling (spec 2026-09-30 tester findings §5).
125
+ * `except` is the entry being saved: its own old spelling is not a precedent. */
126
+ async function foldTags(collection, locale, fields, descriptors, except) {
127
+ const tagKeys = Object.entries(descriptors).filter(([key, d]) => d.type === "tags" && Array.isArray(fields[key]));
128
+ if (tagKeys.length === 0)
129
+ return fields;
130
+ const records = await entries.list(collection);
131
+ const out = { ...fields };
132
+ for (const [key, d] of tagKeys) {
133
+ out[key] = canonicalTags(out[key], tagsIn(records, key, d.localized === true, locale, except));
134
+ }
135
+ return out;
136
+ }
120
137
  // Element nodes inside richtext validate against these (spec 2026-09-08 block nodes §2).
121
138
  // The serialized registry drift walks (spec 2026-09-27 editor safety §2) — the admin client's shape.
122
139
  let adminRegistryMemo = null;
@@ -739,11 +756,7 @@ export function createAdminOps(deps) {
739
756
  const d = descriptors[field];
740
757
  if (d?.type !== "tags")
741
758
  return err("validation", `smoodly: "${field}" is not a tags field of "${collection}".`);
742
- const records = await entries.list(collection);
743
- const values = d.localized === true
744
- ? records.flatMap((r) => (r.locales[locale] ? [r.locales[locale].fields[field]] : []))
745
- : records.map((r) => r.fields[field]);
746
- return ok(distinctTags(values));
759
+ return ok(tagsIn(await entries.list(collection), field, d.localized === true, locale));
747
760
  }),
748
761
  get: (collection, id, locale) => guard(async () => {
749
762
  const unsupported = supportedLocale(locale);
@@ -770,7 +783,7 @@ export function createAdminOps(deps) {
770
783
  const descriptors = collectionFields(collection);
771
784
  if (!descriptors)
772
785
  return err("not_found", `smoodly: no collection named "${collection}".`);
773
- const created = await entries.create(collection, withDefaults(input.fields, descriptors), { locale: input.locale, parentId: input.parentId ?? null }, by);
786
+ const created = await entries.create(collection, withDefaults(await foldTags(collection, input.locale, input.fields, descriptors), descriptors), { locale: input.locale, parentId: input.parentId ?? null }, by);
774
787
  expireCollection(collection);
775
788
  return ok(created);
776
789
  }),
@@ -796,7 +809,7 @@ export function createAdminOps(deps) {
796
809
  }
797
810
  let entry;
798
811
  try {
799
- entry = await entries.update(collection, id, locale, fields, by, token, { redirect: options?.redirect ?? true });
812
+ entry = await entries.update(collection, id, locale, await foldTags(collection, locale, fields, descriptors, id), by, token, { redirect: options?.redirect ?? true });
800
813
  }
801
814
  catch (e) {
802
815
  // A sibling's slug is the slug field's problem, not the whole draft's (spec 2026-09-28 ASCII slugs §5).
@@ -1,4 +1,4 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  export function SchemaNotice({ status }) {
3
- return (_jsx("div", { style: { height: "100vh", display: "grid", placeItems: "center", padding: 24, color: "var(--text)" }, children: _jsxs("div", { style: { maxWidth: 520, display: "grid", gap: 12 }, children: [_jsx("h1", { className: "sm-title", style: { fontSize: 22, margin: 0 }, children: "The database needs an update" }), _jsxs("p", { style: { margin: 0 }, children: ["The database schema is at version ", status.database ?? "none", ". This version of Smoodly needs version ", status.required, "."] }), _jsx("p", { style: { margin: 0 }, children: "From the site's folder:" }), _jsx("pre", { className: "sm-code", children: _jsx("code", { children: "npx smoodly migrate" }) }), _jsx("p", { style: { margin: 0 }, children: "then apply it:" }), _jsx("pre", { className: "sm-code", children: _jsx("code", { children: "supabase db push" }) }), _jsxs("p", { style: { margin: 0, color: "var(--text-muted)" }, children: ["That is for a hosted project; for the local stack run ", _jsx("code", { children: "supabase db reset" }), ". Reload this page afterwards."] })] }) }));
3
+ return (_jsx("div", { style: { height: "100vh", display: "grid", placeItems: "center", padding: 24, color: "var(--text)" }, children: _jsxs("div", { style: { maxWidth: 520, display: "grid", gap: 12 }, children: [_jsx("h1", { className: "sm-title", style: { fontSize: 22, margin: 0 }, children: "The database needs an update" }), _jsxs("p", { style: { margin: 0 }, children: ["The database schema is at version ", status.database ?? "none", ". This version of Smoodly needs version ", status.required, "."] }), _jsx("p", { style: { margin: 0 }, children: "From the site's folder:" }), _jsx("pre", { className: "sm-code", children: _jsx("code", { children: "npx smoodly migrate" }) }), _jsx("p", { style: { margin: 0 }, children: "then apply it:" }), _jsx("pre", { className: "sm-code", children: _jsx("code", { children: "supabase db push" }) }), _jsxs("p", { style: { margin: 0, color: "var(--text-muted)" }, children: ["That is for a hosted project; for the local stack run ", _jsx("code", { children: "supabase migration up" }), ", which keeps the content. Reload this page afterwards."] })] }) }));
4
4
  }
@@ -6,12 +6,18 @@
6
6
  // has one space.
7
7
  import { createClient } from "@supabase/supabase-js";
8
8
  export function supabaseAdminAuth(input) {
9
- const auth = createClient(input.authUrl, input.authKey, {
10
- auth: { persistSession: false, autoRefreshToken: false },
11
- });
9
+ // Built on the first sign-in check, not here: a live page constructs the glue with no publishable
10
+ // key and never verifies anyone (spec 2026-09-30 tester findings §4).
11
+ let client = null;
12
+ const auth = () => {
13
+ if (!input.authKey) {
14
+ throw new Error("smoodly: set SMOODLY_PUBLISHABLE_KEY in .env.local — the admin signs in with it (Deploying: https://smoodly.io/docs/getting-started/deploying).");
15
+ }
16
+ return (client ?? (client = createClient(input.authUrl, input.authKey, { auth: { persistSession: false, autoRefreshToken: false } })));
17
+ };
12
18
  return {
13
19
  async verify(token) {
14
- const res = await auth.auth.getUser(token);
20
+ const res = await auth().auth.getUser(token);
15
21
  return res.data.user ? { userId: res.data.user.id, email: res.data.user.email ?? null } : null;
16
22
  },
17
23
  async memberOf(userId) {
@@ -0,0 +1,11 @@
1
+ import type { MemberStore } from "../member-store.ts";
2
+ /** `adminUrl`: where an invite link lands — the site's admin address. */
3
+ export type AddAdminArgs = {
4
+ email: string;
5
+ adminUrl?: string;
6
+ };
7
+ export declare function runAddAdmin(args: AddAdminArgs, out: (line: string) => void, deps?: {
8
+ cwd?: string;
9
+ members?: MemberStore;
10
+ env?: Record<string, string | undefined>;
11
+ }): Promise<number>;
@@ -0,0 +1,25 @@
1
+ // `smoodly add-admin <email>` (spec 2026-09-30 tester findings §1): the first
2
+ // admin of a project the installer did not make — after an import, say.
3
+ import { SupabaseMemberStore } from "../supabase-member-store.js";
4
+ import { addAdmin } from "../content/content-api.js";
5
+ import { connectFromEnv } from "./space-io.js";
6
+ export async function runAddAdmin(args, out, deps = {}) {
7
+ const members = deps.members ?? new SupabaseMemberStore(connectFromEnv(deps.cwd ?? process.cwd()).db);
8
+ // Read after connectFromEnv: it loads the app's .env files.
9
+ const password = (deps.env ?? process.env).SMOODLY_ADMIN_PASSWORD;
10
+ // An invite link has to land on the admin, which finishes the sign-in: --admin-url, else SITE_URL's /admin.
11
+ const siteUrl = (deps.env ?? process.env).SITE_URL;
12
+ const redirectTo = args.adminUrl ?? (siteUrl ? `${siteUrl.replace(/\/+$/, "")}/admin` : undefined);
13
+ const { invited } = await addAdmin(members, args.email, password !== undefined ? { password } : { redirectTo });
14
+ if (password !== undefined)
15
+ out(`${args.email} is an admin. Sign in at your admin address with that password (an account that already existed keeps its own).`);
16
+ else if (invited) {
17
+ out(`Invited ${args.email} as an admin: the mail has the sign-in link.`);
18
+ out(redirectTo
19
+ ? `The link lands on ${redirectTo}, which must be in the Auth project's redirect URLs.`
20
+ : "No --admin-url and no SITE_URL: the link lands on the Auth project's Site URL. If that is not the admin, run it again with --admin-url <the admin's address>, or set SMOODLY_ADMIN_PASSWORD.");
21
+ }
22
+ else
23
+ out(`${args.email} already had an account and is now an admin.`);
24
+ return 0;
25
+ }
@@ -1,7 +1,8 @@
1
1
  import { type MigrateArgs } from "./migrate.ts";
2
2
  import { type ExportArgs } from "./export.ts";
3
3
  import { type ImportArgs } from "./import.ts";
4
- export declare const USAGE = "usage: smoodly <command>\n\n migrate [--check] [--collections <file>] [--migrations <dir>]\n Copies the core schema files this package ships that supabase/migrations/\n lacks, and writes one views file for the collections whose view is new or\n changed. Prints the push or reset command; never runs it.\n\n --check write nothing; exit 1 when anything is pending\n --collections <file> the module exporting `collections` (default smoodly/collections.ts)\n --migrations <dir> the app's migration folder (default supabase/migrations)\n\n export [--out <dir>]\n Writes the space SMOODLY_SECRET_KEY names \u2014 every content row, history\n included, and the media files \u2014 into a folder (default\n smoodly-export-<date>). Reads only.\n\n import <dir>\n Writes an export folder into the key's space, which must be empty or hold\n only this export's rows (an earlier run to continue). Never deletes.";
4
+ import { type AddAdminArgs } from "./add-admin.ts";
5
+ export declare const USAGE = "usage: smoodly <command>\n\n migrate [--check] [--collections <file>] [--migrations <dir>]\n Copies the core schema files this package ships that supabase/migrations/\n lacks, and writes one views file for the collections whose view is new or\n changed. Prints the push or reset command; never runs it.\n\n --check write nothing; exit 1 when anything is pending\n --collections <file> the module exporting `collections` (default smoodly/collections.ts)\n --migrations <dir> the app's migration folder (default supabase/migrations)\n\n export [--out <dir>]\n Writes the space SMOODLY_SECRET_KEY names \u2014 every content row, history\n included, and the media files \u2014 into a folder (default\n smoodly-export-<date>). Reads only.\n\n import <dir>\n Writes an export folder into the key's space, which must be empty or hold\n only this export's rows (an earlier run to continue). Never deletes.\n\n add-admin <email> [--admin-url <url>]\n Makes <email> an admin of the key's space: with SMOODLY_ADMIN_PASSWORD\n set, creates the account with that password; without, mails an invite\n whose link lands on --admin-url (default: SITE_URL + /admin).";
5
6
  export type Parsed = {
6
7
  command: "migrate";
7
8
  args: MigrateArgs;
@@ -11,6 +12,9 @@ export type Parsed = {
11
12
  } | {
12
13
  command: "import";
13
14
  args: ImportArgs;
15
+ } | {
16
+ command: "add-admin";
17
+ args: AddAdminArgs;
14
18
  } | {
15
19
  command: "help";
16
20
  } | {
package/dist/cli/main.js CHANGED
@@ -1,12 +1,14 @@
1
1
  // packages/smoodly/src/cli/main.ts
2
- // `smoodly` — the package's command line. Three subcommands: migrate
3
- // (spec 2026-09-26 §2), export and import (spec 2026-09-28). Exit codes:
2
+ // `smoodly` — the package's command line. Four subcommands: migrate
3
+ // (spec 2026-09-26 §2), export and import (spec 2026-09-28), add-admin
4
+ // (spec 2026-09-30 tester findings §1). Exit codes:
4
5
  // 0 done or nothing to do, 1 pending (--check), refused or failed, 2 usage.
5
6
  import { resolve } from "node:path";
6
7
  import { fileURLToPath } from "node:url";
7
8
  import { runMigrate } from "./migrate.js";
8
9
  import { runExport } from "./export.js";
9
10
  import { runImport } from "./import.js";
11
+ import { runAddAdmin } from "./add-admin.js";
10
12
  export const USAGE = `usage: smoodly <command>
11
13
 
12
14
  migrate [--check] [--collections <file>] [--migrations <dir>]
@@ -25,7 +27,12 @@ export const USAGE = `usage: smoodly <command>
25
27
 
26
28
  import <dir>
27
29
  Writes an export folder into the key's space, which must be empty or hold
28
- only this export's rows (an earlier run to continue). Never deletes.`;
30
+ only this export's rows (an earlier run to continue). Never deletes.
31
+
32
+ add-admin <email> [--admin-url <url>]
33
+ Makes <email> an admin of the key's space: with SMOODLY_ADMIN_PASSWORD
34
+ set, creates the account with that password; without, mails an invite
35
+ whose link lands on --admin-url (default: SITE_URL + /admin).`;
29
36
  export function parseArgs(argv) {
30
37
  const [command, ...rest] = argv;
31
38
  if (!command || command === "--help" || command === "-h" || command === "help")
@@ -46,6 +53,17 @@ export function parseArgs(argv) {
46
53
  return { command: "error", message: "import needs one export folder: smoodly import <dir>" };
47
54
  return { command: "import", args: { dir: rest[0] } };
48
55
  }
56
+ if (command === "add-admin") {
57
+ const usage = { command: "error", message: "add-admin needs one email: smoodly add-admin <email> [--admin-url <url>]" };
58
+ const [email, ...flags] = rest;
59
+ if (!email || email.startsWith("-"))
60
+ return usage;
61
+ if (flags.length === 0)
62
+ return { command: "add-admin", args: { email } };
63
+ if (flags.length !== 2 || flags[0] !== "--admin-url" || flags[1].startsWith("-"))
64
+ return usage;
65
+ return { command: "add-admin", args: { email, adminUrl: flags[1] } };
66
+ }
49
67
  if (command !== "migrate")
50
68
  return { command: "error", message: `unknown command "${command}"` };
51
69
  const args = { check: false, collections: "smoodly/collections.ts", migrations: "supabase/migrations" };
@@ -77,6 +95,8 @@ export async function main(argv, out = console.log) {
77
95
  return await runImport(parsed.args, out);
78
96
  if (parsed.command === "export")
79
97
  return await runExport(parsed.args, out);
98
+ if (parsed.command === "add-admin")
99
+ return await runAddAdmin(parsed.args, out);
80
100
  return await runMigrate(parsed.args, out);
81
101
  }
82
102
  catch (e) {
@@ -8,7 +8,7 @@ export type MigrateArgs = {
8
8
  /** The folder shipped in the package: `../../migrations/` from dist/cli/ and from src/cli/ alike. */
9
9
  export declare const PACKAGE_MIGRATIONS_DIR: string;
10
10
  export declare const NO_FOLDER = "No supabase/migrations folder here \u2014 nothing to do. (Smoodly Cloud keeps the schema current; a self-host app gets the folder from `supabase init`.)";
11
- export declare const NEXT_STEP = "Next: apply it \u2014 `supabase db push` (hosted) or `supabase db reset` (local).";
11
+ export declare const NEXT_STEP = "Next: apply it \u2014 `supabase db push` (hosted) or `supabase migration up` (local).";
12
12
  export declare const upToDate: (version: number, n: number) => string;
13
13
  export declare const modifiedMessage: (name: string) => string;
14
14
  export declare function readSqlFiles(dir: string): Promise<MigrationFile[]>;
@@ -14,7 +14,7 @@ import { loadCollections } from "./load-collections.js";
14
14
  /** The folder shipped in the package: `../../migrations/` from dist/cli/ and from src/cli/ alike. */
15
15
  export const PACKAGE_MIGRATIONS_DIR = fileURLToPath(new URL("../../migrations/", import.meta.url));
16
16
  export const NO_FOLDER = "No supabase/migrations folder here — nothing to do. (Smoodly Cloud keeps the schema current; a self-host app gets the folder from `supabase init`.)";
17
- export const NEXT_STEP = "Next: apply it — `supabase db push` (hosted) or `supabase db reset` (local).";
17
+ export const NEXT_STEP = "Next: apply it — `supabase db push` (hosted) or `supabase migration up` (local).";
18
18
  export const upToDate = (version, n) => `Up to date: core schema v${version}, every collection's view is current (${n} ${n === 1 ? "collection" : "collections"}).`;
19
19
  export const modifiedMessage = (name) => `Refusing: ${name} differs from the package's copy. A published migration is never edited — restore it from the package, or move your change to a new file.`;
20
20
  export async function readSqlFiles(dir) {
@@ -38,6 +38,11 @@ export async function runMigrate(args, out, deps = {}) {
38
38
  const cwd = deps.cwd ?? process.cwd();
39
39
  const appDir = resolve(cwd, args.migrations);
40
40
  if (!existsSync(appDir)) {
41
+ // A build that runs the check must not pass one it never made (spec 2026-09-30 tester findings §5).
42
+ if (args.check) {
43
+ out(`smoodly: no supabase/migrations here (${appDir}) — run the check in the app's folder, or pass --migrations.`);
44
+ return 1;
45
+ }
41
46
  out(NO_FOLDER);
42
47
  return 0;
43
48
  }
@@ -11,12 +11,7 @@ export declare const PAGE_ROWS = 1000;
11
11
  export declare function loadAppEnv(cwd: string, env?: Record<string, string | undefined>): void;
12
12
  /** The app's content client, from its env. */
13
13
  export declare function connectFromEnv(cwd: string): SpaceConnection;
14
- /** The policies show `smoodly_meta` to a write key only, so no visible row means the key is not a secret key (spec §5). */
15
- export declare function readSchemaVersion(db: SupabaseClient): Promise<{
16
- version: number;
17
- } | {
18
- missing: "key" | "schema";
19
- }>;
14
+ export { readSchemaVersion } from "../schema-read.ts";
20
15
  export declare function schemaRefusal(missing: "key" | "schema", command: "Export" | "Import"): string;
21
16
  /** Every row of one table in one space, paged in primary-key order. The space filter is explicit: a service-role key sees every space. */
22
17
  export declare function readSpaceRows(db: SupabaseClient, table: ContentTable, space: string, where?: Record<string, string>, columns?: string): Promise<Row[]>;
@@ -29,4 +24,3 @@ export declare const isNotFound: (e: StorageErrorLike) => boolean;
29
24
  export declare const isAlreadyExists: (e: StorageErrorLike) => boolean;
30
25
  /** The installed package's version for the manifest: `../../package.json` from src/cli/ and dist/cli/ alike. */
31
26
  export declare function packageVersion(): string;
32
- export {};
@@ -9,7 +9,6 @@ import { join } from "node:path";
9
9
  import { parseEnv } from "node:util";
10
10
  import { createSmoodlyClient } from "../client.js";
11
11
  import { resolveSmoodlyEnv } from "../env.js";
12
- import { SCHEMA_VERSION_KEY } from "../schema-version.js";
13
12
  import { ROW_KEY } from "../space-transfer.js";
14
13
  export const MEDIA_BUCKET = "media";
15
14
  /** Rows per request. A host may cap below it, so a read stops at the first empty page, not the first short one. */
@@ -31,24 +30,11 @@ export function connectFromEnv(cwd) {
31
30
  loadAppEnv(cwd);
32
31
  return { db: createSmoodlyClient(resolveSmoodlyEnv(process.env)) };
33
32
  }
34
- /** The policies show `smoodly_meta` to a write key only, so no visible row means the key is not a secret key (spec §5). */
35
- export async function readSchemaVersion(db) {
36
- const res = await db.from("smoodly_meta").select("value").eq("key", SCHEMA_VERSION_KEY).maybeSingle();
37
- if (res.error) {
38
- // PGRST205: not in PostgREST's schema cache; 42P01: undefined table; 42501: no grant.
39
- if (res.error.code === "PGRST205" || res.error.code === "42P01")
40
- return { missing: "schema" };
41
- if (res.error.code === "42501")
42
- return { missing: "key" };
43
- throw new Error(`smoodly: reading the schema version: ${res.error.message}`);
44
- }
45
- const n = Number(res.data?.value);
46
- return res.data && Number.isInteger(n) ? { version: n } : { missing: "key" };
47
- }
33
+ export { readSchemaVersion } from "../schema-read.js";
48
34
  export function schemaRefusal(missing, command) {
49
35
  return missing === "key"
50
36
  ? `${command} needs SMOODLY_SECRET_KEY — the service-role key, or the cloud's write key. The publishable key reads no content.`
51
- : "This database has no Smoodly schema. Run `npx smoodly migrate`, then `supabase db push` (hosted) or `supabase db reset` (local).";
37
+ : "This database has no Smoodly schema. Run `npx smoodly migrate`, then `supabase db push` (hosted) or `supabase migration up` (local).";
52
38
  }
53
39
  /** Every row of one table in one space, paged in primary-key order. The space filter is explicit: a service-role key sees every space. */
54
40
  export async function readSpaceRows(db, table, space, where = {}, columns = "*") {
package/dist/client.js CHANGED
@@ -7,7 +7,9 @@ export function clientOptions(env) {
7
7
  if (env.secretKey.startsWith("sb_secret_")) {
8
8
  return { url: env.url, key: env.secretKey, headers: {} };
9
9
  }
10
- return { url: env.url, key: env.publishableKey, headers: { Authorization: `Bearer ${env.secretKey}` } };
10
+ // The gateway's apikey is the publishable key, or the secret itself when the site runs
11
+ // without one (spec 2026-09-30 tester findings §4): a self-host service role is a valid apikey.
12
+ return { url: env.url, key: env.publishableKey || env.secretKey, headers: { Authorization: `Bearer ${env.secretKey}` } };
11
13
  }
12
14
  /** The server-side client the stores are built on. */
13
15
  export function createSmoodlyClient(env) {
@@ -0,0 +1,80 @@
1
+ import { createAdminOps } from "../admin/ops-impl.ts";
2
+ import type { EntryCreateInput, OpError, PageCreateInput } from "../admin/ops.ts";
3
+ import type { FieldError } from "../admin/validate.ts";
4
+ import type { EntryRecord } from "../entry-store.ts";
5
+ import type { PageRecord } from "../store.ts";
6
+ import type { PageTree } from "../render.tsx";
7
+ import type { ImageValue } from "../media.ts";
8
+ import type { MemberStore } from "../member-store.ts";
9
+ export type UploadMeta = {
10
+ filename?: string;
11
+ mime?: string;
12
+ alt?: string;
13
+ title?: string;
14
+ caption?: string;
15
+ credit?: string;
16
+ };
17
+ export declare class SmoodlyContentError extends Error {
18
+ readonly code: OpError["code"];
19
+ readonly fields: FieldError[];
20
+ constructor(error: Pick<OpError, "code" | "message" | "fields">);
21
+ }
22
+ export type ContentDeps = Omit<Parameters<typeof createAdminOps>[0], "actor">;
23
+ /** With a password: the account is created (or an existing one kept) and made an admin. Without: an invite mail,
24
+ * whose link goes to `redirectTo` or the Auth project's Site URL. Running it again changes nothing.
25
+ * Its own function because `smoodly add-admin` has a member store and no config. */
26
+ export declare function addAdmin(members: MemberStore | undefined, email: string, options?: {
27
+ password?: string;
28
+ redirectTo?: string;
29
+ }): Promise<{
30
+ email: string;
31
+ invited: boolean;
32
+ }>;
33
+ export declare function createSmoodlyContent(deps: ContentDeps): {
34
+ pages: {
35
+ create: (input: PageCreateInput) => Promise<PageRecord>;
36
+ setTree(id: string, locale: string, tree: PageTree): Promise<void>;
37
+ /** The locale's draft tree — what `setTree` replaces; null for a folder or a locale the page lacks.
38
+ * A seed reads it to tell a code-owned page nobody has filled yet. */
39
+ getTree(id: string, locale: string): Promise<PageTree | null>;
40
+ addLocale(id: string, locale: string, options: {
41
+ from: string;
42
+ slug?: string;
43
+ title?: string;
44
+ }): Promise<PageRecord>;
45
+ publish: (id: string, locale: string) => Promise<PageRecord>;
46
+ /** A code-owned address with no record gets it created the way the admin's Pages list does (spec §2). */
47
+ find(locale: string, path: string): Promise<PageRecord | null>;
48
+ };
49
+ entries: {
50
+ create: (collection: string, input: EntryCreateInput) => Promise<EntryRecord>;
51
+ update: (collection: string, id: string, locale: string, fields: Record<string, unknown>) => Promise<EntryRecord>;
52
+ addLocale(collection: string, id: string, locale: string, options: {
53
+ from: string;
54
+ fields?: Record<string, unknown>;
55
+ }): Promise<EntryRecord>;
56
+ publish: (collection: string, id: string, locale: string) => Promise<EntryRecord>;
57
+ find(collection: string, match: {
58
+ slug: string;
59
+ locale?: string;
60
+ }): Promise<EntryRecord | null>;
61
+ };
62
+ globals: {
63
+ set(name: string, locale: string, fields: Record<string, unknown>): Promise<EntryRecord>;
64
+ publish: (name: string, locale: string) => Promise<EntryRecord>;
65
+ };
66
+ media: {
67
+ /** A file path or bytes in, a ready asset and the value an image field stores out. */
68
+ upload(file: string | Uint8Array, meta?: UploadMeta): Promise<ImageValue>;
69
+ };
70
+ members: {
71
+ addAdmin: (email: string, options?: {
72
+ password?: string;
73
+ redirectTo?: string;
74
+ }) => Promise<{
75
+ email: string;
76
+ invited: boolean;
77
+ }>;
78
+ };
79
+ };
80
+ export type SmoodlyContent = ReturnType<typeof createSmoodlyContent>;
@@ -0,0 +1,152 @@
1
+ // The content API (spec 2026-09-30 tester findings §1): the admin's
2
+ // operations with no signed-in editor, as a few verbs that throw. Scripts
3
+ // and route handlers write content through this, never through the stores,
4
+ // so validation, the code-owned address guards and publish checks apply.
5
+ import { createAdminOps } from "../admin/ops-impl.js";
6
+ import { imageDimensions } from "../image-dimensions.js";
7
+ const MIME = {
8
+ jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", webp: "image/webp", gif: "image/gif", avif: "image/avif",
9
+ svg: "image/svg+xml", mp4: "video/mp4", webm: "video/webm", pdf: "application/pdf",
10
+ };
11
+ const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
12
+ export class SmoodlyContentError extends Error {
13
+ constructor(error) {
14
+ const list = (error.fields ?? []).map((f) => `\n ${f.field}: ${f.message}`).join("");
15
+ super(error.message + list);
16
+ this.name = "SmoodlyContentError";
17
+ this.code = error.code;
18
+ this.fields = error.fields ?? [];
19
+ }
20
+ }
21
+ async function must(result) {
22
+ const r = await result;
23
+ if (!r.ok)
24
+ throw new SmoodlyContentError(r);
25
+ return r.data;
26
+ }
27
+ /** With a password: the account is created (or an existing one kept) and made an admin. Without: an invite mail,
28
+ * whose link goes to `redirectTo` or the Auth project's Site URL. Running it again changes nothing.
29
+ * Its own function because `smoodly add-admin` has a member store and no config. */
30
+ export async function addAdmin(members, email, options = {}) {
31
+ if (!members)
32
+ throw new SmoodlyContentError({ code: "internal", message: "smoodly: no member store — pass `members` to createSmoodlyContent." });
33
+ if (!EMAIL.test(email))
34
+ throw new SmoodlyContentError({ code: "validation", message: `smoodly: "${email}" is not an email address.` });
35
+ if (options.password !== undefined && options.password.length < 8) {
36
+ throw new SmoodlyContentError({ code: "validation", message: "smoodly: the password needs at least 8 characters." });
37
+ }
38
+ try {
39
+ // Where smoodly.io manages the members, nothing here writes them — as the admin's Members page refuses.
40
+ if ((await members.consoleUrl()) !== null) {
41
+ throw new SmoodlyContentError({ code: "blocked", message: "smoodly: this site's members are managed at smoodly.io." });
42
+ }
43
+ if (options.password !== undefined) {
44
+ await members.createAccount({ email, password: options.password, role: "admin" });
45
+ return { email, invited: false };
46
+ }
47
+ const { invited } = await members.invite({ email, role: "admin", redirectTo: options.redirectTo ?? "" });
48
+ return { email, invited };
49
+ }
50
+ catch (e) {
51
+ if (e instanceof SmoodlyContentError)
52
+ throw e;
53
+ throw new SmoodlyContentError({ code: "internal", message: e instanceof Error ? e.message : String(e) });
54
+ }
55
+ }
56
+ export function createSmoodlyContent(deps) {
57
+ const ops = createAdminOps(deps);
58
+ const def = deps.config.locales.default;
59
+ async function findPage(locale, path) {
60
+ const hit = await deps.pages.resolvePath(locale, path);
61
+ if (hit?.kind === "page" && !hit.redirect)
62
+ return deps.pages.getPage(hit.id);
63
+ return null;
64
+ }
65
+ async function updateEntry(collection, id, locale, fields) {
66
+ return must(ops.entries.save(collection, id, locale, fields));
67
+ }
68
+ return {
69
+ pages: {
70
+ create: (input) => must(ops.pages.create(input)),
71
+ async setTree(id, locale, tree) {
72
+ await must(ops.pages.saveDraft(id, locale, tree));
73
+ },
74
+ /** The locale's draft tree — what `setTree` replaces; null for a folder or a locale the page lacks.
75
+ * A seed reads it to tell a code-owned page nobody has filled yet. */
76
+ async getTree(id, locale) {
77
+ return (await must(ops.pages.get(id, locale))).draft;
78
+ },
79
+ async addLocale(id, locale, options) {
80
+ const added = await must(ops.pages.addLocale(id, locale, { from: options.from }));
81
+ if (options.slug === undefined && options.title === undefined)
82
+ return added;
83
+ return must(ops.pages.rename(id, {
84
+ locale,
85
+ ...(options.slug !== undefined ? { slug: options.slug } : {}),
86
+ ...(options.title !== undefined ? { title: options.title } : {}),
87
+ redirect: false,
88
+ }));
89
+ },
90
+ publish: (id, locale) => must(ops.pages.publish(id, locale)),
91
+ /** A code-owned address with no record gets it created the way the admin's Pages list does (spec §2). */
92
+ async find(locale, path) {
93
+ const found = await findPage(locale, path);
94
+ if (found)
95
+ return found;
96
+ await must(ops.pages.list());
97
+ return findPage(locale, path);
98
+ },
99
+ },
100
+ entries: {
101
+ create: (collection, input) => must(ops.entries.create(collection, input)),
102
+ update: updateEntry,
103
+ async addLocale(collection, id, locale, options) {
104
+ const added = await must(ops.entries.addLocale(collection, id, locale, { from: options.from }));
105
+ return options.fields ? updateEntry(collection, id, locale, options.fields) : added;
106
+ },
107
+ publish: (collection, id, locale) => must(ops.entries.setStatus(collection, id, locale, "published")),
108
+ async find(collection, match) {
109
+ const locale = match.locale ?? def;
110
+ const all = await must(ops.entries.list(collection));
111
+ return all.find((e) => e.locales[locale]?.slug === match.slug) ?? null;
112
+ },
113
+ },
114
+ globals: {
115
+ async set(name, locale, fields) {
116
+ await must(ops.globals.list()); // materializes the item (ensureGlobalItems), as opening Globals does
117
+ return must(ops.globals.save(name, locale, fields));
118
+ },
119
+ publish: (name, locale) => must(ops.globals.setStatus(name, locale, "published")),
120
+ },
121
+ media: {
122
+ /** A file path or bytes in, a ready asset and the value an image field stores out. */
123
+ async upload(file, meta = {}) {
124
+ if (!deps.assets)
125
+ throw new SmoodlyContentError({ code: "internal", message: "smoodly: no media store — pass `assets` to createSmoodlyContent." });
126
+ // Loaded here, not at the top: this module is reachable from `smoodly/admin`, which a bundler may walk.
127
+ const bytes = typeof file === "string" ? new Uint8Array(await (await import("node:fs/promises")).readFile(file)) : file;
128
+ const filename = meta.filename ?? (typeof file === "string" ? file.split(/[\\/]/).pop() : "upload");
129
+ const mime = meta.mime ?? MIME[filename.split(".").pop().toLowerCase()] ?? "application/octet-stream";
130
+ // The media library's own rules, as the admin's upload applies them (config.media).
131
+ const { accept, maxSize } = deps.config.media;
132
+ if (!accept.includes(mime))
133
+ throw new SmoodlyContentError({ code: "validation", message: `smoodly: "${filename}" is ${mime}, not an accepted file type.` });
134
+ if (bytes.byteLength > maxSize) {
135
+ throw new SmoodlyContentError({ code: "validation", message: `smoodly: "${filename}" is larger than ${Math.round(maxSize / 1024 / 1024)} MB.` });
136
+ }
137
+ const { alt, title, caption, credit } = meta;
138
+ let asset;
139
+ try {
140
+ asset = await deps.assets.create({ filename, mime, size: bytes.byteLength, bytes, alt, title, caption, credit, ...(imageDimensions(bytes) ?? {}) });
141
+ }
142
+ catch (e) {
143
+ throw new SmoodlyContentError({ code: "internal", message: e instanceof Error ? e.message : String(e) });
144
+ }
145
+ return { asset: asset.id, ...(alt !== undefined ? { alt } : {}) };
146
+ },
147
+ },
148
+ members: {
149
+ addAdmin: (email, options = {}) => addAdmin(deps.members, email, options),
150
+ },
151
+ };
152
+ }
@@ -0,0 +1,7 @@
1
+ import type { BuilderMap, SectionSchema, StoredValues } from "../schema.ts";
2
+ import type { PairedComponent } from "../pairing.tsx";
3
+ import type { TreeNode } from "../render.tsx";
4
+ export declare function node<F extends BuilderMap, St extends BuilderMap>(section: PairedComponent<SectionSchema<F, St>, F, St>, fields: Partial<StoredValues<F>>, options?: {
5
+ settings?: Partial<StoredValues<St>>;
6
+ id?: string;
7
+ }): TreeNode;