@murumets-ee/content 0.36.0 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/admin.d.mts +32 -0
  2. package/dist/admin.d.mts.map +1 -0
  3. package/dist/admin.mjs +1 -0
  4. package/dist/chrome-provider.d.mts +107 -0
  5. package/dist/chrome-provider.d.mts.map +1 -0
  6. package/dist/chrome-provider.mjs +2 -0
  7. package/dist/chrome-provider.mjs.map +1 -0
  8. package/dist/client.d.mts +2 -2
  9. package/dist/client.mjs +1 -1
  10. package/dist/client.mjs.map +1 -1
  11. package/dist/content-config-store-8omQF5p5.mjs +2 -0
  12. package/dist/content-config-store-8omQF5p5.mjs.map +1 -0
  13. package/dist/entities/index.d.mts +37 -11
  14. package/dist/entities/index.d.mts.map +1 -1
  15. package/dist/entities/index.mjs +1 -1
  16. package/dist/entities/index.mjs.map +1 -1
  17. package/dist/{index-D8nN7BFd.d.mts → index-Bsmju0UF.d.mts} +30 -3
  18. package/dist/index-Bsmju0UF.d.mts.map +1 -0
  19. package/dist/index.d.mts +4 -4
  20. package/dist/index.mjs +1 -1
  21. package/dist/index.mjs.map +1 -1
  22. package/dist/locale-utils-DFEk4CE9.mjs +2 -0
  23. package/dist/{locale-utils-CuIBKeaz.mjs.map → locale-utils-DFEk4CE9.mjs.map} +1 -1
  24. package/dist/lock-service.d.mts +1 -1
  25. package/dist/plugin-BSxbiGc1.mjs +2 -0
  26. package/dist/plugin-BSxbiGc1.mjs.map +1 -0
  27. package/dist/plugin.d.mts +17 -6
  28. package/dist/plugin.d.mts.map +1 -1
  29. package/dist/plugin.mjs +1 -1
  30. package/dist/query-client.mjs +1 -1
  31. package/dist/resolve-context-BVk6uObB.mjs +2 -0
  32. package/dist/resolve-context-BVk6uObB.mjs.map +1 -0
  33. package/dist/resolve.d.mts +596 -0
  34. package/dist/resolve.d.mts.map +1 -0
  35. package/dist/resolve.mjs +9 -0
  36. package/dist/resolve.mjs.map +1 -0
  37. package/dist/routes-Bc9DcaPk.mjs +2 -0
  38. package/dist/routes-Bc9DcaPk.mjs.map +1 -0
  39. package/dist/{routing-strategy-TPSzrapD.d.mts → routing-strategy-D3Qowm3D.d.mts} +40 -6
  40. package/dist/routing-strategy-D3Qowm3D.d.mts.map +1 -0
  41. package/dist/routing-strategy-dkt2yO25.mjs +2 -0
  42. package/dist/routing-strategy-dkt2yO25.mjs.map +1 -0
  43. package/dist/schema-BJxAwPrI.mjs +2 -0
  44. package/dist/schema-BJxAwPrI.mjs.map +1 -0
  45. package/dist/{types-CUw2Pf0Y.d.mts → types-B3K2p9t8.d.mts} +22 -1
  46. package/dist/types-B3K2p9t8.d.mts.map +1 -0
  47. package/package.json +21 -7
  48. package/dist/index-D8nN7BFd.d.mts.map +0 -1
  49. package/dist/locale-utils-CuIBKeaz.mjs +0 -2
  50. package/dist/plugin-IW9SM9iO.mjs +0 -2
  51. package/dist/plugin-IW9SM9iO.mjs.map +0 -1
  52. package/dist/routing-strategy-DGx6Rq6F.mjs +0 -2
  53. package/dist/routing-strategy-DGx6Rq6F.mjs.map +0 -1
  54. package/dist/routing-strategy-TPSzrapD.d.mts.map +0 -1
  55. package/dist/types-CUw2Pf0Y.d.mts.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"client.mjs","names":[],"sources":["../src/diff/text-diff.ts","../src/diff/field-diff.ts","../src/diff/block-diff.ts","../src/internal/context.ts","../src/locale-status.ts","../src/internal/apply-locale-routed-update.ts","../src/internal/projection.ts","../src/versions.ts","../src/drafts.ts","../src/translation-status.ts","../src/client.ts"],"sourcesContent":["/**\n * Word-level text diff using a simplified Myers algorithm.\n *\n * Splits text into words (preserving whitespace), computes the longest common\n * subsequence, and returns an array of diff segments.\n */\n\nimport type { DiffSegment } from './types.js'\n\n/**\n * Split text into tokens — words and whitespace runs — so diffs are\n * human-readable (\"Changed *this* word\") rather than character-level.\n */\nfunction tokenize(text: string): string[] {\n return text.match(/\\S+|\\s+/g) ?? []\n}\n\n/**\n * Compute word-level diff between two strings.\n *\n * Uses a simple LCS (dynamic programming) approach which is fine for\n * the text sizes we deal with (entity field values, not source code).\n */\nexport function diffText(from: string, to: string): DiffSegment[] {\n if (from === to) {\n return from ? [{ type: 'equal', value: from }] : []\n }\n if (!from) {\n return [{ type: 'add', value: to }]\n }\n if (!to) {\n return [{ type: 'remove', value: from }]\n }\n\n const fromTokens = tokenize(from)\n const toTokens = tokenize(to)\n\n // Build LCS table\n const m = fromTokens.length\n const n = toTokens.length\n const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0))\n\n for (let i = 1; i <= m; i++) {\n const dpI = dp[i]\n const dpIm1 = dp[i - 1]\n if (!dpI || !dpIm1) continue\n for (let j = 1; j <= n; j++) {\n if (fromTokens[i - 1] === toTokens[j - 1]) {\n dpI[j] = (dpIm1[j - 1] ?? 0) + 1\n } else {\n dpI[j] = Math.max(dpIm1[j] ?? 0, dpI[j - 1] ?? 0)\n }\n }\n }\n\n // Backtrack to build diff segments\n const segments: DiffSegment[] = []\n let i = m\n let j = n\n\n // Collect in reverse, then flip\n const reversed: DiffSegment[] = []\n\n while (i > 0 || j > 0) {\n const fromTok = i > 0 ? fromTokens[i - 1] : undefined\n const toTok = j > 0 ? toTokens[j - 1] : undefined\n if (i > 0 && j > 0 && fromTok !== undefined && fromTok === toTok) {\n reversed.push({ type: 'equal', value: fromTok })\n i--\n j--\n } else if (j > 0 && toTok !== undefined && (i === 0 || (dp[i]?.[j - 1] ?? 0) >= (dp[i - 1]?.[j] ?? 0))) {\n reversed.push({ type: 'add', value: toTok })\n j--\n } else if (fromTok !== undefined) {\n reversed.push({ type: 'remove', value: fromTok })\n i--\n } else {\n // Defensive: i and j both 0, but loop condition implied otherwise.\n break\n }\n }\n\n reversed.reverse()\n\n // Merge consecutive segments of the same type\n for (const seg of reversed) {\n const last = segments[segments.length - 1]\n if (last && last.type === seg.type) {\n last.value += seg.value\n } else {\n segments.push({ ...seg })\n }\n }\n\n return segments\n}\n","/**\n * Field-level diff — dispatches to the appropriate diff strategy per field type.\n */\n\nimport type { FieldConfig } from '@murumets-ee/entity'\nimport { diffText } from './text-diff.js'\nimport type { DiffSegment, FieldDiff } from './types.js'\n\n/** Strip HTML tags for diffing richtext content as plain text. */\nfunction stripHtml(html: string): string {\n return html\n .replace(/<[^>]*>/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim()\n}\n\n/** Stringify a value for display (dates, objects, etc.). */\nfunction stringify(value: unknown): string {\n if (value === null || value === undefined) return ''\n if (value instanceof Date) return value.toISOString()\n if (typeof value === 'object') return JSON.stringify(value)\n return String(value)\n}\n\n/**\n * Compute diff for a single field, using the field's type to determine\n * the best diff strategy.\n */\nexport function diffField(\n fieldName: string,\n from: unknown,\n to: unknown,\n fieldConfig?: FieldConfig,\n): FieldDiff | null {\n // No change\n if (JSON.stringify(from) === JSON.stringify(to)) return null\n\n const type = fieldConfig?.type ?? inferType(from, to)\n\n switch (type) {\n case 'text':\n case 'slug': {\n const segments = diffText(stringify(from), stringify(to))\n return { field: fieldName, type: 'text', from, to, segments }\n }\n\n case 'richtext': {\n // Diff on stripped HTML so users see word changes, not tag noise\n const segments = diffText(stripHtml(stringify(from)), stripHtml(stringify(to)))\n return { field: fieldName, type: 'richtext', from, to, segments }\n }\n\n case 'number':\n return { field: fieldName, type: 'number', from, to }\n\n case 'boolean':\n return { field: fieldName, type: 'boolean', from, to }\n\n case 'date':\n return { field: fieldName, type: 'date', from, to }\n\n case 'select':\n return { field: fieldName, type: 'select', from, to }\n\n case 'media':\n return { field: fieldName, type: 'media', from, to }\n\n case 'json':\n return { field: fieldName, type: 'json', from, to }\n\n case 'blockTree':\n // Block tree — block-diff.ts computes the structural diff; just mark the\n // field type so compareVersions/compareDraftWithCurrent populate\n // `blockDiffs`.\n return { field: fieldName, type: 'blockTree', from, to }\n\n default:\n return { field: fieldName, type: 'unknown', from, to }\n }\n}\n\n/**\n * Diff all fields between two version snapshots.\n * Pass fieldConfigs to get type-aware diffs; without them, types are inferred.\n */\nexport function diffFields(\n from: Record<string, unknown>,\n to: Record<string, unknown>,\n fieldConfigs?: Record<string, FieldConfig>,\n): FieldDiff[] {\n const allKeys = new Set([...Object.keys(from), ...Object.keys(to)])\n const diffs: FieldDiff[] = []\n\n for (const key of allKeys) {\n // Skip internal/auto-generated fields\n if (key === 'id' || key === 'createdAt' || key === 'updatedAt') continue\n\n const diff = diffField(key, from[key], to[key], fieldConfigs?.[key])\n if (diff) diffs.push(diff)\n }\n\n return diffs\n}\n\n/** Best-effort type inference when no FieldConfig is available. */\nfunction inferType(from: unknown, to: unknown): string {\n const val = from ?? to\n if (typeof val === 'number') return 'number'\n if (typeof val === 'boolean') return 'boolean'\n if (val instanceof Date) return 'date'\n if (typeof val === 'string') {\n if (/^\\d{4}-\\d{2}-\\d{2}/.test(val)) return 'date'\n return 'text'\n }\n if (Array.isArray(val)) {\n const first = val[0]\n if (val.length > 0 && typeof first === 'object' && first !== null) {\n // Block tree: instances discriminated by `type` + `id`.\n if ('type' in first && 'id' in first) return 'blockTree'\n }\n return 'json'\n }\n if (typeof val === 'object') return 'json'\n return 'unknown'\n}\n\n/** Diff two string values and return segments (used by block-diff for string props). */\nexport function diffStringValues(from: unknown, to: unknown): DiffSegment[] | undefined {\n if (typeof from === 'string' || typeof to === 'string') {\n return diffText(stringify(from), stringify(to))\n }\n return undefined\n}\n","/**\n * Structural diff for block-tree arrays (`field.blockTree()` values).\n *\n * Operates on new-format `BlockInstance`s (`{ id, type, props, traits,\n * children }`) — the only block shape the toolkit stores. Blocks are\n * identified by their `id`. The algorithm detects:\n * - Added blocks (new id in target)\n * - Removed blocks (missing id in target)\n * - Moved blocks (same id, different position)\n * - Changed blocks (same id, different prop/trait/child values)\n * - Unchanged blocks\n */\n\nimport { diffStringValues } from './field-diff.js'\nimport type { BlockDiffEntry, PropDiff } from './types.js'\n\n/** Comparable view of a block instance. */\ninterface NormalizedBlock {\n id: string\n type: string\n /** Comparable content map: `props` + folded-in `traits` / `children`. */\n props: Record<string, unknown>\n}\n\n/**\n * Normalize a `BlockInstance` to its comparable `{ id, type, props }` view,\n * or null when the value isn't a recognizable block. `traits` and `children`\n * fold into the comparable map so trait edits and nested-tree changes surface\n * as prop diffs (`diffProps` skips JSON-equal values, so empty/unchanged ones\n * add no noise).\n */\nfunction normalizeBlock(val: unknown): NormalizedBlock | null {\n if (typeof val !== 'object' || val === null) return null\n const rec = val as Record<string, unknown>\n\n if (typeof rec.id !== 'string' || typeof rec.type !== 'string') return null\n\n const props: Record<string, unknown> = { ...((rec.props as Record<string, unknown>) ?? {}) }\n if (rec.traits !== undefined) props.traits = rec.traits\n if (rec.children !== undefined) props.children = rec.children\n return { id: rec.id, type: rec.type, props }\n}\n\n/**\n * Diff two prop objects within a single block, returning per-prop diffs.\n */\nfunction diffProps(\n fromProps: Record<string, unknown>,\n toProps: Record<string, unknown>,\n): PropDiff[] {\n const allKeys = new Set([...Object.keys(fromProps), ...Object.keys(toProps)])\n const diffs: PropDiff[] = []\n\n for (const key of allKeys) {\n const fromVal = fromProps[key]\n const toVal = toProps[key]\n\n if (JSON.stringify(fromVal) !== JSON.stringify(toVal)) {\n diffs.push({\n field: key,\n from: fromVal,\n to: toVal,\n segments: diffStringValues(fromVal, toVal),\n })\n }\n }\n\n return diffs\n}\n\n/**\n * Index a block array by id, assigning each block its position AMONG\n * recognizable blocks (not its raw array index). Values that fail\n * normalization are skipped without consuming a position, so a stray\n * non-block entry can't skew move detection for the real blocks around it.\n */\nfunction indexBlocks(blocks: unknown[]): Map<string, { block: NormalizedBlock; index: number }> {\n const map = new Map<string, { block: NormalizedBlock; index: number }>()\n let position = 0\n for (const raw of blocks) {\n const block = normalizeBlock(raw)\n if (block) {\n map.set(block.id, { block, index: position })\n position++\n }\n }\n return map\n}\n\n/**\n * Compute structural diff between two block arrays.\n *\n * Each block is identified by its id. Position changes, additions, removals,\n * and prop-level changes are all detected.\n */\nexport function diffBlocks(fromBlocks: unknown[], toBlocks: unknown[]): BlockDiffEntry[] {\n const fromMap = indexBlocks(fromBlocks)\n const toMap = indexBlocks(toBlocks)\n\n const entries: BlockDiffEntry[] = []\n const processedIds = new Set<string>()\n\n // Walk the \"to\" array to preserve target order\n for (const [id, { block: toBlock, index: toIndex }] of toMap) {\n processedIds.add(id)\n const fromEntry = fromMap.get(id)\n\n if (!fromEntry) {\n // Added\n entries.push({\n blockId: id,\n blockType: toBlock.type,\n status: 'added',\n toIndex,\n })\n continue\n }\n\n const fromBlock = fromEntry.block\n const fromIndex = fromEntry.index\n const propDiffs = diffProps(fromBlock.props, toBlock.props)\n const moved = fromIndex !== toIndex\n\n if (propDiffs.length === 0 && !moved) {\n entries.push({\n blockId: id,\n blockType: toBlock.type,\n status: 'unchanged',\n fromIndex,\n toIndex,\n })\n } else if (propDiffs.length > 0) {\n entries.push({\n blockId: id,\n blockType: toBlock.type,\n status: 'changed',\n fromIndex,\n toIndex,\n propDiffs,\n })\n } else {\n entries.push({\n blockId: id,\n blockType: toBlock.type,\n status: 'moved',\n fromIndex,\n toIndex,\n })\n }\n }\n\n // Removed blocks (in \"from\" but not in \"to\")\n for (const [id, { block, index }] of fromMap) {\n if (!processedIds.has(id)) {\n entries.push({\n blockId: id,\n blockType: block.type,\n status: 'removed',\n fromIndex: index,\n })\n }\n }\n\n return entries\n}\n","/**\n * Shared per-instance state for ContentClient feature modules.\n *\n * The package's domain modules (`versions.ts`, `drafts.ts`,\n * `translation-status.ts`, `locale-status.ts`, `layout.ts`) are written\n * as free functions that take a `ContentContext` as their first\n * argument. ContentClient itself is a thin shell that builds the\n * context once and forwards every public method to the corresponding\n * free function.\n *\n * Why free functions instead of one class with everything inline:\n * - 1000+ LoC in one class is impossible to navigate and audit.\n * - Each module has clear inputs (the context + method args) and no\n * hidden dependencies on private state.\n * - Splits cleanly along the same boundaries as the per-entity\n * capability tables (drafts, versions, locale_status, ...) — every\n * module owns exactly one capability surface.\n */\n\nimport 'server-only'\n\nimport type { Logger } from '@murumets-ee/core'\nimport type { Entity, FieldConfig } from '@murumets-ee/entity'\nimport type { AdminClient } from '@murumets-ee/entity/admin'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\nimport type { getVersionableOptions } from '../behaviors/versionable.js'\nimport { getContentTables } from '../schema/registry.js'\n\n/**\n * Per-instance content context. Built once in `createContentContext()`\n * and threaded through every domain module's free functions.\n */\nexport interface ContentContext<\n AllFields extends Record<string, FieldConfig> = Record<string, FieldConfig>,\n> {\n entity: Entity<AllFields>\n admin: AdminClient<AllFields>\n db: PostgresJsDatabase\n versionableOptions: ReturnType<typeof getVersionableOptions>\n logger?: Logger | undefined\n}\n\n/**\n * Build a content context from an AdminClient + entity. The DB handle\n * is read off the AdminClient (it owns the read-write connection).\n */\nexport function createContentContext<AllFields extends Record<string, FieldConfig>>(\n config: {\n admin: AdminClient<AllFields>\n entity: Entity<AllFields>\n logger?: Logger\n },\n // Lazy import to avoid a static cycle: behaviors/versionable.ts is loaded\n // by client.ts which would otherwise close the loop. Phase-2 validated\n // the static import graph; this is a private helper bundled into the\n // same chunk so the dynamic-vs-static rule doesn't apply.\n versionableOptions: ReturnType<typeof getVersionableOptions>,\n): ContentContext<AllFields> {\n return {\n entity: config.entity,\n admin: config.admin,\n db: config.admin.getDb(),\n versionableOptions,\n logger: config.logger,\n }\n}\n\n// -------------------------------------------------------------------\n// Per-capability TableClient lookups. Each returns null if the entity\n// hasn't registered the corresponding table (i.e. doesn't have the\n// behavior). ContentClient public methods translate that null into\n// either a thrown EntityCapabilityError (writes) or a falsy return\n// (reads).\n// -------------------------------------------------------------------\n\nexport function getDraftsClient(ctx: ContentContext) {\n const { drafts } = getContentTables(ctx.entity.name)\n return drafts ? drafts.makeClient(ctx.db) : null\n}\n\nexport function getVersionsClient(ctx: ContentContext) {\n const { versions } = getContentTables(ctx.entity.name)\n return versions ? versions.makeClient(ctx.db) : null\n}\n\nexport function getLocaleStatusClient(ctx: ContentContext) {\n const { localeStatus } = getContentTables(ctx.entity.name)\n return localeStatus ? localeStatus.makeClient(ctx.db) : null\n}\n","/**\n * Per-locale publish status — independent draft/published state for\n * each locale of a publishable + translatable entity.\n *\n * Capability: requires `publishable() + at least one translatable\n * field` (the `_locale_status` table is registered only for that\n * combination). Reads silently return falsy on entities without it;\n * `setLocaleStatus` throws `EntityCapabilityError`.\n */\n\nimport 'server-only'\n\nimport type { FieldConfig } from '@murumets-ee/entity'\nimport type { InferUpdateInput } from '@murumets-ee/entity/admin'\nimport { EntityCapabilityError } from './errors.js'\nimport { type ContentContext, getLocaleStatusClient } from './internal/context.js'\nimport { upsertLocaleStatusRow } from './locale-status-slot.js'\nimport { validateLocale } from './locale-utils.js'\nimport { getContentConfig } from './plugin.js'\nimport type { LocalePublishStatus } from './types.js'\n\n/**\n * Get per-locale publish status overrides for an entity.\n * Missing locale = inherits the base entity status (no row written).\n */\nexport async function getLocaleStatus(\n ctx: ContentContext,\n entityId: string,\n): Promise<LocalePublishStatus[]> {\n const map = await getLocaleStatusBatch(ctx, [entityId])\n const entry = map.get(entityId)\n if (!entry) return []\n return Object.values(entry)\n}\n\n/**\n * Set per-locale publish status. Upserts into the locale_status\n * table — except when `locale === defaultLocale`, which updates the\n * base entity status column directly (the per-locale table only\n * stores overrides for non-default locales).\n */\nexport async function setLocaleStatus<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n locale: string,\n status: 'draft' | 'published',\n): Promise<void> {\n validateLocale(locale)\n const config = getContentConfig()\n\n // Default locale → update base entity status column.\n if (locale === config.defaultLocale) {\n const data: Record<string, unknown> = { status }\n if (status === 'published') data.publishedAt = new Date()\n await ctx.admin.update(entityId, data as InferUpdateInput<F>)\n return\n }\n\n const client = getLocaleStatusClient(ctx)\n if (!client) {\n // Per-locale status only exists for publishable + translatable\n // entities. Calling on a non-translatable publishable (where the\n // only valid locale is the default) is a programmer error.\n throw new EntityCapabilityError(ctx.entity.name, 'localeStatus')\n }\n\n await upsertLocaleStatusRow(client, entityId, locale, status)\n}\n\n/**\n * Get per-locale publish status for multiple entities in one query.\n */\nexport async function getLocaleStatusBatch(\n ctx: ContentContext,\n entityIds: string[],\n): Promise<Map<string, Record<string, LocalePublishStatus>>> {\n const result = new Map<string, Record<string, LocalePublishStatus>>()\n if (entityIds.length === 0) return result\n const client = getLocaleStatusClient(ctx)\n if (!client) return result\n\n const rows = await client.findMany({ where: { entityId: { in: entityIds } } })\n for (const r of rows) {\n const eid = r.entityId as string\n const locale = r.locale as string\n let entry = result.get(eid)\n if (!entry) {\n entry = {}\n result.set(eid, entry)\n }\n entry[locale] = {\n locale,\n status: r.status as 'draft' | 'published',\n publishedAt: (r.publishedAt as Date | null) ?? null,\n }\n }\n return result\n}\n","/**\n * Locale-routed entity write shared by `publishDraft` and `restoreVersion`.\n *\n * Since SD021, the routing itself lives in ONE place —\n * `AdminClient.updateForLocale` (`@murumets-ee/entity`): default locale →\n * base row; non-default → translatable fields to `saveTranslation`,\n * `status` to the per-locale status store, non-translatable remainder to\n * the base row. This module is the thin content-side call: it supplies the\n * plugin's `defaultLocale` and a per-call locale-status writer bound to\n * THIS context's tables, so routing is deterministic regardless of how\n * `ctx.admin` was constructed.\n *\n * Lives in its own internal module because `drafts.ts` imports\n * `versions.ts` (for `createVersionSnapshot`) — exporting this from\n * either of them would close an import cycle.\n */\n\nimport type { FieldConfig } from '@murumets-ee/entity'\nimport type { InferEntityDTO, InferUpdateInput } from '@murumets-ee/entity/admin'\nimport { setLocaleStatus } from '../locale-status.js'\nimport { DEFAULT_LOCALE_SENTINEL } from '../locale-utils.js'\nimport { getContentConfig } from '../plugin.js'\nimport { type ContentContext, getLocaleStatusClient } from './context.js'\n\n/** Is this snapshot-key locale the default locale (base-row) one? */\nexport function isDefaultLocale(locale: string): boolean {\n return locale === DEFAULT_LOCALE_SENTINEL || locale === getContentConfig().defaultLocale\n}\n\n/**\n * `findById` options that read the same locale view the write targets —\n * default locale reads the base row, non-default reads the\n * translation-merged row.\n */\nexport function localeFindOptions(\n locale: string,\n): { defaultLocale: string } | { locale: string; defaultLocale: string } {\n const { defaultLocale } = getContentConfig()\n return isDefaultLocale(locale) ? { defaultLocale } : { locale, defaultLocale }\n}\n\n/**\n * Apply projected entity-field data to the given locale via the SD021\n * choke point. `updateData` MUST already be projected through\n * `projectToEntityFields` — this function only delegates, it does not\n * whitelist.\n */\nexport async function applyLocaleRoutedUpdate<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n updateData: Record<string, unknown>,\n locale: string,\n): Promise<InferEntityDTO<F>> {\n // Bind the status writer to THIS context's locale-status table when the\n // entity has one; otherwise let updateForLocale fall back to the base\n // status column (non-translatable publishables have only the default\n // locale).\n const hasLocaleStatus = getLocaleStatusClient(ctx) !== null\n return ctx.admin.updateForLocale(entityId, updateData as InferUpdateInput<F>, locale, {\n defaultLocale: getContentConfig().defaultLocale,\n ...(hasLocaleStatus && {\n localeStatusWriter: (id: string, loc: string, status: 'draft' | 'published') =>\n setLocaleStatus(ctx, id, loc, status),\n }),\n })\n}\n","/**\n * Field projection + delta helpers shared between drafts and versions.\n *\n * `projectToEntityFields` is the security-critical whitelist filter\n * applied before replaying stored data through `admin.update()` —\n * see audit-security-content.md M#4 (drafts/restore must not pass\n * through forbidden fields like `status: 'published'` or\n * `_workflowStatus: 'approved'`).\n *\n * `valuesEqual` and `calculateDelta` are pure utilities used by both\n * the version-snapshot delta calculator and the block-diff prop\n * comparison — collapsing 3× repeated `JSON.stringify(a) !== JSON.stringify(b)`\n * sites identified in audit-quality-content.md DRY candidates.\n */\n\nimport type { Entity, FieldConfig } from '@murumets-ee/entity'\n\n/**\n * Identity / audit / timestamp fields that exist as real columns on every\n * entity (added by `defineEntity` / `auditable()` / `timestamped()`) and\n * MUST NOT be relayed through `admin.update()` from a draft or version\n * payload. They're either server-set (`createdAt`, `createdBy`,\n * `updatedAt`, `updatedBy`) or immutable identity (`id`).\n *\n * Without this explicit deny-list, `id` would survive `projectToEntityFields`\n * because `defineEntity` injects it into `entity.allFields` and it's not\n * marked `internal: true`. AdminClient.update's Zod schema strips it\n * downstream as a final defense, but security-critical filtering should\n * live in one place — not split across the projection AND the validator.\n */\nconst PROJECTION_DENYLIST = new Set(['id', 'createdAt', 'createdBy', 'updatedAt', 'updatedBy'])\n\n/**\n * Project a draft / version payload through the entity's declared\n * field map before forwarding to `admin.update()`. Used by\n * `publishDraft()` and `restoreVersion()` — both replay stored data\n * that originated from a write, but neither should be allowed to set\n * fields the entity didn't declare.\n *\n * Drops:\n * - the audit/identity column set above (`id`, `createdAt`,\n * `createdBy`, `updatedAt`, `updatedBy`)\n * - any key starting with `_` (behavior internals managed by hooks)\n * - any field declared `internal: true` (writable only via\n * AdminClient.updateInternal — never via public CRUD or replay)\n * - any key not in `entity.allFields` (whitelist for app-defined fields)\n */\nexport function projectToEntityFields(\n entity: Entity,\n data: Record<string, unknown>,\n): Record<string, unknown> {\n const allowed = entity.allFields as Record<string, FieldConfig>\n const out: Record<string, unknown> = {}\n for (const key of Object.keys(data)) {\n if (PROJECTION_DENYLIST.has(key)) continue\n if (key.startsWith('_')) continue\n if (!Object.hasOwn(allowed, key)) continue\n const f = allowed[key]\n if (f && (f as { internal?: boolean }).internal === true) continue\n out[key] = data[key]\n }\n return out\n}\n\n/**\n * Structural equality via canonical JSON serialization. Coarse, with\n * three known quirks worth flagging at every call site:\n *\n * 1. **Key order matters in pathological cases.** Two objects with\n * identical keys+values but different insertion order serialize\n * differently. Safe for entity field values where keys come from\n * the same field map on both sides; not safe for arbitrary user\n * JSONB blobs.\n * 2. **Dates compare equal to their ISO string.** `new Date('2026-01-01')`\n * and `'2026-01-01T00:00:00.000Z'` both stringify to\n * `\"2026-01-01T00:00:00.000Z\"`. After a round-trip through JSONB\n * storage, a previously Date-typed field will come back as a\n * string and compare equal to its origin Date — exactly what we\n * want for delta calculation.\n * 3. **`undefined` is dropped.** `{ x: undefined }` and `{}` both\n * serialize to `{}`. If you need to distinguish \"field present\n * but null\" from \"field missing\", do not use this helper.\n */\nexport function valuesEqual(a: unknown, b: unknown): boolean {\n return JSON.stringify(a) === JSON.stringify(b)\n}\n\n/**\n * Field-level delta between previous and current entity snapshots.\n * Skips `id` and any underscore-prefixed behavior-internal field.\n * Only changed keys are returned. Used by version-snapshot persistence\n * to store a delta alongside the full snapshot for compact \"what\n * changed since last version\" reads.\n */\nexport function calculateDelta(\n previous: Record<string, unknown>,\n current: Record<string, unknown>,\n): Record<string, unknown> {\n const delta: Record<string, unknown> = {}\n for (const key of Object.keys(current)) {\n if (key === 'id' || key.startsWith('_')) continue\n if (!valuesEqual(previous[key], current[key])) {\n delta[key] = current[key]\n }\n }\n return delta\n}\n","/**\n * Version-history operations on a content entity.\n *\n * All functions take a {@link ContentContext} as first argument and\n * are wired into ContentClient via thin delegation in `client.ts`.\n *\n * Capability: requires the entity to declare `versionable()`. Read\n * methods return falsy when the entity doesn't support versions\n * (defensive, supports mixed-entity loops in the dashboard); write\n * methods throw `EntityCapabilityError` mapped to HTTP 400 by admin-ui.\n */\n\nimport 'server-only'\n\nimport { schemaRegistry } from '@murumets-ee/db'\nimport type { FieldConfig } from '@murumets-ee/entity'\nimport type { InferEntityDTO } from '@murumets-ee/entity/admin'\nimport { eq } from 'drizzle-orm'\nimport { diffBlocks } from './diff/block-diff.js'\nimport { diffFields } from './diff/field-diff.js'\nimport type { FieldDiff } from './diff/types.js'\nimport { EntityCapabilityError } from './errors.js'\nimport { applyLocaleRoutedUpdate } from './internal/apply-locale-routed-update.js'\nimport { type ContentContext, getVersionsClient } from './internal/context.js'\nimport { calculateDelta, projectToEntityFields } from './internal/projection.js'\nimport { DEFAULT_LOCALE_SENTINEL, validateLocale } from './locale-utils.js'\nimport type { VersionEntry } from './types.js'\n\n/**\n * List versions for an entity (newest first), filtered by locale.\n * Uses '_' as the locale value for default-locale snapshots.\n */\nexport async function getVersions<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n options?: { limit?: number; offset?: number; locale?: string },\n): Promise<VersionEntry<InferEntityDTO<F>>[]> {\n if (options?.locale !== undefined) validateLocale(options.locale)\n const client = getVersionsClient(ctx)\n if (!client) return []\n\n const rows = await client.findMany({\n where: { entityId, locale: options?.locale ?? DEFAULT_LOCALE_SENTINEL },\n orderBy: [{ column: 'version', dir: 'desc' }],\n ...(options?.limit !== undefined ? { limit: options.limit } : {}),\n ...(options?.offset !== undefined ? { offset: options.offset } : {}),\n })\n return rows as VersionEntry<InferEntityDTO<F>>[]\n}\n\n/**\n * Get a specific version snapshot.\n */\nexport async function getVersion<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n version: number,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<VersionEntry<InferEntityDTO<F>> | null> {\n validateLocale(locale)\n const client = getVersionsClient(ctx)\n if (!client) return null\n const row = await client.findOne({ entityId, version, locale })\n return (row ?? null) as VersionEntry<InferEntityDTO<F>> | null\n}\n\n/**\n * Compare two versions and return field-level diff.\n *\n * Returns structured diffs with word-level segments for text fields\n * and structural block diffs for blocks fields.\n */\nexport async function compareVersions<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n v1: number,\n v2: number,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<{\n diffs: FieldDiff[]\n fromVersion: VersionEntry<InferEntityDTO<F>>\n toVersion: VersionEntry<InferEntityDTO<F>>\n} | null> {\n validateLocale(locale)\n const [version1, version2] = await Promise.all([\n getVersion(ctx, entityId, v1, locale),\n getVersion(ctx, entityId, v2, locale),\n ])\n if (!version1 || !version2) return null\n\n const fieldConfigs = ctx.entity.allFields as Record<string, FieldConfig> | undefined\n const diffs = diffFields(version1.data, version2.data, fieldConfigs)\n for (const diff of diffs) {\n if (diff.type === 'blockTree') {\n // Normalize a null/undefined side to [] so add/clear of the whole field\n // (null ↔ blockTree[]) still produces a structural diff, not an opaque one.\n diff.blockDiffs = diffBlocks(\n Array.isArray(diff.from) ? diff.from : [],\n Array.isArray(diff.to) ? diff.to : [],\n )\n }\n }\n return { diffs, fromVersion: version1, toVersion: version2 }\n}\n\n/**\n * Restore an entity to a previous version. Replays the snapshot data\n * through the locale-routed writer, projected through `entity.allFields`\n * so forbidden fields (workflow internals, behavior-managed underscore\n * fields, fields the entity declared `internal: true`) cannot leak.\n *\n * Snapshots are locale-keyed; the restore reads the snapshot for the\n * given locale and writes it back to the same locale's home —\n * translations for non-default locales, the base row for the default.\n */\nexport async function restoreVersion<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n version: number,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<InferEntityDTO<F>> {\n validateLocale(locale)\n if (!getVersionsClient(ctx)) {\n throw new EntityCapabilityError(ctx.entity.name, 'versions')\n }\n const versionData = await getVersion(ctx, entityId, version, locale)\n if (!versionData) {\n throw new Error(`Version ${version} not found for entity ${entityId}`)\n }\n const data = versionData.data as Record<string, unknown>\n const updateData = projectToEntityFields(ctx.entity, data)\n return applyLocaleRoutedUpdate(ctx, entityId, updateData, locale)\n}\n\n/**\n * Enforce maxPerDoc retention by deleting oldest versions for a locale.\n */\nexport async function pruneVersions(\n ctx: ContentContext,\n entityId: string,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<void> {\n validateLocale(locale)\n if (!ctx.versionableOptions) return\n const client = getVersionsClient(ctx)\n if (!client) return\n\n const maxPerDoc = ctx.versionableOptions.maxPerDoc\n\n // Get IDs of versions to keep (newest N for this locale). The findMany\n // is capped at `limit: maxPerDoc`, so `keepVersions.length` is\n // `min(totalRows, maxPerDoc)` — never larger than the limit.\n const keepVersions = await client.findMany({\n where: { entityId, locale },\n orderBy: [{ column: 'version', dir: 'desc' }],\n limit: maxPerDoc,\n })\n\n // Strictly under the limit ⇒ totalRows < maxPerDoc ⇒ nothing to prune.\n // At the limit (length == maxPerDoc) we cannot distinguish \"exactly\n // maxPerDoc\" from \"more than maxPerDoc\" without another count query,\n // so we always run the DELETE — Postgres correctly removes 0-or-more\n // older rows. Better one always-cheap DELETE per prune than a guard\n // round trip on every call.\n if (keepVersions.length < maxPerDoc) return\n\n const keepIds = keepVersions.map((v) => v.id as string)\n\n // Delete everything for this entity+locale NOT in the keep list.\n await client.deleteMany({\n entityId,\n locale,\n id: { notIn: keepIds },\n })\n}\n\n/**\n * Persist a new version snapshot. Internal helper used by\n * `createVersionSnapshot` and ContentClient.update / .create.\n *\n * Attribution: ONLY the explicit `user` parameter is trusted. The\n * fallback chain that previously read `data.updatedBy` /\n * `data.createdBy` was an audit-log integrity gap (see audit\n * MEDIUM #1) — version history must never absorb a forged actor from\n * the request payload.\n */\nexport async function saveVersionSnapshot(\n ctx: ContentContext,\n entityId: string,\n data: Record<string, unknown>,\n delta: Record<string, unknown> | null,\n isAutosave: boolean,\n locale = DEFAULT_LOCALE_SENTINEL,\n user?: { id: string; name?: string },\n): Promise<number | undefined> {\n // All current callers pass a pre-validated locale (or the sentinel).\n // Defensive check at the boundary so future callers — or anyone who\n // exposes this helper publicly — still get the validation.\n validateLocale(locale)\n const client = getVersionsClient(ctx)\n if (!client) return\n\n // Get the next version number for this locale (max existing + 1).\n const [maxRow] = await client.aggregate<{ maxVersion: number | null }>({\n select: { maxVersion: { fn: 'max', column: 'version' } },\n where: { entityId, locale },\n })\n const version = Number(maxRow?.maxVersion ?? 0) + 1\n const status = (data.status as string) ?? null\n\n // Autosave: update the existing autosave row at the same version\n // number, preserving \"autosaves land on the latest slot\" semantics.\n if (isAutosave) {\n const existing = await client.findOne({ entityId, isAutosave: true, version, locale })\n if (existing) {\n await client.update(\n { id: existing.id as string },\n { data, delta, status, createdAt: new Date() },\n )\n return version\n }\n }\n\n await client.insert({\n entityId,\n version,\n locale,\n data,\n delta,\n status,\n createdBy: user?.id ?? null,\n createdByName: user?.name ?? null,\n isAutosave,\n })\n return version\n}\n\n/**\n * Create a version snapshot for an entity after an external update.\n * Use this when the update was performed by AdminClient directly\n * (e.g. the admin API handler) rather than through ContentClient.update().\n *\n * @param locale - The locale being edited. Use undefined or '_' for the default locale.\n * @param user - The user who made the change (id + name for display).\n */\nexport async function createVersionSnapshot(\n ctx: ContentContext,\n entityId: string,\n currentData: Record<string, unknown>,\n previousData?: Record<string, unknown> | null,\n locale?: string,\n user?: { id: string; name?: string },\n): Promise<void> {\n if (locale !== undefined) validateLocale(locale)\n if (!ctx.versionableOptions) return\n const snapshotLocale = locale ?? DEFAULT_LOCALE_SENTINEL\n const delta = previousData ? calculateDelta(previousData, currentData) : null\n const version = await saveVersionSnapshot(\n ctx,\n entityId,\n currentData,\n delta,\n false,\n snapshotLocale,\n user,\n )\n\n // Update _version on the entity to match (default locale only).\n if (version !== undefined && snapshotLocale === DEFAULT_LOCALE_SENTINEL) {\n await writeVersionColumn(ctx, entityId, version)\n }\n\n await pruneVersions(ctx, entityId, snapshotLocale)\n}\n\n/**\n * Write the `_version` column on the entity row.\n *\n * KNOWN EXCEPTION: this bypasses AdminClient and goes straight to the\n * entity pgTable via `schemaRegistry` + direct Drizzle. Using\n * `admin.update()` would retrigger the `versionable.beforeUpdate` hook\n * and loop indefinitely. The only ContentClient path that still writes\n * to an entity table directly.\n */\nexport async function writeVersionColumn(\n ctx: ContentContext,\n entityId: string,\n version: number,\n): Promise<void> {\n const table = schemaRegistry.get(ctx.entity.name)\n if (!table) return\n await ctx.db.update(table).set({ _version: version }).where(eq(table.id, entityId))\n}\n","/**\n * Draft-overlay operations.\n *\n * Capability: requires the entity to declare `publishable()`. Reads\n * (`getDraft`, `hasDraft`, `countDrafts`, `hasDraftBatch`) silently\n * return falsy when the entity doesn't support drafts; writes\n * (`saveDraft`, `publishDraft`) throw `EntityCapabilityError`.\n */\n\nimport 'server-only'\n\nimport type { FieldConfig } from '@murumets-ee/entity'\nimport type { InferEntityDTO, InferUpdateInput } from '@murumets-ee/entity/admin'\nimport { diffBlocks } from './diff/block-diff.js'\nimport { diffFields } from './diff/field-diff.js'\nimport type { FieldDiff } from './diff/types.js'\nimport { DraftNotFoundError, EntityCapabilityError } from './errors.js'\nimport {\n applyLocaleRoutedUpdate,\n localeFindOptions,\n} from './internal/apply-locale-routed-update.js'\nimport { type ContentContext, getDraftsClient } from './internal/context.js'\nimport { projectToEntityFields } from './internal/projection.js'\nimport { DEFAULT_LOCALE_SENTINEL, validateLocale } from './locale-utils.js'\nimport { getContentConfig } from './plugin.js'\nimport type { DraftsTable } from './schema/drafts.js'\nimport type { DraftEntry } from './types.js'\nimport { createVersionSnapshot } from './versions.js'\n\ntype DraftRow = NonNullable<Awaited<ReturnType<ReturnType<DraftsTable['makeClient']>['findOne']>>>\n\nfunction toDraftEntry(row: DraftRow): DraftEntry {\n return {\n id: row.id,\n entityId: row.entityId,\n locale: row.locale,\n data: row.data,\n createdBy: row.createdBy,\n createdByName: row.createdByName ?? null,\n createdAt: row.createdAt,\n updatedAt: row.updatedAt,\n }\n}\n\n/**\n * Save (upsert) a draft for an entity+locale. One draft per\n * entity+locale — overwrites any existing draft.\n */\nexport async function saveDraft<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n data: InferUpdateInput<F>,\n locale = DEFAULT_LOCALE_SENTINEL,\n user?: { id: string; name?: string },\n): Promise<DraftEntry<InferUpdateInput<F>>> {\n validateLocale(locale)\n const client = getDraftsClient(ctx)\n if (!client) throw new EntityCapabilityError(ctx.entity.name, 'drafts')\n\n const now = new Date()\n const createdBy = user?.id ?? ''\n const createdByName = user?.name ?? null\n\n const row = await client.upsert(\n {\n entityId,\n locale,\n data: data as Record<string, unknown>,\n createdBy,\n createdByName,\n createdAt: now,\n updatedAt: now,\n },\n {\n target: ['entityId', 'locale'],\n // On conflict, only update the mutable fields — preserve createdBy/createdAt.\n set: { data: data as Record<string, unknown>, updatedAt: now },\n },\n )\n\n return toDraftEntry(row) as DraftEntry<InferUpdateInput<F>>\n}\n\n/**\n * Get the draft for an entity+locale, or null if none exists (or the\n * entity doesn't support drafts).\n */\nexport async function getDraft<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<DraftEntry<InferUpdateInput<F>> | null> {\n validateLocale(locale)\n const client = getDraftsClient(ctx)\n if (!client) return null\n const row = await client.findOne({ entityId, locale })\n return row ? (toDraftEntry(row) as DraftEntry<InferUpdateInput<F>>) : null\n}\n\n/**\n * Delete (discard) a draft for an entity+locale. Returns whether\n * anything was deleted; safely no-ops on entities without drafts.\n */\nexport async function deleteDraft(\n ctx: ContentContext,\n entityId: string,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<boolean> {\n validateLocale(locale)\n const client = getDraftsClient(ctx)\n if (!client) return false\n const deleted = await client.deleteMany({ entityId, locale })\n return deleted.length > 0\n}\n\n/**\n * Publish a draft: project the draft data through the entity field\n * whitelist, apply it to the locale the draft belongs to, then delete\n * the draft and (if versionable) record a version snapshot.\n *\n * Locale routing mirrors the admin PATCH handler (and the taxonomy fix\n * in 10a7cc2b): a default-locale draft writes the base row; a\n * non-default-locale draft routes translatable fields to\n * `saveTranslation` (→ `{entity}_translations`), `status` to the\n * per-locale status table, and only non-translatable fields (which have\n * no per-locale home) to the base row. Without this split, publishing\n * an `et` draft overwrote the default-locale base row wholesale.\n */\nexport async function publishDraft<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n locale = DEFAULT_LOCALE_SENTINEL,\n user?: { id: string; name?: string },\n): Promise<InferEntityDTO<F>> {\n validateLocale(locale)\n if (!getDraftsClient(ctx)) {\n throw new EntityCapabilityError(ctx.entity.name, 'drafts')\n }\n const draft = await getDraft(ctx, entityId, locale)\n if (!draft) {\n throw new DraftNotFoundError(entityId, locale)\n }\n\n // Get previous state for the version delta — locale-merged, so the\n // delta compares against what this locale's readers actually saw.\n let previousState: InferEntityDTO<F> | null = null\n if (ctx.versionableOptions) {\n previousState = await ctx.admin.findById(entityId, localeFindOptions(locale))\n }\n\n // Apply draft data, projecting through the entity's field whitelist:\n // anything not declared in `entity.allFields` (or marked `internal:\n // true`) is silently dropped. NB `status` IS a declared publishable()\n // field and survives the projection — the publish-permission gate lives\n // in the admin route (`requirePermission(…, 'publish')` before\n // publishDraft is called), not here.\n const draftData = draft.data as Record<string, unknown>\n // \"Publish\" means MAKE LIVE. Block-editor drafts carry only content\n // (layout/theme/meta) — without this merge nothing ever flipped `status`,\n // so publishing promoted the content while the document stayed a draft\n // (badge \"Draft\", second click 409 — PR 23 user-smoke find). A draft that\n // explicitly carries `status` (form-save drafts can) wins; the\n // publishable() behavior auto-sets `publishedAt` on the transition.\n // `!== undefined` rather than `in` — a programmatic saveDraft can store\n // `status: undefined` (JSON can't), and that must not skip the merge.\n const dataWithStatus =\n draftData.status !== undefined ? draftData : { ...draftData, status: 'published' }\n const updateData = projectToEntityFields(ctx.entity, dataWithStatus)\n const result = await applyLocaleRoutedUpdate(ctx, entityId, updateData, locale)\n\n await deleteDraft(ctx, entityId, locale)\n\n if (ctx.versionableOptions) {\n await createVersionSnapshot(ctx, entityId, result, previousState, locale, user)\n }\n\n return result\n}\n\n/**\n * Quick existence check for list badges — does a draft exist?\n */\nexport async function hasDraft(\n ctx: ContentContext,\n entityId: string,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<boolean> {\n validateLocale(locale)\n const client = getDraftsClient(ctx)\n if (!client) return false\n return client.exists({ entityId, locale })\n}\n\n/**\n * Count drafts for this entity type, optionally filtered by locale.\n * Single indexed COUNT — used by dashboard widgets.\n */\nexport async function countDrafts(\n ctx: ContentContext,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<number> {\n validateLocale(locale)\n const client = getDraftsClient(ctx)\n if (!client) return 0\n return client.count({ locale })\n}\n\n/**\n * Check draft existence for multiple entities in one query. Returns\n * a set of entity IDs that have a draft for `locale`.\n */\nexport async function hasDraftBatch(\n ctx: ContentContext,\n entityIds: string[],\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<Set<string>> {\n validateLocale(locale)\n const result = new Set<string>()\n if (entityIds.length === 0) return result\n const client = getDraftsClient(ctx)\n if (!client) return result\n\n const rows = await client.findMany({\n where: { entityId: { in: entityIds }, locale },\n })\n for (const row of rows) {\n result.add(row.entityId as string)\n }\n return result\n}\n\n/**\n * Compare the current draft with the live entity. Returns field-level\n * diffs between the published data and the draft (only for fields\n * present in the draft — missing fields are NOT \"changed to empty\").\n */\nexport async function compareDraftWithCurrent<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<{\n diffs: FieldDiff[]\n current: InferEntityDTO<F>\n draft: DraftEntry<InferUpdateInput<F>>\n} | null> {\n validateLocale(locale)\n const draft = await getDraft(ctx, entityId, locale)\n if (!draft) return null\n\n // Fetch the live entity with the same locale context as the draft.\n const config = getContentConfig()\n const defaultLocale = config.defaultLocale\n const findOptions =\n locale === DEFAULT_LOCALE_SENTINEL ? { defaultLocale } : { locale, defaultLocale }\n const current = await ctx.admin.findById(entityId, findOptions)\n if (!current) return null\n\n const fieldConfigs = ctx.entity.allFields as Record<string, FieldConfig> | undefined\n const currentRecord = current as Record<string, unknown>\n const draftRecord = draft.data as Record<string, unknown>\n const currentSubset: Record<string, unknown> = {}\n for (const key of Object.keys(draftRecord)) {\n currentSubset[key] = currentRecord[key]\n }\n const diffs = diffFields(currentSubset, draftRecord, fieldConfigs)\n for (const diff of diffs) {\n if (diff.type === 'blockTree') {\n // Normalize a null/undefined side to [] so add/clear of the whole field\n // (null ↔ blockTree[]) still produces a structural diff, not an opaque one.\n diff.blockDiffs = diffBlocks(\n Array.isArray(diff.from) ? diff.from : [],\n Array.isArray(diff.to) ? diff.to : [],\n )\n }\n }\n\n return { diffs, current, draft }\n}\n","/**\n * Translation status reporting — per-locale completion of translatable\n * fields for one entity, batches of entities, or the whole entity type.\n *\n * Capability: requires the entity to have at least one translatable\n * field. All methods return falsy / empty when the entity has no\n * translatable fields (read-only and defensive).\n */\n\nimport 'server-only'\n\nimport { schemaRegistry } from '@murumets-ee/db'\nimport type { FieldConfig } from '@murumets-ee/entity'\nimport { inArray } from 'drizzle-orm'\nimport type { ContentContext } from './internal/context.js'\nimport { getContentConfig } from './plugin.js'\nimport type { TranslationReport, TranslationStatus } from './types.js'\n\n/**\n * For one entity, compute per-locale completion status across every\n * translatable field. Default-locale rows are skipped.\n */\nfunction computeStatusForEntity(\n translatableFields: string[],\n translatedByLocale: Map<string, Record<string, unknown>> | undefined,\n locales: { code: string }[],\n defaultLocale: string,\n): TranslationStatus {\n const status: TranslationStatus = {}\n for (const locale of locales) {\n if (locale.code === defaultLocale) continue\n const translated = translatedByLocale?.get(locale.code) ?? {}\n const missing: string[] = []\n for (const fieldName of translatableFields) {\n if (translated[fieldName] === undefined || translated[fieldName] === null) {\n missing.push(fieldName)\n }\n }\n status[locale.code] = {\n complete: missing.length === 0,\n total: translatableFields.length,\n translated: translatableFields.length - missing.length,\n missing,\n }\n }\n return status\n}\n\n/**\n * Get translation status for an entity — per-locale completion report.\n */\nexport async function getTranslationStatus(\n ctx: ContentContext,\n entityId: string,\n): Promise<TranslationStatus> {\n // Single-entity is the batch path with one ID — keeps the two\n // codepaths from drifting.\n const map = await getTranslationStatusBatch(ctx, [entityId])\n return map.get(entityId) ?? {}\n}\n\n/**\n * Get translation status for multiple entities in a single query.\n * O(1) DB roundtrips regardless of `entityIds.length`.\n */\nexport async function getTranslationStatusBatch(\n ctx: ContentContext,\n entityIds: string[],\n): Promise<Map<string, TranslationStatus>> {\n const result = new Map<string, TranslationStatus>()\n if (entityIds.length === 0) return result\n\n const config = getContentConfig()\n\n const translatableFields = Object.entries(ctx.entity.allFields as Record<string, FieldConfig>)\n .filter(([_, f]) => f.translatable)\n .map(([name]) => name)\n if (translatableFields.length === 0) return result\n\n // Translation tables are generated in @murumets-ee/entity (with FK\n // refs); accessed via schemaRegistry + direct Drizzle here.\n const translationTable = schemaRegistry.get(`${ctx.entity.name}_translations`)\n if (!translationTable) return result\n\n const translations = await ctx.db\n .select()\n .from(translationTable)\n .where(inArray(translationTable.entityId, entityIds))\n\n // Group: entityId → locale → { fieldName: value }\n const translationsByEntity = new Map<string, Map<string, Record<string, unknown>>>()\n for (const t of translations) {\n const row = t as Record<string, unknown>\n const eid = row.entityId as string\n const locale = row.locale as string\n const fieldValues: Record<string, unknown> = {}\n for (const fieldName of translatableFields) {\n fieldValues[fieldName] = row[fieldName]\n }\n let entry = translationsByEntity.get(eid)\n if (!entry) {\n entry = new Map()\n translationsByEntity.set(eid, entry)\n }\n entry.set(locale, fieldValues)\n }\n\n for (const entityId of entityIds) {\n result.set(\n entityId,\n computeStatusForEntity(\n translatableFields,\n translationsByEntity.get(entityId),\n config.locales,\n config.defaultLocale,\n ),\n )\n }\n\n return result\n}\n\n/**\n * Roll up translation status across every entity of this type.\n *\n * Defaults to the first non-default configured locale; pass\n * `options.locale` to target a specific one. Returns counts of\n * complete / partial / missing entities for that locale.\n */\nexport async function getTranslationReport(\n ctx: ContentContext,\n options?: { locale?: string },\n): Promise<TranslationReport> {\n const config = getContentConfig()\n const targetLocale =\n options?.locale ?? config.locales.find((l) => l.code !== config.defaultLocale)?.code\n if (!targetLocale) return { total: 0, complete: 0, partial: 0, missing: 0 }\n\n const entities = await ctx.admin.findMany()\n if (entities.length === 0) return { total: 0, complete: 0, partial: 0, missing: 0 }\n\n // Single batched query covers every entity at once.\n const statusMap = await getTranslationStatusBatch(\n ctx,\n entities.map((e) => e.id),\n )\n\n let complete = 0\n let partial = 0\n let missing = 0\n for (const entity of entities) {\n const status = statusMap.get(entity.id)\n const localeStatus = status?.[targetLocale]\n if (!localeStatus || localeStatus.translated === 0) missing++\n else if (localeStatus.complete) complete++\n else partial++\n }\n\n return { total: entities.length, complete, partial, missing }\n}\n","/**\n * ContentClient — thin shell over per-feature modules.\n *\n * Wraps AdminClient with version history (`./versions.ts`), draft\n * overlays (`./drafts.ts`), translation status reporting\n * (`./translation-status.ts`), per-locale publish status\n * (`./locale-status.ts`), and block-layout operations (`./layout.ts`).\n *\n * The class is generic over the entity's `AllFields` map: when\n * constructed with a typed entity, every method's input/output is\n * inferred end-to-end. When constructed with a runtime `Entity`\n * (admin-ui's heterogeneous entity loops), the generic defaults to\n * `Record<string, FieldConfig>` and methods accept/return the loose\n * shape — same behavior as the pre-generic version.\n *\n * Usage:\n * import { createContentClient } from '@murumets-ee/content/client'\n * const pages = createContentClient(Page)\n * await pages.create({ title: 'Hello', layout: [...] })\n */\n\nimport 'server-only'\n\nimport { getApp, type Logger } from '@murumets-ee/core'\nimport { createAdminClient } from '@murumets-ee/core/clients'\nimport type { Entity, FieldConfig } from '@murumets-ee/entity'\nimport type {\n AdminClient,\n FindManyOptions,\n InferCreateInput,\n InferEntityDTO,\n InferUpdateInput,\n} from '@murumets-ee/entity/admin'\nimport { getVersionableOptions } from './behaviors/versionable.js'\nimport {\n compareDraftWithCurrent,\n countDrafts,\n deleteDraft,\n getDraft,\n hasDraft,\n hasDraftBatch,\n publishDraft,\n saveDraft,\n} from './drafts.js'\nimport { type ContentContext, createContentContext } from './internal/context.js'\nimport { calculateDelta } from './internal/projection.js'\nimport { getLocaleStatus, getLocaleStatusBatch, setLocaleStatus } from './locale-status.js'\nimport {\n getTranslationReport,\n getTranslationStatus,\n getTranslationStatusBatch,\n} from './translation-status.js'\nimport {\n compareVersions,\n createVersionSnapshot,\n getVersion,\n getVersions,\n pruneVersions,\n restoreVersion,\n saveVersionSnapshot,\n writeVersionColumn,\n} from './versions.js'\n\nexport interface ContentClientConfig<\n AllFields extends Record<string, FieldConfig> = Record<string, FieldConfig>,\n> {\n admin: AdminClient<AllFields>\n entity: Entity<AllFields>\n logger?: Logger\n}\n\nexport class ContentClient<\n AllFields extends Record<string, FieldConfig> = Record<string, FieldConfig>,\n> {\n private ctx: ContentContext<AllFields>\n\n constructor(config: ContentClientConfig<AllFields>) {\n const versionableOptions = getVersionableOptions(config.entity.behaviors)\n this.ctx = createContentContext(config, versionableOptions)\n }\n\n // -----------------------------------------------------------------\n // CRUD (delegated to AdminClient + version snapshots)\n // -----------------------------------------------------------------\n\n async create(data: InferCreateInput<AllFields>): Promise<InferEntityDTO<AllFields>> {\n const result = await this.ctx.admin.create(data)\n if (this.ctx.versionableOptions) {\n await saveVersionSnapshot(this.ctx, result.id, result, null, false)\n }\n return result\n }\n\n findById(id: string, options?: { locale?: string }): Promise<InferEntityDTO<AllFields> | null> {\n return this.ctx.admin.findById(id, options)\n }\n\n findMany(options?: FindManyOptions): Promise<InferEntityDTO<AllFields>[]> {\n return this.ctx.admin.findMany(options)\n }\n\n /**\n * Base-row update + version snapshot. Subject to AdminClient's\n * cross-locale write guard (SD021): under a non-default request-context\n * locale, translatable fields throw `CrossLocaleWriteError` — that's\n * enforcement, not a gap. Locale-belonging writes go through the draft\n * flow (`saveDraft` → `publishDraft`) or `admin.updateForLocale` directly.\n */\n async update(id: string, data: InferUpdateInput<AllFields>): Promise<InferEntityDTO<AllFields>> {\n let previousState: InferEntityDTO<AllFields> | null = null\n if (this.ctx.versionableOptions) {\n previousState = await this.ctx.admin.findById(id)\n }\n\n const result = await this.ctx.admin.update(id, data)\n\n if (this.ctx.versionableOptions && previousState) {\n const delta = calculateDelta(previousState, result)\n const version = await saveVersionSnapshot(this.ctx, id, result, delta, false)\n if (version !== undefined) {\n await writeVersionColumn(this.ctx, id, version)\n await pruneVersions(this.ctx, id)\n return { ...result, _version: version } as InferEntityDTO<AllFields>\n }\n await pruneVersions(this.ctx, id)\n }\n\n return result\n }\n\n delete(id: string): Promise<unknown> {\n return this.ctx.admin.delete(id)\n }\n\n // -----------------------------------------------------------------\n // Translation delegation\n // -----------------------------------------------------------------\n\n saveTranslation(entityId: string, locale: string, translations: Record<string, unknown>) {\n return this.ctx.admin.saveTranslation(entityId, locale, translations)\n }\n\n deleteTranslation(entityId: string, locale?: string) {\n return this.ctx.admin.deleteTranslation(entityId, locale)\n }\n\n // -----------------------------------------------------------------\n // Version history\n // -----------------------------------------------------------------\n\n getVersions(entityId: string, options?: { limit?: number; offset?: number; locale?: string }) {\n return getVersions(this.ctx, entityId, options)\n }\n\n getVersion(entityId: string, version: number, locale?: string) {\n return getVersion(this.ctx, entityId, version, locale)\n }\n\n compareVersions(entityId: string, v1: number, v2: number, locale?: string) {\n return compareVersions(this.ctx, entityId, v1, v2, locale)\n }\n\n restoreVersion(entityId: string, version: number, locale?: string) {\n return restoreVersion(this.ctx, entityId, version, locale)\n }\n\n pruneVersions(entityId: string, locale?: string) {\n return pruneVersions(this.ctx, entityId, locale)\n }\n\n createVersionSnapshot(\n entityId: string,\n currentData: Record<string, unknown>,\n previousData?: Record<string, unknown> | null,\n locale?: string,\n user?: { id: string; name?: string },\n ) {\n return createVersionSnapshot(this.ctx, entityId, currentData, previousData, locale, user)\n }\n\n // -----------------------------------------------------------------\n // Translation status\n // -----------------------------------------------------------------\n\n getTranslationStatus(entityId: string) {\n return getTranslationStatus(this.ctx, entityId)\n }\n\n getTranslationStatusBatch(entityIds: string[]) {\n return getTranslationStatusBatch(this.ctx, entityIds)\n }\n\n getTranslationReport(options?: { locale?: string }) {\n return getTranslationReport(this.ctx, options)\n }\n\n // -----------------------------------------------------------------\n // Per-locale publish status\n // -----------------------------------------------------------------\n\n getLocaleStatus(entityId: string) {\n return getLocaleStatus(this.ctx, entityId)\n }\n\n setLocaleStatus(entityId: string, locale: string, status: 'draft' | 'published') {\n return setLocaleStatus(this.ctx, entityId, locale, status)\n }\n\n getLocaleStatusBatch(entityIds: string[]) {\n return getLocaleStatusBatch(this.ctx, entityIds)\n }\n\n // -----------------------------------------------------------------\n // Drafts\n // -----------------------------------------------------------------\n\n saveDraft(\n entityId: string,\n data: InferUpdateInput<AllFields>,\n locale?: string,\n user?: { id: string; name?: string },\n ) {\n return saveDraft(this.ctx, entityId, data, locale, user)\n }\n\n getDraft(entityId: string, locale?: string) {\n return getDraft(this.ctx, entityId, locale)\n }\n\n deleteDraft(entityId: string, locale?: string) {\n return deleteDraft(this.ctx, entityId, locale)\n }\n\n publishDraft(entityId: string, locale?: string, user?: { id: string; name?: string }) {\n return publishDraft(this.ctx, entityId, locale, user)\n }\n\n hasDraft(entityId: string, locale?: string) {\n return hasDraft(this.ctx, entityId, locale)\n }\n\n countDrafts(locale?: string) {\n return countDrafts(this.ctx, locale)\n }\n\n hasDraftBatch(entityIds: string[], locale?: string) {\n return hasDraftBatch(this.ctx, entityIds, locale)\n }\n\n compareDraftWithCurrent(entityId: string, locale?: string) {\n return compareDraftWithCurrent(this.ctx, entityId, locale)\n }\n}\n\n/**\n * Factory — creates a ContentClient for an entity. Must be called\n * after `createApp()`. Generic flows from the entity definition:\n * `createContentClient(Article)` returns a `ContentClient<typeof\n * Article['allFields']>` typed end-to-end.\n */\nexport function createContentClient<AllFields extends Record<string, FieldConfig>>(\n entity: Entity<AllFields>,\n): ContentClient<AllFields> {\n const admin = createAdminClient(entity)\n return new ContentClient<AllFields>({\n admin,\n entity,\n logger: getApp().logger.child({ content: entity.name }),\n })\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"client.mjs","names":[],"sources":["../src/diff/text-diff.ts","../src/diff/field-diff.ts","../src/diff/block-diff.ts","../src/internal/context.ts","../src/internal/apply-locale-routed-update.ts","../src/internal/projection.ts","../src/versions.ts","../src/drafts.ts","../src/locale-status.ts","../src/translation-status.ts","../src/client.ts"],"sourcesContent":["/**\n * Word-level text diff using a simplified Myers algorithm.\n *\n * Splits text into words (preserving whitespace), computes the longest common\n * subsequence, and returns an array of diff segments.\n */\n\nimport type { DiffSegment } from './types.js'\n\n/**\n * Split text into tokens — words and whitespace runs — so diffs are\n * human-readable (\"Changed *this* word\") rather than character-level.\n */\nfunction tokenize(text: string): string[] {\n return text.match(/\\S+|\\s+/g) ?? []\n}\n\n/**\n * Compute word-level diff between two strings.\n *\n * Uses a simple LCS (dynamic programming) approach which is fine for\n * the text sizes we deal with (entity field values, not source code).\n */\nexport function diffText(from: string, to: string): DiffSegment[] {\n if (from === to) {\n return from ? [{ type: 'equal', value: from }] : []\n }\n if (!from) {\n return [{ type: 'add', value: to }]\n }\n if (!to) {\n return [{ type: 'remove', value: from }]\n }\n\n const fromTokens = tokenize(from)\n const toTokens = tokenize(to)\n\n // Build LCS table\n const m = fromTokens.length\n const n = toTokens.length\n const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0))\n\n for (let i = 1; i <= m; i++) {\n const dpI = dp[i]\n const dpIm1 = dp[i - 1]\n if (!dpI || !dpIm1) continue\n for (let j = 1; j <= n; j++) {\n if (fromTokens[i - 1] === toTokens[j - 1]) {\n dpI[j] = (dpIm1[j - 1] ?? 0) + 1\n } else {\n dpI[j] = Math.max(dpIm1[j] ?? 0, dpI[j - 1] ?? 0)\n }\n }\n }\n\n // Backtrack to build diff segments\n const segments: DiffSegment[] = []\n let i = m\n let j = n\n\n // Collect in reverse, then flip\n const reversed: DiffSegment[] = []\n\n while (i > 0 || j > 0) {\n const fromTok = i > 0 ? fromTokens[i - 1] : undefined\n const toTok = j > 0 ? toTokens[j - 1] : undefined\n if (i > 0 && j > 0 && fromTok !== undefined && fromTok === toTok) {\n reversed.push({ type: 'equal', value: fromTok })\n i--\n j--\n } else if (j > 0 && toTok !== undefined && (i === 0 || (dp[i]?.[j - 1] ?? 0) >= (dp[i - 1]?.[j] ?? 0))) {\n reversed.push({ type: 'add', value: toTok })\n j--\n } else if (fromTok !== undefined) {\n reversed.push({ type: 'remove', value: fromTok })\n i--\n } else {\n // Defensive: i and j both 0, but loop condition implied otherwise.\n break\n }\n }\n\n reversed.reverse()\n\n // Merge consecutive segments of the same type\n for (const seg of reversed) {\n const last = segments[segments.length - 1]\n if (last && last.type === seg.type) {\n last.value += seg.value\n } else {\n segments.push({ ...seg })\n }\n }\n\n return segments\n}\n","/**\n * Field-level diff — dispatches to the appropriate diff strategy per field type.\n */\n\nimport type { FieldConfig } from '@murumets-ee/entity'\nimport { diffText } from './text-diff.js'\nimport type { DiffSegment, FieldDiff } from './types.js'\n\n/** Strip HTML tags for diffing richtext content as plain text. */\nfunction stripHtml(html: string): string {\n return html\n .replace(/<[^>]*>/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim()\n}\n\n/** Stringify a value for display (dates, objects, etc.). */\nfunction stringify(value: unknown): string {\n if (value === null || value === undefined) return ''\n if (value instanceof Date) return value.toISOString()\n if (typeof value === 'object') return JSON.stringify(value)\n return String(value)\n}\n\n/**\n * Compute diff for a single field, using the field's type to determine\n * the best diff strategy.\n */\nexport function diffField(\n fieldName: string,\n from: unknown,\n to: unknown,\n fieldConfig?: FieldConfig,\n): FieldDiff | null {\n // No change\n if (JSON.stringify(from) === JSON.stringify(to)) return null\n\n const type = fieldConfig?.type ?? inferType(from, to)\n\n switch (type) {\n case 'text':\n case 'slug': {\n const segments = diffText(stringify(from), stringify(to))\n return { field: fieldName, type: 'text', from, to, segments }\n }\n\n case 'richtext': {\n // Diff on stripped HTML so users see word changes, not tag noise\n const segments = diffText(stripHtml(stringify(from)), stripHtml(stringify(to)))\n return { field: fieldName, type: 'richtext', from, to, segments }\n }\n\n case 'number':\n return { field: fieldName, type: 'number', from, to }\n\n case 'boolean':\n return { field: fieldName, type: 'boolean', from, to }\n\n case 'date':\n return { field: fieldName, type: 'date', from, to }\n\n case 'select':\n return { field: fieldName, type: 'select', from, to }\n\n case 'media':\n return { field: fieldName, type: 'media', from, to }\n\n case 'json':\n return { field: fieldName, type: 'json', from, to }\n\n case 'blockTree':\n // Block tree — block-diff.ts computes the structural diff; just mark the\n // field type so compareVersions/compareDraftWithCurrent populate\n // `blockDiffs`.\n return { field: fieldName, type: 'blockTree', from, to }\n\n default:\n return { field: fieldName, type: 'unknown', from, to }\n }\n}\n\n/**\n * Diff all fields between two version snapshots.\n * Pass fieldConfigs to get type-aware diffs; without them, types are inferred.\n */\nexport function diffFields(\n from: Record<string, unknown>,\n to: Record<string, unknown>,\n fieldConfigs?: Record<string, FieldConfig>,\n): FieldDiff[] {\n const allKeys = new Set([...Object.keys(from), ...Object.keys(to)])\n const diffs: FieldDiff[] = []\n\n for (const key of allKeys) {\n // Skip internal/auto-generated fields\n if (key === 'id' || key === 'createdAt' || key === 'updatedAt') continue\n\n const diff = diffField(key, from[key], to[key], fieldConfigs?.[key])\n if (diff) diffs.push(diff)\n }\n\n return diffs\n}\n\n/** Best-effort type inference when no FieldConfig is available. */\nfunction inferType(from: unknown, to: unknown): string {\n const val = from ?? to\n if (typeof val === 'number') return 'number'\n if (typeof val === 'boolean') return 'boolean'\n if (val instanceof Date) return 'date'\n if (typeof val === 'string') {\n if (/^\\d{4}-\\d{2}-\\d{2}/.test(val)) return 'date'\n return 'text'\n }\n if (Array.isArray(val)) {\n const first = val[0]\n if (val.length > 0 && typeof first === 'object' && first !== null) {\n // Block tree: instances discriminated by `type` + `id`.\n if ('type' in first && 'id' in first) return 'blockTree'\n }\n return 'json'\n }\n if (typeof val === 'object') return 'json'\n return 'unknown'\n}\n\n/** Diff two string values and return segments (used by block-diff for string props). */\nexport function diffStringValues(from: unknown, to: unknown): DiffSegment[] | undefined {\n if (typeof from === 'string' || typeof to === 'string') {\n return diffText(stringify(from), stringify(to))\n }\n return undefined\n}\n","/**\n * Structural diff for block-tree arrays (`field.blockTree()` values).\n *\n * Operates on new-format `BlockInstance`s (`{ id, type, props, traits,\n * children }`) — the only block shape the toolkit stores. Blocks are\n * identified by their `id`. The algorithm detects:\n * - Added blocks (new id in target)\n * - Removed blocks (missing id in target)\n * - Moved blocks (same id, different position)\n * - Changed blocks (same id, different prop/trait/child values)\n * - Unchanged blocks\n */\n\nimport { diffStringValues } from './field-diff.js'\nimport type { BlockDiffEntry, PropDiff } from './types.js'\n\n/** Comparable view of a block instance. */\ninterface NormalizedBlock {\n id: string\n type: string\n /** Comparable content map: `props` + folded-in `traits` / `children`. */\n props: Record<string, unknown>\n}\n\n/**\n * Normalize a `BlockInstance` to its comparable `{ id, type, props }` view,\n * or null when the value isn't a recognizable block. `traits` and `children`\n * fold into the comparable map so trait edits and nested-tree changes surface\n * as prop diffs (`diffProps` skips JSON-equal values, so empty/unchanged ones\n * add no noise).\n */\nfunction normalizeBlock(val: unknown): NormalizedBlock | null {\n if (typeof val !== 'object' || val === null) return null\n const rec = val as Record<string, unknown>\n\n if (typeof rec.id !== 'string' || typeof rec.type !== 'string') return null\n\n const props: Record<string, unknown> = { ...((rec.props as Record<string, unknown>) ?? {}) }\n if (rec.traits !== undefined) props.traits = rec.traits\n if (rec.children !== undefined) props.children = rec.children\n return { id: rec.id, type: rec.type, props }\n}\n\n/**\n * Diff two prop objects within a single block, returning per-prop diffs.\n */\nfunction diffProps(\n fromProps: Record<string, unknown>,\n toProps: Record<string, unknown>,\n): PropDiff[] {\n const allKeys = new Set([...Object.keys(fromProps), ...Object.keys(toProps)])\n const diffs: PropDiff[] = []\n\n for (const key of allKeys) {\n const fromVal = fromProps[key]\n const toVal = toProps[key]\n\n if (JSON.stringify(fromVal) !== JSON.stringify(toVal)) {\n diffs.push({\n field: key,\n from: fromVal,\n to: toVal,\n segments: diffStringValues(fromVal, toVal),\n })\n }\n }\n\n return diffs\n}\n\n/**\n * Index a block array by id, assigning each block its position AMONG\n * recognizable blocks (not its raw array index). Values that fail\n * normalization are skipped without consuming a position, so a stray\n * non-block entry can't skew move detection for the real blocks around it.\n */\nfunction indexBlocks(blocks: unknown[]): Map<string, { block: NormalizedBlock; index: number }> {\n const map = new Map<string, { block: NormalizedBlock; index: number }>()\n let position = 0\n for (const raw of blocks) {\n const block = normalizeBlock(raw)\n if (block) {\n map.set(block.id, { block, index: position })\n position++\n }\n }\n return map\n}\n\n/**\n * Compute structural diff between two block arrays.\n *\n * Each block is identified by its id. Position changes, additions, removals,\n * and prop-level changes are all detected.\n */\nexport function diffBlocks(fromBlocks: unknown[], toBlocks: unknown[]): BlockDiffEntry[] {\n const fromMap = indexBlocks(fromBlocks)\n const toMap = indexBlocks(toBlocks)\n\n const entries: BlockDiffEntry[] = []\n const processedIds = new Set<string>()\n\n // Walk the \"to\" array to preserve target order\n for (const [id, { block: toBlock, index: toIndex }] of toMap) {\n processedIds.add(id)\n const fromEntry = fromMap.get(id)\n\n if (!fromEntry) {\n // Added\n entries.push({\n blockId: id,\n blockType: toBlock.type,\n status: 'added',\n toIndex,\n })\n continue\n }\n\n const fromBlock = fromEntry.block\n const fromIndex = fromEntry.index\n const propDiffs = diffProps(fromBlock.props, toBlock.props)\n const moved = fromIndex !== toIndex\n\n if (propDiffs.length === 0 && !moved) {\n entries.push({\n blockId: id,\n blockType: toBlock.type,\n status: 'unchanged',\n fromIndex,\n toIndex,\n })\n } else if (propDiffs.length > 0) {\n entries.push({\n blockId: id,\n blockType: toBlock.type,\n status: 'changed',\n fromIndex,\n toIndex,\n propDiffs,\n })\n } else {\n entries.push({\n blockId: id,\n blockType: toBlock.type,\n status: 'moved',\n fromIndex,\n toIndex,\n })\n }\n }\n\n // Removed blocks (in \"from\" but not in \"to\")\n for (const [id, { block, index }] of fromMap) {\n if (!processedIds.has(id)) {\n entries.push({\n blockId: id,\n blockType: block.type,\n status: 'removed',\n fromIndex: index,\n })\n }\n }\n\n return entries\n}\n","/**\n * Shared per-instance state for ContentClient feature modules.\n *\n * The package's domain modules (`versions.ts`, `drafts.ts`,\n * `translation-status.ts`, `locale-status.ts`, `layout.ts`) are written\n * as free functions that take a `ContentContext` as their first\n * argument. ContentClient itself is a thin shell that builds the\n * context once and forwards every public method to the corresponding\n * free function.\n *\n * Why free functions instead of one class with everything inline:\n * - 1000+ LoC in one class is impossible to navigate and audit.\n * - Each module has clear inputs (the context + method args) and no\n * hidden dependencies on private state.\n * - Splits cleanly along the same boundaries as the per-entity\n * capability tables (drafts, versions, locale_status, ...) — every\n * module owns exactly one capability surface.\n */\n\nimport 'server-only'\n\nimport type { Logger } from '@murumets-ee/core'\nimport type { Entity, FieldConfig } from '@murumets-ee/entity'\nimport type { AdminClient } from '@murumets-ee/entity/admin'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\nimport type { getVersionableOptions } from '../behaviors/versionable.js'\nimport { getContentTables } from '../schema/registry.js'\n\n/**\n * Per-instance content context. Built once in `createContentContext()`\n * and threaded through every domain module's free functions.\n */\nexport interface ContentContext<\n AllFields extends Record<string, FieldConfig> = Record<string, FieldConfig>,\n> {\n entity: Entity<AllFields>\n admin: AdminClient<AllFields>\n db: PostgresJsDatabase\n versionableOptions: ReturnType<typeof getVersionableOptions>\n logger?: Logger | undefined\n}\n\n/**\n * Build a content context from an AdminClient + entity. The DB handle\n * is read off the AdminClient (it owns the read-write connection).\n */\nexport function createContentContext<AllFields extends Record<string, FieldConfig>>(\n config: {\n admin: AdminClient<AllFields>\n entity: Entity<AllFields>\n logger?: Logger\n },\n // Lazy import to avoid a static cycle: behaviors/versionable.ts is loaded\n // by client.ts which would otherwise close the loop. Phase-2 validated\n // the static import graph; this is a private helper bundled into the\n // same chunk so the dynamic-vs-static rule doesn't apply.\n versionableOptions: ReturnType<typeof getVersionableOptions>,\n): ContentContext<AllFields> {\n return {\n entity: config.entity,\n admin: config.admin,\n db: config.admin.getDb(),\n versionableOptions,\n logger: config.logger,\n }\n}\n\n// -------------------------------------------------------------------\n// Per-capability TableClient lookups. Each returns null if the entity\n// hasn't registered the corresponding table (i.e. doesn't have the\n// behavior). ContentClient public methods translate that null into\n// either a thrown EntityCapabilityError (writes) or a falsy return\n// (reads).\n// -------------------------------------------------------------------\n\nexport function getDraftsClient(ctx: ContentContext) {\n const { drafts } = getContentTables(ctx.entity.name)\n return drafts ? drafts.makeClient(ctx.db) : null\n}\n\nexport function getVersionsClient(ctx: ContentContext) {\n const { versions } = getContentTables(ctx.entity.name)\n return versions ? versions.makeClient(ctx.db) : null\n}\n\nexport function getLocaleStatusClient(ctx: ContentContext) {\n const { localeStatus } = getContentTables(ctx.entity.name)\n return localeStatus ? localeStatus.makeClient(ctx.db) : null\n}\n","/**\n * Locale-routed entity write shared by `publishDraft` and `restoreVersion`.\n *\n * Since SD021, the routing itself lives in ONE place —\n * `AdminClient.updateForLocale` (`@murumets-ee/entity`): default locale →\n * base row; non-default → translatable fields to `saveTranslation`,\n * `status` to the per-locale status store, non-translatable remainder to\n * the base row. This module is the thin content-side call: it supplies the\n * plugin's `defaultLocale` and a per-call locale-status writer bound to\n * THIS context's tables, so routing is deterministic regardless of how\n * `ctx.admin` was constructed.\n *\n * Lives in its own internal module because `drafts.ts` imports\n * `versions.ts` (for `createVersionSnapshot`) — exporting this from\n * either of them would close an import cycle.\n */\n\nimport type { FieldConfig } from '@murumets-ee/entity'\nimport type { InferEntityDTO, InferUpdateInput } from '@murumets-ee/entity/admin'\nimport { upsertLocaleStatusRow } from '../locale-status-slot.js'\nimport { DEFAULT_LOCALE_SENTINEL, validateLocale } from '../locale-utils.js'\nimport { getContentConfig } from '../plugin.js'\nimport { type ContentContext, getLocaleStatusClient } from './context.js'\n\n/** Is this snapshot-key locale the default locale (base-row) one? */\nexport function isDefaultLocale(locale: string): boolean {\n return locale === DEFAULT_LOCALE_SENTINEL || locale === getContentConfig().defaultLocale\n}\n\n/**\n * `findById` options that read the same locale view the write targets —\n * default locale reads the base row, non-default reads the\n * translation-merged row.\n */\nexport function localeFindOptions(\n locale: string,\n): { defaultLocale: string } | { locale: string; defaultLocale: string } {\n const { defaultLocale } = getContentConfig()\n return isDefaultLocale(locale) ? { defaultLocale } : { locale, defaultLocale }\n}\n\n/**\n * Apply projected entity-field data to the given locale via the SD021\n * choke point. `updateData` MUST already be projected through\n * `projectToEntityFields` — this function only delegates, it does not\n * whitelist.\n */\nexport async function applyLocaleRoutedUpdate<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n updateData: Record<string, unknown>,\n locale: string,\n): Promise<InferEntityDTO<F>> {\n // Bind the status writer to THIS context's locale-status table when the\n // entity has one; otherwise let updateForLocale fall back to the base\n // status column (non-translatable publishables have only the default\n // locale).\n //\n // The writer is the RAW upsert, not `setLocaleStatus`: since the publish gate\n // moved INTO `setLocaleStatus` (which now delegates to this same\n // `updateForLocale`), passing it here would re-enter the method one extra\n // level deep. `updateForLocale` has already asserted the caller's publish\n // authority from `updateData` before it reaches any writer, so the raw upsert\n // is gated by the call below — it is not a second ungated door.\n const localeStatusClient = getLocaleStatusClient(ctx)\n return ctx.admin.updateForLocale(entityId, updateData as InferUpdateInput<F>, locale, {\n defaultLocale: getContentConfig().defaultLocale,\n ...(localeStatusClient !== null && {\n localeStatusWriter: (id: string, loc: string, status: 'draft' | 'published') => {\n // Same whitelist `setLocaleStatus` applied on this path before — an\n // unconfigured locale must not mint a status row no reader will merge.\n validateLocale(loc)\n return upsertLocaleStatusRow(localeStatusClient, id, loc, status)\n },\n }),\n })\n}\n","/**\n * Field projection + delta helpers shared between drafts and versions.\n *\n * `projectToEntityFields` is the security-critical whitelist filter\n * applied before replaying stored data through `admin.update()` —\n * see audit-security-content.md M#4 (drafts/restore must not pass\n * through forbidden fields like `status: 'published'` or\n * `_workflowStatus: 'approved'`).\n *\n * `valuesEqual` and `calculateDelta` are pure utilities used by both\n * the version-snapshot delta calculator and the block-diff prop\n * comparison — collapsing 3× repeated `JSON.stringify(a) !== JSON.stringify(b)`\n * sites identified in audit-quality-content.md DRY candidates.\n */\n\nimport type { Entity, FieldConfig } from '@murumets-ee/entity'\n\n/**\n * Identity / audit / timestamp fields that exist as real columns on every\n * entity (added by `defineEntity` / `auditable()` / `timestamped()`) and\n * MUST NOT be relayed through `admin.update()` from a draft or version\n * payload. They're either server-set (`createdAt`, `createdBy`,\n * `updatedAt`, `updatedBy`) or immutable identity (`id`).\n *\n * Without this explicit deny-list, `id` would survive `projectToEntityFields`\n * because `defineEntity` injects it into `entity.allFields` and it's not\n * marked `internal: true`. AdminClient.update's Zod schema strips it\n * downstream as a final defense, but security-critical filtering should\n * live in one place — not split across the projection AND the validator.\n */\nconst PROJECTION_DENYLIST = new Set(['id', 'createdAt', 'createdBy', 'updatedAt', 'updatedBy'])\n\n/**\n * Project a draft / version payload through the entity's declared\n * field map before forwarding to `admin.update()`. Used by\n * `publishDraft()` and `restoreVersion()` — both replay stored data\n * that originated from a write, but neither should be allowed to set\n * fields the entity didn't declare.\n *\n * Drops:\n * - the audit/identity column set above (`id`, `createdAt`,\n * `createdBy`, `updatedAt`, `updatedBy`)\n * - any key starting with `_` (behavior internals managed by hooks)\n * - any field declared `internal: true` (writable only via\n * AdminClient.updateInternal — never via public CRUD or replay)\n * - any key not in `entity.allFields` (whitelist for app-defined fields)\n */\nexport function projectToEntityFields(\n entity: Entity,\n data: Record<string, unknown>,\n): Record<string, unknown> {\n const allowed = entity.allFields as Record<string, FieldConfig>\n const out: Record<string, unknown> = {}\n for (const key of Object.keys(data)) {\n if (PROJECTION_DENYLIST.has(key)) continue\n if (key.startsWith('_')) continue\n if (!Object.hasOwn(allowed, key)) continue\n const f = allowed[key]\n if (f && (f as { internal?: boolean }).internal === true) continue\n out[key] = data[key]\n }\n return out\n}\n\n/**\n * Structural equality via canonical JSON serialization. Coarse, with\n * three known quirks worth flagging at every call site:\n *\n * 1. **Key order matters in pathological cases.** Two objects with\n * identical keys+values but different insertion order serialize\n * differently. Safe for entity field values where keys come from\n * the same field map on both sides; not safe for arbitrary user\n * JSONB blobs.\n * 2. **Dates compare equal to their ISO string.** `new Date('2026-01-01')`\n * and `'2026-01-01T00:00:00.000Z'` both stringify to\n * `\"2026-01-01T00:00:00.000Z\"`. After a round-trip through JSONB\n * storage, a previously Date-typed field will come back as a\n * string and compare equal to its origin Date — exactly what we\n * want for delta calculation.\n * 3. **`undefined` is dropped.** `{ x: undefined }` and `{}` both\n * serialize to `{}`. If you need to distinguish \"field present\n * but null\" from \"field missing\", do not use this helper.\n */\nexport function valuesEqual(a: unknown, b: unknown): boolean {\n return JSON.stringify(a) === JSON.stringify(b)\n}\n\n/**\n * Field-level delta between previous and current entity snapshots.\n * Skips `id` and any underscore-prefixed behavior-internal field.\n * Only changed keys are returned. Used by version-snapshot persistence\n * to store a delta alongside the full snapshot for compact \"what\n * changed since last version\" reads.\n */\nexport function calculateDelta(\n previous: Record<string, unknown>,\n current: Record<string, unknown>,\n): Record<string, unknown> {\n const delta: Record<string, unknown> = {}\n for (const key of Object.keys(current)) {\n if (key === 'id' || key.startsWith('_')) continue\n if (!valuesEqual(previous[key], current[key])) {\n delta[key] = current[key]\n }\n }\n return delta\n}\n","/**\n * Version-history operations on a content entity.\n *\n * All functions take a {@link ContentContext} as first argument and\n * are wired into ContentClient via thin delegation in `client.ts`.\n *\n * Capability: requires the entity to declare `versionable()`. Read\n * methods return falsy when the entity doesn't support versions\n * (defensive, supports mixed-entity loops in the dashboard); write\n * methods throw `EntityCapabilityError` mapped to HTTP 400 by admin-ui.\n */\n\nimport 'server-only'\n\nimport { schemaRegistry } from '@murumets-ee/db'\nimport type { FieldConfig } from '@murumets-ee/entity'\nimport type { InferEntityDTO } from '@murumets-ee/entity/admin'\nimport { eq } from 'drizzle-orm'\nimport { diffBlocks } from './diff/block-diff.js'\nimport { diffFields } from './diff/field-diff.js'\nimport type { FieldDiff } from './diff/types.js'\nimport { EntityCapabilityError, VersionNotFoundError } from './errors.js'\nimport { applyLocaleRoutedUpdate } from './internal/apply-locale-routed-update.js'\nimport { type ContentContext, getVersionsClient } from './internal/context.js'\nimport { calculateDelta, projectToEntityFields } from './internal/projection.js'\nimport { DEFAULT_LOCALE_SENTINEL, validateLocale } from './locale-utils.js'\nimport type { VersionEntry } from './types.js'\n\n/**\n * List versions for an entity (newest first), filtered by locale.\n * Uses '_' as the locale value for default-locale snapshots.\n */\nexport async function getVersions<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n options?: { limit?: number; offset?: number; locale?: string },\n): Promise<VersionEntry<InferEntityDTO<F>>[]> {\n if (options?.locale !== undefined) validateLocale(options.locale)\n const client = getVersionsClient(ctx)\n if (!client) return []\n\n const rows = await client.findMany({\n where: { entityId, locale: options?.locale ?? DEFAULT_LOCALE_SENTINEL },\n orderBy: [{ column: 'version', dir: 'desc' }],\n ...(options?.limit !== undefined ? { limit: options.limit } : {}),\n ...(options?.offset !== undefined ? { offset: options.offset } : {}),\n })\n return rows as VersionEntry<InferEntityDTO<F>>[]\n}\n\n/**\n * Get a specific version snapshot.\n */\nexport async function getVersion<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n version: number,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<VersionEntry<InferEntityDTO<F>> | null> {\n validateLocale(locale)\n const client = getVersionsClient(ctx)\n if (!client) return null\n const row = await client.findOne({ entityId, version, locale })\n return (row ?? null) as VersionEntry<InferEntityDTO<F>> | null\n}\n\n/**\n * Compare two versions and return field-level diff.\n *\n * Returns structured diffs with word-level segments for text fields\n * and structural block diffs for blocks fields.\n */\nexport async function compareVersions<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n v1: number,\n v2: number,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<{\n diffs: FieldDiff[]\n fromVersion: VersionEntry<InferEntityDTO<F>>\n toVersion: VersionEntry<InferEntityDTO<F>>\n} | null> {\n validateLocale(locale)\n const [version1, version2] = await Promise.all([\n getVersion(ctx, entityId, v1, locale),\n getVersion(ctx, entityId, v2, locale),\n ])\n if (!version1 || !version2) return null\n\n const fieldConfigs = ctx.entity.allFields as Record<string, FieldConfig> | undefined\n const diffs = diffFields(version1.data, version2.data, fieldConfigs)\n for (const diff of diffs) {\n if (diff.type === 'blockTree') {\n // Normalize a null/undefined side to [] so add/clear of the whole field\n // (null ↔ blockTree[]) still produces a structural diff, not an opaque one.\n diff.blockDiffs = diffBlocks(\n Array.isArray(diff.from) ? diff.from : [],\n Array.isArray(diff.to) ? diff.to : [],\n )\n }\n }\n return { diffs, fromVersion: version1, toVersion: version2 }\n}\n\n/**\n * Restore an entity to a previous version. Replays the snapshot data\n * through the locale-routed writer, projected through `entity.allFields`\n * so forbidden fields (workflow internals, behavior-managed underscore\n * fields, fields the entity declared `internal: true`) cannot leak.\n *\n * Snapshots are locale-keyed; the restore reads the snapshot for the\n * given locale and writes it back to the same locale's home —\n * translations for non-default locales, the base row for the default.\n */\nexport async function restoreVersion<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n version: number,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<InferEntityDTO<F>> {\n validateLocale(locale)\n if (!getVersionsClient(ctx)) {\n throw new EntityCapabilityError(ctx.entity.name, 'versions')\n }\n const versionData = await getVersion(ctx, entityId, version, locale)\n if (!versionData) {\n throw new VersionNotFoundError(entityId, version, locale)\n }\n const data = versionData.data as Record<string, unknown>\n const updateData = projectToEntityFields(ctx.entity, data)\n return applyLocaleRoutedUpdate(ctx, entityId, updateData, locale)\n}\n\n/**\n * Enforce maxPerDoc retention by deleting oldest versions for a locale.\n */\nexport async function pruneVersions(\n ctx: ContentContext,\n entityId: string,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<void> {\n validateLocale(locale)\n if (!ctx.versionableOptions) return\n const client = getVersionsClient(ctx)\n if (!client) return\n\n const maxPerDoc = ctx.versionableOptions.maxPerDoc\n\n // Get IDs of versions to keep (newest N for this locale). The findMany\n // is capped at `limit: maxPerDoc`, so `keepVersions.length` is\n // `min(totalRows, maxPerDoc)` — never larger than the limit.\n const keepVersions = await client.findMany({\n where: { entityId, locale },\n orderBy: [{ column: 'version', dir: 'desc' }],\n limit: maxPerDoc,\n })\n\n // Strictly under the limit ⇒ totalRows < maxPerDoc ⇒ nothing to prune.\n // At the limit (length == maxPerDoc) we cannot distinguish \"exactly\n // maxPerDoc\" from \"more than maxPerDoc\" without another count query,\n // so we always run the DELETE — Postgres correctly removes 0-or-more\n // older rows. Better one always-cheap DELETE per prune than a guard\n // round trip on every call.\n if (keepVersions.length < maxPerDoc) return\n\n const keepIds = keepVersions.map((v) => v.id as string)\n\n // Delete everything for this entity+locale NOT in the keep list.\n await client.deleteMany({\n entityId,\n locale,\n id: { notIn: keepIds },\n })\n}\n\n/**\n * Persist a new version snapshot. Internal helper used by\n * `createVersionSnapshot` and ContentClient.update / .create.\n *\n * Attribution: ONLY the explicit `user` parameter is trusted. The\n * fallback chain that previously read `data.updatedBy` /\n * `data.createdBy` was an audit-log integrity gap (see audit\n * MEDIUM #1) — version history must never absorb a forged actor from\n * the request payload.\n */\nexport async function saveVersionSnapshot(\n ctx: ContentContext,\n entityId: string,\n data: Record<string, unknown>,\n delta: Record<string, unknown> | null,\n isAutosave: boolean,\n locale = DEFAULT_LOCALE_SENTINEL,\n user?: { id: string; name?: string },\n): Promise<number | undefined> {\n // All current callers pass a pre-validated locale (or the sentinel).\n // Defensive check at the boundary so future callers — or anyone who\n // exposes this helper publicly — still get the validation.\n validateLocale(locale)\n const client = getVersionsClient(ctx)\n if (!client) return\n\n // Get the next version number for this locale (max existing + 1).\n const [maxRow] = await client.aggregate<{ maxVersion: number | null }>({\n select: { maxVersion: { fn: 'max', column: 'version' } },\n where: { entityId, locale },\n })\n const version = Number(maxRow?.maxVersion ?? 0) + 1\n const status = (data.status as string) ?? null\n\n // Autosave: update the existing autosave row at the same version\n // number, preserving \"autosaves land on the latest slot\" semantics.\n if (isAutosave) {\n const existing = await client.findOne({ entityId, isAutosave: true, version, locale })\n if (existing) {\n await client.update(\n { id: existing.id as string },\n { data, delta, status, createdAt: new Date() },\n )\n return version\n }\n }\n\n await client.insert({\n entityId,\n version,\n locale,\n data,\n delta,\n status,\n createdBy: user?.id ?? null,\n createdByName: user?.name ?? null,\n isAutosave,\n })\n return version\n}\n\n/**\n * Create a version snapshot for an entity after an external update.\n * Use this when the update was performed by AdminClient directly\n * (e.g. the admin API handler) rather than through ContentClient.update().\n *\n * @param locale - The locale being edited. Use undefined or '_' for the default locale.\n * @param user - The user who made the change (id + name for display).\n */\nexport async function createVersionSnapshot(\n ctx: ContentContext,\n entityId: string,\n currentData: Record<string, unknown>,\n previousData?: Record<string, unknown> | null,\n locale?: string,\n user?: { id: string; name?: string },\n): Promise<void> {\n if (locale !== undefined) validateLocale(locale)\n if (!ctx.versionableOptions) return\n const snapshotLocale = locale ?? DEFAULT_LOCALE_SENTINEL\n const delta = previousData ? calculateDelta(previousData, currentData) : null\n const version = await saveVersionSnapshot(\n ctx,\n entityId,\n currentData,\n delta,\n false,\n snapshotLocale,\n user,\n )\n\n // Update _version on the entity to match (default locale only).\n if (version !== undefined && snapshotLocale === DEFAULT_LOCALE_SENTINEL) {\n await writeVersionColumn(ctx, entityId, version)\n }\n\n await pruneVersions(ctx, entityId, snapshotLocale)\n}\n\n/**\n * Write the `_version` column on the entity row.\n *\n * KNOWN EXCEPTION: this bypasses AdminClient and goes straight to the\n * entity pgTable via `schemaRegistry` + direct Drizzle. Using\n * `admin.update()` would retrigger the `versionable.beforeUpdate` hook\n * and loop indefinitely. The only ContentClient path that still writes\n * to an entity table directly.\n */\nexport async function writeVersionColumn(\n ctx: ContentContext,\n entityId: string,\n version: number,\n): Promise<void> {\n const table = schemaRegistry.get(ctx.entity.name)\n if (!table) return\n await ctx.db.update(table).set({ _version: version }).where(eq(table.id, entityId))\n}\n","/**\n * Draft-overlay operations.\n *\n * Capability: requires the entity to declare `publishable()`. Reads\n * (`getDraft`, `hasDraft`, `countDrafts`, `hasDraftBatch`) silently\n * return falsy when the entity doesn't support drafts; writes\n * (`saveDraft`, `publishDraft`) throw `EntityCapabilityError`.\n */\n\nimport 'server-only'\n\nimport type { FieldConfig } from '@murumets-ee/entity'\nimport type { InferEntityDTO, InferUpdateInput } from '@murumets-ee/entity/admin'\nimport { diffBlocks } from './diff/block-diff.js'\nimport { diffFields } from './diff/field-diff.js'\nimport type { FieldDiff } from './diff/types.js'\nimport { DraftNotFoundError, EntityCapabilityError } from './errors.js'\nimport {\n applyLocaleRoutedUpdate,\n localeFindOptions,\n} from './internal/apply-locale-routed-update.js'\nimport { type ContentContext, getDraftsClient } from './internal/context.js'\nimport { projectToEntityFields } from './internal/projection.js'\nimport { DEFAULT_LOCALE_SENTINEL, validateLocale } from './locale-utils.js'\nimport { getContentConfig } from './plugin.js'\nimport type { DraftsTable } from './schema/drafts.js'\nimport type { DraftEntry } from './types.js'\nimport { createVersionSnapshot } from './versions.js'\n\ntype DraftRow = NonNullable<Awaited<ReturnType<ReturnType<DraftsTable['makeClient']>['findOne']>>>\n\nfunction toDraftEntry(row: DraftRow): DraftEntry {\n return {\n id: row.id,\n entityId: row.entityId,\n locale: row.locale,\n data: row.data,\n createdBy: row.createdBy,\n createdByName: row.createdByName ?? null,\n createdAt: row.createdAt,\n updatedAt: row.updatedAt,\n }\n}\n\n/**\n * Save (upsert) a draft for an entity+locale. One draft per\n * entity+locale — overwrites any existing draft.\n *\n * `data` is projected through the entity's declared field whitelist\n * BEFORE it is stored (same `projectToEntityFields` call `publishDraft`\n * makes on the way out). Storing the caller's JSON verbatim let a draft\n * carry keys the entity never declared, identity/audit columns and\n * `internal: true` fields; publishing filtered them, but `getDraft` and\n * `compareDraftWithCurrent` echoed them straight back to the admin UI,\n * and the drafts table accumulated arbitrary caller-controlled JSON\n * (`plan/admin-api-hardening` F002). Validating at the write closes the\n * asymmetry — the stored draft is now always a subset of what the entity\n * can actually hold.\n */\nexport async function saveDraft<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n data: InferUpdateInput<F>,\n locale = DEFAULT_LOCALE_SENTINEL,\n user?: { id: string; name?: string },\n): Promise<DraftEntry<InferUpdateInput<F>>> {\n validateLocale(locale)\n const client = getDraftsClient(ctx)\n if (!client) throw new EntityCapabilityError(ctx.entity.name, 'drafts')\n\n const projected = projectToEntityFields(ctx.entity, data as Record<string, unknown>)\n\n const now = new Date()\n const createdBy = user?.id ?? ''\n const createdByName = user?.name ?? null\n\n const row = await client.upsert(\n {\n entityId,\n locale,\n data: projected,\n createdBy,\n createdByName,\n createdAt: now,\n updatedAt: now,\n },\n {\n target: ['entityId', 'locale'],\n // On conflict, only update the mutable fields — preserve createdBy/createdAt.\n set: { data: projected, updatedAt: now },\n },\n )\n\n return toDraftEntry(row) as DraftEntry<InferUpdateInput<F>>\n}\n\n/**\n * Get the draft for an entity+locale, or null if none exists (or the\n * entity doesn't support drafts).\n */\nexport async function getDraft<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<DraftEntry<InferUpdateInput<F>> | null> {\n validateLocale(locale)\n const client = getDraftsClient(ctx)\n if (!client) return null\n const row = await client.findOne({ entityId, locale })\n return row ? (toDraftEntry(row) as DraftEntry<InferUpdateInput<F>>) : null\n}\n\n/**\n * Delete (discard) a draft for an entity+locale. Returns whether\n * anything was deleted; safely no-ops on entities without drafts.\n */\nexport async function deleteDraft(\n ctx: ContentContext,\n entityId: string,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<boolean> {\n validateLocale(locale)\n const client = getDraftsClient(ctx)\n if (!client) return false\n const deleted = await client.deleteMany({ entityId, locale })\n return deleted.length > 0\n}\n\n/**\n * Publish a draft: project the draft data through the entity field\n * whitelist, apply it to the locale the draft belongs to, then delete\n * the draft and (if versionable) record a version snapshot.\n *\n * Locale routing mirrors the admin PATCH handler (and the taxonomy fix\n * in 10a7cc2b): a default-locale draft writes the base row; a\n * non-default-locale draft routes translatable fields to\n * `saveTranslation` (→ `{entity}_translations`), `status` to the\n * per-locale status table, and only non-translatable fields (which have\n * no per-locale home) to the base row. Without this split, publishing\n * an `et` draft overwrote the default-locale base row wholesale.\n */\nexport async function publishDraft<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n locale = DEFAULT_LOCALE_SENTINEL,\n user?: { id: string; name?: string },\n): Promise<InferEntityDTO<F>> {\n validateLocale(locale)\n if (!getDraftsClient(ctx)) {\n throw new EntityCapabilityError(ctx.entity.name, 'drafts')\n }\n const draft = await getDraft(ctx, entityId, locale)\n if (!draft) {\n throw new DraftNotFoundError(entityId, locale)\n }\n\n // Get previous state for the version delta — locale-merged, so the\n // delta compares against what this locale's readers actually saw.\n let previousState: InferEntityDTO<F> | null = null\n if (ctx.versionableOptions) {\n previousState = await ctx.admin.findById(entityId, localeFindOptions(locale))\n }\n\n // Apply draft data, projecting through the entity's field whitelist:\n // anything not declared in `entity.allFields` (or marked `internal:\n // true`) is silently dropped. NB `status` IS a declared publishable()\n // field and survives the projection — the publish-permission gate lives\n // in the admin route (`requirePermission(…, 'publish')` before\n // publishDraft is called), not here.\n const draftData = draft.data as Record<string, unknown>\n // \"Publish\" means MAKE LIVE. Block-editor drafts carry only content\n // (layout/theme/meta) — without this merge nothing ever flipped `status`,\n // so publishing promoted the content while the document stayed a draft\n // (badge \"Draft\", second click 409 — PR 23 user-smoke find). A draft that\n // explicitly carries `status` (form-save drafts can) wins; the\n // publishable() behavior auto-sets `publishedAt` on the transition.\n // `!== undefined` rather than `in` — a programmatic saveDraft can store\n // `status: undefined` (JSON can't), and that must not skip the merge.\n const dataWithStatus =\n draftData.status !== undefined ? draftData : { ...draftData, status: 'published' }\n const updateData = projectToEntityFields(ctx.entity, dataWithStatus)\n const result = await applyLocaleRoutedUpdate(ctx, entityId, updateData, locale)\n\n // F009 — promote the page's chrome overrides.\n //\n // `projectToEntityFields` drops every `internal: true` field, deliberately, so\n // `_overrides` cannot ride the generic promotion. That guard is NOT relaxed:\n // this is one named field, written through the trusted `updateInternal` path\n // that `with-overrides.ts` documents as \"the only path that can mutate it\".\n // Before F009 that call site did not exist, so an override survived in the\n // draft buffer and never reached the column — the op committed, the canvas\n // updated, and a reload brought the hidden header back.\n //\n // AFTER the main update, so a failed content write does not leave overrides\n // promoted against un-promoted content. `_overrides` is not translatable, so\n // it belongs on the base row for every locale.\n const overrides = readOverridesForPublish(ctx.entity, draftData)\n if (overrides !== undefined) {\n await ctx.admin.updateInternal(entityId, { _overrides: overrides })\n }\n\n await deleteDraft(ctx, entityId, locale)\n\n if (ctx.versionableOptions) {\n await createVersionSnapshot(ctx, entityId, result, previousState, locale, user)\n }\n\n return result\n}\n\n\n/**\n * The page's override map from a draft payload, when the entity declares the\n * field and the draft carries one.\n *\n * Returns `undefined` — never `{}` — so an ordinary content publish does not\n * touch the column. Shape-guarded because a draft payload is stored jsonb: an\n * array or a scalar is malformed data, not an override map. ← F009\n */\nfunction readOverridesForPublish(\n entity: { allFields: Record<string, unknown>; behaviors?: readonly { name: string }[] },\n draftData: Record<string, unknown>,\n): Record<string, unknown> | undefined {\n // The BEHAVIOR, not just a field with the right name — a draft payload is\n // stored jsonb and can be crafted or legacy, so this must not become a way\n // to promote arbitrary JSON into an unrelated internal field on an entity\n // that never opted in. Requiring the behavior also means `beforeUpdate` runs\n // `validateOverridesMap` on the way through `updateInternal`.\n if (!(entity.behaviors ?? []).some((b) => b.name === 'withOverrides')) return undefined\n const field = entity.allFields._overrides as { internal?: boolean } | undefined\n if (field === undefined || field.internal !== true) return undefined\n const value = draftData._overrides\n if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined\n return value as Record<string, unknown>\n}\n\n/**\n * Quick existence check for list badges — does a draft exist?\n */\nexport async function hasDraft(\n ctx: ContentContext,\n entityId: string,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<boolean> {\n validateLocale(locale)\n const client = getDraftsClient(ctx)\n if (!client) return false\n return client.exists({ entityId, locale })\n}\n\n/**\n * Count drafts for this entity type, optionally filtered by locale.\n * Single indexed COUNT — used by dashboard widgets.\n */\nexport async function countDrafts(\n ctx: ContentContext,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<number> {\n validateLocale(locale)\n const client = getDraftsClient(ctx)\n if (!client) return 0\n return client.count({ locale })\n}\n\n/**\n * Check draft existence for multiple entities in one query. Returns\n * a set of entity IDs that have a draft for `locale`.\n */\nexport async function hasDraftBatch(\n ctx: ContentContext,\n entityIds: string[],\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<Set<string>> {\n validateLocale(locale)\n const result = new Set<string>()\n if (entityIds.length === 0) return result\n const client = getDraftsClient(ctx)\n if (!client) return result\n\n const rows = await client.findMany({\n where: { entityId: { in: entityIds }, locale },\n })\n for (const row of rows) {\n result.add(row.entityId as string)\n }\n return result\n}\n\n/**\n * Compare the current draft with the live entity. Returns field-level\n * diffs between the published data and the draft (only for fields\n * present in the draft — missing fields are NOT \"changed to empty\").\n */\nexport async function compareDraftWithCurrent<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<{\n diffs: FieldDiff[]\n current: InferEntityDTO<F>\n draft: DraftEntry<InferUpdateInput<F>>\n} | null> {\n validateLocale(locale)\n const draft = await getDraft(ctx, entityId, locale)\n if (!draft) return null\n\n // Fetch the live entity with the same locale context as the draft.\n const config = getContentConfig()\n const defaultLocale = config.defaultLocale\n const findOptions =\n locale === DEFAULT_LOCALE_SENTINEL ? { defaultLocale } : { locale, defaultLocale }\n const current = await ctx.admin.findById(entityId, findOptions)\n if (!current) return null\n\n const fieldConfigs = ctx.entity.allFields as Record<string, FieldConfig> | undefined\n const currentRecord = current as Record<string, unknown>\n const draftRecord = draft.data as Record<string, unknown>\n const currentSubset: Record<string, unknown> = {}\n for (const key of Object.keys(draftRecord)) {\n currentSubset[key] = currentRecord[key]\n }\n const diffs = diffFields(currentSubset, draftRecord, fieldConfigs)\n for (const diff of diffs) {\n if (diff.type === 'blockTree') {\n // Normalize a null/undefined side to [] so add/clear of the whole field\n // (null ↔ blockTree[]) still produces a structural diff, not an opaque one.\n diff.blockDiffs = diffBlocks(\n Array.isArray(diff.from) ? diff.from : [],\n Array.isArray(diff.to) ? diff.to : [],\n )\n }\n }\n\n return { diffs, current, draft }\n}\n","/**\n * Per-locale publish status — independent draft/published state for\n * each locale of a publishable + translatable entity.\n *\n * Capability: requires `publishable() + at least one translatable\n * field` (the `_locale_status` table is registered only for that\n * combination). Reads silently return falsy on entities without it;\n * `setLocaleStatus` throws `EntityCapabilityError`.\n */\n\nimport 'server-only'\n\nimport type { FieldConfig } from '@murumets-ee/entity'\nimport type { InferUpdateInput } from '@murumets-ee/entity/admin'\nimport { EntityCapabilityError } from './errors.js'\nimport { type ContentContext, getLocaleStatusClient } from './internal/context.js'\nimport { upsertLocaleStatusRow } from './locale-status-slot.js'\nimport { validateLocale } from './locale-utils.js'\nimport { getContentConfig } from './plugin.js'\nimport type { LocalePublishStatus } from './types.js'\n\n/**\n * Get per-locale publish status overrides for an entity.\n * Missing locale = inherits the base entity status (no row written).\n *\n * **Not permission-gated, deliberately — see {@link getLocaleStatusBatch}.**\n */\nexport async function getLocaleStatus(\n ctx: ContentContext,\n entityId: string,\n): Promise<LocalePublishStatus[]> {\n const map = await getLocaleStatusBatch(ctx, [entityId])\n const entry = map.get(entityId)\n if (!entry) return []\n return Object.values(entry)\n}\n\n/**\n * Set per-locale publish status. Upserts into the locale_status\n * table — except when `locale === defaultLocale`, which updates the\n * base entity status column directly (the per-locale table only\n * stores overrides for non-default locales).\n *\n * **Both branches are publish-gated by the entity layer (D041).** The default\n * branch inherits it from `AdminClient.update`; the non-default branch routes\n * through `AdminClient.updateForLocale`, which asserts the SAME\n * `assertPublishAuthority` from the caller's payload before it hands the write\n * to the locale-status writer. This is deliberately a delegation and not a\n * second predicate in this package: `<entity>_locale_status` is the table the\n * public read predicate (`COALESCE(locale_status.status, main.status)`) resolves\n * against, so a direct `TableClient.upsert` here — which is what this function\n * used to do — is a fourth publish door, reachable from any plugin/route/job\n * holding a `ContentClient` (a PUBLIC subpath export) with no `publish` grant\n * ever consulted.\n */\nexport async function setLocaleStatus<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n locale: string,\n status: 'draft' | 'published',\n): Promise<void> {\n validateLocale(locale)\n const config = getContentConfig()\n\n // Default locale → update base entity status column.\n if (locale === config.defaultLocale) {\n const data: Record<string, unknown> = { status }\n if (status === 'published') data.publishedAt = new Date()\n await ctx.admin.update(entityId, data as InferUpdateInput<F>)\n return\n }\n\n const client = getLocaleStatusClient(ctx)\n if (!client) {\n // Per-locale status only exists for publishable + translatable\n // entities. Calling on a non-translatable publishable (where the\n // only valid locale is the default) is a programmer error.\n // Checked BEFORE delegating: `updateForLocale` would otherwise fall back to\n // the base `status` column for a missing writer, silently publishing the\n // default locale instead of refusing.\n throw new EntityCapabilityError(ctx.entity.name, 'localeStatus')\n }\n\n // The writer we hand down is the RAW upsert, not this function — the\n // delegation terminates here rather than re-entering `updateForLocale`.\n //\n // Routing through `updateForLocale` rather than calling the upsert directly\n // is what closes the publish door: this branch used to write\n // `<entity>_locale_status` with no permission check at all, so any holder of\n // this public export could make a translation publicly live without\n // `publish`. `updateForLocale` asserts publish authority at the operation\n // entry (D041), so the gate is inherited rather than duplicated here.\n //\n // Widened through `Record<string, unknown>` for the same reason the\n // default-locale branch above does it: `InferUpdateInput<F>` is keyed on the\n // entity's own field map, which a generic `F` cannot be proven to overlap.\n const localeData: Record<string, unknown> = { status }\n await ctx.admin.updateForLocale(entityId, localeData as InferUpdateInput<F>, locale, {\n defaultLocale: config.defaultLocale,\n localeStatusWriter: (id, loc, s) => upsertLocaleStatusRow(client, id, loc, s),\n })\n}\n\n/**\n * Get per-locale publish status for multiple entities in one query.\n *\n * ## Why this read is NOT permission-gated (and what would gate it)\n *\n * It returns publish-state METADATA only — `{ locale, status, publishedAt }` —\n * never field data, so it is not a content-disclosure door the way the write\n * side above is a publish door. What it does expose is an existence oracle for\n * unpublished TRANSLATIONS: a principal without `view_unpublished` learns that\n * `et` is a draft, which `QueryClient` would have hidden. The correct predicate\n * is therefore `view_unpublished` (D017), NOT `publish`.\n *\n * A CodeRabbit pass proposed gating it on exactly that predicate. Deliberately\n * declined, on the strength of **F063**: `view_unpublished` governs `QueryClient`\n * reads — the PUBLIC delivery surface — and is not a disclosure boundary over\n * the admin API, where `AdminClient` shows editors their own drafts by design.\n * `ContentClient` is an in-process server-side API whose consumers are plugins,\n * routes and jobs on the admin side, so gating it here would extend the control\n * onto the surface F063 explicitly recommended NOT extending it to. The concrete\n * cost would be an editor lacking `view_unpublished` no longer able to see that\n * their OWN translation is still a draft.\n *\n * The asymmetry with the write side above is real, not convenient:\n * `setLocaleStatus` MINTS public visibility, so it is gated regardless of which\n * surface calls it. This read hands publish-state metadata to a caller who\n * already holds `view` on the row — they needed its id to ask.\n *\n * It is not applied here because the predicate (`mayViewUnpublishedEntity`) is\n * module-private to `@murumets-ee/entity` — re-deriving it in this package\n * would create exactly the second, drifting copy of a security predicate that\n * D041 exists to eliminate, and there is no read-path equivalent of\n * `updateForLocale` to delegate to. The one in-repo caller\n * (`admin-ui`'s versions route) is already gated by `checkPermission`. Closing\n * this properly means exporting the predicate from `@murumets-ee/entity` and\n * dropping non-`published` rows for principals without it — tracked as the\n * read-side sibling of F060.\n */\nexport async function getLocaleStatusBatch(\n ctx: ContentContext,\n entityIds: string[],\n): Promise<Map<string, Record<string, LocalePublishStatus>>> {\n const result = new Map<string, Record<string, LocalePublishStatus>>()\n if (entityIds.length === 0) return result\n const client = getLocaleStatusClient(ctx)\n if (!client) return result\n\n const rows = await client.findMany({ where: { entityId: { in: entityIds } } })\n for (const r of rows) {\n const eid = r.entityId as string\n const locale = r.locale as string\n let entry = result.get(eid)\n if (!entry) {\n entry = {}\n result.set(eid, entry)\n }\n entry[locale] = {\n locale,\n status: r.status as 'draft' | 'published',\n publishedAt: (r.publishedAt as Date | null) ?? null,\n }\n }\n return result\n}\n","/**\n * Translation status reporting — per-locale completion of translatable\n * fields for one entity, batches of entities, or the whole entity type.\n *\n * Capability: requires the entity to have at least one translatable\n * field. All methods return falsy / empty when the entity has no\n * translatable fields (read-only and defensive).\n */\n\nimport 'server-only'\n\nimport { schemaRegistry } from '@murumets-ee/db'\nimport type { FieldConfig } from '@murumets-ee/entity'\nimport { inArray } from 'drizzle-orm'\nimport type { ContentContext } from './internal/context.js'\nimport { getContentConfig } from './plugin.js'\nimport type { TranslationReport, TranslationStatus } from './types.js'\n\n/**\n * For one entity, compute per-locale completion status across every\n * translatable field. Default-locale rows are skipped.\n */\nfunction computeStatusForEntity(\n translatableFields: string[],\n translatedByLocale: Map<string, Record<string, unknown>> | undefined,\n locales: { code: string }[],\n defaultLocale: string,\n): TranslationStatus {\n const status: TranslationStatus = {}\n for (const locale of locales) {\n if (locale.code === defaultLocale) continue\n const translated = translatedByLocale?.get(locale.code) ?? {}\n const missing: string[] = []\n for (const fieldName of translatableFields) {\n if (translated[fieldName] === undefined || translated[fieldName] === null) {\n missing.push(fieldName)\n }\n }\n status[locale.code] = {\n complete: missing.length === 0,\n total: translatableFields.length,\n translated: translatableFields.length - missing.length,\n missing,\n }\n }\n return status\n}\n\n/**\n * Get translation status for an entity — per-locale completion report.\n */\nexport async function getTranslationStatus(\n ctx: ContentContext,\n entityId: string,\n): Promise<TranslationStatus> {\n // Single-entity is the batch path with one ID — keeps the two\n // codepaths from drifting.\n const map = await getTranslationStatusBatch(ctx, [entityId])\n return map.get(entityId) ?? {}\n}\n\n/**\n * Get translation status for multiple entities in a single query.\n * O(1) DB roundtrips regardless of `entityIds.length`.\n */\nexport async function getTranslationStatusBatch(\n ctx: ContentContext,\n entityIds: string[],\n): Promise<Map<string, TranslationStatus>> {\n const result = new Map<string, TranslationStatus>()\n if (entityIds.length === 0) return result\n\n const config = getContentConfig()\n\n const translatableFields = Object.entries(ctx.entity.allFields as Record<string, FieldConfig>)\n .filter(([_, f]) => f.translatable)\n .map(([name]) => name)\n if (translatableFields.length === 0) return result\n\n // Translation tables are generated in @murumets-ee/entity (with FK\n // refs); accessed via schemaRegistry + direct Drizzle here.\n const translationTable = schemaRegistry.get(`${ctx.entity.name}_translations`)\n if (!translationTable) return result\n\n const translations = await ctx.db\n .select()\n .from(translationTable)\n .where(inArray(translationTable.entityId, entityIds))\n\n // Group: entityId → locale → { fieldName: value }\n const translationsByEntity = new Map<string, Map<string, Record<string, unknown>>>()\n for (const t of translations) {\n const row = t as Record<string, unknown>\n const eid = row.entityId as string\n const locale = row.locale as string\n const fieldValues: Record<string, unknown> = {}\n for (const fieldName of translatableFields) {\n fieldValues[fieldName] = row[fieldName]\n }\n let entry = translationsByEntity.get(eid)\n if (!entry) {\n entry = new Map()\n translationsByEntity.set(eid, entry)\n }\n entry.set(locale, fieldValues)\n }\n\n for (const entityId of entityIds) {\n result.set(\n entityId,\n computeStatusForEntity(\n translatableFields,\n translationsByEntity.get(entityId),\n config.locales,\n config.defaultLocale,\n ),\n )\n }\n\n return result\n}\n\n/**\n * Roll up translation status across every entity of this type.\n *\n * Defaults to the first non-default configured locale; pass\n * `options.locale` to target a specific one. Returns counts of\n * complete / partial / missing entities for that locale.\n */\nexport async function getTranslationReport(\n ctx: ContentContext,\n options?: { locale?: string },\n): Promise<TranslationReport> {\n const config = getContentConfig()\n const targetLocale =\n options?.locale ?? config.locales.find((l) => l.code !== config.defaultLocale)?.code\n if (!targetLocale) return { total: 0, complete: 0, partial: 0, missing: 0 }\n\n const entities = await ctx.admin.findMany()\n if (entities.length === 0) return { total: 0, complete: 0, partial: 0, missing: 0 }\n\n // Single batched query covers every entity at once.\n const statusMap = await getTranslationStatusBatch(\n ctx,\n entities.map((e) => e.id),\n )\n\n let complete = 0\n let partial = 0\n let missing = 0\n for (const entity of entities) {\n const status = statusMap.get(entity.id)\n const localeStatus = status?.[targetLocale]\n if (!localeStatus || localeStatus.translated === 0) missing++\n else if (localeStatus.complete) complete++\n else partial++\n }\n\n return { total: entities.length, complete, partial, missing }\n}\n","/**\n * ContentClient — thin shell over per-feature modules.\n *\n * Wraps AdminClient with version history (`./versions.ts`), draft\n * overlays (`./drafts.ts`), translation status reporting\n * (`./translation-status.ts`), per-locale publish status\n * (`./locale-status.ts`), and block-layout operations (`./layout.ts`).\n *\n * The class is generic over the entity's `AllFields` map: when\n * constructed with a typed entity, every method's input/output is\n * inferred end-to-end. When constructed with a runtime `Entity`\n * (admin-ui's heterogeneous entity loops), the generic defaults to\n * `Record<string, FieldConfig>` and methods accept/return the loose\n * shape — same behavior as the pre-generic version.\n *\n * Usage:\n * import { createContentClient } from '@murumets-ee/content/client'\n * const pages = createContentClient(Page)\n * await pages.create({ title: 'Hello', layout: [...] })\n */\n\nimport 'server-only'\n\nimport { getApp, type Logger } from '@murumets-ee/core'\nimport { createAdminClient } from '@murumets-ee/core/clients'\nimport type { Entity, FieldConfig } from '@murumets-ee/entity'\nimport type {\n AdminClient,\n FindManyOptions,\n InferCreateInput,\n InferEntityDTO,\n InferUpdateInput,\n} from '@murumets-ee/entity/admin'\nimport { getVersionableOptions } from './behaviors/versionable.js'\nimport {\n compareDraftWithCurrent,\n countDrafts,\n deleteDraft,\n getDraft,\n hasDraft,\n hasDraftBatch,\n publishDraft,\n saveDraft,\n} from './drafts.js'\nimport { type ContentContext, createContentContext } from './internal/context.js'\nimport { calculateDelta } from './internal/projection.js'\nimport { getLocaleStatus, getLocaleStatusBatch, setLocaleStatus } from './locale-status.js'\nimport {\n getTranslationReport,\n getTranslationStatus,\n getTranslationStatusBatch,\n} from './translation-status.js'\nimport {\n compareVersions,\n createVersionSnapshot,\n getVersion,\n getVersions,\n pruneVersions,\n restoreVersion,\n saveVersionSnapshot,\n writeVersionColumn,\n} from './versions.js'\n\nexport interface ContentClientConfig<\n AllFields extends Record<string, FieldConfig> = Record<string, FieldConfig>,\n> {\n admin: AdminClient<AllFields>\n entity: Entity<AllFields>\n logger?: Logger\n}\n\nexport class ContentClient<\n AllFields extends Record<string, FieldConfig> = Record<string, FieldConfig>,\n> {\n private ctx: ContentContext<AllFields>\n\n constructor(config: ContentClientConfig<AllFields>) {\n const versionableOptions = getVersionableOptions(config.entity.behaviors)\n this.ctx = createContentContext(config, versionableOptions)\n }\n\n // -----------------------------------------------------------------\n // CRUD (delegated to AdminClient + version snapshots)\n // -----------------------------------------------------------------\n\n async create(data: InferCreateInput<AllFields>): Promise<InferEntityDTO<AllFields>> {\n const result = await this.ctx.admin.create(data)\n if (this.ctx.versionableOptions) {\n await saveVersionSnapshot(this.ctx, result.id, result, null, false)\n }\n return result\n }\n\n findById(id: string, options?: { locale?: string }): Promise<InferEntityDTO<AllFields> | null> {\n return this.ctx.admin.findById(id, options)\n }\n\n findMany(options?: FindManyOptions): Promise<InferEntityDTO<AllFields>[]> {\n return this.ctx.admin.findMany(options)\n }\n\n /**\n * Base-row update + version snapshot. Subject to AdminClient's\n * cross-locale write guard (SD021): under a non-default request-context\n * locale, translatable fields throw `CrossLocaleWriteError` — that's\n * enforcement, not a gap. Locale-belonging writes go through the draft\n * flow (`saveDraft` → `publishDraft`) or `admin.updateForLocale` directly.\n */\n async update(id: string, data: InferUpdateInput<AllFields>): Promise<InferEntityDTO<AllFields>> {\n let previousState: InferEntityDTO<AllFields> | null = null\n if (this.ctx.versionableOptions) {\n previousState = await this.ctx.admin.findById(id)\n }\n\n const result = await this.ctx.admin.update(id, data)\n\n if (this.ctx.versionableOptions && previousState) {\n const delta = calculateDelta(previousState, result)\n const version = await saveVersionSnapshot(this.ctx, id, result, delta, false)\n if (version !== undefined) {\n await writeVersionColumn(this.ctx, id, version)\n await pruneVersions(this.ctx, id)\n return { ...result, _version: version } as InferEntityDTO<AllFields>\n }\n await pruneVersions(this.ctx, id)\n }\n\n return result\n }\n\n delete(id: string): Promise<unknown> {\n return this.ctx.admin.delete(id)\n }\n\n // -----------------------------------------------------------------\n // Translation delegation\n // -----------------------------------------------------------------\n\n saveTranslation(entityId: string, locale: string, translations: Record<string, unknown>) {\n return this.ctx.admin.saveTranslation(entityId, locale, translations)\n }\n\n deleteTranslation(entityId: string, locale?: string) {\n return this.ctx.admin.deleteTranslation(entityId, locale)\n }\n\n // -----------------------------------------------------------------\n // Version history\n // -----------------------------------------------------------------\n\n getVersions(entityId: string, options?: { limit?: number; offset?: number; locale?: string }) {\n return getVersions(this.ctx, entityId, options)\n }\n\n getVersion(entityId: string, version: number, locale?: string) {\n return getVersion(this.ctx, entityId, version, locale)\n }\n\n compareVersions(entityId: string, v1: number, v2: number, locale?: string) {\n return compareVersions(this.ctx, entityId, v1, v2, locale)\n }\n\n restoreVersion(entityId: string, version: number, locale?: string) {\n return restoreVersion(this.ctx, entityId, version, locale)\n }\n\n pruneVersions(entityId: string, locale?: string) {\n return pruneVersions(this.ctx, entityId, locale)\n }\n\n createVersionSnapshot(\n entityId: string,\n currentData: Record<string, unknown>,\n previousData?: Record<string, unknown> | null,\n locale?: string,\n user?: { id: string; name?: string },\n ) {\n return createVersionSnapshot(this.ctx, entityId, currentData, previousData, locale, user)\n }\n\n // -----------------------------------------------------------------\n // Translation status\n // -----------------------------------------------------------------\n\n getTranslationStatus(entityId: string) {\n return getTranslationStatus(this.ctx, entityId)\n }\n\n getTranslationStatusBatch(entityIds: string[]) {\n return getTranslationStatusBatch(this.ctx, entityIds)\n }\n\n getTranslationReport(options?: { locale?: string }) {\n return getTranslationReport(this.ctx, options)\n }\n\n // -----------------------------------------------------------------\n // Per-locale publish status\n // -----------------------------------------------------------------\n\n getLocaleStatus(entityId: string) {\n return getLocaleStatus(this.ctx, entityId)\n }\n\n setLocaleStatus(entityId: string, locale: string, status: 'draft' | 'published') {\n return setLocaleStatus(this.ctx, entityId, locale, status)\n }\n\n getLocaleStatusBatch(entityIds: string[]) {\n return getLocaleStatusBatch(this.ctx, entityIds)\n }\n\n // -----------------------------------------------------------------\n // Drafts\n // -----------------------------------------------------------------\n\n saveDraft(\n entityId: string,\n data: InferUpdateInput<AllFields>,\n locale?: string,\n user?: { id: string; name?: string },\n ) {\n return saveDraft(this.ctx, entityId, data, locale, user)\n }\n\n getDraft(entityId: string, locale?: string) {\n return getDraft(this.ctx, entityId, locale)\n }\n\n deleteDraft(entityId: string, locale?: string) {\n return deleteDraft(this.ctx, entityId, locale)\n }\n\n publishDraft(entityId: string, locale?: string, user?: { id: string; name?: string }) {\n return publishDraft(this.ctx, entityId, locale, user)\n }\n\n hasDraft(entityId: string, locale?: string) {\n return hasDraft(this.ctx, entityId, locale)\n }\n\n countDrafts(locale?: string) {\n return countDrafts(this.ctx, locale)\n }\n\n hasDraftBatch(entityIds: string[], locale?: string) {\n return hasDraftBatch(this.ctx, entityIds, locale)\n }\n\n compareDraftWithCurrent(entityId: string, locale?: string) {\n return compareDraftWithCurrent(this.ctx, entityId, locale)\n }\n}\n\n/**\n * Factory — creates a ContentClient for an entity. Must be called\n * after `createApp()`. Generic flows from the entity definition:\n * `createContentClient(Article)` returns a `ContentClient<typeof\n * Article['allFields']>` typed end-to-end.\n */\nexport function createContentClient<AllFields extends Record<string, FieldConfig>>(\n entity: Entity<AllFields>,\n): ContentClient<AllFields> {\n const admin = createAdminClient(entity)\n return new ContentClient<AllFields>({\n admin,\n entity,\n logger: getApp().logger.child({ content: entity.name }),\n })\n}\n"],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ const e=Symbol.for(`@murumets-ee/content:content-config`);function t(t){globalThis[e]=t}function n(){let t=globalThis[e];if(!t)throw Error(`@murumets-ee/content plugin not initialized. Add content() to your plugins array.`);return t}export{t as n,n as t};
2
+ //# sourceMappingURL=content-config-store-8omQF5p5.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"content-config-store-8omQF5p5.mjs","names":[],"sources":["../src/content-config-store.ts"],"sourcesContent":["/**\n * The `content()` plugin config store — a leaf module holding ONLY the globalThis\n * slot + accessors, with **no** schema / plugin imports.\n *\n * Extracted from `plugin.ts` (which also pulls `defineTable`-backed schema modules)\n * so that `locale-registry.ts` (the SD006 resolvers, re-exported from the **main entry**)\n * can read the shipped locales WITHOUT dragging the schema/table graph into it — that's\n * what stops `@murumets-ee/content`'s main index from transitively evaluating\n * `schema/locks.ts` at import time (the bug that broke the api-handler unit tests).\n *\n * `locale-utils.ts` (validateLocale) deliberately keeps importing `getContentConfig`\n * from `plugin.js`: it is NOT on the main-index import graph (only `content/query` +\n * `content/client`, both already `server-only`, so there's no bundle/jiti benefit), and\n * its consumers' unit tests mock `plugin.js` — repointing it here would ripple through\n * ~7 test mocks for no runtime gain.\n */\n\nimport type { ContentConfig } from './types.js'\n\n/**\n * The active config lives on `globalThis` keyed by `Symbol.for(...)`, NOT in a\n * module-level binding — same Turbopack-dev module-duplication hazard as the\n * content-tables registry (see schema/registry.ts): a lazily-compiled route chunk\n * can get a SECOND instance of this module whose `let` was never assigned, and\n * `getContentConfig()` throws \"not initialized\" on a perfectly initialized app.\n * `Symbol.for` keys are process-global, so every duplicate instance reads the same\n * slot. (pr-review pass-1 M4 — load-bearing on the publish + locale-routing path.)\n */\nconst CONFIG_KEY = Symbol.for('@murumets-ee/content:content-config')\n\ninterface GlobalWithContentConfig {\n [CONFIG_KEY]?: ContentConfig\n}\n\n/** Store the resolved content config (called once by the plugin's `init`). */\nexport function setContentConfig(config: ContentConfig): void {\n ;(globalThis as GlobalWithContentConfig)[CONFIG_KEY] = config\n}\n\n/**\n * Get the content configuration. Throws if the plugin hasn't initialized.\n */\nexport function getContentConfig(): ContentConfig {\n const config = (globalThis as GlobalWithContentConfig)[CONFIG_KEY]\n if (!config) {\n throw new Error(\n '@murumets-ee/content plugin not initialized. Add content() to your plugins array.',\n )\n }\n return config\n}\n"],"mappings":"AA4BA,MAAM,EAAa,OAAO,IAAI,qCAAqC,EAOnE,SAAgB,EAAiB,EAA6B,CAC3D,WAAwC,GAAc,CACzD,CAKA,SAAgB,GAAkC,CAChD,IAAM,EAAU,WAAuC,GACvD,GAAI,CAAC,EACH,MAAU,MACR,mFACF,EAEF,OAAO,CACT"}
@@ -1,4 +1,4 @@
1
- import { E as VersionableFields, v as WorkflowableFields } from "../index-D8nN7BFd.mjs";
1
+ import { O as VersionableFields, b as WorkflowableFields } from "../index-Bsmju0UF.mjs";
2
2
 
3
3
  //#region src/entities/site-layout.d.ts
4
4
  /**
@@ -41,12 +41,15 @@ import { E as VersionableFields, v as WorkflowableFields } from "../index-D8nN7B
41
41
  * pages; site-layout undo windows tend to be longer because changes
42
42
  * are less frequent + higher-impact
43
43
  *
44
- * NOTE on singleton enforcement: `kind: 'singleton'` is metadata-only
45
- * at the framework level — there's no runtime "at most one row" gate.
46
- * For v1 PR 12 we rely on (a) seed/wizard creating the one row and
47
- * (b) the admin UI hiding "Create new" for singleton entities. A
48
- * future hardening PR will add generic runtime enforcement to
49
- * `AdminClient.create`.
44
+ * NOTE on singleton enforcement: `kind: 'singleton'` is enforced
45
+ * generically by `AdminClient` since `plan/site-chrome` PR B1 (D011,
46
+ * closing F002) — `create` refuses a second live row, and `delete`
47
+ * refuses to leave zero. Nothing here declares the rule; the framework
48
+ * honours the `kind` field above. The admin UI reads the same field: it
49
+ * offers "Create new" only while the slot is empty, and sends an
50
+ * update-capable caller from the one-row list straight to the document.
51
+ * (A view-only caller still sees the list — they have nowhere else to be
52
+ * sent, since the edit page needs `:update`.)
50
53
  */
51
54
  /**
52
55
  * Optional overrides apps can apply when calling the factory. The
@@ -54,9 +57,28 @@ import { E as VersionableFields, v as WorkflowableFields } from "../index-D8nN7B
54
57
  */
55
58
  interface SiteLayoutEntityOptions {
56
59
  /**
57
- * Roles allowed to read site_layout. Default `'editor'`. The admin
58
- * sidebar still gates on `site_layout:view`, but lowering this to
59
- * `'public'` lets headless API consumers query without auth (rare).
60
+ * Roles allowed to read site_layout. Default `'editor'`.
61
+ *
62
+ * **This does NOT govern the public content API, and lowering it to
63
+ * `'public'` does not enable headless reads** — which is what this comment
64
+ * used to claim (corrected by `plan/api` F013). Entity-level `access` is
65
+ * ADVISORY (#616, and `plan/api` D020 declines to reopen it); the public
66
+ * surface resolves a role and asks `buildPermissionChecker(roles)`, whose
67
+ * seed — `buildInitialRoleDefinitions()` — gives `public` zero entity
68
+ * permissions. An operator who follows the old advice gets 404s with no
69
+ * explanation, because a denial is deliberately indistinguishable from an
70
+ * absence (D006).
71
+ *
72
+ * The step that actually works is granting `site_layout:view` to the
73
+ * requesting role in the Permission Matrix. And note that "expose chrome" is
74
+ * TWO decisions, not one: the grant makes it readable, while injecting the
75
+ * `chrome` provider into `createContentApiHandler` is what makes it
76
+ * mountable. Both are deny-by-default operator actions.
77
+ *
78
+ * Chrome reaches an HTTP consumer through `GET /v1/_chrome` as a COMPOSED
79
+ * projection, never as this entity — see `plan/api` D026. Whitelisting
80
+ * `site_layout` in `config.entities` would hand back an uncomposed row, which
81
+ * D011 forbids.
60
82
  */
61
83
  viewRole?: string;
62
84
  /**
@@ -87,7 +109,7 @@ interface SiteLayoutEntityOptions {
87
109
  * would widen N to `string`, which propagates to the resolved-
88
110
  * permissions union and re-introduces a `${string}:view` template
89
111
  * (caught by the wide-template guards in
90
- * `apps/admin-playground/scripts/typed-permissions-demo.ts`).
112
+ * `apps/admin/scripts/typed-permissions-demo.ts`).
91
113
  */
92
114
  declare function siteLayoutEntity(options?: SiteLayoutEntityOptions): import("@murumets-ee/entity").Entity<{
93
115
  id: import("@murumets-ee/entity").IdField;
@@ -102,15 +124,19 @@ declare function siteLayoutEntity(options?: SiteLayoutEntityOptions): import("@m
102
124
  };
103
125
  headerBlocks: import("@murumets-ee/entity").JsonField & {
104
126
  readonly default: () => never[];
127
+ readonly translatable: true;
105
128
  };
106
129
  footerBlocks: import("@murumets-ee/entity").JsonField & {
107
130
  readonly default: () => never[];
131
+ readonly translatable: true;
108
132
  };
109
133
  beforeContentBlocks: import("@murumets-ee/entity").JsonField & {
110
134
  readonly default: () => never[];
135
+ readonly translatable: true;
111
136
  };
112
137
  afterContentBlocks: import("@murumets-ee/entity").JsonField & {
113
138
  readonly default: () => never[];
139
+ readonly translatable: true;
114
140
  };
115
141
  siteName: import("@murumets-ee/entity").TextField & {
116
142
  readonly translatable: true;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/entities/site-layout.ts"],"mappings":";;;;;;AAyDA;;;;;;;;;AAiBa;AAqBb;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;UAtCiB,uBAAA;;;;;;EAMf,QAAA;;;;;;;EAOA,UAAA;;EAEA,WAAA;;EAEA,WAAA;AAAA;;;;;;;;;;;;;;;;;;;iBAqBc,gBAAA,CAAiB,OAAA,GAAU,uBAAA,iCAAuB,MAAA;;sGAAA,iBAAA"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/entities/site-layout.ts"],"mappings":";;;;;;AA4DA;;;;;;;;;AAoCa;AAqBb;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;UAzDiB,uBAAA;;;;;;;;;;;;;;;;;;;;;;;;;EAyBf,QAAA;;;;;;;EAOA,UAAA;;EAEA,WAAA;;EAEA,WAAA;AAAA;;;;;;;;;;;;;;;;;;;iBAqBc,gBAAA,CAAiB,OAAA,GAAU,uBAAA,iCAAuB,MAAA;;sGAAA,iBAAA"}
@@ -1,2 +1,2 @@
1
- import{n as e}from"../versionable-Br_G5VGR.mjs";import{a as t}from"../workflowable-BufrZXu9.mjs";import{auditable as n,defineEntity as r,field as i,publishable as a}from"@murumets-ee/entity";function o(o){let s=o?.viewRole??`editor`,c=o?.updateRole??`admin`,l=o?.publishRole??`admin`,u=o?.maxVersions??50;return r({name:`site_layout`,kind:`singleton`,fields:{slug:i.text({required:!0,default:`default`,indexed:!0}),label:i.text({translatable:!0}),headerBlocks:i.json({default:()=>[]}),footerBlocks:i.json({default:()=>[]}),beforeContentBlocks:i.json({default:()=>[]}),afterContentBlocks:i.json({default:()=>[]}),siteName:i.text({translatable:!0}),siteLogo:i.media(),siteFavicon:i.media()},behaviors:[n(),a(),e({maxPerDoc:u}),t()],scope:`global`,access:{view:s,create:l,update:c,delete:l},admin:{icon:`layout`,description:`Shared header / footer / pre / post-content blocks rendered on every page that does not override them.`,rootFields:[`slug`,`label`]}})}export{o as siteLayoutEntity};
1
+ import{n as e}from"../versionable-Br_G5VGR.mjs";import{a as t}from"../workflowable-BufrZXu9.mjs";import{auditable as n,defineEntity as r,field as i,publishable as a}from"@murumets-ee/entity";function o(o){let s=o?.viewRole??`editor`,c=o?.updateRole??`admin`,l=o?.publishRole??`admin`,u=o?.maxVersions??50;return r({name:`site_layout`,kind:`singleton`,fields:{slug:i.text({required:!0,default:`default`,indexed:!0}),label:i.text({translatable:!0}),headerBlocks:i.json({default:()=>[],translatable:!0}),footerBlocks:i.json({default:()=>[],translatable:!0}),beforeContentBlocks:i.json({default:()=>[],translatable:!0}),afterContentBlocks:i.json({default:()=>[],translatable:!0}),siteName:i.text({translatable:!0}),siteLogo:i.media(),siteFavicon:i.media()},behaviors:[n(),a(),e({maxPerDoc:u}),t()],scope:`global`,access:{view:s,create:l,update:c,delete:l},admin:{icon:`layout`,description:`Shared header / footer / pre / post-content blocks rendered on every page that does not override them.`,rootFields:[`slug`,`label`]}})}export{o as siteLayoutEntity};
2
2
  //# sourceMappingURL=index.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../../src/entities/site-layout.ts"],"sourcesContent":["/**\n * `site_layout` — the singleton document entity that holds site-wide\n * shared content (header, footer, optional before/after-content regions).\n *\n * Phase 05 §2 defines this as a `kind: 'singleton'` entity with one\n * versionable row per (site, locale). The render-time composer\n * (`composeMultiScopeDocument`) joins `site_layout` blocks with the\n * active page's blocks before applying overrides; the result is the\n * page's true rendered shape.\n *\n * The entity ships as a factory function so apps wire it into their\n * `entities` array without us hard-coding the per-app `access` rules:\n *\n * @example\n * ```ts\n * // apps/.../entities/site-layout.ts\n * import { siteLayoutEntity } from '@murumets-ee/content/entities'\n *\n * export const SiteLayout = siteLayoutEntity()\n * ```\n *\n * Plugin contributions (PR 12): the admin route is auto-mounted by the\n * blocks plugin when an entity declares the marker symbol. Permissions\n * — `site_layout:view` (editor), `site_layout:update` (admin),\n * `site_layout:publish` (admin) — flow from the `access` map below;\n * admins can override via per-customer permission catalog.\n *\n * Behaviors layered onto the entity:\n *\n * - `auditable()` — created/updated stamps\n * - `publishable()` — draft / published lifecycle (publishing site_layout\n * affects every page that doesn't override it)\n * - `versionable({ maxPerDoc: 50 })` — per-locale history; longer\n * retention than pages because site-layout edits are higher-stakes\n * - `workflowable()` — same approval gates as pages (so the same\n * review surface applies)\n * - `withOpLog({ retentionDays: 30 })` — longer op retention than\n * pages; site-layout undo windows tend to be longer because changes\n * are less frequent + higher-impact\n *\n * NOTE on singleton enforcement: `kind: 'singleton'` is metadata-only\n * at the framework level — there's no runtime \"at most one row\" gate.\n * For v1 PR 12 we rely on (a) seed/wizard creating the one row and\n * (b) the admin UI hiding \"Create new\" for singleton entities. A\n * future hardening PR will add generic runtime enforcement to\n * `AdminClient.create`.\n */\n\nimport { auditable, publishable } from '@murumets-ee/entity'\nimport { defineEntity, field } from '@murumets-ee/entity'\nimport { versionable } from '../behaviors/versionable.js'\nimport { workflowable } from '../behaviors/workflowable.js'\n\n/**\n * Optional overrides apps can apply when calling the factory. The\n * defaults match phase 05 §7 — editor reads, admin writes / publishes.\n */\nexport interface SiteLayoutEntityOptions {\n /**\n * Roles allowed to read site_layout. Default `'editor'`. The admin\n * sidebar still gates on `site_layout:view`, but lowering this to\n * `'public'` lets headless API consumers query without auth (rare).\n */\n viewRole?: string\n /**\n * Roles allowed to mutate site_layout. Default `'admin'` — high\n * blast radius. Override to `'editor'` for trusted teams; the per-\n * customer permission catalog (#357) is the right configuration\n * surface for per-deployment relaxation.\n */\n updateRole?: string\n /** Roles allowed to publish. Default `'admin'`. */\n publishRole?: string\n /** Override the version-retention cap. Default 50 versions per (site, locale). */\n maxVersions?: number\n}\n\n/**\n * Build the `site_layout` entity. Apps add the returned entity to their\n * `defineLumiConfig({ entities: [...] })` list; the blocks plugin\n * auto-mounts the admin route + emits permission catalog entries.\n *\n * Returns a fresh entity per call — multiple calls in the same process\n * produce structurally-identical but referentially-distinct entities.\n * That's fine in practice; the framework deduplicates by entity name at\n * plugin merge time.\n *\n * The return type is intentionally INFERRED (no annotation) so the\n * `'site_layout'` name stays as a string literal in the caller's\n * `Entity<…, 'site_layout', …>` view. Annotating it as bare `Entity`\n * would widen N to `string`, which propagates to the resolved-\n * permissions union and re-introduces a `${string}:view` template\n * (caught by the wide-template guards in\n * `apps/admin-playground/scripts/typed-permissions-demo.ts`).\n */\nexport function siteLayoutEntity(options?: SiteLayoutEntityOptions) {\n const viewRole = options?.viewRole ?? 'editor'\n const updateRole = options?.updateRole ?? 'admin'\n const publishRole = options?.publishRole ?? 'admin'\n const maxVersions = options?.maxVersions ?? 50\n\n return defineEntity({\n name: 'site_layout',\n kind: 'singleton',\n fields: {\n // Singleton-friendly key — apps may pre-seed a row with a known id\n // (e.g. 'default') so the admin route + composer can find it\n // without scanning. Translatable: false so the slot itself\n // doesn't fork per-locale (the BLOCKS inside each region fork\n // per-locale via versionable's chain, but the slot id is global).\n slug: field.text({\n required: true,\n default: 'default',\n indexed: true,\n }),\n label: field.text({ translatable: true }),\n // Shared content regions — each is its own block array. Validated\n // by the blocks plugin's `validateBlockTree` hook (PR 4).\n headerBlocks: field.json({ default: () => [] }),\n footerBlocks: field.json({ default: () => [] }),\n // Optional pre/post-content regions per phase 05 §2.\n beforeContentBlocks: field.json({ default: () => [] }),\n afterContentBlocks: field.json({ default: () => [] }),\n // Site metadata that some blocks might reference.\n siteName: field.text({ translatable: true }),\n siteLogo: field.media(),\n siteFavicon: field.media(),\n },\n behaviors: [\n auditable(),\n publishable(),\n versionable({ maxPerDoc: maxVersions }),\n workflowable(),\n // withOpLog ships from `@murumets-ee/blocks/server` — apps that\n // want the block-editor surface for site_layout should layer it\n // onto the entity at app-config time (matching how the playground\n // wires page-with-blocks). Keeping it out of the factory keeps the\n // content package's import direction one-way (content doesn't\n // depend on blocks).\n ],\n scope: 'global',\n access: {\n view: viewRole,\n // Singleton — apps usually seed the row; `create` admin gating\n // prevents accidental \"second row\" but stays open for the seed\n // path (which runs as the system role).\n create: publishRole,\n update: updateRole,\n delete: publishRole,\n },\n admin: {\n icon: 'layout',\n description:\n 'Shared header / footer / pre / post-content blocks rendered on every page that does not override them.',\n rootFields: ['slug', 'label'],\n },\n })\n}\n"],"mappings":"+LA+FA,SAAgB,EAAiB,EAAmC,CAClE,IAAM,EAAW,GAAS,UAAY,SAChC,EAAa,GAAS,YAAc,QACpC,EAAc,GAAS,aAAe,QACtC,EAAc,GAAS,aAAe,GAE5C,OAAO,EAAa,CAClB,KAAM,cACN,KAAM,YACN,OAAQ,CAMN,KAAM,EAAM,KAAK,CACf,SAAU,GACV,QAAS,UACT,QAAS,EACX,CAAC,EACD,MAAO,EAAM,KAAK,CAAE,aAAc,EAAK,CAAC,EAGxC,aAAc,EAAM,KAAK,CAAE,YAAe,CAAC,CAAE,CAAC,EAC9C,aAAc,EAAM,KAAK,CAAE,YAAe,CAAC,CAAE,CAAC,EAE9C,oBAAqB,EAAM,KAAK,CAAE,YAAe,CAAC,CAAE,CAAC,EACrD,mBAAoB,EAAM,KAAK,CAAE,YAAe,CAAC,CAAE,CAAC,EAEpD,SAAU,EAAM,KAAK,CAAE,aAAc,EAAK,CAAC,EAC3C,SAAU,EAAM,MAAM,EACtB,YAAa,EAAM,MAAM,CAC3B,EACA,UAAW,CACT,EAAU,EACV,EAAY,EACZ,EAAY,CAAE,UAAW,CAAY,CAAC,EACtC,EAAa,CAOf,EACA,MAAO,SACP,OAAQ,CACN,KAAM,EAIN,OAAQ,EACR,OAAQ,EACR,OAAQ,CACV,EACA,MAAO,CACL,KAAM,SACN,YACE,yGACF,WAAY,CAAC,OAAQ,OAAO,CAC9B,CACF,CAAC,CACH"}
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../../src/entities/site-layout.ts"],"sourcesContent":["/**\n * `site_layout` — the singleton document entity that holds site-wide\n * shared content (header, footer, optional before/after-content regions).\n *\n * Phase 05 §2 defines this as a `kind: 'singleton'` entity with one\n * versionable row per (site, locale). The render-time composer\n * (`composeMultiScopeDocument`) joins `site_layout` blocks with the\n * active page's blocks before applying overrides; the result is the\n * page's true rendered shape.\n *\n * The entity ships as a factory function so apps wire it into their\n * `entities` array without us hard-coding the per-app `access` rules:\n *\n * @example\n * ```ts\n * // apps/.../entities/site-layout.ts\n * import { siteLayoutEntity } from '@murumets-ee/content/entities'\n *\n * export const SiteLayout = siteLayoutEntity()\n * ```\n *\n * Plugin contributions (PR 12): the admin route is auto-mounted by the\n * blocks plugin when an entity declares the marker symbol. Permissions\n * — `site_layout:view` (editor), `site_layout:update` (admin),\n * `site_layout:publish` (admin) — flow from the `access` map below;\n * admins can override via per-customer permission catalog.\n *\n * Behaviors layered onto the entity:\n *\n * - `auditable()` — created/updated stamps\n * - `publishable()` — draft / published lifecycle (publishing site_layout\n * affects every page that doesn't override it)\n * - `versionable({ maxPerDoc: 50 })` — per-locale history; longer\n * retention than pages because site-layout edits are higher-stakes\n * - `workflowable()` — same approval gates as pages (so the same\n * review surface applies)\n * - `withOpLog({ retentionDays: 30 })` — longer op retention than\n * pages; site-layout undo windows tend to be longer because changes\n * are less frequent + higher-impact\n *\n * NOTE on singleton enforcement: `kind: 'singleton'` is enforced\n * generically by `AdminClient` since `plan/site-chrome` PR B1 (D011,\n * closing F002) — `create` refuses a second live row, and `delete`\n * refuses to leave zero. Nothing here declares the rule; the framework\n * honours the `kind` field above. The admin UI reads the same field: it\n * offers \"Create new\" only while the slot is empty, and sends an\n * update-capable caller from the one-row list straight to the document.\n * (A view-only caller still sees the list — they have nowhere else to be\n * sent, since the edit page needs `:update`.)\n */\n\nimport { auditable, publishable } from '@murumets-ee/entity'\nimport { defineEntity, field } from '@murumets-ee/entity'\nimport { versionable } from '../behaviors/versionable.js'\nimport { workflowable } from '../behaviors/workflowable.js'\n\n/**\n * Optional overrides apps can apply when calling the factory. The\n * defaults match phase 05 §7 — editor reads, admin writes / publishes.\n */\nexport interface SiteLayoutEntityOptions {\n /**\n * Roles allowed to read site_layout. Default `'editor'`.\n *\n * **This does NOT govern the public content API, and lowering it to\n * `'public'` does not enable headless reads** — which is what this comment\n * used to claim (corrected by `plan/api` F013). Entity-level `access` is\n * ADVISORY (#616, and `plan/api` D020 declines to reopen it); the public\n * surface resolves a role and asks `buildPermissionChecker(roles)`, whose\n * seed — `buildInitialRoleDefinitions()` — gives `public` zero entity\n * permissions. An operator who follows the old advice gets 404s with no\n * explanation, because a denial is deliberately indistinguishable from an\n * absence (D006).\n *\n * The step that actually works is granting `site_layout:view` to the\n * requesting role in the Permission Matrix. And note that \"expose chrome\" is\n * TWO decisions, not one: the grant makes it readable, while injecting the\n * `chrome` provider into `createContentApiHandler` is what makes it\n * mountable. Both are deny-by-default operator actions.\n *\n * Chrome reaches an HTTP consumer through `GET /v1/_chrome` as a COMPOSED\n * projection, never as this entity — see `plan/api` D026. Whitelisting\n * `site_layout` in `config.entities` would hand back an uncomposed row, which\n * D011 forbids.\n */\n viewRole?: string\n /**\n * Roles allowed to mutate site_layout. Default `'admin'` — high\n * blast radius. Override to `'editor'` for trusted teams; the per-\n * customer permission catalog (#357) is the right configuration\n * surface for per-deployment relaxation.\n */\n updateRole?: string\n /** Roles allowed to publish. Default `'admin'`. */\n publishRole?: string\n /** Override the version-retention cap. Default 50 versions per (site, locale). */\n maxVersions?: number\n}\n\n/**\n * Build the `site_layout` entity. Apps add the returned entity to their\n * `defineLumiConfig({ entities: [...] })` list; the blocks plugin\n * auto-mounts the admin route + emits permission catalog entries.\n *\n * Returns a fresh entity per call — multiple calls in the same process\n * produce structurally-identical but referentially-distinct entities.\n * That's fine in practice; the framework deduplicates by entity name at\n * plugin merge time.\n *\n * The return type is intentionally INFERRED (no annotation) so the\n * `'site_layout'` name stays as a string literal in the caller's\n * `Entity<…, 'site_layout', …>` view. Annotating it as bare `Entity`\n * would widen N to `string`, which propagates to the resolved-\n * permissions union and re-introduces a `${string}:view` template\n * (caught by the wide-template guards in\n * `apps/admin/scripts/typed-permissions-demo.ts`).\n */\nexport function siteLayoutEntity(options?: SiteLayoutEntityOptions) {\n const viewRole = options?.viewRole ?? 'editor'\n const updateRole = options?.updateRole ?? 'admin'\n const publishRole = options?.publishRole ?? 'admin'\n const maxVersions = options?.maxVersions ?? 50\n\n return defineEntity({\n name: 'site_layout',\n kind: 'singleton',\n fields: {\n // Singleton-friendly key — apps may pre-seed a row with a known id\n // (e.g. 'default') so the admin route + composer can find it\n // without scanning. Translatable: false so the slot itself\n // doesn't fork per-locale (the BLOCKS inside each region fork\n // per-locale via versionable's chain, but the slot id is global).\n slug: field.text({\n required: true,\n default: 'default',\n indexed: true,\n }),\n label: field.text({ translatable: true }),\n // Shared content regions — each is its own block array. Validated\n // by the blocks plugin's `validateBlockTree` hook (PR 4).\n //\n // `translatable: true` is load-bearing, not decorative. Without it\n // these columns live on the BASE row, so `updateForLocale(id, {\n // headerBlocks }, 'en')` writes the English header over the Estonian\n // one and every locale then serves English. The failure is silent —\n // the write succeeds, and only a per-locale READ reveals it.\n //\n // `translatable` is declared on the base `FieldConfig`, so it is\n // valid on every field type; `packages/entity/src/fields/base.ts`\n // documents the JSON case explicitly (\"yields a per-locale nullable\n // JSONB column on `{entity}_translations`\"). This also matches the\n // established shape for block content elsewhere — a page's own\n // `layout` is `field.blockTree({ translatable: true })`, and a\n // site-wide region is no different in kind.\n //\n // Phase 05 §8's \"per-locale via versionable()'s locale chains\" is\n // about VERSION HISTORY per locale; it does not give the current row\n // per-locale columns, which is what a locale-scoped read merges on.\n headerBlocks: field.json({ default: () => [], translatable: true }),\n footerBlocks: field.json({ default: () => [], translatable: true }),\n // Optional pre/post-content regions per phase 05 §2.\n beforeContentBlocks: field.json({ default: () => [], translatable: true }),\n afterContentBlocks: field.json({ default: () => [], translatable: true }),\n // Site metadata that some blocks might reference.\n siteName: field.text({ translatable: true }),\n siteLogo: field.media(),\n siteFavicon: field.media(),\n },\n behaviors: [\n auditable(),\n publishable(),\n versionable({ maxPerDoc: maxVersions }),\n workflowable(),\n // withOpLog ships from `@murumets-ee/blocks/server` — apps that\n // want the block-editor surface for site_layout should layer it\n // onto the entity at app-config time (matching how the admin app\n // wires page-with-blocks). Keeping it out of the factory keeps the\n // content package's import direction one-way (content doesn't\n // depend on blocks).\n ],\n scope: 'global',\n access: {\n view: viewRole,\n // Singleton — apps usually seed the row. This restricts WHO may\n // create; the framework's cardinality gate (D011) restricts HOW\n // MANY. Orthogonal, and both apply.\n create: publishRole,\n update: updateRole,\n delete: publishRole,\n },\n admin: {\n icon: 'layout',\n description:\n 'Shared header / footer / pre / post-content blocks rendered on every page that does not override them.',\n rootFields: ['slug', 'label'],\n },\n })\n}\n"],"mappings":"+LAqHA,SAAgB,EAAiB,EAAmC,CAClE,IAAM,EAAW,GAAS,UAAY,SAChC,EAAa,GAAS,YAAc,QACpC,EAAc,GAAS,aAAe,QACtC,EAAc,GAAS,aAAe,GAE5C,OAAO,EAAa,CAClB,KAAM,cACN,KAAM,YACN,OAAQ,CAMN,KAAM,EAAM,KAAK,CACf,SAAU,GACV,QAAS,UACT,QAAS,EACX,CAAC,EACD,MAAO,EAAM,KAAK,CAAE,aAAc,EAAK,CAAC,EAqBxC,aAAc,EAAM,KAAK,CAAE,YAAe,CAAC,EAAG,aAAc,EAAK,CAAC,EAClE,aAAc,EAAM,KAAK,CAAE,YAAe,CAAC,EAAG,aAAc,EAAK,CAAC,EAElE,oBAAqB,EAAM,KAAK,CAAE,YAAe,CAAC,EAAG,aAAc,EAAK,CAAC,EACzE,mBAAoB,EAAM,KAAK,CAAE,YAAe,CAAC,EAAG,aAAc,EAAK,CAAC,EAExE,SAAU,EAAM,KAAK,CAAE,aAAc,EAAK,CAAC,EAC3C,SAAU,EAAM,MAAM,EACtB,YAAa,EAAM,MAAM,CAC3B,EACA,UAAW,CACT,EAAU,EACV,EAAY,EACZ,EAAY,CAAE,UAAW,CAAY,CAAC,EACtC,EAAa,CAOf,EACA,MAAO,SACP,OAAQ,CACN,KAAM,EAIN,OAAQ,EACR,OAAQ,EACR,OAAQ,CACV,EACA,MAAO,CACL,KAAM,SACN,YACE,yGACF,WAAY,CAAC,OAAQ,OAAO,CAC9B,CACF,CAAC,CACH"}