Skip to content

Commit 8a74ca0

Browse files
authored
Move inline specs to the blob table at boot on the libSQL hosts (#968)
Cloud's databases were rewritten by the out-of-band migrate-specs-to-blobs script, but the libSQL hosts (local, selfhost) have no operator — their integration.config rows still inline multi-MB OpenAPI spec text and GraphQL introspection snapshots from before the blob seam existed. This adds the ledger counterpart: a shared migration body in the SDK (runSqliteConfigBlobMigration) that moves a named inline config field into the blob table under the runtime's exact naming (o:<tenant>/<plugin> namespace, <prefix>/<sha256> key) and rewrites the config to the content-hash pointer, all inside one transaction. The openapi and graphql plugins bind their field names and export ledger entries, registered in both apps' data-migration registries. Idempotent (pointer rows plan zero work, blob writes are content-addressed upserts), tolerant of a fresh database with no integration table, and stamped once by the ledger. With every host now converging on the pointer shape, the legacy inline-read paths in the plugins become removable.
1 parent 6d0ad93 commit 8a74ca0

9 files changed

Lines changed: 411 additions & 2 deletions

File tree

apps/host-selfhost/src/db/data-migrations.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@
77

88
import { sqliteDataMigration, type SqliteDataMigration } from "@executor-js/sdk";
99
import { runSqliteAuthConfigMigration } from "@executor-js/sdk/http-auth";
10-
import { openApiOutputSchemaDataMigration } from "@executor-js/plugin-openapi";
10+
import {
11+
openApiOutputSchemaDataMigration,
12+
openApiSpecBlobDataMigration,
13+
} from "@executor-js/plugin-openapi";
14+
import { graphqlIntrospectionBlobDataMigration } from "@executor-js/plugin-graphql";
1115

1216
import { authConfigTransforms } from "./auth-config-migration";
1317

@@ -20,4 +24,9 @@ export const selfHostDataMigrations: readonly SqliteDataMigration[] = [
2024
// Unwrap the retired {status, headers, data} transport envelope from
2125
// persisted openapi tool output schemas (mirrors cloud's drizzle 0002).
2226
openApiOutputSchemaDataMigration,
27+
// Move inline spec / introspection text out of integration.config into the
28+
// blob table (config keeps the content hash). Mirrors cloud's
29+
// migrate-specs-to-blobs script.
30+
openApiSpecBlobDataMigration,
31+
graphqlIntrospectionBlobDataMigration,
2332
];

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@
77

88
import { sqliteDataMigration, type SqliteDataMigration } from "@executor-js/sdk";
99
import { runSqliteAuthConfigMigration } from "@executor-js/sdk/http-auth";
10-
import { openApiOutputSchemaDataMigration } from "@executor-js/plugin-openapi";
10+
import {
11+
openApiOutputSchemaDataMigration,
12+
openApiSpecBlobDataMigration,
13+
} from "@executor-js/plugin-openapi";
14+
import { graphqlIntrospectionBlobDataMigration } from "@executor-js/plugin-graphql";
1115

1216
import { authConfigTransforms } from "./auth-config-migration";
1317

@@ -20,4 +24,9 @@ export const localDataMigrations: readonly SqliteDataMigration[] = [
2024
// Unwrap the retired {status, headers, data} transport envelope from
2125
// persisted openapi tool output schemas (mirrors cloud's drizzle 0002).
2226
openApiOutputSchemaDataMigration,
27+
// Move inline spec / introspection text out of integration.config into the
28+
// blob table (config keeps the content hash). Mirrors cloud's
29+
// migrate-specs-to-blobs script.
30+
openApiSpecBlobDataMigration,
31+
graphqlIntrospectionBlobDataMigration,
2332
];

packages/core/sdk/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,12 @@ export {
334334
type SqliteDataMigration,
335335
type SqliteDataMigrationClient,
336336
} from "./sqlite-data-migrations";
337+
// Shared inline-config-field → blob-table migration body; the protocol
338+
// plugins bind their field names and export the ledger entries.
339+
export {
340+
runSqliteConfigBlobMigration,
341+
type SqliteConfigBlobMigrationOptions,
342+
} from "./sqlite-config-blob-migration";
337343
export {
338344
authToolFailure,
339345
type AuthToolFailureCode,
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import { describe, expect, it } from "@effect/vitest";
2+
import { Effect, Schema } from "effect";
3+
4+
import { collectTables } from "./executor";
5+
import { createSqliteTestFumaDb } from "./sqlite-test-db";
6+
import { runSqliteConfigBlobMigration } from "./sqlite-config-blob-migration";
7+
import { runSqliteDataMigrations, type SqliteDataMigrationClient } from "./sqlite-data-migrations";
8+
9+
const OPENAPI_OPTIONS = {
10+
migrationName: "test-spec-to-blob",
11+
pluginId: "openapi",
12+
inlineField: "spec",
13+
hashField: "specHash",
14+
blobKeyPrefix: "spec",
15+
} as const;
16+
17+
const decodeConfigJson = Schema.decodeUnknownSync(
18+
Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)),
19+
);
20+
21+
const insertIntegration = async (
22+
client: SqliteDataMigrationClient,
23+
row: { rowId: string; tenant: string; slug: string; pluginId: string; config: unknown },
24+
) => {
25+
await client.execute({
26+
sql: `INSERT INTO integration (row_id, tenant, slug, plugin_id, description, config, can_remove, can_refresh, created_at, updated_at)
27+
VALUES (?, ?, ?, ?, ?, ?, 1, 0, ?, ?)`,
28+
args: [
29+
row.rowId,
30+
row.tenant,
31+
row.slug,
32+
row.pluginId,
33+
row.slug,
34+
JSON.stringify(row.config),
35+
Date.now(),
36+
Date.now(),
37+
],
38+
});
39+
};
40+
41+
describe("runSqliteConfigBlobMigration", () => {
42+
it.effect("moves inline spec text to a blob row and rewrites the config pointer", () =>
43+
Effect.gen(function* () {
44+
const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() }));
45+
const spec = JSON.stringify({ openapi: "3.0.0", info: { title: "T", version: "1" } });
46+
yield* Effect.promise(() =>
47+
insertIntegration(db.client, {
48+
rowId: "r1",
49+
tenant: "t1",
50+
slug: "legacy_api",
51+
pluginId: "openapi",
52+
config: { spec, sourceUrl: "https://example.com/spec.json" },
53+
}),
54+
);
55+
56+
const moved = yield* runSqliteConfigBlobMigration(db.client, OPENAPI_OPTIONS);
57+
expect(moved).toBe(1);
58+
59+
const integration = yield* Effect.promise(() =>
60+
db.client.execute("SELECT config FROM integration WHERE row_id = 'r1'"),
61+
);
62+
const config = decodeConfigJson(String(integration.rows[0]!.config));
63+
expect(config.spec).toBeUndefined();
64+
expect(typeof config.specHash).toBe("string");
65+
// Untouched fields survive the rewrite.
66+
expect(config.sourceUrl).toBe("https://example.com/spec.json");
67+
68+
// The blob row uses the runtime's exact naming: namespace
69+
// `o:<tenant>/<pluginId>`, key `spec/<hash>`, id JSON.stringify pair.
70+
const blob = yield* Effect.promise(() =>
71+
db.client.execute({
72+
sql: "SELECT namespace, key, value FROM blob WHERE id = ?",
73+
args: [JSON.stringify([`o:t1/openapi`, `spec/${config.specHash}`])],
74+
}),
75+
);
76+
expect(blob.rows).toHaveLength(1);
77+
expect(blob.rows[0]!.value).toBe(spec);
78+
79+
yield* Effect.promise(() => db.close());
80+
}),
81+
);
82+
83+
it.effect("is idempotent and leaves pointer-shaped and foreign rows alone", () =>
84+
Effect.gen(function* () {
85+
const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() }));
86+
yield* Effect.promise(() =>
87+
insertIntegration(db.client, {
88+
rowId: "r1",
89+
tenant: "t1",
90+
slug: "already_migrated",
91+
pluginId: "openapi",
92+
config: { specHash: "abc123" },
93+
}),
94+
);
95+
yield* Effect.promise(() =>
96+
insertIntegration(db.client, {
97+
rowId: "r2",
98+
tenant: "t1",
99+
slug: "mcp_thing",
100+
pluginId: "mcp",
101+
config: { endpoint: "https://mcp.example.com" },
102+
}),
103+
);
104+
yield* Effect.promise(() =>
105+
insertIntegration(db.client, {
106+
rowId: "r3",
107+
tenant: "t1",
108+
slug: "legacy_api",
109+
pluginId: "openapi",
110+
config: { spec: "{}" },
111+
}),
112+
);
113+
114+
expect(yield* runSqliteConfigBlobMigration(db.client, OPENAPI_OPTIONS)).toBe(1);
115+
// Second run: everything is pointer-shaped now.
116+
expect(yield* runSqliteConfigBlobMigration(db.client, OPENAPI_OPTIONS)).toBe(0);
117+
118+
const mcpRow = yield* Effect.promise(() =>
119+
db.client.execute("SELECT config FROM integration WHERE row_id = 'r2'"),
120+
);
121+
expect(decodeConfigJson(String(mcpRow.rows[0]!.config))).toEqual({
122+
endpoint: "https://mcp.example.com",
123+
});
124+
125+
yield* Effect.promise(() => db.close());
126+
}),
127+
);
128+
129+
it.effect("identical specs across integrations share one blob per tenant", () =>
130+
Effect.gen(function* () {
131+
const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() }));
132+
const spec = JSON.stringify({ openapi: "3.0.0", info: { title: "Shared", version: "1" } });
133+
for (const [rowId, slug] of [
134+
["r1", "api_a"],
135+
["r2", "api_b"],
136+
] as const) {
137+
yield* Effect.promise(() =>
138+
insertIntegration(db.client, {
139+
rowId,
140+
tenant: "t1",
141+
slug,
142+
pluginId: "openapi",
143+
config: { spec },
144+
}),
145+
);
146+
}
147+
148+
expect(yield* runSqliteConfigBlobMigration(db.client, OPENAPI_OPTIONS)).toBe(2);
149+
const blobs = yield* Effect.promise(() => db.client.execute("SELECT id FROM blob"));
150+
expect(blobs.rows).toHaveLength(1);
151+
152+
yield* Effect.promise(() => db.close());
153+
}),
154+
);
155+
156+
it.effect("returns 0 when the integration table does not exist yet", () =>
157+
Effect.gen(function* () {
158+
// The ledger may run against a brand-new database before any schema
159+
// bring-up; an absent table is "nothing to migrate", not an error.
160+
const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() }));
161+
yield* Effect.promise(() => db.client.execute("DROP TABLE integration"));
162+
expect(yield* runSqliteConfigBlobMigration(db.client, OPENAPI_OPTIONS)).toBe(0);
163+
yield* Effect.promise(() => db.close());
164+
}),
165+
);
166+
167+
it.effect("runs once under the ledger and is stamped", () =>
168+
Effect.gen(function* () {
169+
const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() }));
170+
yield* Effect.promise(() =>
171+
insertIntegration(db.client, {
172+
rowId: "r1",
173+
tenant: "t1",
174+
slug: "legacy_api",
175+
pluginId: "openapi",
176+
config: { spec: "{}" },
177+
}),
178+
);
179+
const entry = {
180+
name: "test-spec-to-blob",
181+
run: (client: SqliteDataMigrationClient) =>
182+
runSqliteConfigBlobMigration(client, OPENAPI_OPTIONS).pipe(Effect.asVoid),
183+
};
184+
185+
expect(yield* runSqliteDataMigrations(db.client, [entry])).toEqual(["test-spec-to-blob"]);
186+
// Stamped: the second boot doesn't re-run it.
187+
expect(yield* runSqliteDataMigrations(db.client, [entry])).toEqual([]);
188+
189+
yield* Effect.promise(() => db.close());
190+
}),
191+
);
192+
});
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
// ---------------------------------------------------------------------------
2+
// Data migration: move an oversized inline `integration.config` field into
3+
// the blob table. The shape both protocol plugins need is identical — only
4+
// the field names differ — so the body lives here and each plugin exports a
5+
// ledger entry that binds its constants (openapi: spec → specHash under
6+
// `spec/<hash>`; graphql: introspectionJson → introspectionHash under
7+
// `introspection/<hash>`).
8+
//
9+
// Blob rows are written with the EXACT naming `makeFumaBlobStore` +
10+
// `pluginBlobStore` read back at runtime: namespace `o:<tenant>/<pluginId>`
11+
// (the org partition — integration configs are catalog-level), key
12+
// `<prefix>/<sha256>`, id `JSON.stringify([namespace, key])`. That makes it
13+
// correct ONLY for hosts whose runtime blob backend is the FumaDB store
14+
// (local, selfhost) — a host that reads blobs elsewhere (the D1 host reads
15+
// R2) must not register it, or the rewritten pointers would dangle.
16+
//
17+
// Idempotent: pointer-shaped configs (no inline field) plan zero updates,
18+
// and blob writes are content-addressed upserts.
19+
// ---------------------------------------------------------------------------
20+
21+
import { Effect, Option, Schema } from "effect";
22+
23+
import { sha256Hex } from "./blob";
24+
import { DataMigrationError, type SqliteDataMigrationClient } from "./sqlite-data-migrations";
25+
26+
export interface SqliteConfigBlobMigrationOptions {
27+
/** The ledger entry name, for error attribution. */
28+
readonly migrationName: string;
29+
/** Rows whose `plugin_id` equals this are candidates. */
30+
readonly pluginId: string;
31+
/** The config field holding the inline text to move (e.g. `spec`). */
32+
readonly inlineField: string;
33+
/** The config field that will carry the content hash (e.g. `specHash`). */
34+
readonly hashField: string;
35+
/** Blob key prefix; the key is `<prefix>/<sha256>` (e.g. `spec`). */
36+
readonly blobKeyPrefix: string;
37+
}
38+
39+
const decodeJsonOption = Schema.decodeUnknownOption(Schema.UnknownFromJsonString);
40+
41+
const isRecord = (value: unknown): value is Record<string, unknown> =>
42+
typeof value === "object" && value !== null && !Array.isArray(value);
43+
44+
/**
45+
* Move every inline `options.inlineField` in this plugin's integration
46+
* configs into the blob table and rewrite the config to carry
47+
* `options.hashField`. Returns the number of rows rewritten. The
48+
* `integration` table may not exist yet on a fresh database — that counts
49+
* as nothing to migrate.
50+
*/
51+
export const runSqliteConfigBlobMigration = (
52+
client: SqliteDataMigrationClient,
53+
options: SqliteConfigBlobMigrationOptions,
54+
): Effect.Effect<number, DataMigrationError> =>
55+
Effect.gen(function* () {
56+
const execute = (stmt: string | { readonly sql: string; readonly args: readonly unknown[] }) =>
57+
Effect.tryPromise({
58+
try: () => client.execute(stmt),
59+
catch: (cause) => new DataMigrationError({ migration: options.migrationName, cause }),
60+
});
61+
62+
const exists = yield* execute(
63+
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'integration'",
64+
);
65+
if (exists.rows.length === 0) return 0;
66+
67+
const result = yield* execute({
68+
sql: "SELECT row_id, tenant, config FROM integration WHERE plugin_id = ? AND config IS NOT NULL",
69+
args: [options.pluginId],
70+
});
71+
72+
interface PlannedMove {
73+
readonly rowId: string;
74+
readonly namespace: string;
75+
readonly key: string;
76+
readonly blobId: string;
77+
readonly inlineText: string;
78+
readonly nextConfig: string;
79+
}
80+
const moves: PlannedMove[] = [];
81+
for (const row of result.rows) {
82+
if (typeof row.row_id !== "string" || typeof row.tenant !== "string") continue;
83+
if (typeof row.config !== "string") continue;
84+
const decoded = decodeJsonOption(row.config);
85+
if (Option.isNone(decoded) || !isRecord(decoded.value)) continue;
86+
const inline = decoded.value[options.inlineField];
87+
if (typeof inline !== "string") continue;
88+
89+
const hash = yield* sha256Hex(inline);
90+
const namespace = `o:${row.tenant}/${options.pluginId}`;
91+
const key = `${options.blobKeyPrefix}/${hash}`;
92+
const { [options.inlineField]: _removed, ...rest } = decoded.value;
93+
moves.push({
94+
rowId: row.row_id,
95+
namespace,
96+
key,
97+
blobId: JSON.stringify([namespace, key]),
98+
inlineText: inline,
99+
nextConfig: JSON.stringify({ ...rest, [options.hashField]: hash }),
100+
});
101+
}
102+
if (moves.length === 0) return 0;
103+
104+
const applyAll = Effect.gen(function* () {
105+
for (const move of moves) {
106+
const existing = yield* execute({
107+
sql: "SELECT row_id FROM blob WHERE id = ?",
108+
args: [move.blobId],
109+
});
110+
if (existing.rows.length === 0) {
111+
yield* execute({
112+
sql: "INSERT INTO blob (namespace, key, value, row_id, id) VALUES (?, ?, ?, ?, ?)",
113+
args: [move.namespace, move.key, move.inlineText, crypto.randomUUID(), move.blobId],
114+
});
115+
} else {
116+
yield* execute({
117+
sql: "UPDATE blob SET value = ? WHERE id = ?",
118+
args: [move.inlineText, move.blobId],
119+
});
120+
}
121+
yield* execute({
122+
sql: "UPDATE integration SET config = ? WHERE row_id = ?",
123+
args: [move.nextConfig, move.rowId],
124+
});
125+
}
126+
yield* execute("COMMIT");
127+
});
128+
129+
yield* execute("BEGIN");
130+
yield* applyAll.pipe(Effect.tapError(() => execute("ROLLBACK").pipe(Effect.ignore)));
131+
return moves.length;
132+
});

packages/plugins/graphql/src/sdk/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,5 +37,7 @@ export {
3737

3838
export { migrateGraphqlAuthConfig } from "./migrate-config";
3939

40+
export { graphqlIntrospectionBlobDataMigration } from "./introspection-blob-migration";
41+
4042
// Request-shaped authoring: `headers: { Authorization: ["Bearer ", variable("token")] }`.
4143
export { variable, type ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth";

0 commit comments

Comments
 (0)