@murumets-ee/content 0.0.0-preview-20261014110621

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 (111) hide show
  1. package/LICENSE +94 -0
  2. package/README.md +15 -0
  3. package/dist/admin.d.mts +56 -0
  4. package/dist/admin.d.mts.map +1 -0
  5. package/dist/admin.mjs +1 -0
  6. package/dist/block-resolver.d.mts +320 -0
  7. package/dist/block-resolver.d.mts.map +1 -0
  8. package/dist/block-resolver.mjs +2 -0
  9. package/dist/block-resolver.mjs.map +1 -0
  10. package/dist/breadcrumbs-BqSuMNac.mjs +2 -0
  11. package/dist/breadcrumbs-BqSuMNac.mjs.map +1 -0
  12. package/dist/chrome-entity-names-zYJzP-_U.mjs +2 -0
  13. package/dist/chrome-entity-names-zYJzP-_U.mjs.map +1 -0
  14. package/dist/chrome-provider.d.mts +143 -0
  15. package/dist/chrome-provider.d.mts.map +1 -0
  16. package/dist/chrome-provider.mjs +2 -0
  17. package/dist/chrome-provider.mjs.map +1 -0
  18. package/dist/client.d.mts +89 -0
  19. package/dist/client.d.mts.map +1 -0
  20. package/dist/client.mjs +2 -0
  21. package/dist/client.mjs.map +1 -0
  22. package/dist/content-config-store-8omQF5p5.mjs +2 -0
  23. package/dist/content-config-store-8omQF5p5.mjs.map +1 -0
  24. package/dist/context-D7eEItCY.mjs +2 -0
  25. package/dist/context-D7eEItCY.mjs.map +1 -0
  26. package/dist/detail-template-names-CD1Y9n1U.mjs +2 -0
  27. package/dist/detail-template-names-CD1Y9n1U.mjs.map +1 -0
  28. package/dist/detail-template-provider.d.mts +28 -0
  29. package/dist/detail-template-provider.d.mts.map +1 -0
  30. package/dist/detail-template-provider.mjs +2 -0
  31. package/dist/detail-template-provider.mjs.map +1 -0
  32. package/dist/document-site-B8tmEqLw.mjs +2 -0
  33. package/dist/document-site-B8tmEqLw.mjs.map +1 -0
  34. package/dist/entities/index.d.mts +64 -0
  35. package/dist/entities/index.d.mts.map +1 -0
  36. package/dist/entities/index.mjs +2 -0
  37. package/dist/entities/index.mjs.map +1 -0
  38. package/dist/enumerate-CYHJKQz6.mjs +2 -0
  39. package/dist/enumerate-CYHJKQz6.mjs.map +1 -0
  40. package/dist/errors-NRNILhhm.mjs +2 -0
  41. package/dist/errors-NRNILhhm.mjs.map +1 -0
  42. package/dist/index-cT8Qk4gu.d.mts +275 -0
  43. package/dist/index-cT8Qk4gu.d.mts.map +1 -0
  44. package/dist/index.d.mts +5 -0
  45. package/dist/index.mjs +2 -0
  46. package/dist/index.mjs.map +1 -0
  47. package/dist/locale-entity-CoSq3C77.mjs +2 -0
  48. package/dist/locale-entity-CoSq3C77.mjs.map +1 -0
  49. package/dist/locale-registry-COsJrVZl.mjs +2 -0
  50. package/dist/locale-registry-COsJrVZl.mjs.map +1 -0
  51. package/dist/locale-utils-B0mFoV47.mjs +2 -0
  52. package/dist/locale-utils-B0mFoV47.mjs.map +1 -0
  53. package/dist/lock-service.d.mts +58 -0
  54. package/dist/lock-service.d.mts.map +1 -0
  55. package/dist/lock-service.mjs +2 -0
  56. package/dist/lock-service.mjs.map +1 -0
  57. package/dist/locks-5779ugSW.mjs +2 -0
  58. package/dist/locks-5779ugSW.mjs.map +1 -0
  59. package/dist/plugin-CjgqBSFy.mjs +2 -0
  60. package/dist/plugin-CjgqBSFy.mjs.map +1 -0
  61. package/dist/plugin.d.mts +41 -0
  62. package/dist/plugin.d.mts.map +1 -0
  63. package/dist/plugin.mjs +1 -0
  64. package/dist/query-client.d.mts +131 -0
  65. package/dist/query-client.d.mts.map +1 -0
  66. package/dist/query-client.mjs +2 -0
  67. package/dist/query-client.mjs.map +1 -0
  68. package/dist/registry-CaPbY5lm.mjs +2 -0
  69. package/dist/registry-CaPbY5lm.mjs.map +1 -0
  70. package/dist/resolve-context-DklN53BH.mjs +2 -0
  71. package/dist/resolve-context-DklN53BH.mjs.map +1 -0
  72. package/dist/resolve.d.mts +651 -0
  73. package/dist/resolve.d.mts.map +1 -0
  74. package/dist/resolve.mjs +9 -0
  75. package/dist/resolve.mjs.map +1 -0
  76. package/dist/rolldown-runtime-DK3Fl9T5.mjs +1 -0
  77. package/dist/routes-siXmjKDA.mjs +2 -0
  78. package/dist/routes-siXmjKDA.mjs.map +1 -0
  79. package/dist/routing-strategy-CAG3tZP0.d.mts +155 -0
  80. package/dist/routing-strategy-CAG3tZP0.d.mts.map +1 -0
  81. package/dist/routing-strategy-D6B4ngT9.mjs +2 -0
  82. package/dist/routing-strategy-D6B4ngT9.mjs.map +1 -0
  83. package/dist/schema-BYrYzsmO.mjs +2 -0
  84. package/dist/schema-BYrYzsmO.mjs.map +1 -0
  85. package/dist/schema-LlK6EkOJ.mjs +2 -0
  86. package/dist/schema-LlK6EkOJ.mjs.map +1 -0
  87. package/dist/seed.d.mts +177 -0
  88. package/dist/seed.d.mts.map +1 -0
  89. package/dist/seed.mjs +2 -0
  90. package/dist/seed.mjs.map +1 -0
  91. package/dist/site-chrome-BAtQaAqC.mjs +2 -0
  92. package/dist/site-chrome-BAtQaAqC.mjs.map +1 -0
  93. package/dist/site-chrome-DNCcePSR.d.mts +98 -0
  94. package/dist/site-chrome-DNCcePSR.d.mts.map +1 -0
  95. package/dist/translation-provenance-B41lEDM-.mjs +2 -0
  96. package/dist/translation-provenance-B41lEDM-.mjs.map +1 -0
  97. package/dist/translation-provenance-BZ0MlbFR.d.mts +253 -0
  98. package/dist/translation-provenance-BZ0MlbFR.d.mts.map +1 -0
  99. package/dist/translation.d.mts +144 -0
  100. package/dist/translation.d.mts.map +1 -0
  101. package/dist/translation.mjs +2 -0
  102. package/dist/translation.mjs.map +1 -0
  103. package/dist/types-BOk9tg_9.d.mts +150 -0
  104. package/dist/types-BOk9tg_9.d.mts.map +1 -0
  105. package/dist/versionable-Br_G5VGR.mjs +2 -0
  106. package/dist/versionable-Br_G5VGR.mjs.map +1 -0
  107. package/dist/versionable-CRvV87nZ.d.mts +18 -0
  108. package/dist/versionable-CRvV87nZ.d.mts.map +1 -0
  109. package/dist/versions-w2ui-Vzb.mjs +2 -0
  110. package/dist/versions-w2ui-Vzb.mjs.map +1 -0
  111. package/package.json +93 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schema-BYrYzsmO.mjs","names":[],"sources":["../src/paths/apply-path.ts","../src/schema/site-partition.ts","../src/paths/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 * WHY a (document, locale) has no computed path — carried alongside the\n * `skipped-null` outcome so a caller/operator can tell \"this row has no path\n * by design\" from \"this row should have had one\" (F075). Produced by\n * {@link import('./generate-path.js').computeCanonicalPath}, except\n * `row-unreadable`, which the writer sets itself when it could not even READ\n * the row (see `createPathIndexWriter` in `writers.ts`).\n *\n * - `not-routable` — `entity.frontend?.path` is undefined. Defensive: nothing\n * should call `computeCanonicalPath` for a non-routable entity in the\n * first place, so this reason firing at all is itself the anomaly.\n * - `no-pattern-for-locale` — `resolvePattern` has no pattern for this\n * locale. EXPECTED for an entity that is intentionally routable in only\n * some locales.\n * - `parent-not-indexed` — the pattern needs `[parent.path]`, the row has a\n * parent, but the parent has no canonical `toolkit_paths` row YET.\n * Ordering-sensitive (PR23's collection-order defect produced exactly\n * this) — a well-ordered write should not hit it in steady state, so it is\n * treated as noteworthy rather than expected.\n * - `unresolved-token` — `buildEntityHref` returned null (a required token,\n * e.g. `[slug]`, has no value yet). EXPECTED for a document mid-creation.\n * - `row-unreadable` — the writer's recompute read came back `null` (the row\n * is trashed, gone, or — before F074's fix — the caller lacked `view` on\n * it). Always noteworthy: a row that legitimately has no path never\n * reaches this branch, because there is no row to read a path FOR.\n */\nexport type PathSkipReason =\n | 'not-routable'\n | 'no-pattern-for-locale'\n | 'parent-not-indexed'\n | 'unresolved-token'\n | 'row-unreadable'\n\n/**\n * `skipReason`s worth a `logger.warn`. The other reasons are legitimate,\n * everyday states — a document mid-creation with no slug yet, an entity\n * routable in only some locales — and warning on every one of THOSE would\n * train operators to ignore the message, which is worse than the silence\n * F075 replaces (CLAUDE.md \"negative-test every gate\": a check nobody reads\n * is not a check). These are the reasons a WELL-FORMED write should never\n * produce, so seeing one at all is the signal.\n */\nconst NOTEWORTHY_SKIP_REASONS: ReadonlySet<PathSkipReason> = new Set([\n 'not-routable',\n 'parent-not-indexed',\n 'row-unreadable',\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` — no path was available for this (document, locale) — see\n * {@link ApplyPathResult.skipReason} for WHY.\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 * WHY the outcome is `skipped-null` (F075) — `null` for every other\n * outcome, and also `null` for a `skipped-null` whose caller didn't supply\n * a reason (older/test call sites; treated as \"nothing to report\").\n */\n skipReason: PathSkipReason | 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 /**\n * WHY `path` is `null` (F075) — required when `path` is `null` so the\n * `skipped-null` outcome is never reported with no explanation; omit\n * entirely when `path` is non-null; it is ignored either way.\n */\n skipReason?: PathSkipReason\n /**\n * Reasons this CALLER already knows are transient in its own context, so a\n * hit should not warn even though {@link NOTEWORTHY_SKIP_REASONS} would\n * normally treat it as one. The one caller that needs this today:\n * `runPathsRebuild`'s multi-pass convergence sweep scans documents in\n * ascending id (uncorrelated with hierarchy depth — see its own docblock),\n * so on a genuinely COLD `toolkit_paths` index a descendant scanned before\n * its not-yet-created ancestor legitimately hits `parent-not-indexed` on\n * pass 1 and self-heals by the ancestor's own pass. `runPathsCascade`'s BFS\n * is ancestor-first by construction and the entity-hook writer never\n * recomputes an unindexed ancestor's descendant in the same call, so\n * NEITHER passes this — for them, `parent-not-indexed` staying noteworthy\n * is exactly the signal that caught PR23's own collection-order defect.\n */\n suppressNoteworthy?: readonly PathSkipReason[]\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 // F075 — a `skipped-null` used to leave no trace at all. Most reasons are\n // legitimate everyday states (see NOTEWORTHY_SKIP_REASONS's docblock) and\n // stay silent; the reasons a well-formed write should never hit get a\n // `warn` so an operator debugging \"this page is a 404\" has somewhere to\n // look instead of reasoning about the code from scratch.\n const skipReason = args.skipReason ?? null\n const isSuppressed = skipReason !== null && (args.suppressNoteworthy?.includes(skipReason) ?? false)\n if (skipReason !== null && !isSuppressed && NOTEWORTHY_SKIP_REASONS.has(skipReason)) {\n logger.warn(\n { entity: entityType, id: entityId, locale, skipReason },\n `toolkit_paths: no canonical path computed for this (document, locale) — ${skipReason}. ` +\n 'The document has no toolkit_paths row and will 404 on the public site until this is ' +\n 'resolved (fix the input and re-save, or run `lumi paths:rebuild`).',\n )\n }\n return { outcome: 'skipped-null', oldPath: null, newPath: null, conflictWith: null, skipReason }\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 {\n outcome: 'skipped-reserved',\n oldPath: null,\n newPath: path,\n conflictWith: null,\n skipReason: null,\n }\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, skipReason: 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 skipReason: null,\n }\n }\n return {\n outcome: currentCanonical === null ? 'created' : 'renamed',\n oldPath,\n newPath: path,\n conflictWith: null,\n skipReason: 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, skipReason: null }\n }\n\n if (currentCanonical === null) {\n return { outcome: 'created', oldPath: null, newPath: path, conflictWith: null, skipReason: 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, skipReason: 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 * Site-partitioned tables (plan `multisite` D003, phase 03).\n *\n * Two kinds of table are kept per site but are read and written through table\n * clients rather than through `AdminClient`, so the entity firewall's site\n * filter never sees them:\n *\n * - the companions of a site-bound entity — `<entity>_drafts`,\n * `<entity>_versions`, `<entity>_locale_status` ({@link defineCompanionTable});\n * - content's per-site infrastructure — `toolkit_paths` and\n * `toolkit_redirects` ({@link defineSitePartitionedTable}).\n *\n * Both get the same treatment: a `_site` column, every unique leading with it\n * (matching the DDL `lumi migrate` emits), and `partitionColumn: '_site'` — so\n * `makeClient(db)` refuses to build a client without a site, and the one it\n * builds confines every read and stamps every write. Forgetting the site is an\n * error, never a wider result.\n *\n * Shared entities' companions get exactly the table they always had.\n */\n\nimport { type ColumnFactory, column, defineTable, type TableDefinition } from '@murumets-ee/db'\nimport { type Entity, isSiteBound } from '@murumets-ee/entity'\n\n/**\n * `@murumets-ee/entity`'s `SITE_COLUMN` / `SITE_HANDLE_MAX_LENGTH`, restated:\n * `toolkit_paths` and `toolkit_redirects` are defined at MODULE LOAD, and\n * reading the entity constants there would make every importer of this\n * package's route modules evaluate the entity package's exports eagerly. The\n * equality is pinned by `site-partition.test.ts`.\n */\nexport const SITE_PARTITION_COLUMN = '_site'\nexport const SITE_PARTITION_LENGTH = 64\n\ntype PartitionableDef<TCols extends Record<string, ColumnFactory>> = Pick<\n TableDefinition<TCols>,\n 'name' | 'columns' | 'unique' | 'indexes'\n>\n\n/**\n * Define a table partitioned by site. The `_site` column is reached only BY\n * NAME, through the client's partition — no caller ever names it — so the\n * typed surface a caller sees is the declared columns.\n */\nexport function defineSitePartitionedTable<const TCols extends Record<string, ColumnFactory>>(\n def: PartitionableDef<TCols>,\n): ReturnType<typeof defineTable<TCols>> {\n const withSite: TableDefinition<Record<string, ColumnFactory>> = {\n name: def.name,\n columns: {\n ...def.columns,\n [SITE_PARTITION_COLUMN]: column.varchar({ length: SITE_PARTITION_LENGTH, notNull: true }),\n },\n unique: (def.unique ?? []).map((u) => ({\n ...u,\n on: [SITE_PARTITION_COLUMN, ...u.on.map(String)],\n })),\n ...(def.indexes !== undefined && {\n indexes: def.indexes.map((i) => ({ ...i, on: i.on.map(String) })),\n }),\n partitionColumn: SITE_PARTITION_COLUMN,\n }\n // The one place the hidden `_site` column is stated — see the docblock.\n return defineTable(withSite) as unknown as ReturnType<typeof defineTable<TCols>>\n}\n\n/** A companion table of `entity` — partitioned by site when the entity is bound. */\nexport function defineCompanionTable<const TCols extends Record<string, ColumnFactory>>(\n entity: Entity,\n def: Pick<TableDefinition<TCols>, 'name' | 'columns' | 'unique'>,\n): ReturnType<typeof defineTable<TCols>> {\n if (!isSiteBound(entity)) return defineTable(def)\n return defineSitePartitionedTable(def)\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 } from '@murumets-ee/db'\nimport { sql } from 'drizzle-orm'\nimport { defineSitePartitionedTable } from '../schema/site-partition.js'\n\n/**\n * Per site (plan `multisite` phase 03, D005): the table is partitioned by\n * `_site` — every unique below leads with it, so two sites may each own `/` and\n * `/retseptid`, and a document's redirect history lives in its site. A client\n * cannot be built without a site (`makeClient(db, site)`); `./site-scope.ts`\n * holds the request-scoped readers every resolution door uses.\n */\nexport const toolkitPathsTable = defineSitePartitionedTable({\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"],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ import{a as e}from"./schema-BYrYzsmO.mjs";import{buildEntityHref as t,matchReservedPrefix as n,resolvePattern as r}from"@murumets-ee/entity";import{column as i}from"@murumets-ee/db";import{sql as a}from"drizzle-orm";var o=class extends Error{entityName;locale;path;conflictingEntityId;constructor(e,t,n,r,i){super(i??`Path "${n}" (locale "${t}") is already used by another ${e} document. Choose a different slug, or set this document's slug manually.`),this.name=`PathConflictError`,this.entityName=e,this.locale=t,this.path=n,this.conflictingEntityId=r}},s=class extends Error{entityName;path;prefix;constructor(e,t,n){super(`Path "${t}" falls under the reserved prefix "${n}" and cannot be used as a public URL for a ${e} document.`),this.name=`PathReservedPrefixError`,this.entityName=e,this.path=t,this.prefix=n}};async function c(e){let n=e.entity.frontend?.path;if(n===void 0)return{path:null,skipReason:`not-routable`};let i=r(n,e.locale,e.defaultLocale);if(i===null)return{path:null,skipReason:`no-pattern-for-locale`};let a=i;if(typeof i==`string`&&i.includes(`[parent.path]`)){let t=e.row.parent;if(typeof t==`string`&&t.length>0){let n=await e.pathsClient.findOne({entityType:e.entity.name,entityId:t,locale:e.locale,isCanonical:!0});if(!n)return{path:null,skipReason:`parent-not-indexed`};a=i.replaceAll(`[parent.path]`,n.path)}else a=i.replaceAll(`[parent.path]`,``)}let o=t(a,e.row,e.locale,e.defaultLocale);return o===null?{path:null,skipReason:`unresolved-token`}:{path:l(o)}}function l(e){let t=e.replace(/\/{2,}/g,`/`);return t.length>1&&t.endsWith(`/`)?t.slice(0,-1):t}async function u(e){let{entity:t,data:r,currentRow:i,id:a,locale:l,defaultLocale:u,slugMode:f,reservedPathPrefixes:p,pathsClient:m}=e,h=``;for(let e=0;e<50;e++){let e={...i??{},...r},g=await c({entity:t,row:e,locale:l,defaultLocale:u,pathsClient:m});if(g.path===null)return;let _=g.path;h=_;let v=n(_,p);if(v!==void 0)throw new s(t.name,_,v);let y=await m.findOne({locale:l,path:_,isCanonical:!0}),b=y!=null&&y.entityType===t.name&&y.entityId===a;if(y==null||b)return;if(f===`manual`)throw new o(t.name,l,_,y.entityId);let x=e.slug;if(typeof x!=`string`||x.length===0)throw new o(t.name,l,_,y.entityId);r.slug=d(x)}throw new o(t.name,l,h,void 0,`Could not find a free path for a ${t.name} document near "${h}" (locale "${l}") after 50 attempts — choose a different slug manually.`)}function d(e){let t=/^(.*)-(\d+)$/.exec(e);return t?`${t[1]??e}-${Number.parseInt(t[2]??`1`,10)+1}`:`${e}-2`}async function f(e){let{pathsClient:t,locale:n,path:r,logger:i}=e,a=await t.findMany({where:{locale:n,path:r},orderBy:[{column:`updatedAt`,dir:`desc`}],limit:10});for(let e of a){let r=await t.findOne({entityType:e.entityType,entityId:e.entityId,locale:n,isCanonical:!0});if(r)return{entityType:e.entityType,entityId:e.entityId,currentPath:r.path}}return a.length===10&&i?.warn?.({locale:n,path:r,examined:a.length},`toolkit_paths: redirect lookup examined the full candidate window without resolving one — answering 404, but a further candidate may exist`),null}const p=5;async function m(e,t,n,r){return(await r.pathsClient.findOne({entityType:e,entityId:t,locale:n,isCanonical:!0}))?.path??null}function h(e){return{async isPublished(t,n,r){let i=e.entityResolver(t);return i===void 0?!1:await e.queryClientFactory(i).findById(n,{locale:r})!=null}}}function g(e,t,n){return v(e,t,n,(e,t,r)=>n.publishChecker.isPublished(e,t,r))}function _(e,t,n){return v(e,t,n,async()=>!0)}async function v(e,t,n,r){let i=l(e),a=e=>n.isRoutable?.(e)??!0,o=await n.homepageReader();if(i===`/`)return o===null||!await r(o.entity,o.id,t)?{kind:`none`}:{kind:`document`,entityType:o.entity,id:o.id};let s=await n.pathsClient.findOne({locale:t,path:i,isCanonical:!0});if(s)return!a(s.entityType)||!await r(s.entityType,s.entityId,t)?{kind:`none`}:S(o,s.entityType,s.entityId)?{kind:`redirect`,to:`/`,status:301}:{kind:`document`,entityType:s.entityType,id:s.entityId};let c=await y(i,t,n,o,a);if(c!==null)return c;let u=await f({pathsClient:n.pathsClient,locale:t,path:i,...n.logger!==void 0&&{logger:n.logger}});return u===null||!a(u.entityType)||!await r(u.entityType,u.entityId,t)?{kind:`none`}:S(o,u.entityType,u.entityId)?{kind:`redirect`,to:`/`,status:301}:{kind:`redirect`,to:u.currentPath,status:301}}async function y(e,t,n,r,i){let a=await b(n.redirectsClient,e,t);if(a===null)return null;for(let e=0;e<5;e++){let e=a.target,o=C(a.status);if(e.kind===`url`)return{kind:`redirect`,to:e.url,status:o};if(e.kind===`entity`){if(S(r,e.entity,e.id))return{kind:`redirect`,to:`/`,status:o};if(!i(e.entity))return{kind:`none`};let a=await m(e.entity,e.id,t,n);return a===null?{kind:`none`}:{kind:`redirect`,to:a,status:o}}let s=l(e.path),c=await b(n.redirectsClient,s,t);if(c!==null){a=c;continue}let u=await n.pathsClient.findOne({locale:t,path:s,isCanonical:!0});return u&&i(u.entityType)?S(r,u.entityType,u.entityId)?{kind:`redirect`,to:`/`,status:301}:{kind:`redirect`,to:s,status:o}:{kind:`none`}}return{kind:`none`}}async function b(e,t,n){return await e.findOne({fromPath:t,locale:n})||e.findOne({fromPath:t,locale:null})}async function x(e,t,n){let r=[];for(let i of n.locales){let a=await m(e,t,i,n);a!==null&&await n.publishChecker.isPublished(e,t,i)&&r.push({locale:i,path:a})}return r}function S(e,t,n){return e!==null&&e.entity===t&&e.id===n}function C(e){return e===302?302:301}const w=e({name:`toolkit_redirects`,columns:{id:i.uuid({primaryKey:!0,defaultRandom:!0}),locale:i.varchar({length:10}),fromPath:i.text({notNull:!0,pgName:`from_path`}),target:i.jsonb({notNull:!0}),status:i.integer({notNull:!0,default:301}),createdBy:i.varchar({length:255,pgName:`created_by`}),createdAt:i.timestamp({notNull:!0,defaultNow:!0,withTimezone:!0,pgName:`created_at`}),updatedAt:i.timestamp({notNull:!0,defaultNow:!0,withTimezone:!0,pgName:`updated_at`})},unique:[{on:[`locale`,`fromPath`],where:e=>a`${e.locale} IS NOT NULL`},{on:[`fromPath`],where:e=>a`${e.locale} IS NULL`}]}),T=w.table;export{x as a,g as c,l as d,h as i,c as l,w as n,m as o,p as r,_ as s,T as t,u};
