@murumets-ee/content 0.86.0 → 0.88.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/admin.d.mts.map +1 -1
  2. package/dist/admin.mjs +1 -1
  3. package/dist/block-resolver.d.mts +21 -1
  4. package/dist/block-resolver.d.mts.map +1 -1
  5. package/dist/block-resolver.mjs +1 -1
  6. package/dist/block-resolver.mjs.map +1 -1
  7. package/dist/client.d.mts +1 -1
  8. package/dist/client.mjs +1 -1
  9. package/dist/client.mjs.map +1 -1
  10. package/dist/{enumerate-CBqu63Kp.mjs → enumerate-CYHJKQz6.mjs} +2 -2
  11. package/dist/{enumerate-CBqu63Kp.mjs.map → enumerate-CYHJKQz6.mjs.map} +1 -1
  12. package/dist/{index-C2G4IJck.d.mts → index-C3VyZLjf.d.mts} +55 -2
  13. package/dist/{index-C2G4IJck.d.mts.map → index-C3VyZLjf.d.mts.map} +1 -1
  14. package/dist/index.d.mts +3 -3
  15. package/dist/index.mjs +1 -1
  16. package/dist/index.mjs.map +1 -1
  17. package/dist/locale-entity-CoSq3C77.mjs +2 -0
  18. package/dist/locale-entity-CoSq3C77.mjs.map +1 -0
  19. package/dist/locale-registry-Bn6e_nM7.mjs +2 -0
  20. package/dist/locale-registry-Bn6e_nM7.mjs.map +1 -0
  21. package/dist/plugin-BmxghXzV.mjs +2 -0
  22. package/dist/plugin-BmxghXzV.mjs.map +1 -0
  23. package/dist/plugin.d.mts +4 -4
  24. package/dist/plugin.mjs +1 -1
  25. package/dist/resolve-context-Cvc1aNJe.mjs +2 -0
  26. package/dist/resolve-context-Cvc1aNJe.mjs.map +1 -0
  27. package/dist/resolve.d.mts +54 -16
  28. package/dist/resolve.d.mts.map +1 -1
  29. package/dist/resolve.mjs +6 -6
  30. package/dist/resolve.mjs.map +1 -1
  31. package/dist/routes-BmNSYyuT.mjs +2 -0
  32. package/dist/{routes-C9I1iZw8.mjs.map → routes-BmNSYyuT.mjs.map} +1 -1
  33. package/dist/routing-strategy-D6B4ngT9.mjs +2 -0
  34. package/dist/routing-strategy-D6B4ngT9.mjs.map +1 -0
  35. package/dist/{routing-strategy-D3Qowm3D.d.mts → routing-strategy-DV7KGarX.d.mts} +4 -4
  36. package/dist/{routing-strategy-D3Qowm3D.d.mts.map → routing-strategy-DV7KGarX.d.mts.map} +1 -1
  37. package/dist/schema-BYrYzsmO.mjs +2 -0
  38. package/dist/schema-BYrYzsmO.mjs.map +1 -0
  39. package/dist/schema-IML1rRU_.mjs +2 -0
  40. package/dist/{schema-0zayzpD_.mjs.map → schema-IML1rRU_.mjs.map} +1 -1
  41. package/dist/seed.d.mts.map +1 -1
  42. package/dist/seed.mjs +1 -1
  43. package/dist/seed.mjs.map +1 -1
  44. package/dist/{versions-C9A4rUAR.mjs → versions-DLrR7hIZ.mjs} +2 -2
  45. package/dist/{versions-C9A4rUAR.mjs.map → versions-DLrR7hIZ.mjs.map} +1 -1
  46. package/package.json +9 -9
  47. package/dist/plugin-D0i6odXQ.mjs +0 -2
  48. package/dist/plugin-D0i6odXQ.mjs.map +0 -1
  49. package/dist/resolve-context-MRZZ3rQl.mjs +0 -2
  50. package/dist/resolve-context-MRZZ3rQl.mjs.map +0 -1
  51. package/dist/routes-C9I1iZw8.mjs +0 -2
  52. package/dist/routing-strategy-CQdu1qV7.mjs +0 -2
  53. package/dist/routing-strategy-CQdu1qV7.mjs.map +0 -1
  54. package/dist/schema-0zayzpD_.mjs +0 -2
  55. package/dist/schema-D8sBQozw.mjs +0 -2
  56. package/dist/schema-D8sBQozw.mjs.map +0 -1
