/** * 台账 schema 自生长 —— 纯算法,零 IO(编码规范 §7.2:落此文件,零 mock 全覆盖测试)。 * * 语义出处 OS 04 / AM 11.1: * 声明的是**活动要用什么字段**,不是对象该定义什么字段; * 对象的实际 schema = 全部使用声明的**最大化合并**(并集); * 定义**只增不破坏**:声明永不缩表、不删改已有字段; * 同名字段类型不一致时,以**能兼容双方的最宽类型**落地(放宽即增,不破坏),静态审提示、不阻塞; * **语义互斥**不可兼容的极端情形才拦下,交数据管理裁决。 */ import type { FieldDeclaration, LedgerFieldType, ObjectDeclaration } from '@contracts'; /* ── 类型格:widen(a, b) = 能同时容纳 a 与 b 的最窄类型 ────────────────── */ /** * 标量的加宽链: * integer → number → text * date → timestamp → text * boolean → text * `json` 是容器类型,与任何标量**语义互斥** —— 不加宽,直接拦下。 */ const SCALAR_CHAIN: Readonly> = { integer: ['integer', 'number', 'text'], number: ['number', 'text'], date: ['date', 'timestamp', 'text'], timestamp: ['timestamp', 'text'], boolean: ['boolean', 'text'], text: ['text'], json: ['json'], }; export type WidenResult = | { readonly ok: true; readonly type: LedgerFieldType; readonly widened: boolean } | { readonly ok: false; readonly reason: string }; export function widen(a: LedgerFieldType, b: LedgerFieldType): WidenResult { if (a === b) return { ok: true, type: a, widened: false }; if (a === 'json' || b === 'json') { return { ok: false, reason: `json 与 ${a === 'json' ? b : a} 语义互斥,不可兼容` }; } // 取两条链的最靠前的公共上界 for (const candidate of SCALAR_CHAIN[a]) { if (SCALAR_CHAIN[b].includes(candidate)) { return { ok: true, type: candidate, widened: true }; } } /* istanbul ignore next —— text 是标量的公共顶,理论不可达 */ return { ok: false, reason: `${a} 与 ${b} 无公共上界` }; } /* ── 合并结果 ───────────────────────────────────────────────────────── */ export type MergedField = { readonly name: string; readonly type: LedgerFieldType; readonly label: string | undefined; readonly primaryKey: boolean; readonly masked: boolean; }; export type MergeNotice = { readonly kind: 'type-widened' | 'masked-propagated' | 'label-kept-first'; readonly field: string; readonly message: string; }; export type MergeConflict = { readonly kind: 'type-incompatible' | 'primary-key-divergent'; readonly field: string; readonly message: string; }; export type MergedObject = { readonly domain: string; readonly object: string; readonly label: string | undefined; readonly fields: readonly MergedField[]; readonly primaryKey: readonly string[]; }; export type MergeResult = { readonly merged: MergedObject | undefined; /** 静态审提示,不阻塞 */ readonly notices: readonly MergeNotice[]; /** 语义互斥 —— 拦下,交数据管理裁决 */ readonly conflicts: readonly MergeConflict[]; }; function mergeField( acc: MergedField, next: FieldDeclaration, notices: MergeNotice[], conflicts: MergeConflict[], ): MergedField { const w = widen(acc.type, next.type); if (!w.ok) { conflicts.push({ kind: 'type-incompatible', field: acc.name, message: w.reason }); return acc; } if (w.widened) { notices.push({ kind: 'type-widened', field: acc.name, message: `同名异型 ${acc.type} + ${next.type} → 取最宽类型 ${w.type}(放宽即增,不破坏)`, }); } // 脱敏取或:任一声明要求脱敏即脱敏(安全方向单调) const masked = acc.masked || next.masked; if (masked && !acc.masked) { notices.push({ kind: 'masked-propagated', field: acc.name, message: '任一声明要求脱敏即脱敏' }); } if (next.label !== undefined && acc.label !== undefined && next.label !== acc.label) { notices.push({ kind: 'label-kept-first', field: acc.name, message: `字段名称冲突(${acc.label} / ${next.label}),保留首个声明`, }); } return { name: acc.name, type: w.type, label: acc.label ?? next.label, // 主键参与取或:任一声明视其为主键即入主键集 primaryKey: acc.primaryKey || next.primaryKey, masked, }; } /** * 最大化合并:把同一个「域.对象」的**全部**使用声明合成一份对象 schema。 * 声明顺序不影响结果(合并是可交换、可结合的)——除标签保留首个声明外。 */ export function mergeDeclarations(declarations: readonly ObjectDeclaration[]): MergeResult { const notices: MergeNotice[] = []; const conflicts: MergeConflict[] = []; const first = declarations[0]; if (first === undefined) return { merged: undefined, notices, conflicts }; for (const d of declarations) { if (d.domain !== first.domain || d.object !== first.object) { throw new Error(`mergeDeclarations 只接受同一对象的声明:${d.domain}.${d.object} ≠ ${first.domain}.${first.object}`); } } const byName = new Map(); const order: string[] = []; for (const declaration of declarations) { for (const field of declaration.fields) { const existing = byName.get(field.name); if (existing === undefined) { order.push(field.name); byName.set(field.name, { name: field.name, type: field.type, label: field.label, primaryKey: field.primaryKey, masked: field.masked, }); continue; } byName.set(field.name, mergeField(existing, field, notices, conflicts)); } } const fields = order.map((name) => byName.get(name)!); const primaryKey = fields.filter((f) => f.primaryKey).map((f) => f.name); return { merged: { domain: first.domain, object: first.object, label: declarations.find((d) => d.label !== undefined)?.label, fields, primaryKey, }, notices, conflicts, }; } /* ── 与已物化 schema 的差分:生成「只增不破坏」的变更计划 ─────────────── */ export type MaterializedColumn = { readonly name: string; readonly type: LedgerFieldType; /** 人读名 —— 落在 PG 的列注释上,建表/加列时随声明写下 */ readonly label?: string | undefined; }; export type MaterializedObject = { readonly domain: string; readonly object: string; /** 人读名 —— 落在 PG 的表注释上;没有就退回对象键 */ readonly label?: string | undefined; readonly columns: readonly MaterializedColumn[]; readonly primaryKey: readonly string[]; }; export type SchemaChange = | { readonly kind: 'create-table'; readonly object: MergedObject } | { readonly kind: 'add-column'; readonly field: MergedField } | { readonly kind: 'widen-column'; readonly field: MergedField; readonly from: LedgerFieldType }; export type SchemaPlan = { readonly changes: readonly SchemaChange[]; readonly notices: readonly MergeNotice[]; readonly conflicts: readonly MergeConflict[]; }; /** * 差分:目标 schema(合并结果)相对已物化 schema 的增量。 * **永不产出删列、缩窄或改主键的变更** —— 那三样是破坏,由数据管理的治理动作承担,不经声明发生。 */ export function planSchemaChanges( target: MergedObject, current: MaterializedObject | undefined, ): SchemaPlan { const notices: MergeNotice[] = []; const conflicts: MergeConflict[] = []; if (current === undefined) { return { changes: [{ kind: 'create-table', object: target }], notices, conflicts }; } // 主键一经建立不得由声明变更 —— 新增主键字段是破坏性变更 const currentPk = [...current.primaryKey].sort(); const targetPk = [...target.primaryKey].sort(); const addedPk = targetPk.filter((k) => !currentPk.includes(k)); if (addedPk.length > 0) { conflicts.push({ kind: 'primary-key-divergent', field: addedPk.join(','), message: `主键已建立为 (${currentPk.join(', ')}),声明试图新增主键字段 ${addedPk.join(', ')} —— 只增不破坏,交数据管理裁决`, }); } const columns = new Map(current.columns.map((c) => [c.name, c])); const changes: SchemaChange[] = []; for (const field of target.fields) { const existing = columns.get(field.name); if (existing === undefined) { changes.push({ kind: 'add-column', field }); continue; } const w = widen(existing.type, field.type); if (!w.ok) { conflicts.push({ kind: 'type-incompatible', field: field.name, message: w.reason }); continue; } if (w.type !== existing.type) { notices.push({ kind: 'type-widened', field: field.name, message: `已物化 ${existing.type},声明需要 ${field.type} → 加宽为 ${w.type}`, }); changes.push({ kind: 'widen-column', field: { ...field, type: w.type }, from: existing.type }); } } return { changes, notices, conflicts }; } /** 声明只增不破坏的机检:计划里不得出现任何缩表动作 */ export function planIsNonDestructive(plan: SchemaPlan): boolean { return plan.changes.every( (c) => c.kind === 'create-table' || c.kind === 'add-column' || c.kind === 'widen-column', ); }