2
+ //# sourceMappingURL=schema-LlK6EkOJ.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schema-LlK6EkOJ.mjs","names":[],"sources":["../src/paths/errors.ts","../src/paths/generate-path.ts","../src/paths/redirect-target.ts","../src/paths/resolve.ts","../src/redirects/schema.ts"],"sourcesContent":["/**\n * Domain errors thrown by the pre-commit `pathConflictGuard` (plan/routing PR03,\n * F010). Distinct classes so the admin-ui API handler can map them to a 4xx\n * (caller-actionable) rather than a generic 500, and so callers can branch on\n * `instanceof`.\n */\n\n/**\n * A document's candidate canonical URL is already claimed by a DIFFERENT\n * document in the same locale, and it can't be auto-resolved: either the slug\n * is manually pinned (`slug_mode = 'manual'`), or the auto-suffix ladder was\n * exhausted / there is no slug to suffix. The write is aborted before commit.\n */\nexport class PathConflictError extends Error {\n readonly entityName: string\n readonly locale: string\n readonly path: string\n /** The id of the document already holding `(locale, path)`, when known. */\n readonly conflictingEntityId: string | undefined\n\n constructor(\n entityName: string,\n locale: string,\n path: string,\n conflictingEntityId?: string,\n message?: string,\n ) {\n super(\n message ??\n `Path \"${path}\" (locale \"${locale}\") is already used by another ${entityName} document. ` +\n `Choose a different slug, or set this document's slug manually.`,\n )\n this.name = 'PathConflictError'\n this.entityName = entityName\n this.locale = locale\n this.path = path\n this.conflictingEntityId = conflictingEntityId\n }\n}\n\n/**\n * A document's resolved canonical URL falls under a reserved path prefix\n * (`reservedPathPrefixes`, e.g. `/admin` or `/api`). Allowing it would make the\n * document reachable under a route the framework owns, so the write is aborted\n * before commit — for the WRITE's OWN locale only (F010's one-locale-per-write\n * scope; the boot-time lint only WARNs, and only on statically-analysable\n * patterns). A reserved-prefix hit in a DIFFERENT locale than the one being\n * written (e.g. a base-row reparent that only collides for a non-default\n * locale) isn't caught here — `pathIndexWriter`'s post-commit pass screens\n * every other locale too and refuses to persist a reserved path, but by then\n * the entity write has already committed (F013).\n */\nexport class PathReservedPrefixError extends Error {\n readonly entityName: string\n readonly path: string\n readonly prefix: string\n\n constructor(entityName: string, path: string, prefix: string) {\n super(\n `Path \"${path}\" falls under the reserved prefix \"${prefix}\" and cannot be used as a ` +\n `public URL for a ${entityName} document.`,\n )\n this.name = 'PathReservedPrefixError'\n this.entityName = entityName\n this.path = path\n this.prefix = prefix\n }\n}\n","/**\n * Shared path-generation + conflict logic (plan/routing PR03, Part 3).\n *\n * `computeCanonicalPath` resolves a routable row to its locale-relative\n * canonical URL — reusing `buildEntityHref` (the PR02-shipped token engine) and\n * expanding the `[parent.path]` token against the parent's own indexed path\n * (F004's PR03 payoff). Used by BOTH seams:\n * - `pathIndexWriter` (post-commit) — compute + upsert.\n * - `pathConflictGuard` (pre-commit, via `ensurePathAvailable`) — compute +\n * check + maybe-mutate-and-rethrow.\n *\n * so the pattern-resolution + parent-expansion logic exists exactly once.\n *\n * NOTE on the signature vs. the task brief: the brief typed\n * `computeCanonicalPath` as synchronous (`: string | null`), but the\n * `[parent.path]` expansion requires a `toolkit_paths` lookup, which is async —\n * so both functions are `Promise`-returning and take the `ToolkitPathsClient`.\n * (Same shape of necessary deviation as Task 1's `defineTable` `where`\n * predicate, which the brief typed as `SQL` but had to widen to a callback.)\n */\n\nimport {\n buildEntityHref,\n type Entity,\n matchReservedPrefix,\n resolvePattern,\n} from '@murumets-ee/entity'\nimport type { PathSkipReason } from './apply-path.js'\nimport { PathConflictError, PathReservedPrefixError } from './errors.js'\nimport type { ToolkitPathsClient } from './schema.js'\n\n/** Hard cap on auto-suffix attempts — a pathological run of taken slugs throws\n * rather than looping forever (matches the repo's no-unbounded-work posture,\n * even though this is a single-document loop, not a fan-out). */\nconst MAX_SUFFIX_ATTEMPTS = 50\n\n/** {@link computeCanonicalPath}'s result — a computed path, or `null` plus WHY (F075). */\nexport type ComputeCanonicalPathResult =\n | { path: string; skipReason?: undefined }\n | { path: null; skipReason: PathSkipReason }\n\n/**\n * Compute a row's locale-relative canonical path for `locale`, or `{ path:\n * null, skipReason }` when it can't be built — the same typed-BROKEN-link\n * contract as {@link buildEntityHref}, plus a reason a caller can log or\n * ignore (F075; see {@link PathSkipReason} for what each reason means and\n * which ones are worth surfacing).\n *\n * `[parent.path]` expansion: when `row.parent` is a set self-reference\n * (`pathParent()`), the parent's OWN canonical `toolkit_paths` path for the\n * SAME `(entityType, locale, isCanonical)` is substituted directly into the\n * PATTERN STRING (not via `buildEntityHref`'s row-based token substitution —\n * see below for why). If the parent isn't indexed yet in this locale, the\n * whole path is unresolved (`skipReason: 'parent-not-indexed'` — usually a\n * transient/ordering condition, not a permanent error). A ROOT document\n * (`row.parent` null/absent) substitutes `''` — per Phase 01 §3's settled\n * design, \"root documents expand `[parent.path]` to nothing\", so a root under\n * `/[parent.path]/[slug]` still resolves (to `/[slug]` after\n * {@link normalizePath} collapses the doubled separator).\n *\n * This can't go through `buildEntityHref`'s normal `row[token]` substitution:\n * that function treats ANY empty-string token value as a broken link\n * (`missing = true`) — correct for ordinary content tokens (an empty slug\n * really is broken), wrong for `parent.path`'s root case (empty there is\n * intentional, not missing). So `[parent.path]` is resolved against the raw\n * PATTERN STRING first (via `resolvePattern`, the same per-locale/map\n * resolution `buildEntityHref` uses internally), and the already-substituted\n * string is then handed to `buildEntityHref` for the REMAINING tokens (e.g.\n * `[slug]`), which keep the normal missing-is-broken rule.\n */\nexport async function computeCanonicalPath(args: {\n entity: Entity\n /** Locale-merged row for the target locale. */\n row: Record<string, unknown>\n locale: string\n defaultLocale: string\n pathsClient: ToolkitPathsClient\n}): Promise<ComputeCanonicalPathResult> {\n const declaredPath = args.entity.frontend?.path\n if (declaredPath === undefined) return { path: null, skipReason: 'not-routable' }\n\n const pattern = resolvePattern(declaredPath, args.locale, args.defaultLocale)\n if (pattern === null) return { path: null, skipReason: 'no-pattern-for-locale' }\n\n let effectivePattern = pattern\n if (typeof pattern === 'string' && pattern.includes('[parent.path]')) {\n const parentId = args.row.parent\n if (typeof parentId === 'string' && parentId.length > 0) {\n // Same-entity self-reference, so the parent lives in this entity's own\n // path rows — no cross-entity-type registry needed.\n const parentRow = await args.pathsClient.findOne({\n entityType: args.entity.name,\n entityId: parentId,\n locale: args.locale,\n isCanonical: true,\n })\n // parent not indexed in this locale yet — broken, not root\n if (!parentRow) return { path: null, skipReason: 'parent-not-indexed' }\n effectivePattern = pattern.replaceAll('[parent.path]', parentRow.path)\n } else {\n // Root document — expands to nothing (Phase 01 §3), not \"missing\".\n effectivePattern = pattern.replaceAll('[parent.path]', '')\n }\n }\n\n const href = buildEntityHref(effectivePattern, args.row, args.locale, args.defaultLocale)\n return href === null\n ? { path: null, skipReason: 'unresolved-token' }\n : { path: normalizePath(href) }\n}\n\n/**\n * Canonicalize a path for storage/comparison: collapse duplicate slashes and\n * strip a trailing slash (except the bare root `/`). This makes the\n * `[parent.path]` composition robust — `/[parent.path]/[slug]` with a\n * `parent.path` of `/handbook` yields `//handbook/intro`, which normalizes to\n * `/handbook/intro` — and guarantees the guard and writer compute byte-identical\n * paths for the same row (so a re-save is correctly seen as \"no change\").\n */\nexport function normalizePath(path: string): string {\n const collapsed = path.replace(/\\/{2,}/g, '/')\n return collapsed.length > 1 && collapsed.endsWith('/') ? collapsed.slice(0, -1) : collapsed\n}\n\n/**\n * Pre-commit conflict check + auto-suffix for a SINGLE locale's write. Called by\n * `pathConflictGuard`. Computes the candidate path from `data` merged onto\n * `currentRow`, rejects reserved-prefix hits and manual-slug conflicts, and —\n * for auto slugs — mutates `data.slug` in place with an incrementing suffix\n * (`hello` → `hello-2` → `hello-3`) until the path is free (bounded).\n *\n * `data.slug` mutations are persisted by the SAME entity write (the guard fires\n * before the INSERT/UPDATE and `data` is the in-flight payload), so no\n * follow-up write is needed.\n */\nexport async function ensurePathAvailable(args: {\n entity: Entity\n /** Mutable in-flight write payload — may get `data.slug` rewritten. */\n data: Record<string, unknown>\n /** Pre-write locale-merged row for `update`/`updateForLocale`; `null` on create. */\n currentRow: Record<string, unknown> | null\n /** The row id, or `undefined` on create (nothing to self-exclude). */\n id: string | undefined\n locale: string\n defaultLocale: string\n slugMode: 'auto' | 'manual'\n reservedPathPrefixes: readonly string[]\n pathsClient: ToolkitPathsClient\n}): Promise<void> {\n const {\n entity,\n data,\n currentRow,\n id,\n locale,\n defaultLocale,\n slugMode,\n reservedPathPrefixes,\n pathsClient,\n } = args\n\n let lastPath = ''\n for (let attempt = 0; attempt < MAX_SUFFIX_ATTEMPTS; attempt++) {\n // Working row = current row overlaid with the in-flight write (create:\n // `data` IS the full row, `currentRow` is null).\n const row = { ...(currentRow ?? {}), ...data }\n const computed = await computeCanonicalPath({ entity, row, locale, defaultLocale, pathsClient })\n // Typed broken link — nothing to conflict-check for this locale. Pre-commit\n // silence is correct here (unlike the post-commit writer, F075): the write\n // hasn't happened yet, so there is nothing yet to be missing a path.\n if (computed.path === null) return\n const path = computed.path\n lastPath = path\n\n // Reserved-prefix boundary — the real enforcement point (Part 2 write-time).\n const reserved = matchReservedPrefix(path, reservedPathPrefixes)\n if (reserved !== undefined) {\n throw new PathReservedPrefixError(entity.name, path, reserved)\n }\n\n // The partial-unique index guarantees at most ONE canonical row per\n // (locale, path). A hit that ISN'T this document's own row is a conflict\n // (self-match excluded — a routine re-save keeping its own path is fine;\n // on create `id` is undefined so nothing is excluded).\n const existing = await pathsClient.findOne({ locale, path, isCanonical: true })\n const isSelf =\n existing != null && existing.entityType === entity.name && existing.entityId === id\n if (existing == null || isSelf) return\n\n // Real conflict with another document.\n if (slugMode === 'manual') {\n throw new PathConflictError(entity.name, locale, path, existing.entityId)\n }\n const currentSlug = row.slug\n if (typeof currentSlug !== 'string' || currentSlug.length === 0) {\n // Auto mode but nothing to suffix — can't resolve automatically.\n throw new PathConflictError(entity.name, locale, path, existing.entityId)\n }\n // Persisted by the same write (the guard runs before the SQL is built).\n data.slug = incrementSlugSuffix(currentSlug)\n }\n\n // Exhausted the suffix ladder — pathological (50 taken slugs in a row).\n throw new PathConflictError(\n entity.name,\n locale,\n lastPath,\n undefined,\n `Could not find a free path for a ${entity.name} document near \"${lastPath}\" (locale \"${locale}\") ` +\n `after ${MAX_SUFFIX_ATTEMPTS} attempts — choose a different slug manually.`,\n )\n}\n\n/**\n * Increment a numeric slug suffix, WordPress/pathauto-style:\n * `hello` → `hello-2`, `hello-2` → `hello-3`, `hello-9` → `hello-10`.\n * A slug with no trailing `-<n>` gets `-2` appended.\n */\nexport function incrementSlugSuffix(slug: string): string {\n const match = /^(.*)-(\\d+)$/.exec(slug)\n if (match) {\n const base = match[1] ?? slug\n const n = Number.parseInt(match[2] ?? '1', 10)\n return `${base}-${n + 1}`\n }\n return `${slug}-2`\n}\n","/**\n * Internal redirect-resolution helper (plan/routing PR04, Part 4).\n *\n * Given a `(locale, path)` that may be a HISTORICAL (demoted) URL, resolve the\n * CURRENT canonical path of the document that once lived there — the \"301 to\n * wherever this document now lives\" lookup that backs automatic redirect\n * history (D005 / the change-matrix in `04-path-lifecycle-and-redirects.md`).\n *\n * **Chain-free by construction.** Every historical row is keyed to the OWNING\n * DOCUMENT (`entity_type`, `entity_id`), never to \"the next URL in the rename\n * sequence\". So a document renamed A → B → C leaves historical rows at A and B\n * both pointing at the same document, and this helper resolves EITHER of them\n * straight to C in one hop — it never walks A → B → C. (WP `_wp_old_slug` /\n * Drupal redirect both use this keyed-to-target model — R01.)\n *\n * NOT the public resolution API. Phase 05 / PR06 owns `resolvePath` and the\n * canonical → manual → historical → 404 precedence contract (§2, §4 of Phase\n * 04); this phase ships only the underlying historical-row → current-canonical\n * mechanism. PR06 imports and wraps this. (Scope split filed as a Finding in\n * `plan/routing/progress.md`, mirroring F004's PR02/03 precedent.)\n */\n\nimport type { Logger } from '@murumets-ee/core'\nimport type { PathIndexReader } from './index-reader.js'\n\n/**\n * How many `(locale, path)` candidates a single redirect lookup will consider\n * before giving up and answering 404.\n *\n * Several documents can hold a historical row at one URL, and one whose owner\n * has no current canonical (trashed, or that locale unpublished) must not\n * shadow one that does — so the lookup walks candidates rather than trusting\n * the newest. This is the ceiling on that walk: it runs on the miss path of\n * every unmatched URL, which is where crawlers and scanners spend their time,\n * and CLAUDE.md's no-unbounded-fan-out rule applies to a per-request loop as\n * much as to a server one.\n */\nconst MAX_REDIRECT_CANDIDATES = 10\n\n/** The current canonical location a historical/canonical `(locale, path)` resolves to. */\nexport interface RedirectTarget {\n entityType: string\n entityId: string\n /** The owning document's CURRENT canonical path for this locale. */\n currentPath: string\n}\n\n/**\n * Resolve `(locale, path)` — canonical OR historical — to the current canonical\n * path of its owning document, or `null` when nothing routes there.\n *\n * Returns `null` when:\n * - No `toolkit_paths` row (of any `is_canonical`) matches `(locale, path)`.\n * - The owning document has no current canonical row for `locale` anymore\n * (its locale variant was unpublished/deleted since) — the caller treats\n * this as a 404, matching the Unpublish/Delete rows of the §2 change matrix.\n *\n * Known edge case (documented, NOT over-engineered per the brief): two DIFFERENT\n * documents can both hold a HISTORICAL row at the same `(locale, path)` if one\n * occupied that URL, moved away, and a LATER document independently claimed then\n * also moved away from it — the partial-unique only forbids two *canonical* rows\n * sharing a URL, never two historical ones. When multiple rows match we prefer\n * the most-recently-updated, but only among those that still RESOLVE: self-\n * healing / approximate, consistent with this feature's already-accepted\n * tolerance for narrow non-security drift (F014's precedent — accept the race\n * rather than add locking machinery).\n *\n * Preferring the newest *resolvable* candidate rather than simply the newest is\n * load-bearing since trashing a document releases its canonical claim while\n * keeping its history: without it, a trashed document's leftover history row\n * would out-rank and silently kill a LIVE document's redirect at the same URL —\n * the same \"a row belonging to an invisible document shadows a live one\" defect\n * that the trash-side release exists to remove, displaced one tier down.\n */\nexport async function findRedirectTarget(args: {\n // Narrow structural reader (the real `ToolkitPathsClient` satisfies it — see\n // `./index-reader.ts`). Widened from `ToolkitPathsClient` in PR06 so the\n // resolution core can inject a cast-free unit-test stub; the integration\n // tests still pass the real client, proving the assignment.\n pathsClient: PathIndexReader\n locale: string\n path: string\n /**\n * Optional, and used for ONE thing: saying so when the candidate cap was\n * reached and nothing resolved. Without it that 404 is indistinguishable\n * from a genuine miss and the pathological URL stays invisible — which is\n * the silent-truncation failure CLAUDE.md's fan-out rule names directly.\n */\n logger?: Logger\n}): Promise<RedirectTarget | null> {\n const { pathsClient, locale, path, logger } = args\n\n // Any row (canonical or historical) at this URL, most-recent first. Several\n // documents can hold a historical row at the same URL — each one lived here\n // and moved on — and the most recent is the best answer among those that can\n // still BE answered.\n //\n // Bounded, and taking only the first row would be the bug: a document whose\n // canonical is gone (trashed, so its claim was released; or its locale\n // variant unpublished) still owns its history rows, and if one of those wins\n // the pick, an unanswerable candidate would shadow a live document's\n // redirect at the same URL and turn a working 301 into a 404. So walk the\n // candidates in recency order and take the first whose owner can actually be\n // redirected to.\n //\n // `MAX_REDIRECT_CANDIDATES` is a hard ceiling per CLAUDE.md's no-unbounded-\n // fan-out rule — this runs on the miss path of every unmatched URL, which is\n // exactly where a crawler or a scanner spends its time. Beyond it we answer\n // 404 rather than keep looking; a URL contended by that many documents is\n // already pathological, and the alternative is letting one request issue\n // unbounded lookups.\n const matches = await pathsClient.findMany({\n where: { locale, path },\n orderBy: [{ column: 'updatedAt', dir: 'desc' }],\n limit: MAX_REDIRECT_CANDIDATES,\n })\n\n for (const match of matches) {\n // Resolve the owning document's CURRENT canonical row for this locale.\n // This is the single hop that makes redirects chain-free: we jump from\n // \"some URL this document used to (or does) live at\" straight to \"where it\n // lives now\".\n const canonical = await pathsClient.findOne({\n entityType: match.entityType,\n entityId: match.entityId,\n locale,\n isCanonical: true,\n })\n // This owner has no live canonical path in this locale anymore. Skip it and\n // try the next candidate rather than answering 404 on its behalf — the §2\n // change matrix's 404 is for when NO candidate resolves, not when the\n // newest one happens not to.\n if (canonical) {\n return {\n entityType: match.entityType,\n entityId: match.entityId,\n currentPath: canonical.path,\n }\n }\n }\n\n // Nothing resolved. If we also filled the candidate window, the answer may be\n // a TRUNCATION rather than a miss — say so, because the two are identical\n // from outside and only one of them is a URL somebody should look at.\n if (matches.length === MAX_REDIRECT_CANDIDATES) {\n logger?.warn?.(\n { locale, path, examined: matches.length },\n 'toolkit_paths: redirect lookup examined the full candidate window without ' +\n 'resolving one — answering 404, but a further candidate may exist',\n )\n }\n\n return null\n}\n","/**\n * The public path-resolution API (plan/routing PR06; phase\n * `05-resolution-and-enumeration-api.md` §2, phase `04` §2-4).\n *\n * `resolvePath` maps an incoming `(path, locale)` to a document, a redirect, or\n * a 404, applying the settled precedence:\n *\n * `/` homepage → canonical → manual redirect → historical redirect → none\n *\n * **Security (CLAUDE.md #1 priority).** These functions gate whether content is\n * reachable by URL. `resolvePath` publish-checks at every tier that resolves to\n * a document (homepage / canonical / historical) via the injected\n * {@link PublishChecker} — an UNPUBLISHED document in the requested locale is\n * NOT routable (`{ kind: 'none' }`). A canonical row \"claims\" its path even in\n * draft, so an unpublished canonical returns `none` WITHOUT falling through to\n * manual/historical (nothing else may resolve there). `resolveDraftPath` is the\n * ONLY function that skips publish checks — it is admin/preview-only. Its one\n * sanctioned HTTP caller is `./routes.ts` (`GET /api/admin/paths/resolve`,\n * added for #775: permission-gated `path:resolve` PLUS a per-document `view`\n * re-check). It must NEVER be wired to a `content-api` endpoint — that surface\n * is public and published-only.\n *\n * **Leaf module.** All cross-package state (path/redirect readers, the publish\n * checker, the homepage reader) is injected via `deps` — this module imports no\n * `@murumets-ee/core` and no `server-only` graph. The concrete `deps` are built\n * from an app in `./resolve-context.ts`.\n */\n\nimport type { Logger } from '@murumets-ee/core'\nimport type { Entity } from '@murumets-ee/entity'\nimport type { RedirectTarget } from '../redirects/target.js'\nimport { normalizePath } from './generate-path.js'\nimport type { PathIndexReader } from './index-reader.js'\nimport type { HomepagePointer } from './homepage.js'\nimport { findRedirectTarget } from './redirect-target.js'\n\n// ---------------------------------------------------------------------------\n// Result type (phase 05 §2 — exact shape)\n// ---------------------------------------------------------------------------\n\n/** The document that serves the requested path. */\nexport interface ResolveDocumentResult {\n kind: 'document'\n entityType: string\n id: string\n}\n/** A redirect to another URL/path. `301` permanent, `302` temporary. */\nexport interface ResolveRedirectResult {\n kind: 'redirect'\n to: string\n status: 301 | 302\n}\n/** Nothing routes here (404). */\nexport interface ResolveNoneResult {\n kind: 'none'\n}\nexport type ResolveResult = ResolveDocumentResult | ResolveRedirectResult | ResolveNoneResult\n\n// ---------------------------------------------------------------------------\n// Dependency-injection surface\n// ---------------------------------------------------------------------------\n\n/**\n * Is `(entityType, entityId)` published in `locale`? Backed by\n * `QueryClient.findById`, which applies the publish filter for any principal\n * WITHOUT `view_unpublished` (D017) — so the answer is only \"published\" if the\n * injected client's context withholds that action, which the real one\n * (`resolve-context.ts`'s reach context) does. See {@link createPublishChecker}.\n * `resolveDraftPath` bypasses this entirely.\n */\nexport interface PublishChecker {\n isPublished(entityType: string, entityId: string, locale: string): Promise<boolean>\n}\n\n/** Resolve an entity TYPE NAME to its `Entity`, or `undefined` when unregistered. */\nexport type EntityResolver = (entityType: string) => Entity | undefined\n\n/** The `QueryClient.findById` surface the publish checker needs (publish filter is internal). */\nexport interface PublishQueryClient {\n findById(id: string, options?: { locale?: string | undefined }): Promise<unknown>\n}\n\n/** Build a publish-filtering read client for an entity (real: `new QueryClient(...)`). */\nexport type QueryClientFactory = (entity: Entity) => PublishQueryClient\n\n/** Read the current homepage pointer, or `null` when unset/malformed. */\nexport type HomepageReader = () => Promise<HomepagePointer | null>\n\n/** The manual-redirect lookup surface (locale-specific + catch-all rows). */\nexport interface RedirectLookupReader {\n findOne(where: {\n fromPath: string\n /** A concrete locale, or `null` for the catch-all (all-locales) row. */\n locale: string | null\n }): Promise<{ target: RedirectTarget; status: number } | null>\n}\n\n/** Deps for {@link getCanonicalPath} — the minimal index read. */\nexport interface CanonicalPathDeps {\n pathsClient: PathIndexReader\n}\n\n/** Deps for {@link resolvePath} / {@link resolveDraftPath}. */\nexport interface ResolvePathDeps extends CanonicalPathDeps {\n redirectsClient: RedirectLookupReader\n publishChecker: PublishChecker\n homepageReader: HomepageReader\n /**\n * Is this entity type still routable (does it declare `frontend.path`)? A\n * `toolkit_paths` row outlives its entity losing the path (F068), so every\n * tier that serves a row's document asks first: a row of a non-routable\n * entity is `none`, never a document or a redirect. `buildResolveDeps`\n * always supplies it; omitted (unit-test builders only) means \"every entity\n * is routable\".\n */\n isRoutable?: (entityType: string) => boolean\n /**\n * Optional so the unit-test deps builders stay unchanged. Its only use is\n * letting the historical-redirect lookup report a truncated candidate window\n * — see {@link findRedirectTarget}.\n */\n logger?: Logger\n}\n\n/** Deps for {@link getAlternates}. */\nexport interface AlternatesDeps extends CanonicalPathDeps {\n publishChecker: PublishChecker\n /** The locales to enumerate (configured content locales). */\n locales: readonly string[]\n}\n\n/**\n * Hop cap on manual-redirect `path`-chain following at REQUEST time (F021 /\n * F027 point 4). `collapsePathTarget` only collapses ONE hop at save time, so a\n * chain created out-of-order can still need re-hopping here; exceeding the cap\n * resolves to `none` rather than looping or 500ing.\n */\nexport const MAX_REDIRECT_HOPS = 5\n\n// ---------------------------------------------------------------------------\n// getCanonicalPath — forward index read, NO publish filter (D-note below)\n// ---------------------------------------------------------------------------\n\n/**\n * The current canonical path for `(entityType, entityId, locale)`, or `null`\n * when the document has no canonical row in that locale.\n *\n * **No publish filter — deliberate.** This matches the CURRENT behavior of\n * `resolveEntityHref` / search's URL builder (both call `AdminClient.findById`,\n * which does zero publish filtering); Task 2 re-points those consumers onto this\n * function, so changing the filtering here would be an unplanned behavior change.\n * Callers that need a publish gate compose one explicitly (resolution's canonical\n * tier does).\n */\nexport async function getCanonicalPath(\n entityType: string,\n entityId: string,\n locale: string,\n deps: CanonicalPathDeps,\n): Promise<string | null> {\n const row = await deps.pathsClient.findOne({ entityType, entityId, locale, isCanonical: true })\n return row?.path ?? null\n}\n\n// ---------------------------------------------------------------------------\n// Publish checker\n// ---------------------------------------------------------------------------\n\n/**\n * Build a {@link PublishChecker} from an entity resolver + a query-client\n * factory. `isPublished` is `false` for an unknown entity type, else\n * `findById(id, { locale }) !== null` — a non-null result means \"published in\n * that locale\" because `QueryClient` applies the publish filter (and excludes\n * trashed rows) for every principal without `view_unpublished` (D017). The\n * factory therefore has to hand back a client whose context withholds that\n * action; `resolve-context.ts`'s reach context is the one in-repo\n * implementation and does. F027 point 1.\n */\nexport function createPublishChecker(deps: {\n entityResolver: EntityResolver\n queryClientFactory: QueryClientFactory\n}): PublishChecker {\n return {\n async isPublished(entityType, entityId, locale) {\n const entity = deps.entityResolver(entityType)\n if (entity === undefined) return false\n const client = deps.queryClientFactory(entity)\n const row = await client.findById(entityId, { locale })\n return row !== null && row !== undefined\n },\n }\n}\n\n// ---------------------------------------------------------------------------\n// resolvePath / resolveDraftPath\n// ---------------------------------------------------------------------------\n\n/** The publish predicate threaded through the tiers (real, or always-true for draft). */\ntype PublishPredicate = (entityType: string, entityId: string, locale: string) => Promise<boolean>\n\n/**\n * Resolve `(path, locale)` to a document / redirect / none, publish-checking at\n * every document-resolving tier. Public request surface (phase 05 §2).\n */\nexport function resolvePath(\n path: string,\n locale: string,\n deps: ResolvePathDeps,\n): Promise<ResolveResult> {\n return resolveInternal(path, locale, deps, (entityType, entityId, loc) =>\n deps.publishChecker.isPublished(entityType, entityId, loc),\n )\n}\n\n/**\n * Admin/preview resolution — identical tier structure to {@link resolvePath} but\n * SKIPS every publish check (draft and published documents both resolve). Per\n * phase 05 §2 this surface is admin-only. Exactly ONE HTTP route may call it —\n * `./routes.ts`'s `GET /api/admin/paths/resolve` (#775), which gates on\n * `path:resolve` and re-checks the caller's `view` grant on whatever it\n * resolves. NEVER wire it to `content-api` (public, published-only).\n */\nexport function resolveDraftPath(\n path: string,\n locale: string,\n deps: ResolvePathDeps,\n): Promise<ResolveResult> {\n // The ONLY difference from resolvePath: the publish predicate is always true.\n return resolveInternal(path, locale, deps, async () => true)\n}\n\n/**\n * Shared tier engine for `resolvePath` / `resolveDraftPath`. The two differ ONLY\n * in `checkPublished` (real checker vs. always-true), so the precedence + every\n * branch stay byte-identical between the public and admin surfaces.\n */\nasync function resolveInternal(\n rawPath: string,\n locale: string,\n deps: ResolvePathDeps,\n checkPublished: PublishPredicate,\n): Promise<ResolveResult> {\n const path = normalizePath(rawPath)\n const routable = (entityType: string): boolean => deps.isRoutable?.(entityType) ?? true\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 // A row whose entity stopped declaring a frontend path serves nothing, and\n // (like an unpublished canonical) claims the path: no fall-through.\n if (!routable(canonical.entityType)) return { kind: 'none' }\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, routable)\n if (manual !== null) return manual\n\n // ── Tier 4: historical (automatic rename) redirects ───────────────────────\n const historical = await findRedirectTarget({\n pathsClient: deps.pathsClient,\n locale,\n path,\n ...(deps.logger !== undefined && { logger: deps.logger }),\n })\n if (historical === null) return { kind: 'none' }\n if (!routable(historical.entityType)) 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 routable: (entityType: string) => boolean,\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 if (!routable(target.entity)) return { kind: 'none' }\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 (!routable(canonical.entityType)) return { kind: 'none' }\n if (isHomepage(homepage, canonical.entityType, canonical.entityId)) {\n return { kind: 'redirect', to: '/', status: 301 }\n }\n return { kind: 'redirect', to: nextPath, status }\n }\n return { kind: 'none' } // path target points at neither a redirect nor content\n }\n\n return { kind: 'none' } // exceeded MAX_REDIRECT_HOPS — never loop, never 500\n}\n\n/**\n * Look up a manual redirect at `fromPath`: a locale-specific row wins, else the\n * catch-all (`locale = null`) row. Mirrors `collapsePathTarget`'s precedence.\n */\nasync function lookupManualRedirect(\n client: RedirectLookupReader,\n fromPath: string,\n locale: string,\n): Promise<{ target: RedirectTarget; status: number } | null> {\n const exact = await client.findOne({ fromPath, locale })\n if (exact) return exact\n return client.findOne({ fromPath, locale: null })\n}\n\n// ---------------------------------------------------------------------------\n// getAlternates\n// ---------------------------------------------------------------------------\n\n/**\n * The published per-locale canonical URLs for a document — the `hreflang`\n * alternates set (phase 05 §2, \"published locales only\"). A locale is included\n * only when the document has a canonical row AND is published there. Bounded by\n * the (small, fixed) configured-locale count — no fan-out concern.\n */\nexport async function getAlternates(\n entityType: string,\n id: string,\n deps: AlternatesDeps,\n): Promise<Array<{ locale: string; path: string }>> {\n const alternates: Array<{ locale: string; path: string }> = []\n for (const locale of deps.locales) {\n const path = await getCanonicalPath(entityType, id, locale, deps)\n if (path === null) continue\n if (!(await deps.publishChecker.isPublished(entityType, id, locale))) continue\n alternates.push({ locale, path })\n }\n return alternates\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\n/** Is `(entityType, entityId)` the configured homepage document? */\nfunction isHomepage(\n homepage: HomepagePointer | null,\n entityType: string,\n entityId: string,\n): boolean {\n return homepage !== null && homepage.entity === entityType && homepage.id === entityId\n}\n\n/**\n * Narrow the DB `status` integer (validated `301 | 302` at write time) to the\n * result union. Defaults to `301` for any unexpected value — never a raw number\n * leaking into the typed result.\n */\nfunction toRedirectStatus(status: number): 301 | 302 {\n return status === 302 ? 302 : 301\n}\n","/**\n * `toolkit_redirects` — the manual-redirect table (plan/routing PR05, phase\n * `04-path-lifecycle-and-redirects.md` §3). One row per admin-authored redirect:\n * a `from_path` (locale-relative source URL) → a {@link RedirectTarget}\n * (entity / url / path). Distinct from the AUTOMATIC redirect history, which lives\n * as demoted (`is_canonical = false`) rows in `toolkit_paths` — this table is only\n * for the URLs automatic history can't express (campaign URLs, migrated-site\n * paths). Owned by `@murumets-ee/content`.\n *\n * Uses `defineTable` (per plan/routing C3, CLAUDE.md \"no raw SQL / typed APIs\n * only\") so reads/writes go through a typed `TableClient` — Task 2's CRUD routes\n * and the save-time validation (`./validation.ts`) never touch raw SQL.\n *\n * ## Uniqueness — TWO partial-unique indexes (F020 point 4)\n *\n * `locale` is nullable: `null` means \"applies to all locales\" (a catch-all\n * redirect). Postgres treats `NULL`s as DISTINCT in an ordinary unique constraint,\n * so a naive `UNIQUE (locale, from_path)` would silently allow duplicate catch-all\n * rows at the same path. Two partial-uniques express the real intent:\n *\n * - `UNIQUE (locale, from_path) WHERE locale IS NOT NULL` — a locale-specific\n * redirect is unique per (locale, from_path).\n * - `UNIQUE (from_path) WHERE locale IS NULL` — at most ONE catch-all redirect per\n * from_path.\n *\n * A locale-specific row and a catch-all row MAY legitimately coexist at the same\n * `from_path` (intentional per-locale override layering) — the two indexes don't\n * forbid that, only duplicates WITHIN each stratum. Both indexes are keyed on\n * `from_path`, so the \"does this from_path already exist as ANY redirect\"\n * collapse-lookup (`./validation.ts`) is already covered — no extra plain index\n * needed.\n *\n * Both use the callback `where` form (F007's `defineTable` partial-unique support)\n * with an INLINE `IS NULL` / `IS NOT NULL` literal — never `eq()`, which would bind\n * a `$1` param that is invalid in DDL (CLAUDE.md partial-unique gotcha; same form\n * `toolkit_paths` uses).\n */\n\nimport { column } from '@murumets-ee/db'\nimport { sql } from 'drizzle-orm'\nimport { defineSitePartitionedTable } from '../schema/site-partition.js'\nimport type { RedirectTarget } from './target.js'\n\n/**\n * Per site (plan `multisite` phase 03, D005): partitioned by `_site` — both\n * uniques below lead with it, so each site keeps its own redirects from the\n * same `from_path`. A client cannot be built without a site.\n */\nexport const toolkitRedirectsTable = defineSitePartitionedTable({\n name: 'toolkit_redirects',\n columns: {\n id: column.uuid({ primaryKey: true, defaultRandom: true }),\n /** BCP47 locale, or `null` = applies to all locales (catch-all). */\n locale: column.varchar({ length: 10 }),\n /** Locale-relative source URL: leading '/', no trailing '/' (same convention as `toolkit_paths.path`). */\n fromPath: column.text({ notNull: true, pgName: 'from_path' }),\n /** The redirect destination — see {@link RedirectTarget}. Stored as the discriminated-union object. */\n target: column.jsonb<RedirectTarget, true>({ notNull: true }),\n /** HTTP status. `301 | 302` is validated at the Zod boundary in Task 2's route layer. */\n status: column.integer({ notNull: true, default: 301 }),\n /**\n * The admin who authored the redirect. VARCHAR, never UUID — better-auth user\n * ids are random strings (CLAUDE.md). Nullable: system-created rows (future\n * migration/import tooling) have no natural actor, matching how other\n * audit-style columns in `defineTable` tables stay nullable.\n */\n createdBy: column.varchar({ length: 255, pgName: 'created_by' }),\n createdAt: column.timestamp({\n notNull: true,\n defaultNow: true,\n withTimezone: true,\n pgName: 'created_at',\n }),\n updatedAt: column.timestamp({\n notNull: true,\n defaultNow: true,\n withTimezone: true,\n pgName: 'updated_at',\n }),\n },\n unique: [\n // A locale-specific redirect is unique per (locale, from_path). Excludes\n // catch-all rows (locale IS NULL) from this stratum.\n { on: ['locale', 'fromPath'], where: (c) => sql`${c.locale} IS NOT NULL` },\n // At most ONE catch-all (all-locales) redirect per from_path. Postgres would\n // otherwise treat every NULL locale as distinct and allow duplicates.\n { on: ['fromPath'], where: (c) => sql`${c.locale} IS NULL` },\n ],\n})\n\n/** Backwards-compatible raw pgTable export (for the plugin `tables:` slot + migrations). */\nexport const toolkitRedirects = toolkitRedirectsTable.table\n\n/** The full `DefinedTable` for `toolkit_redirects`. */\nexport type ToolkitRedirectsTable = typeof toolkitRedirectsTable\n\n/** The typed `TableClient` for `toolkit_redirects` (`toolkitRedirectsTable.makeClient(db)`). */\nexport type ToolkitRedirectsClient = ReturnType<typeof toolkitRedirectsTable.makeClient>\n"],"mappings":"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"}
@@ -0,0 +1,177 @@
1
+ //#region src/seed.d.ts
2
+ /**
3
+ * The site-chrome seeder — creates the one `site_chrome` row per placement
4
+ * that a site needs, POPULATED, before any of them can be edited.
5
+ *
6
+ * Contributed as `content:site-chrome` from the `content()` plugin factory and
7
+ * reached through the `@murumets-ee/content/seed` subpath — its own build
8
+ * entry so the lazy import keeps `@murumets-ee/core/clients`, which carries
9
+ * `import 'server-only'`, off the config-load path.
10
+ *
11
+ * ## Why a seeder at all, and why nobody has to run it
12
+ *
13
+ * An unseeded chrome document is indistinguishable from a site that HAS no
14
+ * header, and both the canvas and `/v1/_chrome` treat it that way — they
15
+ * compose nothing and let the consumer render its own fallback. That is right,
16
+ * and it means nothing ever CREATES the row, so without this an editor has no
17
+ * header to open.
18
+ *
19
+ * Until PR30 that left the gap S5 names: nothing in the printed setup path ran
20
+ * this, so a scaffolded project booted with an empty Site list and the only
21
+ * bootstrap was a human pressing "New". The plugin now declares this seeder
22
+ * `bootstrap: true`, and `lumi migrate` runs every bootstrap seeder after it
23
+ * applies migrations — which is already step 4 of the printed next-steps. It
24
+ * stays an ordinary named seeder too, runnable from `lumi seed` and the admin
25
+ * Seed page; `bootstrap` adds a trigger rather than changing what it is.
26
+ *
27
+ * ## Why the entity is resolved by NAME
28
+ *
29
+ * This package ships the chrome entity as a FACTORY (`siteChromeEntity()`),
30
+ * because an app must pass options it cannot get from here — `withOpLog()`
31
+ * ships from `@murumets-ee/blocks/server`, and content must not depend on
32
+ * blocks (blocks depends back on content). So there is no entity object in
33
+ * this package to import; there is only the name the factory stamps on
34
+ * whatever the app instantiated.
35
+ *
36
+ * That name is not an assumption: `site-chrome.ts` hardcodes
37
+ * `name: 'site_chrome'`, so an app can configure the entity but cannot rename
38
+ * it. This package's own chrome PROVIDER resolves it the same way
39
+ * (`app.entities.get(name)`, `null` when absent), and `@murumets-ee/auth`'s
40
+ * default permission maps and `@murumets-ee/frontend-cache`'s
41
+ * `DEFAULT_CHROME_ENTITIES` name the same string.
42
+ *
43
+ * ## Why it is `available`-gated rather than merely tolerant
44
+ *
45
+ * An app is free to register no chrome at all. Seeding plan F009 deferred this
46
+ * seeder on exactly that: contributed unconditionally it would put a button on
47
+ * the Seed page whose entire outcome is `created: 0`, which a reader takes as
48
+ * "this seeder is broken", not as "this project has no chrome". The plugin
49
+ * declares `available` so the button is ABSENT instead — the same contract
50
+ * `rent:cars-demo` has, where absence follows from the plugin's absence rather
51
+ * than from a silent no-op.
52
+ *
53
+ * ## Idempotent by construction, not by a flag
54
+ *
55
+ * Every run finds before it creates, keyed on `placement`. That is what makes
56
+ * a second run a no-op — necessary now in a way it was not before, because
57
+ * `lumi migrate` runs this on EVERY migrate, not once. It is also why the
58
+ * find is a `where` on `placement` rather than a bare `limit: 1`: the old
59
+ * `kind: 'singleton'` made "is there a row" the whole question, and under one
60
+ * entity holding two rows it is the wrong question — a bare existence check
61
+ * would see the header and never create the footer.
62
+ *
63
+ * ## DRAFT, deliberately — and this reverses an earlier choice
64
+ *
65
+ * The rows are created unpublished, so `lumi migrate` changes nothing a
66
+ * visitor sees. That is not a detail; it is what makes `bootstrap: true` safe,
67
+ * because this now runs on EVERY migrate, in production, against a database
68
+ * full of a customer's content (see `PluginSeeder.bootstrap`). A provisioning
69
+ * step that silently alters the public site is not additive.
70
+ *
71
+ * This file used to create them PUBLISHED, reasoning that "a draft-seeded site
72
+ * is indistinguishable from an unseeded one to every public reader" and that
73
+ * "the rows are EMPTY, so publishing them renders nothing". The first half is
74
+ * true and is the POINT: an empty chrome IS unseeded, and a public reader
75
+ * should see exactly what they saw before. The second half was measured and is
76
+ * false.
77
+ *
78
+ * Measured, on a running front-astro against a real CMS: an empty PUBLISHED
79
+ * region arrives on the wire as `{ blocks: [] }`, which `placeRegion` reads as
80
+ * `hidden` — "the page hid this on purpose" — and `rendersFallback` returns
81
+ * false for `hidden`. So publishing an empty row does not render nothing; it
82
+ * renders nothing AND SUPPRESSES THE TEMPLATE'S OWN FALLBACK. A freshly
83
+ * scaffolded site lost its `<header>` and `<footer>` landmarks entirely, where
84
+ * before it rendered the fallback header with its Home link and nav.
85
+ *
86
+ * `null` (no published row) reads as `fallback` instead, which is the answer
87
+ * that leaves the delivery template in charge until somebody has actually
88
+ * authored a header. The moment they do and press Publish, the CMS takes over
89
+ * — ordinary publish semantics, the same as every other document.
90
+ *
91
+ * ## EMPTY is what starved the canvas — and only the SITE was ever considered
92
+ *
93
+ * The section above is right and stays. Its blind spot is that it reasons
94
+ * entirely about what a VISITOR sees. The rows it creates were also `layout:
95
+ * []`, and the comment on that line said an empty tree "is a header with no
96
+ * blocks yet, which is what the editor opens onto" — which is true, and is the
97
+ * defect.
98
+ *
99
+ * The live site has a fallback: no published chrome row reads as `fallback`
100
+ * and the delivery template renders its own header. **The editing canvas has
101
+ * no fallback.** Its only channel for chrome is these rows — it is React in the
102
+ * admin app and cannot render an Astro template's markup — so an empty row
103
+ * composes to zero blocks and `CanvasDocument` emits a bare `<div>` with
104
+ * nothing in it. Every page, forever, on every project ever scaffolded. The
105
+ * site looked right the whole time, which is why no gate and no person caught
106
+ * it for months (`editor` D014).
107
+ *
108
+ * So the two properties are independent and both are required: the rows are
109
+ * DRAFT (nothing a visitor sees changes) and NON-EMPTY (the canvas has a header
110
+ * and a footer to paint). Draft is what makes non-empty safe — the tree is
111
+ * real, and no visitor sees it until somebody publishes.
112
+ *
113
+ * ## What this canNOT fix, stated because it is the interesting half
114
+ *
115
+ * This seeder is ADDITIVE: `if (existing.length > 0) continue`. An install
116
+ * whose rows already exist — every project bootstrapped before this change —
117
+ * keeps its empty ones, and must, because overwriting an authored header is the
118
+ * one thing a bootstrap seeder may never do. The same hole reopens for anyone
119
+ * who deletes their header's last block.
120
+ *
121
+ * That half belongs to the canvas and is fixed there: an empty non-page
122
+ * document now renders a NAMED region band rather than nothing
123
+ * (`block-editor-canvas.tsx`'s `EndAddZone`). This seeder decides what a NEW
124
+ * site starts with; the canvas decides that an empty region is never invisible.
125
+ *
126
+ * ## What it assumes about its caller
127
+ *
128
+ * An admin context — `runSeederAction`, `lumi seed` and `lumi migrate`'s
129
+ * bootstrap pass all establish one. Auth, the admin-only role check and the
130
+ * production gate live there.
131
+ *
132
+ * The entity NAME and the `available` predicate live one module over
133
+ * (`./chrome-entity-names.ts`), which imports nothing — `plugin.ts` needs the
134
+ * predicate statically and must not pull this file's `server-only` client
135
+ * along with it.
136
+ */
137
+ declare function seedSiteChrome(): Promise<{
138
+ created: number;
139
+ }>;
140
+ /**
141
+ * Create the DRAFT detail template for every entity the app's
142
+ * `detail_template` entity targets (block-library D009), so each has a document
143
+ * an editor can open. Idempotent: an existing row for a target is left alone.
144
+ *
145
+ * The targets are read from the `target` select's own options — the list the
146
+ * app's `detailTemplateEntity({ for })` validated at boot — so the seeder and
147
+ * the entity cannot disagree about which templates exist.
148
+ *
149
+ * A draft, like chrome's: until someone publishes it, every row of the target
150
+ * keeps drawing the plain fallback page, so a migrate changes nothing a visitor
151
+ * sees.
152
+ */
153
+ declare function seedDetailTemplates(): Promise<{
154
+ created: number;
155
+ }>;
156
+ /**
157
+ * Forget every `toolkit_paths` row — canonical and redirect history — for the
158
+ * named entities. For a seeder that empties those entities' tables in bulk.
159
+ *
160
+ * `AdminClient.delete` drops a document's paths when the row is physically
161
+ * removed (the path-index writer's `'delete'` action). A bulk reset that skips
162
+ * the client — the scaffold's demo `content` seeder TRUNCATEs its tables so it
163
+ * can re-run — leaves every old URL claimed by a document that no longer
164
+ * exists. The re-seeded documents then collide with their own ghosts and come
165
+ * back as `/about-2`, `/welcome-2`, and the old URLs keep answering as
166
+ * redirects to nothing (lumi-site PR04).
167
+ *
168
+ * Seeder-only, like the reset it follows: it asks no permission of its own,
169
+ * exactly as a TRUNCATE does not. The seeders that call it already run as an
170
+ * admin — `lumi seed` under `runAsCli`, the Seed page behind its admin gate.
171
+ *
172
+ * Returns how many rows were removed, per entity, so a seeder can log it.
173
+ */
174
+ declare function forgetEntityPaths(entityNames: readonly string[]): Promise<Record<string, number>>;
175
+ //#endregion
176
+ export { forgetEntityPaths, seedDetailTemplates, seedSiteChrome };
177
+ //# sourceMappingURL=seed.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"seed.d.mts","names":[],"sources":["../src/seed.ts"],"mappings":";;AA+KA;;;;AAAyD;AAyFzD;;;;AAA8D;AAuE9D;;;;;;;;AAEiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAlKK,cAAA,IAAkB,OAAO;EAAG,OAAO;AAAA;;;;;;;;;;;;;;iBAyFnC,mBAAA,IAAuB,OAAO;EAAG,OAAO;AAAA;;;;;;;;;;;;;;;;;;;iBAuExC,iBAAA,CACpB,WAAA,sBACC,OAAO,CAAC,MAAA"}
package/dist/seed.mjs ADDED
@@ -0,0 +1,2 @@
1
+ import{t as e}from"./chrome-entity-names-zYJzP-_U.mjs";import{n as t,t as n}from"./detail-template-names-CD1Y9n1U.mjs";import{n as r,s as i}from"./schema-BYrYzsmO.mjs";import{n as a,t as o}from"./site-chrome-BAtQaAqC.mjs";import{getApp as s,requireRequestSite as c,runInEverySite as l}from"@murumets-ee/core";import{eq as u}from"drizzle-orm";import{createAdminClient as d}from"@murumets-ee/core/clients";import{randomUUID as f}from"node:crypto";import{defaultChromeBlocks as p}from"@murumets-ee/blocks/library";function m(e){return p(e,()=>f())}async function h(){let t=s(),n=t.entities.get(e);return n?{created:(await l(()=>g(n),{app:t,label:`content:site-chrome`})).reduce((e,t)=>e+t.created,0)}:{created:0}}async function g(t){let n=d(t),r=0,s=n.getTable().placement;if(!s)throw Error(`${e} has no "placement" column — the chrome entity registered by this app is not the one @murumets-ee/content ships.`);for(let e of o)if(!((await n.findMany({where:u(s,e),limit:1})).length>0))try{await n.create({placement:e,label:a[e],layout:m(e)}),r++}catch(e){if(!i(e))throw e}return{created:r}}async function _(){let e=s(),t=e.entities.get(n);return t?{created:(await l(()=>y(t),{app:e,label:`content:detail-templates`})).reduce((e,t)=>e+t.created,0)}:{created:0}}function v(e){let t=e.replace(/[_-]+/g,` `).trim();return`${t.charAt(0).toUpperCase()}${t.slice(1)} detail`}async function y(e){let r=e.allFields[t],a=r?.type===`select`?r.options:[];if(a.length===0)return{created:0};let o=d(e),s=o.getTable()[t];if(!s)throw Error(`${n} has no "${t}" column — the entity registered by this app is not the one @murumets-ee/content ships.`);let c=0;for(let e of a)if(!((await o.findMany({where:u(s,e),limit:1})).length>0))try{await o.create({[t]:e,label:v(e),layout:[{id:f(),type:`documentTitle`,props:{},traits:{}}]}),c++}catch(e){if(!i(e))throw e}return{created:c}}async function b(e){let t=r.makeClient(s().db.readWrite,await c(`toolkit_paths`)),n={};for(let r of e)n[r]=(await t.deleteMany({entityType:r})).length;return n}export{b as forgetEntityPaths,_ as seedDetailTemplates,h as seedSiteChrome};
2
+ //# sourceMappingURL=seed.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"seed.mjs","names":[],"sources":["../src/seed.ts"],"sourcesContent":["/**\n * The site-chrome seeder — creates the one `site_chrome` row per placement\n * that a site needs, POPULATED, before any of them can be edited.\n *\n * Contributed as `content:site-chrome` from the `content()` plugin factory and\n * reached through the `@murumets-ee/content/seed` subpath — its own build\n * entry so the lazy import keeps `@murumets-ee/core/clients`, which carries\n * `import 'server-only'`, off the config-load path.\n *\n * ## Why a seeder at all, and why nobody has to run it\n *\n * An unseeded chrome document is indistinguishable from a site that HAS no\n * header, and both the canvas and `/v1/_chrome` treat it that way — they\n * compose nothing and let the consumer render its own fallback. That is right,\n * and it means nothing ever CREATES the row, so without this an editor has no\n * header to open.\n *\n * Until PR30 that left the gap S5 names: nothing in the printed setup path ran\n * this, so a scaffolded project booted with an empty Site list and the only\n * bootstrap was a human pressing \"New\". The plugin now declares this seeder\n * `bootstrap: true`, and `lumi migrate` runs every bootstrap seeder after it\n * applies migrations — which is already step 4 of the printed next-steps. It\n * stays an ordinary named seeder too, runnable from `lumi seed` and the admin\n * Seed page; `bootstrap` adds a trigger rather than changing what it is.\n *\n * ## Why the entity is resolved by NAME\n *\n * This package ships the chrome entity as a FACTORY (`siteChromeEntity()`),\n * because an app must pass options it cannot get from here — `withOpLog()`\n * ships from `@murumets-ee/blocks/server`, and content must not depend on\n * blocks (blocks depends back on content). So there is no entity object in\n * this package to import; there is only the name the factory stamps on\n * whatever the app instantiated.\n *\n * That name is not an assumption: `site-chrome.ts` hardcodes\n * `name: 'site_chrome'`, so an app can configure the entity but cannot rename\n * it. This package's own chrome PROVIDER resolves it the same way\n * (`app.entities.get(name)`, `null` when absent), and `@murumets-ee/auth`'s\n * default permission maps and `@murumets-ee/frontend-cache`'s\n * `DEFAULT_CHROME_ENTITIES` name the same string.\n *\n * ## Why it is `available`-gated rather than merely tolerant\n *\n * An app is free to register no chrome at all. Seeding plan F009 deferred this\n * seeder on exactly that: contributed unconditionally it would put a button on\n * the Seed page whose entire outcome is `created: 0`, which a reader takes as\n * \"this seeder is broken\", not as \"this project has no chrome\". The plugin\n * declares `available` so the button is ABSENT instead — the same contract\n * `rent:cars-demo` has, where absence follows from the plugin's absence rather\n * than from a silent no-op.\n *\n * ## Idempotent by construction, not by a flag\n *\n * Every run finds before it creates, keyed on `placement`. That is what makes\n * a second run a no-op — necessary now in a way it was not before, because\n * `lumi migrate` runs this on EVERY migrate, not once. It is also why the\n * find is a `where` on `placement` rather than a bare `limit: 1`: the old\n * `kind: 'singleton'` made \"is there a row\" the whole question, and under one\n * entity holding two rows it is the wrong question — a bare existence check\n * would see the header and never create the footer.\n *\n * ## DRAFT, deliberately — and this reverses an earlier choice\n *\n * The rows are created unpublished, so `lumi migrate` changes nothing a\n * visitor sees. That is not a detail; it is what makes `bootstrap: true` safe,\n * because this now runs on EVERY migrate, in production, against a database\n * full of a customer's content (see `PluginSeeder.bootstrap`). A provisioning\n * step that silently alters the public site is not additive.\n *\n * This file used to create them PUBLISHED, reasoning that \"a draft-seeded site\n * is indistinguishable from an unseeded one to every public reader\" and that\n * \"the rows are EMPTY, so publishing them renders nothing\". The first half is\n * true and is the POINT: an empty chrome IS unseeded, and a public reader\n * should see exactly what they saw before. The second half was measured and is\n * false.\n *\n * Measured, on a running front-astro against a real CMS: an empty PUBLISHED\n * region arrives on the wire as `{ blocks: [] }`, which `placeRegion` reads as\n * `hidden` — \"the page hid this on purpose\" — and `rendersFallback` returns\n * false for `hidden`. So publishing an empty row does not render nothing; it\n * renders nothing AND SUPPRESSES THE TEMPLATE'S OWN FALLBACK. A freshly\n * scaffolded site lost its `<header>` and `<footer>` landmarks entirely, where\n * before it rendered the fallback header with its Home link and nav.\n *\n * `null` (no published row) reads as `fallback` instead, which is the answer\n * that leaves the delivery template in charge until somebody has actually\n * authored a header. The moment they do and press Publish, the CMS takes over\n * — ordinary publish semantics, the same as every other document.\n *\n * ## EMPTY is what starved the canvas — and only the SITE was ever considered\n *\n * The section above is right and stays. Its blind spot is that it reasons\n * entirely about what a VISITOR sees. The rows it creates were also `layout:\n * []`, and the comment on that line said an empty tree \"is a header with no\n * blocks yet, which is what the editor opens onto\" — which is true, and is the\n * defect.\n *\n * The live site has a fallback: no published chrome row reads as `fallback`\n * and the delivery template renders its own header. **The editing canvas has\n * no fallback.** Its only channel for chrome is these rows — it is React in the\n * admin app and cannot render an Astro template's markup — so an empty row\n * composes to zero blocks and `CanvasDocument` emits a bare `<div>` with\n * nothing in it. Every page, forever, on every project ever scaffolded. The\n * site looked right the whole time, which is why no gate and no person caught\n * it for months (`editor` D014).\n *\n * So the two properties are independent and both are required: the rows are\n * DRAFT (nothing a visitor sees changes) and NON-EMPTY (the canvas has a header\n * and a footer to paint). Draft is what makes non-empty safe — the tree is\n * real, and no visitor sees it until somebody publishes.\n *\n * ## What this canNOT fix, stated because it is the interesting half\n *\n * This seeder is ADDITIVE: `if (existing.length > 0) continue`. An install\n * whose rows already exist — every project bootstrapped before this change —\n * keeps its empty ones, and must, because overwriting an authored header is the\n * one thing a bootstrap seeder may never do. The same hole reopens for anyone\n * who deletes their header's last block.\n *\n * That half belongs to the canvas and is fixed there: an empty non-page\n * document now renders a NAMED region band rather than nothing\n * (`block-editor-canvas.tsx`'s `EndAddZone`). This seeder decides what a NEW\n * site starts with; the canvas decides that an empty region is never invisible.\n *\n * ## What it assumes about its caller\n *\n * An admin context — `runSeederAction`, `lumi seed` and `lumi migrate`'s\n * bootstrap pass all establish one. Auth, the admin-only role check and the\n * production gate live there.\n *\n * The entity NAME and the `available` predicate live one module over\n * (`./chrome-entity-names.ts`), which imports nothing — `plugin.ts` needs the\n * predicate statically and must not pull this file's `server-only` client\n * along with it.\n */\n\nimport { randomUUID } from 'node:crypto'\nimport { type DefaultChromeBlock, defaultChromeBlocks } from '@murumets-ee/blocks/library'\nimport { getApp, requireRequestSite, runInEverySite } from '@murumets-ee/core'\nimport { createAdminClient } from '@murumets-ee/core/clients'\nimport type { Entity } from '@murumets-ee/entity'\nimport { eq } from 'drizzle-orm'\nimport { CHROME_ENTITY_NAME } from './chrome-entity-names.js'\nimport {\n DETAIL_TEMPLATE_ENTITY_NAME,\n DETAIL_TEMPLATE_TARGET_FIELD,\n} from './detail-template-names.js'\nimport {\n CHROME_PLACEMENT_LABELS,\n CHROME_PLACEMENTS,\n type ChromePlacement,\n} from './entities/site-chrome.js'\nimport { isUniqueViolation } from './paths/apply-path.js'\nimport { toolkitPathsTable } from './paths/schema.js'\n\n/**\n * The block tree a bootstrapped chrome row starts life with is the library's\n * DEFAULT tree — `defaultChromeBlocks` in `@murumets-ee/blocks/library`, the\n * same function a frontend calls to draw a region the CMS has not published\n * (davatoidud-toolkit PR10). One definition, so a new site's canvas and its\n * visitors cannot be shown two different defaults.\n *\n * Minted with `randomUUID`: block ids are the keys a page's `_overrides` map\n * is written under (`compose-chrome.ts`'s `tagScopedRegion` uses `block.id` as\n * `scopeBlockId`), so two rows sharing one id would make a per-page override\n * of the header also target the footer.\n *\n * What the default holds (the library's Site Header and Site Footer, naming\n * the `main` and `footer` menus, with no copy of ours) and why: see that\n * module's docblock.\n */\nfunction defaultChromeLayout(placement: ChromePlacement): DefaultChromeBlock[] {\n return defaultChromeBlocks(placement, () => randomUUID())\n}\n\nexport async function seedSiteChrome(): Promise<{ created: number }> {\n const app = getApp()\n const entity = app.entities.get(CHROME_ENTITY_NAME)\n // Absent rather than thrown: `available` guarantees the app registered the\n // entity at the moment the list was built, and a config reloaded between\n // list and run should produce \"nothing to do\", not a stack trace.\n if (!entity) return { created: 0 }\n\n // Chrome is per site (multisite D002): every site gets its own header and\n // footer. In a run that names a site (`lumi seed --site`), that site; on a\n // single-site install, the one site; otherwise once per active site — so a\n // site added since the last run gets its chrome on the next `lumi migrate`.\n const perSite = await runInEverySite(() => seedSiteChromeInSite(entity), {\n app,\n label: 'content:site-chrome',\n })\n return { created: perSite.reduce((sum, r) => sum + r.created, 0) }\n}\n\nasync function seedSiteChromeInSite(entity: Entity): Promise<{ created: number }> {\n const client = createAdminClient(entity)\n let created = 0\n\n // Column refs through `getTable()` — the sanctioned accessor — never a bare\n // property on a dynamically-generated table, which would put an `any` into\n // the WHERE clause.\n const placementColumn = client.getTable().placement\n if (!placementColumn) {\n // Unreachable while the entity declares the field, and cheaper to state\n // than to debug: without it a missing column would make `eq()` build a\n // WHERE against `undefined` and the find would silently match nothing —\n // so every run would try to create, and every run after the first would\n // be absorbed by the unique-violation catch below as though it had\n // succeeded.\n throw new Error(\n `${CHROME_ENTITY_NAME} has no \"placement\" column — the chrome entity registered by this ` +\n `app is not the one @murumets-ee/content ships.`,\n )\n }\n\n for (const placement of CHROME_PLACEMENTS) {\n // Keyed on `placement`, which is also the unique column — so this find is\n // asking exactly the question the database constraint answers, and a race\n // between two callers ends in a unique violation rather than a duplicate.\n const existing = await client.findMany({ where: eq(placementColumn, placement), limit: 1 })\n if (existing.length > 0) continue\n try {\n // A POPULATED tree, not `[]` — see \"Empty is what starves the canvas\"\n // in this file's header. `defaultChromeLayout` is the one place that\n // decides what a bootstrapped region holds.\n // No `status` — `publishable()` defaults to `draft`, which is the whole\n // of the \"changes nothing a visitor sees\" property. See the header.\n await client.create({\n placement,\n label: CHROME_PLACEMENT_LABELS[placement],\n layout: defaultChromeLayout(placement),\n })\n created++\n } catch (err) {\n // Losing the find-then-create race is a SUCCESS, not a failure: someone\n // else created the row this run was about to create, which is the\n // outcome either way. `placement`'s unique constraint is what makes that\n // safe to assume — the violation can only be the row we just looked for.\n //\n // Worth the four lines rather than trusting the find, because `lumi\n // migrate`'s bootstrap pass runs this on every migrate and two deploys\n // can overlap. Payload's equivalent conditions its write on an existence\n // check that is simply WRONG on Postgres and survives only because the\n // adapter happens to re-check underneath (R036).\n if (!isUniqueViolation(err)) throw err\n }\n }\n\n return { created }\n}\n\n/**\n * Create the DRAFT detail template for every entity the app's\n * `detail_template` entity targets (block-library D009), so each has a document\n * an editor can open. Idempotent: an existing row for a target is left alone.\n *\n * The targets are read from the `target` select's own options — the list the\n * app's `detailTemplateEntity({ for })` validated at boot — so the seeder and\n * the entity cannot disagree about which templates exist.\n *\n * A draft, like chrome's: until someone publishes it, every row of the target\n * keeps drawing the plain fallback page, so a migrate changes nothing a visitor\n * sees.\n */\nexport async function seedDetailTemplates(): Promise<{ created: number }> {\n const app = getApp()\n const entity = app.entities.get(DETAIL_TEMPLATE_ENTITY_NAME)\n if (!entity) return { created: 0 }\n const perSite = await runInEverySite(() => seedDetailTemplatesInSite(entity), {\n app,\n label: 'content:detail-templates',\n })\n return { created: perSite.reduce((sum, r) => sum + r.created, 0) }\n}\n\n/** `yhikas_room_type` → `Yhikas room type`. English; an editor renames it. */\nfunction defaultTemplateLabel(target: string): string {\n const words = target.replace(/[_-]+/g, ' ').trim()\n return `${words.charAt(0).toUpperCase()}${words.slice(1)} detail`\n}\n\nasync function seedDetailTemplatesInSite(entity: Entity): Promise<{ created: number }> {\n const targetField = entity.allFields[DETAIL_TEMPLATE_TARGET_FIELD]\n const targets: readonly string[] = targetField?.type === 'select' ? targetField.options : []\n if (targets.length === 0) return { created: 0 }\n\n const client = createAdminClient(entity)\n const targetColumn = client.getTable()[DETAIL_TEMPLATE_TARGET_FIELD]\n if (!targetColumn) {\n throw new Error(\n `${DETAIL_TEMPLATE_ENTITY_NAME} has no \"${DETAIL_TEMPLATE_TARGET_FIELD}\" column — the ` +\n `entity registered by this app is not the one @murumets-ee/content ships.`,\n )\n }\n\n let created = 0\n for (const target of targets) {\n const existing = await client.findMany({ where: eq(targetColumn, target), limit: 1 })\n if (existing.length > 0) continue\n try {\n await client.create({\n [DETAIL_TEMPLATE_TARGET_FIELD]: target,\n label: defaultTemplateLabel(target),\n // One bound title, not `[]`: an empty canvas offers nothing to click,\n // and the title is the one value every detail declaration can feed.\n layout: [{ id: randomUUID(), type: 'documentTitle', props: {}, traits: {} }],\n })\n created++\n } catch (err) {\n // Losing the find-then-create race is the outcome wanted either way; the\n // unique `target` is what makes that safe to assume.\n if (!isUniqueViolation(err)) throw err\n }\n }\n return { created }\n}\n\n/**\n * Forget every `toolkit_paths` row — canonical and redirect history — for the\n * named entities. For a seeder that empties those entities' tables in bulk.\n *\n * `AdminClient.delete` drops a document's paths when the row is physically\n * removed (the path-index writer's `'delete'` action). A bulk reset that skips\n * the client — the scaffold's demo `content` seeder TRUNCATEs its tables so it\n * can re-run — leaves every old URL claimed by a document that no longer\n * exists. The re-seeded documents then collide with their own ghosts and come\n * back as `/about-2`, `/welcome-2`, and the old URLs keep answering as\n * redirects to nothing (lumi-site PR04).\n *\n * Seeder-only, like the reset it follows: it asks no permission of its own,\n * exactly as a TRUNCATE does not. The seeders that call it already run as an\n * admin — `lumi seed` under `runAsCli`, the Seed page behind its admin gate.\n *\n * Returns how many rows were removed, per entity, so a seeder can log it.\n */\nexport async function forgetEntityPaths(\n entityNames: readonly string[],\n): Promise<Record<string, number>> {\n // The seeding site's paths only (multisite phase 03) — a reset of one\n // site's pages must not strip another site's URLs.\n const paths = toolkitPathsTable.makeClient(\n getApp().db.readWrite,\n await requireRequestSite('toolkit_paths'),\n )\n const removed: Record<string, number> = {}\n for (const entityType of entityNames) {\n removed[entityType] = (await paths.deleteMany({ entityType })).length\n }\n return removed\n}\n"],"mappings":"+fA2KA,SAAS,EAAoB,EAAkD,CAC7E,OAAO,EAAoB,MAAiB,EAAW,CAAC,CAC1D,CAEA,eAAsB,GAA+C,CACnE,IAAM,EAAM,EAAO,EACb,EAAS,EAAI,SAAS,IAAI,CAAkB,EAclD,OAVK,EAUE,CAAE,SAAS,MAJI,MAAqB,EAAqB,CAAM,EAAG,CACvE,MACA,MAAO,qBACT,CAAC,EAAA,CACyB,QAAQ,EAAK,IAAM,EAAM,EAAE,QAAS,CAAC,CAAE,EAV7C,CAAE,QAAS,CAAE,CAWnC,CAEA,eAAe,EAAqB,EAA8C,CAChF,IAAM,EAAS,EAAkB,CAAM,EACnC,EAAU,EAKR,EAAkB,EAAO,SAAS,CAAC,CAAC,UAC1C,GAAI,CAAC,EAOH,MAAU,MACR,GAAG,EAAmB,iHAExB,EAGF,IAAK,IAAM,KAAa,EAKtB,MAAI,MADmB,EAAO,SAAS,CAAE,MAAO,EAAG,EAAiB,CAAS,EAAG,MAAO,CAAE,CAAC,EAAA,CAC7E,OAAS,GACtB,GAAI,CAMF,MAAM,EAAO,OAAO,CAClB,YACA,MAAO,EAAwB,GAC/B,OAAQ,EAAoB,CAAS,CACvC,CAAC,EACD,GACF,OAAS,EAAK,CAWZ,GAAI,CAAC,EAAkB,CAAG,EAAG,MAAM,CACrC,CAGF,MAAO,CAAE,SAAQ,CACnB,CAeA,eAAsB,GAAoD,CACxE,IAAM,EAAM,EAAO,EACb,EAAS,EAAI,SAAS,IAAI,CAA2B,EAM3D,OALK,EAKE,CAAE,SAAS,MAJI,MAAqB,EAA0B,CAAM,EAAG,CAC5E,MACA,MAAO,0BACT,CAAC,EAAA,CACyB,QAAQ,EAAK,IAAM,EAAM,EAAE,QAAS,CAAC,CAAE,EAL7C,CAAE,QAAS,CAAE,CAMnC,CAGA,SAAS,EAAqB,EAAwB,CACpD,IAAM,EAAQ,EAAO,QAAQ,SAAU,GAAG,CAAC,CAAC,KAAK,EACjD,MAAO,GAAG,EAAM,OAAO,CAAC,CAAC,CAAC,YAAY,IAAI,EAAM,MAAM,CAAC,EAAE,QAC3D,CAEA,eAAe,EAA0B,EAA8C,CACrF,IAAM,EAAc,EAAO,UAAU,GAC/B,EAA6B,GAAa,OAAS,SAAW,EAAY,QAAU,CAAC,EAC3F,GAAI,EAAQ,SAAW,EAAG,MAAO,CAAE,QAAS,CAAE,EAE9C,IAAM,EAAS,EAAkB,CAAM,EACjC,EAAe,EAAO,SAAS,CAAC,CAAC,GACvC,GAAI,CAAC,EACH,MAAU,MACR,GAAG,EAA4B,WAAW,EAA6B,wFAEzE,EAGF,IAAI,EAAU,EACd,IAAK,IAAM,KAAU,EAEnB,MAAI,MADmB,EAAO,SAAS,CAAE,MAAO,EAAG,EAAc,CAAM,EAAG,MAAO,CAAE,CAAC,EAAA,CACvE,OAAS,GACtB,GAAI,CACF,MAAM,EAAO,OAAO,EACjB,GAA+B,EAChC,MAAO,EAAqB,CAAM,EAGlC,OAAQ,CAAC,CAAE,GAAI,EAAW,EAAG,KAAM,gBAAiB,MAAO,CAAC,EAAG,OAAQ,CAAC,CAAE,CAAC,CAC7E,CAAC,EACD,GACF,OAAS,EAAK,CAGZ,GAAI,CAAC,EAAkB,CAAG,EAAG,MAAM,CACrC,CAEF,MAAO,CAAE,SAAQ,CACnB,CAoBA,eAAsB,EACpB,EACiC,CAGjC,IAAM,EAAQ,EAAkB,WAC9B,EAAO,CAAC,CAAC,GAAG,UACZ,MAAM,EAAmB,eAAe,CAC1C,EACM,EAAkC,CAAC,EACzC,IAAK,IAAM,KAAc,EACvB,EAAQ,IAAe,MAAM,EAAM,WAAW,CAAE,YAAW,CAAC,EAAA,CAAG,OAEjE,OAAO,CACT"}
@@ -0,0 +1,2 @@
1
+ import{n as e}from"./versionable-Br_G5VGR.mjs";import{auditable as t,defineEntity as n,field as r,publishable as i}from"@murumets-ee/entity";const a=[`header`,`footer`],o={header:`Site header`,footer:`Site footer`},s={label:{label:`Name`,description:`Shown in the admin list, and as the editor title when this document is opened by itself. Visitors never see it.`}};function c(e){let t={...s};for(let[n,r]of Object.entries(e??{}))t[n]={...t[n],...r};return t}function l(o){let s=o?.viewRole??`editor`,l=o?.updateRole??`editor`,u=o?.adminRole??`admin`,d=o?.maxVersions??30;return n({name:`site_chrome`,site:`bound`,kind:`collection`,fields:{placement:r.select({options:a,required:!0,unique:!0}),label:r.text({required:!0}),layout:r.blockTree({translatable:!0,areaFrom:`placement`,...o?.environment!==void 0&&{environment:o.environment}})},behaviors:[t(),i(),e({maxPerDoc:d}),...o?.behaviors??[]],scope:`global`,access:{view:s,create:u,update:l,delete:u},admin:{icon:`panel-top`,description:`Blocks rendered around every page that does not override them.`,rootFields:[`label`],fieldOverrides:c(o?.admin?.fieldOverrides),titleField:`label`,group:`Site`,disableCreate:!0}})}l().name;export{o as n,l as r,a as t};
2
+ //# sourceMappingURL=site-chrome-BAtQaAqC.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"site-chrome-BAtQaAqC.mjs","names":[],"sources":["../src/entities/site-chrome.ts"],"sourcesContent":["/**\n * `site_chrome` — the block documents that hold the site chrome, one row per\n * PLACEMENT, so a header is edited on the same canvas as the page it sits\n * above instead of being typed into a JSON textarea.\n *\n * ## Placement, not type\n *\n * There is ONE entity. A chrome document says where it appears by carrying a\n * `placement` value, and `'header'` / `'footer'` are exactly the region keys\n * `GET /v1/_chrome` emits — so the provider maps placement to region by\n * identity rather than through a lookup table. That is the whole of the model.\n *\n * It replaces three entities (`site_header`, `site_footer`, and a single-row\n * site layout) and six hardcoded name lists, the most consequential of which\n * was not a list of names at all: `apps/admin/lib/chrome-documents.ts` carried\n * a module constant pairing each chrome entity with a\n * `position: 'before' | 'after'`. That was the placement flag, written in code,\n * in the app, where no operator could reach it and no plugin could extend it.\n *\n * ## What survives, and why it had to\n *\n * **A separate ROW per placement.** `publishable()` and `versionable()` are\n * per-row, so a header and a footer sharing one row could not be published or\n * rolled back independently. That constraint is real and this model honours\n * it — it is only the entity TYPE that collapses, never the row.\n *\n * **Peer-document editing.** The editor composes header, page and footer as\n * peers with per-document undo (`plan/site-chrome` D001 / D003). It never\n * keyed on the entity NAME to do that: `DocumentSlice.entity` is the only\n * discriminator and `pageDocumentId` is explicit state, precisely so that no\n * entity-name allowlist is needed. One name in place of two costs it nothing.\n *\n * ## Cardinality — why `kind: 'collection'` and a unique column\n *\n * `kind: 'singleton'` is enforced now (`plan/site-chrome` D011), and\n * `assertSingletonSlotFree` counts every live row of an entity — so an entity\n * holding a header row AND a footer row trips the gate on the second insert.\n * The kind and the collapse are mutually exclusive, which is why phase 09 lists\n * \"the singleton kind\" among the things that go.\n *\n * `placement`'s `unique: true` is what takes over the half that mattered: the\n * rule becomes one row per placement, enforced at the DATABASE for every\n * writer rather than in one client. On a `trashable()` entity — which this is,\n * by auto-enable, because it is publishable — the schema builder re-emits that\n * as a PARTIAL unique index (`WHERE \"deletedAt\" IS NULL`), so trashing a\n * header frees its placement for a replacement instead of wedging it.\n *\n * What is NOT carried over is the singleton DELETE rule (\"never leave zero\").\n * Deleting the only header is now possible, and the site falls back to the\n * delivery template's own markup — the same thing it does before the first\n * header is ever created. `trashable()` makes it recoverable.\n *\n * ## `admin.disableCreate` is correctness, not a workaround\n *\n * The block-editor create branch auto-POSTs `{ title: 'Untitled' }` and\n * nothing else, so any entity on that branch with a second required field\n * would reject on `/new`. `placement` is exactly such a field.\n *\n * Suppressing the affordance is also the honest answer: a third chrome row\n * would render NOWHERE, because the delivery template has exactly two chrome\n * slots. A new placement is a code change on both sides — a `placement` option\n * here and a slot in `BaseLayout.astro` — and that coupling is what keeps the\n * enum from accumulating values nothing renders.\n *\n * The two rows a site needs are created by the `content:site-chrome` seeder,\n * which `lumi migrate` runs automatically as a `bootstrap` seeder. Nobody has\n * to press anything.\n *\n * ## Wiring\n *\n * @example\n * ```ts\n * // apps/<app>/entities/site-chrome.ts\n * import { withOpLog } from '@murumets-ee/blocks/server'\n * import { siteChromeEntity } from '@murumets-ee/content/entities'\n *\n * export const SiteChrome = siteChromeEntity({ environment: 'page' })\n * .with(withOpLog()) // see below — the app layers this on\n * ```\n *\n * `withOpLog()` is deliberately NOT in the factory: it ships from\n * `@murumets-ee/blocks/server`, and `@murumets-ee/content` must not depend on\n * `@murumets-ee/blocks` (blocks depends back on content). The app layers it at\n * config time. **Without it the block editor 404s** — `editor-state` refuses a\n * document whose entity has no op-log behavior — so an app that registers this\n * and skips it gets a chrome document it cannot open.\n *\n * Behaviors the factory does apply:\n *\n * - `auditable()` — created/updated stamps\n * - `publishable()` — a header is drafted and published like any document,\n * which is the point of it being one\n * - `versionable({ maxPerDoc })` — per-locale history\n */\n\nimport type { Behavior, EntityAdminConfig } from '@murumets-ee/entity'\nimport { auditable, defineEntity, field, publishable } from '@murumets-ee/entity'\nimport { versionable } from '../behaviors/versionable.js'\n\n/**\n * Where a chrome document appears on every page that does not override it.\n *\n * These strings ARE the `/v1/_chrome` region keys and the delivery template's\n * slot names. Adding one means adding a slot to `BaseLayout.astro` in the same\n * change, or the value names a region nothing renders.\n */\nexport const CHROME_PLACEMENTS = ['header', 'footer'] as const\n\n/** One placement. Also a region name on the wire — same string, by design. */\nexport type ChromePlacement = (typeof CHROME_PLACEMENTS)[number]\n\n/** The default label a bootstrap row gets, per placement. */\nexport const CHROME_PLACEMENT_LABELS: Readonly<Record<ChromePlacement, string>> = {\n header: 'Site header',\n footer: 'Site footer',\n}\n\n/** Optional overrides apps can apply when calling the factory. */\nexport interface SiteChromeEntityOptions {\n /**\n * Roles allowed to read the document. Default `'editor'`.\n *\n * ADVISORY entity-level metadata that does NOT govern the public content API\n * — that path resolves a role and asks `buildPermissionChecker`.\n * `site_chrome:view` is seeded onto `public` by\n * `buildInitialRoleDefinitions()` so a fresh install serves its chrome;\n * changing this option does not change that.\n */\n viewRole?: string\n /** Roles allowed to edit a document's blocks. Default `'editor'`. */\n updateRole?: string\n /** Roles allowed to create/delete a chrome row. Default `'admin'`. */\n adminRole?: string\n /**\n * The block environment the editor offers in this document's canvas. Pass\n * the same value the site's page entity uses so a header can be built from\n * the same vocabulary. Omitted means \"no environment restriction\".\n */\n environment?: string\n /** Override the version-retention cap. Default 30 versions per locale. */\n maxVersions?: number\n /**\n * Extra behaviors layered after the factory's own.\n *\n * This is how an app supplies `withOpLog()`, which is REQUIRED for the block\n * editor to open the document and which cannot live in this package —\n * `withOpLog` ships from `@murumets-ee/blocks/server`, and\n * `@murumets-ee/content` must not depend on `@murumets-ee/blocks` because\n * blocks depends back on content.\n *\n * Typed as field-contributing-nothing on purpose: the entity's inferred field\n * map is part of its public type, and a behavior that added fields here would\n * widen it silently. `withOpLog()` adds none, which its own docblock states.\n */\n behaviors?: readonly Behavior<Record<never, never>, string>[]\n /**\n * How the Page-settings tab and forms NAME this entity's fields. Merged per\n * field over the factory's English defaults, so a project adds only what it\n * translates — typically `labelKey` / `descriptionKey` into its OWN catalogue\n * (e.g. `{ label: { labelKey: 'SiteChrome.fields.label', descriptionKey:\n * 'SiteChrome.fields.labelHelp' } }`). Resolution is key → English label →\n * humanized name, never the raw field name (editor-curation D003).\n */\n admin?: {\n fieldOverrides?: EntityAdminConfig['fieldOverrides']\n }\n}\n\n/**\n * The English defaults every project gets. `label` is the only root field the\n * Page-settings tab offers (`rootFields` below), so it is the one an operator\n * reads; without this it humanized to \"Label\", untranslatable.\n */\nconst DEFAULT_FIELD_OVERRIDES: NonNullable<EntityAdminConfig['fieldOverrides']> = {\n label: {\n label: 'Name',\n description:\n 'Shown in the admin list, and as the editor title when this document is opened by itself. Visitors never see it.',\n },\n}\n\n/** Per-field shallow merge — a project's key never drops the default English fallback. */\nfunction mergeFieldOverrides(\n project: EntityAdminConfig['fieldOverrides'],\n): NonNullable<EntityAdminConfig['fieldOverrides']> {\n const merged: NonNullable<EntityAdminConfig['fieldOverrides']> = { ...DEFAULT_FIELD_OVERRIDES }\n for (const [name, override] of Object.entries(project ?? {})) {\n merged[name] = { ...merged[name], ...override }\n }\n return merged\n}\n\n/**\n * Build the `site_chrome` entity — the block documents rendered around every\n * page that does not override them.\n *\n * The return type is intentionally INFERRED (no annotation) so `'site_chrome'`\n * stays a string literal in the caller's `Entity<…, 'site_chrome', …>` view;\n * annotating it as bare `Entity` widens the name to `string`, which propagates\n * into the resolved-permissions union as a `${string}:view` template. The\n * compile-time guard at the foot of this file is what keeps that true.\n */\nexport function siteChromeEntity(options?: SiteChromeEntityOptions) {\n const viewRole = options?.viewRole ?? 'editor'\n const updateRole = options?.updateRole ?? 'editor'\n const adminRole = options?.adminRole ?? 'admin'\n const maxVersions = options?.maxVersions ?? 30\n\n return defineEntity({\n name: 'site_chrome',\n // Each site has its own header and footer (multisite D002/D005): the\n // placement unique is per site, so every site holds one of each.\n site: 'bound',\n // NOT `'singleton'` — see the cardinality note in this file's header. One\n // row per placement, enforced by `placement`'s own unique constraint.\n kind: 'collection',\n fields: {\n // THE placement flag. Deliberately not translatable: where a document\n // appears is a property of the document, not of a translation of it.\n //\n // `unique: true` is load-bearing rather than decorative — it is the\n // whole cardinality rule. `indexed` is NOT set alongside it: the schema\n // builder skips indexing unique fields because the constraint already\n // emits its own btree.\n placement: field.select({\n options: CHROME_PLACEMENTS,\n required: true,\n unique: true,\n }),\n // The human name of the row, shown in the list and as the editor's\n // title. `titleField` below points at it explicitly so nothing has to\n // fall back to a heuristic — or to a `title` column this entity has\n // never had.\n label: field.text({ required: true }),\n // The ONE blockTree field, which is what makes this a canvas.\n //\n // `translatable: true`, load-bearing: without it the column lives on the\n // base row, so writing the English header overwrites the Estonian one\n // and every locale then serves English. The write succeeds; only a\n // per-locale READ reveals it.\n //\n // `translation: 'shared'` is not an option — the schema builder throws on\n // it for blockTree — so each locale's chrome is an independent tree. That\n // matches the editor, whose session id is `${id}:${locale}`.\n //\n // `areaFrom: 'placement'` is what makes this row a HEADER or a FOOTER to\n // everything that asks (davatoidud-toolkit D006): the canvas, op-commit,\n // the entity write path and the agent catalogue all call\n // `resolveDocumentArea(entity, row)`, which reads this declaration. A\n // header block is offered and accepted in the header row and nowhere else.\n layout: field.blockTree({\n translatable: true,\n areaFrom: 'placement',\n ...(options?.environment !== undefined && { environment: options.environment }),\n }),\n },\n behaviors: [\n auditable(),\n publishable(),\n versionable({ maxPerDoc: maxVersions }),\n ...(options?.behaviors ?? []),\n ],\n scope: 'global',\n access: {\n view: viewRole,\n // Restricts WHO may create; `placement`'s unique constraint restricts\n // HOW MANY. Both apply, and neither substitutes for the other.\n create: adminRole,\n update: updateRole,\n delete: adminRole,\n },\n admin: {\n icon: 'panel-top',\n description: 'Blocks rendered around every page that does not override them.',\n // `layout` is a blockTree and is excluded from forms by type, so this is\n // only about what the block editor's settings sheet offers. `placement`\n // is deliberately absent: moving a header to the footer slot by editing\n // a dropdown is not an operation anyone wants, and it would collide with\n // the row already holding that placement.\n rootFields: ['label'],\n fieldOverrides: mergeFieldOverrides(options?.admin?.fieldOverrides),\n titleField: 'label',\n group: 'Site',\n // See this file's header — the rows are bootstrapped, not hand-created.\n disableCreate: true,\n },\n })\n}\n\n/**\n * COMPILE-TIME guard for the inferred-return-type note above.\n *\n * It lives in `src/`, NOT in `tests/`, and that placement is the point: this\n * package's tsconfig includes `src` only, so a type-level assertion under\n * `tests/` is compiled by no gate at all — vitest transpiles without checking\n * types. A guard nobody compiles is worse than no guard, because it reads as\n * one.\n *\n * Negative-tested: annotating `siteChromeEntity`'s return as bare `Entity`\n * makes this line fail `tsc --noEmit` with TS2322.\n */\nconst _chromeNameStaysLiteral: 'site_chrome' = siteChromeEntity().name\nvoid _chromeNameStaysLiteral\n"],"mappings":"6IA0GA,MAAa,EAAoB,CAAC,SAAU,QAAQ,EAMvC,EAAqE,CAChF,OAAQ,cACR,OAAQ,aACV,EA0DM,EAA4E,CAChF,MAAO,CACL,MAAO,OACP,YACE,iHACJ,CACF,EAGA,SAAS,EACP,EACkD,CAClD,IAAM,EAA2D,CAAE,GAAG,CAAwB,EAC9F,IAAK,GAAM,CAAC,EAAM,KAAa,OAAO,QAAQ,GAAW,CAAC,CAAC,EACzD,EAAO,GAAQ,CAAE,GAAG,EAAO,GAAO,GAAG,CAAS,EAEhD,OAAO,CACT,CAYA,SAAgB,EAAiB,EAAmC,CAClE,IAAM,EAAW,GAAS,UAAY,SAChC,EAAa,GAAS,YAAc,SACpC,EAAY,GAAS,WAAa,QAClC,EAAc,GAAS,aAAe,GAE5C,OAAO,EAAa,CAClB,KAAM,cAGN,KAAM,QAGN,KAAM,aACN,OAAQ,CAQN,UAAW,EAAM,OAAO,CACtB,QAAS,EACT,SAAU,GACV,OAAQ,EACV,CAAC,EAKD,MAAO,EAAM,KAAK,CAAE,SAAU,EAAK,CAAC,EAiBpC,OAAQ,EAAM,UAAU,CACtB,aAAc,GACd,SAAU,YACV,GAAI,GAAS,cAAgB,IAAA,IAAa,CAAE,YAAa,EAAQ,WAAY,CAC/E,CAAC,CACH,EACA,UAAW,CACT,EAAU,EACV,EAAY,EACZ,EAAY,CAAE,UAAW,CAAY,CAAC,EACtC,GAAI,GAAS,WAAa,CAAC,CAC7B,EACA,MAAO,SACP,OAAQ,CACN,KAAM,EAGN,OAAQ,EACR,OAAQ,EACR,OAAQ,CACV,EACA,MAAO,CACL,KAAM,YACN,YAAa,iEAMb,WAAY,CAAC,OAAO,EACpB,eAAgB,EAAoB,GAAS,OAAO,cAAc,EAClE,WAAY,QACZ,MAAO,OAEP,cAAe,EACjB,CACF,CAAC,CACH,CAc+C,EAAiB,CAAC,CAAC"}
@@ -0,0 +1,98 @@
1
+ import { t as VersionableFields } from "./versionable-CRvV87nZ.mjs";
2
+ import { Behavior, EntityAdminConfig } from "@murumets-ee/entity";
3
+
4
+ //#region src/entities/site-chrome.d.ts
5
+ /**
6
+ * Where a chrome document appears on every page that does not override it.
7
+ *
8
+ * These strings ARE the `/v1/_chrome` region keys and the delivery template's
9
+ * slot names. Adding one means adding a slot to `BaseLayout.astro` in the same
10
+ * change, or the value names a region nothing renders.
11
+ */
12
+ declare const CHROME_PLACEMENTS: readonly ["header", "footer"];
13
+ /** One placement. Also a region name on the wire — same string, by design. */
14
+ type ChromePlacement = (typeof CHROME_PLACEMENTS)[number];
15
+ /** The default label a bootstrap row gets, per placement. */
16
+ declare const CHROME_PLACEMENT_LABELS: Readonly<Record<ChromePlacement, string>>;
17
+ /** Optional overrides apps can apply when calling the factory. */
18
+ interface SiteChromeEntityOptions {
19
+ /**
20
+ * Roles allowed to read the document. Default `'editor'`.
21
+ *
22
+ * ADVISORY entity-level metadata that does NOT govern the public content API
23
+ * — that path resolves a role and asks `buildPermissionChecker`.
24
+ * `site_chrome:view` is seeded onto `public` by
25
+ * `buildInitialRoleDefinitions()` so a fresh install serves its chrome;
26
+ * changing this option does not change that.
27
+ */
28
+ viewRole?: string;
29
+ /** Roles allowed to edit a document's blocks. Default `'editor'`. */
30
+ updateRole?: string;
31
+ /** Roles allowed to create/delete a chrome row. Default `'admin'`. */
32
+ adminRole?: string;
33
+ /**
34
+ * The block environment the editor offers in this document's canvas. Pass
35
+ * the same value the site's page entity uses so a header can be built from
36
+ * the same vocabulary. Omitted means "no environment restriction".
37
+ */
38
+ environment?: string;
39
+ /** Override the version-retention cap. Default 30 versions per locale. */
40
+ maxVersions?: number;
41
+ /**
42
+ * Extra behaviors layered after the factory's own.
43
+ *
44
+ * This is how an app supplies `withOpLog()`, which is REQUIRED for the block
45
+ * editor to open the document and which cannot live in this package —
46
+ * `withOpLog` ships from `@murumets-ee/blocks/server`, and
47
+ * `@murumets-ee/content` must not depend on `@murumets-ee/blocks` because
48
+ * blocks depends back on content.
49
+ *
50
+ * Typed as field-contributing-nothing on purpose: the entity's inferred field
51
+ * map is part of its public type, and a behavior that added fields here would
52
+ * widen it silently. `withOpLog()` adds none, which its own docblock states.
53
+ */
54
+ behaviors?: readonly Behavior<Record<never, never>, string>[];
55
+ /**
56
+ * How the Page-settings tab and forms NAME this entity's fields. Merged per
57
+ * field over the factory's English defaults, so a project adds only what it
58
+ * translates — typically `labelKey` / `descriptionKey` into its OWN catalogue
59
+ * (e.g. `{ label: { labelKey: 'SiteChrome.fields.label', descriptionKey:
60
+ * 'SiteChrome.fields.labelHelp' } }`). Resolution is key → English label →
61
+ * humanized name, never the raw field name (editor-curation D003).
62
+ */
63
+ admin?: {
64
+ fieldOverrides?: EntityAdminConfig['fieldOverrides'];
65
+ };
66
+ }
67
+ /**
68
+ * Build the `site_chrome` entity — the block documents rendered around every
69
+ * page that does not override them.
70
+ *
71
+ * The return type is intentionally INFERRED (no annotation) so `'site_chrome'`
72
+ * stays a string literal in the caller's `Entity<…, 'site_chrome', …>` view;
73
+ * annotating it as bare `Entity` widens the name to `string`, which propagates
74
+ * into the resolved-permissions union as a `${string}:view` template. The
75
+ * compile-time guard at the foot of this file is what keeps that true.
76
+ */
77
+ declare function siteChromeEntity(options?: SiteChromeEntityOptions): import("@murumets-ee/entity").Entity<{
78
+ id: import("@murumets-ee/entity").IdField;
79
+ } & import("@murumets-ee/entity").AuditableFields & import("@murumets-ee/entity").PublishableFields & VersionableFields & {
80
+ placement: import("@murumets-ee/entity").SelectField & {
81
+ options: readonly ["header", "footer"];
82
+ } & {
83
+ readonly options: readonly ["header", "footer"];
84
+ readonly required: true;
85
+ readonly unique: true;
86
+ };
87
+ label: import("@murumets-ee/entity").TextField & {
88
+ readonly required: true;
89
+ };
90
+ layout: import("@murumets-ee/entity").BlockTreeField & {
91
+ readonly environment?: string;
92
+ readonly translatable: true;
93
+ readonly areaFrom: "placement";
94
+ };
95
+ }, "site_chrome", [Behavior<import("@murumets-ee/entity").AuditableFields, string>, Behavior<import("@murumets-ee/entity").PublishableFields, "publishable">, Behavior<VersionableFields, string>, ...Behavior<Record<never, never>, string>[]]>;
96
+ //#endregion
97
+ export { siteChromeEntity as a, SiteChromeEntityOptions as i, CHROME_PLACEMENT_LABELS as n, ChromePlacement as r, CHROME_PLACEMENTS as t };
98
+ //# sourceMappingURL=site-chrome-DNCcePSR.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"site-chrome-DNCcePSR.d.mts","names":[],"sources":["../src/entities/site-chrome.ts"],"mappings":";;;;;;;;;;;cA0Ga,iBAAA;;KAGD,eAAA,WAA0B,iBAAiB;;cAG1C,uBAAA,EAAyB,QAAA,CAAS,MAAA,CAAO,eAAA;;UAMrC,uBAAA;;;;;;;;;;EAUf,QAAA;;EAEA,UAAA;;EAEA,SAAA;;;;;;EAMA,WAAA;;EAEA,WAAA;;;;;;;;;;;;;;EAcA,SAAA,YAAqB,QAAA,CAAS,MAAA;;;;;;;;;EAS9B,KAAA;IACE,cAAA,GAAiB,iBAAA;EAAA;AAAA;;;;;;;;;;;iBAsCL,gBAAA,CAAiB,OAAA,GAAU,uBAAA,iCAAuB,MAAA;;sGAAA,iBAAA"}