Skip to content

Commit bfbf1c9

Browse files
committed
Add a streaming backfill that moves inline specs into the blob store
Walks integration rows whose config still inlines spec text (openapi spec / graphql introspectionJson), writes each to the blob backend under the runtime's exact object naming (o:<tenant>/<plugin>/spec/<sha256>), verifies the round-trip, and rewrites the config to the hash pointer — one row at a time, since the inline specs total ~500 MB and must never be loaded at once. The blob target must match the deployed worker: --target r2 --bucket <name> writes through wrangler to the bucket the BLOBS binding reads; --target db writes the Postgres blob table for setups without R2. Idempotent (pointer rows plan zero work, blobs are content-addressed) with --dry-run and --limit for a canary pass. Every rewrite is decoded under the deployed runtime before anything is written.
1 parent 6e8705d commit bfbf1c9

2 files changed

Lines changed: 222 additions & 0 deletions

File tree

apps/cloud/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
"db:migrate-auth:dev": "op run --env-file=.env.op -- bun run scripts/migrate-auth-configs.ts",
1818
"db:repartition-vault:prod": "op run --env-file=.env.production -- bun run scripts/repartition-vault-metadata.ts",
1919
"db:repartition-vault:dev": "op run --env-file=.env.op -- bun run scripts/repartition-vault-metadata.ts",
20+
"db:migrate-specs:prod": "op run --env-file=.env.production -- bun run scripts/migrate-specs-to-blobs.ts",
21+
"db:migrate-specs:dev": "op run --env-file=.env.op -- bun run scripts/migrate-specs-to-blobs.ts",
2022
"db:migrate:dev": "op run --env-file=.env.op -- bun --bun ../../node_modules/.bun/node_modules/drizzle-kit/bin.cjs migrate",
2123
"build": "node scripts/build.mjs",
2224
"preview": "vite preview",
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
/* oxlint-disable executor/no-try-catch-or-throw, executor/no-json-parse, executor/no-error-constructor -- boundary: out-of-band migration script over a raw postgres connection + wrangler subprocess */
2+
// ---------------------------------------------------------------------------
3+
// One-off data migration: move inline spec text out of `integration.config`
4+
// into the blob store the deployed worker reads.
5+
//
6+
// openapi: config.spec -> blob spec/<sha256>, config.specHash
7+
// graphql: config.introspectionJson -> blob introspection/<sha256>, config.introspectionHash
8+
//
9+
// Blob object names follow the runtime seam exactly
10+
// (`pluginBlobStore` + `makeR2BlobStore`): `o:<tenant>/<plugin>/<key>`.
11+
//
12+
// The blob TARGET must match what the worker is deployed with:
13+
// --target r2 --bucket executor-cloud-blobs # wrangler-authed R2 writes
14+
// --target db # the Postgres `blob` table
15+
// Run AFTER the worker that reads `specHash` is deployable, BEFORE relying
16+
// on it for latency (legacy inline rows keep working either way).
17+
//
18+
// bun run db:migrate-specs:prod -- --target r2 --bucket executor-cloud-blobs
19+
// bun run db:migrate-specs:dev -- --target db
20+
//
21+
// Rows are processed ONE AT A TIME (the inline specs total ~500 MB in prod;
22+
// never load them all). Idempotent: pointer-shaped rows plan zero work, blob
23+
// writes are content-addressed, and a re-run resumes where it left off.
24+
// Pass --dry-run to print the plan without writing, --limit N for a canary.
25+
// ---------------------------------------------------------------------------
26+
27+
import { createHash } from "node:crypto";
28+
import { execFileSync } from "node:child_process";
29+
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
30+
import { tmpdir } from "node:os";
31+
import { join } from "node:path";
32+
33+
import postgres from "postgres";
34+
import { Option } from "effect";
35+
import { decodeOpenApiIntegrationConfig } from "@executor-js/plugin-openapi";
36+
import { decodeGraphqlIntegrationConfigOption } from "@executor-js/plugin-graphql";
37+
38+
// --- per-plugin shape: which inline field moves, to which blob key prefix ---
39+
40+
interface SpecField {
41+
readonly inlineField: string;
42+
readonly hashField: string;
43+
readonly blobKeyPrefix: string;
44+
readonly decodes: (config: unknown) => boolean;
45+
}
46+
47+
const SPEC_FIELDS: Record<string, SpecField> = {
48+
openapi: {
49+
inlineField: "spec",
50+
hashField: "specHash",
51+
blobKeyPrefix: "spec",
52+
decodes: (config) => decodeOpenApiIntegrationConfig(config) !== null,
53+
},
54+
graphql: {
55+
inlineField: "introspectionJson",
56+
hashField: "introspectionHash",
57+
blobKeyPrefix: "introspection",
58+
decodes: (config) => Option.isSome(decodeGraphqlIntegrationConfigOption(config)),
59+
},
60+
};
61+
62+
// --- args ---
63+
64+
const argValue = (name: string): string | undefined => {
65+
const index = process.argv.indexOf(name);
66+
return index >= 0 ? process.argv[index + 1] : undefined;
67+
};
68+
69+
const dryRun = process.argv.includes("--dry-run");
70+
const target = argValue("--target") ?? "r2";
71+
const bucket = argValue("--bucket");
72+
const limit = Number(argValue("--limit") ?? Infinity);
73+
74+
if (target !== "r2" && target !== "db") {
75+
console.error(`--target must be "r2" or "db", got "${target}"`);
76+
process.exit(1);
77+
}
78+
if (target === "r2" && !bucket && !dryRun) {
79+
console.error("--target r2 requires --bucket <name> (e.g. executor-cloud-blobs)");
80+
process.exit(1);
81+
}
82+
83+
const connectionString = process.env.DATABASE_URL;
84+
if (!connectionString) {
85+
console.error("DATABASE_URL is not set");
86+
process.exit(1);
87+
}
88+
89+
// Direct (non-Hyperdrive) connection — PlanetScale requires TLS.
90+
const sql = postgres(connectionString, { max: 1, prepare: false, ssl: "require" });
91+
92+
const sha256Hex = (text: string): string => createHash("sha256").update(text, "utf8").digest("hex");
93+
94+
// Runtime object naming, byte-for-byte: `pluginBlobStore` namespaces by
95+
// `o:<tenant>/<pluginId>` and `makeR2BlobStore` joins `namespace/key`; the
96+
// FumaDB store keys rows by `JSON.stringify([namespace, key])`.
97+
const blobNamespace = (tenant: string, pluginId: string) => `o:${tenant}/${pluginId}`;
98+
const fumaBlobId = (namespace: string, key: string) => JSON.stringify([namespace, key]);
99+
100+
// --- blob writers (verify-on-write: a pointer must never replace a spec the
101+
// --- store can't serve back) ---
102+
103+
const tempDir = mkdtempSync(join(tmpdir(), "spec-blobs-"));
104+
105+
const putBlobR2 = (objectName: string, value: string): void => {
106+
const file = join(tempDir, "blob-upload");
107+
writeFileSync(file, value, "utf8");
108+
execFileSync(
109+
"wrangler",
110+
["r2", "object", "put", `${bucket}/${objectName}`, "--file", file, "--remote"],
111+
{ stdio: ["ignore", "ignore", "inherit"] },
112+
);
113+
const verifyFile = join(tempDir, "blob-verify");
114+
execFileSync(
115+
"wrangler",
116+
["r2", "object", "get", `${bucket}/${objectName}`, "--file", verifyFile, "--remote"],
117+
{ stdio: ["ignore", "ignore", "inherit"] },
118+
);
119+
const roundTripped = readFileSync(verifyFile, "utf8");
120+
if (sha256Hex(roundTripped) !== sha256Hex(value)) {
121+
throw new Error(`R2 round-trip mismatch for ${objectName}`);
122+
}
123+
};
124+
125+
const putBlobDb = async (namespace: string, key: string, value: string): Promise<void> => {
126+
const id = fumaBlobId(namespace, key);
127+
await sql`
128+
INSERT INTO blob (namespace, key, value, id)
129+
VALUES (${namespace}, ${key}, ${value}, ${id})
130+
ON CONFLICT (id) DO UPDATE SET value = EXCLUDED.value
131+
`;
132+
};
133+
134+
// --- main ---
135+
136+
try {
137+
// Plan pass: metadata only — never load every config at once.
138+
const candidates = await sql<
139+
{ row_id: string; tenant: string; slug: string; plugin_id: string; config_bytes: number }[]
140+
>`
141+
SELECT row_id, tenant, slug, plugin_id, length(config::text) AS config_bytes
142+
FROM integration
143+
WHERE (plugin_id = 'openapi' AND config::jsonb ? 'spec')
144+
OR (plugin_id = 'graphql' AND config::jsonb ? 'introspectionJson')
145+
ORDER BY row_id
146+
`;
147+
148+
const totalBytes = candidates.reduce((sum, row) => sum + Number(row.config_bytes), 0);
149+
console.log(
150+
`${candidates.length} row(s) carry inline spec text (${(totalBytes / 1024 / 1024).toFixed(1)} MB total config)`,
151+
);
152+
153+
const work = candidates.slice(0, Number.isFinite(limit) ? limit : candidates.length);
154+
if (work.length < candidates.length) console.log(`--limit: processing first ${work.length}`);
155+
156+
if (dryRun) {
157+
for (const row of work) {
158+
console.log(
159+
` would move ${row.plugin_id} ${row.tenant}/${row.slug} (${(Number(row.config_bytes) / 1024).toFixed(0)} kB) -> blob`,
160+
);
161+
}
162+
process.exit(0);
163+
}
164+
165+
let moved = 0;
166+
const writtenObjects = new Set<string>();
167+
168+
for (const candidate of work) {
169+
const field = SPEC_FIELDS[candidate.plugin_id];
170+
if (!field) continue;
171+
172+
// Fetch this one row's config — one multi-MB value in memory at a time.
173+
const [row] = await sql<{ config: unknown }[]>`
174+
SELECT config FROM integration WHERE row_id = ${candidate.row_id}
175+
`;
176+
if (!row) continue;
177+
const config = typeof row.config === "string" ? JSON.parse(row.config) : row.config;
178+
if (typeof config !== "object" || config === null) continue;
179+
180+
const inline = (config as Record<string, unknown>)[field.inlineField];
181+
if (typeof inline !== "string") continue; // already migrated (or foreign shape)
182+
183+
const hash = sha256Hex(inline);
184+
const namespace = blobNamespace(candidate.tenant, candidate.plugin_id);
185+
const blobKey = `${field.blobKeyPrefix}/${hash}`;
186+
const objectName = `${namespace}/${blobKey}`;
187+
188+
// Audit BEFORE writing: the rewritten config must decode under the
189+
// deployed runtime, or this row would read as "no usable config".
190+
const { [field.inlineField]: _removed, ...rest } = config as Record<string, unknown>;
191+
const nextConfig = { ...rest, [field.hashField]: hash };
192+
if (!field.decodes(nextConfig)) {
193+
console.error(`SKIP ${candidate.tenant}/${candidate.slug}: rewrite would not decode`);
194+
continue;
195+
}
196+
197+
// Content-addressed: identical specs (same tenant) write once.
198+
if (!writtenObjects.has(objectName)) {
199+
if (target === "r2") putBlobR2(objectName, inline);
200+
else await putBlobDb(namespace, blobKey, inline);
201+
writtenObjects.add(objectName);
202+
}
203+
204+
await sql`
205+
UPDATE integration
206+
SET config = ${sql.json(nextConfig as never)}
207+
WHERE row_id = ${candidate.row_id}
208+
`;
209+
210+
moved += 1;
211+
console.log(
212+
`moved ${candidate.plugin_id} ${candidate.tenant}/${candidate.slug} -> ${field.blobKeyPrefix}/${hash.slice(0, 12)}… (${moved}/${work.length})`,
213+
);
214+
}
215+
216+
console.log(`done: ${moved} row(s) rewritten, ${writtenObjects.size} blob object(s) written`);
217+
} finally {
218+
rmSync(tempDir, { recursive: true, force: true });
219+
await sql.end();
220+
}

0 commit comments

Comments
 (0)