smoodly 0.0.49 → 0.0.51

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.
@@ -5,7 +5,7 @@ import { canonicalTags, distinctTags } from "../tags.js";
5
5
  import { can, isMemberRole, normalizeRole } from "../permissions.js";
6
6
  import { ASSET_COLLECTION } from "../media.js";
7
7
  import { adminHref, adminSlugTaken, isAdminSlug } from "../admin-path.js";
8
- import { affectedTargets, collectionTag, entryTag, pageTag, pagesTreeTag, globalTag } from "../revalidate.js";
8
+ import { addressesTag, affectedTargets, collectionTag, entryTag, pageTag, pagesTreeTag, globalTag } from "../revalidate.js";
9
9
  import { ensureFixedNodes, fixedNodeOf, fixedNodeSpecs } from "./fixed-nodes.js";
10
10
  import { ensureGlobalItems } from "./global-items.js";
11
11
  import { SLUG_GRAMMAR_MESSAGE, validateCustomNode, validateFields, validateNode } from "./validate.js";
@@ -268,10 +268,21 @@ export function createAdminOps(deps) {
268
268
  for (const id of unique)
269
269
  effects.revalidate(pageTag(id));
270
270
  };
271
- /** The tree listing's unit (spec 2026-09-11 §2): every page op that changes what it shows. */
272
- const expireTree = () => effects?.revalidate?.(pagesTreeTag());
273
- /** A collection's list units: every entry write, since a non-versioned collection's published read is its live row. */
274
- const expireCollection = (name) => effects?.revalidate?.(collectionTag(name));
271
+ /** The tree listing's unit (spec 2026-09-11 §2): every page op that changes what it shows. The same ops
272
+ * change which addresses resolve, so the addresses tag fires with it (plan 2026-10-09 next cached routes). */
273
+ const expireTree = () => {
274
+ effects?.revalidate?.(pagesTreeTag());
275
+ effects?.revalidate?.(addressesTag());
276
+ };
277
+ /** A collection's list units: every entry write, since a non-versioned collection's published read is its live
278
+ * row. An entry write can give an address a document or take it away, so the addresses tag fires with it —
279
+ * except on a save that changed no slug (`addresses: false`): an autosave of fields moves no address, and a
280
+ * tag that fired per keystroke would re-render every cached miss and code-owned page for nothing. */
281
+ const expireCollection = (name, options = {}) => {
282
+ effects?.revalidate?.(collectionTag(name));
283
+ if (options.addresses !== false)
284
+ effects?.revalidate?.(addressesTag());
285
+ };
275
286
  /** A node and every descendant, node first, in listPages() order. */
276
287
  const subtreeIds = async (id) => {
277
288
  const all = await pages.listPages();
@@ -900,7 +911,7 @@ export function createAdminOps(deps) {
900
911
  if (below.length > 0)
901
912
  await fanOut(below);
902
913
  }
903
- expireCollection(collection);
914
+ expireCollection(collection, { addresses: slugChanged }); // the slug is the address; nothing else in a save moves one
904
915
  return ok(entry);
905
916
  }),
906
917
  setStatus: (collection, id, locale, status, expected) => guard(async () => {
@@ -1300,6 +1311,7 @@ export function createAdminOps(deps) {
1300
1311
  return err("validation", `smoodly: "${path}" is the address of "${owner}".`);
1301
1312
  }
1302
1313
  await pages.addRedirect({ kind: target.kind, id: target.id, locale, path });
1314
+ effects?.revalidate?.(addressesTag()); // a cached 404 at the old address must become the 308
1303
1315
  return ok(null);
1304
1316
  }),
1305
1317
  remove: (locale, path) => guard(async () => {
@@ -1307,6 +1319,7 @@ export function createAdminOps(deps) {
1307
1319
  if (unsupported)
1308
1320
  return unsupported;
1309
1321
  await pages.removeRedirect(locale, path); // the stored address, as list gave it
1322
+ effects?.revalidate?.(addressesTag()); // a cached 308 must become the 404
1310
1323
  return ok(null);
1311
1324
  }),
1312
1325
  },