@@ -1 +0,0 @@
1
- {"version":3,"file":"schema-D8sBQozw.mjs","names":[],"sources":["../src/paths/apply-path.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 * `toolkit_paths` — the central materialized path index (plan/routing D005/D006,\n * F007). One row per (document, locale) mapping a routable entity's row to its\n * locale-relative canonical URL. Owned by `@murumets-ee/content`; maintained by\n * the `pathIndexWriter` (post-commit) + `pathConflictGuard` (pre-commit) seams\n * on `@murumets-ee/entity`'s `AdminClient`.\n *\n * Uses `defineTable` (per plan/routing C3) so both seams read/write through a\n * typed `TableClient` — zero raw SQL.\n *\n * Uniqueness + lookup (two partial-uniques + one plain index):\n * - `UNIQUE (locale, path) WHERE is_canonical` — no two documents can claim the\n * same canonical URL in the same locale (F007's partial-unique callback form,\n * exactly as Task 1 shipped `defineTable` support for). `is_canonical = false`\n * rows (redirect history) are excluded, so an old path can coexist with the\n * live one holding the same URL. THIS is the routing invariant; it is\n * unchanged across PR03→PR04.\n * - `UNIQUE (entity_type, entity_id, locale) WHERE is_canonical` — at most ONE\n * canonical row per (document, locale). PR03 enforced this as a FULL\n * `UNIQUE (entity_type, entity_id, locale)` (no history yet, so exactly one\n * row per (document, locale) held). PR04 introduced redirect history — a\n * rename DEMOTES the old canonical (`is_canonical = false`) and INSERTS a\n * fresh one — so the FULL unique could no longer hold and migration 0058\n * dropped it. But dropping it outright left NOTHING enforcing \"one canonical\n * per document\": the demote/insert sequence, or two concurrent writers on the\n * same document, could leave TWO permanently-canonical rows — a silent,\n * permanent violation of the invariant resolution / `entity_refs` / the admin\n * UI's \"the URL for a document\" all depend on. This PARTIAL unique restores\n * the guarantee while still allowing history: only `is_canonical = true` rows\n * are in scope, so any number of demoted historical rows may coexist. It also\n * makes the writer's demote-FIRST → insert ordering DB-safe (see\n * `apply-path.ts`).\n * - `INDEX (entity_type, entity_id, locale)` — a PLAIN (non-unique) index, the\n * natural \"all rows of this document\" lookup key used by the delete sweep,\n * the parent-path resolution query, and the writer's per-locale\n * demote-then-insert (PR04). The partial unique above only covers CANONICAL\n * rows, so this plain index is still needed for the history-inclusive\n * lookups. (Migration 0058 drops the old full `..._unique` constraint and\n * creates this plain index in its place; a later PR04 migration adds the\n * partial unique.)\n */\n\nimport { column, defineTable } from '@murumets-ee/db'\nimport { sql } from 'drizzle-orm'\n\nexport const toolkitPathsTable = defineTable({\n name: 'toolkit_paths',\n columns: {\n id: column.uuid({ primaryKey: true, defaultRandom: true }),\n entityType: column.varchar({ length: 100, notNull: true, pgName: 'entity_type' }),\n entityId: column.uuid({ notNull: true, pgName: 'entity_id' }),\n locale: column.varchar({ length: 10, notNull: true }),\n /** Locale-relative canonical path: leading '/', no trailing '/'. */\n path: column.text({ notNull: true }),\n isCanonical: column.boolean({ notNull: true, default: true, pgName: 'is_canonical' }),\n createdAt: column.timestamp({\n notNull: true,\n defaultNow: true,\n withTimezone: true,\n pgName: 'created_at',\n }),\n updatedAt: column.timestamp({\n notNull: true,\n defaultNow: true,\n withTimezone: true,\n pgName: 'updated_at',\n }),\n },\n unique: [\n // No two documents share a canonical URL per locale (F007 partial-unique).\n // The one true routing invariant — unchanged from PR03.\n { on: ['locale', 'path'], where: (c) => sql`${c.isCanonical} = true` },\n // At most ONE canonical row per (document, locale). Restores (as a partial\n // unique, so redirect history still accumulates) the \"one canonical path per\n // document\" guarantee the full unique dropped in migration 0058 used to give.\n // Historical (is_canonical = false) rows are excluded from the predicate.\n { on: ['entityType', 'entityId', 'locale'], where: (c) => sql`${c.isCanonical} = true` },\n ],\n indexes: [\n // \"All rows of this document\" — canonical + historical. Plain (non-unique)\n // because PR04's redirect history keeps demoted rows here (see file header).\n { on: ['entityType', 'entityId', 'locale'] },\n ],\n})\n\n/** Backwards-compatible raw pgTable export (for the plugin `tables:` slot + migrations). */\nexport const toolkitPaths = toolkitPathsTable.table\n\n/** The full `DefinedTable` for `toolkit_paths`. */\nexport type ToolkitPathsTable = typeof toolkitPathsTable\n\n/** The typed `TableClient` for `toolkit_paths` (`toolkitPathsTable.makeClient(db)`). */\nexport type ToolkitPathsClient = ReturnType<typeof toolkitPathsTable.makeClient>\n"],"mappings":"sJA4CA,SAAgB,EAAkB,EAAuB,CACvD,IAAI,EAAkB,EACtB,IAAK,IAAI,EAAQ,EAAG,GAAU,EAAQ,EAAG,IAAS,CAChD,GAAK,EAA8B,OAAS,QAAS,MAAO,GAC5D,EAAU,EAA+B,KAC3C,CACA,MAAO,EACT,CA4CA,MAAM,EAAuD,IAAI,IAAI,CACnE,eACA,qBACA,gBACF,CAAC,EAmHD,eAAsB,EAAmB,EAAwD,CAC/F,GAAM,CAAE,cAAa,aAAY,WAAU,SAAQ,OAAM,uBAAsB,UAAW,EAE1F,GAAI,IAAS,KAAM,CAMjB,IAAM,EAAa,EAAK,YAAc,KAChC,EAAe,IAAe,OAAS,EAAK,oBAAoB,SAAS,CAAU,GAAK,IAS9F,OARI,IAAe,MAAQ,CAAC,GAAgB,EAAwB,IAAI,CAAU,GAChF,EAAO,KACL,CAAE,OAAQ,EAAY,GAAI,EAAU,SAAQ,YAAW,EACvD,2EAA2E,EAAW,2JAGxF,EAEK,CAAE,QAAS,eAAgB,QAAS,KAAM,QAAS,KAAM,aAAc,KAAM,YAAW,CACjG,CAIA,IAAM,EAAW,EAAoB,EAAM,CAAoB,EAC/D,GAAI,IAAa,IAAA,GAMf,OALA,EAAO,KACL,CAAE,OAAQ,EAAY,GAAI,EAAU,SAAQ,OAAM,UAAS,EAC3D,8HAEF,EACO,CACL,QAAS,mBACT,QAAS,KACT,QAAS,EACT,aAAc,KACd,WAAY,IACd,EAKF,IAAM,EAAmB,MAAM,EAAY,QAAQ,CACjD,aACA,WACA,SACA,YAAa,EACf,CAAC,EACK,EAAU,GAAkB,MAAQ,KAI1C,GAAI,IAAqB,MAAQ,EAAiB,OAAS,EACzD,MAAO,CAAE,QAAS,YAAa,UAAS,QAAS,EAAM,aAAc,KAAM,WAAY,IAAK,EAG9F,GAAI,EAAK,OAAQ,CACf,IAAM,EAAQ,MAAM,EAAY,QAAQ,CAAE,SAAQ,OAAM,YAAa,EAAK,CAAC,EAU3E,OATI,IAAU,MAAQ,EAAE,EAAM,aAAe,GAAc,EAAM,WAAa,GACrE,CACL,QAAS,WACT,UACA,QAAS,EACT,aAAc,CAAE,WAAY,EAAM,WAAY,SAAU,EAAM,QAAS,EACvE,WAAY,IACd,EAEK,CACL,QAAS,IAAqB,KAAO,UAAY,UACjD,UACA,QAAS,EACT,aAAc,KACd,WAAY,IACd,CACF,CAOI,IAAqB,MACvB,MAAM,EAAY,OAChB,CAAE,GAAI,EAAiB,EAAG,EAC1B,CAAE,YAAa,GAAO,UAAW,IAAI,IAAO,CAC9C,EAMF,IAAI,EAAW,GACf,GAAI,CACF,MAAM,EAAY,OAAO,CAAE,aAAY,WAAU,SAAQ,OAAM,YAAa,EAAK,CAAC,EAClF,EAAW,EACb,OAAS,EAAK,CACZ,GAAI,CAAC,EAAkB,CAAG,EAAG,MAAM,CACrC,CAEA,GAAI,CAAC,EAAU,CAOb,GAAI,IAAqB,KACvB,GAAI,CACF,MAAM,EAAY,OAChB,CAAE,GAAI,EAAiB,EAAG,EAC1B,CAAE,YAAa,GAAM,UAAW,IAAI,IAAO,CAC7C,CACF,OAAS,EAAK,CACZ,GAAI,CAAC,EAAkB,CAAG,EAAG,MAAM,EACnC,EAAO,KACL,CAAE,OAAQ,EAAY,GAAI,EAAU,SAAQ,MAAK,EACjD,iLAGF,CACF,CAGF,IAAM,EAAQ,MAAM,EAAY,QAAQ,CAAE,SAAQ,OAAM,YAAa,EAAK,CAAC,EACrE,EACJ,IAAU,MAAQ,EAAE,EAAM,aAAe,GAAc,EAAM,WAAa,GACtE,CAAE,WAAY,EAAM,WAAY,SAAU,EAAM,QAAS,EACzD,KAMN,OALA,EAAO,KACL,CAAE,OAAQ,EAAY,GAAI,EAAU,SAAQ,MAAK,EACjD,iIAEF,EACO,CAAE,QAAS,WAAY,UAAS,QAAS,EAAM,eAAc,WAAY,IAAK,CACvF,CAQA,OANI,IAAqB,KAChB,CAAE,QAAS,UAAW,QAAS,KAAM,QAAS,EAAM,aAAc,KAAM,WAAY,IAAK,GAIlG,MAAM,EAAkB,EAAa,EAAY,EAAU,CAAM,EAC1D,CAAE,QAAS,UAAW,UAAS,QAAS,EAAM,aAAc,KAAM,WAAY,IAAK,EAC5F,CAOA,eAAe,EACb,EACA,EACA,EACA,EACe,CAOf,IAAM,EAAW,MANQ,EAAY,MAAM,CACzC,aACA,WACA,SACA,YAAa,EACf,CAAC,EAAA,GAED,GAAI,GAAY,EAAG,OASnB,IAAM,GAAU,MARK,EAAY,SAAS,CACxC,MAAO,CAAE,aAAY,WAAU,SAAQ,YAAa,EAAM,EAC1D,QAAS,CACP,CAAE,OAAQ,YAAa,IAAK,KAAM,EAClC,CAAE,OAAQ,YAAa,IAAK,KAAM,CACpC,EACA,MAAO,KAAK,IAAI,EAAU,GAAgB,CAC5C,CAAC,EAAA,CACsB,IAAK,GAAM,EAAE,EAAE,EAClC,EAAQ,OAAS,GACnB,MAAM,EAAY,WAAW,CAAE,GAAI,CAAE,GAAI,CAAQ,CAAE,CAAC,CAExD,CAYA,eAAsB,EACpB,EACA,EACA,EACe,CACf,IAAM,EAAQ,EAAM,OACpB,GAAI,IAAU,EAAG,OACjB,IAAI,EAAO,EAIL,EAAc,KAAK,IAAI,EAAG,KAAK,IADxB,OAAO,SAAS,CAAW,EAAI,KAAK,MAAM,CAAW,EAAI,EACvB,CAAK,CAAC,EACrD,eAAe,GAAwB,CACrC,OAAS,CACP,IAAM,EAAQ,EAEd,GADA,GAAQ,EACJ,GAAS,EAAO,OACpB,MAAM,EAAG,EAAM,GAAa,CAAK,CACnC,CACF,CACA,MAAM,QAAQ,IAAI,MAAM,KAAK,CAAE,OAAQ,CAAY,MAAS,EAAO,CAAC,CAAC,CACvE,CC1XA,MAAa,EAAoB,EAAY,CAC3C,KAAM,gBACN,QAAS,CACP,GAAI,EAAO,KAAK,CAAE,WAAY,GAAM,cAAe,EAAK,CAAC,EACzD,WAAY,EAAO,QAAQ,CAAE,OAAQ,IAAK,QAAS,GAAM,OAAQ,aAAc,CAAC,EAChF,SAAU,EAAO,KAAK,CAAE,QAAS,GAAM,OAAQ,WAAY,CAAC,EAC5D,OAAQ,EAAO,QAAQ,CAAE,OAAQ,GAAI,QAAS,EAAK,CAAC,EAEpD,KAAM,EAAO,KAAK,CAAE,QAAS,EAAK,CAAC,EACnC,YAAa,EAAO,QAAQ,CAAE,QAAS,GAAM,QAAS,GAAM,OAAQ,cAAe,CAAC,EACpF,UAAW,EAAO,UAAU,CAC1B,QAAS,GACT,WAAY,GACZ,aAAc,GACd,OAAQ,YACV,CAAC,EACD,UAAW,EAAO,UAAU,CAC1B,QAAS,GACT,WAAY,GACZ,aAAc,GACd,OAAQ,YACV,CAAC,CACH,EACA,OAAQ,CAGN,CAAE,GAAI,CAAC,SAAU,MAAM,EAAG,MAAQ,GAAM,CAAG,GAAG,EAAE,YAAY,QAAS,EAKrE,CAAE,GAAI,CAAC,aAAc,WAAY,QAAQ,EAAG,MAAQ,GAAM,CAAG,GAAG,EAAE,YAAY,QAAS,CACzF,EACA,QAAS,CAGP,CAAE,GAAI,CAAC,aAAc,WAAY,QAAQ,CAAE,CAC7C,CACF,CAAC,EAGY,EAAe,EAAkB"}