smoodly 0.0.42 → 0.0.43

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.
@@ -15,8 +15,9 @@ export async function gateAdminOp(auth, call, run) {
15
15
  let stored;
16
16
  try {
17
17
  user = await auth.verify(token);
18
+ // Marked, so the browser signs itself out instead of showing this on every screen (2026-10-02).
18
19
  if (!user)
19
- return unauthorized("smoodly: your session has expired — sign in again.");
20
+ return { ok: false, code: "unauthorized", message: "smoodly: your session has expired — sign in again.", expired: true };
20
21
  stored = await auth.memberOf(user.userId);
21
22
  }
22
23
  catch (e) {
@@ -1,2 +1,5 @@
1
1
  import type { AdminOpAction, AdminOps } from "./ops.ts";
2
- export declare function makeClientOps(action: AdminOpAction, token?: () => string | null): AdminOps;
2
+ /** `onExpired` runs when the server refuses the token outright (gateAdminOp's `expired`): the browser
3
+ * still holds a session the auth project no longer knows — after a database reset, a deleted user —
4
+ * and only a local sign-out brings the sign-in form back (2026-10-02). */
5
+ export declare function makeClientOps(action: AdminOpAction, token?: () => string | null, onExpired?: () => void): AdminOps;
@@ -9,14 +9,21 @@ const GROUPS = {
9
9
  schema: ["status"],
10
10
  preview: ["enable", "disable"],
11
11
  };
12
- export function makeClientOps(action, token) {
12
+ /** `onExpired` runs when the server refuses the token outright (gateAdminOp's `expired`): the browser
13
+ * still holds a session the auth project no longer knows — after a database reset, a deleted user —
14
+ * and only a local sign-out brings the sign-in form back (2026-10-02). */
15
+ export function makeClientOps(action, token, onExpired) {
13
16
  const out = {};
14
17
  for (const [group, methods] of Object.entries(GROUPS)) {
15
18
  out[group] = {};
16
19
  for (const method of methods) {
17
20
  out[group][method] = (...args) => {
18
21
  const t = token?.() ?? null;
19
- return action({ path: `${group}.${method}`, args, ...(t ? { token: t } : {}) });
22
+ return action({ path: `${group}.${method}`, args, ...(t ? { token: t } : {}) }).then((r) => {
23
+ if (!r.ok && r.expired)
24
+ onExpired?.();
25
+ return r;
26
+ });
20
27
  };
21
28
  }
22
29
  }
@@ -3,5 +3,10 @@ import type { AdminOps } from "../ops.ts";
3
3
  import type { AdminCollection, AdminRegistry } from "../serialize.ts";
4
4
  import type { RefOption } from "./ref-picker.ts";
5
5
  export declare function refOptionsOf(records: EntryRecord[], meta: AdminCollection | undefined, registry: AdminRegistry, locale: string): RefOption[];
6
+ /** What the options are read from, by content: the registry crosses the server → client boundary
7
+ * and is re-serialized on every autosave (a Server Action re-renders the route), so a key on its
8
+ * identity emptied the picker to "Loading…" and refetched every target on each save (2026-10-02;
9
+ * the richtext editor's toolset and elements are keyed the same way, 2026-09-26). */
10
+ export declare function refOptionsKey(registry: AdminRegistry, locale: string, targets: readonly string[]): string;
6
11
  /** Options by target collection; a target is `undefined` until its list arrives. */
7
12
  export declare function useRefOptions(ops: AdminOps, registry: AdminRegistry, locale: string, targets: readonly string[]): Record<string, RefOption[] | undefined>;
@@ -3,7 +3,7 @@
3
3
  // collection, loaded once per locale and filtered in the browser. One hook
4
4
  // for the entry form, the Globals form and the page editor — the picker
5
5
  // takes options as a prop, so a move to server search changes this file only.
6
- import { useEffect, useState } from "react";
6
+ import { useEffect, useRef, useState } from "react";
7
7
  import { entryTitle } from "../shell/entries-list.js";
8
8
  import { localeStatusOf } from "../shell/pages-tree.js";
9
9
  export function refOptionsOf(records, meta, registry, locale) {
@@ -13,14 +13,27 @@ export function refOptionsOf(records, meta, registry, locale) {
13
13
  return row ? { id: e.id, title, status: localeStatusOf(row) } : { id: e.id, title, status: "", absent: true };
14
14
  });
15
15
  }
16
+ /** What the options are read from, by content: the registry crosses the server → client boundary
17
+ * and is re-serialized on every autosave (a Server Action re-renders the route), so a key on its
18
+ * identity emptied the picker to "Loading…" and refetched every target on each save (2026-10-02;
19
+ * the richtext editor's toolset and elements are keyed the same way, 2026-09-26). */
20
+ export function refOptionsKey(registry, locale, targets) {
21
+ const names = [...new Set(targets)].sort();
22
+ const metas = names.map((n) => registry.collections.find((c) => c.name === n) ?? null);
23
+ return JSON.stringify([locale, registry.locales.default, names, metas]);
24
+ }
16
25
  /** Options by target collection; a target is `undefined` until its list arrives. */
17
26
  export function useRefOptions(ops, registry, locale, targets) {
18
27
  const [loaded, setLoaded] = useState({});
19
- const key = [...new Set(targets)].sort().join("\n");
28
+ const key = refOptionsKey(registry, locale, targets);
29
+ // The effect reads the render's registry, but reloads only when the key says its content changed.
30
+ const latest = useRef({ registry, locale, targets });
31
+ latest.current = { registry, locale, targets };
20
32
  useEffect(() => {
33
+ const { registry, locale, targets } = latest.current;
21
34
  let cancelled = false;
22
35
  setLoaded({}); // the previous locale's options must not show under this one
23
- for (const target of key === "" ? [] : key.split("\n")) {
36
+ for (const target of new Set(targets)) {
24
37
  const meta = registry.collections.find((c) => c.name === target);
25
38
  ops.entries.list(target).then((r) => {
26
39
  if (cancelled)
@@ -31,6 +44,6 @@ export function useRefOptions(ops, registry, locale, targets) {
31
44
  setLoaded((prev) => ({ ...prev, [target]: [] })); });
32
45
  }
33
46
  return () => { cancelled = true; };
34
- }, [ops, registry, locale, key]);
47
+ }, [ops, key]);
35
48
  return loaded;
36
49
  }
@@ -20,6 +20,8 @@ export type OpError = {
20
20
  /** Who wrote it last and when — the freeze notice's two facts. */
21
21
  by?: string | null;
22
22
  at?: number;
23
+ /** An `unauthorized` for a token the auth project rejects: the browser signs itself out (2026-10-02). */
24
+ expired?: true;
23
25
  };
24
26
  export type OpResult<T> = {
25
27
  ok: true;
@@ -41,7 +41,8 @@ import { connectRealtime } from "./presence-realtime.js";
41
41
  import { PresenceProvider } from "./presence-context.js";
42
42
  export function AdminApp({ registry, op, auth, presenceConfig }) {
43
43
  const session = useAdminSession(auth);
44
- const [ops] = useState(() => makeClientOps(op, session.token));
44
+ // An expired session signs out here, locally: the user it names may no longer exist to log out.
45
+ const [ops] = useState(() => makeClientOps(op, session.token, () => { void session.client.auth.signOut({ scope: "local" }); }));
45
46
  const route = useRoute(registry.adminPath);
46
47
  const [head = "pages", tail] = route.segments.length ? route.segments : ["pages"];
47
48
  const isCollection = registry.collections.some((c) => c.name === head);
@@ -102,6 +102,17 @@ export function EntryForm({ ops, registry, collection, id }) {
102
102
  useTrackPresence(target);
103
103
  const { others } = usePresenceState();
104
104
  const presence = usePresence();
105
+ /** After a publish, an unpublish or a slug change only the address indexes are stale: re-read the URLs
106
+ * ("View live"), the redirects and the live findings — never the fields, which may be ahead of the store
107
+ * (as the page editor's reload after a rename, 2026-10-02). */
108
+ const reloadIndexes = async () => {
109
+ const r = await ops.entries.get(collection, id, locale).catch(() => null);
110
+ if (!r?.ok)
111
+ return;
112
+ setUrls(r.data.urls);
113
+ setRedirects(r.data.redirects);
114
+ setLiveFindings(r.data.liveFindings);
115
+ };
105
116
  const { saveState, mark, flush, reset } = useDraftAutosave({
106
117
  read: () => fieldsRef.current,
107
118
  persist: async (value) => {
@@ -140,11 +151,14 @@ export function EntryForm({ ops, registry, collection, id }) {
140
151
  }
141
152
  const r = await ops.entries.save(collection, id, locale, value, token(), { redirect: redirectRef.current });
142
153
  if (r.ok) {
154
+ const slugBefore = recordRef.current?.locales[locale]?.slug;
143
155
  redirectRef.current = true;
144
156
  recordRef.current = r.data;
145
157
  setRecord(r.data);
146
158
  setSaveErrors({});
147
159
  presence?.announce(target, r.data.locales[locale]?.draftSavedAt ?? null);
160
+ if (r.data.locales[locale]?.slug !== slugBefore)
161
+ void reloadIndexes();
148
162
  }
149
163
  else if (isStale(r))
150
164
  setStale(staleInfoOf(r));
@@ -271,6 +285,7 @@ export function EntryForm({ ops, registry, collection, id }) {
271
285
  setRecord(r.data);
272
286
  presence?.announce(target, r.data.locales[locale]?.draftSavedAt ?? null);
273
287
  setFieldErrors({});
288
+ void reloadIndexes();
274
289
  setPublished(true);
275
290
  setTimeout(() => setPublished(false), 1600);
276
291
  };
@@ -285,8 +300,10 @@ export function EntryForm({ ops, registry, collection, id }) {
285
300
  r = { ok: false, code: "internal", message: "Couldn't reach the server." };
286
301
  }
287
302
  setBusy(null);
288
- if (r.ok)
303
+ if (r.ok) {
289
304
  setRecord(r.data);
305
+ void reloadIndexes();
306
+ }
290
307
  else
291
308
  fail(r);
292
309
  };
@@ -474,7 +491,7 @@ export function EntryForm({ ops, registry, collection, id }) {
474
491
  trailing: multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: present, parentLocales: parentLocales, value: locale, adding: adding, onSwitch: (l) => { void switchLocale(l); }, onAdd: (l) => void doAddLocale(l) })),
475
492
  },
476
493
  { label: heading },
477
- ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error" || saveState === "refused") && _jsx(SaveIndicator, { state: saveState, pending: dirty }), urls[locale] && (_jsx(LinkButton, { icon: true, href: urls[locale], target: "_blank", rel: "noreferrer", "aria-label": "View live", title: "View live \u2014 opens the site route. While the admin's draft-mode cookie is on, this shows the DRAFT \u2014 open it in a private window to see what visitors see.", children: _jsx(Eye, { size: 16, strokeWidth: 1.5, "aria-hidden": "true" }) })), _jsx(PublishControl, { status: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsx("fieldset", { disabled: true, className: "sm-history__form", children: Object.entries(meta.fields).map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: snapshot.fields[key], refOptions: refTargets[key] ? refOptions[refTargets[key]] : undefined, prefix: d.type === "slug" ? slugPrefix : undefined, onChange: () => { } }, key))) })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsx(PresenceLine, { names: editorsOf(others, target), noun: "entry" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsx(DriftNotice, { draft: findings, live: liveFindings }), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
494
+ ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error" || saveState === "refused") && _jsx(SaveIndicator, { state: saveState, pending: dirty }), urls[locale] && (_jsx(LinkButton, { icon: true, href: urls[locale], target: "_blank", rel: "noreferrer", "aria-label": "View live", title: "View live \u2014 opens the site route. While the admin's draft-mode cookie is on, this shows the DRAFT \u2014 open it in a private window to see what visitors see.", children: _jsx(Eye, { size: 16, strokeWidth: 1.5, "aria-hidden": "true" }) })), _jsx(PublishControl, { status: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsx("fieldset", { disabled: true, className: "sm-history__form", children: Object.entries(meta.fields).map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: snapshot.fields[key], refOptions: refTargets[key] ? refOptions[refTargets[key]] : undefined, prefix: d.type === "slug" ? slugPrefix : undefined, onChange: () => { } }, key))) })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsx(PresenceLine, { names: editorsOf(others, target), noun: "entry" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsx(DriftNotice, { draft: findings, live: liveFindings }), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), revealKey: `${notice.tone}:${notice.text}`, children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
478
495
  revealField(formRef.current, path); } })] })), absent ? (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12 }, children: [_jsxs("p", { children: ["This entry doesn't exist in ", locale, " yet. Adding it copies the ", entrySourceLocale(record, registry), " fields as the first ", locale, " draft."] }), _jsxs("div", { style: { display: "flex", gap: 8 }, children: [_jsxs(Button, { onClick: () => void doAddLocale(locale), disabled: adding || !parentLocales.includes(locale), title: parentLocales.includes(locale) ? undefined : `add ${locale} to the parent first`, children: ["Add ", locale] }), _jsxs(Button, { variant: "secondary", onClick: () => { void switchLocale(entrySourceLocale(record, registry)); }, children: ["Back to ", entrySourceLocale(record, registry)] })] })] })) : fields !== null ? (_jsxs(_Fragment, { children: [Object.entries(meta.fields).map(widget), _jsx(OrphanFields, { orphans: findings, onClear: (path) => {
479
496
  setFields((f) => unsetAtPath(f ?? {}, path));
480
497
  setDirty(true);
@@ -319,6 +319,6 @@ export function GlobalForm({ ops, registry, name }) {
319
319
  trailing: multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: registry.locales.supported, parentLocales: [], value: locale, adding: false, onSwitch: (l) => { void switchLocale(l); }, onAdd: () => undefined })),
320
320
  },
321
321
  { label: item.title },
322
- ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error" || saveState === "refused") && _jsx(SaveIndicator, { state: saveState, pending: dirty }), _jsx(PublishControl, { status: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsxs("fieldset", { disabled: true, className: "sm-history__form", children: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } }), settingGroups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Settings" }), _jsx(GlobalFields, { item: item, groups: settingGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } })] }))] })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsx(PresenceLine, { names: editorsOf(others, target), noun: "item" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsx(DriftNotice, { draft: findings, live: liveFindings }), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
322
+ ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error" || saveState === "refused") && _jsx(SaveIndicator, { state: saveState, pending: dirty }), _jsx(PublishControl, { status: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsxs("fieldset", { disabled: true, className: "sm-history__form", children: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } }), settingGroups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Settings" }), _jsx(GlobalFields, { item: item, groups: settingGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } })] }))] })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsx(PresenceLine, { names: editorsOf(others, target), noun: "item" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsx(DriftNotice, { draft: findings, live: liveFindings }), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), revealKey: `${notice.tone}:${notice.text}`, children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
323
323
  revealField(formRef.current, path); } })] })), record && (_jsxs("p", { className: "sm-hint", children: [pages.size === 0 ? "Not placed on any page yet." : `Used on ${pages.size} page${pages.size === 1 ? "" : "s"}: `, [...pages].map((id, i) => (_jsxs("span", { children: [i > 0 && ", ", _jsx("a", { href: adminHref(`/pages/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`), children: id })] }, id)))] })), fields !== null ? (_jsxs(_Fragment, { children: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: fields, errors: markerErrors, refOptions: refOptions, multilingual: multilingual, onChange: onChange }), settingGroups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Settings" }), _jsx(GlobalFields, { item: item, groups: settingGroups, fields: fields, errors: markerErrors, refOptions: refOptions, multilingual: multilingual, onChange: onChange })] })), _jsx(OrphanFields, { orphans: findings, onClear: clearOrphan })] })) : !notice && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsx(ResizeHandle, {}), _jsx("aside", { className: "sm-inspector", children: record && row && (_jsxs("div", { className: "sm-card", children: [_jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Kind", value: item.section !== undefined ? `Visual · ${item.section}` : "Fields" }), _jsx(InfoRow, { label: "Updated", value: updatedLine(row.updatedAt, row.updatedBy) }), _jsx(InfoRow, { label: "ID", value: record.id, mono: true })] })) })] })] }));
