smoodly 0.0.37 → 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 (45) hide show
  1. package/dist/admin/fixed-nodes.d.ts +1 -1
  2. package/dist/admin/fixed-nodes.js +26 -1
  3. package/dist/admin/index.d.ts +1 -0
  4. package/dist/admin/index.js +1 -0
  5. package/dist/admin/ops-impl.js +21 -8
  6. package/dist/admin/shell/SchemaNotice.js +1 -1
  7. package/dist/admin/supabase-auth.js +10 -4
  8. package/dist/cli/add-admin.d.ts +11 -0
  9. package/dist/cli/add-admin.js +25 -0
  10. package/dist/cli/main.d.ts +5 -1
  11. package/dist/cli/main.js +23 -3
  12. package/dist/cli/migrate.d.ts +1 -1
  13. package/dist/cli/migrate.js +6 -1
  14. package/dist/cli/space-io.d.ts +1 -7
  15. package/dist/cli/space-io.js +2 -16
  16. package/dist/client.js +3 -1
  17. package/dist/content/content-api.d.ts +80 -0
  18. package/dist/content/content-api.js +152 -0
  19. package/dist/content/node.d.ts +7 -0
  20. package/dist/content/node.js +10 -0
  21. package/dist/env.d.ts +6 -3
  22. package/dist/env.js +39 -15
  23. package/dist/index.d.ts +4 -3
  24. package/dist/index.js +1 -0
  25. package/dist/member-store.d.ts +12 -0
  26. package/dist/member-store.js +8 -0
  27. package/dist/model-key.d.ts +1 -0
  28. package/dist/model-key.js +36 -0
  29. package/dist/next/page-renderer.js +6 -5
  30. package/dist/next/queries.js +1 -1
  31. package/dist/schema-read.d.ts +7 -0
  32. package/dist/schema-read.js +15 -0
  33. package/dist/schema-version.d.ts +2 -0
  34. package/dist/schema-version.js +3 -1
  35. package/dist/schema.d.ts +10 -4
  36. package/dist/site.d.ts +8 -0
  37. package/dist/site.js +40 -2
  38. package/dist/supabase-member-store.d.ts +6 -0
  39. package/dist/supabase-member-store.js +20 -2
  40. package/dist/supabase-store.js +16 -14
  41. package/dist/tags.d.ts +3 -0
  42. package/dist/tags.js +16 -0
  43. package/dist/version.d.ts +1 -0
  44. package/dist/version.js +2 -0
  45. package/package.json +16 -13
@@ -0,0 +1,10 @@
1
+ import { newNodeId } from "../admin/tree-ops.js";
2
+ export function node(section, fields, options = {}) {
3
+ const type = section.schema.name;
4
+ return {
5
+ id: options.id ?? newNodeId(type),
6
+ type,
7
+ fields: fields,
8
+ ...(options.settings ? { settings: options.settings } : {}),
9
+ };
10
+ }
package/dist/env.d.ts CHANGED
@@ -4,17 +4,20 @@ export type SmoodlyEnv = {
4
4
  url: string;
5
5
  /** Server-only: the service-role key (self-host) or the environment's write key (cloud). */
6
6
  secretKey: string;
7
- /** What a browser may hold for the content project: anon/publishable key, or the cloud's per-environment key. It reads no content (sign-in and presence only). */
7
+ /** What a browser may hold for the content project: anon/publishable key, or the cloud's per-environment key. It reads no content (sign-in and presence only). "" on a live site that runs without the admin. */
8
8
  publishableKey: string;
9
9
  /** Where editors sign in. Defaults to `url`. */
10
10
  authUrl: string;
11
11
  /** That project's publishable key. Defaults to `publishableKey`. */
12
12
  authKey: string;
13
13
  };
14
- export declare function resolveSmoodlyEnv(env: Record<string, string | undefined>): SmoodlyEnv;
14
+ /** The live site reads with the URL and the secret key; the admin and presence also need the
15
+ * publishable key, so their callers pass "admin" (spec 2026-09-30 tester findings §4). A key
16
+ * in the wrong variable or of no known form is named here, not by a failed read later. */
17
+ export declare function resolveSmoodlyEnv(env: Record<string, string | undefined>, need?: "site" | "admin"): SmoodlyEnv;
15
18
  /** `resolveSmoodlyEnv(process.env)` — call lazily, never at module load,
16
19
  * so `next build` without env still succeeds. */
17
- export declare const smoodlyEnv: () => SmoodlyEnv;
20
+ export declare const smoodlyEnv: (need?: "site" | "admin") => SmoodlyEnv;
18
21
  /** What the browser needs to join the admin's presence channel: the content project's URL, publishable key and space, public values only — never for content reads (spec 2026-09-29 content API contract §8). */
