From 2d0a2578b09ea35bd135e16c7d473170e55904f0 Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:35:54 +0300 Subject: [PATCH] =?UTF-8?q?feat(core-ts):=20collection=20permalinks,=20ste?= =?UTF-8?q?p=202=20=E2=80=94=20redirects,=20overlap=20checks,=20search?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC-0038 / ADR-0040, step 2: - Changing a collection's template adds a prefix redirect (old → new) in the same atomic batch as the site CAS, for the default locale and each other site locale; RedirectPlan collapses chains and drops a redirect that would point at itself. `OpBatch.anchorOn` anchors the redirect writes on the site's new hash; a failed op guard is a missed CAS. - A changed template must not overlap another collection's prefix, a taxonomy archive, a page at or under it, media, /search or a locale prefix (`path_conflict`, `reserved_path`), guarded on the CAS. - Pages and taxonomy archives can no longer move into a configured collection prefix (checks and guards on the site row). - Search rewrites every entry row when a published snapshot's templates differ from the indexed one. Tests run on sqlite, D1 and Durable Object SQLite; the renderer serves /blog/ and redirects /posts/. Refs #50 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- .changeset/collection-permalinks-step2.md | 6 + docs/rfcs/0038-collection-permalinks.md | 10 + .../astro-renderer/test/permalinks.test.ts | 46 +++ packages/core-ts/src/node-sql.ts | 9 + packages/core-ts/src/permalink-paths.ts | 87 ++++++ packages/core-ts/src/permalinks.ts | 38 +++ packages/core-ts/src/search.ts | 22 +- packages/core-ts/src/store/pages.ts | 17 +- packages/core-ts/src/store/site-permalinks.ts | 146 ++++++++++ packages/core-ts/src/store/sites.ts | 41 ++- packages/core-ts/src/taxonomy.ts | 23 +- packages/core-ts/src/testing-permalinks.ts | 262 ++++++++++++++++++ packages/core-ts/src/testing-search.ts | 14 + packages/core-ts/src/testing.ts | 2 + scripts/size-baseline.json | 2 +- 15 files changed, 692 insertions(+), 33 deletions(-) create mode 100644 .changeset/collection-permalinks-step2.md create mode 100644 packages/astro-renderer/test/permalinks.test.ts create mode 100644 packages/core-ts/src/permalink-paths.ts create mode 100644 packages/core-ts/src/store/site-permalinks.ts create mode 100644 packages/core-ts/src/testing-permalinks.ts diff --git a/.changeset/collection-permalinks-step2.md b/.changeset/collection-permalinks-step2.md new file mode 100644 index 00000000..9a7f60d3 --- /dev/null +++ b/.changeset/collection-permalinks-step2.md @@ -0,0 +1,6 @@ +--- +"@cloudhal/core-ts": patch +"@cloudhal/cli": patch +--- + +Collection permalinks, step 2 (RFC-0038 §3–§4): changing a collection's permalink in a site update adds a prefix redirect from the old URL prefix to the new one, in every locale and in the same batch as the site update. A template may not overlap another collection, a taxonomy archive, a page or a reserved path (`path_conflict`/`reserved_path`), and pages and archives may not be created inside a configured prefix. The search index rewrites entry links on the next publish. diff --git a/docs/rfcs/0038-collection-permalinks.md b/docs/rfcs/0038-collection-permalinks.md index 780603a3..dc8f3d38 100644 --- a/docs/rfcs/0038-collection-permalinks.md +++ b/docs/rfcs/0038-collection-permalinks.md @@ -139,3 +139,13 @@ No new scopes: changing a template is a site update (`site:write`). In Studio a 1. **Per-locale templates:** not in v1; one template for all locales, with locale prefixes as today. They come later as a map (see Later). 2. **`/` as a base:** not in v1; allowed later once page writes also run the collision check. 3. **The collection index page:** not part of this RFC; it stays a page the site owner creates, and `collectionIndex` links to it. An explicit `indexPath` comes later. + +## Implementation notes (step 2) + +- **Redirects in the site's batch.** `SiteEngine.update` (`store/sites.ts`) compares `collectionPermalink(stored, key)` with `collectionPermalink(next, key)` for every key in either site (`changedPermalinks` in `permalinks.ts`). `store/site-permalinks.ts` plans one `RedirectPlan.move(oldPrefix, newPrefix, { match: "prefix", reason: "slug-change" })` per changed collection, as `updateTaxonomy` does for `archive.basePath`, and always moves (taxonomy archives do not check for published entries either). The plan's writes go into an `OpBatch` anchored on the site's new hash (`OpBatch.anchorOn(hashIs(site, hash))`), and its statements, `op_guards` assertions included, join the site CAS's batch. A failed assertion is treated as a missed CAS, so a stale or raced site write raises `ConflictError` and leaves no redirect. +- **Every locale.** Redirects are per locale (RFC-0033) and entries in every locale share the v1 template, so the move is made for the default locale (untagged) and for each other site locale. `updateTaxonomy` moves only the default locale's redirects; that difference is deliberate here, since entries exist in every locale. +- **Chains and loops** are `RedirectPlan`'s: moving again repoints the older redirects onto the new prefix, and moving back to the default drops the redirect that would point at itself. +- **Overlaps.** A changed template is checked against the effective prefixes of the other collections (configured ones, and the default `/` of collections with entries), every taxonomy archive base path (`pathsOverlap`), and, for a configured template, every page at or under its prefix, media URLs, `/search` and locale prefixes (`reserved_path`). A collection moved back to its default prefix keeps today's rule that a page under it wins. The site CAS carries race guards (`noArchiveOverlapping`, `noPageWithin` in `permalink-paths.ts`); a miss at the same rev re-runs the check for the `path_conflict` message. +- **The reverse.** Page writes (`checkPagePath`/`pageGuard` in `store/site-permalinks.ts`) and taxonomy writes (`#checkTaxonomy`/`#archiveGuard`) refuse a path in a configured prefix; their SQL guards read the site row's `collections` (`configuredPrefixFree`). +- **Search.** `planSearch` rewrites every entry row when the live site node's templates differ from the indexed snapshot's, so hrefs follow the move on the next publish. +- **Rollback** restores the template with the site node; redirects stay, as invariant 5 says. diff --git a/packages/astro-renderer/test/permalinks.test.ts b/packages/astro-renderer/test/permalinks.test.ts new file mode 100644 index 00000000..b86176be --- /dev/null +++ b/packages/astro-renderer/test/permalinks.test.ts @@ -0,0 +1,46 @@ +/** RFC-0038 on the public site: an entry answers at its collection's permalink, the old URL redirects. */ +import type { Site } from "@cloudhal/core-contracts"; +import { SiteGraphStore, seedStarterSite } from "@cloudhal/core-ts"; +import { MemoryBlobStore } from "@cloudhal/storage"; +import { BetterSqlite3Driver } from "@cloudhal/storage/sqlite"; +import { describe, expect, it } from "vitest"; +import { resolveRoute } from "../src/routing.ts"; +import { type GraphSource, LiveSource, SnapshotSource } from "../src/source.ts"; + +async function moved(): Promise<{ store: SiteGraphStore; site: Site }> { + const store = new SiteGraphStore({ + sql: await BetterSqlite3Driver.open(":memory:"), + blobs: new MemoryBlobStore(), + }); + await store.init(); + const seeded = await seedStarterSite(store, { name: "Links", slug: "links" }); + const site = await store.updateSite({ + ...seeded, + collections: { posts: { permalink: "/blog/{slug}" } }, + }); + return { store, site }; +} + +describe("RFC-0038 collection permalinks on the public site", () => { + it("serves the entry at /blog/ and redirects /posts/ there (live and snapshot)", async () => { + const { store, site } = await moved(); + const snap = await store.createSnapshot(site.id, { + actor: { kind: "system", id: "sys_test0001" }, + reason: "manual", + }); + const sources: GraphSource[] = [ + new LiveSource(store, site.id), + await SnapshotSource.open(store, snap.id), + ]; + for (const source of sources) { + const served = await resolveRoute(source, "/blog/hello-world"); + expect(served.kind).toBe("entry"); + if (served.kind === "entry") expect(served.entry.slug).toBe("hello-world"); + expect(await resolveRoute(source, "/posts/hello-world", { search: "?ref=x" })).toEqual({ + kind: "redirect", + status: 301, + location: "/blog/hello-world?ref=x", + }); + } + }); +}); diff --git a/packages/core-ts/src/node-sql.ts b/packages/core-ts/src/node-sql.ts index f743f761..c0107486 100644 --- a/packages/core-ts/src/node-sql.ts +++ b/packages/core-ts/src/node-sql.ts @@ -158,6 +158,15 @@ export class OpBatch { return this.#planned; } + /** + * Anchors every write of this batch on `cond` instead of on its own first write: for statements + * that join another node's CAS (the site update, RFC-0038), anchored on that node's new hash. + */ + anchorOn(cond: Cond): this { + this.#anchor ??= cond; + return this; + } + #guard(own?: Cond): Cond | undefined { return and(this.#anchor, own); } diff --git a/packages/core-ts/src/permalink-paths.ts b/packages/core-ts/src/permalink-paths.ts new file mode 100644 index 00000000..899ee124 --- /dev/null +++ b/packages/core-ts/src/permalink-paths.ts @@ -0,0 +1,87 @@ +/** + * Path overlap between collection prefixes (RFC-0038 §4 invariant 1), taxonomy archives and pages + * (RFC-0002 invariant 13): the JS checks that give the message, and the SQL race guards that + * hold them inside a write's batch. Worker-safe. + */ +import type { Cond } from "./node-sql.ts"; +import { collectionPermalink, type PermalinkSite, permalinkPrefix } from "./permalinks.ts"; + +/** `a` equals `b` or one lies under the other. */ +export function pathsOverlap(a: string, b: string): boolean { + return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`); +} + +/** `path` equals `base` or lies under it. */ +export function pathWithin(path: string, base: string): boolean { + return path === base || path.startsWith(`${base}/`); +} + +export interface CollectionPrefix { + readonly collection: string; + /** `/blog` for `/blog/{slug}`. */ + readonly prefix: string; + /** From the collection's own `permalink`, not the default `/`. */ + readonly configured: boolean; +} + +/** + * The prefixes entries answer under: every collection with its own template, and the default + * `/` of each name in `used` (collections that have entries) that has none. + */ +export function collectionPrefixes( + site: PermalinkSite | undefined, + used: Iterable, +): CollectionPrefix[] { + const configured = Object.keys(site?.collections ?? {}).filter( + (c) => site?.collections?.[c]?.permalink, + ); + const out = configured.map((collection) => ({ + collection, + prefix: permalinkPrefix(collectionPermalink(site, collection)), + configured: true, + })); + for (const collection of new Set(used)) + if (!configured.includes(collection)) + out.push({ collection, prefix: `/${collection}`, configured: false }); + return out; +} + +const PERMALINK = "json_extract(c.value, '$.permalink')"; +/** The template minus its 7-character `/{slug}` tail. */ +const PREFIX = `substr(${PERMALINK}, 1, length(${PERMALINK}) - 7)`; + +/** + * Holds while no configured collection prefix in the site row's `collections` contains `path` + * (`within`: a page path) or overlaps it either way (`overlap`: an archive base path). + */ +export function configuredPrefixFree( + siteId: string, + path: string, + mode: "within" | "overlap", +): Cond { + const within = `${PREFIX} = ? OR substr(?, 1, length(${PREFIX}) + 1) = ${PREFIX} || '/'`; + const cond = mode === "within" ? within : `${within} OR substr(${PREFIX}, 1, ?) = ?`; + const extra = mode === "within" ? [] : [path.length + 1, `${path}/`]; + return { + sql: `NOT EXISTS (SELECT 1 FROM nodes s, json_each(s.body, '$.collections') c WHERE s.id = ? AND s.kind = 'site' AND ${PERMALINK} IS NOT NULL AND (${cond}))`, + params: [siteId, path, path, ...extra], + }; +} + +/** Holds while no page of the site sits at `base` or under it. */ +export function noPageWithin(siteId: string, base: string): Cond { + const path = "json_extract(body, '$.path')"; + return { + sql: `NOT EXISTS (SELECT 1 FROM nodes WHERE site_id = ? AND kind = 'page' AND (${path} = ? OR substr(${path}, 1, ?) = ?))`, + params: [siteId, base, base.length + 1, `${base}/`], + }; +} + +/** Holds while no taxonomy archive base path of the site overlaps `base`. */ +export function noArchiveOverlapping(siteId: string, base: string): Cond { + const b = "json_extract(body, '$.archive.basePath')"; + return { + sql: `NOT EXISTS (SELECT 1 FROM nodes WHERE site_id = ? AND kind = 'taxonomy' AND (${b} = ? OR substr(${b}, 1, ?) = ? OR substr(?, 1, length(${b}) + 1) = ${b} || '/'))`, + params: [siteId, base, base.length + 1, `${base}/`, base], + }; +} diff --git a/packages/core-ts/src/permalinks.ts b/packages/core-ts/src/permalinks.ts index ed595d7b..b45af646 100644 --- a/packages/core-ts/src/permalinks.ts +++ b/packages/core-ts/src/permalinks.ts @@ -57,3 +57,41 @@ export function entryAt( if (site?.collections?.[collection]?.permalink) return undefined; return { collection, slug }; } + +/** One collection whose effective template differs between two versions of a site. */ +export interface PermalinkChange { + readonly collection: string; + /** The old fixed prefix (`/posts`). */ + readonly from: string; + /** The new fixed prefix (`/blog`). */ + readonly to: string; + /** The new template is the collection's own (`permalink` set), not the default. */ + readonly configured: boolean; +} + +/** + * The collections whose effective template differs between `before` and `after`, for every key + * in either site's `collections` (RFC-0038 §3). Empty when nothing moved. + */ +export function changedPermalinks( + before: PermalinkSite | undefined, + after: PermalinkSite | undefined, +): PermalinkChange[] { + const keys = new Set([ + ...Object.keys(before?.collections ?? {}), + ...Object.keys(after?.collections ?? {}), + ]); + const out: PermalinkChange[] = []; + for (const collection of [...keys].sort()) { + const from = collectionPermalink(before, collection); + const to = collectionPermalink(after, collection); + if (from === to) continue; + out.push({ + collection, + from: permalinkPrefix(from), + to: permalinkPrefix(to), + configured: Boolean(after?.collections?.[collection]?.permalink), + }); + } + return out; +} diff --git a/packages/core-ts/src/search.ts b/packages/core-ts/src/search.ts index 9ad2f3b1..1c821f18 100644 --- a/packages/core-ts/src/search.ts +++ b/packages/core-ts/src/search.ts @@ -19,6 +19,7 @@ */ import type { ContentEntry, NodeRef, Page, Site, Taxonomy, Term } from "@cloudhal/core-contracts"; import type { SqlDriver, SqlStatement } from "@cloudhal/storage"; +import { changedPermalinks } from "./permalinks.ts"; import { CJK_TABLE, SEARCH_TABLES, searchTable, trigramQuery } from "./search-cjk.ts"; import { ftsQuery, @@ -182,8 +183,14 @@ export async function planSearch(input: { !incremental || target.nodes.some((n) => (n.kind === "taxonomy" || n.kind === "site") && changed(n)) || (base?.nodes ?? []).some((n) => n.kind === "taxonomy" && !live.has(n.id)); + // RFC-0038: a moved collection template changes every entry's href; rewrite all entry rows. + const hrefsMoved = incremental && (await permalinksMoved(target, base, input.read)); const rewrite = target.nodes.filter( - (n) => INDEXED.has(n.kind) && (changed(n) || (n.kind === "term" && archivesChanged)), + (n) => + INDEXED.has(n.kind) && + (changed(n) || + (n.kind === "term" && archivesChanged) || + (n.kind === "contentEntry" && hrefsMoved)), ); const rows: SearchRow[] = []; const siteRef = target.nodes.find((n) => n.kind === "site"); @@ -218,6 +225,19 @@ export async function planSearch(input: { }; } +/** Whether the site node's collection templates differ between `base` and `target`. */ +async function permalinksMoved( + target: Snap, + base: Snap | undefined, + read: (ref: NodeRef) => Promise, +): Promise { + const after = target.nodes.find((n) => n.kind === "site"); + const before = base?.nodes.find((n) => n.kind === "site"); + if (!after || !before || after.hash === before.hash) return false; + const [a, b] = [(await read(after)) as Site, (await read(before)) as Site]; + return changedPermalinks(b, a).length > 0; +} + function pushRow(rows: SearchRow[], row: SearchRow | undefined): void { if (row) rows.push(row); } diff --git a/packages/core-ts/src/store/pages.ts b/packages/core-ts/src/store/pages.ts index 2fa2ef7f..87849fb0 100644 --- a/packages/core-ts/src/store/pages.ts +++ b/packages/core-ts/src/store/pages.ts @@ -9,6 +9,7 @@ import { fillMissingSrc } from "../media/refs.ts"; import { and, casUpdateStatement, OpBatch, runOpBatch } from "../node-sql.ts"; import { type Creatable, uniquenessKey } from "../nodes.ts"; import type { StoreContext } from "./context.ts"; +import { checkPagePath, pageGuard } from "./site-permalinks.ts"; import { checkLocalizedNode, checkTranslationLinks } from "./translations.ts"; import type { NodeWriteOptions } from "./types.ts"; import { checkComponentSections, checkReservedPath } from "./validation.ts"; @@ -52,7 +53,7 @@ export class PageEngine { updatedAt: ts, }; assertValid("site", nextSite); - await this.#ctx.tax.checkPagePath(page); + await checkPagePath(this.#ctx, page, site); const plan = await this.#ctx.tax.planPathChange(site, ts, { locale: page.locale, wasPublished: false, @@ -63,14 +64,14 @@ export class PageEngine { }); const batch = new OpBatch(this.#ctx.newId(ID_PREFIX.operation), site.id, ts); await batch.update("site", site, nextSite); - await batch.insert("page", page, and(this.#ctx.tax.pageGuard(page), refs.guard)); + await batch.insert("page", page, and(pageGuard(this.#ctx, page), refs.guard)); batch.append(...refs.statements(await nodeHash(page))); await plan?.emit(batch); await runOpBatch(this.#ctx.sql, batch, { uniqueKind: "page", uniqueKey: uniquenessKey("page", page), diagnose: async () => { - await this.#ctx.tax.checkPagePath(page); + await checkPagePath(this.#ctx, page, site); await refs.diagnose(); }, }); @@ -92,7 +93,7 @@ export class PageEngine { await this.#ctx.checkGates(draft.siteId, stored.availability, draft.availability); const refs = await this.#ctx.assets.planRefs("page", draft.siteId, draft, stored); const next = refs.node; - await this.#ctx.tax.checkPagePath(next); + await checkPagePath(this.#ctx, next); const site = await this.#ctx.graph.getSite(next.siteId); if (!site) throw new NotFoundError("site", next.siteId); checkLocalizedNode("page", site, next); @@ -114,7 +115,7 @@ export class PageEngine { "page", next, page.rev, - and(this.#ctx.tax.pageGuard(next), refs.guard), + and(pageGuard(this.#ctx, next), refs.guard), ), "page", key, @@ -123,7 +124,7 @@ export class PageEngine { if (r.changes !== 1) { const rev = await this.#ctx.revOf(page.id); if (rev === page.rev) { - await this.#ctx.tax.checkPagePath(next); + await checkPagePath(this.#ctx, next); await refs.diagnose(); } throw new ConflictError("page", page.id, page.rev, rev); @@ -131,14 +132,14 @@ export class PageEngine { return next; } const batch = new OpBatch(this.#ctx.newId(ID_PREFIX.operation), site.id, ts); - await batch.update("page", stored, next, and(this.#ctx.tax.pageGuard(next), refs.guard)); + await batch.update("page", stored, next, and(pageGuard(this.#ctx, next), refs.guard)); batch.append(...extra); await plan.emit(batch); await runOpBatch(this.#ctx.sql, batch, { uniqueKind: "page", uniqueKey: key, diagnose: async () => { - await this.#ctx.tax.checkPagePath(next); + await checkPagePath(this.#ctx, next); await refs.diagnose(); }, }); diff --git a/packages/core-ts/src/store/site-permalinks.ts b/packages/core-ts/src/store/site-permalinks.ts new file mode 100644 index 00000000..9de48580 --- /dev/null +++ b/packages/core-ts/src/store/site-permalinks.ts @@ -0,0 +1,146 @@ +/** + * RFC-0038 §3–§4 on the store side: a site update that changes a collection's permalink leaves a + * prefix redirect behind in the same batch as the site CAS, and a template may not overlap + * another collection, a taxonomy archive, a page or a reserved path. The reverse checks for page + * writes live here too. Worker-safe. + */ +import type { Page, Site } from "@cloudhal/core-contracts"; +import type { SqlStatement } from "@cloudhal/storage"; +import { InvariantError } from "../errors.ts"; +import { ID_PREFIX } from "../ids.ts"; +import { and, type Cond, hashIs, OpBatch } from "../node-sql.ts"; +import { + collectionPrefixes, + configuredPrefixFree, + noArchiveOverlapping, + noPageWithin, + pathsOverlap, + pathWithin, +} from "../permalink-paths.ts"; +import { changedPermalinks, type PermalinkChange } from "../permalinks.ts"; +import { RedirectPlan, redirectLocale } from "../redirects.ts"; +import { SEARCH_PATH } from "../search-common.ts"; +import type { StoreContext } from "./context.ts"; +import { checkReservedPath } from "./validation.ts"; + +/** What a site update with changed permalinks adds to its batch. */ +export interface PermalinkPlan { + readonly changes: readonly PermalinkChange[]; + /** Race guard on the site CAS: the new prefixes are still free when the batch runs. */ + readonly guard: Cond | undefined; + /** Redirect writes plus their `op_guards` assertions, anchored on the site's new hash. */ + statements(hash: string): Promise; + /** After a failed CAS at the same rev: the `path_conflict` that explains it, if any. */ + diagnose(): Promise; +} + +/** + * Plans the permalink part of a site update from `stored` to `next`, or undefined when no + * collection's effective template changed. Throws `path_conflict`, `reserved_path` or + * `invalid_redirect` before anything is written. + */ +export async function planPermalinks( + ctx: StoreContext, + stored: Site | undefined, + next: Site, +): Promise { + const changes = changedPermalinks(stored, next); + if (!stored || changes.length === 0) return undefined; + await checkPermalinks(ctx, next, changes); + const plan = new RedirectPlan( + next.id, + await ctx.tax.listRedirects(next.id), + next.updatedAt, + ctx.newId, + ); + // Entries in every locale share the template (v1), and redirects are per locale (RFC-0033). + const locales = new Set([next.defaultLocale, ...next.locales]); + for (const locale of locales) { + plan.forLocale(redirectLocale(locale, next.defaultLocale)); + for (const c of changes) plan.move(c.from, c.to, { match: "prefix", reason: "slug-change" }); + } + return { + changes, + guard: and( + ...changes.flatMap((c) => [ + noArchiveOverlapping(next.id, c.to), + ...(c.configured ? [noPageWithin(next.id, c.to)] : []), + ]), + ), + async statements(hash) { + if (plan.writes === 0) return []; + const ops = new OpBatch(ctx.newId(ID_PREFIX.operation), next.id, next.updatedAt); + ops.anchorOn(hashIs(next.id, hash)); + await plan.emit(ops); + return ops.statements(); + }, + diagnose: () => checkPermalinks(ctx, next, changes), + }; +} + +/** RFC-0038 invariant 1 for the collections whose template changed, against the stored graph. */ +async function checkPermalinks( + ctx: StoreContext, + site: Site, + changes: readonly PermalinkChange[], +): Promise { + const used = await ctx.sql.all<{ c: string }>( + "SELECT DISTINCT json_extract(body, '$.collection') AS c FROM nodes WHERE site_id = ? AND kind = 'contentEntry'", + [site.id], + ); + const prefixes = collectionPrefixes( + site, + used.map((r) => r.c), + ); + const taxonomies = await ctx.tax.listTaxonomies(site.id); + const pages = changes.some((c) => c.configured) ? await ctx.graph.listPages(site.id) : []; + for (const c of changes) { + const at = `collection "${c.collection}" at ${c.to}`; + if (c.configured) checkReservedPrefix(site, c.to); + for (const other of prefixes) + if (other.collection !== c.collection && pathsOverlap(other.prefix, c.to)) + conflict(`${at} collides with collection "${other.collection}" at ${other.prefix}`); + for (const tax of taxonomies) + if (pathsOverlap(tax.archive.basePath, c.to)) + conflict(`${at} collides with the ${tax.key} archive ${tax.archive.basePath}`); + // A default `/` keeps today's rule: a page there wins over an entry. + if (!c.configured) continue; + const page = pages.find((p) => pathWithin(p.path, c.to)); + if (page) conflict(`${at} would cover page ${page.path}`, { pageId: page.id }); + } +} + +/** Media URLs, the search page and locale prefixes are not entry prefixes. */ +function checkReservedPrefix(site: Site, prefix: string): void { + checkReservedPath(`${prefix}/`); + const first = prefix.split("/")[1] ?? ""; + const locales = [site.defaultLocale, ...site.locales].map((l) => l.toLowerCase()); + if (pathsOverlap(prefix, SEARCH_PATH) || locales.includes(first)) + throw new InvariantError( + "reserved_path", + `${prefix} is reserved (the search page or a locale prefix); choose another permalink`, + ); +} + +function conflict(message: string, details?: Record): never { + throw new InvariantError("path_conflict", message, details); +} + +/** The reverse of invariant 1 for page writes: no page at or under a configured prefix. */ +export async function checkPagePath(ctx: StoreContext, page: Page, site?: Site): Promise { + await ctx.tax.checkPagePath(page); + const s = site ?? (await ctx.graph.getSite(page.siteId)); + for (const { collection, prefix } of collectionPrefixes(s, [])) + if (pathWithin(page.path, prefix)) + conflict(`page path ${page.path} lies in collection "${collection}" at ${prefix}`, { + collection, + }); +} + +/** Race guard for `checkPagePath`: archives (invariant 13) and configured collection prefixes. */ +export function pageGuard(ctx: StoreContext, page: Page): Cond { + return and( + ctx.tax.pageGuard(page), + configuredPrefixFree(page.siteId, page.path, "within"), + ) as Cond; +} diff --git a/packages/core-ts/src/store/sites.ts b/packages/core-ts/src/store/sites.ts index 899ee1cf..235e0b29 100644 --- a/packages/core-ts/src/store/sites.ts +++ b/packages/core-ts/src/store/sites.ts @@ -1,17 +1,19 @@ -/** Site writes: create, and the CAS update with the footer region, theme config and brand assets. */ +/** Site writes: create, and the CAS update with the footer region, theme config, brand assets and collection permalinks. */ import { assertValid, type Site } from "@cloudhal/core-contracts"; import type { SqlStatement } from "@cloudhal/storage"; import { checkTimeZone, isTimeZone } from "../availability.ts"; -import { ConflictError, InvariantError } from "../errors.ts"; +import { ConflictError, InvariantError, isOpGuardViolation } from "../errors.ts"; import { nodeHash } from "../hash.ts"; import { checkHosts, hostStatements, hostsOf } from "../hosts.ts"; import { ID_PREFIX } from "../ids.ts"; import { fillMissingSrc } from "../media/refs.ts"; -import { casUpdateStatement, insertNodeStatement } from "../node-sql.ts"; +import { and, casUpdateStatement, insertNodeStatement } from "../node-sql.ts"; import { type Creatable, uniquenessKey } from "../nodes.ts"; +import { changedPermalinks } from "../permalinks.ts"; import type { SiteGuard } from "../search.ts"; import { checkPageSurfaces, createCatalog } from "../surfaces.ts"; import type { StoreContext } from "./context.ts"; +import { planPermalinks } from "./site-permalinks.ts"; import { checkThemeConfig } from "./validation.ts"; export class SiteEngine { @@ -87,24 +89,45 @@ export class SiteEngine { const hosts = hostsOf(next); const hostsChanged = !stored || JSON.stringify(hostsOf(stored)) !== JSON.stringify(hosts); if (hostsChanged) await checkHosts(this.#ctx.sql, hosts); + // RFC-0038 §3: a moved collection template leaves a prefix redirect, in this batch. + if (stored && stored.rev !== site.rev && changedPermalinks(stored, next).length > 0) + throw new ConflictError("site", site.id, site.rev, stored.rev); + const permalinks = await planPermalinks(this.#ctx, stored, next); const refs = await this.#ctx.assets.planRefs("site", site.id, next, stored); const written = refs.node; - const s = await casUpdateStatement("site", written, site.rev, refs.guard); + const s = await casUpdateStatement( + "site", + written, + site.rev, + and(refs.guard, permalinks?.guard), + ); const hash = await nodeHash(written); const more = [ ...(hostsChanged ? hostStatements(hosts, { siteId: site.id, hash }) : []), ...(extra?.({ siteId: site.id, hash }, written.updatedAt) ?? []), ...refs.statements(hash), + ...((await permalinks?.statements(hash)) ?? []), ]; - const r = - more.length > 0 - ? ((await this.#ctx.sql.batch([s, ...more]))[0] ?? { changes: 0 }) - : await this.#ctx.sql.run(s.sql, s.params ?? []); + const r = await this.#run(s, more); if (r.changes !== 1) { const rev = await this.#ctx.revOf(site.id); - if (rev === site.rev) await refs.diagnose(); + if (rev === site.rev) { + await permalinks?.diagnose(); + await refs.diagnose(); + } throw new ConflictError("site", site.id, site.rev, rev); } return written; } + + /** The CAS alone, or with its anchored statements; a failed `op_guards` assertion is a miss. */ + async #run(s: SqlStatement, more: SqlStatement[]): Promise<{ changes: number }> { + if (more.length === 0) return this.#ctx.sql.run(s.sql, s.params ?? []); + try { + return (await this.#ctx.sql.batch([s, ...more]))[0] ?? { changes: 0 }; + } catch (e) { + if (isOpGuardViolation(e)) return { changes: 0 }; + throw e; + } + } } diff --git a/packages/core-ts/src/taxonomy.ts b/packages/core-ts/src/taxonomy.ts index 98f2257a..4a32024e 100644 --- a/packages/core-ts/src/taxonomy.ts +++ b/packages/core-ts/src/taxonomy.ts @@ -38,6 +38,7 @@ import { termsPath, } from "./node-sql.ts"; import type { Creatable, NodeKind, NodeTypes } from "./nodes.ts"; +import { collectionPrefixes, configuredPrefixFree, pathsOverlap } from "./permalink-paths.ts"; import { ancestorPaths, assertNoLoop, @@ -157,11 +158,6 @@ function archivePath(tax: Taxonomy, slug: string): string { return `${tax.archive.basePath}/${slug}`; } -/** `a` equals `b` or one lies under the other. */ -function pathsOverlap(a: string, b: string): boolean { - return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`); -} - function cap(items: T[], max: number): T[] { return items.length > max ? items.slice(items.length - max) : items; } @@ -346,15 +342,13 @@ export class TaxonomyEngine { pageId: page.id, }, ); - const collections = new Set([ - ...(await this.#collections(tax.siteId)), - ...(tax.collections ?? []), - ]); - for (const c of collections) - if (pathsOverlap(`/${c}`, base)) + // RFC-0038: a collection with its own permalink answers under that prefix, not `/`. + const used = [...(await this.#collections(tax.siteId)), ...(tax.collections ?? [])]; + for (const { prefix } of collectionPrefixes(site, used)) + if (pathsOverlap(prefix, base)) throw new InvariantError( "path_conflict", - `archive path ${base} collides with the collection prefix /${c}`, + `archive path ${base} collides with the collection prefix ${prefix}`, ); } @@ -362,9 +356,10 @@ export class TaxonomyEngine { #archiveGuard(tax: Taxonomy): Cond { const base = tax.archive.basePath; const otherBase = "json_extract(body, '$.archive.basePath')"; + const prefixes = configuredPrefixFree(tax.siteId, base, "overlap"); const taxonomies: Cond = { - sql: `NOT EXISTS (SELECT 1 FROM nodes WHERE site_id = ? AND kind = 'taxonomy' AND id != ? AND (${otherBase} = ? OR substr(${otherBase}, 1, ?) = ? OR substr(?, 1, length(${otherBase}) + 1) = ${otherBase} || '/'))`, - params: [tax.siteId, tax.id, base, base.length + 1, `${base}/`, base], + sql: `NOT EXISTS (SELECT 1 FROM nodes WHERE site_id = ? AND kind = 'taxonomy' AND id != ? AND (${otherBase} = ? OR substr(${otherBase}, 1, ?) = ? OR substr(?, 1, length(${otherBase}) + 1) = ${otherBase} || '/')) AND ${prefixes.sql}`, + params: [tax.siteId, tax.id, base, base.length + 1, `${base}/`, base, ...prefixes.params], }; if (!tax.archive.enabled) return taxonomies; return { diff --git a/packages/core-ts/src/testing-permalinks.ts b/packages/core-ts/src/testing-permalinks.ts new file mode 100644 index 00000000..81592f18 --- /dev/null +++ b/packages/core-ts/src/testing-permalinks.ts @@ -0,0 +1,262 @@ +/** + * RFC-0038 §3–§4 behavioural suite: changing a collection's permalink leaves a prefix redirect in + * the site update's batch (chains collapsed, no loops, every locale), and templates, pages and + * taxonomy archives never overlap. Called by `describeSiteGraphStore`, so it runs on every storage + * backend (better-sqlite3 and Miniflare D1). + */ +import type { Actor, Page, Site } from "@cloudhal/core-contracts"; +import { describe, expect, it } from "vitest"; +import { ConflictError, InvariantError } from "./errors.ts"; +import type { SuiteContext } from "./testing-taxonomy.ts"; + +const person: Actor = { kind: "human", id: "user_01HZX8K3Q9CD", name: "Halil" }; + +async function code(p: Promise): Promise { + try { + await p; + } catch (e) { + return e instanceof InvariantError ? e.code : (e as Error).name; + } + throw new Error("expected a rejection"); +} + +export function permalinksSuite(ctx: SuiteContext): void { + describe("RFC-0038 collection permalinks in the store", () => { + let n = 0; + async function setup(): Promise { + const store = ctx.store(); + n++; + const project = await store.createProject({ + name: "Links", + slug: `links-${n}`, + ownerId: person.id, + siteIds: [], + }); + let site = await store.createSite({ + projectId: project.id, + name: "Links", + slug: `links-${n}`, + defaultLocale: "en", + locales: ["en", "tr"], + domains: [], + pageIds: [], + theme: { name: "blog" }, + }); + ({ site } = await store.createPage(site, { + siteId: site.id, + path: "/about", + title: "About", + locale: "en", + status: "published", + sections: [], + })); + await store.createDefaultTaxonomies(site, { categoryRequired: false }); + await store.createContentEntry({ + siteId: site.id, + collection: "posts", + slug: "hello", + locale: "en", + status: "published", + title: "Hello", + body: { format: "markdown", value: "Body" }, + fields: {}, + tags: [], + author: person, + } as never); + return (await store.getSite(site.id)) as Site; + } + + const fresh = async (site: Site) => (await ctx.store().getSite(site.id)) as Site; + const move = async (site: Site, permalink: string | undefined) => { + const s = await fresh(site); + const { posts: _drop, ...others } = s.collections ?? {}; + const collections = permalink ? { ...others, posts: { permalink } } : others; + return ctx.store().updateSite({ ...s, collections }); + }; + const location = async (site: Site, path: string, locale?: string) => + (await ctx.store().redirectFor(site.id, path, locale))?.location; + const redirects = async (site: Site) => + (await ctx.store().listRedirects(site.id)).map((r) => ({ + from: r.from, + to: r.to, + match: r.match, + ...(r.locale ? { locale: r.locale } : {}), + })); + + it("§3: a new template leaves one prefix redirect per locale in the site's batch", async () => { + const site = await setup(); + const updated = await move(site, "/blog/{slug}"); + expect(updated.collections?.posts?.permalink).toBe("/blog/{slug}"); + expect(await redirects(site)).toEqual([ + { from: "/posts", to: "/blog", match: "prefix" }, + { from: "/posts", to: "/blog", match: "prefix", locale: "tr" }, + ]); + const [r] = await ctx.store().listRedirects(site.id); + expect(r).toMatchObject({ reason: "slug-change", status: 301 }); + expect(await location(site, "/posts/hello")).toBe("/blog/hello"); + expect(await location(site, "/posts/hello", "tr")).toBe("/blog/hello"); + // An update that leaves the template alone writes no redirect. + await ctx.store().updateSite({ ...(await fresh(site)), name: "Renamed" }); + expect(await redirects(site)).toHaveLength(2); + }); + + it("§3: moving again collapses the chain; moving back to the default leaves no loop", async () => { + const site = await setup(); + await move(site, "/blog/{slug}"); + await move(site, "/journal/{slug}"); + expect(await location(site, "/posts/hello")).toBe("/journal/hello"); + expect(await location(site, "/blog/hello")).toBe("/journal/hello"); + expect((await redirects(site)).filter((r) => !r.locale)).toEqual([ + { from: "/blog", to: "/journal", match: "prefix" }, + { from: "/posts", to: "/journal", match: "prefix" }, + ]); + await move(site, undefined); + expect(await location(site, "/posts/hello")).toBeUndefined(); + expect(await location(site, "/blog/hello")).toBe("/posts/hello"); + expect(await location(site, "/journal/hello")).toBe("/posts/hello"); + expect(await location(site, "/journal/hello", "tr")).toBe("/posts/hello"); + expect((await redirects(site)).filter((r) => !r.locale)).toEqual([ + { from: "/blog", to: "/posts", match: "prefix" }, + { from: "/journal", to: "/posts", match: "prefix" }, + ]); + }); + + it("§4.1: a template may not overlap a page, an archive, a collection or a reserved path", async () => { + const site = await setup(); + const store = ctx.store(); + await store.createPage(await fresh(site), { + siteId: site.id, + path: "/work/team", + title: "Team", + locale: "en", + status: "draft", + sections: [], + }); + await store.createContentEntry({ + siteId: site.id, + collection: "notes", + slug: "first", + locale: "en", + status: "draft", + title: "First", + body: { format: "markdown", value: "Body" }, + fields: {}, + tags: [], + author: person, + } as never); + expect(await code(move(site, "/about/{slug}"))).toBe("path_conflict"); + expect(await code(move(site, "/work/{slug}"))).toBe("path_conflict"); + expect(await code(move(site, "/category/{slug}"))).toBe("path_conflict"); + expect(await code(move(site, "/tag/posts/{slug}"))).toBe("path_conflict"); + expect(await code(move(site, "/notes/{slug}"))).toBe("path_conflict"); + expect(await code(move(site, "/search/{slug}"))).toBe("reserved_path"); + expect(await code(move(site, "/tr/{slug}"))).toBe("reserved_path"); + const s = await fresh(site); + const both = { posts: { permalink: "/blog/{slug}" }, notes: { permalink: "/blog/x/{slug}" } }; + expect(await code(store.updateSite({ ...s, collections: both }))).toBe("path_conflict"); + // Nothing was written by the refused updates. + expect(await redirects(site)).toEqual([]); + expect((await fresh(site)).rev).toBe(s.rev); + }); + + it("§4.1 reverse: pages and archives may not enter a configured prefix", async () => { + let site = await setup(); + const store = ctx.store(); + const page = (path: string) => + store.createPage(site, { + siteId: site.id, + path, + title: "P", + locale: "en", + status: "draft", + sections: [], + }); + // A default prefix keeps today's rule: a page under /posts wins over an entry. + ({ site } = await page("/posts/landing")); + // A page in the way is refused when the template moves there, not afterwards. + site = await move(site, "/blog/{slug}"); + expect(await code(page("/blog"))).toBe("path_conflict"); + expect(await code(page("/blog/about"))).toBe("path_conflict"); + const about = (await store.listPages(site.id)).find((p) => p.path === "/about"); + expect(about).toBeDefined(); + expect(await code(store.updatePage({ ...(about as Page), path: "/blog/about" }))).toBe( + "path_conflict", + ); + const tax = (basePath: string) => + store.createTaxonomy({ + siteId: site.id, + key: "series", + labels: { en: { singular: "Series", plural: "Series" } }, + hierarchical: false, + allowNewTerms: "none", + required: false, + archive: { enabled: true, basePath }, + feed: false, + minEntriesToIndex: 1, + }); + expect(await code(tax("/blog"))).toBe("path_conflict"); + expect(await code(tax("/blog/series"))).toBe("path_conflict"); + // Outside the configured prefix (the old default included), pages are written as before. + expect((await page("/posts/new")).page.path).toBe("/posts/new"); + }); + + it("§3: a stale site rev raises ConflictError and writes no redirect", async () => { + const site = await setup(); + const stale = await fresh(site); + await ctx.store().updateSite({ ...stale, name: "Moved on" }); + const blog = { posts: { permalink: "/blog/{slug}" } }; + await expect(ctx.store().updateSite({ ...stale, collections: blog })).rejects.toBeInstanceOf( + ConflictError, + ); + // A writer that gets in between the read and the batch: the redirects roll back with the CAS. + const racing = ctx.racing(async () => { + await ctx.store().updateSite({ ...(await fresh(site)), name: "Raced" }); + }); + await expect( + racing.updateSite({ ...(await fresh(site)), collections: blog }), + ).rejects.toBeInstanceOf(ConflictError); + expect(await redirects(site)).toEqual([]); + expect((await fresh(site)).collections).toBeUndefined(); + }); + + it("§4.1 races: an archive taken during the update, or a template taken during an archive write", async () => { + const site = await setup(); + const series = { + siteId: site.id, + key: "series", + labels: { en: { singular: "Series", plural: "Series" } }, + hierarchical: false, + allowNewTerms: "none" as const, + required: false, + archive: { enabled: true, basePath: "/blog" }, + feed: false, + minEntriesToIndex: 1, + }; + const racing = ctx.racing(async () => { + await ctx.store().createTaxonomy(series); + }); + expect( + await code( + racing.updateSite({ + ...(await fresh(site)), + collections: { posts: { permalink: "/blog/x/{slug}" } }, + }), + ), + ).toBe("path_conflict"); + expect(await redirects(site)).toEqual([]); + const other = await setup(); + const racing2 = ctx.racing(async () => { + await move(other, "/series/{slug}"); + }); + expect( + await code( + racing2.createTaxonomy({ + ...series, + siteId: other.id, + archive: { enabled: true, basePath: "/series" }, + }), + ), + ).toBe("path_conflict"); + }); + }); +} diff --git a/packages/core-ts/src/testing-search.ts b/packages/core-ts/src/testing-search.ts index f963c537..eca1abc1 100644 --- a/packages/core-ts/src/testing-search.ts +++ b/packages/core-ts/src/testing-search.ts @@ -96,6 +96,20 @@ export function searchSuite( expect((await find("")).total).toBe(0); }); + it("RFC-0038: a publish with a moved collection template rewrites every entry's href", async () => { + await article("cay", "Çay, the glass that never empties", "Tea gardens."); + await publish(); + expect(await hrefs("cay")).toEqual(["/posts/cay"]); + // Only the site node changes: the entry rows are rewritten all the same. + await store.updateSite({ + ...(await fresh()), + collections: { posts: { permalink: "/blog/{slug}" } }, + }); + await publish(); + expect(await hrefs("cay")).toEqual(["/blog/cay"]); + expect(await hrefs("tea")).toEqual(["/blog/cay"]); + }); + it("follows edits, deletions and rollbacks, and says when it lags behind", async () => { const a = await article("first", "Hagia Sophia", "Fifteen centuries under one dome."); const b = await article("second", "Ephesus", "Walking a Roman street."); diff --git a/packages/core-ts/src/testing.ts b/packages/core-ts/src/testing.ts index 243e56c5..69eaa70e 100644 --- a/packages/core-ts/src/testing.ts +++ b/packages/core-ts/src/testing.ts @@ -9,6 +9,7 @@ import { nodeHash } from "./hash.ts"; import { SiteGraphStore } from "./store.ts"; import { assetsSuite } from "./testing-assets.ts"; import { menuSuite } from "./testing-menu.ts"; +import { permalinksSuite } from "./testing-permalinks.ts"; import { racingDriver, type SuiteContext, taxonomySuite } from "./testing-taxonomy.ts"; export interface StoreFactory { @@ -259,5 +260,6 @@ export function describeSiteGraphStore(label: string, factory: StoreFactory): vo taxonomySuite(suite); menuSuite(suite); assetsSuite(suite); + permalinksSuite(suite); }); } diff --git a/scripts/size-baseline.json b/scripts/size-baseline.json index 04e70749..ab955af6 100644 --- a/scripts/size-baseline.json +++ b/scripts/size-baseline.json @@ -23,7 +23,7 @@ "packages/core-ts/src/releases.ts": 996, "packages/core-ts/src/store.ts": 1221, "packages/core-ts/src/surface-interop.ts": 1235, - "packages/core-ts/src/taxonomy.ts": 1723, + "packages/core-ts/src/taxonomy.ts": 1718, "packages/mcp-server/src/menu-tools.ts": 1058, "packages/mcp-server/src/schedule-tools.ts": 1596, "packages/mcp-server/src/schedules.ts": 866,