/** * efcore-history.ts — Immutable audit trail for the migration operations that * touch SHARED state. * * Every squash that DELETES migration files (and ALWAYS a `-BruteForce` * re-baseline), and every `apply` / `revert` that runs against a database which is * not provably loopback-local (a working database on a LAN / shared host, named by * `appsettings.Local.json`), appends one JSON record under * `/.claude/Efcore-history/`. One file per operation — never rewritten — * so there is always a trace of who rewrote migration history or migrated a shared * database, when, from which branch, and exactly which migrations. * * `buildHistoryRecord` / `buildApplyHistoryRecord` are PURE (timestamp injected) * so they are unit-testable. `appendHistory` does the I/O (git-root resolution + * write) and never throws — a logging failure must not abort an operation that * already mutated the tree or the database; it returns `{ path: null, error }` so * the caller can surface a warning. */ import path from 'node:path'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { ensureDirectory, writeText } from '../../../lib/fs.js'; const execFileAsync = promisify(execFile); export type SquashHistoryOperation = 'squash' | 'squash-bruteforce' | 'rebase-snapshot'; export type ApplyHistoryOperation = 'apply' | 'revert'; export type HistoryOperation = SquashHistoryOperation | ApplyHistoryOperation; export interface HistoryAssemblyEntry { assemblyName: string; /** Every migration file basename deleted in this assembly. */ deletedMigrations: string[]; /** * Reference/production migrations destroyed. Empty for a normal squash * (which never touches reference migrations); populated only by a brute-force * re-baseline — the genuinely dangerous deletions. */ parentMigrationsDestroyed: string[]; newMigrationName: string | null; backupDir: string | null; } export interface SquashHistoryRecord { timestamp: string; operation: SquashHistoryOperation; cwd: string; currentBranch: string; baseBranch: string; branchType: string; /** True for a `-BruteForce` re-baseline. */ bruteForce: boolean; /** True when the human passed the explicit confirmation token. */ confirmed: boolean; assemblies: HistoryAssemblyEntry[]; } export interface ApplyHistoryContext { contextName: string; assemblyName: string; pending: number; applied: boolean; appliedMigrations: string[]; success: boolean; } /** One `apply` / `revert` run against a database that is not loopback-local. */ export interface ApplyHistoryRecord { timestamp: string; operation: ApplyHistoryOperation; cwd: string; currentBranch: string; branchType: string; /** The classified host (instance/port stripped). */ dbHost: string | null; dbTarget: string; /** Which configuration layer named the database — the grant that made it green. */ configSource: { kind: string; file: string | null }; policy: { tier: string; axis: string }; /** True when the human passed the explicit confirmation token (🟡 runs). */ confirmed: boolean; /** The revert target, or null for an apply-to-latest. */ targetMigration: string | null; contexts: ApplyHistoryContext[]; } export type HistoryRecord = SquashHistoryRecord | ApplyHistoryRecord; export interface BuildHistoryInput { /** ISO timestamp — injected by the caller so the builder stays pure/testable. */ timestamp: string; bruteForce: boolean; /** True when invoked through the rebase-snapshot mode — labels the audit record. */ rebaseSnapshot?: boolean; confirmed: boolean; cwd: string; currentBranch: string; baseBranch: string; branchType: string; assemblies: HistoryAssemblyEntry[]; } export interface BuildApplyHistoryInput { /** ISO timestamp — injected by the caller so the builder stays pure/testable. */ timestamp: string; /** `revert` when a `targetMigration` was given, else `apply`. */ targetMigration?: string | null; cwd: string; currentBranch: string; branchType: string; dbHost: string | null; dbTarget: string; configSource: { kind: string; file: string | null }; policy: { tier: string; axis: string }; confirmed: boolean; contexts: ApplyHistoryContext[]; } /** Pure record builder for an apply / revert run. */ export function buildApplyHistoryRecord(input: BuildApplyHistoryInput): ApplyHistoryRecord { return { timestamp: input.timestamp, operation: input.targetMigration ? 'revert' : 'apply', cwd: input.cwd, currentBranch: input.currentBranch, branchType: input.branchType, dbHost: input.dbHost, dbTarget: input.dbTarget, configSource: { kind: input.configSource.kind, file: input.configSource.file }, policy: { tier: input.policy.tier, axis: input.policy.axis }, confirmed: input.confirmed, targetMigration: input.targetMigration ?? null, contexts: input.contexts, }; } /** * Does this apply / revert deserve an audit record? Every revert (data loss is * possible even locally), and every apply that is NOT on a provably loopback-local * database — i.e. the provenance lane (a working DB on a LAN / shared host). */ export function shouldJournalApply(op: 'apply' | 'revert', dbTarget: string): boolean { return op === 'revert' || dbTarget !== 'local'; } /** Pure record builder — derives the operation kind from the inputs. */ export function buildHistoryRecord(input: BuildHistoryInput): SquashHistoryRecord { const operation: SquashHistoryOperation = input.bruteForce ? 'squash-bruteforce' : input.rebaseSnapshot ? 'rebase-snapshot' : 'squash'; return { timestamp: input.timestamp, operation, cwd: input.cwd, currentBranch: input.currentBranch, baseBranch: input.baseBranch, branchType: input.branchType, bruteForce: input.bruteForce, confirmed: input.confirmed, assemblies: input.assemblies, }; } /** Resolve the git work-tree root so the log always lands at the project's `.claude/`. Falls back to `cwd` when not in a git repo. */ async function gitTopLevel(cwd: string): Promise { try { const { stdout } = await execFileAsync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf-8', }); return stdout.trim() || cwd; } catch { return cwd; } } /** * Append `record` to `/.claude/Efcore-history/_.json`. * Never throws — returns the written path, or `{ path: null, error }` on failure. */ export async function appendHistory( cwd: string, record: HistoryRecord, ): Promise<{ path: string | null; error?: string }> { try { const root = await gitTopLevel(cwd); const dir = path.join(root, '.claude', 'Efcore-history'); await ensureDirectory(dir); const safeStamp = record.timestamp.replace(/[:.]/g, '-'); const file = path.join(dir, `${safeStamp}_${record.operation}.json`); await writeText(file, `${JSON.stringify(record, null, 2)}\n`); return { path: file }; } catch (e) { return { path: null, error: (e as Error).message }; } }