Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/legacy-empty-json-columns.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@executor-js/host-selfhost": patch
"executor": patch
---

Repair connections that an older build saved with an empty string instead of NULL in a JSON column. Reading one back threw while mapping the row, so every toolkit MCP endpoint failed `initialize` with an internal error. A boot-time migration now sets those empty strings to NULL in every nullable JSON column the schema declares. It runs once, and rows written by a current build are left exactly as they are.
5 changes: 5 additions & 0 deletions apps/host-selfhost/src/db/data-migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import {
bigintStorageClassSqliteMigration,
emptyJsonSqliteMigration,
sqliteDataMigration,
type SqliteDataMigration,
} from "@executor-js/sdk";
Expand All @@ -29,6 +30,10 @@ export const selfHostDataMigrations: readonly SqliteDataMigration[] = [
// class cannot be read by the bigint row mapper, so a single legacy
// `connection.expires_at` failed every catalog read (issue #1771).
bigintStorageClassSqliteMigration,
// Same reason: an older build left `''` in nullable `json` columns, which
// the json row mapper cannot parse, so one legacy `connection.credential_write`
// failed every toolkit MCP session (issue #2092).
emptyJsonSqliteMigration,
// Rewrite pre-canonical integration auth configs into the shared
// placements model.
sqliteDataMigration("2026-06-05-auth-config-placements", (client) =>
Expand Down
89 changes: 89 additions & 0 deletions apps/host-selfhost/src/db/legacy-empty-json-boot.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// ---------------------------------------------------------------------------
// Boot-level proof for issue #2092: a self-host database holding `''` in a
// nullable `json` column of `connection` cannot list connections, and the
// self-host boot sequence heals it.
//
// The migration body is unit-tested in the SDK. What this pins is the WIRING:
// that `selfHostDataMigrations` carries the entry, so the `connections.list`
// every toolkit MCP session runs on `initialize` sees repaired rows.
// ---------------------------------------------------------------------------

import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Effect } from "effect";
import { withQueryContext } from "@executor-js/fumadb/query";

import { runSqliteDataMigrations } from "@executor-js/sdk";

import { selfHostDataMigrations } from "./data-migrations";
import { createSelfHostDb } from "./self-host-db";

const TENANT = "executor-workspace-2092";
const SUBJECT = "user_a";

let workDir: string;

beforeEach(() => {
workDir = mkdtempSync(join(tmpdir(), "executor-legacy-empty-json-"));
});

afterEach(() => {
rmSync(workDir, { recursive: true, force: true });
});

/** Write the row an older build left behind: `''` rather than NULL in the
* nullable `json` columns of `connection`. */
const seedLegacyConnection = async (dbPath: string): Promise<void> => {
const sqlite = await createSelfHostDb({ path: dbPath });
await sqlite.client.execute({
sql: `INSERT INTO connection
(row_id, tenant, owner, subject, integration, name, template, provider, item_ids,
credential_write, last_health, provider_state, created_at, updated_at)
VALUES ('c1', ?, 'user', ?, 'acme', 'default', 'oauth2', 'file', ?, '', '', '', ?, ?)`,
args: [
TENANT,
SUBJECT,
JSON.stringify({ token: "item_1" }),
Math.floor(Date.now() / 1000),
Math.floor(Date.now() / 1000),
],
});
await sqlite.close();
};

describe("self-host boot over legacy empty json columns", () => {
it("cannot read the connection table before the migrations run", async () => {
const dbPath = join(workDir, "data.db");
await seedLegacyConnection(dbPath);

const sqlite = await createSelfHostDb({ path: dbPath });
const scoped = withQueryContext(sqlite.db, { tenant: TENANT, subject: SUBJECT });
await expect(scoped.findMany("connection", {})).rejects.toThrow(/JSON/);
await sqlite.close();
});

it("heals it through the self-host data-migration registry", async () => {
const dbPath = join(workDir, "data.db");
await seedLegacyConnection(dbPath);

const sqlite = await createSelfHostDb({ path: dbPath });
const applied = await Effect.runPromise(
runSqliteDataMigrations(sqlite.client, selfHostDataMigrations),
);
expect(applied).toContain("2026-09-24-empty-json-columns");

const scoped = withQueryContext(sqlite.db, { tenant: TENANT, subject: SUBJECT });
const rows = await scoped.findMany("connection", {});
expect(
rows.map((row) => [
row.name,
row.credential_write ?? null,
row.last_health ?? null,
row.provider_state ?? null,
]),
).toEqual([["default", null, null, null]]);
await sqlite.close();
});
});
5 changes: 5 additions & 0 deletions apps/local/src/db/data-migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import {
Effect,
bigintStorageClassSqliteMigration,
emptyJsonSqliteMigration,
oauthClientGcSqliteMigration,
sqliteDataMigration,
type SqliteDataMigration,
Expand Down Expand Up @@ -36,6 +37,10 @@ export const localDataMigrations: readonly SqliteDataMigration[] = [
// class cannot be read by the bigint row mapper, so a single legacy
// `connection.expires_at` failed every catalog read (issue #1771).
bigintStorageClassSqliteMigration,
// Same reason: an older build left `''` in nullable `json` columns, which
// the json row mapper cannot parse, so one legacy `connection.credential_write`
// failed every toolkit MCP session (issue #2092).
emptyJsonSqliteMigration,
// Rewrite pre-canonical integration auth configs (incl. v1→v2 outputs)
// into the shared placements model.
sqliteDataMigration("2026-06-05-auth-config-placements", (client) =>
Expand Down
8 changes: 8 additions & 0 deletions packages/core/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,14 @@ export {
LEGACY_BIGINT_STORAGE_CLASS_COLUMNS,
type BigintStorageClassColumn,
} from "./sqlite-bigint-storage-class-migration";
// Rewrite `''` left in nullable `json` columns by pre-FumaDB builds, which the
// json row mapper cannot parse (issue #2092).
export {
emptyJsonSqliteMigration,
runSqliteEmptyJsonMigration,
NULLABLE_JSON_COLUMNS,
type EmptyJsonColumn,
} from "./sqlite-empty-json-migration";
export {
authToolFailure,
isUnauthorizedToolFailure,
Expand Down
163 changes: 163 additions & 0 deletions packages/core/sdk/src/sqlite-empty-json-migration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
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 {
NULLABLE_JSON_COLUMNS,
emptyJsonSqliteMigration,
runSqliteEmptyJsonMigration,
} from "./sqlite-empty-json-migration";

// A `json` column is stored on SQLite as TEXT and read back with `JSON.parse`.
// Databases carried forward from before the FumaDB cutover can hold `''` in
// nullable `json` columns, and `JSON.parse('')` throws on the ROW mapper, so
// one such row takes down the whole `findMany`.
//
// That is issue #2092: `connection.credential_write` held `''`, so
// `connections.list` threw and every toolkit MCP endpoint failed `initialize`.

const TENANT = "t1";
const SUBJECT = "user_a";
const CREDENTIAL_WRITE = { runtimeId: "runtime_1", attemptId: "attempt_1" };

const withDb = <A>(body: (db: SqliteTestFumaDb) => Promise<A>): Promise<A> =>
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 with `credential_write` and `last_health` bound as
* raw values, so the test controls exactly what the legacy build left behind. */
const insertConnection = (
db: SqliteTestFumaDb,
row: {
readonly rowId: string;
readonly name: string;
readonly credentialWrite: string | null;
readonly lastHealth?: string | null;
},
): Promise<unknown> =>
db.client.execute({
sql: `INSERT INTO connection
(row_id, tenant, owner, subject, integration, name, template, provider, item_ids,
credential_write, last_health, created_at, updated_at)
VALUES (?, ?, 'user', ?, 'acme', ?, 'oauth2', 'file', ?, ?, ?, ?, ?)`,
args: [
row.rowId,
TENANT,
SUBJECT,
row.name,
JSON.stringify({ token: "item_1" }),
row.credentialWrite,
row.lastHealth ?? null,
seconds(Date.now()),
seconds(Date.now()),
],
});

describe("legacy empty json column migration", () => {
it.effect("reproduces the connection read failure on a legacy empty string", () =>
Effect.promise(() =>
withDb(async (db) => {
await insertConnection(db, { rowId: "c_legacy", name: "legacy", credentialWrite: "" });

const scoped = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT });
await expect(scoped.findMany("connection", {})).rejects.toThrow(/JSON/);
}),
),
);

it.effect("rewrites empty strings to NULL so connections read again", () =>
Effect.promise(() =>
withDb(async (db) => {
await insertConnection(db, {
rowId: "c_legacy",
name: "legacy",
credentialWrite: "",
lastHealth: "",
});
await insertConnection(db, {
rowId: "c_healthy",
name: "healthy",
credentialWrite: JSON.stringify(CREDENTIAL_WRITE),
});
await insertConnection(db, { rowId: "c_null", name: "null", credentialWrite: null });

const rewritten = await Effect.runPromise(runSqliteEmptyJsonMigration(db.client));
expect(rewritten).toBe(2);

const scoped = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT });
const rows = await scoped.findMany("connection", {});
expect(
rows.map((row) => [row.name, row.credential_write ?? null, row.last_health ?? null]),
).toEqual([
["healthy", CREDENTIAL_WRITE, null],
["legacy", null, null],
["null", null, null],
]);
}),
),
);

it.effect("is idempotent", () =>
Effect.promise(() =>
withDb(async (db) => {
await insertConnection(db, { rowId: "c_legacy", name: "legacy", credentialWrite: "" });

expect(await Effect.runPromise(runSqliteEmptyJsonMigration(db.client))).toBe(1);
expect(await Effect.runPromise(runSqliteEmptyJsonMigration(db.client))).toBe(0);

const scoped = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT });
const rows = await scoped.findMany("connection", {});
expect(rows.map((row) => row.credential_write ?? null)).toEqual([null]);
}),
),
);

it.effect("leaves NOT NULL json columns alone", () =>
Effect.promise(() =>
withDb(async (db) => {
await insertConnection(db, { rowId: "c1", name: "c1", credentialWrite: null });
await db.client.execute(`UPDATE connection SET item_ids = '' WHERE row_id = 'c1'`);

expect(await Effect.runPromise(runSqliteEmptyJsonMigration(db.client))).toBe(0);

const result = await db.client.execute(
"SELECT item_ids FROM connection WHERE row_id = 'c1'",
);
expect(result.rows[0]?.["item_ids"]).toBe("");
}),
),
);

it("covers every nullable json column the core schema declares", () => {
const tables = collectTables() as Record<string, { readonly columns: Record<string, unknown> }>;
const declared: string[] = [];
for (const [tableName, table] of Object.entries(tables)) {
for (const [columnName, column] of Object.entries(table.columns)) {
const { type, isNullable } = column as {
readonly type?: string;
readonly isNullable?: boolean;
};
if (type === "json" && isNullable) declared.push(`${tableName}.${columnName}`);
}
}
const covered = NULLABLE_JSON_COLUMNS.map((entry) => `${entry.table}.${entry.column}`);
expect(covered).toContain("connection.credential_write");
expect(covered).toContain("connection.last_health");
expect(covered).toContain("connection.provider_state");
expect(covered).not.toContain("connection.item_ids");
expect(covered.slice().sort()).toEqual(declared.sort());
});

it("is registered under a stable, date-prefixed name", () => {
expect(emptyJsonSqliteMigration.name).toBe("2026-09-24-empty-json-columns");
});
});
Loading
Loading