/** * Decide how a field group's STORED definition must change to describe its LIVE tables. * * `diverged` means the tables moved and the row recording them did not, so the stored definition * describes the previous shape. This plans the repair of the RECORD. It never issues DDL, and it is * pure: every input is a value, so the decisions can be tested without a database. * * 🔴 The truth is SPLIT, and getting this backwards destroys what the repair exists to preserve: * * - the STORED definition is truth for what each field MEANS — its logical type, and every * authored property this planner copies through untouched; * - the LIVE tables are truth for what SHAPE each column has — presence, nullability, indexes. * * The reason is that `ColumnSpec` carries a PHYSICAL type only. `email`, `url` and `text` are one * `text` column, so rebuilding the definitions from introspection would silently downgrade every * one of them, on the single path an operator runs to get out of trouble. So a matched field keeps * its declared type and only the physical attributes are corrected. * * `core-reconcile.ts` is prior art with the OPPOSITE polarity — it repairs the DATABASE to match the * declared schema. Copying its shape here would produce an operation that re-applies DDL, which is * the last thing a diverged group needs. * * @module domains/field-groups/services/reconcile-field-group-plan */ /** Which of a field group's two tables a column lives in. */ type ReconcileTable = "main" | "companion"; /** A stored field whose column is gone from both tables. */ interface ReconcileRemoval { fieldName: string; columnName: string; } /** A stored field kept for its meaning, with one physical attribute corrected. */ interface ReconcileRepair { fieldName: string; columnName: string; table: ReconcileTable; attribute: "required" | "unique" | "index" | "localized"; from: boolean; to: boolean; } /** * A drift the planner can SEE but must not decide, so the whole repair refuses. * * Reported rather than resolved because each of these is genuinely ambiguous — the database holds * two readings and nothing in it says which the operator meant. Guessing would write a definition * that describes neither, and this operation's whole value is that its output describes the tables. */ interface ReconcileBlocker { fieldName: string; columnName: string; kind: "column-on-both-tables" | "physical-type-changed" | "unrepresentable-column-name" | "structural-column-missing" | "system-index-missing" | "column-default-changed" | "ambiguous-rename"; detail: string; } /** A live column no stored field described, adopted with a type this planner had to guess. */ interface ReconcileAdoption { fieldName: string; columnName: string; table: ReconcileTable; /** The physical type introspection reported, so the summary can show what the guess came from. */ liveType: string; /** Always a guess — the physical type cannot name the logical one it came from. */ guessedType: string; } /** * Repair a field group's STORED definition to describe its LIVE tables. * * The exit from `diverged`: the tables moved and the row recording them did not, so every * storage-moving edit is refused until the record is made honest again. This operation reads the * tables, plans the smallest definition that describes them (`reconcile-field-group-plan.ts` * holds the decisions), and writes the repaired record in ONE version-conditional statement. It * never issues DDL — the tables are the truth being described, not the thing being fixed. * * Deliberately runnable on a group that is NOT marked `diverged`: a divergence can exist with no * mark (a recording write that failed after its DDL committed leaves exactly that), and the * operation is idempotent — on a healthy group the plan comes back unchanged and nothing is * written. That is also why this does not gate on `migrationStatus`. * * @module domains/field-groups/services/field-group-reconcile-service */ /** * What a reconcile did, by IDENTITY. The lists are the operator's only window into a field whose * column vanished or whose type had to be guessed, so they name fields and columns rather than * counting them. */ interface ReconcileFieldGroupResult { slug: string; /** Re-derived from which table holds the columns — see the planner. */ localized: boolean; removed: ReconcileRemoval[]; repaired: ReconcileRepair[]; adopted: ReconcileAdoption[]; /** True when the definition already described the tables and nothing was written. */ unchanged: boolean; /** The version after the repair; unchanged repairs report the version they found. */ schemaVersion: number; /** * Whether the RUNNING process was re-pointed at the repaired shape. * * Separate from the repair's own success because the two can genuinely differ: the row is written * durably before this is attempted, so `false` means the database is correct and this process is * not, which a restart fixes. Reported rather than folded into an error so an operator is never * told a landed repair failed, nor told a stale process is ready. */ runtimeRefreshed: boolean; /** Why the refresh did not happen; present only when `runtimeRefreshed` is false. */ runtimeRefreshReason?: string; } /** * What a reconcile WOULD do, computed without writing anything. * * The repair rewrites a definition in ways re-running cannot undo — a field whose column vanished * loses its authored label, validation and options, and an adopted column is recorded under a * logical type that was GUESSED from a physical one. An operator asked to approve that has to be * able to read it first, so the plan is offered before the write rather than reported after it. * * `blockers` is the reason this is a result rather than a thrown refusal. The repair throws on a * drift it must not decide, and a thrown error carries no structured payload to the browser — its * `logContext` is stripped from the response — so the only channel that can name each blocker * individually is a successful one. */ interface ReconcileFieldGroupPreview { slug: string; /** Re-derived from which table holds the columns, exactly as the repair would derive it. */ localized: boolean; removed: ReconcileRemoval[]; repaired: ReconcileRepair[]; adopted: ReconcileAdoption[]; /** Non-empty means the repair would REFUSE. Nothing here is applicable until each is resolved. */ blockers: ReconcileBlocker[]; /** * True when the definition already describes the tables. * * This is NOT the same as "applying does nothing" — read `wouldWrite` for that. A group whose * every field matches can still carry a stale failure mark, and clearing that is a write. */ unchanged: boolean; /** Whether applying would write at all, decided by the same expression the repair uses. */ wouldWrite: boolean; /** * The stale status applying would clear, when one stands over healthy tables. * * Present so the surface can say WHY applying still does something on a group it just described * as unchanged, rather than showing an empty change list beside an enabled button. */ staleStatus?: string; /** * The version this plan was computed against. * * Sent back on the apply so the operator cannot approve one plan and have another execute: the * repair refuses when the row has moved since. Without it the apply re-plans against whatever * the row says by then, which may be a different repair than the one that was shown. */ schemaVersion: number; } export type { ReconcileAdoption, ReconcileBlocker, ReconcileFieldGroupPreview, ReconcileFieldGroupResult, ReconcileRemoval, ReconcileRepair, ReconcileTable };