/** * Shared column-value transform layer for the exodus migration. * * The exodus copy (`migrate.ts`) does not move source values byte-for-byte into * the consolidated `cleo.db`: a handful of columns are TRANSFORMED on the way in * so they satisfy the consolidated schema's CHECK constraints (which the legacy * runtime DBs did not carry). Three transform classes exist: * * 1. **Epoch INTEGER → ISO-8601 TEXT** ({@link buildEpochToIsoExpr}) — for any * target column carrying an ISO-GLOB CHECK ({@link detectIsoGlobColumns}). * A per-row magnitude heuristic ({@link EPOCH_SECONDS_THRESHOLD}) classifies * seconds vs milliseconds. * 2. **Legacy enum → canonical member** ({@link ENUM_NORMALIZATIONS} + * {@link enumNormExpr}) — maps pre-tightening enum aliases (e.g. * `tasks_commits.conventional_type` `'style'`/`'merge'` → `'chore'`). * 3. **Non-finite REAL → finite** ({@link NUMERIC_CLAMPS} + * {@link numericClampExpr}) — `Inf`/`-Inf`/`NaN` → a finite in-range value * (`brain_weight_history.delta_weight`). * * ## Why this module exists (T11809 · AC2) * * Before T11809 these transforms lived ONLY in `migrate.ts`. The parity verifier * (`verify-migration.ts`) digested RAW source values against the TRANSFORMED * target values, so every coerced column (epoch INTEGER vs ISO TEXT, legacy enum * vs canonical, Inf vs clamped) produced a `hashMatch === false` even on a * perfectly lossless migration — a false-negative that aborted the cutover and * lost the batch writes accumulated during the migrating open. * * Extracting the transform logic into ONE shared module lets the verifier digest * the SOURCE side **through the same transforms migrate applied** * ({@link buildDigestExpr}), so equal logical data digests equal. `migrate.ts` * re-imports these primitives so its copy behaviour stays byte-identical; the * verifier imports {@link buildDigestExpr} for the digest-oriented variant. * * @task T11809 (exodus verify applies source-side coercion — hashMatch on equal data) * @task T11546 (epoch→ISO coercion — original) * @task T11547 (enum normalization — original) * @task T11782 (non-finite numeric clamp — original) * @epic T11249 (E6) * @saga T11242 */ import type { DatabaseSync } from 'node:sqlite'; /** * Determine a safe SQL literal default for a NOT NULL column with no schema * default, given its SQLite type affinity. * * Used by `migrate.ts` to coalesce NULL source values for target-only NOT NULL * columns so that rows are not silently dropped by `INSERT OR IGNORE` when a * source value is NULL (T11533 ROOT CAUSE 2 fix). * * @param colType - Raw `type` string from `PRAGMA table_info` (e.g. `"INTEGER"`, * `"TEXT"`, `"REAL"`, `"BLOB"`, or compound forms like `"text NOT NULL"`). * @returns A SQL literal string suitable for embedding in a `COALESCE()` call. */ export declare function typeDefaultLiteral(colType: string): string; /** * Function shape for an enum-normalization rule: given the `srcRef` SQL * expression for a column, return a SQL expression that produces the canonical * value. */ export type NormalizeFn = (srcRef: string) => string; /** * Per-(targetTable, column) normalization rules that map legacy enum values to * the canonical enum accepted by the consolidated schema CHECK constraints. * * Each entry is a function that, given the `srcRef` SQL expression for the * column, returns a SQL CASE expression that produces the canonical value. * Rows with already-canonical values pass through unchanged (the ELSE branch). * * ## Brain enum normalizations REMOVED (T11647) * * The brain memory family no longer participates in enum normalization. Its * consolidated exodus target now matches the LEGACY RUNTIME shape, which carries * NO SQL CHECK constraints (the `text({ enum })` unions are enforced at the * application layer only). With no CHECK to satisfy, coercing brain enum values * would be data corruption, not a fix — so every brain enum value is copied * VERBATIM. The historical brain rules (`brain_observations.{source_type,type}`, * `brain_decisions.{confirmation_state,decision_category,confidence,outcome, * decided_by}`) were deleted. The TASKS-domain rules below remain because those * consolidated tables keep their CHECK constraints. * * ## nexus/signaldock enum-drift audit (T11809 · AC1) * * A real-data audit of `nexus.db` (106 MB) and `signaldock.db` (280 KB) against * the consolidated CHECK enums found ZERO out-of-enum values for every * CHECK-constrained column that has source data: `nexus_nodes.kind` (24,482 * rows), `nexus_relations.type` (39,163 rows), `nexus_contracts.type` (0 rows), * `nexus_sigils.role` (8 rows), `agent_registry_agents.status` (19 rows), * `agent_registry_users.role` (0 rows). No new normalization entry was required. * The reported "nexus/signaldock drop rows" symptom was in fact the AC2 verify * false-negative (epoch→ISO coercion makes the source digest differ from the * target digest), which fixing {@link buildDigestExpr} resolves — NOT a CHECK * drop. See the T11809 return notes for the full per-column row counts. * * Lookup key: `${targetTable}.${column}` (lowercase, dotted). * * @since T11547 (P0 data-loss fix) * @since T11548 (P0 final enum coverage) * @since T11647 (brain target = runtime shape — brain enum coercions removed) */ export declare const ENUM_NORMALIZATIONS: ReadonlyMap; /** * Return a SQL CASE expression that normalises legacy enum values for `col` in * `targetTableName` to the canonical values accepted by the consolidated CHECK, * or return `null` when no normalization rule exists for this (table, column). * * @param targetTableName - Physical consolidated target table name. * @param col - Column name. * @param srcRef - SQL expression referencing the source column. * @returns A SQL CASE expression string, or `null` if no rule applies. */ export declare function enumNormExpr(targetTableName: string, col: string, srcRef: string): string | null; /** * Function shape for a numeric-clamp rule: given the `srcRef` SQL expression for * a column, return a SQL expression that maps non-finite values to finite ones. */ export type NumericClampFn = (srcRef: string) => string; /** * Per-(targetTable, column) numeric-clamp rules that coerce non-finite legacy * REAL values (`Inf` / `-Inf` / `NaN`) to a finite in-range value so the row is * NOT silently dropped by `INSERT OR IGNORE`. * * ## Why this exists (T11782) * * 188,926 of 697,780 legacy `brain_weight_history` rows carry * `delta_weight = Inf`/`-Inf` (the R-STDP plasticity writer saturated the delta * before the value was clamped at write time). SQLite stores ±Inf as the IEEE-754 * float, but the consolidated `brain_weight_history.delta_weight` column is a * plain `real NOT NULL` with NO CHECK — so a verbatim copy would land the Inf * value. The historical deficit, however, was that those rows tripped a constraint * elsewhere in the copy chain and `INSERT OR IGNORE` dropped them, yielding a * deficit that fired the parity-gate abort. Clamping the non-finite value to a * finite member of the column's domain guarantees every row lands. * * The clamp mirrors the {@link ENUM_NORMALIZATIONS} shape: each entry is a * function `(srcRef) => CASE …`. Finite values pass through unchanged via the * ELSE branch. The `col != col` self-comparison is the canonical SQL NaN guard * (NaN is the only value not equal to itself); `9e999` is the SQLite literal that * evaluates to `+Infinity` (and `-9e999` to `-Infinity`). * * Lookup key: `${targetTable}.${column}` (lowercase, dotted). * * @since T11782 (P0 — brain_weight_history Inf recovery) */ export declare const NUMERIC_CLAMPS: ReadonlyMap; /** * Return a SQL CASE expression that clamps non-finite legacy REAL values for * `col` in `targetTableName` to a finite in-range value, or `null` when no * clamp rule exists for this (table, column). * * @param targetTableName - Physical consolidated target table name. * @param col - Column name. * @param srcRef - SQL expression referencing the source column. * @returns A SQL CASE expression string, or `null` if no rule applies. */ export declare function numericClampExpr(targetTableName: string, col: string, srcRef: string): string | null; /** * Magnitude threshold distinguishing epoch SECONDS from epoch MILLISECONDS. * * A Unix epoch value for years 2020–2100 is roughly 1.6e9 – 4.1e9 seconds, * or 1.6e12 – 4.1e12 milliseconds. The safe boundary is 1e11 (100 billion): * any value BELOW 1e11 is in seconds (even year 2100 seconds ≈ 4.1e9 < 1e11); * any value AT OR ABOVE 1e11 is in milliseconds (year 2020 ms ≈ 1.6e12 > 1e11). * * This constant is embedded directly in the generated SQL CASE expression so * it is evaluated per-row — each row's epoch is classified independently. */ export declare const EPOCH_SECONDS_THRESHOLD: 100000000000; /** * Build a SQL expression that converts an INTEGER epoch column to ISO-8601 TEXT, * automatically detecting whether the stored value is in seconds or milliseconds * using a magnitude heuristic (T11549 correctness fix). * * ## Heuristic * * A per-row CASE checks whether the column value is below {@link EPOCH_SECONDS_THRESHOLD} * (100 billion). If so, the value is treated as seconds and passed directly to * `strftime(..., 'unixepoch')`. If at or above the threshold, it is divided by * 1000.0 first (milliseconds → seconds). * * This replaces the previous per-source heuristic which failed when individual * columns within a source DB used a different epoch unit than the majority of that * source's columns. The specific bug: `nexus.user_profile.{first_observed_at, * last_reinforced_at}` stores SECONDS (value ≈ 1.78e9) but the nexus source was * labeled `milliseconds`, causing these values to be divided by 1000 and converted * to a 1970 date. * * ## NULL handling * * A NULL source value is preserved as NULL so it passes the `IS NULL` branch of * the ISO GLOB CHECK constraint on the target column. * * @param srcRef - SQL expression referencing the source column value. * @returns A SQL CASE expression producing an ISO-8601 TEXT timestamp. */ export declare function buildEpochToIsoExpr(srcRef: string): string; /** * Parse the DDL for a given table from `sqlite_master` and return the set of * column names that have an ISO GLOB CHECK constraint. * * Reads the raw DDL text and uses a regex to extract column names appearing in * `CHECK ("colname" IS NULL OR "colname" GLOB '[0-9]...')` patterns. This is * robust to Drizzle's generated CHECK format (all CHECK constraints generated * by T11363 follow this exact pattern). * * @param db - Target DB with the consolidated schema. * @param tableName - Physical table name (consolidated, e.g. `conduit_messages`). * @param targetSchema - Schema name the target table lives in (`'main'`, or an * ATTACH alias for cross-scope routing — ADR-090 nexus graph residency, T11539). * @returns Set of column names that require ISO GLOB validation. */ export declare function detectIsoGlobColumns(db: DatabaseSync, tableName: string, targetSchema?: string): Set; /** * Target-column metadata a {@link buildDigestExpr} caller passes so the digest * can mirror migrate's NOT-NULL `COALESCE(..., type_default)` substitution * (T11836). * * Keyed by column name, this carries the consolidated target's NOT-NULL flag and * declared schema default for every column the digest visits, sourced from the * target `PRAGMA table_info`. A column is NOT-NULL-without-default when * `notnull === 1 && dflt_value === null` — exactly the condition under which * migrate's `buildSelectExpr` wraps the value in `COALESCE(expr, type_default)`. */ export interface TargetColumnInfo { /** `PRAGMA table_info.notnull` — `1` for a NOT NULL column, `0` otherwise. */ readonly notnull: number; /** `PRAGMA table_info.dflt_value` — the column's schema default, or `null`. */ readonly dflt_value: string | null; /** `PRAGMA table_info.type` — raw affinity string (drives {@link typeDefaultLiteral}). */ readonly type: string; } /** * Build the SQL expression a column's SOURCE value must pass through so it * matches the canonical value the migration STORES in the target — for use by * the parity verifier's content digest (T11809 · AC2). * * This is the digest-oriented sibling of migrate's `buildSelectExpr`. It applies * the SAME value transforms the migration actually performs — and ONLY those: * * 1. **Epoch→ISO-8601** ({@link buildEpochToIsoExpr}) — when the target has an * ISO GLOB CHECK and the source column is INTEGER-typed. * 2. **Non-finite numeric clamp** ({@link numericClampExpr}). * 3. **Enum-value normalization** ({@link enumNormExpr}). * 4. **Plain column reference** otherwise. * * ## NOT-NULL default substitution mirroring (T11836) * * After the value transform, the result is wrapped in the SAME * `COALESCE(expr, type_default)` substitution migrate's `buildSelectExpr` uses * for a TARGET column that is NOT NULL without a schema default (see * {@link maybeCoalesceNotNull}). migrate stores the type default (`''`/`0`/ * `0.0`/`x''`) for a NULL source value in such a column; without mirroring it * here the source digest would read NULL and diverge from the stored default — a * false `hashMatch === false` on a faithful FRESH migration (the exact T11836 * symptom). Mirroring it makes a NULL→NOT-NULL-default substitution a NON-FATAL * diagnostic class rather than a hash failure. The target metadata is supplied * via the `tgtColByCol` map; when it is absent (best-effort) no COALESCE is * applied and a genuine NULL→'' divergence remains visible as before. * * The returned expression is a bare SQL value expression (no `AS "col"` alias) * suitable for embedding directly in a `SELECT ... ORDER BY ...` digest * query. When no transform applies, a plain quoted column reference is returned. * * @param targetTableName - Physical consolidated target table name (transform * lookup key). * @param col - Column name (present in BOTH source and target). * @param srcType - Raw `type` string from the source `PRAGMA table_info`. * @param isoGlobCols - Set of target columns carrying an ISO GLOB CHECK. * @param tgtCol - Target column metadata (NOT-NULL flag + default + * affinity) for `col`, or `undefined` when unavailable. * @returns A SQL value expression that maps the raw source value to the canonical * value the target stores. */ export declare function buildDigestExpr(targetTableName: string, col: string, srcType: string, isoGlobCols: ReadonlySet, tgtCol?: TargetColumnInfo): string; //# sourceMappingURL=column-transforms.d.ts.map