324
324
  }
@@ -142,10 +142,14 @@ export declare function FieldErrorList({ fields, onReveal }: {
142
142
  fields: FieldError[];
143
143
  onReveal?(path: string): void;
144
144
  }): import("react").JSX.Element | null;
145
- export declare function Banner({ tone, children, onDismiss }: {
145
+ export declare function Banner({ tone, children, onDismiss, revealKey }: {
146
146
  tone: "ok" | "error" | "warn";
147
147
  children: ReactNode;
148
148
  onDismiss?: () => void;
149
+ /** Scrolls the banner into view when it appears and whenever this changes: a refused publish
150
+ * clicked at the foot of a long form shows its list of fields (2026-10-02). Unchanged while the
151
+ * list shrinks as fields are fixed, so typing never scrolls. */
152
+ revealKey?: string;
149
153
  }): import("react").JSX.Element;
150
154
  export declare function cx(...parts: (string | false | null | undefined)[]): string;
151
155
  /** One line of a form's info block: a muted label, the value right-aligned and clipped. */
@@ -4,7 +4,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
4
  // components/ set (design_handoff_smoodly_cms). Structure and dynamic
5
5
  // values live here; every static rule, and all hover/focus state, lives in
6
6
  // theme.tsx's stylesheet.
7
- import { Fragment } from "react";
7
+ import { Fragment, useEffect, useRef } from "react";
8
8
  import { GlobeOff, GripVertical, Moon, Sun } from "lucide-react";
9
9
  import { THEME_KEY } from "./theme.js";
10
10
  import { RouteLink } from "../shell/RouteLink.js";
@@ -153,8 +153,13 @@ export function FieldErrorList({ fields, onReveal }) {
153
153
  ? _jsx("button", { type: "button", className: "sm-fielderr__field", onClick: () => onReveal(f.field), children: f.field })
154
154
  : f.field, ": ", f.message] }, f.field))) }));
