// ---------------------------------------------------------------------------// Data migration: rebuild tool catalogs whose operations return NDJSON.//// Extraction used to persist an NDJSON operation's PER-LINE response schema as// the tool's output schema, while the invoke path returns an ARRAY of parsed// lines, so describe previews promised a single object that invocations never// returned. The producer now wraps those schemas in an array; the per-line// shape can't be recognized from a persisted `tool` row alone (it's just a// JSON schema), but the stored operation bindings kept the response content// type, so the affected connections are findable and the catalog machinery// already knows how to rebuild them: stale-mark `tools_synced_at` and the next// read re-produces the rows through the fixed producer. Mirrors the cloud// drizzle migration (apps/cloud/drizzle/0010_ndjson_output_arrays.sql).//// Effectively idempotent: re-running re-marks the same connections, and a// rebuild from unchanged bindings writes identical rows.// ---------------------------------------------------------------------------import { Effect } from "effect";import { DataMigrationError, type SqliteDataMigrationClient } from "@executor-js/sdk/core";const MIGRATION_NAME = "2026-07-09-openapi-ndjson-output-arrays";// Substring probes for the NDJSON media types (NDJSON_MEDIA_TYPES in// openapi-utils.ts). The binding JSON inside `plugin_storage.data` may be a// nested object or a re-encoded string, so a LIKE over the raw row text is the// shape-agnostic match; false positives only cause a harmless extra rebuild.const NDJSON_TYPE_PROBES = [ "application/stream+json", "application/x-ndjson", "application/jsonl",] as const;const execute = ( client: SqliteDataMigrationClient, stmt: string | { readonly sql: string; readonly args: readonly unknown[] },) => Effect.tryPromise({ try: () => client.execute(stmt), catch: (cause) => new DataMigrationError({ migration: MIGRATION_NAME, cause }), });const tableExists = (client: SqliteDataMigrationClient, table: string) => Effect.map( execute(client, { sql: "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", args: [table], }), (result) => result.rows.length > 0, );/** Stale-mark every connection whose integration has at least one stored * operation binding with an NDJSON response, so the next read rebuilds its * tool rows (with array-wrapped output schemas). Returns the number of * connections marked. Fresh databases may lack either table; nothing to * migrate. */export const runSqliteNdjsonOutputMigration = ( client: SqliteDataMigrationClient,): Effect.Effect<number, DataMigrationError> => Effect.gen(function* () { for (const table of ["connection", "plugin_storage"]) { if (!(yield* tableExists(client, table))) return 0; } const probes = NDJSON_TYPE_PROBES.map(() => "ps.data LIKE ?").join(" OR "); const affected = yield* execute(client, { sql: `SELECT DISTINCT ps.tenant AS tenant, json_extract(ps.data, '$.integration') AS integration FROM plugin_storage ps WHERE ps.collection = 'operation' AND (${probes})`, args: NDJSON_TYPE_PROBES.map((probe) => `%${probe}%`), }); let marked = 0; for (const row of affected.rows) { if (typeof row.tenant !== "string" || typeof row.integration !== "string") continue; yield* execute(client, { sql: "UPDATE connection SET tools_synced_at = NULL WHERE tenant = ? AND integration = ?", args: [row.tenant, row.integration], }); marked += 1; } return marked; });/** Registry entry for the boot-time data-migration ledger. */export const openApiNdjsonOutputDataMigration = { name: MIGRATION_NAME, run: (client: SqliteDataMigrationClient) => runSqliteNdjsonOutputMigration(client).pipe(Effect.asVoid),};
// ---------------------------------------------------------------------------
// Data migration: rebuild tool catalogs whose operations return NDJSON.
//
// Extraction used to persist an NDJSON operation's PER-LINE response schema as
// the tool's output schema, while the invoke path returns an ARRAY of parsed
// lines, so describe previews promised a single object that invocations never
// returned. The producer now wraps those schemas in an array; the per-line
// shape can't be recognized from a persisted `tool` row alone (it's just a
// JSON schema), but the stored operation bindings kept the response content
// type, so the affected connections are findable and the catalog machinery
// already knows how to rebuild them: stale-mark `tools_synced_at` and the next
// read re-produces the rows through the fixed producer. Mirrors the cloud
// drizzle migration (apps/cloud/drizzle/0010_ndjson_output_arrays.sql).
//
// Effectively idempotent: re-running re-marks the same connections, and a
// rebuild from unchanged bindings writes identical rows.
// ---------------------------------------------------------------------------
import { Effect } from "effect";
import { DataMigrationError, type SqliteDataMigrationClient } from "@executor-js/sdk/core";
const MIGRATION_NAME = "2026-07-09-openapi-ndjson-output-arrays";
// Substring probes for the NDJSON media types (NDJSON_MEDIA_TYPES in
// openapi-utils.ts). The binding JSON inside `plugin_storage.data` may be a
// nested object or a re-encoded string, so a LIKE over the raw row text is the
// shape-agnostic match; false positives only cause a harmless extra rebuild.
const NDJSON_TYPE_PROBES = [
"application/stream+json",
"application/x-ndjson",
"application/jsonl",
] as const;
const execute = (
client: SqliteDataMigrationClient,
stmt: string | { readonly sql: string; readonly args: readonly unknown[] },
) =>
Effect.tryPromise({
try: () => client.execute(stmt),
catch: (cause) => new DataMigrationError({ migration: MIGRATION_NAME, cause }),
});
const tableExists = (client: SqliteDataMigrationClient, table: string) =>
Effect.map(
execute(client, {
sql: "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
args: [table],
}),
(result) => result.rows.length > 0,
);
/** Stale-mark every connection whose integration has at least one stored
* operation binding with an NDJSON response, so the next read rebuilds its
* tool rows (with array-wrapped output schemas). Returns the number of
* connections marked. Fresh databases may lack either table; nothing to
* migrate. */
export const runSqliteNdjsonOutputMigration = (
client: SqliteDataMigrationClient,
): Effect.Effect<number, DataMigrationError> =>
Effect.gen(function* () {
for (const table of ["connection", "plugin_storage"]) {
if (!(yield* tableExists(client, table))) return 0;
}
const probes = NDJSON_TYPE_PROBES.map(() => "ps.data LIKE ?").join(" OR ");
const affected = yield* execute(client, {
sql: `SELECT DISTINCT ps.tenant AS tenant, json_extract(ps.data, '$.integration') AS integration
FROM plugin_storage ps
WHERE ps.collection = 'operation' AND (${probes})`,
args: NDJSON_TYPE_PROBES.map((probe) => `%${probe}%`),
});
let marked = 0;
for (const row of affected.rows) {
if (typeof row.tenant !== "string" || typeof row.integration !== "string") continue;
yield* execute(client, {
sql: "UPDATE connection SET tools_synced_at = NULL WHERE tenant = ? AND integration = ?",
args: [row.tenant, row.integration],
});
marked += 1;
}
return marked;
});
/** Registry entry for the boot-time data-migration ledger. */
export const openApiNdjsonOutputDataMigration = {
name: MIGRATION_NAME,
run: (client: SqliteDataMigrationClient) =>
runSqliteNdjsonOutputMigration(client).pipe(Effect.asVoid),
};