import { describe, expect, it } from "@effect/vitest"; import { Effect } from "effect"; import { withQueryContext } from "@executor-js/fumadb/query"; import { collectTables } from "./executor"; import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "./sqlite-test-db"; import { LEGACY_BIGINT_STORAGE_CLASS_COLUMNS, bigintStorageClassSqliteMigration, runSqliteBigintStorageClassMigration, } from "./sqlite-bigint-storage-class-migration"; // A `bigint` column is stored on SQLite as a blob holding the decimal digits // (drizzle's `blob({ mode: "bigint" })`). Before the columns below carried the // `bigint` flag they were plain numbers, so SQLite kept them in the INTEGER // storage class — and an install written by that build still holds integers // today. Reading one back through the bigint mapper reaches // `Buffer.from()`, which throws `ERR_INVALID_ARG_TYPE`, and because the // throw is on the ROW mapper it takes down the whole `findMany`, not one field. // // That is issue #1771: `connection.expires_at` held an epoch-millis integer, so // every catalog read threw and the MCP gateway served an empty tool list even // though the integrations were still saved. const TENANT = "t1"; const SUBJECT = "user_a"; const LEGACY_EXPIRES_AT = 1787321623456; const HEALTHY_EXPIRES_AT = 1787321699999; const withDb = (body: (db: SqliteTestFumaDb) => Promise): Promise => Effect.runPromise( Effect.acquireUseRelease( Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })), (db) => Effect.promise(() => body(db)), (db) => Effect.promise(() => db.close()), ), ); const seconds = (ms: number) => Math.floor(ms / 1000); /** Insert a connection row whose `expires_at` is set by a raw SQL expression, * so the test controls its SQLite storage class exactly: an integer literal * lands as INTEGER — what a build that declared the column `integer` wrote — * while `CAST(... AS BLOB)` lands as BLOB, what the ORM writes today. A bound * JS number would land as REAL under the column's current BLOB affinity, which * is a different (also broken) shape. */ const insertConnection = ( db: SqliteTestFumaDb, row: { readonly rowId: string; readonly name: string; readonly expiresAtSql: string }, ): Promise => db.client.execute({ sql: `INSERT INTO connection (row_id, tenant, owner, subject, integration, name, template, provider, item_ids, expires_at, created_at, updated_at) VALUES (?, ?, 'user', ?, 'acme', ?, 'oauth2', 'file', ?, ${row.expiresAtSql}, ?, ?)`, args: [ row.rowId, TENANT, SUBJECT, row.name, JSON.stringify({ token: "item_1" }), seconds(Date.now()), seconds(Date.now()), ], }); /** The legacy shape: a bare integer literal, stored in the INTEGER class. */ const legacyInteger = String(LEGACY_EXPIRES_AT); /** The current shape: the decimal digits as bytes. */ const currentBlob = `CAST('${HEALTHY_EXPIRES_AT}' AS BLOB)`; const storageClassOf = async (db: SqliteTestFumaDb, rowId: string): Promise => { const result = await db.client.execute({ sql: "SELECT typeof(expires_at) AS kind FROM connection WHERE row_id = ?", args: [rowId], }); return String(result.rows[0]?.["kind"]); }; describe("legacy bigint storage class migration", () => { it.effect("reproduces the catalog read failure on a legacy integer row", () => Effect.promise(() => withDb(async (db) => { await insertConnection(db, { rowId: "c_legacy", name: "legacy", expiresAtSql: legacyInteger, }); expect(await storageClassOf(db, "c_legacy")).toBe("integer"); const scoped = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT }); // Not "returns a bad value" — the read THROWS, which is why the gateway // lost every saved integration rather than one field of one row. await expect(scoped.findMany("connection", {})).rejects.toThrow(/type number/); }), ), ); it.effect("converts legacy integers so the catalog reads again", () => Effect.promise(() => withDb(async (db) => { await insertConnection(db, { rowId: "c_legacy", name: "legacy", expiresAtSql: legacyInteger, }); await insertConnection(db, { rowId: "c_healthy", name: "healthy", expiresAtSql: currentBlob, }); await insertConnection(db, { rowId: "c_null", name: "null", expiresAtSql: "NULL" }); const converted = await Effect.runPromise(runSqliteBigintStorageClassMigration(db.client)); expect(converted).toBe(1); expect(await storageClassOf(db, "c_legacy")).toBe("blob"); expect(await storageClassOf(db, "c_healthy")).toBe("blob"); expect(await storageClassOf(db, "c_null")).toBe("null"); const scoped = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT }); const rows = await scoped.findMany("connection", {}); expect( rows.map((row) => [row.name, row.expires_at == null ? null : Number(row.expires_at)]), ).toEqual([ ["healthy", HEALTHY_EXPIRES_AT], ["legacy", LEGACY_EXPIRES_AT], ["null", null], ]); }), ), ); it.effect("is idempotent", () => Effect.promise(() => withDb(async (db) => { await insertConnection(db, { rowId: "c_legacy", name: "legacy", expiresAtSql: legacyInteger, }); expect(await Effect.runPromise(runSqliteBigintStorageClassMigration(db.client))).toBe(1); expect(await Effect.runPromise(runSqliteBigintStorageClassMigration(db.client))).toBe(0); const scoped = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT }); const rows = await scoped.findMany("connection", {}); expect(rows.map((row) => Number(row.expires_at))).toEqual([LEGACY_EXPIRES_AT]); }), ), ); it.effect("converts every bigint column that predates the flag", () => Effect.promise(() => withDb(async (db) => { // `oauth_session.expires_at` was re-typed in the same change, and // `connection.tools_synced_at` / `integration.config_revised_at` / // `subject.last_seen_at` share the representation. await db.client.execute({ sql: `INSERT INTO integration (row_id, tenant, slug, plugin_id, description, config_revised_at, created_at, updated_at) VALUES ('i1', ?, 'acme', 'openapi', '', ${legacyInteger}, ?, ?)`, args: [TENANT, seconds(Date.now()), seconds(Date.now())], }); await insertConnection(db, { rowId: "c1", name: "c1", expiresAtSql: "NULL" }); await db.client.execute( `UPDATE connection SET tools_synced_at = ${legacyInteger} WHERE row_id = 'c1'`, ); expect(await Effect.runPromise(runSqliteBigintStorageClassMigration(db.client))).toBe(2); const scoped = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT }); const integrations = await scoped.findMany("integration", {}); expect(integrations.map((row) => Number(row.config_revised_at))).toEqual([ LEGACY_EXPIRES_AT, ]); const connections = await scoped.findMany("connection", {}); expect(connections.map((row) => Number(row.tools_synced_at))).toEqual([LEGACY_EXPIRES_AT]); }), ), ); it("covers every bigint column the core schema declares", () => { const tables = collectTables() as Record }>; const declared: string[] = []; for (const [tableName, table] of Object.entries(tables)) { for (const [columnName, column] of Object.entries(table.columns)) { if ((column as { readonly type?: string }).type === "bigint") { declared.push(`${tableName}.${columnName}`); } } } const covered = LEGACY_BIGINT_STORAGE_CLASS_COLUMNS.map( (entry) => `${entry.table}.${entry.column}`, ); expect(covered.slice().sort()).toEqual(declared.sort()); }); it("is registered under a stable, date-prefixed name", () => { expect(bigintStorageClassSqliteMigration.name).toBe("2026-08-28-bigint-storage-class"); }); });