* Add openapi provider catalog design notes * Add OpenAPI spec format adapter support * Add Google and Microsoft spec format adapters * Preserve streaming spec format refresh * Move provider add surfaces into catalog presets * Fix provider catalog health checks * Fix Google Directory health check args * Format design notes * Add openapi provider service split migration * Fix service split blob migration * Read all provider operation rows for the migration plan * Assign provider operations by fresh-add spec ownership * Fix Microsoft service split ownership * Map singular mail folder and users profile segments * Seed add form identity from the catalog preset name * Use OIDC id token for OAuth identity labels * Handle OAuth health without probes * Canonicalize Microsoft scopes and decode JWT claims as UTF-8 * Update lockfile for provider package dependency changes * Package provider service split migration * Retarget orphan guardrail policies as dormant rows in the cloud runner * Retarget orphan approve policies too * Fold provider catalogs into openapi
Showing the changes to scripts/migration/service-split-postgres-cli.ts (1 of 148 files). Show all
Unified · Split
| @@ -0,0 +1,639 @@ | ||
| 1 | +/* oxlint-disable executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: one-shot migration CLI */ | |
| 2 | + | |
| 3 | +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; | |
| 4 | +import { resolve } from "node:path"; | |
| 5 | +import { pathToFileURL } from "node:url"; | |
| 6 | + | |
| 7 | +import postgres from "../../apps/cloud/node_modules/postgres/src/index.js"; | |
| 8 | + | |
| 9 | +import { | |
| 10 | + operationStorageKey, | |
| 11 | + planMigration, | |
| 12 | + renderOrgDiff, | |
| 13 | + renderSummary, | |
| 14 | + storageDataRecord, | |
| 15 | + tenantHash, | |
| 16 | + verifyPolicyRewriteNeverWidens, | |
| 17 | + type BlobRow, | |
| 18 | + type ConnectionRow, | |
| 19 | + type IntegrationRow, | |
| 20 | + type MigrationInput, | |
| 21 | + type OrgPlan, | |
| 22 | + type PluginStorageRow, | |
| 23 | + type ToolPolicyRow, | |
| 24 | + type ToolRow, | |
| 25 | +} from "@executor-js/plugin-provider-service-split/planner"; | |
| 26 | + | |
| 27 | +export interface SqlClient { | |
| 28 | + readonly unsafe: <T extends readonly object[] = readonly Record<string, unknown>[]>( | |
| 29 | + query: string, | |
| 30 | + params?: readonly unknown[], | |
| 31 | + ) => Promise<T>; | |
| 32 | + readonly begin: <T>(fn: (sql: SqlClient) => Promise<T>) => Promise<T>; | |
| 33 | + readonly end: (options?: { readonly timeout?: number }) => Promise<void>; | |
| 34 | +} | |
| 35 | + | |
| 36 | +const args = process.argv.slice(2); | |
| 37 | +const hasArg = (name: string): boolean => args.includes(name); | |
| 38 | +const argValue = (name: string): string | undefined => { | |
| 39 | + const index = args.indexOf(name); | |
| 40 | + return index >= 0 ? args[index + 1] : undefined; | |
| 41 | +}; | |
| 42 | + | |
| 43 | +const apply = hasArg("--apply"); | |
| 44 | +const inputJson = argValue("--input-json"); | |
| 45 | +const outputDir = resolve( | |
| 46 | + argValue("--output-dir") ?? "~/agent-workspace/scratch/service-split-migration-dryrun", | |
| 47 | +).replace(/^~(?=\/)/, process.env.HOME ?? "~"); | |
| 48 | +const orgFilter = argValue("--org"); | |
| 49 | +const trafficLastTenant = argValue("--traffic-last-tenant"); | |
| 50 | +const databaseUrl = argValue("--database-url") ?? process.env.DATABASE_URL; | |
| 51 | +const r2CopiesDone = hasArg("--r2-copies-done"); | |
| 52 | +export const LEDGER_TABLE = "provider_service_split_org_migration"; | |
| 53 | + | |
| 54 | +const parseInputJson = (path: string): MigrationInput => | |
| 55 | + JSON.parse(readFileSync(path, "utf8")) as MigrationInput; | |
| 56 | + | |
| 57 | +const readRows = async <T extends readonly object[]>(sql: SqlClient, query: string): Promise<T> => | |
| 58 | + sql.unsafe<T>(query); | |
| 59 | + | |
| 60 | +const readCompletedTenants = async ( | |
| 61 | + sql: SqlClient, | |
| 62 | + dryRun: boolean, | |
| 63 | +): Promise<readonly string[]> => { | |
| 64 | + const [table] = await sql.unsafe<{ ledger_table: string | null }[]>( | |
| 65 | + `SELECT to_regclass('public.${LEDGER_TABLE}')::text AS ledger_table`, | |
| 66 | + ); | |
| 67 | + if (!table?.ledger_table) { | |
| 68 | + if (dryRun) return []; | |
| 69 | + await sql.unsafe(` | |
| 70 | + CREATE TABLE IF NOT EXISTS ${LEDGER_TABLE} ( | |
| 71 | + tenant text PRIMARY KEY, | |
| 72 | + time_completed bigint NOT NULL | |
| 73 | + ) | |
| 74 | + `); | |
| 75 | + return []; | |
| 76 | + } | |
| 77 | + const rows = await sql.unsafe<{ tenant: string }[]>( | |
| 78 | + `SELECT tenant FROM ${LEDGER_TABLE} ORDER BY tenant`, | |
| 79 | + ); | |
| 80 | + return rows.map((row) => row.tenant); | |
| 81 | +}; | |
| 82 | + | |
| 83 | +const filterInputByOrg = (input: MigrationInput, filter: string | undefined): MigrationInput => { | |
| 84 | + if (!filter) return input; | |
| 85 | + const tenantMatches = new Set( | |
| 86 | + input.integrations | |
| 87 | + .map((row) => row.tenant) | |
| 88 | + .filter((tenant) => tenant === filter || tenantHash(tenant) === filter), | |
| 89 | + ); | |
| 90 | + return { | |
| 91 | + ...input, | |
| 92 | + integrations: input.integrations.filter((row) => tenantMatches.has(row.tenant)), | |
| 93 | + connections: input.connections.filter((row) => tenantMatches.has(row.tenant)), | |
| 94 | + tools: input.tools.filter((row) => tenantMatches.has(row.tenant)), | |
| 95 | + pluginStorage: input.pluginStorage?.filter((row) => tenantMatches.has(row.tenant)), | |
| 96 | + blobs: input.blobs?.filter((row) => { | |
| 97 | + for (const tenant of tenantMatches) { | |
| 98 | + if (row.namespace.startsWith(`o:${tenant}/`)) return true; | |
| 99 | + } | |
| 100 | + return false; | |
| 101 | + }), | |
| 102 | + policies: input.policies.filter((row) => tenantMatches.has(row.tenant)), | |
| 103 | + completedTenants: input.completedTenants?.filter((tenant) => tenantMatches.has(tenant)), | |
| 104 | + }; | |
| 105 | +}; | |
| 106 | + | |
| 107 | +export const readDatabaseInput = async ( | |
| 108 | + sql: SqlClient, | |
| 109 | + dryRun: boolean, | |
| 110 | + blobBackend: MigrationInput["blobBackend"], | |
| 111 | +): Promise<MigrationInput> => { | |
| 112 | + const completedTenants = await readCompletedTenants(sql, dryRun); | |
| 113 | + const integrations = await readRows<IntegrationRow[]>( | |
| 114 | + sql, | |
| 115 | + ` | |
| 116 | + SELECT tenant, slug, plugin_id, name, description, config, health_check, config_revised_at, | |
| 117 | + can_remove, can_refresh, created_at::text, updated_at::text, row_id | |
| 118 | + FROM integration | |
| 119 | + WHERE plugin_id IN ('google', 'microsoft') | |
| 120 | + OR slug IN ('google', 'microsoft') | |
| 121 | + OR tenant IN ( | |
| 122 | + SELECT tenant FROM integration | |
| 123 | + WHERE plugin_id IN ('google', 'microsoft') | |
| 124 | + ) | |
| 125 | + ORDER BY tenant, slug | |
| 126 | + `, | |
| 127 | + ); | |
| 128 | + const connections = await readRows<ConnectionRow[]>( | |
| 129 | + sql, | |
| 130 | + ` | |
| 131 | + SELECT tenant, owner, subject, integration, name, template, provider, item_ids, | |
| 132 | + identity_label, description, last_health, tools_synced_at, oauth_client, | |
| 133 | + oauth_client_owner, refresh_item_id, expires_at, oauth_scope, oauth_token_url, | |
| 134 | + provider_state, created_at::text, updated_at::text, row_id | |
| 135 | + FROM connection | |
| 136 | + WHERE tenant IN ( | |
| 137 | + SELECT tenant FROM integration | |
| 138 | + WHERE plugin_id IN ('google', 'microsoft') | |
| 139 | + ) | |
| 140 | + ORDER BY tenant, integration, owner, subject, name | |
| 141 | + `, | |
| 142 | + ); | |
| 143 | + const tools = await readRows<ToolRow[]>( | |
| 144 | + sql, | |
| 145 | + ` | |
| 146 | + SELECT tenant, owner, subject, integration, connection, plugin_id, name, description, | |
| 147 | + input_schema, output_schema, annotations, created_at::text, updated_at::text, row_id | |
| 148 | + FROM tool | |
| 149 | + WHERE tenant IN ( | |
| 150 | + SELECT tenant FROM integration | |
| 151 | + WHERE plugin_id IN ('google', 'microsoft') | |
| 152 | + ) | |
| 153 | + ORDER BY tenant, integration, connection, name | |
| 154 | + `, | |
| 155 | + ); | |
| 156 | + const pluginStorage = await sql.unsafe<PluginStorageRow[]>( | |
| 157 | + ` | |
| 158 | + SELECT tenant, owner, subject, plugin_id, collection, key, data, | |
| 159 | + created_at::text, updated_at::text, row_id | |
| 160 | + FROM plugin_storage | |
| 161 | + WHERE tenant IN ( | |
| 162 | + SELECT tenant FROM integration | |
| 163 | + WHERE plugin_id IN ('google', 'microsoft') | |
| 164 | + ) | |
| 165 | + AND plugin_id IN ('google', 'microsoft') | |
| 166 | + AND collection = 'operation' | |
| 167 | + ORDER BY tenant, plugin_id, collection, key | |
| 168 | + `, | |
| 169 | + ); | |
| 170 | + const blobs = await readRows<BlobRow[]>( | |
| 171 | + sql, | |
| 172 | + ` | |
| 173 | + SELECT id, namespace, key | |
| 174 | + FROM blob | |
| 175 | + WHERE namespace IN ( | |
| 176 | + SELECT 'o:' || tenant || '/' || plugin_id | |
| 177 | + FROM integration | |
| 178 | + WHERE plugin_id IN ('google', 'microsoft') | |
| 179 | + ) | |
| 180 | + AND (key LIKE 'spec/%' OR key LIKE 'defs/%') | |
| 181 | + ORDER BY namespace, key | |
| 182 | + `, | |
| 183 | + ); | |
| 184 | + const policies = await readRows<ToolPolicyRow[]>( | |
| 185 | + sql, | |
| 186 | + ` | |
| 187 | + SELECT tenant, owner, subject, id, pattern, action, position, created_at::text, | |
| 188 | + updated_at::text, row_id | |
| 189 | + FROM tool_policy | |
| 190 | + WHERE tenant IN ( | |
| 191 | + SELECT tenant FROM integration | |
| 192 | + WHERE plugin_id IN ('google', 'microsoft') | |
| 193 | + ) | |
| 194 | + ORDER BY tenant, owner, subject, position, id | |
| 195 | + `, | |
| 196 | + ); | |
| 197 | + return { | |
| 198 | + integrations, | |
| 199 | + connections, | |
| 200 | + tools, | |
| 201 | + pluginStorage, | |
| 202 | + blobs, | |
| 203 | + policies, | |
| 204 | + completedTenants, | |
| 205 | + trafficLastTenant, | |
| 206 | + blobBackend, | |
| 207 | + assumeExternalBlobSourcePresent: blobBackend === "external", | |
| 208 | + }; | |
| 209 | +}; | |
| 210 | + | |
| 211 | +const scrubJson = (value: unknown): unknown => { | |
| 212 | + if (Array.isArray(value)) return value.map(scrubJson); | |
| 213 | + if (value && typeof value === "object") { | |
| 214 | + return Object.fromEntries( | |
| 215 | + Object.entries(value) | |
| 216 | + .filter(([, inner]) => inner !== undefined) | |
| 217 | + .map(([key, inner]) => [key, scrubJson(inner)]), | |
| 218 | + ); | |
| 219 | + } | |
| 220 | + return value; | |
| 221 | +}; | |
| 222 | + | |
| 223 | +const stableId = (...parts: readonly string[]): string => { | |
| 224 | + const text = parts.join("\u0000"); | |
| 225 | + let hash = 0; | |
| 226 | + for (let index = 0; index < text.length; index += 1) { | |
| 227 | + hash = (hash * 31 + text.charCodeAt(index)) >>> 0; | |
| 228 | + } | |
| 229 | + return `service_split_${hash.toString(36)}`; | |
| 230 | +}; | |
| 231 | + | |
| 232 | +const operationKeyPrefix = (integration: string): string => | |
| 233 | + `${operationStorageKey(integration, "").split(".").slice(0, 2).join(".")}.%`; | |
| 234 | + | |
| 235 | +export const applyOrg = async (sql: SqlClient, org: OrgPlan): Promise<void> => { | |
| 236 | + if (org.completed) return; | |
| 237 | + if (org.hardErrors.length > 0) { | |
| 238 | + throw new Error(`org ${org.tenantHash} has unresolved hard errors; apply is blocked`); | |
| 239 | + } | |
| 240 | + await sql.begin(async (tx) => { | |
| 241 | + const now = new Date(); | |
| 242 | + for (const copy of org.blobCopies.filter((item) => item.backend === "database")) { | |
| 243 | + const [source] = await tx.unsafe<{ readonly value: string }[]>( | |
| 244 | + "SELECT value FROM blob WHERE namespace = $1 AND key = $2 LIMIT 1", | |
| 245 | + [copy.sourceNamespace, copy.key], | |
| 246 | + ); | |
| 247 | + if (!source) { | |
| 248 | + throw new Error(`Missing source blob ${copy.sourceNamespace}/${copy.key}`); | |
| 249 | + } | |
| 250 | + await tx.unsafe( | |
| 251 | + ` | |
| 252 | + INSERT INTO blob (namespace, key, value, row_id, id) | |
| 253 | + VALUES ($1, $2, $3, $4, $5) | |
| 254 | + ON CONFLICT (id) DO NOTHING | |
| 255 | + `, | |
| 256 | + [ | |
| 257 | + copy.targetNamespace, | |
| 258 | + copy.key, | |
| 259 | + source.value, | |
| 260 | + stableId("blob", org.tenant, copy.targetNamespace, copy.key), | |
| 261 | + JSON.stringify([copy.targetNamespace, copy.key]), | |
| 262 | + ], | |
| 263 | + ); | |
| 264 | + } | |
| 265 | + | |
| 266 | + for (const row of org.integrations.filter((item) => item.action === "create")) { | |
| 267 | + await tx.unsafe( | |
| 268 | + ` | |
| 269 | + INSERT INTO integration ( | |
| 270 | + slug, plugin_id, name, description, config, health_check, config_revised_at, | |
| 271 | + can_remove, can_refresh, created_at, updated_at, row_id, tenant | |
| 272 | + ) | |
| 273 | + VALUES ($1, $2, $3, $4, $5::json, $6::json, NULL, true, true, $7, $7, $8, $9) | |
| 274 | + ON CONFLICT (tenant, slug) DO NOTHING | |
| 275 | + `, | |
| 276 | + [ | |
| 277 | + row.target.slug, | |
| 278 | + row.target.pluginId, | |
| 279 | + row.target.name, | |
| 280 | + row.target.description, | |
| 281 | + JSON.stringify(scrubJson(row.config)), | |
| 282 | + row.healthCheck ? JSON.stringify(scrubJson(row.healthCheck)) : null, | |
| 283 | + now, | |
| 284 | + stableId("integration", org.tenant, row.target.slug), | |
| 285 | + org.tenant, | |
| 286 | + ], | |
| 287 | + ); | |
| 288 | + } | |
| 289 | + | |
| 290 | + for (const row of org.connections.filter((item) => item.action === "clone")) { | |
| 291 | + await tx.unsafe( | |
| 292 | + ` | |
| 293 | + INSERT INTO connection ( | |
| 294 | + integration, name, template, provider, item_ids, identity_label, description, | |
| 295 | + last_health, tools_synced_at, oauth_client, oauth_client_owner, refresh_item_id, | |
| 296 | + expires_at, oauth_scope, oauth_token_url, provider_state, created_at, updated_at, | |
| 297 | + row_id, tenant, owner, subject | |
| 298 | + ) | |
| 299 | + SELECT $1, name, template, provider, item_ids, identity_label, description, | |
| 300 | + NULL, NULL, oauth_client, oauth_client_owner, refresh_item_id, expires_at, | |
| 301 | + oauth_scope, oauth_token_url, provider_state, created_at, $2, $3, tenant, owner, subject | |
| 302 | + FROM connection | |
| 303 | + WHERE tenant = $4 AND owner = $5 AND subject = $6 AND integration = $7 AND name = $8 | |
| 304 | + ON CONFLICT (tenant, owner, subject, integration, name) DO NOTHING | |
| 305 | + `, | |
| 306 | + [ | |
| 307 | + row.targetIntegration, | |
| 308 | + now, | |
| 309 | + stableId( | |
| 310 | + "connection", | |
| 311 | + org.tenant, | |
| 312 | + row.source.owner, | |
| 313 | + row.source.subject, | |
| 314 | + row.targetIntegration, | |
| 315 | + row.source.name, | |
| 316 | + ), | |
| 317 | + org.tenant, | |
| 318 | + row.source.owner, | |
| 319 | + row.source.subject, | |
| 320 | + row.source.integration, | |
| 321 | + row.source.name, | |
| 322 | + ], | |
| 323 | + ); | |
| 324 | + } | |
| 325 | + | |
| 326 | + for (const integration of org.integrations) { | |
| 327 | + for (const contribution of integration.sourceContributions) { | |
| 328 | + for (const toolName of contribution.operationToolNames) { | |
| 329 | + const [operation] = await tx.unsafe< | |
| 330 | + { | |
| 331 | + readonly data: unknown; | |
| 332 | + readonly created_at: string; | |
| 333 | + readonly updated_at: string; | |
| 334 | + }[] | |
| 335 | + >( | |
| 336 | + ` | |
| 337 | + SELECT data, created_at::text, updated_at::text | |
| 338 | + FROM plugin_storage | |
| 339 | + WHERE tenant = $1 | |
| 340 | + AND owner = 'org' | |
| 341 | + AND subject = '' | |
| 342 | + AND plugin_id = $2 | |
| 343 | + AND collection = 'operation' | |
| 344 | + AND ( | |
| 345 | + key = $3 | |
| 346 | + OR ( | |
| 347 | + data::jsonb ->> 'integration' = $4 | |
| 348 | + AND data::jsonb ->> 'toolName' = $5 | |
| 349 | + ) | |
| 350 | + ) | |
| 351 | + LIMIT 1 | |
| 352 | + `, | |
| 353 | + [ | |
| 354 | + org.tenant, | |
| 355 | + contribution.source.plugin_id, | |
| 356 | + operationStorageKey(contribution.source.slug, toolName), | |
| 357 | + contribution.source.slug, | |
| 358 | + toolName, | |
| 359 | + ], | |
| 360 | + ); | |
| 361 | + if (!operation) { | |
| 362 | + throw new Error( | |
| 363 | + `Missing operation row for ${tenantHash(org.tenant)}/${contribution.source.slug}/${toolName}`, | |
| 364 | + ); | |
| 365 | + } | |
| 366 | + await tx.unsafe( | |
| 367 | + ` | |
| 368 | + INSERT INTO plugin_storage ( | |
| 369 | + plugin_id, collection, key, data, created_at, updated_at, row_id, | |
| 370 | + tenant, owner, subject | |
| 371 | + ) | |
| 372 | + VALUES ($1, 'operation', $2, $3::json, $4, $5, $6, $7, 'org', '') | |
| 373 | + ON CONFLICT (tenant, owner, subject, plugin_id, collection, key) | |
| 374 | + DO UPDATE SET data = EXCLUDED.data, updated_at = EXCLUDED.updated_at | |
| 375 | + `, | |
| 376 | + [ | |
| 377 | + integration.target.pluginId, | |
| 378 | + operationStorageKey(integration.target.slug, toolName), | |
| 379 | + JSON.stringify( | |
| 380 | + scrubJson({ | |
| 381 | + ...storageDataRecord(operation), | |
| 382 | + integration: integration.target.slug, | |
| 383 | + toolName, | |
| 384 | + }), | |
| 385 | + ), | |
| 386 | + operation.created_at, | |
| 387 | + now, | |
| 388 | + stableId("operation", org.tenant, integration.target.slug, toolName), | |
| 389 | + org.tenant, | |
| 390 | + ], | |
| 391 | + ); | |
| 392 | + await tx.unsafe( | |
| 393 | + ` | |
| 394 | + INSERT INTO tool ( | |
| 395 | + integration, connection, plugin_id, name, description, input_schema, output_schema, | |
| 396 | + annotations, created_at, updated_at, row_id, tenant, owner, subject | |
| 397 | + ) | |
| 398 | + SELECT $1, connection, $2, name, description, input_schema, output_schema, | |
| 399 | + annotations, created_at, $3, concat($4::text, '_', row_id), tenant, owner, subject | |
| 400 | + FROM tool | |
| 401 | + WHERE tenant = $5 AND integration = $6 AND name = $7 | |
| 402 | + ON CONFLICT (tenant, owner, subject, integration, connection, name) DO NOTHING | |
| 403 | + `, | |
| 404 | + [ | |
| 405 | + integration.target.slug, | |
| 406 | + integration.target.pluginId, | |
| 407 | + now, | |
| 408 | + stableId("tool", org.tenant, integration.target.slug), | |
| 409 | + org.tenant, | |
| 410 | + contribution.source.slug, | |
| 411 | + toolName, | |
| 412 | + ], | |
| 413 | + ); | |
| 414 | + } | |
| 415 | + } | |
| 416 | + } | |
| 417 | + | |
| 418 | + for (const policy of org.policies.filter((item) => item.action === "rewrite")) { | |
| 419 | + await tx.unsafe( | |
| 420 | + "DELETE FROM tool_policy WHERE tenant = $1 AND owner = $2 AND subject = $3 AND id = $4", | |
| 421 | + [org.tenant, policy.policy.owner, policy.policy.subject, policy.policy.id], | |
| 422 | + ); | |
| 423 | + for (const [index, pattern] of policy.afterPatterns.entries()) { | |
| 424 | + await tx.unsafe( | |
| 425 | + ` | |
| 426 | + INSERT INTO tool_policy ( | |
| 427 | + id, pattern, action, position, created_at, updated_at, row_id, tenant, owner, subject | |
| 428 | + ) | |
| 429 | + VALUES ($1, $2, $3, $4, $5, $5, $6, $7, $8, $9) | |
| 430 | + ON CONFLICT (tenant, owner, subject, id) DO NOTHING | |
| 431 | + `, | |
| 432 | + [ | |
| 433 | + policy.afterPatterns.length === 1 | |
| 434 | + ? policy.policy.id | |
| 435 | + : `${policy.policy.id}_${index + 1}`, | |
| 436 | + pattern, | |
| 437 | + policy.policy.action, | |
| 438 | + policy.policy.position, | |
| 439 | + now, | |
| 440 | + stableId("policy", org.tenant, policy.policy.id, pattern), | |
| 441 | + org.tenant, | |
| 442 | + policy.policy.owner, | |
| 443 | + policy.policy.subject, | |
| 444 | + ], | |
| 445 | + ); | |
| 446 | + } | |
| 447 | + } | |
| 448 | + | |
| 449 | + for (const monolith of org.deleteMonoliths) { | |
| 450 | + await tx.unsafe("DELETE FROM tool WHERE tenant = $1 AND integration = $2", [ | |
| 451 | + org.tenant, | |
| 452 | + monolith.slug, | |
| 453 | + ]); | |
| 454 | + await tx.unsafe("DELETE FROM connection WHERE tenant = $1 AND integration = $2", [ | |
| 455 | + org.tenant, | |
| 456 | + monolith.slug, | |
| 457 | + ]); | |
| 458 | + await tx.unsafe( | |
| 459 | + "DELETE FROM integration WHERE tenant = $1 AND slug = $2 AND plugin_id = $3", | |
| 460 | + [org.tenant, monolith.slug, monolith.plugin_id], | |
| 461 | + ); | |
| 462 | + await tx.unsafe( | |
| 463 | + ` | |
| 464 | + DELETE FROM plugin_storage | |
| 465 | + WHERE tenant = $1 | |
| 466 | + AND owner = 'org' | |
| 467 | + AND subject = '' | |
| 468 | + AND plugin_id = $2 | |
| 469 | + AND collection = 'operation' | |
| 470 | + AND ( | |
| 471 | + data::jsonb ->> 'integration' = $3 | |
| 472 | + OR key LIKE $4 | |
| 473 | + ) | |
| 474 | + `, | |
| 475 | + [org.tenant, monolith.plugin_id, monolith.slug, operationKeyPrefix(monolith.slug)], | |
| 476 | + ); | |
| 477 | + } | |
| 478 | + | |
| 479 | + await tx.unsafe( | |
| 480 | + ` | |
| 481 | + INSERT INTO ${LEDGER_TABLE} (tenant, time_completed) | |
| 482 | + VALUES ($1, $2) | |
| 483 | + ON CONFLICT (tenant) DO UPDATE SET time_completed = EXCLUDED.time_completed | |
| 484 | + `, | |
| 485 | + [org.tenant, Date.now()], | |
| 486 | + ); | |
| 487 | + }); | |
| 488 | +}; | |
| 489 | + | |
| 490 | +const writeDryRun = (planInput: MigrationInput): void => { | |
| 491 | + const plan = planMigration({ | |
| 492 | + ...planInput, | |
| 493 | + collectPolicyErrors: true, | |
| 494 | + orphanPolicyMode: "retarget_all", | |
| 495 | + }); | |
| 496 | + const neverWiden = verifyPolicyRewriteNeverWidens(plan, planInput); | |
| 497 | + const r2Copies = plan.orgs | |
| 498 | + .filter((org) => !org.completed && org.hardErrors.length === 0) | |
| 499 | + .flatMap((org) => | |
| 500 | + org.blobCopies | |
| 501 | + .filter((copy) => copy.backend === "external") | |
| 502 | + .map((copy) => ({ | |
| 503 | + tenantHash: org.tenantHash, | |
| 504 | + tenant: org.tenant, | |
| 505 | + sourceKey: copy.sourceObjectName, | |
| 506 | + destKey: copy.targetObjectName, | |
| 507 | + })), | |
| 508 | + ); | |
| 509 | + mkdirSync(outputDir, { recursive: true }); | |
| 510 | + for (const org of plan.orgs) { | |
| 511 | + writeFileSync(`${outputDir}/org-${org.tenantHash}.md`, renderOrgDiff(org)); | |
| 512 | + } | |
| 513 | + writeFileSync(`${outputDir}/r2-copy-manifest.json`, `${JSON.stringify(r2Copies, null, 2)}\n`); | |
| 514 | + writeFileSync( | |
| 515 | + `${outputDir}/summary.md`, | |
| 516 | + `${renderSummary(plan)}\npolicy_never_widen=${neverWiden.ok}\npolicy_never_widen_checked=${neverWiden.checkedPolicies}\nr2_copy_manifest=${outputDir}/r2-copy-manifest.json\nr2_copy_count=${r2Copies.length}\nr2_copy_command=wrangler r2 object get <bucket>/<sourceKey> | wrangler r2 object put <bucket>/<destKey> --pipe\n`, | |
| 517 | + ); | |
| 518 | + console.log(renderSummary(plan)); | |
| 519 | + console.log(`policy_never_widen=${neverWiden.ok}`); | |
| 520 | + console.log(`policy_never_widen_checked=${neverWiden.checkedPolicies}`); | |
| 521 | + console.log(`r2_copy_manifest=${outputDir}/r2-copy-manifest.json`); | |
| 522 | + console.log(`r2_copy_count=${r2Copies.length}`); | |
| 523 | + console.log( | |
| 524 | + "r2_copy_command=wrangler r2 object get <bucket>/<sourceKey> | wrangler r2 object put <bucket>/<destKey> --pipe", | |
| 525 | + ); | |
| 526 | + console.log(`diff_dir=${outputDir}`); | |
| 527 | + if (!neverWiden.ok) { | |
| 528 | + throw new Error( | |
| 529 | + `Policy rewrite failed coverage checks: widened=${neverWiden.widened.length}, narrowed=${neverWiden.narrowed.length}`, | |
| 530 | + ); | |
| 531 | + } | |
| 532 | + if ( | |
| 533 | + plan.summary.integrationsMissingSpecBlob > 0 || | |
| 534 | + plan.summary.integrationsMissingDefsBlob > 0 | |
| 535 | + ) { | |
| 536 | + throw new Error( | |
| 537 | + `Serving blob check failed: missing spec=${plan.summary.integrationsMissingSpecBlob}, missing defs=${plan.summary.integrationsMissingDefsBlob}`, | |
| 538 | + ); | |
| 539 | + } | |
| 540 | + const zeroOperationIntegrations = plan.orgs | |
| 541 | + .filter((org) => !org.completed && org.hardErrors.length === 0) | |
| 542 | + .flatMap((org) => org.integrations) | |
| 543 | + .filter( | |
| 544 | + (integration) => | |
| 545 | + integration.servingState.operationsToBuild === 0 && | |
| 546 | + !integration.servingState.expectedZeroOperations, | |
| 547 | + ); | |
| 548 | + if (zeroOperationIntegrations.length > 0) { | |
| 549 | + throw new Error( | |
| 550 | + `Serving operation check failed for ${zeroOperationIntegrations.length} planned integration(s)`, | |
| 551 | + ); | |
| 552 | + } | |
| 553 | +}; | |
| 554 | + | |
| 555 | +const main = async (): Promise<void> => { | |
| 556 | + if (inputJson) { | |
| 557 | + const input = parseInputJson(inputJson); | |
| 558 | + writeDryRun( | |
| 559 | + filterInputByOrg( | |
| 560 | + { | |
| 561 | + ...input, | |
| 562 | + trafficLastTenant: trafficLastTenant ?? input.trafficLastTenant, | |
| 563 | + }, | |
| 564 | + orgFilter, | |
| 565 | + ), | |
| 566 | + ); | |
| 567 | + return; | |
| 568 | + } | |
| 569 | + if (!databaseUrl) { | |
| 570 | + throw new Error("Set DATABASE_URL or pass --input-json"); | |
| 571 | + } | |
| 572 | + const usesLocalDatabase = databaseUrl.includes("127.0.0.1") || databaseUrl.includes("localhost"); | |
| 573 | + const sql = postgres(databaseUrl, { | |
| 574 | + max: 1, | |
| 575 | + prepare: false, | |
| 576 | + ...(usesLocalDatabase ? {} : { ssl: "require" as const }), | |
| 577 | + }) as unknown as SqlClient; | |
| 578 | + try { | |
| 579 | + const input = filterInputByOrg( | |
| 580 | + await readDatabaseInput(sql, !apply, usesLocalDatabase ? "database" : "external"), | |
| 581 | + orgFilter, | |
| 582 | + ); | |
| 583 | + if (!apply) { | |
| 584 | + writeDryRun(input); | |
| 585 | + return; | |
| 586 | + } | |
| 587 | + // Orphan block/require_approval policies (a guardrail targeting a service | |
| 588 | + // the org never derived, e.g. a gmail send-block on a Search-Console-only | |
| 589 | + // org) retarget to every derived service as dormant rows rather than | |
| 590 | + // blocking apply. Approved by Rhys 2026-07-08; matches the boot rail. | |
| 591 | + const plan = planMigration({ ...input, orphanPolicyMode: "retarget_all" }); | |
| 592 | + const neverWiden = verifyPolicyRewriteNeverWidens(plan, input); | |
| 593 | + if (!neverWiden.ok) { | |
| 594 | + throw new Error( | |
| 595 | + `Policy rewrite failed coverage checks: widened=${neverWiden.widened.length}, narrowed=${neverWiden.narrowed.length}`, | |
| 596 | + ); | |
| 597 | + } | |
| 598 | + if ( | |
| 599 | + plan.summary.integrationsMissingSpecBlob > 0 || | |
| 600 | + plan.summary.integrationsMissingDefsBlob > 0 | |
| 601 | + ) { | |
| 602 | + throw new Error( | |
| 603 | + `Serving blob check failed: missing spec=${plan.summary.integrationsMissingSpecBlob}, missing defs=${plan.summary.integrationsMissingDefsBlob}`, | |
| 604 | + ); | |
| 605 | + } | |
| 606 | + const zeroOperationIntegrations = plan.orgs | |
| 607 | + .filter((org) => !org.completed && org.hardErrors.length === 0) | |
| 608 | + .flatMap((org) => org.integrations) | |
| 609 | + .filter( | |
| 610 | + (integration) => | |
| 611 | + integration.servingState.operationsToBuild === 0 && | |
| 612 | + !integration.servingState.expectedZeroOperations, | |
| 613 | + ); | |
| 614 | + if (zeroOperationIntegrations.length > 0) { | |
| 615 | + throw new Error( | |
| 616 | + `Serving operation check failed for ${zeroOperationIntegrations.length} planned integration(s)`, | |
| 617 | + ); | |
| 618 | + } | |
| 619 | + const requiredR2Copies = plan.orgs | |
| 620 | + .filter((org) => !org.completed && org.hardErrors.length === 0) | |
| 621 | + .flatMap((org) => org.blobCopies.filter((copy) => copy.backend === "external")); | |
| 622 | + if (requiredR2Copies.length > 0 && !r2CopiesDone) { | |
| 623 | + throw new Error( | |
| 624 | + `External R2 blob copies are required before apply (${requiredR2Copies.length}); run dry-run, execute r2-copy-manifest.json, then pass --r2-copies-done`, | |
| 625 | + ); | |
| 626 | + } | |
| 627 | + for (const org of plan.orgs) { | |
| 628 | + await applyOrg(sql, org); | |
| 629 | + console.log(`applied_org=${tenantHash(org.tenant)}`); | |
| 630 | + } | |
| 631 | + console.log(renderSummary(plan)); | |
| 632 | + } finally { | |
| 633 | + await sql.end({ timeout: 0 }); | |
| 634 | + } | |
| 635 | +}; | |
| 636 | + | |
| 637 | +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { | |
| 638 | + await main(); | |
| 639 | +} | |