Skip to content

Commit d74865f

Browse files
authored
fix(local): repair bigint columns left in SQLite's integer storage class (#1823)
1 parent 435c0f2 commit d74865f

7 files changed

Lines changed: 523 additions & 1 deletion

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
7+
Some 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+
9+
The `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+
11+
A 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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@
55
// renamed.
66
// ---------------------------------------------------------------------------
77

8-
import { sqliteDataMigration, type SqliteDataMigration } from "@executor-js/sdk";
8+
import {
9+
bigintStorageClassSqliteMigration,
10+
sqliteDataMigration,
11+
type SqliteDataMigration,
12+
} from "@executor-js/sdk";
913
import { runSqliteAuthConfigMigration } from "@executor-js/sdk/http-auth";
1014
import {
1115
openApiNdjsonOutputDataMigration,
@@ -20,6 +24,11 @@ import { encryptedSecretsRepartitionDataMigration } from "@executor-js/plugin-en
2024
import { authConfigTransforms } from "./auth-config-migration";
2125

2226
export 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,
2332
// Rewrite pre-canonical integration auth configs into the shared
2433
// placements model.
2534
sqliteDataMigration("2026-06-05-auth-config-placements", (client) =>

apps/local/src/db/data-migrations.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import {
99
Effect,
10+
bigintStorageClassSqliteMigration,
1011
oauthClientGcSqliteMigration,
1112
sqliteDataMigration,
1213
type SqliteDataMigration,
@@ -30,6 +31,11 @@ export const localDataMigrations: readonly SqliteDataMigration[] = [
3031
// stamped atomically inside the staged v2 build; fresh/pre-v2-native DBs get
3132
// the same stamp here so future boots skip legacy shape probing.
3233
{ 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,
3339
// Rewrite pre-canonical integration auth configs (incl. v1→v2 outputs)
3440
// into the shared placements model.
3541
sqliteDataMigration("2026-06-05-auth-config-placements", (client) =>
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
12+
import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest";
13+
import { mkdtempSync, rmSync } from "node:fs";
14+
import { tmpdir } from "node:os";
15+
import { join } from "node:path";
16+
import { Effect } from "effect";
17+
import { withQueryContext } from "@executor-js/fumadb/query";
18+
19+
import { collectTables } from "@executor-js/api/server";
20+
import { runSqliteDataMigrations } from "@executor-js/sdk";
21+
22+
import { localDataMigrations } from "./data-migrations";
23+
import { createSqliteFumaDb } from "./sqlite-fumadb";
24+
25+
const TENANT = "executor-workspace-1771";
26+
const SUBJECT = "local";
27+
// Epoch millis, the shape an OAuth token expiry takes.
28+
const LEGACY_EXPIRES_AT = 1787321623456;
29+
30+
let workDir: string;
31+
32+
beforeEach(() => {
33+
workDir = mkdtempSync(join(tmpdir(), "executor-legacy-bigint-"));
34+
});
35+
36+
afterEach(() => {
37+
rmSync(workDir, { recursive: true, force: true });
38+
});
39+
40+
const 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. */
49+
const 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+
68+
describe("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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,14 @@ export {
488488
oauthClientGcSqliteMigration,
489489
runSqliteOAuthClientGcMigration,
490490
} 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).
493+
export {
494+
bigintStorageClassSqliteMigration,
495+
runSqliteBigintStorageClassMigration,
496+
LEGACY_BIGINT_STORAGE_CLASS_COLUMNS,
497+
type BigintStorageClassColumn,
498+
} from "./sqlite-bigint-storage-class-migration";
491499
export {
492500
authToolFailure,
493501
isUnauthorizedToolFailure,
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
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

Comments
 (0)