diff --git a/.changeset/collections-store-api.md b/.changeset/collections-store-api.md new file mode 100644 index 00000000..34d7f609 --- /dev/null +++ b/.changeset/collections-store-api.md @@ -0,0 +1,17 @@ +--- +"@cloudhal/core-ts": minor +"@cloudhal/api": minor +"@cloudhal/astro-renderer": minor +"@cloudhal/mcp-server": patch +--- + +First-class collections, step 2 (RFC-0040 / ADR-0042). +- **Store:** the first entry of a new collection is refused (`path_conflict`, `reserved_path`) when its default prefix `/` overlaps another collection's permalink, a taxonomy archive, `/search` or a locale prefix. This closes the gap RFC-0038 left. A site update that declares a new collection runs the same check, and a declared collection that still has entries, or that a taxonomy names, cannot be undeclared (`collection_in_use`). +- **API:** new routes under `/api/sites/:id/collections`: + - `GET` (`content:read`) lists the registry; + - `PUT /:key { settings, rev }` declares or changes a collection (`site:settings`), with `?dryRun=1`; + - `DELETE /:key?rev=` undeclares an empty one. + + Creating an entry in a collection outside the registry is refused with `collection_undeclared` (422). +- **Renderer:** RSS feeds leave out collections with `feed: false`, and `sitemap.xml` leaves out those with `sitemap: false`. The defaults are unchanged. `collectionLabel` and `collectionSettings` are re-exported from `./routing` for themes. +- **MCP:** resetting a permalink with `set_collection_permalink` keeps the collection declared. Agents get hints for the new error codes. diff --git a/apps/studio/src/lib/collection-permalinks.ts b/apps/studio/src/lib/collection-permalinks.ts index 32583ebf..91290611 100644 --- a/apps/studio/src/lib/collection-permalinks.ts +++ b/apps/studio/src/lib/collection-permalinks.ts @@ -1,7 +1,8 @@ /** * Site › Settings › Collections (RFC-0038 §5): where each collection's entries live. Version 1 * templates are a fixed prefix followed by `/{slug}`, so the form edits the prefix (`/blog`); - * an empty field is the default, `/`, and removes the setting. + * an empty field is the default, `/`, and removes the setting (the collection stays + * declared, RFC-0040). */ import type { ContentEntry, Site } from "@cloudhal/core-contracts"; import { collectionPermalink, entryPath, permalinkPrefix } from "@cloudhal/core-ts/permalinks"; @@ -44,7 +45,7 @@ export function validatePrefix(value: string): PrefixError | undefined { return PREFIX.test(v) ? undefined : "invalid"; } -/** The site with the form applied. An empty prefix, or the default one, removes the setting. */ +/** The site with the form applied. An empty prefix, or the default one, removes the permalink. */ export function withPrefixes(site: Site, form: PrefixForm): Site { const collections: NonNullable = { ...site.collections }; for (const [key, raw] of Object.entries(form)) { @@ -52,7 +53,8 @@ export function withPrefixes(site: Site, form: PrefixForm): Site { const { permalink: _old, ...rest } = collections[key] ?? {}; if (prefix && prefix !== `/${key}`) collections[key] = { ...rest, permalink: `${prefix}/{slug}` }; - else if (Object.keys(rest).length) collections[key] = rest; + // RFC-0040: resetting a permalink keeps a declared collection declared. + else if (Object.keys(rest).length || site.collections?.[key]) collections[key] = rest; else delete collections[key]; } const { collections: _drop, ...base } = site; diff --git a/apps/studio/test/collection-settings.test.tsx b/apps/studio/test/collection-settings.test.tsx index c1034f9f..0ab024f3 100644 --- a/apps/studio/test/collection-settings.test.tsx +++ b/apps/studio/test/collection-settings.test.tsx @@ -56,7 +56,8 @@ describe("collection permalinks (lib)", () => { expect(keys).toEqual(["docs", "posts"]); expect(prefixForm(site, keys)).toEqual({ docs: "/handbook", posts: "" }); const next = withPrefixes(site, { docs: "", posts: "/blog" }); - expect(next.collections).toEqual({ posts: { permalink: "/blog/{slug}" } }); + // RFC-0040: docs goes back to the default and stays declared. + expect(next.collections).toEqual({ docs: {}, posts: { permalink: "/blog/{slug}" } }); // The default prefix is the same as no setting. expect(withPrefixes(SITE, { posts: "/posts" }).collections).toBeUndefined(); expect(changedKeys(site, { docs: "/handbook", posts: "/blog" })).toEqual(["posts"]); diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index b8a52848..de1356fa 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -23,6 +23,7 @@ import { approvals } from "./routes/approvals.ts"; import { assets, siteMedia } from "./routes/assets.ts"; import { blockTypes } from "./routes/block-types.ts"; import { chrome } from "./routes/chrome.ts"; +import { collections } from "./routes/collections.ts"; import { comments } from "./routes/comments.ts"; import { design } from "./routes/design.ts"; import { entries } from "./routes/entries.ts"; @@ -71,6 +72,8 @@ const FENCED_AUTH_PATHS = /^\/api\/auth\/(organization|admin|api-key)(\/|$)/; const SITE_AREAS: readonly (readonly [string, Hono])[] = [ ["pages", pages], ["entries", entries], + // RFC-0040: the collection registry. + ["collections", collections], ["proposals", proposals], // RFC-0015: the block registry (built-ins and the theme's types). ["block-types", blockTypes], diff --git a/packages/api/src/errors.ts b/packages/api/src/errors.ts index e5cef206..7d95486a 100644 --- a/packages/api/src/errors.ts +++ b/packages/api/src/errors.ts @@ -65,6 +65,10 @@ export const INVARIANT_STATUS: Record = new Set([ `GET ${S}`, `GET ${S}/pending`, `PUT ${S}`, + // RFC-0040: the collection registry, part of the site's structure. + `GET ${S}/collections`, + `PUT ${S}/collections/:key`, + `DELETE ${S}/collections/:key`, `GET ${S}/pages`, `POST ${S}/pages`, `GET ${S}/pages/:pageId/preview`, diff --git a/packages/api/src/routes/collections.ts b/packages/api/src/routes/collections.ts new file mode 100644 index 00000000..ebdafeb4 --- /dev/null +++ b/packages/api/src/routes/collections.ts @@ -0,0 +1,73 @@ +/** + * `/api/sites/:siteId/collections` (RFC-0040 §5): the collection registry. `GET` (content:read) + * lists declared collections, those inferred from entries, and `posts`, with entry counts. + * `PUT /:key { settings, rev }` (site:settings) declares a collection or replaces its settings + * through the site update, so the store's overlap and permalink-redirect checks run (`rev` is the + * site's: a stale one is 409); `?dryRun=1` runs them and writes nothing. `DELETE /:key?rev=` + * (site:settings) undeclares a collection without entries. + */ +import type { CollectionSettings, Site } from "@cloudhal/core-contracts"; +import { Hono } from "hono"; +import type { Env } from "../context.ts"; +import { badRequest, notFound } from "../errors.ts"; +import { param } from "../params.ts"; + +const KEY = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** The site with `key` declared with `settings`, or undeclared (`undefined`). */ +export function withCollection( + site: Site, + key: string, + settings: CollectionSettings | undefined, +): Site { + const { [key]: _old, ...others } = site.collections ?? {}; + const collections = settings ? { ...others, [key]: settings } : others; + const { collections: _drop, ...base } = site; + return Object.keys(collections).length ? { ...base, collections } : (base as Site); +} + +function keyOf(raw: string): string { + if (!KEY.test(raw) || raw.length > 128) throw badRequest("the collection key must be a slug"); + return raw; +} + +function revOf(raw: unknown): number { + const rev = typeof raw === "string" ? Number(raw) : raw; + if (typeof rev !== "number" || !Number.isInteger(rev) || rev < 0) + throw badRequest("rev (the site's revision) is required"); + return rev; +} + +export const collections: Hono = new Hono() + .get("/", async (c) => { + const store = c.get("store"); + const siteId = param(c, "siteId"); + if (!(await store.getSite(siteId))) throw notFound("site not found"); + return c.json(await store.listCollections(siteId)); + }) + .put("/:key", async (c) => { + const store = c.get("store"); + const key = keyOf(param(c, "key")); + const site = await store.getSite(param(c, "siteId")); + if (!site) throw notFound("site not found"); + const body = (await c.req.json().catch(() => ({}))) as { settings?: unknown; rev?: unknown }; + if (!body.settings || typeof body.settings !== "object" || Array.isArray(body.settings)) + throw badRequest("settings must be an object"); + const next = withCollection( + { ...site, rev: revOf(body.rev) }, + key, + body.settings as CollectionSettings, + ); + const dryRun = c.req.query("dryRun") === "1"; + const updated = await store.updateSite(next, { dryRun }); + return c.json({ site: updated, dryRun }); + }) + .delete("/:key", async (c) => { + const store = c.get("store"); + const key = keyOf(param(c, "key")); + const site = await store.getSite(param(c, "siteId")); + if (!site) throw notFound("site not found"); + if (!site.collections?.[key]) throw notFound("collection not declared"); + const next = withCollection({ ...site, rev: revOf(c.req.query("rev")) }, key, undefined); + return c.json({ site: await store.updateSite(next) }); + }); diff --git a/packages/api/src/routes/entries.ts b/packages/api/src/routes/entries.ts index e2d860d9..c57779a4 100644 --- a/packages/api/src/routes/entries.ts +++ b/packages/api/src/routes/entries.ts @@ -1,6 +1,8 @@ import { checkEntryLimit } from "@cloudhal/agent-runtime"; import { renderEntryPreview, renderFragmentPreview } from "@cloudhal/astro-renderer/preview"; import type { ContentEntry, PublishStatus } from "@cloudhal/core-contracts"; +import { InvariantError } from "@cloudhal/core-ts"; +import { isRegistered } from "@cloudhal/core-ts/collections"; import { Hono } from "hono"; import { patchBody } from "../body.ts"; import type { Env } from "../context.ts"; @@ -43,6 +45,13 @@ export const entries: Hono = new Hono() ContentEntry, "id" | "rev" | "createdAt" | "updatedAt" | "schemaVersion" >; + // RFC-0040 §5: a new collection is declared first (Studio's New collection). + const registry = await c.get("store").listCollections(siteId); + if (typeof input.collection === "string" && !isRegistered(registry, input.collection)) + throw new InvariantError( + "collection_undeclared", + `collection "${input.collection}" is not declared; declare it first`, + ); const actor = c.get("actor") ?? API_ACTOR; // `author` is server-controlled (RFC-0005 I9): the caller, unless they hold full // content:write (Editors may credit someone else, e.g. imports). diff --git a/packages/api/src/scopes.ts b/packages/api/src/scopes.ts index cd5f0baa..5c2ec321 100644 --- a/packages/api/src/scopes.ts +++ b/packages/api/src/scopes.ts @@ -120,6 +120,10 @@ export const ROUTE_SCOPES: readonly (readonly [Method, string, RouteRequirement] // what a publish would carry, as counts (Studio's badge beside Publish) ["GET", `${S}/pending`, "site:read"], ["PUT", S, "site:settings"], + // RFC-0040: the collection registry; declaring or changing one is a site setting + ["GET", `${S}/collections`, "content:read"], + ["PUT", `${S}/collections/:key`, "site:settings"], + ["DELETE", `${S}/collections/:key`, "site:settings"], ["GET", `${S}/pages`, "content:read"], ["POST", `${S}/pages`, "content:write"], ["GET", `${S}/pages/:pageId/preview`, "content:read"], diff --git a/packages/api/src/testing-collections.ts b/packages/api/src/testing-collections.ts new file mode 100644 index 00000000..e26bcc8a --- /dev/null +++ b/packages/api/src/testing-collections.ts @@ -0,0 +1,138 @@ +/** + * Shared API suite for RFC-0040 step 2: the collection registry (`GET`), declaring and changing a + * collection (`PUT /:key`, `site:settings`, with a dry run), undeclaring an empty one (`DELETE`), + * and entries refused in a collection that is not in the registry. Runs against any + * SiteGraphStore backend, like `testing.ts`. + */ +import type { Site } from "@cloudhal/core-contracts"; +import { type CollectionInfo, type SiteGraphStore, seedStarterSite } from "@cloudhal/core-ts"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; +import { createApi } from "./app.ts"; +import type { ApiStoreFactory } from "./testing.ts"; + +const human = { kind: "human" as const, id: "user_apitest00001", name: "Tester" }; + +type Json = Record; + +const EVENTS = { labels: { en: { singular: "Event", plural: "Events" } }, icon: "calendar" }; + +export function describeCollectionsApi(label: string, factory: ApiStoreFactory): void { + describe(`API collections: ${label}`, () => { + let store: SiteGraphStore; + let siteId: string; + let app: ReturnType; + const headers = { "content-type": "application/json", "x-test-user": "1" }; + const call = async (path: string, init: RequestInit = {}) => { + const res = await app.request(path, { headers, ...init }); + return { status: res.status, body: (await res.json()) as Json }; + }; + const site = async () => (await call(`/api/sites/${siteId}`)).body as unknown as Site; + const list = async () => + (await call(`/api/sites/${siteId}/collections`)).body as unknown as CollectionInfo[]; + const put = async (key: string, settings: unknown, query = "") => + call(`/api/sites/${siteId}/collections/${key}${query}`, { + method: "PUT", + body: JSON.stringify({ settings, rev: (await site()).rev }), + }); + const createEntry = (collection: string) => + call(`/api/sites/${siteId}/entries`, { + method: "POST", + body: JSON.stringify({ + collection, + slug: "first", + locale: "en", + status: "draft", + title: "First", + body: { format: "markdown", value: "Body" }, + fields: {}, + tags: [], + }), + }); + + beforeEach(async () => { + store = await factory.create(); + siteId = (await seedStarterSite(store, { name: "Kinds", slug: "kinds" })).id; + app = createApi({ + store, + resolveActor: async (c) => (c.req.header("x-test-user") ? human : undefined), + }); + }); + + afterAll(async () => { + await factory.dispose?.(); + }); + + it("lists the starter's posts as inferred, with counts", async () => { + const [posts, ...rest] = await list(); + expect(rest).toEqual([]); + expect(posts).toMatchObject({ key: "posts", declared: false, settings: {} }); + expect(Object.values(posts?.counts ?? {}).reduce((a, b) => a + b, 0)).toBeGreaterThan(0); + }); + + it("declares a collection; a dry run writes nothing", async () => { + const dry = await put("events", EVENTS, "?dryRun=1"); + expect(dry.status, JSON.stringify(dry.body)).toBe(200); + expect(dry.body.dryRun).toBe(true); + expect((await site()).collections?.events).toBeUndefined(); + + const saved = await put("events", EVENTS); + expect(saved.status, JSON.stringify(saved.body)).toBe(200); + expect((await site()).collections?.events).toEqual(EVENTS); + expect((await list()).map((c) => [c.key, c.declared])).toEqual([ + ["events", true], + ["posts", false], + ]); + }); + + it("refuses entries in an undeclared collection (422), accepts them once declared", async () => { + const refused = await createEntry("jobs"); + expect(refused.status).toBe(422); + expect(refused.body.error).toBe("collection_undeclared"); + expect((await put("jobs", {})).status).toBe(200); + const created = await createEntry("jobs"); + expect(created.status, JSON.stringify(created.body)).toBe(201); + }); + + it("refuses a collection whose default prefix overlaps another's or an archive (409)", async () => { + expect((await put("posts", { permalink: "/blog/{slug}" })).status).toBe(200); + const clash = await put("blog", {}); + expect(clash.status).toBe(409); + expect(clash.body.error).toBe("path_conflict"); + expect(clash.body.message).toContain('"posts"'); + const archive = await put("tag", {}); + expect(archive.status).toBe(409); + expect(archive.body.error).toBe("path_conflict"); + expect((await site()).collections?.blog).toBeUndefined(); + }); + + it("undeclares an empty collection; one with entries is in use (409)", async () => { + expect((await put("posts", { icon: "pen" })).status).toBe(200); + expect((await put("team", {})).status).toBe(200); + const inUse = await call(`/api/sites/${siteId}/collections/posts?rev=${(await site()).rev}`, { + method: "DELETE", + }); + expect(inUse.status).toBe(409); + expect(inUse.body.error).toBe("collection_in_use"); + const gone = await call(`/api/sites/${siteId}/collections/team?rev=${(await site()).rev}`, { + method: "DELETE", + }); + expect(gone.status, JSON.stringify(gone.body)).toBe(200); + expect(Object.keys((await site()).collections ?? {})).toEqual(["posts"]); + }); + + it("needs the site's current rev (409 on a stale one, 400 without)", async () => { + const rev = (await site()).rev; + expect((await put("events", EVENTS)).status).toBe(200); + const stale = await call(`/api/sites/${siteId}/collections/team`, { + method: "PUT", + body: JSON.stringify({ settings: {}, rev }), + }); + expect(stale.status).toBe(409); + const missing = await call(`/api/sites/${siteId}/collections/team`, { + method: "PUT", + body: JSON.stringify({ settings: {} }), + }); + expect(missing.status).toBe(400); + }); + }); +} diff --git a/packages/api/src/testing-permalinks.ts b/packages/api/src/testing-permalinks.ts index c2f8b326..e85e683e 100644 --- a/packages/api/src/testing-permalinks.ts +++ b/packages/api/src/testing-permalinks.ts @@ -58,11 +58,11 @@ export function describePermalinkApi(label: string, factory: ApiStoreFactory): v ["/posts", "/blog", "prefix", 301], ]); - // Back to the default: the setting goes, the redirect that would loop goes with it. - const { collections: _drop, ...rest } = await site(); - const reset = await putSite(rest as Site); + // Back to the default: the setting goes (posts stays declared, RFC-0040), and the redirect + // that would loop goes with it. + const reset = await putSite({ ...(await site()), collections: { posts: {} } }); expect(reset.status, JSON.stringify(reset.body)).toBe(200); - expect((reset.body as unknown as Site).collections?.posts).toBeUndefined(); + expect((reset.body as unknown as Site).collections?.posts).toEqual({}); const after = (await call(`/api/sites/${siteId}/redirects`)).body as unknown as Redirect[]; expect(after.some((r) => r.from === "/posts" && r.to === "/blog")).toBe(false); }); diff --git a/packages/api/test/collections-api.test.ts b/packages/api/test/collections-api.test.ts new file mode 100644 index 00000000..feb756b7 --- /dev/null +++ b/packages/api/test/collections-api.test.ts @@ -0,0 +1,32 @@ +import { SiteGraphStore } from "@cloudhal/core-ts"; +import { MemoryBlobStore } from "@cloudhal/storage"; +import { D1Driver } from "@cloudhal/storage/d1"; +import { BetterSqlite3Driver } from "@cloudhal/storage/sqlite"; +import { createMiniflareD1, createTableReset } from "@cloudhal/storage/testing"; +import { describeCollectionsApi } from "../src/testing-collections.ts"; + +describeCollectionsApi("better-sqlite3", { + async create() { + const store = new SiteGraphStore({ + sql: await BetterSqlite3Driver.open(":memory:"), + blobs: new MemoryBlobStore(), + }); + await store.init(); + return store; + }, +}); + +let d1: Awaited> | undefined; +const reset = createTableReset(); +describeCollectionsApi("Cloudflare D1 (Miniflare)", { + async create() { + d1 ??= await createMiniflareD1(); + const sql = new D1Driver(d1.db); + const store = new SiteGraphStore({ sql, blobs: new MemoryBlobStore() }); + await reset(sql, () => store.init()); + return store; + }, + async dispose() { + await d1?.dispose(); + }, +}); diff --git a/packages/astro-renderer/src/feeds.ts b/packages/astro-renderer/src/feeds.ts index 109e292e..b07b9135 100644 --- a/packages/astro-renderer/src/feeds.ts +++ b/packages/astro-renderer/src/feeds.ts @@ -4,7 +4,7 @@ * published only, noindex respected, canonical elsewhere excluded — live in one tested place. */ import type { ContentEntry, Page, Redirect, Site } from "@cloudhal/core-contracts"; -import { entryPath } from "@cloudhal/core-ts"; +import { collectionSettings, entryPath } from "@cloudhal/core-ts"; import type { ArchiveSummary, ArchiveView } from "./archive.ts"; import { markdownText, renderBody } from "./markdown.ts"; import type { AssetIndex } from "./media.ts"; @@ -72,39 +72,41 @@ export function rssXml(options: FeedOptions): string { const { site, origin } = options; const base = options.linkOrigin ?? origin; const self = `${base}${options.feedPath ?? "/rss.xml"}`; - const items = published(options.entries) - .slice(0, FEED_LIMIT) - .map((e) => { - const url = `${base}${entryPath(site, e)}`; - const date = new Date(e.publishedAt ?? e.createdAt).toUTCString(); - const cover = e.coverImage?.assetId ? options.assets?.get(e.coverImage.assetId) : undefined; - const image = cover - ? absoluteUrl( - origin, - cover.storage.kind === "external" ? cover.storage.url : e.coverImage?.src, - ) - : absoluteUrl(origin, e.coverImage?.src); - const mediaAttrs = cover - ? ` type="${xml(cover.mediaType)}" fileSize="${cover.bytes}"${cover.image ? ` width="${cover.image.width}" height="${cover.image.height}"` : ""}` - : ""; - const author = e.author.kind === "human" && e.author.name ? e.author.name : site.name; - return [ - "", - `${xml(e.title)}`, - `${xml(url)}`, - `${xml(url)}`, - `${date}`, - `${cdata(author)}`, - ...itemCategories(e, options.taxonomy).map((t) => `${xml(t)}`), - e.summary ? `${cdata(e.summary)}` : "", - image ? `` : "", - `${cdata(renderBody(e.body, options.assets ? { assets: options.assets, locale: e.locale, defaultLocale: site.defaultLocale } : undefined))}`, - "", - ] - .filter(Boolean) - .join(""); - }); - const newest = published(options.entries)[0]; + // RFC-0040: a collection with `feed: false` stays out of every feed. + const shown = published(options.entries).filter( + (e) => collectionSettings(site, e.collection).feed, + ); + const items = shown.slice(0, FEED_LIMIT).map((e) => { + const url = `${base}${entryPath(site, e)}`; + const date = new Date(e.publishedAt ?? e.createdAt).toUTCString(); + const cover = e.coverImage?.assetId ? options.assets?.get(e.coverImage.assetId) : undefined; + const image = cover + ? absoluteUrl( + origin, + cover.storage.kind === "external" ? cover.storage.url : e.coverImage?.src, + ) + : absoluteUrl(origin, e.coverImage?.src); + const mediaAttrs = cover + ? ` type="${xml(cover.mediaType)}" fileSize="${cover.bytes}"${cover.image ? ` width="${cover.image.width}" height="${cover.image.height}"` : ""}` + : ""; + const author = e.author.kind === "human" && e.author.name ? e.author.name : site.name; + return [ + "", + `${xml(e.title)}`, + `${xml(url)}`, + `${xml(url)}`, + `${date}`, + `${cdata(author)}`, + ...itemCategories(e, options.taxonomy).map((t) => `${xml(t)}`), + e.summary ? `${cdata(e.summary)}` : "", + image ? `` : "", + `${cdata(renderBody(e.body, options.assets ? { assets: options.assets, locale: e.locale, defaultLocale: site.defaultLocale } : undefined))}`, + "", + ] + .filter(Boolean) + .join(""); + }); + const newest = shown[0]; return [ '', '', @@ -160,7 +162,8 @@ export interface SitemapUrl { * What belongs in `sitemap.xml`: published pages and entries of the default locale, and the * indexable term archives (page 1 only, `lastmod` = the later of the newest listed entry and the * term; see `archiveSummaries` — pass it published entries). Pages and archives asking for - * `noindex`, or canonicalised to another URL, are left out (a sitemap lists canonical URLs). + * `noindex`, or canonicalised to another URL, are left out (a sitemap lists canonical URLs), and so + * are the entries of collections with `sitemap: false` (RFC-0040). */ export function sitemapUrls( site: Site, @@ -183,6 +186,7 @@ export function sitemapUrls( for (const entry of published(entries)) { const path = entryPath(site, entry); if (entry.locale !== site.defaultLocale || seen.has(path)) continue; + if (!collectionSettings(site, entry.collection).sitemap) continue; seen.add(path); urls.push({ loc: `${origin}${path}`, lastmod: entry.updatedAt }); } diff --git a/packages/astro-renderer/src/routing.ts b/packages/astro-renderer/src/routing.ts index 05727248..dd6bfc3f 100644 --- a/packages/astro-renderer/src/routing.ts +++ b/packages/astro-renderer/src/routing.ts @@ -32,6 +32,8 @@ import { taxonomyIndex, } from "./taxonomy.ts"; +/** RFC-0040: a collection's labels and settings with defaults, for themes. */ +export { collectionLabel, collectionSettings } from "@cloudhal/core-ts/collections"; /** Themes link entries with core-ts's `entryPath(site, entry)`; re-exported for them. */ export { entryAt, entryPath }; diff --git a/packages/astro-renderer/test/seo-feeds.test.ts b/packages/astro-renderer/test/seo-feeds.test.ts index 360c0357..98f4d777 100644 --- a/packages/astro-renderer/test/seo-feeds.test.ts +++ b/packages/astro-renderer/test/seo-feeds.test.ts @@ -204,6 +204,30 @@ describe("feeds and sitemap", () => { }); }); +describe("RFC-0040 collection feed and sitemap settings", () => { + it("feed: false and sitemap: false keep a collection out; the defaults keep it in", async () => { + await entry({}); + await entry({ collection: "notes", slug: "a-note", title: "A note", tags: [] }); + await entry({ collection: "team", slug: "ayse", title: "Ayşe", tags: [] }); + const settled = await store.updateSite({ + ...site, + collections: { notes: { feed: false }, team: { sitemap: false } }, + }); + const live = new LiveSource(store, site.id); + const all = await live.allEntries(); + const feed = rssXml({ site: settled, origin: "https://news.example.com", entries: all }); + expect(feed).toContain("/posts/launch"); + expect(feed).not.toContain("/notes/a-note"); + expect(feed).toContain("/team/ayse"); + const locs = sitemapUrls(settled, await live.pages(), all, "https://news.example.com").map( + (u) => u.loc, + ); + expect(locs).toContain("https://news.example.com/posts/launch"); + expect(locs).toContain("https://news.example.com/notes/a-note"); + expect(locs).not.toContain("https://news.example.com/team/ayse"); + }); +}); + describe("routing", () => { it("resolves pages first, then articles of any collection; lists every routable path", async () => { await entry({ collection: "notes", slug: "a-note", title: "A note" }); diff --git a/packages/core-ts/src/errors.ts b/packages/core-ts/src/errors.ts index 5e6c016c..8ef2771d 100644 --- a/packages/core-ts/src/errors.ts +++ b/packages/core-ts/src/errors.ts @@ -167,7 +167,14 @@ export type InvariantCode = /** Invariant 5: a machine translation in a review-required collection was published unreviewed. */ | "review_required" /** RFC-0034 invariant 1: the hostname already serves another site of the installation. */ - | "host_taken"; + | "host_taken" + // ── RFC-0040 (first-class collections) ── + /** An entry or tool names a collection that is neither declared, in use, nor `posts`. */ + | "collection_undeclared" + /** A declared collection with entries (or named by a taxonomy) cannot be undeclared. */ + | "collection_in_use" + /** `create_collection` for a key that is already declared. */ + | "collection_exists"; export class InvariantError extends Error { override readonly name = "InvariantError"; diff --git a/packages/core-ts/src/store/collection-paths.ts b/packages/core-ts/src/store/collection-paths.ts new file mode 100644 index 00000000..06c48267 --- /dev/null +++ b/packages/core-ts/src/store/collection-paths.ts @@ -0,0 +1,142 @@ +/** + * RFC-0040 §3 invariant 3: the first entry of a new collection claims its default prefix + * `/`, which may not overlap another collection's configured permalink, a taxonomy + * archive or a reserved path (the gap RFC-0038 left: those checks ran only when a permalink, a + * page or a taxonomy changed). Pages under a default prefix still win, as RFC-0038 decided. + * Worker-safe. + */ +import type { ContentEntry, Site } from "@cloudhal/core-contracts"; +import { InvariantError } from "../errors.ts"; +import { and, type Cond } from "../node-sql.ts"; +import { configuredPrefixFree, noArchiveOverlapping, pathsOverlap } from "../permalink-paths.ts"; +import { collectionPermalink, permalinkPrefix } from "../permalinks.ts"; +import type { StoreContext } from "./context.ts"; +import { checkReservedPrefix } from "./site-permalinks.ts"; + +/** What a write that opens a new collection adds: its race guard and the check behind it. */ +export interface NewCollection { + readonly guard: Cond | undefined; + diagnose(): Promise; +} + +/** + * Checks `collection`'s default prefix when it has no entries yet (other than `except`) and no + * configured permalink; undefined when the collection is already in use or configured. Throws + * `path_conflict` or `reserved_path`. + */ +export async function planNewCollection( + ctx: StoreContext, + site: Site, + collection: string, + except?: string, +): Promise { + if (site.collections?.[collection]?.permalink) return undefined; + const used = await ctx.sql.all<{ id: string }>( + "SELECT id FROM nodes WHERE site_id = ? AND kind = 'contentEntry' AND json_extract(body, '$.collection') = ? AND id != ? LIMIT 1", + [site.id, collection, except ?? ""], + ); + if (used.length > 0) return undefined; + const prefix = permalinkPrefix(collectionPermalink(site, collection)); + await checkCollectionPrefix(ctx, site, collection, prefix); + return { + guard: and( + configuredPrefixFree(site.id, prefix, "overlap"), + noArchiveOverlapping(site.id, prefix), + ), + diagnose: () => checkCollectionPrefix(ctx, site, collection, prefix), + }; +} + +/** The prefix against reserved paths, other configured prefixes and taxonomy archives. */ +export async function checkCollectionPrefix( + ctx: StoreContext, + site: Site, + collection: string, + prefix: string, +): Promise { + checkReservedPrefix(site, prefix); + const at = `collection "${collection}" at ${prefix}`; + for (const [other, settings] of Object.entries(site.collections ?? {})) { + if (other === collection || !settings?.permalink) continue; + const theirs = permalinkPrefix(settings.permalink); + if (pathsOverlap(theirs, prefix)) + conflict(`${at} collides with collection "${other}" at ${theirs}`, { collection: other }); + } + for (const tax of await ctx.tax.listTaxonomies(site.id)) + if (pathsOverlap(tax.archive.basePath, prefix)) + conflict(`${at} collides with the ${tax.key} archive ${tax.archive.basePath}`, { + taxonomy: tax.key, + }); +} + +/** + * RFC-0040 §3 invariants 2 and 3 for a site update: a declared key that still has entries, or that a + * taxonomy names, cannot be dropped (`collection_in_use`), and a newly declared key without a + * permalink and without entries claims its default prefix. Undefined when no key comes or goes. + */ +export async function planDeclarations( + ctx: StoreContext, + stored: Site | undefined, + next: Site, +): Promise { + const before = Object.keys(stored?.collections ?? {}); + const after = Object.keys(next.collections ?? {}); + const removed = before.filter((k) => !after.includes(k)); + const added = after.filter((k) => !before.includes(k) && !next.collections?.[k]?.permalink); + if (!stored || (removed.length === 0 && added.length === 0)) return undefined; + const taxonomies = removed.length ? await ctx.tax.listTaxonomies(next.id) : []; + for (const key of removed) { + if (await hasEntries(ctx, next.id, key)) + throw new InvariantError("collection_in_use", `collection "${key}" still has entries`, { + collection: key, + }); + const tax = taxonomies.find((t) => t.collections?.includes(key)); + if (tax) + throw new InvariantError( + "collection_in_use", + `the ${tax.key} taxonomy applies to collection "${key}"; remove it there first`, + { collection: key, taxonomy: tax.key }, + ); + } + const opened: string[] = []; + for (const key of added) if (!(await hasEntries(ctx, next.id, key))) opened.push(key); + const check = async () => { + for (const key of opened) await checkCollectionPrefix(ctx, next, key, `/${key}`); + }; + await check(); + return { + guard: and( + ...removed.map((key) => noEntryIn(next.id, key)), + ...opened.flatMap((key) => [ + configuredPrefixFree(next.id, `/${key}`, "overlap"), + noArchiveOverlapping(next.id, `/${key}`), + ]), + ), + diagnose: check, + }; +} + +async function hasEntries(ctx: StoreContext, siteId: string, key: string): Promise { + const rows = await ctx.sql.all<{ id: string }>( + "SELECT id FROM nodes WHERE site_id = ? AND kind = 'contentEntry' AND json_extract(body, '$.collection') = ? LIMIT 1", + [siteId, key], + ); + return rows.length > 0; +} + +/** Holds while the site has no entry in `collection`. */ +function noEntryIn(siteId: string, collection: string): Cond { + return { + sql: "NOT EXISTS (SELECT 1 FROM nodes WHERE site_id = ? AND kind = 'contentEntry' AND json_extract(body, '$.collection') = ?)", + params: [siteId, collection], + }; +} + +/** Whether a write puts `entry` in a collection it was not in before. */ +export function entersCollection(stored: ContentEntry | undefined, entry: ContentEntry): boolean { + return stored?.collection !== entry.collection; +} + +function conflict(message: string, details: Record): never { + throw new InvariantError("path_conflict", message, details); +} diff --git a/packages/core-ts/src/store/entries.ts b/packages/core-ts/src/store/entries.ts index bfc426a8..9a1943ab 100644 --- a/packages/core-ts/src/store/entries.ts +++ b/packages/core-ts/src/store/entries.ts @@ -11,6 +11,7 @@ import { and, casUpdateStatement, insertNodeStatement, OpBatch, runOpBatch } fro import { uniquenessKey } from "../nodes.ts"; import { entryPath } from "../permalinks.ts"; import { canonicalTerms } from "../taxonomy.ts"; +import { entersCollection, planNewCollection } from "./collection-paths.ts"; import type { StoreContext } from "./context.ts"; import { checkLocalizedNode, checkTranslationLinks } from "./translations.ts"; import type { NodeWriteOptions } from "./types.ts"; @@ -90,7 +91,11 @@ export class EntryEngine { const refs = await this.#ctx.assets.planRefs("contentEntry", site.id, draft, stored); const prepared = await this.#ctx.tax.prepareEntry(refs.node, site, ts); const { entry, newTerms } = prepared; - const guard = and(prepared.guard, refs.guard); + // RFC-0040 §3: the first entry of a collection claims its default prefix. + const opening = entersCollection(stored, entry) + ? await planNewCollection(this.#ctx, site, entry.collection, entry.id) + : undefined; + const guard = and(prepared.guard, refs.guard, opening?.guard); const extra = refs.statements(await nodeHash(entry)); const key = uniquenessKey("contentEntry", entry); const sameLocale = stored?.locale === entry.locale; @@ -115,6 +120,7 @@ export class EntryEngine { throw new ConflictError("contentEntry", entry.id, stored.rev, rev); await this.#ctx.tax.diagnoseEntry(entry); await refs.diagnose(); + await opening?.diagnose(); throw new ConflictError("contentEntry", entry.id, stored?.rev ?? 0, rev); } return entry; @@ -132,6 +138,7 @@ export class EntryEngine { diagnose: async () => { await this.#ctx.tax.diagnoseEntry(entry); await refs.diagnose(); + await opening?.diagnose(); }, }); return entry; diff --git a/packages/core-ts/src/store/site-permalinks.ts b/packages/core-ts/src/store/site-permalinks.ts index 9de48580..8b6995ca 100644 --- a/packages/core-ts/src/store/site-permalinks.ts +++ b/packages/core-ts/src/store/site-permalinks.ts @@ -111,7 +111,7 @@ async function checkPermalinks( } /** Media URLs, the search page and locale prefixes are not entry prefixes. */ -function checkReservedPrefix(site: Site, prefix: string): void { +export function checkReservedPrefix(site: Site, prefix: string): void { checkReservedPath(`${prefix}/`); const first = prefix.split("/")[1] ?? ""; const locales = [site.defaultLocale, ...site.locales].map((l) => l.toLowerCase()); diff --git a/packages/core-ts/src/store/sites.ts b/packages/core-ts/src/store/sites.ts index 770327e2..20294867 100644 --- a/packages/core-ts/src/store/sites.ts +++ b/packages/core-ts/src/store/sites.ts @@ -13,6 +13,7 @@ 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 { planDeclarations } from "./collection-paths.ts"; import type { StoreContext } from "./context.ts"; import { planPermalinks } from "./site-permalinks.ts"; import { checkThemeConfig } from "./validation.ts"; @@ -100,6 +101,8 @@ export class SiteEngine { 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); + // RFC-0040 §3: a collection in use stays declared; a new one claims a free prefix. + const declared = await planDeclarations(this.#ctx, stored, next); const refs = await this.#ctx.assets.planRefs("site", site.id, next, stored); const written = refs.node; if (options.dryRun) return written; @@ -107,7 +110,7 @@ export class SiteEngine { "site", written, site.rev, - and(refs.guard, permalinks?.guard), + and(refs.guard, permalinks?.guard, declared?.guard), ); const hash = await nodeHash(written); const more = [ @@ -121,6 +124,7 @@ export class SiteEngine { const rev = await this.#ctx.revOf(site.id); if (rev === site.rev) { await permalinks?.diagnose(); + await declared?.diagnose(); await refs.diagnose(); } throw new ConflictError("site", site.id, site.rev, rev); diff --git a/packages/core-ts/src/testing-collections.ts b/packages/core-ts/src/testing-collections.ts index 168b5879..84f00460 100644 --- a/packages/core-ts/src/testing-collections.ts +++ b/packages/core-ts/src/testing-collections.ts @@ -5,10 +5,20 @@ */ import type { Actor, ContentEntry, Site } from "@cloudhal/core-contracts"; import { describe, expect, it } from "vitest"; +import { 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 collectionsSuite(ctx: SuiteContext): void { describe("RFC-0040 collection registry in the store", () => { let n = 0; @@ -88,6 +98,61 @@ export function collectionsSuite(ctx: SuiteContext): void { expect(await ctx.store().getSite(site.id)).toEqual(before); }); + describe("§3 invariants", () => { + const fresh = async (site: Site) => (await ctx.store().getSite(site.id)) as Site; + const withTaxonomies = async (collections?: Site["collections"]) => { + const site = await setup(collections); + await ctx.store().createDefaultTaxonomies(site, { categoryRequired: false }); + return fresh(site); + }; + + it("the first entry of a new collection may not take another's URLs", async () => { + const site = await withTaxonomies({ posts: { permalink: "/blog/{slug}" } }); + expect(await code(entry(site, "blog", "first"))).toBe("path_conflict"); + expect(await code(entry(site, "tag", "first"))).toBe("path_conflict"); + expect(await code(entry(site, "tr", "first"))).toBe("reserved_path"); + expect(await code(entry(site, "search", "first"))).toBe("reserved_path"); + expect(await ctx.store().listContentEntries(site.id)).toEqual([]); + // A free prefix, then a second entry in the now-used collection, are fine. + await entry(site, "events", "one"); + await entry(site, "events", "two"); + expect((await ctx.store().listContentEntries(site.id, "events")).length).toBe(2); + }); + + it("moving an entry into a new collection runs the same check", async () => { + const site = await withTaxonomies({ posts: { permalink: "/blog/{slug}" } }); + const e = (await entry(site, "posts", "hello")) as ContentEntry; + expect(await code(ctx.store().updateContentEntry({ ...e, collection: "blog" }))).toBe( + "path_conflict", + ); + const moved = await ctx.store().updateContentEntry({ ...e, collection: "notes" }); + expect(moved.collection).toBe("notes"); + }); + + it("declaring a collection checks its default prefix", async () => { + const site = await withTaxonomies({ posts: { permalink: "/blog/{slug}" } }); + const clash = { ...site, collections: { ...site.collections, blog: {} } }; + expect(await code(ctx.store().updateSite(clash))).toBe("path_conflict"); + const ok = { ...site, collections: { ...site.collections, events: {} } }; + expect((await ctx.store().updateSite(ok)).collections?.events).toEqual({}); + }); + + it("a declared collection with entries, or named by a taxonomy, stays declared", async () => { + const site = await withTaxonomies({ events: {}, team: {}, notes: {} }); + await entry(site, "events", "launch"); + const drop = (s: Site, key: string) => { + const { [key]: _gone, ...rest } = s.collections ?? {}; + return ctx.store().updateSite({ ...s, collections: rest }); + }; + expect(await code(drop(site, "events"))).toBe("collection_in_use"); + const tag = (await ctx.store().listTaxonomies(site.id)).find((t) => t.key === "tag"); + if (tag) await ctx.store().updateTaxonomy({ ...tag, collections: ["notes"] }); + expect(await code(drop(await fresh(site), "notes"))).toBe("collection_in_use"); + const after = await drop(await fresh(site), "team"); + expect(Object.keys(after.collections ?? {}).sort()).toEqual(["events", "notes"]); + }); + }); + it("an unknown site is not found", async () => { await expect(ctx.store().listCollections("site_01HZX8K3Q9ZZ")).rejects.toThrow("not found"); }); diff --git a/packages/core-ts/src/testing-permalinks.ts b/packages/core-ts/src/testing-permalinks.ts index 874d8f86..4f37246c 100644 --- a/packages/core-ts/src/testing-permalinks.ts +++ b/packages/core-ts/src/testing-permalinks.ts @@ -70,7 +70,8 @@ export function permalinksSuite(ctx: SuiteContext): void { 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; + // RFC-0040: back to the default keeps posts declared (`{}`), as the tools and Studio do. + const collections = { ...others, posts: permalink ? { permalink } : {} }; return ctx.store().updateSite({ ...s, collections }); }; const location = async (site: Site, path: string, locale?: string) => diff --git a/packages/mcp-server/src/common.ts b/packages/mcp-server/src/common.ts index 8e67956f..02179b61 100644 --- a/packages/mcp-server/src/common.ts +++ b/packages/mcp-server/src/common.ts @@ -418,6 +418,12 @@ function invariantHint(e: InvariantError): string | undefined { return "save it as a draft; a person reviews machine translations of this collection before they publish"; case "host_taken": return "pick another hostname; this one already serves another site of the installation"; + case "collection_undeclared": + return "call list_collections and use one of them, or create_collection first (a person approves it)"; + case "collection_in_use": + return "the collection still has entries or a taxonomy names it; move or delete those first"; + case "collection_exists": + return "the collection is already declared; change it with update_collection"; case "invalid_surface": return "fix the parts named by the JSON Pointers in details.issues; get_catalog lists every component, its props and the binding rules"; default: diff --git a/packages/mcp-server/src/permalink-tools.ts b/packages/mcp-server/src/permalink-tools.ts index 9b37602b..fd12e332 100644 --- a/packages/mcp-server/src/permalink-tools.ts +++ b/packages/mcp-server/src/permalink-tools.ts @@ -45,7 +45,9 @@ function nextSite(site: Site, collection: string, permalink: string | null): Sit const { [collection]: current, ...others } = site.collections ?? {}; const { permalink: _old, ...rest } = current ?? {}; const settings = permalink ? { ...rest, permalink } : rest; - const collections = Object.keys(settings).length ? { ...others, [collection]: settings } : others; + // RFC-0040: a declared collection stays declared when its permalink goes back to the default. + const keep = Object.keys(settings).length > 0 || current !== undefined; + const collections = keep ? { ...others, [collection]: settings } : others; const { collections: _drop, ...base } = site; return Object.keys(collections).length ? { ...base, collections } : (base as Site); } diff --git a/packages/mcp-server/test/permalinks.test.ts b/packages/mcp-server/test/permalinks.test.ts index b969a0fb..11e90033 100644 --- a/packages/mcp-server/test/permalinks.test.ts +++ b/packages/mcp-server/test/permalinks.test.ts @@ -121,7 +121,8 @@ describe("set_collection_permalink (RFC-0038 §5)", () => { expect(back.approvalPrompt).toContain("/blog/{slug} to /posts/{slug} (the default)"); expect(back.approvalPrompt).toContain("− redirect /posts → /blog (it would point to itself)"); const reset = await run({ permalink: null }, true); - expect((reset.result as { site: Site }).site.collections).toBeUndefined(); + // RFC-0040: the collection stays declared, with no settings. + expect((reset.result as { site: Site }).site.collections).toEqual({ posts: {} }); }); it("an overlap or a no-op is refused before a person is asked", async () => {