@@ -6,4 +6,5 @@ export type { SmoodlyQueries, QueryOptions, EntriesQuery, TypedEntryItem, Global
6
6
  export { previewRoute } from "./preview-route.ts";
7
7
  export { previewPill } from "./preview-pill.tsx";
8
8
  export { createAdminRoute } from "./admin-route.ts";
9
+ export { buildGate } from "./reads.ts";
9
10
  export type { AdminRouteDeps } from "./admin-route.ts";
@@ -4,3 +4,4 @@ export { createSmoodlyQueries } from "./queries.js";
4
4
  export { previewRoute } from "./preview-route.js";
5
5
  export { previewPill } from "./preview-pill.js";
6
6
  export { createAdminRoute } from "./admin-route.js";
7
+ export { buildGate } from "./reads.js";
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- // The site-route seam (spec 2026-09-05 §5): the two route files call
3
- // renderSmoodlyPath; everything else — request gating, draft mode, 404s,
2
+ // The site-route seam (spec 2026-09-05 §5): the starters' one route file calls
3
+ // renderSmoodlyPath; everything else — the build-only gate, draft mode, 404s,
4
4
  // the editor bridge (through the lazy canvas stub) — lives here, over the
5
5
  // framework-free site layer. The reads are the read layer's units, keyed
6
6
  // by (locale, path, target) and tagged by id (../reads.ts), run by Next's
@@ -8,19 +8,18 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
8
8
  import { Suspense } from "react";
9
9
  import { draftMode } from "next/headers";
10
10
  import { notFound, permanentRedirect, unstable_rethrow } from "next/navigation";
11
- import { connection } from "next/server";
12
11
  import { BRIDGE_CSS } from "../marks.js";
13
12
  import { CanvasBridgeSlot, CanvasNetSlot } from "./canvas-slot.js";
14
13
  import { DRAFT_META_NAME } from "../admin/editor/canvas-diagnosis.js";
15
14
  import { fixedPathFor } from "../localize.js";
16
15
  import { decodeSegment, joinPath } from "../paths.js";
17
16
  import { renderPage } from "../render.js";
18
- import { globalUnit, hitUnit } from "../reads.js";
17
+ import { globalUnit, hitUnit, missUnit } from "../reads.js";
19
18
  import { toEntryItem } from "../read-shapes.js";
20
19
  import { pageContextOf } from "../site.js";
21
20
  import { metadataFromSeo } from "./meta.js";
22
21
  import { previewPill } from "./preview-pill.js";
23
- import { nextReads } from "./reads.js";
22
+ import { buildGate, nextReads } from "./reads.js";
24
23
  /** Logged once per renderer when Next's cacheComponents is on (spec 2026-09-27 cache components §2). */
25
24
  export const CACHE_COMPONENTS_WARNING = "smoodly: Next's cacheComponents is on, and Smoodly does not support it — a missing page answers 200 with noindex instead of 404, and a redirect answers 200 instead of 308. Turn cacheComponents off in next.config.ts; the Next.js guide's \"Cache Components\" section says why (https://smoodly.io/docs/frameworks/nextjs).";
26
25
  /** The canvas net (spec 2026-09-27 section guard §5): Server Components hand a section that failed to the
@@ -96,34 +95,47 @@ export function createPageRenderer(options) {
96
95
  console.warn(CACHE_COMPONENTS_WARNING);
97
96
  }
98
97
  async function page(pathFor, opts, accept) {
99
- warnCacheComponents(); // before connection(), so a build's prerender prints it too
100
- // A CMS route is request-rendered by construction: without this a
101
- // route with no dynamic segment would be prerendered against the
102
- // database at build time (DESIGN.md §3).
103
- await connection();
98
+ warnCacheComponents(); // before buildGate(), so a build's prerender prints it too
99
+ // A CMS route renders on its first visit and is cached until a tag expires it (plan 2026-10-09 next
100
+ // cached routes): nothing here may touch a dynamic API at run time. At build, the gate keeps a route
101
+ // without a dynamic segment off the database (DESIGN.md §3).
102
+ await buildGate();
104
103
  const locale = opts?.locale ?? site.locales.default;
105
104
  const path = pathFor(locale);
106
105
  const { isEnabled: draft } = await draftMode();
107
- const params = await opts?.searchParams;
106
+ // searchParams is a dynamic API: read inside draft mode only, where the render is dynamic anyway.
107
+ const params = draft ? await opts?.searchParams : undefined;
108
108
  // Both parameters exist only inside draft mode: the public site never sees an editor bridge or an old version.
109
109
  const editor = draft && params?.["smoodly-editor"] === "1";
110
110
  const versionParam = params?.["smoodly-version"];
111
111
  const version = draft && typeof versionParam === "string" && versionParam !== "" ? versionParam : undefined;
112
112
  const target = await site.locate(locale, path);
113
- if (target?.redirect)
113
+ // No document to tag: a redirect row, a miss or a code-owned page. Next caches the response like a
114
+ // page, so it hears the addresses tag (reads.ts missUnit, decision 4).
115
+ const tagAddress = () => nextReads.run(missUnit(site));
116
+ if (target?.redirect) {
117
+ await tagAddress();
114
118
  await redirectTo(target, locale, params);
119
+ }
120
+ if (!target)
121
+ await tagAddress();
115
122
  // A code-owned address with no record yet is served from code (spec 2026-09-30 tester findings §2).
116
123
  const hit = target ? await load(target, locale, path, version) : site.codePage(locale, path);
117
- if (!hit || !accept(hit))
124
+ if (!hit || !accept(hit)) {
125
+ if (target)
126
+ await tagAddress();
118
127
  notFound();
128
+ }
119
129
  // The draft marker tells the page editor's canvas diagnosis that Smoodly
120
130
  // rendered this response (spec 2026-09-27 shadowed-page detector §3);
121
131
  // React hoists the meta into the head. Never outside draft mode.
122
132
  return (_jsxs(_Fragment, { children: [draft ? _jsx("meta", { name: DRAFT_META_NAME }) : null, editor ? _jsx("style", { "data-smoodly-bridge": "", children: BRIDGE_CSS }) : null, editor ? _jsx(CanvasBridgeSlot, {}) : null, draft && !editor && !version ? previewPill({ route: options.previewRoute }) : null, render(hit, editor, locale)] }));
123
133
  }
124
134
  async function metadata(pathFor, opts, accept) {
125
- // generateMetadata runs alongside the page render, so it gates too.
126
- await connection();
135
+ // generateMetadata runs alongside the page render, so it gates too. It runs no miss unit: Next renders
136
+ // metadata and page in one work-unit store, so the route entry's tags are the union of both, and no
137
+ // metadata-only render path exists in the App Router (plan 2026-10-09 next cached routes).
138
+ await buildGate();
127
139
  const locale = opts?.locale ?? site.locales.default;
128
140
  const path = pathFor(locale);
129
141
  const target = await site.locate(locale, path);
@@ -145,13 +157,13 @@ export function createPageRenderer(options) {
145
157
  const any = () => true;
146
158
  /** The escape hatch may only render ITS OWN registration's record. */
147
159
  const only = (Page) => (hit) => hit.kind === "page" && hit.template === Page.schema.name;
148
- /** Resolved AFTER connection(), inside page()/metadata(), so a wiring
160
+ /** Resolved AFTER buildGate(), inside page()/metadata(), so a wiring
149
161
  * error surfaces as a rejected promise like every other failure. */
150
162
  const fixedPath = (Page) => (locale) => fixedPathFor(Page.schema, locale, site.locales, site.homePageSlug);
151
163
  const literal = (segments) => () => joinPath(segments.map(decodeSegment));
152
164
  /** A global item for a layout, by schema: the unit `q.getGlobal` runs for the same item and locale. */
153
165
  async function getSmoodlyGlobal(item, opts) {
154
- await connection();
166
+ await buildGate();
155
167
  return nextReads.run(globalUnit(site, item, opts?.locale ?? site.locales.default));
156
168
  }
157
169
  return {
@@ -1,2 +1,7 @@
1
1
  import type { ReadAdapter } from "../reads.ts";
2
+ /** Waits for a request only while `next build` prerenders (`NEXT_PHASE`), so a route file without a
3
+ * dynamic segment, and a layout above it, read nothing at build and need no keys. At run time it does
4
+ * nothing: a cached render that called `connection()` would answer a 500. The app's glue calls it before
5
+ * reading the environment (smoodly/server.ts), the renderer before every read. */
6
+ export declare function buildGate(): Promise<void>;
2
7
  export declare const nextReads: ReadAdapter;
@@ -1,14 +1,25 @@
1
1
  // The read layer's adapter for Next (spec 2026-10-02 astro cloud fit §6): the one place in the Next glue
2
- // that knows where a read is cached. The gate is `connection()`, so a build prerenders nothing against
3
- // the database; inside draft mode a unit reads the live draft and touches no cache; outside it the
4
- // published read is one `unstable_cache` unit under the unit's key and tags, which the admin's
5
- // operations expire (revalidate.ts). Next's cache key is the callback's source text plus the key parts,
6
- // so every unit is cached from the one closure below (a unit's `tagsOf` is ignored: the tags go in before the read): a second call site would split a shared unit.
2
+ // that knows where a read is cached. The gate is `buildGate`: `connection()` while `next build` prerenders,
3
+ // so a route without a dynamic segment stays off the database at build; nothing at run time, because a
4
+ // render Next caches must not touch a dynamic API (it answers a 500, plan 2026-10-09 next cached routes).
5
+ // Inside draft mode a unit reads the live draft and touches no cache; outside it the published read is
6
+ // one `unstable_cache` unit under the unit's key and tags, which the admin's operations expire
7
+ // (revalidate.ts). Next's cache key is the callback's source text plus the key parts, so every unit is
8
+ // cached from the one closure below (a unit's `tagsOf` is ignored: the tags go in before the read): a
9
+ // second call site would split a shared unit.
7
10
  import { unstable_cache } from "next/cache";
8
11
  import { draftMode } from "next/headers";
9
12
  import { connection } from "next/server";
13
+ /** Waits for a request only while `next build` prerenders (`NEXT_PHASE`), so a route file without a
14
+ * dynamic segment, and a layout above it, read nothing at build and need no keys. At run time it does
15
+ * nothing: a cached render that called `connection()` would answer a 500. The app's glue calls it before
16
+ * reading the environment (smoodly/server.ts), the renderer before every read. */
17
+ export async function buildGate() {
18
+ if (process.env.NEXT_PHASE === "phase-production-build")
19
+ await connection();
20
+ }
10
21
  export const nextReads = {
11
- gate: () => connection(),
22
+ gate: () => buildGate(),
12
23
  async run(unit) {
13
24
  const { isEnabled: draft } = await draftMode();
14
25
  if (draft)
package/dist/reads.d.ts CHANGED
@@ -57,6 +57,12 @@ export type SmoodlyQueries<C extends readonly CollectionSchema<any, string>[], S
57
57
  * record, which fires no tag — can never serve the previous occupant (spec 2026-09-29 pre-install fixes
58
58
  * §5). `version` is History's stored version and counts inside draft mode only. */
59
59
  export declare function hitUnit(site: SmoodlySite, target: PathTarget, locale: string, path: string, version?: string): ReadUnit<SiteHit | null>;
60
+ /** An address with no document to tag — a miss, a redirect row or a code-owned page. The unit reads
61
+ * nothing: it exists so a cached response hears the `addresses` tag, which every address-changing
62
+ * operation fires (revalidate.ts). Next runs it (its route cache stores a 404 like a page); Astro never
63
+ * caches such a response and does not call it. One key for every address: the tag reaches the route
64
+ * entry before the lookup, so a per-address key would only write one data-cache row per scanned 404. */
65
+ export declare function missUnit(site: SmoodlySite): ReadUnit<null>;
60
66
  /** Astro's route read of an address: one lookup outside draft mode — the tags come with the document, so no
61
67
  * locate comes first; in draft mode, locate then load as the editing tables need (spec 2026-10-07 delivery
62
68
  * model §3). A redirect row answers as a redirect; a code-owned page answers where no record lives. */
package/dist/reads.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { referencedCollections } from "./read-shapes.js";
2
- import { collectionTag, entryTag, globalTag, pageTag, pagesTreeTag } from "./revalidate.js";
2
+ import { addressesTag, collectionTag, entryTag, globalTag, pageTag, pagesTreeTag } from "./revalidate.js";
3
3
  /** JSON with object keys sorted at every level and `undefined` dropped, so two spellings of one query share a unit. */
4
4
  function stable(value) {
5
5
  if (Array.isArray(value))
@@ -28,6 +28,14 @@ export function hitUnit(site, target, locale, path, version) {
28
28
  read: (draft) => draft ? site.load(target, locale, path, { draft: true, ...(version !== undefined ? { version } : {}) }) : site.load(target, locale, path),
29
29
  };
30
30
  }
31
+ /** An address with no document to tag — a miss, a redirect row or a code-owned page. The unit reads
32
+ * nothing: it exists so a cached response hears the `addresses` tag, which every address-changing
33
+ * operation fires (revalidate.ts). Next runs it (its route cache stores a 404 like a page); Astro never
34
+ * caches such a response and does not call it. One key for every address: the tag reaches the route
35
+ * entry before the lookup, so a per-address key would only write one data-cache row per scanned 404. */
36
+ export function missUnit(site) {
37
+ return { key: ["smoodly-miss", site.cacheKey], tags: [addressesTag()], read: async () => null };
38
+ }
31
39
  /** Astro's route read of an address: one lookup outside draft mode — the tags come with the document, so no
32
40
  * locate comes first; in draft mode, locate then load as the editing tables need (spec 2026-10-07 delivery
33
41
  * model §3). A redirect row answers as a redirect; a code-owned page answers where no record lives. */
@@ -18,6 +18,11 @@ export declare const pagesTreeTag: () => string;
18
18
  /** A collection's list units: fired by every entry write in the
19
19
  * collection, since a non-versioned collection's published read is its live row. */
20
20
  export declare const collectionTag: (name: string) => string;
21
+ /** Every address that may begin or stop resolving: a miss, a redirect and a code-owned page cache
22
+ * under it on Next (reads.ts `missUnit`), since Next caches a 404 like a hit (plan 2026-10-09 next
23
+ * cached routes, decision 4). Fired by every page op that fires the tree tag, every entry write and
24
+ * every redirect change (admin/ops-impl.ts). */
25
+ export declare const addressesTag: () => string;
21
26
  export declare function affectedTargets(stores: {
22
27
  pages: PageStore;
23
28
  entries: EntryStore;
@@ -17,6 +17,11 @@ export const pagesTreeTag = () => "pages:tree";
17
17
  /** A collection's list units: fired by every entry write in the
18
18
  * collection, since a non-versioned collection's published read is its live row. */
19
19
  export const collectionTag = (name) => `entries:${name}`;
20
+ /** Every address that may begin or stop resolving: a miss, a redirect and a code-owned page cache
21
+ * under it on Next (reads.ts `missUnit`), since Next caches a 404 like a hit (plan 2026-10-09 next
22
+ * cached routes, decision 4). Fired by every page op that fires the tree tag, every entry write and
23
+ * every redirect change (admin/ops-impl.ts). */
24
+ export const addressesTag = () => "addresses";
20
25
  export async function affectedTargets(stores, entryId, options = {}) {
21
26
  const maxDepth = options.maxDepth ?? Infinity;
22
27
  const seen = new Set([entryId]);
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const SMOODLY_VERSION = "0.0.49";
1
+ export declare const SMOODLY_VERSION = "0.0.51";
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.49";
2
+ export const SMOODLY_VERSION = "0.0.51";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smoodly",
3
- "version": "0.0.49",
3
+ "version": "0.0.51",
4
4
  "description": "Open-source, code-first CMS for Astro and Next.js on Supabase: define a component once, it becomes a typed, editable building block.",
5
5
  "license": "MIT",
6
6
  "repository": {