// ---------------------------------------------------------------------------// OpenAPI OAuth legacy → Connection backfill (cloud)// ---------------------------------------------------------------------------//// Dry-run by default. `--apply` runs the per-row transactions:// 1. INSERT connection row with provider_state pulled from the legacy row.// 2. UPDATE secret.owned_by_connection_id for the 1–2 referenced secrets.// 3. UPDATE openapi_source (oauth2 + invocation_config.oauth2) to the// new pointer shape.//// Self-contained: the only plugin imports are parser helpers for the// current shape. The pre-refactor OAuth2 shape is defined here — this// script is the last place it's needed, and it ships with the migration.//// Run (dry-run):// op run --env-file=.env.production -- bun run scripts/migrate-connections.ts// Run (apply):// op run --env-file=.env.production -- bun run scripts/migrate-connections.ts --apply import { randomUUID } from "node:crypto";import { Effect, Option, Schema } from "effect";import { FetchHttpClient } from "@effect/platform";import postgres from "postgres"; import { parse as parseOpenApi, resolveSpecText, OAuth2Auth,} from "@executor/plugin-openapi"; const APPLY = process.argv.includes("--apply"); // ---------------------------------------------------------------------------// Legacy OAuth2 shape (pre-refactor). Inlined on purpose: this is the only// place in the codebase that still needs to know about it. Once cloud +// local have run this migration, this script can be deleted.// --------------------------------------------------------------------------- const OAuth2Flow = Schema.Literal("authorizationCode", "clientCredentials"); class LegacyOAuth2Auth extends Schema.Class<LegacyOAuth2Auth>("LegacyOAuth2Auth")({ kind: Schema.Literal("oauth2"), securitySchemeName: Schema.String, flow: OAuth2Flow, tokenUrl: Schema.String, clientIdSecretId: Schema.String, clientSecretSecretId: Schema.NullOr(Schema.String), accessTokenSecretId: Schema.String, refreshTokenSecretId: Schema.NullOr(Schema.String), tokenType: Schema.String, expiresAt: Schema.NullOr(Schema.Number), scope: Schema.NullOr(Schema.String), scopes: Schema.Array(Schema.String),}) {} const decodeCurrent = Schema.decodeUnknownOption(OAuth2Auth);const decodeLegacy = Schema.decodeUnknownOption(LegacyOAuth2Auth); const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);const isString = (v: unknown): v is string => typeof v === "string"; // ---------------------------------------------------------------------------// Spec → authorizationUrl extraction (for authorizationCode flow only).// Uses the plugin's real parser so JSON/YAML + URL-fetch fallback match// runtime behavior exactly.// --------------------------------------------------------------------------- const extractAuthorizationUrl = async ( rawSpec: string, securitySchemeName: string, flow: "authorizationCode" | "clientCredentials",): Promise<{ url: string | null; note: string }> => { if (flow === "clientCredentials") { return { url: null, note: "clientCredentials — no authorizationUrl needed" }; } const parsed = await Effect.runPromise( resolveSpecText(rawSpec).pipe( Effect.flatMap((text) => parseOpenApi(text)), Effect.provide(FetchHttpClient.layer), Effect.either, ), ); if (parsed._tag === "Left") { return { url: null, note: `spec parse failed: ${parsed.left.message}` }; } const spec = parsed.right as unknown; if (!isRecord(spec)) return { url: null, note: "spec is not an object" }; const components = isRecord(spec.components) ? spec.components : null; if (!components) return { url: null, note: "spec.components missing" }; const schemes = isRecord(components.securitySchemes) ? components.securitySchemes : null; if (!schemes) return { url: null, note: "spec.components.securitySchemes missing" }; const scheme = schemes[securitySchemeName]; if (!isRecord(scheme)) { return { url: null, note: `securitySchemes[${securitySchemeName}] missing` }; } const flows = isRecord(scheme.flows) ? scheme.flows : null; if (!flows) return { url: null, note: "scheme.flows missing" }; const flowObj = isRecord(flows.authorizationCode) ? flows.authorizationCode : null; if (!flowObj) return { url: null, note: "flows.authorizationCode missing" }; if (!isString(flowObj.authorizationUrl)) { return { url: null, note: "authorizationUrl missing on flow" }; } return { url: flowObj.authorizationUrl, note: "extracted from spec" };}; // ---------------------------------------------------------------------------// Row classification// --------------------------------------------------------------------------- type Row = { scope_id: string; id: string; name: string; spec: string; invocation_config: unknown; oauth2_col: unknown;}; type Bucket = | { kind: "no-oauth"; row: Row } | { kind: "current"; row: Row } | { kind: "legacy-migratable"; row: Row; legacy: LegacyOAuth2Auth; authorizationUrl: string | null; authorizationUrlNote: string; } | { kind: "legacy-blocked"; row: Row; legacy: LegacyOAuth2Auth; reason: string } | { kind: "unknown"; row: Row; rawOAuth2: unknown; shape: string }; const classifyRow = async (row: Row): Promise<Bucket> => { const invocation = isRecord(row.invocation_config) ? row.invocation_config : {}; const primary = invocation.oauth2 ?? row.oauth2_col; if (primary == null) return { kind: "no-oauth", row }; if (Option.isSome(decodeCurrent(primary))) return { kind: "current", row }; const legacyOption = decodeLegacy(primary); if (Option.isSome(legacyOption)) { const legacy = legacyOption.value; const { url, note } = await extractAuthorizationUrl( row.spec, legacy.securitySchemeName, legacy.flow, ); if (legacy.flow === "authorizationCode" && url === null) { return { kind: "legacy-blocked", row, legacy, reason: `authorizationCode flow but ${note}`, }; } return { kind: "legacy-migratable", row, legacy, authorizationUrl: url, authorizationUrlNote: note, }; } const shape = isRecord(primary) ? `{${Object.keys(primary).sort().join(",")}}` : typeof primary; return { kind: "unknown", row, rawOAuth2: primary, shape };}; // ---------------------------------------------------------------------------// Main// --------------------------------------------------------------------------- type SecretRow = { id: string; scope_id: string; owned_by_connection_id: string | null;}; const main = async () => { const connectionString = process.env.DATABASE_URL || process.env.HYPERDRIVE_CONNECTION_STRING || ""; if (!connectionString) { console.error( "DATABASE_URL not set (try: op run --env-file=.env.production -- ...)", ); process.exit(1); } const sql = postgres(connectionString, { max: 1, onnotice: () => undefined, ssl: "require", }); try { const rows = (await sql<Row[]>` select scope_id, id, name, spec, invocation_config, oauth2 as oauth2_col from openapi_source `) as Row[]; console.log(`\nScanned ${rows.length} openapi_source row(s)`); console.log(APPLY ? "Mode: APPLY (writes enabled)\n" : "Mode: DRY-RUN (no writes)\n"); const buckets = await Promise.all(rows.map(classifyRow)); const counts = { "no-oauth": 0, current: 0, "legacy-migratable": 0, "legacy-blocked": 0, unknown: 0, }; for (const b of buckets) counts[b.kind]++; console.log("Classification:"); console.log(` no oauth2 config: ${counts["no-oauth"]}`); console.log(` already on new shape: ${counts.current}`); console.log(` legacy — would migrate: ${counts["legacy-migratable"]}`); console.log(` legacy — blocked: ${counts["legacy-blocked"]}`); console.log(` unrecognized shape: ${counts.unknown}\n`); const migratable = buckets.filter( (b): b is Extract<Bucket, { kind: "legacy-migratable" }> => b.kind === "legacy-migratable", ); const blocked = buckets.filter( (b) => b.kind === "legacy-blocked" || b.kind === "unknown", ); for (const b of blocked) { const ref = `${b.row.scope_id}/${b.row.id}`; if (b.kind === "legacy-blocked") { console.log(`[BLOCKED] ${ref}`); console.log(` reason: ${b.reason}`); console.log(` spec length: ${b.row.spec.length}`); } else if (b.kind === "unknown") { console.log(`[UNKNOWN] ${ref}`); console.log(` shape: ${b.shape}`); } console.log(); } for (const b of migratable) { const ref = `${b.row.scope_id}/${b.row.id}`; console.log(`[migratable] ${ref}`); console.log(` flow: ${b.legacy.flow}`); console.log(` scheme: ${b.legacy.securitySchemeName}`); console.log( ` authorizationUrl: ${b.authorizationUrl ?? "(null)"} — ${b.authorizationUrlNote}`, ); console.log(); } if (blocked.length > 0) { console.log( `ABORT: ${blocked.length} row(s) blocked or unrecognized; inspect above and fix or delete before re-running.`, ); process.exit(2); } if (!APPLY) { console.log(`OK: ${migratable.length} row(s) would migrate. Re-run with --apply.`); return; } if (migratable.length === 0) { console.log("Nothing to migrate."); return; } let applied = 0; let failed = 0; for (const b of migratable) { const ref = `${b.row.scope_id}/${b.row.id}`; const connectionId = `openapi-oauth2-${randomUUID()}`; const l = b.legacy; const providerState = { flow: l.flow, tokenUrl: l.tokenUrl, clientIdSecretId: l.clientIdSecretId, clientSecretSecretId: l.clientSecretSecretId, scopes: l.scopes, }; const oauth2Pointer = { kind: "oauth2" as const, connectionId, securitySchemeName: l.securitySchemeName, flow: l.flow, tokenUrl: l.tokenUrl, authorizationUrl: b.authorizationUrl, clientIdSecretId: l.clientIdSecretId, clientSecretSecretId: l.clientSecretSecretId, scopes: l.scopes, }; try { await sql.begin(async (tx) => { await tx` insert into connection ( id, scope_id, provider, kind, identity_label, access_token_secret_id, refresh_token_secret_id, expires_at, scope, provider_state, created_at, updated_at ) values ( ${connectionId}, ${b.row.scope_id}, ${"openapi:oauth2"}, ${"user"}, ${b.row.name}, ${l.accessTokenSecretId}, ${l.refreshTokenSecretId}, ${l.expiresAt}, ${l.scope}, ${tx.json(providerState)}, now(), now() ) `; const secretIds = [l.accessTokenSecretId]; if (l.refreshTokenSecretId) secretIds.push(l.refreshTokenSecretId); const existing = (await tx<SecretRow[]>` select id, scope_id, owned_by_connection_id from secret where scope_id = ${b.row.scope_id} and id = any(${secretIds}) `) as SecretRow[]; const alreadyOwned = existing.filter( (r) => r.owned_by_connection_id !== null && r.owned_by_connection_id !== connectionId, ); if (alreadyOwned.length > 0) { throw new Error( `secret(s) already owned: ${alreadyOwned.map((r) => `${r.id}(owner=${r.owned_by_connection_id})`).join(", ")}`, ); } // Some early-onboarded OpenAPI OAuth tokens never got a `secret` // routing row — the pre-refactor `secretsGet` fallback resolved // them via provider enumeration. Backfill the missing rows // pointing at `workos-vault` (the only writable provider on // cloud) so the new SDK's id-indexed fast path finds them. const missing = secretIds.filter( (id) => !existing.some((r) => r.id === id), ); for (const id of missing) { const name = id === l.accessTokenSecretId ? `Connection ${connectionId} access token` : `Connection ${connectionId} refresh token`; await tx` insert into secret ( id, scope_id, provider, name, owned_by_connection_id, created_at ) values ( ${id}, ${b.row.scope_id}, ${"workos-vault"}, ${name}, ${connectionId}, now() ) `; } if (existing.length > 0) { await tx` update secret set owned_by_connection_id = ${connectionId} where scope_id = ${b.row.scope_id} and id = any(${secretIds}) `; } const nextInvocation = { ...(isRecord(b.row.invocation_config) ? b.row.invocation_config : {}), oauth2: oauth2Pointer, }; await tx` update openapi_source set oauth2 = ${tx.json(oauth2Pointer)}, invocation_config = ${tx.json(nextInvocation)} where scope_id = ${b.row.scope_id} and id = ${b.row.id} `; }); applied++; console.log(` [OK] ${ref} -> ${connectionId}`); } catch (err) { failed++; console.log( ` [FAIL] ${ref}: ${err instanceof Error ? err.message : String(err)}`, ); } } console.log(); console.log(`Applied: ${applied}`); console.log(`Failed: ${failed}`); if (failed > 0) process.exit(3); } finally { await sql.end({ timeout: 5 }); }}; main().catch((err) => { console.error("migrate-connections failed:", err); process.exit(1);});
// ---------------------------------------------------------------------------
// OpenAPI OAuth legacy → Connection backfill (cloud)
// ---------------------------------------------------------------------------
//
// Dry-run by default. `--apply` runs the per-row transactions:
// 1. INSERT connection row with provider_state pulled from the legacy row.
// 2. UPDATE secret.owned_by_connection_id for the 1–2 referenced secrets.
// 3. UPDATE openapi_source (oauth2 + invocation_config.oauth2) to the
// new pointer shape.
//
// Self-contained: the only plugin imports are parser helpers for the
// current shape. The pre-refactor OAuth2 shape is defined here — this
// script is the last place it's needed, and it ships with the migration.
//
// Run (dry-run):
// op run --env-file=.env.production -- bun run scripts/migrate-connections.ts
// Run (apply):
// op run --env-file=.env.production -- bun run scripts/migrate-connections.ts --apply
import { randomUUID } from "node:crypto";
import { Effect, Option, Schema } from "effect";
import { FetchHttpClient } from "@effect/platform";
import postgres from "postgres";
import {
parse as parseOpenApi,
resolveSpecText,
OAuth2Auth,
} from "@executor/plugin-openapi";
const APPLY = process.argv.includes("--apply");
// ---------------------------------------------------------------------------
// Legacy OAuth2 shape (pre-refactor). Inlined on purpose: this is the only
// place in the codebase that still needs to know about it. Once cloud +
// local have run this migration, this script can be deleted.
// ---------------------------------------------------------------------------
const OAuth2Flow = Schema.Literal("authorizationCode", "clientCredentials");
class LegacyOAuth2Auth extends Schema.Class<LegacyOAuth2Auth>("LegacyOAuth2Auth")({
kind: Schema.Literal("oauth2"),
securitySchemeName: Schema.String,
flow: OAuth2Flow,
tokenUrl: Schema.String,
clientIdSecretId: Schema.String,
clientSecretSecretId: Schema.NullOr(Schema.String),
accessTokenSecretId: Schema.String,
refreshTokenSecretId: Schema.NullOr(Schema.String),
tokenType: Schema.String,
expiresAt: Schema.NullOr(Schema.Number),
scope: Schema.NullOr(Schema.String),
scopes: Schema.Array(Schema.String),
}) {}
const decodeCurrent = Schema.decodeUnknownOption(OAuth2Auth);
const decodeLegacy = Schema.decodeUnknownOption(LegacyOAuth2Auth);
const isRecord = (v: unknown): v is Record<string, unknown> =>
typeof v === "object" && v !== null && !Array.isArray(v);
const isString = (v: unknown): v is string => typeof v === "string";
// ---------------------------------------------------------------------------
// Spec → authorizationUrl extraction (for authorizationCode flow only).
// Uses the plugin's real parser so JSON/YAML + URL-fetch fallback match
// runtime behavior exactly.
// ---------------------------------------------------------------------------
const extractAuthorizationUrl = async (
rawSpec: string,
securitySchemeName: string,
flow: "authorizationCode" | "clientCredentials",
): Promise<{ url: string | null; note: string }> => {
if (flow === "clientCredentials") {
return { url: null, note: "clientCredentials — no authorizationUrl needed" };
}
const parsed = await Effect.runPromise(
resolveSpecText(rawSpec).pipe(
Effect.flatMap((text) => parseOpenApi(text)),
Effect.provide(FetchHttpClient.layer),
Effect.either,
),
);
if (parsed._tag === "Left") {
return { url: null, note: `spec parse failed: ${parsed.left.message}` };
}
const spec = parsed.right as unknown;
if (!isRecord(spec)) return { url: null, note: "spec is not an object" };
const components = isRecord(spec.components) ? spec.components : null;
if (!components) return { url: null, note: "spec.components missing" };
const schemes = isRecord(components.securitySchemes)
? components.securitySchemes
: null;
if (!schemes) return { url: null, note: "spec.components.securitySchemes missing" };
const scheme = schemes[securitySchemeName];
if (!isRecord(scheme)) {
return { url: null, note: `securitySchemes[${securitySchemeName}] missing` };
}
const flows = isRecord(scheme.flows) ? scheme.flows : null;
if (!flows) return { url: null, note: "scheme.flows missing" };
const flowObj = isRecord(flows.authorizationCode) ? flows.authorizationCode : null;
if (!flowObj) return { url: null, note: "flows.authorizationCode missing" };
if (!isString(flowObj.authorizationUrl)) {
return { url: null, note: "authorizationUrl missing on flow" };
}
return { url: flowObj.authorizationUrl, note: "extracted from spec" };
};
// ---------------------------------------------------------------------------
// Row classification
// ---------------------------------------------------------------------------
type Row = {
scope_id: string;
id: string;
name: string;
spec: string;
invocation_config: unknown;
oauth2_col: unknown;
};
type Bucket =
| { kind: "no-oauth"; row: Row }
| { kind: "current"; row: Row }
| {
kind: "legacy-migratable";
row: Row;
legacy: LegacyOAuth2Auth;
authorizationUrl: string | null;
authorizationUrlNote: string;
}
| { kind: "legacy-blocked"; row: Row; legacy: LegacyOAuth2Auth; reason: string }
| { kind: "unknown"; row: Row; rawOAuth2: unknown; shape: string };
const classifyRow = async (row: Row): Promise<Bucket> => {
const invocation = isRecord(row.invocation_config) ? row.invocation_config : {};
const primary = invocation.oauth2 ?? row.oauth2_col;
if (primary == null) return { kind: "no-oauth", row };
if (Option.isSome(decodeCurrent(primary))) return { kind: "current", row };
const legacyOption = decodeLegacy(primary);
if (Option.isSome(legacyOption)) {
const legacy = legacyOption.value;
const { url, note } = await extractAuthorizationUrl(
row.spec,
legacy.securitySchemeName,
legacy.flow,
);
if (legacy.flow === "authorizationCode" && url === null) {
return {
kind: "legacy-blocked",
row,
legacy,
reason: `authorizationCode flow but ${note}`,
};
}
return {
kind: "legacy-migratable",
row,
legacy,
authorizationUrl: url,
authorizationUrlNote: note,
};
}
const shape = isRecord(primary)
? `{${Object.keys(primary).sort().join(",")}}`
: typeof primary;
return { kind: "unknown", row, rawOAuth2: primary, shape };
};
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
type SecretRow = {
id: string;
scope_id: string;
owned_by_connection_id: string | null;
};
const main = async () => {
const connectionString =
process.env.DATABASE_URL || process.env.HYPERDRIVE_CONNECTION_STRING || "";
if (!connectionString) {
console.error(
"DATABASE_URL not set (try: op run --env-file=.env.production -- ...)",
);
process.exit(1);
}
const sql = postgres(connectionString, {
max: 1,
onnotice: () => undefined,
ssl: "require",
});
try {
const rows = (await sql<Row[]>`
select
scope_id,
id,
name,
spec,
invocation_config,
oauth2 as oauth2_col
from openapi_source
`) as Row[];
console.log(`\nScanned ${rows.length} openapi_source row(s)`);
console.log(APPLY ? "Mode: APPLY (writes enabled)\n" : "Mode: DRY-RUN (no writes)\n");
const buckets = await Promise.all(rows.map(classifyRow));
const counts = {
"no-oauth": 0,
current: 0,
"legacy-migratable": 0,
"legacy-blocked": 0,
unknown: 0,
};
for (const b of buckets) counts[b.kind]++;
console.log("Classification:");
console.log(` no oauth2 config: ${counts["no-oauth"]}`);
console.log(` already on new shape: ${counts.current}`);
console.log(` legacy — would migrate: ${counts["legacy-migratable"]}`);
console.log(` legacy — blocked: ${counts["legacy-blocked"]}`);
console.log(` unrecognized shape: ${counts.unknown}\n`);
const migratable = buckets.filter(
(b): b is Extract<Bucket, { kind: "legacy-migratable" }> =>
b.kind === "legacy-migratable",
);
const blocked = buckets.filter(
(b) => b.kind === "legacy-blocked" || b.kind === "unknown",
);
for (const b of blocked) {
const ref = `${b.row.scope_id}/${b.row.id}`;
if (b.kind === "legacy-blocked") {
console.log(`[BLOCKED] ${ref}`);
console.log(` reason: ${b.reason}`);
console.log(` spec length: ${b.row.spec.length}`);
} else if (b.kind === "unknown") {
console.log(`[UNKNOWN] ${ref}`);
console.log(` shape: ${b.shape}`);
}
console.log();
}
for (const b of migratable) {
const ref = `${b.row.scope_id}/${b.row.id}`;
console.log(`[migratable] ${ref}`);
console.log(` flow: ${b.legacy.flow}`);
console.log(` scheme: ${b.legacy.securitySchemeName}`);
console.log(
` authorizationUrl: ${b.authorizationUrl ?? "(null)"} — ${b.authorizationUrlNote}`,
);
console.log();
}
if (blocked.length > 0) {
console.log(
`ABORT: ${blocked.length} row(s) blocked or unrecognized; inspect above and fix or delete before re-running.`,
);
process.exit(2);
}
if (!APPLY) {
console.log(`OK: ${migratable.length} row(s) would migrate. Re-run with --apply.`);
return;
}
if (migratable.length === 0) {
console.log("Nothing to migrate.");
return;
}
let applied = 0;
let failed = 0;
for (const b of migratable) {
const ref = `${b.row.scope_id}/${b.row.id}`;
const connectionId = `openapi-oauth2-${randomUUID()}`;
const l = b.legacy;
const providerState = {
flow: l.flow,
tokenUrl: l.tokenUrl,
clientIdSecretId: l.clientIdSecretId,
clientSecretSecretId: l.clientSecretSecretId,
scopes: l.scopes,
};
const oauth2Pointer = {
kind: "oauth2" as const,
connectionId,
securitySchemeName: l.securitySchemeName,
flow: l.flow,
tokenUrl: l.tokenUrl,
authorizationUrl: b.authorizationUrl,
clientIdSecretId: l.clientIdSecretId,
clientSecretSecretId: l.clientSecretSecretId,
scopes: l.scopes,
};
try {
await sql.begin(async (tx) => {
await tx`
insert into connection (
id, scope_id, provider, kind, identity_label,
access_token_secret_id, refresh_token_secret_id,
expires_at, scope, provider_state,
created_at, updated_at
) values (
${connectionId},
${b.row.scope_id},
${"openapi:oauth2"},
${"user"},
${b.row.name},
${l.accessTokenSecretId},
${l.refreshTokenSecretId},
${l.expiresAt},
${l.scope},
${tx.json(providerState)},
now(),
now()
)
`;
const secretIds = [l.accessTokenSecretId];
if (l.refreshTokenSecretId) secretIds.push(l.refreshTokenSecretId);
const existing = (await tx<SecretRow[]>`
select id, scope_id, owned_by_connection_id
from secret
where scope_id = ${b.row.scope_id} and id = any(${secretIds})
`) as SecretRow[];
const alreadyOwned = existing.filter(
(r) =>
r.owned_by_connection_id !== null &&
r.owned_by_connection_id !== connectionId,
);
if (alreadyOwned.length > 0) {
throw new Error(
`secret(s) already owned: ${alreadyOwned.map((r) => `${r.id}(owner=${r.owned_by_connection_id})`).join(", ")}`,
);
}
// Some early-onboarded OpenAPI OAuth tokens never got a `secret`
// routing row — the pre-refactor `secretsGet` fallback resolved
// them via provider enumeration. Backfill the missing rows
// pointing at `workos-vault` (the only writable provider on
// cloud) so the new SDK's id-indexed fast path finds them.
const missing = secretIds.filter(
(id) => !existing.some((r) => r.id === id),
);
for (const id of missing) {
const name =
id === l.accessTokenSecretId
? `Connection ${connectionId} access token`
: `Connection ${connectionId} refresh token`;
await tx`
insert into secret (
id, scope_id, provider, name,
owned_by_connection_id, created_at
) values (
${id}, ${b.row.scope_id}, ${"workos-vault"}, ${name},
${connectionId}, now()
)
`;
}
if (existing.length > 0) {
await tx`
update secret
set owned_by_connection_id = ${connectionId}
where scope_id = ${b.row.scope_id} and id = any(${secretIds})
`;
}
const nextInvocation = {
...(isRecord(b.row.invocation_config) ? b.row.invocation_config : {}),
oauth2: oauth2Pointer,
};
await tx`
update openapi_source
set
oauth2 = ${tx.json(oauth2Pointer)},
invocation_config = ${tx.json(nextInvocation)}
where scope_id = ${b.row.scope_id} and id = ${b.row.id}
`;
});
applied++;
console.log(` [OK] ${ref} -> ${connectionId}`);
} catch (err) {
failed++;
console.log(
` [FAIL] ${ref}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
console.log();
console.log(`Applied: ${applied}`);
console.log(`Failed: ${failed}`);
if (failed > 0) process.exit(3);
} finally {
await sql.end({ timeout: 5 });
}
};
main().catch((err) => {
console.error("migrate-connections failed:", err);
process.exit(1);
});