smoodly 0.0.49 → 0.0.50

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,18 @@ 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
+ const expireCollection = (name) => {
280
+ effects?.revalidate?.(collectionTag(name));
281
+ effects?.revalidate?.(addressesTag());
282
+ };
275
283
  /** A node and every descendant, node first, in listPages() order. */
276
284
  const subtreeIds = async (id) => {
277
285
  const all = await pages.listPages();
@@ -1300,6 +1308,7 @@ export function createAdminOps(deps) {
1300
1308
  return err("validation", `smoodly: "${path}" is the address of "${owner}".`);
1301
1309
  }
1302
1310
  await pages.addRedirect({ kind: target.kind, id: target.id, locale, path });
1311
+ effects?.revalidate?.(addressesTag()); // a cached 404 at the old address must become the 308
1303
1312
  return ok(null);
1304
1313
  }),
1305
1314
  remove: (locale, path) => guard(async () => {
@@ -1307,6 +1316,7 @@ export function createAdminOps(deps) {
1307
1316
  if (unsupported)
1308
1317
  return unsupported;
1309
1318
  await pages.removeRedirect(locale, path); // the stored address, as list gave it
1319
+ effects?.revalidate?.(addressesTag()); // a cached 308 must become the 404
1310
1320
  return ok(null);
1311
1321
  }),
1312
1322
  },
@@ -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";
@@ -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,26 +95,37 @@ 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, locale, path));
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.
@@ -123,7 +133,7 @@ export function createPageRenderer(options) {
123
133
  }
124
134
  async function metadata(pathFor, opts, accept) {
125
135
  // generateMetadata runs alongside the page render, so it gates too.
126
- await connection();
136
+ await buildGate();
127
137
  const locale = opts?.locale ?? site.locales.default;
128
138
  const path = pathFor(locale);
129
139
  const target = await site.locate(locale, path);
@@ -145,13 +155,13 @@ export function createPageRenderer(options) {
145
155
  const any = () => true;
146
156
  /** The escape hatch may only render ITS OWN registration's record. */
147
157
  const only = (Page) => (hit) => hit.kind === "page" && hit.template === Page.schema.name;
148
- /** Resolved AFTER connection(), inside page()/metadata(), so a wiring
158
+ /** Resolved AFTER buildGate(), inside page()/metadata(), so a wiring
149
159
  * error surfaces as a rejected promise like every other failure. */
150
160
  const fixedPath = (Page) => (locale) => fixedPathFor(Page.schema, locale, site.locales, site.homePageSlug);
151
161
  const literal = (segments) => () => joinPath(segments.map(decodeSegment));
152
162
  /** A global item for a layout, by schema: the unit `q.getGlobal` runs for the same item and locale. */
153
163
  async function getSmoodlyGlobal(item, opts) {
154
- await connection();
164
+ await buildGate();
155
165
  return nextReads.run(globalUnit(site, item, opts?.locale ?? site.locales.default));
156
166
  }
157
167
  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,11 @@ 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. */
64
+ export declare function missUnit(site: SmoodlySite, locale: string, path: string): ReadUnit<null>;
60
65
  /** Astro's route read of an address: one lookup outside draft mode — the tags come with the document, so no
61
66
  * locate comes first; in draft mode, locate then load as the editing tables need (spec 2026-10-07 delivery
62
67
  * 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,13 @@ 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. */
35
+ export function missUnit(site, locale, path) {
36
+ return { key: ["smoodly-miss", site.cacheKey, locale, path], tags: [addressesTag()], read: async () => null };
37
+ }
31
38
  /** Astro's route read of an address: one lookup outside draft mode — the tags come with the document, so no
32
39
  * locate comes first; in draft mode, locate then load as the editing tables need (spec 2026-10-07 delivery
33
40
  * 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.50";
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.50";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smoodly",
3
- "version": "0.0.49",
3
+ "version": "0.0.50",
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": {