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
6 changes: 6 additions & 0 deletions .changeset/collection-permalinks-step2.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions docs/rfcs/0038-collection-permalinks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `/<collection>` 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.
46 changes: 46 additions & 0 deletions packages/astro-renderer/test/permalinks.test.ts
Original file line number Diff line number Diff line change
@@ -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/<slug> and redirects /posts/<slug> 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",
});
}
});
});
9 changes: 9 additions & 0 deletions packages/core-ts/src/node-sql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
87 changes: 87 additions & 0 deletions packages/core-ts/src/permalink-paths.ts
Original file line number Diff line number Diff line change
@@ -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 `/<collection>`. */
readonly configured: boolean;
}

/**
* The prefixes entries answer under: every collection with its own template, and the default
* `/<collection>` of each name in `used` (collections that have entries) that has none.
*/
export function collectionPrefixes(
site: PermalinkSite | undefined,
used: Iterable<string>,
): 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],
};
}
38 changes: 38 additions & 0 deletions packages/core-ts/src/permalinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
22 changes: 21 additions & 1 deletion packages/core-ts/src/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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<unknown>,
): Promise<boolean> {
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);
}
Expand Down
17 changes: 9 additions & 8 deletions packages/core-ts/src/store/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -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();
},
});
Expand All @@ -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);
Expand All @@ -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,
Expand All @@ -123,22 +124,22 @@ 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);
}
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();
},
});
Expand Down
Loading
Loading