History

Add a stamped boot-time data-migration ledger for the libSQL apps

Before this, every boot-time data fix (auth-config placements, openapi envelope unwrap) re-scanned its tables on every startup to decide whether it had already run, inferring state from data shape. That accumulates — N migrations means N full-table scans per boot forever — and makes idempotence a per-migration proof obligation. Now the sdk owns a data_migration ledger (name -> completion time) and an Effect-native runner: apps compose an ordered registry of named migrations; pending ones run once and are stamped, stamped ones are skipped without touching data. Failures are typed (DataMigrationError / DuplicateDataMigrationError) and fail the boot without stamping. apps/local and apps/host-selfhost convert their two existing boot scans into the first registry entries. The openapi unwrap migration is rewritten Effect-native with the same envelope detection. Registry names are append-only and never renamed. Verified end-to-end against a real libSQL database: first boot applies both migrations and unwraps a seeded envelope row (payload rows untouched), second boot applies nothing, ledger holds both stamps. Also fixes a cloud test still asserting the retired envelope shape (missed in the payload-first change; the turbo test pipeline did not propagate that failure).

Rhys Sullivan committed 5 months ago

Commit bea88351 parent fed315110 files · +476 −130Browse files

Unified · Split

10 files
  1. apps/host-selfhost/src/app.ts +7 −13
  2. apps/host-selfhost/src/db/data-migrations.ts +23 −0
  3. apps/local/src/db/data-migrations.ts +23 −0
  4. apps/local/src/executor.ts +17 −22
  5. packages/core/sdk/src/index.ts +10 −0
  6. packages/core/sdk/src/sqlite-data-migrations.test.ts +130 −0
  7. packages/core/sdk/src/sqlite-data-migrations.ts +126 −0
  8. packages/plugins/openapi/src/sdk/index.ts +1 −1
  9. packages/plugins/openapi/src/sdk/output-schema-migration.test.ts +71 −34
  10. packages/plugins/openapi/src/sdk/output-schema-migration.ts +68 −60
