@murumets-ee/content 0.87.0 → 0.89.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/admin.d.mts.map +1 -1
  2. package/dist/admin.mjs +1 -1
  3. package/dist/block-resolver.mjs +1 -1
  4. package/dist/client.d.mts +1 -1
  5. package/dist/client.mjs +1 -1
  6. package/dist/client.mjs.map +1 -1
  7. package/dist/{enumerate-CBqu63Kp.mjs → enumerate-CYHJKQz6.mjs} +2 -2
  8. package/dist/{enumerate-CBqu63Kp.mjs.map → enumerate-CYHJKQz6.mjs.map} +1 -1
  9. package/dist/{index-C2G4IJck.d.mts → index-C3VyZLjf.d.mts} +55 -2
  10. package/dist/{index-C2G4IJck.d.mts.map → index-C3VyZLjf.d.mts.map} +1 -1
  11. package/dist/index.d.mts +3 -3
  12. package/dist/index.mjs +1 -1
  13. package/dist/index.mjs.map +1 -1
  14. package/dist/locale-entity-CoSq3C77.mjs +2 -0
  15. package/dist/locale-entity-CoSq3C77.mjs.map +1 -0
  16. package/dist/locale-registry-Bn6e_nM7.mjs +2 -0
  17. package/dist/locale-registry-Bn6e_nM7.mjs.map +1 -0
  18. package/dist/plugin-BmxghXzV.mjs +2 -0
  19. package/dist/plugin-BmxghXzV.mjs.map +1 -0
  20. package/dist/plugin.d.mts +4 -4
  21. package/dist/plugin.mjs +1 -1
  22. package/dist/resolve-context-Cvc1aNJe.mjs +2 -0
  23. package/dist/resolve-context-Cvc1aNJe.mjs.map +1 -0
  24. package/dist/resolve.d.mts +54 -16
  25. package/dist/resolve.d.mts.map +1 -1
  26. package/dist/resolve.mjs +6 -6
  27. package/dist/resolve.mjs.map +1 -1
  28. package/dist/routes-BmNSYyuT.mjs +2 -0
  29. package/dist/{routes-C9I1iZw8.mjs.map → routes-BmNSYyuT.mjs.map} +1 -1
  30. package/dist/routing-strategy-D6B4ngT9.mjs +2 -0
  31. package/dist/routing-strategy-D6B4ngT9.mjs.map +1 -0
  32. package/dist/{routing-strategy-D3Qowm3D.d.mts → routing-strategy-DV7KGarX.d.mts} +4 -4
  33. package/dist/{routing-strategy-D3Qowm3D.d.mts.map → routing-strategy-DV7KGarX.d.mts.map} +1 -1
  34. package/dist/schema-BYrYzsmO.mjs +2 -0
  35. package/dist/schema-BYrYzsmO.mjs.map +1 -0
  36. package/dist/schema-IML1rRU_.mjs +2 -0
  37. package/dist/{schema-0zayzpD_.mjs.map → schema-IML1rRU_.mjs.map} +1 -1
  38. package/dist/seed.d.mts.map +1 -1
  39. package/dist/seed.mjs +1 -1
  40. package/dist/seed.mjs.map +1 -1
  41. package/dist/{versions-C9A4rUAR.mjs → versions-DLrR7hIZ.mjs} +2 -2
  42. package/dist/{versions-C9A4rUAR.mjs.map → versions-DLrR7hIZ.mjs.map} +1 -1
  43. package/package.json +9 -9
  44. package/dist/plugin-D0i6odXQ.mjs +0 -2
  45. package/dist/plugin-D0i6odXQ.mjs.map +0 -1
  46. package/dist/resolve-context-MRZZ3rQl.mjs +0 -2
  47. package/dist/resolve-context-MRZZ3rQl.mjs.map +0 -1
  48. package/dist/routes-C9I1iZw8.mjs +0 -2
  49. package/dist/routing-strategy-CQdu1qV7.mjs +0 -2
  50. package/dist/routing-strategy-CQdu1qV7.mjs.map +0 -1
  51. package/dist/schema-0zayzpD_.mjs +0 -2
  52. package/dist/schema-D8sBQozw.mjs +0 -2
  53. package/dist/schema-D8sBQozw.mjs.map +0 -1
