Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/collections-store-api.md
Original file line number Diff line number Diff line change
@@ -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 `/<collection>` 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.
8 changes: 5 additions & 3 deletions apps/studio/src/lib/collection-permalinks.ts
Original file line number Diff line number Diff line change
@@ -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, `/<collection>`, and removes the setting.
* an empty field is the default, `/<collection>`, 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";
Expand Down Expand Up @@ -44,15 +45,16 @@ 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"]> = { ...site.collections };
for (const [key, raw] of Object.entries(form)) {
const prefix = raw.trim();
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;
Expand Down
3 changes: 2 additions & 1 deletion apps/studio/test/collection-settings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
Expand Down
3 changes: 3 additions & 0 deletions packages/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -71,6 +72,8 @@ const FENCED_AUTH_PATHS = /^\/api\/auth\/(organization|admin|api-key)(\/|$)/;
const SITE_AREAS: readonly (readonly [string, Hono<Env>])[] = [
["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],
Expand Down
4 changes: 4 additions & 0 deletions packages/api/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ export const INVARIANT_STATUS: Record<InvariantCode, 400 | 409 | 410 | 415 | 422
too_large: 409,
slug_taken: 409,
path_conflict: 409,
// RFC-0040
collection_undeclared: 422,
collection_in_use: 409,
collection_exists: 409,
reserved_key: 400,
immutable_field: 400,
primary_not_assigned: 422,
Expand Down
4 changes: 4 additions & 0 deletions packages/api/src/playground.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,10 @@ export const PLAYGROUND_ALLOW: ReadonlySet<string> = 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`,
Expand Down
73 changes: 73 additions & 0 deletions packages/api/src/routes/collections.ts
Original file line number Diff line number Diff line change
@@ -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<Env> = new Hono<Env>()
.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) });
});
9 changes: 9 additions & 0 deletions packages/api/src/routes/entries.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -43,6 +45,13 @@ export const entries: Hono<Env> = new Hono<Env>()
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).
Expand Down
4 changes: 4 additions & 0 deletions packages/api/src/scopes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
138 changes: 138 additions & 0 deletions packages/api/src/testing-collections.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

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<typeof createApi>;
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);
});
});
}
8 changes: 4 additions & 4 deletions packages/api/src/testing-permalinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
32 changes: 32 additions & 0 deletions packages/api/test/collections-api.test.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof createMiniflareD1>> | 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();
},
});
Loading
Loading