155
155
  }
156
- export function Banner({ tone, children, onDismiss }) {
157
- return (_jsxs("div", { className: `sm-banner sm-banner--${tone}`, role: tone === "error" ? "alert" : undefined, children: [_jsx("div", { style: { flex: 1, minWidth: 0 }, children: children }), onDismiss && (_jsx("button", { type: "button", onClick: onDismiss, "aria-label": "Dismiss", style: { background: "transparent", border: 0, color: "inherit", cursor: "pointer", font: "inherit", padding: 0 }, children: "\u00D7" }))] }));
156
+ export function Banner({ tone, children, onDismiss, revealKey }) {
157
+ const ref = useRef(null);
158
+ useEffect(() => {
159
+ if (revealKey !== undefined)
160
+ ref.current?.scrollIntoView({ block: "nearest", behavior: "smooth" });
161
+ }, [revealKey]);
162
+ return (_jsxs("div", { ref: ref, className: `sm-banner sm-banner--${tone}`, role: tone === "error" ? "alert" : undefined, children: [_jsx("div", { style: { flex: 1, minWidth: 0 }, children: children }), onDismiss && (_jsx("button", { type: "button", onClick: onDismiss, "aria-label": "Dismiss", style: { background: "transparent", border: 0, color: "inherit", cursor: "pointer", font: "inherit", padding: 0 }, children: "\u00D7" }))] }));
158
163
  }
159
164
  export function cx(...parts) {
160
165
  return parts.filter(Boolean).join(" ");
@@ -2,7 +2,7 @@ import { type MigrateArgs } from "./migrate.ts";
2
2
  import { type ExportArgs } from "./export.ts";
3
3
  import { type ImportArgs } from "./import.ts";
4
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
+ 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, or when\n SMOODLY_SECRET_KEY (if set) cannot read the database\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).";
6
6
  export type Parsed = {
7
7
  command: "migrate";
8
8
  args: MigrateArgs;
package/dist/cli/main.js CHANGED
@@ -16,7 +16,8 @@ export const USAGE = `usage: smoodly <command>
16
16
  lacks, and writes one views file for the collections whose view is new or
17
17
  changed. Prints the push or reset command; never runs it.
18
18
 
19
- --check write nothing; exit 1 when anything is pending
19
+ --check write nothing; exit 1 when anything is pending, or when
20
+ SMOODLY_SECRET_KEY (if set) cannot read the database
20
21
  --collections <file> the module exporting \`collections\` (default smoodly/collections.ts)
21
22
  --migrations <dir> the app's migration folder (default supabase/migrations)
22
23
 
@@ -1,4 +1,5 @@
1
1
  import type { CollectionSchema } from "../collections.ts";
2
+ import { type SchemaRead } from "../schema-read.ts";
2
3
  import { type MigrationFile, type MigratePlan } from "../migrations.ts";
3
4
  export type MigrateArgs = {
4
5
  check: boolean;
@@ -10,6 +11,8 @@ export declare const PACKAGE_MIGRATIONS_DIR: string;
10
11
  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
12
  export declare const NEXT_STEP = "Next: apply it \u2014 `supabase db push` (hosted) or `supabase migration up` (local).";
12
13
  export declare const upToDate: (version: number, n: number) => string;
14
+ export declare const KEY_NOT_CHECKED = "Key not checked: SMOODLY_URL and SMOODLY_SECRET_KEY are not both set here.";
15
+ export declare const keyChecked: (version: number) => string;
13
16
  export declare const modifiedMessage: (name: string) => string;
14
17
  export declare function readSqlFiles(dir: string): Promise<MigrationFile[]>;
15
18
  /** One line per thing the plan does; "Pending" wording under --check. */
@@ -19,5 +22,9 @@ export type MigrateDeps = {
19
22
  /** Test seam: replaces the tsx loader. */
20
23
  load?: (file: string, cwd: string) => Promise<CollectionSchema<any>[]>;
21
24
  now?: () => Date;
25
+ /** The env the key check reads; `.env.local` and `.env` fill what it lacks, as Next does. */
26
+ env?: Record<string, string | undefined>;
27
+ /** Test seam: replaces the database read behind the key check. */
28
+ readKey?: (env: Record<string, string | undefined>) => Promise<SchemaRead>;
22
29
  };
23
30
  export declare function runMigrate(args: MigrateArgs, out: (line: string) => void, deps?: MigrateDeps): Promise<number>;
@@ -8,7 +8,11 @@ import { readdir, readFile, writeFile } from "node:fs/promises";
8
8
  import { join, resolve } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import { collectionSQL } from "../sql.js";
11
- import { coreFileVersion, SCHEMA_VERSION } from "../schema-version.js";
11
+ import { coreFileVersion, SCHEMA_VERSION, secretKeyRefusedMessage } from "../schema-version.js";
12
+ import { readSchemaVersion } from "../schema-read.js";
13
+ import { createSmoodlyClient } from "../client.js";
14
+ import { resolveSmoodlyEnv } from "../env.js";
15
+ import { loadAppEnv } from "./space-io.js";
12
16
  import { planIsEmpty, planMigrate } from "../migrations.js";
13
17
  import { loadCollections } from "./load-collections.js";
14
18
  /** The folder shipped in the package: `../../migrations/` from dist/cli/ and from src/cli/ alike. */
@@ -16,6 +20,8 @@ export const PACKAGE_MIGRATIONS_DIR = fileURLToPath(new URL("../../migrations/",
16
20
  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
21
  export const NEXT_STEP = "Next: apply it — `supabase db push` (hosted) or `supabase migration up` (local).";
18
22
  export const upToDate = (version, n) => `Up to date: core schema v${version}, every collection's view is current (${n} ${n === 1 ? "collection" : "collections"}).`;
23
+ export const KEY_NOT_CHECKED = "Key not checked: SMOODLY_URL and SMOODLY_SECRET_KEY are not both set here.";
24
+ export const keyChecked = (version) => `SMOODLY_SECRET_KEY reads the database (schema v${version}).`;
19
25
  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
26
  export async function readSqlFiles(dir) {
21
27
  const names = (await readdir(dir)).filter((n) => n.endsWith(".sql")).sort();
@@ -34,6 +40,41 @@ export function describePlan(plan, check) {
34
40
  }
35
41
  return lines;
36
42
  }
43
+ /** Errors that are a verdict on the env itself, not on the network: they fail the build. */
44
+ const ENV_VERDICT = /SMOODLY_(SECRET|PUBLISHABLE)_KEY|SMOODLY_URL "/;
45
+ /** The build's key check (decided 2026-10-02): a key the site would be refused with fails the
46
+ * deploy, so the running deployment keeps serving. No key here, or no route to the database
47
+ * (a CI build), passes without a verdict. */
48
+ async function checkKey(out, cwd, deps) {
49
+ const env = deps.env ?? process.env;
50
+ loadAppEnv(cwd, env);
51
+ if (!env.SMOODLY_URL || !env.SMOODLY_SECRET_KEY) {
52
+ out(KEY_NOT_CHECKED);
53
+ return 0;
54
+ }
55
+ let read;
56
+ try {
57
+ read = await (deps.readKey ?? ((e) => readSchemaVersion(createSmoodlyClient(resolveSmoodlyEnv(e)))))(env);
58
+ }
59
+ catch (e) {
60
+ const message = e instanceof Error ? e.message : String(e);
61
+ if (ENV_VERDICT.test(message)) {
62
+ out(message);
63
+ return 1;
64
+ }
65
+ out(`Key not checked: ${message}`);
66
+ return 0;
67
+ }
68
+ if ("version" in read)
69
+ out(keyChecked(read.version));
70
+ else if (read.missing === "key") {
71
+ out(secretKeyRefusedMessage);
72
+ return 1;
73
+ }
74
+ else
75
+ out("Key not checked: the database has no Smoodly schema yet — apply the migrations before the site goes live.");
76
+ return 0;
77
+ }
37
78
  export async function runMigrate(args, out, deps = {}) {
38
79
  const cwd = deps.cwd ?? process.cwd();
39
80
  const appDir = resolve(cwd, args.migrations);
@@ -58,7 +99,7 @@ export async function runMigrate(args, out, deps = {}) {
58
99
  return 1;
59
100
  if (planIsEmpty(plan)) {
60
101
  out(upToDate(SCHEMA_VERSION, collections.length));
61
- return 0;
102
+ return args.check ? checkKey(out, cwd, deps) : 0;
62
103
  }
63
104
  if (args.check)
64
105
  return 1;
@@ -1,7 +1,23 @@
1
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<{
2
+ export type SchemaRead = {
4
3
  version: number;
5
4
  } | {
6
5
  missing: "key" | "schema";
7
- }>;
6
+ };
7
+ /** The policies show `smoodly_meta` to a write key only, so no visible row means the key is not a secret key (spec §5). */
8
+ export declare function readSchemaVersion(db: SupabaseClient): Promise<SchemaRead>;
9
+ /** How long a refused key is believed before it is read again. */
10
+ export declare const KEY_RECHECK_MS = 30000;
11
+ /** The site's schema check, once per process (spec 2026-09-26 §5). An old or missing schema warns
12
+ * and never blocks. A key that cannot see the version row reads every table as empty rather than
13
+ * refused, so lookups fail instead of serving empty pages and 404s (decided 2026-10-02) — but only
14
+ * for {@link KEY_RECHECK_MS}: a fixed key or a briefly missing row recovers without a restart. A
15
+ * failed read refuses nothing and is tried again; the lookup itself names that error. */
16
+ export declare class SecretKeyCheck {
17
+ private readonly read;
18
+ private readonly now;
19
+ private pending;
20
+ private refusedAt;
21
+ constructor(read: () => Promise<SchemaRead>, now?: () => number);
22
+ refused(): Promise<boolean>;
23
+ }
@@ -1,4 +1,4 @@
1
- import { SCHEMA_VERSION_KEY } from "./schema-version.js";
1
+ import { SCHEMA_VERSION_KEY, schemaBehind, schemaBehindMessage } from "./schema-version.js";
2
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
3
  export async function readSchemaVersion(db) {
4
4
  const res = await db.from("smoodly_meta").select("value").eq("key", SCHEMA_VERSION_KEY).maybeSingle();
@@ -13,3 +13,38 @@ export async function readSchemaVersion(db) {
13
13
  const n = Number(res.data?.value);
14
14
  return res.data && Number.isInteger(n) ? { version: n } : { missing: "key" };
15
15
  }
16
+ /** How long a refused key is believed before it is read again. */
17
+ export const KEY_RECHECK_MS = 30000;
18
+ /** The site's schema check, once per process (spec 2026-09-26 §5). An old or missing schema warns
19
+ * and never blocks. A key that cannot see the version row reads every table as empty rather than
20
+ * refused, so lookups fail instead of serving empty pages and 404s (decided 2026-10-02) — but only
21
+ * for {@link KEY_RECHECK_MS}: a fixed key or a briefly missing row recovers without a restart. A
22
+ * failed read refuses nothing and is tried again; the lookup itself names that error. */
23
+ export class SecretKeyCheck {
24
+ constructor(read, now = Date.now) {
25
+ this.read = read;
26
+ this.now = now;
27
+ this.pending = null;
28
+ this.refusedAt = null;
29
+ }
30
+ refused() {
31
+ if (this.refusedAt !== null && this.now() - this.refusedAt >= KEY_RECHECK_MS)
32
+ this.pending = null;
33
+ this.pending ?? (this.pending = this.read().then((read) => {
34
+ if ("missing" in read && read.missing === "key") {
35
+ this.refusedAt = this.now();
36
+ return true;
37
+ }
38
+ this.refusedAt = null;
39
+ if ("missing" in read)
40
+ console.warn(schemaBehindMessage(null));
41
+ else if (schemaBehind(read.version))
42
+ console.warn(schemaBehindMessage(read.version));
43
+ return false;
44
+ }, () => {
45
+ this.pending = null;
46
+ return false;
47
+ }));
48
+ return this.pending;
49
+ }
50
+ }
@@ -62,8 +62,8 @@ export declare class SupabasePageStore implements PageStore {
62
62
  getPage(id: string): Promise<PageRecord | null>;
63
63
  scanTrees(): Promise<PageScan[]>;
64
64
  listPages(): Promise<PageRecord[]>;
65
- /** The site's one warning per process (spec 2026-09-26 §5): fired by the first locate, never blocking. */
66
- private schemaChecked;
65
+ /** Once per store; a refused key fails lookups for 30 s at a time (schema-read.ts). */
66
+ private readonly keyCheck;
67
67
  resolvePath(locale: string, path: string): Promise<import("./path-index.ts").PathHit | null>;
68
68
  schemaVersion(): Promise<number | null>;
69
69
  pathsOf(id: string): Promise<{}>;
@@ -8,8 +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 { schemaBehind, schemaBehindMessage, secretKeyRefusedMessage } from "./schema-version.js";
12
- import { readSchemaVersion } from "./schema-read.js";
11
+ import { secretKeyRefusedMessage } from "./schema-version.js";
12
+ import { readSchemaVersion, SecretKeyCheck } from "./schema-read.js";
13
13
  import { groupRedirects } from "./store.js";
14
14
  import { assertFresh, draftToken, StaleDraftError } from "./draft-token.js";
15
15
  import { assertDepth, assertSegment, isHomeNode, pagePathRows, rewriteTargets } from "./paths.js";
@@ -46,8 +46,8 @@ export class SupabasePageStore {
46
46
  constructor(db, options = {}) {
47
47
  this.db = db;
48
48
  this.options = options;
49
- /** The site's one warning per process (spec 2026-09-26 §5): fired by the first locate, never blocking. */
50
- this.schemaChecked = false;
49
+ /** Once per store; a refused key fails lookups for 30 s at a time (schema-read.ts). */
50
+ this.keyCheck = new SecretKeyCheck(() => readSchemaVersion(this.db));
51
51
  this.options = { ...options, sections: refSections(options) };
52
52
  this.opts = pathOptions(options);
53
53
  // Alone, the store's own index reads the addresses it was given (spec 2026-09-29 anchored paths §2).
@@ -379,20 +379,12 @@ export class SupabasePageStore {
379
379
  this.fail(res.error.message);
380
380
  return res.data.map(toRecord);
381
381
  }
382
- resolvePath(locale, path) {
383
- if (!this.schemaChecked) {
384
- this.schemaChecked = true;
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));
392
- })
393
- .catch(() => { });
394
- }
395
- return this.paths.resolve(locale, path);
382
+ async resolvePath(locale, path) {
383
+ // In parallel: the check costs the first request no extra round trip.
384
+ const [refused, hit] = await Promise.all([this.keyCheck.refused(), this.paths.resolve(locale, path)]);
385
+ if (refused)
386
+ throw new Error(secretKeyRefusedMessage);
387
+ return hit;
396
388
  }
397
389
  async schemaVersion() {
398
390
  const read = await readSchemaVersion(this.db);
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const SMOODLY_VERSION = "0.0.42";
1
+ export declare const SMOODLY_VERSION = "0.0.43";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Generated by scripts/write-version.mjs from package.json — do not edit.
2
- export const SMOODLY_VERSION = "0.0.42";
2
+ export const SMOODLY_VERSION = "0.0.43";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smoodly",
3
- "version": "0.0.42",
3
+ "version": "0.0.43",
4
4
  "description": "Open-source, code-first CMS for Next.js on Supabase: define a component once, it becomes a typed, editable building block.",
5
5
  "license": "MIT",
6
6
  "repository": {