@murumets-ee/content 0.84.0 → 0.86.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chrome-provider.mjs +1 -1
- package/dist/client.mjs +1 -1
- package/dist/client.mjs.map +1 -1
- package/dist/context-D7eEItCY.mjs +2 -0
- package/dist/context-D7eEItCY.mjs.map +1 -0
- package/dist/entities/index.mjs +1 -1
- package/dist/index.mjs +1 -1
- package/dist/plugin-D0i6odXQ.mjs +2 -0
- package/dist/plugin-D0i6odXQ.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/dist/routing-strategy-CQdu1qV7.mjs +2 -0
- package/dist/routing-strategy-CQdu1qV7.mjs.map +1 -0
- package/dist/routing-strategy-D3Qowm3D.d.mts.map +1 -1
- package/dist/seed.mjs +1 -1
- package/dist/site-chrome-DvP9SXGu.mjs +2 -0
- package/dist/{site-chrome-BUEOhmyf.mjs.map → site-chrome-DvP9SXGu.mjs.map} +1 -1
- package/dist/{versions-BDxB6k_S.mjs → versions-C9A4rUAR.mjs} +2 -2
- package/dist/versions-C9A4rUAR.mjs.map +1 -0
- package/package.json +9 -9
- package/dist/context-Bw0n6P47.mjs +0 -2
- package/dist/context-Bw0n6P47.mjs.map +0 -1
- package/dist/plugin-BxOHoKPR.mjs +0 -2
- package/dist/plugin-BxOHoKPR.mjs.map +0 -1
- package/dist/routing-strategy-DQGPPBlW.mjs +0 -2
- package/dist/routing-strategy-DQGPPBlW.mjs.map +0 -1
- package/dist/site-chrome-BUEOhmyf.mjs +0 -2
- package/dist/versions-BDxB6k_S.mjs.map +0 -1
|
@@ -0,0 +1 @@
|
|
|
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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"}
|
package/dist/plugin.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{a as e,i as t,n,o as r,r as i,t as a}from"./routing-strategy-
|
|
1
|
+
import{a as e,i as t,n,o as r,r as i,t as a}from"./routing-strategy-CQdu1qV7.mjs";import{t as o}from"./content-config-store-8omQF5p5.mjs";import{t as s}from"./plugin-D0i6odXQ.mjs";export{t as Locale,i as Message,e as ROUTING_STRATEGIES,s as content,o as getContentConfig,r as i18nSettings,a as localePrefixForStrategy,n as prefixDefaultLocaleForStrategy};
|
|
@@ -0,0 +1,2 @@
|
|
|
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
|
|
@@ -0,0 +1 @@
|
|
|
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 +1 @@
|
|
|
1
|
-
{"version":3,"file":"routing-strategy-D3Qowm3D.d.mts","names":[],"sources":["../src/i18n-settings.ts","../src/locale-entity.ts","../src/message-entity.ts","../src/routing-strategy.ts"],"mappings":";;;;;;;;AAiBA;;;;AAAwF;AAExF;;;;;cAFa,kBAAA;AAAA,cAEA,YAAA,kCAAY,kBAAA;;;;;;;;;;;ACQzB;;;;;;;;;;;;;ADVA;;;;AAAwF;AAExF;;;;;;;;;;;;;;;cCQa,MAAA,gCAAM,MAAA;;;
|
|
1
|
+
{"version":3,"file":"routing-strategy-D3Qowm3D.d.mts","names":[],"sources":["../src/i18n-settings.ts","../src/locale-entity.ts","../src/message-entity.ts","../src/routing-strategy.ts"],"mappings":";;;;;;;;AAiBA;;;;AAAwF;AAExF;;;;;cAFa,kBAAA;AAAA,cAEA,YAAA,kCAAY,kBAAA;;;;;;;;;;;ACQzB;;;;;;;;;;;;;ADVA;;;;AAAwF;AAExF;;;;;;;;;;;;;;;cCQa,MAAA,gCAAM,MAAA;;;EA0CjB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cCTW,OAAA,gCAAO,MAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBClCJ,uBAAA,CACd,QAAyB,EAAf,eAAe;;AFA3B;;;;iBEiBgB,8BAAA,CAA+B,QAAyB,EAAf,eAAe"}
|
package/dist/seed.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{t as e}from"./chrome-entity-names-zYJzP-_U.mjs";import{i as t,n}from"./schema-D8sBQozw.mjs";import{n as r,t as i}from"./site-chrome-
|
|
1
|
+
import{t as e}from"./chrome-entity-names-zYJzP-_U.mjs";import{i as t,n}from"./schema-D8sBQozw.mjs";import{n as r,t as i}from"./site-chrome-DvP9SXGu.mjs";import{getApp as a}from"@murumets-ee/core";import{eq as o}from"drizzle-orm";import{createAdminClient as s}from"@murumets-ee/core/clients";import{randomUUID as c}from"node:crypto";const l={header:{handle:`main`},footer:{handle:`footer`}};function u(e){return[{id:c(),type:`menu`,props:{handle:l[e].handle,orientation:`horizontal`},traits:{}}]}async function d(){let n=a().entities.get(e);if(!n)return{created:0};let c=s(n),l=0,d=c.getTable().placement;if(!d)throw Error(`${e} has no "placement" column — the chrome entity registered by this app is not the one @murumets-ee/content ships.`);for(let e of i)if(!((await c.findMany({where:o(d,e),limit:1})).length>0))try{await c.create({placement:e,label:r[e],layout:u(e)}),l++}catch(e){if(!t(e))throw e}return{created:l}}async function f(e){let t=n.makeClient(a().db.readWrite),r={};for(let n of e)r[n]=(await t.deleteMany({entityType:n})).length;return r}export{f as forgetEntityPaths,d as seedSiteChrome};
|
|
2
2
|
//# sourceMappingURL=seed.mjs.map
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{n as e}from"./versionable-Br_G5VGR.mjs";import{auditable as t,defineEntity as n,field as r,publishable as i}from"@murumets-ee/entity";const a=[`header`,`footer`],o={header:`Site header`,footer:`Site footer`},s={label:{label:`Name`,description:`Shown in the admin list and as the editor title. Visitors never see it.`}};function c(e){let t={...s};for(let[n,r]of Object.entries(e??{}))t[n]={...t[n],...r};return t}function l(o){let s=o?.viewRole??`editor`,l=o?.updateRole??`editor`,u=o?.adminRole??`admin`,d=o?.maxVersions??30;return n({name:`site_chrome`,site:`bound`,kind:`collection`,fields:{placement:r.select({options:a,required:!0,unique:!0}),label:r.text({required:!0}),layout:r.blockTree({translatable:!0,...o?.environment!==void 0&&{environment:o.environment}})},behaviors:[t(),i(),e({maxPerDoc:d}),...o?.behaviors??[]],scope:`global`,access:{view:s,create:u,update:l,delete:u},admin:{icon:`panel-top`,description:`Blocks rendered around every page that does not override them.`,rootFields:[`label`],fieldOverrides:c(o?.admin?.fieldOverrides),titleField:`label`,group:`Site`,disableCreate:!0}})}l().name;export{o as n,l as r,a as t};
|
|
2
|
+
//# sourceMappingURL=site-chrome-DvP9SXGu.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"site-chrome-BUEOhmyf.mjs","names":[],"sources":["../src/entities/site-chrome.ts"],"sourcesContent":["/**\n * `site_chrome` — the block documents that hold the site chrome, one row per\n * PLACEMENT, so a header is edited on the same canvas as the page it sits\n * above instead of being typed into a JSON textarea.\n *\n * ## Placement, not type\n *\n * There is ONE entity. A chrome document says where it appears by carrying a\n * `placement` value, and `'header'` / `'footer'` are exactly the region keys\n * `GET /v1/_chrome` emits — so the provider maps placement to region by\n * identity rather than through a lookup table. That is the whole of the model.\n *\n * It replaces three entities (`site_header`, `site_footer`, and a single-row\n * site layout) and six hardcoded name lists, the most consequential of which\n * was not a list of names at all: `apps/admin/lib/chrome-documents.ts` carried\n * a module constant pairing each chrome entity with a\n * `position: 'before' | 'after'`. That was the placement flag, written in code,\n * in the app, where no operator could reach it and no plugin could extend it.\n *\n * ## What survives, and why it had to\n *\n * **A separate ROW per placement.** `publishable()` and `versionable()` are\n * per-row, so a header and a footer sharing one row could not be published or\n * rolled back independently. That constraint is real and this model honours\n * it — it is only the entity TYPE that collapses, never the row.\n *\n * **Peer-document editing.** The editor composes header, page and footer as\n * peers with per-document undo (`plan/site-chrome` D001 / D003). It never\n * keyed on the entity NAME to do that: `DocumentSlice.entity` is the only\n * discriminator and `pageDocumentId` is explicit state, precisely so that no\n * entity-name allowlist is needed. One name in place of two costs it nothing.\n *\n * ## Cardinality — why `kind: 'collection'` and a unique column\n *\n * `kind: 'singleton'` is enforced now (`plan/site-chrome` D011), and\n * `assertSingletonSlotFree` counts every live row of an entity — so an entity\n * holding a header row AND a footer row trips the gate on the second insert.\n * The kind and the collapse are mutually exclusive, which is why phase 09 lists\n * \"the singleton kind\" among the things that go.\n *\n * `placement`'s `unique: true` is what takes over the half that mattered: the\n * rule becomes one row per placement, enforced at the DATABASE for every\n * writer rather than in one client. On a `trashable()` entity — which this is,\n * by auto-enable, because it is publishable — the schema builder re-emits that\n * as a PARTIAL unique index (`WHERE \"deletedAt\" IS NULL`), so trashing a\n * header frees its placement for a replacement instead of wedging it.\n *\n * What is NOT carried over is the singleton DELETE rule (\"never leave zero\").\n * Deleting the only header is now possible, and the site falls back to the\n * delivery template's own markup — the same thing it does before the first\n * header is ever created. `trashable()` makes it recoverable.\n *\n * ## `admin.disableCreate` is correctness, not a workaround\n *\n * The block-editor create branch auto-POSTs `{ title: 'Untitled' }` and\n * nothing else, so any entity on that branch with a second required field\n * would reject on `/new`. `placement` is exactly such a field.\n *\n * Suppressing the affordance is also the honest answer: a third chrome row\n * would render NOWHERE, because the delivery template has exactly two chrome\n * slots. A new placement is a code change on both sides — a `placement` option\n * here and a slot in `BaseLayout.astro` — and that coupling is what keeps the\n * enum from accumulating values nothing renders.\n *\n * The two rows a site needs are created by the `content:site-chrome` seeder,\n * which `lumi migrate` runs automatically as a `bootstrap` seeder. Nobody has\n * to press anything.\n *\n * ## Wiring\n *\n * @example\n * ```ts\n * // apps/<app>/entities/site-chrome.ts\n * import { withOpLog } from '@murumets-ee/blocks/server'\n * import { siteChromeEntity } from '@murumets-ee/content/entities'\n *\n * export const SiteChrome = siteChromeEntity({ environment: 'page' })\n * .with(withOpLog()) // see below — the app layers this on\n * ```\n *\n * `withOpLog()` is deliberately NOT in the factory: it ships from\n * `@murumets-ee/blocks/server`, and `@murumets-ee/content` must not depend on\n * `@murumets-ee/blocks` (blocks depends back on content). The app layers it at\n * config time. **Without it the block editor 404s** — `editor-state` refuses a\n * document whose entity has no op-log behavior — so an app that registers this\n * and skips it gets a chrome document it cannot open.\n *\n * Behaviors the factory does apply:\n *\n * - `auditable()` — created/updated stamps\n * - `publishable()` — a header is drafted and published like any document,\n * which is the point of it being one\n * - `versionable({ maxPerDoc })` — per-locale history\n */\n\nimport type { Behavior, EntityAdminConfig } from '@murumets-ee/entity'\nimport { auditable, defineEntity, field, publishable } from '@murumets-ee/entity'\nimport { versionable } from '../behaviors/versionable.js'\n\n/**\n * Where a chrome document appears on every page that does not override it.\n *\n * These strings ARE the `/v1/_chrome` region keys and the delivery template's\n * slot names. Adding one means adding a slot to `BaseLayout.astro` in the same\n * change, or the value names a region nothing renders.\n */\nexport const CHROME_PLACEMENTS = ['header', 'footer'] as const\n\n/** One placement. Also a region name on the wire — same string, by design. */\nexport type ChromePlacement = (typeof CHROME_PLACEMENTS)[number]\n\n/** The default label a bootstrap row gets, per placement. */\nexport const CHROME_PLACEMENT_LABELS: Readonly<Record<ChromePlacement, string>> = {\n header: 'Site header',\n footer: 'Site footer',\n}\n\n/** Optional overrides apps can apply when calling the factory. */\nexport interface SiteChromeEntityOptions {\n /**\n * Roles allowed to read the document. Default `'editor'`.\n *\n * ADVISORY entity-level metadata that does NOT govern the public content API\n * — that path resolves a role and asks `buildPermissionChecker`.\n * `site_chrome:view` is seeded onto `public` by\n * `buildInitialRoleDefinitions()` so a fresh install serves its chrome;\n * changing this option does not change that.\n */\n viewRole?: string\n /** Roles allowed to edit a document's blocks. Default `'editor'`. */\n updateRole?: string\n /** Roles allowed to create/delete a chrome row. Default `'admin'`. */\n adminRole?: string\n /**\n * The block environment the editor offers in this document's canvas. Pass\n * the same value the site's page entity uses so a header can be built from\n * the same vocabulary. Omitted means \"no environment restriction\".\n */\n environment?: string\n /** Override the version-retention cap. Default 30 versions per locale. */\n maxVersions?: number\n /**\n * Extra behaviors layered after the factory's own.\n *\n * This is how an app supplies `withOpLog()`, which is REQUIRED for the block\n * editor to open the document and which cannot live in this package —\n * `withOpLog` ships from `@murumets-ee/blocks/server`, and\n * `@murumets-ee/content` must not depend on `@murumets-ee/blocks` because\n * blocks depends back on content.\n *\n * Typed as field-contributing-nothing on purpose: the entity's inferred field\n * map is part of its public type, and a behavior that added fields here would\n * widen it silently. `withOpLog()` adds none, which its own docblock states.\n */\n behaviors?: readonly Behavior<Record<never, never>, string>[]\n /**\n * How the Page-settings tab and forms NAME this entity's fields. Merged per\n * field over the factory's English defaults, so a project adds only what it\n * translates — typically `labelKey` / `descriptionKey` into its OWN catalogue\n * (e.g. `{ label: { labelKey: 'SiteChrome.fields.label', descriptionKey:\n * 'SiteChrome.fields.labelHelp' } }`). Resolution is key → English label →\n * humanized name, never the raw field name (editor-curation D003).\n */\n admin?: {\n fieldOverrides?: EntityAdminConfig['fieldOverrides']\n }\n}\n\n/**\n * The English defaults every project gets. `label` is the only root field the\n * Page-settings tab offers (`rootFields` below), so it is the one an operator\n * reads; without this it humanized to \"Label\", untranslatable.\n */\nconst DEFAULT_FIELD_OVERRIDES: NonNullable<EntityAdminConfig['fieldOverrides']> = {\n label: {\n label: 'Name',\n description: 'Shown in the admin list and as the editor title. Visitors never see it.',\n },\n}\n\n/** Per-field shallow merge — a project's key never drops the default English fallback. */\nfunction mergeFieldOverrides(\n project: EntityAdminConfig['fieldOverrides'],\n): NonNullable<EntityAdminConfig['fieldOverrides']> {\n const merged: NonNullable<EntityAdminConfig['fieldOverrides']> = { ...DEFAULT_FIELD_OVERRIDES }\n for (const [name, override] of Object.entries(project ?? {})) {\n merged[name] = { ...merged[name], ...override }\n }\n return merged\n}\n\n/**\n * Build the `site_chrome` entity — the block documents rendered around every\n * page that does not override them.\n *\n * The return type is intentionally INFERRED (no annotation) so `'site_chrome'`\n * stays a string literal in the caller's `Entity<…, 'site_chrome', …>` view;\n * annotating it as bare `Entity` widens the name to `string`, which propagates\n * into the resolved-permissions union as a `${string}:view` template. The\n * compile-time guard at the foot of this file is what keeps that true.\n */\nexport function siteChromeEntity(options?: SiteChromeEntityOptions) {\n const viewRole = options?.viewRole ?? 'editor'\n const updateRole = options?.updateRole ?? 'editor'\n const adminRole = options?.adminRole ?? 'admin'\n const maxVersions = options?.maxVersions ?? 30\n\n return defineEntity({\n name: 'site_chrome',\n // NOT `'singleton'` — see the cardinality note in this file's header. One\n // row per placement, enforced by `placement`'s own unique constraint.\n kind: 'collection',\n fields: {\n // THE placement flag. Deliberately not translatable: where a document\n // appears is a property of the document, not of a translation of it.\n //\n // `unique: true` is load-bearing rather than decorative — it is the\n // whole cardinality rule. `indexed` is NOT set alongside it: the schema\n // builder skips indexing unique fields because the constraint already\n // emits its own btree.\n placement: field.select({\n options: CHROME_PLACEMENTS,\n required: true,\n unique: true,\n }),\n // The human name of the row, shown in the list and as the editor's\n // title. `titleField` below points at it explicitly so nothing has to\n // fall back to a heuristic — or to a `title` column this entity has\n // never had.\n label: field.text({ required: true }),\n // The ONE blockTree field, which is what makes this a canvas.\n //\n // `translatable: true`, load-bearing: without it the column lives on the\n // base row, so writing the English header overwrites the Estonian one\n // and every locale then serves English. The write succeeds; only a\n // per-locale READ reveals it.\n //\n // `translation: 'shared'` is not an option — the schema builder throws on\n // it for blockTree — so each locale's chrome is an independent tree. That\n // matches the editor, whose session id is `${id}:${locale}`.\n layout: field.blockTree({\n translatable: true,\n ...(options?.environment !== undefined && { environment: options.environment }),\n }),\n },\n behaviors: [\n auditable(),\n publishable(),\n versionable({ maxPerDoc: maxVersions }),\n ...(options?.behaviors ?? []),\n ],\n scope: 'global',\n access: {\n view: viewRole,\n // Restricts WHO may create; `placement`'s unique constraint restricts\n // HOW MANY. Both apply, and neither substitutes for the other.\n create: adminRole,\n update: updateRole,\n delete: adminRole,\n },\n admin: {\n icon: 'panel-top',\n description: 'Blocks rendered around every page that does not override them.',\n // `layout` is a blockTree and is excluded from forms by type, so this is\n // only about what the block editor's settings sheet offers. `placement`\n // is deliberately absent: moving a header to the footer slot by editing\n // a dropdown is not an operation anyone wants, and it would collide with\n // the row already holding that placement.\n rootFields: ['label'],\n fieldOverrides: mergeFieldOverrides(options?.admin?.fieldOverrides),\n titleField: 'label',\n group: 'Site',\n // See this file's header — the rows are bootstrapped, not hand-created.\n disableCreate: true,\n },\n })\n}\n\n/**\n * COMPILE-TIME guard for the inferred-return-type note above.\n *\n * It lives in `src/`, NOT in `tests/`, and that placement is the point: this\n * package's tsconfig includes `src` only, so a type-level assertion under\n * `tests/` is compiled by no gate at all — vitest transpiles without checking\n * types. A guard nobody compiles is worse than no guard, because it reads as\n * one.\n *\n * Negative-tested: annotating `siteChromeEntity`'s return as bare `Entity`\n * makes this line fail `tsc --noEmit` with TS2322.\n */\nconst _chromeNameStaysLiteral: 'site_chrome' = siteChromeEntity().name\nvoid _chromeNameStaysLiteral\n"],"mappings":"6IA0GA,MAAa,EAAoB,CAAC,SAAU,QAAQ,EAMvC,EAAqE,CAChF,OAAQ,cACR,OAAQ,aACV,EA0DM,EAA4E,CAChF,MAAO,CACL,MAAO,OACP,YAAa,yEACf,CACF,EAGA,SAAS,EACP,EACkD,CAClD,IAAM,EAA2D,CAAE,GAAG,CAAwB,EAC9F,IAAK,GAAM,CAAC,EAAM,KAAa,OAAO,QAAQ,GAAW,CAAC,CAAC,EACzD,EAAO,GAAQ,CAAE,GAAG,EAAO,GAAO,GAAG,CAAS,EAEhD,OAAO,CACT,CAYA,SAAgB,EAAiB,EAAmC,CAClE,IAAM,EAAW,GAAS,UAAY,SAChC,EAAa,GAAS,YAAc,SACpC,EAAY,GAAS,WAAa,QAClC,EAAc,GAAS,aAAe,GAE5C,OAAO,EAAa,CAClB,KAAM,cAGN,KAAM,aACN,OAAQ,CAQN,UAAW,EAAM,OAAO,CACtB,QAAS,EACT,SAAU,GACV,OAAQ,EACV,CAAC,EAKD,MAAO,EAAM,KAAK,CAAE,SAAU,EAAK,CAAC,EAWpC,OAAQ,EAAM,UAAU,CACtB,aAAc,GACd,GAAI,GAAS,cAAgB,IAAA,IAAa,CAAE,YAAa,EAAQ,WAAY,CAC/E,CAAC,CACH,EACA,UAAW,CACT,EAAU,EACV,EAAY,EACZ,EAAY,CAAE,UAAW,CAAY,CAAC,EACtC,GAAI,GAAS,WAAa,CAAC,CAC7B,EACA,MAAO,SACP,OAAQ,CACN,KAAM,EAGN,OAAQ,EACR,OAAQ,EACR,OAAQ,CACV,EACA,MAAO,CACL,KAAM,YACN,YAAa,iEAMb,WAAY,CAAC,OAAO,EACpB,eAAgB,EAAoB,GAAS,OAAO,cAAc,EAClE,WAAY,QACZ,MAAO,OAEP,cAAe,EACjB,CACF,CAAC,CACH,CAc+C,EAAiB,CAAC,CAAC"}
|
|
1
|
+
{"version":3,"file":"site-chrome-DvP9SXGu.mjs","names":[],"sources":["../src/entities/site-chrome.ts"],"sourcesContent":["/**\n * `site_chrome` — the block documents that hold the site chrome, one row per\n * PLACEMENT, so a header is edited on the same canvas as the page it sits\n * above instead of being typed into a JSON textarea.\n *\n * ## Placement, not type\n *\n * There is ONE entity. A chrome document says where it appears by carrying a\n * `placement` value, and `'header'` / `'footer'` are exactly the region keys\n * `GET /v1/_chrome` emits — so the provider maps placement to region by\n * identity rather than through a lookup table. That is the whole of the model.\n *\n * It replaces three entities (`site_header`, `site_footer`, and a single-row\n * site layout) and six hardcoded name lists, the most consequential of which\n * was not a list of names at all: `apps/admin/lib/chrome-documents.ts` carried\n * a module constant pairing each chrome entity with a\n * `position: 'before' | 'after'`. That was the placement flag, written in code,\n * in the app, where no operator could reach it and no plugin could extend it.\n *\n * ## What survives, and why it had to\n *\n * **A separate ROW per placement.** `publishable()` and `versionable()` are\n * per-row, so a header and a footer sharing one row could not be published or\n * rolled back independently. That constraint is real and this model honours\n * it — it is only the entity TYPE that collapses, never the row.\n *\n * **Peer-document editing.** The editor composes header, page and footer as\n * peers with per-document undo (`plan/site-chrome` D001 / D003). It never\n * keyed on the entity NAME to do that: `DocumentSlice.entity` is the only\n * discriminator and `pageDocumentId` is explicit state, precisely so that no\n * entity-name allowlist is needed. One name in place of two costs it nothing.\n *\n * ## Cardinality — why `kind: 'collection'` and a unique column\n *\n * `kind: 'singleton'` is enforced now (`plan/site-chrome` D011), and\n * `assertSingletonSlotFree` counts every live row of an entity — so an entity\n * holding a header row AND a footer row trips the gate on the second insert.\n * The kind and the collapse are mutually exclusive, which is why phase 09 lists\n * \"the singleton kind\" among the things that go.\n *\n * `placement`'s `unique: true` is what takes over the half that mattered: the\n * rule becomes one row per placement, enforced at the DATABASE for every\n * writer rather than in one client. On a `trashable()` entity — which this is,\n * by auto-enable, because it is publishable — the schema builder re-emits that\n * as a PARTIAL unique index (`WHERE \"deletedAt\" IS NULL`), so trashing a\n * header frees its placement for a replacement instead of wedging it.\n *\n * What is NOT carried over is the singleton DELETE rule (\"never leave zero\").\n * Deleting the only header is now possible, and the site falls back to the\n * delivery template's own markup — the same thing it does before the first\n * header is ever created. `trashable()` makes it recoverable.\n *\n * ## `admin.disableCreate` is correctness, not a workaround\n *\n * The block-editor create branch auto-POSTs `{ title: 'Untitled' }` and\n * nothing else, so any entity on that branch with a second required field\n * would reject on `/new`. `placement` is exactly such a field.\n *\n * Suppressing the affordance is also the honest answer: a third chrome row\n * would render NOWHERE, because the delivery template has exactly two chrome\n * slots. A new placement is a code change on both sides — a `placement` option\n * here and a slot in `BaseLayout.astro` — and that coupling is what keeps the\n * enum from accumulating values nothing renders.\n *\n * The two rows a site needs are created by the `content:site-chrome` seeder,\n * which `lumi migrate` runs automatically as a `bootstrap` seeder. Nobody has\n * to press anything.\n *\n * ## Wiring\n *\n * @example\n * ```ts\n * // apps/<app>/entities/site-chrome.ts\n * import { withOpLog } from '@murumets-ee/blocks/server'\n * import { siteChromeEntity } from '@murumets-ee/content/entities'\n *\n * export const SiteChrome = siteChromeEntity({ environment: 'page' })\n * .with(withOpLog()) // see below — the app layers this on\n * ```\n *\n * `withOpLog()` is deliberately NOT in the factory: it ships from\n * `@murumets-ee/blocks/server`, and `@murumets-ee/content` must not depend on\n * `@murumets-ee/blocks` (blocks depends back on content). The app layers it at\n * config time. **Without it the block editor 404s** — `editor-state` refuses a\n * document whose entity has no op-log behavior — so an app that registers this\n * and skips it gets a chrome document it cannot open.\n *\n * Behaviors the factory does apply:\n *\n * - `auditable()` — created/updated stamps\n * - `publishable()` — a header is drafted and published like any document,\n * which is the point of it being one\n * - `versionable({ maxPerDoc })` — per-locale history\n */\n\nimport type { Behavior, EntityAdminConfig } from '@murumets-ee/entity'\nimport { auditable, defineEntity, field, publishable } from '@murumets-ee/entity'\nimport { versionable } from '../behaviors/versionable.js'\n\n/**\n * Where a chrome document appears on every page that does not override it.\n *\n * These strings ARE the `/v1/_chrome` region keys and the delivery template's\n * slot names. Adding one means adding a slot to `BaseLayout.astro` in the same\n * change, or the value names a region nothing renders.\n */\nexport const CHROME_PLACEMENTS = ['header', 'footer'] as const\n\n/** One placement. Also a region name on the wire — same string, by design. */\nexport type ChromePlacement = (typeof CHROME_PLACEMENTS)[number]\n\n/** The default label a bootstrap row gets, per placement. */\nexport const CHROME_PLACEMENT_LABELS: Readonly<Record<ChromePlacement, string>> = {\n header: 'Site header',\n footer: 'Site footer',\n}\n\n/** Optional overrides apps can apply when calling the factory. */\nexport interface SiteChromeEntityOptions {\n /**\n * Roles allowed to read the document. Default `'editor'`.\n *\n * ADVISORY entity-level metadata that does NOT govern the public content API\n * — that path resolves a role and asks `buildPermissionChecker`.\n * `site_chrome:view` is seeded onto `public` by\n * `buildInitialRoleDefinitions()` so a fresh install serves its chrome;\n * changing this option does not change that.\n */\n viewRole?: string\n /** Roles allowed to edit a document's blocks. Default `'editor'`. */\n updateRole?: string\n /** Roles allowed to create/delete a chrome row. Default `'admin'`. */\n adminRole?: string\n /**\n * The block environment the editor offers in this document's canvas. Pass\n * the same value the site's page entity uses so a header can be built from\n * the same vocabulary. Omitted means \"no environment restriction\".\n */\n environment?: string\n /** Override the version-retention cap. Default 30 versions per locale. */\n maxVersions?: number\n /**\n * Extra behaviors layered after the factory's own.\n *\n * This is how an app supplies `withOpLog()`, which is REQUIRED for the block\n * editor to open the document and which cannot live in this package —\n * `withOpLog` ships from `@murumets-ee/blocks/server`, and\n * `@murumets-ee/content` must not depend on `@murumets-ee/blocks` because\n * blocks depends back on content.\n *\n * Typed as field-contributing-nothing on purpose: the entity's inferred field\n * map is part of its public type, and a behavior that added fields here would\n * widen it silently. `withOpLog()` adds none, which its own docblock states.\n */\n behaviors?: readonly Behavior<Record<never, never>, string>[]\n /**\n * How the Page-settings tab and forms NAME this entity's fields. Merged per\n * field over the factory's English defaults, so a project adds only what it\n * translates — typically `labelKey` / `descriptionKey` into its OWN catalogue\n * (e.g. `{ label: { labelKey: 'SiteChrome.fields.label', descriptionKey:\n * 'SiteChrome.fields.labelHelp' } }`). Resolution is key → English label →\n * humanized name, never the raw field name (editor-curation D003).\n */\n admin?: {\n fieldOverrides?: EntityAdminConfig['fieldOverrides']\n }\n}\n\n/**\n * The English defaults every project gets. `label` is the only root field the\n * Page-settings tab offers (`rootFields` below), so it is the one an operator\n * reads; without this it humanized to \"Label\", untranslatable.\n */\nconst DEFAULT_FIELD_OVERRIDES: NonNullable<EntityAdminConfig['fieldOverrides']> = {\n label: {\n label: 'Name',\n description: 'Shown in the admin list and as the editor title. Visitors never see it.',\n },\n}\n\n/** Per-field shallow merge — a project's key never drops the default English fallback. */\nfunction mergeFieldOverrides(\n project: EntityAdminConfig['fieldOverrides'],\n): NonNullable<EntityAdminConfig['fieldOverrides']> {\n const merged: NonNullable<EntityAdminConfig['fieldOverrides']> = { ...DEFAULT_FIELD_OVERRIDES }\n for (const [name, override] of Object.entries(project ?? {})) {\n merged[name] = { ...merged[name], ...override }\n }\n return merged\n}\n\n/**\n * Build the `site_chrome` entity — the block documents rendered around every\n * page that does not override them.\n *\n * The return type is intentionally INFERRED (no annotation) so `'site_chrome'`\n * stays a string literal in the caller's `Entity<…, 'site_chrome', …>` view;\n * annotating it as bare `Entity` widens the name to `string`, which propagates\n * into the resolved-permissions union as a `${string}:view` template. The\n * compile-time guard at the foot of this file is what keeps that true.\n */\nexport function siteChromeEntity(options?: SiteChromeEntityOptions) {\n const viewRole = options?.viewRole ?? 'editor'\n const updateRole = options?.updateRole ?? 'editor'\n const adminRole = options?.adminRole ?? 'admin'\n const maxVersions = options?.maxVersions ?? 30\n\n return defineEntity({\n name: 'site_chrome',\n // Each site has its own header and footer (multisite D002/D005): the\n // placement unique is per site, so every site holds one of each.\n site: 'bound',\n // NOT `'singleton'` — see the cardinality note in this file's header. One\n // row per placement, enforced by `placement`'s own unique constraint.\n kind: 'collection',\n fields: {\n // THE placement flag. Deliberately not translatable: where a document\n // appears is a property of the document, not of a translation of it.\n //\n // `unique: true` is load-bearing rather than decorative — it is the\n // whole cardinality rule. `indexed` is NOT set alongside it: the schema\n // builder skips indexing unique fields because the constraint already\n // emits its own btree.\n placement: field.select({\n options: CHROME_PLACEMENTS,\n required: true,\n unique: true,\n }),\n // The human name of the row, shown in the list and as the editor's\n // title. `titleField` below points at it explicitly so nothing has to\n // fall back to a heuristic — or to a `title` column this entity has\n // never had.\n label: field.text({ required: true }),\n // The ONE blockTree field, which is what makes this a canvas.\n //\n // `translatable: true`, load-bearing: without it the column lives on the\n // base row, so writing the English header overwrites the Estonian one\n // and every locale then serves English. The write succeeds; only a\n // per-locale READ reveals it.\n //\n // `translation: 'shared'` is not an option — the schema builder throws on\n // it for blockTree — so each locale's chrome is an independent tree. That\n // matches the editor, whose session id is `${id}:${locale}`.\n layout: field.blockTree({\n translatable: true,\n ...(options?.environment !== undefined && { environment: options.environment }),\n }),\n },\n behaviors: [\n auditable(),\n publishable(),\n versionable({ maxPerDoc: maxVersions }),\n ...(options?.behaviors ?? []),\n ],\n scope: 'global',\n access: {\n view: viewRole,\n // Restricts WHO may create; `placement`'s unique constraint restricts\n // HOW MANY. Both apply, and neither substitutes for the other.\n create: adminRole,\n update: updateRole,\n delete: adminRole,\n },\n admin: {\n icon: 'panel-top',\n description: 'Blocks rendered around every page that does not override them.',\n // `layout` is a blockTree and is excluded from forms by type, so this is\n // only about what the block editor's settings sheet offers. `placement`\n // is deliberately absent: moving a header to the footer slot by editing\n // a dropdown is not an operation anyone wants, and it would collide with\n // the row already holding that placement.\n rootFields: ['label'],\n fieldOverrides: mergeFieldOverrides(options?.admin?.fieldOverrides),\n titleField: 'label',\n group: 'Site',\n // See this file's header — the rows are bootstrapped, not hand-created.\n disableCreate: true,\n },\n })\n}\n\n/**\n * COMPILE-TIME guard for the inferred-return-type note above.\n *\n * It lives in `src/`, NOT in `tests/`, and that placement is the point: this\n * package's tsconfig includes `src` only, so a type-level assertion under\n * `tests/` is compiled by no gate at all — vitest transpiles without checking\n * types. A guard nobody compiles is worse than no guard, because it reads as\n * one.\n *\n * Negative-tested: annotating `siteChromeEntity`'s return as bare `Entity`\n * makes this line fail `tsc --noEmit` with TS2322.\n */\nconst _chromeNameStaysLiteral: 'site_chrome' = siteChromeEntity().name\nvoid _chromeNameStaysLiteral\n"],"mappings":"6IA0GA,MAAa,EAAoB,CAAC,SAAU,QAAQ,EAMvC,EAAqE,CAChF,OAAQ,cACR,OAAQ,aACV,EA0DM,EAA4E,CAChF,MAAO,CACL,MAAO,OACP,YAAa,yEACf,CACF,EAGA,SAAS,EACP,EACkD,CAClD,IAAM,EAA2D,CAAE,GAAG,CAAwB,EAC9F,IAAK,GAAM,CAAC,EAAM,KAAa,OAAO,QAAQ,GAAW,CAAC,CAAC,EACzD,EAAO,GAAQ,CAAE,GAAG,EAAO,GAAO,GAAG,CAAS,EAEhD,OAAO,CACT,CAYA,SAAgB,EAAiB,EAAmC,CAClE,IAAM,EAAW,GAAS,UAAY,SAChC,EAAa,GAAS,YAAc,SACpC,EAAY,GAAS,WAAa,QAClC,EAAc,GAAS,aAAe,GAE5C,OAAO,EAAa,CAClB,KAAM,cAGN,KAAM,QAGN,KAAM,aACN,OAAQ,CAQN,UAAW,EAAM,OAAO,CACtB,QAAS,EACT,SAAU,GACV,OAAQ,EACV,CAAC,EAKD,MAAO,EAAM,KAAK,CAAE,SAAU,EAAK,CAAC,EAWpC,OAAQ,EAAM,UAAU,CACtB,aAAc,GACd,GAAI,GAAS,cAAgB,IAAA,IAAa,CAAE,YAAa,EAAQ,WAAY,CAC/E,CAAC,CACH,EACA,UAAW,CACT,EAAU,EACV,EAAY,EACZ,EAAY,CAAE,UAAW,CAAY,CAAC,EACtC,GAAI,GAAS,WAAa,CAAC,CAC7B,EACA,MAAO,SACP,OAAQ,CACN,KAAM,EAGN,OAAQ,EACR,OAAQ,EACR,OAAQ,CACV,EACA,MAAO,CACL,KAAM,YACN,YAAa,iEAMb,WAAY,CAAC,OAAO,EACpB,eAAgB,EAAoB,GAAS,OAAO,cAAc,EAClE,WAAY,QACZ,MAAO,OAEP,cAAe,EACjB,CACF,CAAC,CACH,CAc+C,EAAiB,CAAC,CAAC"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{t as e}from"./rolldown-runtime-DK3Fl9T5.mjs";import{i as t,n}from"./errors-NRNILhhm.mjs";import{t as r}from"./content-config-store-8omQF5p5.mjs";import{n as i}from"./plugin-
|
|
2
|
-
//# sourceMappingURL=versions-
|
|
1
|
+
import{t as e}from"./rolldown-runtime-DK3Fl9T5.mjs";import{i as t,n}from"./errors-NRNILhhm.mjs";import{t as r}from"./content-config-store-8omQF5p5.mjs";import{n as i}from"./plugin-D0i6odXQ.mjs";import{t as a}from"./locale-utils-B0mFoV47.mjs";import{a as o,i as s}from"./context-D7eEItCY.mjs";import{schemaRegistry as c}from"@murumets-ee/db";import{eq as l}from"drizzle-orm";import"server-only";function u(e){return e.match(/\S+|\s+/g)??[]}function d(e,t){if(e===t)return e?[{type:`equal`,value:e}]:[];if(!e)return[{type:`add`,value:t}];if(!t)return[{type:`remove`,value:e}];let n=u(e),r=u(t),i=n.length,a=r.length,o=Array.from({length:i+1},()=>Array(a+1).fill(0));for(let e=1;e<=i;e++){let t=o[e],i=o[e-1];if(!(!t||!i))for(let o=1;o<=a;o++)n[e-1]===r[o-1]?t[o]=(i[o-1]??0)+1:t[o]=Math.max(i[o]??0,t[o-1]??0)}let s=[],c=i,l=a,d=[];for(;c>0||l>0;){let e=c>0?n[c-1]:void 0,t=l>0?r[l-1]:void 0;if(c>0&&l>0&&e!==void 0&&e===t)d.push({type:`equal`,value:e}),c--,l--;else if(l>0&&t!==void 0&&(c===0||(o[c]?.[l-1]??0)>=(o[c-1]?.[l]??0)))d.push({type:`add`,value:t}),l--;else if(e!==void 0)d.push({type:`remove`,value:e}),c--;else break}d.reverse();for(let e of d){let t=s[s.length-1];t&&t.type===e.type?t.value+=e.value:s.push({...e})}return s}function f(e){return e.replace(/<[^>]*>/g,` `).replace(/\s+/g,` `).trim()}function p(e){return e==null?``:e instanceof Date?e.toISOString():typeof e==`object`?JSON.stringify(e):String(e)}function m(e,t,n,r){if(JSON.stringify(t)===JSON.stringify(n))return null;switch(r?.type??g(t,n)){case`text`:case`slug`:return{field:e,type:`text`,from:t,to:n,segments:d(p(t),p(n))};case`richtext`:return{field:e,type:`richtext`,from:t,to:n,segments:d(f(p(t)),f(p(n)))};case`number`:return{field:e,type:`number`,from:t,to:n};case`boolean`:return{field:e,type:`boolean`,from:t,to:n};case`date`:return{field:e,type:`date`,from:t,to:n};case`select`:return{field:e,type:`select`,from:t,to:n};case`media`:return{field:e,type:`media`,from:t,to:n};case`json`:return{field:e,type:`json`,from:t,to:n};case`blockTree`:return{field:e,type:`blockTree`,from:t,to:n};default:return{field:e,type:`unknown`,from:t,to:n}}}function h(e,t,n){let r=new Set([...Object.keys(e),...Object.keys(t)]),i=[];for(let a of r){if(a===`id`||a===`createdAt`||a===`updatedAt`)continue;let r=m(a,e[a],t[a],n?.[a]);r&&i.push(r)}return i}function g(e,t){let n=e??t;if(typeof n==`number`)return`number`;if(typeof n==`boolean`)return`boolean`;if(n instanceof Date)return`date`;if(typeof n==`string`)return/^\d{4}-\d{2}-\d{2}/.test(n)?`date`:`text`;if(Array.isArray(n)){let e=n[0];return n.length>0&&typeof e==`object`&&e&&`type`in e&&`id`in e?`blockTree`:`json`}return typeof n==`object`?`json`:`unknown`}function _(e,t){if(typeof e==`string`||typeof t==`string`)return d(p(e),p(t))}function v(e){if(typeof e!=`object`||!e)return null;let t=e;if(typeof t.id!=`string`||typeof t.type!=`string`)return null;let n={...t.props??{}};return t.traits!==void 0&&(n.traits=t.traits),t.children!==void 0&&(n.children=t.children),{id:t.id,type:t.type,props:n}}function y(e,t){let n=new Set([...Object.keys(e),...Object.keys(t)]),r=[];for(let i of n){let n=e[i],a=t[i];JSON.stringify(n)!==JSON.stringify(a)&&r.push({field:i,from:n,to:a,segments:_(n,a)})}return r}function b(e){let t=new Map,n=0;for(let r of e){let e=v(r);e&&(t.set(e.id,{block:e,index:n}),n++)}return t}function x(e,t){let n=b(e),r=b(t),i=[],a=new Set;for(let[e,{block:t,index:o}]of r){a.add(e);let r=n.get(e);if(!r){i.push({blockId:e,blockType:t.type,status:`added`,toIndex:o});continue}let s=r.block,c=r.index,l=y(s.props,t.props),u=c!==o;l.length===0&&!u?i.push({blockId:e,blockType:t.type,status:`unchanged`,fromIndex:c,toIndex:o}):l.length>0?i.push({blockId:e,blockType:t.type,status:`changed`,fromIndex:c,toIndex:o,propDiffs:l}):i.push({blockId:e,blockType:t.type,status:`moved`,fromIndex:c,toIndex:o})}for(let[e,{block:t,index:r}]of n)a.has(e)||i.push({blockId:e,blockType:t.type,status:`removed`,fromIndex:r});return i}function S(e){return e===`_`||e===r().defaultLocale}function C(e){let{defaultLocale:t}=r();return S(e)?{defaultLocale:t}:{locale:e,defaultLocale:t}}async function w(e,t,n,o,c){if(c!==void 0&&e.db!==c)throw Error("applyLocaleRoutedUpdate: `tx` must be the context's own db handle");let l=await s(e);return e.admin.updateForLocale(t,n,o,{defaultLocale:r().defaultLocale,...c!==void 0&&{tx:c},...l!==null&&{localeStatusWriter:(e,t,n)=>(a(t),i(l,e,t,n))}})}const T=new Set([`id`,`createdAt`,`createdBy`,`updatedAt`,`updatedBy`]);function E(e,t){let n=e.allFields,r={};for(let e of Object.keys(t)){if(T.has(e)||e.startsWith(`_`)||!Object.hasOwn(n,e))continue;let i=n[e];i&&i.internal===!0||(r[e]=t[e])}return r}function D(e,t){return JSON.stringify(e)===JSON.stringify(t)}function O(e,t){let n={};for(let r of Object.keys(t))r===`id`||r.startsWith(`_`)||D(e[r],t[r])||(n[r]=t[r]);return n}var k=e({compareVersions:()=>M,createVersionSnapshot:()=>I,getVersion:()=>j,getVersions:()=>A,pruneVersions:()=>P,restoreVersion:()=>N,saveVersionSnapshot:()=>F,writeVersionColumn:()=>L});async function A(e,t,n){n?.locale!==void 0&&a(n.locale);let r=await o(e);return r?await r.findMany({where:{entityId:t,locale:n?.locale??`_`},orderBy:[{column:`version`,dir:`desc`}],...n?.limit===void 0?{}:{limit:n.limit},...n?.offset===void 0?{}:{offset:n.offset}}):[]}async function j(e,t,n,r=`_`){a(r);let i=await o(e);return i?await i.findOne({entityId:t,version:n,locale:r})??null:null}async function M(e,t,n,r,i=`_`){a(i);let[o,s]=await Promise.all([j(e,t,n,i),j(e,t,r,i)]);if(!o||!s)return null;let c=e.entity.allFields,l=h(o.data,s.data,c);for(let e of l)e.type===`blockTree`&&(e.blockDiffs=x(Array.isArray(e.from)?e.from:[],Array.isArray(e.to)?e.to:[]));return{diffs:l,fromVersion:o,toVersion:s}}async function N(e,r,i,s=`_`){if(a(s),!await o(e))throw new n(e.entity.name,`versions`);let c=await j(e,r,i,s);if(!c)throw new t(r,i,s);let l=c.data;return w(e,r,E(e.entity,l),s)}async function P(e,t,n=`_`){if(a(n),!e.versionableOptions)return;let r=await o(e);if(!r)return;let i=e.versionableOptions.maxPerDoc,s=await r.findMany({where:{entityId:t,locale:n},orderBy:[{column:`version`,dir:`desc`}],limit:i});if(s.length<i)return;let c=s.map(e=>e.id);await r.deleteMany({entityId:t,locale:n,id:{notIn:c}})}async function F(e,t,n,r,i,s=`_`,c){a(s);let l=await o(e);if(!l)return;let[u]=await l.aggregate({select:{maxVersion:{fn:`max`,column:`version`}},where:{entityId:t,locale:s}}),d=Number(u?.maxVersion??0)+1,f=n.status??null;if(i){let e=await l.findOne({entityId:t,isAutosave:!0,version:d,locale:s});if(e)return await l.update({id:e.id},{data:n,delta:r,status:f,createdAt:new Date}),d}return await l.insert({entityId:t,version:d,locale:s,data:n,delta:r,status:f,createdBy:c?.id??null,createdByName:c?.name??null,isAutosave:i}),d}async function I(e,t,n,r,i,o){if(i!==void 0&&a(i),!e.versionableOptions)return;let s=i??`_`,c=await F(e,t,n,r?O(r,n):null,!1,s,o);c!==void 0&&s===`_`&&await L(e,t,c),await P(e,t,s)}async function L(e,t,n){let r=c.get(e.entity.name);r&&await e.db.update(r).set({_version:n}).where(l(r.id,t))}export{P as a,k as c,E as d,w as f,h,A as i,L as l,x as m,I as n,N as o,C as p,j as r,F as s,M as t,O as u};
|
|
2
|
+
//# sourceMappingURL=versions-C9A4rUAR.mjs.map
|