apps/host-selfhost/src/app.ts+7−13
@@ -1,14 +1,13 @@
11 import { HttpApiSwagger } from "effect/unstable/httpapi";
22 import { HttpEffect, HttpRouter } from "effect/unstable/http";
3−import { Layer } from "effect";
3+import { Effect, Layer } from "effect";
44
55 import { composePluginApi, ExecutorApp, textFailureStrategy } from "@executor-js/api/server";
66
7−import { runSqliteAuthConfigMigration } from "@executor-js/sdk/http-auth";
8−import { runSqliteOpenApiOutputSchemaMigration } from "@executor-js/plugin-openapi";
7+import { runSqliteDataMigrations } from "@executor-js/sdk";
98
109 import { resolveAuthProviders } from "./auth";
11−import { authConfigTransforms } from "./db/auth-config-migration";
10+import { selfHostDataMigrations } from "./db/data-migrations";
1211 import { makeSelfHostAdminApiLayer } from "./admin/handlers";
1312 import { makeSelfHostSystemApiLayer } from "./system/handlers";
1413 import { selfHostAccountMiddleware } from "./account";
@@ -58,15 +57,10 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 43 unmodified lines
5857 version: SELF_HOST_SCHEMA_VERSION,
5958 });
6059
61− // One-off data migration: rewrite pre-canonical integration auth configs
62− // into the shared placements model. Idempotent — a no-op once every row is
63− // canonical (same defensive-at-boot pattern as the column adds above).
64− await runSqliteAuthConfigMigration(dbHandle.client, authConfigTransforms);
65−
66− // One-off data migration: unwrap the retired {status, headers, data}
67− // transport envelope from persisted openapi tool output schemas (mirrors
68− // cloud's drizzle 0002). Idempotent — payload-shaped rows don't match.
69− await runSqliteOpenApiOutputSchemaMigration(dbHandle.client);
60+ // Boot-time data migrations: each registry entry runs once and is stamped
61+ // in the `data_migration` ledger; stamped entries are skipped without
62+ // touching the data.
63+ await Effect.runPromise(runSqliteDataMigrations(dbHandle.client, selfHostDataMigrations));
7064
7165 // ---- auth providers ---------------------------------------------------
7266 // Better Auth: cookie/bearer/api-key identity + /api/auth handler + account
apps/host-selfhost/src/db/data-migrations.ts+23−0
@@ -0,0 +1,23 @@
1+// ---------------------------------------------------------------------------
2+// The ordered boot-time data-migration registry for the selfhost app.
3+// Entries run once and are stamped in the `data_migration` ledger (see
4+// @executor-js/sdk sqlite-data-migrations). Names are append-only and never
5+// renamed.
6+// ---------------------------------------------------------------------------
7+
8+import { sqliteDataMigration, type SqliteDataMigration } from "@executor-js/sdk";
9+import { runSqliteAuthConfigMigration } from "@executor-js/sdk/http-auth";
10+import { openApiOutputSchemaDataMigration } from "@executor-js/plugin-openapi";
11+
12+import { authConfigTransforms } from "./auth-config-migration";
13+
14+export const selfHostDataMigrations: readonly SqliteDataMigration[] = [
15+ // Rewrite pre-canonical integration auth configs into the shared
16+ // placements model.
17+ sqliteDataMigration("2026-06-05-auth-config-placements", (client) =>
18+ runSqliteAuthConfigMigration(client, authConfigTransforms),
19+ ),
20+ // Unwrap the retired {status, headers, data} transport envelope from
21+ // persisted openapi tool output schemas (mirrors cloud's drizzle 0002).
22+ openApiOutputSchemaDataMigration,
23+];
apps/local/src/db/data-migrations.ts+23−0
@@ -0,0 +1,23 @@
1+// ---------------------------------------------------------------------------
2+// The ordered boot-time data-migration registry for the local app. Entries
3+// run once and are stamped in the `data_migration` ledger (see
4+// @executor-js/sdk sqlite-data-migrations). Names are append-only and never
5+// renamed.
6+// ---------------------------------------------------------------------------
7+
8+import { sqliteDataMigration, type SqliteDataMigration } from "@executor-js/sdk";
9+import { runSqliteAuthConfigMigration } from "@executor-js/sdk/http-auth";
10+import { openApiOutputSchemaDataMigration } from "@executor-js/plugin-openapi";
11+
12+import { authConfigTransforms } from "./auth-config-migration";
13+
14+export const localDataMigrations: readonly SqliteDataMigration[] = [
15+ // Rewrite pre-canonical integration auth configs (incl. v1→v2 outputs)
16+ // into the shared placements model.
17+ sqliteDataMigration("2026-06-05-auth-config-placements", (client) =>
18+ runSqliteAuthConfigMigration(client, authConfigTransforms),
19+ ),
20+ // Unwrap the retired {status, headers, data} transport envelope from
21+ // persisted openapi tool output schemas (mirrors cloud's drizzle 0002).
22+ openApiOutputSchemaDataMigration,
23+];
apps/local/src/executor.ts+17−22
@@ -4,14 +4,19 @@ import { homedir } from "node:os"; · 3 unmodified lines
44 import { basename, join } from "node:path";
55 import { createHash } from "node:crypto";
66
7−import { Subject, Tenant, createExecutor, type AnyPlugin, type Executor } from "@executor-js/sdk";
8−import { runSqliteAuthConfigMigration } from "@executor-js/sdk/http-auth";
9−import { runSqliteOpenApiOutputSchemaMigration } from "@executor-js/plugin-openapi";
7+import {
8+ Subject,
9+ Tenant,
10+ createExecutor,
11+ runSqliteDataMigrations,
12+ type AnyPlugin,
13+ type Executor,
14+} from "@executor-js/sdk";
1015 import { collectTables } from "@executor-js/api/server";
1116 import { loadPluginsFromJsonc } from "@executor-js/config";
1217
1318 import executorConfig from "../executor.config";
14−import { authConfigTransforms } from "./db/auth-config-migration";
19+import { localDataMigrations } from "./db/data-migrations";
1520 import { createSqliteFumaDb } from "./db/sqlite-fumadb";
1621 import { migrateLocalV1ToV2IfNeeded } from "./db/v1-v2-migration";
1722
@@ -170,24 +175,14 @@ const createLocalExecutorLayer = () => { · 152 unmodified lines
170175 (db) => Effect.promise(() => db.close()).pipe(Effect.ignore),
171176 );
172177
173− // One-off data migration: rewrite pre-canonical integration auth
174− // configs (incl. v1→v2 outputs) into the shared placements model.
175− // Idempotent — a no-op once every row is canonical.
176− yield* Effect.tryPromise({
177− try: () => runSqliteAuthConfigMigration(sqlite.client, authConfigTransforms),
178− catch: (cause) =>
179− new LocalExecutorCreateError({ message: CREATE_SQLITE_ERROR_MESSAGE, cause }),
180− });
181−
182− // One-off data migration: unwrap the retired {status, headers, data}
183− // transport envelope from persisted openapi tool output schemas
184− // (mirrors cloud's drizzle 0002). Idempotent — payload-shaped rows
185− // don't match the envelope signature.
186− yield* Effect.tryPromise({
187− try: () => runSqliteOpenApiOutputSchemaMigration(sqlite.client),
188− catch: (cause) =>
189− new LocalExecutorCreateError({ message: CREATE_SQLITE_ERROR_MESSAGE, cause }),
190− });
178+ // Boot-time data migrations: each registry entry runs once and is
179+ // stamped in the `data_migration` ledger; stamped entries are skipped
180+ // without touching the data.
181+ yield* runSqliteDataMigrations(sqlite.client, localDataMigrations).pipe(
182+ Effect.mapError(
183+ (cause) => new LocalExecutorCreateError({ message: CREATE_SQLITE_ERROR_MESSAGE, cause }),
184+ ),
185+ );
191186
192187 // webBaseUrl is where the executor's web UI listens — same port as the
193188 // daemon API since the daemon serves both. Mirrors serve.ts's port
packages/core/sdk/src/index.ts+10−0
@@ -324,6 +324,16 @@ export { InternalError } from "./api-errors"; · 323 unmodified lines
324324
325325 // ToolResult — typed value-based discriminated union for tool outcomes.
326326 export { ToolResult, isToolResult, type ToolError, type ToolHttpMeta } from "./tool-result";
327+
328+// Stamped boot-time data-migration ledger for the libSQL-backed apps.
329+export {
330+ DataMigrationError,
331+ DuplicateDataMigrationError,
332+ runSqliteDataMigrations,
333+ sqliteDataMigration,
334+ type SqliteDataMigration,
335+ type SqliteDataMigrationClient,
336+} from "./sqlite-data-migrations";
327337 export {
328338 authToolFailure,
329339 type AuthToolFailureCode,
packages/core/sdk/src/sqlite-data-migrations.test.ts+130−0
@@ -0,0 +1,130 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Effect, Predicate } from "effect";
3+
4+import {
5+ DataMigrationError,
6+ runSqliteDataMigrations,
7+ sqliteDataMigration,
8+ type SqliteDataMigration,
9+ type SqliteDataMigrationClient,
10+} from "./sqlite-data-migrations";
11+
12+// A tiny scripted fake standing in for a libSQL client: tracks statements
13+// and simulates the ledger table.
14+const makeFakeClient = (stampedNames: string[]) => {
15+ const log: unknown[] = [];
16+ const stamps = [...stampedNames];
17+ const client: SqliteDataMigrationClient = {
18+ execute: (stmt) => {
19+ log.push(stmt);
20+ if (typeof stmt === "string" && stmt.startsWith("SELECT name FROM data_migration")) {
21+ return Promise.resolve({ rows: stamps.map((name) => ({ name })) });
22+ }
23+ if (typeof stmt === "object" && stmt.sql.startsWith("INSERT INTO data_migration")) {
24+ stamps.push(String(stmt.args[0]));
25+ }
26+ return Promise.resolve({ rows: [] });
27+ },
28+ };
29+ return { client, log, stamps };
30+};
31+
32+const migrationSpy = (name: string) => {
33+ const calls: number[] = [];
34+ const migration: SqliteDataMigration = {
35+ name,
36+ run: () => Effect.sync(() => void calls.push(calls.length)),
37+ };
38+ return { migration, calls };
39+};
40+
41+describe("runSqliteDataMigrations", () => {
42+ it.effect("runs pending migrations in order and stamps them", () =>
43+ Effect.gen(function* () {
44+ const { client, log, stamps } = makeFakeClient([]);
45+ const a = migrationSpy("2026-06-05-a");
46+ const b = migrationSpy("2026-06-11-b");
47+ const applied = yield* runSqliteDataMigrations(client, [a.migration, b.migration]);
48+ expect(applied).toEqual(["2026-06-05-a", "2026-06-11-b"]);
49+ expect(a.calls.length).toBe(1);
50+ expect(b.calls.length).toBe(1);
51+ expect(stamps).toEqual(["2026-06-05-a", "2026-06-11-b"]);
52+ expect(String(log[0])).toContain("CREATE TABLE IF NOT EXISTS data_migration");
53+ }),
54+ );
55+
56+ it.effect("skips stamped migrations without running them", () =>
57+ Effect.gen(function* () {
58+ const { client } = makeFakeClient(["2026-06-05-a"]);
59+ const a = migrationSpy("2026-06-05-a");
60+ const b = migrationSpy("2026-06-11-b");
61+ const applied = yield* runSqliteDataMigrations(client, [a.migration, b.migration]);
62+ expect(applied).toEqual(["2026-06-11-b"]);
63+ expect(a.calls.length).toBe(0);
64+ expect(b.calls.length).toBe(1);
65+ }),
66+ );
67+
68+ it.effect("is a no-op once everything is stamped", () =>
69+ Effect.gen(function* () {
70+ const { client } = makeFakeClient([]);
71+ const a = migrationSpy("2026-06-05-a");
72+ yield* runSqliteDataMigrations(client, [a.migration]);
73+ const applied = yield* runSqliteDataMigrations(client, [a.migration]);
74+ expect(applied).toEqual([]);
75+ expect(a.calls.length).toBe(1);
76+ }),
77+ );
78+
79+ it.effect("a failing migration leaves no stamp and surfaces the failure", () =>
80+ Effect.gen(function* () {
81+ const { client, stamps } = makeFakeClient([]);
82+ const boom: SqliteDataMigration = {
83+ name: "2026-06-11-boom",
84+ run: () =>
85+ Effect.fail(new DataMigrationError({ migration: "2026-06-11-boom", cause: "nope" })),
86+ };
87+ const after = migrationSpy("2026-06-12-after");
88+ const failure = yield* runSqliteDataMigrations(client, [boom, after.migration]).pipe(
89+ Effect.flip,
90+ );
91+ expect(Predicate.isTagged(failure, "DataMigrationError")).toBe(true);
92+ expect(stamps).toEqual([]);
93+ expect(after.calls.length).toBe(0);
94+ }),
95+ );
96+
97+ it.effect("rejects duplicate names before touching the database", () =>
98+ Effect.gen(function* () {
99+ const { client, log } = makeFakeClient([]);
100+ const a1 = migrationSpy("2026-06-05-a");
101+ const a2 = migrationSpy("2026-06-05-a");
102+ const failure = yield* runSqliteDataMigrations(client, [a1.migration, a2.migration]).pipe(
103+ Effect.flip,
104+ );
105+ expect(Predicate.isTagged(failure, "DuplicateDataMigrationError")).toBe(true);
106+ expect(log).toEqual([]);
107+ }),
108+ );
109+
110+ it.effect("sqliteDataMigration adapts promise-shaped bodies with typed failures", () =>
111+ Effect.gen(function* () {
112+ const { client, stamps } = makeFakeClient([]);
113+ let ran = 0;
114+ const ok = sqliteDataMigration("2026-06-05-promise", () => {
115+ ran++;
116+ return Promise.resolve(42);
117+ });
118+ yield* runSqliteDataMigrations(client, [ok]);
119+ expect(ran).toBe(1);
120+ expect(stamps).toEqual(["2026-06-05-promise"]);
121+
122+ // oxlint-disable-next-line executor/no-promise-reject -- simulates a raw driver rejection at the adapter boundary under test
123+ const bad = sqliteDataMigration("2026-06-12-bad", () => Promise.reject("disk full"));
124+ const failure = yield* runSqliteDataMigrations(client, [ok, bad]).pipe(Effect.flip);
125+ expect(Predicate.isTagged(failure, "DataMigrationError")).toBe(true);
126+ expect((failure as DataMigrationError).migration).toBe("2026-06-12-bad");
127+ expect(ran).toBe(1);
128+ }),
129+ );
130+});
packages/core/sdk/src/sqlite-data-migrations.ts+126−0
@@ -0,0 +1,126 @@
1+// ---------------------------------------------------------------------------
2+// Stamped data-migration ledger for the libSQL-backed apps (local boot,
3+// selfhost boot). Cloud runs schema + data migrations through its drizzle
4+// chain out-of-band; the local apps have no operator, so their migrations
5+// run at boot — and before this ledger existed, each one re-scanned its
6+// tables on every startup to decide "did I already run?" by data shape.
7+// That accumulates (N migrations = N full-table scans per boot, forever)
8+// and makes idempotence a per-migration proof obligation.
9+//
10+// This is the rail instead: a `data_migration` table (name → completion
11+// time), an ordered registry the app composes, and a runner that executes
12+// each pending migration once and stamps it. Stamped names are skipped
13+// without touching the data.
14+//
15+// Write migrations idempotently anyway (defense in depth — the support
16+// remedy for a half-applied state is deleting the stamp row and
17+// rebooting). One deliberate semantics change from the scan-every-boot
18+// era: after a migration is stamped, rows written later by an OLDER binary
19+// (downgrade, then re-upgrade) are NOT re-healed. That matches the cloud
20+// chain's semantics; the stamp row, not the data shape, is the source of
21+// truth.
22+// ---------------------------------------------------------------------------
23+
24+import { Data, Effect } from "effect";
25+
26+/** Structural client interface so this module stays dependency-free;
27+ * `@libsql/client` satisfies it. */
28+export interface SqliteDataMigrationClient {
29+ execute(
30+ stmt: string | { readonly sql: string; readonly args: readonly unknown[] },
31+ ): Promise<{ readonly rows: readonly Record<string, unknown>[] }>;
32+}
33+
34+export class DataMigrationError extends Data.TaggedError("DataMigrationError")<{
35+ /** The migration that failed, or null when the ledger itself did. */
36+ readonly migration: string | null;
37+ readonly cause: unknown;
38+}> {}
39+
40+export class DuplicateDataMigrationError extends Data.TaggedError("DuplicateDataMigrationError")<{
41+ readonly name: string;
42+}> {}
43+
44+export interface SqliteDataMigration {
45+ /** Stable unique id, date-prefixed so the registry reads in order
46+ * (e.g. "2026-06-05-auth-config-placements"). Renaming an applied
47+ * migration re-runs it — never rename. */
48+ readonly name: string;
49+ readonly run: (client: SqliteDataMigrationClient) => Effect.Effect<void, DataMigrationError>;
50+}
51+
52+const LEDGER_TABLE = "data_migration";
53+
54+const execute = (
55+ client: SqliteDataMigrationClient,
56+ stmt: string | { readonly sql: string; readonly args: readonly unknown[] },
57+ migration: string | null,
58+) =>
59+ Effect.tryPromise({
60+ try: () => client.execute(stmt),
61+ catch: (cause) => new DataMigrationError({ migration, cause }),
62+ });
63+
64+/** Wrap a promise-shaped migration body as a registry entry. */
65+export const sqliteDataMigration = (
66+ name: string,
67+ run: (client: SqliteDataMigrationClient) => Promise<unknown>,
68+): SqliteDataMigration => ({
69+ name,
70+ run: (client) =>
71+ Effect.tryPromise({
72+ try: () => run(client),
73+ catch: (cause) => new DataMigrationError({ migration: name, cause }),
74+ }).pipe(Effect.asVoid),
75+});
76+
77+/**
78+ * Run every registry entry whose name has no stamp row, in registry order,
79+ * stamping each on success. Returns the applied names.
80+ *
81+ * Atomicity is the migration's own job (the existing migrations run their
82+ * rewrites inside BEGIN…COMMIT), so the runner does not wrap them — SQLite
83+ * has no nested transactions. The stamp is written after the migration
84+ * succeeds; a crash between the two re-runs the (idempotent) migration on
85+ * the next boot, which is a no-op. A failed migration leaves no stamp and
86+ * fails the boot.
87+ */
88+export const runSqliteDataMigrations = (
89+ client: SqliteDataMigrationClient,
90+ migrations: readonly SqliteDataMigration[],
91+): Effect.Effect<readonly string[], DataMigrationError | DuplicateDataMigrationError> =>
92+ Effect.gen(function* () {
93+ const names = new Set<string>();
94+ for (const migration of migrations) {
95+ if (names.has(migration.name)) {
96+ return yield* new DuplicateDataMigrationError({ name: migration.name });
97+ }
98+ names.add(migration.name);
99+ }
100+
101+ yield* execute(
102+ client,
103+ `CREATE TABLE IF NOT EXISTS ${LEDGER_TABLE} (name TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)`,
104+ null,
105+ );
106+ const stamped = yield* execute(client, `SELECT name FROM ${LEDGER_TABLE}`, null);
107+ const completed = new Set(
108+ stamped.rows.map((row) => row.name).filter((name) => typeof name === "string"),
109+ );
110+
111+ const applied: string[] = [];
112+ for (const migration of migrations) {
113+ if (completed.has(migration.name)) continue;
114+ yield* migration.run(client);
115+ yield* execute(
116+ client,
117+ {
118+ sql: `INSERT INTO ${LEDGER_TABLE} (name, time_completed) VALUES (?, ?)`,
119+ args: [migration.name, Date.now()],
120+ },
121+ migration.name,
122+ );
123+ applied.push(migration.name);
124+ }
125+ return applied;
126+ });
packages/plugins/openapi/src/sdk/index.ts+1−1
@@ -79,7 +79,7 @@ export { variable, type ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth"; · 78 unmodified lines
7979 export { migrateOpenApiAuthConfig } from "./migrate-config";
8080
8181 export {
82+ openApiOutputSchemaDataMigration,
8283 runSqliteOpenApiOutputSchemaMigration,
8384 unwrapOpenApiTransportEnvelope,
84− type SqliteToolSchemaClient,
8585 } from "./output-schema-migration";
packages/plugins/openapi/src/sdk/output-schema-migration.test.ts+71−34
@@ -1,9 +1,10 @@
11 import { describe, expect, it } from "@effect/vitest";
2+import { Effect, Predicate } from "effect";
3+import type { SqliteDataMigrationClient } from "@executor-js/sdk/core";
24
35 import {
46 runSqliteOpenApiOutputSchemaMigration,
57 unwrapOpenApiTransportEnvelope,
6− type SqliteToolSchemaClient,
78 } from "./output-schema-migration";
89
910 // The exact shape openApiTransportOutputSchema used to emit.
@@ -50,7 +51,7 @@ describe("unwrapOpenApiTransportEnvelope", () => { · 40 unmodified lines
5051 // A tiny scripted fake standing in for a libSQL client.
5152 const makeFakeClient = (rows: Record<string, unknown>[], options?: { noTable?: boolean }) => {
5253 const log: unknown[] = [];
53− const client: SqliteToolSchemaClient = {
54+ const client: SqliteDataMigrationClient = {
5455 execute: (stmt) => {
5556 log.push(stmt);
5657 if (typeof stmt === "string" && stmt.includes("sqlite_master")) {
@@ -66,38 +67,74 @@ const makeFakeClient = (rows: Record<string, unknown>[], options?: { noTable?: b · 9 unmodified lines
6667 };
6768
6869 describe("runSqliteOpenApiOutputSchemaMigration", () => {
69− it("rewrites envelope rows in a transaction and reports the count", async () => {
70− const payload = { type: "array", items: { type: "object" } };
71− const { client, log } = makeFakeClient([
72− { row_id: "a", output_schema: JSON.stringify(envelope(payload)) },
73− { row_id: "b", output_schema: JSON.stringify(envelope({})) },
74− { row_id: "c", output_schema: JSON.stringify({ $ref: "#/$defs/already_payload" }) },
75− { row_id: "d", output_schema: "not json" },
76− ]);
77− const count = await runSqliteOpenApiOutputSchemaMigration(client);
78− expect(count).toBe(2);
79− expect(log).toContainEqual("BEGIN");
80− expect(log).toContainEqual({
81− sql: "UPDATE tool SET output_schema = ? WHERE row_id = ?",
82− args: [JSON.stringify(payload), "a"],
83− });
84− expect(log).toContainEqual({
85− sql: "UPDATE tool SET output_schema = ? WHERE row_id = ?",
86− args: [null, "b"],
87− });
88− expect(log).toContainEqual("COMMIT");
89− });
70+ it.effect("rewrites envelope rows in a transaction and reports the count", () =>
71+ Effect.gen(function* () {
72+ const payload = { type: "array", items: { type: "object" } };
73+ const { client, log } = makeFakeClient([
74+ { row_id: "a", output_schema: JSON.stringify(envelope(payload)) },
75+ { row_id: "b", output_schema: JSON.stringify(envelope({})) },
76+ { row_id: "c", output_schema: JSON.stringify({ $ref: "#/$defs/already_payload" }) },
77+ { row_id: "d", output_schema: "not json" },
78+ ]);
79+ const count = yield* runSqliteOpenApiOutputSchemaMigration(client);
80+ expect(count).toBe(2);
81+ expect(log).toContainEqual("BEGIN");
82+ expect(log).toContainEqual({
83+ sql: "UPDATE tool SET output_schema = ? WHERE row_id = ?",
84+ args: [JSON.stringify(payload), "a"],
85+ });
86+ expect(log).toContainEqual({
87+ sql: "UPDATE tool SET output_schema = ? WHERE row_id = ?",
88+ args: [null, "b"],
89+ });
90+ expect(log).toContainEqual("COMMIT");
91+ }),
92+ );
9093
91− it("no-ops when every row is already payload-shaped", async () => {
92− const { client, log } = makeFakeClient([
93− { row_id: "a", output_schema: JSON.stringify({ $ref: "#/$defs/already_payload" }) },
94− ]);
95− expect(await runSqliteOpenApiOutputSchemaMigration(client)).toBe(0);
96− expect(log).not.toContainEqual("BEGIN");
97− });
94+ it.effect("no-ops when every row is already payload-shaped", () =>
95+ Effect.gen(function* () {
96+ const { client, log } = makeFakeClient([
97+ { row_id: "a", output_schema: JSON.stringify({ $ref: "#/$defs/already_payload" }) },
98+ ]);
99+ const count = yield* runSqliteOpenApiOutputSchemaMigration(client);
100+ expect(count).toBe(0);
101+ expect(log).not.toContainEqual("BEGIN");
102+ }),
103+ );
98104
99− it("treats a missing tool table as nothing to migrate", async () => {
100− const { client } = makeFakeClient([], { noTable: true });
101− expect(await runSqliteOpenApiOutputSchemaMigration(client)).toBe(0);
102− });
105+ it.effect("treats a missing tool table as nothing to migrate", () =>
106+ Effect.gen(function* () {
107+ const { client } = makeFakeClient([], { noTable: true });
108+ const count = yield* runSqliteOpenApiOutputSchemaMigration(client);
109+ expect(count).toBe(0);
110+ }),
111+ );
112+
113+ it.effect("rolls back and surfaces the failure when an update fails", () =>
114+ Effect.gen(function* () {
115+ const log: unknown[] = [];
116+ const client: SqliteDataMigrationClient = {
117+ execute: (stmt) => {
118+ log.push(stmt);
119+ if (typeof stmt === "string" && stmt.includes("sqlite_master")) {
120+ return Promise.resolve({ rows: [{ name: "tool" }] });
121+ }
122+ if (typeof stmt === "string" && stmt.startsWith("SELECT row_id")) {
123+ return Promise.resolve({
124+ rows: [{ row_id: "a", output_schema: JSON.stringify(envelope({})) }],
125+ });
126+ }
127+ if (typeof stmt === "object" && stmt.sql.startsWith("UPDATE tool")) {
128+ // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- simulates a raw driver failure at the adapter boundary under test
129+ return Promise.reject(new Error("disk full"));
130+ }
131+ return Promise.resolve({ rows: [] });
132+ },
133+ };
134+ const failure = yield* runSqliteOpenApiOutputSchemaMigration(client).pipe(Effect.flip);
135+ expect(Predicate.isTagged(failure, "DataMigrationError")).toBe(true);
136+ expect(log).toContainEqual("ROLLBACK");
137+ expect(log).not.toContainEqual("COMMIT");
138+ }),
139+ );
103140 });
packages/plugins/openapi/src/sdk/output-schema-migration.ts+68−60
@@ -1,18 +1,22 @@
1−/* oxlint-disable executor/no-try-catch-or-throw, executor/no-json-parse -- boundary: one-shot data migration drives a raw SQL client (JSON text columns, transaction + rollback) */
21 // ---------------------------------------------------------------------------
3−// One-off boot migration: unwrap the retired {status, headers, data}
4−// transport envelope from persisted OpenAPI tool output schemas. The runtime
5−// returns the upstream payload as `data` (status/headers live in the
6−// ToolResult `http` side channel), so persisted schemas must describe the
7−// payload only — otherwise describe previews show an envelope invocations no
2+// Data migration: unwrap the retired {status, headers, data} transport
3+// envelope from persisted OpenAPI tool output schemas. The runtime returns
4+// the upstream payload as `data` (status/headers live in the ToolResult
5+// `http` side channel), so persisted schemas must describe the payload
6+// only — otherwise describe previews show an envelope invocations no
87 // longer return. Mirrors the cloud drizzle migration
98 // (apps/cloud/drizzle/0002_unwrap_openapi_output_envelope.sql) for the
10−// libSQL-backed apps, which have no SQL migration chain at boot.
9+// libSQL-backed apps, where it runs once through the data-migration ledger.
1110 //
12−// Idempotent by construction: payload-shaped rows don't match the envelope
13−// signature, so re-running plans zero updates.
11+// Idempotent: payload-shaped rows don't match the envelope signature, so
12+// re-running plans zero updates.
1413 // ---------------------------------------------------------------------------
1514
15+import { Effect, Option, Schema } from "effect";
16+import { DataMigrationError, type SqliteDataMigrationClient } from "@executor-js/sdk/core";
17+
18+const MIGRATION_NAME = "2026-06-11-openapi-output-envelope-unwrap";
19+
1620 const isRecord = (value: unknown): value is Record<string, unknown> =>
1721 typeof value === "object" && value !== null && !Array.isArray(value);
1822
@@ -54,61 +58,65 @@ export const unwrapOpenApiTransportEnvelope = ( · 35 unmodified lines
5458 return { outputSchema };
5559 };
5660
57−// ---------------------------------------------------------------------------
58−// SQLite runner — shared by the libSQL-backed apps (local boot, selfhost
59−// boot). Structural client interface so the plugin doesn't take a driver
60−// dependency; `@libsql/client` satisfies it.
61−// ---------------------------------------------------------------------------
61+const decodeJsonOption = Schema.decodeUnknownOption(Schema.UnknownFromJsonString);
6262
63−export interface SqliteToolSchemaClient {
64− execute(
65− stmt: string | { readonly sql: string; readonly args: readonly unknown[] },
66− ): Promise<{ readonly rows: readonly Record<string, unknown>[] }>;
67−}
63+const execute = (
64+ client: SqliteDataMigrationClient,
65+ stmt: string | { readonly sql: string; readonly args: readonly unknown[] },
66+) =>
67+ Effect.tryPromise({
68+ try: () => client.execute(stmt),
69+ catch: (cause) => new DataMigrationError({ migration: MIGRATION_NAME, cause }),
70+ });
6871
6972 /** Unwrap envelope-shaped openapi tool output schemas in a SQLite database.
70− * Idempotent; returns the number of rows rewritten. The `tool` table may
71− * not exist yet on a fresh database — that counts as nothing to migrate. */
72−export const runSqliteOpenApiOutputSchemaMigration = async (
73− client: SqliteToolSchemaClient,
74−): Promise<number> => {
75− const exists = await client.execute(
76− "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'tool'",
77− );
78− if (exists.rows.length === 0) return 0;
73+ * Returns the number of rows rewritten. The `tool` table may not exist yet
74+ * on a fresh database — that counts as nothing to migrate. */
75+export const runSqliteOpenApiOutputSchemaMigration = (
76+ client: SqliteDataMigrationClient,
77+): Effect.Effect<number, DataMigrationError> =>
78+ Effect.gen(function* () {
79+ const exists = yield* execute(
80+ client,
81+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'tool'",
82+ );
83+ if (exists.rows.length === 0) return 0;
7984
80− const result = await client.execute(
81− "SELECT row_id, output_schema FROM tool WHERE plugin_id = 'openapi' AND output_schema IS NOT NULL",
82− );
83− const updates: { readonly rowId: string; readonly outputSchema: unknown | null }[] = [];
84− for (const row of result.rows) {
85− if (typeof row.row_id !== "string" || typeof row.output_schema !== "string") continue;
86− let schema: unknown;
87− try {
88− schema = JSON.parse(row.output_schema);
89− } catch {
90− continue;
85+ const result = yield* execute(
86+ client,
87+ "SELECT row_id, output_schema FROM tool WHERE plugin_id = 'openapi' AND output_schema IS NOT NULL",
88+ );
89+ const updates: { readonly rowId: string; readonly outputSchema: unknown | null }[] = [];
90+ for (const row of result.rows) {
91+ if (typeof row.row_id !== "string" || typeof row.output_schema !== "string") continue;
92+ const schema = decodeJsonOption(row.output_schema);
93+ if (Option.isNone(schema)) continue;
94+ const unwrapped = unwrapOpenApiTransportEnvelope(schema.value);
95+ if (unwrapped !== undefined) updates.push({ rowId: row.row_id, ...unwrapped });
9196 }
92− const unwrapped = unwrapOpenApiTransportEnvelope(schema);
93− if (unwrapped !== undefined) updates.push({ rowId: row.row_id, ...unwrapped });
94− }
95− if (updates.length === 0) return 0;
97+ if (updates.length === 0) return 0;
9698
97− await client.execute("BEGIN");
98− try {
99− for (const update of updates) {
100− await client.execute({
101− sql: "UPDATE tool SET output_schema = ? WHERE row_id = ?",
102− args: [
103− update.outputSchema === null ? null : JSON.stringify(update.outputSchema),
104− update.rowId,
105− ],
106− });
107− }
108− await client.execute("COMMIT");
109− } catch (cause) {
110− await client.execute("ROLLBACK");
111− throw cause;
112− }
113− return updates.length;
99+ const applyAll = Effect.gen(function* () {
100+ for (const update of updates) {
101+ yield* execute(client, {
102+ sql: "UPDATE tool SET output_schema = ? WHERE row_id = ?",
103+ args: [
104+ update.outputSchema === null ? null : JSON.stringify(update.outputSchema),
105+ update.rowId,
106+ ],
107+ });
108+ }
109+ yield* execute(client, "COMMIT");
110+ });
111+
112+ yield* execute(client, "BEGIN");
113+ yield* applyAll.pipe(Effect.tapError(() => execute(client, "ROLLBACK").pipe(Effect.ignore)));
114+ return updates.length;
115+ });
116+
117+/** Registry entry for the boot-time data-migration ledger. */
118+export const openApiOutputSchemaDataMigration = {
119+ name: MIGRATION_NAME,
120+ run: (client: SqliteDataMigrationClient) =>
121+ runSqliteOpenApiOutputSchemaMigration(client).pipe(Effect.asVoid),
114122 };