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.
- package/dist/admin/fixed-nodes.d.ts +1 -1
- package/dist/admin/fixed-nodes.js +26 -1
- package/dist/admin/index.d.ts +1 -0
- package/dist/admin/index.js +1 -0
- package/dist/admin/ops-impl.js +21 -8
- package/dist/admin/shell/SchemaNotice.js +1 -1
- package/dist/admin/supabase-auth.js +10 -4
- package/dist/cli/add-admin.d.ts +11 -0
- package/dist/cli/add-admin.js +25 -0
- package/dist/cli/main.d.ts +5 -1
- package/dist/cli/main.js +23 -3
- package/dist/cli/migrate.d.ts +1 -1
- package/dist/cli/migrate.js +6 -1
- package/dist/cli/space-io.d.ts +1 -7
- package/dist/cli/space-io.js +2 -16
- package/dist/client.js +3 -1
- package/dist/content/content-api.d.ts +80 -0
- package/dist/content/content-api.js +152 -0
- package/dist/content/node.d.ts +7 -0
- package/dist/content/node.js +10 -0
- package/dist/env.d.ts +6 -3
- package/dist/env.js +39 -15
- package/dist/index.d.ts +4 -3
- package/dist/index.js +1 -0
- package/dist/member-store.d.ts +12 -0
- package/dist/member-store.js +8 -0
- package/dist/model-key.d.ts +1 -0
- package/dist/model-key.js +36 -0
- package/dist/next/page-renderer.js +6 -5
- package/dist/next/queries.js +1 -1
- package/dist/schema-read.d.ts +7 -0
- package/dist/schema-read.js +15 -0
- package/dist/schema-version.d.ts +2 -0
- package/dist/schema-version.js +3 -1
- package/dist/schema.d.ts +10 -4
- package/dist/site.d.ts +8 -0
- package/dist/site.js +40 -2
- package/dist/supabase-member-store.d.ts +6 -0
- package/dist/supabase-member-store.js +20 -2
- package/dist/supabase-store.js +16 -14
- package/dist/tags.d.ts +3 -0
- package/dist/tags.js +16 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +2 -0
- 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
|
-
|
|
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
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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<
|
|
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<
|
|
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";
|
package/dist/member-store.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/member-store.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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
|
}
|
package/dist/next/queries.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/schema-version.d.ts
CHANGED
|
@@ -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.";
|
package/dist/schema-version.js
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
58
|
-
|
|
59
|
-
|
|
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<
|
|
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
|
|
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
|
|
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
|
|
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();
|
package/dist/supabase-store.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
|
395
|
-
if (
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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";
|
package/dist/version.js
ADDED