@murumets-ee/content 0.86.0 → 0.88.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.d.mts.map +1 -1
- package/dist/admin.mjs +1 -1
- package/dist/block-resolver.d.mts +21 -1
- 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.d.mts +1 -1
- package/dist/client.mjs +1 -1
- package/dist/client.mjs.map +1 -1
- package/dist/{enumerate-CBqu63Kp.mjs → enumerate-CYHJKQz6.mjs} +2 -2
- package/dist/{enumerate-CBqu63Kp.mjs.map → enumerate-CYHJKQz6.mjs.map} +1 -1
- package/dist/{index-C2G4IJck.d.mts → index-C3VyZLjf.d.mts} +55 -2
- package/dist/{index-C2G4IJck.d.mts.map → index-C3VyZLjf.d.mts.map} +1 -1
- package/dist/index.d.mts +3 -3
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/locale-entity-CoSq3C77.mjs +2 -0
- package/dist/locale-entity-CoSq3C77.mjs.map +1 -0
- package/dist/locale-registry-Bn6e_nM7.mjs +2 -0
- package/dist/locale-registry-Bn6e_nM7.mjs.map +1 -0
- package/dist/plugin-BmxghXzV.mjs +2 -0
- package/dist/plugin-BmxghXzV.mjs.map +1 -0
- package/dist/plugin.d.mts +4 -4
- package/dist/plugin.mjs +1 -1
- package/dist/resolve-context-Cvc1aNJe.mjs +2 -0
- package/dist/resolve-context-Cvc1aNJe.mjs.map +1 -0
- package/dist/resolve.d.mts +54 -16
- package/dist/resolve.d.mts.map +1 -1
- package/dist/resolve.mjs +6 -6
- package/dist/resolve.mjs.map +1 -1
- package/dist/routes-BmNSYyuT.mjs +2 -0
- package/dist/{routes-C9I1iZw8.mjs.map → routes-BmNSYyuT.mjs.map} +1 -1
- package/dist/routing-strategy-D6B4ngT9.mjs +2 -0
- package/dist/routing-strategy-D6B4ngT9.mjs.map +1 -0
- package/dist/{routing-strategy-D3Qowm3D.d.mts → routing-strategy-DV7KGarX.d.mts} +4 -4
- package/dist/{routing-strategy-D3Qowm3D.d.mts.map → routing-strategy-DV7KGarX.d.mts.map} +1 -1
- package/dist/schema-BYrYzsmO.mjs +2 -0
- package/dist/schema-BYrYzsmO.mjs.map +1 -0
- package/dist/schema-IML1rRU_.mjs +2 -0
- package/dist/{schema-0zayzpD_.mjs.map → schema-IML1rRU_.mjs.map} +1 -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-C9A4rUAR.mjs → versions-DLrR7hIZ.mjs} +2 -2
- package/dist/{versions-C9A4rUAR.mjs.map → versions-DLrR7hIZ.mjs.map} +1 -1
- package/package.json +9 -9
- package/dist/plugin-D0i6odXQ.mjs +0 -2
- package/dist/plugin-D0i6odXQ.mjs.map +0 -1
- package/dist/resolve-context-MRZZ3rQl.mjs +0 -2
- package/dist/resolve-context-MRZZ3rQl.mjs.map +0 -1
- package/dist/routes-C9I1iZw8.mjs +0 -2
- package/dist/routing-strategy-CQdu1qV7.mjs +0 -2
- package/dist/routing-strategy-CQdu1qV7.mjs.map +0 -1
- package/dist/schema-0zayzpD_.mjs +0 -2
- package/dist/schema-D8sBQozw.mjs +0 -2
- package/dist/schema-D8sBQozw.mjs.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schema-0zayzpD_.mjs","names":[],"sources":["../src/paths/errors.ts","../src/paths/generate-path.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 * 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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"}
|
|
1
|
+
{"version":3,"file":"schema-IML1rRU_.mjs","names":[],"sources":["../src/paths/errors.ts","../src/paths/generate-path.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 * 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 } from '@murumets-ee/db'\nimport { sql } from 'drizzle-orm'\nimport { defineSitePartitionedTable } from '../schema/site-partition.js'\nimport type { RedirectTarget } from './target.js'\n\n/**\n * Per site (plan `multisite` phase 03, D005): partitioned by `_site` — both\n * uniques below lead with it, so each site keeps its own redirects from the\n * same `from_path`. A client cannot be built without a site.\n */\nexport const toolkitRedirectsTable = defineSitePartitionedTable({\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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"}
|
package/dist/seed.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"seed.d.mts","names":[],"sources":["../src/seed.ts"],"mappings":";;
|
|
1
|
+
{"version":3,"file":"seed.d.mts","names":[],"sources":["../src/seed.ts"],"mappings":";;AAwOA;;;;AAAyD;AA8FzD;;;;;;;;AAEiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAhGK,cAAA,CAAA,GAAkB,OAAO;EAAG,OAAO;AAAA;;;;;;;;;;;;;;;;;;;iBA8FnC,iBAAA,CACpB,WAAA,sBACC,OAAO,CAAC,MAAA"}
|
package/dist/seed.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{t as e}from"./chrome-entity-names-zYJzP-_U.mjs";import{
|
|
1
|
+
import{t as e}from"./chrome-entity-names-zYJzP-_U.mjs";import{n as t,s as n}from"./schema-BYrYzsmO.mjs";import{n as r,t as i}from"./site-chrome-DvP9SXGu.mjs";import{getApp as a,requireRequestSite as o,runInEverySite as s}from"@murumets-ee/core";import{eq as c}from"drizzle-orm";import{createAdminClient as l}from"@murumets-ee/core/clients";import{randomUUID as u}from"node:crypto";const d={header:{handle:`main`},footer:{handle:`footer`}};function f(e){return[{id:u(),type:`menu`,props:{handle:d[e].handle,orientation:`horizontal`},traits:{}}]}async function p(){let t=a(),n=t.entities.get(e);return n?{created:(await s(()=>m(n),{app:t,label:`content:site-chrome`})).reduce((e,t)=>e+t.created,0)}:{created:0}}async function m(t){let a=l(t),o=0,s=a.getTable().placement;if(!s)throw Error(`${e} has no "placement" column — the chrome entity registered by this app is not the one @murumets-ee/content ships.`);for(let e of i)if(!((await a.findMany({where:c(s,e),limit:1})).length>0))try{await a.create({placement:e,label:r[e],layout:f(e)}),o++}catch(e){if(!n(e))throw e}return{created:o}}async function h(e){let n=t.makeClient(a().db.readWrite,await o(`toolkit_paths`)),r={};for(let t of e)r[t]=(await n.deleteMany({entityType:t})).length;return r}export{h as forgetEntityPaths,p as seedSiteChrome};
|
|
2
2
|
//# sourceMappingURL=seed.mjs.map
|
package/dist/seed.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"seed.mjs","names":[],"sources":["../src/seed.ts"],"sourcesContent":["/**\n * The site-chrome seeder — creates the one `site_chrome` row per placement\n * that a site needs, POPULATED, before any of them can be edited.\n *\n * Contributed as `content:site-chrome` from the `content()` plugin factory and\n * reached through the `@murumets-ee/content/seed` subpath — its own build\n * entry so the lazy import keeps `@murumets-ee/core/clients`, which carries\n * `import 'server-only'`, off the config-load path.\n *\n * ## Why a seeder at all, and why nobody has to run it\n *\n * An unseeded chrome document is indistinguishable from a site that HAS no\n * header, and both the canvas and `/v1/_chrome` treat it that way — they\n * compose nothing and let the consumer render its own fallback. That is right,\n * and it means nothing ever CREATES the row, so without this an editor has no\n * header to open.\n *\n * Until PR30 that left the gap S5 names: nothing in the printed setup path ran\n * this, so a scaffolded project booted with an empty Site list and the only\n * bootstrap was a human pressing \"New\". The plugin now declares this seeder\n * `bootstrap: true`, and `lumi migrate` runs every bootstrap seeder after it\n * applies migrations — which is already step 4 of the printed next-steps. It\n * stays an ordinary named seeder too, runnable from `lumi seed` and the admin\n * Seed page; `bootstrap` adds a trigger rather than changing what it is.\n *\n * ## Why the entity is resolved by NAME\n *\n * This package ships the chrome entity as a FACTORY (`siteChromeEntity()`),\n * because an app must pass options it cannot get from here — `withOpLog()`\n * ships from `@murumets-ee/blocks/server`, and content must not depend on\n * blocks (blocks depends back on content). So there is no entity object in\n * this package to import; there is only the name the factory stamps on\n * whatever the app instantiated.\n *\n * That name is not an assumption: `site-chrome.ts` hardcodes\n * `name: 'site_chrome'`, so an app can configure the entity but cannot rename\n * it. This package's own chrome PROVIDER resolves it the same way\n * (`app.entities.get(name)`, `null` when absent), and `@murumets-ee/auth`'s\n * default permission maps and `@murumets-ee/frontend-cache`'s\n * `DEFAULT_CHROME_ENTITIES` name the same string.\n *\n * ## Why it is `available`-gated rather than merely tolerant\n *\n * An app is free to register no chrome at all. Seeding plan F009 deferred this\n * seeder on exactly that: contributed unconditionally it would put a button on\n * the Seed page whose entire outcome is `created: 0`, which a reader takes as\n * \"this seeder is broken\", not as \"this project has no chrome\". The plugin\n * declares `available` so the button is ABSENT instead — the same contract\n * `rent:cars-demo` has, where absence follows from the plugin's absence rather\n * than from a silent no-op.\n *\n * ## Idempotent by construction, not by a flag\n *\n * Every run finds before it creates, keyed on `placement`. That is what makes\n * a second run a no-op — necessary now in a way it was not before, because\n * `lumi migrate` runs this on EVERY migrate, not once. It is also why the\n * find is a `where` on `placement` rather than a bare `limit: 1`: the old\n * `kind: 'singleton'` made \"is there a row\" the whole question, and under one\n * entity holding two rows it is the wrong question — a bare existence check\n * would see the header and never create the footer.\n *\n * ## DRAFT, deliberately — and this reverses an earlier choice\n *\n * The rows are created unpublished, so `lumi migrate` changes nothing a\n * visitor sees. That is not a detail; it is what makes `bootstrap: true` safe,\n * because this now runs on EVERY migrate, in production, against a database\n * full of a customer's content (see `PluginSeeder.bootstrap`). A provisioning\n * step that silently alters the public site is not additive.\n *\n * This file used to create them PUBLISHED, reasoning that \"a draft-seeded site\n * is indistinguishable from an unseeded one to every public reader\" and that\n * \"the rows are EMPTY, so publishing them renders nothing\". The first half is\n * true and is the POINT: an empty chrome IS unseeded, and a public reader\n * should see exactly what they saw before. The second half was measured and is\n * false.\n *\n * Measured, on a running front-astro against a real CMS: an empty PUBLISHED\n * region arrives on the wire as `{ blocks: [] }`, which `placeRegion` reads as\n * `hidden` — \"the page hid this on purpose\" — and `rendersFallback` returns\n * false for `hidden`. So publishing an empty row does not render nothing; it\n * renders nothing AND SUPPRESSES THE TEMPLATE'S OWN FALLBACK. A freshly\n * scaffolded site lost its `<header>` and `<footer>` landmarks entirely, where\n * before it rendered the fallback header with its Home link and nav.\n *\n * `null` (no published row) reads as `fallback` instead, which is the answer\n * that leaves the delivery template in charge until somebody has actually\n * authored a header. The moment they do and press Publish, the CMS takes over\n * — ordinary publish semantics, the same as every other document.\n *\n * ## EMPTY is what starved the canvas — and only the SITE was ever considered\n *\n * The section above is right and stays. Its blind spot is that it reasons\n * entirely about what a VISITOR sees. The rows it creates were also `layout:\n * []`, and the comment on that line said an empty tree \"is a header with no\n * blocks yet, which is what the editor opens onto\" — which is true, and is the\n * defect.\n *\n * The live site has a fallback: no published chrome row reads as `fallback`\n * and the delivery template renders its own header. **The editing canvas has\n * no fallback.** Its only channel for chrome is these rows — it is React in the\n * admin app and cannot render an Astro template's markup — so an empty row\n * composes to zero blocks and `CanvasDocument` emits a bare `<div>` with\n * nothing in it. Every page, forever, on every project ever scaffolded. The\n * site looked right the whole time, which is why no gate and no person caught\n * it for months (`editor` D014).\n *\n * So the two properties are independent and both are required: the rows are\n * DRAFT (nothing a visitor sees changes) and NON-EMPTY (the canvas has a header\n * and a footer to paint). Draft is what makes non-empty safe — the tree is\n * real, and no visitor sees it until somebody publishes.\n *\n * ## What this canNOT fix, stated because it is the interesting half\n *\n * This seeder is ADDITIVE: `if (existing.length > 0) continue`. An install\n * whose rows already exist — every project bootstrapped before this change —\n * keeps its empty ones, and must, because overwriting an authored header is the\n * one thing a bootstrap seeder may never do. The same hole reopens for anyone\n * who deletes their header's last block.\n *\n * That half belongs to the canvas and is fixed there: an empty non-page\n * document now renders a NAMED region band rather than nothing\n * (`block-editor-canvas.tsx`'s `EndAddZone`). This seeder decides what a NEW\n * site starts with; the canvas decides that an empty region is never invisible.\n *\n * ## What it assumes about its caller\n *\n * An admin context — `runSeederAction`, `lumi seed` and `lumi migrate`'s\n * bootstrap pass all establish one. Auth, the admin-only role check and the\n * production gate live there.\n *\n * The entity NAME and the `available` predicate live one module over\n * (`./chrome-entity-names.ts`), which imports nothing — `plugin.ts` needs the\n * predicate statically and must not pull this file's `server-only` client\n * along with it.\n */\n\nimport { randomUUID } from 'node:crypto'\nimport { getApp } from '@murumets-ee/core'\nimport { createAdminClient } from '@murumets-ee/core/clients'\nimport { eq } from 'drizzle-orm'\nimport { CHROME_ENTITY_NAME } from './chrome-entity-names.js'\nimport {\n CHROME_PLACEMENT_LABELS,\n CHROME_PLACEMENTS,\n type ChromePlacement,\n} from './entities/site-chrome.js'\nimport { isUniqueViolation } from './paths/apply-path.js'\nimport { toolkitPathsTable } from './paths/schema.js'\n\n/**\n * The `menu` block's registered type name, and the handle each placement's\n * default nav points at.\n *\n * ## Why a block instance can be written from HERE at all\n *\n * A block instance is plain JSON — `{ id, type, props, traits }` — so seeding\n * one needs no import. That matters: `@murumets-ee/content` must not depend on\n * `@murumets-ee/blocks` (blocks depends back on content, see this package's\n * chrome-entity docblock), so the type NAME is the whole contract and there is\n * no definition object to reach for.\n *\n * ## Why `menu`, and not something with words in it\n *\n * A header holds navigation. `menu` is the only block in the canonical\n * vocabulary that renders navigation, it is in `canonicalBlocks` — so it is in\n * the `page` environment's BASE registry and cannot be removed by an extension\n * (`environment-registry.ts` merges base ⊕ extensions and later contributors\n * only ever shadow by name) — and `themes/structural`'s `MenuRender` paints it\n * in the default theme. So the one block this seeder writes is the one block\n * every install is guaranteed to be able to paint.\n *\n * The alternative was a `text` block carrying a site name or a copyright line.\n * That is FABRICATED CONTENT: a string in a customer's header that they did\n * not write and must now find and delete. A `menu` block carries no words of\n * ours — it renders whatever menu the operator publishes at the handle, and\n * nothing at all before then.\n *\n * ## What it looks like before a menu exists\n *\n * A fresh scaffold has no `Menu` rows: `menus:main` is not a `bootstrap`\n * seeder, so `lumi migrate` does not run it. Until one exists, `getMenu`\n * answers `null` and the block renders its own documented inert state — an\n * empty `<nav>` on the live site, and inside the editor a labelled\n * `Menu: main` placeholder that is selectable and opens `@murumets-ee/menus-ui`'s\n * tree editor (the block declares `customEditorSurface`).\n *\n * That is the honest render, and it is the useful one: the canvas shows a\n * header, in the header's place, containing the thing a header contains, and\n * clicking it is the route to the tool that fills it.\n *\n * `'main'` and `'footer'` are the handles `MenuBlock`'s own docblock names, and\n * `'main'` is what `apps/front-astro`'s fallback header already fetches\n * (`PRIMARY_MENU_HANDLE`) and what `menus:main` creates.\n */\nconst CHROME_DEFAULT_MENU: Readonly<Record<ChromePlacement, { handle: string }>> = {\n header: { handle: 'main' },\n footer: { handle: 'footer' },\n}\n\n/**\n * The block tree a bootstrapped chrome row starts life with.\n *\n * Returns a fresh array with a fresh block id per call: block ids are the keys\n * a page's `_overrides` map is written under (`compose-chrome.ts`'s\n * `tagScopedRegion` uses `block.id` as `scopeBlockId`), so two rows sharing one\n * id would make a per-page override of the header also target the footer.\n *\n * `traits: {}` rather than omitted — `BlockInstance.traits` is required, and\n * the editor's own palette inserts carry it.\n *\n * Typed as the block-tree node shape rather than `Record<string, unknown>`. The\n * looser type only compiled because the widened column-value union used to\n * include `field.richtext()`'s old `Record<string, unknown>[]` — the very type\n * that let an array be written into a text column (F105).\n */\nfunction defaultChromeLayout(placement: ChromePlacement): Array<{\n id: string\n type: string\n props: Record<string, unknown>\n traits: Record<string, unknown>\n}> {\n return [\n {\n id: randomUUID(),\n type: 'menu',\n props: { handle: CHROME_DEFAULT_MENU[placement].handle, orientation: 'horizontal' },\n traits: {},\n },\n ]\n}\n\nexport async function seedSiteChrome(): Promise<{ created: number }> {\n const app = getApp()\n const entity = app.entities.get(CHROME_ENTITY_NAME)\n // Absent rather than thrown: `available` guarantees the app registered the\n // entity at the moment the list was built, and a config reloaded between\n // list and run should produce \"nothing to do\", not a stack trace.\n if (!entity) return { created: 0 }\n\n const client = createAdminClient(entity)\n let created = 0\n\n // Column refs through `getTable()` — the sanctioned accessor — never a bare\n // property on a dynamically-generated table, which would put an `any` into\n // the WHERE clause.\n const placementColumn = client.getTable().placement\n if (!placementColumn) {\n // Unreachable while the entity declares the field, and cheaper to state\n // than to debug: without it a missing column would make `eq()` build a\n // WHERE against `undefined` and the find would silently match nothing —\n // so every run would try to create, and every run after the first would\n // be absorbed by the unique-violation catch below as though it had\n // succeeded.\n throw new Error(\n `${CHROME_ENTITY_NAME} has no \"placement\" column — the chrome entity registered by this ` +\n `app is not the one @murumets-ee/content ships.`,\n )\n }\n\n for (const placement of CHROME_PLACEMENTS) {\n // Keyed on `placement`, which is also the unique column — so this find is\n // asking exactly the question the database constraint answers, and a race\n // between two callers ends in a unique violation rather than a duplicate.\n const existing = await client.findMany({ where: eq(placementColumn, placement), limit: 1 })\n if (existing.length > 0) continue\n try {\n // A POPULATED tree, not `[]` — see \"Empty is what starves the canvas\"\n // in this file's header. `defaultChromeLayout` is the one place that\n // decides what a bootstrapped region holds.\n // No `status` — `publishable()` defaults to `draft`, which is the whole\n // of the \"changes nothing a visitor sees\" property. See the header.\n await client.create({\n placement,\n label: CHROME_PLACEMENT_LABELS[placement],\n layout: defaultChromeLayout(placement),\n })\n created++\n } catch (err) {\n // Losing the find-then-create race is a SUCCESS, not a failure: someone\n // else created the row this run was about to create, which is the\n // outcome either way. `placement`'s unique constraint is what makes that\n // safe to assume — the violation can only be the row we just looked for.\n //\n // Worth the four lines rather than trusting the find, because `lumi\n // migrate`'s bootstrap pass runs this on every migrate and two deploys\n // can overlap. Payload's equivalent conditions its write on an existence\n // check that is simply WRONG on Postgres and survives only because the\n // adapter happens to re-check underneath (R036).\n if (!isUniqueViolation(err)) throw err\n }\n }\n\n return { created }\n}\n\n/**\n * Forget every `toolkit_paths` row — canonical and redirect history — for the\n * named entities. For a seeder that empties those entities' tables in bulk.\n *\n * `AdminClient.delete` drops a document's paths when the row is physically\n * removed (the path-index writer's `'delete'` action). A bulk reset that skips\n * the client — the scaffold's demo `content` seeder TRUNCATEs its tables so it\n * can re-run — leaves every old URL claimed by a document that no longer\n * exists. The re-seeded documents then collide with their own ghosts and come\n * back as `/about-2`, `/welcome-2`, and the old URLs keep answering as\n * redirects to nothing (lumi-site PR04).\n *\n * Seeder-only, like the reset it follows: it asks no permission of its own,\n * exactly as a TRUNCATE does not. The seeders that call it already run as an\n * admin — `lumi seed` under `runAsCli`, the Seed page behind its admin gate.\n *\n * Returns how many rows were removed, per entity, so a seeder can log it.\n */\nexport async function forgetEntityPaths(\n entityNames: readonly string[],\n): Promise<Record<string, number>> {\n const paths = toolkitPathsTable.makeClient(getApp().db.readWrite)\n const removed: Record<string, number> = {}\n for (const entityType of entityNames) {\n removed[entityType] = (await paths.deleteMany({ entityType })).length\n }\n return removed\n}\n"],"mappings":"4UAkMA,MAAM,EAA6E,CACjF,OAAQ,CAAE,OAAQ,MAAO,EACzB,OAAQ,CAAE,OAAQ,QAAS,CAC7B,EAkBA,SAAS,EAAoB,EAK1B,CACD,MAAO,CACL,CACE,GAAI,EAAW,EACf,KAAM,OACN,MAAO,CAAE,OAAQ,EAAoB,EAAU,CAAC,OAAQ,YAAa,YAAa,EAClF,OAAQ,CAAC,CACX,CACF,CACF,CAEA,eAAsB,GAA+C,CAEnE,IAAM,EADM,EACK,CAAC,CAAC,SAAS,IAAI,CAAkB,EAIlD,GAAI,CAAC,EAAQ,MAAO,CAAE,QAAS,CAAE,EAEjC,IAAM,EAAS,EAAkB,CAAM,EACnC,EAAU,EAKR,EAAkB,EAAO,SAAS,CAAC,CAAC,UAC1C,GAAI,CAAC,EAOH,MAAU,MACR,GAAG,EAAmB,iHAExB,EAGF,IAAK,IAAM,KAAa,EAKtB,MAAI,MADmB,EAAO,SAAS,CAAE,MAAO,EAAG,EAAiB,CAAS,EAAG,MAAO,CAAE,CAAC,EAAA,CAC7E,OAAS,GACtB,GAAI,CAMF,MAAM,EAAO,OAAO,CAClB,YACA,MAAO,EAAwB,GAC/B,OAAQ,EAAoB,CAAS,CACvC,CAAC,EACD,GACF,OAAS,EAAK,CAWZ,GAAI,CAAC,EAAkB,CAAG,EAAG,MAAM,CACrC,CAGF,MAAO,CAAE,SAAQ,CACnB,CAoBA,eAAsB,EACpB,EACiC,CACjC,IAAM,EAAQ,EAAkB,WAAW,EAAO,CAAC,CAAC,GAAG,SAAS,EAC1D,EAAkC,CAAC,EACzC,IAAK,IAAM,KAAc,EACvB,EAAQ,IAAe,MAAM,EAAM,WAAW,CAAE,YAAW,CAAC,EAAA,CAAG,OAEjE,OAAO,CACT"}
|
|
1
|
+
{"version":3,"file":"seed.mjs","names":[],"sources":["../src/seed.ts"],"sourcesContent":["/**\n * The site-chrome seeder — creates the one `site_chrome` row per placement\n * that a site needs, POPULATED, before any of them can be edited.\n *\n * Contributed as `content:site-chrome` from the `content()` plugin factory and\n * reached through the `@murumets-ee/content/seed` subpath — its own build\n * entry so the lazy import keeps `@murumets-ee/core/clients`, which carries\n * `import 'server-only'`, off the config-load path.\n *\n * ## Why a seeder at all, and why nobody has to run it\n *\n * An unseeded chrome document is indistinguishable from a site that HAS no\n * header, and both the canvas and `/v1/_chrome` treat it that way — they\n * compose nothing and let the consumer render its own fallback. That is right,\n * and it means nothing ever CREATES the row, so without this an editor has no\n * header to open.\n *\n * Until PR30 that left the gap S5 names: nothing in the printed setup path ran\n * this, so a scaffolded project booted with an empty Site list and the only\n * bootstrap was a human pressing \"New\". The plugin now declares this seeder\n * `bootstrap: true`, and `lumi migrate` runs every bootstrap seeder after it\n * applies migrations — which is already step 4 of the printed next-steps. It\n * stays an ordinary named seeder too, runnable from `lumi seed` and the admin\n * Seed page; `bootstrap` adds a trigger rather than changing what it is.\n *\n * ## Why the entity is resolved by NAME\n *\n * This package ships the chrome entity as a FACTORY (`siteChromeEntity()`),\n * because an app must pass options it cannot get from here — `withOpLog()`\n * ships from `@murumets-ee/blocks/server`, and content must not depend on\n * blocks (blocks depends back on content). So there is no entity object in\n * this package to import; there is only the name the factory stamps on\n * whatever the app instantiated.\n *\n * That name is not an assumption: `site-chrome.ts` hardcodes\n * `name: 'site_chrome'`, so an app can configure the entity but cannot rename\n * it. This package's own chrome PROVIDER resolves it the same way\n * (`app.entities.get(name)`, `null` when absent), and `@murumets-ee/auth`'s\n * default permission maps and `@murumets-ee/frontend-cache`'s\n * `DEFAULT_CHROME_ENTITIES` name the same string.\n *\n * ## Why it is `available`-gated rather than merely tolerant\n *\n * An app is free to register no chrome at all. Seeding plan F009 deferred this\n * seeder on exactly that: contributed unconditionally it would put a button on\n * the Seed page whose entire outcome is `created: 0`, which a reader takes as\n * \"this seeder is broken\", not as \"this project has no chrome\". The plugin\n * declares `available` so the button is ABSENT instead — the same contract\n * `rent:cars-demo` has, where absence follows from the plugin's absence rather\n * than from a silent no-op.\n *\n * ## Idempotent by construction, not by a flag\n *\n * Every run finds before it creates, keyed on `placement`. That is what makes\n * a second run a no-op — necessary now in a way it was not before, because\n * `lumi migrate` runs this on EVERY migrate, not once. It is also why the\n * find is a `where` on `placement` rather than a bare `limit: 1`: the old\n * `kind: 'singleton'` made \"is there a row\" the whole question, and under one\n * entity holding two rows it is the wrong question — a bare existence check\n * would see the header and never create the footer.\n *\n * ## DRAFT, deliberately — and this reverses an earlier choice\n *\n * The rows are created unpublished, so `lumi migrate` changes nothing a\n * visitor sees. That is not a detail; it is what makes `bootstrap: true` safe,\n * because this now runs on EVERY migrate, in production, against a database\n * full of a customer's content (see `PluginSeeder.bootstrap`). A provisioning\n * step that silently alters the public site is not additive.\n *\n * This file used to create them PUBLISHED, reasoning that \"a draft-seeded site\n * is indistinguishable from an unseeded one to every public reader\" and that\n * \"the rows are EMPTY, so publishing them renders nothing\". The first half is\n * true and is the POINT: an empty chrome IS unseeded, and a public reader\n * should see exactly what they saw before. The second half was measured and is\n * false.\n *\n * Measured, on a running front-astro against a real CMS: an empty PUBLISHED\n * region arrives on the wire as `{ blocks: [] }`, which `placeRegion` reads as\n * `hidden` — \"the page hid this on purpose\" — and `rendersFallback` returns\n * false for `hidden`. So publishing an empty row does not render nothing; it\n * renders nothing AND SUPPRESSES THE TEMPLATE'S OWN FALLBACK. A freshly\n * scaffolded site lost its `<header>` and `<footer>` landmarks entirely, where\n * before it rendered the fallback header with its Home link and nav.\n *\n * `null` (no published row) reads as `fallback` instead, which is the answer\n * that leaves the delivery template in charge until somebody has actually\n * authored a header. The moment they do and press Publish, the CMS takes over\n * — ordinary publish semantics, the same as every other document.\n *\n * ## EMPTY is what starved the canvas — and only the SITE was ever considered\n *\n * The section above is right and stays. Its blind spot is that it reasons\n * entirely about what a VISITOR sees. The rows it creates were also `layout:\n * []`, and the comment on that line said an empty tree \"is a header with no\n * blocks yet, which is what the editor opens onto\" — which is true, and is the\n * defect.\n *\n * The live site has a fallback: no published chrome row reads as `fallback`\n * and the delivery template renders its own header. **The editing canvas has\n * no fallback.** Its only channel for chrome is these rows — it is React in the\n * admin app and cannot render an Astro template's markup — so an empty row\n * composes to zero blocks and `CanvasDocument` emits a bare `<div>` with\n * nothing in it. Every page, forever, on every project ever scaffolded. The\n * site looked right the whole time, which is why no gate and no person caught\n * it for months (`editor` D014).\n *\n * So the two properties are independent and both are required: the rows are\n * DRAFT (nothing a visitor sees changes) and NON-EMPTY (the canvas has a header\n * and a footer to paint). Draft is what makes non-empty safe — the tree is\n * real, and no visitor sees it until somebody publishes.\n *\n * ## What this canNOT fix, stated because it is the interesting half\n *\n * This seeder is ADDITIVE: `if (existing.length > 0) continue`. An install\n * whose rows already exist — every project bootstrapped before this change —\n * keeps its empty ones, and must, because overwriting an authored header is the\n * one thing a bootstrap seeder may never do. The same hole reopens for anyone\n * who deletes their header's last block.\n *\n * That half belongs to the canvas and is fixed there: an empty non-page\n * document now renders a NAMED region band rather than nothing\n * (`block-editor-canvas.tsx`'s `EndAddZone`). This seeder decides what a NEW\n * site starts with; the canvas decides that an empty region is never invisible.\n *\n * ## What it assumes about its caller\n *\n * An admin context — `runSeederAction`, `lumi seed` and `lumi migrate`'s\n * bootstrap pass all establish one. Auth, the admin-only role check and the\n * production gate live there.\n *\n * The entity NAME and the `available` predicate live one module over\n * (`./chrome-entity-names.ts`), which imports nothing — `plugin.ts` needs the\n * predicate statically and must not pull this file's `server-only` client\n * along with it.\n */\n\nimport { randomUUID } from 'node:crypto'\nimport { getApp, requireRequestSite, runInEverySite } from '@murumets-ee/core'\nimport { createAdminClient } from '@murumets-ee/core/clients'\nimport type { Entity } from '@murumets-ee/entity'\nimport { eq } from 'drizzle-orm'\nimport { CHROME_ENTITY_NAME } from './chrome-entity-names.js'\nimport {\n CHROME_PLACEMENT_LABELS,\n CHROME_PLACEMENTS,\n type ChromePlacement,\n} from './entities/site-chrome.js'\nimport { isUniqueViolation } from './paths/apply-path.js'\nimport { toolkitPathsTable } from './paths/schema.js'\n\n/**\n * The `menu` block's registered type name, and the handle each placement's\n * default nav points at.\n *\n * ## Why a block instance can be written from HERE at all\n *\n * A block instance is plain JSON — `{ id, type, props, traits }` — so seeding\n * one needs no import. That matters: `@murumets-ee/content` must not depend on\n * `@murumets-ee/blocks` (blocks depends back on content, see this package's\n * chrome-entity docblock), so the type NAME is the whole contract and there is\n * no definition object to reach for.\n *\n * ## Why `menu`, and not something with words in it\n *\n * A header holds navigation. `menu` is the only block in the canonical\n * vocabulary that renders navigation, it is in `canonicalBlocks` — so it is in\n * the `page` environment's BASE registry and cannot be removed by an extension\n * (`environment-registry.ts` merges base ⊕ extensions and later contributors\n * only ever shadow by name) — and `themes/structural`'s `MenuRender` paints it\n * in the default theme. So the one block this seeder writes is the one block\n * every install is guaranteed to be able to paint.\n *\n * The alternative was a `text` block carrying a site name or a copyright line.\n * That is FABRICATED CONTENT: a string in a customer's header that they did\n * not write and must now find and delete. A `menu` block carries no words of\n * ours — it renders whatever menu the operator publishes at the handle, and\n * nothing at all before then.\n *\n * ## What it looks like before a menu exists\n *\n * A fresh scaffold has no `Menu` rows: `menus:main` is not a `bootstrap`\n * seeder, so `lumi migrate` does not run it. Until one exists, `getMenu`\n * answers `null` and the block renders its own documented inert state — an\n * empty `<nav>` on the live site, and inside the editor a labelled\n * `Menu: main` placeholder that is selectable and opens `@murumets-ee/menus-ui`'s\n * tree editor (the block declares `customEditorSurface`).\n *\n * That is the honest render, and it is the useful one: the canvas shows a\n * header, in the header's place, containing the thing a header contains, and\n * clicking it is the route to the tool that fills it.\n *\n * `'main'` and `'footer'` are the handles `MenuBlock`'s own docblock names, and\n * `'main'` is what `apps/front-astro`'s fallback header already fetches\n * (`PRIMARY_MENU_HANDLE`) and what `menus:main` creates.\n */\nconst CHROME_DEFAULT_MENU: Readonly<Record<ChromePlacement, { handle: string }>> = {\n header: { handle: 'main' },\n footer: { handle: 'footer' },\n}\n\n/**\n * The block tree a bootstrapped chrome row starts life with.\n *\n * Returns a fresh array with a fresh block id per call: block ids are the keys\n * a page's `_overrides` map is written under (`compose-chrome.ts`'s\n * `tagScopedRegion` uses `block.id` as `scopeBlockId`), so two rows sharing one\n * id would make a per-page override of the header also target the footer.\n *\n * `traits: {}` rather than omitted — `BlockInstance.traits` is required, and\n * the editor's own palette inserts carry it.\n *\n * Typed as the block-tree node shape rather than `Record<string, unknown>`. The\n * looser type only compiled because the widened column-value union used to\n * include `field.richtext()`'s old `Record<string, unknown>[]` — the very type\n * that let an array be written into a text column (F105).\n */\nfunction defaultChromeLayout(placement: ChromePlacement): Array<{\n id: string\n type: string\n props: Record<string, unknown>\n traits: Record<string, unknown>\n}> {\n return [\n {\n id: randomUUID(),\n type: 'menu',\n props: { handle: CHROME_DEFAULT_MENU[placement].handle, orientation: 'horizontal' },\n traits: {},\n },\n ]\n}\n\nexport async function seedSiteChrome(): Promise<{ created: number }> {\n const app = getApp()\n const entity = app.entities.get(CHROME_ENTITY_NAME)\n // Absent rather than thrown: `available` guarantees the app registered the\n // entity at the moment the list was built, and a config reloaded between\n // list and run should produce \"nothing to do\", not a stack trace.\n if (!entity) return { created: 0 }\n\n // Chrome is per site (multisite D002): every site gets its own header and\n // footer. In a run that names a site (`lumi seed --site`), that site; on a\n // single-site install, the one site; otherwise once per active site — so a\n // site added since the last run gets its chrome on the next `lumi migrate`.\n const perSite = await runInEverySite(() => seedSiteChromeInSite(entity), {\n app,\n label: 'content:site-chrome',\n })\n return { created: perSite.reduce((sum, r) => sum + r.created, 0) }\n}\n\nasync function seedSiteChromeInSite(entity: Entity): Promise<{ created: number }> {\n const client = createAdminClient(entity)\n let created = 0\n\n // Column refs through `getTable()` — the sanctioned accessor — never a bare\n // property on a dynamically-generated table, which would put an `any` into\n // the WHERE clause.\n const placementColumn = client.getTable().placement\n if (!placementColumn) {\n // Unreachable while the entity declares the field, and cheaper to state\n // than to debug: without it a missing column would make `eq()` build a\n // WHERE against `undefined` and the find would silently match nothing —\n // so every run would try to create, and every run after the first would\n // be absorbed by the unique-violation catch below as though it had\n // succeeded.\n throw new Error(\n `${CHROME_ENTITY_NAME} has no \"placement\" column — the chrome entity registered by this ` +\n `app is not the one @murumets-ee/content ships.`,\n )\n }\n\n for (const placement of CHROME_PLACEMENTS) {\n // Keyed on `placement`, which is also the unique column — so this find is\n // asking exactly the question the database constraint answers, and a race\n // between two callers ends in a unique violation rather than a duplicate.\n const existing = await client.findMany({ where: eq(placementColumn, placement), limit: 1 })\n if (existing.length > 0) continue\n try {\n // A POPULATED tree, not `[]` — see \"Empty is what starves the canvas\"\n // in this file's header. `defaultChromeLayout` is the one place that\n // decides what a bootstrapped region holds.\n // No `status` — `publishable()` defaults to `draft`, which is the whole\n // of the \"changes nothing a visitor sees\" property. See the header.\n await client.create({\n placement,\n label: CHROME_PLACEMENT_LABELS[placement],\n layout: defaultChromeLayout(placement),\n })\n created++\n } catch (err) {\n // Losing the find-then-create race is a SUCCESS, not a failure: someone\n // else created the row this run was about to create, which is the\n // outcome either way. `placement`'s unique constraint is what makes that\n // safe to assume — the violation can only be the row we just looked for.\n //\n // Worth the four lines rather than trusting the find, because `lumi\n // migrate`'s bootstrap pass runs this on every migrate and two deploys\n // can overlap. Payload's equivalent conditions its write on an existence\n // check that is simply WRONG on Postgres and survives only because the\n // adapter happens to re-check underneath (R036).\n if (!isUniqueViolation(err)) throw err\n }\n }\n\n return { created }\n}\n\n/**\n * Forget every `toolkit_paths` row — canonical and redirect history — for the\n * named entities. For a seeder that empties those entities' tables in bulk.\n *\n * `AdminClient.delete` drops a document's paths when the row is physically\n * removed (the path-index writer's `'delete'` action). A bulk reset that skips\n * the client — the scaffold's demo `content` seeder TRUNCATEs its tables so it\n * can re-run — leaves every old URL claimed by a document that no longer\n * exists. The re-seeded documents then collide with their own ghosts and come\n * back as `/about-2`, `/welcome-2`, and the old URLs keep answering as\n * redirects to nothing (lumi-site PR04).\n *\n * Seeder-only, like the reset it follows: it asks no permission of its own,\n * exactly as a TRUNCATE does not. The seeders that call it already run as an\n * admin — `lumi seed` under `runAsCli`, the Seed page behind its admin gate.\n *\n * Returns how many rows were removed, per entity, so a seeder can log it.\n */\nexport async function forgetEntityPaths(\n entityNames: readonly string[],\n): Promise<Record<string, number>> {\n // The seeding site's paths only (multisite phase 03) — a reset of one\n // site's pages must not strip another site's URLs.\n const paths = toolkitPathsTable.makeClient(\n getApp().db.readWrite,\n await requireRequestSite('toolkit_paths'),\n )\n const removed: Record<string, number> = {}\n for (const entityType of entityNames) {\n removed[entityType] = (await paths.deleteMany({ entityType })).length\n }\n return removed\n}\n"],"mappings":"6XAmMA,MAAM,EAA6E,CACjF,OAAQ,CAAE,OAAQ,MAAO,EACzB,OAAQ,CAAE,OAAQ,QAAS,CAC7B,EAkBA,SAAS,EAAoB,EAK1B,CACD,MAAO,CACL,CACE,GAAI,EAAW,EACf,KAAM,OACN,MAAO,CAAE,OAAQ,EAAoB,EAAU,CAAC,OAAQ,YAAa,YAAa,EAClF,OAAQ,CAAC,CACX,CACF,CACF,CAEA,eAAsB,GAA+C,CACnE,IAAM,EAAM,EAAO,EACb,EAAS,EAAI,SAAS,IAAI,CAAkB,EAclD,OAVK,EAUE,CAAE,SAAS,MAJI,MAAqB,EAAqB,CAAM,EAAG,CACvE,MACA,MAAO,qBACT,CAAC,EAAA,CACyB,QAAQ,EAAK,IAAM,EAAM,EAAE,QAAS,CAAC,CAAE,EAV7C,CAAE,QAAS,CAAE,CAWnC,CAEA,eAAe,EAAqB,EAA8C,CAChF,IAAM,EAAS,EAAkB,CAAM,EACnC,EAAU,EAKR,EAAkB,EAAO,SAAS,CAAC,CAAC,UAC1C,GAAI,CAAC,EAOH,MAAU,MACR,GAAG,EAAmB,iHAExB,EAGF,IAAK,IAAM,KAAa,EAKtB,MAAI,MADmB,EAAO,SAAS,CAAE,MAAO,EAAG,EAAiB,CAAS,EAAG,MAAO,CAAE,CAAC,EAAA,CAC7E,OAAS,GACtB,GAAI,CAMF,MAAM,EAAO,OAAO,CAClB,YACA,MAAO,EAAwB,GAC/B,OAAQ,EAAoB,CAAS,CACvC,CAAC,EACD,GACF,OAAS,EAAK,CAWZ,GAAI,CAAC,EAAkB,CAAG,EAAG,MAAM,CACrC,CAGF,MAAO,CAAE,SAAQ,CACnB,CAoBA,eAAsB,EACpB,EACiC,CAGjC,IAAM,EAAQ,EAAkB,WAC9B,EAAO,CAAC,CAAC,GAAG,UACZ,MAAM,EAAmB,eAAe,CAC1C,EACM,EAAkC,CAAC,EACzC,IAAK,IAAM,KAAc,EACvB,EAAQ,IAAe,MAAM,EAAM,WAAW,CAAE,YAAW,CAAC,EAAA,CAAG,OAEjE,OAAO,CACT"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{t as e}from"./rolldown-runtime-DK3Fl9T5.mjs";import{i as t,n}from"./errors-NRNILhhm.mjs";import{t as r}from"./content-config-store-8omQF5p5.mjs";import{n as i}from"./plugin-
|
|
2
|
-
//# sourceMappingURL=versions-
|
|
1
|
+
import{t as e}from"./rolldown-runtime-DK3Fl9T5.mjs";import{i as t,n}from"./errors-NRNILhhm.mjs";import{t as r}from"./content-config-store-8omQF5p5.mjs";import{n as i}from"./plugin-BmxghXzV.mjs";import{t as a}from"./locale-utils-B0mFoV47.mjs";import{a as o,i as s}from"./context-D7eEItCY.mjs";import{schemaRegistry as c}from"@murumets-ee/db";import{eq as l}from"drizzle-orm";import"server-only";function u(e){return e.match(/\S+|\s+/g)??[]}function d(e,t){if(e===t)return e?[{type:`equal`,value:e}]:[];if(!e)return[{type:`add`,value:t}];if(!t)return[{type:`remove`,value:e}];let n=u(e),r=u(t),i=n.length,a=r.length,o=Array.from({length:i+1},()=>Array(a+1).fill(0));for(let e=1;e<=i;e++){let t=o[e],i=o[e-1];if(!(!t||!i))for(let o=1;o<=a;o++)n[e-1]===r[o-1]?t[o]=(i[o-1]??0)+1:t[o]=Math.max(i[o]??0,t[o-1]??0)}let s=[],c=i,l=a,d=[];for(;c>0||l>0;){let e=c>0?n[c-1]:void 0,t=l>0?r[l-1]:void 0;if(c>0&&l>0&&e!==void 0&&e===t)d.push({type:`equal`,value:e}),c--,l--;else if(l>0&&t!==void 0&&(c===0||(o[c]?.[l-1]??0)>=(o[c-1]?.[l]??0)))d.push({type:`add`,value:t}),l--;else if(e!==void 0)d.push({type:`remove`,value:e}),c--;else break}d.reverse();for(let e of d){let t=s[s.length-1];t&&t.type===e.type?t.value+=e.value:s.push({...e})}return s}function f(e){return e.replace(/<[^>]*>/g,` `).replace(/\s+/g,` `).trim()}function p(e){return e==null?``:e instanceof Date?e.toISOString():typeof e==`object`?JSON.stringify(e):String(e)}function m(e,t,n,r){if(JSON.stringify(t)===JSON.stringify(n))return null;switch(r?.type??g(t,n)){case`text`:case`slug`:return{field:e,type:`text`,from:t,to:n,segments:d(p(t),p(n))};case`richtext`:return{field:e,type:`richtext`,from:t,to:n,segments:d(f(p(t)),f(p(n)))};case`number`:return{field:e,type:`number`,from:t,to:n};case`boolean`:return{field:e,type:`boolean`,from:t,to:n};case`date`:return{field:e,type:`date`,from:t,to:n};case`select`:return{field:e,type:`select`,from:t,to:n};case`media`:return{field:e,type:`media`,from:t,to:n};case`json`:return{field:e,type:`json`,from:t,to:n};case`blockTree`:return{field:e,type:`blockTree`,from:t,to:n};default:return{field:e,type:`unknown`,from:t,to:n}}}function h(e,t,n){let r=new Set([...Object.keys(e),...Object.keys(t)]),i=[];for(let a of r){if(a===`id`||a===`createdAt`||a===`updatedAt`)continue;let r=m(a,e[a],t[a],n?.[a]);r&&i.push(r)}return i}function g(e,t){let n=e??t;if(typeof n==`number`)return`number`;if(typeof n==`boolean`)return`boolean`;if(n instanceof Date)return`date`;if(typeof n==`string`)return/^\d{4}-\d{2}-\d{2}/.test(n)?`date`:`text`;if(Array.isArray(n)){let e=n[0];return n.length>0&&typeof e==`object`&&e&&`type`in e&&`id`in e?`blockTree`:`json`}return typeof n==`object`?`json`:`unknown`}function _(e,t){if(typeof e==`string`||typeof t==`string`)return d(p(e),p(t))}function v(e){if(typeof e!=`object`||!e)return null;let t=e;if(typeof t.id!=`string`||typeof t.type!=`string`)return null;let n={...t.props??{}};return t.traits!==void 0&&(n.traits=t.traits),t.children!==void 0&&(n.children=t.children),{id:t.id,type:t.type,props:n}}function y(e,t){let n=new Set([...Object.keys(e),...Object.keys(t)]),r=[];for(let i of n){let n=e[i],a=t[i];JSON.stringify(n)!==JSON.stringify(a)&&r.push({field:i,from:n,to:a,segments:_(n,a)})}return r}function b(e){let t=new Map,n=0;for(let r of e){let e=v(r);e&&(t.set(e.id,{block:e,index:n}),n++)}return t}function x(e,t){let n=b(e),r=b(t),i=[],a=new Set;for(let[e,{block:t,index:o}]of r){a.add(e);let r=n.get(e);if(!r){i.push({blockId:e,blockType:t.type,status:`added`,toIndex:o});continue}let s=r.block,c=r.index,l=y(s.props,t.props),u=c!==o;l.length===0&&!u?i.push({blockId:e,blockType:t.type,status:`unchanged`,fromIndex:c,toIndex:o}):l.length>0?i.push({blockId:e,blockType:t.type,status:`changed`,fromIndex:c,toIndex:o,propDiffs:l}):i.push({blockId:e,blockType:t.type,status:`moved`,fromIndex:c,toIndex:o})}for(let[e,{block:t,index:r}]of n)a.has(e)||i.push({blockId:e,blockType:t.type,status:`removed`,fromIndex:r});return i}function S(e){return e===`_`||e===r().defaultLocale}function C(e){let{defaultLocale:t}=r();return S(e)?{defaultLocale:t}:{locale:e,defaultLocale:t}}async function w(e,t,n,o,c){if(c!==void 0&&e.db!==c)throw Error("applyLocaleRoutedUpdate: `tx` must be the context's own db handle");let l=await s(e);return e.admin.updateForLocale(t,n,o,{defaultLocale:r().defaultLocale,...c!==void 0&&{tx:c},...l!==null&&{localeStatusWriter:(e,t,n)=>(a(t),i(l,e,t,n))}})}const T=new Set([`id`,`createdAt`,`createdBy`,`updatedAt`,`updatedBy`]);function E(e,t){let n=e.allFields,r={};for(let e of Object.keys(t)){if(T.has(e)||e.startsWith(`_`)||!Object.hasOwn(n,e))continue;let i=n[e];i&&i.internal===!0||(r[e]=t[e])}return r}function D(e,t){return JSON.stringify(e)===JSON.stringify(t)}function O(e,t){let n={};for(let r of Object.keys(t))r===`id`||r.startsWith(`_`)||D(e[r],t[r])||(n[r]=t[r]);return n}var k=e({compareVersions:()=>M,createVersionSnapshot:()=>I,getVersion:()=>j,getVersions:()=>A,pruneVersions:()=>P,restoreVersion:()=>N,saveVersionSnapshot:()=>F,writeVersionColumn:()=>L});async function A(e,t,n){n?.locale!==void 0&&a(n.locale);let r=await o(e);return r?await r.findMany({where:{entityId:t,locale:n?.locale??`_`},orderBy:[{column:`version`,dir:`desc`}],...n?.limit===void 0?{}:{limit:n.limit},...n?.offset===void 0?{}:{offset:n.offset}}):[]}async function j(e,t,n,r=`_`){a(r);let i=await o(e);return i?await i.findOne({entityId:t,version:n,locale:r})??null:null}async function M(e,t,n,r,i=`_`){a(i);let[o,s]=await Promise.all([j(e,t,n,i),j(e,t,r,i)]);if(!o||!s)return null;let c=e.entity.allFields,l=h(o.data,s.data,c);for(let e of l)e.type===`blockTree`&&(e.blockDiffs=x(Array.isArray(e.from)?e.from:[],Array.isArray(e.to)?e.to:[]));return{diffs:l,fromVersion:o,toVersion:s}}async function N(e,r,i,s=`_`){if(a(s),!await o(e))throw new n(e.entity.name,`versions`);let c=await j(e,r,i,s);if(!c)throw new t(r,i,s);let l=c.data;return w(e,r,E(e.entity,l),s)}async function P(e,t,n=`_`){if(a(n),!e.versionableOptions)return;let r=await o(e);if(!r)return;let i=e.versionableOptions.maxPerDoc,s=await r.findMany({where:{entityId:t,locale:n},orderBy:[{column:`version`,dir:`desc`}],limit:i});if(s.length<i)return;let c=s.map(e=>e.id);await r.deleteMany({entityId:t,locale:n,id:{notIn:c}})}async function F(e,t,n,r,i,s=`_`,c){a(s);let l=await o(e);if(!l)return;let[u]=await l.aggregate({select:{maxVersion:{fn:`max`,column:`version`}},where:{entityId:t,locale:s}}),d=Number(u?.maxVersion??0)+1,f=n.status??null;if(i){let e=await l.findOne({entityId:t,isAutosave:!0,version:d,locale:s});if(e)return await l.update({id:e.id},{data:n,delta:r,status:f,createdAt:new Date}),d}return await l.insert({entityId:t,version:d,locale:s,data:n,delta:r,status:f,createdBy:c?.id??null,createdByName:c?.name??null,isAutosave:i}),d}async function I(e,t,n,r,i,o){if(i!==void 0&&a(i),!e.versionableOptions)return;let s=i??`_`,c=await F(e,t,n,r?O(r,n):null,!1,s,o);c!==void 0&&s===`_`&&await L(e,t,c),await P(e,t,s)}async function L(e,t,n){let r=c.get(e.entity.name);r&&await e.db.update(r).set({_version:n}).where(l(r.id,t))}export{P as a,k as c,E as d,w as f,h,A as i,L as l,x as m,I as n,N as o,C as p,j as r,F as s,M as t,O as u};
|
|
2
|
+
//# sourceMappingURL=versions-DLrR7hIZ.mjs.map
|