@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.
Files changed (50) hide show
  1. package/dist/admin.d.mts +32 -0
  2. package/dist/admin.d.mts.map +1 -0
  3. package/dist/admin.mjs +1 -0
  4. package/dist/client.d.mts +2 -2
  5. package/dist/client.mjs +1 -1
  6. package/dist/client.mjs.map +1 -1
  7. package/dist/content-config-store-8omQF5p5.mjs +2 -0
  8. package/dist/content-config-store-8omQF5p5.mjs.map +1 -0
  9. package/dist/entities/index.d.mts +5 -1
  10. package/dist/entities/index.mjs +1 -1
  11. package/dist/entities/index.mjs.map +1 -1
  12. package/dist/{index-D8nN7BFd.d.mts → index-Bsmju0UF.d.mts} +30 -3
  13. package/dist/index-Bsmju0UF.d.mts.map +1 -0
  14. package/dist/index.d.mts +4 -4
  15. package/dist/index.mjs +1 -1
  16. package/dist/index.mjs.map +1 -1
  17. package/dist/locale-utils-Dyw5qWDf.mjs +2 -0
  18. package/dist/{locale-utils-CuIBKeaz.mjs.map → locale-utils-Dyw5qWDf.mjs.map} +1 -1
  19. package/dist/lock-service.d.mts +1 -1
  20. package/dist/plugin-BTynhZMx.mjs +2 -0
  21. package/dist/plugin-BTynhZMx.mjs.map +1 -0
  22. package/dist/plugin.d.mts +17 -6
  23. package/dist/plugin.d.mts.map +1 -1
  24. package/dist/plugin.mjs +1 -1
  25. package/dist/query-client.mjs +1 -1
  26. package/dist/resolve-context-p3m6gn3_.mjs +2 -0
  27. package/dist/resolve-context-p3m6gn3_.mjs.map +1 -0
  28. package/dist/resolve.d.mts +587 -0
  29. package/dist/resolve.d.mts.map +1 -0
  30. package/dist/resolve.mjs +9 -0
  31. package/dist/resolve.mjs.map +1 -0
  32. package/dist/routes-D3dzAfQw.mjs +2 -0
  33. package/dist/routes-D3dzAfQw.mjs.map +1 -0
  34. package/dist/{routing-strategy-TPSzrapD.d.mts → routing-strategy-D3Qowm3D.d.mts} +40 -6
  35. package/dist/routing-strategy-D3Qowm3D.d.mts.map +1 -0
  36. package/dist/routing-strategy-dkt2yO25.mjs +2 -0
  37. package/dist/routing-strategy-dkt2yO25.mjs.map +1 -0
  38. package/dist/schema-BJxAwPrI.mjs +2 -0
  39. package/dist/schema-BJxAwPrI.mjs.map +1 -0
  40. package/dist/{types-CUw2Pf0Y.d.mts → types-B3K2p9t8.d.mts} +22 -1
  41. package/dist/types-B3K2p9t8.d.mts.map +1 -0
  42. package/package.json +17 -7
  43. package/dist/index-D8nN7BFd.d.mts.map +0 -1
  44. package/dist/locale-utils-CuIBKeaz.mjs +0 -2
  45. package/dist/plugin-IW9SM9iO.mjs +0 -2
  46. package/dist/plugin-IW9SM9iO.mjs.map +0 -1
  47. package/dist/routing-strategy-DGx6Rq6F.mjs +0 -2
  48. package/dist/routing-strategy-DGx6Rq6F.mjs.map +0 -1
  49. package/dist/routing-strategy-TPSzrapD.d.mts.map +0 -1
  50. package/dist/types-CUw2Pf0Y.d.mts.map +0 -1
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schema-BJxAwPrI.mjs","names":[],"sources":["../src/paths/apply-path.ts","../src/paths/errors.ts","../src/paths/generate-path.ts","../src/paths/schema.ts","../src/paths/redirect-target.ts","../src/paths/resolve.ts","../src/redirects/schema.ts"],"sourcesContent":["/**\n * Shared `toolkit_paths` canonical-row apply/plan logic (plan/routing PR04).\n *\n * PR04 Task 1 inlined a find-canonical → insert/demote sequence inside\n * `createPathIndexWriter`'s per-locale closure. Task 2 (the descendant-cascade\n * + full-rebuild queue jobs) needs the EXACT same \"recompute-and-demote-if-\n * changed\" write so a renamed ancestor's descendants — and a `paths:rebuild`\n * sweep — mint redirect history identically to a first-party entity write.\n *\n * Rather than reimplement it (and risk drift in the routing invariant), the\n * sequence lives here once as {@link applyCanonicalPath}; the entity-hook\n * writer, the cascade job, and the rebuild job all call it. The writer adds the\n * one thing the jobs must NOT do — firing the cascade seam — on a `renamed`\n * outcome; the jobs write descendant paths DIRECTLY through this helper (never\n * via `AdminClient.update`) precisely so they DON'T re-fire the seam and trip\n * the queue's self-enqueue guard.\n *\n * NOTE: on the content plugin's `init` load path (via `writers.ts` →\n * `plugin.ts`), so it must stay free of `import 'server-only'`. Every import\n * here is server-only-free; `Logger` is a TYPE-only import from core.\n */\n\nimport type { Logger } from '@murumets-ee/core'\nimport { matchReservedPrefix } from '@murumets-ee/entity'\nimport type { ToolkitPathsClient } from './schema.js'\n\n/**\n * Per-document redirect-history cap (plan/routing §2 \"capped at 25/document\").\n * When a rename demotes the old canonical row, non-canonical rows beyond this\n * many (per document+locale) are pruned oldest-demoted-first to bound growth.\n * Moved here from `writers.ts` so both the entity-hook writer and the jobs\n * share ONE cap constant.\n */\nexport const MAX_REDIRECT_HISTORY = 25\n\n/**\n * Steady-state overflow is 1 (we trim on every demote); this clamp only guards\n * a pathological backlog (e.g. after a rebuild that demoted many at once) from\n * tripping the `findMany` MAX_LIMIT throw — the remainder self-heals on the\n * next write.\n */\nconst HISTORY_TRIM_MAX = 1000\n\n/** True iff `err` (or any `.cause` in its chain) is a Postgres unique violation (23505). */\nexport function isUniqueViolation(err: unknown): boolean {\n let cursor: unknown = err\n for (let depth = 0; cursor && depth < 5; depth++) {\n if ((cursor as { code?: unknown }).code === '23505') return true\n cursor = (cursor as { cause?: unknown }).cause\n }\n return false\n}\n\n/**\n * What {@link applyCanonicalPath} did (or, in `dryRun`, WOULD do) for one\n * (document, locale):\n * - `created` — no canonical row existed → a fresh canonical was inserted.\n * - `unchanged`— the canonical row already held this exact path (no-op).\n * - `renamed` — the path changed → new canonical inserted, old demoted to\n * history (redirect), history pruned to the cap.\n * - `skipped-null` — `computeCanonicalPath` returned `null` (typed broken\n * link — no pattern / unresolved token / parent not indexed yet).\n * - `skipped-reserved` — the computed path falls under a reserved prefix; it\n * is kept OUT of the routable index (never persisted).\n * - `conflict` — the target `(locale, path)` canonical is owned by a DIFFERENT\n * document; the old row is left canonical (nothing written).\n */\nexport type ApplyPathOutcome =\n | 'created'\n | 'unchanged'\n | 'renamed'\n | 'skipped-null'\n | 'skipped-reserved'\n | 'conflict'\n\n/** Structured outcome of one canonical-path apply/plan. */\nexport interface ApplyPathResult {\n outcome: ApplyPathOutcome\n /** The document's previous canonical path, or `null` when it had none. */\n oldPath: string | null\n /** The freshly computed canonical path, or `null` for `skipped-null`. */\n newPath: string | null\n /** For `conflict`: the document already owning the target URL (else `null`). */\n conflictWith: { entityType: string; entityId: string } | null\n}\n\nexport interface ApplyCanonicalPathArgs {\n pathsClient: ToolkitPathsClient\n entityType: string\n entityId: string\n locale: string\n /**\n * Precomputed canonical path (from `computeCanonicalPath`), or `null` for a\n * typed broken link. Kept as an explicit input so the caller owns the\n * entity+row → path resolution (the writer, cascade, and rebuild each source\n * `row` differently) and this helper stays a pure `toolkit_paths` mutator.\n */\n path: string | null\n reservedPathPrefixes: readonly string[]\n logger: Logger\n /**\n * When `true`, compute the outcome + diff but perform NO writes (the rebuild\n * job's dry-run preview). Conflict is then detected via a lookup rather than\n * the insert-and-catch used on the real-write path.\n */\n dryRun?: boolean\n}\n\n/**\n * Recompute-and-demote-if-changed for ONE (document, locale) — the shared core\n * of PR04's redirect-history writer, reused by the entity hook AND the cascade\n * / rebuild jobs.\n *\n * Real-write path (default): the old canonical is DEMOTED FIRST\n * (`is_canonical = false`), then the new canonical is inserted. This ordering is\n * DB-SAFE because `toolkit_paths` carries a partial unique\n * `UNIQUE (entity_type, entity_id, locale) WHERE is_canonical` — so at every\n * point in time Postgres guarantees at most ONE canonical row per\n * (document, locale). (An insert-FIRST order would transiently hold two\n * canonical rows and therefore VIOLATE that partial unique on every rename.)\n *\n * If the insert then fails with a unique violation — either the pre-existing\n * `(locale, path)` conflict (another document owns the target URL, F010's known\n * tolerance) or, under a genuine concurrent double-write, the\n * `(entity_type, entity_id, locale)` partial unique — the old row is\n * RE-PROMOTED (`is_canonical = true`) so the document is never left with zero\n * canonical rows for the locale. That corrective re-promote can itself hit the\n * partial unique in the concurrent case (another writer already re-inserted a\n * canonical for this document); that is tolerated + logged, because the document\n * already HAS a canonical then. On success the old row's demotion stands and\n * history is pruned to {@link MAX_REDIRECT_HISTORY}.\n *\n * It NEVER fires the cascade seam — the caller decides that on a `renamed`\n * outcome (only the entity hook does; the jobs must not, to avoid re-entrant\n * self-enqueue).\n *\n * `dryRun` path performs no writes and detects a cross-document conflict with a\n * `(locale, path, isCanonical)` lookup.\n */\nexport async function applyCanonicalPath(args: ApplyCanonicalPathArgs): Promise<ApplyPathResult> {\n const { pathsClient, entityType, entityId, locale, path, reservedPathPrefixes, logger } = args\n\n if (path === null) {\n return { outcome: 'skipped-null', oldPath: null, newPath: null, conflictWith: null }\n }\n\n // Never persist a reserved path (F013). The write-time guard only screens the\n // write's OWN locale, and a rebuild sweeps every locale — so re-check here.\n const reserved = matchReservedPrefix(path, reservedPathPrefixes)\n if (reserved !== undefined) {\n logger.warn(\n { entity: entityType, id: entityId, locale, path, reserved },\n 'toolkit_paths: computed path falls under a reserved prefix — skipping this locale ' +\n '(kept out of the routable index, see F013)',\n )\n return { outcome: 'skipped-reserved', oldPath: null, newPath: path, conflictWith: null }\n }\n\n // The document's CURRENT canonical row for this locale. The `isCanonical`\n // filter is REQUIRED since PR04 keeps historical (`is_canonical = false`) rows.\n const currentCanonical = await pathsClient.findOne({\n entityType,\n entityId,\n locale,\n isCanonical: true,\n })\n const oldPath = currentCanonical?.path ?? null\n\n // (b) Same path → unrelated field changed; a no-op. Leave the row (and its\n // updatedAt) alone; don't mint spurious history.\n if (currentCanonical !== null && currentCanonical.path === path) {\n return { outcome: 'unchanged', oldPath, newPath: path, conflictWith: null }\n }\n\n if (args.dryRun) {\n const owner = await pathsClient.findOne({ locale, path, isCanonical: true })\n if (owner !== null && !(owner.entityType === entityType && owner.entityId === entityId)) {\n return {\n outcome: 'conflict',\n oldPath,\n newPath: path,\n conflictWith: { entityType: owner.entityType, entityId: owner.entityId },\n }\n }\n return {\n outcome: currentCanonical === null ? 'created' : 'renamed',\n oldPath,\n newPath: path,\n conflictWith: null,\n }\n }\n\n // Real write. DEMOTE the old canonical FIRST so the new insert can't\n // transiently create a second canonical row — the\n // `(entity_type, entity_id, locale) WHERE is_canonical` partial unique would\n // otherwise reject the insert on every rename. Demoting first keeps at most one\n // canonical per (document, locale) at all times (enforced by Postgres).\n if (currentCanonical !== null) {\n await pathsClient.update(\n { id: currentCanonical.id },\n { isCanonical: false, updatedAt: new Date() },\n )\n }\n\n // Insert the new canonical. On a unique violation the target URL is already\n // owned by another document (F010) — or, under a concurrent double-write, this\n // document's canonical was just re-inserted by the other writer.\n let inserted = false\n try {\n await pathsClient.insert({ entityType, entityId, locale, path, isCanonical: true })\n inserted = true\n } catch (err) {\n if (!isUniqueViolation(err)) throw err\n }\n\n if (!inserted) {\n // Re-promote the old row so the document is never left with ZERO canonical\n // rows for this locale (this is what the demote-first order must protect\n // against). The re-promote can ITSELF hit the partial unique when a\n // concurrent writer has already re-inserted a canonical for this document —\n // in which case the document already has a canonical and no repair is\n // needed; tolerate + log (F010/F013 log-and-tolerate posture).\n if (currentCanonical !== null) {\n try {\n await pathsClient.update(\n { id: currentCanonical.id },\n { isCanonical: true, updatedAt: new Date() },\n )\n } catch (err) {\n if (!isUniqueViolation(err)) throw err\n logger.warn(\n { entity: entityType, id: entityId, locale, path },\n 'toolkit_paths: could not re-promote the old canonical after a failed insert — ' +\n 'a canonical row already exists for this document+locale (concurrent write); ' +\n 'leaving history as-is',\n )\n }\n }\n // Best-effort: identify who owns the URL for structured reporting.\n const owner = await pathsClient.findOne({ locale, path, isCanonical: true })\n const conflictWith =\n owner !== null && !(owner.entityType === entityType && owner.entityId === entityId)\n ? { entityType: owner.entityType, entityId: owner.entityId }\n : null\n logger.warn(\n { entity: entityType, id: entityId, locale, path },\n 'toolkit_paths: canonical (locale, path) already claimed by another document — ' +\n 'skipping (old canonical restored / left in place)',\n )\n return { outcome: 'conflict', oldPath, newPath: path, conflictWith }\n }\n\n if (currentCanonical === null) {\n return { outcome: 'created', oldPath: null, newPath: path, conflictWith: null }\n }\n\n // Old canonical was demoted above; prune redirect history to the cap.\n await enforceHistoryCap(pathsClient, entityType, entityId, locale)\n return { outcome: 'renamed', oldPath, newPath: path, conflictWith: null }\n}\n\n/**\n * Prune redirect history for (document, locale) back down to\n * {@link MAX_REDIRECT_HISTORY}, trimming the rows demoted longest ago first\n * (smallest `updated_at`). Extracted verbatim from Task 1's writer closure.\n */\nasync function enforceHistoryCap(\n pathsClient: ToolkitPathsClient,\n entityType: string,\n entityId: string,\n locale: string,\n): Promise<void> {\n const historical = await pathsClient.count({\n entityType,\n entityId,\n locale,\n isCanonical: false,\n })\n const overflow = historical - MAX_REDIRECT_HISTORY\n if (overflow <= 0) return\n const oldest = await pathsClient.findMany({\n where: { entityType, entityId, locale, isCanonical: false },\n orderBy: [\n { column: 'updatedAt', dir: 'asc' },\n { column: 'createdAt', dir: 'asc' },\n ],\n limit: Math.min(overflow, HISTORY_TRIM_MAX),\n })\n const trimIds = oldest.map((r) => r.id)\n if (trimIds.length > 0) {\n await pathsClient.deleteMany({ id: { in: trimIds } })\n }\n}\n\n/**\n * Run `fn` over `items` with bounded concurrency (a fixed worker pool pulling\n * from a shared cursor) — the `runMeritBatch` shape (CLAUDE.md's\n * no-unbounded-fan-out rule; never `Promise.all` over an unbounded list). `fn`\n * MUST isolate its own per-item errors (this helper does not catch) so one bad\n * item can't abort the batch.\n *\n * Increments/tallies inside `fn` are safe without a lock: JS is single-threaded,\n * so a `count++` between `await` points is atomic.\n */\nexport async function mapBounded<I>(\n items: readonly I[],\n concurrency: number,\n fn: (item: I, index: number) => Promise<void>,\n): Promise<void> {\n const total = items.length\n if (total === 0) return\n let next = 0\n // `Math.floor` guards a fractional/NaN concurrency (config-derived) from\n // producing a zero-length worker array that silently no-ops the batch.\n const safe = Number.isFinite(concurrency) ? Math.floor(concurrency) : 1\n const workerCount = Math.max(1, Math.min(safe, total))\n async function worker(): Promise<void> {\n for (;;) {\n const index = next\n next += 1\n if (index >= total) return\n await fn(items[index] as I, index)\n }\n }\n await Promise.all(Array.from({ length: workerCount }, () => worker()))\n}\n","/**\n * Domain errors thrown by the pre-commit `pathConflictGuard` (plan/routing PR03,\n * F010). Distinct classes so the admin-ui API handler can map them to a 4xx\n * (caller-actionable) rather than a generic 500, and so callers can branch on\n * `instanceof`.\n */\n\n/**\n * A document's candidate canonical URL is already claimed by a DIFFERENT\n * document in the same locale, and it can't be auto-resolved: either the slug\n * is manually pinned (`slug_mode = 'manual'`), or the auto-suffix ladder was\n * exhausted / there is no slug to suffix. The write is aborted before commit.\n */\nexport class PathConflictError extends Error {\n readonly entityName: string\n readonly locale: string\n readonly path: string\n /** The id of the document already holding `(locale, path)`, when known. */\n readonly conflictingEntityId: string | undefined\n\n constructor(\n entityName: string,\n locale: string,\n path: string,\n conflictingEntityId?: string,\n message?: string,\n ) {\n super(\n message ??\n `Path \"${path}\" (locale \"${locale}\") is already used by another ${entityName} document. ` +\n `Choose a different slug, or set this document's slug manually.`,\n )\n this.name = 'PathConflictError'\n this.entityName = entityName\n this.locale = locale\n this.path = path\n this.conflictingEntityId = conflictingEntityId\n }\n}\n\n/**\n * A document's resolved canonical URL falls under a reserved path prefix\n * (`reservedPathPrefixes`, e.g. `/admin` or `/api`). Allowing it would make the\n * document reachable under a route the framework owns, so the write is aborted\n * before commit — for the WRITE's OWN locale only (F010's one-locale-per-write\n * scope; the boot-time lint only WARNs, and only on statically-analysable\n * patterns). A reserved-prefix hit in a DIFFERENT locale than the one being\n * written (e.g. a base-row reparent that only collides for a non-default\n * locale) isn't caught here — `pathIndexWriter`'s post-commit pass screens\n * every other locale too and refuses to persist a reserved path, but by then\n * the entity write has already committed (F013).\n */\nexport class PathReservedPrefixError extends Error {\n readonly entityName: string\n readonly path: string\n readonly prefix: string\n\n constructor(entityName: string, path: string, prefix: string) {\n super(\n `Path \"${path}\" falls under the reserved prefix \"${prefix}\" and cannot be used as a ` +\n `public URL for a ${entityName} document.`,\n )\n this.name = 'PathReservedPrefixError'\n this.entityName = entityName\n this.path = path\n this.prefix = prefix\n }\n}\n","/**\n * Shared path-generation + conflict logic (plan/routing PR03, Part 3).\n *\n * `computeCanonicalPath` resolves a routable row to its locale-relative\n * canonical URL — reusing `buildEntityHref` (the PR02-shipped token engine) and\n * expanding the `[parent.path]` token against the parent's own indexed path\n * (F004's PR03 payoff). Used by BOTH seams:\n * - `pathIndexWriter` (post-commit) — compute + upsert.\n * - `pathConflictGuard` (pre-commit, via `ensurePathAvailable`) — compute +\n * check + maybe-mutate-and-rethrow.\n *\n * so the pattern-resolution + parent-expansion logic exists exactly once.\n *\n * NOTE on the signature vs. the task brief: the brief typed\n * `computeCanonicalPath` as synchronous (`: string | null`), but the\n * `[parent.path]` expansion requires a `toolkit_paths` lookup, which is async —\n * so both functions are `Promise`-returning and take the `ToolkitPathsClient`.\n * (Same shape of necessary deviation as Task 1's `defineTable` `where`\n * predicate, which the brief typed as `SQL` but had to widen to a callback.)\n */\n\nimport {\n buildEntityHref,\n type Entity,\n matchReservedPrefix,\n resolvePattern,\n} from '@murumets-ee/entity'\nimport { PathConflictError, PathReservedPrefixError } from './errors.js'\nimport type { ToolkitPathsClient } from './schema.js'\n\n/** Hard cap on auto-suffix attempts — a pathological run of taken slugs throws\n * rather than looping forever (matches the repo's no-unbounded-work posture,\n * even though this is a single-document loop, not a fan-out). */\nconst MAX_SUFFIX_ATTEMPTS = 50\n\n/**\n * Compute a row's locale-relative canonical path for `locale`, or `null` when\n * it can't be built (no pattern for the locale, an unresolved token, or the\n * function form returned null) — the same typed-BROKEN-link contract as\n * {@link buildEntityHref}.\n *\n * `[parent.path]` expansion: when `row.parent` is a set self-reference\n * (`pathParent()`), the parent's OWN canonical `toolkit_paths` path for the\n * SAME `(entityType, locale, isCanonical)` is substituted directly into the\n * PATTERN STRING (not via `buildEntityHref`'s row-based token substitution —\n * see below for why). If the parent isn't indexed yet in this locale, the\n * whole path is unresolved (`null`, a legitimate broken-link outcome, not an\n * error). A ROOT document (`row.parent` null/absent) substitutes `''` — per\n * Phase 01 §3's settled design, \"root documents expand `[parent.path]` to\n * nothing\", so a root under `/[parent.path]/[slug]` still resolves (to\n * `/[slug]` after {@link normalizePath} collapses the doubled separator).\n *\n * This can't go through `buildEntityHref`'s normal `row[token]` substitution:\n * that function treats ANY empty-string token value as a broken link\n * (`missing = true`) — correct for ordinary content tokens (an empty slug\n * really is broken), wrong for `parent.path`'s root case (empty there is\n * intentional, not missing). So `[parent.path]` is resolved against the raw\n * PATTERN STRING first (via `resolvePattern`, the same per-locale/map\n * resolution `buildEntityHref` uses internally), and the already-substituted\n * string is then handed to `buildEntityHref` for the REMAINING tokens (e.g.\n * `[slug]`), which keep the normal missing-is-broken rule.\n */\nexport async function computeCanonicalPath(args: {\n entity: Entity\n /** Locale-merged row for the target locale. */\n row: Record<string, unknown>\n locale: string\n defaultLocale: string\n pathsClient: ToolkitPathsClient\n}): Promise<string | null> {\n const declaredPath = args.entity.frontend?.path\n if (declaredPath === undefined) return null\n\n const pattern = resolvePattern(declaredPath, args.locale, args.defaultLocale)\n if (pattern === null) return null\n\n let effectivePattern = pattern\n if (typeof pattern === 'string' && pattern.includes('[parent.path]')) {\n const parentId = args.row.parent\n if (typeof parentId === 'string' && parentId.length > 0) {\n // Same-entity self-reference, so the parent lives in this entity's own\n // path rows — no cross-entity-type registry needed.\n const parentRow = await args.pathsClient.findOne({\n entityType: args.entity.name,\n entityId: parentId,\n locale: args.locale,\n isCanonical: true,\n })\n if (!parentRow) return null // parent not indexed in this locale yet — broken, not root\n effectivePattern = pattern.replaceAll('[parent.path]', parentRow.path)\n } else {\n // Root document — expands to nothing (Phase 01 §3), not \"missing\".\n effectivePattern = pattern.replaceAll('[parent.path]', '')\n }\n }\n\n const href = buildEntityHref(effectivePattern, args.row, args.locale, args.defaultLocale)\n return href === null ? null : normalizePath(href)\n}\n\n/**\n * Canonicalize a path for storage/comparison: collapse duplicate slashes and\n * strip a trailing slash (except the bare root `/`). This makes the\n * `[parent.path]` composition robust — `/[parent.path]/[slug]` with a\n * `parent.path` of `/handbook` yields `//handbook/intro`, which normalizes to\n * `/handbook/intro` — and guarantees the guard and writer compute byte-identical\n * paths for the same row (so a re-save is correctly seen as \"no change\").\n */\nexport function normalizePath(path: string): string {\n const collapsed = path.replace(/\\/{2,}/g, '/')\n return collapsed.length > 1 && collapsed.endsWith('/') ? collapsed.slice(0, -1) : collapsed\n}\n\n/**\n * Pre-commit conflict check + auto-suffix for a SINGLE locale's write. Called by\n * `pathConflictGuard`. Computes the candidate path from `data` merged onto\n * `currentRow`, rejects reserved-prefix hits and manual-slug conflicts, and —\n * for auto slugs — mutates `data.slug` in place with an incrementing suffix\n * (`hello` → `hello-2` → `hello-3`) until the path is free (bounded).\n *\n * `data.slug` mutations are persisted by the SAME entity write (the guard fires\n * before the INSERT/UPDATE and `data` is the in-flight payload), so no\n * follow-up write is needed.\n */\nexport async function ensurePathAvailable(args: {\n entity: Entity\n /** Mutable in-flight write payload — may get `data.slug` rewritten. */\n data: Record<string, unknown>\n /** Pre-write locale-merged row for `update`/`updateForLocale`; `null` on create. */\n currentRow: Record<string, unknown> | null\n /** The row id, or `undefined` on create (nothing to self-exclude). */\n id: string | undefined\n locale: string\n defaultLocale: string\n slugMode: 'auto' | 'manual'\n reservedPathPrefixes: readonly string[]\n pathsClient: ToolkitPathsClient\n}): Promise<void> {\n const {\n entity,\n data,\n currentRow,\n id,\n locale,\n defaultLocale,\n slugMode,\n reservedPathPrefixes,\n pathsClient,\n } = args\n\n let lastPath = ''\n for (let attempt = 0; attempt < MAX_SUFFIX_ATTEMPTS; attempt++) {\n // Working row = current row overlaid with the in-flight write (create:\n // `data` IS the full row, `currentRow` is null).\n const row = { ...(currentRow ?? {}), ...data }\n const path = await computeCanonicalPath({ entity, row, locale, defaultLocale, pathsClient })\n // Typed broken link — nothing to conflict-check for this locale.\n if (path === null) return\n lastPath = path\n\n // Reserved-prefix boundary — the real enforcement point (Part 2 write-time).\n const reserved = matchReservedPrefix(path, reservedPathPrefixes)\n if (reserved !== undefined) {\n throw new PathReservedPrefixError(entity.name, path, reserved)\n }\n\n // The partial-unique index guarantees at most ONE canonical row per\n // (locale, path). A hit that ISN'T this document's own row is a conflict\n // (self-match excluded — a routine re-save keeping its own path is fine;\n // on create `id` is undefined so nothing is excluded).\n const existing = await pathsClient.findOne({ locale, path, isCanonical: true })\n const isSelf =\n existing != null && existing.entityType === entity.name && existing.entityId === id\n if (existing == null || isSelf) return\n\n // Real conflict with another document.\n if (slugMode === 'manual') {\n throw new PathConflictError(entity.name, locale, path, existing.entityId)\n }\n const currentSlug = row.slug\n if (typeof currentSlug !== 'string' || currentSlug.length === 0) {\n // Auto mode but nothing to suffix — can't resolve automatically.\n throw new PathConflictError(entity.name, locale, path, existing.entityId)\n }\n // Persisted by the same write (the guard runs before the SQL is built).\n data.slug = incrementSlugSuffix(currentSlug)\n }\n\n // Exhausted the suffix ladder — pathological (50 taken slugs in a row).\n throw new PathConflictError(\n entity.name,\n locale,\n lastPath,\n undefined,\n `Could not find a free path for a ${entity.name} document near \"${lastPath}\" (locale \"${locale}\") ` +\n `after ${MAX_SUFFIX_ATTEMPTS} attempts — choose a different slug manually.`,\n )\n}\n\n/**\n * Increment a numeric slug suffix, WordPress/pathauto-style:\n * `hello` → `hello-2`, `hello-2` → `hello-3`, `hello-9` → `hello-10`.\n * A slug with no trailing `-<n>` gets `-2` appended.\n */\nexport function incrementSlugSuffix(slug: string): string {\n const match = /^(.*)-(\\d+)$/.exec(slug)\n if (match) {\n const base = match[1] ?? slug\n const n = Number.parseInt(match[2] ?? '1', 10)\n return `${base}-${n + 1}`\n }\n return `${slug}-2`\n}\n","/**\n * `toolkit_paths` — the central materialized path index (plan/routing D005/D006,\n * F007). One row per (document, locale) mapping a routable entity's row to its\n * locale-relative canonical URL. Owned by `@murumets-ee/content`; maintained by\n * the `pathIndexWriter` (post-commit) + `pathConflictGuard` (pre-commit) seams\n * on `@murumets-ee/entity`'s `AdminClient`.\n *\n * Uses `defineTable` (per plan/routing C3) so both seams read/write through a\n * typed `TableClient` — zero raw SQL.\n *\n * Uniqueness + lookup (two partial-uniques + one plain index):\n * - `UNIQUE (locale, path) WHERE is_canonical` — no two documents can claim the\n * same canonical URL in the same locale (F007's partial-unique callback form,\n * exactly as Task 1 shipped `defineTable` support for). `is_canonical = false`\n * rows (redirect history) are excluded, so an old path can coexist with the\n * live one holding the same URL. THIS is the routing invariant; it is\n * unchanged across PR03→PR04.\n * - `UNIQUE (entity_type, entity_id, locale) WHERE is_canonical` — at most ONE\n * canonical row per (document, locale). PR03 enforced this as a FULL\n * `UNIQUE (entity_type, entity_id, locale)` (no history yet, so exactly one\n * row per (document, locale) held). PR04 introduced redirect history — a\n * rename DEMOTES the old canonical (`is_canonical = false`) and INSERTS a\n * fresh one — so the FULL unique could no longer hold and migration 0058\n * dropped it. But dropping it outright left NOTHING enforcing \"one canonical\n * per document\": the demote/insert sequence, or two concurrent writers on the\n * same document, could leave TWO permanently-canonical rows — a silent,\n * permanent violation of the invariant resolution / `entity_refs` / the admin\n * UI's \"the URL for a document\" all depend on. This PARTIAL unique restores\n * the guarantee while still allowing history: only `is_canonical = true` rows\n * are in scope, so any number of demoted historical rows may coexist. It also\n * makes the writer's demote-FIRST → insert ordering DB-safe (see\n * `apply-path.ts`).\n * - `INDEX (entity_type, entity_id, locale)` — a PLAIN (non-unique) index, the\n * natural \"all rows of this document\" lookup key used by the delete sweep,\n * the parent-path resolution query, and the writer's per-locale\n * demote-then-insert (PR04). The partial unique above only covers CANONICAL\n * rows, so this plain index is still needed for the history-inclusive\n * lookups. (Migration 0058 drops the old full `..._unique` constraint and\n * creates this plain index in its place; a later PR04 migration adds the\n * partial unique.)\n */\n\nimport { column, defineTable } from '@murumets-ee/db'\nimport { sql } from 'drizzle-orm'\n\nexport const toolkitPathsTable = defineTable({\n name: 'toolkit_paths',\n columns: {\n id: column.uuid({ primaryKey: true, defaultRandom: true }),\n entityType: column.varchar({ length: 100, notNull: true, pgName: 'entity_type' }),\n entityId: column.uuid({ notNull: true, pgName: 'entity_id' }),\n locale: column.varchar({ length: 10, notNull: true }),\n /** Locale-relative canonical path: leading '/', no trailing '/'. */\n path: column.text({ notNull: true }),\n isCanonical: column.boolean({ notNull: true, default: true, pgName: 'is_canonical' }),\n createdAt: column.timestamp({\n notNull: true,\n defaultNow: true,\n withTimezone: true,\n pgName: 'created_at',\n }),\n updatedAt: column.timestamp({\n notNull: true,\n defaultNow: true,\n withTimezone: true,\n pgName: 'updated_at',\n }),\n },\n unique: [\n // No two documents share a canonical URL per locale (F007 partial-unique).\n // The one true routing invariant — unchanged from PR03.\n { on: ['locale', 'path'], where: (c) => sql`${c.isCanonical} = true` },\n // At most ONE canonical row per (document, locale). Restores (as a partial\n // unique, so redirect history still accumulates) the \"one canonical path per\n // document\" guarantee the full unique dropped in migration 0058 used to give.\n // Historical (is_canonical = false) rows are excluded from the predicate.\n { on: ['entityType', 'entityId', 'locale'], where: (c) => sql`${c.isCanonical} = true` },\n ],\n indexes: [\n // \"All rows of this document\" — canonical + historical. Plain (non-unique)\n // because PR04's redirect history keeps demoted rows here (see file header).\n { on: ['entityType', 'entityId', 'locale'] },\n ],\n})\n\n/** Backwards-compatible raw pgTable export (for the plugin `tables:` slot + migrations). */\nexport const toolkitPaths = toolkitPathsTable.table\n\n/** The full `DefinedTable` for `toolkit_paths`. */\nexport type ToolkitPathsTable = typeof toolkitPathsTable\n\n/** The typed `TableClient` for `toolkit_paths` (`toolkitPathsTable.makeClient(db)`). */\nexport type ToolkitPathsClient = ReturnType<typeof toolkitPathsTable.makeClient>\n","/**\n * Internal redirect-resolution helper (plan/routing PR04, Part 4).\n *\n * Given a `(locale, path)` that may be a HISTORICAL (demoted) URL, resolve the\n * CURRENT canonical path of the document that once lived there — the \"301 to\n * wherever this document now lives\" lookup that backs automatic redirect\n * history (D005 / the change-matrix in `04-path-lifecycle-and-redirects.md`).\n *\n * **Chain-free by construction.** Every historical row is keyed to the OWNING\n * DOCUMENT (`entity_type`, `entity_id`), never to \"the next URL in the rename\n * sequence\". So a document renamed A → B → C leaves historical rows at A and B\n * both pointing at the same document, and this helper resolves EITHER of them\n * straight to C in one hop — it never walks A → B → C. (WP `_wp_old_slug` /\n * Drupal redirect both use this keyed-to-target model — R01.)\n *\n * NOT the public resolution API. Phase 05 / PR06 owns `resolvePath` and the\n * canonical → manual → historical → 404 precedence contract (§2, §4 of Phase\n * 04); this phase ships only the underlying historical-row → current-canonical\n * mechanism. PR06 imports and wraps this. (Scope split filed as a Finding in\n * `plan/routing/progress.md`, mirroring F004's PR02/03 precedent.)\n */\n\nimport type { PathIndexReader } from './index-reader.js'\n\n/** The current canonical location a historical/canonical `(locale, path)` resolves to. */\nexport interface RedirectTarget {\n entityType: string\n entityId: string\n /** The owning document's CURRENT canonical path for this locale. */\n currentPath: string\n}\n\n/**\n * Resolve `(locale, path)` — canonical OR historical — to the current canonical\n * path of its owning document, or `null` when nothing routes there.\n *\n * Returns `null` when:\n * - No `toolkit_paths` row (of any `is_canonical`) matches `(locale, path)`.\n * - The owning document has no current canonical row for `locale` anymore\n * (its locale variant was unpublished/deleted since) — the caller treats\n * this as a 404, matching the Unpublish/Delete rows of the §2 change matrix.\n *\n * Known edge case (documented, NOT over-engineered per the brief): two DIFFERENT\n * documents can both hold a HISTORICAL row at the same `(locale, path)` if one\n * occupied that URL, moved away, and a LATER document independently claimed then\n * also moved away from it — the partial-unique only forbids two *canonical* rows\n * sharing a URL, never two historical ones. When multiple rows match, we pick\n * the most-recently-updated (`ORDER BY updated_at DESC LIMIT 1`): self-healing /\n * approximate, consistent with this feature's already-accepted tolerance for\n * narrow non-security drift (F014's precedent — accept the race rather than add\n * locking machinery).\n */\nexport async function findRedirectTarget(args: {\n // Narrow structural reader (the real `ToolkitPathsClient` satisfies it — see\n // `./index-reader.ts`). Widened from `ToolkitPathsClient` in PR06 so the\n // resolution core can inject a cast-free unit-test stub; the integration\n // tests still pass the real client, proving the assignment.\n pathsClient: PathIndexReader\n locale: string\n path: string\n}): Promise<RedirectTarget | null> {\n const { pathsClient, locale, path } = args\n\n // Any row (canonical or historical) at this URL. Most-recent wins on the\n // documented multi-document-historical collision.\n const matches = await pathsClient.findMany({\n where: { locale, path },\n orderBy: [{ column: 'updatedAt', dir: 'desc' }],\n limit: 1,\n })\n const match = matches[0]\n if (!match) return null\n\n // Resolve the owning document's CURRENT canonical row for this locale. This\n // is the single hop that makes redirects chain-free: we jump from \"some URL\n // this document used to (or does) live at\" straight to \"where it lives now\".\n const canonical = await pathsClient.findOne({\n entityType: match.entityType,\n entityId: match.entityId,\n locale,\n isCanonical: true,\n })\n // Owning document has no live canonical path in this locale anymore\n // (unpublished/deleted that variant) → 404, per the §2 change matrix.\n if (!canonical) return null\n\n return {\n entityType: match.entityType,\n entityId: match.entityId,\n currentPath: canonical.path,\n }\n}\n","/**\n * The public path-resolution API (plan/routing PR06; phase\n * `05-resolution-and-enumeration-api.md` §2, phase `04` §2-4).\n *\n * `resolvePath` maps an incoming `(path, locale)` to a document, a redirect, or\n * a 404, applying the settled precedence:\n *\n * `/` homepage → canonical → manual redirect → historical redirect → none\n *\n * **Security (CLAUDE.md #1 priority).** These functions gate whether content is\n * reachable by URL. `resolvePath` publish-checks at every tier that resolves to\n * a document (homepage / canonical / historical) via the injected\n * {@link PublishChecker} — an UNPUBLISHED document in the requested locale is\n * NOT routable (`{ kind: 'none' }`). A canonical row \"claims\" its path even in\n * draft, so an unpublished canonical returns `none` WITHOUT falling through to\n * manual/historical (nothing else may resolve there). `resolveDraftPath` is the\n * ONLY function that skips publish checks — it is admin/preview-only. Its one\n * sanctioned HTTP caller is `./routes.ts` (`GET /api/admin/paths/resolve`,\n * added for #775: permission-gated `path:resolve` PLUS a per-document `view`\n * re-check). It must NEVER be wired to a `content-api` endpoint — that surface\n * is public and published-only.\n *\n * **Leaf module.** All cross-package state (path/redirect readers, the publish\n * checker, the homepage reader) is injected via `deps` — this module imports no\n * `@murumets-ee/core` and no `server-only` graph. The concrete `deps` are built\n * from an app in `./resolve-context.ts`.\n */\n\nimport type { Entity } from '@murumets-ee/entity'\nimport type { RedirectTarget } from '../redirects/target.js'\nimport { normalizePath } from './generate-path.js'\nimport type { PathIndexReader } from './index-reader.js'\nimport type { HomepagePointer } from './homepage.js'\nimport { findRedirectTarget } from './redirect-target.js'\n\n// ---------------------------------------------------------------------------\n// Result type (phase 05 §2 — exact shape)\n// ---------------------------------------------------------------------------\n\n/** The document that serves the requested path. */\nexport interface ResolveDocumentResult {\n kind: 'document'\n entityType: string\n id: string\n}\n/** A redirect to another URL/path. `301` permanent, `302` temporary. */\nexport interface ResolveRedirectResult {\n kind: 'redirect'\n to: string\n status: 301 | 302\n}\n/** Nothing routes here (404). */\nexport interface ResolveNoneResult {\n kind: 'none'\n}\nexport type ResolveResult = ResolveDocumentResult | ResolveRedirectResult | ResolveNoneResult\n\n// ---------------------------------------------------------------------------\n// Dependency-injection surface\n// ---------------------------------------------------------------------------\n\n/**\n * Is `(entityType, entityId)` published in `locale`? Backed by\n * `QueryClient.findById` (auto-applies the publish filter) — see\n * {@link createPublishChecker}. `resolveDraftPath` bypasses this entirely.\n */\nexport interface PublishChecker {\n isPublished(entityType: string, entityId: string, locale: string): Promise<boolean>\n}\n\n/** Resolve an entity TYPE NAME to its `Entity`, or `undefined` when unregistered. */\nexport type EntityResolver = (entityType: string) => Entity | undefined\n\n/** The `QueryClient.findById` surface the publish checker needs (publish filter is internal). */\nexport interface PublishQueryClient {\n findById(id: string, options?: { locale?: string | undefined }): Promise<unknown>\n}\n\n/** Build a publish-filtering read client for an entity (real: `new QueryClient(...)`). */\nexport type QueryClientFactory = (entity: Entity) => PublishQueryClient\n\n/** Read the current homepage pointer, or `null` when unset/malformed. */\nexport type HomepageReader = () => Promise<HomepagePointer | null>\n\n/** The manual-redirect lookup surface (locale-specific + catch-all rows). */\nexport interface RedirectLookupReader {\n findOne(where: {\n fromPath: string\n /** A concrete locale, or `null` for the catch-all (all-locales) row. */\n locale: string | null\n }): Promise<{ target: RedirectTarget; status: number } | null>\n}\n\n/** Deps for {@link getCanonicalPath} — the minimal index read. */\nexport interface CanonicalPathDeps {\n pathsClient: PathIndexReader\n}\n\n/** Deps for {@link resolvePath} / {@link resolveDraftPath}. */\nexport interface ResolvePathDeps extends CanonicalPathDeps {\n redirectsClient: RedirectLookupReader\n publishChecker: PublishChecker\n homepageReader: HomepageReader\n}\n\n/** Deps for {@link getAlternates}. */\nexport interface AlternatesDeps extends CanonicalPathDeps {\n publishChecker: PublishChecker\n /** The locales to enumerate (configured content locales). */\n locales: readonly string[]\n}\n\n/**\n * Hop cap on manual-redirect `path`-chain following at REQUEST time (F021 /\n * F027 point 4). `collapsePathTarget` only collapses ONE hop at save time, so a\n * chain created out-of-order can still need re-hopping here; exceeding the cap\n * resolves to `none` rather than looping or 500ing.\n */\nexport const MAX_REDIRECT_HOPS = 5\n\n// ---------------------------------------------------------------------------\n// getCanonicalPath — forward index read, NO publish filter (D-note below)\n// ---------------------------------------------------------------------------\n\n/**\n * The current canonical path for `(entityType, entityId, locale)`, or `null`\n * when the document has no canonical row in that locale.\n *\n * **No publish filter — deliberate.** This matches the CURRENT behavior of\n * `resolveEntityHref` / search's URL builder (both call `AdminClient.findById`,\n * which does zero publish filtering); Task 2 re-points those consumers onto this\n * function, so changing the filtering here would be an unplanned behavior change.\n * Callers that need a publish gate compose one explicitly (resolution's canonical\n * tier does).\n */\nexport async function getCanonicalPath(\n entityType: string,\n entityId: string,\n locale: string,\n deps: CanonicalPathDeps,\n): Promise<string | null> {\n const row = await deps.pathsClient.findOne({ entityType, entityId, locale, isCanonical: true })\n return row?.path ?? null\n}\n\n// ---------------------------------------------------------------------------\n// Publish checker\n// ---------------------------------------------------------------------------\n\n/**\n * Build a {@link PublishChecker} from an entity resolver + a query-client\n * factory. `isPublished` is `false` for an unknown entity type, else\n * `findById(id, { locale }) !== null` — a non-null result means \"published in\n * that locale\" because `QueryClient` auto-applies the publish filter (and\n * excludes trashed rows). F027 point 1.\n */\nexport function createPublishChecker(deps: {\n entityResolver: EntityResolver\n queryClientFactory: QueryClientFactory\n}): PublishChecker {\n return {\n async isPublished(entityType, entityId, locale) {\n const entity = deps.entityResolver(entityType)\n if (entity === undefined) return false\n const client = deps.queryClientFactory(entity)\n const row = await client.findById(entityId, { locale })\n return row !== null && row !== undefined\n },\n }\n}\n\n// ---------------------------------------------------------------------------\n// resolvePath / resolveDraftPath\n// ---------------------------------------------------------------------------\n\n/** The publish predicate threaded through the tiers (real, or always-true for draft). */\ntype PublishPredicate = (entityType: string, entityId: string, locale: string) => Promise<boolean>\n\n/**\n * Resolve `(path, locale)` to a document / redirect / none, publish-checking at\n * every document-resolving tier. Public request surface (phase 05 §2).\n */\nexport function resolvePath(\n path: string,\n locale: string,\n deps: ResolvePathDeps,\n): Promise<ResolveResult> {\n return resolveInternal(path, locale, deps, (entityType, entityId, loc) =>\n deps.publishChecker.isPublished(entityType, entityId, loc),\n )\n}\n\n/**\n * Admin/preview resolution — identical tier structure to {@link resolvePath} but\n * SKIPS every publish check (draft and published documents both resolve). Per\n * phase 05 §2 this surface is admin-only. Exactly ONE HTTP route may call it —\n * `./routes.ts`'s `GET /api/admin/paths/resolve` (#775), which gates on\n * `path:resolve` and re-checks the caller's `view` grant on whatever it\n * resolves. NEVER wire it to `content-api` (public, published-only).\n */\nexport function resolveDraftPath(\n path: string,\n locale: string,\n deps: ResolvePathDeps,\n): Promise<ResolveResult> {\n // The ONLY difference from resolvePath: the publish predicate is always true.\n return resolveInternal(path, locale, deps, async () => true)\n}\n\n/**\n * Shared tier engine for `resolvePath` / `resolveDraftPath`. The two differ ONLY\n * in `checkPublished` (real checker vs. always-true), so the precedence + every\n * branch stay byte-identical between the public and admin surfaces.\n */\nasync function resolveInternal(\n rawPath: string,\n locale: string,\n deps: ResolvePathDeps,\n checkPublished: PublishPredicate,\n): Promise<ResolveResult> {\n const path = normalizePath(rawPath)\n const homepage = await deps.homepageReader()\n\n // ── Tier 1: `/` homepage special case ────────────────────────────────────\n if (path === '/') {\n if (homepage === null) return { kind: 'none' }\n if (!(await checkPublished(homepage.entity, homepage.id, locale))) return { kind: 'none' }\n return { kind: 'document', entityType: homepage.entity, id: homepage.id }\n }\n\n // ── Tier 2: canonical ─────────────────────────────────────────────────────\n const canonical = await deps.pathsClient.findOne({ locale, path, isCanonical: true })\n if (canonical) {\n // The path is CLAIMED by this document even in draft — an unpublished\n // canonical returns `none` and does NOT fall through to manual/historical.\n if (!(await checkPublished(canonical.entityType, canonical.entityId, locale))) {\n return { kind: 'none' }\n }\n // D004 dedup: the homepage document's own pattern URL always 301s to `/`.\n if (isHomepage(homepage, canonical.entityType, canonical.entityId)) {\n return { kind: 'redirect', to: '/', status: 301 }\n }\n return { kind: 'document', entityType: canonical.entityType, id: canonical.entityId }\n }\n\n // ── Tier 3: manual redirects ──────────────────────────────────────────────\n const manual = await resolveManualRedirect(path, locale, deps, homepage)\n if (manual !== null) return manual\n\n // ── Tier 4: historical (automatic rename) redirects ───────────────────────\n const historical = await findRedirectTarget({ pathsClient: deps.pathsClient, locale, path })\n if (historical === null) return { kind: 'none' }\n if (!(await checkPublished(historical.entityType, historical.entityId, locale))) {\n return { kind: 'none' }\n }\n if (isHomepage(homepage, historical.entityType, historical.entityId)) {\n return { kind: 'redirect', to: '/', status: 301 }\n }\n return { kind: 'redirect', to: historical.currentPath, status: 301 }\n}\n\n/**\n * Tier 3 — resolve a manual `toolkit_redirects` row at `fromPath`. Returns:\n * - `null` when NO manual redirect exists at `fromPath` (caller falls to tier 4).\n * - a {@link ResolveResult} otherwise (including `{ kind: 'none' }` for a target\n * that resolves to nothing).\n *\n * `entity`-kind targets resolve via `getCanonicalPath` with NO publish check\n * (F027 point 3 — an unpublished target 404s on the FOLLOWING request via tier\n * 2's own check). `path`-kind targets are re-resolved at request time, bounded\n * by {@link MAX_REDIRECT_HOPS}, so an out-of-order chain (F021) can't loop.\n */\nasync function resolveManualRedirect(\n fromPath: string,\n locale: string,\n deps: ResolvePathDeps,\n homepage: HomepagePointer | null,\n): Promise<ResolveResult | null> {\n let row = await lookupManualRedirect(deps.redirectsClient, fromPath, locale)\n if (row === null) return null // no manual redirect here → fall through to tier 4\n\n for (let hop = 0; hop < MAX_REDIRECT_HOPS; hop++) {\n const target = row.target\n const status = toRedirectStatus(row.status)\n\n if (target.kind === 'url') {\n return { kind: 'redirect', to: target.url, status } // terminal, external\n }\n\n if (target.kind === 'entity') {\n // Homepage-collision (D004), same as tiers 2/4 + the path-walk-hits-canonical\n // case: if the redirect points AT the homepage document, go straight to `/`\n // rather than its pattern URL (which would then 301 to `/` anyway — a pointless\n // double hop). Keep the manual redirect's own status.\n if (isHomepage(homepage, target.entity, target.id)) {\n return { kind: 'redirect', to: '/', status }\n }\n const to = await getCanonicalPath(target.entity, target.id, locale, deps)\n if (to === null) return { kind: 'none' } // target has no canonical row in this locale\n return { kind: 'redirect', to, status }\n }\n\n // target.kind === 'path' — re-resolve at request time (bounded).\n const nextPath = normalizePath(target.path)\n const nextRow = await lookupManualRedirect(deps.redirectsClient, nextPath, locale)\n if (nextRow !== null) {\n row = nextRow // hop to the next redirect and keep walking\n continue\n }\n // No further redirect at `nextPath` — does it serve canonical content?\n const canonical = await deps.pathsClient.findOne({\n locale,\n path: nextPath,\n isCanonical: true,\n })\n if (canonical) {\n if (isHomepage(homepage, canonical.entityType, canonical.entityId)) {\n return { kind: 'redirect', to: '/', status: 301 }\n }\n return { kind: 'redirect', to: nextPath, status }\n }\n return { kind: 'none' } // path target points at neither a redirect nor content\n }\n\n return { kind: 'none' } // exceeded MAX_REDIRECT_HOPS — never loop, never 500\n}\n\n/**\n * Look up a manual redirect at `fromPath`: a locale-specific row wins, else the\n * catch-all (`locale = null`) row. Mirrors `collapsePathTarget`'s precedence.\n */\nasync function lookupManualRedirect(\n client: RedirectLookupReader,\n fromPath: string,\n locale: string,\n): Promise<{ target: RedirectTarget; status: number } | null> {\n const exact = await client.findOne({ fromPath, locale })\n if (exact) return exact\n return client.findOne({ fromPath, locale: null })\n}\n\n// ---------------------------------------------------------------------------\n// getAlternates\n// ---------------------------------------------------------------------------\n\n/**\n * The published per-locale canonical URLs for a document — the `hreflang`\n * alternates set (phase 05 §2, \"published locales only\"). A locale is included\n * only when the document has a canonical row AND is published there. Bounded by\n * the (small, fixed) configured-locale count — no fan-out concern.\n */\nexport async function getAlternates(\n entityType: string,\n id: string,\n deps: AlternatesDeps,\n): Promise<Array<{ locale: string; path: string }>> {\n const alternates: Array<{ locale: string; path: string }> = []\n for (const locale of deps.locales) {\n const path = await getCanonicalPath(entityType, id, locale, deps)\n if (path === null) continue\n if (!(await deps.publishChecker.isPublished(entityType, id, locale))) continue\n alternates.push({ locale, path })\n }\n return alternates\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\n/** Is `(entityType, entityId)` the configured homepage document? */\nfunction isHomepage(\n homepage: HomepagePointer | null,\n entityType: string,\n entityId: string,\n): boolean {\n return homepage !== null && homepage.entity === entityType && homepage.id === entityId\n}\n\n/**\n * Narrow the DB `status` integer (validated `301 | 302` at write time) to the\n * result union. Defaults to `301` for any unexpected value — never a raw number\n * leaking into the typed result.\n */\nfunction toRedirectStatus(status: number): 301 | 302 {\n return status === 302 ? 302 : 301\n}\n","/**\n * `toolkit_redirects` — the manual-redirect table (plan/routing PR05, phase\n * `04-path-lifecycle-and-redirects.md` §3). One row per admin-authored redirect:\n * a `from_path` (locale-relative source URL) → a {@link RedirectTarget}\n * (entity / url / path). Distinct from the AUTOMATIC redirect history, which lives\n * as demoted (`is_canonical = false`) rows in `toolkit_paths` — this table is only\n * for the URLs automatic history can't express (campaign URLs, migrated-site\n * paths). Owned by `@murumets-ee/content`.\n *\n * Uses `defineTable` (per plan/routing C3, CLAUDE.md \"no raw SQL / typed APIs\n * only\") so reads/writes go through a typed `TableClient` — Task 2's CRUD routes\n * and the save-time validation (`./validation.ts`) never touch raw SQL.\n *\n * ## Uniqueness — TWO partial-unique indexes (F020 point 4)\n *\n * `locale` is nullable: `null` means \"applies to all locales\" (a catch-all\n * redirect). Postgres treats `NULL`s as DISTINCT in an ordinary unique constraint,\n * so a naive `UNIQUE (locale, from_path)` would silently allow duplicate catch-all\n * rows at the same path. Two partial-uniques express the real intent:\n *\n * - `UNIQUE (locale, from_path) WHERE locale IS NOT NULL` — a locale-specific\n * redirect is unique per (locale, from_path).\n * - `UNIQUE (from_path) WHERE locale IS NULL` — at most ONE catch-all redirect per\n * from_path.\n *\n * A locale-specific row and a catch-all row MAY legitimately coexist at the same\n * `from_path` (intentional per-locale override layering) — the two indexes don't\n * forbid that, only duplicates WITHIN each stratum. Both indexes are keyed on\n * `from_path`, so the \"does this from_path already exist as ANY redirect\"\n * collapse-lookup (`./validation.ts`) is already covered — no extra plain index\n * needed.\n *\n * Both use the callback `where` form (F007's `defineTable` partial-unique support)\n * with an INLINE `IS NULL` / `IS NOT NULL` literal — never `eq()`, which would bind\n * a `$1` param that is invalid in DDL (CLAUDE.md partial-unique gotcha; same form\n * `toolkit_paths` uses).\n */\n\nimport { column, defineTable } from '@murumets-ee/db'\nimport { sql } from 'drizzle-orm'\nimport type { RedirectTarget } from './target.js'\n\nexport const toolkitRedirectsTable = defineTable({\n name: 'toolkit_redirects',\n columns: {\n id: column.uuid({ primaryKey: true, defaultRandom: true }),\n /** BCP47 locale, or `null` = applies to all locales (catch-all). */\n locale: column.varchar({ length: 10 }),\n /** Locale-relative source URL: leading '/', no trailing '/' (same convention as `toolkit_paths.path`). */\n fromPath: column.text({ notNull: true, pgName: 'from_path' }),\n /** The redirect destination — see {@link RedirectTarget}. Stored as the discriminated-union object. */\n target: column.jsonb<RedirectTarget, true>({ notNull: true }),\n /** HTTP status. `301 | 302` is validated at the Zod boundary in Task 2's route layer. */\n status: column.integer({ notNull: true, default: 301 }),\n /**\n * The admin who authored the redirect. VARCHAR, never UUID — better-auth user\n * ids are random strings (CLAUDE.md). Nullable: system-created rows (future\n * migration/import tooling) have no natural actor, matching how other\n * audit-style columns in `defineTable` tables stay nullable.\n */\n createdBy: column.varchar({ length: 255, pgName: 'created_by' }),\n createdAt: column.timestamp({\n notNull: true,\n defaultNow: true,\n withTimezone: true,\n pgName: 'created_at',\n }),\n updatedAt: column.timestamp({\n notNull: true,\n defaultNow: true,\n withTimezone: true,\n pgName: 'updated_at',\n }),\n },\n unique: [\n // A locale-specific redirect is unique per (locale, from_path). Excludes\n // catch-all rows (locale IS NULL) from this stratum.\n { on: ['locale', 'fromPath'], where: (c) => sql`${c.locale} IS NOT NULL` },\n // At most ONE catch-all (all-locales) redirect per from_path. Postgres would\n // otherwise treat every NULL locale as distinct and allow duplicates.\n { on: ['fromPath'], where: (c) => sql`${c.locale} IS NULL` },\n ],\n})\n\n/** Backwards-compatible raw pgTable export (for the plugin `tables:` slot + migrations). */\nexport const toolkitRedirects = toolkitRedirectsTable.table\n\n/** The full `DefinedTable` for `toolkit_redirects`. */\nexport type ToolkitRedirectsTable = typeof toolkitRedirectsTable\n\n/** The typed `TableClient` for `toolkit_redirects` (`toolkitRedirectsTable.makeClient(db)`). */\nexport type ToolkitRedirectsClient = ReturnType<typeof toolkitRedirectsTable.makeClient>\n"],"mappings":"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"}
@@ -43,6 +43,27 @@ interface I18nConfig {
43
43
  interface ContentConfig {
44
44
  locales: LocaleConfig[];
45
45
  defaultLocale: string;
46
+ /**
47
+ * Path prefixes the CMS reserves for itself — a document's canonical URL may
48
+ * never fall under one (plan/routing PR03, Phase 02 §3). Each entry is an
49
+ * absolute, locale-relative prefix (must start with `/`, e.g. `/admin`,
50
+ * `/api`). Enforced in three places:
51
+ * - boot-time WARN, for entities whose `frontend.path` is a STATIC pattern
52
+ * with a literal prefix under a reserved one (best-effort, non-blocking);
53
+ * - write-time THROW, inside the pre-commit `pathConflictGuard`, for the
54
+ * WRITE's own locale's actually-resolved path (catches dynamic/function
55
+ * patterns too, but only that one locale — F010's one-locale-per-write
56
+ * scope; a base-row write that only creates a reserved-prefix collision
57
+ * in a DIFFERENT locale isn't blocked here);
58
+ * - post-commit SKIP (not a throw — the write already committed), inside
59
+ * `pathIndexWriter`, which screens every OTHER configured locale before
60
+ * persisting its `toolkit_paths` row and refuses to index a reserved path
61
+ * even when the pre-commit guard didn't check that locale (F013).
62
+ *
63
+ * Segment-boundary matching: `/admin` reserves `/admin` and `/admin/x` but
64
+ * NOT `/administrator`.
65
+ */
66
+ reservedPathPrefixes?: string[];
46
67
  /**
47
68
  * Auto-enable WordPress-style recoverable soft-delete (Trash) for the
48
69
  * content-managed (publishable) entities in the app (plan/trash SD002). The
@@ -117,4 +138,4 @@ interface LockInfo {
117
138
  }
118
139
  //#endregion
119
140
  export { LocaleDirection as a, LockInfo as c, TranslationReport as d, TranslationStatus as f, LocaleConfig as i, RoutingStrategy as l, VersionableOptions as m, DraftEntry as n, LocaleEntry as o, VersionEntry as p, I18nConfig as r, LocalePublishStatus as s, ContentConfig as t, TranslationFieldStatus as u };
120
- //# sourceMappingURL=types-CUw2Pf0Y.d.mts.map
141
+ //# sourceMappingURL=types-B3K2p9t8.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types-B3K2p9t8.d.mts","names":[],"sources":["../src/types.ts"],"mappings":";;AAQA;;UAAiB,YAAA;EACf,IAAA;EACA,KAAK;AAAA;;KAIK,eAAA;;AAAe;AAQ3B;;;;KAAY,eAAA;AAUZ;;;;;;;;AAAA,UAAiB,WAAA;EACf,IAAA;EACA,KAAA;EACA,OAAA;EACA,SAAA,EAAW,eAAe;EAC1B,SAAA;AAAA;;;;;;AAWgC;UAFjB,UAAA;EACf,aAAA;EACA,eAAA,EAAiB,eAAe;AAAA;AAAA,UAGjB,aAAA;EACf,OAAA,EAAS,YAAY;EACrB,aAAA;EAqBA;;;;AAc+B;AAOjC;;;;AAIU;AAOV;;;;;;;;;;EAhCE,oBAAA;EAgC4B;;;;;;;;;;;;;EAlB5B,SAAA;IAAwB,OAAA;EAAA;AAAA;AAAA,UAOT,kBAAA;EAsBL;EApBV,SAAA;EAiCe;EA/Bf,QAAQ;AAAA;AAAA,UAOO,YAAA,eAA2B,MAAA,oBAA0B,MAAA;EACpE,EAAA;EACA,QAAA;EACA,OAAA;EACA,MAAA;EACA,IAAA,EAAM,KAAA;EACN,KAAA,EAAO,OAAA,CAAQ,KAAA;EACf,MAAA;EACA,SAAA;EACA,aAAA;EACA,SAAA,EAAW,IAAA;EACX,UAAA;AAAA;AAAA,UAae,sBAAA;EACf,QAAA;EACA,KAAA;EACA,UAAA;EACA,OAAA;AAAA;AAAA,UAGe,iBAAA;EAAA,CACd,MAAA,WAAiB,sBAAsB;AAAA;AAAA,UAGzB,iBAAA;EACf,KAAA;EACA,QAAA;EACA,OAAA;EACA,OAAA;AAAA;AAAA,UAOe,mBAAA;EACf,MAAA;EACA,MAAA;EACA,WAAA,EAAa,IAAI;AAAA;AAAA,UAOF,UAAA,eAAyB,MAAA,oBAA0B,MAAA;EAClE,EAAA;EACA,QAAA;EACA,MAAA;EACA,IAAA,EAAM,KAAA;EACN,SAAA;EACA,aAAA;EACA,SAAA,EAAW,IAAA;EACX,SAAA,EAAW,IAAA;AAAA;AAAA,UAOI,QAAA;EACf,QAAA;EACA,YAAA;EACA,QAAA,EAAU,IAAA;EACV,SAAA,EAAW,IAAI;AAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@murumets-ee/content",
3
- "version": "0.36.0",
3
+ "version": "0.37.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -27,6 +27,14 @@
27
27
  "./lock": {
28
28
  "types": "./dist/lock-service.d.mts",
29
29
  "import": "./dist/lock-service.mjs"
30
+ },
31
+ "./admin": {
32
+ "types": "./dist/admin.d.mts",
33
+ "import": "./dist/admin.mjs"
34
+ },
35
+ "./resolve": {
36
+ "types": "./dist/resolve.d.mts",
37
+ "import": "./dist/resolve.mjs"
30
38
  }
31
39
  },
32
40
  "files": [
@@ -36,12 +44,14 @@
36
44
  "drizzle-orm": "^0.45.2",
37
45
  "server-only": "^0.0.1",
38
46
  "zod": "^3.24.1",
39
- "@murumets-ee/blocks": "0.36.0",
40
- "@murumets-ee/db": "0.36.0",
41
- "@murumets-ee/entity": "0.36.0",
42
- "@murumets-ee/core": "0.36.0",
43
- "@murumets-ee/logging": "0.36.0",
44
- "@murumets-ee/settings": "0.36.0"
47
+ "@murumets-ee/blocks": "0.37.0",
48
+ "@murumets-ee/core": "0.37.0",
49
+ "@murumets-ee/db": "0.37.0",
50
+ "@murumets-ee/entity": "0.37.0",
51
+ "@murumets-ee/i18n": "0.37.0",
52
+ "@murumets-ee/logging": "0.37.0",
53
+ "@murumets-ee/queue": "0.37.0",
54
+ "@murumets-ee/settings": "0.37.0"
45
55
  },
46
56
  "devDependencies": {
47
57
  "@types/node": "^20.19.42",
@@ -1 +0,0 @@
1
- {"version":3,"file":"index-D8nN7BFd.d.mts","names":[],"sources":["../src/behaviors/versionable.ts","../src/behaviors/with-overrides.ts","../src/behaviors/workflowable.ts","../src/capabilities.ts","../src/diff/types.ts","../src/errors.ts","../src/locale-registry.ts"],"mappings":";;;;;KAmBY,iBAAA;EACV,QAAA,EAAU,WAAA;IAAgB,QAAA;EAAA;EAC1B,YAAA,EAAc,SAAS;AAAA;AAAA,iBAQT,WAAA,CAAY,OAAA,GAAU,kBAAA,GAAqB,QAAA,CAAS,iBAAA;;;;iBAqDpD,qBAAA,CAAsB,SAAA,GAAY,QAAA,KAAa,QAAA,CAAS,kBAAA;;;;;ACrCV;;;KAQzD,iBAAA;EACC,IAAA;AAAA;EAEA,IAAA;EACA,KAAA,GAAQ,MAAA;EACR,MAAA,GAAS,MAAA,SAAe,MAAA;AAAA;EAExB,IAAA;EAAiB,KAAA,EAAO,MAAA;AAAA;AAAA,KAEzB,YAAA,GAAe,MAAM,SAAS,iBAAA;AAAA,KAEvB,mBAAA;EAcV,UAAA,EAAY,SAAS;AAAA;;;;AAlBa;iBAsLpB,aAAA,CAAA,GAAiB,QAAQ,CAAC,mBAAA;;;;AApLU;AAEpD;iBA8NgB,gBAAA,CACd,SAAA,EAAW,aAAa;EAAG,IAAA;AAAA,IAAiB,MAAA;;;cCjPjC,eAAA;AAAA,KACD,aAAA,WAAwB,eAAe;;;;;AFmCuC;;;cE1B7E,oBAAA;EAAA;;;;;;;;;iBAWG,mBAAA,CAAoB,IAAA,EAAM,aAAA,EAAe,EAAA,EAAI,aAAa;;;;;cAU7D,uBAAA,SAAgC,KAAA;EAAA,SAClC,IAAA,EAAM,aAAA;EAAA,SACN,EAAA,EAAI,aAAA;cAED,IAAA,EAAM,aAAA,EAAe,EAAA,EAAI,aAAA;AAAA;AAAA,KAW3B,kBAAA;EACV,eAAA,EAAiB,WAAA;IAAgB,QAAA;EAAA;EACjC,iBAAA,EAAmB,SAAA;EACnB,aAAA,EAAe,SAAA;EACf,kBAAA,EAAoB,SAAA;AAAA;AAAA,iBAGN,YAAA,CAAA,GAAgB,QAAQ,CAAC,kBAAA;;;KC7E7B,gBAAA;;;;;;iBAOI,cAAA,CAAe,MAAA,EAAQ,MAAA,EAAQ,UAAA,EAAY,gBAAgB;;;;;;;UCxB1D,WAAA;EACf,IAAA;EACA,KAAK;AAAA;;UAIU,SAAA;EACf,KAAA;EACA,IAAA;EAWA,IAAA;EACA,EAAA;EJJuB;EIMvB,QAAA,GAAW,WAAA;EJEG;EIAd,UAAA,GAAa,cAAc;AAAA;;KAIjB,eAAA;;UAGK,cAAA;EACf,OAAA;EACA,SAAA;EACA,MAAA,EAAQ,eAAA;EACR,SAAA;EACA,OAAA;EJZmF;EIcnF,SAAA,GAAY,QAAQ;AAAA;;UAIL,QAAA;EACf,KAAA;EACA,IAAA;EACA,EAAA;EJgCqE;EI9BrE,QAAA,GAAW,WAAW;AAAA;;;;;;;;AJjCxB;;;;;;;cKNa,qBAAA,SAA8B,KAAK;EAAA,SACrC,UAAA;EAAA,SACA,UAAA;cAGP,UAAA,UACA,UAAA,EAAY,qBAAA,gBACZ,OAAA;AAAA;;;;;;;;;;cAsBS,kBAAA,SAA2B,KAAK;EAAA,SAClC,QAAA;EAAA,SACA,MAAA;cAEG,QAAA,UAAkB,MAAA;AAAA;;;;;;;;;;cAuBnB,kBAAA,SAA2B,KAAK;EAAA,SAClC,MAAA;EAAA,SACA,OAAA;cAEG,MAAA,UAAgB,OAAA;AAAA;;;;;;;;iBCOR,uBAAA,CAAwB,GAAA,EAAK,UAAA,GAAa,OAAA,CAAQ,WAAA;;iBAoBxD,kBAAA,CAAmB,OAA+B,WAAb,WAAW;;;;ANvEqB;AAqDrF;;;iBM6BsB,iBAAA,CACpB,GAAA,EAAK,UAAA,EACL,OAAA,YAAmB,WAAA,KAClB,OAAA,CAAQ,UAAA"}
@@ -1,2 +0,0 @@
1
- import{r as e,u as t}from"./routing-strategy-DGx6Rq6F.mjs";import"./plugin-IW9SM9iO.mjs";import{getContext as n}from"@murumets-ee/core";function r(r){if(r===`_`)return;let i=n()?.availableLocales??e().locales.map(e=>e.code);for(let e of i)if(e===r)return;throw new t(r,[...i])}export{r as t};
2
- //# sourceMappingURL=locale-utils-CuIBKeaz.mjs.map
@@ -1,2 +0,0 @@
1
- import{a as e,i as t,s as n,u as r}from"./routing-strategy-DGx6Rq6F.mjs";import{n as i}from"./locks-5779ugSW.mjs";import{definePlugin as a,getContext as o}from"@murumets-ee/core";import{column as s,defineTable as c,schemaRegistry as l}from"@murumets-ee/db";import{eq as u,sql as d}from"drizzle-orm";const f=Symbol.for(`@murumets-ee/content:content-tables-registry`);function p(){let e=globalThis,t=e[f];return t||(t=new Map,e[f]=t),t}function m(e,t){let n=p(),r=n.get(e)??{};n.set(e,{...r,...t})}function h(e){return p().get(e)??{}}const g=Symbol.for(`@murumets-ee/content:locale-status-writer`);async function _(e,t,n,r){let i=r===`published`?new Date:null;await e.upsert({entityId:t,locale:n,status:r,publishedAt:i},{target:[`entityId`,`locale`],set:{status:r,publishedAt:i}})}function v(e){globalThis[g]=(t,n)=>{let{localeStatus:i}=h(t.name);if(i)return async(t,a,s)=>{let c=o()?.availableLocales??e;if(!c.includes(a))throw new r(a,[...c]);await _(i.makeClient(n),t,a,s)}}}function y(){return{async writeDraft({documentType:e,documentId:t,locale:n,dataPatch:r,user:i,tx:a}){let{drafts:o}=h(e);if(!o)return!1;let s=o.makeClient(a),c={...(await s.findOne({entityId:t,locale:n}))?.data??{},...r},l=new Date;return await s.upsert({entityId:t,locale:n,data:c,createdBy:i.id,createdByName:i.name??null,createdAt:l,updatedAt:l},{target:[`entityId`,`locale`],set:{data:c,updatedAt:l}}),!0},async hasAnyDraft({documentType:e,documentId:t,db:n}){let{drafts:r}=h(e);return r?r.makeClient(n).exists({entityId:t}):!1},async hasAnyLocaleActivity({documentType:e,documentId:t,db:n}){let{localeStatus:r}=h(e);if(r&&await r.makeClient(n).exists({entityId:t}))return!0;let i=l.get(`${e}_translations`);if(i){let e=i.entityId;if(e!==void 0&&(await n.select({one:d`1`}).from(i).where(u(e,t)).limit(1)).length>0)return!0}return!1}}}function b(e){return c({name:`${e.name}_drafts`,columns:{id:s.uuid({primaryKey:!0,defaultRandom:!0}),entityId:s.uuid({notNull:!0,pgName:`entity_id`}),locale:s.varchar({length:10,notNull:!0,default:`_`}),data:s.jsonb({notNull:!0}),createdBy:s.varchar({length:255,notNull:!0,pgName:`created_by`}),createdByName:s.varchar({length:255,pgName:`created_by_name`}),createdAt:s.timestamp({notNull:!0,defaultNow:!0,withTimezone:!0,pgName:`created_at`}),updatedAt:s.timestamp({notNull:!0,defaultNow:!0,withTimezone:!0,pgName:`updated_at`})},unique:[{on:[`entityId`,`locale`]}]})}function x(e){return c({name:`${e.name}_locale_status`,columns:{id:s.uuid({primaryKey:!0,defaultRandom:!0}),entityId:s.uuid({notNull:!0,pgName:`entity_id`}),locale:s.varchar({length:10,notNull:!0}),status:s.varchar({length:20,notNull:!0,default:`draft`}),publishedAt:s.timestamp({withTimezone:!0,pgName:`published_at`})},unique:[{on:[`entityId`,`locale`]}]})}function S(e){return c({name:`${e.name}_versions`,columns:{id:s.uuid({primaryKey:!0,defaultRandom:!0}),entityId:s.uuid({notNull:!0,pgName:`entity_id`}),version:s.integer({notNull:!0}),locale:s.varchar({length:10,notNull:!0,default:`_`}),data:s.jsonb({notNull:!0}),delta:s.jsonb(),status:s.varchar({length:20}),createdBy:s.varchar({length:255,pgName:`created_by`}),createdByName:s.varchar({length:255,pgName:`created_by_name`}),createdAt:s.timestamp({notNull:!0,defaultNow:!0,withTimezone:!0,pgName:`created_at`}),isAutosave:s.boolean({notNull:!0,default:!1,pgName:`is_autosave`})},unique:[{on:[`entityId`,`version`,`locale`]}]})}function C(r){if(!r.locales||r.locales.length===0)throw Error(`content() requires at least one locale`);if(!r.defaultLocale)throw Error(`content() requires a defaultLocale`);if(!r.locales.some(e=>e.code===r.defaultLocale))throw Error(`defaultLocale "${r.defaultLocale}" not found in locales: [${r.locales.map(e=>e.code).join(`, `)}]`);let o=r.autoTrash??!0,s=o===!1?void 0:{publishable:!0,...typeof o==`object`&&o.exclude?{exclude:o.exclude}:{}};return a({name:`@murumets-ee/content`,contributes:{"blocks.documentDraftStore":y(),...s?{"trash.autoEnable":s}:{}},shared:{settings:[n]},server:{entities:[e],tables:{toolkitContentLocks:i},init:async e=>{t(r);let n=0,i=0,a=0;for(let[t,r]of e.entities){if(r.behaviors?.some(e=>e.name===`versionable`)){let e=`${t}_versions`;if(!l.has(e)){let n=S(r);l.register(e,n.table),m(t,{versions:n})}n++}let e=r.behaviors?.some(e=>e.name===`publishable`),o=r.allFields,s=Object.values(o).some(e=>e.translatable);if(e&&s){let e=`${t}_locale_status`;if(!l.has(e)){let n=x(r);l.register(e,n.table),m(t,{localeStatus:n})}i++}if(e){let e=`${t}_drafts`;if(!l.has(e)){let n=b(r);l.register(e,n.table),m(t,{drafts:n})}a++}}v(r.locales.map(e=>e.code)),e.logger.info({locales:r.locales.map(e=>e.code),defaultLocale:r.defaultLocale,versionableEntities:n,localeStatusEntities:i,draftEntities:a},`Content plugin initialized`)}}})}export{_ as n,h as r,C as t};
2
- //# sourceMappingURL=plugin-IW9SM9iO.mjs.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"plugin-IW9SM9iO.mjs","names":["cols"],"sources":["../src/schema/registry.ts","../src/locale-status-slot.ts","../src/document-draft-store.ts","../src/schema/drafts.ts","../src/schema/locale-status.ts","../src/schema/versions.ts","../src/plugin.ts"],"sourcesContent":["/**\n * Per-entity content table registry.\n *\n * Maps an entity name to its content infrastructure tables — drafts,\n * versions, locale_status. Each entry stores the `DefinedTable` (from\n * `defineTable`), which exposes a typed `.makeClient(db)` for CRUD via\n * `TableClient` and a raw `.table` for legacy callers.\n *\n * The content plugin populates this registry at `init` time; ContentClient\n * reads from it lazily on first use. Tables that don't apply to an entity\n * (non-publishable entities have no drafts, etc.) are simply absent.\n */\n\nimport type { DraftsTable } from './drafts.js'\nimport type { LocaleStatusTable } from './locale-status.js'\nimport type { VersionsTable } from './versions.js'\n\n/**\n * The full set of optional content infra tables attached to an entity.\n * Every field is optional — entities without the relevant behavior don't\n * get the corresponding table.\n */\nexport interface ContentTables {\n /** `{entity}_drafts` — present when the entity has `publishable()`. */\n drafts?: DraftsTable\n /** `{entity}_versions` — present when the entity has `versionable()`. */\n versions?: VersionsTable\n /** `{entity}_locale_status` — present when the entity is publishable + has translatable fields. */\n localeStatus?: LocaleStatusTable\n}\n\n/**\n * The registry map lives on `globalThis` keyed by `Symbol.for(...)`, NOT as a\n * module-level binding — matching core's `PluginContributionsRegistry`\n * pattern. A module-level `Map` here is the exact Turbopack-dev footgun\n * CLAUDE.md documents (the createdBy/updatedBy bug class): a lazily-compiled\n * route chunk can resolve this module to a DIFFERENT instance than the boot\n * path that called `registerContentTables`, so `getContentTables()` returns\n * `{}` and callers silently take their no-table fallbacks. Observed in the\n * wild 2026-06-11 (F031 #1): the blocks op-commit route saw an empty registry,\n * `writeDraft` fell back to live-row writes, and Publish 500'd with \"No draft\n * found\" — flaky per-recompile, dev-only. `Symbol.for` is process-global, so\n * every duplicate module instance reads the same map.\n */\nconst REGISTRY_KEY = Symbol.for('@murumets-ee/content:content-tables-registry')\n\ninterface GlobalWithRegistry {\n [REGISTRY_KEY]?: Map<string, ContentTables>\n}\n\nfunction registryMap(): Map<string, ContentTables> {\n const g = globalThis as GlobalWithRegistry\n let map = g[REGISTRY_KEY]\n if (!map) {\n map = new Map<string, ContentTables>()\n g[REGISTRY_KEY] = map\n }\n return map\n}\n\n/**\n * Register content tables for an entity. Called from `content().init()`.\n *\n * Subsequent calls for the same entity name MERGE the provided patch into\n * the existing entry. This lets the plugin add tables one at a time (drafts,\n * then versions, then locale_status) without the caller needing to build the\n * whole object up front.\n */\nexport function registerContentTables(entityName: string, patch: ContentTables): void {\n const tables = registryMap()\n const existing = tables.get(entityName) ?? {}\n tables.set(entityName, { ...existing, ...patch })\n}\n\n/**\n * Get the content tables for an entity. Returns an empty object (rather\n * than undefined) when the entity has no registered tables — that\n * simplifies the common ContentClient lookup idiom:\n *\n * const { drafts } = getContentTables(entity.name)\n * if (!drafts) return null\n */\nexport function getContentTables(entityName: string): ContentTables {\n return registryMap().get(entityName) ?? {}\n}\n\n/**\n * Clear the registry. **Test-only.** Throws in production.\n */\nexport function clearContentTables(): void {\n if (process.env.NODE_ENV === 'production') {\n throw new Error(\n 'clearContentTables() is not allowed in production — this is a test-only utility.',\n )\n }\n registryMap().clear()\n}\n","/**\n * Process-global slot exposing the content plugin's per-locale publish\n * status writer to `AdminClient.updateForLocale` (SD021 — the\n * locale-write choke point).\n *\n * `@murumets-ee/core`'s `createAdminClient` reads the slot by symbol\n * identity when constructing a client, so EVERY framework-built\n * AdminClient routes non-default-locale `status` writes into\n * `<entity>_locale_status` automatically — future writers (import\n * pipelines, AI integration, workflow actions) cannot forget the\n * routing. Same `globalThis` + `Symbol.for` shape as queue's\n * `enqueue-on-commit-slot.ts`: survives HMR and avoids a dependency\n * edge from core onto content.\n *\n * NOTE: this module must stay free of `import 'server-only'` — it is in\n * the content plugin's `init` load path, which the `lumi` CLI loads in\n * plain Node (see memory: server-only in the CLI load path breaks every\n * command and only scaffold-e2e catches it).\n */\n\nimport { getContext } from '@murumets-ee/core'\nimport type { Entity } from '@murumets-ee/entity'\nimport type { LocaleStatusWriter } from '@murumets-ee/entity/admin'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\nimport { InvalidLocaleError } from './errors.js'\nimport type { LocaleStatusTable } from './schema/locale-status.js'\nimport { getContentTables } from './schema/registry.js'\n\nconst LOCALE_STATUS_WRITER_KEY = Symbol.for('@murumets-ee/content:locale-status-writer')\n\n/** The typed TableClient for a `{entity}_locale_status` table. */\ntype LocaleStatusClient = ReturnType<LocaleStatusTable['makeClient']>\n\n/**\n * Upsert one non-default locale's status row. The single implementation\n * shared by `setLocaleStatus` (content's own API) and the slot writer\n * below — keeping the `publishedAt` semantics in one place.\n *\n * `client` is the locale-status TableClient; callers resolve it from\n * `getContentTables(entity).localeStatus` (capability-gated).\n */\nexport async function upsertLocaleStatusRow(\n client: LocaleStatusClient,\n entityId: string,\n locale: string,\n status: 'draft' | 'published',\n): Promise<void> {\n const publishedAt = status === 'published' ? new Date() : null\n await client.upsert(\n { entityId, locale, status, publishedAt },\n { target: ['entityId', 'locale'], set: { status, publishedAt } },\n )\n}\n\n/**\n * Write the locale-status writer factory into the slot. Called from the\n * content plugin's `init` AFTER the per-entity `<entity>_locale_status`\n * tables are registered (the factory consults that registry per entity).\n *\n * `localeCodes` is the plugin's configured locale set (the shipped `content()`\n * defaults), passed in by init rather than read via `getContentConfig()` —\n * importing `locale-utils`/`plugin` here would close an import cycle (plugin →\n * slot → … → plugin; `verify:cycles` catches it). It is the **static fallback**\n * only; the write-time whitelist prefers the request-scoped effective set (below).\n *\n * The factory returns `undefined` for entities without a locale-status\n * table — `updateForLocale` then falls back to the base `status` column,\n * which is correct: for non-translatable publishables the default locale\n * is the only locale.\n */\nexport function setActiveLocaleStatusWriterFactory(localeCodes: readonly string[]): void {\n const factory = (entity: Entity, db: PostgresJsDatabase): LocaleStatusWriter | undefined => {\n const { localeStatus } = getContentTables(entity.name)\n if (!localeStatus) return undefined\n return async (entityId, locale, status) => {\n // Same runtime-aware whitelist as locale-utils' validateLocale (SD006), sans\n // the '_' sentinel (updateForLocale never sends it here): prefer the\n // request-scoped effective enabled codes so a locale added/enabled at runtime\n // can have translatable content published in it with no rebuild, falling back\n // to the init-time static set outside a request (CLI / build / tests).\n // Unvalidated locales must not mint locale_status rows no reader will ever merge.\n const allowed = getContext()?.availableLocales ?? localeCodes\n if (!allowed.includes(locale)) {\n throw new InvalidLocaleError(locale, [...allowed])\n }\n // The caller (core's createAdminClient) passes the SAME db handle the\n // AdminClient writes through — a client built against an explicit app\n // (tests, multi-app setups) must not write its locale-status rows\n // through the singleton app's connection.\n const client = localeStatus.makeClient(db)\n await upsertLocaleStatusRow(client, entityId, locale, status)\n }\n }\n ;(globalThis as Record<symbol, unknown>)[LOCALE_STATUS_WRITER_KEY] = factory\n}\n","/**\n * `DocumentDraftStore` implementation contributed to the block editor's\n * `blocks.documentDraftStore` slot (SD015).\n *\n * The block editor's op-commit route (in `@murumets-ee/blocks`) materializes a\n * `withOpLog()` document's working tree by calling this store inside its\n * commit transaction — so block edits land in the per-locale `{entity}_drafts`\n * buffer, never the live/public row. A later \"Publish\" (`publishDraft`)\n * promotes the draft to the live row + snapshots a version.\n *\n * blocks owns the slot type (imported `type`-only below — that import also\n * pulls the `PluginContributionSlots` augmentation into this compilation);\n * content owns the draft tables. No runtime dependency between the packages.\n *\n * NB: no `import 'server-only'` here. This module is imported by\n * `content/plugin.ts` at construct time (for the `contributes` slot), and the\n * content plugin must stay loadable in a plain-Node context (the `lumi migrate`\n * CLI loads `lumi.config.ts` without the `react-server` resolution condition,\n * where `server-only` throws). The store is only ever *called* server-side (by\n * the op-commit committer) and is never reachable from a client bundle.\n */\n\nimport type { DocumentDraftStore } from '@murumets-ee/blocks/server'\nimport { schemaRegistry } from '@murumets-ee/db'\nimport { eq, sql } from 'drizzle-orm'\nimport type { PgColumn } from 'drizzle-orm/pg-core'\nimport { getContentTables } from './schema/registry.js'\n\n/**\n * Build the draft store. Stateless — resolves the per-entity drafts table from\n * the schema registry at call time, so it composes with any app's entity set.\n */\nexport function createDocumentDraftStore(): DocumentDraftStore {\n return {\n async writeDraft({ documentType, documentId, locale, dataPatch, user, tx }) {\n const { drafts } = getContentTables(documentType)\n // No drafts table → the document type isn't publishable; signal the\n // caller to fall back to writing the live row.\n if (!drafts) return false\n\n // Bind the drafts TableClient to the committer's tx so the upsert joins\n // the same SERIALIZABLE transaction as the editor_op insert.\n const client = drafts.makeClient(tx)\n const existing = await client.findOne({ entityId: documentId, locale })\n // Merge onto any existing draft payload so a concurrent form-field draft\n // (saved via saveDraft) isn't clobbered by a block edit, and vice versa.\n const baseData = (existing?.data as Record<string, unknown> | undefined) ?? {}\n const data = { ...baseData, ...dataPatch }\n const now = new Date()\n\n await client.upsert(\n {\n entityId: documentId,\n locale,\n data,\n createdBy: user.id,\n createdByName: user.name ?? null,\n createdAt: now,\n updatedAt: now,\n },\n {\n target: ['entityId', 'locale'],\n // On conflict, only touch the mutable fields — preserve createdBy/createdAt.\n set: { data, updatedAt: now },\n },\n )\n return true\n },\n\n async hasAnyDraft({ documentType, documentId, db }) {\n const { drafts } = getContentTables(documentType)\n if (!drafts) return false\n // Any-locale existence probe — the orphan-draft GC (blocks PR 25)\n // spares a row when ANY locale still carries draft work.\n const client = drafts.makeClient(db)\n return client.exists({ entityId: documentId })\n },\n\n async hasAnyLocaleActivity({ documentType, documentId, db }) {\n // Per-locale publish status (publishDraft on a non-default locale\n // writes HERE, never the base row's status — pr-review H2).\n const { localeStatus } = getContentTables(documentType)\n if (localeStatus) {\n const client = localeStatus.makeClient(db)\n if (await client.exists({ entityId: documentId })) return true\n }\n // Translation rows (saveTranslation writes them directly, no hooks —\n // the base row's updatedAt never moves). Translation tables are\n // generated in @murumets-ee/entity; accessed via schemaRegistry +\n // direct Drizzle, same idiom as translation-status.ts.\n const translationTable = schemaRegistry.get(`${documentType}_translations`)\n if (translationTable) {\n const cols = translationTable as Record<string, PgColumn | undefined>\n const entityIdCol = cols.entityId\n if (entityIdCol !== undefined) {\n const rows = await db\n .select({ one: sql`1` })\n .from(translationTable)\n .where(eq(entityIdCol, documentId))\n .limit(1)\n if (rows.length > 0) return true\n }\n }\n return false\n },\n }\n}\n","/**\n * Draft overlay table schema generator\n *\n * Generates a {entity}_drafts table for entities with the publishable() behavior.\n * Stores a mutable work-in-progress snapshot that non-publishers edit instead of\n * writing directly to the live published row.\n *\n * One draft per entity+locale. Drafts are upserted, discarded, or promoted to live.\n *\n * Uses `defineTable` so ContentClient can operate on drafts through a typed\n * TableClient (see registry.ts). The migration pipeline uses the\n * entity-package generator (which emits FK references) — this one produces a\n * runtime-only table shape for read/write via TableClient.\n */\n\nimport { column, defineTable } from '@murumets-ee/db'\nimport type { Entity } from '@murumets-ee/entity'\n\n/**\n * Generate a `DefinedTable` for a publishable entity's drafts table.\n *\n * Schema:\n * {entity}_drafts (\n * id uuid PK,\n * entity_id uuid NOT NULL,\n * locale varchar(10) NOT NULL DEFAULT '_',\n * data jsonb NOT NULL, -- full entity snapshot\n * created_by varchar(255) NOT NULL, -- user ID (varchar, NOT uuid!)\n * created_by_name varchar(255),\n * created_at timestamptz NOT NULL DEFAULT now(),\n * updated_at timestamptz NOT NULL DEFAULT now(),\n * UNIQUE(entity_id, locale)\n * )\n */\nexport function generateDraftsTable(entity: Entity) {\n return defineTable({\n name: `${entity.name}_drafts`,\n columns: {\n id: column.uuid({ primaryKey: true, defaultRandom: true }),\n entityId: column.uuid({ notNull: true, pgName: 'entity_id' }),\n locale: column.varchar({ length: 10, notNull: true, default: '_' }),\n data: column.jsonb<Record<string, unknown>, true>({ notNull: true }),\n createdBy: column.varchar({ length: 255, notNull: true, pgName: 'created_by' }),\n createdByName: column.varchar({ length: 255, pgName: 'created_by_name' }),\n createdAt: column.timestamp({\n notNull: true,\n defaultNow: true,\n withTimezone: true,\n pgName: 'created_at',\n }),\n updatedAt: column.timestamp({\n notNull: true,\n defaultNow: true,\n withTimezone: true,\n pgName: 'updated_at',\n }),\n },\n unique: [{ on: ['entityId', 'locale'] }],\n })\n}\n\n/** Public type export — the full DefinedTable returned by the generator. */\nexport type DraftsTable = ReturnType<typeof generateDraftsTable>\n","/**\n * Per-locale publish status table schema generator\n *\n * Generates a {entity}_locale_status table for entities that have both\n * publishable() behavior AND translatable fields. This allows different\n * locales to have independent publish statuses.\n *\n * Fallback rule: no row for a locale = inherit base entity status.\n *\n * Uses `defineTable` so ContentClient can operate on rows through a typed\n * TableClient. The migration pipeline uses the entity-package generator\n * (which emits FK references); this one produces a runtime-only table shape\n * for read/write via TableClient.\n */\n\nimport { column, defineTable } from '@murumets-ee/db'\nimport type { Entity } from '@murumets-ee/entity'\n\n/**\n * Generate a `DefinedTable` for a publishable + translatable entity's\n * locale status table.\n *\n * Schema:\n * {entity}_locale_status (\n * id uuid PK,\n * entity_id uuid NOT NULL,\n * locale varchar(10) NOT NULL,\n * status varchar(20) NOT NULL DEFAULT 'draft',\n * published_at timestamptz,\n * UNIQUE(entity_id, locale)\n * )\n */\nexport function generateLocaleStatusTable(entity: Entity) {\n return defineTable({\n name: `${entity.name}_locale_status`,\n columns: {\n id: column.uuid({ primaryKey: true, defaultRandom: true }),\n entityId: column.uuid({ notNull: true, pgName: 'entity_id' }),\n locale: column.varchar({ length: 10, notNull: true }),\n status: column.varchar({ length: 20, notNull: true, default: 'draft' }),\n publishedAt: column.timestamp({ withTimezone: true, pgName: 'published_at' }),\n },\n unique: [{ on: ['entityId', 'locale'] }],\n })\n}\n\n/** Public type export — the full DefinedTable returned by the generator. */\nexport type LocaleStatusTable = ReturnType<typeof generateLocaleStatusTable>\n","/**\n * Version history table schema generator\n *\n * Generates a {entity}_versions table for entities with the versionable() behavior.\n * Each version stores a full snapshot + delta (changed fields only).\n *\n * Uses `defineTable` so ContentClient can operate on versions through a typed\n * TableClient. The migration pipeline uses the entity-package generator\n * (which emits FK references) — this one produces a runtime-only table shape\n * for read/write via TableClient.\n */\n\nimport { column, defineTable } from '@murumets-ee/db'\nimport type { Entity } from '@murumets-ee/entity'\n\n/**\n * Generate a `DefinedTable` for a versionable entity's versions table.\n *\n * Schema:\n * {entity}_versions (\n * id uuid PK,\n * entity_id uuid NOT NULL,\n * version integer NOT NULL,\n * locale varchar(10) NOT NULL DEFAULT '_', -- locale this snapshot belongs to\n * data jsonb NOT NULL, -- full snapshot (locale-merged view)\n * delta jsonb, -- only changed fields\n * status varchar(20), -- 'draft' | 'published' at time of save\n * created_by varchar(255),\n * created_by_name varchar(255),\n * created_at timestamptz NOT NULL DEFAULT now(),\n * is_autosave boolean NOT NULL DEFAULT false,\n * UNIQUE(entity_id, version, locale)\n * )\n *\n * Each locale has its own version sequence. The default locale uses '_'\n * as the locale value (distinguishable from actual locale codes).\n */\nexport function generateVersionsTable(entity: Entity) {\n return defineTable({\n name: `${entity.name}_versions`,\n columns: {\n id: column.uuid({ primaryKey: true, defaultRandom: true }),\n entityId: column.uuid({ notNull: true, pgName: 'entity_id' }),\n version: column.integer({ notNull: true }),\n locale: column.varchar({ length: 10, notNull: true, default: '_' }),\n data: column.jsonb<Record<string, unknown>, true>({ notNull: true }),\n delta: column.jsonb<Record<string, unknown>>(),\n status: column.varchar({ length: 20 }),\n createdBy: column.varchar({ length: 255, pgName: 'created_by' }),\n createdByName: column.varchar({ length: 255, pgName: 'created_by_name' }),\n createdAt: column.timestamp({\n notNull: true,\n defaultNow: true,\n withTimezone: true,\n pgName: 'created_at',\n }),\n isAutosave: column.boolean({ notNull: true, default: false, pgName: 'is_autosave' }),\n },\n unique: [{ on: ['entityId', 'version', 'locale'] }],\n })\n}\n\n/** Public type export — the full DefinedTable returned by the generator. */\nexport type VersionsTable = ReturnType<typeof generateVersionsTable>\n","/**\n * Content plugin — registers locale configuration and version schemas.\n *\n * Usage:\n * import { content } from '@murumets-ee/content/plugin'\n *\n * export default defineLumiConfig({\n * plugins: [\n * content({\n * locales: [\n * { code: 'en', label: 'English' },\n * { code: 'de', label: 'German' },\n * ],\n * defaultLocale: 'en',\n * }),\n * ],\n * })\n */\n\nimport { definePlugin, type TrashAutoEnablePolicy } from '@murumets-ee/core'\nimport { schemaRegistry } from '@murumets-ee/db'\nimport { setContentConfig } from './content-config-store.js'\nimport { createDocumentDraftStore } from './document-draft-store.js'\nimport { i18nSettings } from './i18n-settings.js'\nimport { Locale } from './locale-entity.js'\n\n// Re-export the config accessor from the leaf store so the `@murumets-ee/content/plugin`\n// subpath API is unchanged for existing consumers (admin-context, entity pages).\nexport { getContentConfig } from './content-config-store.js'\n// Re-export the i18n registry primitives from this jiti-safe subpath (all sources are\n// leaf: locale-entity → entity only, i18n-settings → settings/define, routing-strategy →\n// types only), so the admin-ui Languages editor route can import them WITHOUT dragging\n// content's `server-only` main entry through the config-load / bundle graph.\nexport { ROUTING_STRATEGIES, i18nSettings } from './i18n-settings.js'\nexport { Locale } from './locale-entity.js'\nexport {\n localePrefixForStrategy,\n prefixDefaultLocaleForStrategy,\n} from './routing-strategy.js'\nexport type { I18nConfig, LocaleDirection, LocaleEntry, RoutingStrategy } from './types.js'\nimport { setActiveLocaleStatusWriterFactory } from './locale-status-slot.js'\nimport { generateDraftsTable } from './schema/drafts.js'\nimport { generateLocaleStatusTable } from './schema/locale-status.js'\nimport { toolkitContentLocks } from './schema/locks.js'\nimport { registerContentTables } from './schema/registry.js'\nimport { generateVersionsTable } from './schema/versions.js'\nimport type { ContentConfig } from './types.js'\n\n/**\n * Content plugin factory.\n */\nexport function content(config: ContentConfig) {\n // Validate config\n if (!config.locales || config.locales.length === 0) {\n throw new Error('content() requires at least one locale')\n }\n\n if (!config.defaultLocale) {\n throw new Error('content() requires a defaultLocale')\n }\n\n const defaultExists = config.locales.some((l) => l.code === config.defaultLocale)\n if (!defaultExists) {\n throw new Error(\n `defaultLocale \"${config.defaultLocale}\" not found in locales: [${config.locales.map((l) => l.code).join(', ')}]`,\n )\n }\n\n // plan/trash SD002 — declare the auto-enable policy for the framework to\n // apply at boot (createApp injects `trashable()` into publishable entities\n // BEFORE schema-gen). Declarative, so it can't be forgotten and carries no\n // plugin-array-order hazard. `autoTrash` defaults ON.\n const autoTrash = config.autoTrash ?? true\n const trashPolicy: TrashAutoEnablePolicy | undefined =\n autoTrash === false\n ? undefined\n : {\n publishable: true,\n ...(typeof autoTrash === 'object' && autoTrash.exclude\n ? { exclude: autoTrash.exclude }\n : {}),\n }\n\n return definePlugin({\n name: '@murumets-ee/content',\n // SD015 — contribute the draft store the block editor's op-commit route\n // writes through. blocks owns the slot; content owns the {entity}_drafts\n // tables. Stateless; resolves the per-entity drafts table at call time.\n contributes: {\n 'blocks.documentDraftStore': createDocumentDraftStore(),\n // SD002 — only contribute when auto-trash is enabled (omitted → no policy\n // → createApp's applyTrashAutoEnable is a no-op for content).\n ...(trashPolicy ? { 'trash.autoEnable': trashPolicy } : {}),\n },\n // The `i18n` singletons (defaultLocale, routingStrategy) — a simple-config\n // namespace that auto-registers as a config type (D006/D009/SD006) and rides\n // export/import for free. Edited via the Languages editor (`hideFromMenu`).\n shared: {\n settings: [i18nSettings],\n },\n server: {\n // `Locale` — the content-locale registry config entity (D009/SD006). Declared\n // statically so the framework registers its table at merge (before init) and\n // auto-derives it into the ConfigTypeRegistry.\n entities: [Locale],\n // The shared locks table is registered declaratively via the\n // framework's merge step (#469). The per-entity tables below\n // (versions / layout / drafts / locale_status) are generated\n // dynamically from `app.entities` and stay imperatively\n // registered — they're not known at plugin-construct time.\n tables: { toolkitContentLocks },\n init: async (app) => {\n // Store config globally (leaf store — no schema imports)\n setContentConfig(config)\n\n let versionableCount = 0\n let localeStatusCount = 0\n let draftsCount = 0\n\n for (const [name, entity] of app.entities) {\n // Register version tables for versionable entities\n const hasVersionable = entity.behaviors?.some(\n (b: { name: string }) => b.name === 'versionable',\n )\n if (hasVersionable) {\n const versionsTableName = `${name}_versions`\n if (!schemaRegistry.has(versionsTableName)) {\n const versions = generateVersionsTable(entity)\n schemaRegistry.register(versionsTableName, versions.table)\n registerContentTables(name, { versions })\n }\n versionableCount++\n }\n\n // Register locale_status tables for publishable + translatable entities\n const isPublishable = entity.behaviors?.some(\n (b: { name: string }) => b.name === 'publishable',\n )\n const allFields = entity.allFields as Record<string, { translatable?: boolean }>\n const hasTranslatable = Object.values(allFields).some((f) => f.translatable)\n if (isPublishable && hasTranslatable) {\n const statusTableName = `${name}_locale_status`\n if (!schemaRegistry.has(statusTableName)) {\n const localeStatus = generateLocaleStatusTable(entity)\n schemaRegistry.register(statusTableName, localeStatus.table)\n registerContentTables(name, { localeStatus })\n }\n localeStatusCount++\n }\n\n // Register drafts table for publishable entities\n if (isPublishable) {\n const draftsTableName = `${name}_drafts`\n if (!schemaRegistry.has(draftsTableName)) {\n const drafts = generateDraftsTable(entity)\n schemaRegistry.register(draftsTableName, drafts.table)\n registerContentTables(name, { drafts })\n }\n draftsCount++\n }\n }\n\n // Expose the per-locale status writer to AdminClient.updateForLocale\n // (SD021). Must run AFTER the locale_status registration loop above —\n // the factory consults that registry per entity. This is capability\n // injection via a globalThis slot (queue's enqueue-on-commit\n // precedent), not contribution registration — no merge-step involved.\n setActiveLocaleStatusWriterFactory(config.locales.map((l) => l.code))\n\n app.logger.info(\n {\n locales: config.locales.map((l) => l.code),\n defaultLocale: config.defaultLocale,\n versionableEntities: versionableCount,\n localeStatusEntities: localeStatusCount,\n draftEntities: draftsCount,\n },\n 'Content plugin initialized',\n )\n },\n },\n })\n}\n"],"mappings":"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"}
@@ -1,2 +0,0 @@
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{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 c=[`prefix-all`,`no-prefix-default`,`cookie`],l=r({namespace:`i18n`,scope:`global`,label:`Languages`,iconName:`languages`,hideFromMenu:!0,schema:{defaultLocale:i.text({default:``}),routingStrategy:i.select({options:c,default:`no-prefix-default`})}}),u=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`}}),d=Symbol.for(`@murumets-ee/content:content-config`);function f(e){globalThis[d]=e}function p(){let e=globalThis[d];if(!e)throw Error(`@murumets-ee/content plugin not initialized. Add content() to your plugins array.`);return e}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{u as a,o as c,f as i,a as l,h as n,c as o,p as r,l as s,m as t,s as u};
2
- //# sourceMappingURL=routing-strategy-DGx6Rq6F.mjs.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"routing-strategy-DGx6Rq6F.mjs","names":[],"sources":["../src/errors.ts","../src/i18n-settings.ts","../src/locale-entity.ts","../src/content-config-store.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 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 * The `content()` plugin config store — a leaf module holding ONLY the globalThis\n * slot + accessors, with **no** schema / plugin imports.\n *\n * Extracted from `plugin.ts` (which also pulls `defineTable`-backed schema modules)\n * so that `locale-registry.ts` (the SD006 resolvers, re-exported from the **main entry**)\n * can read the shipped locales WITHOUT dragging the schema/table graph into it — that's\n * what stops `@murumets-ee/content`'s main index from transitively evaluating\n * `schema/locks.ts` at import time (the bug that broke the api-handler unit tests).\n *\n * `locale-utils.ts` (validateLocale) deliberately keeps importing `getContentConfig`\n * from `plugin.js`: it is NOT on the main-index import graph (only `content/query` +\n * `content/client`, both already `server-only`, so there's no bundle/jiti benefit), and\n * its consumers' unit tests mock `plugin.js` — repointing it here would ripple through\n * ~7 test mocks for no runtime gain.\n */\n\nimport type { ContentConfig } from './types.js'\n\n/**\n * The active config lives on `globalThis` keyed by `Symbol.for(...)`, NOT in a\n * module-level binding — same Turbopack-dev module-duplication hazard as the\n * content-tables registry (see schema/registry.ts): a lazily-compiled route chunk\n * can get a SECOND instance of this module whose `let` was never assigned, and\n * `getContentConfig()` throws \"not initialized\" on a perfectly initialized app.\n * `Symbol.for` keys are process-global, so every duplicate instance reads the same\n * slot. (pr-review pass-1 M4 — load-bearing on the publish + locale-routing path.)\n */\nconst CONFIG_KEY = Symbol.for('@murumets-ee/content:content-config')\n\ninterface GlobalWithContentConfig {\n [CONFIG_KEY]?: ContentConfig\n}\n\n/** Store the resolved content config (called once by the plugin's `init`). */\nexport function setContentConfig(config: ContentConfig): void {\n ;(globalThis as GlobalWithContentConfig)[CONFIG_KEY] = config\n}\n\n/**\n * Get the content configuration. Throws if the plugin hasn't initialized.\n */\nexport function getContentConfig(): ContentConfig {\n const config = (globalThis as GlobalWithContentConfig)[CONFIG_KEY]\n if (!config) {\n throw new Error(\n '@murumets-ee/content plugin not initialized. Add content() to your plugins array.',\n )\n }\n return config\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":"uJAaA,IAAa,EAAb,cAA2C,KAAM,CAC/C,WACA,WAEA,YACE,EACA,EACA,EACA,CACA,MACE,GACE,WAAW,EAAW,qBAAqB,EAAW,wEAE1D,EACA,KAAK,KAAO,wBACZ,KAAK,WAAa,EAClB,KAAK,WAAa,CACpB,CACF,EAWa,EAAb,cAAwC,KAAM,CAC5C,SACA,OAEA,YAAY,EAAkB,EAAgB,CAG5C,MACE,IAAW,IACP,qEACA,2CAA2C,EAAO,8BACxD,EACA,KAAK,KAAO,qBACZ,KAAK,SAAW,EAChB,KAAK,OAAS,CAChB,CACF,EAWa,EAAb,cAAwC,KAAM,CAC5C,OACA,QAEA,YAAY,EAAgB,EAA4B,CACtD,MACE,mBAAmB,EAAO,0BAA0B,EAAQ,KAAK,IAAI,EAAE,mEAEzE,EACA,KAAK,KAAO,qBACZ,KAAK,OAAS,EACd,KAAK,QAAU,CACjB,CACF,ECjEA,MAAa,EAAqB,CAAC,aAAc,oBAAqB,QAAQ,EAEjE,EAAe,EAAe,CACzC,UAAW,OACX,MAAO,SACP,MAAO,YACP,SAAU,YAEV,aAAc,GACd,OAAQ,CAMN,cAAe,EAAQ,KAAK,CAAE,QAAS,EAAG,CAAC,EAK3C,gBAAiB,EAAQ,OAAO,CAC9B,QAAS,EACT,QAAS,mBACX,CAAC,CACH,CACF,CAAC,ECfY,EAAS,EAAa,CACjC,KAAM,SACN,MAAO,SACP,MAAO,SACP,OAAQ,CACN,KAAM,cACN,OAAQ,gBACR,OAAQ,gBACR,OAAQ,eACV,EACA,OAAQ,CAON,KAAM,EAAM,KAAK,CAAE,SAAU,GAAM,OAAQ,GAAM,UAAW,EAAG,CAAC,EAEhE,MAAO,EAAM,KAAK,CAAE,SAAU,GAAM,UAAW,EAAG,CAAC,EAMnD,QAAS,EAAM,QAAQ,CAAE,QAAS,EAAK,CAAC,EAGxC,UAAW,EAAM,OAAO,CAAE,QAAS,CAAC,MAAO,KAAK,EAAY,QAAS,KAAM,CAAC,EAE5E,UAAW,EAAM,OAAO,CAAE,QAAS,CAAE,CAAC,CACxC,EACA,UAAW,CAAC,EAAS,UAAU,CAAC,EAChC,MAAO,CAGL,OAAQ,GACR,MAAO,YACP,cAAe,UACjB,CACF,CAAC,ECvCK,EAAa,OAAO,IAAI,qCAAqC,EAOnE,SAAgB,EAAiB,EAA6B,CAC3D,WAAwC,GAAc,CACzD,CAKA,SAAgB,GAAkC,CAChD,IAAM,EAAU,WAAuC,GACvD,GAAI,CAAC,EACH,MAAU,MACR,mFACF,EAEF,OAAO,CACT,CCxBA,SAAgB,EACd,EACkC,CAClC,OAAQ,EAAR,CACE,IAAK,aACH,MAAO,SACT,IAAK,oBACH,MAAO,YACT,IAAK,SACH,MAAO,OACX,CACF,CAOA,SAAgB,EAA+B,EAAoC,CACjF,OAAO,IAAa,YACtB"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"routing-strategy-TPSzrapD.d.mts","names":[],"sources":["../src/i18n-settings.ts","../src/locale-entity.ts","../src/routing-strategy.ts"],"mappings":";;;;;;AAiBA;;;;AAAwF;AAExF;;;;;;cAFa,kBAAA;AAAA,cAEA,YAAA,yCAAY,kBAAA;;;;;;;;;;ACQzB;;;;;;;;;;;;;ADVA;;;;AAAwF;AAExF;;;;;;;;;;;;;;;;cCQa,MAAA,gCAAM,MAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBCDH,uBAAA,CACd,QAAyB,EAAf,eAAe;ADA3B;;;;;AAAA,iBCiBgB,8BAAA,CAA+B,QAAyB,EAAf,eAAe"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"types-CUw2Pf0Y.d.mts","names":[],"sources":["../src/types.ts"],"mappings":";;AAQA;;UAAiB,YAAA;EACf,IAAA;EACA,KAAK;AAAA;;KAIK,eAAA;;AAAe;AAQ3B;;;;KAAY,eAAA;AAUZ;;;;;;;;AAAA,UAAiB,WAAA;EACf,IAAA;EACA,KAAA;EACA,OAAA;EACA,SAAA,EAAW,eAAe;EAC1B,SAAA;AAAA;;;;;;AAWgC;UAFjB,UAAA;EACf,aAAA;EACA,eAAA,EAAiB,eAAe;AAAA;AAAA,UAGjB,aAAA;EACf,OAAA,EAAS,YAAY;EACrB,aAAA;EAcA;;;AAA+B;AAOjC;;;;AAIU;AAOV;;;;EAlBE,SAAA;IAAwB,OAAA;EAAA;AAAA;AAAA,UAOT,kBAAA;EAqBA;EAnBf,SAAA;EAS4B;EAP5B,QAAQ;AAAA;AAAA,UAOO,YAAA,eAA2B,MAAA,oBAA0B,MAAA;EACpE,EAAA;EACA,QAAA;EACA,OAAA;EACA,MAAA;EACA,IAAA,EAAM,KAAA;EACN,KAAA,EAAO,OAAA,CAAQ,KAAA;EACf,MAAA;EACA,SAAA;EACA,aAAA;EACA,SAAA,EAAW,IAAA;EACX,UAAA;AAAA;AAAA,UAae,sBAAA;EACf,QAAA;EACA,KAAA;EACA,UAAA;EACA,OAAA;AAAA;AAAA,UAGe,iBAAA;EAAA,CACd,MAAA,WAAiB,sBAAsB;AAAA;AAAA,UAGzB,iBAAA;EACf,KAAA;EACA,QAAA;EACA,OAAA;EACA,OAAA;AAAA;AAAA,UAOe,mBAAA;EACf,MAAA;EACA,MAAA;EACA,WAAA,EAAa,IAAI;AAAA;AAAA,UAOF,UAAA,eAAyB,MAAA,oBAA0B,MAAA;EAClE,EAAA;EACA,QAAA;EACA,MAAA;EACA,IAAA,EAAM,KAAA;EACN,SAAA;EACA,aAAA;EACA,SAAA,EAAW,IAAA;EACX,SAAA,EAAW,IAAA;AAAA;AAAA,UAOI,QAAA;EACf,QAAA;EACA,YAAA;EACA,QAAA,EAAU,IAAA;EACV,SAAA,EAAW,IAAI;AAAA"}