@murumets-ee/content 0.75.0 → 0.76.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admin.mjs +1 -1
- package/dist/block-resolver.d.mts +5 -0
- package/dist/block-resolver.d.mts.map +1 -1
- package/dist/block-resolver.mjs +1 -1
- package/dist/block-resolver.mjs.map +1 -1
- package/dist/client.mjs +1 -1
- package/dist/enumerate-CBqu63Kp.mjs +2 -0
- package/dist/{enumerate-B5tWe9JC.mjs.map → enumerate-CBqu63Kp.mjs.map} +1 -1
- package/dist/plugin-BxOHoKPR.mjs +2 -0
- package/dist/{plugin-DEYaEeWC.mjs.map → plugin-BxOHoKPR.mjs.map} +1 -1
- package/dist/plugin.mjs +1 -1
- package/dist/resolve-context-MRZZ3rQl.mjs +2 -0
- package/dist/{resolve-context-rr-Eanwp.mjs.map → resolve-context-MRZZ3rQl.mjs.map} +1 -1
- package/dist/resolve.mjs +2 -2
- package/dist/resolve.mjs.map +1 -1
- package/dist/routes-C9I1iZw8.mjs +2 -0
- package/dist/{routes-DuJa-w4T.mjs.map → routes-C9I1iZw8.mjs.map} +1 -1
- package/dist/schema-0zayzpD_.mjs +2 -0
- package/dist/schema-0zayzpD_.mjs.map +1 -0
- package/dist/schema-D8sBQozw.mjs +2 -0
- package/dist/schema-D8sBQozw.mjs.map +1 -0
- package/dist/seed.d.mts +20 -1
- package/dist/seed.d.mts.map +1 -1
- package/dist/seed.mjs +1 -1
- package/dist/seed.mjs.map +1 -1
- package/dist/{versions-qld3n7B-.mjs → versions-BDxB6k_S.mjs} +2 -2
- package/dist/{versions-qld3n7B-.mjs.map → versions-BDxB6k_S.mjs.map} +1 -1
- package/package.json +9 -9
- package/dist/apply-path-s-YeCUXP.mjs +0 -2
- package/dist/apply-path-s-YeCUXP.mjs.map +0 -1
- package/dist/enumerate-B5tWe9JC.mjs +0 -2
- package/dist/plugin-DEYaEeWC.mjs +0 -2
- package/dist/resolve-context-rr-Eanwp.mjs +0 -2
- package/dist/routes-DuJa-w4T.mjs +0 -2
- package/dist/schema-BPDn8ajz.mjs +0 -2
- package/dist/schema-BPDn8ajz.mjs.map +0 -1
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"schema-BPDn8ajz.mjs","names":[],"sources":["../src/paths/errors.ts","../src/paths/generate-path.ts","../src/paths/schema.ts","../src/paths/redirect-target.ts","../src/paths/resolve.ts","../src/redirects/schema.ts"],"sourcesContent":["/**\n * Domain errors thrown by the pre-commit `pathConflictGuard` (plan/routing PR03,\n * F010). Distinct classes so the admin-ui API handler can map them to a 4xx\n * (caller-actionable) rather than a generic 500, and so callers can branch on\n * `instanceof`.\n */\n\n/**\n * A document's candidate canonical URL is already claimed by a DIFFERENT\n * document in the same locale, and it can't be auto-resolved: either the slug\n * is manually pinned (`slug_mode = 'manual'`), or the auto-suffix ladder was\n * exhausted / there is no slug to suffix. The write is aborted before commit.\n */\nexport class PathConflictError extends Error {\n readonly entityName: string\n readonly locale: string\n readonly path: string\n /** The id of the document already holding `(locale, path)`, when known. */\n readonly conflictingEntityId: string | undefined\n\n constructor(\n entityName: string,\n locale: string,\n path: string,\n conflictingEntityId?: string,\n message?: string,\n ) {\n super(\n message ??\n `Path \"${path}\" (locale \"${locale}\") is already used by another ${entityName} document. ` +\n `Choose a different slug, or set this document's slug manually.`,\n )\n this.name = 'PathConflictError'\n this.entityName = entityName\n this.locale = locale\n this.path = path\n this.conflictingEntityId = conflictingEntityId\n }\n}\n\n/**\n * A document's resolved canonical URL falls under a reserved path prefix\n * (`reservedPathPrefixes`, e.g. `/admin` or `/api`). Allowing it would make the\n * document reachable under a route the framework owns, so the write is aborted\n * before commit — for the WRITE's OWN locale only (F010's one-locale-per-write\n * scope; the boot-time lint only WARNs, and only on statically-analysable\n * patterns). A reserved-prefix hit in a DIFFERENT locale than the one being\n * written (e.g. a base-row reparent that only collides for a non-default\n * locale) isn't caught here — `pathIndexWriter`'s post-commit pass screens\n * every other locale too and refuses to persist a reserved path, but by then\n * the entity write has already committed (F013).\n */\nexport class PathReservedPrefixError extends Error {\n readonly entityName: string\n readonly path: string\n readonly prefix: string\n\n constructor(entityName: string, path: string, prefix: string) {\n super(\n `Path \"${path}\" falls under the reserved prefix \"${prefix}\" and cannot be used as a ` +\n `public URL for a ${entityName} document.`,\n )\n this.name = 'PathReservedPrefixError'\n this.entityName = entityName\n this.path = path\n this.prefix = prefix\n }\n}\n","/**\n * Shared path-generation + conflict logic (plan/routing PR03, Part 3).\n *\n * `computeCanonicalPath` resolves a routable row to its locale-relative\n * canonical URL — reusing `buildEntityHref` (the PR02-shipped token engine) and\n * expanding the `[parent.path]` token against the parent's own indexed path\n * (F004's PR03 payoff). Used by BOTH seams:\n * - `pathIndexWriter` (post-commit) — compute + upsert.\n * - `pathConflictGuard` (pre-commit, via `ensurePathAvailable`) — compute +\n * check + maybe-mutate-and-rethrow.\n *\n * so the pattern-resolution + parent-expansion logic exists exactly once.\n *\n * NOTE on the signature vs. the task brief: the brief typed\n * `computeCanonicalPath` as synchronous (`: string | null`), but the\n * `[parent.path]` expansion requires a `toolkit_paths` lookup, which is async —\n * so both functions are `Promise`-returning and take the `ToolkitPathsClient`.\n * (Same shape of necessary deviation as Task 1's `defineTable` `where`\n * predicate, which the brief typed as `SQL` but had to widen to a callback.)\n */\n\nimport {\n buildEntityHref,\n type Entity,\n matchReservedPrefix,\n resolvePattern,\n} from '@murumets-ee/entity'\nimport type { PathSkipReason } from './apply-path.js'\nimport { PathConflictError, PathReservedPrefixError } from './errors.js'\nimport type { ToolkitPathsClient } from './schema.js'\n\n/** Hard cap on auto-suffix attempts — a pathological run of taken slugs throws\n * rather than looping forever (matches the repo's no-unbounded-work posture,\n * even though this is a single-document loop, not a fan-out). */\nconst MAX_SUFFIX_ATTEMPTS = 50\n\n/** {@link computeCanonicalPath}'s result — a computed path, or `null` plus WHY (F075). */\nexport type ComputeCanonicalPathResult =\n | { path: string; skipReason?: undefined }\n | { path: null; skipReason: PathSkipReason }\n\n/**\n * Compute a row's locale-relative canonical path for `locale`, or `{ path:\n * null, skipReason }` when it can't be built — the same typed-BROKEN-link\n * contract as {@link buildEntityHref}, plus a reason a caller can log or\n * ignore (F075; see {@link PathSkipReason} for what each reason means and\n * which ones are worth surfacing).\n *\n * `[parent.path]` expansion: when `row.parent` is a set self-reference\n * (`pathParent()`), the parent's OWN canonical `toolkit_paths` path for the\n * SAME `(entityType, locale, isCanonical)` is substituted directly into the\n * PATTERN STRING (not via `buildEntityHref`'s row-based token substitution —\n * see below for why). If the parent isn't indexed yet in this locale, the\n * whole path is unresolved (`skipReason: 'parent-not-indexed'` — usually a\n * transient/ordering condition, not a permanent error). A ROOT document\n * (`row.parent` null/absent) substitutes `''` — per Phase 01 §3's settled\n * design, \"root documents expand `[parent.path]` to nothing\", so a root under\n * `/[parent.path]/[slug]` still resolves (to `/[slug]` after\n * {@link normalizePath} collapses the doubled separator).\n *\n * This can't go through `buildEntityHref`'s normal `row[token]` substitution:\n * that function treats ANY empty-string token value as a broken link\n * (`missing = true`) — correct for ordinary content tokens (an empty slug\n * really is broken), wrong for `parent.path`'s root case (empty there is\n * intentional, not missing). So `[parent.path]` is resolved against the raw\n * PATTERN STRING first (via `resolvePattern`, the same per-locale/map\n * resolution `buildEntityHref` uses internally), and the already-substituted\n * string is then handed to `buildEntityHref` for the REMAINING tokens (e.g.\n * `[slug]`), which keep the normal missing-is-broken rule.\n */\nexport async function computeCanonicalPath(args: {\n entity: Entity\n /** Locale-merged row for the target locale. */\n row: Record<string, unknown>\n locale: string\n defaultLocale: string\n pathsClient: ToolkitPathsClient\n}): Promise<ComputeCanonicalPathResult> {\n const declaredPath = args.entity.frontend?.path\n if (declaredPath === undefined) return { path: null, skipReason: 'not-routable' }\n\n const pattern = resolvePattern(declaredPath, args.locale, args.defaultLocale)\n if (pattern === null) return { path: null, skipReason: 'no-pattern-for-locale' }\n\n let effectivePattern = pattern\n if (typeof pattern === 'string' && pattern.includes('[parent.path]')) {\n const parentId = args.row.parent\n if (typeof parentId === 'string' && parentId.length > 0) {\n // Same-entity self-reference, so the parent lives in this entity's own\n // path rows — no cross-entity-type registry needed.\n const parentRow = await args.pathsClient.findOne({\n entityType: args.entity.name,\n entityId: parentId,\n locale: args.locale,\n isCanonical: true,\n })\n // parent not indexed in this locale yet — broken, not root\n if (!parentRow) return { path: null, skipReason: 'parent-not-indexed' }\n effectivePattern = pattern.replaceAll('[parent.path]', parentRow.path)\n } else {\n // Root document — expands to nothing (Phase 01 §3), not \"missing\".\n effectivePattern = pattern.replaceAll('[parent.path]', '')\n }\n }\n\n const href = buildEntityHref(effectivePattern, args.row, args.locale, args.defaultLocale)\n return href === null\n ? { path: null, skipReason: 'unresolved-token' }\n : { path: normalizePath(href) }\n}\n\n/**\n * Canonicalize a path for storage/comparison: collapse duplicate slashes and\n * strip a trailing slash (except the bare root `/`). This makes the\n * `[parent.path]` composition robust — `/[parent.path]/[slug]` with a\n * `parent.path` of `/handbook` yields `//handbook/intro`, which normalizes to\n * `/handbook/intro` — and guarantees the guard and writer compute byte-identical\n * paths for the same row (so a re-save is correctly seen as \"no change\").\n */\nexport function normalizePath(path: string): string {\n const collapsed = path.replace(/\\/{2,}/g, '/')\n return collapsed.length > 1 && collapsed.endsWith('/') ? collapsed.slice(0, -1) : collapsed\n}\n\n/**\n * Pre-commit conflict check + auto-suffix for a SINGLE locale's write. Called by\n * `pathConflictGuard`. Computes the candidate path from `data` merged onto\n * `currentRow`, rejects reserved-prefix hits and manual-slug conflicts, and —\n * for auto slugs — mutates `data.slug` in place with an incrementing suffix\n * (`hello` → `hello-2` → `hello-3`) until the path is free (bounded).\n *\n * `data.slug` mutations are persisted by the SAME entity write (the guard fires\n * before the INSERT/UPDATE and `data` is the in-flight payload), so no\n * follow-up write is needed.\n */\nexport async function ensurePathAvailable(args: {\n entity: Entity\n /** Mutable in-flight write payload — may get `data.slug` rewritten. */\n data: Record<string, unknown>\n /** Pre-write locale-merged row for `update`/`updateForLocale`; `null` on create. */\n currentRow: Record<string, unknown> | null\n /** The row id, or `undefined` on create (nothing to self-exclude). */\n id: string | undefined\n locale: string\n defaultLocale: string\n slugMode: 'auto' | 'manual'\n reservedPathPrefixes: readonly string[]\n pathsClient: ToolkitPathsClient\n}): Promise<void> {\n const {\n entity,\n data,\n currentRow,\n id,\n locale,\n defaultLocale,\n slugMode,\n reservedPathPrefixes,\n pathsClient,\n } = args\n\n let lastPath = ''\n for (let attempt = 0; attempt < MAX_SUFFIX_ATTEMPTS; attempt++) {\n // Working row = current row overlaid with the in-flight write (create:\n // `data` IS the full row, `currentRow` is null).\n const row = { ...(currentRow ?? {}), ...data }\n const computed = await computeCanonicalPath({ entity, row, locale, defaultLocale, pathsClient })\n // Typed broken link — nothing to conflict-check for this locale. Pre-commit\n // silence is correct here (unlike the post-commit writer, F075): the write\n // hasn't happened yet, so there is nothing yet to be missing a path.\n if (computed.path === null) return\n const path = computed.path\n lastPath = path\n\n // Reserved-prefix boundary — the real enforcement point (Part 2 write-time).\n const reserved = matchReservedPrefix(path, reservedPathPrefixes)\n if (reserved !== undefined) {\n throw new PathReservedPrefixError(entity.name, path, reserved)\n }\n\n // The partial-unique index guarantees at most ONE canonical row per\n // (locale, path). A hit that ISN'T this document's own row is a conflict\n // (self-match excluded — a routine re-save keeping its own path is fine;\n // on create `id` is undefined so nothing is excluded).\n const existing = await pathsClient.findOne({ locale, path, isCanonical: true })\n const isSelf =\n existing != null && existing.entityType === entity.name && existing.entityId === id\n if (existing == null || isSelf) return\n\n // Real conflict with another document.\n if (slugMode === 'manual') {\n throw new PathConflictError(entity.name, locale, path, existing.entityId)\n }\n const currentSlug = row.slug\n if (typeof currentSlug !== 'string' || currentSlug.length === 0) {\n // Auto mode but nothing to suffix — can't resolve automatically.\n throw new PathConflictError(entity.name, locale, path, existing.entityId)\n }\n // Persisted by the same write (the guard runs before the SQL is built).\n data.slug = incrementSlugSuffix(currentSlug)\n }\n\n // Exhausted the suffix ladder — pathological (50 taken slugs in a row).\n throw new PathConflictError(\n entity.name,\n locale,\n lastPath,\n undefined,\n `Could not find a free path for a ${entity.name} document near \"${lastPath}\" (locale \"${locale}\") ` +\n `after ${MAX_SUFFIX_ATTEMPTS} attempts — choose a different slug manually.`,\n )\n}\n\n/**\n * Increment a numeric slug suffix, WordPress/pathauto-style:\n * `hello` → `hello-2`, `hello-2` → `hello-3`, `hello-9` → `hello-10`.\n * A slug with no trailing `-<n>` gets `-2` appended.\n */\nexport function incrementSlugSuffix(slug: string): string {\n const match = /^(.*)-(\\d+)$/.exec(slug)\n if (match) {\n const base = match[1] ?? slug\n const n = Number.parseInt(match[2] ?? '1', 10)\n return `${base}-${n + 1}`\n }\n return `${slug}-2`\n}\n","/**\n * `toolkit_paths` — the central materialized path index (plan/routing D005/D006,\n * F007). One row per (document, locale) mapping a routable entity's row to its\n * locale-relative canonical URL. Owned by `@murumets-ee/content`; maintained by\n * the `pathIndexWriter` (post-commit) + `pathConflictGuard` (pre-commit) seams\n * on `@murumets-ee/entity`'s `AdminClient`.\n *\n * Uses `defineTable` (per plan/routing C3) so both seams read/write through a\n * typed `TableClient` — zero raw SQL.\n *\n * Uniqueness + lookup (two partial-uniques + one plain index):\n * - `UNIQUE (locale, path) WHERE is_canonical` — no two documents can claim the\n * same canonical URL in the same locale (F007's partial-unique callback form,\n * exactly as Task 1 shipped `defineTable` support for). `is_canonical = false`\n * rows (redirect history) are excluded, so an old path can coexist with the\n * live one holding the same URL. THIS is the routing invariant; it is\n * unchanged across PR03→PR04.\n * - `UNIQUE (entity_type, entity_id, locale) WHERE is_canonical` — at most ONE\n * canonical row per (document, locale). PR03 enforced this as a FULL\n * `UNIQUE (entity_type, entity_id, locale)` (no history yet, so exactly one\n * row per (document, locale) held). PR04 introduced redirect history — a\n * rename DEMOTES the old canonical (`is_canonical = false`) and INSERTS a\n * fresh one — so the FULL unique could no longer hold and migration 0058\n * dropped it. But dropping it outright left NOTHING enforcing \"one canonical\n * per document\": the demote/insert sequence, or two concurrent writers on the\n * same document, could leave TWO permanently-canonical rows — a silent,\n * permanent violation of the invariant resolution / `entity_refs` / the admin\n * UI's \"the URL for a document\" all depend on. This PARTIAL unique restores\n * the guarantee while still allowing history: only `is_canonical = true` rows\n * are in scope, so any number of demoted historical rows may coexist. It also\n * makes the writer's demote-FIRST → insert ordering DB-safe (see\n * `apply-path.ts`).\n * - `INDEX (entity_type, entity_id, locale)` — a PLAIN (non-unique) index, the\n * natural \"all rows of this document\" lookup key used by the delete sweep,\n * the parent-path resolution query, and the writer's per-locale\n * demote-then-insert (PR04). The partial unique above only covers CANONICAL\n * rows, so this plain index is still needed for the history-inclusive\n * lookups. (Migration 0058 drops the old full `..._unique` constraint and\n * creates this plain index in its place; a later PR04 migration adds the\n * partial unique.)\n */\n\nimport { column, defineTable } from '@murumets-ee/db'\nimport { sql } from 'drizzle-orm'\n\nexport const toolkitPathsTable = defineTable({\n name: 'toolkit_paths',\n columns: {\n id: column.uuid({ primaryKey: true, defaultRandom: true }),\n entityType: column.varchar({ length: 100, notNull: true, pgName: 'entity_type' }),\n entityId: column.uuid({ notNull: true, pgName: 'entity_id' }),\n locale: column.varchar({ length: 10, notNull: true }),\n /** Locale-relative canonical path: leading '/', no trailing '/'. */\n path: column.text({ notNull: true }),\n isCanonical: column.boolean({ notNull: true, default: true, pgName: 'is_canonical' }),\n createdAt: column.timestamp({\n notNull: true,\n defaultNow: true,\n withTimezone: true,\n pgName: 'created_at',\n }),\n updatedAt: column.timestamp({\n notNull: true,\n defaultNow: true,\n withTimezone: true,\n pgName: 'updated_at',\n }),\n },\n unique: [\n // No two documents share a canonical URL per locale (F007 partial-unique).\n // The one true routing invariant — unchanged from PR03.\n { on: ['locale', 'path'], where: (c) => sql`${c.isCanonical} = true` },\n // At most ONE canonical row per (document, locale). Restores (as a partial\n // unique, so redirect history still accumulates) the \"one canonical path per\n // document\" guarantee the full unique dropped in migration 0058 used to give.\n // Historical (is_canonical = false) rows are excluded from the predicate.\n { on: ['entityType', 'entityId', 'locale'], where: (c) => sql`${c.isCanonical} = true` },\n ],\n indexes: [\n // \"All rows of this document\" — canonical + historical. Plain (non-unique)\n // because PR04's redirect history keeps demoted rows here (see file header).\n { on: ['entityType', 'entityId', 'locale'] },\n ],\n})\n\n/** Backwards-compatible raw pgTable export (for the plugin `tables:` slot + migrations). */\nexport const toolkitPaths = toolkitPathsTable.table\n\n/** The full `DefinedTable` for `toolkit_paths`. */\nexport type ToolkitPathsTable = typeof toolkitPathsTable\n\n/** The typed `TableClient` for `toolkit_paths` (`toolkitPathsTable.makeClient(db)`). */\nexport type ToolkitPathsClient = ReturnType<typeof toolkitPathsTable.makeClient>\n","/**\n * Internal redirect-resolution helper (plan/routing PR04, Part 4).\n *\n * Given a `(locale, path)` that may be a HISTORICAL (demoted) URL, resolve the\n * CURRENT canonical path of the document that once lived there — the \"301 to\n * wherever this document now lives\" lookup that backs automatic redirect\n * history (D005 / the change-matrix in `04-path-lifecycle-and-redirects.md`).\n *\n * **Chain-free by construction.** Every historical row is keyed to the OWNING\n * DOCUMENT (`entity_type`, `entity_id`), never to \"the next URL in the rename\n * sequence\". So a document renamed A → B → C leaves historical rows at A and B\n * both pointing at the same document, and this helper resolves EITHER of them\n * straight to C in one hop — it never walks A → B → C. (WP `_wp_old_slug` /\n * Drupal redirect both use this keyed-to-target model — R01.)\n *\n * NOT the public resolution API. Phase 05 / PR06 owns `resolvePath` and the\n * canonical → manual → historical → 404 precedence contract (§2, §4 of Phase\n * 04); this phase ships only the underlying historical-row → current-canonical\n * mechanism. PR06 imports and wraps this. (Scope split filed as a Finding in\n * `plan/routing/progress.md`, mirroring F004's PR02/03 precedent.)\n */\n\nimport type { Logger } from '@murumets-ee/core'\nimport type { PathIndexReader } from './index-reader.js'\n\n/**\n * How many `(locale, path)` candidates a single redirect lookup will consider\n * before giving up and answering 404.\n *\n * Several documents can hold a historical row at one URL, and one whose owner\n * has no current canonical (trashed, or that locale unpublished) must not\n * shadow one that does — so the lookup walks candidates rather than trusting\n * the newest. This is the ceiling on that walk: it runs on the miss path of\n * every unmatched URL, which is where crawlers and scanners spend their time,\n * and CLAUDE.md's no-unbounded-fan-out rule applies to a per-request loop as\n * much as to a server one.\n */\nconst MAX_REDIRECT_CANDIDATES = 10\n\n/** The current canonical location a historical/canonical `(locale, path)` resolves to. */\nexport interface RedirectTarget {\n entityType: string\n entityId: string\n /** The owning document's CURRENT canonical path for this locale. */\n currentPath: string\n}\n\n/**\n * Resolve `(locale, path)` — canonical OR historical — to the current canonical\n * path of its owning document, or `null` when nothing routes there.\n *\n * Returns `null` when:\n * - No `toolkit_paths` row (of any `is_canonical`) matches `(locale, path)`.\n * - The owning document has no current canonical row for `locale` anymore\n * (its locale variant was unpublished/deleted since) — the caller treats\n * this as a 404, matching the Unpublish/Delete rows of the §2 change matrix.\n *\n * Known edge case (documented, NOT over-engineered per the brief): two DIFFERENT\n * documents can both hold a HISTORICAL row at the same `(locale, path)` if one\n * occupied that URL, moved away, and a LATER document independently claimed then\n * also moved away from it — the partial-unique only forbids two *canonical* rows\n * sharing a URL, never two historical ones. When multiple rows match we prefer\n * the most-recently-updated, but only among those that still RESOLVE: self-\n * healing / approximate, consistent with this feature's already-accepted\n * tolerance for narrow non-security drift (F014's precedent — accept the race\n * rather than add locking machinery).\n *\n * Preferring the newest *resolvable* candidate rather than simply the newest is\n * load-bearing since trashing a document releases its canonical claim while\n * keeping its history: without it, a trashed document's leftover history row\n * would out-rank and silently kill a LIVE document's redirect at the same URL —\n * the same \"a row belonging to an invisible document shadows a live one\" defect\n * that the trash-side release exists to remove, displaced one tier down.\n */\nexport async function findRedirectTarget(args: {\n // Narrow structural reader (the real `ToolkitPathsClient` satisfies it — see\n // `./index-reader.ts`). Widened from `ToolkitPathsClient` in PR06 so the\n // resolution core can inject a cast-free unit-test stub; the integration\n // tests still pass the real client, proving the assignment.\n pathsClient: PathIndexReader\n locale: string\n path: string\n /**\n * Optional, and used for ONE thing: saying so when the candidate cap was\n * reached and nothing resolved. Without it that 404 is indistinguishable\n * from a genuine miss and the pathological URL stays invisible — which is\n * the silent-truncation failure CLAUDE.md's fan-out rule names directly.\n */\n logger?: Logger\n}): Promise<RedirectTarget | null> {\n const { pathsClient, locale, path, logger } = args\n\n // Any row (canonical or historical) at this URL, most-recent first. Several\n // documents can hold a historical row at the same URL — each one lived here\n // and moved on — and the most recent is the best answer among those that can\n // still BE answered.\n //\n // Bounded, and taking only the first row would be the bug: a document whose\n // canonical is gone (trashed, so its claim was released; or its locale\n // variant unpublished) still owns its history rows, and if one of those wins\n // the pick, an unanswerable candidate would shadow a live document's\n // redirect at the same URL and turn a working 301 into a 404. So walk the\n // candidates in recency order and take the first whose owner can actually be\n // redirected to.\n //\n // `MAX_REDIRECT_CANDIDATES` is a hard ceiling per CLAUDE.md's no-unbounded-\n // fan-out rule — this runs on the miss path of every unmatched URL, which is\n // exactly where a crawler or a scanner spends its time. Beyond it we answer\n // 404 rather than keep looking; a URL contended by that many documents is\n // already pathological, and the alternative is letting one request issue\n // unbounded lookups.\n const matches = await pathsClient.findMany({\n where: { locale, path },\n orderBy: [{ column: 'updatedAt', dir: 'desc' }],\n limit: MAX_REDIRECT_CANDIDATES,\n })\n\n for (const match of matches) {\n // Resolve the owning document's CURRENT canonical row for this locale.\n // This is the single hop that makes redirects chain-free: we jump from\n // \"some URL this document used to (or does) live at\" straight to \"where it\n // lives now\".\n const canonical = await pathsClient.findOne({\n entityType: match.entityType,\n entityId: match.entityId,\n locale,\n isCanonical: true,\n })\n // This owner has no live canonical path in this locale anymore. Skip it and\n // try the next candidate rather than answering 404 on its behalf — the §2\n // change matrix's 404 is for when NO candidate resolves, not when the\n // newest one happens not to.\n if (canonical) {\n return {\n entityType: match.entityType,\n entityId: match.entityId,\n currentPath: canonical.path,\n }\n }\n }\n\n // Nothing resolved. If we also filled the candidate window, the answer may be\n // a TRUNCATION rather than a miss — say so, because the two are identical\n // from outside and only one of them is a URL somebody should look at.\n if (matches.length === MAX_REDIRECT_CANDIDATES) {\n logger?.warn?.(\n { locale, path, examined: matches.length },\n 'toolkit_paths: redirect lookup examined the full candidate window without ' +\n 'resolving one — answering 404, but a further candidate may exist',\n )\n }\n\n return null\n}\n","/**\n * The public path-resolution API (plan/routing PR06; phase\n * `05-resolution-and-enumeration-api.md` §2, phase `04` §2-4).\n *\n * `resolvePath` maps an incoming `(path, locale)` to a document, a redirect, or\n * a 404, applying the settled precedence:\n *\n * `/` homepage → canonical → manual redirect → historical redirect → none\n *\n * **Security (CLAUDE.md #1 priority).** These functions gate whether content is\n * reachable by URL. `resolvePath` publish-checks at every tier that resolves to\n * a document (homepage / canonical / historical) via the injected\n * {@link PublishChecker} — an UNPUBLISHED document in the requested locale is\n * NOT routable (`{ kind: 'none' }`). A canonical row \"claims\" its path even in\n * draft, so an unpublished canonical returns `none` WITHOUT falling through to\n * manual/historical (nothing else may resolve there). `resolveDraftPath` is the\n * ONLY function that skips publish checks — it is admin/preview-only. Its one\n * sanctioned HTTP caller is `./routes.ts` (`GET /api/admin/paths/resolve`,\n * added for #775: permission-gated `path:resolve` PLUS a per-document `view`\n * re-check). It must NEVER be wired to a `content-api` endpoint — that surface\n * is public and published-only.\n *\n * **Leaf module.** All cross-package state (path/redirect readers, the publish\n * checker, the homepage reader) is injected via `deps` — this module imports no\n * `@murumets-ee/core` and no `server-only` graph. The concrete `deps` are built\n * from an app in `./resolve-context.ts`.\n */\n\nimport type { Logger } from '@murumets-ee/core'\nimport type { Entity } from '@murumets-ee/entity'\nimport type { RedirectTarget } from '../redirects/target.js'\nimport { normalizePath } from './generate-path.js'\nimport type { PathIndexReader } from './index-reader.js'\nimport type { HomepagePointer } from './homepage.js'\nimport { findRedirectTarget } from './redirect-target.js'\n\n// ---------------------------------------------------------------------------\n// Result type (phase 05 §2 — exact shape)\n// ---------------------------------------------------------------------------\n\n/** The document that serves the requested path. */\nexport interface ResolveDocumentResult {\n kind: 'document'\n entityType: string\n id: string\n}\n/** A redirect to another URL/path. `301` permanent, `302` temporary. */\nexport interface ResolveRedirectResult {\n kind: 'redirect'\n to: string\n status: 301 | 302\n}\n/** Nothing routes here (404). */\nexport interface ResolveNoneResult {\n kind: 'none'\n}\nexport type ResolveResult = ResolveDocumentResult | ResolveRedirectResult | ResolveNoneResult\n\n// ---------------------------------------------------------------------------\n// Dependency-injection surface\n// ---------------------------------------------------------------------------\n\n/**\n * Is `(entityType, entityId)` published in `locale`? Backed by\n * `QueryClient.findById`, which applies the publish filter for any principal\n * WITHOUT `view_unpublished` (D017) — so the answer is only \"published\" if the\n * injected client's context withholds that action, which the real one\n * (`resolve-context.ts`'s reach context) does. See {@link createPublishChecker}.\n * `resolveDraftPath` bypasses this entirely.\n */\nexport interface PublishChecker {\n isPublished(entityType: string, entityId: string, locale: string): Promise<boolean>\n}\n\n/** Resolve an entity TYPE NAME to its `Entity`, or `undefined` when unregistered. */\nexport type EntityResolver = (entityType: string) => Entity | undefined\n\n/** The `QueryClient.findById` surface the publish checker needs (publish filter is internal). */\nexport interface PublishQueryClient {\n findById(id: string, options?: { locale?: string | undefined }): Promise<unknown>\n}\n\n/** Build a publish-filtering read client for an entity (real: `new QueryClient(...)`). */\nexport type QueryClientFactory = (entity: Entity) => PublishQueryClient\n\n/** Read the current homepage pointer, or `null` when unset/malformed. */\nexport type HomepageReader = () => Promise<HomepagePointer | null>\n\n/** The manual-redirect lookup surface (locale-specific + catch-all rows). */\nexport interface RedirectLookupReader {\n findOne(where: {\n fromPath: string\n /** A concrete locale, or `null` for the catch-all (all-locales) row. */\n locale: string | null\n }): Promise<{ target: RedirectTarget; status: number } | null>\n}\n\n/** Deps for {@link getCanonicalPath} — the minimal index read. */\nexport interface CanonicalPathDeps {\n pathsClient: PathIndexReader\n}\n\n/** Deps for {@link resolvePath} / {@link resolveDraftPath}. */\nexport interface ResolvePathDeps extends CanonicalPathDeps {\n redirectsClient: RedirectLookupReader\n publishChecker: PublishChecker\n homepageReader: HomepageReader\n /**\n * Optional so the unit-test deps builders stay unchanged. Its only use is\n * letting the historical-redirect lookup report a truncated candidate window\n * — see {@link findRedirectTarget}.\n */\n logger?: Logger\n}\n\n/** Deps for {@link getAlternates}. */\nexport interface AlternatesDeps extends CanonicalPathDeps {\n publishChecker: PublishChecker\n /** The locales to enumerate (configured content locales). */\n locales: readonly string[]\n}\n\n/**\n * Hop cap on manual-redirect `path`-chain following at REQUEST time (F021 /\n * F027 point 4). `collapsePathTarget` only collapses ONE hop at save time, so a\n * chain created out-of-order can still need re-hopping here; exceeding the cap\n * resolves to `none` rather than looping or 500ing.\n */\nexport const MAX_REDIRECT_HOPS = 5\n\n// ---------------------------------------------------------------------------\n// getCanonicalPath — forward index read, NO publish filter (D-note below)\n// ---------------------------------------------------------------------------\n\n/**\n * The current canonical path for `(entityType, entityId, locale)`, or `null`\n * when the document has no canonical row in that locale.\n *\n * **No publish filter — deliberate.** This matches the CURRENT behavior of\n * `resolveEntityHref` / search's URL builder (both call `AdminClient.findById`,\n * which does zero publish filtering); Task 2 re-points those consumers onto this\n * function, so changing the filtering here would be an unplanned behavior change.\n * Callers that need a publish gate compose one explicitly (resolution's canonical\n * tier does).\n */\nexport async function getCanonicalPath(\n entityType: string,\n entityId: string,\n locale: string,\n deps: CanonicalPathDeps,\n): Promise<string | null> {\n const row = await deps.pathsClient.findOne({ entityType, entityId, locale, isCanonical: true })\n return row?.path ?? null\n}\n\n// ---------------------------------------------------------------------------\n// Publish checker\n// ---------------------------------------------------------------------------\n\n/**\n * Build a {@link PublishChecker} from an entity resolver + a query-client\n * factory. `isPublished` is `false` for an unknown entity type, else\n * `findById(id, { locale }) !== null` — a non-null result means \"published in\n * that locale\" because `QueryClient` applies the publish filter (and excludes\n * trashed rows) for every principal without `view_unpublished` (D017). The\n * factory therefore has to hand back a client whose context withholds that\n * action; `resolve-context.ts`'s reach context is the one in-repo\n * implementation and does. F027 point 1.\n */\nexport function createPublishChecker(deps: {\n entityResolver: EntityResolver\n queryClientFactory: QueryClientFactory\n}): PublishChecker {\n return {\n async isPublished(entityType, entityId, locale) {\n const entity = deps.entityResolver(entityType)\n if (entity === undefined) return false\n const client = deps.queryClientFactory(entity)\n const row = await client.findById(entityId, { locale })\n return row !== null && row !== undefined\n },\n }\n}\n\n// ---------------------------------------------------------------------------\n// resolvePath / resolveDraftPath\n// ---------------------------------------------------------------------------\n\n/** The publish predicate threaded through the tiers (real, or always-true for draft). */\ntype PublishPredicate = (entityType: string, entityId: string, locale: string) => Promise<boolean>\n\n/**\n * Resolve `(path, locale)` to a document / redirect / none, publish-checking at\n * every document-resolving tier. Public request surface (phase 05 §2).\n */\nexport function resolvePath(\n path: string,\n locale: string,\n deps: ResolvePathDeps,\n): Promise<ResolveResult> {\n return resolveInternal(path, locale, deps, (entityType, entityId, loc) =>\n deps.publishChecker.isPublished(entityType, entityId, loc),\n )\n}\n\n/**\n * Admin/preview resolution — identical tier structure to {@link resolvePath} but\n * SKIPS every publish check (draft and published documents both resolve). Per\n * phase 05 §2 this surface is admin-only. Exactly ONE HTTP route may call it —\n * `./routes.ts`'s `GET /api/admin/paths/resolve` (#775), which gates on\n * `path:resolve` and re-checks the caller's `view` grant on whatever it\n * resolves. NEVER wire it to `content-api` (public, published-only).\n */\nexport function resolveDraftPath(\n path: string,\n locale: string,\n deps: ResolvePathDeps,\n): Promise<ResolveResult> {\n // The ONLY difference from resolvePath: the publish predicate is always true.\n return resolveInternal(path, locale, deps, async () => true)\n}\n\n/**\n * Shared tier engine for `resolvePath` / `resolveDraftPath`. The two differ ONLY\n * in `checkPublished` (real checker vs. always-true), so the precedence + every\n * branch stay byte-identical between the public and admin surfaces.\n */\nasync function resolveInternal(\n rawPath: string,\n locale: string,\n deps: ResolvePathDeps,\n checkPublished: PublishPredicate,\n): Promise<ResolveResult> {\n const path = normalizePath(rawPath)\n const homepage = await deps.homepageReader()\n\n // ── Tier 1: `/` homepage special case ────────────────────────────────────\n if (path === '/') {\n if (homepage === null) return { kind: 'none' }\n if (!(await checkPublished(homepage.entity, homepage.id, locale))) return { kind: 'none' }\n return { kind: 'document', entityType: homepage.entity, id: homepage.id }\n }\n\n // ── Tier 2: canonical ─────────────────────────────────────────────────────\n const canonical = await deps.pathsClient.findOne({ locale, path, isCanonical: true })\n if (canonical) {\n // The path is CLAIMED by this document even in draft — an unpublished\n // canonical returns `none` and does NOT fall through to manual/historical.\n if (!(await checkPublished(canonical.entityType, canonical.entityId, locale))) {\n return { kind: 'none' }\n }\n // D004 dedup: the homepage document's own pattern URL always 301s to `/`.\n if (isHomepage(homepage, canonical.entityType, canonical.entityId)) {\n return { kind: 'redirect', to: '/', status: 301 }\n }\n return { kind: 'document', entityType: canonical.entityType, id: canonical.entityId }\n }\n\n // ── Tier 3: manual redirects ──────────────────────────────────────────────\n const manual = await resolveManualRedirect(path, locale, deps, homepage)\n if (manual !== null) return manual\n\n // ── Tier 4: historical (automatic rename) redirects ───────────────────────\n const historical = await findRedirectTarget({\n pathsClient: deps.pathsClient,\n locale,\n path,\n ...(deps.logger !== undefined && { logger: deps.logger }),\n })\n if (historical === null) return { kind: 'none' }\n if (!(await checkPublished(historical.entityType, historical.entityId, locale))) {\n return { kind: 'none' }\n }\n if (isHomepage(homepage, historical.entityType, historical.entityId)) {\n return { kind: 'redirect', to: '/', status: 301 }\n }\n return { kind: 'redirect', to: historical.currentPath, status: 301 }\n}\n\n/**\n * Tier 3 — resolve a manual `toolkit_redirects` row at `fromPath`. Returns:\n * - `null` when NO manual redirect exists at `fromPath` (caller falls to tier 4).\n * - a {@link ResolveResult} otherwise (including `{ kind: 'none' }` for a target\n * that resolves to nothing).\n *\n * `entity`-kind targets resolve via `getCanonicalPath` with NO publish check\n * (F027 point 3 — an unpublished target 404s on the FOLLOWING request via tier\n * 2's own check). `path`-kind targets are re-resolved at request time, bounded\n * by {@link MAX_REDIRECT_HOPS}, so an out-of-order chain (F021) can't loop.\n */\nasync function resolveManualRedirect(\n fromPath: string,\n locale: string,\n deps: ResolvePathDeps,\n homepage: HomepagePointer | null,\n): Promise<ResolveResult | null> {\n let row = await lookupManualRedirect(deps.redirectsClient, fromPath, locale)\n if (row === null) return null // no manual redirect here → fall through to tier 4\n\n for (let hop = 0; hop < MAX_REDIRECT_HOPS; hop++) {\n const target = row.target\n const status = toRedirectStatus(row.status)\n\n if (target.kind === 'url') {\n return { kind: 'redirect', to: target.url, status } // terminal, external\n }\n\n if (target.kind === 'entity') {\n // Homepage-collision (D004), same as tiers 2/4 + the path-walk-hits-canonical\n // case: if the redirect points AT the homepage document, go straight to `/`\n // rather than its pattern URL (which would then 301 to `/` anyway — a pointless\n // double hop). Keep the manual redirect's own status.\n if (isHomepage(homepage, target.entity, target.id)) {\n return { kind: 'redirect', to: '/', status }\n }\n const to = await getCanonicalPath(target.entity, target.id, locale, deps)\n if (to === null) return { kind: 'none' } // target has no canonical row in this locale\n return { kind: 'redirect', to, status }\n }\n\n // target.kind === 'path' — re-resolve at request time (bounded).\n const nextPath = normalizePath(target.path)\n const nextRow = await lookupManualRedirect(deps.redirectsClient, nextPath, locale)\n if (nextRow !== null) {\n row = nextRow // hop to the next redirect and keep walking\n continue\n }\n // No further redirect at `nextPath` — does it serve canonical content?\n const canonical = await deps.pathsClient.findOne({\n locale,\n path: nextPath,\n isCanonical: true,\n })\n if (canonical) {\n if (isHomepage(homepage, canonical.entityType, canonical.entityId)) {\n return { kind: 'redirect', to: '/', status: 301 }\n }\n return { kind: 'redirect', to: nextPath, status }\n }\n return { kind: 'none' } // path target points at neither a redirect nor content\n }\n\n return { kind: 'none' } // exceeded MAX_REDIRECT_HOPS — never loop, never 500\n}\n\n/**\n * Look up a manual redirect at `fromPath`: a locale-specific row wins, else the\n * catch-all (`locale = null`) row. Mirrors `collapsePathTarget`'s precedence.\n */\nasync function lookupManualRedirect(\n client: RedirectLookupReader,\n fromPath: string,\n locale: string,\n): Promise<{ target: RedirectTarget; status: number } | null> {\n const exact = await client.findOne({ fromPath, locale })\n if (exact) return exact\n return client.findOne({ fromPath, locale: null })\n}\n\n// ---------------------------------------------------------------------------\n// getAlternates\n// ---------------------------------------------------------------------------\n\n/**\n * The published per-locale canonical URLs for a document — the `hreflang`\n * alternates set (phase 05 §2, \"published locales only\"). A locale is included\n * only when the document has a canonical row AND is published there. Bounded by\n * the (small, fixed) configured-locale count — no fan-out concern.\n */\nexport async function getAlternates(\n entityType: string,\n id: string,\n deps: AlternatesDeps,\n): Promise<Array<{ locale: string; path: string }>> {\n const alternates: Array<{ locale: string; path: string }> = []\n for (const locale of deps.locales) {\n const path = await getCanonicalPath(entityType, id, locale, deps)\n if (path === null) continue\n if (!(await deps.publishChecker.isPublished(entityType, id, locale))) continue\n alternates.push({ locale, path })\n }\n return alternates\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\n/** Is `(entityType, entityId)` the configured homepage document? */\nfunction isHomepage(\n homepage: HomepagePointer | null,\n entityType: string,\n entityId: string,\n): boolean {\n return homepage !== null && homepage.entity === entityType && homepage.id === entityId\n}\n\n/**\n * Narrow the DB `status` integer (validated `301 | 302` at write time) to the\n * result union. Defaults to `301` for any unexpected value — never a raw number\n * leaking into the typed result.\n */\nfunction toRedirectStatus(status: number): 301 | 302 {\n return status === 302 ? 302 : 301\n}\n","/**\n * `toolkit_redirects` — the manual-redirect table (plan/routing PR05, phase\n * `04-path-lifecycle-and-redirects.md` §3). One row per admin-authored redirect:\n * a `from_path` (locale-relative source URL) → a {@link RedirectTarget}\n * (entity / url / path). Distinct from the AUTOMATIC redirect history, which lives\n * as demoted (`is_canonical = false`) rows in `toolkit_paths` — this table is only\n * for the URLs automatic history can't express (campaign URLs, migrated-site\n * paths). Owned by `@murumets-ee/content`.\n *\n * Uses `defineTable` (per plan/routing C3, CLAUDE.md \"no raw SQL / typed APIs\n * only\") so reads/writes go through a typed `TableClient` — Task 2's CRUD routes\n * and the save-time validation (`./validation.ts`) never touch raw SQL.\n *\n * ## Uniqueness — TWO partial-unique indexes (F020 point 4)\n *\n * `locale` is nullable: `null` means \"applies to all locales\" (a catch-all\n * redirect). Postgres treats `NULL`s as DISTINCT in an ordinary unique constraint,\n * so a naive `UNIQUE (locale, from_path)` would silently allow duplicate catch-all\n * rows at the same path. Two partial-uniques express the real intent:\n *\n * - `UNIQUE (locale, from_path) WHERE locale IS NOT NULL` — a locale-specific\n * redirect is unique per (locale, from_path).\n * - `UNIQUE (from_path) WHERE locale IS NULL` — at most ONE catch-all redirect per\n * from_path.\n *\n * A locale-specific row and a catch-all row MAY legitimately coexist at the same\n * `from_path` (intentional per-locale override layering) — the two indexes don't\n * forbid that, only duplicates WITHIN each stratum. Both indexes are keyed on\n * `from_path`, so the \"does this from_path already exist as ANY redirect\"\n * collapse-lookup (`./validation.ts`) is already covered — no extra plain index\n * needed.\n *\n * Both use the callback `where` form (F007's `defineTable` partial-unique support)\n * with an INLINE `IS NULL` / `IS NOT NULL` literal — never `eq()`, which would bind\n * a `$1` param that is invalid in DDL (CLAUDE.md partial-unique gotcha; same form\n * `toolkit_paths` uses).\n */\n\nimport { column, defineTable } from '@murumets-ee/db'\nimport { sql } from 'drizzle-orm'\nimport type { RedirectTarget } from './target.js'\n\nexport const toolkitRedirectsTable = defineTable({\n name: 'toolkit_redirects',\n columns: {\n id: column.uuid({ primaryKey: true, defaultRandom: true }),\n /** BCP47 locale, or `null` = applies to all locales (catch-all). */\n locale: column.varchar({ length: 10 }),\n /** Locale-relative source URL: leading '/', no trailing '/' (same convention as `toolkit_paths.path`). */\n fromPath: column.text({ notNull: true, pgName: 'from_path' }),\n /** The redirect destination — see {@link RedirectTarget}. Stored as the discriminated-union object. */\n target: column.jsonb<RedirectTarget, true>({ notNull: true }),\n /** HTTP status. `301 | 302` is validated at the Zod boundary in Task 2's route layer. */\n status: column.integer({ notNull: true, default: 301 }),\n /**\n * The admin who authored the redirect. VARCHAR, never UUID — better-auth user\n * ids are random strings (CLAUDE.md). Nullable: system-created rows (future\n * migration/import tooling) have no natural actor, matching how other\n * audit-style columns in `defineTable` tables stay nullable.\n */\n createdBy: column.varchar({ length: 255, pgName: 'created_by' }),\n createdAt: column.timestamp({\n notNull: true,\n defaultNow: true,\n withTimezone: true,\n pgName: 'created_at',\n }),\n updatedAt: column.timestamp({\n notNull: true,\n defaultNow: true,\n withTimezone: true,\n pgName: 'updated_at',\n }),\n },\n unique: [\n // A locale-specific redirect is unique per (locale, from_path). Excludes\n // catch-all rows (locale IS NULL) from this stratum.\n { on: ['locale', 'fromPath'], where: (c) => sql`${c.locale} IS NOT NULL` },\n // At most ONE catch-all (all-locales) redirect per from_path. Postgres would\n // otherwise treat every NULL locale as distinct and allow duplicates.\n { on: ['fromPath'], where: (c) => sql`${c.locale} IS NULL` },\n ],\n})\n\n/** Backwards-compatible raw pgTable export (for the plugin `tables:` slot + migrations). */\nexport const toolkitRedirects = toolkitRedirectsTable.table\n\n/** The full `DefinedTable` for `toolkit_redirects`. */\nexport type ToolkitRedirectsTable = typeof toolkitRedirectsTable\n\n/** The typed `TableClient` for `toolkit_redirects` (`toolkitRedirectsTable.makeClient(db)`). */\nexport type ToolkitRedirectsClient = ReturnType<typeof toolkitRedirectsTable.makeClient>\n"],"mappings":"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"}
|