@murumets-ee/content 0.36.0 → 0.37.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 +32 -0
- package/dist/admin.d.mts.map +1 -0
- package/dist/admin.mjs +1 -0
- package/dist/client.d.mts +2 -2
- package/dist/client.mjs +1 -1
- package/dist/client.mjs.map +1 -1
- package/dist/content-config-store-8omQF5p5.mjs +2 -0
- package/dist/content-config-store-8omQF5p5.mjs.map +1 -0
- package/dist/entities/index.d.mts +5 -1
- package/dist/entities/index.mjs +1 -1
- package/dist/entities/index.mjs.map +1 -1
- package/dist/{index-D8nN7BFd.d.mts → index-Bsmju0UF.d.mts} +30 -3
- package/dist/index-Bsmju0UF.d.mts.map +1 -0
- package/dist/index.d.mts +4 -4
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/locale-utils-Dyw5qWDf.mjs +2 -0
- package/dist/{locale-utils-CuIBKeaz.mjs.map → locale-utils-Dyw5qWDf.mjs.map} +1 -1
- package/dist/lock-service.d.mts +1 -1
- package/dist/plugin-BTynhZMx.mjs +2 -0
- package/dist/plugin-BTynhZMx.mjs.map +1 -0
- package/dist/plugin.d.mts +17 -6
- package/dist/plugin.d.mts.map +1 -1
- package/dist/plugin.mjs +1 -1
- package/dist/query-client.mjs +1 -1
- package/dist/resolve-context-p3m6gn3_.mjs +2 -0
- package/dist/resolve-context-p3m6gn3_.mjs.map +1 -0
- package/dist/resolve.d.mts +587 -0
- package/dist/resolve.d.mts.map +1 -0
- package/dist/resolve.mjs +9 -0
- package/dist/resolve.mjs.map +1 -0
- package/dist/routes-D3dzAfQw.mjs +2 -0
- package/dist/routes-D3dzAfQw.mjs.map +1 -0
- package/dist/{routing-strategy-TPSzrapD.d.mts → routing-strategy-D3Qowm3D.d.mts} +40 -6
- package/dist/routing-strategy-D3Qowm3D.d.mts.map +1 -0
- package/dist/routing-strategy-dkt2yO25.mjs +2 -0
- package/dist/routing-strategy-dkt2yO25.mjs.map +1 -0
- package/dist/schema-BJxAwPrI.mjs +2 -0
- package/dist/schema-BJxAwPrI.mjs.map +1 -0
- package/dist/{types-CUw2Pf0Y.d.mts → types-B3K2p9t8.d.mts} +22 -1
- package/dist/types-B3K2p9t8.d.mts.map +1 -0
- package/package.json +17 -7
- package/dist/index-D8nN7BFd.d.mts.map +0 -1
- package/dist/locale-utils-CuIBKeaz.mjs +0 -2
- package/dist/plugin-IW9SM9iO.mjs +0 -2
- package/dist/plugin-IW9SM9iO.mjs.map +0 -1
- package/dist/routing-strategy-DGx6Rq6F.mjs +0 -2
- package/dist/routing-strategy-DGx6Rq6F.mjs.map +0 -1
- package/dist/routing-strategy-TPSzrapD.d.mts.map +0 -1
- package/dist/types-CUw2Pf0Y.d.mts.map +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resolve.mjs","names":[],"sources":["../src/paths/enumerate.ts","../src/paths/sitemap.ts","../src/paths/redirects-list.ts"],"sourcesContent":["/**\n * Path enumeration for SSG / sitemaps (plan/routing PR06; phase\n * `05-resolution-and-enumeration-api.md` §2).\n *\n * `enumeratePaths` streams the PUBLISHED canonical `(path, entityType, id,\n * updatedAt)` rows for one locale. It powers `getStaticPaths` + the sitemap\n * helper, so it must NEVER leak an unpublished document (CLAUDE.md #1 — the\n * output feeds public build artifacts).\n *\n * **Security + availability.** Two properties matter here:\n * 1. *No unpublished leak.* `toolkit_paths` carries no publish-status column,\n * so each page's rows are publish-checked against the live entity via the\n * injected {@link EnumeratePathsDeps.filterPublished} — an unpublished (or\n * trashed) document is dropped from the stream.\n * 2. *No unbounded fan-out* (CLAUDE.md \"no amplification\"). Work is bounded by\n * PAGE SIZE, not by total document count: rows are keyset-paginated on the\n * immutable `id` PK (one index query per page), and the publish check is\n * BATCHED — ONE call per distinct entity-type bucket per page (a handful of\n * types at most; the routable set is config-time-fixed, never\n * attacker-influenced), never one call per row.\n *\n * **Leaf module.** Every cross-package dependency (the index reader, the publish\n * filter) is injected via `deps`; this module imports no `@murumets-ee/core` and\n * no `server-only` graph. Concrete deps are built from an app in\n * `./resolve-context.ts` (`buildEnumerateDeps`), keeping the resolution surface\n * off the `lumi` CLI command graph (F027 guardrail).\n *\n * Function-form-path entities need NO special handling — their rows were already\n * materialized into `toolkit_paths` at write time (F004 / phase 05 §2), so a\n * plain index read includes them.\n */\n\nimport { mapBounded } from './apply-path.js'\n\n// ---------------------------------------------------------------------------\n// Read surface (a `type` alias, per index-reader.ts's Deviation-1 rationale, so\n// the real `ToolkitPathsClient` stays assignable to it without any cast).\n// ---------------------------------------------------------------------------\n\n/** The projected `toolkit_paths` row the enumeration + batch lookup read. */\nexport type PathEnumRow = {\n id: string\n entityType: string\n entityId: string\n locale: string\n path: string\n updatedAt: Date\n}\n\n/**\n * The (operator-carrying) where shapes enumeration issues: keyset `id > cursor`,\n * `entityId IN (...)` for the batch lookup, plus plain-value equality filters.\n * A subset of the real client's `ColumnOperators` where-clause, so the real\n * `ToolkitPathsClient` is assignable to {@link PathEnumReader}.\n */\nexport type PathEnumWhere = {\n id?: { gt: string }\n entityType?: string\n entityId?: { in: string[] }\n locale?: string\n path?: string\n isCanonical?: boolean\n}\n\n/**\n * The batched keyset read surface {@link enumeratePaths} / {@link getCanonicalPaths}\n * need. NARROWER than Task 1's point-lookup `PathIndexReader` (which only orders\n * by `updatedAt` and takes plain-value where): enumeration needs `id`-ordering,\n * a `{ gt }` cursor, and `{ in }` batching. Construct the real client directly\n * (`toolkitPathsTable.makeClient(db)`) — it satisfies this type.\n */\nexport type PathEnumReader = {\n findMany(options: {\n where?: PathEnumWhere\n orderBy?: Array<{ column: 'id'; dir?: 'asc' | 'desc' }>\n limit?: number\n }): Promise<PathEnumRow[]>\n}\n\n/**\n * Batch publish check: return the SUBSET of `entityIds` that is published in\n * `locale`. Called ONCE per entity-type group per page. The concrete\n * implementation (see `resolve-context.ts`) runs an elevated-context\n * `QueryClient.findMany({ where: inArray(getTable().id, ids), locale })` — the\n * publish + trash filters apply unconditionally, so no unpublished/trashed id\n * comes back.\n */\nexport type PublishFilter = (\n entityType: string,\n entityIds: readonly string[],\n locale: string,\n) => Promise<ReadonlySet<string>>\n\n// ---------------------------------------------------------------------------\n// Public surface\n// ---------------------------------------------------------------------------\n\n/** One enumerated path row (phase 05 §2 — exact shape). */\nexport interface PathEntry {\n path: string\n entityType: string\n id: string\n updatedAt: Date\n}\n\n/** Options for {@link enumeratePaths} (phase 05 §2 — exact shape). */\nexport interface EnumeratePathsOptions {\n /** Enumerate this locale's canonical rows only. */\n locale: string\n /** Optionally narrow to a single routable entity type. */\n entityType?: string\n /**\n * Resume the keyset AFTER this `toolkit_paths` row id (exclusive); omit to\n * start from the beginning. The value is the opaque `toolkit_paths` PK the\n * internal page cursor advances on — NOT the yielded `PathEntry.id`, which is\n * the DOCUMENT (entity) id. A cross-request paginating caller (e.g. the\n * content-api `_paths` endpoint) resolves the last consumed entry's row PK and\n * passes it here to continue exactly where the prior page stopped, with no row\n * skipped or repeated. Fully backward-compatible: existing callers omit it and\n * traverse from the start unchanged.\n */\n afterId?: string\n}\n\n/** Injected dependencies for {@link enumeratePaths}. */\nexport interface EnumeratePathsDeps {\n pathsClient: PathEnumReader\n filterPublished: PublishFilter\n}\n\n/** Internal tuning seam (page size / batch concurrency). Real callers omit it. */\nexport interface EnumeratePathsTuning {\n /** Rows per index page. Defaults to {@link ENUMERATE_PAGE_SIZE}. Tests force a small value. */\n pageSize?: number\n /** Peak concurrent publish-check groups. Defaults to {@link PUBLISH_CHECK_CONCURRENCY}. */\n publishConcurrency?: number\n}\n\n/**\n * Index page size — mirrors the rebuild job's `REBUILD_PAGE_SIZE`. Must stay\n * ≤ the `defineTable` `MAX_LIMIT` (1000), which THROWS rather than clamps.\n */\nconst ENUMERATE_PAGE_SIZE = 200\n\n/**\n * Peak concurrent publish-check groups per page. A page holds a handful of\n * distinct entity types at most, so this bound is rarely reached — it exists so\n * the fan-out is EXPLICITLY capped (CLAUDE.md), never `Promise.all` over an\n * unbounded set.\n */\nconst PUBLISH_CHECK_CONCURRENCY = 5\n\n/**\n * Stream the published canonical paths for `opts.locale` (optionally narrowed to\n * `opts.entityType`), keyset-paginated with a per-page batched publish filter.\n * See the module header for the security + fan-out guarantees.\n */\nexport async function* enumeratePaths(\n opts: EnumeratePathsOptions,\n deps: EnumeratePathsDeps,\n tuning: EnumeratePathsTuning = {},\n): AsyncGenerator<PathEntry> {\n const pageSize = tuning.pageSize ?? ENUMERATE_PAGE_SIZE\n const concurrency = tuning.publishConcurrency ?? PUBLISH_CHECK_CONCURRENCY\n const { locale, entityType } = opts\n\n // Seed from `opts.afterId` so a caller can resume mid-stream; undefined starts\n // at the beginning (every existing caller's behavior, unchanged).\n let cursorId: string | undefined = opts.afterId\n for (;;) {\n // Keyset cursor on the immutable `id` PK (the rebuild job's precedent):\n // visits every canonical row exactly once even under concurrent mutation,\n // which OFFSET paging cannot guarantee.\n const where: PathEnumWhere = { locale, isCanonical: true }\n if (entityType !== undefined) where.entityType = entityType\n if (cursorId !== undefined) where.id = { gt: cursorId }\n\n const page = await deps.pathsClient.findMany({\n where,\n orderBy: [{ column: 'id', dir: 'asc' }],\n limit: pageSize,\n })\n if (page.length === 0) break\n\n // Group this page's rows by entity type (bounded: the routable-type set is\n // small + config-fixed), then ONE publish check per group.\n const byType = new Map<string, PathEnumRow[]>()\n for (const row of page) {\n const bucket = byType.get(row.entityType)\n if (bucket) bucket.push(row)\n else byType.set(row.entityType, [row])\n }\n\n const publishedByType = new Map<string, ReadonlySet<string>>()\n await mapBounded([...byType.entries()], concurrency, async ([type, rows]) => {\n const published = await deps.filterPublished(\n type,\n rows.map((r) => r.entityId),\n locale,\n )\n publishedByType.set(type, published)\n })\n\n // Yield only the page's PUBLISHED rows, preserving the id-ascending order.\n for (const row of page) {\n if (publishedByType.get(row.entityType)?.has(row.entityId)) {\n yield { path: row.path, entityType: row.entityType, id: row.entityId, updatedAt: row.updatedAt }\n }\n }\n\n // Advance the cursor to the last (max-id) row of this raw page. `page` is\n // non-empty here (length-0 broke above). Publish-filtering is a POST-filter,\n // so the cursor always steps over every index row regardless of publish state.\n const lastRow = page[page.length - 1]\n if (!lastRow) break\n cursorId = lastRow.id\n if (page.length < pageSize) break\n }\n}\n\n// ---------------------------------------------------------------------------\n// getCanonicalPaths — batched forward lookup (search url re-point), NO publish filter\n// ---------------------------------------------------------------------------\n\n/** Deps for {@link getCanonicalPaths}. */\nexport interface CanonicalPathsBatchDeps {\n pathsClient: PathEnumReader\n}\n\n/**\n * Max ids per batched index query — well under `defineTable`'s `MAX_LIMIT`\n * (1000, which throws rather than clamps). Search result pages are far smaller\n * than this, so this is one query in practice; larger id sets chunk safely.\n */\nconst CANONICAL_BATCH_SIZE = 500\n\n/**\n * The current canonical paths for many `(entityType, entityId)` in ONE locale,\n * as an `entityId → path` map — the batched sibling of `getCanonicalPath`.\n *\n * **No publish filter — deliberate**, matching `getCanonicalPath` (and the\n * search url builder it re-points): a search result's url is display-only and\n * the caller already gates row visibility. One index query per {@link\n * CANONICAL_BATCH_SIZE}-sized chunk (one query total for a normal search page),\n * so it never fans out per row.\n */\nexport async function getCanonicalPaths(\n entityType: string,\n entityIds: readonly string[],\n locale: string,\n deps: CanonicalPathsBatchDeps,\n): Promise<Map<string, string>> {\n const result = new Map<string, string>()\n for (let i = 0; i < entityIds.length; i += CANONICAL_BATCH_SIZE) {\n const chunk = entityIds.slice(i, i + CANONICAL_BATCH_SIZE)\n const rows = await deps.pathsClient.findMany({\n where: { entityType, locale, isCanonical: true, entityId: { in: [...chunk] } },\n limit: chunk.length,\n })\n for (const row of rows) result.set(row.entityId, row.path)\n }\n return result\n}\n","/**\n * Sitemap generation for the routed content surface (plan/routing PR07; phase\n * `05-resolution-and-enumeration-api.md` §4).\n *\n * `generateSitemap` streams the standard-protocol sitemap document set for the\n * configured locales: one `<urlset>` per locale (split into numbered files at\n * {@link SitemapOptions.maxUrlsPerFile}), plus a `<sitemapindex>` when the run\n * produced more than one file. Each `<url>` carries a `<lastmod>` (from the\n * enumerated `updatedAt`) and the full `xhtml:link` hreflang-alternates set —\n * including an `x-default` pointing at the default-locale URL. It is a PURE\n * generator, NOT an HTTP endpoint: the frontend recipe (PR08) mounts it in a\n * route handler; this module only produces the documents.\n *\n * **Security (CLAUDE.md #1).** The output feeds a public build artifact, so it\n * must NEVER leak an unpublished / trashed document. Two independent publish\n * gates apply:\n * 1. The primary rows come from {@link enumeratePaths}, which publish-filters\n * each page against the entity (unpublished/trashed dropped) for the\n * sitemap's OWN locale.\n * 2. The cross-locale hreflang alternates come from {@link getCanonicalPaths},\n * which does NOT publish-filter (it is a display-only forward index read).\n * So this module ADDITIONALLY runs the same batched `filterPublished` over\n * every candidate other-locale alternate before linking it — an\n * unpublished-in-that-locale document is never emitted as an alternate.\n *\n * **No unbounded fan-out (CLAUDE.md \"no amplification\").** The cross-locale\n * alternate lookup is BATCHED exactly like `enumeratePaths`'s own publish check:\n * per {@link SITEMAP_ALTERNATES_BATCH_SIZE}-sized chunk of enumerated rows, the\n * work is ONE `getCanonicalPaths` + ONE `filterPublished` per (entity-type\n * group × other locale) — bounded by (batch size × distinct routable types ×\n * locale count), NEVER one query per document. `getAlternates` (the per-document\n * N-locale function) is deliberately NOT used here — calling it per enumerated\n * row would be the O(rows × locales) amplification this bound forbids.\n *\n * **`noIndex` exclusion (PR 11's `seoMeta()`).** `SitemapDeps.filterNoIndex` is\n * an OPTIONAL batched dep, same shape as `filterPublished` (a `PublishFilter`)\n * but semantics-inverted: it returns the subset of ids that ARE `noIndex` in\n * the given locale. It lives on `deps`, not `opts`, because — like\n * `filterPublished` — a real implementation needs a DB read, and `noIndex` is\n * `translatable: true`, so the SAME callback must be invoked once per\n * (locale, entity-type group, batch) to see the right per-locale value; a\n * per-entry (locale-blind) predicate could not express this correctly. Omitted\n * → nothing excluded (current default). See `excludeNoIndexed`.\n *\n * **Leaf module.** Every cross-package dependency is injected via `deps`\n * ({@link SitemapDeps}); this module imports no `@murumets-ee/core` and no\n * `server-only` graph. Concrete deps are built from an app in\n * `./resolve-context.ts` (`buildSitemapDeps`).\n */\n\nimport { mapBounded } from './apply-path.js'\nimport {\n type CanonicalPathsBatchDeps,\n enumeratePaths,\n type EnumeratePathsDeps,\n type EnumeratePathsTuning,\n getCanonicalPaths,\n type PathEntry,\n type PublishFilter,\n} from './enumerate.js'\n\n// ---------------------------------------------------------------------------\n// Public surface\n// ---------------------------------------------------------------------------\n\n/** Options for {@link generateSitemap}. */\nexport interface SitemapOptions {\n /** The locales to emit a `<urlset>` for (configured content locales). */\n locales: readonly string[]\n /**\n * The default locale — its URL is the `x-default` hreflang target. Explicit\n * (NOT inferred from `locales` order) so callers stay unambiguous.\n */\n defaultLocale: string\n /** Absolute site origin, e.g. `https://example.com` (trailing slash tolerated). */\n baseUrl: string\n /**\n * Per-locale URL prefix rule (e.g. `(l) => l === 'en' ? '' : '/' + l`). The\n * frontend owns the locale-prefix convention (D002); omitted → no prefix.\n */\n prefix?: (locale: string) => string\n /**\n * Max `<url>` entries per file before splitting (sitemap protocol caps at\n * 50 000). Defaults to {@link DEFAULT_MAX_URLS_PER_FILE}.\n */\n maxUrlsPerFile?: number\n}\n\n/** Injected dependencies for {@link generateSitemap}. */\nexport interface SitemapDeps {\n /**\n * Deps for {@link enumeratePaths} — the `toolkit_paths` reader plus the\n * batched `filterPublished`. `filterPublished` is REUSED for the cross-locale\n * alternate publish gate (same batched shape), so no second publish surface is\n * needed.\n */\n enumerate: EnumeratePathsDeps\n /**\n * Deps for {@link getCanonicalPaths} — the batched cross-locale forward index\n * read (NO publish filter; this module applies `enumerate.filterPublished` on\n * top).\n */\n canonicalBatch: CanonicalPathsBatchDeps\n /**\n * Optional: batched `noIndex` check, same shape as `filterPublished` but\n * semantics-inverted — returns the subset of `entityIds` that ARE `noIndex`\n * in `locale` (PR 11's `seoMeta()`). Called at most once per (entity-type\n * group, batch), never once per entry — see `excludeNoIndexed`. Omitted →\n * nothing excluded (current default).\n */\n filterNoIndex?: PublishFilter\n}\n\n/** Internal tuning seam (batch size / enumerate page size). Real callers omit it. */\nexport interface SitemapTuning {\n /**\n * Enumerated rows per cross-locale alternate batch. Defaults to\n * {@link SITEMAP_ALTERNATES_BATCH_SIZE}. Tests force a small value to span\n * multiple batches. Bounds the per-batch fan-out (see module header).\n */\n batchSize?: number\n /** Passed through to {@link enumeratePaths} for its own paging/publish tuning. */\n enumerate?: EnumeratePathsTuning\n}\n\n/** One emitted sitemap document. */\nexport interface SitemapFile {\n /** `'urlset'` for a per-locale URL set, `'index'` for the sitemap index. */\n kind: 'urlset' | 'index'\n /**\n * Suggested filename served at `${baseUrl}/${filename}`. Per-locale urlsets are\n * `sitemap-<locale>.xml` (first) / `sitemap-<locale>-<n>.xml` (splits, n≥2);\n * the index is {@link SITEMAP_INDEX_FILENAME}.\n */\n filename: string\n /** The complete, well-formed XML document text (trailing newline included). */\n xml: string\n}\n\n/** Default `<url>` cap per file — under the sitemap protocol's 50 000 hard limit. */\nexport const DEFAULT_MAX_URLS_PER_FILE = 40_000\n\n/** Filename of the emitted sitemap index (referenced by the mounting route). */\nexport const SITEMAP_INDEX_FILENAME = 'sitemap.xml'\n\n/**\n * Enumerated rows per cross-locale alternate batch. Aligned with\n * `enumeratePaths`'s own page size so a batch is ~one enumeration page; the\n * exact value only affects HOW MANY batched lookups run, never their per-batch\n * bound (see module header). Never per-row.\n */\nconst SITEMAP_ALTERNATES_BATCH_SIZE = 200\n\n// ---------------------------------------------------------------------------\n// Generator\n// ---------------------------------------------------------------------------\n\n/** One resolved hreflang alternate (a published, canonical per-locale URL). */\ninterface Alternate {\n locale: string\n path: string\n}\n\n/**\n * Stream the sitemap document set for `opts.locales`. See the module header for\n * the security + fan-out guarantees. Yields each `<urlset>` file as it fills,\n * then a `<sitemapindex>` LAST iff more than one file was produced.\n */\nexport async function* generateSitemap(\n opts: SitemapOptions,\n deps: SitemapDeps,\n tuning: SitemapTuning = {},\n): AsyncGenerator<SitemapFile> {\n const maxUrls = normalizePositiveInt(opts.maxUrlsPerFile, DEFAULT_MAX_URLS_PER_FILE)\n const batchSize = normalizePositiveInt(tuning.batchSize, SITEMAP_ALTERNATES_BATCH_SIZE)\n const base = stripTrailingSlash(opts.baseUrl)\n\n /** (filename, lastmod) of every urlset file produced — feeds the index. */\n const producedFiles: Array<{ filename: string; lastmod: string }> = []\n\n for (const locale of opts.locales) {\n let buffer: string[] = []\n let bufferMaxUpdatedAt: Date | null = null\n let fileIndex = 0\n\n /** Flush the current buffer as a numbered urlset file (or return null if empty). */\n const flush = (): SitemapFile | null => {\n if (buffer.length === 0) return null\n const filename = urlsetFilename(locale, fileIndex)\n const lastmod = (bufferMaxUpdatedAt ?? new Date()).toISOString()\n const file: SitemapFile = { kind: 'urlset', filename, xml: buildUrlset(buffer) }\n producedFiles.push({ filename, lastmod })\n buffer = []\n bufferMaxUpdatedAt = null\n fileIndex += 1\n return file\n }\n\n const source = enumeratePaths({ locale }, deps.enumerate, tuning.enumerate)\n for await (const batch of chunkAsync(source, batchSize)) {\n // Exclude noIndex rows FIRST so they're never fetched cross-locale.\n const kept = await excludeNoIndexed(batch, locale, deps)\n if (kept.length === 0) continue\n\n // ONE batched cross-locale lookup per (entity-type group × other locale).\n const alternatesByEntry = await resolveAlternates(kept, locale, opts, deps)\n\n for (const entry of kept) {\n const alternates = alternatesByEntry.get(entry.id) ?? [{ locale, path: entry.path }]\n buffer.push(buildUrlEntry(entry, locale, alternates, opts, base))\n if (bufferMaxUpdatedAt === null || entry.updatedAt > bufferMaxUpdatedAt) {\n bufferMaxUpdatedAt = entry.updatedAt\n }\n if (buffer.length >= maxUrls) {\n const full = flush()\n if (full) yield full\n }\n }\n }\n\n const tail = flush()\n if (tail) yield tail\n }\n\n // The index is only meaningful when it references more than one file.\n if (producedFiles.length > 1) {\n yield { kind: 'index', filename: SITEMAP_INDEX_FILENAME, xml: buildIndex(producedFiles, base) }\n }\n}\n\n/**\n * Resolve the full hreflang-alternate set for a batch of same-locale entries.\n * The current locale is always a self-alternate (`enumeratePaths` already proved\n * it published + canonical there). Every OTHER locale is resolved with ONE\n * batched `getCanonicalPaths` (forward index) + ONE batched `filterPublished`\n * (publish gate) per (entity-type group × locale) — never per row.\n */\nasync function resolveAlternates(\n entries: readonly PathEntry[],\n currentLocale: string,\n opts: SitemapOptions,\n deps: SitemapDeps,\n): Promise<Map<string, Alternate[]>> {\n const result = new Map<string, Alternate[]>()\n // Seed each entry with its self-alternate (its own published canonical URL).\n for (const entry of entries) result.set(entry.id, [{ locale: currentLocale, path: entry.path }])\n\n const otherLocales = opts.locales.filter((l) => l !== currentLocale)\n if (otherLocales.length === 0) return result\n\n // Group the batch's ids by entity type (bounded: routable types are few + config-fixed).\n const idsByType = new Map<string, string[]>()\n for (const entry of entries) {\n const bucket = idsByType.get(entry.entityType)\n if (bucket) bucket.push(entry.id)\n else idsByType.set(entry.entityType, [entry.id])\n }\n\n for (const otherLocale of otherLocales) {\n for (const [type, ids] of idsByType) {\n // (1) Forward index: which of these ids have a canonical row in otherLocale?\n const pathMap = await getCanonicalPaths(type, ids, otherLocale, deps.canonicalBatch)\n if (pathMap.size === 0) continue\n // (2) Publish gate: of THOSE, which are actually published in otherLocale?\n // getCanonicalPaths does NOT publish-filter — this is the required\n // no-unpublished-leak step, batched (never per-row).\n const candidateIds = [...pathMap.keys()]\n const publishedIds = await deps.enumerate.filterPublished(type, candidateIds, otherLocale)\n // (3) noIndex gate — same shape, other-locale value. Without this, a\n // document marked noIndex in `otherLocale` (excluded from ITS OWN\n // urlset by `excludeNoIndexed` below) still gets published as an\n // hreflang alternate on every OTHER locale's urlset, publishing the\n // exact URL the editor asked to hide.\n const noIndexIds =\n deps.filterNoIndex !== undefined\n ? await deps.filterNoIndex(type, candidateIds, otherLocale)\n : undefined\n for (const [id, path] of pathMap) {\n if (!publishedIds.has(id)) continue\n if (noIndexIds?.has(id)) continue\n const alternates = result.get(id)\n if (alternates) alternates.push({ locale: otherLocale, path })\n }\n }\n }\n\n return result\n}\n\n/**\n * Peak concurrent `noIndex`-check groups per batch — mirrors `enumerate.ts`'s\n * `PUBLISH_CHECK_CONCURRENCY` bound (CLAUDE.md \"no amplification\"): a batch\n * holds a handful of distinct entity types at most, so this is rarely reached\n * — it exists so the fan-out is EXPLICITLY capped, never unbounded.\n */\nconst NOINDEX_CHECK_CONCURRENCY = 5\n\n/**\n * Keep the entries `deps.filterNoIndex` does NOT report as `noIndex` in\n * `locale` (all of them when the dep is omitted — current default: nothing\n * excluded). Groups the batch by entity type (mirrors `enumeratePaths`'s own\n * `byType` grouping) and calls `deps.filterNoIndex` ONCE per (entity-type\n * group, batch) — never once per entry — bounded to\n * {@link NOINDEX_CHECK_CONCURRENCY} concurrent groups.\n *\n * FAIL-CLOSED, deliberately, unlike `loadMetaPanelData`'s fail-open contract\n * (`packages/admin-ui/src/server/meta-panel-data.ts`): `mapBounded` does not\n * catch per-item errors (see its own doc comment in `apply-path.ts`), so a\n * `deps.filterNoIndex` rejection aborts the whole `generateSitemap` run rather\n * than silently including a possibly-noIndexed row. A build-time sitemap\n * generator failing loudly is preferable to a build succeeding with a leaked\n * noIndex URL.\n */\nasync function excludeNoIndexed(\n entries: readonly PathEntry[],\n locale: string,\n deps: SitemapDeps,\n): Promise<PathEntry[]> {\n const filterNoIndex = deps.filterNoIndex\n if (filterNoIndex === undefined) return [...entries]\n\n // Group this batch's ids by entity type (bounded: routable types are few).\n const idsByType = new Map<string, string[]>()\n for (const entry of entries) {\n const bucket = idsByType.get(entry.entityType)\n if (bucket) bucket.push(entry.id)\n else idsByType.set(entry.entityType, [entry.id])\n }\n\n const noIndexedByType = new Map<string, ReadonlySet<string>>()\n await mapBounded([...idsByType.entries()], NOINDEX_CHECK_CONCURRENCY, async ([type, ids]) => {\n noIndexedByType.set(type, await filterNoIndex(type, ids, locale))\n })\n\n return entries.filter((entry) => !noIndexedByType.get(entry.entityType)?.has(entry.id))\n}\n\n// ---------------------------------------------------------------------------\n// XML builders\n// ---------------------------------------------------------------------------\n\nconst URLSET_HEADER =\n '<?xml version=\"1.0\" encoding=\"UTF-8\"?>\\n' +\n '<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\" ' +\n 'xmlns:xhtml=\"http://www.w3.org/1999/xhtml\">'\n\nconst INDEX_HEADER =\n '<?xml version=\"1.0\" encoding=\"UTF-8\"?>\\n' +\n '<sitemapindex xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">'\n\n/** Wrap the buffered `<url>` blocks in a `<urlset>` envelope. */\nfunction buildUrlset(urlBlocks: readonly string[]): string {\n return `${URLSET_HEADER}\\n${urlBlocks.join('\\n')}\\n</urlset>\\n`\n}\n\n/** Build one `<url>` block: `<loc>`, `<lastmod>`, one `xhtml:link` per alternate + x-default. */\nfunction buildUrlEntry(\n entry: PathEntry,\n currentLocale: string,\n alternates: readonly Alternate[],\n opts: SitemapOptions,\n base: string,\n): string {\n const loc = absoluteUrl(base, opts.prefix, currentLocale, entry.path)\n const links = alternates.map(\n (alt) => ` ${hreflangLink(alt.locale, absoluteUrl(base, opts.prefix, alt.locale, alt.path))}`,\n )\n // x-default → the default-locale URL, when the document is published there.\n const defaultAlt = alternates.find((alt) => alt.locale === opts.defaultLocale)\n if (defaultAlt) {\n links.push(\n ` ${hreflangLink('x-default', absoluteUrl(base, opts.prefix, defaultAlt.locale, defaultAlt.path))}`,\n )\n }\n return (\n ' <url>\\n' +\n ` <loc>${escapeXml(loc)}</loc>\\n` +\n ` <lastmod>${entry.updatedAt.toISOString()}</lastmod>\\n` +\n `${links.join('\\n')}\\n` +\n ' </url>'\n )\n}\n\n/** Build the `<sitemapindex>` referencing every produced urlset file. */\nfunction buildIndex(files: ReadonlyArray<{ filename: string; lastmod: string }>, base: string): string {\n const entries = files.map(\n (f) =>\n ' <sitemap>\\n' +\n ` <loc>${escapeXml(`${base}/${f.filename}`)}</loc>\\n` +\n ` <lastmod>${f.lastmod}</lastmod>\\n` +\n ' </sitemap>',\n )\n return `${INDEX_HEADER}\\n${entries.join('\\n')}\\n</sitemapindex>\\n`\n}\n\n/** One `<xhtml:link rel=\"alternate\" hreflang=... href=.../>` tag (both values escaped). */\nfunction hreflangLink(hreflang: string, href: string): string {\n return `<xhtml:link rel=\"alternate\" hreflang=\"${escapeXml(hreflang)}\" href=\"${escapeXml(href)}\"/>`\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\n/** `baseUrl` + optional `prefix(locale)` + `path` — the absolute, un-escaped URL. */\nfunction absoluteUrl(\n base: string,\n prefix: SitemapOptions['prefix'],\n locale: string,\n path: string,\n): string {\n return `${base}${prefix ? prefix(locale) : ''}${path}`\n}\n\n/** Strip any trailing slash(es) so `base + '/' + file` / `base + path` never doubles up. */\nfunction stripTrailingSlash(url: string): string {\n return url.replace(/\\/+$/, '')\n}\n\n/**\n * Escape the five XML predefined entities. `&` MUST be replaced first so the\n * entity ampersands it introduces are not re-escaped.\n */\nfunction escapeXml(value: string): string {\n return value\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''')\n}\n\n/** `sitemap-<locale>.xml` (index 0) / `sitemap-<locale>-<n>.xml` (splits, n≥2). */\nfunction urlsetFilename(locale: string, index: number): string {\n return index === 0 ? `sitemap-${locale}.xml` : `sitemap-${locale}-${index + 1}.xml`\n}\n\n/**\n * A finite, ≥1 integer from a config-derived value, else `fallback`. Guards a 0 /\n * negative / fractional / NaN `maxUrlsPerFile` or `batchSize` from producing a\n * zero-length split loop.\n */\nfunction normalizePositiveInt(value: number | undefined, fallback: number): number {\n if (value === undefined || !Number.isFinite(value)) return fallback\n const floored = Math.floor(value)\n return floored >= 1 ? floored : fallback\n}\n\n/**\n * Buffer an async source into fixed-size chunks — bounds the per-batch\n * cross-locale fan-out (see module header). The final chunk may be short.\n */\nasync function* chunkAsync<T>(source: AsyncIterable<T>, size: number): AsyncGenerator<T[]> {\n let buffer: T[] = []\n for await (const item of source) {\n buffer.push(item)\n if (buffer.length >= size) {\n yield buffer\n buffer = []\n }\n }\n if (buffer.length > 0) yield buffer\n}\n","/**\n * Build-time redirect list for SSG frontends (plan/routing PR08; phase\n * `06-frontend-adapters.md` §3, finding F037 point 3).\n *\n * `getRedirects` streams the full `(from, to, status)` redirect set for one\n * locale — the manual (`toolkit_redirects`) rows PLUS the automatic historical\n * (`toolkit_paths` non-canonical) rows — so a frontend can materialize a static\n * redirect map (`_redirects` / `vercel.json` / an nginx include) at build time.\n * It is a PURE generator, not an HTTP endpoint: the frontend recipe (PR08 Task\n * 2b) maps the yielded entries into its host's adapter format.\n *\n * **Security (CLAUDE.md #1).** The output feeds a public, static build artifact,\n * so a redirect must NEVER point at content the live site would 404 — a static\n * 301 to unpublished content has no request-time fallback to catch it (unlike\n * `resolvePath`, where the target's own canonical tier re-checks). So every\n * redirect whose target is a resolved DOCUMENT (entity-kind manual + all\n * historical) is publish-gated with the SAME batched `filterPublished` primitive\n * `enumeratePaths` / `generateSitemap` use, and an unpublished target is DROPPED\n * — mirroring `resolvePath`'s tier-4 `checkPublished` gate. `url`-kind targets\n * are admin-authored external URLs (never a public write); `path`-kind targets\n * are gated on the target path currently serving canonical content, matching\n * `resolveManualRedirect`'s own path-kind branch.\n *\n * **No unbounded fan-out (CLAUDE.md \"no amplification\").** Both halves are\n * keyset-paginated on the immutable `id` PK (one index query per page), and the\n * document resolution + publish check are BATCHED — one `getCanonicalPaths` +\n * one `filterPublished` per (page × distinct entity-type group), never one query\n * per row (mirrors `enumeratePaths`'s / the sitemap's own bound). Manual redirects\n * are admin-authored (bounded volume, not content-scaled) but are still paginated\n * on principle. The only inherently per-row read is the `path`-kind existence\n * check — one bounded `limit: 1` lookup per surviving `path`-kind manual row —\n * which can't be batched (it keys on the target PATH, not an id set) and rides on\n * the same bounded admin-authored volume.\n *\n * **Precedence, mirroring `resolvePath`.** `resolvePath` resolves\n * canonical → manual → historical, and within manual a locale-specific row wins\n * over the catch-all (`locale IS NULL`) row (`lookupManualRedirect`). This list\n * reproduces that ordering: locale-specific manual rows first (claiming their\n * `from`), then catch-all manual rows (skipped where a locale-specific row\n * already claimed the path), then historical rows (skipped where ANY manual\n * redirect claimed the path — matching resolvePath's tier-3-before-tier-4\n * short-circuit, which fires even when the manual row itself resolves to nothing).\n *\n * **Not reproduced (deliberate, deps stay minimal).** (1) The homepage-collision\n * collapse (`resolvePath` sends a redirect that targets the homepage document\n * straight to `/`): a manual/historical redirect targeting the homepage instead\n * emits its pattern URL, which the live `resolvePath` then 301s to `/` — one\n * extra hop for a rare case, not a broken link, and it avoids threading a\n * `homepageReader` dep. (2) `path`-kind live multi-hop walking: `path` targets are\n * collapsed to ≤1 hop at save time (`collapsePathTarget`, phase 04 §3), so a\n * single existence lookup suffices; a rebuild re-runs this generator whenever\n * redirects change. (3) The multi-document historical collision (two docs holding\n * a historical row at the same path — findRedirectTarget's documented\n * approximate/self-healing edge, F014): this list emits at most one rule per\n * `from` (first that resolves, in id order) rather than most-recent-updated wins.\n *\n * **Leaf module.** Every cross-package dependency is injected via `deps`; this\n * module imports no `@murumets-ee/core` and no `server-only` graph. Concrete deps\n * are built from an app in `./resolve-context.ts` (`buildRedirectsListDeps`).\n */\n\nimport {\n type CanonicalPathsBatchDeps,\n getCanonicalPaths,\n type PathEnumReader,\n type PathEnumRow,\n type PathEnumWhere,\n type PublishFilter,\n} from './enumerate.js'\nimport { normalizePath } from './generate-path.js'\nimport type { RedirectTarget } from '../redirects/target.js'\n\n// ---------------------------------------------------------------------------\n// Read surface — `type` aliases (not interfaces) so the real\n// `ToolkitRedirectsClient`, typed against `WhereClause`'s template-literal index\n// signature, stays assignable without a cast (same rationale as\n// `PathEnumReader` / `index-reader.ts`'s Deviation-1).\n// ---------------------------------------------------------------------------\n\n/** The projected `toolkit_redirects` row the manual-redirect enumeration reads. */\nexport type RedirectListRow = {\n id: string\n fromPath: string\n /** BCP47 locale, or `null` for the catch-all (all-locales) row. */\n locale: string | null\n target: RedirectTarget\n status: number\n}\n\n/**\n * The (operator-carrying) where shapes the manual enumeration issues: a keyset\n * `id > cursor` plus a `locale` equality (a concrete locale, or `null` for the\n * catch-all stratum). A subset of the real client's `WhereClause`, so the real\n * `ToolkitRedirectsClient` is assignable to {@link RedirectListReader}.\n */\nexport type RedirectListWhere = {\n id?: { gt: string }\n locale?: string | null\n}\n\n/**\n * The batched keyset read surface the manual half needs over `toolkit_redirects`.\n * Construct the real client directly (`toolkitRedirectsTable.makeClient(db)`) —\n * it satisfies this type.\n */\nexport type RedirectListReader = {\n findMany(options: {\n where?: RedirectListWhere\n orderBy?: Array<{ column: 'id'; dir?: 'asc' | 'desc' }>\n limit?: number\n }): Promise<RedirectListRow[]>\n}\n\n// ---------------------------------------------------------------------------\n// Public surface\n// ---------------------------------------------------------------------------\n\n/** One emitted redirect. `301` permanent, `302` temporary (phase 05 §2 shape). */\nexport interface RedirectEntry {\n from: string\n to: string\n status: 301 | 302\n}\n\n/** Options for {@link getRedirects}. */\nexport interface RedirectsListOptions {\n /** Emit this locale's redirects only (mirrors `enumeratePaths` / `generateSitemap`). */\n locale: string\n}\n\n/** Injected dependencies for {@link getRedirects}. */\nexport interface RedirectsListDeps {\n /** Keyset reader over `toolkit_redirects` (manual redirects). */\n redirectsClient: RedirectListReader\n /**\n * Keyset reader over `toolkit_paths` — the historical (non-canonical) rows AND\n * the `path`-kind existence check. Reuses `enumeratePaths`'s exact reader shape.\n */\n pathsClient: PathEnumReader\n /** Batched forward index read for owning-document current-canonical resolution. */\n canonicalBatch: CanonicalPathsBatchDeps\n /** The SAME batched publish filter `enumeratePaths` / `generateSitemap` use. */\n filterPublished: PublishFilter\n}\n\n/** Internal tuning seam (page size). Real callers omit it. */\nexport interface RedirectsListTuning {\n /** Rows per keyset page. Defaults to {@link REDIRECTS_PAGE_SIZE}. Tests force a small value. */\n pageSize?: number\n}\n\n/**\n * Redirect-list page size — same order of magnitude as `enumeratePaths`'s\n * `ENUMERATE_PAGE_SIZE` (200). Must stay ≤ the `defineTable` `MAX_LIMIT` (1000),\n * which THROWS rather than clamps.\n */\nconst REDIRECTS_PAGE_SIZE = 200\n\n/**\n * Stream the redirect list for `opts.locale`: manual redirects (locale-specific\n * then catch-all, deduped by precedence) followed by historical redirects\n * (skipping any `from` a manual redirect already claimed). See the module header\n * for the security + fan-out + precedence guarantees.\n */\nexport async function* getRedirects(\n opts: RedirectsListOptions,\n deps: RedirectsListDeps,\n tuning: RedirectsListTuning = {},\n): AsyncGenerator<RedirectEntry> {\n const { locale } = opts\n const pageSize = tuning.pageSize ?? REDIRECTS_PAGE_SIZE\n\n /**\n * Every `from` a manual redirect (either stratum) OWNS for this locale. Serves\n * two roles: (1) a locale-specific row shadows a catch-all row at the same\n * path; (2) ANY manual redirect at a path shadows the historical tier there —\n * even when the manual row itself resolves to nothing (resolvePath tier-3\n * short-circuit). Populated for EVERY manual row seen (dropped or emitted).\n */\n const manualFrom = new Set<string>()\n\n // ── Tier 3a: locale-specific manual redirects ──────────────────────────────\n yield* manualRedirects(locale, locale, deps, pageSize, manualFrom)\n // ── Tier 3b: catch-all (locale IS NULL) manual redirects ───────────────────\n // `manualFrom` now holds every locale-specific `from`, so catch-all rows at\n // those paths are suppressed inside `manualRedirects`.\n yield* manualRedirects(locale, null, deps, pageSize, manualFrom)\n\n // ── Tier 4: historical (automatic rename) redirects ────────────────────────\n yield* historicalRedirects(locale, deps, pageSize, manualFrom)\n}\n\n// ---------------------------------------------------------------------------\n// Manual redirects\n// ---------------------------------------------------------------------------\n\n/**\n * Stream one `locale` stratum of `toolkit_redirects` (a concrete locale, or\n * `null` for the catch-all rows), keyset-paginated. Records every surviving\n * `from` in `manualFrom` (for precedence + historical shadowing) and yields the\n * resolved, publish-gated entries in id-ascending order.\n */\nasync function* manualRedirects(\n locale: string,\n whereLocale: string | null,\n deps: RedirectsListDeps,\n pageSize: number,\n manualFrom: Set<string>,\n): AsyncGenerator<RedirectEntry> {\n let cursorId: string | undefined\n for (;;) {\n const where: RedirectListWhere = { locale: whereLocale }\n if (cursorId !== undefined) where.id = { gt: cursorId }\n\n const page = await deps.redirectsClient.findMany({\n where,\n orderBy: [{ column: 'id', dir: 'asc' }],\n limit: pageSize,\n })\n if (page.length === 0) break\n\n // Drop rows whose `from` a higher-precedence manual redirect already claimed\n // (a locale-specific row shadowing a catch-all one). Record every surviving\n // `from` — even one we go on to DROP — so precedence + historical shadowing\n // hold regardless of resolution outcome (matches resolvePath's short-circuit).\n const survivors: RedirectListRow[] = []\n for (const row of page) {\n if (manualFrom.has(row.fromPath)) continue\n manualFrom.add(row.fromPath)\n survivors.push(row)\n }\n\n // Batched entity-kind resolution: group by target entity type, then ONE\n // getCanonicalPaths + ONE filterPublished per type group (never per row).\n const resolvedEntityTo = await resolveEntityTargets(survivors, locale, deps)\n\n // Emit in survivor (id) order.\n for (const row of survivors) {\n const status = toRedirectStatus(row.status)\n const target = row.target\n\n if (target.kind === 'url') {\n // Terminal external URL — admin-authored, no publish gate, no self-loop.\n yield { from: row.fromPath, to: target.url, status }\n continue\n }\n\n if (target.kind === 'entity') {\n const to = resolvedEntityTo.get(row.id)\n // Dropped when unpublished / no canonical row (matches resolvePath's\n // tier-3 `{kind:'none'}`). Skip a degenerate self-redirect too.\n if (to !== undefined && to !== row.fromPath) {\n yield { from: row.fromPath, to, status }\n }\n continue\n }\n\n // target.kind === 'path' — collapsed to ≤1 hop at save time, so one bounded\n // existence lookup suffices. Emit only when the target path currently serves\n // canonical content (matches resolveManualRedirect's path-kind branch).\n const to = normalizePath(target.path)\n if (to === row.fromPath) continue\n if (await pathServesCanonical(deps.pathsClient, locale, to)) {\n yield { from: row.fromPath, to, status }\n }\n }\n\n const lastRow = page[page.length - 1]\n if (!lastRow) break\n cursorId = lastRow.id\n if (page.length < pageSize) break\n }\n}\n\n/**\n * Resolve the entity-kind targets in a page to their published canonical path,\n * BATCHED by entity type: one `getCanonicalPaths` (forward index) + one\n * `filterPublished` (publish gate) per distinct target type — never per row\n * (mirrors the sitemap's `resolveAlternates` bucketing). Returns a\n * `redirectRowId → path` map holding only the rows that resolve AND publish.\n */\nasync function resolveEntityTargets(\n rows: readonly RedirectListRow[],\n locale: string,\n deps: RedirectsListDeps,\n): Promise<Map<string, string>> {\n const result = new Map<string, string>()\n\n // Group entity-kind rows by target entity type (bounded: routable types are\n // few + config-fixed). Capture `(redirectRowId, targetId)` while the union arm\n // is still narrowed, so no discriminant re-cast is needed downstream.\n const targetsByType = new Map<string, Array<{ rowId: string; targetId: string }>>()\n for (const row of rows) {\n if (row.target.kind !== 'entity') continue\n const pair = { rowId: row.id, targetId: row.target.id }\n const bucket = targetsByType.get(row.target.entity)\n if (bucket) bucket.push(pair)\n else targetsByType.set(row.target.entity, [pair])\n }\n\n for (const [type, targets] of targetsByType) {\n const ids = targets.map((t) => t.targetId)\n // (1) Forward index: which target ids have a canonical row in `locale`?\n const pathMap = await getCanonicalPaths(type, ids, locale, deps.canonicalBatch)\n const candidateIds = [...pathMap.keys()]\n if (candidateIds.length === 0) continue\n // (2) Publish gate: of THOSE, which are actually published in `locale`?\n const published = await deps.filterPublished(type, candidateIds, locale)\n for (const { rowId, targetId } of targets) {\n const path = pathMap.get(targetId)\n if (path !== undefined && published.has(targetId)) result.set(rowId, path)\n }\n }\n\n return result\n}\n\n/** True iff `path` currently serves a canonical document in `locale` (one bounded lookup). */\nasync function pathServesCanonical(\n pathsClient: PathEnumReader,\n locale: string,\n path: string,\n): Promise<boolean> {\n const rows = await pathsClient.findMany({\n where: { locale, path, isCanonical: true },\n limit: 1,\n })\n return rows.length > 0\n}\n\n// ---------------------------------------------------------------------------\n// Historical redirects\n// ---------------------------------------------------------------------------\n\n/**\n * Stream the historical (automatic rename) redirects for `locale`: the demoted\n * (`is_canonical = false`) `toolkit_paths` rows, each resolved to its owning\n * document's CURRENT canonical path. Keyset-paginated; per page, ONE\n * `getCanonicalPaths` + ONE `filterPublished` per entity-type group (never per\n * row). Drops rows whose document has no live published canonical variant\n * anymore (matches `findRedirectTarget`'s `null`), and any `from` a manual\n * redirect already claimed (tier-3 wins) or an already-emitted historical `from`\n * (at most one rule per `from`).\n */\nasync function* historicalRedirects(\n locale: string,\n deps: RedirectsListDeps,\n pageSize: number,\n manualFrom: ReadonlySet<string>,\n): AsyncGenerator<RedirectEntry> {\n /** Historical `from`s already emitted — dedup the documented multi-doc collision. */\n const emittedFrom = new Set<string>()\n let cursorId: string | undefined\n for (;;) {\n const where: PathEnumWhere = { locale, isCanonical: false }\n if (cursorId !== undefined) where.id = { gt: cursorId }\n\n const page = await deps.pathsClient.findMany({\n where,\n orderBy: [{ column: 'id', dir: 'asc' }],\n limit: pageSize,\n })\n if (page.length === 0) break\n\n // Group this page's rows by entity type, then ONE batched canonical lookup +\n // ONE publish check per group. `entityId → current published canonical path`.\n const byType = new Map<string, PathEnumRow[]>()\n for (const row of page) {\n const bucket = byType.get(row.entityType)\n if (bucket) bucket.push(row)\n else byType.set(row.entityType, [row])\n }\n\n const currentPath = new Map<string, string>()\n for (const [type, rows] of byType) {\n const ids = rows.map((r) => r.entityId)\n const pathMap = await getCanonicalPaths(type, ids, locale, deps.canonicalBatch)\n const candidateIds = [...pathMap.keys()]\n if (candidateIds.length === 0) continue\n const published = await deps.filterPublished(type, candidateIds, locale)\n for (const [id, path] of pathMap) {\n if (published.has(id)) currentPath.set(id, path)\n }\n }\n\n for (const row of page) {\n // A manual redirect at this path wins (tier 3 before tier 4).\n if (manualFrom.has(row.path)) continue\n // Already emitted a historical rule at this `from` (multi-doc collision).\n if (emittedFrom.has(row.path)) continue\n const to = currentPath.get(row.entityId)\n if (to === undefined) continue // no live published canonical → dropped\n if (to === row.path) continue // self-redirect (from == to) → skip\n emittedFrom.add(row.path)\n yield { from: row.path, to, status: 301 }\n }\n\n const lastRow = page[page.length - 1]\n if (!lastRow) break\n cursorId = lastRow.id\n if (page.length < pageSize) break\n }\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\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 entry (mirrors `resolve.ts`'s `toRedirectStatus`).\n */\nfunction toRedirectStatus(status: number): 301 | 302 {\n return status === 302 ? 302 : 301\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{t as e}from"./content-config-store-8omQF5p5.mjs";import{h as t,n,p as r,s as i,u as a}from"./schema-BJxAwPrI.mjs";import{matchReservedPrefix as o}from"@murumets-ee/entity";import{combineAdminRoutes as s,defineAdminRoute as c,getContext as l,safeAudit as u}from"@murumets-ee/core";import{and as d,eq as f}from"drizzle-orm";import{z as p}from"zod";import{entityRefs as m}from"@murumets-ee/entity/refs";const h=[`admin`,`editor`];function g(e,t=200){return new Response(JSON.stringify(e),{status:t,headers:{"Content-Type":`application/json`}})}const _={kind:`none`};function v(e){return e===null||e.length===0?{error:`query param 'path' is required`}:e.length>2048?{error:`query param 'path' is too long`}:e.startsWith(`/`)?e.startsWith(`//`)?{error:`query param 'path' must not be protocol-relative`}:e.includes(`?`)||e.includes(`#`)?{error:`query param 'path' must not include a query or fragment`}:{path:e}:{error:`query param 'path' must start with '/'`}}function y(t,n){let r=e(),i=t??n??r.defaultLocale;return(l()?.availableLocales??r.locales.map(e=>e.code)).includes(i)?{locale:i}:{error:`query param 'locale' is not a content locale`}}const b=async(e,t)=>{let n=new URL(e.url).searchParams,r=v(n.get(`path`));if(`error`in r)return g({error:r.error},400);let a=y(n.get(`locale`),t.locale??t.defaultLocale);if(`error`in a)return g({error:a.error},400);let{locale:o}=a,{buildResolveDeps:s}=await import(`./resolve-context-p3m6gn3_.mjs`).then(e=>e.l),c=s(t.app),l=(e,n)=>t.checkPermission(e,`view`)?{kind:`document`,entityType:e,id:n}:_,u=await i(r.path,o,c);if(u.kind===`document`)return g(l(u.entityType,u.id));if(u.kind===`none`)return g(_);if(!(u.to.startsWith(`/`)&&!u.to.startsWith(`//`)))return g({kind:`redirect`,to:u.to});let d=await i(u.to,o,c);return g(d.kind===`document`?l(d.entityType,d.id):_)};function x(){return s([c({prefix:`paths`,path:`resolve`,method:`GET`,permission:`path:resolve`,defaultRoles:h,description:`Resolve a site URL path to the CMS document that serves it (editor canvas link navigation).`,handler:b})])}var S=class extends Error{fromPath;prefix;constructor(e,t){super(`Redirect source "${e}" falls under the reserved prefix "${t}" and cannot be used — that path range is owned by the framework.`),this.name=`RedirectReservedPrefixError`,this.fromPath=e,this.prefix=t}},C=class extends Error{fromPath;locale;conflictingLocale;constructor(e,t,n){super(`Redirect source "${e}" already serves content in locale "${n}" (it is a live canonical URL). Choose a different source path, or unpublish/rename the content first.`),this.name=`RedirectServesContentError`,this.fromPath=e,this.locale=t,this.conflictingLocale=n}},w=class extends Error{entity;constructor(e){super(`Redirect target references entity type "${e}", which is not a registered entity. Check the entity name.`),this.name=`RedirectUnknownEntityError`,this.entity=e}},T=class extends Error{entity;id;constructor(e,t){super(`Redirect target ${e} "${t}" does not exist. Redirect to a document that exists, or use a URL/path target instead.`),this.name=`RedirectTargetNotFoundError`,this.entity=e,this.id=t}},E=class extends Error{fromPath;locale;constructor(e,t){super(`Redirect source "${e}"${t===null?``:` (locale "${t}")`} resolves back to itself — a redirect cannot point at its own source path. Choose a different destination.`),this.name=`RedirectSelfReferenceError`,this.fromPath=e,this.locale=t}};const D=`toolkit_redirects`;async function O(e,t,n){n.kind===`entity`&&await e.insert(m).values({sourceEntity:D,sourceId:t,sourceField:`target`,targetEntity:n.entity,targetId:n.id,relationKind:`link`}).onConflictDoNothing()}async function k(e,t){await e.delete(m).where(d(f(m.sourceEntity,D),f(m.sourceId,t)))}const A=p.discriminatedUnion(`kind`,[p.object({kind:p.literal(`entity`),entity:p.string().min(1),id:p.string().uuid()}),p.object({kind:p.literal(`url`),url:p.string().max(2048).url()}),p.object({kind:p.literal(`path`),path:p.string().min(1).max(2048).startsWith(`/`)})]);function j(e){return r(e)}function ee(e){let t=j(e.fromPath),n=o(t,e.reservedPathPrefixes);if(n!==void 0)throw new S(t,n)}async function te(e){let t=j(e.fromPath);if(e.locale!==null){let n=await e.pathsClient.findOne({path:t,isCanonical:!0,locale:e.locale});if(n)throw new C(t,e.locale,n.locale);return}let n=await e.pathsClient.findOne({path:t,isCanonical:!0,locale:{in:[...e.configuredLocales]}});if(n)throw new C(t,null,n.locale)}async function M(e){if(e.target.kind!==`path`)return e.target;let t=j(e.target.path),n=e.excludeId===void 0?{}:{id:{ne:e.excludeId}};if(e.locale!==null){let r=await e.redirectsClient.findOne({fromPath:t,locale:e.locale,...n});if(r)return r.target}let r=await e.redirectsClient.findOne({fromPath:t,locale:null,...n});return r?r.target:{kind:`path`,path:t}}async function N(e){if(e.target.kind!==`entity`)return;let{entity:t,id:n}=e.target,r=e.resolveClient(t);if(r===void 0)throw new w(t);if(await r.findById(n)==null)throw new T(t,n)}const P=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,F=/^[a-zA-Z0-9:_.-]{1,100}$/,I=`redirect:manage`,L=[`admin`];function R(e,t=200){return new Response(JSON.stringify(e),{status:t,headers:{"Content-Type":`application/json`}})}function z(e,t){return R({error:e},t)}async function B(e,t){let n;try{n=await e.json()}catch{return{response:z(`Invalid JSON body`,400)}}let r=t.safeParse(n);if(!r.success){let e=r.error.issues[0];return{response:z(e?`${e.path.join(`.`)||`body`}: ${e.message}`:`Invalid body`,400)}}return{data:r.data}}function V(e,t){if(e===null||!/^\d+$/.test(e))return t;let n=Number(e);return Number.isSafeInteger(n)?n:t}function H(e){return e.name===void 0?{userId:e.id}:{userId:e.id,userName:e.name}}async function U(){let{getApp:e}=await import(`@murumets-ee/core`);return e()}async function W(){return(await U()).db.readWrite}async function G(){let e=await U();return t=>{let n=e.entities.get(t);return n?e.getClient(n):void 0}}function K(e){return e}const q=p.union([p.literal(301),p.literal(302)]),J=p.string().min(1).max(2048).startsWith(`/`),Y=p.object({locale:p.string().min(1).nullable(),fromPath:J,target:A,status:q.default(301)}),X=p.object({locale:p.string().min(1).nullable().optional(),fromPath:J.optional(),target:A.optional(),status:q.optional()});function Z(t){return t===null||e().locales.map(e=>e.code).includes(t)?null:z(`Unknown locale "${t}" — not one of the configured content locales.`,400)}function Q(e){if(e instanceof S)return R({error:e.message,fromPath:e.fromPath,prefix:e.prefix},409);if(e instanceof C)return R({error:e.message,fromPath:e.fromPath,locale:e.locale,conflictingLocale:e.conflictingLocale},409);if(e instanceof E)return R({error:e.message,fromPath:e.fromPath,locale:e.locale},409);if(e instanceof w)return R({error:e.message,entity:e.entity},400);if(e instanceof T)return R({error:e.message,entity:e.entity,id:e.id},400);if(t(e))return R({error:`A redirect already exists for this locale + source path.`},409);throw e}async function $(t){let r=e(),i=r.reservedPathPrefixes??[],o=r.locales.map(e=>e.code),s=j(t.fromPath);return ee({fromPath:s,reservedPathPrefixes:i}),await N({target:t.target,resolveClient:t.resolveClient}),(await W()).transaction(async e=>{let r=K(e),i=n.makeClient(r),c=a.makeClient(r);await te({fromPath:s,locale:t.locale,configuredLocales:o,pathsClient:c});let l=await M({target:t.target,locale:t.locale,redirectsClient:i,...t.id===null?{}:{excludeId:t.id}});if(l.kind===`path`&&j(l.path)===s)throw new E(s,t.locale);if(t.id===null){let e=await i.insert({locale:t.locale,fromPath:s,target:l,status:t.status,createdBy:t.userId});return await O(r,e.id,l),e}let u=await i.update({id:t.id},{locale:t.locale,fromPath:s,target:l,status:t.status,updatedAt:new Date});return u?(await k(r,t.id),await O(r,t.id,l),u):null})}const ne=async e=>{let t=new URL(e.url),r=t.searchParams.get(`locale`),i=Math.min(Math.max(V(t.searchParams.get(`limit`),50),1),200),a=Math.min(V(t.searchParams.get(`offset`),0),1e5),o;if(r!==null&&r!==``){let e=Z(r);if(e)return e;o=r}let s=n.makeClient(await W());return R({items:o===void 0?await s.findMany({orderBy:[{column:`createdAt`,dir:`desc`}],limit:i,offset:a}):await s.findMany({where:{locale:o},orderBy:[{column:`createdAt`,dir:`desc`}],limit:i,offset:a}),total:o===void 0?await s.count():await s.count({locale:o})})},re=async(e,t)=>{let n=await B(e,Y);if(`response`in n)return n.response;let r=n.data,i=Z(r.locale);if(i)return i;try{let e=await G(),n=await $({id:null,locale:r.locale,fromPath:r.fromPath,target:r.target,status:r.status,userId:t.user.id,resolveClient:e});return n?(u(t,{action:`redirect.created`,entityType:`redirect`,entityId:n.id,...H(t.user),metadata:{locale:n.locale,fromPath:n.fromPath,targetKind:n.target.kind}}),R({item:n},201)):z(`Failed to create redirect`,500)}catch(e){return Q(e)}},ie=async(e,t)=>{let r=t.segments[0];if(!r||!P.test(r))return z(`Invalid id`,400);let i=await B(e,X);if(`response`in i)return i.response;let a=i.data,o=await n.makeClient(await W()).findOne({id:r});if(!o)return z(`Not found`,404);let s=a.locale===void 0?o.locale:a.locale,c=a.fromPath===void 0?o.fromPath:a.fromPath,l=a.target===void 0?o.target:a.target,d=a.status===void 0?o.status:a.status,f=Z(s);if(f)return f;try{let e=await G(),n=await $({id:r,locale:s,fromPath:c,target:l,status:d,userId:t.user.id,resolveClient:e});return n?(u(t,{action:`redirect.updated`,entityType:`redirect`,entityId:n.id,...H(t.user),metadata:{locale:n.locale,fromPath:n.fromPath,targetKind:n.target.kind}}),R({item:n})):z(`Not found`,404)}catch(e){return Q(e)}},ae=async(e,t)=>{let r=t.segments[0];if(!r||!P.test(r))return z(`Invalid id`,400);let i=await(await W()).transaction(async e=>{let t=K(e);return await k(t,r),n.makeClient(t).delete({id:r})});return i?(u(t,{action:`redirect.deleted`,entityType:`redirect`,entityId:r,...H(t.user),metadata:{locale:i.locale,fromPath:i.fromPath}}),R({ok:!0})):z(`Not found`,404)},oe=async e=>{let t=new URL(e.url),n=t.searchParams.get(`entityType`),r=t.searchParams.get(`entityId`);if(!n||!F.test(n))return z(`entityType is required`,400);if(!r||!P.test(r))return z(`entityId is required and must be a UUID`,400);let i=t.searchParams.get(`locale`),o;if(i!==null&&i!==``){let e=Z(i);if(e)return e;o=i}return R({items:await a.makeClient(await W()).findMany({where:{entityType:n,entityId:r,isCanonical:!1,...o!==void 0&&{locale:o}},orderBy:[{column:`updatedAt`,dir:`desc`}],limit:100})})};function se(){return s([c({prefix:`redirects`,path:``,method:`GET`,permission:I,defaultRoles:L,description:`List manual URL redirects (paginated, optional locale filter).`,handler:ne}),c({prefix:`redirects`,path:`history`,method:`GET`,permission:I,defaultRoles:L,description:`Read-only automatic-redirect (rename) history for one document.`,handler:oe}),c({prefix:`redirects`,path:``,method:`POST`,permission:I,defaultRoles:L,description:`Create a manual URL redirect.`,handler:re}),c({prefix:`redirects`,path:``,method:`PATCH`,matchAnyPath:!0,permission:I,defaultRoles:L,description:`Update a manual URL redirect by id.`,handler:ie}),c({prefix:`redirects`,path:``,method:`DELETE`,matchAnyPath:!0,permission:I,defaultRoles:L,description:`Delete a manual URL redirect by id.`,handler:ae})])}export{x as n,se as t};
|
|
2
|
+
//# sourceMappingURL=routes-D3dzAfQw.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"routes-D3dzAfQw.mjs","names":["json"],"sources":["../src/paths/routes.ts","../src/redirects/errors.ts","../src/redirects/refs.ts","../src/redirects/target.ts","../src/redirects/validation.ts","../src/redirects/routes.ts"],"sourcesContent":["/**\n * Path-resolution admin route — \"which document owns this URL?\".\n *\n * Mounted under prefix `paths`. The URL surface:\n *\n * GET /api/admin/paths/resolve ?path=/foo/bar&locale=en\n *\n * **Why this exists.** The block editor's preview canvas is a `srcDoc` iframe,\n * so a rendered link's site-absolute `href` resolves against the ADMIN origin —\n * clicking one navigated the CANVAS and could render the whole admin editor\n * inside itself (#775). `@murumets-ee/blocks` now contains every in-canvas link\n * click and hands it to its host; the host needs exactly one server capability\n * to make that click useful: turn a site path into the document that serves it,\n * so it can open THAT document's editor.\n *\n * **Draft resolution, deliberately.** Uses `resolveDraftPath` (not\n * `resolvePath`): an editor following a link to an unpublished sibling page\n * must land on its editor, and the caller is an authenticated admin who can\n * already read the row through the entity API. This is exactly the\n * \"admin-only surface\" `resolveDraftPath`'s docblock reserves — the FIRST HTTP\n * route wired to it. It must never be reachable from `content-api` (public,\n * published-only).\n *\n * **Authorization — two gates.**\n * 1. Route gate (`defineAdminRoute`): `path:resolve`, `defaultRoles:\n * ['admin', 'editor']`. Viewers are excluded; the capability only exists\n * to navigate BETWEEN editors.\n * 2. Per-document gate: the resolved `entityType` must also pass\n * `ctx.checkPermission(entityType, 'view')`. A caller with the route\n * permission but no grant on that entity gets `{ kind: 'none' }` — the\n * SAME answer as a path that owns nothing, so the response never\n * discloses that a document exists there (no-leak, matching\n * `menus`' anchors route and content-api's 404-not-403 rule).\n *\n * **Bounded work.** One resolve is a handful of indexed single-row reads. A\n * `redirect`-kind result is followed exactly ONCE more (the index stores\n * collapsed targets, and `resolveInternal` already caps its own chain at\n * `MAX_REDIRECT_HOPS`) — so this handler's cost is constant, never a chain\n * walk driven by caller input.\n */\n\nimport {\n type AdminRoute,\n type AdminRouteHandler,\n combineAdminRoutes,\n defineAdminRoute,\n getContext,\n} from '@murumets-ee/core'\nimport { getContentConfig } from '../content-config-store.js'\n// `./resolve.js` is a LEAF (every dependency injected, no `@murumets-ee/core`\n// graph) so it imports statically. `./resolve-context.js` is NOT — it holds the\n// app-binding imports, and its own header reserves it for request-time surfaces\n// and forbids the `lumi` CLI command graph (F027 guardrail). This module is\n// reached from `plugin.ts`'s STATIC graph, which the CLI loads through\n// `lumi.config.ts`, so the binding is imported at REQUEST time inside the\n// handler instead — same deferral `redirects/routes.ts` uses for `getApp`.\nimport { resolveDraftPath } from './resolve.js'\n\n/** Route permission + the roles that hold it by default. */\nexport const PATH_RESOLVE_PERMISSION = 'path:resolve'\nexport const PATH_RESOLVE_DEFAULT_ROLES = ['admin', 'editor'] as const\n\n/**\n * Cap on the `path` query param. `toolkit_paths.path` is `text` (no DB cap), so\n * the bound is ours: generously above any real URL, low enough that the param\n * can't be used to push megabytes through the query planner.\n */\nconst MAX_PATH_LENGTH = 2048\n\n/** The answer shape the caller (admin-ui's editor canvas) consumes. */\nexport type ResolvePathResponse =\n /** The path is served by this document. */\n | { readonly kind: 'document'; readonly entityType: string; readonly id: string }\n /** The path redirects OFF-SITE (a `url`-kind manual redirect). */\n | { readonly kind: 'redirect'; readonly to: string }\n /** Nothing the caller may see owns this path. */\n | { readonly kind: 'none' }\n\nfunction json(data: ResolvePathResponse | { error: string }, status = 200): Response {\n return new Response(JSON.stringify(data), {\n status,\n headers: { 'Content-Type': 'application/json' },\n })\n}\n\nconst NONE: ResolvePathResponse = { kind: 'none' }\n\n/**\n * Boundary validation for the `path` param. Rejects (rather than normalizes)\n * anything that isn't a bare locale-relative path: a protocol-relative\n * `//host` or a `?query` / `#fragment` tail means the CALLER built the request\n * wrong, and answering it would silently resolve something other than what it\n * asked about.\n */\nfunction validatePath(raw: string | null): { path: string } | { error: string } {\n if (raw === null || raw.length === 0) return { error: \"query param 'path' is required\" }\n if (raw.length > MAX_PATH_LENGTH) return { error: \"query param 'path' is too long\" }\n if (!raw.startsWith('/')) return { error: \"query param 'path' must start with '/'\" }\n if (raw.startsWith('//')) return { error: \"query param 'path' must not be protocol-relative\" }\n if (raw.includes('?') || raw.includes('#')) {\n return { error: \"query param 'path' must not include a query or fragment\" }\n }\n return { path: raw }\n}\n\n/**\n * Whitelist the locale (CLAUDE.md \"whitelist, don't blacklist\"), falling back\n * to the request's own content locale and then the configured default so the\n * common call can omit it.\n *\n * Checks the **request-scoped effective set** — `content()` shipped defaults ⊕\n * DB `locale` override rows, resolved once per request (SD006) — with the static\n * set as fallback outside a request context. Using only the static list was a\n * real bug, not just an inconsistency: the in-editor locale switcher is driven\n * from `effectiveLocales`, so a locale enabled at RUNTIME is a legal editor\n * locale, and it would have 400'd here — making EVERY canvas link click in that\n * locale report \"no CMS page serves …\".\n *\n * **Why this doesn't just call `locale-utils.validateLocale`**, which implements\n * exactly this predicate: that module imports `getContentConfig` from\n * `./plugin.js` rather than from the leaf store, deliberately and with a\n * documented reason (see `content-config-store.ts`'s header — its consumers'\n * unit tests mock `plugin.js`, and repointing it ripples through ~7 mocks).\n * Since `plugin.ts` imports THIS module, calling it would close a real import\n * cycle (`locale-utils -> plugin -> paths/routes -> locale-utils`), which\n * `verify:cycles` rejects. So the same two-line read is done here against the\n * same sources of truth, rather than growing this PR into that refactor.\n */\nfunction resolveRequestLocale(\n raw: string | null,\n fallback: string | undefined,\n): { locale: string } | { error: string } {\n const config = getContentConfig()\n const candidate = raw ?? fallback ?? config.defaultLocale\n const allowed = getContext()?.availableLocales ?? config.locales.map((l) => l.code)\n if (!allowed.includes(candidate)) {\n // Deliberately does NOT echo the allowed set — no reason to enumerate the\n // deployment's configured locales to the caller.\n return { error: \"query param 'locale' is not a content locale\" }\n }\n return { locale: candidate }\n}\n\n/**\n * `GET /api/admin/paths/resolve?path=&locale=` — answer \"which document serves\n * this URL?\".\n *\n * Validates both params at the boundary, resolves through the draft resolver,\n * and projects the result through the per-document `view` gate so an\n * unviewable document is indistinguishable from an unowned path. See the module\n * header for the authorization model and why the deps builder is imported\n * lazily.\n */\nconst handleResolve: AdminRouteHandler = async (req, ctx) => {\n const params = new URL(req.url).searchParams\n\n const pathResult = validatePath(params.get('path'))\n if ('error' in pathResult) return json({ error: pathResult.error }, 400)\n const localeResult = resolveRequestLocale(params.get('locale'), ctx.locale ?? ctx.defaultLocale)\n if ('error' in localeResult) return json({ error: localeResult.error }, 400)\n const { locale } = localeResult\n\n const { buildResolveDeps } = await import('./resolve-context.js')\n const deps = buildResolveDeps(ctx.app)\n\n // Gate 2 (see the module header) — a document the caller can't view answers\n // exactly like an unowned path.\n const project = (entityType: string, id: string): ResolvePathResponse =>\n ctx.checkPermission(entityType, 'view') ? { kind: 'document', entityType, id } : NONE\n\n const result = await resolveDraftPath(pathResult.path, locale, deps)\n if (result.kind === 'document') return json(project(result.entityType, result.id))\n if (result.kind === 'none') return json(NONE)\n\n // `redirect`: an off-site target is handed back for the host to open; a\n // same-site one is followed ONE hop so the caller lands on the document\n // instead of round-tripping. No loop — exactly one extra resolve. A\n // protocol-relative `//host` target counts as off-site, not as a path (target\n // validation shouldn't produce one, but resolving it AS a path would silently\n // answer about something else entirely).\n const sameSite = result.to.startsWith('/') && !result.to.startsWith('//')\n if (!sameSite) return json({ kind: 'redirect', to: result.to })\n const hop = await resolveDraftPath(result.to, locale, deps)\n return json(hop.kind === 'document' ? project(hop.entityType, hop.id) : NONE)\n}\n\n/**\n * Build the path-resolution admin routes. One entry: `GET /paths/resolve`.\n */\nexport function pathRoutes(): AdminRoute[] {\n return combineAdminRoutes([\n defineAdminRoute({\n prefix: 'paths',\n path: 'resolve',\n method: 'GET',\n permission: PATH_RESOLVE_PERMISSION,\n defaultRoles: PATH_RESOLVE_DEFAULT_ROLES,\n description:\n 'Resolve a site URL path to the CMS document that serves it (editor canvas link navigation).',\n handler: handleResolve,\n }),\n ])\n}\n","/**\n * Domain errors thrown by the manual-redirect save-time validation\n * (`./validation.ts`, plan/routing PR05, phase `04-path-lifecycle-and-redirects.md`\n * §3). Distinct classes so Task 2's admin API handler can map each to a 4xx\n * (caller-actionable) rather than a generic 500, and so callers can branch on\n * `instanceof`.\n *\n * Style mirrors `../paths/errors.ts` exactly (`extends Error`, named readonly\n * fields, an actionable message, `this.name = 'ClassName'`). A redirect-specific\n * reserved-prefix error is defined here rather than reusing `paths`'\n * `PathReservedPrefixError`, whose constructor requires an `entityName` — a\n * redirect has no owning entity, so forcing that field would be a lie.\n */\n\n/**\n * A manual redirect's `from_path` falls under a reserved path prefix\n * (`reservedPathPrefixes`, e.g. `/admin` or `/api`). Allowing it would make the\n * redirect shadow a route the framework owns — the same hazard the path-index\n * guard rejects for entity canonical URLs — so the write is aborted before commit.\n */\nexport class RedirectReservedPrefixError extends Error {\n readonly fromPath: string\n readonly prefix: string\n\n constructor(fromPath: string, prefix: string) {\n super(\n `Redirect source \"${fromPath}\" falls under the reserved prefix \"${prefix}\" and cannot be ` +\n `used — that path range is owned by the framework.`,\n )\n this.name = 'RedirectReservedPrefixError'\n this.fromPath = fromPath\n this.prefix = prefix\n }\n}\n\n/**\n * A manual redirect's `from_path` already matches a live canonical path — that URL\n * serves content, so redirecting away from it would hide the content (phase 04 §3,\n * \"that URL serves content\"). The write is aborted before commit.\n *\n * `locale` is the redirect's own locale (`null` = a catch-all redirect, which was\n * checked against every configured locale); `conflictingLocale` names the specific\n * locale whose canonical path collided, so the message can be precise even for a\n * catch-all redirect.\n */\nexport class RedirectServesContentError extends Error {\n readonly fromPath: string\n readonly locale: string | null\n readonly conflictingLocale: string\n\n constructor(fromPath: string, locale: string | null, conflictingLocale: string) {\n super(\n `Redirect source \"${fromPath}\" already serves content in locale \"${conflictingLocale}\" ` +\n `(it is a live canonical URL). Choose a different source path, or unpublish/rename the ` +\n `content first.`,\n )\n this.name = 'RedirectServesContentError'\n this.fromPath = fromPath\n this.locale = locale\n this.conflictingLocale = conflictingLocale\n }\n}\n\n/**\n * An `entity`-kind redirect target names an entity type that isn't registered in\n * the app. A redirect to an unknown entity type can never resolve, so it's\n * rejected at save (CLAUDE.md \"validate at the boundary\").\n */\nexport class RedirectUnknownEntityError extends Error {\n readonly entity: string\n\n constructor(entity: string) {\n super(\n `Redirect target references entity type \"${entity}\", which is not a registered entity. ` +\n `Check the entity name.`,\n )\n this.name = 'RedirectUnknownEntityError'\n this.entity = entity\n }\n}\n\n/**\n * An `entity`-kind redirect target names a real entity type but a row id that\n * doesn't exist. A redirect to a nonexistent document can never resolve to a\n * useful URL, so it's rejected at save rather than discovered as a dead link at\n * request time (CLAUDE.md \"validate at the boundary\").\n */\nexport class RedirectTargetNotFoundError extends Error {\n readonly entity: string\n readonly id: string\n\n constructor(entity: string, id: string) {\n super(\n `Redirect target ${entity} \"${id}\" does not exist. Redirect to a document that exists, or ` +\n `use a URL/path target instead.`,\n )\n this.name = 'RedirectTargetNotFoundError'\n this.entity = entity\n this.id = id\n }\n}\n\n/**\n * A manual redirect's collapsed target points straight back at its OWN\n * `from_path` (in the same locale being saved) — a self-loop / no-op that could\n * never resolve to content. Rejected by Task 2's CRUD route BEFORE the row is\n * persisted, rather than stored as a degenerate row a request-time resolver\n * would have to special-case.\n *\n * Distinct from the collapse rule (`./validation.ts` `collapsePathTarget`, which\n * dedupes redirect CHAINS down to one hop): this guards the case where that\n * collapse resolves the destination back to the source path of the row itself.\n * `locale` is the redirect's own locale (`null` = catch-all).\n */\nexport class RedirectSelfReferenceError extends Error {\n readonly fromPath: string\n readonly locale: string | null\n\n constructor(fromPath: string, locale: string | null) {\n super(\n `Redirect source \"${fromPath}\"${locale === null ? '' : ` (locale \"${locale}\")`} resolves ` +\n `back to itself — a redirect cannot point at its own source path. Choose a different ` +\n `destination.`,\n )\n this.name = 'RedirectSelfReferenceError'\n this.fromPath = fromPath\n this.locale = locale\n }\n}\n","/**\n * `entity_refs` bookkeeping for manual redirects (plan/routing PR05, phase\n * `04-path-lifecycle-and-redirects.md` §3; decision F020 point 2).\n *\n * When a redirect targets an ENTITY, we register a row in the universal\n * `entity_refs` table so the broken-link dashboard can surface \"this redirect\n * points at a deleted document\". No public write helper exists for this:\n * `AdminClient.syncRefs` is private and entity-only (it needs a field map to\n * `extractRefs`), and `toolkit_redirects` is a `defineTable` infra row, not an\n * AdminClient entity. So we hand-roll insert/delete against the exported\n * `entityRefs` table, mirroring `syncRefs`'s shape (F020 point 2).\n *\n * The ref uses `relationKind: 'link'` — a SOFT reference. Only `'structured'`\n * refs block deleting the target entity (AdminClient's delete-protection filters\n * on that kind); a redirect must NEVER block deleting the document it points at\n * (phase 04 §3's Delete row: the manual redirect merely *surfaces in the\n * broken-link view* via `entity_refs`, it doesn't hard-depend). `'link'` is\n * exactly that: tracked, never blocking.\n *\n * `sourceEntity: 'toolkit_redirects'` is safe as a free-form marker —\n * `entity_refs.sourceEntity` / `targetEntity` are plain varchar with no FK to a\n * registered-entity list.\n *\n * Both functions take `exec: PostgresJsDatabase` (the same parameter type\n * `AdminClient.syncRefs` takes), so Task 2's route can call them inside the SAME\n * transaction as the `toolkit_redirects` write itself — keeping the row write and\n * its ref bookkeeping atomic.\n */\n\nimport { entityRefs, type RelationKind } from '@murumets-ee/entity/refs'\nimport { and, eq } from 'drizzle-orm'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\nimport type { RedirectTarget } from './target.js'\n\n/** The `entity_refs.source_entity` marker for every manual-redirect ref row. */\nconst SOURCE_ENTITY = 'toolkit_redirects'\n/** The `entity_refs.source_field` marker — the `target` column of the redirect. */\nconst SOURCE_FIELD = 'target'\n/** Soft reference: tracked for the broken-link dashboard, never delete-blocking. */\nconst RELATION_KIND: RelationKind = 'link'\n\n/**\n * Register the `entity_refs` row for a redirect whose target is an entity.\n * No-op for `url` / `path` targets (nothing to track). Idempotent via\n * `.onConflictDoNothing()` (the `uq_entity_refs` unique covers\n * source+field+target), matching `AdminClient.syncRefs`'s own pattern — so a\n * re-run (e.g. an update that re-writes the same entity target) doesn't error.\n *\n * Call INSIDE the redirect-write transaction, AFTER {@link clearRedirectRefs} on\n * update (delete-then-insert; see that function's note).\n */\nexport async function writeRedirectRef(\n exec: PostgresJsDatabase,\n redirectId: string,\n target: RedirectTarget,\n): Promise<void> {\n if (target.kind !== 'entity') return\n await exec\n .insert(entityRefs)\n .values({\n sourceEntity: SOURCE_ENTITY,\n sourceId: redirectId,\n sourceField: SOURCE_FIELD,\n targetEntity: target.entity,\n targetId: target.id,\n relationKind: RELATION_KIND,\n })\n .onConflictDoNothing()\n}\n\n/**\n * Remove ALL `entity_refs` rows for a redirect. Called:\n * - before {@link writeRedirectRef} on UPDATE — delete-then-insert is simpler and\n * safer than diffing the old vs new target, and this is not a hot path; and\n * - on DELETE of the redirect row, so no dangling ref survives.\n *\n * Call INSIDE the redirect-write transaction so the ref cleanup commits (or rolls\n * back) atomically with the row change.\n */\nexport async function clearRedirectRefs(\n exec: PostgresJsDatabase,\n redirectId: string,\n): Promise<void> {\n await exec\n .delete(entityRefs)\n .where(and(eq(entityRefs.sourceEntity, SOURCE_ENTITY), eq(entityRefs.sourceId, redirectId)))\n}\n","/**\n * `RedirectTarget` — the discriminated union stored in `toolkit_redirects.target`\n * (plan/routing PR05, phase `04-path-lifecycle-and-redirects.md` §3; decision\n * F020 point 1).\n *\n * Phase 04 §3 sketches this as \"LinkValue-shaped\", but it is NOT literally blocks'\n * D008 `LinkValue` (`packages/blocks/src/core/types.ts`), which is\n * `entity | media | url | email` — it has NO `path` arm and carries `media`/`email`\n * arms a redirect never needs. `asLinkValue` / `resolveLinkHref` / the picker UI all\n * switch on that union exhaustively with no `default`, so bolting a `path` arm onto\n * the real type would ripple into blocks' richtext/picker surface for zero benefit\n * here. So this is a redirect-local union matching the phase doc's actual three\n * kinds (F020 point 1) — \"LinkValue-shaped\" read as \"same tagged-union spirit\",\n * not a literal type import.\n *\n * The three kinds:\n * - `entity` — points at a document. Resolves to that document's *current*\n * canonical path AT REQUEST TIME (never stale, per phase 04 §3 / PR06's\n * `resolvePath`), and registers an `entity_refs` row for broken-link dashboard\n * coverage (see `./refs.ts`).\n * - `url` — an absolute external URL. Admin-authored only (the table is never\n * writable from a public surface), so no open-redirect vector.\n * - `path` — a locale-relative internal path. Collapsed at save time if it\n * itself points at another manual redirect (see `./validation.ts`), so a stored\n * `path` target always points at content, never at another redirect row.\n */\n\nimport { z } from 'zod'\n\n/** The stored shape of `toolkit_redirects.target`. */\nexport type RedirectTarget =\n | { kind: 'entity'; entity: string; id: string }\n | { kind: 'url'; url: string }\n | { kind: 'path'; path: string }\n\n/**\n * API-boundary validation for a {@link RedirectTarget} (CLAUDE.md \"validate at the\n * boundary\"). Task 2's admin CRUD routes parse the request body's `target` with\n * this before any write. Discriminated on `kind` so a malformed arm reports the\n * precise field, and unknown `kind`s are rejected rather than silently stored.\n */\nexport const redirectTargetSchema = z.discriminatedUnion('kind', [\n z.object({\n kind: z.literal('entity'),\n /** Registered entity type name (existence checked in `./validation.ts`). */\n entity: z.string().min(1),\n /** Entity row id — always a UUID (entity ids are UUIDs; only user ids are varchar). */\n id: z.string().uuid(),\n }),\n z.object({\n kind: z.literal('url'),\n /** Absolute external URL. */\n url: z.string().max(2048).url(),\n }),\n z.object({\n kind: z.literal('path'),\n /** Locale-relative internal path — leading '/', same convention as `toolkit_paths.path`. */\n path: z.string().min(1).max(2048).startsWith('/'),\n }),\n])\n","/**\n * Save-time validation for manual redirects (plan/routing PR05, phase\n * `04-path-lifecycle-and-redirects.md` §3). Each rule is an exported function\n * Task 2's admin CRUD route calls BEFORE writing a `toolkit_redirects` row.\n *\n * The recommended route order (Task 2 owns the actual wiring):\n * 1. normalize `fromPath` + a `path`-kind target's `path` via {@link normalizeRedirectPath}\n * 2. {@link assertFromPathNotReserved} — reserved-prefix rejection (security)\n * 3. {@link assertFromPathNotServingContent} — \"that URL serves content\" rejection\n * 4. {@link assertEntityTargetValid} — entity target type + id existence\n * 5. {@link collapsePathTarget} — collapse a redirect-chain to one hop\n * 6. write the row (+ `entity_refs` via `./refs.ts`) inside one transaction\n *\n * ## Dependency injection (leaf-friendly, testable)\n *\n * The paths / redirects readers are injected as NARROW structural interfaces\n * ({@link PathsCanonicalReader}, {@link RedirectsCollapseReader}) rather than the\n * full `TableClient`. The real `ToolkitPathsClient` / `ToolkitRedirectsClient`\n * satisfy them (proven at compile time by the integration test, which passes the\n * real clients straight in), so Task 2 injects the real client while unit tests\n * inject a trivial stub — no `as unknown as` casts on either side. The\n * entity-existence check is injected the same way, as {@link\n * RedirectEntityClientResolver}, so this module never imports `@murumets-ee/core`.\n */\n\nimport { matchReservedPrefix } from '@murumets-ee/entity'\nimport { normalizePath } from '../paths/generate-path.js'\nimport {\n RedirectReservedPrefixError,\n RedirectServesContentError,\n RedirectTargetNotFoundError,\n RedirectUnknownEntityError,\n} from './errors.js'\nimport type { RedirectTarget } from './target.js'\n\n/**\n * Normalize a redirect path the SAME way the path index normalizes\n * `toolkit_paths.path` (collapse duplicate slashes + strip a trailing slash), so a\n * redirect's `from_path` and a `path`-kind target compare byte-identically against\n * the stored canonical/redirect paths. Task 2 should apply this to the stored\n * value too, so what is written matches what was validated. Re-exported thin\n * wrapper over the path module's `normalizePath` (DRY — do not reimplement).\n */\nexport function normalizeRedirectPath(path: string): string {\n return normalizePath(path)\n}\n\n/**\n * The read surface {@link assertFromPathNotServingContent} needs from the paths\n * index. The real `ToolkitPathsClient` satisfies this (its `findOne` accepts any\n * `WhereClause` and returns the full row).\n */\nexport interface PathsCanonicalReader {\n findOne(where: {\n path: string\n isCanonical: true\n /** A single locale, or an `IN (...)` set for the catch-all \"any configured locale\" check. */\n locale: string | { in: string[] }\n }): Promise<{ locale: string } | null>\n}\n\n/**\n * The read surface {@link collapsePathTarget} needs from the redirects table.\n * The real `ToolkitRedirectsClient` satisfies this.\n */\nexport interface RedirectsCollapseReader {\n findOne(where: {\n fromPath: string\n locale: string | null\n /** Excludes the row being updated so its own pre-write state can't mask a\n * self-referential collapse (M1 fix — see {@link collapsePathTarget}). */\n id?: { ne: string }\n }): Promise<{ target: RedirectTarget } | null>\n}\n\n/** Minimal client surface used to confirm an `entity`-kind target's id exists. */\nexport interface RedirectEntityClient {\n findById(id: string): Promise<unknown>\n}\n\n/**\n * Resolve an `AdminClient`-like reader for an entity TYPE NAME, or `undefined`\n * when that type isn't registered. Task 2 wires this to\n * `app.entities.get(name)` → `app.getClient(entity)`; injecting it keeps this\n * module a leaf (no `@murumets-ee/core` import).\n */\nexport type RedirectEntityClientResolver = (entity: string) => RedirectEntityClient | undefined\n\n/**\n * Rule (a) — reject a `from_path` under a reserved prefix (`/admin`, `/api`, …).\n * A manual redirect claiming a framework-owned path would be as broken as an\n * entity claiming it, and is the same security-relevant class as F013's gap, so\n * the same guard applies. Synchronous — pure string check. Throws\n * {@link RedirectReservedPrefixError}.\n */\nexport function assertFromPathNotReserved(args: {\n fromPath: string\n reservedPathPrefixes: readonly string[]\n}): void {\n const fromPath = normalizeRedirectPath(args.fromPath)\n const reserved = matchReservedPrefix(fromPath, args.reservedPathPrefixes)\n if (reserved !== undefined) {\n throw new RedirectReservedPrefixError(fromPath, reserved)\n }\n}\n\n/**\n * Rule (b) — reject a `from_path` that matches a live canonical path (\"that URL\n * serves content\"; phase 04 §3). Redirecting away from a URL that already serves\n * content would hide the content.\n *\n * - A LOCALE-SPECIFIC redirect (`locale` a string) is checked against that one\n * locale's canonical rows.\n * - A CATCH-ALL redirect (`locale === null`) is checked against EVERY configured\n * locale (a single `locale IN (...)` lookup — `toolkit_paths` has no null-locale\n * rows, so a null-locale lookup would never match). Restricting to configured\n * locales avoids over-blocking on orphaned rows left by a removed locale.\n *\n * Throws {@link RedirectServesContentError} naming the specific conflicting locale.\n */\nexport async function assertFromPathNotServingContent(args: {\n fromPath: string\n locale: string | null\n configuredLocales: readonly string[]\n pathsClient: PathsCanonicalReader\n}): Promise<void> {\n const fromPath = normalizeRedirectPath(args.fromPath)\n\n if (args.locale !== null) {\n const hit = await args.pathsClient.findOne({\n path: fromPath,\n isCanonical: true,\n locale: args.locale,\n })\n if (hit) throw new RedirectServesContentError(fromPath, args.locale, hit.locale)\n return\n }\n\n // Catch-all: does ANY configured locale serve this path? One IN-lookup covers\n // it (the query is bounded by the small, static configured-locale count).\n const hit = await args.pathsClient.findOne({\n path: fromPath,\n isCanonical: true,\n locale: { in: [...args.configuredLocales] },\n })\n if (hit) throw new RedirectServesContentError(fromPath, null, hit.locale)\n}\n\n/**\n * Rule (c) — collapse a `path`-kind target that itself points at another manual\n * redirect down to that redirect's (already-collapsed) target, so a stored `path`\n * target always points at CONTENT, never at another redirect row. \"Max one hop by\n * construction\" (phase 04 §3): every stored row's target is already fully\n * collapsed by this same rule, so a SINGLE lookup suffices — no loop, no\n * cycle-guard.\n *\n * Precedence, given the redirect being saved has its own `locale`:\n * - LOCALE-SPECIFIC save — prefer an exact-locale redirect at the target path,\n * else fall back to a catch-all (`locale = null`) redirect there.\n * - CATCH-ALL save (`locale === null`) — only a catch-all redirect at the target\n * path applies (a locale-specific row can't stand in for an all-locales\n * redirect).\n *\n * Non-`path` targets (entity / url) are returned unchanged without any lookup.\n * A `path` target that points at no redirect is returned unchanged (it points at\n * content, or a dead path — not this rule's concern).\n *\n * `excludeId` — pass the row's own id on UPDATE. Without it, a `PATCH` that\n * keeps `fromPath`/`locale` unchanged and sets a self-referential `path` target\n * would have this lookup match the row's own PRE-write state (the UPDATE hasn't\n * executed yet within the same transaction), silently returning its OLD target\n * instead of surfacing as a self-reference — the caller's write appears to\n * succeed (200) while the target silently doesn't change to what was requested.\n * Excluding the row's own id makes the lookup correctly report \"no redirect at\n * the target path\", which the caller's self-redirect guard then catches.\n */\nexport async function collapsePathTarget(args: {\n target: RedirectTarget\n locale: string | null\n redirectsClient: RedirectsCollapseReader\n excludeId?: string\n}): Promise<RedirectTarget> {\n if (args.target.kind !== 'path') return args.target\n const path = normalizeRedirectPath(args.target.path)\n const idFilter = args.excludeId === undefined ? {} : { id: { ne: args.excludeId } }\n\n // Prefer an exact-locale redirect (only meaningful for a locale-specific save).\n if (args.locale !== null) {\n const exact = await args.redirectsClient.findOne({\n fromPath: path,\n locale: args.locale,\n ...idFilter,\n })\n if (exact) return exact.target\n }\n // Fall back to (or, for a catch-all save, only consider) a catch-all redirect.\n const catchAll = await args.redirectsClient.findOne({ fromPath: path, locale: null, ...idFilter })\n if (catchAll) return catchAll.target\n\n // No redirect at the target path — leave the path target as-is.\n return { kind: 'path', path }\n}\n\n/**\n * Rule for `entity`-kind targets — confirm the referenced entity TYPE is\n * registered and the referenced ID exists. A redirect to an unknown type or a\n * nonexistent document can never resolve to a useful URL, so both are rejected at\n * save (CLAUDE.md \"validate at the boundary\") rather than surfacing as a dead link\n * at request time. No-op for `url` / `path` targets. Entity targets do NOT resolve\n * to a path here — that happens at REQUEST time in PR06 (phase 04 §3).\n *\n * Throws {@link RedirectUnknownEntityError} (unregistered type) or\n * {@link RedirectTargetNotFoundError} (type ok, id missing).\n */\nexport async function assertEntityTargetValid(args: {\n target: RedirectTarget\n resolveClient: RedirectEntityClientResolver\n}): Promise<void> {\n if (args.target.kind !== 'entity') return\n const { entity, id } = args.target\n\n const client = args.resolveClient(entity)\n if (client === undefined) throw new RedirectUnknownEntityError(entity)\n\n const found = await client.findById(id)\n if (found === null || found === undefined) throw new RedirectTargetNotFoundError(entity, id)\n}\n","/**\n * Manual-redirect admin CRUD routes — declarative `defineAdminRoute` entries\n * (plan/routing PR05, phase `04-path-lifecycle-and-redirects.md` §3-4; F022:\n * backend-only, no admin-ui page in this PR).\n *\n * Mounted under prefix `redirects`. The URL surface:\n *\n * GET /api/admin/redirects ?locale=&limit=&offset= — list (paginated)\n * POST /api/admin/redirects { locale, fromPath, target, status? }\n * PATCH /api/admin/redirects/:id { locale?, fromPath?, target?, status? }\n * DELETE /api/admin/redirects/:id\n * GET /api/admin/redirects/history ?entityType=&entityId=&locale= — automatic\n * (rename) redirect history for one document\n *\n * **Registration shape** (per `defineAdminRoute`'s single-segment-path\n * constraint): PATCH + DELETE embed the redirect id directly after the prefix\n * (`/redirects/<uuid>`), so they register `path: '', matchAnyPath: true` and read\n * the id from `ctx.segments[0]`. `combineAdminRoutes`' dispatcher routes any\n * unmatched sub-path under `(prefix, method)` through the single catch-all when\n * no exact `path` claims `segments[0]` (see `makeDispatcher`). GET registers two\n * EXACT entries — `''` (list) and `'history'` — which take precedence over any\n * catch-all and never collide. POST registers `''` (create) only.\n *\n * **Authorization model:** every route gates on the SINGLE `redirect:manage`\n * permission (phase 04 §4 — one permission for all CRUD, not a per-verb split).\n * `defaultRoles: ['admin']`. The wrapper (`guardedHandler`) emits the\n * `permission.denied` audit on every 403 uniformly.\n *\n * **DB access** is resolved at REQUEST time (never at factory/module scope —\n * `getApp()` throws before boot; the factory runs at config-load under jiti/tsx).\n * Create / update / delete each run inside ONE `db.transaction` so the\n * `toolkit_redirects` row write and its `entity_refs` bookkeeping (`./refs.ts`)\n * commit — or roll back — atomically.\n */\n\nimport {\n type AdminRoute,\n type AdminRouteHandler,\n type AuthUser,\n combineAdminRoutes,\n defineAdminRoute,\n safeAudit,\n} from '@murumets-ee/core'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\nimport { z } from 'zod'\nimport { getContentConfig } from '../content-config-store.js'\nimport { isUniqueViolation } from '../paths/apply-path.js'\nimport { toolkitPathsTable } from '../paths/schema.js'\nimport {\n RedirectReservedPrefixError,\n RedirectSelfReferenceError,\n RedirectServesContentError,\n RedirectTargetNotFoundError,\n RedirectUnknownEntityError,\n} from './errors.js'\nimport { clearRedirectRefs, writeRedirectRef } from './refs.js'\nimport { toolkitRedirectsTable } from './schema.js'\nimport { type RedirectTarget, redirectTargetSchema } from './target.js'\nimport {\n assertEntityTargetValid,\n assertFromPathNotReserved,\n assertFromPathNotServingContent,\n collapsePathTarget,\n normalizeRedirectPath,\n type RedirectEntityClientResolver,\n} from './validation.js'\n\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n/** `toolkit_paths.entity_type` is `varchar(100)`; same shape as queue's type filter. */\nconst ENTITY_TYPE_RE = /^[a-zA-Z0-9:_.-]{1,100}$/\n\n/** ONE permission gates every redirect CRUD verb (phase 04 §4). */\nconst REDIRECT_PERMISSION = 'redirect:manage'\nconst REDIRECT_DEFAULT_ROLES = ['admin'] as const\n\n// ---------------------------------------------------------------------------\n// HTTP helpers (same shape as queue/src/admin.ts)\n// ---------------------------------------------------------------------------\n\nfunction json(data: unknown, status = 200): Response {\n return new Response(JSON.stringify(data), {\n status,\n headers: { 'Content-Type': 'application/json' },\n })\n}\n\nfunction errorJson(message: string, status: number): Response {\n return json({ error: message }, status)\n}\n\nasync function parseJsonBody<S extends z.ZodTypeAny>(\n req: Request,\n schema: S,\n): Promise<{ data: z.infer<S> } | { response: Response }> {\n let raw: unknown\n try {\n raw = await req.json()\n } catch {\n return { response: errorJson('Invalid JSON body', 400) }\n }\n const result = schema.safeParse(raw)\n if (!result.success) {\n const issue = result.error.issues[0]\n const message = issue ? `${issue.path.join('.') || 'body'}: ${issue.message}` : 'Invalid body'\n return { response: errorJson(message, 400) }\n }\n return { data: result.data }\n}\n\n/**\n * Strict uint query-param parse — same regex + `Number.isSafeInteger` guard as\n * queue's `parseUintParam` (plain `Number()` silently coerces blanks / hex /\n * exponent / signed input; a long digit string overflows to `Infinity`).\n */\nfunction parseUintParam(raw: string | null, fallback: number): number {\n if (raw === null || !/^\\d+$/.test(raw)) return fallback\n const parsed = Number(raw)\n if (!Number.isSafeInteger(parsed)) return fallback\n return parsed\n}\n\n/** Pagination offset cap — a deep `?offset=` forces a scan-and-discard pass. */\nconst MAX_OFFSET = 100_000\n/** History is inherently bounded (PR04 caps rename history per doc/locale), but keep a hard ceiling. */\nconst HISTORY_LIMIT = 100\n\nfunction auditActor(user: AuthUser): { userId: string; userName?: string } {\n return user.name === undefined ? { userId: user.id } : { userId: user.id, userName: user.name }\n}\n\n// ---------------------------------------------------------------------------\n// App / DB access — deferred to request time (getApp throws before boot; the\n// factory runs at config-load). Mirrors queue/src/admin.ts's getDb().\n// ---------------------------------------------------------------------------\n\nasync function getAppInstance() {\n const { getApp } = await import('@murumets-ee/core')\n return getApp()\n}\n\nasync function getDb(): Promise<PostgresJsDatabase> {\n return (await getAppInstance()).db.readWrite\n}\n\n/**\n * Build the `entity`-target existence resolver from the live app registry:\n * `app.entities.get(name)` → `app.getClient(entity)`. Passed into\n * `assertEntityTargetValid`, keeping `./validation.ts` a leaf (no core import).\n */\nasync function buildEntityResolver(): Promise<RedirectEntityClientResolver> {\n const app = await getAppInstance()\n return (name) => {\n const entity = app.entities.get(name)\n return entity ? app.getClient(entity) : undefined\n }\n}\n\n/**\n * Drizzle's transaction handle is structurally a `PgDatabase` but its generic\n * parameters don't line up with the `PostgresJsDatabase` alias `makeClient` /\n * `writeRedirectRef` expect, so the tx handle is cast at this single boundary —\n * the exact pattern `@murumets-ee/db`'s own `TableClient.transaction` uses\n * (`innerDb as unknown as PostgresJsDatabase`). One cast per transaction.\n */\ntype DrizzleTx = Parameters<Parameters<PostgresJsDatabase['transaction']>[0]>[0]\nfunction txAsDb(tx: DrizzleTx): PostgresJsDatabase {\n return tx as unknown as PostgresJsDatabase\n}\n\n// ---------------------------------------------------------------------------\n// Request-body schemas — validated at the boundary (CLAUDE.md). `createdBy` is\n// NEVER accepted from the body; it's set server-side from ctx.user.id.\n// ---------------------------------------------------------------------------\n\n/** `301 | 302` only. */\nconst statusSchema = z.union([z.literal(301), z.literal(302)])\n\n/** Locale-relative internal path — leading '/', bounded, same convention as `target.ts`'s `path` arm. */\nconst fromPathSchema = z.string().min(1).max(2048).startsWith('/')\n\nconst createBodySchema = z.object({\n /** BCP47 locale, or `null` = catch-all (applies to all locales). Membership vs. configured locales is checked in the handler. */\n locale: z.string().min(1).nullable(),\n fromPath: fromPathSchema,\n target: redirectTargetSchema,\n status: statusSchema.default(301),\n})\n\nconst updateBodySchema = z.object({\n locale: z.string().min(1).nullable().optional(),\n fromPath: fromPathSchema.optional(),\n target: redirectTargetSchema.optional(),\n status: statusSchema.optional(),\n})\n\n/**\n * Whitelist a redirect's locale against the CONFIGURED content locales\n * (deny-by-default; CLAUDE.md). `null` (catch-all) is always allowed. Returns a\n * 400 Response on rejection, or `null` when the locale is acceptable.\n */\nfunction rejectUnconfiguredLocale(locale: string | null): Response | null {\n if (locale === null) return null\n const configured = getContentConfig().locales.map((l) => l.code)\n if (!configured.includes(locale)) {\n return errorJson(`Unknown locale \"${locale}\" — not one of the configured content locales.`, 400)\n }\n return null\n}\n\n// ---------------------------------------------------------------------------\n// Error → HTTP status mapping. Conflict-with-existing-state errors → 409\n// (matches the codebase convention: ReferencedEntityError / CrossLocaleWriteError\n// / RestoreConflictError all → 409). Bad-target-in-payload → 400 (matches\n// EntityCapabilityError / InvalidLocaleError → 400). A 23505 escaping the tx (the\n// DB-level partial-unique backstop firing on a race) → 409, never a raw 500.\n// Anything else is rethrown so the api-handler's central catch logs + 500s.\n// ---------------------------------------------------------------------------\n\nfunction mapRedirectError(err: unknown): Response {\n if (err instanceof RedirectReservedPrefixError) {\n return json({ error: err.message, fromPath: err.fromPath, prefix: err.prefix }, 409)\n }\n if (err instanceof RedirectServesContentError) {\n return json(\n {\n error: err.message,\n fromPath: err.fromPath,\n locale: err.locale,\n conflictingLocale: err.conflictingLocale,\n },\n 409,\n )\n }\n if (err instanceof RedirectSelfReferenceError) {\n return json({ error: err.message, fromPath: err.fromPath, locale: err.locale }, 409)\n }\n if (err instanceof RedirectUnknownEntityError) {\n return json({ error: err.message, entity: err.entity }, 400)\n }\n if (err instanceof RedirectTargetNotFoundError) {\n return json({ error: err.message, entity: err.entity, id: err.id }, 400)\n }\n if (isUniqueViolation(err)) {\n return json({ error: 'A redirect already exists for this locale + source path.' }, 409)\n }\n // Unknown — let the api-handler's central catch log it and return 500.\n throw err\n}\n\n// ---------------------------------------------------------------------------\n// Core write path — shared by create + update. Runs the full save-time\n// validation chain (normalize → reserved-prefix → entity-target → serves-content\n// → collapse → self-redirect guard) then writes the row + entity_refs inside ONE\n// transaction. Returns the written row, or `null` when an UPDATE matched no row.\n// ---------------------------------------------------------------------------\n\n/** The stored redirect row (typed via the DefinedTable's client). */\ntype RedirectRow = Awaited<\n ReturnType<ReturnType<typeof toolkitRedirectsTable.makeClient>['insert']>\n>\n\nasync function runRedirectWrite(args: {\n /** `null` = create; a UUID = update that id. */\n id: string | null\n locale: string | null\n fromPath: string\n target: RedirectTarget\n status: number\n userId: string\n resolveClient: RedirectEntityClientResolver\n}): Promise<RedirectRow | null> {\n const cfg = getContentConfig()\n const reservedPathPrefixes = cfg.reservedPathPrefixes ?? []\n const configuredLocales = cfg.locales.map((l) => l.code)\n const fromPath = normalizeRedirectPath(args.fromPath)\n\n // 1. Reserved-prefix rejection (pure string check; security).\n assertFromPathNotReserved({ fromPath, reservedPathPrefixes })\n\n // 2. Entity-target existence — a non-tx AdminClient read (soft pre-check; the\n // row it validates isn't part of this write's transaction).\n await assertEntityTargetValid({ target: args.target, resolveClient: args.resolveClient })\n\n const db = await getDb()\n return db.transaction(async (txHandle) => {\n const tx = txAsDb(txHandle)\n const redirectsClient = toolkitRedirectsTable.makeClient(tx)\n const pathsClient = toolkitPathsTable.makeClient(tx)\n\n // 3. \"That URL serves content\" rejection — reads toolkit_paths in-tx.\n await assertFromPathNotServingContent({\n fromPath,\n locale: args.locale,\n configuredLocales,\n pathsClient,\n })\n\n // 4. Collapse a path-kind target that points at another redirect (one hop).\n // excludeId (update only) keeps the row's own pre-write state from\n // masking a self-referential collapse — see collapsePathTarget's doc.\n const collapsed = await collapsePathTarget({\n target: args.target,\n locale: args.locale,\n redirectsClient,\n ...(args.id === null ? {} : { excludeId: args.id }),\n })\n\n // 5. Self-redirect guard — a collapsed path target that points back at this\n // very from_path (same locale) is a no-op loop; reject rather than store.\n if (collapsed.kind === 'path' && normalizeRedirectPath(collapsed.path) === fromPath) {\n throw new RedirectSelfReferenceError(fromPath, args.locale)\n }\n\n // 6. Write the row + entity_refs bookkeeping, atomically.\n if (args.id === null) {\n const created = await redirectsClient.insert({\n locale: args.locale,\n fromPath,\n target: collapsed,\n status: args.status,\n createdBy: args.userId,\n })\n await writeRedirectRef(tx, created.id, collapsed)\n return created\n }\n\n const updated = await redirectsClient.update(\n { id: args.id },\n {\n locale: args.locale,\n fromPath,\n target: collapsed,\n status: args.status,\n updatedAt: new Date(),\n },\n )\n if (!updated) return null // row vanished between the pre-tx fetch and here.\n // Delete-then-insert refs (not diff) — matches ./refs.ts's documented usage.\n await clearRedirectRefs(tx, args.id)\n await writeRedirectRef(tx, args.id, collapsed)\n return updated\n })\n}\n\n// ---------------------------------------------------------------------------\n// Handlers\n// ---------------------------------------------------------------------------\n\nconst handleList: AdminRouteHandler = async (req) => {\n const url = new URL(req.url)\n const localeParam = url.searchParams.get('locale')\n const limit = Math.min(Math.max(parseUintParam(url.searchParams.get('limit'), 50), 1), 200)\n const offset = Math.min(parseUintParam(url.searchParams.get('offset'), 0), MAX_OFFSET)\n\n let localeFilter: string | undefined\n if (localeParam !== null && localeParam !== '') {\n const rejected = rejectUnconfiguredLocale(localeParam)\n if (rejected) return rejected\n localeFilter = localeParam\n }\n\n const client = toolkitRedirectsTable.makeClient(await getDb())\n const items =\n localeFilter !== undefined\n ? await client.findMany({\n where: { locale: localeFilter },\n orderBy: [{ column: 'createdAt', dir: 'desc' }],\n limit,\n offset,\n })\n : await client.findMany({\n orderBy: [{ column: 'createdAt', dir: 'desc' }],\n limit,\n offset,\n })\n const total =\n localeFilter !== undefined ? await client.count({ locale: localeFilter }) : await client.count()\n\n return json({ items, total })\n}\n\nconst handleCreate: AdminRouteHandler = async (req, ctx) => {\n const parsed = await parseJsonBody(req, createBodySchema)\n if ('response' in parsed) return parsed.response\n const body = parsed.data\n\n const localeRejected = rejectUnconfiguredLocale(body.locale)\n if (localeRejected) return localeRejected\n\n try {\n const resolveClient = await buildEntityResolver()\n const row = await runRedirectWrite({\n id: null,\n locale: body.locale,\n fromPath: body.fromPath,\n target: body.target,\n status: body.status,\n userId: ctx.user.id,\n resolveClient,\n })\n // Create always returns a row (insert never matches \"nothing\").\n if (!row) return errorJson('Failed to create redirect', 500)\n safeAudit(ctx, {\n action: 'redirect.created',\n entityType: 'redirect',\n entityId: row.id,\n ...auditActor(ctx.user),\n metadata: { locale: row.locale, fromPath: row.fromPath, targetKind: row.target.kind },\n })\n return json({ item: row }, 201)\n } catch (err) {\n return mapRedirectError(err)\n }\n}\n\nconst handleUpdate: AdminRouteHandler = async (req, ctx) => {\n const id = ctx.segments[0]\n if (!id || !UUID_RE.test(id)) return errorJson('Invalid id', 400)\n\n const parsed = await parseJsonBody(req, updateBodySchema)\n if ('response' in parsed) return parsed.response\n const patch = parsed.data\n\n // Fetch the existing row so a partial PATCH merges onto its current state.\n const existing = await toolkitRedirectsTable.makeClient(await getDb()).findOne({ id })\n if (!existing) return errorJson('Not found', 404)\n\n // Merge — only provided keys change. `locale` may be explicitly `null`\n // (switch to catch-all), so key-presence (!== undefined), not truthiness,\n // decides whether to keep the existing value.\n const locale = patch.locale !== undefined ? patch.locale : existing.locale\n const fromPath = patch.fromPath !== undefined ? patch.fromPath : existing.fromPath\n const target = patch.target !== undefined ? patch.target : existing.target\n const status = patch.status !== undefined ? patch.status : existing.status\n\n // Re-validate the RESULTING merged state (e.g. changing only `locale` can\n // newly conflict with content served in the new locale).\n const localeRejected = rejectUnconfiguredLocale(locale)\n if (localeRejected) return localeRejected\n\n try {\n const resolveClient = await buildEntityResolver()\n const row = await runRedirectWrite({\n id,\n locale,\n fromPath,\n target,\n status,\n userId: ctx.user.id,\n resolveClient,\n })\n if (!row) return errorJson('Not found', 404)\n safeAudit(ctx, {\n action: 'redirect.updated',\n entityType: 'redirect',\n entityId: row.id,\n ...auditActor(ctx.user),\n metadata: { locale: row.locale, fromPath: row.fromPath, targetKind: row.target.kind },\n })\n return json({ item: row })\n } catch (err) {\n return mapRedirectError(err)\n }\n}\n\nconst handleDelete: AdminRouteHandler = async (_req, ctx) => {\n const id = ctx.segments[0]\n if (!id || !UUID_RE.test(id)) return errorJson('Invalid id', 400)\n\n const db = await getDb()\n const deleted = await db.transaction(async (txHandle) => {\n const tx = txAsDb(txHandle)\n // Clear refs then delete, same tx — no dangling entity_refs row survives.\n await clearRedirectRefs(tx, id)\n return toolkitRedirectsTable.makeClient(tx).delete({ id })\n })\n if (!deleted) return errorJson('Not found', 404)\n\n safeAudit(ctx, {\n action: 'redirect.deleted',\n entityType: 'redirect',\n entityId: id,\n ...auditActor(ctx.user),\n metadata: { locale: deleted.locale, fromPath: deleted.fromPath },\n })\n return json({ ok: true })\n}\n\n/**\n * Read-only automatic-redirect history for ONE document — the demoted\n * (`is_canonical = false`) `toolkit_paths` rows a rename leaves behind.\n * `entityType` + `entityId` are REQUIRED; `locale` optional. No write path.\n */\nconst handleHistory: AdminRouteHandler = async (req) => {\n const url = new URL(req.url)\n const entityType = url.searchParams.get('entityType')\n const entityId = url.searchParams.get('entityId')\n if (!entityType || !ENTITY_TYPE_RE.test(entityType)) {\n return errorJson('entityType is required', 400)\n }\n if (!entityId || !UUID_RE.test(entityId)) {\n return errorJson('entityId is required and must be a UUID', 400)\n }\n\n const localeParam = url.searchParams.get('locale')\n let localeFilter: string | undefined\n if (localeParam !== null && localeParam !== '') {\n const rejected = rejectUnconfiguredLocale(localeParam)\n if (rejected) return rejected\n localeFilter = localeParam\n }\n\n const client = toolkitPathsTable.makeClient(await getDb())\n const items = await client.findMany({\n where: {\n entityType,\n entityId,\n isCanonical: false,\n ...(localeFilter !== undefined && { locale: localeFilter }),\n },\n orderBy: [{ column: 'updatedAt', dir: 'desc' }],\n limit: HISTORY_LIMIT,\n })\n return json({ items })\n}\n\n// ---------------------------------------------------------------------------\n// Route factory\n// ---------------------------------------------------------------------------\n\n/**\n * Build the manual-redirect admin API routes.\n *\n * Returns `AdminRoute[]` from `combineAdminRoutes` — one entry for prefix\n * `'redirects'` with GET/POST/PATCH/DELETE handlers. Every entry gates on\n * `redirect:manage` (defaultRoles `['admin']`); the permission is\n * auto-registered into the catalog by `defineAdminRoute`.\n */\nexport function redirectRoutes(): AdminRoute[] {\n return combineAdminRoutes([\n defineAdminRoute({\n prefix: 'redirects',\n path: '',\n method: 'GET',\n permission: REDIRECT_PERMISSION,\n defaultRoles: REDIRECT_DEFAULT_ROLES,\n description: 'List manual URL redirects (paginated, optional locale filter).',\n handler: handleList,\n }),\n defineAdminRoute({\n prefix: 'redirects',\n path: 'history',\n method: 'GET',\n permission: REDIRECT_PERMISSION,\n defaultRoles: REDIRECT_DEFAULT_ROLES,\n description: 'Read-only automatic-redirect (rename) history for one document.',\n handler: handleHistory,\n }),\n defineAdminRoute({\n prefix: 'redirects',\n path: '',\n method: 'POST',\n permission: REDIRECT_PERMISSION,\n defaultRoles: REDIRECT_DEFAULT_ROLES,\n description: 'Create a manual URL redirect.',\n handler: handleCreate,\n }),\n defineAdminRoute({\n prefix: 'redirects',\n path: '',\n method: 'PATCH',\n matchAnyPath: true,\n permission: REDIRECT_PERMISSION,\n defaultRoles: REDIRECT_DEFAULT_ROLES,\n description: 'Update a manual URL redirect by id.',\n handler: handleUpdate,\n }),\n defineAdminRoute({\n prefix: 'redirects',\n path: '',\n method: 'DELETE',\n matchAnyPath: true,\n permission: REDIRECT_PERMISSION,\n defaultRoles: REDIRECT_DEFAULT_ROLES,\n description: 'Delete a manual URL redirect by id.',\n handler: handleDelete,\n }),\n ])\n}\n"],"mappings":"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"}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { l as RoutingStrategy } from "./types-
|
|
1
|
+
import { l as RoutingStrategy } from "./types-B3K2p9t8.mjs";
|
|
2
|
+
import { Behavior } from "@murumets-ee/entity";
|
|
2
3
|
|
|
3
4
|
//#region src/i18n-settings.d.ts
|
|
4
5
|
/**
|
|
@@ -16,20 +17,20 @@ import { l as RoutingStrategy } from "./types-CUw2Pf0Y.mjs";
|
|
|
16
17
|
*/
|
|
17
18
|
/** The three URL locale-routing strategies (D009). */
|
|
18
19
|
declare const ROUTING_STRATEGIES: readonly ["prefix-all", "no-prefix-default", "cookie"];
|
|
19
|
-
declare const i18nSettings: import("@murumets-ee/settings
|
|
20
|
+
declare const i18nSettings: import("@murumets-ee/settings").SettingsDefinition<{
|
|
20
21
|
/**
|
|
21
22
|
* The site-wide default content locale. Empty ⇒ inherit the `content()`
|
|
22
23
|
* shipped `defaultLocale`. The resolver ({@link resolveI18nConfig}) validates
|
|
23
24
|
* it is an **enabled** locale and falls back to the `content()` default if not.
|
|
24
25
|
*/
|
|
25
|
-
readonly defaultLocale: import("@murumets-ee/settings
|
|
26
|
+
readonly defaultLocale: import("@murumets-ee/settings").TextSettingConfig & {
|
|
26
27
|
readonly default: "";
|
|
27
28
|
};
|
|
28
29
|
/**
|
|
29
30
|
* How the public URL encodes locale. Maps to next-intl `localePrefix` /
|
|
30
31
|
* Astro `prefixDefaultLocale` via `./routing-strategy.js`.
|
|
31
32
|
*/
|
|
32
|
-
readonly routingStrategy: import("@murumets-ee/settings
|
|
33
|
+
readonly routingStrategy: import("@murumets-ee/settings").SelectSettingConfig<readonly ["prefix-all", "no-prefix-default", "cookie"]> & {
|
|
33
34
|
readonly options: readonly ["prefix-all", "no-prefix-default", "cookie"];
|
|
34
35
|
readonly default: "no-prefix-default";
|
|
35
36
|
};
|
|
@@ -99,6 +100,39 @@ declare const Locale: import("@murumets-ee/entity").Entity<{
|
|
|
99
100
|
};
|
|
100
101
|
}, "locale", [import("@murumets-ee/entity").Behavior<import("@murumets-ee/entity").AuditableFields, string>]>;
|
|
101
102
|
//#endregion
|
|
103
|
+
//#region src/message-entity.d.ts
|
|
104
|
+
declare const Message: import("@murumets-ee/entity").Entity<{
|
|
105
|
+
id: import("@murumets-ee/entity").IdField;
|
|
106
|
+
} & import("@murumets-ee/entity").AuditableFields & Record<never, never> & {
|
|
107
|
+
/** Top-level catalog key — the namespace (e.g. `AuthUI`, `home`). */namespace: import("@murumets-ee/entity").TextField & {
|
|
108
|
+
readonly required: true;
|
|
109
|
+
readonly maxLength: 64;
|
|
110
|
+
}; /** Dot-path within the namespace (e.g. `signIn.title`). */
|
|
111
|
+
key: import("@murumets-ee/entity").TextField & {
|
|
112
|
+
readonly required: true;
|
|
113
|
+
readonly maxLength: 255;
|
|
114
|
+
};
|
|
115
|
+
/**
|
|
116
|
+
* BCP47 locale code — matches the `locale` registry codes (varchar(10)).
|
|
117
|
+
* Indexed: the read-time merge filters `WHERE locale = ?`, which can't use the
|
|
118
|
+
* `(namespace, key, locale)` composite prefix (that serves the reconcile's
|
|
119
|
+
* `namespace` filter).
|
|
120
|
+
*/
|
|
121
|
+
locale: import("@murumets-ee/entity").TextField & {
|
|
122
|
+
readonly required: true;
|
|
123
|
+
readonly maxLength: 10;
|
|
124
|
+
readonly indexed: true;
|
|
125
|
+
};
|
|
126
|
+
/**
|
|
127
|
+
* The (ICU-pattern) override string. Unbounded `text` — interface strings are
|
|
128
|
+
* short but ICU plural/select bodies can run long; a varchar cap would reject a
|
|
129
|
+
* legitimate long translation. Length is still bounded at the route boundary.
|
|
130
|
+
*/
|
|
131
|
+
value: import("@murumets-ee/entity").TextField & {
|
|
132
|
+
readonly required: true;
|
|
133
|
+
};
|
|
134
|
+
}, "message", [Behavior<import("@murumets-ee/entity").AuditableFields, string>, Behavior<Record<never, never>, "messageKeyUnique">]>;
|
|
135
|
+
//#endregion
|
|
102
136
|
//#region src/routing-strategy.d.ts
|
|
103
137
|
/**
|
|
104
138
|
* Map a {@link RoutingStrategy} to next-intl's `localePrefix`:
|
|
@@ -117,5 +151,5 @@ declare function localePrefixForStrategy(strategy: RoutingStrategy): 'always' |
|
|
|
117
151
|
*/
|
|
118
152
|
declare function prefixDefaultLocaleForStrategy(strategy: RoutingStrategy): boolean;
|
|
119
153
|
//#endregion
|
|
120
|
-
export {
|
|
121
|
-
//# sourceMappingURL=routing-strategy-
|
|
154
|
+
export { ROUTING_STRATEGIES as a, Locale as i, prefixDefaultLocaleForStrategy as n, i18nSettings as o, Message as r, localePrefixForStrategy as t };
|
|
155
|
+
//# sourceMappingURL=routing-strategy-D3Qowm3D.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"routing-strategy-D3Qowm3D.d.mts","names":[],"sources":["../src/i18n-settings.ts","../src/locale-entity.ts","../src/message-entity.ts","../src/routing-strategy.ts"],"mappings":";;;;;;;;AAiBA;;;;AAAwF;AAExF;;;;;cAFa,kBAAA;AAAA,cAEA,YAAA,kCAAY,kBAAA;;;;;;;;;;;ACQzB;;;;;;;;;;;;;ADVA;;;;AAAwF;AAExF;;;;;;;;;;;;;;;cCQa,MAAA,gCAAM,MAAA;;;EAwCjB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cCPW,OAAA,gCAAO,MAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBClCJ,uBAAA,CACd,QAAyB,EAAf,eAAe;;AFA3B;;;;iBEiBgB,8BAAA,CAA+B,QAAyB,EAAf,eAAe"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{behavior as e,defineEntity as t,field as n}from"@murumets-ee/entity";import{defineSettings as r,setting as i}from"@murumets-ee/settings/define";var a=class extends Error{entityName;capability;constructor(e,t,n){super(n??`Entity "${e}" does not support ${t}. Add the corresponding behavior to defineEntity({ behaviors: [...] }).`),this.name=`EntityCapabilityError`,this.entityName=e,this.capability=t}},o=class extends Error{entityId;locale;constructor(e,t){super(t===`_`?`No pending draft to publish — this document is already up to date.`:`No pending draft to publish for locale "${t}" — it is already up to date.`),this.name=`DraftNotFoundError`,this.entityId=e,this.locale=t}},s=class extends Error{entityId;version;locale;constructor(e,t,n){super(`Version ${t} not found for entity ${e}`),this.name=`VersionNotFoundError`,this.entityId=e,this.version=t,this.locale=n}},c=class extends Error{locale;allowed;constructor(e,t){super(`Invalid locale "${e}". Configured locales: [${t.join(`, `)}]. (The "_" sentinel is also accepted for default-locale storage.)`),this.name=`InvalidLocaleError`,this.locale=e,this.allowed=t}};const l=[`prefix-all`,`no-prefix-default`,`cookie`],u=r({namespace:`i18n`,scope:`global`,label:`Languages`,iconName:`languages`,hideFromMenu:!0,schema:{defaultLocale:i.text({default:``}),routingStrategy:i.select({options:l,default:`no-prefix-default`})}}),d=t({name:`locale`,class:`config`,scope:`global`,access:{view:`locale:view`,create:`locale:create`,update:`locale:update`,delete:`locale:delete`},fields:{code:n.text({required:!0,unique:!0,maxLength:10}),label:n.text({required:!0,maxLength:64}),enabled:n.boolean({default:!0}),direction:n.select({options:[`ltr`,`rtl`],default:`ltr`}),sortOrder:n.number({default:0})},behaviors:[e.auditable()],admin:{hidden:!0,label:`Languages`,labelSingular:`Language`}});function f(){return{name:`messageKeyUnique`,fields:{},constraints:(e,t)=>{if(t!==`main`)return[];let{namespace:n,key:r,locale:i}=e;return!n||!r||!i?[]:[{kind:`uniqueConstraint`,name:`message_namespace_key_locale_unique`,on:[n,r,i]}]}}}const p=t({name:`message`,class:`config`,scope:`global`,access:{view:`message:view`,create:`message:create`,update:`message:update`,delete:`message:delete`},fields:{namespace:n.text({required:!0,maxLength:64}),key:n.text({required:!0,maxLength:255}),locale:n.text({required:!0,maxLength:10,indexed:!0}),value:n.text({required:!0})},behaviors:[e.auditable(),f()],admin:{hidden:!0,label:`Translations`,labelSingular:`Message`}});function m(e){switch(e){case`prefix-all`:return`always`;case`no-prefix-default`:return`as-needed`;case`cookie`:return`never`}}function h(e){return e===`prefix-all`}export{l as a,a as c,d as i,c as l,h as n,u as o,p as r,o as s,m as t,s as u};
|
|
2
|
+
//# sourceMappingURL=routing-strategy-dkt2yO25.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"routing-strategy-dkt2yO25.mjs","names":[],"sources":["../src/errors.ts","../src/i18n-settings.ts","../src/locale-entity.ts","../src/message-entity.ts","../src/routing-strategy.ts"],"sourcesContent":["/**\n * Domain errors for ContentClient + LockService.\n */\n\n/**\n * Thrown when a caller invokes an entity-capability-specific method on an\n * entity that doesn't have the corresponding behavior — e.g. `saveDraft`\n * on a non-publishable entity, `setLocaleStatus` on an entity without\n * publishable+translatable, `restoreVersion` on a non-versionable entity.\n *\n * Distinct from generic `Error` so admin-ui's API handler can map it to\n * 400 Bad Request (caller's fault) rather than 500 (server bug).\n */\nexport class EntityCapabilityError extends Error {\n readonly entityName: string\n readonly capability: 'drafts' | 'versions' | 'localeStatus' | 'translations'\n\n constructor(\n entityName: string,\n capability: EntityCapabilityError['capability'],\n message?: string,\n ) {\n super(\n message ??\n `Entity \"${entityName}\" does not support ${capability}. ` +\n `Add the corresponding behavior to defineEntity({ behaviors: [...] }).`,\n )\n this.name = 'EntityCapabilityError'\n this.entityName = entityName\n this.capability = capability\n }\n}\n\n/**\n * Thrown by `publishDraft` when there is no pending draft for the requested\n * entity+locale — publishing is a promote-the-draft operation, so \"nothing\n * to promote\" is a caller-state condition, not a server fault.\n *\n * Distinct from generic `Error` so admin-ui's API handler can map it to\n * 409 Conflict with a human-readable message instead of a generic 500\n * (which is what users saw pre-PR-23 — F031 #1).\n */\nexport class DraftNotFoundError extends Error {\n readonly entityId: string\n readonly locale: string\n\n constructor(entityId: string, locale: string) {\n // NB: the `_` default-locale sentinel is an internal storage key — never\n // show it to users (PR 23 user-smoke find).\n super(\n locale === '_'\n ? 'No pending draft to publish — this document is already up to date.'\n : `No pending draft to publish for locale \"${locale}\" — it is already up to date.`,\n )\n this.name = 'DraftNotFoundError'\n this.entityId = entityId\n this.locale = locale\n }\n}\n\n/**\n * Thrown by `restoreVersion` when the requested version snapshot does not\n * exist for the entity+locale — either the version number is out of range,\n * or the entity row itself is gone (a deleted row has no versions).\n *\n * Distinct from generic `Error` so admin-ui's API handler can map it to\n * 404 Not Found, matching the route-level 404 the publishable branch\n * already returns for the same condition. Before this it fell through to\n * the generic 500 branch on non-publishable entities — the same\n * \"caller-state condition audited as a server fault\" defect as\n * `EntityNotFoundError` (`plan/admin-api-hardening` F008).\n */\nexport class VersionNotFoundError extends Error {\n readonly entityId: string\n readonly version: number\n readonly locale: string\n\n constructor(entityId: string, version: number, locale: string) {\n super(`Version ${version} not found for entity ${entityId}`)\n this.name = 'VersionNotFoundError'\n this.entityId = entityId\n this.version = version\n this.locale = locale\n }\n}\n\n/**\n * Thrown when a locale string passed at runtime is not configured in the\n * content plugin. The plugin validates `defaultLocale` at init time, but\n * the per-call `locale` parameter on every read/write is not naturally\n * checked — `validateLocale()` is the boundary check.\n *\n * Allows the `'_'` sentinel (used internally for default-locale rows in\n * versions/drafts/locale_status tables) so callers can pass it through.\n */\nexport class InvalidLocaleError extends Error {\n readonly locale: string\n readonly allowed: readonly string[]\n\n constructor(locale: string, allowed: readonly string[]) {\n super(\n `Invalid locale \"${locale}\". Configured locales: [${allowed.join(', ')}]. ` +\n `(The \"_\" sentinel is also accepted for default-locale storage.)`,\n )\n this.name = 'InvalidLocaleError'\n this.locale = locale\n this.allowed = allowed\n }\n}\n","/**\n * `i18n` — the global i18n singletons **simple-config** namespace (plan/config\n * phase 05 §3, D002/D009/SD006): `defaultLocale` + `routingStrategy`.\n *\n * Simple config (D002) rather than a config entity because these are singletons,\n * not a list. Contributed via the content plugin's `shared.settings` slot, so it\n * auto-registers as a config type (D006) and rides export/import (phase 09) for\n * free. Edited through the bespoke **Languages** editor (`hideFromMenu`), which\n * also enforces `defaultLocale ∈ enabled locales`.\n *\n * Imports from `@murumets-ee/settings/define` (the pure builder subpath, no\n * `server-only`) so the content plugin stays jiti-safe at config-load time.\n */\n\nimport { defineSettings, setting } from '@murumets-ee/settings/define'\n\n/** The three URL locale-routing strategies (D009). */\nexport const ROUTING_STRATEGIES = ['prefix-all', 'no-prefix-default', 'cookie'] as const\n\nexport const i18nSettings = defineSettings({\n namespace: 'i18n',\n scope: 'global',\n label: 'Languages',\n iconName: 'languages',\n // Edited via the Languages editor, not the generic per-namespace settings page.\n hideFromMenu: true,\n schema: {\n /**\n * The site-wide default content locale. Empty ⇒ inherit the `content()`\n * shipped `defaultLocale`. The resolver ({@link resolveI18nConfig}) validates\n * it is an **enabled** locale and falls back to the `content()` default if not.\n */\n defaultLocale: setting.text({ default: '' }),\n /**\n * How the public URL encodes locale. Maps to next-intl `localePrefix` /\n * Astro `prefixDefaultLocale` via `./routing-strategy.js`.\n */\n routingStrategy: setting.select({\n options: ROUTING_STRATEGIES,\n default: 'no-prefix-default',\n }),\n },\n})\n","/**\n * `locale` — the content-locale registry **config entity** (plan/config phase 05\n * §3, D006/D009/SD006).\n *\n * One row per content locale the operator adds or customizes. The row is a\n * **read-time-merge override** (D004): a row whose `code` matches a `content()`\n * shipped default replaces that default (relabel / set direction / reorder /\n * `enabled: false` to hide it); a row with a new `code` adds a locale. Locales\n * with no row are the shipped defaults. See {@link resolveEffectiveLocales}.\n *\n * `class: 'config'` (SD001) marks it a configuration entity: it auto-registers\n * into the `ConfigTypeRegistry` (phase-09 export/import picks it up for free),\n * defaults to audit-only (no publish / no trash — trash auto-enable skips config\n * entities), and is edited through the bespoke **Languages** editor, not the\n * generic auto-CRUD (`admin.hidden`).\n *\n * `access` is admin-only: managing the language set is an admin capability. The\n * app-wide *effective-locale read* ({@link resolveEffectiveLocales}) uses a\n * system-context query so every role still resolves valid locales for editing\n * content, without being able to list/edit the registry itself.\n *\n * Leaf-safe: imports only `@murumets-ee/entity`, so the content plugin can pull\n * it in without a `server-only` chain through the jiti config load.\n */\n\nimport { behavior, defineEntity, field } from '@murumets-ee/entity'\n\nexport const Locale = defineEntity({\n name: 'locale',\n class: 'config',\n scope: 'global',\n access: {\n view: 'locale:view',\n create: 'locale:create',\n update: 'locale:update',\n delete: 'locale:delete',\n },\n fields: {\n /**\n * BCP47 locale code (e.g. `en`, `et`, `pt-BR`). Unique — one row per locale.\n * Capped at 10 to match the `varchar(10)` locale columns in the\n * versions / drafts / locale_status tables (truncation past 10 would break\n * later equality — see `locale-utils.ts`).\n */\n code: field.text({ required: true, unique: true, maxLength: 10 }),\n /** Human label shown in the switcher + editor (e.g. `English`). */\n label: field.text({ required: true, maxLength: 64 }),\n /**\n * When `false` the locale is hidden from the content switcher + rejected by\n * `validateLocale` (the D004 disable-not-delete edge), but stays listable in\n * the Languages editor so it can be re-enabled.\n */\n enabled: field.boolean({ default: true }),\n /** Text direction (`ltr`/`rtl`) — persisted per locale for RTL-aware surfaces to read\n * when setting `dir`; the surface-level `dir` wiring lands with RTL support. */\n direction: field.select({ options: ['ltr', 'rtl'] as const, default: 'ltr' }),\n /** Display order in the switcher + editor (ascending). */\n sortOrder: field.number({ default: 0 }),\n },\n behaviors: [behavior.auditable()],\n admin: {\n // Edited via the Languages editor, not the generic CRUD — keep it out of the\n // auto entity list + sidebar.\n hidden: true,\n label: 'Languages',\n labelSingular: 'Language',\n },\n})\n","/**\n * `message` — the interface-message store **config entity** (plan/config phase 06,\n * D006/D010).\n *\n * One row per `(namespace, key, locale)` **override** of an interface string. A row\n * is a read-time-merge customization (D004): the per-package / per-app message JSON\n * ships the defaults; a `message` row exists ONLY for a string the operator edited.\n * Merge-on-upgrade is automatic — new shipped keys reach non-customized strings; a\n * customized string keeps its row. No `customized` flag. See {@link resolveMessageOverrides}.\n *\n * `class: 'config'` (SD001) marks it a configuration entity: auto-registers into the\n * `ConfigTypeRegistry` (phase-09 export/import picks it up for free), audit-only (no\n * publish / no trash), edited through the bespoke **Translations** editor, not the\n * generic auto-CRUD (`admin.hidden`).\n *\n * `access` is admin-only metadata; the operator grants `message:update` to editors via\n * the Permission Matrix (F007 — entity-derived `message:*` is the grantable gate; the\n * literal `translations:update` in the phase doc would be admin-only/non-grantable).\n *\n * Keyed uniquely on `(namespace, key, locale)` via the `messageKeyUnique()` behavior —\n * `defineEntity` has no entity-level composite-unique option, so a behavior contributes\n * the constraint (the `searchable()`/`publishable()` pattern). The unique makes the save\n * route's create race fail closed → retryable 409.\n *\n * Leaf-safe: imports only `@murumets-ee/entity`, so the content plugin + the admin-ui\n * route can pull it in through the jiti config-load without a `server-only` chain.\n */\n\nimport {\n type Behavior,\n behavior,\n defineEntity,\n field,\n type SchemaFragment,\n} from '@murumets-ee/entity'\n\n/**\n * Composite unique `(namespace, key, locale)` for the message store. A no-field\n * behavior contributing a single `uniqueConstraint` fragment to the main table.\n */\nfunction messageKeyUnique(): Behavior<Record<never, never>, 'messageKeyUnique'> {\n return {\n name: 'messageKeyUnique',\n fields: {},\n constraints: (table, target): SchemaFragment[] => {\n if (target !== 'main') return []\n const { namespace, key, locale } = table\n // Defensive: only emit when all three key columns resolved.\n if (!namespace || !key || !locale) return []\n return [\n {\n kind: 'uniqueConstraint',\n name: 'message_namespace_key_locale_unique',\n on: [namespace, key, locale],\n },\n ]\n },\n }\n}\n\nexport const Message = defineEntity({\n name: 'message',\n class: 'config',\n scope: 'global',\n access: {\n view: 'message:view',\n create: 'message:create',\n update: 'message:update',\n delete: 'message:delete',\n },\n fields: {\n /** Top-level catalog key — the namespace (e.g. `AuthUI`, `home`). */\n namespace: field.text({ required: true, maxLength: 64 }),\n /** Dot-path within the namespace (e.g. `signIn.title`). */\n key: field.text({ required: true, maxLength: 255 }),\n /**\n * BCP47 locale code — matches the `locale` registry codes (varchar(10)).\n * Indexed: the read-time merge filters `WHERE locale = ?`, which can't use the\n * `(namespace, key, locale)` composite prefix (that serves the reconcile's\n * `namespace` filter).\n */\n locale: field.text({ required: true, maxLength: 10, indexed: true }),\n /**\n * The (ICU-pattern) override string. Unbounded `text` — interface strings are\n * short but ICU plural/select bodies can run long; a varchar cap would reject a\n * legitimate long translation. Length is still bounded at the route boundary.\n */\n value: field.text({ required: true }),\n },\n behaviors: [behavior.auditable(), messageKeyUnique()],\n admin: {\n // Edited via the Translations editor, not the generic CRUD — keep it out of the\n // auto entity list + sidebar.\n hidden: true,\n label: 'Translations',\n labelSingular: 'Message',\n },\n})\n","/**\n * Routing-strategy → framework-config mappings (plan/config phase 05 §7, D009/SD006).\n *\n * The `i18n` config's `routingStrategy` is the tenant-editable, framework-neutral\n * choice; these pure helpers are the **\"consumers read it\"** seam that maps it to\n * each frontend's native knob:\n *\n * - **next-intl** — `localePrefix` (`'always'` / `'as-needed'` / `'never'`).\n * - **Astro** — `i18n.routing.prefixDefaultLocale` (boolean).\n *\n * Kept dependency-free (no entity/settings/db import) so the public Astro build\n * (`astro.config`) and next-intl `routing.ts` can import the mapping without\n * dragging a `server-only` chain.\n */\n\nimport type { RoutingStrategy } from './types.js'\n\n/**\n * Map a {@link RoutingStrategy} to next-intl's `localePrefix`:\n *\n * | strategy | localePrefix | URL shape |\n * |---------------------|--------------|-----------------------------------|\n * | `prefix-all` | `always` | `/en/about`, `/et/about` |\n * | `no-prefix-default` | `as-needed` | `/about` (default), `/et/about` |\n * | `cookie` | `never` | `/about` (locale via cookie/edge) |\n */\nexport function localePrefixForStrategy(\n strategy: RoutingStrategy,\n): 'always' | 'as-needed' | 'never' {\n switch (strategy) {\n case 'prefix-all':\n return 'always'\n case 'no-prefix-default':\n return 'as-needed'\n case 'cookie':\n return 'never'\n }\n}\n\n/**\n * Map a {@link RoutingStrategy} to Astro's `i18n.routing.prefixDefaultLocale`.\n * Only `prefix-all` prefixes the default locale; the other two leave the default\n * unprefixed (`cookie` resolves the locale at the edge worker, not in the path).\n */\nexport function prefixDefaultLocaleForStrategy(strategy: RoutingStrategy): boolean {\n return strategy === 'prefix-all'\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{buildEntityHref as e,matchReservedPrefix as t,resolvePattern as n}from"@murumets-ee/entity";import{column as r,defineTable as i}from"@murumets-ee/db";import{sql as a}from"drizzle-orm";function o(e){let t=e;for(let e=0;t&&e<5;e++){if(t.code===`23505`)return!0;t=t.cause}return!1}async function s(e){let{pathsClient:n,entityType:r,entityId:i,locale:a,path:s,reservedPathPrefixes:l,logger:u}=e;if(s===null)return{outcome:`skipped-null`,oldPath:null,newPath:null,conflictWith:null};let d=t(s,l);if(d!==void 0)return u.warn({entity:r,id:i,locale:a,path:s,reserved:d},`toolkit_paths: computed path falls under a reserved prefix — skipping this locale (kept out of the routable index, see F013)`),{outcome:`skipped-reserved`,oldPath:null,newPath:s,conflictWith:null};let f=await n.findOne({entityType:r,entityId:i,locale:a,isCanonical:!0}),p=f?.path??null;if(f!==null&&f.path===s)return{outcome:`unchanged`,oldPath:p,newPath:s,conflictWith:null};if(e.dryRun){let e=await n.findOne({locale:a,path:s,isCanonical:!0});return e!==null&&!(e.entityType===r&&e.entityId===i)?{outcome:`conflict`,oldPath:p,newPath:s,conflictWith:{entityType:e.entityType,entityId:e.entityId}}:{outcome:f===null?`created`:`renamed`,oldPath:p,newPath:s,conflictWith:null}}f!==null&&await n.update({id:f.id},{isCanonical:!1,updatedAt:new Date});let m=!1;try{await n.insert({entityType:r,entityId:i,locale:a,path:s,isCanonical:!0}),m=!0}catch(e){if(!o(e))throw e}if(!m){if(f!==null)try{await n.update({id:f.id},{isCanonical:!0,updatedAt:new Date})}catch(e){if(!o(e))throw e;u.warn({entity:r,id:i,locale:a,path:s},`toolkit_paths: could not re-promote the old canonical after a failed insert — a canonical row already exists for this document+locale (concurrent write); leaving history as-is`)}let e=await n.findOne({locale:a,path:s,isCanonical:!0}),t=e!==null&&!(e.entityType===r&&e.entityId===i)?{entityType:e.entityType,entityId:e.entityId}:null;return u.warn({entity:r,id:i,locale:a,path:s},`toolkit_paths: canonical (locale, path) already claimed by another document — skipping (old canonical restored / left in place)`),{outcome:`conflict`,oldPath:p,newPath:s,conflictWith:t}}return f===null?{outcome:`created`,oldPath:null,newPath:s,conflictWith:null}:(await c(n,r,i,a),{outcome:`renamed`,oldPath:p,newPath:s,conflictWith:null})}async function c(e,t,n,r){let i=await e.count({entityType:t,entityId:n,locale:r,isCanonical:!1})-25;if(i<=0)return;let a=(await e.findMany({where:{entityType:t,entityId:n,locale:r,isCanonical:!1},orderBy:[{column:`updatedAt`,dir:`asc`},{column:`createdAt`,dir:`asc`}],limit:Math.min(i,1e3)})).map(e=>e.id);a.length>0&&await e.deleteMany({id:{in:a}})}async function l(e,t,n){let r=e.length;if(r===0)return;let i=0,a=Math.max(1,Math.min(Number.isFinite(t)?Math.floor(t):1,r));async function o(){for(;;){let t=i;if(i+=1,t>=r)return;await n(e[t],t)}}await Promise.all(Array.from({length:a},()=>o()))}var u=class extends Error{entityName;locale;path;conflictingEntityId;constructor(e,t,n,r,i){super(i??`Path "${n}" (locale "${t}") is already used by another ${e} document. Choose a different slug, or set this document's slug manually.`),this.name=`PathConflictError`,this.entityName=e,this.locale=t,this.path=n,this.conflictingEntityId=r}},d=class extends Error{entityName;path;prefix;constructor(e,t,n){super(`Path "${t}" falls under the reserved prefix "${n}" and cannot be used as a public URL for a ${e} document.`),this.name=`PathReservedPrefixError`,this.entityName=e,this.path=t,this.prefix=n}};async function f(t){let r=t.entity.frontend?.path;if(r===void 0)return null;let i=n(r,t.locale,t.defaultLocale);if(i===null)return null;let a=i;if(typeof i==`string`&&i.includes(`[parent.path]`)){let e=t.row.parent;if(typeof e==`string`&&e.length>0){let n=await t.pathsClient.findOne({entityType:t.entity.name,entityId:e,locale:t.locale,isCanonical:!0});if(!n)return null;a=i.replaceAll(`[parent.path]`,n.path)}else a=i.replaceAll(`[parent.path]`,``)}let o=e(a,t.row,t.locale,t.defaultLocale);return o===null?null:p(o)}function p(e){let t=e.replace(/\/{2,}/g,`/`);return t.length>1&&t.endsWith(`/`)?t.slice(0,-1):t}async function m(e){let{entity:n,data:r,currentRow:i,id:a,locale:o,defaultLocale:s,slugMode:c,reservedPathPrefixes:l,pathsClient:p}=e,m=``;for(let e=0;e<50;e++){let e={...i??{},...r},g=await f({entity:n,row:e,locale:o,defaultLocale:s,pathsClient:p});if(g===null)return;m=g;let _=t(g,l);if(_!==void 0)throw new d(n.name,g,_);let v=await p.findOne({locale:o,path:g,isCanonical:!0}),y=v!=null&&v.entityType===n.name&&v.entityId===a;if(v==null||y)return;if(c===`manual`)throw new u(n.name,o,g,v.entityId);let b=e.slug;if(typeof b!=`string`||b.length===0)throw new u(n.name,o,g,v.entityId);r.slug=h(b)}throw new u(n.name,o,m,void 0,`Could not find a free path for a ${n.name} document near "${m}" (locale "${o}") after 50 attempts — choose a different slug manually.`)}function h(e){let t=/^(.*)-(\d+)$/.exec(e);return t?`${t[1]??e}-${Number.parseInt(t[2]??`1`,10)+1}`:`${e}-2`}const g=i({name:`toolkit_paths`,columns:{id:r.uuid({primaryKey:!0,defaultRandom:!0}),entityType:r.varchar({length:100,notNull:!0,pgName:`entity_type`}),entityId:r.uuid({notNull:!0,pgName:`entity_id`}),locale:r.varchar({length:10,notNull:!0}),path:r.text({notNull:!0}),isCanonical:r.boolean({notNull:!0,default:!0,pgName:`is_canonical`}),createdAt:r.timestamp({notNull:!0,defaultNow:!0,withTimezone:!0,pgName:`created_at`}),updatedAt:r.timestamp({notNull:!0,defaultNow:!0,withTimezone:!0,pgName:`updated_at`})},unique:[{on:[`locale`,`path`],where:e=>a`${e.isCanonical} = true`},{on:[`entityType`,`entityId`,`locale`],where:e=>a`${e.isCanonical} = true`}],indexes:[{on:[`entityType`,`entityId`,`locale`]}]}),_=g.table;async function v(e){let{pathsClient:t,locale:n,path:r}=e,i=(await t.findMany({where:{locale:n,path:r},orderBy:[{column:`updatedAt`,dir:`desc`}],limit:1}))[0];if(!i)return null;let a=await t.findOne({entityType:i.entityType,entityId:i.entityId,locale:n,isCanonical:!0});return a?{entityType:i.entityType,entityId:i.entityId,currentPath:a.path}:null}const y=5;async function b(e,t,n,r){return(await r.pathsClient.findOne({entityType:e,entityId:t,locale:n,isCanonical:!0}))?.path??null}function x(e){return{async isPublished(t,n,r){let i=e.entityResolver(t);return i===void 0?!1:await e.queryClientFactory(i).findById(n,{locale:r})!=null}}}function S(e,t,n){return w(e,t,n,(e,t,r)=>n.publishChecker.isPublished(e,t,r))}function C(e,t,n){return w(e,t,n,async()=>!0)}async function w(e,t,n,r){let i=p(e),a=await n.homepageReader();if(i===`/`)return a===null||!await r(a.entity,a.id,t)?{kind:`none`}:{kind:`document`,entityType:a.entity,id:a.id};let o=await n.pathsClient.findOne({locale:t,path:i,isCanonical:!0});if(o)return await r(o.entityType,o.entityId,t)?O(a,o.entityType,o.entityId)?{kind:`redirect`,to:`/`,status:301}:{kind:`document`,entityType:o.entityType,id:o.entityId}:{kind:`none`};let s=await T(i,t,n,a);if(s!==null)return s;let c=await v({pathsClient:n.pathsClient,locale:t,path:i});return c===null||!await r(c.entityType,c.entityId,t)?{kind:`none`}:O(a,c.entityType,c.entityId)?{kind:`redirect`,to:`/`,status:301}:{kind:`redirect`,to:c.currentPath,status:301}}async function T(e,t,n,r){let i=await E(n.redirectsClient,e,t);if(i===null)return null;for(let e=0;e<5;e++){let e=i.target,a=k(i.status);if(e.kind===`url`)return{kind:`redirect`,to:e.url,status:a};if(e.kind===`entity`){if(O(r,e.entity,e.id))return{kind:`redirect`,to:`/`,status:a};let i=await b(e.entity,e.id,t,n);return i===null?{kind:`none`}:{kind:`redirect`,to:i,status:a}}let o=p(e.path),s=await E(n.redirectsClient,o,t);if(s!==null){i=s;continue}let c=await n.pathsClient.findOne({locale:t,path:o,isCanonical:!0});return c?O(r,c.entityType,c.entityId)?{kind:`redirect`,to:`/`,status:301}:{kind:`redirect`,to:o,status:a}:{kind:`none`}}return{kind:`none`}}async function E(e,t,n){return await e.findOne({fromPath:t,locale:n})||e.findOne({fromPath:t,locale:null})}async function D(e,t,n){let r=[];for(let i of n.locales){let a=await b(e,t,i,n);a!==null&&await n.publishChecker.isPublished(e,t,i)&&r.push({locale:i,path:a})}return r}function O(e,t,n){return e!==null&&e.entity===t&&e.id===n}function k(e){return e===302?302:301}const A=i({name:`toolkit_redirects`,columns:{id:r.uuid({primaryKey:!0,defaultRandom:!0}),locale:r.varchar({length:10}),fromPath:r.text({notNull:!0,pgName:`from_path`}),target:r.jsonb({notNull:!0}),status:r.integer({notNull:!0,default:301}),createdBy:r.varchar({length:255,pgName:`created_by`}),createdAt:r.timestamp({notNull:!0,defaultNow:!0,withTimezone:!0,pgName:`created_at`}),updatedAt:r.timestamp({notNull:!0,defaultNow:!0,withTimezone:!0,pgName:`updated_at`})},unique:[{on:[`locale`,`fromPath`],where:e=>a`${e.locale} IS NOT NULL`},{on:[`fromPath`],where:e=>a`${e.locale} IS NULL`}]}),j=A.table;export{D as a,S as c,f as d,m as f,l as g,o as h,x as i,_ as l,s as m,A as n,b as o,p,y as r,C as s,j as t,g as u};
|
|
2
|
+
//# sourceMappingURL=schema-BJxAwPrI.mjs.map
|