@murumets-ee/content 0.37.0 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admin.mjs +1 -1
- package/dist/chrome-provider.d.mts +107 -0
- package/dist/chrome-provider.d.mts.map +1 -0
- package/dist/chrome-provider.mjs +2 -0
- package/dist/chrome-provider.mjs.map +1 -0
- package/dist/client.mjs +1 -1
- package/dist/client.mjs.map +1 -1
- package/dist/entities/index.d.mts +32 -10
- package/dist/entities/index.d.mts.map +1 -1
- package/dist/entities/index.mjs.map +1 -1
- package/dist/index-Bsmju0UF.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/{locale-utils-Dyw5qWDf.mjs → locale-utils-DFEk4CE9.mjs} +2 -2
- package/dist/{locale-utils-Dyw5qWDf.mjs.map → locale-utils-DFEk4CE9.mjs.map} +1 -1
- package/dist/plugin-BSxbiGc1.mjs +2 -0
- package/dist/plugin-BSxbiGc1.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/dist/query-client.mjs +1 -1
- package/dist/resolve-context-BVk6uObB.mjs +2 -0
- package/dist/resolve-context-BVk6uObB.mjs.map +1 -0
- package/dist/resolve.d.mts +19 -10
- package/dist/resolve.d.mts.map +1 -1
- package/dist/resolve.mjs +1 -1
- package/dist/resolve.mjs.map +1 -1
- package/dist/{routes-D3dzAfQw.mjs → routes-Bc9DcaPk.mjs} +2 -2
- package/dist/{routes-D3dzAfQw.mjs.map → routes-Bc9DcaPk.mjs.map} +1 -1
- package/dist/schema-BJxAwPrI.mjs.map +1 -1
- package/package.json +13 -9
- package/dist/plugin-BTynhZMx.mjs +0 -2
- package/dist/plugin-BTynhZMx.mjs.map +0 -1
- package/dist/resolve-context-p3m6gn3_.mjs +0 -2
- package/dist/resolve-context-p3m6gn3_.mjs.map +0 -1
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"plugin-BTynhZMx.mjs","names":["cols"],"sources":["../src/schema/registry.ts","../src/locale-status-slot.ts","../src/document-draft-store.ts","../src/paths/writers.ts","../src/paths/jobs.ts","../src/paths/pattern-hash-schema.ts","../src/paths/pattern-hash.ts","../src/schema/drafts.ts","../src/schema/locale-status.ts","../src/schema/versions.ts","../src/plugin.ts"],"sourcesContent":["/**\n * Per-entity content table registry.\n *\n * Maps an entity name to its content infrastructure tables — drafts,\n * versions, locale_status. Each entry stores the `DefinedTable` (from\n * `defineTable`), which exposes a typed `.makeClient(db)` for CRUD via\n * `TableClient` and a raw `.table` for legacy callers.\n *\n * The content plugin populates this registry at `init` time; ContentClient\n * reads from it lazily on first use. Tables that don't apply to an entity\n * (non-publishable entities have no drafts, etc.) are simply absent.\n */\n\nimport type { DraftsTable } from './drafts.js'\nimport type { LocaleStatusTable } from './locale-status.js'\nimport type { VersionsTable } from './versions.js'\n\n/**\n * The full set of optional content infra tables attached to an entity.\n * Every field is optional — entities without the relevant behavior don't\n * get the corresponding table.\n */\nexport interface ContentTables {\n /** `{entity}_drafts` — present when the entity has `publishable()`. */\n drafts?: DraftsTable\n /** `{entity}_versions` — present when the entity has `versionable()`. */\n versions?: VersionsTable\n /** `{entity}_locale_status` — present when the entity is publishable + has translatable fields. */\n localeStatus?: LocaleStatusTable\n}\n\n/**\n * The registry map lives on `globalThis` keyed by `Symbol.for(...)`, NOT as a\n * module-level binding — matching core's `PluginContributionsRegistry`\n * pattern. A module-level `Map` here is the exact Turbopack-dev footgun\n * CLAUDE.md documents (the createdBy/updatedBy bug class): a lazily-compiled\n * route chunk can resolve this module to a DIFFERENT instance than the boot\n * path that called `registerContentTables`, so `getContentTables()` returns\n * `{}` and callers silently take their no-table fallbacks. Observed in the\n * wild 2026-06-11 (F031 #1): the blocks op-commit route saw an empty registry,\n * `writeDraft` fell back to live-row writes, and Publish 500'd with \"No draft\n * found\" — flaky per-recompile, dev-only. `Symbol.for` is process-global, so\n * every duplicate module instance reads the same map.\n */\nconst REGISTRY_KEY = Symbol.for('@murumets-ee/content:content-tables-registry')\n\ninterface GlobalWithRegistry {\n [REGISTRY_KEY]?: Map<string, ContentTables>\n}\n\nfunction registryMap(): Map<string, ContentTables> {\n const g = globalThis as GlobalWithRegistry\n let map = g[REGISTRY_KEY]\n if (!map) {\n map = new Map<string, ContentTables>()\n g[REGISTRY_KEY] = map\n }\n return map\n}\n\n/**\n * Register content tables for an entity. Called from `content().init()`.\n *\n * Subsequent calls for the same entity name MERGE the provided patch into\n * the existing entry. This lets the plugin add tables one at a time (drafts,\n * then versions, then locale_status) without the caller needing to build the\n * whole object up front.\n */\nexport function registerContentTables(entityName: string, patch: ContentTables): void {\n const tables = registryMap()\n const existing = tables.get(entityName) ?? {}\n tables.set(entityName, { ...existing, ...patch })\n}\n\n/**\n * Get the content tables for an entity. Returns an empty object (rather\n * than undefined) when the entity has no registered tables — that\n * simplifies the common ContentClient lookup idiom:\n *\n * const { drafts } = getContentTables(entity.name)\n * if (!drafts) return null\n */\nexport function getContentTables(entityName: string): ContentTables {\n return registryMap().get(entityName) ?? {}\n}\n\n/**\n * Clear the registry. **Test-only.** Throws in production.\n */\nexport function clearContentTables(): void {\n if (process.env.NODE_ENV === 'production') {\n throw new Error(\n 'clearContentTables() is not allowed in production — this is a test-only utility.',\n )\n }\n registryMap().clear()\n}\n","/**\n * Process-global slot exposing the content plugin's per-locale publish\n * status writer to `AdminClient.updateForLocale` (SD021 — the\n * locale-write choke point).\n *\n * `@murumets-ee/core`'s `createAdminClient` reads the slot by symbol\n * identity when constructing a client, so EVERY framework-built\n * AdminClient routes non-default-locale `status` writes into\n * `<entity>_locale_status` automatically — future writers (import\n * pipelines, AI integration, workflow actions) cannot forget the\n * routing. Same `globalThis` + `Symbol.for` shape as queue's\n * `enqueue-on-commit-slot.ts`: survives HMR and avoids a dependency\n * edge from core onto content.\n *\n * NOTE: this module must stay free of `import 'server-only'` — it is in\n * the content plugin's `init` load path, which the `lumi` CLI loads in\n * plain Node (see memory: server-only in the CLI load path breaks every\n * command and only scaffold-e2e catches it).\n */\n\nimport { getContext } from '@murumets-ee/core'\nimport type { Entity } from '@murumets-ee/entity'\nimport type { LocaleStatusWriter } from '@murumets-ee/entity/admin'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\nimport { InvalidLocaleError } from './errors.js'\nimport type { LocaleStatusTable } from './schema/locale-status.js'\nimport { getContentTables } from './schema/registry.js'\n\nconst LOCALE_STATUS_WRITER_KEY = Symbol.for('@murumets-ee/content:locale-status-writer')\n\n/** The typed TableClient for a `{entity}_locale_status` table. */\ntype LocaleStatusClient = ReturnType<LocaleStatusTable['makeClient']>\n\n/**\n * Upsert one non-default locale's status row. The single implementation\n * shared by `setLocaleStatus` (content's own API) and the slot writer\n * below — keeping the `publishedAt` semantics in one place.\n *\n * `client` is the locale-status TableClient; callers resolve it from\n * `getContentTables(entity).localeStatus` (capability-gated).\n */\nexport async function upsertLocaleStatusRow(\n client: LocaleStatusClient,\n entityId: string,\n locale: string,\n status: 'draft' | 'published',\n): Promise<void> {\n const publishedAt = status === 'published' ? new Date() : null\n await client.upsert(\n { entityId, locale, status, publishedAt },\n { target: ['entityId', 'locale'], set: { status, publishedAt } },\n )\n}\n\n/**\n * Write the locale-status writer factory into the slot. Called from the\n * content plugin's `init` AFTER the per-entity `<entity>_locale_status`\n * tables are registered (the factory consults that registry per entity).\n *\n * `localeCodes` is the plugin's configured locale set (the shipped `content()`\n * defaults), passed in by init rather than read via `getContentConfig()` —\n * importing `locale-utils`/`plugin` here would close an import cycle (plugin →\n * slot → … → plugin; `verify:cycles` catches it). It is the **static fallback**\n * only; the write-time whitelist prefers the request-scoped effective set (below).\n *\n * The factory returns `undefined` for entities without a locale-status\n * table — `updateForLocale` then falls back to the base `status` column,\n * which is correct: for non-translatable publishables the default locale\n * is the only locale.\n */\nexport function setActiveLocaleStatusWriterFactory(localeCodes: readonly string[]): void {\n const factory = (entity: Entity, db: PostgresJsDatabase): LocaleStatusWriter | undefined => {\n const { localeStatus } = getContentTables(entity.name)\n if (!localeStatus) return undefined\n return async (entityId, locale, status) => {\n // Same runtime-aware whitelist as locale-utils' validateLocale (SD006), sans\n // the '_' sentinel (updateForLocale never sends it here): prefer the\n // request-scoped effective enabled codes so a locale added/enabled at runtime\n // can have translatable content published in it with no rebuild, falling back\n // to the init-time static set outside a request (CLI / build / tests).\n // Unvalidated locales must not mint locale_status rows no reader will ever merge.\n const allowed = getContext()?.availableLocales ?? localeCodes\n if (!allowed.includes(locale)) {\n throw new InvalidLocaleError(locale, [...allowed])\n }\n // The caller (core's createAdminClient) passes the SAME db handle the\n // AdminClient writes through — a client built against an explicit app\n // (tests, multi-app setups) must not write its locale-status rows\n // through the singleton app's connection.\n const client = localeStatus.makeClient(db)\n await upsertLocaleStatusRow(client, entityId, locale, status)\n }\n }\n ;(globalThis as Record<symbol, unknown>)[LOCALE_STATUS_WRITER_KEY] = factory\n}\n","/**\n * `DocumentDraftStore` implementation contributed to the block editor's\n * `blocks.documentDraftStore` slot (SD015).\n *\n * The block editor's op-commit route (in `@murumets-ee/blocks`) materializes a\n * `withOpLog()` document's working tree by calling this store inside its\n * commit transaction — so block edits land in the per-locale `{entity}_drafts`\n * buffer, never the live/public row. A later \"Publish\" (`publishDraft`)\n * promotes the draft to the live row + snapshots a version.\n *\n * blocks owns the slot type (imported `type`-only below — that import also\n * pulls the `PluginContributionSlots` augmentation into this compilation);\n * content owns the draft tables. No runtime dependency between the packages.\n *\n * NB: no `import 'server-only'` here. This module is imported by\n * `content/plugin.ts` at construct time (for the `contributes` slot), and the\n * content plugin must stay loadable in a plain-Node context (the `lumi migrate`\n * CLI loads `lumi.config.ts` without the `react-server` resolution condition,\n * where `server-only` throws). The store is only ever *called* server-side (by\n * the op-commit committer) and is never reachable from a client bundle.\n */\n\nimport type { DocumentDraftStore } from '@murumets-ee/blocks/server'\nimport { schemaRegistry } from '@murumets-ee/db'\nimport { eq, sql } from 'drizzle-orm'\nimport type { PgColumn } from 'drizzle-orm/pg-core'\nimport { getContentTables } from './schema/registry.js'\n\n/**\n * Build the draft store. Stateless — resolves the per-entity drafts table from\n * the schema registry at call time, so it composes with any app's entity set.\n */\nexport function createDocumentDraftStore(): DocumentDraftStore {\n return {\n async writeDraft({ documentType, documentId, locale, dataPatch, user, tx }) {\n const { drafts } = getContentTables(documentType)\n // No drafts table → the document type isn't publishable; signal the\n // caller to fall back to writing the live row.\n if (!drafts) return false\n\n // Bind the drafts TableClient to the committer's tx so the upsert joins\n // the same SERIALIZABLE transaction as the editor_op insert.\n const client = drafts.makeClient(tx)\n const existing = await client.findOne({ entityId: documentId, locale })\n // Merge onto any existing draft payload so a concurrent form-field draft\n // (saved via saveDraft) isn't clobbered by a block edit, and vice versa.\n const baseData = (existing?.data as Record<string, unknown> | undefined) ?? {}\n const data = { ...baseData, ...dataPatch }\n const now = new Date()\n\n await client.upsert(\n {\n entityId: documentId,\n locale,\n data,\n createdBy: user.id,\n createdByName: user.name ?? null,\n createdAt: now,\n updatedAt: now,\n },\n {\n target: ['entityId', 'locale'],\n // On conflict, only touch the mutable fields — preserve createdBy/createdAt.\n set: { data, updatedAt: now },\n },\n )\n return true\n },\n\n async hasAnyDraft({ documentType, documentId, db }) {\n const { drafts } = getContentTables(documentType)\n if (!drafts) return false\n // Any-locale existence probe — the orphan-draft GC (blocks PR 25)\n // spares a row when ANY locale still carries draft work.\n const client = drafts.makeClient(db)\n return client.exists({ entityId: documentId })\n },\n\n async hasAnyLocaleActivity({ documentType, documentId, db }) {\n // Per-locale publish status (publishDraft on a non-default locale\n // writes HERE, never the base row's status — pr-review H2).\n const { localeStatus } = getContentTables(documentType)\n if (localeStatus) {\n const client = localeStatus.makeClient(db)\n if (await client.exists({ entityId: documentId })) return true\n }\n // Translation rows (saveTranslation writes them directly, no hooks —\n // the base row's updatedAt never moves). Translation tables are\n // generated in @murumets-ee/entity; accessed via schemaRegistry +\n // direct Drizzle, same idiom as translation-status.ts.\n const translationTable = schemaRegistry.get(`${documentType}_translations`)\n if (translationTable) {\n const cols = translationTable as Record<string, PgColumn | undefined>\n const entityIdCol = cols.entityId\n if (entityIdCol !== undefined) {\n const rows = await db\n .select({ one: sql`1` })\n .from(translationTable)\n .where(eq(entityIdCol, documentId))\n .limit(1)\n if (rows.length > 0) return true\n }\n }\n return false\n },\n }\n}\n","/**\n * The real bodies for the two `@murumets-ee/entity` path seams (plan/routing\n * PR03, Part 3 + Part 4), plus the process-global slot that exposes them to\n * `@murumets-ee/core`'s `createAdminClient` — the same `globalThis` +\n * `Symbol.for` capability-injection shape as `locale-status-slot.ts` (survives\n * HMR, avoids a core→content dependency edge).\n *\n * - `createPathIndexWriter` — POST-commit: recompute `toolkit_paths` for the\n * written locale(s). PR04 replaced the PR03 blind upsert with a\n * find-canonical → insert/demote so a slug change leaves the old path behind\n * as redirect history (`is_canonical = false`) instead of overwriting it in\n * place. Deletes all rows on `action:'delete'`. Fires the cascade-trigger\n * seam (below) when a `pathParent()` document's canonical path actually\n * changes, so a Task-2 queue job can regenerate descendant paths.\n * - `createPathConflictGuard` — PRE-commit: resolve the candidate path, reject\n * reserved-prefix hits + manual conflicts, auto-suffix `data.slug` otherwise.\n *\n * NOTE: this module is on the content plugin's `init` load path (via\n * `plugin.ts`), so it must stay free of `import 'server-only'` — every import\n * here (entity path helpers, the `defineTable`-backed schema, the leaf config\n * store) is server-only-free, and `ToolkitApp` is a TYPE-only import.\n */\n\nimport type { ToolkitApp } from '@murumets-ee/core'\nimport type { Entity } from '@murumets-ee/entity'\nimport { hasPathParent, resolveEffectiveSlugMode } from '@murumets-ee/entity'\nimport type { PathConflictGuard, PathIndexWriter } from '@murumets-ee/entity/admin'\nimport { getContentConfig } from '../content-config-store.js'\nimport { applyCanonicalPath } from './apply-path.js'\nimport { computeCanonicalPath, ensurePathAvailable } from './generate-path.js'\nimport { toolkitPathsTable } from './schema.js'\n\nconst PATH_WRITERS_KEY = Symbol.for('@murumets-ee/content:path-writers-factory')\n\nconst CASCADE_TRIGGER_KEY = Symbol.for('@murumets-ee/content:cascade-trigger-factory')\n\n/**\n * Cascade seam invoked when a `pathParent()` document's canonical path actually\n * changes in a locale — so descendants (whose `[parent.path]` expansions now\n * point at a stale ancestor URL) can be regenerated. The fan-out is too large\n * for the request path, so Task 2 registers a body here that enqueues a queue\n * job (§2/§4 change matrix). PR04 Task 1 ships only the SEAM: when nothing is\n * registered (queue is an optional peer dependency), the call is a NO-OP —\n * a deployment without a queue configured must never see path writes fail.\n *\n * @param app The resolved toolkit app (queue client, db, logger).\n * @param entity The routable entity whose ancestor row changed.\n * @param entityId The renamed ancestor document's id.\n * @param locale The locale whose canonical path changed.\n */\nexport type CascadeTrigger = (\n app: ToolkitApp,\n entity: Entity,\n entityId: string,\n locale: string,\n) => Promise<void>\n\n/**\n * Register the cascade-trigger body (called from content's plugin `init` in\n * Task 2). Same `globalThis` + `Symbol.for` capability-injection shape as\n * {@link setActivePathWritersFactory} / `locale-status-slot.ts` — survives HMR,\n * avoids a core→content dependency edge.\n */\nexport function setActiveCascadeTrigger(trigger: CascadeTrigger): void {\n ;(globalThis as Record<symbol, unknown>)[CASCADE_TRIGGER_KEY] = trigger\n}\n\n/** Read the registered cascade trigger, or `undefined` when none is wired. */\nfunction getActiveCascadeTrigger(): CascadeTrigger | undefined {\n const slot = (globalThis as Record<symbol, unknown>)[CASCADE_TRIGGER_KEY]\n return typeof slot === 'function' ? (slot as CascadeTrigger) : undefined\n}\n\n/** The pair of seams a routable entity's `AdminClient` gets wired with. */\nexport interface PathWriters {\n pathIndexWriter: PathIndexWriter\n pathConflictGuard: PathConflictGuard\n}\n\n/**\n * Factory read by core's `createAdminClient` (per client, with the resolved\n * `app` + the entity). Core gates on `entity.frontend?.path` before calling —\n * see `client-factories.ts`.\n */\nexport type PathWritersFactory = (app: ToolkitApp, entity: Entity) => PathWriters\n\n/**\n * POST-commit path-index maintenance for a routable entity. Recomputes every\n * relevant locale's canonical `toolkit_paths` row (or deletes all of them on\n * `action:'delete'`).\n *\n * PR04 — instead of a blind upsert-in-place, each locale runs a\n * find-canonical → insert/demote (the shared {@link applyCanonicalPath}, reused\n * by the cascade + rebuild jobs so redirect history is minted identically):\n * - no canonical row yet (create) → insert a fresh canonical row;\n * - canonical row with the SAME path (unrelated field changed) → no-op;\n * - canonical row with a DIFFERENT path (a rename) → insert the new canonical\n * row, DEMOTE the old one to `is_canonical = false` (it stays as redirect\n * history — D005 / the change-matrix), prune history past\n * {@link MAX_REDIRECT_HISTORY}, and fire the cascade-trigger seam.\n *\n * The old canonical is DEMOTED before the new one is inserted — DB-safe because\n * of the `(entity_type, entity_id, locale) WHERE is_canonical` partial unique,\n * which caps a document at one canonical row per locale (an insert-first order\n * would transiently hold two and violate it). If the insert then fails on a\n * `(locale, path)` conflict (another document already owns the target URL — the\n * pre-commit guard only screens the write's OWN locale, F010) the old row is\n * re-promoted so the document is never left without a canonical (see\n * {@link applyCanonicalPath}).\n *\n * The ONE thing the writer adds over the shared helper: it fires the\n * cascade-trigger seam on an ANCESTOR's `renamed` outcome so a queue job can\n * regenerate descendants. The jobs themselves call `applyCanonicalPath`\n * directly (never re-firing the seam) — that's how a single cascade invocation\n * walks the whole subtree without tripping the queue's self-enqueue guard.\n */\nexport function createPathIndexWriter(app: ToolkitApp, entity: Entity): PathIndexWriter {\n // Safe to build in the closure — no recursion (just a TableClient wrapper).\n const pathsClient = toolkitPathsTable.makeClient(app.db.readWrite)\n\n return async (input) => {\n if (input.action === 'delete') {\n await pathsClient.deleteMany({ entityType: input.entityName, entityId: input.id })\n return\n }\n\n const config = getContentConfig()\n const defaultLocale = config.defaultLocale\n const reservedPathPrefixes = config.reservedPathPrefixes ?? []\n // A locale-scoped write (`updateForLocale`) touches only that locale; a\n // base write recomputes every configured locale (the create + base-update\n // path, and how the fallback ladder gets each locale's row).\n const locales = input.locale ? [input.locale] : config.locales.map((l) => l.code)\n\n // Lazy (inside the body) so constructing this read client — which itself\n // re-wires these seams — cannot recurse at wiring time. `findById` never\n // fires the seams, so there is no runtime recursion either.\n const readClient = app.getClient(entity)\n\n // Fire the cascade seam for an ANCESTOR whose canonical path changed. No-op\n // unless the entity is a `pathParent()` hierarchy AND a trigger is wired\n // (queue is an optional peer). Never let a cascade failure fail the already-\n // committed entity write (D005 drift tolerance — same as the uniqueness catch).\n const fireCascade = async (locale: string): Promise<void> => {\n if (!hasPathParent(entity)) return\n const trigger = getActiveCascadeTrigger()\n if (!trigger) return\n try {\n await trigger(app, entity, input.id, locale)\n } catch (err) {\n app.logger.warn(\n { err, entity: input.entityName, id: input.id, locale },\n 'toolkit_paths: descendant-cascade trigger threw — the entity write already ' +\n 'committed, so tolerating and continuing (paths are rebuildable, D005)',\n )\n }\n }\n\n for (const locale of locales) {\n const row =\n locale === input.locale\n ? (input.row ?? null)\n : ((await readClient.findById(input.id, { locale, defaultLocale })) as Record<\n string,\n unknown\n > | null)\n if (!row) continue\n\n const path = await computeCanonicalPath({ entity, row, locale, defaultLocale, pathsClient })\n\n // Shared find-canonical → insert/demote (also honours the reserved-prefix\n // skip: `pathConflictGuard` only validates the WRITE's own locale (F010),\n // and a base-row write recomputes every locale, so a reserved path could\n // slip through undetected in a locale the guard never checked — F013).\n const result = await applyCanonicalPath({\n pathsClient,\n entityType: input.entityName,\n entityId: input.id,\n locale,\n path,\n reservedPathPrefixes,\n logger: app.logger,\n })\n\n // An ancestor rename → fan out to descendants via the queue-job seam.\n if (result.outcome === 'renamed') await fireCascade(locale)\n }\n }\n}\n\n/**\n * PRE-commit path-conflict guard for a routable entity. Resolves the candidate\n * path for the write's locale, throws on a reserved-prefix hit or a manual\n * conflict, and auto-suffixes `data.slug` in place otherwise (persisted by the\n * same write).\n */\nexport function createPathConflictGuard(app: ToolkitApp, entity: Entity): PathConflictGuard {\n const pathsClient = toolkitPathsTable.makeClient(app.db.readWrite)\n\n return async (input) => {\n const config = getContentConfig()\n const defaultLocale = config.defaultLocale\n const reservedPathPrefixes = config.reservedPathPrefixes ?? []\n // Base/default writes check the default locale; `updateForLocale` checks\n // its own locale. Only one locale per write (F010 accepted simplification).\n const locale = input.locale ?? defaultLocale\n\n // For an update, load the pre-write locale-merged row so path tokens the\n // write didn't touch (slug on a title-only edit, parent, …) still resolve.\n // Lazy client construction — see writer note on recursion.\n let currentRow: Record<string, unknown> | null = null\n if (input.id !== undefined) {\n currentRow = (await app\n .getClient(entity)\n .findById(input.id, { locale, defaultLocale })) as Record<string, unknown> | null\n }\n\n // Effective slug mode for this locale — the same rule `sluggable()` itself\n // uses (explicit write value wins, else the current row's, else 'auto'),\n // shared via `resolveEffectiveSlugMode` so the two never drift apart.\n const slugMode = resolveEffectiveSlugMode(input.data.slugMode, currentRow?.slugMode)\n\n await ensurePathAvailable({\n entity,\n data: input.data,\n currentRow,\n id: input.id,\n locale,\n defaultLocale,\n slugMode,\n reservedPathPrefixes,\n pathsClient,\n })\n }\n}\n\n/**\n * Write the path-writers factory into the process-global slot. Called from the\n * content plugin's `init`. Core's `createAdminClient` reads it by `Symbol.for`\n * identity — keep {@link PATH_WRITERS_KEY} in sync with the reader in\n * `packages/core/src/client-factories.ts`.\n */\nexport function setActivePathWritersFactory(): void {\n const factory: PathWritersFactory = (app, entity) => ({\n pathIndexWriter: createPathIndexWriter(app, entity),\n pathConflictGuard: createPathConflictGuard(app, entity),\n })\n ;(globalThis as Record<symbol, unknown>)[PATH_WRITERS_KEY] = factory\n}\n","/**\n * Routing path-lifecycle queue jobs (plan/routing PR04 Task 2).\n *\n * Two on-demand (never scheduled) jobs maintain the `toolkit_paths` index\n * beyond a single first-party entity write:\n *\n * 1. `content:paths-cascade` — per-event. Fired from Task 1's cascade-trigger\n * seam when a `pathParent()` ancestor's canonical path changes in a locale.\n * A SINGLE invocation BFS-walks the ancestor's full (bounded) descendant\n * subtree and regenerates each descendant's canonical row — because every\n * descendant's `[parent.path]` expansion now points at a stale ancestor URL.\n *\n * 2. `content:paths-rebuild` — on-demand (the `lumi paths:rebuild` CLI, a later\n * task). Recomputes every routable document's canonical row, optionally\n * scoped to one entity/locale; `dryRun` reports the diff without writing.\n *\n * CRITICAL — no self-enqueue. `QueueClient.enqueue()` throws\n * `QueueSelfEnqueueError` if a handler enqueues its OWN job type (the recurrence\n * anti-pattern). So the cascade job must NOT re-enqueue itself per subtree\n * level. Instead each invocation walks the WHOLE subtree in-process, and applies\n * descendant path changes DIRECTLY via {@link applyCanonicalPath} — never\n * through `AdminClient.update`, which would re-fire the entity-hook writer's\n * cascade seam and try to enqueue `content:paths-cascade` from inside the\n * `content:paths-cascade` handler. Neither `runPathsCascade` nor\n * `runPathsRebuild` ever calls `enqueue`; the only enqueue is the trigger in\n * {@link registerPathJobs}, which runs OUTSIDE any job handler.\n *\n * Both jobs are bounded (CLAUDE.md's no-unbounded-fan-out rule): the cascade\n * caps total descendants processed and logs truncation; the rebuild pages\n * through documents and runs each page with bounded concurrency. Neither ever\n * `Promise.all`s over an unbounded set.\n *\n * NOTE: reachable only via `plugin.ts` (the jiti-safe plugin entry), never\n * content's `server-only` main entry. `@murumets-ee/queue/client` is\n * DYNAMIC-imported inside {@link registerPathJobs} so queue stays a peer off\n * this module's static graph (the blocks-jobs precedent).\n */\n\nimport { runAsCli, type ToolkitApp } from '@murumets-ee/core'\nimport { type Entity, hasPathParent, MAX_PATH_DEPTH } from '@murumets-ee/entity'\nimport { asc, inArray } from 'drizzle-orm'\nimport type { PgColumn } from 'drizzle-orm/pg-core'\nimport { z } from 'zod'\nimport { getContentConfig } from '../content-config-store.js'\nimport { applyCanonicalPath, mapBounded } from './apply-path.js'\nimport { computeCanonicalPath } from './generate-path.js'\nimport { toolkitPathsTable } from './schema.js'\nimport { setActiveCascadeTrigger } from './writers.js'\n\n// ── Job names (Task 3's CLI depends on these EXACT strings) ──────────────────\n\n/** Descendant-regeneration job. `pluginName:action` convention. */\nexport const PATHS_CASCADE_JOB_NAME = 'content:paths-cascade'\n/** Full-index rebuild job. `pluginName:action` convention. */\nexport const PATHS_REBUILD_JOB_NAME = 'content:paths-rebuild'\n\n// ── Bounds (see CLAUDE.md no-unbounded-fan-out) ──────────────────────────────\n\n/**\n * Cascade total-descendant ceiling. Depth is already capped at 10 by\n * `pathParent()`'s cycle/depth check, but BREADTH (siblings at any one level) is\n * uncapped — a single ancestor could have thousands of descendants. 2000 is a\n * generous ceiling for an interactive-triggered job that must not exhaust the\n * connection pool; beyond it we log truncation and point at `lumi paths:rebuild`\n * (Job 2) for guaranteed full coverage.\n */\nconst CASCADE_MAX_ITEMS = 2000\n\n/** Worker-pool size within one BFS level / rebuild page (the `runMeritBatch` shape). */\nconst DEFAULT_CONCURRENCY = 5\n\n/** Rebuild page size — documents fetched per `findMany` (never load a whole table). */\nconst REBUILD_PAGE_SIZE = 200\n\n/**\n * Max diff entries stored in the rebuild job's progress blob. `toolkit_jobs.progress`\n * is capped at ~7 KB (PROGRESS_MAX_BYTES) — an oversized payload is DROPPED, losing\n * even the counters. Each entry serializes to ~200 bytes, so 25 keeps the final\n * progress well under the cap while leaving room for the counters + scope. Overflow\n * beyond this is reported via `sampleTruncated` + the aggregate counts (never silent).\n */\nconst DIFF_SAMPLE_CAP = 25\n\n// ── Payloads (Task 3's CLI builds these) ─────────────────────────────────────\n\n/** `content:paths-cascade` payload. */\nexport const cascadePayloadSchema = z.object({\n /** The renamed ancestor's entity type (must be a `pathParent()` hierarchy). */\n entityType: z.string().min(1),\n /** The renamed ancestor document's id. */\n parentId: z.string().uuid(),\n /** The single locale whose canonical path changed. */\n locale: z.string().min(1),\n})\nexport type CascadePayload = z.infer<typeof cascadePayloadSchema>\n\n/** `content:paths-rebuild` payload. Omit `entity`/`locale` for all routable entities / all locales. */\nexport const rebuildPayloadSchema = z.object({\n entity: z.string().min(1).optional(),\n locale: z.string().min(1).optional(),\n dryRun: z.boolean(),\n})\nexport type RebuildPayload = z.infer<typeof rebuildPayloadSchema>\n\n// ── Progress / result shapes (Task 3 reads these back via getJob(id).progress) ─\n\n/** Cascade progress snapshot (also the shape stored in `toolkit_jobs.progress`). */\nexport interface CascadeProgress {\n phase: 'walking' | 'done'\n /** Descendants examined so far. */\n processed: number\n /** Descendants whose canonical row was (re)generated (`created` or `renamed`). */\n regenerated: number\n /** Descendants already at the correct path (no-op). */\n unchanged: number\n /** Descendants skipped (broken link, reserved prefix, or cross-document conflict). */\n skipped: number\n /** Descendants that threw during processing (isolated — did not abort the batch). */\n failed: number\n /** True when the subtree exceeded {@link CASCADE_MAX_ITEMS} (deeper nodes NOT processed). */\n truncated: boolean\n}\n\n/** Cascade final result (returned by {@link runPathsCascade}). Counters only — no `phase`. */\nexport interface CascadeResult extends Omit<CascadeProgress, 'phase'> {\n entityType: string\n parentId: string\n locale: string\n /** The active cap for this run (echoes {@link CASCADE_MAX_ITEMS} unless overridden in tests). */\n cap: number\n}\n\n/** One rebuild diff entry (a document whose canonical row differs from the recompute). */\nexport interface RebuildDiffEntry {\n /** `changed` — path differs; `missing` — no current canonical; `conflict` — target owned by another doc. */\n kind: 'changed' | 'missing' | 'conflict'\n entityType: string\n entityId: string\n locale: string\n oldPath: string | null\n newPath: string | null\n /** For `kind:'conflict'`: entityId of the document already owning `newPath`. */\n conflictWith?: string\n}\n\n/** Rebuild progress snapshot (also the shape stored in `toolkit_jobs.progress`). */\nexport interface RebuildProgress {\n phase: 'scanning' | 'done'\n dryRun: boolean\n scope: { entity: string | null; locale: string | null }\n /**\n * Documents examined, SUMMED across every convergence pass — a document\n * re-scanned in a later pass is counted again. CUMULATIVE (like\n * `changed`/`applied`), NOT a terminal-state count. Kept cumulative so a reader\n * can see total scan work; the four \"outstanding state\" fields below are\n * terminal instead (see the note on `passes`).\n */\n scanned: number\n /**\n * Number of times a document's canonical path was corrected (or, in dry-run,\n * WOULD change), SUMMED across passes. CUMULATIVE work-log — a doc corrected\n * over N passes counts N times. (Fix 1 keeps this cumulative: \"how much\n * correcting happened\" is a meaningful metric.)\n */\n changed: number\n /**\n * FINAL (terminal) state, not a per-pass sum (Fix 1): documents STILL lacking a\n * canonical row after the last pass — a would-insert count in dry-run, and 0 in\n * a real run that inserted them all (a doc created in an early real pass is no\n * longer \"missing\" at the end).\n */\n missing: number\n /**\n * FINAL (terminal) state, not a per-pass sum (Fix 1): documents whose target\n * path STILL collides with a DIFFERENT document's canonical after the last\n * pass. A collision a later pass FREES (e.g. the colliding doc was itself\n * renamed away) is NOT counted — so a non-zero value is a real, outstanding URL\n * collision to resolve, never a transient scan-order artifact of an early pass.\n */\n conflicts: number\n /**\n * FINAL (terminal) state, not a per-pass sum (Fix 1): documents at the correct\n * path as of the last pass that observed them.\n */\n unchanged: number\n /**\n * FINAL (terminal) state, not a per-pass sum (Fix 1): documents skipped (broken\n * link or reserved prefix) as of the last pass that observed them.\n */\n skipped: number\n /** Rows actually written, SUMMED across every convergence pass (always 0 in dry-run). */\n applied: number\n /** Documents that threw during processing (isolated), SUMMED across passes. */\n failed: number\n /**\n * Number of full convergence passes the real-write path ran (see\n * {@link runPathsRebuild}'s outer loop). Always `1` for dry-run and for the\n * common case (no `pathParent()` hierarchy drift); higher only when a nested\n * hierarchy needed multiple passes to fully converge (bounded by\n * `MAX_PATH_DEPTH`). `scanned`/`changed`/`applied`/`failed` are CUMULATIVE\n * across these passes (a work-log of how much scanning/correcting happened);\n * `missing`/`conflicts`/`unchanged`/`skipped` instead reflect the FINAL\n * (terminal, last-observed) per-document state after the last pass (Fix 1), so\n * a problem an early pass saw but a later pass resolved is not double-reported.\n */\n passes: number\n /**\n * Present (and the relevant flag `true`) ONLY when a `--entity`/`--locale`\n * filter was set but matched nothing — the sweep then no-ops over an empty\n * scope, which is indistinguishable from \"nothing needed fixing\" by the counts\n * alone. Surfaced here (Fix 3) so the CLI — which polls this progress blob but\n * cannot pre-validate `--locale` (content's configured-locale list isn't loaded\n * under `initPlugins: 'none'`) — can print an explicit \"matched nothing\" note.\n * Omitted entirely when both filters matched (or weren't set).\n */\n unmatchedScope?: { entity: boolean; locale: boolean }\n /**\n * Present only in dry-run when the scope includes a `pathParent()` hierarchy:\n * the preview reflects a SINGLE pass, so a hierarchy drifted at multiple levels\n * may not be fully resolved in the diff — the real (non-dry-run) rebuild's\n * multi-pass convergence always resolves it. Omitted otherwise.\n */\n hierarchyNote?: string\n /** Bounded sample of `changed`/`missing`/`conflict` entries (see {@link DIFF_SAMPLE_CAP}). */\n sample: RebuildDiffEntry[]\n /** True when more diff entries existed than {@link DIFF_SAMPLE_CAP} could hold. */\n sampleTruncated: boolean\n}\n\n/** Rebuild final result (returned by {@link runPathsRebuild}). */\nexport type RebuildResult = Omit<RebuildProgress, 'phase'>\n\nexport interface CascadeRunOptions {\n /** Override the total-descendant cap (tests use a small value). */\n maxItems?: number\n /** Override the per-level worker-pool size. */\n concurrency?: number\n /** Progress callback (the handler bridges this to `JobContext.updateProgress`). */\n onProgress?: (progress: CascadeProgress) => void\n}\n\nexport interface RebuildRunOptions {\n pageSize?: number\n concurrency?: number\n sampleCap?: number\n onProgress?: (progress: RebuildProgress) => void\n}\n\n// ── Job 1 — cascade ──────────────────────────────────────────────────────────\n\n/**\n * Regenerate the canonical `toolkit_paths` rows for every descendant of a\n * renamed `pathParent()` ancestor, in one bounded, single-invocation BFS walk.\n *\n * Ordering: level by level (parents strictly before their own children) —\n * within a level, siblings are independent (their `[parent.path]` expands\n * against a PREVIOUS level's already-written canonical), so each level runs with\n * bounded concurrency; the next level is discovered + processed only AFTER the\n * current level's writes have flushed. Descendant path changes are applied\n * DIRECTLY (never via `AdminClient.update`) so the entity-hook cascade seam is\n * not re-fired — that is what keeps this off the queue self-enqueue guard.\n */\nexport async function runPathsCascade(\n app: ToolkitApp,\n payload: CascadePayload,\n opts: CascadeRunOptions = {},\n): Promise<CascadeResult> {\n const { entityType, parentId, locale } = payload\n const cap = opts.maxItems ?? CASCADE_MAX_ITEMS\n const concurrency = opts.concurrency ?? DEFAULT_CONCURRENCY\n\n const counters = {\n processed: 0,\n regenerated: 0,\n unchanged: 0,\n skipped: 0,\n failed: 0,\n truncated: false,\n }\n const snapshot = (phase: CascadeProgress['phase']): CascadeProgress => ({ phase, ...counters })\n const result = (): CascadeResult => ({ entityType, parentId, locale, cap, ...counters })\n\n const entity = app.entities.get(entityType)\n if (!entity) {\n app.logger.warn(\n { entityType, parentId, locale },\n 'paths-cascade: unknown entity type — nothing to regenerate',\n )\n opts.onProgress?.(snapshot('done'))\n return result()\n }\n if (!hasPathParent(entity)) {\n // Defensive: the trigger only fires for pathParent entities. A cascade for a\n // non-hierarchy entity has no `[parent.path]` descendants to regenerate.\n app.logger.warn(\n { entityType, parentId, locale },\n 'paths-cascade: entity is not a pathParent() hierarchy — nothing to regenerate',\n )\n opts.onProgress?.(snapshot('done'))\n return result()\n }\n\n const config = getContentConfig()\n const defaultLocale = config.defaultLocale\n const reservedPathPrefixes = config.reservedPathPrefixes ?? []\n const pathsClient = toolkitPathsTable.makeClient(app.db.readWrite)\n const client = app.getClient(entity)\n // Dynamic `parent` self-reference column (pathParent adds it). `getTable()` is\n // `PgTableWithColumns<any>`; the structural cast narrows to the one column we\n // filter on — the same idiom rent uses for dynamic column refs.\n const parentColumn = (client.getTable() as unknown as { parent: PgColumn }).parent\n\n const processOne = async (row: Record<string, unknown>): Promise<void> => {\n const id = row.id as string\n try {\n const path = await computeCanonicalPath({ entity, row, locale, defaultLocale, pathsClient })\n const applied = await applyCanonicalPath({\n pathsClient,\n entityType,\n entityId: id,\n locale,\n path,\n reservedPathPrefixes,\n logger: app.logger,\n })\n switch (applied.outcome) {\n case 'created':\n case 'renamed':\n counters.regenerated += 1\n break\n case 'unchanged':\n counters.unchanged += 1\n break\n default:\n // skipped-null / skipped-reserved / conflict\n counters.skipped += 1\n break\n }\n } catch (err) {\n counters.failed += 1\n app.logger.warn(\n { err, entityType, id, locale },\n 'paths-cascade: failed to regenerate one descendant path — continuing (per-item isolation)',\n )\n }\n }\n\n // BFS: `expand` holds the ids whose children form the NEXT level to process.\n // Start by expanding the renamed ancestor itself (its own canonical was\n // already written by the entity-hook writer that fired this cascade).\n let expand: string[] = [parentId]\n while (expand.length > 0) {\n const remaining = cap - counters.processed\n if (remaining <= 0) {\n counters.truncated = true\n break\n }\n // One query per level (inArray over the whole level's parents), locale-merged\n // so each row carries the right per-locale slug. `remaining + 1` detects\n // overflow past the cap without another round trip.\n const level = (await client.findMany({\n where: inArray(parentColumn, expand),\n locale,\n defaultLocale,\n limit: remaining + 1,\n })) as Array<Record<string, unknown>>\n if (level.length === 0) break\n\n let batch = level\n if (level.length > remaining) {\n batch = level.slice(0, remaining)\n counters.truncated = true\n }\n\n await mapBounded(batch, concurrency, processOne)\n counters.processed += batch.length\n opts.onProgress?.(snapshot('walking'))\n\n if (counters.truncated) break\n // Next level = the ids we just wrote (their canonical paths are now current).\n expand = batch.map((r) => r.id as string)\n }\n\n if (counters.truncated) {\n app.logger.warn(\n { entityType, parentId, locale, processed: counters.processed, cap },\n 'paths-cascade: descendant subtree exceeded the cap — deeper/remaining descendants were ' +\n 'NOT regenerated. Run `lumi paths:rebuild --entity ' +\n `${entityType}\\` for guaranteed full coverage.`,\n )\n }\n\n opts.onProgress?.(snapshot('done'))\n return result()\n}\n\n// ── Job 2 — rebuild ──────────────────────────────────────────────────────────\n\n/**\n * Recompute every routable document's canonical `toolkit_paths` row (optionally\n * scoped to one entity and/or locale). `dryRun` reports the diff without\n * writing; otherwise each drifted row is corrected via the SAME\n * demote-then-insert-if-changed writer as the cascade + entity hook.\n *\n * Pages through documents with KEYSET (cursor) pagination on the immutable `id`\n * PK — NOT offset. Offset paging has undefined/shifting row order across page\n * boundaries under concurrent inserts/deletes during a large sweep (autovacuum,\n * an admin creating/deleting a document mid-rebuild), which silently skips or\n * double-processes documents and would undermine the \"full coverage\" guarantee\n * this job exists to provide. Keyset (`WHERE id > lastId ORDER BY id ASC`) visits\n * every document exactly once regardless of concurrent mutation. Each page runs\n * with bounded concurrency; the accumulated diff sample is bounded so the final\n * progress fits under the `toolkit_jobs.progress` cap.\n *\n * BOUNDED MULTI-PASS CONVERGENCE (real-write path). Documents are scanned in\n * ascending `id` (a random UUID — uncorrelated with hierarchy depth). A\n * `pathParent()` descendant scanned BEFORE its ancestor in the same sweep\n * expands `[parent.path]` against the ancestor's not-yet-corrected canonical\n * ({@link computeCanonicalPath} reads the parent's PERSISTED `toolkit_paths`\n * row), so one sweep can leave nested URLs stale. The fix: repeat the full sweep\n * until a pass writes nothing. Each pass corrects one more depth level — depth-0\n * roots are right after pass 1 (no parent dependency); a depth-`d` document is\n * right by the START of pass `d+1` because its parent is already right by then —\n * so `MAX_PATH_DEPTH` passes guarantee full convergence for any hierarchy this\n * codebase can build (deeper trees are rejected at write time by `pathParent()`'s\n * own cycle/depth check). We stop the instant a pass writes zero rows, so the\n * common case (no hierarchy drift, or a non-hierarchy entity) converges in\n * exactly ONE pass. Counters are CUMULATIVE across passes (see `passes` on\n * {@link RebuildProgress}). Dry-run writes nothing, so multi-pass can't converge\n * it — it runs a single pass and surfaces `hierarchyNote` when the scope includes\n * a hierarchy, rather than over-engineering an in-memory simulated convergence.\n */\nexport async function runPathsRebuild(\n app: ToolkitApp,\n payload: RebuildPayload,\n opts: RebuildRunOptions = {},\n): Promise<RebuildResult> {\n const pageSize = opts.pageSize ?? REBUILD_PAGE_SIZE\n const concurrency = opts.concurrency ?? DEFAULT_CONCURRENCY\n const sampleCap = opts.sampleCap ?? DIFF_SAMPLE_CAP\n const dryRun = payload.dryRun\n\n const config = getContentConfig()\n const defaultLocale = config.defaultLocale\n const reservedPathPrefixes = config.reservedPathPrefixes ?? []\n const allLocales = config.locales.map((l) => l.code)\n const targetLocales = payload.locale ? allLocales.filter((c) => c === payload.locale) : allLocales\n // Every entity declaring a frontend path, filtered to `payload.entity` if given.\n const targetEntities = [...app.entities.values()].filter(\n (e) =>\n e.frontend?.path !== undefined && (payload.entity === undefined || e.name === payload.entity),\n )\n\n // A typo'd / unknown `--entity` or `--locale` narrows the scope to nothing.\n // Left silent, the job would complete \"successfully\" with all-zero counts —\n // indistinguishable from \"nothing needed fixing\". Warn (mirroring the cascade\n // job's \"unknown entity type\" precedent above) so a likely typo is surfaced;\n // the sweep below then no-ops naturally over the empty scope (still a success,\n // not a hard error).\n const unmatchedEntity = payload.entity !== undefined && targetEntities.length === 0\n const unmatchedLocale = payload.locale !== undefined && targetLocales.length === 0\n if (unmatchedEntity) {\n app.logger.warn(\n { entity: payload.entity, dryRun },\n 'paths-rebuild: --entity matches no routable entity (one declaring frontend.path) — ' +\n 'nothing to rebuild (likely a typo in the entity name)',\n )\n }\n if (unmatchedLocale) {\n app.logger.warn(\n { locale: payload.locale, configuredLocales: allLocales, dryRun },\n 'paths-rebuild: --locale matches no configured locale — nothing to rebuild ' +\n '(likely a typo in the locale code)',\n )\n }\n // The worker's warn above only reaches the WORKER log, never the CLI. Surface\n // the same signal in the progress blob the CLI polls so it can print an\n // explicit \"matched nothing\" note (Fix 3) — the `--locale` half is otherwise\n // invisible to a CLI operator (it can't pre-validate the locale list). Present\n // only when a set filter matched nothing.\n const unmatchedScope: RebuildProgress['unmatchedScope'] =\n unmatchedEntity || unmatchedLocale\n ? { entity: unmatchedEntity, locale: unmatchedLocale }\n : undefined\n\n // Dry-run can't converge a multi-level `pathParent()` hierarchy the way the\n // real run does (the multi-pass loop relies on each pass reading the PERSISTED\n // writes of the previous one; dry-run writes nothing). Rather than simulate\n // convergence in memory (real scope creep), surface an honest caveat when the\n // scope includes a hierarchy entity: the preview is a best-effort SINGLE pass;\n // the real (non-dry-run) rebuild always fully converges.\n const scopeHasHierarchy = targetEntities.some((e) => hasPathParent(e))\n const hierarchyNote =\n dryRun && scopeHasHierarchy\n ? 'Preview reflects a SINGLE pass; a pathParent() hierarchy drifted at multiple levels may ' +\n \"need the real (non-dry-run) rebuild's multi-pass convergence to fully resolve. The real \" +\n 'run always converges.'\n : undefined\n\n const pathsClient = toolkitPathsTable.makeClient(app.db.readWrite)\n\n // CUMULATIVE work-log counters — SUMMED across every convergence pass.\n const counters = {\n scanned: 0,\n changed: 0,\n applied: 0,\n failed: 0,\n }\n // TERMINAL (last-observed) per-document outcome for the four \"outstanding\n // state\" categories (Fix 1). A document's outcome can legitimately change\n // between passes — a pass-1 `conflict` whose colliding URL a later pass frees\n // becomes a `renamed` — so these must report the FINAL state, not a per-pass\n // sum (which would phantom-report an already-self-resolved problem). Maintained\n // last-write-wins with O(1) tallies (keyed by `entityType:entityId:locale`) so\n // building a snapshot stays cheap even on a large, many-page sweep.\n type TerminalState = 'missing' | 'conflicts' | 'unchanged' | 'skipped' | 'resolved'\n const terminalOf = new Map<string, TerminalState>()\n // 'resolved' (a real write, or a dry-run would-change) is NOT an outstanding\n // state — it's captured by the cumulative `changed`/`applied` totals — so it is\n // tracked in the map (to overwrite an earlier problem entry) but never tallied.\n const terminal = { missing: 0, conflicts: 0, unchanged: 0, skipped: 0 }\n const setTerminal = (key: string, next: TerminalState): void => {\n const prev = terminalOf.get(key)\n if (prev === next) return\n if (prev !== undefined && prev !== 'resolved') terminal[prev] -= 1\n terminalOf.set(key, next)\n if (next !== 'resolved') terminal[next] += 1\n }\n const sample: RebuildDiffEntry[] = []\n let sampleTruncated = false\n const addSample = (entry: RebuildDiffEntry): void => {\n if (sample.length < sampleCap) sample.push(entry)\n else sampleTruncated = true\n }\n // Number of full convergence passes actually run (see the outer loop below).\n let passes = 0\n const snapshot = (phase: RebuildProgress['phase']): RebuildProgress => ({\n phase,\n dryRun,\n scope: { entity: payload.entity ?? null, locale: payload.locale ?? null },\n scanned: counters.scanned,\n changed: counters.changed,\n applied: counters.applied,\n failed: counters.failed,\n missing: terminal.missing,\n conflicts: terminal.conflicts,\n unchanged: terminal.unchanged,\n skipped: terminal.skipped,\n passes,\n sample,\n sampleTruncated,\n ...(hierarchyNote !== undefined ? { hierarchyNote } : {}),\n ...(unmatchedScope !== undefined ? { unmatchedScope } : {}),\n })\n\n // Compute + apply the canonical path for ONE (document, locale): updates the\n // cumulative counters, the terminal-state map, and the diff sample. Returns\n // `true` iff it made a REAL canonical write (created/renamed on the non-dry-run\n // path) so the caller can sum \"how many rows this pass wrote\" for the\n // convergence stop signal. Extracted (Fix 2) so BOTH the per-page keyset scan\n // (runOnePass) and the targeted problem-doc retry (retryProblemDocuments) go\n // through ONE outcome-handling path — no drift between the two callers.\n const processDocument = async (\n entity: Entity,\n entityType: string,\n row: Record<string, unknown>,\n locale: string,\n ): Promise<boolean> => {\n const id = row.id as string\n let wrote = false\n try {\n const path = await computeCanonicalPath({\n entity,\n row,\n locale,\n defaultLocale,\n pathsClient,\n })\n const applied = await applyCanonicalPath({\n pathsClient,\n entityType,\n entityId: id,\n locale,\n path,\n reservedPathPrefixes,\n logger: app.logger,\n dryRun,\n })\n // Terminal-outcome key: last-write-wins across passes (Fix 1).\n const termKey = `${entityType}:${id}:${locale}`\n switch (applied.outcome) {\n case 'unchanged':\n setTerminal(termKey, 'unchanged')\n break\n case 'skipped-null':\n case 'skipped-reserved':\n setTerminal(termKey, 'skipped')\n break\n case 'conflict':\n setTerminal(termKey, 'conflicts')\n addSample({\n kind: 'conflict',\n entityType,\n entityId: id,\n locale,\n oldPath: applied.oldPath,\n newPath: applied.newPath,\n ...(applied.conflictWith ? { conflictWith: applied.conflictWith.entityId } : {}),\n })\n break\n case 'created':\n if (!dryRun) {\n // Real insert — the doc is no longer \"missing\"; the write is\n // captured by cumulative `applied`, so its terminal state is\n // 'resolved' (not tallied), and a later pass may mark it\n // 'unchanged'. In dry-run nothing is written, so it stays a\n // would-insert → terminal 'missing'.\n counters.applied += 1\n wrote = true\n setTerminal(termKey, 'resolved')\n } else {\n setTerminal(termKey, 'missing')\n }\n addSample({\n kind: 'missing',\n entityType,\n entityId: id,\n locale,\n oldPath: null,\n newPath: applied.newPath,\n })\n break\n case 'renamed':\n // `changed` is the cumulative \"how many corrections happened\"\n // work-log; a rename (real OR dry-run would-change) is therefore\n // 'resolved' terminally, never an outstanding-state category.\n counters.changed += 1\n if (!dryRun) {\n counters.applied += 1\n wrote = true\n }\n setTerminal(termKey, 'resolved')\n addSample({\n kind: 'changed',\n entityType,\n entityId: id,\n locale,\n oldPath: applied.oldPath,\n newPath: applied.newPath,\n })\n break\n }\n } catch (err) {\n counters.failed += 1\n app.logger.warn(\n { err, entityType, id, locale },\n 'paths-rebuild: failed to process one document — continuing (per-item isolation)',\n )\n }\n return wrote\n }\n\n // One full sweep over every (entity, locale) in scope. Returns the number of\n // canonical rows this pass WROTE (created/renamed) — 0 means the index has\n // converged (or the only remaining diffs are conflicts / skips / failures that\n // more passes can't resolve), which is the outer loop's stop signal.\n const runOnePass = async (entities: readonly Entity[]): Promise<number> => {\n let passApplied = 0\n for (const entity of entities) {\n const client = app.getClient(entity)\n const entityType = entity.name\n // `id` column ref for the stable keyset ORDER BY — `findMany`'s `orderBy`\n // takes a Drizzle SQL/column, not the TableClient's `{ column, dir }` shape.\n // `getTable()` is `PgTableWithColumns<any>`; the structural cast narrows to\n // the one column we sort on (same idiom the cascade job uses for `parent`).\n const idColumn = (client.getTable() as unknown as { id: PgColumn }).id\n for (const locale of targetLocales) {\n // Keyset cursor on the immutable `id` PK (see the job docblock): visits\n // every document exactly once even under concurrent inserts/deletes, which\n // OFFSET paging cannot guarantee.\n let cursorId: string | undefined\n for (;;) {\n const page = (await client.findMany({\n locale,\n defaultLocale,\n limit: pageSize,\n orderBy: asc(idColumn),\n ...(cursorId !== undefined\n ? { cursor: { field: 'id', value: cursorId, direction: 'asc' } }\n : {}),\n })) as Array<Record<string, unknown>>\n if (page.length === 0) break\n\n await mapBounded(page, concurrency, async (row) => {\n const wrote = await processDocument(entity, entityType, row, locale)\n // `+= 1` between awaits is atomic (JS single-threaded) — no lock.\n if (wrote) passApplied += 1\n })\n\n counters.scanned += page.length\n // Advance the cursor to the last (max-`id`) row of this page. `page` is\n // non-empty here (length-0 broke above), so `lastRow` is always defined.\n const lastRow = page[page.length - 1]\n if (lastRow) cursorId = lastRow.id as string\n opts.onProgress?.(snapshot('scanning'))\n if (page.length < pageSize) break\n }\n }\n }\n return passApplied\n }\n\n // Fix 2 (correctness gap in the passes-2+ narrowing) — targeted retry of the\n // SPECIFIC non-hierarchy documents left in an outstanding \"problem\" state\n // (conflict / skipped / missing) by an earlier pass.\n //\n // Canonical-path uniqueness is GLOBAL per `(locale, path)`, NOT scoped to\n // entity type. So a NON-hierarchy document can `conflict` in pass 1 because a\n // HIERARCHY document currently owns its target path; when that hierarchy\n // document later moves away (its own ancestor corrected in a later pass), the\n // path frees up. The passes-2+ scan narrows to `hierarchyEntities`, so it\n // never re-scans that non-hierarchy document — leaving a stale `conflicts`\n // entry a second `paths:rebuild` invocation would have resolved.\n //\n // Rather than widen the pass-2+ scan back to every scoped entity (which would\n // undo the narrowing win), re-run JUST those problem documents as O(1) point\n // lookups keyed off the `terminalOf` map we already maintain. The set is\n // naturally small and shrinks as problems resolve, so it doesn't reintroduce\n // the rescan-everything cost. Hierarchy entities are EXCLUDED — runOnePass\n // already full-scans them every pass. Returns the number of REAL canonical\n // writes made (summed into the pass total so the loop keeps iterating while\n // retries make progress).\n const retryProblemDocuments = async (\n hierarchyEntityNames: ReadonlySet<string>,\n ): Promise<number> => {\n // Snapshot the problem set BEFORE processing — `processDocument` mutates\n // `terminalOf`, and mutating a Map's values while iterating it is fragile.\n const retryTargets: Array<{\n entity: Entity\n entityType: string\n entityId: string\n locale: string\n }> = []\n for (const [key, state] of terminalOf) {\n if (state !== 'conflicts' && state !== 'skipped' && state !== 'missing') continue\n // Parse `${entityType}:${entityId}:${locale}`. Entity names and BCP47\n // locale codes are colon-free, so anchoring on the FIRST and LAST colon\n // extracts all three parts even though the middle id is opaque.\n const firstColon = key.indexOf(':')\n const lastColon = key.lastIndexOf(':')\n if (firstColon <= 0 || lastColon <= firstColon) continue\n const entityType = key.slice(0, firstColon)\n // Hierarchy entities are already fully re-scanned every pass by runOnePass —\n // retrying them here would double-process for no gain.\n if (hierarchyEntityNames.has(entityType)) continue\n const entity = app.entities.get(entityType)\n if (entity === undefined) continue\n retryTargets.push({\n entity,\n entityType,\n entityId: key.slice(firstColon + 1, lastColon),\n locale: key.slice(lastColon + 1),\n })\n }\n if (retryTargets.length === 0) return 0\n\n let retryApplied = 0\n await mapBounded(retryTargets, concurrency, async (t) => {\n const client = app.getClient(t.entity)\n // Locale-merged single-row point lookup (same merge the page scan uses) —\n // NOT a table scan, so this keeps the narrowing's cost profile. A row\n // deleted mid-rebuild resolves to null → nothing to retry.\n const row = (await client.findById(t.entityId, {\n locale: t.locale,\n defaultLocale,\n })) as Record<string, unknown> | null\n if (row === null) return\n // Re-examined this pass — count it in the cumulative scan work-log.\n counters.scanned += 1\n const wrote = await processDocument(t.entity, t.entityType, row, t.locale)\n if (wrote) retryApplied += 1\n })\n opts.onProgress?.(snapshot('scanning'))\n return retryApplied\n }\n\n // Bounded multi-pass convergence — see the function docblock. Multi-pass is\n // only meaningful for a `pathParent()` hierarchy (a `[parent.path]` document\n // scanned before its ancestor). A scope with NO hierarchy entity has no\n // cross-document dependency, so a single pass provably corrects every drifted\n // row (order-independent) — no confirming pass needed. Dry-run writes nothing,\n // so re-running can't converge it either — it does one pass and relies on\n // `hierarchyNote` to flag a possibly-incomplete hierarchy preview. When we DO\n // multi-pass, the loop stops the instant a pass writes nothing.\n // `[parent.path]` is a SAME-entity self-reference only (generate-path.ts — no\n // cross-entity-type dependency), so any NON-`pathParent()` entity is provably\n // converged after ONE pass: it has no ancestor whose later correction could\n // shift its path. Re-scanning it on passes 2+ is pure read-amplification (up to\n // MAX_PATH_DEPTH× the whole keyset of every unrelated entity). So pass 1 scans\n // the full original scope; passes 2+ narrow to the hierarchy entities that can\n // still drift (Fix 2).\n const hierarchyEntities = targetEntities.filter((e) => hasPathParent(e))\n const hierarchyEntityNames: ReadonlySet<string> = new Set(hierarchyEntities.map((e) => e.name))\n const maxPasses = dryRun || !scopeHasHierarchy ? 1 : MAX_PATH_DEPTH\n for (let pass = 0; pass < maxPasses; pass++) {\n passes = pass + 1\n let passApplied = await runOnePass(pass === 0 ? targetEntities : hierarchyEntities)\n // Passes 2+ ALSO retry the specific non-hierarchy documents whose earlier\n // problem state a hierarchy move THIS pass may have just freed (Fix 2). Runs\n // AFTER the hierarchy full-scan so the freed path is already visible. Point\n // lookups, not a rescan — keeps pass 1's narrowing win.\n if (pass > 0) passApplied += await retryProblemDocuments(hierarchyEntityNames)\n if (passApplied === 0) break\n }\n\n if (sampleTruncated) {\n app.logger.warn(\n {\n scanned: counters.scanned,\n changed: counters.changed,\n missing: terminal.missing,\n conflicts: terminal.conflicts,\n sampleCap,\n },\n 'paths-rebuild: more diff entries than the progress sample can hold — see the aggregate counts; ' +\n 'the stored sample is truncated',\n )\n }\n\n const final = snapshot('done')\n opts.onProgress?.(final)\n const { phase: _phase, ...rest } = final\n return rest\n}\n\n// ── Registration (dynamic-imports queue — keeps it a peer off the static graph) ─\n\n/**\n * Register both path jobs and wire the Task 1 cascade-trigger seam to enqueue\n * the cascade job. Called from content's plugin `init` (dynamic-imported so\n * `@murumets-ee/queue` stays a peer — the blocks-jobs precedent).\n *\n * The cascade trigger runs OUTSIDE any job handler (post-commit on an entity\n * write), so enqueuing `content:paths-cascade` there is safe; the cascade\n * HANDLER never enqueues anything (it walks the subtree in-process), so the\n * queue self-enqueue guard is never engaged.\n */\nexport async function registerPathJobs(app: ToolkitApp): Promise<void> {\n const { defineJob, registerJob, QueueClient } = await import('@murumets-ee/queue/client')\n\n const cascadeJob = defineJob({\n name: PATHS_CASCADE_JOB_NAME,\n description:\n 'Regenerate toolkit_paths canonical rows for the descendants of a renamed routable-hierarchy ' +\n 'ancestor (one locale). Walks the full bounded subtree in a single invocation.',\n schema: cascadePayloadSchema,\n })\n\n const rebuildJob = defineJob({\n name: PATHS_REBUILD_JOB_NAME,\n description:\n \"Recompute every routable document's canonical toolkit_paths row (optionally scoped to one \" +\n 'entity/locale); dryRun reports the diff without writing. On-demand (lumi paths:rebuild).',\n schema: rebuildPayloadSchema,\n })\n\n registerJob(cascadeJob, async (ctx) => {\n // The whole body runs elevated (AdminClient reads demand a SecurityContext,\n // and a background queue worker has no request-scoped one) — same\n // `runAsCli` wrapping every other entity-touching job handler in this\n // codebase uses (see e.g. `packages/rent/src/plugin.ts`'s\n // `close_batch_periods` job). `runPathsCascade` itself assumes it is\n // already elevated and never nests `runAsCli`.\n await runAsCli(async () => {\n const payload = cascadePayloadSchema.parse(ctx.payload)\n const cascadeResult = await runPathsCascade(app, payload, {\n onProgress: (p) => ctx.updateProgress(p),\n })\n app.logger.info(cascadeResult, 'paths-cascade: descendant regeneration complete')\n })\n })\n\n registerJob(rebuildJob, async (ctx) => {\n await runAsCli(async () => {\n const payload = rebuildPayloadSchema.parse(ctx.payload)\n const rebuildResult = await runPathsRebuild(app, payload, {\n onProgress: (p) => ctx.updateProgress(p),\n })\n app.logger.info(\n {\n dryRun: rebuildResult.dryRun,\n scanned: rebuildResult.scanned,\n changed: rebuildResult.changed,\n missing: rebuildResult.missing,\n conflicts: rebuildResult.conflicts,\n applied: rebuildResult.applied,\n failed: rebuildResult.failed,\n // Multi-pass convergence visibility for worker-log-only operators\n // (matches what the CLI renderer already surfaces) — Fix 5.\n passes: rebuildResult.passes,\n ...(rebuildResult.hierarchyNote !== undefined\n ? { hierarchyNote: rebuildResult.hierarchyNote }\n : {}),\n },\n 'paths-rebuild: sweep complete',\n )\n })\n })\n\n // Task 1 seam → enqueue the cascade job on an ancestor rename.\n setActiveCascadeTrigger(async (triggerApp, entity, entityId, locale) => {\n const qc = new QueueClient({ app: triggerApp, logger: triggerApp.logger })\n await qc.enqueue(cascadeJob, { entityType: entity.name, parentId: entityId, locale })\n })\n}\n","/**\n * `toolkit_path_patterns` — the per-(entity, locale) frontend-path PATTERN HASH\n * store (plan/routing PR04 §2 change-matrix row \"Pattern changed in code\").\n *\n * One row per (entity_type, locale) holding a sha256 of the RESOLVED\n * `frontend.path` pattern the last time the app booted. The content plugin's\n * `onReady` hook (Phase B — after every plugin's `init` has settled, so\n * `app.db` is available; a pure boot-lint at `createApp` step 4d can't reach\n * the DB) recomputes each pattern's hash at startup and compares it against the\n * stored row: a mismatch logs a loud drift warning naming the entity + locale,\n * because a code-side pattern change can leave stale rows in `toolkit_paths`.\n *\n * Distinct from `toolkit_paths` (the materialized per-DOCUMENT path index):\n * this table holds no document paths, only a fingerprint of the code-declared\n * PATTERN per (entity, locale) — a tiny, bounded table (rows = entities × locales).\n *\n * Uses `defineTable` (per plan/routing C3, and CLAUDE.md's \"no raw SQL\") so the\n * `onReady` check reads/writes through a typed `TableClient`. Registered\n * declaratively via the content plugin's `server.tables` slot (NOT an imperative\n * `schemaRegistry.register` from `init`) — it's statically known at\n * plugin-construct time, so it follows the same shape as `toolkit_paths`.\n */\n\nimport { column, defineTable } from '@murumets-ee/db'\n\nexport const toolkitPathPatternsTable = defineTable({\n name: 'toolkit_path_patterns',\n columns: {\n id: column.uuid({ primaryKey: true, defaultRandom: true }),\n entityType: column.varchar({ length: 100, notNull: true, pgName: 'entity_type' }),\n locale: column.varchar({ length: 10, notNull: true }),\n /** sha256 hex digest (64 chars) of the resolved pattern — see `computePatternHash`. */\n patternHash: column.varchar({ length: 64, notNull: true, pgName: 'pattern_hash' }),\n updatedAt: column.timestamp({\n notNull: true,\n defaultNow: true,\n withTimezone: true,\n pgName: 'updated_at',\n }),\n },\n unique: [\n // Exactly one hash per (entity, locale) — the natural key the boot check\n // reads by and upserts on. A plain unique (no partial `where`): every row\n // is a live fingerprint, there is no history here (unlike `toolkit_paths`).\n { on: ['entityType', 'locale'] },\n ],\n})\n\n/** Backwards-compatible raw pgTable export (for the plugin `tables:` slot + migrations). */\nexport const toolkitPathPatterns = toolkitPathPatternsTable.table\n\n/** The full `DefinedTable` for `toolkit_path_patterns`. */\nexport type ToolkitPathPatternsTable = typeof toolkitPathPatternsTable\n\n/** The typed `TableClient` for `toolkit_path_patterns`. */\nexport type ToolkitPathPatternsClient = ReturnType<typeof toolkitPathPatternsTable.makeClient>\n","/**\n * Boot-time frontend-path PATTERN-DRIFT detection (plan/routing PR04 §2\n * change-matrix row: \"Pattern changed in code | Detected at boot: per-entity\n * pattern hash compared against the stored hash; mismatch logs a loud warning\n * naming the entity + locale\").\n *\n * `computePatternHash` fingerprints an entity's resolved `frontend.path` pattern\n * for a locale; `warnOnPatternDrift` (run from the content plugin's `onReady`)\n * compares every (entity, locale)'s current fingerprint against the last stored\n * one in `toolkit_path_patterns` and warns on a mismatch.\n *\n * Why `onReady`, not the PR02 boot-lint: PR02's `findFrontendPathCollisions`\n * (`@murumets-ee/core`) is a PURE function over `Entity[]`, wired into\n * `createApp` step 4d — which runs BEFORE the `app` object (and its `.db`)\n * exists. This check must READ the persisted last-known hash per (entity,\n * locale), so it needs `app.db` — only available in Phase B (`onReady`), after\n * every plugin's `init` has settled.\n *\n * Boundedness (CLAUDE.md \"no unbounded fan-out\"): this runs ONCE per process, at\n * boot — never on a request / per-keystroke path — and iterates a fixed,\n * app-configured set (entities × locales), so no additional concurrency bound is\n * needed.\n */\n\nimport { createHash } from 'node:crypto'\nimport type { ToolkitApp } from '@murumets-ee/core'\nimport { type EntityFrontendPath, resolvePattern } from '@murumets-ee/entity'\nimport { getContentConfig } from '../content-config-store.js'\nimport { toolkitPathPatternsTable } from './pattern-hash-schema.js'\n\n/**\n * Compute a stable sha256-hex fingerprint of an entity's `frontend.path`\n * RESOLVED for a single `locale`, or `null` when the entity has no pattern for\n * that locale (a per-locale map missing both the locale AND the `defaultLocale`\n * entry — the same typed-BROKEN contract `resolvePattern` returns).\n *\n * Two pattern forms after resolution (`resolvePattern` collapses the map form to\n * the locale's string):\n * - **string** → hash `JSON.stringify(resolved)`. Stable across processes: a\n * fixed string always JSON-encodes to the same bytes.\n * - **function** → hash the function's SOURCE TEXT (`fn.toString()`).\n *\n * KNOWN, ACCEPTED LIMITATION (documented per the F014 precedent of accepting a\n * narrow edge case rather than over-engineering a fix): the function form\n * fingerprints only the function's literal source text. A resolver function\n * whose OUTPUT changes via captured EXTERNAL state — a module-level constant it\n * closes over, an imported lookup table, an env var — while its own source text\n * stays byte-identical will produce the SAME hash, so that drift goes\n * undetected. Detecting it would require executing the resolver against\n * representative rows at boot (a fan-out with side-effect risk), which is\n * deliberately out of scope. Static string / per-locale-map patterns — the\n * overwhelmingly common case — are fully covered.\n *\n * The string form is JSON-encoded (`\"…\"`) and the function form is raw source\n * (`(args) => …` / `function …`), so the two forms can never collide on the\n * same digest.\n */\nexport function computePatternHash(\n path: EntityFrontendPath,\n locale: string,\n defaultLocale: string,\n): string | null {\n const resolved = resolvePattern(path, locale, defaultLocale)\n if (resolved === null) return null\n const material = typeof resolved === 'function' ? resolved.toString() : JSON.stringify(resolved)\n return createHash('sha256').update(material).digest('hex')\n}\n\n/**\n * Boot-time (`onReady`) drift check. For every entity in `app.entities` that\n * declares `frontend.path`, and every configured locale, compare the current\n * resolved-pattern hash against the stored `toolkit_path_patterns` row:\n *\n * - no stored row (first boot / a newly-added entity+locale) → record the\n * current hash, no warning (this is bootstrap, not drift);\n * - stored row, hash matches → no-op;\n * - stored row, hash differs → `app.logger.warn` naming entity + locale, THEN\n * re-baseline the stored hash to the current value. The warning answers \"did\n * the pattern change since the LAST BOOT\", NOT \"until someone rebuilds\" — so\n * it is deliberately decoupled from the `paths:rebuild` job: a subsequent boot\n * with the SAME (already-warned) pattern must not re-warn.\n *\n * The record/re-baseline write is a single `upsert` (NOT a plain insert) so two\n * app instances cold-starting against the same freshly-migrated (empty) table\n * can't race to a 23505: two instances both read `null`, both attempt to write\n * the first-boot baseline, and a plain INSERT would fail the loser on the\n * `UNIQUE(entity_type, locale)` constraint — an uncaught throw that, because this\n * runs in `onReady` (Phase B), ABORTS BOOT for that instance (a rolling /\n * multi-replica first deploy). `ON CONFLICT DO UPDATE` makes it idempotent\n * regardless of which instance's write lands first.\n */\nexport async function warnOnPatternDrift(app: ToolkitApp): Promise<void> {\n const config = getContentConfig()\n const defaultLocale = config.defaultLocale\n const locales = config.locales.map((l) => l.code)\n const client = toolkitPathPatternsTable.makeClient(app.db.readWrite)\n\n for (const [entityType, entity] of app.entities) {\n const declaredPath = entity.frontend?.path\n if (declaredPath === undefined) continue\n\n for (const locale of locales) {\n const currentHash = computePatternHash(declaredPath, locale, defaultLocale)\n // No resolvable pattern for this locale — nothing to fingerprint / track.\n if (currentHash === null) continue\n\n const stored = await client.findOne({ entityType, locale })\n\n // Unchanged — no-op (leaves the stored row's updatedAt untouched).\n if (stored !== null && stored.patternHash === currentHash) continue\n\n // Drift (stored row, different hash) → warn BEFORE re-baselining. First\n // boot (stored === null) is bootstrap, not drift, so it never warns.\n if (stored !== null) {\n app.logger.warn(\n {\n entity: entityType,\n locale,\n previousHash: stored.patternHash,\n currentHash,\n },\n `toolkit_paths: frontend.path pattern for '${entityType}' (locale '${locale}') changed ` +\n `since last boot — stale toolkit_paths rows may exist; ` +\n `run \\`lumi paths:rebuild --entity ${entityType}\\``,\n )\n }\n\n // Record the baseline (first boot) OR re-baseline (drift) via ONE\n // idempotent upsert — safe under concurrent cold-start, unlike a plain\n // insert (see the docblock; `onReady` throws abort boot).\n await client.upsert(\n { entityType, locale, patternHash: currentHash },\n {\n target: ['entityType', 'locale'],\n set: { patternHash: currentHash, updatedAt: new Date() },\n },\n )\n }\n }\n}\n","/**\n * Draft overlay table schema generator\n *\n * Generates a {entity}_drafts table for entities with the publishable() behavior.\n * Stores a mutable work-in-progress snapshot that non-publishers edit instead of\n * writing directly to the live published row.\n *\n * One draft per entity+locale. Drafts are upserted, discarded, or promoted to live.\n *\n * Uses `defineTable` so ContentClient can operate on drafts through a typed\n * TableClient (see registry.ts). The migration pipeline uses the\n * entity-package generator (which emits FK references) — this one produces a\n * runtime-only table shape for read/write via TableClient.\n */\n\nimport { column, defineTable } from '@murumets-ee/db'\nimport type { Entity } from '@murumets-ee/entity'\n\n/**\n * Generate a `DefinedTable` for a publishable entity's drafts table.\n *\n * Schema:\n * {entity}_drafts (\n * id uuid PK,\n * entity_id uuid NOT NULL,\n * locale varchar(10) NOT NULL DEFAULT '_',\n * data jsonb NOT NULL, -- full entity snapshot\n * created_by varchar(255) NOT NULL, -- user ID (varchar, NOT uuid!)\n * created_by_name varchar(255),\n * created_at timestamptz NOT NULL DEFAULT now(),\n * updated_at timestamptz NOT NULL DEFAULT now(),\n * UNIQUE(entity_id, locale)\n * )\n */\nexport function generateDraftsTable(entity: Entity) {\n return defineTable({\n name: `${entity.name}_drafts`,\n columns: {\n id: column.uuid({ primaryKey: true, defaultRandom: true }),\n entityId: column.uuid({ notNull: true, pgName: 'entity_id' }),\n locale: column.varchar({ length: 10, notNull: true, default: '_' }),\n data: column.jsonb<Record<string, unknown>, true>({ notNull: true }),\n createdBy: column.varchar({ length: 255, notNull: true, pgName: 'created_by' }),\n createdByName: column.varchar({ length: 255, pgName: 'created_by_name' }),\n createdAt: column.timestamp({\n notNull: true,\n defaultNow: true,\n withTimezone: true,\n pgName: 'created_at',\n }),\n updatedAt: column.timestamp({\n notNull: true,\n defaultNow: true,\n withTimezone: true,\n pgName: 'updated_at',\n }),\n },\n unique: [{ on: ['entityId', 'locale'] }],\n })\n}\n\n/** Public type export — the full DefinedTable returned by the generator. */\nexport type DraftsTable = ReturnType<typeof generateDraftsTable>\n","/**\n * Per-locale publish status table schema generator\n *\n * Generates a {entity}_locale_status table for entities that have both\n * publishable() behavior AND translatable fields. This allows different\n * locales to have independent publish statuses.\n *\n * Fallback rule: no row for a locale = inherit base entity status.\n *\n * Uses `defineTable` so ContentClient can operate on rows through a typed\n * TableClient. The migration pipeline uses the entity-package generator\n * (which emits FK references); this one produces a runtime-only table shape\n * for read/write via TableClient.\n */\n\nimport { column, defineTable } from '@murumets-ee/db'\nimport type { Entity } from '@murumets-ee/entity'\n\n/**\n * Generate a `DefinedTable` for a publishable + translatable entity's\n * locale status table.\n *\n * Schema:\n * {entity}_locale_status (\n * id uuid PK,\n * entity_id uuid NOT NULL,\n * locale varchar(10) NOT NULL,\n * status varchar(20) NOT NULL DEFAULT 'draft',\n * published_at timestamptz,\n * UNIQUE(entity_id, locale)\n * )\n */\nexport function generateLocaleStatusTable(entity: Entity) {\n return defineTable({\n name: `${entity.name}_locale_status`,\n columns: {\n id: column.uuid({ primaryKey: true, defaultRandom: true }),\n entityId: column.uuid({ notNull: true, pgName: 'entity_id' }),\n locale: column.varchar({ length: 10, notNull: true }),\n status: column.varchar({ length: 20, notNull: true, default: 'draft' }),\n publishedAt: column.timestamp({ withTimezone: true, pgName: 'published_at' }),\n },\n unique: [{ on: ['entityId', 'locale'] }],\n })\n}\n\n/** Public type export — the full DefinedTable returned by the generator. */\nexport type LocaleStatusTable = ReturnType<typeof generateLocaleStatusTable>\n","/**\n * Version history table schema generator\n *\n * Generates a {entity}_versions table for entities with the versionable() behavior.\n * Each version stores a full snapshot + delta (changed fields only).\n *\n * Uses `defineTable` so ContentClient can operate on versions through a typed\n * TableClient. The migration pipeline uses the entity-package generator\n * (which emits FK references) — this one produces a runtime-only table shape\n * for read/write via TableClient.\n */\n\nimport { column, defineTable } from '@murumets-ee/db'\nimport type { Entity } from '@murumets-ee/entity'\n\n/**\n * Generate a `DefinedTable` for a versionable entity's versions table.\n *\n * Schema:\n * {entity}_versions (\n * id uuid PK,\n * entity_id uuid NOT NULL,\n * version integer NOT NULL,\n * locale varchar(10) NOT NULL DEFAULT '_', -- locale this snapshot belongs to\n * data jsonb NOT NULL, -- full snapshot (locale-merged view)\n * delta jsonb, -- only changed fields\n * status varchar(20), -- 'draft' | 'published' at time of save\n * created_by varchar(255),\n * created_by_name varchar(255),\n * created_at timestamptz NOT NULL DEFAULT now(),\n * is_autosave boolean NOT NULL DEFAULT false,\n * UNIQUE(entity_id, version, locale)\n * )\n *\n * Each locale has its own version sequence. The default locale uses '_'\n * as the locale value (distinguishable from actual locale codes).\n */\nexport function generateVersionsTable(entity: Entity) {\n return defineTable({\n name: `${entity.name}_versions`,\n columns: {\n id: column.uuid({ primaryKey: true, defaultRandom: true }),\n entityId: column.uuid({ notNull: true, pgName: 'entity_id' }),\n version: column.integer({ notNull: true }),\n locale: column.varchar({ length: 10, notNull: true, default: '_' }),\n data: column.jsonb<Record<string, unknown>, true>({ notNull: true }),\n delta: column.jsonb<Record<string, unknown>>(),\n status: column.varchar({ length: 20 }),\n createdBy: column.varchar({ length: 255, pgName: 'created_by' }),\n createdByName: column.varchar({ length: 255, pgName: 'created_by_name' }),\n createdAt: column.timestamp({\n notNull: true,\n defaultNow: true,\n withTimezone: true,\n pgName: 'created_at',\n }),\n isAutosave: column.boolean({ notNull: true, default: false, pgName: 'is_autosave' }),\n },\n unique: [{ on: ['entityId', 'version', 'locale'] }],\n })\n}\n\n/** Public type export — the full DefinedTable returned by the generator. */\nexport type VersionsTable = ReturnType<typeof generateVersionsTable>\n","/**\n * Content plugin — registers locale configuration and version schemas.\n *\n * Usage:\n * import { content } from '@murumets-ee/content/plugin'\n *\n * export default defineLumiConfig({\n * plugins: [\n * content({\n * locales: [\n * { code: 'en', label: 'English' },\n * { code: 'de', label: 'German' },\n * ],\n * defaultLocale: 'en',\n * }),\n * ],\n * })\n */\n\nimport { definePlugin, type TrashAutoEnablePolicy } from '@murumets-ee/core'\nimport { schemaRegistry } from '@murumets-ee/db'\nimport { setContentConfig } from './content-config-store.js'\nimport { createDocumentDraftStore } from './document-draft-store.js'\nimport { i18nSettings } from './i18n-settings.js'\nimport { Locale } from './locale-entity.js'\nimport { Message } from './message-entity.js'\n\n// Re-export the config accessor from the leaf store so the `@murumets-ee/content/plugin`\n// subpath API is unchanged for existing consumers (admin-context, entity pages).\nexport { getContentConfig } from './content-config-store.js'\n// Re-export the i18n registry primitives from this jiti-safe subpath (all sources are\n// leaf: locale-entity → entity only, i18n-settings → settings/define, routing-strategy →\n// types only), so the admin-ui Languages editor route can import them WITHOUT dragging\n// content's `server-only` main entry through the config-load / bundle graph.\nexport { i18nSettings, ROUTING_STRATEGIES } from './i18n-settings.js'\nexport { Locale } from './locale-entity.js'\n// The `message` config entity is leaf-safe (entity-only), so the admin-ui\n// Translations editor route can import it from this jiti-safe subpath without\n// dragging content's `server-only` main entry through the config-load graph.\nexport { Message } from './message-entity.js'\nexport {\n localePrefixForStrategy,\n prefixDefaultLocaleForStrategy,\n} from './routing-strategy.js'\nexport type { I18nConfig, LocaleDirection, LocaleEntry, RoutingStrategy } from './types.js'\n\nimport { setActiveLocaleStatusWriterFactory } from './locale-status-slot.js'\nimport { registerPathJobs } from './paths/jobs.js'\nimport { warnOnPatternDrift } from './paths/pattern-hash.js'\nimport { toolkitPathPatterns } from './paths/pattern-hash-schema.js'\nimport { pathRoutes } from './paths/routes.js'\nimport { toolkitPaths } from './paths/schema.js'\nimport { setActivePathWritersFactory } from './paths/writers.js'\nimport { redirectRoutes } from './redirects/routes.js'\nimport { toolkitRedirects } from './redirects/schema.js'\nimport { generateDraftsTable } from './schema/drafts.js'\nimport { generateLocaleStatusTable } from './schema/locale-status.js'\nimport { toolkitContentLocks } from './schema/locks.js'\nimport { registerContentTables } from './schema/registry.js'\nimport { generateVersionsTable } from './schema/versions.js'\nimport type { ContentConfig } from './types.js'\n\n/**\n * Content plugin factory.\n */\nexport function content(config: ContentConfig) {\n // Validate config\n if (!config.locales || config.locales.length === 0) {\n throw new Error('content() requires at least one locale')\n }\n\n if (!config.defaultLocale) {\n throw new Error('content() requires a defaultLocale')\n }\n\n const defaultExists = config.locales.some((l) => l.code === config.defaultLocale)\n if (!defaultExists) {\n throw new Error(\n `defaultLocale \"${config.defaultLocale}\" not found in locales: [${config.locales.map((l) => l.code).join(', ')}]`,\n )\n }\n\n // Reserved path prefixes must be absolute (locale-relative) — a prefix that\n // doesn't start with '/' can never match a computed path (which always leads\n // with '/'), so it would be a silent no-op reservation. Fail loud (routing\n // PR03, Phase 02 §3).\n if (config.reservedPathPrefixes) {\n for (const prefix of config.reservedPathPrefixes) {\n if (typeof prefix !== 'string' || !prefix.startsWith('/')) {\n throw new Error(\n `content(): reservedPathPrefixes entries must be absolute paths starting with '/', ` +\n `got ${JSON.stringify(prefix)}`,\n )\n }\n }\n }\n\n // plan/trash SD002 — declare the auto-enable policy for the framework to\n // apply at boot (createApp injects `trashable()` into publishable entities\n // BEFORE schema-gen). Declarative, so it can't be forgotten and carries no\n // plugin-array-order hazard. `autoTrash` defaults ON.\n const autoTrash = config.autoTrash ?? true\n const trashPolicy: TrashAutoEnablePolicy | undefined =\n autoTrash === false\n ? undefined\n : {\n publishable: true,\n ...(typeof autoTrash === 'object' && autoTrash.exclude\n ? { exclude: autoTrash.exclude }\n : {}),\n }\n\n return definePlugin({\n name: '@murumets-ee/content',\n // SD015 — contribute the draft store the block editor's op-commit route\n // writes through. blocks owns the slot; content owns the {entity}_drafts\n // tables. Stateless; resolves the per-entity drafts table at call time.\n contributes: {\n 'blocks.documentDraftStore': createDocumentDraftStore(),\n // SD002 — only contribute when auto-trash is enabled (omitted → no policy\n // → createApp's applyTrashAutoEnable is a no-op for content).\n ...(trashPolicy ? { 'trash.autoEnable': trashPolicy } : {}),\n // routing PR03 — surface reserved path prefixes to createApp's boot-time\n // lint (gathered BEFORE init, so available at the frontend-path lint step\n // even though config is stored in init). Omitted when none configured.\n ...(config.reservedPathPrefixes\n ? { 'routing.reservedPathPrefixes': config.reservedPathPrefixes }\n : {}),\n },\n // The `i18n` singletons (defaultLocale, routingStrategy) — a simple-config\n // namespace that auto-registers as a config type (D006/D009/SD006) and rides\n // export/import for free. Edited via the Languages editor (`hideFromMenu`).\n shared: {\n settings: [i18nSettings],\n // Manual-redirect CRUD permission (routing PR05, phase 04 §4). ONE\n // permission — `redirect:manage` — gates the whole `/api/admin/redirects/*`\n // surface (no per-verb split). `defineAdminRoute` already contributes it to\n // the catalog when `redirectRoutes()` is evaluated; declaring it here as a\n // feature is the stable, introspection-friendly source of the description\n // (CLAUDE.md `Plugin.shared.features` guidance). Repeat registration of the\n // same permission is a safe union-merge in `registerPermission`, not a throw.\n features: [\n {\n resource: 'redirect',\n actions: ['manage'],\n defaultRoles: ['admin'],\n description: 'Create, edit, and delete manual URL redirects (toolkit_redirects).',\n },\n // Reverse path lookup (#775) — `GET /api/admin/paths/resolve`. Editors\n // hold it because the only consumer is \"I clicked a link in the preview\n // canvas, open that page's editor\"; viewers have no editor to open. The\n // resolved document is ALSO checked against the caller's `view` grant on\n // that entity, so this permission alone discloses nothing.\n {\n resource: 'path',\n actions: ['resolve'],\n defaultRoles: ['admin', 'editor'],\n description: 'Resolve a site URL path to the CMS document that serves it.',\n },\n ],\n },\n server: {\n // `Locale` — the content-locale registry config entity (D009/SD006). `Message` —\n // the interface-message store config entity (D010). Declared statically so the\n // framework registers their tables at merge (before init) and auto-derives them\n // into the ConfigTypeRegistry.\n entities: [Locale, Message],\n // The shared locks + paths + path-pattern-hash tables are registered\n // declaratively via the framework's merge step (#469). The per-entity\n // tables below (versions / layout / drafts / locale_status) are generated\n // dynamically from `app.entities` and stay imperatively\n // registered — they're not known at plugin-construct time.\n tables: { toolkitContentLocks, toolkitPaths, toolkitPathPatterns, toolkitRedirects },\n // Manual-redirect admin CRUD (routing PR05) + reverse path resolution\n // (#775). `defineAdminRoute` entries are built here at plugin-construct\n // time — no DB / server-only touch (handlers resolve the app lazily at\n // request time). See ./redirects/routes.ts + ./paths/routes.ts.\n routes: [...redirectRoutes(), ...pathRoutes()],\n init: async (app) => {\n // Store config globally (leaf store — no schema imports)\n setContentConfig(config)\n\n let versionableCount = 0\n let localeStatusCount = 0\n let draftsCount = 0\n\n for (const [name, entity] of app.entities) {\n // Register version tables for versionable entities\n const hasVersionable = entity.behaviors?.some(\n (b: { name: string }) => b.name === 'versionable',\n )\n if (hasVersionable) {\n const versionsTableName = `${name}_versions`\n if (!schemaRegistry.has(versionsTableName)) {\n const versions = generateVersionsTable(entity)\n schemaRegistry.register(versionsTableName, versions.table)\n registerContentTables(name, { versions })\n }\n versionableCount++\n }\n\n // Register locale_status tables for publishable + translatable entities\n const isPublishable = entity.behaviors?.some(\n (b: { name: string }) => b.name === 'publishable',\n )\n const allFields = entity.allFields as Record<string, { translatable?: boolean }>\n const hasTranslatable = Object.values(allFields).some((f) => f.translatable)\n if (isPublishable && hasTranslatable) {\n const statusTableName = `${name}_locale_status`\n if (!schemaRegistry.has(statusTableName)) {\n const localeStatus = generateLocaleStatusTable(entity)\n schemaRegistry.register(statusTableName, localeStatus.table)\n registerContentTables(name, { localeStatus })\n }\n localeStatusCount++\n }\n\n // Register drafts table for publishable entities\n if (isPublishable) {\n const draftsTableName = `${name}_drafts`\n if (!schemaRegistry.has(draftsTableName)) {\n const drafts = generateDraftsTable(entity)\n schemaRegistry.register(draftsTableName, drafts.table)\n registerContentTables(name, { drafts })\n }\n draftsCount++\n }\n }\n\n // Expose the per-locale status writer to AdminClient.updateForLocale\n // (SD021). Must run AFTER the locale_status registration loop above —\n // the factory consults that registry per entity. This is capability\n // injection via a globalThis slot (queue's enqueue-on-commit\n // precedent), not contribution registration — no merge-step involved.\n setActiveLocaleStatusWriterFactory(config.locales.map((l) => l.code))\n\n // Expose the path-index writer + conflict guard to core's\n // createAdminClient for routable entities (routing PR03, F008/F010).\n // Same globalThis-slot capability injection; the shared `toolkit_paths`\n // table is registered declaratively (the `tables:` slot), so this needs\n // no per-entity registration and can run any time in init.\n setActivePathWritersFactory()\n\n // routing PR04 Task 2 — register the descendant-cascade + full-rebuild\n // queue jobs and wire the Task 1 cascade-trigger seam to enqueue the\n // cascade job. `@murumets-ee/queue/client` is DYNAMIC-imported inside\n // registerPathJobs (keeps queue a peer off content's static graph — the\n // blocks-jobs precedent). Wrapped so a queue-load failure degrades to\n // \"no cascade wired\" (Task 1's queueless no-op — the seam simply stays\n // unregistered) instead of failing content init or entity path writes.\n // Runs AFTER setActivePathWritersFactory so the writer seam is always\n // wired even if job registration fails.\n try {\n await registerPathJobs(app)\n } catch (err) {\n app.logger.warn(\n { err },\n 'content: failed to register routing path jobs — descendant cascade + ' +\n '`lumi paths:rebuild` will be unavailable (path writes still succeed)',\n )\n }\n\n app.logger.info(\n {\n locales: config.locales.map((l) => l.code),\n defaultLocale: config.defaultLocale,\n versionableEntities: versionableCount,\n localeStatusEntities: localeStatusCount,\n draftEntities: draftsCount,\n },\n 'Content plugin initialized',\n )\n },\n // Phase B (after every plugin's `init` has settled, so `app.db` is\n // available — a `createApp` step-4d pure boot-lint can't reach the DB).\n // Boot-time frontend-path pattern-drift check (routing PR04 §2): compares\n // each routable entity's resolved-pattern hash against the last stored\n // `toolkit_path_patterns` row and warns on drift. Runs ONCE per process at\n // startup — bounded by the app's own (entities × locales), never a\n // request path — so no fan-out concurrency bound is needed.\n //\n // Wrapped in try/catch: `warnOnPatternDrift` does a DB round-trip per\n // (entity × locale), and per core's plugin contract ANY throw from\n // `onReady` ABORTS app boot. This is a boot-time DIAGNOSTIC whose stated\n // intent is \"log a warning, never abort\" — so a transient DB hiccup during\n // it must degrade to a logged error, not crash the app (same posture as the\n // `registerPathJobs` wrap in `init` above).\n onReady: async (app) => {\n try {\n await warnOnPatternDrift(app)\n } catch (err) {\n app.logger.error(\n { err },\n 'content: boot-time frontend-path pattern-drift check failed — continuing app boot ' +\n 'without it. Inspect the logged `err` for the cause: a transient DB error can be ' +\n 'retried (and `lumi paths:rebuild` re-derives the index if a pattern actually ' +\n 'changed), whereas a structural cause — e.g. a missing migration leaving ' +\n '`toolkit_path_patterns` absent — will recur on every boot and needs `lumi migrate`',\n )\n }\n },\n },\n })\n}\n"],"mappings":"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"}
|
|
@@ -1,2 +0,0 @@
|
|
|
1
|
-
import{t as e}from"./content-config-store-8omQF5p5.mjs";import{i as t,n,u as r}from"./schema-BJxAwPrI.mjs";import{QueryClient as i}from"@murumets-ee/entity/query";import{inArray as a}from"drizzle-orm";import{z as o}from"zod";import{DEFAULT_LOCALE as s,GLOBAL_SCOPE_ID as c}from"@murumets-ee/settings";import{toolkitSettingsTable as l}from"@murumets-ee/settings/schema";var u=Object.defineProperty,d=(e,t)=>{let n={};for(var r in e)u(n,r,{get:e[r],enumerable:!0});return t||u(n,Symbol.toStringTag,{value:`Module`}),n};const f=o.object({entity:o.string().min(1),id:o.string().uuid()});function p(e){let t=f.safeParse(e);return t.success?t.data:null}var m=d({buildCanonicalPathDeps:()=>g,buildCanonicalPathsBatchDeps:()=>b,buildEnumerateDeps:()=>y,buildPublishChecker:()=>_,buildRedirectsListDeps:()=>C,buildResolveDeps:()=>w,buildSitemapDeps:()=>S,createHomepageReader:()=>v});const h=()=>({user:{id:`system`,groups:[`admin`]},checker:()=>!0});function g(e){return{pathsClient:r.makeClient(e.db.readOnly)}}function _(e){return t({entityResolver:t=>e.entities.get(t),queryClientFactory:t=>new i({entity:t,db:e.db.readOnly,logger:e.logger.child({resolve:`publish-check`,entity:t.name}),contextResolver:h})})}function v(e){let t=l.makeClient(e);return async()=>{let e=await t.findOne({namespace:`site`,key:`homeDocument`,scope:`global`,scopeId:c,locale:s});return e?p(e.value):null}}function y(e){let t=e.db.readOnly;return{pathsClient:r.makeClient(t),filterPublished:async(n,r,o)=>{if(r.length===0)return new Set;let s=e.entities.get(n);if(s===void 0)return new Set;let c=new i({entity:s,db:t,logger:e.logger.child({resolve:`enumerate-publish`,entity:s.name}),contextResolver:h}),l=c.getTable().id,u=await c.findMany({where:a(l,[...r]),locale:o}),d=new Set;for(let e of u){let t=e.id;typeof t==`string`&&d.add(t)}return d}}}function b(e){return{pathsClient:r.makeClient(e.db.readOnly)}}function x(e){let t=e.db.readOnly;return async(n,r,o)=>{if(r.length===0)return new Set;let s=e.entities.get(n);if(s===void 0||!s.behaviors?.some(e=>e.name===`seoMeta`))return new Set;let c=new i({entity:s,db:t,logger:e.logger.child({resolve:`sitemap-noindex`,entity:s.name}),contextResolver:h}),l=c.getTable().id,u=await c.findMany({where:a(l,[...r]),locale:o}),d=new Set;for(let e of u){let t=e.id,n=e.noIndex;typeof t==`string`&&n===!0&&d.add(t)}return d}}function S(e){return{enumerate:y(e),canonicalBatch:b(e),filterNoIndex:x(e)}}function C(e){let t=y(e);return{redirectsClient:n.makeClient(e.db.readOnly),pathsClient:t.pathsClient,canonicalBatch:b(e),filterPublished:t.filterPublished}}function w(t){let i=t.db.readOnly;return{pathsClient:r.makeClient(i),redirectsClient:n.makeClient(i),publishChecker:_(t),homepageReader:v(i),locales:e().locales.map(e=>e.code)}}export{C as a,v as c,p as d,_ as i,m as l,b as n,w as o,y as r,S as s,g as t,f as u};
|
|
2
|
-
//# sourceMappingURL=resolve-context-p3m6gn3_.mjs.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"resolve-context-p3m6gn3_.mjs","names":[],"sources":["../src/paths/homepage.ts","../src/paths/resolve-context.ts"],"sourcesContent":["/**\n * Homepage pointer — the `site.homeDocument` setting that maps `/` to a document\n * (plan/routing PR06; phase `05-resolution-and-enumeration-api.md` §3, D004).\n *\n * The value is stored as JSONB in `toolkit_settings` (the `site` namespace,\n * declared per-app — see each app's `settings/site.ts`). Content can't statically\n * import an app's `SettingsDefinition`, so the resolution layer reads the raw row\n * and parses the value HERE, defensively (CLAUDE.md \"validate at the boundary\"):\n * an unset row OR a value that fails this schema means \"no homepage configured\"\n * (`/` resolves to `none`) — never a thrown error, since this runs on every `/`\n * request.\n *\n * This module is a LEAF (zod only) so the parse is unit-testable without a DB and\n * the resolution core stays free of settings/core imports; the actual\n * `toolkit_settings` read is wired in `./resolve-context.ts`.\n */\n\nimport { z } from 'zod'\n\n/** A resolved pointer at the document that serves `/`. */\nexport interface HomepagePointer {\n entity: string\n id: string\n}\n\n/**\n * Shape of the `site.homeDocument` JSONB value. `id` is a UUID — entity row ids\n * are UUIDs across this codebase (only USER ids are varchar; CLAUDE.md).\n */\nexport const homepagePointerSchema = z.object({\n entity: z.string().min(1),\n id: z.string().uuid(),\n})\n\n/**\n * Parse a raw `site.homeDocument` value into a {@link HomepagePointer}, or `null`\n * when it's unset / malformed. Never throws.\n */\nexport function parseHomepagePointer(value: unknown): HomepagePointer | null {\n const parsed = homepagePointerSchema.safeParse(value)\n return parsed.success ? parsed.data : null\n}\n","/**\n * App-binding for the path-resolution core (plan/routing PR06).\n *\n * `./resolve.ts` is a LEAF — it takes every dependency (path/redirect readers,\n * publish checker, homepage reader) as injected params so it imports no\n * `@murumets-ee/core` graph. THIS module is the outer seam that holds those\n * imports and builds concrete `deps` from a live `ToolkitApp`. Only the\n * request-time resolution / enumeration surfaces (future admin routes,\n * content-api, a frontend catch-all) reach this module — never the `lumi` CLI\n * command graph (F027 guardrail; keeps the `server-only`-adjacent imports out of\n * `paths/jobs.ts`).\n *\n * **Publish check runs under an ELEVATED system context** (mirrors\n * `../locale-registry.ts`'s `SYSTEM_CONTEXT`), NOT the ambient request context.\n * Rationale + security: URL resolution is a SYSTEM derivation that must work for\n * any caller — an anonymous public request, or a build-time enumeration with no\n * request context at all — and must not fail just because the caller lacks a\n * per-entity `view` grant. The elevated context only bypasses the\n * permission/scope check; the PUBLISH FILTER (`QueryClient.buildPublishFilter`)\n * and the trashed-row exclusion still apply unconditionally, so an unpublished /\n * trashed document still resolves to `null` → not routable. No content data is\n * returned from the check (only a boolean), so elevation leaks nothing — the\n * downstream content fetch still runs under the caller's real permissions.\n */\n\nimport type { ToolkitApp } from '@murumets-ee/core'\nimport type { Entity, SecurityContext } from '@murumets-ee/entity'\nimport { QueryClient } from '@murumets-ee/entity/query'\nimport { DEFAULT_LOCALE, GLOBAL_SCOPE_ID } from '@murumets-ee/settings'\nimport { toolkitSettingsTable } from '@murumets-ee/settings/schema'\nimport { inArray } from 'drizzle-orm'\nimport type { PgColumn } from 'drizzle-orm/pg-core'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\nimport { getContentConfig } from '../content-config-store.js'\nimport { toolkitRedirectsTable } from '../redirects/schema.js'\nimport type { CanonicalPathsBatchDeps, EnumeratePathsDeps, PublishFilter } from './enumerate.js'\nimport { parseHomepagePointer } from './homepage.js'\nimport type { RedirectsListDeps } from './redirects-list.js'\nimport type { SitemapDeps } from './sitemap.js'\nimport {\n type AlternatesDeps,\n type CanonicalPathDeps,\n createPublishChecker,\n type HomepageReader,\n type PublishChecker,\n type ResolvePathDeps,\n} from './resolve.js'\nimport { toolkitPathsTable } from './schema.js'\n\n/**\n * Elevated resolver for the publish check — the roles/locale-registry-read\n * analog (see file header for why this is safe). `checker: () => true` grants\n * permission; the publish filter is applied by `QueryClient` regardless.\n */\nconst SYSTEM_CONTEXT = (): SecurityContext => ({\n user: { id: 'system', groups: ['admin'] },\n checker: () => true,\n})\n\n/**\n * Build the minimal deps for {@link import('./resolve.js').getCanonicalPath} —\n * just the `toolkit_paths` reader. Task 2's `resolveEntityHref` / search\n * url-builder re-points use this (no publish checker / settings machinery).\n */\nexport function buildCanonicalPathDeps(app: ToolkitApp): CanonicalPathDeps {\n return { pathsClient: toolkitPathsTable.makeClient(app.db.readOnly) }\n}\n\n/**\n * Build a live {@link PublishChecker} that reads through an elevated-context\n * `QueryClient` per entity (publish filter auto-applied). See file header.\n */\nexport function buildPublishChecker(app: ToolkitApp): PublishChecker {\n return createPublishChecker({\n entityResolver: (name) => app.entities.get(name),\n queryClientFactory: (entity: Entity) =>\n new QueryClient({\n entity,\n db: app.db.readOnly,\n logger: app.logger.child({ resolve: 'publish-check', entity: entity.name }),\n contextResolver: SYSTEM_CONTEXT,\n }),\n })\n}\n\n/**\n * Build the homepage reader — a single indexed `toolkit_settings` lookup for\n * `site.homeDocument` (global scope, default locale), zod-parsed defensively\n * (unset / malformed → `null`, never a throw). No cache in v1 (phase 05 §5).\n */\nexport function createHomepageReader(db: PostgresJsDatabase): HomepageReader {\n const settingsClient = toolkitSettingsTable.makeClient(db)\n return async () => {\n const row = await settingsClient.findOne({\n namespace: 'site',\n key: 'homeDocument',\n scope: 'global',\n scopeId: GLOBAL_SCOPE_ID,\n locale: DEFAULT_LOCALE,\n })\n if (!row) return null\n return parseHomepagePointer(row.value)\n }\n}\n\n/**\n * Build the deps for {@link import('./enumerate.js').enumeratePaths} — the\n * `toolkit_paths` reader plus a BATCHED publish filter. `filterPublished` runs\n * ONE elevated-context `QueryClient.findMany({ where: inArray(getTable().id,\n * ids), locale })` per entity-type group (not per row) — the publish + trash\n * filters apply unconditionally (see file header), so no unpublished/trashed id\n * is returned. An unknown/unregistered entity type yields the empty set (its\n * rows are treated as unpublished → dropped, fail-closed).\n */\nexport function buildEnumerateDeps(app: ToolkitApp): EnumeratePathsDeps {\n const db = app.db.readOnly\n return {\n pathsClient: toolkitPathsTable.makeClient(db),\n filterPublished: async (entityType, entityIds, locale) => {\n if (entityIds.length === 0) return new Set()\n const entity = app.entities.get(entityType)\n if (entity === undefined) return new Set()\n const client = new QueryClient({\n entity,\n db,\n logger: app.logger.child({ resolve: 'enumerate-publish', entity: entity.name }),\n contextResolver: SYSTEM_CONTEXT,\n })\n // Dynamic `id` column ref for the batched IN filter. `getTable()` is\n // `PgTableWithColumns<any>`; the structural cast narrows to the one column\n // we filter on (the same idiom paths/jobs.ts uses for dynamic column refs).\n const idColumn = (client.getTable() as unknown as { id: PgColumn }).id\n const rows = await client.findMany({ where: inArray(idColumn, [...entityIds]), locale })\n const published = new Set<string>()\n for (const row of rows) {\n const id = (row as { id?: unknown }).id\n if (typeof id === 'string') published.add(id)\n }\n return published\n },\n }\n}\n\n/**\n * Build the deps for {@link import('./enumerate.js').getCanonicalPaths} — just\n * the `toolkit_paths` reader (batched, NO publish filter, like\n * {@link buildCanonicalPathDeps}). Task 2's search url re-point uses this to\n * resolve a search page's result urls from the index in one query.\n */\nexport function buildCanonicalPathsBatchDeps(app: ToolkitApp): CanonicalPathsBatchDeps {\n return { pathsClient: toolkitPathsTable.makeClient(app.db.readOnly) }\n}\n\n/**\n * Build the `filterNoIndex` dep for {@link import('./sitemap.js').generateSitemap}\n * (PR 11's `seoMeta()`) — mirrors {@link buildEnumerateDeps}'s `filterPublished`\n * construction EXACTLY (elevated-context `QueryClient`, one `findMany` per\n * entity-type group), with two differences:\n *\n * 1. Short-circuits to the empty set when the entity does NOT declare\n * `seoMeta()` — most routable entities won't have this behavior, and\n * querying a `noIndex` column that doesn't exist would throw.\n * 2. Filters on the LOCALE-MERGED DTO rather than a raw SQL column, because\n * `noIndex` is `translatable: true` — its live per-locale value only\n * exists after `QueryClient.findMany`'s `mergeTranslations` step, not as a\n * column on the main table itself (unlike `filterPublished`'s `status`,\n * which is never translatable). The `id IN (...)` filter is still pushed\n * to SQL; only the `noIndex` check runs over the (batch-bounded) merged\n * rows in memory.\n */\nfunction buildNoIndexFilter(app: ToolkitApp): PublishFilter {\n const db = app.db.readOnly\n return async (entityType, entityIds, locale) => {\n if (entityIds.length === 0) return new Set()\n const entity = app.entities.get(entityType)\n if (entity === undefined) return new Set()\n if (!entity.behaviors?.some((b) => b.name === 'seoMeta')) return new Set()\n const client = new QueryClient({\n entity,\n db,\n logger: app.logger.child({ resolve: 'sitemap-noindex', entity: entity.name }),\n contextResolver: SYSTEM_CONTEXT,\n })\n // Dynamic `id` column ref for the batched IN filter — same idiom as\n // `buildEnumerateDeps.filterPublished`.\n const idColumn = (client.getTable() as unknown as { id: PgColumn }).id\n const rows = await client.findMany({ where: inArray(idColumn, [...entityIds]), locale })\n const noIndexed = new Set<string>()\n for (const row of rows) {\n const id = (row as { id?: unknown }).id\n const noIndex = (row as { noIndex?: unknown }).noIndex\n if (typeof id === 'string' && noIndex === true) noIndexed.add(id)\n }\n return noIndexed\n }\n}\n\n/**\n * Build the deps for {@link import('./sitemap.js').generateSitemap} — the\n * one-stop bundle of {@link buildEnumerateDeps} (per-locale enumeration + its\n * BATCHED publish filter, reused for the cross-locale alternate publish gate),\n * {@link buildCanonicalPathsBatchDeps} (the batched cross-locale forward index\n * read), and {@link buildNoIndexFilter} (PR 11's batched `noIndex` exclusion).\n * Mirrors `buildResolveDeps`'s one-stop-deps pattern.\n */\nexport function buildSitemapDeps(app: ToolkitApp): SitemapDeps {\n return {\n enumerate: buildEnumerateDeps(app),\n canonicalBatch: buildCanonicalPathsBatchDeps(app),\n filterNoIndex: buildNoIndexFilter(app),\n }\n}\n\n/**\n * Build the deps for {@link import('./redirects-list.js').getRedirects} — the\n * one-stop bundle for the build-time redirect list (PR08). Composes\n * {@link buildEnumerateDeps} (its `toolkit_paths` reader powers both the\n * historical keyset and the `path`-kind existence check; its BATCHED\n * `filterPublished` is the shared publish gate), {@link buildCanonicalPathsBatchDeps}\n * (the batched owning-document current-canonical lookup), and a\n * `toolkit_redirects` reader for the manual-redirect keyset. Mirrors\n * {@link buildSitemapDeps}'s one-stop-deps style.\n */\nexport function buildRedirectsListDeps(app: ToolkitApp): RedirectsListDeps {\n const enumerate = buildEnumerateDeps(app)\n return {\n redirectsClient: toolkitRedirectsTable.makeClient(app.db.readOnly),\n pathsClient: enumerate.pathsClient,\n canonicalBatch: buildCanonicalPathsBatchDeps(app),\n filterPublished: enumerate.filterPublished,\n }\n}\n\n/**\n * Build the full {@link ResolvePathDeps} (+ {@link AlternatesDeps}) from an app —\n * the one-stop deps for `resolvePath` / `resolveDraftPath` / `getAlternates`.\n * `locales` are the statically-configured content locales (v1 — matches the\n * redirect routes' own configured-locale source; DB-merged locale overrides are\n * a later refinement).\n */\nexport function buildResolveDeps(app: ToolkitApp): ResolvePathDeps & AlternatesDeps {\n const db = app.db.readOnly\n return {\n pathsClient: toolkitPathsTable.makeClient(db),\n redirectsClient: toolkitRedirectsTable.makeClient(db),\n publishChecker: buildPublishChecker(app),\n homepageReader: createHomepageReader(db),\n locales: getContentConfig().locales.map((l) => l.code),\n }\n}\n"],"mappings":"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"}
|