122 lines · 5.4 KB · TypeScript
RawDownload
// ---------------------------------------------------------------------------// Data migration: unwrap the retired {status, headers, data} transport// envelope from persisted OpenAPI tool output schemas. The runtime returns// the upstream payload as `data` (status/headers live in the ToolResult// `http` side channel), so persisted schemas must describe the payload// only — otherwise describe previews show an envelope invocations no// longer return. Mirrors the cloud drizzle migration// (apps/cloud/drizzle/0002_unwrap_openapi_output_envelope.sql) for the// libSQL-backed apps, where it runs once through the data-migration ledger.//// Idempotent: payload-shaped rows don't match the envelope signature, so// re-running plans zero updates.// --------------------------------------------------------------------------- import { Effect, Option, Schema } from "effect";import { DataMigrationError, type SqliteDataMigrationClient } from "@executor-js/sdk/core"; const MIGRATION_NAME = "2026-06-11-openapi-output-envelope-unwrap"; const isRecord = (value: unknown): value is Record<string, unknown> =>  typeof value === "object" && value !== null && !Array.isArray(value); // `{"type": "integer"}` — the envelope's `status` property schema, exactly.const isEnvelopeStatusSchema = (value: unknown): boolean =>  isRecord(value) && Object.keys(value).length === 1 && value.type === "integer"; // `{"type": "object", "additionalProperties": {"type": "string"}}` — the// envelope's `headers` property schema, exactly.const isEnvelopeHeadersSchema = (value: unknown): boolean =>  isRecord(value) &&  Object.keys(value).length === 2 &&  value.type === "object" &&  isRecord(value.additionalProperties) &&  Object.keys(value.additionalProperties).length === 1 &&  value.additionalProperties.type === "string"; /** * If `schema` is the retired transport envelope, return the payload schema * to persist instead (`null` when the envelope carried an empty `{}` data * schema — the new producer persists no output schema for those). Returns * undefined when the schema is not an envelope and the row must be left * untouched. */export const unwrapOpenApiTransportEnvelope = (  schema: unknown,): { readonly outputSchema: unknown | null } | undefined => {  if (!isRecord(schema)) return undefined;  if (schema.type !== "object" || schema.additionalProperties !== false) return undefined;  const required = schema.required;  if (!Array.isArray(required) || required.length !== 3) return undefined;  if (!["status", "headers", "data"].every((key) => required.includes(key))) return undefined;  const properties = schema.properties;  if (!isRecord(properties) || !("data" in properties)) return undefined;  if (!isEnvelopeStatusSchema(properties.status)) return undefined;  if (!isEnvelopeHeadersSchema(properties.headers)) return undefined;  const data = properties.data;  const outputSchema = isRecord(data) && Object.keys(data).length === 0 ? null : data;  return { outputSchema };}; const decodeJsonOption = Schema.decodeUnknownOption(Schema.UnknownFromJsonString); 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 }),  }); /** Unwrap envelope-shaped openapi tool output schemas in a SQLite database. *  Returns the number of rows rewritten. The `tool` table may not exist yet *  on a fresh database — that counts as nothing to migrate. */export const runSqliteOpenApiOutputSchemaMigration = (  client: SqliteDataMigrationClient,): Effect.Effect<number, DataMigrationError> =>  Effect.gen(function* () {    const exists = yield* execute(      client,      "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'tool'",    );    if (exists.rows.length === 0) return 0;     const result = yield* execute(      client,      "SELECT row_id, output_schema FROM tool WHERE plugin_id = 'openapi' AND output_schema IS NOT NULL",    );    const updates: { readonly rowId: string; readonly outputSchema: unknown | null }[] = [];    for (const row of result.rows) {      if (typeof row.row_id !== "string" || typeof row.output_schema !== "string") continue;      const schema = decodeJsonOption(row.output_schema);      if (Option.isNone(schema)) continue;      const unwrapped = unwrapOpenApiTransportEnvelope(schema.value);      if (unwrapped !== undefined) updates.push({ rowId: row.row_id, ...unwrapped });    }    if (updates.length === 0) return 0;     const applyAll = Effect.gen(function* () {      for (const update of updates) {        yield* execute(client, {          sql: "UPDATE tool SET output_schema = ? WHERE row_id = ?",          args: [            update.outputSchema === null ? null : JSON.stringify(update.outputSchema),            update.rowId,          ],        });      }      yield* execute(client, "COMMIT");    });     yield* execute(client, "BEGIN");    yield* applyAll.pipe(Effect.tapError(() => execute(client, "ROLLBACK").pipe(Effect.ignore)));    return updates.length;  }); /** Registry entry for the boot-time data-migration ledger. */export const openApiOutputSchemaDataMigration = {  name: MIGRATION_NAME,  run: (client: SqliteDataMigrationClient) =>    runSqliteOpenApiOutputSchemaMigration(client).pipe(Effect.asVoid),};