166 lines · 6.5 KB · TypeScript
RawDownload
// ---------------------------------------------------------------------------// libSQL boot migration: rewrite `bigint` columns that an earlier build left in// SQLite's INTEGER storage class (issue #1771).//// A `bigint` column is stored on SQLite as a BLOB holding the value's decimal// digits — that is what drizzle's `blob({ mode: "bigint" })` writes, and its// row mapper reads the blob back with `Buffer.from(...)`. Before these columns// carried the `bigint` flag they were plain numbers, which SQLite kept in the// INTEGER storage class. SQLite does not rewrite existing rows when a column's// declared type changes, so an install written by that build still holds// integers today.//// Reading one back now reaches `Buffer.from(<number>)`, which throws// `ERR_INVALID_ARG_TYPE`. The throw is in the ROW mapper, so it fails the whole// `findMany` rather than one field: on a database whose `connection` rows// carried an OAuth `expires_at` from that era, EVERY catalog read threw and the// MCP gateway served an empty tool list even though the integrations were all// still saved.//// The rewrite is deliberately narrow. It touches only the columns the schema// declares `bigint`, and within them only values whose storage class is// `integer` or `real` — the shapes the mapper cannot read. Values already// stored as BLOB or TEXT (TEXT is what the v1→v2 migration writes, and the// mapper reads it fine) are left exactly as they are. `CAST(x AS BLOB)`// converts through TEXT, so the stored bytes end up identical to what the ORM// writes today. Idempotent: after a run no column is in a numeric storage// class, so a second run updates nothing.// --------------------------------------------------------------------------- import { Effect } from "effect"; import { coreSchema } from "./core-schema";import {  DataMigrationError,  type SqliteDataMigration,  type SqliteDataMigrationClient,} from "./sqlite-data-migrations"; const MIGRATION_NAME = "2026-08-28-bigint-storage-class"; export interface BigintStorageClassColumn {  /** SQL table name. */  readonly table: string;  /** SQL column name. */  readonly column: string;} /** * Every `bigint` column in the core schema, by SQL name. * * Derived from the schema rather than hand-listed so a column added later can * never be silently missed. Scanning a column that never held an integer is a * no-op, so over-coverage costs one indexed-free table scan at first boot. */export const LEGACY_BIGINT_STORAGE_CLASS_COLUMNS: readonly BigintStorageClassColumn[] =  Object.values(coreSchema).flatMap((table) =>    Object.values(table.columns)      .filter((column) => column.type === "bigint")      .map((column) => ({ table: table.names.sql, column: column.names.sql })),  ); const execute = (  client: SqliteDataMigrationClient,  stmt: string | { readonly sql: string; readonly args: readonly unknown[] },) =>  Effect.tryPromise({    try: () => client.execute(stmt),    catch: (cause) => new DataMigrationError({ migration: MIGRATION_NAME, cause }),  }); /** SQLite identifiers are quoted, not parameterized. Every name here comes from *  the compiled-in schema, so this always matches; anything else is refused *  rather than interpolated. */const quoteIdentifier = (name: string): Effect.Effect<string, DataMigrationError> =>  /^[A-Za-z_][A-Za-z0-9_]*$/.test(name)    ? Effect.succeed(`"${name}"`)    : Effect.fail(        new DataMigrationError({          migration: MIGRATION_NAME,          cause: `Refusing to interpolate SQL identifier: ${name}`,        }),      ); const hasColumn = (  client: SqliteDataMigrationClient,  table: string,  quotedTable: string,  column: string,): Effect.Effect<boolean, DataMigrationError> =>  execute(client, {    sql: "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",    args: [table],  }).pipe(    Effect.flatMap((tables) =>      tables.rows.length === 0        ? Effect.succeed(false)        : execute(client, `PRAGMA table_info(${quotedTable})`).pipe(            Effect.map((info) => info.rows.some((row) => row["name"] === column)),          ),    ),  ); /** * Convert legacy INTEGER/REAL values in the schema's `bigint` columns to the * blob representation the ORM reads. * * Returns the number of rows rewritten. Wrapped in BEGIN…COMMIT so a mid-run * failure leaves the database untouched and the (unstamped) migration re-runs * cleanly on the next boot. */export const runSqliteBigintStorageClassMigration = (  client: SqliteDataMigrationClient,): Effect.Effect<number, DataMigrationError> =>  Effect.gen(function* () {    const pending: { readonly sql: string; readonly count: number }[] = [];     for (const target of LEGACY_BIGINT_STORAGE_CLASS_COLUMNS) {      const table = yield* quoteIdentifier(target.table);      const column = yield* quoteIdentifier(target.column);      if (!(yield* hasColumn(client, target.table, table, target.column))) continue;       // `typeof()` reports the STORAGE class, which is what the mapper trips      // over — the declared column type is irrelevant to SQLite here.      const predicate = `typeof(${column}) IN ('integer', 'real')`;       const counted = yield* execute(        client,        `SELECT COUNT(*) AS n FROM ${table} WHERE ${predicate}`,      );      const count = Number(counted.rows[0]?.["n"] ?? 0);      if (count === 0) continue;       pending.push({        sql: `UPDATE ${table} SET ${column} = CAST(CAST(${column} AS INTEGER) AS BLOB) WHERE ${predicate}`,        count,      });    }     if (pending.length === 0) return 0;     const applyAll = Effect.gen(function* () {      let converted = 0;      for (const statement of pending) {        yield* execute(client, statement.sql);        converted += statement.count;      }      yield* execute(client, "COMMIT");      return converted;    });     yield* execute(client, "BEGIN");    return yield* applyAll.pipe(      Effect.tapError(() => execute(client, "ROLLBACK").pipe(Effect.ignore)),      // `tapError` only fires on a typed failure, not on fiber interruption      // (e.g. the boot sequence timing out mid-migration) — so without this an      // interrupted run can leave the transaction open. Never fires on success:      // COMMIT has already run by then.      Effect.onInterrupt(() => execute(client, "ROLLBACK").pipe(Effect.ignore)),    );  }); /** Registry entry for the SQLite hosts' boot-time data-migration ledger. */export const bigintStorageClassSqliteMigration: SqliteDataMigration = {  name: MIGRATION_NAME,  run: (client) => runSqliteBigintStorageClassMigration(client).pipe(Effect.asVoid),};