@murumets-ee/content 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/blocks/index.d.mts +1 -1
- package/dist/blocks/index.mjs +1 -1
- package/dist/client.d.mts +38 -311
- package/dist/client.d.mts.map +1 -1
- package/dist/client.mjs +1 -1
- package/dist/client.mjs.map +1 -1
- package/dist/{define-block-DpMPEEdA.mjs → define-block-D-7OpIn5.mjs} +1 -1
- package/dist/{define-block-DpMPEEdA.mjs.map → define-block-D-7OpIn5.mjs.map} +1 -1
- package/dist/errors-kkf8Ue1H.mjs +2 -0
- package/dist/errors-kkf8Ue1H.mjs.map +1 -0
- package/dist/index-dos8Dc3T.d.mts +148 -0
- package/dist/index-dos8Dc3T.d.mts.map +1 -0
- package/dist/index.d.mts +3 -71
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/locale-utils-By7EdAT1.mjs +2 -0
- package/dist/locale-utils-By7EdAT1.mjs.map +1 -0
- package/dist/lock-service.d.mts +16 -10
- package/dist/lock-service.d.mts.map +1 -1
- package/dist/lock-service.mjs +1 -1
- package/dist/lock-service.mjs.map +1 -1
- package/dist/locks-BRtmYqgS.mjs +2 -0
- package/dist/{locks-BkO2qMfm.mjs.map → locks-BRtmYqgS.mjs.map} +1 -1
- package/dist/plugin-yijaaFS7.mjs +2 -0
- package/dist/plugin-yijaaFS7.mjs.map +1 -0
- package/dist/plugin.d.mts +1 -1
- package/dist/plugin.d.mts.map +1 -1
- package/dist/plugin.mjs +1 -2
- package/dist/query-client.d.mts +1 -1
- package/dist/query-client.d.mts.map +1 -1
- package/dist/query-client.mjs +1 -1
- package/dist/query-client.mjs.map +1 -1
- package/dist/{types-D-ATlx7t.d.mts → types-BCmX1nBL.d.mts} +6 -6
- package/dist/types-BCmX1nBL.d.mts.map +1 -0
- package/dist/versionable-B0bP2Gib.mjs +2 -0
- package/dist/versionable-B0bP2Gib.mjs.map +1 -0
- package/package.json +8 -5
- package/dist/block-diff-BBH6FoFT.mjs +0 -2
- package/dist/block-diff-BBH6FoFT.mjs.map +0 -1
- package/dist/drafts-CyOivEtr.mjs +0 -2
- package/dist/drafts-CyOivEtr.mjs.map +0 -1
- package/dist/index.d.mts.map +0 -1
- package/dist/locale-status-DTyv4wcn.mjs +0 -2
- package/dist/locale-status-DTyv4wcn.mjs.map +0 -1
- package/dist/locks-BkO2qMfm.mjs +0 -2
- package/dist/plugin.mjs.map +0 -1
- package/dist/registry-BJSOGFEN.mjs +0 -2
- package/dist/registry-BJSOGFEN.mjs.map +0 -1
- package/dist/rolldown-runtime-DR3Ue2cl.mjs +0 -1
- package/dist/types-COcneY5H.d.mts +0 -43
- package/dist/types-COcneY5H.d.mts.map +0 -1
- package/dist/types-D-ATlx7t.d.mts.map +0 -1
- package/dist/versions-CO6oDyet.mjs +0 -2
- package/dist/versions-CO6oDyet.mjs.map +0 -1
package/dist/client.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.mjs","names":[],"sources":["../src/client.ts"],"sourcesContent":["/**\n * ContentClient — wraps AdminClient with version history, drafts, and translation status.\n *\n * All per-entity infrastructure tables (drafts, versions, locale_status) are\n * accessed through typed TableClients built on `defineTable`. There are NO\n * direct Drizzle queries on these tables — the only remaining `this.db.*`\n * calls are for layout (a separate entity-package concern) and for the\n * `_version` column on the entity table (writing through AdminClient.update\n * would retrigger the versionable hook and loop).\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 type { Logger } from '@murumets-ee/core'\nimport type { Entity, FieldConfig, FieldToTS } from '@murumets-ee/entity'\nimport type { AdminClient, FindManyOptions } from '@murumets-ee/entity/admin'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\nimport { getVersionableOptions } from './behaviors/versionable.js'\nimport type { DraftsTable } from './schema/drafts.js'\n\n/** Row shape returned by the drafts TableClient's reads (Date timestamps, typed columns). */\ntype DraftRow = NonNullable<Awaited<ReturnType<ReturnType<DraftsTable['makeClient']>['findOne']>>>\nimport { diffBlocks } from './diff/block-diff.js'\nimport { diffFields } from './diff/field-diff.js'\nimport type { FieldDiff } from './diff/types.js'\nimport { getContentTables } from './schema/registry.js'\nimport type {\n DraftEntry,\n LocalePublishStatus,\n TranslationReport,\n TranslationStatus,\n VersionEntry,\n} from './types.js'\n\n/** Value type that any entity field can hold */\ntype EntityFieldValue = FieldToTS<FieldConfig> | null | undefined\n\nexport interface ContentClientConfig {\n admin: AdminClient\n entity: Entity\n logger?: Logger\n}\n\nexport class ContentClient {\n private entity: Entity\n private admin: AdminClient\n private logger?: Logger\n private versionableOptions: ReturnType<typeof getVersionableOptions>\n\n constructor(config: ContentClientConfig) {\n this.entity = config.entity\n this.admin = config.admin\n this.logger = config.logger\n this.versionableOptions = getVersionableOptions(config.entity.behaviors)\n }\n\n /** Database handle inherited from the injected AdminClient. */\n private get db(): PostgresJsDatabase {\n return this.admin.getDb()\n }\n\n /** Lazy TableClient for the drafts table. Returns null if the entity has no drafts table. */\n private getDraftsClient() {\n const { drafts } = getContentTables(this.entity.name)\n return drafts ? drafts.makeClient(this.db) : null\n }\n\n /** Lazy TableClient for the versions table. Returns null if the entity has no versions table. */\n private getVersionsClient() {\n const { versions } = getContentTables(this.entity.name)\n return versions ? versions.makeClient(this.db) : null\n }\n\n /** Lazy TableClient for the locale_status table. Returns null if the entity has no locale_status table. */\n private getLocaleStatusClient() {\n const { localeStatus } = getContentTables(this.entity.name)\n return localeStatus ? localeStatus.makeClient(this.db) : null\n }\n\n // ---------------------------------------------------------------\n // Standard CRUD (delegated to AdminClient)\n // ---------------------------------------------------------------\n\n async create(data: Record<string, EntityFieldValue>) {\n const admin = this.admin\n const result = await admin.create(data)\n\n // Save initial version snapshot\n if (this.versionableOptions) {\n await this.saveVersionSnapshot(result.id, result, null, false)\n }\n\n return result\n }\n\n async findById(id: string, options?: { locale?: string }) {\n const admin = this.admin\n return admin.findById(id, options)\n }\n\n async findMany(options?: FindManyOptions) {\n const admin = this.admin\n return admin.findMany(options)\n }\n\n async update(id: string, data: Record<string, EntityFieldValue>) {\n const admin = this.admin\n\n // Get previous state for delta calculation\n let previousState: Record<string, unknown> | null = null\n if (this.versionableOptions) {\n previousState = await admin.findById(id)\n }\n\n const result = await admin.update(id, data)\n\n // Save version snapshot and update _version on the entity\n if (this.versionableOptions && previousState) {\n const delta = this.calculateDelta(previousState, result)\n const version = await this.saveVersionSnapshot(id, result, delta, false)\n\n if (version !== undefined) {\n await this.writeVersionColumn(id, version)\n // ContentClient is entity-generic; `_version` is added by the versionable\n // behavior at runtime but is not visible in the generic DTO. Safe write.\n ;(result as Record<string, unknown>)._version = version\n }\n\n await this.pruneVersions(id)\n }\n\n return result\n }\n\n async delete(id: string) {\n const admin = this.admin\n return admin.delete(id)\n }\n\n // ---------------------------------------------------------------\n // Translation delegation\n // ---------------------------------------------------------------\n\n async saveTranslation(entityId: string, locale: string, translations: Record<string, unknown>) {\n const admin = this.admin\n return admin.saveTranslation(entityId, locale, translations)\n }\n\n async deleteTranslation(entityId: string, locale?: string) {\n const admin = this.admin\n return admin.deleteTranslation(entityId, locale)\n }\n\n // ---------------------------------------------------------------\n // Block operations\n // ---------------------------------------------------------------\n\n /**\n * Copy layout from one locale to another (for localized: true blocks).\n *\n * NOTE: Layout tables are generated in `@murumets-ee/entity` (they need\n * FK references to the parent entity table, which `defineTable` does not\n * currently support). So this method uses direct Drizzle queries against\n * the layout pgTable via `schemaRegistry` — it is the one remaining\n * escape valve in ContentClient. If `defineTable` gains FK support in the\n * future, migrate this to a TableClient pattern like drafts/versions.\n */\n async copyLayout(entityId: string, options: { from: string; to: string | string[] }) {\n const { schemaRegistry } = await import('@murumets-ee/db')\n const { eq, and } = await import('drizzle-orm')\n\n const layoutTable = schemaRegistry.get(`${this.entity.name}_layout`)\n if (!layoutTable) return\n\n const targetLocales = Array.isArray(options.to) ? options.to : [options.to]\n\n // Get source layout rows\n const sourceRows = await this.db\n .select()\n .from(layoutTable)\n .where(and(eq(layoutTable.entityId, entityId), eq(layoutTable.locale, options.from)))\n .orderBy(layoutTable.sortOrder)\n\n if (sourceRows.length === 0) return\n\n for (const targetLocale of targetLocales) {\n // Delete existing target layout\n await this.db\n .delete(layoutTable)\n .where(and(eq(layoutTable.entityId, entityId), eq(layoutTable.locale, targetLocale)))\n\n // Copy source rows with new locale\n for (const row of sourceRows) {\n await this.db.insert(layoutTable).values({\n entityId: row.entityId,\n fieldName: row.fieldName,\n blockType: row.blockType,\n sortOrder: row.sortOrder,\n data: row.data,\n locale: targetLocale,\n })\n }\n }\n\n this.logger?.debug(\n { entityId, from: options.from, to: targetLocales },\n 'Layout copied between locales',\n )\n }\n\n // ---------------------------------------------------------------\n // Version operations\n // ---------------------------------------------------------------\n\n /**\n * List versions for an entity (newest first), filtered by locale.\n * Uses '_' as the locale value for default-locale snapshots.\n */\n async getVersions(\n entityId: string,\n options?: { limit?: number; offset?: number; locale?: string },\n ): Promise<VersionEntry[]> {\n const client = this.getVersionsClient()\n if (!client) return []\n\n const rows = await client.findMany({\n where: { entityId, locale: options?.locale ?? '_' },\n orderBy: [{ column: 'version', dir: 'desc' }],\n ...(options?.limit !== undefined ? { limit: options.limit } : {}),\n ...(options?.offset !== undefined ? { offset: options.offset } : {}),\n })\n // Row<VersionsColumns> is structurally compatible with VersionEntry.\n return rows\n }\n\n /**\n * Get a specific version snapshot.\n */\n async getVersion(entityId: string, version: number, locale = '_'): Promise<VersionEntry | null> {\n const client = this.getVersionsClient()\n if (!client) return null\n\n const row = await client.findOne({ entityId, version, locale })\n // Row<VersionsColumns> is structurally compatible with VersionEntry.\n return row ?? 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 */\n async compareVersions(\n entityId: string,\n v1: number,\n v2: number,\n locale = '_',\n ): Promise<{ diffs: FieldDiff[]; fromVersion: VersionEntry; toVersion: VersionEntry } | null> {\n const [version1, version2] = await Promise.all([\n this.getVersion(entityId, v1, locale),\n this.getVersion(entityId, v2, locale),\n ])\n\n if (!version1 || !version2) return null\n\n const data1 = version1.data as Record<string, unknown>\n const data2 = version2.data as Record<string, unknown>\n\n const fieldConfigs = this.entity.allFields as Record<string, FieldConfig> | undefined\n const diffs = diffFields(data1, data2, fieldConfigs)\n\n // Enhance blocks fields with structural diff\n for (const diff of diffs) {\n if (diff.type === 'blocks' && Array.isArray(diff.from) && Array.isArray(diff.to)) {\n diff.blockDiffs = diffBlocks(diff.from, diff.to)\n }\n }\n\n return { diffs, fromVersion: version1, toVersion: version2 }\n }\n\n /**\n * Compare the current draft with the live entity (or a specific version).\n * Returns field-level diffs between the current published data and the draft.\n */\n async compareDraftWithCurrent(\n entityId: string,\n locale = '_',\n ): Promise<{ diffs: FieldDiff[]; current: Record<string, unknown>; draft: DraftEntry } | null> {\n const draft = await this.getDraft(entityId, locale)\n if (!draft) return null\n\n const admin = this.admin\n\n // Fetch the live entity with the same locale context as the draft\n const { getContentConfig } = await import('./plugin.js')\n const config = getContentConfig()\n const defaultLocale = config.defaultLocale\n const findOptions = locale === '_' ? { defaultLocale } : { locale, defaultLocale }\n const current = (await admin.findById(entityId, findOptions)) as Record<string, unknown> | null\n if (!current) return null\n\n const fieldConfigs = this.entity.allFields as Record<string, FieldConfig> | undefined\n\n // Only compare fields present in the draft (draft stores a partial snapshot\n // of user-edited fields — missing fields are NOT \"changed to empty\")\n const currentSubset: Record<string, unknown> = {}\n for (const key of Object.keys(draft.data)) {\n currentSubset[key] = current[key]\n }\n const diffs = diffFields(currentSubset, draft.data, fieldConfigs)\n\n // Enhance blocks fields with structural diff\n for (const diff of diffs) {\n if (diff.type === 'blocks' && Array.isArray(diff.from) && Array.isArray(diff.to)) {\n diff.blockDiffs = diffBlocks(diff.from, diff.to)\n }\n }\n\n return { diffs, current, draft }\n }\n\n /**\n * Restore an entity to a previous version.\n */\n async restoreVersion(entityId: string, version: number) {\n const versionData = await this.getVersion(entityId, version)\n if (!versionData) {\n throw new Error(`Version ${version} not found for entity ${entityId}`)\n }\n\n const data = versionData.data as Record<string, EntityFieldValue>\n\n // Remove auto-generated fields before update\n const { id, createdAt, createdBy, _version, _versionedAt, ...updateData } = data\n\n const admin = this.admin\n return admin.update(entityId, updateData)\n }\n\n /**\n * Enforce maxPerDoc retention by deleting oldest versions for a locale.\n */\n async pruneVersions(entityId: string, locale = '_') {\n if (!this.versionableOptions) return\n const client = this.getVersionsClient()\n if (!client) return\n\n const maxPerDoc = this.versionableOptions.maxPerDoc\n\n // Get IDs of versions to keep (newest N for this locale)\n const keepVersions = await client.findMany({\n where: { entityId, locale },\n orderBy: [{ column: 'version', dir: 'desc' }],\n limit: maxPerDoc,\n })\n\n if (keepVersions.length < maxPerDoc) return // Under limit\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 // Translation status\n // ---------------------------------------------------------------\n\n /**\n * Get translation status for an entity — per-locale completion report.\n */\n async getTranslationStatus(entityId: string): Promise<TranslationStatus> {\n const { getContentConfig } = await import('./plugin.js')\n const config = getContentConfig()\n\n // Find all translatable fields\n const translatableFields = Object.entries(this.entity.allFields as Record<string, FieldConfig>)\n .filter(([_, f]) => f.translatable)\n .map(([name]) => name)\n\n if (translatableFields.length === 0) return {}\n\n // Translation tables are generated in @murumets-ee/entity (with FK refs)\n // — still accessed via schemaRegistry + direct Drizzle.\n const { schemaRegistry } = await import('@murumets-ee/db')\n const { eq } = await import('drizzle-orm')\n\n const translationTable = schemaRegistry.get(`${this.entity.name}_translations`)\n if (!translationTable) return {}\n\n const translations = await this.db\n .select()\n .from(translationTable)\n .where(eq(translationTable.entityId, entityId))\n\n // Build lookup: locale → { fieldName: value } from real columns\n const translationMap = new Map<string, Record<string, unknown>>()\n for (const t of translations) {\n const row = t as Record<string, unknown>\n const fieldValues: Record<string, unknown> = {}\n for (const fieldName of translatableFields) {\n fieldValues[fieldName] = row[fieldName]\n }\n translationMap.set(row.locale as string, fieldValues)\n }\n\n const status: TranslationStatus = {}\n\n for (const locale of config.locales) {\n if (locale.code === config.defaultLocale) continue // Skip default locale\n\n const translated = translationMap.get(locale.code) ?? {}\n const missing: string[] = []\n\n for (const fieldName of translatableFields) {\n if (translated[fieldName] === undefined || translated[fieldName] === null) {\n missing.push(fieldName)\n }\n }\n\n const translatedCount = translatableFields.length - missing.length\n\n status[locale.code] = {\n complete: missing.length === 0,\n total: translatableFields.length,\n translated: translatedCount,\n missing,\n }\n }\n\n return status\n }\n\n /**\n * Get translation status for multiple entities in a single query.\n * Returns a map of entityId → TranslationStatus (O(1) DB roundtrips).\n */\n async getTranslationStatusBatch(entityIds: string[]): Promise<Map<string, TranslationStatus>> {\n const result = new Map<string, TranslationStatus>()\n if (entityIds.length === 0) return result\n\n const { getContentConfig } = await import('./plugin.js')\n const config = getContentConfig()\n\n // Find all translatable fields\n const translatableFields = Object.entries(this.entity.allFields as Record<string, FieldConfig>)\n .filter(([_, f]) => f.translatable)\n .map(([name]) => name)\n\n if (translatableFields.length === 0) return result\n\n // Translation tables are generated in @murumets-ee/entity (with FK refs)\n // — still accessed via schemaRegistry + direct Drizzle.\n const { schemaRegistry } = await import('@murumets-ee/db')\n const { inArray } = await import('drizzle-orm')\n\n const translationTable = schemaRegistry.get(`${this.entity.name}_translations`)\n if (!translationTable) return result\n\n const translations = await this.db\n .select()\n .from(translationTable)\n .where(inArray(translationTable.entityId, entityIds))\n\n // Group translations by entityId → locale → { fieldName: value } from real columns\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 entityId = 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 if (!translationsByEntity.has(entityId)) {\n translationsByEntity.set(entityId, new Map())\n }\n translationsByEntity.get(entityId)!.set(locale, fieldValues)\n }\n\n // Compute status for each entity\n for (const entityId of entityIds) {\n const localeMap = translationsByEntity.get(entityId)\n const status: TranslationStatus = {}\n\n for (const locale of config.locales) {\n if (locale.code === config.defaultLocale) continue\n\n const translated = localeMap?.get(locale.code) ?? {}\n const missing: string[] = []\n\n for (const fieldName of translatableFields) {\n if (translated[fieldName] === undefined || translated[fieldName] === null) {\n missing.push(fieldName)\n }\n }\n\n const translatedCount = translatableFields.length - missing.length\n\n status[locale.code] = {\n complete: missing.length === 0,\n total: translatableFields.length,\n translated: translatedCount,\n missing,\n }\n }\n\n result.set(entityId, status)\n }\n\n return result\n }\n\n /**\n * Get translation report across all entities of this type.\n */\n async getTranslationReport(options?: { locale?: string }): Promise<TranslationReport> {\n const { getContentConfig } = await import('./plugin.js')\n const config = getContentConfig()\n\n const targetLocale =\n options?.locale ?? config.locales.find((l) => l.code !== config.defaultLocale)?.code\n\n if (!targetLocale) return { total: 0, complete: 0, partial: 0, missing: 0 }\n\n // Get all entities\n const admin = this.admin\n const entities = await admin.findMany()\n\n let complete = 0\n let partial = 0\n let missing = 0\n\n for (const entity of entities) {\n const status = await this.getTranslationStatus(entity.id)\n const localeStatus = status[targetLocale]\n\n if (!localeStatus || localeStatus.translated === 0) {\n missing++\n } else if (localeStatus.complete) {\n complete++\n } else {\n partial++\n }\n }\n\n return {\n total: entities.length,\n complete,\n partial,\n missing,\n }\n }\n\n // ---------------------------------------------------------------\n // Per-locale publish status\n // ---------------------------------------------------------------\n\n /**\n * Get per-locale publish status for an entity.\n * Returns status for all non-default locales that have explicit overrides.\n * Missing locale = inherits base entity status.\n */\n async getLocaleStatus(entityId: string): Promise<LocalePublishStatus[]> {\n const client = this.getLocaleStatusClient()\n if (!client) return []\n\n const rows = await client.findMany({ where: { entityId } })\n\n return rows.map((r) => ({\n locale: r.locale as string,\n status: r.status as 'draft' | 'published',\n publishedAt: (r.publishedAt as Date | null) ?? null,\n }))\n }\n\n /**\n * Set per-locale publish status. Upserts into locale_status table.\n * When locale === defaultLocale, updates the base entity status instead.\n */\n async setLocaleStatus(\n entityId: string,\n locale: string,\n status: 'draft' | 'published',\n ): Promise<void> {\n const { getContentConfig } = await import('./plugin.js')\n const config = getContentConfig()\n\n // Default locale → update base entity status column\n if (locale === config.defaultLocale) {\n const admin = this.admin\n const data: Record<string, EntityFieldValue> = { status }\n if (status === 'published') {\n data.publishedAt = new Date()\n }\n await admin.update(entityId, data)\n return\n }\n\n const client = this.getLocaleStatusClient()\n if (!client) return\n\n const publishedAt = status === 'published' ? new Date() : null\n\n await client.upsert(\n { entityId, locale, status, publishedAt },\n { target: ['entityId', 'locale'], set: { status, publishedAt } },\n )\n }\n\n /**\n * Get per-locale publish status for multiple entities in a single query.\n * Returns a map of entityId → locale → LocalePublishStatus.\n */\n async getLocaleStatusBatch(\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\n const client = this.getLocaleStatusClient()\n if (!client) return result\n\n const rows = await client.findMany({ where: { entityId: { in: entityIds } } })\n\n for (const r of rows) {\n const eid = r.entityId as string\n const locale = r.locale as string\n if (!result.has(eid)) {\n result.set(eid, {})\n }\n result.get(eid)![locale] = {\n locale,\n status: r.status as 'draft' | 'published',\n publishedAt: (r.publishedAt as Date | null) ?? null,\n }\n }\n\n return result\n }\n\n // ---------------------------------------------------------------\n // Draft operations\n // ---------------------------------------------------------------\n\n /**\n * Save (upsert) a draft for an entity+locale.\n * One draft per entity+locale — overwrites any existing draft.\n */\n async saveDraft(\n entityId: string,\n data: Record<string, unknown>,\n locale = '_',\n user?: { id: string; name?: string },\n ): Promise<DraftEntry> {\n const client = this.getDraftsClient()\n if (!client) {\n throw new Error(\n `Drafts table not found for entity \"${this.entity.name}\". Is the content plugin initialized?`,\n )\n }\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,\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, updatedAt: now },\n },\n )\n\n return this.toDraftEntry(row)\n }\n\n /**\n * Get the draft for an entity+locale, or null if none exists.\n */\n async getDraft(entityId: string, locale = '_'): Promise<DraftEntry | null> {\n const client = this.getDraftsClient()\n if (!client) return null\n\n const row = await client.findOne({ entityId, locale })\n return row ? this.toDraftEntry(row) : null\n }\n\n /**\n * Delete (discard) a draft for an entity+locale.\n */\n async deleteDraft(entityId: string, locale = '_'): Promise<boolean> {\n const client = this.getDraftsClient()\n if (!client) return false\n\n const deleted = await client.deleteMany({ entityId, locale })\n return deleted.length > 0\n }\n\n /**\n * Publish a draft: apply draft data to the live entity, then delete the draft.\n * Runs in a transaction to ensure atomicity.\n */\n async publishDraft(\n entityId: string,\n locale = '_',\n user?: { id: string; name?: string },\n ): Promise<Record<string, unknown>> {\n const draft = await this.getDraft(entityId, locale)\n if (!draft) {\n throw new Error(`No draft found for entity ${entityId} locale ${locale}`)\n }\n\n const admin = this.admin\n\n // Get previous state for version delta\n let previousState: Record<string, unknown> | null = null\n if (this.versionableOptions) {\n previousState = await admin.findById(entityId)\n }\n\n // Apply draft data to entity\n const draftData = draft.data as Record<string, EntityFieldValue>\n const { id, createdAt, createdBy, _version, _versionedAt, ...updateData } = draftData\n const result = await admin.update(entityId, updateData)\n\n // Delete the draft\n await this.deleteDraft(entityId, locale)\n\n // Create version snapshot\n if (this.versionableOptions) {\n await this.createVersionSnapshot(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 */\n async hasDraft(entityId: string, locale = '_'): Promise<boolean> {\n const client = this.getDraftsClient()\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 query — used by dashboard widgets.\n */\n async countDrafts(locale = '_'): Promise<number> {\n const client = this.getDraftsClient()\n if (!client) return 0\n return client.count({ locale })\n }\n\n /**\n * Check draft existence for multiple entities in one query.\n * Returns a set of entity IDs that have drafts.\n */\n async hasDraftBatch(entityIds: string[], locale = '_'): Promise<Set<string>> {\n const result = new Set<string>()\n if (entityIds.length === 0) return result\n\n const client = this.getDraftsClient()\n if (!client) return result\n\n const rows = await client.findMany({\n where: { entityId: { in: entityIds }, locale },\n })\n\n for (const row of rows) {\n result.add(row.entityId as string)\n }\n\n return result\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 */\n async 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 if (!this.versionableOptions) return\n const snapshotLocale = locale ?? '_'\n const delta = previousData ? this.calculateDelta(previousData, currentData) : null\n const version = await this.saveVersionSnapshot(\n entityId,\n currentData,\n delta,\n false,\n snapshotLocale,\n user,\n )\n\n // Update _version on the entity to match the snapshot version (default locale only)\n if (version !== undefined && snapshotLocale === '_') {\n await this.writeVersionColumn(entityId, version)\n }\n\n await this.pruneVersions(entityId, snapshotLocale)\n }\n\n // ---------------------------------------------------------------\n // Private helpers\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. This is the only ContentClient path that still\n * writes to an entity table directly.\n */\n private async writeVersionColumn(entityId: string, version: number) {\n const { schemaRegistry } = await import('@murumets-ee/db')\n const { eq } = await import('drizzle-orm')\n const table = schemaRegistry.get(this.entity.name)\n if (!table) return\n await this.db.update(table).set({ _version: version }).where(eq(table.id, entityId))\n }\n\n private async saveVersionSnapshot(\n entityId: string,\n data: Record<string, unknown>,\n delta: Record<string, unknown> | null,\n isAutosave: boolean,\n locale = '_',\n user?: { id: string; name?: string },\n ): Promise<number | undefined> {\n const client = this.getVersionsClient()\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\n const version = Number(maxRow?.maxVersion ?? 0) + 1\n const status = (data.status as string) ?? null\n\n // For autosave: update existing autosave version instead of creating new one.\n // We look for an autosave row at the SAME version number we just computed —\n // preserves the original control flow (autosaves land on the latest slot).\n if (isAutosave) {\n const existing = await client.findOne({\n entityId,\n isAutosave: true,\n version,\n locale,\n })\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 ?? (data.updatedBy as string) ?? (data.createdBy as string) ?? null,\n createdByName: user?.name ?? null,\n isAutosave,\n })\n return version\n }\n\n private 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 private calculateDelta(\n previous: Record<string, unknown>,\n current: Record<string, unknown>,\n ): Record<string, unknown> {\n const delta: Record<string, unknown> = {}\n\n for (const key of Object.keys(current)) {\n if (key === 'id' || key.startsWith('_')) continue\n if (JSON.stringify(previous[key]) !== JSON.stringify(current[key])) {\n delta[key] = current[key]\n }\n }\n\n return delta\n }\n}\n\n/**\n * Factory — creates a ContentClient for an entity.\n * Must be called after createApp().\n */\nexport async function createContentClient(entity: Entity): Promise<ContentClient> {\n const { createAdminClient } = await import('@murumets-ee/core/clients')\n const { getApp } = await import('@murumets-ee/core')\n const admin = createAdminClient(entity)\n return new ContentClient({\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/projection.ts","../src/versions.ts","../src/drafts.ts","../src/layout.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 for (let j = 1; j <= n; j++) {\n if (fromTokens[i - 1] === toTokens[j - 1]) {\n dp[i][j] = dp[i - 1][j - 1] + 1\n } else {\n dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1])\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 if (i > 0 && j > 0 && fromTokens[i - 1] === toTokens[j - 1]) {\n reversed.push({ type: 'equal', value: fromTokens[i - 1] })\n i--\n j--\n } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {\n reversed.push({ type: 'add', value: toTokens[j - 1] })\n j--\n } else {\n reversed.push({ type: 'remove', value: fromTokens[i - 1] })\n i--\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 'blocks':\n // Blocks are handled separately by block-diff.ts — just mark the field\n return { field: fieldName, type: 'blocks', 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 // Check if it looks like a blocks array\n if (val.length > 0 && typeof val[0] === 'object' && val[0] !== null && '_block' in val[0]) {\n return 'blocks'\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 Puck block arrays.\n *\n * Blocks are identified by `_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 values)\n * - Unchanged blocks\n */\n\nimport { diffStringValues } from './field-diff.js'\nimport type { BlockDiffEntry, PropDiff } from './types.js'\n\ninterface Block {\n _block: string\n _id: string\n [key: string]: unknown\n}\n\nfunction isBlock(val: unknown): val is Block {\n return (\n typeof val === 'object' &&\n val !== null &&\n '_block' in val &&\n '_id' in val &&\n typeof (val as Block)._block === 'string' &&\n typeof (val as Block)._id === 'string'\n )\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 // Skip block metadata\n if (key === '_block' || key === '_id') continue\n\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 * Compute structural diff between two block arrays.\n *\n * Each block is identified by its `_id` field. Position changes,\n * additions, removals, and prop-level changes are all detected.\n */\nexport function diffBlocks(fromBlocks: unknown[], toBlocks: unknown[]): BlockDiffEntry[] {\n const from = fromBlocks.filter(isBlock)\n const to = toBlocks.filter(isBlock)\n\n const fromMap = new Map<string, { block: Block; index: number }>()\n const toMap = new Map<string, { block: Block; index: number }>()\n\n for (let i = 0; i < from.length; i++) {\n fromMap.set(from[i]._id, { block: from[i], index: i })\n }\n for (let i = 0; i < to.length; i++) {\n toMap.set(to[i]._id, { block: to[i], index: i })\n }\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._block,\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, toBlock)\n const moved = fromIndex !== toIndex\n\n if (propDiffs.length === 0 && !moved) {\n entries.push({\n blockId: id,\n blockType: toBlock._block,\n status: 'unchanged',\n fromIndex,\n toIndex,\n })\n } else if (propDiffs.length > 0) {\n entries.push({\n blockId: id,\n blockType: toBlock._block,\n status: 'changed',\n fromIndex,\n toIndex,\n propDiffs,\n })\n } else {\n entries.push({\n blockId: id,\n blockType: toBlock._block,\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._block,\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\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 * 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, InferUpdateInput } 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 { 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 === 'blocks' && Array.isArray(diff.from) && Array.isArray(diff.to)) {\n diff.blockDiffs = diffBlocks(diff.from, diff.to)\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 `admin.update()`, projected through `entity.allFields` so\n * forbidden fields (workflow internals, behavior-managed underscore\n * fields, fields the entity declared `internal: true`) cannot leak.\n */\nexport async function restoreVersion<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n version: number,\n): Promise<InferEntityDTO<F>> {\n if (!getVersionsClient(ctx)) {\n throw new EntityCapabilityError(ctx.entity.name, 'versions')\n }\n const versionData = await getVersion(ctx, entityId, version)\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 ctx.admin.update(entityId, updateData as InferUpdateInput<F>)\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 { EntityCapabilityError } from './errors.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 via `admin.update()`, then delete the draft and\n * (if versionable) record a version snapshot.\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 Error(`No draft found for entity ${entityId} locale ${locale}`)\n }\n\n // Get previous state for the version delta.\n let previousState: InferEntityDTO<F> | null = null\n if (ctx.versionableOptions) {\n previousState = await ctx.admin.findById(entityId)\n }\n\n // Apply draft data, projecting through the entity's field whitelist.\n // The draft was written from arbitrary user input; anything not\n // declared in `entity.allFields` (or marked `internal: true`) is\n // silently dropped — so a draft saved with `status: 'published'`\n // cannot bypass the publishable workflow when published.\n const draftData = draft.data as Record<string, unknown>\n const updateData = projectToEntityFields(ctx.entity, draftData)\n const result = await ctx.admin.update(entityId, updateData as InferUpdateInput<F>)\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 === 'blocks' && Array.isArray(diff.from) && Array.isArray(diff.to)) {\n diff.blockDiffs = diffBlocks(diff.from, diff.to)\n }\n }\n\n return { diffs, current, draft }\n}\n","/**\n * Block-layout operations for entities with `field.blocks()`.\n *\n * Layout tables are generated in `@murumets-ee/entity` (they need FK\n * references to the parent entity table, which `defineTable` does not\n * currently support), so this module is the one place ContentClient\n * still uses direct Drizzle queries against the layout pgTable. Once\n * `defineTable` gains FK support this will move behind a TableClient\n * like drafts/versions.\n */\n\nimport 'server-only'\n\nimport { schemaRegistry } from '@murumets-ee/db'\nimport { and, eq } from 'drizzle-orm'\nimport type { ContentContext } from './internal/context.js'\nimport { validateLocale } from './locale-utils.js'\n\n/**\n * Copy block layout from one locale to another (for `localized: true`\n * blocks fields). Replaces the target locale's layout entirely.\n */\nexport async function copyLayout(\n ctx: ContentContext,\n entityId: string,\n options: { from: string; to: string | string[] },\n): Promise<void> {\n validateLocale(options.from)\n const targets = Array.isArray(options.to) ? options.to : [options.to]\n for (const t of targets) validateLocale(t)\n\n const layoutTable = schemaRegistry.get(`${ctx.entity.name}_layout`)\n if (!layoutTable) return\n\n // Get source layout rows\n const sourceRows = await ctx.db\n .select()\n .from(layoutTable)\n .where(and(eq(layoutTable.entityId, entityId), eq(layoutTable.locale, options.from)))\n .orderBy(layoutTable.sortOrder)\n if (sourceRows.length === 0) return\n\n for (const targetLocale of targets) {\n // Delete existing target layout\n await ctx.db\n .delete(layoutTable)\n .where(and(eq(layoutTable.entityId, entityId), eq(layoutTable.locale, targetLocale)))\n\n // Copy source rows under the new locale\n for (const row of sourceRows) {\n await ctx.db.insert(layoutTable).values({\n entityId: row.entityId,\n fieldName: row.fieldName,\n blockType: row.blockType,\n sortOrder: row.sortOrder,\n data: row.data,\n locale: targetLocale,\n })\n }\n }\n\n ctx.logger?.debug({ entityId, from: options.from, to: targets }, 'Layout copied between locales')\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 { 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 const publishedAt = status === 'published' ? new Date() : null\n await client.upsert(\n { entityId, locale, status, publishedAt },\n { target: ['entityId', 'locale'], set: { status, publishedAt } },\n )\n}\n\n/**\n * 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 * 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 { copyLayout } from './layout.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 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 // Block layout\n // -----------------------------------------------------------------\n\n copyLayout(entityId: string, options: { from: string; to: string | string[] }) {\n return copyLayout(this.ctx, entityId, options)\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) {\n return restoreVersion(this.ctx, entityId, version)\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,2 +1,2 @@
|
|
|
1
1
|
function e(e){if(!e.slug||!e.slug.match(/^[a-z][a-z0-9-]*$/))throw Error(`Block slug must be lowercase alphanumeric with hyphens, got: "${e.slug}"`);if(!e.label)throw Error(`Block label is required for slug: "${e.slug}"`);if(!e.fields||Object.keys(e.fields).length===0)throw Error(`Block "${e.slug}" must have at least one field`);return{slug:e.slug,label:e.label,fields:e.fields}}export{e as t};
|
|
2
|
-
//# sourceMappingURL=define-block-
|
|
2
|
+
//# sourceMappingURL=define-block-D-7OpIn5.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"define-block-
|
|
1
|
+
{"version":3,"file":"define-block-D-7OpIn5.mjs","names":[],"sources":["../src/define-block.ts"],"sourcesContent":["/**\n * Block definition API\n *\n * defineBlock() creates a reusable content block with typed fields.\n * Uses `const F` generic to preserve literal field types for inference.\n *\n * Blocks are NOT entities — they lack independent identity, CRUD, and access control.\n * They exist only as embedded data within a parent entity's blocks field.\n *\n * @example\n * const HeroBlock = defineBlock({\n * slug: 'hero',\n * label: 'Hero Section',\n * fields: {\n * heading: field.text({ required: true, translatable: true }),\n * background: field.media({ accept: ['image/*'] }),\n * },\n * })\n */\n\nimport type { FieldConfig } from '@murumets-ee/entity'\nimport type { BlockDefinition } from './types.js'\n\nexport function defineBlock<const F extends Record<string, FieldConfig>>(definition: {\n slug: string\n label: string\n fields: F\n}): BlockDefinition<F> {\n if (!definition.slug || !definition.slug.match(/^[a-z][a-z0-9-]*$/)) {\n throw new Error(\n `Block slug must be lowercase alphanumeric with hyphens, got: \"${definition.slug}\"`,\n )\n }\n\n if (!definition.label) {\n throw new Error(`Block label is required for slug: \"${definition.slug}\"`)\n }\n\n if (!definition.fields || Object.keys(definition.fields).length === 0) {\n throw new Error(`Block \"${definition.slug}\" must have at least one field`)\n }\n\n return {\n slug: definition.slug,\n label: definition.label,\n fields: definition.fields,\n }\n}\n"],"mappings":"AAuBA,SAAgB,EAAyD,EAIlD,CACrB,GAAI,CAAC,EAAW,MAAQ,CAAC,EAAW,KAAK,MAAM,oBAAoB,CACjE,MAAU,MACR,iEAAiE,EAAW,KAAK,GAClF,CAGH,GAAI,CAAC,EAAW,MACd,MAAU,MAAM,sCAAsC,EAAW,KAAK,GAAG,CAG3E,GAAI,CAAC,EAAW,QAAU,OAAO,KAAK,EAAW,OAAO,CAAC,SAAW,EAClE,MAAU,MAAM,UAAU,EAAW,KAAK,gCAAgC,CAG5E,MAAO,CACL,KAAM,EAAW,KACjB,MAAO,EAAW,MAClB,OAAQ,EAAW,OACpB"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
var e=class extends Error{entityName;capability;constructor(e,t,n){super(n??`Entity "${e}" does not support ${t}. Add the corresponding behavior to defineEntity({ behaviors: [...] }).`),this.name=`EntityCapabilityError`,this.entityName=e,this.capability=t}},t=class extends Error{locale;allowed;constructor(e,t){super(`Invalid locale "${e}". Configured locales: [${t.join(`, `)}]. (The "_" sentinel is also accepted for default-locale storage.)`),this.name=`InvalidLocaleError`,this.locale=e,this.allowed=t}};export{t as n,e as t};
|
|
2
|
+
//# sourceMappingURL=errors-kkf8Ue1H.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors-kkf8Ue1H.mjs","names":[],"sources":["../src/errors.ts"],"sourcesContent":["/**\n * Domain errors for ContentClient + LockService.\n */\n\n/**\n * Thrown when a caller invokes an entity-capability-specific method on an\n * entity that doesn't have the corresponding behavior — e.g. `saveDraft`\n * on a non-publishable entity, `setLocaleStatus` on an entity without\n * publishable+translatable, `restoreVersion` on a non-versionable entity.\n *\n * Distinct from generic `Error` so admin-ui's API handler can map it to\n * 400 Bad Request (caller's fault) rather than 500 (server bug).\n */\nexport class EntityCapabilityError extends Error {\n readonly entityName: string\n readonly capability: 'drafts' | 'versions' | 'localeStatus' | 'translations'\n\n constructor(\n entityName: string,\n capability: EntityCapabilityError['capability'],\n message?: string,\n ) {\n super(\n message ??\n `Entity \"${entityName}\" does not support ${capability}. ` +\n `Add the corresponding behavior to defineEntity({ behaviors: [...] }).`,\n )\n this.name = 'EntityCapabilityError'\n this.entityName = entityName\n this.capability = capability\n }\n}\n\n/**\n * Thrown when a locale string passed at runtime is not configured in the\n * content plugin. The plugin validates `defaultLocale` at init time, but\n * the per-call `locale` parameter on every read/write is not naturally\n * checked — `validateLocale()` is the boundary check.\n *\n * Allows the `'_'` sentinel (used internally for default-locale rows in\n * versions/drafts/locale_status tables) so callers can pass it through.\n */\nexport class InvalidLocaleError extends Error {\n readonly locale: string\n readonly allowed: readonly string[]\n\n constructor(locale: string, allowed: readonly string[]) {\n super(\n `Invalid locale \"${locale}\". Configured locales: [${allowed.join(', ')}]. ` +\n `(The \"_\" sentinel is also accepted for default-locale storage.)`,\n )\n this.name = 'InvalidLocaleError'\n this.locale = locale\n this.allowed = allowed\n }\n}\n"],"mappings":"AAaA,IAAa,EAAb,cAA2C,KAAM,CAC/C,WACA,WAEA,YACE,EACA,EACA,EACA,CACA,MACE,GACE,WAAW,EAAW,qBAAqB,EAAW,yEAEzD,CACD,KAAK,KAAO,wBACZ,KAAK,WAAa,EAClB,KAAK,WAAa,IAaT,EAAb,cAAwC,KAAM,CAC5C,OACA,QAEA,YAAY,EAAgB,EAA4B,CACtD,MACE,mBAAmB,EAAO,0BAA0B,EAAQ,KAAK,KAAK,CAAC,oEAExE,CACD,KAAK,KAAO,qBACZ,KAAK,OAAS,EACd,KAAK,QAAU"}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { p as VersionableOptions, t as BlockDefinition } from "./types-BCmX1nBL.mjs";
|
|
2
|
+
import { Behavior, DateField, Entity, FieldConfig, NumberField, SelectField, TextField } from "@murumets-ee/entity";
|
|
3
|
+
|
|
4
|
+
//#region src/behaviors/versionable.d.ts
|
|
5
|
+
type VersionableFields = {
|
|
6
|
+
_version: NumberField & {
|
|
7
|
+
required: true;
|
|
8
|
+
};
|
|
9
|
+
_versionedAt: DateField;
|
|
10
|
+
};
|
|
11
|
+
declare function versionable(options?: VersionableOptions): Behavior<VersionableFields>;
|
|
12
|
+
/**
|
|
13
|
+
* Extract versionable options from an entity's behaviors
|
|
14
|
+
*/
|
|
15
|
+
declare function getVersionableOptions(behaviors?: Behavior[]): Required<VersionableOptions> | null;
|
|
16
|
+
//#endregion
|
|
17
|
+
//#region src/behaviors/workflowable.d.ts
|
|
18
|
+
declare const WORKFLOW_STATES: readonly ["draft", "in_review", "approved", "rejected", "published"];
|
|
19
|
+
type WorkflowState = (typeof WORKFLOW_STATES)[number];
|
|
20
|
+
/**
|
|
21
|
+
* Allowed transitions table. Each key is the *current* workflow status;
|
|
22
|
+
* the value is the set of statuses it can move to.
|
|
23
|
+
*
|
|
24
|
+
* Encoded as a const-asserted record so a typo in the source produces
|
|
25
|
+
* a compile error rather than a silent no-match at runtime.
|
|
26
|
+
*/
|
|
27
|
+
declare const WORKFLOW_TRANSITIONS: {
|
|
28
|
+
readonly draft: readonly ["in_review"];
|
|
29
|
+
readonly in_review: readonly ["approved", "rejected"];
|
|
30
|
+
readonly approved: readonly ["published", "in_review"];
|
|
31
|
+
readonly rejected: readonly ["in_review", "draft"];
|
|
32
|
+
readonly published: readonly ["draft"];
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Is `to` an allowed transition from `from`? Pure, no I/O.
|
|
36
|
+
*/
|
|
37
|
+
declare function isAllowedTransition(from: WorkflowState, to: WorkflowState): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Thrown when `beforeUpdate` rejects a workflow transition. Distinct
|
|
40
|
+
* subclass so admin-ui's API handler can map it to HTTP 400.
|
|
41
|
+
*/
|
|
42
|
+
declare class WorkflowTransitionError extends Error {
|
|
43
|
+
readonly from: WorkflowState;
|
|
44
|
+
readonly to: WorkflowState;
|
|
45
|
+
constructor(from: WorkflowState, to: WorkflowState);
|
|
46
|
+
}
|
|
47
|
+
type WorkflowableFields = {
|
|
48
|
+
_workflowStatus: SelectField & {
|
|
49
|
+
required: true;
|
|
50
|
+
};
|
|
51
|
+
_workflowAssignee: TextField;
|
|
52
|
+
_workflowNote: TextField;
|
|
53
|
+
_workflowUpdatedAt: DateField;
|
|
54
|
+
};
|
|
55
|
+
declare function workflowable(): Behavior<WorkflowableFields>;
|
|
56
|
+
//#endregion
|
|
57
|
+
//#region src/capabilities.d.ts
|
|
58
|
+
type EntityCapability = 'drafts' | 'versions' | 'localeStatus' | 'translations';
|
|
59
|
+
/**
|
|
60
|
+
* Does this entity support the given content capability? Pure check
|
|
61
|
+
* against the entity's declared behaviors and field translatability —
|
|
62
|
+
* no plugin lookup, no DB call.
|
|
63
|
+
*/
|
|
64
|
+
declare function entitySupports(entity: Entity, capability: EntityCapability): boolean;
|
|
65
|
+
//#endregion
|
|
66
|
+
//#region src/define-block.d.ts
|
|
67
|
+
declare function defineBlock<const F extends Record<string, FieldConfig>>(definition: {
|
|
68
|
+
slug: string;
|
|
69
|
+
label: string;
|
|
70
|
+
fields: F;
|
|
71
|
+
}): BlockDefinition<F>;
|
|
72
|
+
//#endregion
|
|
73
|
+
//#region src/diff/types.d.ts
|
|
74
|
+
/**
|
|
75
|
+
* Diff result types for version comparison.
|
|
76
|
+
*/
|
|
77
|
+
/** A segment within a word-level text diff. */
|
|
78
|
+
interface DiffSegment {
|
|
79
|
+
type: 'equal' | 'add' | 'remove';
|
|
80
|
+
value: string;
|
|
81
|
+
}
|
|
82
|
+
/** Diff result for a single scalar field. */
|
|
83
|
+
interface FieldDiff {
|
|
84
|
+
field: string;
|
|
85
|
+
type: 'text' | 'number' | 'boolean' | 'date' | 'select' | 'media' | 'richtext' | 'json' | 'blocks' | 'unknown';
|
|
86
|
+
from: unknown;
|
|
87
|
+
to: unknown;
|
|
88
|
+
/** Word-level diff segments (present for text/richtext/slug fields). */
|
|
89
|
+
segments?: DiffSegment[];
|
|
90
|
+
/** Block-level structural diff (present for blocks fields). */
|
|
91
|
+
blockDiffs?: BlockDiffEntry[];
|
|
92
|
+
}
|
|
93
|
+
/** Status of a single block within a structural diff. */
|
|
94
|
+
type BlockDiffStatus = 'added' | 'removed' | 'moved' | 'changed' | 'unchanged';
|
|
95
|
+
/** Diff result for a single block within a blocks field. */
|
|
96
|
+
interface BlockDiffEntry {
|
|
97
|
+
blockId: string;
|
|
98
|
+
blockType: string;
|
|
99
|
+
status: BlockDiffStatus;
|
|
100
|
+
fromIndex?: number;
|
|
101
|
+
toIndex?: number;
|
|
102
|
+
/** Per-prop diffs for changed blocks. */
|
|
103
|
+
propDiffs?: PropDiff[];
|
|
104
|
+
}
|
|
105
|
+
/** Diff for a single prop within a changed block. */
|
|
106
|
+
interface PropDiff {
|
|
107
|
+
field: string;
|
|
108
|
+
from: unknown;
|
|
109
|
+
to: unknown;
|
|
110
|
+
/** Word-level segments for string props. */
|
|
111
|
+
segments?: DiffSegment[];
|
|
112
|
+
}
|
|
113
|
+
//#endregion
|
|
114
|
+
//#region src/errors.d.ts
|
|
115
|
+
/**
|
|
116
|
+
* Domain errors for ContentClient + LockService.
|
|
117
|
+
*/
|
|
118
|
+
/**
|
|
119
|
+
* Thrown when a caller invokes an entity-capability-specific method on an
|
|
120
|
+
* entity that doesn't have the corresponding behavior — e.g. `saveDraft`
|
|
121
|
+
* on a non-publishable entity, `setLocaleStatus` on an entity without
|
|
122
|
+
* publishable+translatable, `restoreVersion` on a non-versionable entity.
|
|
123
|
+
*
|
|
124
|
+
* Distinct from generic `Error` so admin-ui's API handler can map it to
|
|
125
|
+
* 400 Bad Request (caller's fault) rather than 500 (server bug).
|
|
126
|
+
*/
|
|
127
|
+
declare class EntityCapabilityError extends Error {
|
|
128
|
+
readonly entityName: string;
|
|
129
|
+
readonly capability: 'drafts' | 'versions' | 'localeStatus' | 'translations';
|
|
130
|
+
constructor(entityName: string, capability: EntityCapabilityError['capability'], message?: string);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Thrown when a locale string passed at runtime is not configured in the
|
|
134
|
+
* content plugin. The plugin validates `defaultLocale` at init time, but
|
|
135
|
+
* the per-call `locale` parameter on every read/write is not naturally
|
|
136
|
+
* checked — `validateLocale()` is the boundary check.
|
|
137
|
+
*
|
|
138
|
+
* Allows the `'_'` sentinel (used internally for default-locale rows in
|
|
139
|
+
* versions/drafts/locale_status tables) so callers can pass it through.
|
|
140
|
+
*/
|
|
141
|
+
declare class InvalidLocaleError extends Error {
|
|
142
|
+
readonly locale: string;
|
|
143
|
+
readonly allowed: readonly string[];
|
|
144
|
+
constructor(locale: string, allowed: readonly string[]);
|
|
145
|
+
}
|
|
146
|
+
//#endregion
|
|
147
|
+
export { workflowable as _, DiffSegment as a, defineBlock as c, WORKFLOW_STATES as d, WORKFLOW_TRANSITIONS as f, isAllowedTransition as g, WorkflowableFields as h, BlockDiffStatus as i, EntityCapability as l, WorkflowTransitionError as m, InvalidLocaleError as n, FieldDiff as o, WorkflowState as p, BlockDiffEntry as r, PropDiff as s, EntityCapabilityError as t, entitySupports as u, getVersionableOptions as v, versionable as y };
|
|
148
|
+
//# sourceMappingURL=index-dos8Dc3T.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index-dos8Dc3T.d.mts","names":[],"sources":["../src/behaviors/versionable.ts","../src/behaviors/workflowable.ts","../src/capabilities.ts","../src/define-block.ts","../src/diff/types.ts","../src/errors.ts"],"mappings":";;;;KAmBY,iBAAA;EACV,QAAA,EAAU,WAAA;IAAgB,QAAA;EAAA;EAC1B,YAAA,EAAc,SAAA;AAAA;AAAA,iBAQA,WAAA,CAAY,OAAA,GAAU,kBAAA,GAAqB,QAAA,CAAS,iBAAA;;;;iBAqDpD,qBAAA,CAAsB,SAAA,GAAY,QAAA,KAAa,QAAA,CAAS,kBAAA;;;cCpC3D,eAAA;AAAA,KACD,aAAA,WAAwB,eAAA;;;;;;;;cASvB,oBAAA;EAAA;;;;;;;;;iBAWG,mBAAA,CAAoB,IAAA,EAAM,aAAA,EAAe,EAAA,EAAI,aAAA;;;;;cAUhD,uBAAA,SAAgC,KAAA;EAAA,SAClC,IAAA,EAAM,aAAA;EAAA,SACN,EAAA,EAAI,aAAA;cAED,IAAA,EAAM,aAAA,EAAe,EAAA,EAAI,aAAA;AAAA;AAAA,KAW3B,kBAAA;EACV,eAAA,EAAiB,WAAA;IAAgB,QAAA;EAAA;EACjC,iBAAA,EAAmB,SAAA;EACnB,aAAA,EAAe,SAAA;EACf,kBAAA,EAAoB,SAAA;AAAA;AAAA,iBAGN,YAAA,CAAA,GAAgB,QAAA,CAAS,kBAAA;;;KC7E7B,gBAAA;;;;;;iBAOI,cAAA,CAAe,MAAA,EAAQ,MAAA,EAAQ,UAAA,EAAY,gBAAA;;;iBCN3C,WAAA,iBAA4B,MAAA,SAAe,WAAA,EAAA,CAAc,UAAA;EACvE,IAAA;EACA,KAAA;EACA,MAAA,EAAQ,CAAA;AAAA,IACN,eAAA,CAAgB,CAAA;;;;;;;UCtBH,WAAA;EACf,IAAA;EACA,KAAA;AAAA;;UAIe,SAAA;EACf,KAAA;EACA,IAAA;EAWA,IAAA;EACA,EAAA;EJJuB;EIMvB,QAAA,GAAW,WAAA;EJEc;EIAzB,UAAA,GAAa,cAAA;AAAA;;KAIH,eAAA;;UAGK,cAAA;EACf,OAAA;EACA,SAAA;EACA,MAAA,EAAQ,eAAA;EACR,SAAA;EACA,OAAA;EJZmF;EIcnF,SAAA,GAAY,QAAA;AAAA;;UAIG,QAAA;EACf,KAAA;EACA,IAAA;EACA,EAAA;EJgCqE;EI9BrE,QAAA,GAAW,WAAA;AAAA;;;;;;;AJjCb;;;;;;;;cKNa,qBAAA,SAA8B,KAAA;EAAA,SAChC,UAAA;EAAA,SACA,UAAA;cAGP,UAAA,UACA,UAAA,EAAY,qBAAA,gBACZ,OAAA;AAAA;;;;;;;;;;cAsBS,kBAAA,SAA2B,KAAA;EAAA,SAC7B,MAAA;EAAA,SACA,OAAA;cAEG,MAAA,UAAgB,OAAA;AAAA"}
|
package/dist/index.d.mts
CHANGED
|
@@ -1,71 +1,3 @@
|
|
|
1
|
-
import { a as InferBlocksUnion, c as LockInfo, d as TranslationStatus, f as VersionEntry, i as InferBlockData, l as TranslationFieldStatus, n as ContentConfig, o as LocaleConfig, p as VersionableOptions, r as DraftEntry, t as BlockDefinition, u as TranslationReport } from "./types-
|
|
2
|
-
import { a as
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
//#region src/behaviors/versionable.d.ts
|
|
6
|
-
type VersionableFields = {
|
|
7
|
-
_version: NumberField & {
|
|
8
|
-
required: true;
|
|
9
|
-
};
|
|
10
|
-
_versionedAt: DateField;
|
|
11
|
-
};
|
|
12
|
-
declare function versionable(options?: VersionableOptions): Behavior<VersionableFields>;
|
|
13
|
-
/**
|
|
14
|
-
* Extract versionable options from an entity's behaviors
|
|
15
|
-
*/
|
|
16
|
-
declare function getVersionableOptions(behaviors?: Behavior[]): Required<VersionableOptions> | null;
|
|
17
|
-
//#endregion
|
|
18
|
-
//#region src/behaviors/workflowable.d.ts
|
|
19
|
-
declare const WORKFLOW_STATES: readonly ["draft", "in_review", "approved", "rejected", "published"];
|
|
20
|
-
type WorkflowState = (typeof WORKFLOW_STATES)[number];
|
|
21
|
-
type WorkflowableFields = {
|
|
22
|
-
_workflowStatus: SelectField & {
|
|
23
|
-
required: true;
|
|
24
|
-
};
|
|
25
|
-
_workflowAssignee: TextField;
|
|
26
|
-
_workflowNote: TextField;
|
|
27
|
-
_workflowUpdatedAt: DateField;
|
|
28
|
-
};
|
|
29
|
-
declare function workflowable(): Behavior<WorkflowableFields>;
|
|
30
|
-
//#endregion
|
|
31
|
-
//#region src/define-block.d.ts
|
|
32
|
-
declare function defineBlock<const F extends Record<string, FieldConfig>>(definition: {
|
|
33
|
-
slug: string;
|
|
34
|
-
label: string;
|
|
35
|
-
fields: F;
|
|
36
|
-
}): BlockDefinition<F>;
|
|
37
|
-
//#endregion
|
|
38
|
-
//#region src/diff/block-diff.d.ts
|
|
39
|
-
/**
|
|
40
|
-
* Compute structural diff between two block arrays.
|
|
41
|
-
*
|
|
42
|
-
* Each block is identified by its `_id` field. Position changes,
|
|
43
|
-
* additions, removals, and prop-level changes are all detected.
|
|
44
|
-
*/
|
|
45
|
-
declare function diffBlocks(fromBlocks: unknown[], toBlocks: unknown[]): BlockDiffEntry[];
|
|
46
|
-
//#endregion
|
|
47
|
-
//#region src/diff/field-diff.d.ts
|
|
48
|
-
/**
|
|
49
|
-
* Compute diff for a single field, using the field's type to determine
|
|
50
|
-
* the best diff strategy.
|
|
51
|
-
*/
|
|
52
|
-
declare function diffField(fieldName: string, from: unknown, to: unknown, fieldConfig?: FieldConfig): FieldDiff | null;
|
|
53
|
-
/**
|
|
54
|
-
* Diff all fields between two version snapshots.
|
|
55
|
-
* Pass fieldConfigs to get type-aware diffs; without them, types are inferred.
|
|
56
|
-
*/
|
|
57
|
-
declare function diffFields(from: Record<string, unknown>, to: Record<string, unknown>, fieldConfigs?: Record<string, FieldConfig>): FieldDiff[];
|
|
58
|
-
/** Diff two string values and return segments (used by block-diff for string props). */
|
|
59
|
-
declare function diffStringValues(from: unknown, to: unknown): DiffSegment[] | undefined;
|
|
60
|
-
//#endregion
|
|
61
|
-
//#region src/diff/text-diff.d.ts
|
|
62
|
-
/**
|
|
63
|
-
* Compute word-level diff between two strings.
|
|
64
|
-
*
|
|
65
|
-
* Uses a simple LCS (dynamic programming) approach which is fine for
|
|
66
|
-
* the text sizes we deal with (entity field values, not source code).
|
|
67
|
-
*/
|
|
68
|
-
declare function diffText(from: string, to: string): DiffSegment[];
|
|
69
|
-
//#endregion
|
|
70
|
-
export { type BlockDefinition, type BlockDiffEntry, type BlockDiffStatus, type ContentConfig, type DiffSegment, type DraftEntry, type FieldDiff, type InferBlockData, type InferBlocksUnion, type LocaleConfig, type LockInfo, type PropDiff, type TranslationFieldStatus, type TranslationReport, type TranslationStatus, type VersionEntry, type VersionableOptions, WORKFLOW_STATES, type WorkflowState, type WorkflowableFields, defineBlock, diffBlocks, diffField, diffFields, diffStringValues, diffText, getVersionableOptions, versionable, workflowable };
|
|
71
|
-
//# sourceMappingURL=index.d.mts.map
|
|
1
|
+
import { a as InferBlocksUnion, c as LockInfo, d as TranslationStatus, f as VersionEntry, i as InferBlockData, l as TranslationFieldStatus, n as ContentConfig, o as LocaleConfig, p as VersionableOptions, r as DraftEntry, t as BlockDefinition, u as TranslationReport } from "./types-BCmX1nBL.mjs";
|
|
2
|
+
import { _ as workflowable, a as DiffSegment, c as defineBlock, d as WORKFLOW_STATES, f as WORKFLOW_TRANSITIONS, g as isAllowedTransition, h as WorkflowableFields, i as BlockDiffStatus, l as EntityCapability, m as WorkflowTransitionError, n as InvalidLocaleError, o as FieldDiff, p as WorkflowState, r as BlockDiffEntry, s as PropDiff, t as EntityCapabilityError, u as entitySupports, v as getVersionableOptions, y as versionable } from "./index-dos8Dc3T.mjs";
|
|
3
|
+
export { BlockDefinition, BlockDiffEntry, BlockDiffStatus, ContentConfig, DiffSegment, DraftEntry, EntityCapability, EntityCapabilityError, FieldDiff, InferBlockData, InferBlocksUnion, InvalidLocaleError, LocaleConfig, LockInfo, PropDiff, TranslationFieldStatus, TranslationReport, TranslationStatus, VersionEntry, VersionableOptions, WORKFLOW_STATES, WORKFLOW_TRANSITIONS, WorkflowState, WorkflowTransitionError, WorkflowableFields, defineBlock, entitySupports, getVersionableOptions, isAllowedTransition, versionable, workflowable };
|
package/dist/index.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{
|
|
1
|
+
import{n as e,t}from"./versionable-B0bP2Gib.mjs";import{t as n}from"./define-block-D-7OpIn5.mjs";import{n as r,t as i}from"./errors-kkf8Ue1H.mjs";import{field as a}from"@murumets-ee/entity";const o=[`draft`,`in_review`,`approved`,`rejected`,`published`],s={draft:[`in_review`],in_review:[`approved`,`rejected`],approved:[`published`,`in_review`],rejected:[`in_review`,`draft`],published:[`draft`]};function c(e,t){return e===t?!0:s[e]?.includes(t)??!1}var l=class extends Error{from;to;constructor(e,t){super(`Invalid workflow transition: ${e} → ${t}. Allowed transitions from "${e}": [${(s[e]??[]).join(`, `)}].`),this.name=`WorkflowTransitionError`,this.from=e,this.to=t}};function u(){return{name:`workflowable`,fields:{_workflowStatus:a.select({options:o,default:`draft`,indexed:!0,required:!0,internal:!0}),_workflowAssignee:a.text({required:!1,internal:!0}),_workflowNote:a.text({required:!1,internal:!0}),_workflowUpdatedAt:a.date({internal:!0})},hooks:{beforeCreate:async e=>(e._workflowStatus=`draft`,e._workflowUpdatedAt=new Date,e),beforeUpdate:async(e,t,n)=>{let r=t._workflowStatus;if(r===void 0)return t;if(!o.includes(r))throw new l(`draft`,r);let i=(await n?.loadCurrent?.())?._workflowStatus??`draft`,a=r;if(!c(i,a))throw new l(i,a);return t._workflowUpdatedAt=new Date,t}}}}function d(e,t){switch(t){case`drafts`:return f(e,`publishable`);case`versions`:return f(e,`versionable`);case`localeStatus`:return f(e,`publishable`)&&p(e);case`translations`:return p(e)}}function f(e,t){return e.behaviors?.some(e=>e.name===t)??!1}function p(e){let t=e.allFields;for(let e of Object.keys(t)){let n=t[e];if(n&&n.translatable===!0)return!0}return!1}export{i as EntityCapabilityError,r as InvalidLocaleError,o as WORKFLOW_STATES,s as WORKFLOW_TRANSITIONS,l as WorkflowTransitionError,n as defineBlock,d as entitySupports,t as getVersionableOptions,c as isAllowedTransition,e as versionable,u as workflowable};
|
|
2
2
|
//# sourceMappingURL=index.mjs.map
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../src/behaviors/workflowable.ts"],"sourcesContent":["/**\n * Workflowable behavior — editorial workflow with approval gates.\n *\n * Adds workflow status tracking fields to an entity
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/behaviors/workflowable.ts","../src/capabilities.ts"],"sourcesContent":["/**\n * Workflowable behavior — editorial workflow with approval gates.\n *\n * Adds workflow status tracking fields to an entity and enforces the\n * documented transition state machine in `beforeUpdate`. Works alongside\n * `publishable()` — the workflow status tracks the editorial pipeline,\n * while publishable's `status` remains the public-facing state.\n *\n * Workflow state machine:\n *\n * draft ─submit─► in_review ─approve─► approved ─publish─► published\n * │ ▲ │\n * │ └────resubmit─────┐ │\n * └─reject─► rejected ─┘ │\n * │\n * unpublish ◄──────────────┘\n * (back to draft)\n *\n * Internal-write only: every workflow field is declared `internal: true`,\n * so the HTTP PATCH surface strips them from caller input. Workflow\n * transitions are performed by trusted server-side routes via\n * `AdminClient.updateInternal()` after authorizing the action with the\n * permission checker. The `beforeUpdate` hook validates that the\n * proposed transition is in the allowed table.\n *\n * Permission gating lives in the route handler (admin-ui workflow\n * routes), not in this behavior — by the time the hook fires, the\n * route has already authorized via `checkPermission`. The hook is\n * the structural defense: even a bug in the route layer cannot push\n * a workflow row into an illegal state.\n *\n * UI integration is tracked in GitHub issue #124. Until the workflow\n * UX ships, the behavior can still be added to entities — it'll simply\n * seed `_workflowStatus = 'draft'` on create and reject anyone trying\n * to write the field via PATCH.\n */\n\nimport type {\n Behavior,\n BehaviorContext,\n DateField,\n SelectField,\n TextField,\n} from '@murumets-ee/entity'\nimport { field } from '@murumets-ee/entity'\n\nexport const WORKFLOW_STATES = ['draft', 'in_review', 'approved', 'rejected', 'published'] as const\nexport type WorkflowState = (typeof WORKFLOW_STATES)[number]\n\n/**\n * Allowed transitions table. Each key is the *current* workflow status;\n * the value is the set of statuses it can move to.\n *\n * Encoded as a const-asserted record so a typo in the source produces\n * a compile error rather than a silent no-match at runtime.\n */\nexport const WORKFLOW_TRANSITIONS = {\n draft: ['in_review'],\n in_review: ['approved', 'rejected'],\n approved: ['published', 'in_review'],\n rejected: ['in_review', 'draft'],\n published: ['draft'],\n} as const satisfies Record<WorkflowState, readonly WorkflowState[]>\n\n/**\n * Is `to` an allowed transition from `from`? Pure, no I/O.\n */\nexport function isAllowedTransition(from: WorkflowState, to: WorkflowState): boolean {\n if (from === to) return true // no-op self-transition is fine\n const allowed = WORKFLOW_TRANSITIONS[from] as readonly WorkflowState[] | undefined\n return allowed?.includes(to) ?? false\n}\n\n/**\n * Thrown when `beforeUpdate` rejects a workflow transition. Distinct\n * subclass so admin-ui's API handler can map it to HTTP 400.\n */\nexport class WorkflowTransitionError extends Error {\n readonly from: WorkflowState\n readonly to: WorkflowState\n\n constructor(from: WorkflowState, to: WorkflowState) {\n super(\n `Invalid workflow transition: ${from} → ${to}. ` +\n `Allowed transitions from \"${from}\": [${(WORKFLOW_TRANSITIONS[from] ?? []).join(', ')}].`,\n )\n this.name = 'WorkflowTransitionError'\n this.from = from\n this.to = to\n }\n}\n\nexport type WorkflowableFields = {\n _workflowStatus: SelectField & { required: true }\n _workflowAssignee: TextField\n _workflowNote: TextField\n _workflowUpdatedAt: DateField\n}\n\nexport function workflowable(): Behavior<WorkflowableFields> {\n return {\n name: 'workflowable',\n fields: {\n _workflowStatus: field.select({\n options: WORKFLOW_STATES as unknown as string[],\n default: 'draft',\n indexed: true,\n required: true,\n internal: true,\n }) as SelectField & { required: true },\n _workflowAssignee: field.text({ required: false, internal: true }),\n _workflowNote: field.text({ required: false, internal: true }),\n _workflowUpdatedAt: field.date({ internal: true }),\n },\n hooks: {\n beforeCreate: async (data) => {\n data._workflowStatus = 'draft'\n data._workflowUpdatedAt = new Date()\n return data\n },\n beforeUpdate: async (\n _id: string,\n data: Record<string, unknown>,\n ctx?: BehaviorContext,\n ): Promise<Record<string, unknown>> => {\n // Only validate when the patch tries to change the workflow status.\n // Updates that don't touch _workflowStatus pass through (the field's\n // `internal: true` already prevents it from sneaking in via PATCH;\n // only updateInternal callers reach this branch with a value).\n const proposed = data._workflowStatus\n if (proposed === undefined) return data\n\n if (!WORKFLOW_STATES.includes(proposed as WorkflowState)) {\n throw new WorkflowTransitionError('draft', proposed as WorkflowState)\n }\n\n // Need the current row to validate the from→to transition. The\n // lazy loader is provided by AdminClient — present on update,\n // absent on create.\n const current = await ctx?.loadCurrent?.()\n const fromState = (current?._workflowStatus as WorkflowState | undefined) ?? 'draft'\n const toState = proposed as WorkflowState\n\n if (!isAllowedTransition(fromState, toState)) {\n throw new WorkflowTransitionError(fromState, toState)\n }\n\n data._workflowUpdatedAt = new Date()\n return data\n },\n },\n }\n}\n","/**\n * Per-entity content capability gates.\n *\n * The content plugin attaches different infrastructure tables to an\n * entity based on which behaviors it has:\n * - `publishable()` → drafts table\n * - `versionable()` → versions table\n * - `publishable() + translatable fields` → locale_status table\n * - any translatable field → translations table (also written by the\n * entity package; included for symmetric capability checks)\n *\n * Calling a capability-specific ContentClient method (`saveDraft`,\n * `publishDraft`, `setLocaleStatus`, `restoreVersion`, ...) on an\n * entity that doesn't have the required behavior is a programming\n * error. ContentClient throws `EntityCapabilityError` on writes and\n * silently returns falsy on reads. Callers that want to gate ahead —\n * \"should I show the draft button on this entity row?\" — use\n * `entitySupports()` instead of catching the error.\n */\n\nimport type { Entity, FieldConfig } from '@murumets-ee/entity'\n\nexport type EntityCapability = 'drafts' | 'versions' | 'localeStatus' | 'translations'\n\n/**\n * Does this entity support the given content capability? Pure check\n * against the entity's declared behaviors and field translatability —\n * no plugin lookup, no DB call.\n */\nexport function entitySupports(entity: Entity, capability: EntityCapability): boolean {\n switch (capability) {\n case 'drafts':\n return hasBehavior(entity, 'publishable')\n case 'versions':\n return hasBehavior(entity, 'versionable')\n case 'localeStatus':\n return hasBehavior(entity, 'publishable') && hasTranslatableField(entity)\n case 'translations':\n return hasTranslatableField(entity)\n }\n}\n\nfunction hasBehavior(entity: Entity, name: string): boolean {\n return entity.behaviors?.some((b) => b.name === name) ?? false\n}\n\nfunction hasTranslatableField(entity: Entity): boolean {\n const fields = entity.allFields as Record<string, FieldConfig>\n for (const key of Object.keys(fields)) {\n const f = fields[key]\n if (f && (f as { translatable?: boolean }).translatable === true) return true\n }\n return false\n}\n"],"mappings":"8LA8CA,MAAa,EAAkB,CAAC,QAAS,YAAa,WAAY,WAAY,YAAY,CAU7E,EAAuB,CAClC,MAAO,CAAC,YAAY,CACpB,UAAW,CAAC,WAAY,WAAW,CACnC,SAAU,CAAC,YAAa,YAAY,CACpC,SAAU,CAAC,YAAa,QAAQ,CAChC,UAAW,CAAC,QAAQ,CACrB,CAKD,SAAgB,EAAoB,EAAqB,EAA4B,CAGnF,OAFI,IAAS,EAAW,GACR,EAAqB,IACrB,SAAS,EAAG,EAAI,GAOlC,IAAa,EAAb,cAA6C,KAAM,CACjD,KACA,GAEA,YAAY,EAAqB,EAAmB,CAClD,MACE,gCAAgC,EAAK,KAAK,EAAG,8BACd,EAAK,OAAO,EAAqB,IAAS,EAAE,EAAE,KAAK,KAAK,CAAC,IACzF,CACD,KAAK,KAAO,0BACZ,KAAK,KAAO,EACZ,KAAK,GAAK,IAWd,SAAgB,GAA6C,CAC3D,MAAO,CACL,KAAM,eACN,OAAQ,CACN,gBAAiB,EAAM,OAAO,CAC5B,QAAS,EACT,QAAS,QACT,QAAS,GACT,SAAU,GACV,SAAU,GACX,CAAC,CACF,kBAAmB,EAAM,KAAK,CAAE,SAAU,GAAO,SAAU,GAAM,CAAC,CAClE,cAAe,EAAM,KAAK,CAAE,SAAU,GAAO,SAAU,GAAM,CAAC,CAC9D,mBAAoB,EAAM,KAAK,CAAE,SAAU,GAAM,CAAC,CACnD,CACD,MAAO,CACL,aAAc,KAAO,KACnB,EAAK,gBAAkB,QACvB,EAAK,mBAAqB,IAAI,KACvB,GAET,aAAc,MACZ,EACA,EACA,IACqC,CAKrC,IAAM,EAAW,EAAK,gBACtB,GAAI,IAAa,IAAA,GAAW,OAAO,EAEnC,GAAI,CAAC,EAAgB,SAAS,EAA0B,CACtD,MAAM,IAAI,EAAwB,QAAS,EAA0B,CAOvE,IAAM,GADU,MAAM,GAAK,eAAe,GACd,iBAAiD,QACvE,EAAU,EAEhB,GAAI,CAAC,EAAoB,EAAW,EAAQ,CAC1C,MAAM,IAAI,EAAwB,EAAW,EAAQ,CAIvD,MADA,GAAK,mBAAqB,IAAI,KACvB,GAEV,CACF,CC1HH,SAAgB,EAAe,EAAgB,EAAuC,CACpF,OAAQ,EAAR,CACE,IAAK,SACH,OAAO,EAAY,EAAQ,cAAc,CAC3C,IAAK,WACH,OAAO,EAAY,EAAQ,cAAc,CAC3C,IAAK,eACH,OAAO,EAAY,EAAQ,cAAc,EAAI,EAAqB,EAAO,CAC3E,IAAK,eACH,OAAO,EAAqB,EAAO,EAIzC,SAAS,EAAY,EAAgB,EAAuB,CAC1D,OAAO,EAAO,WAAW,KAAM,GAAM,EAAE,OAAS,EAAK,EAAI,GAG3D,SAAS,EAAqB,EAAyB,CACrD,IAAM,EAAS,EAAO,UACtB,IAAK,IAAM,KAAO,OAAO,KAAK,EAAO,CAAE,CACrC,IAAM,EAAI,EAAO,GACjB,GAAI,GAAM,EAAiC,eAAiB,GAAM,MAAO,GAE3E,MAAO"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{n as e}from"./errors-kkf8Ue1H.mjs";import{n as t}from"./plugin-yijaaFS7.mjs";function n(n){if(n===`_`)return;let r=t();for(let e of r.locales)if(e.code===n)return;throw new e(n,r.locales.map(e=>e.code))}export{n as t};
|
|
2
|
+
//# sourceMappingURL=locale-utils-By7EdAT1.mjs.map
|