|
| 1 | +import { describe, expect, it } from "@effect/vitest"; |
| 2 | +import { Effect } from "effect"; |
| 3 | +import { withQueryContext } from "@executor-js/fumadb/query"; |
| 4 | + |
| 5 | +import { collectTables } from "./executor"; |
| 6 | +import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "./sqlite-test-db"; |
| 7 | +import { |
| 8 | + LEGACY_BIGINT_STORAGE_CLASS_COLUMNS, |
| 9 | + bigintStorageClassSqliteMigration, |
| 10 | + runSqliteBigintStorageClassMigration, |
| 11 | +} from "./sqlite-bigint-storage-class-migration"; |
| 12 | + |
| 13 | +// A `bigint` column is stored on SQLite as a blob holding the decimal digits |
| 14 | +// (drizzle's `blob({ mode: "bigint" })`). Before the columns below carried the |
| 15 | +// `bigint` flag they were plain numbers, so SQLite kept them in the INTEGER |
| 16 | +// storage class — and an install written by that build still holds integers |
| 17 | +// today. Reading one back through the bigint mapper reaches |
| 18 | +// `Buffer.from(<number>)`, which throws `ERR_INVALID_ARG_TYPE`, and because the |
| 19 | +// throw is on the ROW mapper it takes down the whole `findMany`, not one field. |
| 20 | +// |
| 21 | +// That is issue #1771: `connection.expires_at` held an epoch-millis integer, so |
| 22 | +// every catalog read threw and the MCP gateway served an empty tool list even |
| 23 | +// though the integrations were still saved. |
| 24 | + |
| 25 | +const TENANT = "t1"; |
| 26 | +const SUBJECT = "user_a"; |
| 27 | +const LEGACY_EXPIRES_AT = 1787321623456; |
| 28 | +const HEALTHY_EXPIRES_AT = 1787321699999; |
| 29 | + |
| 30 | +const withDb = <A>(body: (db: SqliteTestFumaDb) => Promise<A>): Promise<A> => |
| 31 | + Effect.runPromise( |
| 32 | + Effect.acquireUseRelease( |
| 33 | + Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })), |
| 34 | + (db) => Effect.promise(() => body(db)), |
| 35 | + (db) => Effect.promise(() => db.close()), |
| 36 | + ), |
| 37 | + ); |
| 38 | + |
| 39 | +const seconds = (ms: number) => Math.floor(ms / 1000); |
| 40 | + |
| 41 | +/** Insert a connection row whose `expires_at` is set by a raw SQL expression, |
| 42 | + * so the test controls its SQLite storage class exactly: an integer literal |
| 43 | + * lands as INTEGER — what a build that declared the column `integer` wrote — |
| 44 | + * while `CAST(... AS BLOB)` lands as BLOB, what the ORM writes today. A bound |
| 45 | + * JS number would land as REAL under the column's current BLOB affinity, which |
| 46 | + * is a different (also broken) shape. */ |
| 47 | +const insertConnection = ( |
| 48 | + db: SqliteTestFumaDb, |
| 49 | + row: { readonly rowId: string; readonly name: string; readonly expiresAtSql: string }, |
| 50 | +): Promise<unknown> => |
| 51 | + db.client.execute({ |
| 52 | + sql: `INSERT INTO connection |
| 53 | + (row_id, tenant, owner, subject, integration, name, template, provider, item_ids, |
| 54 | + expires_at, created_at, updated_at) |
| 55 | + VALUES (?, ?, 'user', ?, 'acme', ?, 'oauth2', 'file', ?, ${row.expiresAtSql}, ?, ?)`, |
| 56 | + args: [ |
| 57 | + row.rowId, |
| 58 | + TENANT, |
| 59 | + SUBJECT, |
| 60 | + row.name, |
| 61 | + JSON.stringify({ token: "item_1" }), |
| 62 | + seconds(Date.now()), |
| 63 | + seconds(Date.now()), |
| 64 | + ], |
| 65 | + }); |
| 66 | + |
| 67 | +/** The legacy shape: a bare integer literal, stored in the INTEGER class. */ |
| 68 | +const legacyInteger = String(LEGACY_EXPIRES_AT); |
| 69 | +/** The current shape: the decimal digits as bytes. */ |
| 70 | +const currentBlob = `CAST('${HEALTHY_EXPIRES_AT}' AS BLOB)`; |
| 71 | + |
| 72 | +const storageClassOf = async (db: SqliteTestFumaDb, rowId: string): Promise<string> => { |
| 73 | + const result = await db.client.execute({ |
| 74 | + sql: "SELECT typeof(expires_at) AS kind FROM connection WHERE row_id = ?", |
| 75 | + args: [rowId], |
| 76 | + }); |
| 77 | + return String(result.rows[0]?.["kind"]); |
| 78 | +}; |
| 79 | + |
| 80 | +describe("legacy bigint storage class migration", () => { |
| 81 | + it.effect("reproduces the catalog read failure on a legacy integer row", () => |
| 82 | + Effect.promise(() => |
| 83 | + withDb(async (db) => { |
| 84 | + await insertConnection(db, { |
| 85 | + rowId: "c_legacy", |
| 86 | + name: "legacy", |
| 87 | + expiresAtSql: legacyInteger, |
| 88 | + }); |
| 89 | + expect(await storageClassOf(db, "c_legacy")).toBe("integer"); |
| 90 | + |
| 91 | + const scoped = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT }); |
| 92 | + // Not "returns a bad value" — the read THROWS, which is why the gateway |
| 93 | + // lost every saved integration rather than one field of one row. |
| 94 | + await expect(scoped.findMany("connection", {})).rejects.toThrow(/type number/); |
| 95 | + }), |
| 96 | + ), |
| 97 | + ); |
| 98 | + |
| 99 | + it.effect("converts legacy integers so the catalog reads again", () => |
| 100 | + Effect.promise(() => |
| 101 | + withDb(async (db) => { |
| 102 | + await insertConnection(db, { |
| 103 | + rowId: "c_legacy", |
| 104 | + name: "legacy", |
| 105 | + expiresAtSql: legacyInteger, |
| 106 | + }); |
| 107 | + await insertConnection(db, { |
| 108 | + rowId: "c_healthy", |
| 109 | + name: "healthy", |
| 110 | + expiresAtSql: currentBlob, |
| 111 | + }); |
| 112 | + await insertConnection(db, { rowId: "c_null", name: "null", expiresAtSql: "NULL" }); |
| 113 | + |
| 114 | + const converted = await Effect.runPromise(runSqliteBigintStorageClassMigration(db.client)); |
| 115 | + expect(converted).toBe(1); |
| 116 | + |
| 117 | + expect(await storageClassOf(db, "c_legacy")).toBe("blob"); |
| 118 | + expect(await storageClassOf(db, "c_healthy")).toBe("blob"); |
| 119 | + expect(await storageClassOf(db, "c_null")).toBe("null"); |
| 120 | + |
| 121 | + const scoped = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT }); |
| 122 | + const rows = await scoped.findMany("connection", {}); |
| 123 | + expect( |
| 124 | + rows.map((row) => [row.name, row.expires_at == null ? null : Number(row.expires_at)]), |
| 125 | + ).toEqual([ |
| 126 | + ["healthy", HEALTHY_EXPIRES_AT], |
| 127 | + ["legacy", LEGACY_EXPIRES_AT], |
| 128 | + ["null", null], |
| 129 | + ]); |
| 130 | + }), |
| 131 | + ), |
| 132 | + ); |
| 133 | + |
| 134 | + it.effect("is idempotent", () => |
| 135 | + Effect.promise(() => |
| 136 | + withDb(async (db) => { |
| 137 | + await insertConnection(db, { |
| 138 | + rowId: "c_legacy", |
| 139 | + name: "legacy", |
| 140 | + expiresAtSql: legacyInteger, |
| 141 | + }); |
| 142 | + |
| 143 | + expect(await Effect.runPromise(runSqliteBigintStorageClassMigration(db.client))).toBe(1); |
| 144 | + expect(await Effect.runPromise(runSqliteBigintStorageClassMigration(db.client))).toBe(0); |
| 145 | + |
| 146 | + const scoped = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT }); |
| 147 | + const rows = await scoped.findMany("connection", {}); |
| 148 | + expect(rows.map((row) => Number(row.expires_at))).toEqual([LEGACY_EXPIRES_AT]); |
| 149 | + }), |
| 150 | + ), |
| 151 | + ); |
| 152 | + |
| 153 | + it.effect("converts every bigint column that predates the flag", () => |
| 154 | + Effect.promise(() => |
| 155 | + withDb(async (db) => { |
| 156 | + // `oauth_session.expires_at` was re-typed in the same change, and |
| 157 | + // `connection.tools_synced_at` / `integration.config_revised_at` / |
| 158 | + // `subject.last_seen_at` share the representation. |
| 159 | + await db.client.execute({ |
| 160 | + sql: `INSERT INTO integration |
| 161 | + (row_id, tenant, slug, plugin_id, description, config_revised_at, created_at, updated_at) |
| 162 | + VALUES ('i1', ?, 'acme', 'openapi', '', ${legacyInteger}, ?, ?)`, |
| 163 | + args: [TENANT, seconds(Date.now()), seconds(Date.now())], |
| 164 | + }); |
| 165 | + await insertConnection(db, { rowId: "c1", name: "c1", expiresAtSql: "NULL" }); |
| 166 | + await db.client.execute( |
| 167 | + `UPDATE connection SET tools_synced_at = ${legacyInteger} WHERE row_id = 'c1'`, |
| 168 | + ); |
| 169 | + |
| 170 | + expect(await Effect.runPromise(runSqliteBigintStorageClassMigration(db.client))).toBe(2); |
| 171 | + |
| 172 | + const scoped = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT }); |
| 173 | + const integrations = await scoped.findMany("integration", {}); |
| 174 | + expect(integrations.map((row) => Number(row.config_revised_at))).toEqual([ |
| 175 | + LEGACY_EXPIRES_AT, |
| 176 | + ]); |
| 177 | + const connections = await scoped.findMany("connection", {}); |
| 178 | + expect(connections.map((row) => Number(row.tools_synced_at))).toEqual([LEGACY_EXPIRES_AT]); |
| 179 | + }), |
| 180 | + ), |
| 181 | + ); |
| 182 | + |
| 183 | + it("covers every bigint column the core schema declares", () => { |
| 184 | + const tables = collectTables() as Record<string, { readonly columns: Record<string, unknown> }>; |
| 185 | + const declared: string[] = []; |
| 186 | + for (const [tableName, table] of Object.entries(tables)) { |
| 187 | + for (const [columnName, column] of Object.entries(table.columns)) { |
| 188 | + if ((column as { readonly type?: string }).type === "bigint") { |
| 189 | + declared.push(`${tableName}.${columnName}`); |
| 190 | + } |
| 191 | + } |
| 192 | + } |
| 193 | + const covered = LEGACY_BIGINT_STORAGE_CLASS_COLUMNS.map( |
| 194 | + (entry) => `${entry.table}.${entry.column}`, |
| 195 | + ); |
| 196 | + expect(covered.slice().sort()).toEqual(declared.sort()); |
| 197 | + }); |
| 198 | + |
| 199 | + it("is registered under a stable, date-prefixed name", () => { |
| 200 | + expect(bigintStorageClassSqliteMigration.name).toBe("2026-08-28-bigint-storage-class"); |
| 201 | + }); |
| 202 | +}); |
0 commit comments