19
22
  export type PresenceConfig = {
20
23
  url: string;
package/dist/env.js CHANGED
@@ -1,27 +1,51 @@
1
1
  // The one config shape (spec 2026-09-04 §4/§6). Self-host sets three
2
- // names; the cloud sets five. The two defaults are what keep a single
2
+ // names (the live site alone needs two); the cloud sets five. The two defaults are what keep a single
3
3
  // code path: a default for an absent value is not a mode branch.
4
4
  import { createSmoodlyClient } from "./client.js";
5
5
  import { readCurrentSpace } from "./current-space.js";
6
- const REQUIRED = ["SMOODLY_URL", "SMOODLY_SECRET_KEY", "SMOODLY_PUBLISHABLE_KEY"];
7
- export function resolveSmoodlyEnv(env) {
8
- const missing = REQUIRED.filter((name) => !env[name]);
9
- if (missing.length > 0) {
10
- throw new Error(`smoodly: set ${missing.join(", ")} — see .env.local.example.`);
6
+ const SITE_NEEDS = ["SMOODLY_URL", "SMOODLY_SECRET_KEY"];
7
+ const ADMIN_NEEDS = [...SITE_NEEDS, "SMOODLY_PUBLISHABLE_KEY"];
8
+ const DEPLOYING = "https://smoodly.io/docs/getting-started/deploying";
9
+ const isJwt = (v) => v.split(".").length === 3;
10
+ /** A legacy JWT key's role claim ("anon", "service_role"; "authenticated" for a cloud space key); null when it cannot be read. */
11
+ function jwtRole(v) {
12
+ if (!isJwt(v))
13
+ return null;
14
+ try {
15
+ const role = JSON.parse(atob(v.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"))).role;
16
+ return typeof role === "string" ? role : null;
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ /** The live site reads with the URL and the secret key; the admin and presence also need the
23
+ * publishable key, so their callers pass "admin" (spec 2026-09-30 tester findings §4). A key
24
+ * in the wrong variable or of no known form is named here, not by a failed read later. */
25
+ export function resolveSmoodlyEnv(env, need = "site") {
26
+ const missing = (need === "admin" ? ADMIN_NEEDS : SITE_NEEDS).filter((name) => !env[name]);
27
+ if (missing.length > 0)
28
+ throw new Error(`smoodly: set ${missing.join(", ")} in .env.local (Deploying: ${DEPLOYING}).`);
29
+ const secretKey = env.SMOODLY_SECRET_KEY;
30
+ const publishableKey = env.SMOODLY_PUBLISHABLE_KEY ?? "";
31
+ if (secretKey.startsWith("sb_publishable_") || jwtRole(secretKey) === "anon") {
32
+ throw new Error("smoodly: SMOODLY_SECRET_KEY holds a publishable key — copy the secret (service-role) key from your Supabase project.");
33
+ }
34
+ if (!secretKey.startsWith("sb_secret_") && !isJwt(secretKey)) {
35
+ throw new Error("smoodly: SMOODLY_SECRET_KEY is neither an sb_secret_ key nor a JWT — copy the secret (service-role) key from your Supabase project.");
36
+ }
37
+ if (publishableKey.startsWith("sb_secret_") || jwtRole(publishableKey) === "service_role") {
38
+ throw new Error("smoodly: SMOODLY_PUBLISHABLE_KEY holds a secret key — it reaches the browser; copy the publishable (anon) key from your Supabase project.");
39
+ }
40
+ if (publishableKey && !publishableKey.startsWith("sb_publishable_") && !isJwt(publishableKey)) {
41
+ throw new Error("smoodly: SMOODLY_PUBLISHABLE_KEY is neither an sb_publishable_ key nor a JWT — copy the publishable (anon) key from your Supabase project.");
11
42
  }
12
43
  const url = env.SMOODLY_URL;
13
- const publishableKey = env.SMOODLY_PUBLISHABLE_KEY;
14
- return {
15
- url,
16
- secretKey: env.SMOODLY_SECRET_KEY,
17
- publishableKey,
18
- authUrl: env.SMOODLY_AUTH_URL || url,
19
- authKey: env.SMOODLY_AUTH_KEY || publishableKey,
20
- };
44
+ return { url, secretKey, publishableKey, authUrl: env.SMOODLY_AUTH_URL || url, authKey: env.SMOODLY_AUTH_KEY || publishableKey };
21
45
  }
22
46
  /** `resolveSmoodlyEnv(process.env)` — call lazily, never at module load,
23
47
  * so `next build` without env still succeeds. */
24
- export const smoodlyEnv = () => resolveSmoodlyEnv(process.env);
48
+ export const smoodlyEnv = (need = "site") => resolveSmoodlyEnv(process.env, need);
25
49
  /** One lookup per process per content project and key: the space never
26
50
  * changes for a key. A failed lookup is not cached, so the next call retries. */
27
51
  const spaces = new Map();
package/dist/index.d.ts CHANGED
@@ -66,7 +66,8 @@ export type { PageStore, PageRecord, PageLocale, PageVersion, PageScan, PageStor
66
66
  export { pathOptions, pageErrors, localeRow, pageRefEdges, DEFAULT_LOCALES } from "./store.ts";
67
67
  export type { SmoodlyConfig, ResolvedConfig } from "./config.ts";
68
68
  export type { FieldBuilder, Descriptor, ValueOf, Option, OptionInput, OptionValue } from "./fields.ts";
69
- export type { SectionSchema, ElementSchema, BuilderMap, Values } from "./schema.ts";
69
+ export type { SectionSchema, ElementSchema, BuilderMap, Values, StoredValues } from "./schema.ts";
70
+ export { node } from "./content/node.ts";
70
71
  export type { CollectionSeo } from "./seo.ts";
71
72
  export type { EntryRef, EntryItem, PageItem, GlobalItem, VisualGlobalItem, Seo } from "./read-shapes.ts";
72
73
  export type { RefTo } from "./fields.ts";
@@ -97,7 +98,7 @@ export declare const smoodly: {
97
98
  omit: string[];
98
99
  };
99
100
  css?: false;
100
- sample?: Partial<{ [K in keyof import("./schema.ts").NamespaceValues<F>]: import("./schema.ts").NamespaceValues<F>[K] extends import("./fields.ts").RefTo<any, any> ? string : import("./schema.ts").NamespaceValues<F>[K] extends import("./fields.ts").RefTo<any, any>[] ? string[] : import("./schema.ts").NamespaceValues<F>[K]; } & { [K_1 in keyof import("./schema.ts").NamespaceValues<S>]: import("./schema.ts").NamespaceValues<S>[K_1] extends import("./fields.ts").RefTo<any, any> ? string : import("./schema.ts").NamespaceValues<S>[K_1] extends import("./fields.ts").RefTo<any, any>[] ? string[] : import("./schema.ts").NamespaceValues<S>[K_1]; }> | undefined;
101
+ sample?: Partial<import("./schema.ts").StoredValues<F> & import("./schema.ts").StoredValues<S>> | undefined;
101
102
  }): import("./schema.ts").SectionSchema<F, S>;
102
103
  element<F extends import("./schema.ts").BuilderMap = {}, S extends import("./schema.ts").BuilderMap = {}>(input: {
103
104
  name: string;
@@ -111,7 +112,7 @@ export declare const smoodly: {
111
112
  omit: string[];
112
113
  };
113
114
  css?: false;
114
- sample?: Partial<{ [K in keyof import("./schema.ts").NamespaceValues<F>]: import("./schema.ts").NamespaceValues<F>[K] extends import("./fields.ts").RefTo<any, any> ? string : import("./schema.ts").NamespaceValues<F>[K] extends import("./fields.ts").RefTo<any, any>[] ? string[] : import("./schema.ts").NamespaceValues<F>[K]; } & { [K_1 in keyof import("./schema.ts").NamespaceValues<S>]: import("./schema.ts").NamespaceValues<S>[K_1] extends import("./fields.ts").RefTo<any, any> ? string : import("./schema.ts").NamespaceValues<S>[K_1] extends import("./fields.ts").RefTo<any, any>[] ? string[] : import("./schema.ts").NamespaceValues<S>[K_1]; }> | undefined;
115
+ sample?: Partial<import("./schema.ts").StoredValues<F> & import("./schema.ts").StoredValues<S>> | undefined;
115
116
  } & {
116
117
  contexts: ("richtext" | "column")[];
117
118
  }): import("./schema.ts").ElementSchema<F, S>;
package/dist/index.js CHANGED
@@ -46,6 +46,7 @@ export { fixedPathFor } from "./localize.js";
46
46
  export { addressMap, assertAddress, codeAddresses, collectionKey, lastPart, ownerCollection, pageKey } from "./addresses.js";
47
47
  export { anchorUrl, anchorsFor, candidates, fullPath, pickHit } from "./anchors.js";
48
48
  export { pathOptions, pageErrors, localeRow, pageRefEdges, DEFAULT_LOCALES } from "./store.js";
49
+ export { node } from "./content/node.js";
49
50
  export { DEFAULT_TOOLSET } from "./toolset.js";
50
51
  export { collectionOrder, collectionDepth, DEFAULT_ORDER } from "./collections.js";
51
52
  export { GLOBAL_NODE_TYPE, SETTINGS_KEY, STYLES_KEY, CSS_KEY, globalLink } from "./global.js";
@@ -21,6 +21,13 @@ export interface MemberStore {
21
21
  consoleUrl(): Promise<string | null>;
22
22
  /** Creates or finds the Auth user for the email, mails the invite when it created one, upserts the row (spec 2026-09-25 members management §3). */
23
23
  invite(input: InviteInput): Promise<InviteResult>;
24
+ /** Creates the Auth user with a password (confirmed, no mail) and upserts the row — the installer's first editor, `add-admin`.
25
+ * An existing account keeps its password and only gains the membership. */
26
+ createAccount(input: {
27
+ email: string;
28
+ password: string;
29
+ role: MemberRole;
30
+ }): Promise<MemberRecord>;
24
31
  setRole(userId: string, role: MemberRole): Promise<MemberRecord>;
25
32
  /** Deletes the member row only; the Auth account stays. */
26
33
  remove(userId: string): Promise<void>;
@@ -44,6 +51,11 @@ export declare class MemoryMemberStore implements MemberStore {
44
51
  list(): Promise<MemberRecord[]>;
45
52
  consoleUrl(): Promise<string | null>;
46
53
  invite(input: InviteInput): Promise<InviteResult>;
54
+ createAccount(input: {
55
+ email: string;
56
+ password: string;
57
+ role: MemberRole;
58
+ }): Promise<MemberRecord>;
47
59
  setRole(userId: string, role: MemberRole): Promise<MemberRecord>;
48
60
  remove(userId: string): Promise<void>;
49
61
  }
@@ -44,6 +44,14 @@ export class MemoryMemberStore {
44
44
  this.rows = [...this.rows.filter((r) => r.userId !== userId), member];
45
45
  return { member, invited };
46
46
  }
47
+ async createAccount(input) {
48
+ const key = input.email.toLowerCase();
49
+ const userId = this.accounts[key] ?? this.rows.find((r) => r.email?.toLowerCase() === key)?.userId ?? crypto.randomUUID();
50
+ this.accounts[key] = userId;
51
+ const member = { userId, email: input.email, role: input.role };
52
+ this.rows = [...this.rows.filter((r) => r.userId !== userId), member];
53
+ return member;
54
+ }
47
55
  async setRole(userId, role) {
48
56
  const row = this.rows.find((r) => r.userId === userId);
49
57
  if (!row)
@@ -0,0 +1 @@
1
+ export declare function modelKey(model: unknown): string;
@@ -0,0 +1,36 @@
1
+ // A short, stable hash of the registered model (spec 2026-09-30 tester findings §3):
2
+ // object keys sorted, functions dropped, cycles cut — two processes agree.
3
+ // Plain arithmetic, no node:crypto: the root entry is imported by client sections too.
4
+ function canonical(value, seen) {
5
+ if (typeof value === "function" || value === undefined)
6
+ return undefined;
7
+ if (value === null || typeof value !== "object")
8
+ return value;
9
+ if (seen.has(value))
10
+ return "[cycle]";
11
+ seen.add(value);
12
+ const out = Array.isArray(value)
13
+ ? value.map((v) => canonical(v, seen) ?? null)
14
+ : Object.fromEntries(Object.keys(value).sort().flatMap((k) => {
15
+ const v = canonical(value[k], seen);
16
+ return v === undefined ? [] : [[k, v]];
17
+ }));
18
+ seen.delete(value);
19
+ return out;
20
+ }
21
+ /** Two 32-bit FNV-1a style runs with different seeds (cyrb53's mixing): 64 bits, of which 48 are kept. */
22
+ function hash(text) {
23
+ let h1 = 0xdeadbeef, h2 = 0x41c6ce57;
24
+ for (let i = 0; i < text.length; i++) {
25
+ const c = text.charCodeAt(i);
26
+ h1 = Math.imul(h1 ^ c, 2654435761);
27
+ h2 = Math.imul(h2 ^ c, 1597334677);
28
+ }
29
+ h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
30
+ h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
31
+ const hex = (n) => (n >>> 0).toString(16).padStart(8, "0");
32
+ return (hex(h2) + hex(h1)).slice(0, 12);
33
+ }
34
+ export function modelKey(model) {
35
+ return hash(JSON.stringify(canonical(model, new WeakSet())));
36
+ }
@@ -39,7 +39,7 @@ export function createPageRenderer(options) {
39
39
  // Keyed by what the unit holds, not only where: an address that changes
40
40
  // owner — a deploy handing it to a code record, which fires no tag —
41
41
  // can never serve the previous occupant (spec 2026-09-29 pre-install fixes §5).
42
- return unstable_cache(() => site.load(target, locale, path), ["smoodly-site", locale, path, target.kind, target.id], {
42
+ return unstable_cache(() => site.load(target, locale, path), ["smoodly-site", site.cacheKey, locale, path, target.kind, target.id], {
43
43
  tags: [target.kind === "page" ? pageTag(target.id) : entryTag(target.id)],
44
44
  })();
45
45
  }
@@ -114,7 +114,8 @@ export function createPageRenderer(options) {
114
114
  const target = await site.locate(locale, path);
115
115
  if (target?.redirect)
116
116
  await redirectTo(target, locale, params);
117
- const hit = target ? await load(target, locale, path, draft, version) : null;
117
+ // A code-owned address with no record yet is served from code (spec 2026-09-30 tester findings §2).
118
+ const hit = target ? await load(target, locale, path, draft, version) : site.codePage(locale, path);
118
119
  if (!hit || !accept(hit))
119
120
  notFound();
120
121
  // The draft marker tells the page editor's canvas diagnosis that Smoodly
@@ -129,9 +130,9 @@ export function createPageRenderer(options) {
129
130
  const path = pathFor(locale);
130
131
  const { isEnabled: draft } = await draftMode();
131
132
  const target = await site.locate(locale, path);
132
- if (!target || target.redirect)
133
+ if (target?.redirect)
133
134
  return {};
134
- const hit = await load(target, locale, path, draft);
135
+ const hit = target ? await load(target, locale, path, draft) : site.codePage(locale, path);
135
136
  if (!hit || !accept(hit))
136
137
  return {};
137
138
  // A page type without seo: true still gets its page title as <title>; seo: true adds the panel's values on top.
@@ -153,7 +154,7 @@ export function createPageRenderer(options) {
153
154
  const { isEnabled: draft } = await draftMode();
154
155
  if (draft)
155
156
  return site.getGlobal(item, { locale, draft: true });
156
- return unstable_cache(() => site.getGlobal(item, { locale }), ["smoodly-global", item.name, locale], {
157
+ return unstable_cache(() => site.getGlobal(item, { locale }), ["smoodly-global", site.cacheKey, item.name, locale], {
157
158
  tags: [globalTag(item.name)],
158
159
  })();
159
160
  }
@@ -36,7 +36,7 @@ export function createSmoodlyQueries(options) {
36
36
  const { isEnabled: draft } = await draftMode();
37
37
  if (draft)
38
38
  return read(true);
39
- return unstable_cache(() => read(false), ["smoodly-q", ...key], { tags })();
39
+ return unstable_cache(() => read(false), ["smoodly-q", site.cacheKey, ...key], { tags })();
40
40
  }
41
41
  /** The tag needs the id: a path is located first, uncached, as the renderer does. */
42
42
  async function idOf(kind, locale, pathOrId) {
@@ -0,0 +1,7 @@
1
+ import type { SupabaseClient } from "@supabase/supabase-js";
2
+ /** The policies show `smoodly_meta` to a write key only, so no visible row means the key is not a secret key (spec §5). */
3
+ export declare function readSchemaVersion(db: SupabaseClient): Promise<{
4
+ version: number;
5
+ } | {
6
+ missing: "key" | "schema";
7
+ }>;
@@ -0,0 +1,15 @@
1
+ import { SCHEMA_VERSION_KEY } from "./schema-version.js";
2
+ /** The policies show `smoodly_meta` to a write key only, so no visible row means the key is not a secret key (spec §5). */
3
+ export async function readSchemaVersion(db) {
4
+ const res = await db.from("smoodly_meta").select("value").eq("key", SCHEMA_VERSION_KEY).maybeSingle();
5
+ if (res.error) {
6
+ // PGRST205: not in PostgREST's schema cache; 42P01: undefined table; 42501: no grant.
7
+ if (res.error.code === "PGRST205" || res.error.code === "42P01")
8
+ return { missing: "schema" };
9
+ if (res.error.code === "42501")
10
+ return { missing: "key" };
11
+ throw new Error(`smoodly: reading the schema version: ${res.error.message}`);
12
+ }
13
+ const n = Number(res.data?.value);
14
+ return res.data && Number.isInteger(n) ? { version: n } : { missing: "key" };
15
+ }
@@ -15,3 +15,5 @@ export declare function schemaVersionSQL(version: number): string;
15
15
  * bump, and is fine. */
16
16
  export declare const schemaBehind: (database: number | null, required?: number) => boolean;
17
17
  export declare function schemaBehindMessage(database: number | null, required?: number): string;
18
+ /** The version row is hidden from anything but a secret key, so an invisible row means the wrong key, not an old schema. */
19
+ export declare const secretKeyRefusedMessage = "smoodly: SMOODLY_SECRET_KEY cannot read the schema version \u2014 it is not the project's secret (service-role) key; the publishable key there is the usual mix-up.";
@@ -28,5 +28,7 @@ export function schemaVersionSQL(version) {
28
28
  export const schemaBehind = (database, required = SCHEMA_VERSION) => database === null || database < required;
29
29
  export function schemaBehindMessage(database, required = SCHEMA_VERSION) {
30
30
  return (`smoodly: the database schema is at version ${database ?? "none"}; this Smoodly needs version ${required}. ` +
31
- "Run `npx smoodly migrate`, then `supabase db push` (hosted) or `supabase db reset` (local).");
31
+ "Run `npx smoodly migrate`, then apply it: `supabase db push` (hosted) or `supabase migration up` (local).");
32
32
  }
33
+ /** The version row is hidden from anything but a secret key, so an invisible row means the wrong key, not an old schema. */
34
+ export const secretKeyRefusedMessage = "smoodly: SMOODLY_SECRET_KEY cannot read the schema version — it is not the project's secret (service-role) key; the publishable key there is the usual mix-up.";
package/dist/schema.d.ts CHANGED
@@ -2,6 +2,7 @@ import { type Descriptor, type FieldBuilder, type ValueOf } from "./fields.ts";
2
2
  import { pageSchema } from "./page.ts";
3
3
  import type { RefTo } from "./fields.ts";
4
4
  import type { EntryRef } from "./read-shapes.ts";
5
+ import type { FileValue, ImageValue, ResolvedFile, ResolvedImage, ResolvedVideo, VideoValue } from "./media.ts";
5
6
  export type BuilderMap = Record<string, FieldBuilder<any, any>>;
6
7
  export type SectionSchema<F extends BuilderMap = BuilderMap, S extends BuilderMap = BuilderMap> = {
7
8
  kind: "section";
@@ -54,9 +55,14 @@ export type Materialize<V> = V extends RefTo<infer T, infer M> ? EntryRef<ShapeO
54
55
  export type Values<M extends BuilderMap> = {
55
56
  [K in keyof NamespaceValues<M>]: Materialize<NamespaceValues<M>[K]>;
56
57
  };
57
- /** A sample stores ids where a read shows references. */
58
- type Stored<M extends BuilderMap> = {
59
- [K in keyof NamespaceValues<M>]: NamespaceValues<M>[K] extends RefTo<any, any> ? string : NamespaceValues<M>[K] extends RefTo<any, any>[] ? string[] : NamespaceValues<M>[K];
58
+ /** One value as storage holds it: a ref is the entry's id, media is the `{ asset }` value, and lists and
59
+ * objects hold the same inside. Richtext (`unknown`) and scalars pass through. */
60
+ export type StoredOf<V> = unknown extends V ? V : V extends RefTo<any, any> ? string : V extends ResolvedImage ? ImageValue : V extends ResolvedVideo ? VideoValue : V extends ResolvedFile ? FileValue : V extends readonly (infer I)[] ? StoredOf<I>[] : V extends Record<string, unknown> ? {
61
+ [K in keyof V]: StoredOf<V[K]>;
62
+ } : V;
63
+ /** What storage holds for a field map (a sample, a node built from code). */
64
+ export type StoredValues<M extends BuilderMap> = {
65
+ [K in keyof NamespaceValues<M>]: StoredOf<NamespaceValues<M>[K]>;
60
66
  };
61
67
  type SectionInput<F extends BuilderMap, S extends BuilderMap> = {
62
68
  name: string;
@@ -71,7 +77,7 @@ type SectionInput<F extends BuilderMap, S extends BuilderMap> = {
71
77
  omit: string[];
72
78
  };
73
79
  css?: false;
74
- sample?: Partial<Stored<F> & Stored<S>>;
80
+ sample?: Partial<StoredValues<F> & StoredValues<S>>;
75
81
  };
76
82
  export declare const schema: {
77
83
  page: typeof pageSchema;
package/dist/site.d.ts CHANGED
@@ -108,6 +108,9 @@ export type LoadOptions = {
108
108
  export interface SmoodlySite {
109
109
  readonly locales: LocaleSet;
110
110
  readonly homePageSlug: string;
111
+ /** The package version and a hash of the registered model: a part of every cache key, so an upgrade or a
112
+ * model change never serves a unit cached in the old shape (spec 2026-09-30 tester findings §3). */
113
+ readonly cacheKey: string;
111
114
  /** The resolved styles values the renderer and `getGlobal` hand the wrapper. */
112
115
  readonly styles: StyleValues;
113
116
  /** The custom section's resolved options the renderer hands renderPage. */
@@ -119,6 +122,11 @@ export interface SmoodlySite {
119
122
  /** Everything the renderer needs for one target: null when it is a folder, unpublished (outside draft), or gone. */
120
123
  load(target: PathTarget, locale: string, path: string, options?: LoadOptions): Promise<SiteHit | null>;
121
124
  resolve(locale: string, path: string, options?: LoadOptions): Promise<SiteHit | null>;
125
+ /** A fixed page type (or a collection's index page) as code alone describes it, when `path` is its address in
126
+ * `locale`: an empty tree, the page fields' defaults, the registration's title. The renderer serves it where
127
+ * the index has no row — a page type added after install, a locale added to the config — until the admin
128
+ * makes the record (spec 2026-09-30 tester findings §2). Not in `getPagesTree`: that lists records. */
129
+ codePage(locale: string, path: string): SitePage | null;
122
130
  getPagesTree(options?: {
123
131
  locale?: string;
124
132
  draft?: boolean;
package/dist/site.js CHANGED
@@ -6,9 +6,11 @@ import { globalNodeId, SETTINGS_KEY, STYLES_KEY, CSS_KEY } from "./global.js";
6
6
  import { SMD_KEY } from "./section-wrapper.js";
7
7
  import { resolveStyleValues } from "./styles.js";
8
8
  import { resolveCustom } from "./custom/index.js";
9
- import { addressMap, codeAddresses } from "./addresses.js";
9
+ import { addressMap, codeAddresses, lastPart } from "./addresses.js";
10
10
  import { fullPath, servedAnchor } from "./anchors.js";
11
11
  import { withDefaults } from "./field-defaults.js";
12
+ import { modelKey } from "./model-key.js";
13
+ import { SMOODLY_VERSION } from "./version.js";
12
14
  import { toEntryItem, toPageItem } from "./read-shapes.js";
13
15
  import { entrySeo, pageSeo, SEO_KEY } from "./seo.js";
14
16
  /** The `page` prop for a loaded page. `href` is the app's seam from a
@@ -36,11 +38,22 @@ export function createSmoodlySite(options) {
36
38
  const custom = resolveCustom(options.custom);
37
39
  const elements = elementSchemas(options.elements);
38
40
  // Code-owned addresses from the site's own registry (spec 2026-09-29 nested paths §3): a code-owned root's path.
39
- const addresses = addressMap(codeAddresses({ locales, pages: options.pages, collections: options.collections }));
41
+ const codeOwned = codeAddresses({ locales, pages: options.pages, collections: options.collections });
42
+ const addresses = addressMap(codeOwned);
40
43
  const pathOpts = { locales, homePageSlug, addresses };
41
44
  /** The same addresses as the path index sees them: an entry's stored part joined to its collection's (spec 2026-09-29 anchored paths §2). */
42
45
  const anchors = { addresses, homePageSlug, defaultLocale: locales.default };
43
46
  const mounted = options.collections.filter((c) => c.path !== undefined);
47
+ // Every cached read keys on it (spec 2026-09-30 tester findings §3): an upgrade or a model change starts a fresh cache.
48
+ const cacheKey = `${SMOODLY_VERSION}:${modelKey({
49
+ // Whole schemas: a collection's order, tree or title field changes a read as its fields do (functions drop out in the hash).
50
+ collections: options.collections,
51
+ globals: (options.globals ?? []).map((g) => ({ name: g.name, fields: g.fields, section: g.section })),
52
+ sections: options.sections.map((x) => ("schema" in x ? x.schema : x)),
53
+ elements: (options.elements ?? []).map((x) => ("schema" in x ? x.schema : x)),
54
+ pages: options.pages.map((x) => x.schema),
55
+ locales, homePageSlug, styles, custom,
56
+ })}`;
44
57
  /** The resolver's fetcher, bound to a locale: the merged fields of that
45
58
  * locale — published ones (a versioned collection's snapshot) on the
46
59
  * public path, the live rows in draft mode. */
@@ -336,9 +349,33 @@ export function createSmoodlySite(options) {
336
349
  };
337
350
  },
338
351
  };
352
+ const codePath = (address) => (address === homePageSlug ? "/" : `/${address}`);
353
+ function codePage(locale, path) {
354
+ const spec = codeOwned.find((a) => a.template !== null && a.segments[locale] !== undefined && codePath(a.segments[locale]) === path);
355
+ if (!spec || !locales.supported.includes(locale))
356
+ return null;
357
+ const template = spec.template;
358
+ const withSeo = options.pages.some((x) => x.schema.kind === "page" && x.schema.name === template && x.schema.seo === true);
359
+ return {
360
+ kind: "page",
361
+ locale,
362
+ // No record yet: the id only has to be stable and never a stored one.
363
+ record: { id: `code:${template}`, parentId: null, template, fixed: spec.key, locales: {}, sort: null },
364
+ template,
365
+ tree: { zones: {} },
366
+ title: spec.title,
367
+ path,
368
+ fields: withDefaults(undefined, pageFields(template)),
369
+ ancestors: [],
370
+ children: [],
371
+ locales: Object.fromEntries(Object.entries(spec.segments).map(([l, address]) => [l, { slug: lastPart(address), path: codePath(address) }])),
372
+ ...(withSeo ? { seo: pageSeo(undefined, spec.title) } : {}),
373
+ };
374
+ }
339
375
  const site = {
340
376
  locales,
341
377
  homePageSlug,
378
+ cacheKey,
342
379
  styles,
343
380
  custom,
344
381
  locate: (locale, path) => options.stores().pages.resolvePath(locale, path),
@@ -352,6 +389,7 @@ export function createSmoodlySite(options) {
352
389
  const target = await site.locate(locale, path);
353
390
  return target && !target.redirect ? site.load(target, locale, path, opts) : null;
354
391
  },
392
+ codePage,
355
393
  ...api,
356
394
  };
357
395
  return site;
@@ -10,6 +10,12 @@ export declare class SupabaseMemberStore implements MemberStore {
10
10
  /** The admin API has no lookup by email: page through the users (tens, not thousands). */
11
11
  private userIdByEmail;
12
12
  invite(input: InviteInput): Promise<InviteResult>;
13
+ private upsertRow;
14
+ createAccount(input: {
15
+ email: string;
16
+ password: string;
17
+ role: MemberRole;
18
+ }): Promise<MemberRecord>;
13
19
  setRole(userId: string, role: MemberRole): Promise<MemberRecord>;
14
20
  remove(userId: string): Promise<void>;
15
21
  }
@@ -51,14 +51,32 @@ export class SupabaseMemberStore {
51
51
  else {
52
52
  throw new Error(`smoodly: ${sent.error.message}`);
53
53
  }
54
+ return { member: await this.upsertRow(userId, input.email, input.role), invited };
55
+ }
56
+ async upsertRow(userId, email, role) {
54
57
  const res = await this.db
55
58
  .from("members")
56
- .upsert({ user_id: userId, email: input.email, role: input.role }, { onConflict: "space_id,user_id" })
59
+ .upsert({ user_id: userId, email, role }, { onConflict: "space_id,user_id" })
57
60
  .select("user_id, email, role")
58
61
  .single();
59
62
  if (res.error)
60
63
  throw new Error(`smoodly: ${res.error.message}`);
61
- return { member: this.toRecord(res.data), invited };
64
+ return this.toRecord(res.data);
65
+ }
66
+ async createAccount(input) {
67
+ const created = await this.db.auth.admin.createUser({ email: input.email, password: input.password, email_confirm: true });
68
+ let userId;
69
+ if (!created.error) {
70
+ userId = created.data.user.id;
71
+ }
72
+ else if (created.error.code === "email_exists" || /already (been )?registered/i.test(created.error.message)) {
73
+ // An existing account keeps its password; it only gains the membership.
74
+ userId = await this.userIdByEmail(input.email);
75
+ }
76
+ else {
77
+ throw new Error(`smoodly: ${created.error.message}`);
78
+ }
79
+ return this.upsertRow(userId, input.email, input.role);
62
80
  }
63
81
  async setRole(userId, role) {
64
82
  const res = await this.db.from("members").update({ role }).eq("user_id", userId).select("user_id, email, role").maybeSingle();
@@ -8,7 +8,8 @@
8
8
  // rewritten through ONE RPC per write, so a collision rolls back.
9
9
  import { assertPublishable, assertSupportedLocale, localeRow, pageErrors, pageRefEdges, pathOptions, refSections, storeAnchors, } from "./store.js";
10
10
  import { SupabasePathIndex } from "./supabase-path-index.js";
11
- import { SCHEMA_VERSION_KEY, schemaBehind, schemaBehindMessage } from "./schema-version.js";
11
+ import { schemaBehind, schemaBehindMessage, secretKeyRefusedMessage } from "./schema-version.js";
12
+ import { readSchemaVersion } from "./schema-read.js";
12
13
  import { groupRedirects } from "./store.js";
13
14
  import { assertFresh, draftToken, StaleDraftError } from "./draft-token.js";
14
15
  import { assertDepth, assertSegment, isHomeNode, pagePathRows, rewriteTargets } from "./paths.js";
@@ -381,25 +382,26 @@ export class SupabasePageStore {
381
382
  resolvePath(locale, path) {
382
383
  if (!this.schemaChecked) {
383
384
  this.schemaChecked = true;
384
- void this.schemaVersion()
385
- .then((v) => {
386
- if (schemaBehind(v))
387
- console.warn(schemaBehindMessage(v));
385
+ // Once per store: a wrong key is named as a wrong key, not as an old schema (spec 2026-09-30 tester findings §4).
386
+ void readSchemaVersion(this.db)
387
+ .then((read) => {
388
+ if ("missing" in read)
389
+ console.warn(read.missing === "key" ? secretKeyRefusedMessage : schemaBehindMessage(null));
390
+ else if (schemaBehind(read.version))
391
+ console.warn(schemaBehindMessage(read.version));
388
392
  })
389
393
  .catch(() => { });
390
394
  }
391
395
  return this.paths.resolve(locale, path);
392
396
  }
393
397
  async schemaVersion() {
394
- const res = await this.db.from("smoodly_meta").select("value").eq("key", SCHEMA_VERSION_KEY).maybeSingle();
395
- if (res.error) {
396
- // PGRST205: not in PostgREST's schema cache; 42P01: undefined table.
397
- if (res.error.code === "PGRST205" || res.error.code === "42P01")
398
- return null;
399
- this.fail(res.error.message);
400
- }
401
- const n = Number(res.data?.value);
402
- return Number.isInteger(n) ? n : null;
398
+ const read = await readSchemaVersion(this.db);
399
+ if ("version" in read)
400
+ return read.version;
401
+ // The table exists and the row is hidden: the key is not a secret key.
402
+ if (read.missing === "key")
403
+ throw new Error(secretKeyRefusedMessage);
404
+ return null;
403
405
  }
404
406
  pathsOf(id) {
405
407
  return UUID_RE.test(id) ? this.paths.pathsOf({ kind: "page", id }) : Promise.resolve({});
package/dist/tags.d.ts CHANGED
@@ -17,3 +17,6 @@ export declare function addTags(current: readonly string[], input: string, opts?
17
17
  * entries disagree on a tag's casing the most used one wins, a tie going to plain string
18
18
  * order — never to the order the values arrived in, which is the store's business. */
19
19
  export declare function distinctTags(values: readonly unknown[]): string[];
20
+ /** A tags value as stored (spec 2026-09-30 tester findings §5): normalized, one per
21
+ * case-folded tag, each taking the spelling already used in the collection. */
22
+ export declare function canonicalTags(values: readonly unknown[], known: readonly string[]): string[];
package/dist/tags.js CHANGED
@@ -48,3 +48,19 @@ export function distinctTags(values) {
48
48
  const pick = (counts) => [...counts].sort(([a, n], [b, m]) => m - n || (a < b ? -1 : 1))[0][0];
49
49
  return [...variants.values()].map(pick).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
50
50
  }
51
+ /** A tags value as stored (spec 2026-09-30 tester findings §5): normalized, one per
52
+ * case-folded tag, each taking the spelling already used in the collection. */
53
+ export function canonicalTags(values, known) {
54
+ const out = [];
55
+ for (const value of values) {
56
+ if (typeof value !== "string")
57
+ continue;
58
+ const typed = normalizeTag(value);
59
+ if (typed === "")
60
+ continue;
61
+ const tag = known.find((k) => sameTag(k, typed)) ?? typed;
62
+ if (!out.some((t) => sameTag(t, tag)))
63
+ out.push(tag);
64
+ }
65
+ return out;
66
+ }
@@ -0,0 +1 @@
1
+ export declare const SMOODLY_VERSION = "0.0.38";
@@ -0,0 +1,2 @@
1
+ // Generated by scripts/write-version.mjs from package.json — do not edit.
2
+ export const SMOODLY_VERSION = "0.0.38";