@@ -1 +0,0 @@
1
- {"version":3,"file":"plugin-D0i6odXQ.mjs","names":["cols"],"sources":["../src/locale-status-slot.ts","../src/internal/document-site.ts","../src/document-draft-store.ts","../src/document-version-store.ts","../src/paths/writers.ts","../src/paths/jobs.ts","../src/paths/pattern-hash-schema.ts","../src/paths/pattern-hash.ts","../src/paths/published-path-resolver.ts","../src/schema/site-partition.ts","../src/schema/drafts.ts","../src/schema/locale-status.ts","../src/schema/versions.ts","../src/plugin.ts"],"sourcesContent":["/**\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, site) => {\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 // `site` is the document's site when the entity is bound (AdminClient\n // resolved it); the partitioned client stamps it and confines the upsert.\n const client = localeStatus.makeClient(db, site)\n await upsertLocaleStatusRow(client, entityId, locale, status)\n }\n }\n ;(globalThis as Record<symbol, unknown>)[LOCALE_STATUS_WRITER_KEY] = factory\n}\n","/**\n * The site a document-type-keyed companion access acts on (plan `multisite`\n * D003) — for the stores the block editor calls with a bare `documentType`\n * string rather than an entity object (`document-draft-store.ts`,\n * `document-version-store.ts`).\n *\n * `undefined` for a shared entity, or a type this app does not know (its\n * content tables are not registered either, so nothing is read). Throws\n * `SiteRequiredError` for a bound entity with no site in the request.\n */\n\nimport { getAppOrUndefined, getContext, requireSiteFor } from '@murumets-ee/core'\n\nexport async function siteForDocumentType(documentType: string): Promise<string | undefined> {\n // No booted app (a unit harness): there is no entity to consult, and a\n // bound type's partitioned table then refuses to build an unconfined client\n // — so returning `undefined` here cannot widen anything.\n const app = getContext()?.app ?? getAppOrUndefined()\n if (!app) return undefined\n const entity = app.entities.get(documentType)\n if (!entity) return undefined\n return requireSiteFor(entity, app)\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, and its bound-field route stages a bound column through\n * it (editor D019) — so canvas 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 { buildPartitionCondition } from '@murumets-ee/entity'\nimport { and, eq, sql } from 'drizzle-orm'\nimport type { PgColumn } from 'drizzle-orm/pg-core'\nimport { siteForDocumentType } from './internal/document-site.js'\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 // Bound to the caller's handle — op-commit's SERIALIZABLE transaction, so\n // the upsert commits with the `editor_op` insert, or the bound-field\n // route's plain handle.\n // Confined to the document's site (multisite D003): the upsert stamps\n // `_site`, and the composite FK refuses a draft for another site's row.\n const client = drafts.makeClient(tx, await siteForDocumentType(documentType))\n const now = new Date()\n // MERGED in one statement (`stored || dataPatch`), not read-then-written.\n // Two writers stage into one draft since editor D019 — op-commit's tree\n // and a bound field's column — and a read-modify-write here let the later\n // one write back the draft as it was BEFORE the earlier one committed:\n // a block edit erased by a title typed a moment later. Same shallow,\n // top-level merge the spread used to do.\n await client.upsert(\n {\n entityId: documentId,\n locale,\n data: dataPatch,\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: { updatedAt: now },\n mergeJsonb: ['data'],\n },\n )\n return true\n },\n\n async readDraft({ documentType, documentId, locale, db }) {\n const { drafts } = getContentTables(documentType)\n // No drafts table → the document type isn't publishable; the caller\n // falls back to the live row, exactly as `writeDraft`'s `false` does.\n if (!drafts) return null\n const row = await drafts\n .makeClient(db, await siteForDocumentType(documentType))\n .findOne({ entityId: documentId, locale })\n if (!row) return null\n return {\n data: (row.data as Record<string, unknown> | undefined) ?? {},\n createdBy: row.createdBy ?? null,\n createdByName: row.createdByName ?? null,\n updatedAt: row.updatedAt ?? null,\n }\n },\n\n async readLocaleStatus({ documentType, documentId, locale, db }) {\n // No locale-status table → not publishable + translatable; the locale\n // inherits the base row's status, which the caller already holds.\n const { localeStatus } = getContentTables(documentType)\n if (!localeStatus) return null\n const row = await localeStatus\n .makeClient(db, await siteForDocumentType(documentType))\n .findOne({ entityId: documentId, locale })\n if (!row) return null\n return row.status === 'published' ? 'published' : 'draft'\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, await siteForDocumentType(documentType))\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 const site = await siteForDocumentType(documentType)\n if (localeStatus) {\n const client = localeStatus.makeClient(db, site)\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 // A bound type's translation rows are confined to the site too — the\n // same predicate the partitioned clients above apply. The partition\n // builder THROWS if the column is missing, never widening.\n const where =\n site !== undefined\n ? and(\n eq(entityIdCol, documentId),\n buildPartitionCondition(translationTable, { site }, documentType),\n )\n : eq(entityIdCol, documentId)\n const rows = await db.select({ one: sql`1` }).from(translationTable).where(where).limit(1)\n if (rows.length > 0) return true\n }\n }\n return false\n },\n }\n}\n","/**\n * `DocumentVersionStore` implementation contributed to the block editor's\n * `blocks.documentVersionStore` slot (F053).\n *\n * The block editor's `snapshot-version` admin route (in `@murumets-ee/blocks`)\n * calls this BEFORE the detach flow forks a saved pattern and dispatches the\n * `block:detach` op, so a recovery point exists for the document's current\n * state even when it has never been published.\n *\n * ## Every value import here must be safe for a jiti-loaded plugin entry\n *\n * `plugin.ts` imports this module STATICALLY, at construct time, to build\n * its `contributes` object — `lumi migrate` then loads `plugin.ts` (via\n * `content/dist/plugin.mjs`) through jiti, which does NOT resolve the\n * `react-server` export condition. Any module reachable from here that\n * carries `import 'server-only'` at its top throws UNCONDITIONALLY in that\n * context (the package's bare export is the throwing variant outside\n * `react-server`) — `pnpm verify:plugin-loads` exists specifically to catch\n * this before a developer does. Three things fell out of getting this file\n * right, each recorded so the next module contributed from `plugin.ts`\n * doesn't rediscover them by trial and error:\n *\n * 1. **The draft is read via `getContentTables` directly** — the SAME\n * low-level access `document-draft-store.ts`'s own `readDraft` uses —\n * rather than through `drafts.ts`'s `getDraft(ctx, ...)`. `drafts.ts`\n * imports `versions.ts` (`server-only`) for `createVersionSnapshot`.\n *\n * 2. **`localeFindOptions`/`isDefaultLocale` are INLINED below**, not\n * imported from `internal/apply-locale-routed-update.ts`. That file's\n * OTHER export, `applyLocaleRoutedUpdate`, has a value import of\n * `getLocaleStatusClient` from `internal/context.ts` (`server-only`) —\n * and ES modules evaluate a file's full top-level graph on import\n * regardless of which single export you use. Importing the file at all,\n * for any export, drags `server-only` in. The two functions are small\n * enough that duplicating them beats a third module split.\n *\n * 3. **`createContentContext` (`internal/context.ts`) and\n * `createVersionSnapshot` (`versions.ts`) are DYNAMIC imports**, inside\n * `snapshotCurrentState` — never at module scope. Same precedent\n * `@murumets-ee/blocks/plugin.ts` already uses repeatedly for\n * `@murumets-ee/core/clients` (also `server-only`) inside its job\n * callbacks: dynamic import is NOT the banned \"circular-dependency\n * escape hatch\" pattern here — it is the sanctioned way to keep a\n * lazily-invoked function able to reach a genuinely server-only module\n * without tainting the eagerly-evaluated plugin entry that contributes it.\n *\n * `getApp`, `getContentTables`, and `getVersionableOptions` stay static\n * imports; none of the three carries the sentinel (verified, not assumed —\n * `grep -rl \"^import 'server-only'\" packages/content/src` names the eight\n * files that do, and these three aren't in it).\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 version tables + `createVersionSnapshot`. No runtime\n * dependency between the packages.\n */\n\nimport type { DocumentVersionStore } from '@murumets-ee/blocks/server'\nimport { getApp } from '@murumets-ee/core'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\nimport { getVersionableOptions } from './behaviors/versionable.js'\nimport { getContentConfig } from './content-config-store.js'\nimport { siteForDocumentType } from './internal/document-site.js'\nimport { DEFAULT_LOCALE_SENTINEL } from './locale-utils.js'\nimport { getContentTables } from './schema/registry.js'\n\n/**\n * `findById` options that read the same locale view a write would target —\n * default locale reads the base row, non-default reads the\n * translation-merged row. DUPLICATED from\n * `internal/apply-locale-routed-update.ts`'s `localeFindOptions` /\n * `isDefaultLocale` — see the module docblock's point 2 for why importing\n * the original isn't safe here.\n */\nfunction localeFindOptions(\n locale: string,\n): { defaultLocale: string } | { locale: string; defaultLocale: string } {\n const { defaultLocale } = getContentConfig()\n const isDefault = locale === DEFAULT_LOCALE_SENTINEL || locale === defaultLocale\n return isDefault ? { defaultLocale } : { locale, defaultLocale }\n}\n\n/**\n * Map a caller-supplied locale to the STORAGE key drafts/versions actually\n * use. Both `'_'` and the literal default-locale code (e.g. `'en'`) name the\n * same document for a *read* (`localeFindOptions` already treats them as\n * equivalent) — but the draft buffer and version table key default-locale\n * rows under `'_'` ONLY, never the literal code (`WriteDraftArgs`'s own\n * docstring: \"`'_'` (default-locale sentinel) or a BCP-47 locale\" — the\n * sentinel is the one that is actually written). `createVersionSnapshot`\n * goes further: it updates the entity's `_version` column ONLY when its\n * `locale` argument is exactly `DEFAULT_LOCALE_SENTINEL` (`versions.ts`) —\n * passing the literal code silently skips that write.\n *\n * The one caller today (`use-patterns.ts` → `readDocumentMeta` →\n * `doc.session.locale`) already sends `'_'` for a default-locale document\n * (`entity-edit-page.tsx`'s `opLocale = isDefaultLocale ? '_' : contentLocale`\n * is what seeds the editor session), so this normalization is currently a\n * no-op on the only real path. It is not defensive filler, though: the\n * route's own `bodySchema` accepts either spelling, so anything else calling\n * `POST /api/admin/blocks/snapshot-version` with the literal default-locale\n * code would otherwise miss the pending draft and skip the `_version` bump —\n * silently produce a snapshot with less data than the author actually has,\n * which is the exact failure mode this whole feature exists to prevent.\n */\nfunction normalizeLocale(locale: string): string {\n const { defaultLocale } = getContentConfig()\n return locale === DEFAULT_LOCALE_SENTINEL || locale === defaultLocale\n ? DEFAULT_LOCALE_SENTINEL\n : locale\n}\n\n/**\n * Read one document's per-locale draft patch, or `undefined` when the\n * document type has no draft buffer or no draft row exists. Same low-level\n * access `document-draft-store.ts`'s `readDraft` uses — see the module\n * docblock's point 1 for why this doesn't go through `drafts.ts`'s\n * `getDraft`.\n */\nasync function readDraftPatch(\n documentType: string,\n documentId: string,\n locale: string,\n db: PostgresJsDatabase,\n): Promise<Record<string, unknown> | undefined> {\n const { drafts } = getContentTables(documentType)\n if (!drafts) return undefined\n const row = await drafts\n .makeClient(db, await siteForDocumentType(documentType))\n .findOne({ entityId: documentId, locale })\n return row ? ((row.data as Record<string, unknown> | undefined) ?? undefined) : undefined\n}\n\n/**\n * Build the version store. Stateless — resolves the entity + its AdminClient\n * from `app.entities` / `app.getClient` at call time, so it composes with\n * any app's entity set (same shape `createDocumentDraftStore` uses for the\n * content-tables registry).\n */\nexport function createDocumentVersionStore(): DocumentVersionStore {\n return {\n async snapshotCurrentState({ documentType, documentId, locale, user }) {\n const app = getApp()\n const entity = app.entities.get(documentType)\n if (!entity) return false\n\n const versionableOptions = getVersionableOptions(entity.behaviors)\n // Not versionable → nothing to snapshot. Not an error: the route\n // reports `snapshotted: false` and the client's flow proceeds\n // unchanged (PR07's original posture for this document type).\n if (!versionableOptions) return false\n\n const admin = app.getClient(entity)\n const db = admin.getDb()\n\n // Normalize ONCE — see `normalizeLocale`'s docblock — and use the\n // result everywhere below instead of the raw `locale` argument, so the\n // read, the draft lookup, and the snapshot write all key on the same\n // storage-locale value.\n const storageLocale = normalizeLocale(locale)\n\n // The locale-merged live row — the SAME read `publishDraft` takes as\n // its delta baseline, and the fallback state when no draft exists yet\n // (this document's very first edit is the detach itself).\n const live = await admin.findById(documentId, localeFindOptions(storageLocale))\n if (!live) return false\n\n // Overlay any pending draft patch on top of the live row, IN MEMORY\n // ONLY — this must never write the merge back (that would silently\n // publish the draft as a side effect of taking a safety snapshot).\n // Mirrors what `publishDraft` computes dynamically via\n // `applyLocaleRoutedUpdate`, minus the actual write.\n const draftPatch = await readDraftPatch(documentType, documentId, storageLocale, db)\n const merged: Record<string, unknown> = draftPatch ? { ...live, ...draftPatch } : { ...live }\n\n // 🔴 DO NOT \"TIDY\" THESE BACK TO STATIC IMPORTS. Both target modules\n // carry `import 'server-only'`; hoisting either one to the top of this\n // file reintroduces the exact throw the module docblock's point 3\n // describes — `pnpm verify:plugin-loads` (wired into CI,\n // .github/workflows/test.yml) will catch a regression, but it is\n // cheaper for the next reader to know this is load-bearing than to\n // rediscover it from a red PR.\n const [{ createContentContext }, { createVersionSnapshot }] = await Promise.all([\n import('./internal/context.js'),\n import('./versions.js'),\n ])\n const ctx = createContentContext({ admin, entity }, versionableOptions)\n\n await createVersionSnapshot(\n ctx,\n documentId,\n merged,\n live as Record<string, unknown>,\n storageLocale,\n // `exactOptionalPropertyTypes` — `createVersionSnapshot`'s `name?:\n // string` strictly excludes `null`, but the slot's `user` (matching\n // the wire-facing `ctx.user.name ?? null` shape) allows it.\n { id: user.id, ...(user.name != null ? { name: user.name } : {}) },\n )\n return true\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'` (the document is gone) and\n * only the CANONICAL ones on `action:'release'` (the document was trashed,\n * so its URL is freed but its redirect history outlives it). Fires the\n * 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 — and on\n * `action:'restore'`, where the release already deleted the canonical so a\n * move reports `created` rather than `renamed` (F062).\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, on the two removal\n * actions, drops rows without recomputing anything: `'delete'` takes every row\n * (the document is physically gone, so its redirect history is meaningless),\n * `'release'` takes only the canonical ones (the document is in the trash, so\n * its URL must be free for another document while its history waits for a\n * restore).\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 *\n * `'restore'` is handled as an `'upsert'` that ALSO fires the cascade on a\n * `created` outcome. It has to be: the trash-side `'release'` deleted the\n * canonical row, so the restore has nothing to demote and reports `created`\n * even when it moved the document to a new URL. See the call site.\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 // TRASH. Drop only the canonical rows: that releases the `(locale, path)`\n // claim so another document may take the URL, while leaving the demoted\n // history rows — the 301 sources a restore will need — in place.\n //\n // They are inert in the meantime rather than a leak: tier 4 of\n // `resolvePath` publish-checks a historical row's target before redirecting\n // to it, and a trashed document fails that check, so an old URL answers\n // `none` while the document is in the trash and starts redirecting again\n // the moment it is restored.\n //\n // `deleteMany` requires a non-empty where, which this is; the partial\n // unique that made the stale claim block a new document is predicated on\n // `is_canonical`, so dropping exactly these rows is what frees the URL.\n if (input.action === 'release') {\n await pathsClient.deleteMany({\n entityType: input.entityName,\n entityId: input.id,\n isCanonical: true,\n })\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 //\n // F074 — `input.viaCascade` means `dispatchCascadeSeams` is the caller:\n // this event is for a CASCADE CHILD, not the entity the ambient caller\n // directly authorized a write on. `onDelete: 'cascade'` / `'set-null'` is\n // the data-model author's standing declaration that the child follows the\n // parent's lifecycle — the parent operation was already authorized, but\n // the ambient caller may hold no `view` grant at all on the child's\n // entity type. Reading it under the ambient context then throws or\n // silently returns null (see the `!row` branch below), and the child's\n // path stops being maintained even though the cascade itself was fine.\n // `app.getSystemClient` is `createSystemAdminClient`'s shape (identity +\n // scope preserved, checker elevated, no publish filter — same read\n // semantics as `app.getClient`, unlike a `createSystemReachResolver`\n // `QueryClient`, which would hide every DRAFT document from this same\n // read and silently stop indexing them). See `ToolkitApp.getSystemClient`.\n const readClient = input.viaCascade === true ? app.getSystemClient(entity) : 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) {\n // F074/F075 — this used to be a bare `continue`: no row, nothing\n // computed, nothing recorded anywhere. Route it through the SAME\n // `skipped-null` reporting `applyCanonicalPath` already has (a\n // `path: null` call costs no DB round trip — it returns before any\n // query) so a defect here surfaces exactly like a broken-pattern\n // defect does. `row-unreadable` is always in NOTEWORTHY_SKIP_REASONS:\n // a row that legitimately has no path is never the one missing here —\n // there is no row read to be missing FOR.\n await applyCanonicalPath({\n pathsClient,\n entityType: input.entityName,\n entityId: input.id,\n locale,\n path: null,\n skipReason: 'row-unreadable',\n reservedPathPrefixes,\n logger: app.logger,\n })\n continue\n }\n\n const computed = 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: computed.path,\n ...(computed.skipReason !== undefined && { skipReason: computed.skipReason }),\n reservedPathPrefixes,\n logger: app.logger,\n })\n\n // An ancestor rename → fan out to descendants via the queue-job seam.\n //\n // A RESTORE counts as a rename here even though the outcome says\n // `created`, and that asymmetry is the whole of F062. Trashing releases\n // the canonical row, so by the time a restore re-asserts one there is no\n // existing canonical to demote — `applyCanonicalPath` reports `created`\n // unconditionally, including when `opts.overrides.slug` moved the\n // document to a completely different URL. Gating on `renamed` alone left\n // every descendant deriving `[parent.path]` from a URL nothing owns,\n // while that URL was simultaneously free for another document to claim.\n //\n // Restricted to the restore action rather than firing on every `created`:\n // a genuine create has no descendants, so that form would enqueue a job\n // per create of a hierarchical entity to find nothing every time. A\n // restore is rare and human-initiated, and `fireCascade` no-ops unless\n // the entity is a `pathParent()` hierarchy AND a trigger is wired, so a\n // same-slug restore costs one job per locale that finds every descendant\n // `unchanged`. That is the price of not needing to know whether the slug\n // moved — the pre-trash canonical was deleted by the release, so nothing\n // here can answer that question.\n //\n // ⚠️ THE COUNT IS PER ROW, NOT PER RESTORE, and a cascade restore is\n // many rows. `AdminClient.restore` now runs this writer for every\n // reactivated cascade child and every same-entity batch sibling, so a\n // batch of N rows across L locales enqueues up to N×L jobs, each walking\n // its own subtree — and `setActiveCascadeTrigger`'s body is a bare\n // `enqueue` with no dedup key. What bounds it is on the caller's side:\n // `MAX_CASCADE_PATH_RECOMPUTES` refuses recompute-action path writes\n // past a per-operation budget, so N is capped before it reaches here.\n // Not reachable in-repo today either — `pathParent()` hardcodes\n // `onDelete: 'restrict'`, so a hierarchy never self-cascades — but the\n // bound is the caller's cap and not this line, and a reader of this line\n // alone would conclude otherwise.\n if (\n result.outcome === 'renamed' ||\n (input.action === 'restore' && result.outcome === 'created')\n )\n 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 { getCurrentSite, runAsCli, runInEverySite, 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 computed = 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: computed.path,\n ...(computed.skipReason !== undefined && { skipReason: computed.skipReason }),\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 // Truthiness, NOT `!== undefined`. `client-factories.ts`'s\n // `readActivePathWriters` gate and `AdminClient`'s `routable` flag both test\n // `!!entity.frontend?.path`, so `frontend: { path: '' }` is non-routable to\n // the write path — and was routable here, which would have this job mint\n // canonical rows for a document no writer ever maintains. Nothing declares\n // an empty path today; the divergence is closed while it is still free.\n const targetEntities = [...app.entities.values()].filter(\n (e) => !!e.frontend?.path && (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 computed = 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: computed.path,\n ...(computed.skipReason !== undefined && { skipReason: computed.skipReason }),\n // `parent-not-indexed` is EXPECTED here on a cold `toolkit_paths`\n // index: this sweep scans in ascending id (uncorrelated with\n // hierarchy depth — see the function docblock), so a descendant\n // legitimately outruns its not-yet-created ancestor on pass 1 and\n // self-heals by the ancestor's own pass. Warning on every such hit\n // across every pass of a first-time rebuild would be exactly the\n // \"trains operators to ignore the message\" failure F075 exists to\n // avoid — this job already reports its own skipped/regenerated\n // counts in its progress/summary.\n suppressNoteworthy: ['parent-not-indexed'],\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 // Once per site on an app serving several (multisite D003) — a rebuild\n // reads every routable (site-bound) row. Per-site path indexing is phase 03.\n await runAsCli(() =>\n runInEverySite(\n async () => {\n const payload = rebuildPayloadSchema.parse(ctx.payload)\n const rebuildResult = await runPathsRebuild(app, payload, {\n onProgress: (p) => ctx.updateProgress({ ...p, site: getCurrentSite() ?? null }),\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 { app, label: 'paths-rebuild' },\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 * The published-path resolver this plugin contributes to the block editor's\n * canvas (`blocks.publishedPathResolver`, editor PR59, F143), so a collection\n * card in Preview opens the entry it shows.\n *\n * The SAME lookup the public site links its cards through\n * (`createBlockResolver`'s `resolvePublishedPaths`): the batched publish filter,\n * then canonical paths — so a canvas card links exactly where the site's does,\n * and an unpublished or trashed entry yields no link.\n *\n * Lazy by necessity. This module is imported by `plugin.ts`, which the CLI loads\n * under plain Node through jiti; `./resolve-context.js` pulls the entity query\n * runtime and must not load there — the reason `paths/routes.ts` imports it the\n * same way.\n */\n\nimport type { PublishedPathResolver } from '@murumets-ee/blocks/server'\nimport { getApp } from '@murumets-ee/core'\n\nexport const resolvePublishedCanonicalPaths: PublishedPathResolver = async (\n entityType,\n ids,\n locale,\n) => {\n if (ids.length === 0) return new Map()\n const [{ buildEnumerateDeps }, { getCanonicalPaths }] = await Promise.all([\n import('./resolve-context.js'),\n import('./enumerate.js'),\n ])\n const deps = buildEnumerateDeps(getApp())\n const published = await deps.filterPublished(entityType, ids, locale)\n if (published.size === 0) return new Map()\n return getCanonicalPaths(entityType, [...published], locale, deps)\n}\n","/**\n * Companion tables of a site-bound entity (plan `multisite` D003).\n *\n * `<entity>_drafts`, `<entity>_versions` and `<entity>_locale_status` are\n * read and written through table clients, not through `AdminClient`, so the\n * entity firewall's site filter never sees them. For a BOUND entity they are\n * therefore defined as PARTITIONED tables: a `_site` column, every unique\n * leading with it (matching the DDL `lumi migrate` emits), and\n * `partitionColumn: '_site'` — so `makeClient(db)` refuses to build a client\n * without a site, and the one it builds confines every read and stamps every\n * write. The database backs this with the composite `(_site, entity_id)` FK.\n *\n * Shared entities get exactly the table they always had.\n */\n\nimport { type ColumnFactory, column, defineTable, type TableDefinition } from '@murumets-ee/db'\nimport { type Entity, isSiteBound, SITE_COLUMN, SITE_HANDLE_MAX_LENGTH } from '@murumets-ee/entity'\n\nexport function defineCompanionTable<const TCols extends Record<string, ColumnFactory>>(\n entity: Entity,\n def: Pick<TableDefinition<TCols>, 'name' | 'columns' | 'unique'>,\n): ReturnType<typeof defineTable<TCols>> {\n if (!isSiteBound(entity)) return defineTable(def)\n const withSite: TableDefinition<Record<string, ColumnFactory>> = {\n name: def.name,\n columns: {\n ...def.columns,\n [SITE_COLUMN]: column.varchar({ length: SITE_HANDLE_MAX_LENGTH, notNull: true }),\n },\n unique: (def.unique ?? []).map((u) => ({ ...u, on: [SITE_COLUMN, ...u.on.map(String)] })),\n partitionColumn: SITE_COLUMN,\n }\n // The `_site` column is reached only BY NAME, through the client's partition\n // — no caller ever names it — so the typed surface a caller sees is the\n // declared columns. This cast is the one place that is stated.\n return defineTable(withSite) as unknown as ReturnType<typeof defineTable<TCols>>\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 } from '@murumets-ee/db'\nimport type { Entity } from '@murumets-ee/entity'\nimport { defineCompanionTable } from './site-partition.js'\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 defineCompanionTable(entity, {\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 } from '@murumets-ee/db'\nimport type { Entity } from '@murumets-ee/entity'\nimport { defineCompanionTable } from './site-partition.js'\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 defineCompanionTable(entity, {\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 } from '@murumets-ee/db'\nimport type { Entity } from '@murumets-ee/entity'\nimport { defineCompanionTable } from './site-partition.js'\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 defineCompanionTable(entity, {\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 { createDocumentVersionStore } from './document-version-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\n// The `available` predicate, from the import-free leaf module. NOT from\n// `./seed.js` — that one imports `@murumets-ee/core/clients` (`server-only`),\n// and a static import of it here would put the sentinel on the module every\n// `lumi` command loads. See the `seeders` block below.\nimport { hasChromeEntities } from './chrome-entity-names.js'\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 { resolvePublishedCanonicalPaths } from './paths/published-path-resolver.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 // F053 — the version-snapshot seam the detach flow's snapshot-version\n // route calls before forking a saved pattern and dispatching the op.\n 'blocks.documentVersionStore': createDocumentVersionStore(),\n // editor PR59 (F143) — published canonical paths for the canvas's\n // collection cards, so Preview opens the recipe or tip a card shows.\n // content owns `toolkit_paths`; blocks cannot import it.\n 'blocks.publishedPathResolver': resolvePublishedCanonicalPaths,\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 // The site-chrome seeder, contributed rather than re-authored per app.\n //\n // `available` is the point of this entry, and the reason F009 deferred\n // it. The chrome entity is a FACTORY (`siteChromeEntity()`) an app may\n // or may not instantiate — `withOpLog()` has to come\n // from the app, because it ships from `@murumets-ee/blocks/server` and\n // this package must not depend on blocks. So there is no entity here to\n // put on `server.entities`, and contributing the seeder unconditionally\n // would give a project that registered no chrome a button whose entire\n // outcome is `created: 0` — which reads as \"broken\", not as \"this site\n // has no header\". The predicate makes it ABSENT instead, the same\n // contract `rent:cars-demo` has.\n //\n // The predicate and the seeder ask about the same NAME\n // (`CHROME_ENTITY_NAME`), imported rather than re-spelled: two spellings\n // would drift, and the drift is invisible — a button that appears and\n // creates nothing, or none on a site that has chrome.\n //\n // `hasChromeEntities` is imported STATICALLY, unlike `seedSiteChrome`.\n // It has to be: `available` is called at LIST time, on the seed page's\n // synchronous shell and inside `lumi seed --list`, so it cannot be\n // behind an `await import`. That is only safe because it lives in\n // `./chrome-entity-names.ts`, which imports NOTHING — importing it from\n // `./seed.js` would have hoisted that module's\n // `@murumets-ee/core/clients` into `dist/plugin.mjs` and thrown under\n // jiti on the next `lumi migrate`. The RUN path stays lazy per D004.\n seeders: [\n {\n name: 'content:site-chrome',\n description:\n \"Create the empty site_chrome row for each placement (header, footer), as a DRAFT, so they can be edited on the page canvas. Publishing one is what makes /v1/_chrome serve it instead of the delivery template's own fallback. Idempotent — an existing row for a placement is left alone.\",\n available: hasChromeEntities,\n // 🔴 THE ANSWER TO S5. `lumi migrate` runs every bootstrap seeder\n // after it applies migrations, and `pnpm db:migrate` is already step\n // 4 of the printed next-steps — so a scaffolded project's header is\n // editable on first boot with no seeder anyone invokes, no SQL and\n // no manual step.\n //\n // It is declarative for the usual reason: the alternative shapes all\n // put the trigger somewhere a plugin cannot reach. An install-time\n // data migration never ships (the scaffold skips `migrations/` and\n // regenerates from the runtime entities), a create-on-first-open\n // writes during a render, and hanging it off the app's own boot code\n // makes every app re-wire what the plugin already knows.\n //\n // Safe to run on every migrate ONLY because the run is idempotent —\n // see `seedSiteChrome`, which finds per placement before creating.\n bootstrap: true,\n run: async () => {\n const { seedSiteChrome } = await import('@murumets-ee/content/seed')\n return await seedSiteChrome()\n },\n },\n ],\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"./rolldown-runtime-DK3Fl9T5.mjs";import{t}from"./content-config-store-8omQF5p5.mjs";import{n}from"./schema-D8sBQozw.mjs";import{i as r,n as i}from"./schema-0zayzpD_.mjs";import{createSystemReachResolver as a}from"@murumets-ee/core";import{QueryClient as o}from"@murumets-ee/entity/query";import{inArray as s}from"drizzle-orm";import{z as c}from"zod";import{DEFAULT_LOCALE as l,GLOBAL_SCOPE_ID as u}from"@murumets-ee/settings";import{toolkitSettingsTable as d}from"@murumets-ee/settings/schema";const f=c.object({entity:c.string().min(1),id:c.string().uuid()});function p(e){let t=f.safeParse(e);return t.success?t.data:null}var m=e({buildCanonicalPathDeps:()=>g,buildCanonicalPathsBatchDeps:()=>b,buildEnumerateDeps:()=>y,buildPublishChecker:()=>_,buildRedirectsListDeps:()=>C,buildResolveDeps:()=>w,buildSitemapDeps:()=>S,createHomepageReader:()=>v});const h=a(`path-resolver`);function g(e){return{pathsClient:n.makeClient(e.db.readOnly)}}function _(e){return r({entityResolver:t=>e.entities.get(t),queryClientFactory:t=>new o({entity:t,db:e.db.readOnly,logger:e.logger.child({resolve:`publish-check`,entity:t.name}),contextResolver:h})})}function v(e){let t=d.makeClient(e);return async()=>{let e=await t.findOne({namespace:`site`,key:`homeDocument`,scope:`global`,scopeId:u,locale:l});return e?p(e.value):null}}function y(e){let t=e.db.readOnly;return{pathsClient:n.makeClient(t),filterPublished:async(n,r,i)=>{if(r.length===0)return new Set;let a=e.entities.get(n);if(a===void 0)return new Set;let c=new o({entity:a,db:t,logger:e.logger.child({resolve:`enumerate-publish`,entity:a.name}),contextResolver:h}),l=c.getTable().id,u=await c.findMany({where:s(l,[...r]),locale:i}),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:n.makeClient(e.db.readOnly)}}function x(e){let t=e.db.readOnly;return async(n,r,i)=>{if(r.length===0)return new Set;let a=e.entities.get(n);if(a===void 0||!a.behaviors?.some(e=>e.name===`seoMeta`))return new Set;let c=new o({entity:a,db:t,logger:e.logger.child({resolve:`sitemap-noindex`,entity:a.name}),contextResolver:h}),l=c.getTable().id,u=await c.findMany({where:s(l,[...r]),locale:i}),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:i.makeClient(e.db.readOnly),pathsClient:t.pathsClient,canonicalBatch:b(e),filterPublished:t.filterPublished}}function w(e){let r=e.db.readOnly;return{pathsClient:n.makeClient(r),redirectsClient:i.makeClient(r),publishChecker:_(e),homepageReader:v(r),locales:t().locales.map(e=>e.code),logger:e.logger}}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-MRZZ3rQl.mjs.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"resolve-context-MRZZ3rQl.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, so an unpublished / trashed\n * document still resolves to `null` → not routable.\n *\n * Since D017 that publish filter is CONDITIONAL — it holds for any principal\n * WITHOUT `view_unpublished`, and is lifted for one that holds it. What makes\n * the paragraph above true is therefore not the filter being unconditional but\n * {@link SYSTEM_CONTEXT} WITHHOLDING that action (and every write action with\n * it). No content data is returned from the check (only a boolean), so\n * elevation leaks nothing — the downstream content fetch still runs under the\n * caller's real permissions.\n */\n\nimport type { ToolkitApp } from '@murumets-ee/core'\nimport { createSystemReachResolver } 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 {\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'\nimport type { SitemapDeps } from './sitemap.js'\n\n/**\n * Elevated **reach** resolver for the publish check — the roles/locale-registry\n * -read analog (see file header for why this is safe).\n *\n * It grants `view` and NOTHING else — in particular not `view_unpublished`,\n * which is what makes the file header's claim true. Until D017 the header's\n * \"the publish filter is applied by `QueryClient` regardless\" held for ANY\n * checker; now that publish-visibility IS a permission, a `checker: () => true`\n * here would answer \"yes\" to `view_unpublished` and every door this module backs\n * (`_resolve`, `_alternates`, `_paths`, sitemap, redirects) would start\n * resolving drafts to public URLs. {@link createSystemReachResolver} owns that\n * exception centrally so it can't be re-derived (or forgotten) per call site.\n *\n * The explicit id is the audit identity: it must be distinguishable from the\n * menu resolver's, or two elevated readers are one indistinguishable `'system'`\n * in the log.\n */\nconst SYSTEM_CONTEXT: () => SecurityContext = createSystemReachResolver('path-resolver')\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 trash filter\n * applies unconditionally, and the publish filter applies because the elevated\n * context WITHHOLDS `view_unpublished` (D017; see file header), so no\n * unpublished/trashed id 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 // Only consumer: the historical-redirect lookup's truncation warning.\n logger: app.logger,\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}from"./schema-D8sBQozw.mjs";import{d as r,n as i,s as a}from"./schema-0zayzpD_.mjs";import{matchReservedPrefix as o}from"@murumets-ee/entity";import{combineAdminRoutes as s,defineAdminRoute as c,getContext as l,safeAudit as u}from"@murumets-ee/core";import{and as d,eq as f}from"drizzle-orm";import{z as p}from"zod";import{entityRefs as m}from"@murumets-ee/entity/refs";const h=`path:resolve`,ee=[`admin`,`editor`];function g(e,t=200){return new Response(JSON.stringify(e),{status:t,headers:{"Content-Type":`application/json`}})}const _={kind:`none`};function v(e){return e===null||e.length===0?{error:`query param 'path' is required`}:e.length>2048?{error:`query param 'path' is too long`}:e.startsWith(`/`)?e.startsWith(`//`)?{error:`query param 'path' must not be protocol-relative`}:e.includes(`?`)||e.includes(`#`)?{error:`query param 'path' must not include a query or fragment`}:{path:e}:{error:`query param 'path' must start with '/'`}}function y(t,n){let r=e(),i=t??n??r.defaultLocale;return(l()?.availableLocales??r.locales.map(e=>e.code)).includes(i)?{locale:i}:{error:`query param 'locale' is not a content locale`}}const b=async(e,t)=>{let n=new URL(e.url).searchParams,r=v(n.get(`path`));if(`error`in r)return g({error:r.error},400);let i=y(n.get(`locale`),t.locale??t.defaultLocale);if(`error`in i)return g({error:i.error},400);let{locale:o}=i,{buildResolveDeps:s}=await import(`./resolve-context-MRZZ3rQl.mjs`).then(e=>e.l),c=s(t.app),l=(e,n)=>t.checkPermission(e,`view`)?{kind:`document`,entityType:e,id:n}:_,u=await a(r.path,o,c);if(u.kind===`document`)return g(l(u.entityType,u.id));if(u.kind===`none`)return g(_);if(!(u.to.startsWith(`/`)&&!u.to.startsWith(`//`)))return g({kind:`redirect`,to:u.to});let d=await a(u.to,o,c);return g(d.kind===`document`?l(d.entityType,d.id):_)};function x(){return s([c({prefix:`paths`,path:`resolve`,method:`GET`,permission:h,defaultRoles:ee,description:`Resolve a site URL path to the CMS document that serves it (editor canvas link navigation).`,handler:b})])}var S=class extends Error{fromPath;prefix;constructor(e,t){super(`Redirect source "${e}" falls under the reserved prefix "${t}" and cannot be used — that path range is owned by the framework.`),this.name=`RedirectReservedPrefixError`,this.fromPath=e,this.prefix=t}},C=class extends Error{fromPath;locale;conflictingLocale;constructor(e,t,n){super(`Redirect source "${e}" already serves content in locale "${n}" (it is a live canonical URL). Choose a different source path, or unpublish/rename the content first.`),this.name=`RedirectServesContentError`,this.fromPath=e,this.locale=t,this.conflictingLocale=n}},w=class extends Error{entity;constructor(e){super(`Redirect target references entity type "${e}", which is not a registered entity. Check the entity name.`),this.name=`RedirectUnknownEntityError`,this.entity=e}},T=class extends Error{entity;id;constructor(e,t){super(`Redirect target ${e} "${t}" does not exist. Redirect to a document that exists, or use a URL/path target instead.`),this.name=`RedirectTargetNotFoundError`,this.entity=e,this.id=t}},E=class extends Error{fromPath;locale;constructor(e,t){super(`Redirect source "${e}"${t===null?``:` (locale "${t}")`} resolves back to itself — a redirect cannot point at its own source path. Choose a different destination.`),this.name=`RedirectSelfReferenceError`,this.fromPath=e,this.locale=t}};const D=`toolkit_redirects`;async function O(e,t,n){n.kind===`entity`&&await e.insert(m).values({sourceEntity:D,sourceId:t,sourceField:`target`,targetEntity:n.entity,targetId:n.id,relationKind:`link`}).onConflictDoNothing()}async function k(e,t){await e.delete(m).where(d(f(m.sourceEntity,D),f(m.sourceId,t)))}const A=p.discriminatedUnion(`kind`,[p.object({kind:p.literal(`entity`),entity:p.string().min(1),id:p.string().uuid()}),p.object({kind:p.literal(`url`),url:p.string().max(2048).url()}),p.object({kind:p.literal(`path`),path:p.string().min(1).max(2048).startsWith(`/`)})]);function j(e){return r(e)}function te(e){let t=j(e.fromPath),n=o(t,e.reservedPathPrefixes);if(n!==void 0)throw new S(t,n)}async function ne(e){let t=j(e.fromPath);if(e.locale!==null){let n=await e.pathsClient.findOne({path:t,isCanonical:!0,locale:e.locale});if(n)throw new C(t,e.locale,n.locale);return}let n=await e.pathsClient.findOne({path:t,isCanonical:!0,locale:{in:[...e.configuredLocales]}});if(n)throw new C(t,null,n.locale)}async function M(e){if(e.target.kind!==`path`)return e.target;let t=j(e.target.path),n=e.excludeId===void 0?{}:{id:{ne:e.excludeId}};if(e.locale!==null){let r=await e.redirectsClient.findOne({fromPath:t,locale:e.locale,...n});if(r)return r.target}let r=await e.redirectsClient.findOne({fromPath:t,locale:null,...n});return r?r.target:{kind:`path`,path:t}}async function N(e){if(e.target.kind!==`entity`)return;let{entity:t,id:n}=e.target,r=e.resolveClient(t);if(r===void 0)throw new w(t);if(await r.findById(n)==null)throw new T(t,n)}const P=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,F=/^[a-zA-Z0-9:_.-]{1,100}$/,I=`redirect:manage`,L=[`admin`];function R(e,t=200){return new Response(JSON.stringify(e),{status:t,headers:{"Content-Type":`application/json`}})}function z(e,t){return R({error:e},t)}async function B(e,t){let n;try{n=await e.json()}catch{return{response:z(`Invalid JSON body`,400)}}let r=t.safeParse(n);if(!r.success){let e=r.error.issues[0];return{response:z(e?`${e.path.join(`.`)||`body`}: ${e.message}`:`Invalid body`,400)}}return{data:r.data}}function V(e,t){if(e===null||!/^\d+$/.test(e))return t;let n=Number(e);return Number.isSafeInteger(n)?n:t}function H(e){return e.name===void 0?{userId:e.id}:{userId:e.id,userName:e.name}}async function U(){let{getApp:e}=await import(`@murumets-ee/core`);return e()}async function W(){return(await U()).db.readWrite}async function G(){let e=await U();return t=>{let n=e.entities.get(t);return n?e.getClient(n):void 0}}function K(e){return e}const q=p.union([p.literal(301),p.literal(302)]),J=p.string().min(1).max(2048).startsWith(`/`),Y=p.object({locale:p.string().min(1).nullable(),fromPath:J,target:A,status:q.default(301)}),X=p.object({locale:p.string().min(1).nullable().optional(),fromPath:J.optional(),target:A.optional(),status:q.optional()});function Z(t){return t===null||e().locales.map(e=>e.code).includes(t)?null:z(`Unknown locale "${t}" — not one of the configured content locales.`,400)}function Q(e){if(e instanceof S)return R({error:e.message,fromPath:e.fromPath,prefix:e.prefix},409);if(e instanceof C)return R({error:e.message,fromPath:e.fromPath,locale:e.locale,conflictingLocale:e.conflictingLocale},409);if(e instanceof E)return R({error:e.message,fromPath:e.fromPath,locale:e.locale},409);if(e instanceof w)return R({error:e.message,entity:e.entity},400);if(e instanceof T)return R({error:e.message,entity:e.entity,id:e.id},400);if(t(e))return R({error:`A redirect already exists for this locale + source path.`},409);throw e}async function $(t){let r=e(),a=r.reservedPathPrefixes??[],o=r.locales.map(e=>e.code),s=j(t.fromPath);return te({fromPath:s,reservedPathPrefixes:a}),await N({target:t.target,resolveClient:t.resolveClient}),(await W()).transaction(async e=>{let r=K(e),a=i.makeClient(r),c=n.makeClient(r);await ne({fromPath:s,locale:t.locale,configuredLocales:o,pathsClient:c});let l=await M({target:t.target,locale:t.locale,redirectsClient:a,...t.id===null?{}:{excludeId:t.id}});if(l.kind===`path`&&j(l.path)===s)throw new E(s,t.locale);if(t.id===null){let e=await a.insert({locale:t.locale,fromPath:s,target:l,status:t.status,createdBy:t.userId});return await O(r,e.id,l),e}let u=await a.update({id:t.id},{locale:t.locale,fromPath:s,target:l,status:t.status,updatedAt:new Date});return u?(await k(r,t.id),await O(r,t.id,l),u):null})}const re=async e=>{let t=new URL(e.url),n=t.searchParams.get(`locale`),r=Math.min(Math.max(V(t.searchParams.get(`limit`),50),1),200),a=Math.min(V(t.searchParams.get(`offset`),0),1e5),o;if(n!==null&&n!==``){let e=Z(n);if(e)return e;o=n}let s=i.makeClient(await W());return R({items:o===void 0?await s.findMany({orderBy:[{column:`createdAt`,dir:`desc`}],limit:r,offset:a}):await s.findMany({where:{locale:o},orderBy:[{column:`createdAt`,dir:`desc`}],limit:r,offset:a}),total:o===void 0?await s.count():await s.count({locale:o})})},ie=async(e,t)=>{let n=await B(e,Y);if(`response`in n)return n.response;let r=n.data,i=Z(r.locale);if(i)return i;try{let e=await G(),n=await $({id:null,locale:r.locale,fromPath:r.fromPath,target:r.target,status:r.status,userId:t.user.id,resolveClient:e});return n?(u(t,{action:`redirect.created`,entityType:`redirect`,entityId:n.id,...H(t.user),metadata:{locale:n.locale,fromPath:n.fromPath,targetKind:n.target.kind}}),R({item:n},201)):z(`Failed to create redirect`,500)}catch(e){return Q(e)}},ae=async(e,t)=>{let n=t.segments[0];if(!n||!P.test(n))return z(`Invalid id`,400);let r=await B(e,X);if(`response`in r)return r.response;let a=r.data,o=await i.makeClient(await W()).findOne({id:n});if(!o)return z(`Not found`,404);let s=a.locale===void 0?o.locale:a.locale,c=a.fromPath===void 0?o.fromPath:a.fromPath,l=a.target===void 0?o.target:a.target,d=a.status===void 0?o.status:a.status,f=Z(s);if(f)return f;try{let e=await G(),r=await $({id:n,locale:s,fromPath:c,target:l,status:d,userId:t.user.id,resolveClient:e});return r?(u(t,{action:`redirect.updated`,entityType:`redirect`,entityId:r.id,...H(t.user),metadata:{locale:r.locale,fromPath:r.fromPath,targetKind:r.target.kind}}),R({item:r})):z(`Not found`,404)}catch(e){return Q(e)}},oe=async(e,t)=>{let n=t.segments[0];if(!n||!P.test(n))return z(`Invalid id`,400);let r=await(await W()).transaction(async e=>{let t=K(e);return await k(t,n),i.makeClient(t).delete({id:n})});return r?(u(t,{action:`redirect.deleted`,entityType:`redirect`,entityId:n,...H(t.user),metadata:{locale:r.locale,fromPath:r.fromPath}}),R({ok:!0})):z(`Not found`,404)},se=async e=>{let t=new URL(e.url),r=t.searchParams.get(`entityType`),i=t.searchParams.get(`entityId`);if(!r||!F.test(r))return z(`entityType is required`,400);if(!i||!P.test(i))return z(`entityId is required and must be a UUID`,400);let a=t.searchParams.get(`locale`),o;if(a!==null&&a!==``){let e=Z(a);if(e)return e;o=a}return R({items:await n.makeClient(await W()).findMany({where:{entityType:r,entityId:i,isCanonical:!1,...o!==void 0&&{locale:o}},orderBy:[{column:`updatedAt`,dir:`desc`}],limit:100})})};function ce(){return s([c({prefix:`redirects`,path:``,method:`GET`,permission:I,defaultRoles:L,description:`List manual URL redirects (paginated, optional locale filter).`,handler:re}),c({prefix:`redirects`,path:`history`,method:`GET`,permission:I,defaultRoles:L,description:`Read-only automatic-redirect (rename) history for one document.`,handler:se}),c({prefix:`redirects`,path:``,method:`POST`,permission:I,defaultRoles:L,description:`Create a manual URL redirect.`,handler:ie}),c({prefix:`redirects`,path:``,method:`PATCH`,matchAnyPath:!0,permission:I,defaultRoles:L,description:`Update a manual URL redirect by id.`,handler:ae}),c({prefix:`redirects`,path:``,method:`DELETE`,matchAnyPath:!0,permission:I,defaultRoles:L,description:`Delete a manual URL redirect by id.`,handler:oe})])}export{h as n,x as r,ce as t};
2
- //# sourceMappingURL=routes-C9I1iZw8.mjs.map
@@ -1,2 +0,0 @@
1
- import{behavior as e,defineEntity as t,field as n}from"@murumets-ee/entity";import{defineSettings as r,setting as i}from"@murumets-ee/settings/define";const a=[`prefix-all`,`no-prefix-default`,`cookie`],o=r({namespace:`i18n`,scope:`global`,label:`Languages`,iconName:`languages`,hideFromMenu:!0,schema:{defaultLocale:i.text({default:``}),routingStrategy:i.select({options:a,default:`no-prefix-default`})}}),s=t({name:`locale`,class:`config`,scope:`global`,site:`shared`,access:{view:`locale:view`,create:`locale:create`,update:`locale:update`,delete:`locale:delete`},fields:{code:n.text({required:!0,unique:!0,maxLength:10}),label:n.text({required:!0,maxLength:64}),enabled:n.boolean({default:!0}),direction:n.select({options:[`ltr`,`rtl`],default:`ltr`}),sortOrder:n.number({default:0})},behaviors:[e.auditable()],admin:{hidden:!0,label:`Languages`,labelSingular:`Language`}});function c(){return{name:`messageKeyUnique`,fields:{},constraints:(e,t)=>{if(t!==`main`)return[];let{namespace:n,key:r,locale:i}=e;return!n||!r||!i?[]:[{kind:`uniqueConstraint`,name:`message_namespace_key_locale_unique`,on:[n,r,i]}]}}}const l=t({name:`message`,class:`config`,scope:`global`,site:`shared`,access:{view:`message:view`,create:`message:create`,update:`message:update`,delete:`message:delete`},fields:{namespace:n.text({required:!0,maxLength:64}),key:n.text({required:!0,maxLength:255}),locale:n.text({required:!0,maxLength:10,indexed:!0}),value:n.text({required:!0})},behaviors:[e.auditable(),c()],admin:{hidden:!0,label:`Translations`,labelSingular:`Message`}});function u(e){switch(e){case`prefix-all`:return`always`;case`no-prefix-default`:return`as-needed`;case`cookie`:return`never`}}function d(e){return e===`prefix-all`}export{a,s as i,d as n,o,l as r,u as t};
2
- //# sourceMappingURL=routing-strategy-CQdu1qV7.mjs.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"routing-strategy-CQdu1qV7.mjs","names":[],"sources":["../src/i18n-settings.ts","../src/locale-entity.ts","../src/message-entity.ts","../src/routing-strategy.ts"],"sourcesContent":["/**\n * `i18n` — the global i18n singletons **simple-config** namespace (plan/config\n * phase 05 §3, D002/D009/SD006): `defaultLocale` + `routingStrategy`.\n *\n * Simple config (D002) rather than a config entity because these are singletons,\n * not a list. Contributed via the content plugin's `shared.settings` slot, so it\n * auto-registers as a config type (D006) and rides export/import (phase 09) for\n * free. Edited through the bespoke **Languages** editor (`hideFromMenu`), which\n * also enforces `defaultLocale ∈ enabled locales`.\n *\n * Imports from `@murumets-ee/settings/define` (the pure builder subpath, no\n * `server-only`) so the content plugin stays jiti-safe at config-load time.\n */\n\nimport { defineSettings, setting } from '@murumets-ee/settings/define'\n\n/** The three URL locale-routing strategies (D009). */\nexport const ROUTING_STRATEGIES = ['prefix-all', 'no-prefix-default', 'cookie'] as const\n\nexport const i18nSettings = defineSettings({\n namespace: 'i18n',\n scope: 'global',\n label: 'Languages',\n iconName: 'languages',\n // Edited via the Languages editor, not the generic per-namespace settings page.\n hideFromMenu: true,\n schema: {\n /**\n * The site-wide default content locale. Empty ⇒ inherit the `content()`\n * shipped `defaultLocale`. The resolver ({@link resolveI18nConfig}) validates\n * it is an **enabled** locale and falls back to the `content()` default if not.\n */\n defaultLocale: setting.text({ default: '' }),\n /**\n * How the public URL encodes locale. Maps to next-intl `localePrefix` /\n * Astro `prefixDefaultLocale` via `./routing-strategy.js`.\n */\n routingStrategy: setting.select({\n options: ROUTING_STRATEGIES,\n default: 'no-prefix-default',\n }),\n },\n})\n","/**\n * `locale` — the content-locale registry **config entity** (plan/config phase 05\n * §3, D006/D009/SD006).\n *\n * One row per content locale the operator adds or customizes. The row is a\n * **read-time-merge override** (D004): a row whose `code` matches a `content()`\n * shipped default replaces that default (relabel / set direction / reorder /\n * `enabled: false` to hide it); a row with a new `code` adds a locale. Locales\n * with no row are the shipped defaults. See {@link resolveEffectiveLocales}.\n *\n * `class: 'config'` (SD001) marks it a configuration entity: it auto-registers\n * into the `ConfigTypeRegistry` (phase-09 export/import picks it up for free),\n * defaults to audit-only (no publish / no trash — trash auto-enable skips config\n * entities), and is edited through the bespoke **Languages** editor, not the\n * generic auto-CRUD (`admin.hidden`).\n *\n * `access` is admin-only: managing the language set is an admin capability. The\n * app-wide *effective-locale read* ({@link resolveEffectiveLocales}) uses a\n * system-context query so every role still resolves valid locales for editing\n * content, without being able to list/edit the registry itself.\n *\n * Leaf-safe: imports only `@murumets-ee/entity`, so the content plugin can pull\n * it in without a `server-only` chain through the jiti config load.\n */\n\nimport { behavior, defineEntity, field } from '@murumets-ee/entity'\n\nexport const Locale = defineEntity({\n name: 'locale',\n class: 'config',\n scope: 'global',\n // The locale registry is global; each site lists the subset it serves (D007).\n site: 'shared',\n access: {\n view: 'locale:view',\n create: 'locale:create',\n update: 'locale:update',\n delete: 'locale:delete',\n },\n fields: {\n /**\n * BCP47 locale code (e.g. `en`, `et`, `pt-BR`). Unique — one row per locale.\n * Capped at 10 to match the `varchar(10)` locale columns in the\n * versions / drafts / locale_status tables (truncation past 10 would break\n * later equality — see `locale-utils.ts`).\n */\n code: field.text({ required: true, unique: true, maxLength: 10 }),\n /** Human label shown in the switcher + editor (e.g. `English`). */\n label: field.text({ required: true, maxLength: 64 }),\n /**\n * When `false` the locale is hidden from the content switcher + rejected by\n * `validateLocale` (the D004 disable-not-delete edge), but stays listable in\n * the Languages editor so it can be re-enabled.\n */\n enabled: field.boolean({ default: true }),\n /** Text direction (`ltr`/`rtl`) — persisted per locale for RTL-aware surfaces to read\n * when setting `dir`; the surface-level `dir` wiring lands with RTL support. */\n direction: field.select({ options: ['ltr', 'rtl'] as const, default: 'ltr' }),\n /** Display order in the switcher + editor (ascending). */\n sortOrder: field.number({ default: 0 }),\n },\n behaviors: [behavior.auditable()],\n admin: {\n // Edited via the Languages editor, not the generic CRUD — keep it out of the\n // auto entity list + sidebar.\n hidden: true,\n label: 'Languages',\n labelSingular: 'Language',\n },\n})\n","/**\n * `message` — the interface-message store **config entity** (plan/config phase 06,\n * D006/D010).\n *\n * One row per `(namespace, key, locale)` **override** of an interface string. A row\n * is a read-time-merge customization (D004): the per-package / per-app message JSON\n * ships the defaults; a `message` row exists ONLY for a string the operator edited.\n * Merge-on-upgrade is automatic — new shipped keys reach non-customized strings; a\n * customized string keeps its row. No `customized` flag. See {@link resolveMessageOverrides}.\n *\n * `class: 'config'` (SD001) marks it a configuration entity: auto-registers into the\n * `ConfigTypeRegistry` (phase-09 export/import picks it up for free), audit-only (no\n * publish / no trash), edited through the bespoke **Translations** editor, not the\n * generic auto-CRUD (`admin.hidden`).\n *\n * `access` is admin-only metadata; the operator grants `message:update` to editors via\n * the Permission Matrix (F007 — entity-derived `message:*` is the grantable gate; the\n * literal `translations:update` in the phase doc would be admin-only/non-grantable).\n *\n * Keyed uniquely on `(namespace, key, locale)` via the `messageKeyUnique()` behavior —\n * `defineEntity` has no entity-level composite-unique option, so a behavior contributes\n * the constraint (the `searchable()`/`publishable()` pattern). The unique makes the save\n * route's create race fail closed → retryable 409.\n *\n * Leaf-safe: imports only `@murumets-ee/entity`, so the content plugin + the admin-ui\n * route can pull it in through the jiti config-load without a `server-only` chain.\n */\n\nimport {\n type Behavior,\n behavior,\n defineEntity,\n field,\n type SchemaFragment,\n} from '@murumets-ee/entity'\n\n/**\n * Composite unique `(namespace, key, locale)` for the message store. A no-field\n * behavior contributing a single `uniqueConstraint` fragment to the main table.\n */\nfunction messageKeyUnique(): Behavior<Record<never, never>, 'messageKeyUnique'> {\n return {\n name: 'messageKeyUnique',\n fields: {},\n constraints: (table, target): SchemaFragment[] => {\n if (target !== 'main') return []\n const { namespace, key, locale } = table\n // Defensive: only emit when all three key columns resolved.\n if (!namespace || !key || !locale) return []\n return [\n {\n kind: 'uniqueConstraint',\n name: 'message_namespace_key_locale_unique',\n on: [namespace, key, locale],\n },\n ]\n },\n }\n}\n\nexport const Message = defineEntity({\n name: 'message',\n class: 'config',\n scope: 'global',\n // Interface strings are the admin's, not a site's.\n site: 'shared',\n access: {\n view: 'message:view',\n create: 'message:create',\n update: 'message:update',\n delete: 'message:delete',\n },\n fields: {\n /** Top-level catalog key — the namespace (e.g. `AuthUI`, `home`). */\n namespace: field.text({ required: true, maxLength: 64 }),\n /** Dot-path within the namespace (e.g. `signIn.title`). */\n key: field.text({ required: true, maxLength: 255 }),\n /**\n * BCP47 locale code — matches the `locale` registry codes (varchar(10)).\n * Indexed: the read-time merge filters `WHERE locale = ?`, which can't use the\n * `(namespace, key, locale)` composite prefix (that serves the reconcile's\n * `namespace` filter).\n */\n locale: field.text({ required: true, maxLength: 10, indexed: true }),\n /**\n * The (ICU-pattern) override string. Unbounded `text` — interface strings are\n * short but ICU plural/select bodies can run long; a varchar cap would reject a\n * legitimate long translation. Length is still bounded at the route boundary.\n */\n value: field.text({ required: true }),\n },\n behaviors: [behavior.auditable(), messageKeyUnique()],\n admin: {\n // Edited via the Translations editor, not the generic CRUD — keep it out of the\n // auto entity list + sidebar.\n hidden: true,\n label: 'Translations',\n labelSingular: 'Message',\n },\n})\n","/**\n * Routing-strategy → framework-config mappings (plan/config phase 05 §7, D009/SD006).\n *\n * The `i18n` config's `routingStrategy` is the tenant-editable, framework-neutral\n * choice; these pure helpers are the **\"consumers read it\"** seam that maps it to\n * each frontend's native knob:\n *\n * - **next-intl** — `localePrefix` (`'always'` / `'as-needed'` / `'never'`).\n * - **Astro** — `i18n.routing.prefixDefaultLocale` (boolean).\n *\n * Kept dependency-free (no entity/settings/db import) so the public Astro build\n * (`astro.config`) and next-intl `routing.ts` can import the mapping without\n * dragging a `server-only` chain.\n */\n\nimport type { RoutingStrategy } from './types.js'\n\n/**\n * Map a {@link RoutingStrategy} to next-intl's `localePrefix`:\n *\n * | strategy | localePrefix | URL shape |\n * |---------------------|--------------|-----------------------------------|\n * | `prefix-all` | `always` | `/en/about`, `/et/about` |\n * | `no-prefix-default` | `as-needed` | `/about` (default), `/et/about` |\n * | `cookie` | `never` | `/about` (locale via cookie/edge) |\n */\nexport function localePrefixForStrategy(\n strategy: RoutingStrategy,\n): 'always' | 'as-needed' | 'never' {\n switch (strategy) {\n case 'prefix-all':\n return 'always'\n case 'no-prefix-default':\n return 'as-needed'\n case 'cookie':\n return 'never'\n }\n}\n\n/**\n * Map a {@link RoutingStrategy} to Astro's `i18n.routing.prefixDefaultLocale`.\n * Only `prefix-all` prefixes the default locale; the other two leave the default\n * unprefixed (`cookie` resolves the locale at the edge worker, not in the path).\n */\nexport function prefixDefaultLocaleForStrategy(strategy: RoutingStrategy): boolean {\n return strategy === 'prefix-all'\n}\n"],"mappings":"uJAiBA,MAAa,EAAqB,CAAC,aAAc,oBAAqB,QAAQ,EAEjE,EAAe,EAAe,CACzC,UAAW,OACX,MAAO,SACP,MAAO,YACP,SAAU,YAEV,aAAc,GACd,OAAQ,CAMN,cAAe,EAAQ,KAAK,CAAE,QAAS,EAAG,CAAC,EAK3C,gBAAiB,EAAQ,OAAO,CAC9B,QAAS,EACT,QAAS,mBACX,CAAC,CACH,CACF,CAAC,ECfY,EAAS,EAAa,CACjC,KAAM,SACN,MAAO,SACP,MAAO,SAEP,KAAM,SACN,OAAQ,CACN,KAAM,cACN,OAAQ,gBACR,OAAQ,gBACR,OAAQ,eACV,EACA,OAAQ,CAON,KAAM,EAAM,KAAK,CAAE,SAAU,GAAM,OAAQ,GAAM,UAAW,EAAG,CAAC,EAEhE,MAAO,EAAM,KAAK,CAAE,SAAU,GAAM,UAAW,EAAG,CAAC,EAMnD,QAAS,EAAM,QAAQ,CAAE,QAAS,EAAK,CAAC,EAGxC,UAAW,EAAM,OAAO,CAAE,QAAS,CAAC,MAAO,KAAK,EAAY,QAAS,KAAM,CAAC,EAE5E,UAAW,EAAM,OAAO,CAAE,QAAS,CAAE,CAAC,CACxC,EACA,UAAW,CAAC,EAAS,UAAU,CAAC,EAChC,MAAO,CAGL,OAAQ,GACR,MAAO,YACP,cAAe,UACjB,CACF,CAAC,EC7BD,SAAS,GAAuE,CAC9E,MAAO,CACL,KAAM,mBACN,OAAQ,CAAC,EACT,aAAc,EAAO,IAA6B,CAChD,GAAI,IAAW,OAAQ,MAAO,CAAC,EAC/B,GAAM,CAAE,YAAW,MAAK,UAAW,EAGnC,MADI,CAAC,GAAa,CAAC,GAAO,CAAC,EAAe,CAAC,EACpC,CACL,CACE,KAAM,mBACN,KAAM,sCACN,GAAI,CAAC,EAAW,EAAK,CAAM,CAC7B,CACF,CACF,CACF,CACF,CAEA,MAAa,EAAU,EAAa,CAClC,KAAM,UACN,MAAO,SACP,MAAO,SAEP,KAAM,SACN,OAAQ,CACN,KAAM,eACN,OAAQ,iBACR,OAAQ,iBACR,OAAQ,gBACV,EACA,OAAQ,CAEN,UAAW,EAAM,KAAK,CAAE,SAAU,GAAM,UAAW,EAAG,CAAC,EAEvD,IAAK,EAAM,KAAK,CAAE,SAAU,GAAM,UAAW,GAAI,CAAC,EAOlD,OAAQ,EAAM,KAAK,CAAE,SAAU,GAAM,UAAW,GAAI,QAAS,EAAK,CAAC,EAMnE,MAAO,EAAM,KAAK,CAAE,SAAU,EAAK,CAAC,CACtC,EACA,UAAW,CAAC,EAAS,UAAU,EAAG,EAAiB,CAAC,EACpD,MAAO,CAGL,OAAQ,GACR,MAAO,eACP,cAAe,SACjB,CACF,CAAC,ECzED,SAAgB,EACd,EACkC,CAClC,OAAQ,EAAR,CACE,IAAK,aACH,MAAO,SACT,IAAK,oBACH,MAAO,YACT,IAAK,SACH,MAAO,OACX,CACF,CAOA,SAAgB,EAA+B,EAAoC,CACjF,OAAO,IAAa,YACtB"}
@@ -1,2 +0,0 @@
1
- import{buildEntityHref as e,matchReservedPrefix as t,resolvePattern as n}from"@murumets-ee/entity";import{column as r,defineTable as i}from"@murumets-ee/db";import{sql as a}from"drizzle-orm";var o=class extends Error{entityName;locale;path;conflictingEntityId;constructor(e,t,n,r,i){super(i??`Path "${n}" (locale "${t}") is already used by another ${e} document. Choose a different slug, or set this document's slug manually.`),this.name=`PathConflictError`,this.entityName=e,this.locale=t,this.path=n,this.conflictingEntityId=r}},s=class extends Error{entityName;path;prefix;constructor(e,t,n){super(`Path "${t}" falls under the reserved prefix "${n}" and cannot be used as a public URL for a ${e} document.`),this.name=`PathReservedPrefixError`,this.entityName=e,this.path=t,this.prefix=n}};async function c(t){let r=t.entity.frontend?.path;if(r===void 0)return{path:null,skipReason:`not-routable`};let i=n(r,t.locale,t.defaultLocale);if(i===null)return{path:null,skipReason:`no-pattern-for-locale`};let a=i;if(typeof i==`string`&&i.includes(`[parent.path]`)){let e=t.row.parent;if(typeof e==`string`&&e.length>0){let n=await t.pathsClient.findOne({entityType:t.entity.name,entityId:e,locale:t.locale,isCanonical:!0});if(!n)return{path:null,skipReason:`parent-not-indexed`};a=i.replaceAll(`[parent.path]`,n.path)}else a=i.replaceAll(`[parent.path]`,``)}let o=e(a,t.row,t.locale,t.defaultLocale);return o===null?{path:null,skipReason:`unresolved-token`}:{path:l(o)}}function l(e){let t=e.replace(/\/{2,}/g,`/`);return t.length>1&&t.endsWith(`/`)?t.slice(0,-1):t}async function u(e){let{entity:n,data:r,currentRow:i,id:a,locale:l,defaultLocale:u,slugMode:f,reservedPathPrefixes:p,pathsClient:m}=e,h=``;for(let e=0;e<50;e++){let e={...i??{},...r},g=await c({entity:n,row:e,locale:l,defaultLocale:u,pathsClient:m});if(g.path===null)return;let _=g.path;h=_;let v=t(_,p);if(v!==void 0)throw new s(n.name,_,v);let y=await m.findOne({locale:l,path:_,isCanonical:!0}),b=y!=null&&y.entityType===n.name&&y.entityId===a;if(y==null||b)return;if(f===`manual`)throw new o(n.name,l,_,y.entityId);let x=e.slug;if(typeof x!=`string`||x.length===0)throw new o(n.name,l,_,y.entityId);r.slug=d(x)}throw new o(n.name,l,h,void 0,`Could not find a free path for a ${n.name} document near "${h}" (locale "${l}") after 50 attempts — choose a different slug manually.`)}function d(e){let t=/^(.*)-(\d+)$/.exec(e);return t?`${t[1]??e}-${Number.parseInt(t[2]??`1`,10)+1}`:`${e}-2`}async function f(e){let{pathsClient:t,locale:n,path:r,logger:i}=e,a=await t.findMany({where:{locale:n,path:r},orderBy:[{column:`updatedAt`,dir:`desc`}],limit:10});for(let e of a){let r=await t.findOne({entityType:e.entityType,entityId:e.entityId,locale:n,isCanonical:!0});if(r)return{entityType:e.entityType,entityId:e.entityId,currentPath:r.path}}return a.length===10&&i?.warn?.({locale:n,path:r,examined:a.length},`toolkit_paths: redirect lookup examined the full candidate window without resolving one — answering 404, but a further candidate may exist`),null}const p=5;async function m(e,t,n,r){return(await r.pathsClient.findOne({entityType:e,entityId:t,locale:n,isCanonical:!0}))?.path??null}function h(e){return{async isPublished(t,n,r){let i=e.entityResolver(t);return i===void 0?!1:await e.queryClientFactory(i).findById(n,{locale:r})!=null}}}function g(e,t,n){return v(e,t,n,(e,t,r)=>n.publishChecker.isPublished(e,t,r))}function _(e,t,n){return v(e,t,n,async()=>!0)}async function v(e,t,n,r){let i=l(e),a=await n.homepageReader();if(i===`/`)return a===null||!await r(a.entity,a.id,t)?{kind:`none`}:{kind:`document`,entityType:a.entity,id:a.id};let o=await n.pathsClient.findOne({locale:t,path:i,isCanonical:!0});if(o)return await r(o.entityType,o.entityId,t)?S(a,o.entityType,o.entityId)?{kind:`redirect`,to:`/`,status:301}:{kind:`document`,entityType:o.entityType,id:o.entityId}:{kind:`none`};let s=await y(i,t,n,a);if(s!==null)return s;let c=await f({pathsClient:n.pathsClient,locale:t,path:i,...n.logger!==void 0&&{logger:n.logger}});return c===null||!await r(c.entityType,c.entityId,t)?{kind:`none`}:S(a,c.entityType,c.entityId)?{kind:`redirect`,to:`/`,status:301}:{kind:`redirect`,to:c.currentPath,status:301}}async function y(e,t,n,r){let i=await b(n.redirectsClient,e,t);if(i===null)return null;for(let e=0;e<5;e++){let e=i.target,a=C(i.status);if(e.kind===`url`)return{kind:`redirect`,to:e.url,status:a};if(e.kind===`entity`){if(S(r,e.entity,e.id))return{kind:`redirect`,to:`/`,status:a};let i=await m(e.entity,e.id,t,n);return i===null?{kind:`none`}:{kind:`redirect`,to:i,status:a}}let o=l(e.path),s=await b(n.redirectsClient,o,t);if(s!==null){i=s;continue}let c=await n.pathsClient.findOne({locale:t,path:o,isCanonical:!0});return c?S(r,c.entityType,c.entityId)?{kind:`redirect`,to:`/`,status:301}:{kind:`redirect`,to:o,status:a}:{kind:`none`}}return{kind:`none`}}async function b(e,t,n){return await e.findOne({fromPath:t,locale:n})||e.findOne({fromPath:t,locale:null})}async function x(e,t,n){let r=[];for(let i of n.locales){let a=await m(e,t,i,n);a!==null&&await n.publishChecker.isPublished(e,t,i)&&r.push({locale:i,path:a})}return r}function S(e,t,n){return e!==null&&e.entity===t&&e.id===n}function C(e){return e===302?302:301}const w=i({name:`toolkit_redirects`,columns:{id:r.uuid({primaryKey:!0,defaultRandom:!0}),locale:r.varchar({length:10}),fromPath:r.text({notNull:!0,pgName:`from_path`}),target:r.jsonb({notNull:!0}),status:r.integer({notNull:!0,default:301}),createdBy:r.varchar({length:255,pgName:`created_by`}),createdAt:r.timestamp({notNull:!0,defaultNow:!0,withTimezone:!0,pgName:`created_at`}),updatedAt:r.timestamp({notNull:!0,defaultNow:!0,withTimezone:!0,pgName:`updated_at`})},unique:[{on:[`locale`,`fromPath`],where:e=>a`${e.locale} IS NOT NULL`},{on:[`fromPath`],where:e=>a`${e.locale} IS NULL`}]}),T=w.table;export{x as a,g as c,l as d,h as i,c as l,w as n,m as o,p as r,_ as s,T as t,u};
2
- //# sourceMappingURL=schema-0zayzpD_.mjs.map
@@ -1,2 +0,0 @@
1
- import{matchReservedPrefix as e}from"@murumets-ee/entity";import{column as t,defineTable as n}from"@murumets-ee/db";import{sql as r}from"drizzle-orm";function i(e){let t=e;for(let e=0;t&&e<5;e++){if(t.code===`23505`)return!0;t=t.cause}return!1}const a=new Set([`not-routable`,`parent-not-indexed`,`row-unreadable`]);async function o(t){let{pathsClient:n,entityType:r,entityId:o,locale:c,path:l,reservedPathPrefixes:u,logger:d}=t;if(l===null){let e=t.skipReason??null,n=e!==null&&(t.suppressNoteworthy?.includes(e)??!1);return e!==null&&!n&&a.has(e)&&d.warn({entity:r,id:o,locale:c,skipReason:e},`toolkit_paths: no canonical path computed for this (document, locale) — ${e}. The document has no toolkit_paths row and will 404 on the public site until this is resolved (fix the input and re-save, or run \`lumi paths:rebuild\`).`),{outcome:`skipped-null`,oldPath:null,newPath:null,conflictWith:null,skipReason:e}}let f=e(l,u);if(f!==void 0)return d.warn({entity:r,id:o,locale:c,path:l,reserved:f},`toolkit_paths: computed path falls under a reserved prefix — skipping this locale (kept out of the routable index, see F013)`),{outcome:`skipped-reserved`,oldPath:null,newPath:l,conflictWith:null,skipReason:null};let p=await n.findOne({entityType:r,entityId:o,locale:c,isCanonical:!0}),m=p?.path??null;if(p!==null&&p.path===l)return{outcome:`unchanged`,oldPath:m,newPath:l,conflictWith:null,skipReason:null};if(t.dryRun){let e=await n.findOne({locale:c,path:l,isCanonical:!0});return e!==null&&!(e.entityType===r&&e.entityId===o)?{outcome:`conflict`,oldPath:m,newPath:l,conflictWith:{entityType:e.entityType,entityId:e.entityId},skipReason:null}:{outcome:p===null?`created`:`renamed`,oldPath:m,newPath:l,conflictWith:null,skipReason:null}}p!==null&&await n.update({id:p.id},{isCanonical:!1,updatedAt:new Date});let h=!1;try{await n.insert({entityType:r,entityId:o,locale:c,path:l,isCanonical:!0}),h=!0}catch(e){if(!i(e))throw e}if(!h){if(p!==null)try{await n.update({id:p.id},{isCanonical:!0,updatedAt:new Date})}catch(e){if(!i(e))throw e;d.warn({entity:r,id:o,locale:c,path:l},`toolkit_paths: could not re-promote the old canonical after a failed insert — a canonical row already exists for this document+locale (concurrent write); leaving history as-is`)}let e=await n.findOne({locale:c,path:l,isCanonical:!0}),t=e!==null&&!(e.entityType===r&&e.entityId===o)?{entityType:e.entityType,entityId:e.entityId}:null;return d.warn({entity:r,id:o,locale:c,path:l},`toolkit_paths: canonical (locale, path) already claimed by another document — skipping (old canonical restored / left in place)`),{outcome:`conflict`,oldPath:m,newPath:l,conflictWith:t,skipReason:null}}return p===null?{outcome:`created`,oldPath:null,newPath:l,conflictWith:null,skipReason:null}:(await s(n,r,o,c),{outcome:`renamed`,oldPath:m,newPath:l,conflictWith:null,skipReason:null})}async function s(e,t,n,r){let i=await e.count({entityType:t,entityId:n,locale:r,isCanonical:!1})-25;if(i<=0)return;let a=(await e.findMany({where:{entityType:t,entityId:n,locale:r,isCanonical:!1},orderBy:[{column:`updatedAt`,dir:`asc`},{column:`createdAt`,dir:`asc`}],limit:Math.min(i,1e3)})).map(e=>e.id);a.length>0&&await e.deleteMany({id:{in:a}})}async function c(e,t,n){let r=e.length;if(r===0)return;let i=0,a=Math.max(1,Math.min(Number.isFinite(t)?Math.floor(t):1,r));async function o(){for(;;){let t=i;if(i+=1,t>=r)return;await n(e[t],t)}}await Promise.all(Array.from({length:a},()=>o()))}const l=n({name:`toolkit_paths`,columns:{id:t.uuid({primaryKey:!0,defaultRandom:!0}),entityType:t.varchar({length:100,notNull:!0,pgName:`entity_type`}),entityId:t.uuid({notNull:!0,pgName:`entity_id`}),locale:t.varchar({length:10,notNull:!0}),path:t.text({notNull:!0}),isCanonical:t.boolean({notNull:!0,default:!0,pgName:`is_canonical`}),createdAt:t.timestamp({notNull:!0,defaultNow:!0,withTimezone:!0,pgName:`created_at`}),updatedAt:t.timestamp({notNull:!0,defaultNow:!0,withTimezone:!0,pgName:`updated_at`})},unique:[{on:[`locale`,`path`],where:e=>r`${e.isCanonical} = true`},{on:[`entityType`,`entityId`,`locale`],where:e=>r`${e.isCanonical} = true`}],indexes:[{on:[`entityType`,`entityId`,`locale`]}]}),u=l.table;export{c as a,i,l as n,o as r,u as t};
2
- //# sourceMappingURL=schema-D8sBQozw.mjs.map