History

fix(local): repair bigint columns left in SQLite's integer storage class (#1823)

Rhys Sullivan committed 2 months ago

Commit d74865f1 parent 435c0f27 files · +523 −1Browse files

Unified · Split

7 files
  1. .changeset/legacy-bigint-storage-class.md +11 −0
  2. apps/host-selfhost/src/db/data-migrations.ts +10 −1
  3. apps/local/src/db/data-migrations.ts +6 −0
  4. apps/local/src/db/legacy-bigint-boot.test.ts +120 −0
  5. packages/core/sdk/src/index.ts +8 −0
  6. packages/core/sdk/src/sqlite-bigint-storage-class-migration.test.ts +202 −0
  7. packages/core/sdk/src/sqlite-bigint-storage-class-migration.ts +166 −0
.changeset/legacy-bigint-storage-class.md+11−0
@@ -0,0 +1,11 @@
1---
2"executor": patch
3---
4
5**Saved integrations come back after an upgrade left an OAuth expiry in the old number format**
6
7Some installs lost every saved integration from the MCP gateway at once. The credentials were never deleted — the gateway simply could not read the table they live in, so it served an empty tool list and restarting did not help.
8
9The `connection.expires_at` column records when an OAuth access token expires. It used to be a plain number; it now holds the value's digits, because a millisecond timestamp is larger than a 32-bit integer. SQLite does not rewrite rows when a column's type changes, so a connection saved by an older build still held the old form. Reading one back failed, and because the failure happened while mapping the row, it failed the whole query rather than that one field — one stale row was enough to hide every integration.
10
11A boot-time migration now converts those values to the current form. It runs before anything reads the table, so the integrations are back on the first restart after upgrading. It only touches values still in the old numeric form: rows already written by a current build are left exactly as they are, and it runs once.
apps/host-selfhost/src/db/data-migrations.ts+10−1
@@ -5,7 +5,11 @@ · 4 unmodified lines
5// renamed.5// renamed.
6// ---------------------------------------------------------------------------6// ---------------------------------------------------------------------------
7 7
8import { sqliteDataMigration, type SqliteDataMigration } from "@executor-js/sdk";8import {
9 bigintStorageClassSqliteMigration,
10 sqliteDataMigration,
11 type SqliteDataMigration,
12} from "@executor-js/sdk";
9import { runSqliteAuthConfigMigration } from "@executor-js/sdk/http-auth";13import { runSqliteAuthConfigMigration } from "@executor-js/sdk/http-auth";
10import {14import {
11 openApiNdjsonOutputDataMigration,15 openApiNdjsonOutputDataMigration,
@@ -20,6 +24,11 @@ import { encryptedSecretsRepartitionDataMigration } from "@executor-js/plugin-en · 8 unmodified lines
20import { authConfigTransforms } from "./auth-config-migration";24import { authConfigTransforms } from "./auth-config-migration";
21 25
22export const selfHostDataMigrations: readonly SqliteDataMigration[] = [26export const selfHostDataMigrations: readonly SqliteDataMigration[] = [
27 // FIRST, because it un-bricks reads every later migration and the whole app
28 // depend on: `bigint` columns an older build left in SQLite's INTEGER storage
29 // class cannot be read by the bigint row mapper, so a single legacy
30 // `connection.expires_at` failed every catalog read (issue #1771).
31 bigintStorageClassSqliteMigration,
23 // Rewrite pre-canonical integration auth configs into the shared32 // Rewrite pre-canonical integration auth configs into the shared
24 // placements model.33 // placements model.
25 sqliteDataMigration("2026-06-05-auth-config-placements", (client) =>34 sqliteDataMigration("2026-06-05-auth-config-placements", (client) =>
apps/local/src/db/data-migrations.ts+6−0
@@ -7,6 +7,7 @@ · 6 unmodified lines
7 7
8import {8import {
9 Effect,9 Effect,
10 bigintStorageClassSqliteMigration,
10 oauthClientGcSqliteMigration,11 oauthClientGcSqliteMigration,
11 sqliteDataMigration,12 sqliteDataMigration,
12 type SqliteDataMigration,13 type SqliteDataMigration,
@@ -30,6 +31,11 @@ export const localDataMigrations: readonly SqliteDataMigration[] = [ · 17 unmodified lines
30 // stamped atomically inside the staged v2 build; fresh/pre-v2-native DBs get31 // stamped atomically inside the staged v2 build; fresh/pre-v2-native DBs get
31 // the same stamp here so future boots skip legacy shape probing.32 // the same stamp here so future boots skip legacy shape probing.
32 { name: LOCAL_V1_V2_LEDGER_NAME, run: () => Effect.void },33 { name: LOCAL_V1_V2_LEDGER_NAME, run: () => Effect.void },
34 // FIRST, because it un-bricks reads every later migration and the whole app
35 // depend on: `bigint` columns an older build left in SQLite's INTEGER storage
36 // class cannot be read by the bigint row mapper, so a single legacy
37 // `connection.expires_at` failed every catalog read (issue #1771).
38 bigintStorageClassSqliteMigration,
33 // Rewrite pre-canonical integration auth configs (incl. v1→v2 outputs)39 // Rewrite pre-canonical integration auth configs (incl. v1→v2 outputs)
34 // into the shared placements model.40 // into the shared placements model.
35 sqliteDataMigration("2026-06-05-auth-config-placements", (client) =>41 sqliteDataMigration("2026-06-05-auth-config-placements", (client) =>
apps/local/src/db/legacy-bigint-boot.test.ts+120−0
@@ -0,0 +1,120 @@
1// ---------------------------------------------------------------------------
2// Boot-level proof for issue #1771: a local database holding a legacy INTEGER
3// `connection.expires_at` is unreadable, and the local boot sequence heals it.
4//
5// The migration body is unit-tested in the SDK. What this pins is the WIRING —
6// that `localDataMigrations` actually carries the entry, early enough that the
7// catalog reads which follow the ledger run see repaired rows. That wiring is
8// the part that recovers a user's install; a correct migration nobody runs
9// would leave the gateway just as empty.
10// ---------------------------------------------------------------------------
11
12import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest";
13import { mkdtempSync, rmSync } from "node:fs";
14import { tmpdir } from "node:os";
15import { join } from "node:path";
16import { Effect } from "effect";
17import { withQueryContext } from "@executor-js/fumadb/query";
18
19import { collectTables } from "@executor-js/api/server";
20import { runSqliteDataMigrations } from "@executor-js/sdk";
21
22import { localDataMigrations } from "./data-migrations";
23import { createSqliteFumaDb } from "./sqlite-fumadb";
24
25const TENANT = "executor-workspace-1771";
26const SUBJECT = "local";
27// Epoch millis, the shape an OAuth token expiry takes.
28const LEGACY_EXPIRES_AT = 1787321623456;
29
30let workDir: string;
31
32beforeEach(() => {
33 workDir = mkdtempSync(join(tmpdir(), "executor-legacy-bigint-"));
34});
35
36afterEach(() => {
37 rmSync(workDir, { recursive: true, force: true });
38});
39
40const openDb = (dbPath: string) =>
41 createSqliteFumaDb({
42 tables: collectTables(),
43 namespace: "executor_local",
44 path: dbPath,
45 });
46
47/** Write the row a pre-`bigint` build left behind: `expires_at` as a bare
48 * integer literal, which SQLite keeps in the INTEGER storage class. */
49const seedLegacyConnection = async (dbPath: string): Promise<void> => {
50 const sqlite = await openDb(dbPath);
51 await sqlite.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 ('c1', ?, 'user', ?, 'acme', 'default', 'oauth2', 'file', ?,
56 ${LEGACY_EXPIRES_AT}, ?, ?)`,
57 args: [
58 TENANT,
59 SUBJECT,
60 JSON.stringify({ token: "item_1" }),
61 Math.floor(Date.now() / 1000),
62 Math.floor(Date.now() / 1000),
63 ],
64 });
65 await sqlite.close();
66};
67
68describe("local boot over a legacy bigint database", () => {
69 it("cannot read the connection table before the migrations run", async () => {
70 const dbPath = join(workDir, "data.db");
71 await seedLegacyConnection(dbPath);
72
73 const sqlite = await openDb(dbPath);
74 const scoped = withQueryContext(sqlite.db, { tenant: TENANT, subject: SUBJECT });
75 // The reported symptom: not a wrong value, a throw — so the gateway lost
76 // every saved integration at once.
77 await expect(scoped.findMany("connection", {})).rejects.toThrow(/type number/);
78 await sqlite.close();
79 });
80
81 it("heals it through the local data-migration registry", async () => {
82 const dbPath = join(workDir, "data.db");
83 await seedLegacyConnection(dbPath);
84
85 const sqlite = await openDb(dbPath);
86 const applied = await Effect.runPromise(
87 runSqliteDataMigrations(sqlite.client, localDataMigrations),
88 );
89 expect(applied).toContain("2026-08-28-bigint-storage-class");
90
91 const scoped = withQueryContext(sqlite.db, { tenant: TENANT, subject: SUBJECT });
92 const rows = await scoped.findMany("connection", {});
93 expect(rows.map((row) => [row.name, Number(row.expires_at)])).toEqual([
94 ["default", LEGACY_EXPIRES_AT],
95 ]);
96 await sqlite.close();
97 });
98
99 it("stays readable across a reboot", async () => {
100 const dbPath = join(workDir, "data.db");
101 await seedLegacyConnection(dbPath);
102
103 const first = await openDb(dbPath);
104 await Effect.runPromise(runSqliteDataMigrations(first.client, localDataMigrations));
105 await first.close();
106
107 // Second boot: the entry is stamped, so it is skipped — the data must
108 // already be in the shape the mapper reads.
109 const second = await openDb(dbPath);
110 const applied = await Effect.runPromise(
111 runSqliteDataMigrations(second.client, localDataMigrations),
112 );
113 expect(applied).not.toContain("2026-08-28-bigint-storage-class");
114
115 const scoped = withQueryContext(second.db, { tenant: TENANT, subject: SUBJECT });
116 const rows = await scoped.findMany("connection", {});
117 expect(rows.map((row) => Number(row.expires_at))).toEqual([LEGACY_EXPIRES_AT]);
118 await second.close();
119 });
120});
packages/core/sdk/src/index.ts+8−0
@@ -488,6 +488,14 @@ export { · 487 unmodified lines
488 oauthClientGcSqliteMigration,488 oauthClientGcSqliteMigration,
489 runSqliteOAuthClientGcMigration,489 runSqliteOAuthClientGcMigration,
490} from "./sqlite-oauth-client-gc-migration";490} from "./sqlite-oauth-client-gc-migration";
491// Rewrite `bigint` columns an earlier build left in SQLite's INTEGER storage
492// class, which the bigint row mapper cannot read (issue #1771).
493export {
494 bigintStorageClassSqliteMigration,
495 runSqliteBigintStorageClassMigration,
496 LEGACY_BIGINT_STORAGE_CLASS_COLUMNS,
497 type BigintStorageClassColumn,
498} from "./sqlite-bigint-storage-class-migration";
491export {499export {
492 authToolFailure,500 authToolFailure,
493 isUnauthorizedToolFailure,501 isUnauthorizedToolFailure,
packages/core/sdk/src/sqlite-bigint-storage-class-migration.test.ts+202−0
@@ -0,0 +1,202 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect } from "effect";
3import { withQueryContext } from "@executor-js/fumadb/query";
4
5import { collectTables } from "./executor";
6import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "./sqlite-test-db";
7import {
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
25const TENANT = "t1";
26const SUBJECT = "user_a";
27const LEGACY_EXPIRES_AT = 1787321623456;
28const HEALTHY_EXPIRES_AT = 1787321699999;
29
30const 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
39const 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. */
47const 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. */
68const legacyInteger = String(LEGACY_EXPIRES_AT);
69/** The current shape: the decimal digits as bytes. */
70const currentBlob = `CAST('${HEALTHY_EXPIRES_AT}' AS BLOB)`;
71
72const 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
80describe("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});
packages/core/sdk/src/sqlite-bigint-storage-class-migration.ts+166−0
@@ -0,0 +1,166 @@
1// ---------------------------------------------------------------------------
2// libSQL boot migration: rewrite `bigint` columns that an earlier build left in
3// SQLite's INTEGER storage class (issue #1771).
4//
5// A `bigint` column is stored on SQLite as a BLOB holding the value's decimal
6// digits — that is what drizzle's `blob({ mode: "bigint" })` writes, and its
7// row mapper reads the blob back with `Buffer.from(...)`. Before these columns
8// carried the `bigint` flag they were plain numbers, which SQLite kept in the
9// INTEGER storage class. SQLite does not rewrite existing rows when a column's
10// declared type changes, so an install written by that build still holds
11// integers today.
12//
13// Reading one back now reaches `Buffer.from(<number>)`, which throws
14// `ERR_INVALID_ARG_TYPE`. The throw is in the ROW mapper, so it fails the whole
15// `findMany` rather than one field: on a database whose `connection` rows
16// carried an OAuth `expires_at` from that era, EVERY catalog read threw and the
17// MCP gateway served an empty tool list even though the integrations were all
18// still saved.
19//
20// The rewrite is deliberately narrow. It touches only the columns the schema
21// declares `bigint`, and within them only values whose storage class is
22// `integer` or `real` — the shapes the mapper cannot read. Values already
23// stored as BLOB or TEXT (TEXT is what the v1→v2 migration writes, and the
24// mapper reads it fine) are left exactly as they are. `CAST(x AS BLOB)`
25// converts through TEXT, so the stored bytes end up identical to what the ORM
26// writes today. Idempotent: after a run no column is in a numeric storage
27// class, so a second run updates nothing.
28// ---------------------------------------------------------------------------
29
30import { Effect } from "effect";
31
32import { coreSchema } from "./core-schema";
33import {
34 DataMigrationError,
35 type SqliteDataMigration,
36 type SqliteDataMigrationClient,
37} from "./sqlite-data-migrations";
38
39const MIGRATION_NAME = "2026-08-28-bigint-storage-class";
40
41export interface BigintStorageClassColumn {
42 /** SQL table name. */
43 readonly table: string;
44 /** SQL column name. */
45 readonly column: string;
46}
47
48/**
49 * Every `bigint` column in the core schema, by SQL name.
50 *
51 * Derived from the schema rather than hand-listed so a column added later can
52 * never be silently missed. Scanning a column that never held an integer is a
53 * no-op, so over-coverage costs one indexed-free table scan at first boot.
54 */
55export const LEGACY_BIGINT_STORAGE_CLASS_COLUMNS: readonly BigintStorageClassColumn[] =
56 Object.values(coreSchema).flatMap((table) =>
57 Object.values(table.columns)
58 .filter((column) => column.type === "bigint")
59 .map((column) => ({ table: table.names.sql, column: column.names.sql })),
60 );
61
62const execute = (
63 client: SqliteDataMigrationClient,
64 stmt: string | { readonly sql: string; readonly args: readonly unknown[] },
65) =>
66 Effect.tryPromise({
67 try: () => client.execute(stmt),
68 catch: (cause) => new DataMigrationError({ migration: MIGRATION_NAME, cause }),
69 });
70
71/** SQLite identifiers are quoted, not parameterized. Every name here comes from
72 * the compiled-in schema, so this always matches; anything else is refused
73 * rather than interpolated. */
74const quoteIdentifier = (name: string): Effect.Effect<string, DataMigrationError> =>
75 /^[A-Za-z_][A-Za-z0-9_]*$/.test(name)
76 ? Effect.succeed(`"${name}"`)
77 : Effect.fail(
78 new DataMigrationError({
79 migration: MIGRATION_NAME,
80 cause: `Refusing to interpolate SQL identifier: ${name}`,
81 }),
82 );
83
84const hasColumn = (
85 client: SqliteDataMigrationClient,
86 table: string,
87 quotedTable: string,
88 column: string,
89): Effect.Effect<boolean, DataMigrationError> =>
90 execute(client, {
91 sql: "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
92 args: [table],
93 }).pipe(
94 Effect.flatMap((tables) =>
95 tables.rows.length === 0
96 ? Effect.succeed(false)
97 : execute(client, `PRAGMA table_info(${quotedTable})`).pipe(
98 Effect.map((info) => info.rows.some((row) => row["name"] === column)),
99 ),
100 ),
101 );
102
103/**
104 * Convert legacy INTEGER/REAL values in the schema's `bigint` columns to the
105 * blob representation the ORM reads.
106 *
107 * Returns the number of rows rewritten. Wrapped in BEGIN…COMMIT so a mid-run
108 * failure leaves the database untouched and the (unstamped) migration re-runs
109 * cleanly on the next boot.
110 */
111export const runSqliteBigintStorageClassMigration = (
112 client: SqliteDataMigrationClient,
113): Effect.Effect<number, DataMigrationError> =>
114 Effect.gen(function* () {
115 const pending: { readonly sql: string; readonly count: number }[] = [];
116
117 for (const target of LEGACY_BIGINT_STORAGE_CLASS_COLUMNS) {
118 const table = yield* quoteIdentifier(target.table);
119 const column = yield* quoteIdentifier(target.column);
120 if (!(yield* hasColumn(client, target.table, table, target.column))) continue;
121
122 // `typeof()` reports the STORAGE class, which is what the mapper trips
123 // over — the declared column type is irrelevant to SQLite here.
124 const predicate = `typeof(${column}) IN ('integer', 'real')`;
125
126 const counted = yield* execute(
127 client,
128 `SELECT COUNT(*) AS n FROM ${table} WHERE ${predicate}`,
129 );
130 const count = Number(counted.rows[0]?.["n"] ?? 0);
131 if (count === 0) continue;
132
133 pending.push({
134 sql: `UPDATE ${table} SET ${column} = CAST(CAST(${column} AS INTEGER) AS BLOB) WHERE ${predicate}`,
135 count,
136 });
137 }
138
139 if (pending.length === 0) return 0;
140
141 const applyAll = Effect.gen(function* () {
142 let converted = 0;
143 for (const statement of pending) {
144 yield* execute(client, statement.sql);
145 converted += statement.count;
146 }
147 yield* execute(client, "COMMIT");
148 return converted;
149 });
150
151 yield* execute(client, "BEGIN");
152 return yield* applyAll.pipe(
153 Effect.tapError(() => execute(client, "ROLLBACK").pipe(Effect.ignore)),
154 // `tapError` only fires on a typed failure, not on fiber interruption
155 // (e.g. the boot sequence timing out mid-migration) — so without this an
156 // interrupted run can leave the transaction open. Never fires on success:
157 // COMMIT has already run by then.
158 Effect.onInterrupt(() => execute(client, "ROLLBACK").pipe(Effect.ignore)),
159 );
160 });
161
162/** Registry entry for the SQLite hosts' boot-time data-migration ledger. */
163export const bigintStorageClassSqliteMigration: SqliteDataMigration = {
164 name: MIGRATION_NAME,
165 run: (client) => runSqliteBigintStorageClassMigration(client).pipe(Effect.asVoid),
166};