返回 DeepSeek-Reasonix
migrate-firebase-data.mjs
根目录 / workers / crash-report / scripts / migrate-firebase-data.mjs
1 #!/usr/bin/env node
2
3 import { createHash, createPrivateKey, createSign } from "node:crypto";
4 import { chmodSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
5 import { spawnSync } from "node:child_process";
6 import { fileURLToPath } from "node:url";
7 import path from "node:path";
8 import { parseWranglerRows } from "./apply-diagnostics-v2.mjs";
9 import {
10 assessMigrationCapacity,
11 accumulateMigrationCapacityAssessment,
12 createMigrationCapacityAssessment,
13 finalizeMigrationCapacityAssessment,
14 } from "./firebase-migration-assessment.mjs";
15
16 export { assessMigrationCapacity };
17
18 const tokenURL = "https://oauth2.googleapis.com/token";
19 const scope = "https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/firebase.database";
20 const PAGE_SIZE = 200;
21 const MAX_PASSES = 3;
22 const STORAGE_BUDGET = 700 * 1024 * 1024;
23 const RESERVATIONS = { active: 640 * 1024, compacted: 128 * 1024, archived: 0 };
24 // One page can contain six retained 96 KiB reports for each of 200 groups.
25 // Keep the capture bounded while leaving room for Wrangler's JSON envelope
26 // and escaping; Node's default child-process buffer is too small for this path.
27 export const wranglerD1MaxBufferBytes = 192 * 1024 * 1024;
28 export const firebaseOAuthGrantType = "urn:ietf:params:oauth:grant-type:jwt-bearer";
29
30 function base64url(value) {
31 return Buffer.from(value).toString("base64url");
32 }
33
34 function text(value) {
35 return typeof value === "string" ? value : value == null ? "" : String(value);
36 }
37
38 function json(value, fallback) {
39 if (typeof value !== "string" || value === "") return fallback;
40 try { return JSON.parse(value); } catch { return fallback; }
41 }
42
43 function eventID(fingerprint, id) {
44 return createHash("sha256").update(`firebase-migration\n${fingerprint}\n${id}`).digest("hex").slice(0, 32);
45 }
46
47 function sample(row, groupCount, sampleEpoch = 1) {
48 return {
49 eventId: eventID(text(row.fingerprint), text(row.id)),
50 receivedAt: text(row.created_at),
51 groupCount,
52 writerGeneration: 0,
53 sampleEpoch,
54 kind: text(row.kind),
55 version: text(row.version),
56 os: text(row.os),
57 arch: text(row.arch),
58 message: text(row.message),
59 device: json(row.device, {}),
60 source: text(row.source),
61 label: text(row.label),
62 errorType: text(row.error_type),
63 errorMessage: text(row.error_message),
64 topFrame: text(row.top_frame),
65 buildCommit: text(row.build_commit),
66 channel: text(row.channel),
67 language: text(row.language),
68 view: text(row.view),
69 breadcrumbs: json(row.breadcrumbs, []),
70 componentStack: text(row.component_stack),
71 stack: text(row.stack),
72 occurredAt: text(row.occurred_at),
73 webview2: json(row.webview2, undefined),
74 webRuntime: json(row.web_runtime, undefined),
75 };
76 }
77
78 export function classifyMigrationGroup(row, now = new Date()) {
79 if (row.status !== "resolved" && row.status !== "ignored") return "active";
80 const age = now.getTime() - new Date(text(row.last_seen)).getTime();
81 if (!Number.isFinite(age) || age < 30 * 86400_000) return "active";
82 return age >= 60 * 86400_000 ? "archived" : "compacted";
83 }
84
85 export function canonicalJSONString(value) {
86 if (Array.isArray(value)) return `[${value.map(canonicalJSONString).join(",")}]`;
87 if (value && typeof value === "object") {
88 return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJSONString(value[key])}`).join(",")}}`;
89 }
90 return JSON.stringify(value);
91 }
92
93 export function contentDigest(value) {
94 return createHash("sha256").update(canonicalJSONString(value)).digest("hex");
95 }
96
97 export function buildFirebaseGroups(groupRows, reportRows, now = new Date()) {
98 const reports = new Map();
99 for (const row of reportRows) {
100 const fingerprint = text(row.fingerprint);
101 const values = reports.get(fingerprint) ?? [];
102 values.push(row);
103 reports.set(fingerprint, values);
104 }
105 const output = new Map();
106 for (const row of groupRows) {
107 const fingerprint = text(row.fingerprint);
108 const count = Number(row.count) || 0;
109 const state = classifyMigrationGroup(row, now);
110 if (state === "archived") {
111 output.set(fingerprint, { state, value: null, firstEventId: "", reservedBytes: 0 });
112 continue;
113 }
114 const retained = (reports.get(fingerprint) ?? []).sort((a, b) => Number(a.id) - Number(b.id));
115 const first = retained[0];
116 const latestRows = state === "active" ? retained.slice(-5) : [];
117 const latest = {};
118 latestRows.forEach((report, index) => {
119 const sampleCount = count - latestRows.length + index + 1;
120 latest[(sampleCount - 1) % 5] = sample(report, sampleCount);
121 });
122 const samples = {
123 ...(first ? { first: sample(first, 1) } : {}),
124 ...(latestRows.length ? { latest } : {}),
125 };
126 const value = {
127 meta: {
128 fingerprint,
129 kind: text(row.kind),
130 count,
131 firstSeen: text(row.first_seen),
132 lastSeen: text(row.last_seen),
133 firstVersion: text(row.first_version),
134 lastVersion: text(row.last_version),
135 status: text(row.status),
136 title: text(row.title),
137 source: text(row.source),
138 label: text(row.label),
139 errorType: text(row.error_type),
140 topFrame: text(row.top_frame),
141 severity: text(row.severity),
142 lastOS: text(row.last_os),
143 lastArch: text(row.last_arch),
144 lastBuildCommit: text(row.last_build_commit),
145 lastChannel: text(row.last_channel),
146 regressedAt: text(row.regressed_at),
147 writerGeneration: 0,
148 sampleEpoch: 1,
149 sampleState: state,
150 },
151 ...(Object.keys(samples).length ? { samples } : {}),
152 };
153 output.set(fingerprint, {
154 state,
155 value,
156 firstEventId: first ? eventID(fingerprint, text(first.id)) : "",
157 reservedBytes: RESERVATIONS[state],
158 });
159 }
160 return output;
161 }
162
163 export function runWrangler(projectDir, database, query, spawn = spawnSync) {
164 const executable = process.platform === "win32" ? "wrangler.cmd" : "wrangler";
165 const wrangler = path.join(projectDir, "node_modules", ".bin", executable);
166 const result = spawn(wrangler, ["d1", "execute", database, "--remote", "--json", "--command", query], {
167 cwd: projectDir,
168 encoding: "utf8",
169 env: process.env,
170 maxBuffer: wranglerD1MaxBufferBytes,
171 stdio: ["ignore", "pipe", "inherit"],
172 });
173 if (result.error) throw result.error;
174 if (result.status !== 0) throw new Error(`wrangler exited with status ${result.status}`);
175 return parseWranglerRows(result.stdout ?? "");
176 }
177
178 function sqlText(value) {
179 return `'${String(value).replaceAll("'", "''")}'`;
180 }
181
182 function validateFingerprint(value) {
183 if (!/^(?:dev:)?[0-9a-f]{64}$/.test(value)) throw new Error("D1 returned an invalid fingerprint");
184 return value;
185 }
186
187 function readPage(projectDir, database, cursor) {
188 const rows = runWrangler(
189 projectDir,
190 database,
191 `SELECT * FROM groups WHERE fingerprint > ${sqlText(cursor)} ORDER BY fingerprint LIMIT ${PAGE_SIZE}`,
192 );
193 if (!rows.length) return { groups: [], reports: [], states: [] };
194 const fingerprints = rows.map((row) => validateFingerprint(text(row.fingerprint)));
195 const reports = runWrangler(
196 projectDir,
197 database,
198 `SELECT * FROM reports WHERE fingerprint IN (${fingerprints.map(sqlText).join(",")}) ORDER BY fingerprint, id`,
199 );
200 const states = runWrangler(
201 projectDir,
202 database,
203 `SELECT fingerprint, sample_state, sample_epoch, epoch_first_event_id, reserved_bytes, last_seen
204 FROM firebase_crash_group_state WHERE fingerprint IN (${fingerprints.map(sqlText).join(",")})`,
205 );
206 return { groups: rows, reports, states };
207 }
208
209 async function accessToken(email, privateKey) {
210 const now = Math.floor(Date.now() / 1000);
211 const header = base64url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
212 const claims = base64url(JSON.stringify({ iss: email, scope, aud: tokenURL, iat: now, exp: now + 3600 }));
213 const unsigned = `${header}.${claims}`;
214 const signer = createSign("RSA-SHA256");
215 signer.update(unsigned);
216 signer.end();
217 const assertion = `${unsigned}.${signer.sign(createPrivateKey(privateKey.replace(/\\n/g, "\n")), "base64url")}`;
218 const response = await fetch(tokenURL, {
219 method: "POST",
220 headers: { "content-type": "application/x-www-form-urlencoded" },
221 body: new URLSearchParams({ grant_type: firebaseOAuthGrantType, assertion }),
222 signal: AbortSignal.timeout(10_000),
223 });
224 if (!response.ok) throw new Error(`Firebase OAuth failed with ${response.status}`);
225 const body = await response.json();
226 if (typeof body.access_token !== "string") throw new Error("Firebase OAuth response omitted access_token");
227 return body.access_token;
228 }
229
230 function databaseURL(raw) {
231 const url = new URL(raw);
232 if (url.protocol !== "https:" || !(url.hostname.endsWith(".firebaseio.com") || url.hostname.endsWith(".firebasedatabase.app"))) {
233 throw new Error("FIREBASE_DATABASE_URL must be an approved Realtime Database host");
234 }
235 url.search = "";
236 url.hash = "";
237 return url.toString().replace(/\/$/, "");
238 }
239
240 async function firebaseRequest(baseURL, token, fingerprint, init = {}) {
241 const silent = init.method && init.method !== "GET" ? "?print=silent" : "";
242 return fetch(`${baseURL}/groups/${encodeURIComponent(fingerprint)}.json${silent}`, {
243 ...init,
244 headers: { authorization: `Bearer ${token}`, "content-type": "application/json", ...init.headers },
245 signal: AbortSignal.timeout(10_000),
246 });
247 }
248
249 async function readAndVerify(baseURL, token, fingerprint, expected) {
250 const response = await firebaseRequest(baseURL, token, fingerprint, { method: "GET" });
251 if (!response.ok) throw new Error(`Firebase migration readback failed with ${response.status}`);
252 const actual = await response.json();
253 const actualHash = contentDigest(actual);
254 const expectedHash = contentDigest(expected);
255 if (actualHash !== expectedHash) {
256 throw new Error(`Firebase readback digest mismatch for ${fingerprint.slice(0, 8)} (${actualHash.slice(0, 12)})`);
257 }
258 return actualHash;
259 }
260
261 async function reconcileGroup(baseURL, token, fingerprint, entry, apply) {
262 if (apply) {
263 const response = await firebaseRequest(baseURL, token, fingerprint, entry.value === null
264 ? { method: "DELETE" }
265 : { method: "PUT", body: JSON.stringify(entry.value) });
266 if (!response.ok) throw new Error(`Firebase migration write failed with ${response.status}`);
267 }
268 return readAndVerify(baseURL, token, fingerprint, entry.value);
269 }
270
271 function stateSQL(fingerprint, row, entry, now) {
272 const compactedAt = entry.state === "compacted" ? now : "";
273 const archivedAt = entry.state === "archived" ? now : "";
274 const reason = entry.state === "archived" ? "retention" : "";
275 return `INSERT INTO firebase_crash_group_state (
276 fingerprint, sample_state, sample_epoch, epoch_first_event_id, reserved_bytes,
277 last_seen, compacted_at, archived_at, archive_reason
278 ) VALUES (
279 ${sqlText(fingerprint)}, ${sqlText(entry.state)}, 1, ${sqlText(entry.firstEventId)},
280 ${entry.reservedBytes}, ${sqlText(text(row.last_seen))}, ${sqlText(compactedAt)},
281 ${sqlText(archivedAt)}, ${sqlText(reason)}
282 ) ON CONFLICT (fingerprint) DO UPDATE SET
283 sample_state = excluded.sample_state,
284 sample_epoch = excluded.sample_epoch,
285 epoch_first_event_id = excluded.epoch_first_event_id,
286 reserved_bytes = excluded.reserved_bytes,
287 last_seen = excluded.last_seen,
288 compacted_at = excluded.compacted_at,
289 archived_at = excluded.archived_at,
290 archive_reason = excluded.archive_reason`;
291 }
292
293 function verifyD1State(row, entry, states) {
294 const state = states.find((candidate) => candidate.fingerprint === row.fingerprint);
295 if (!state || state.sample_state !== entry.state || Number(state.sample_epoch) !== 1 ||
296 text(state.epoch_first_event_id) !== entry.firstEventId ||
297 Number(state.reserved_bytes) !== entry.reservedBytes || text(state.last_seen) !== text(row.last_seen)) {
298 throw new Error(`D1 migration state mismatch for ${text(row.fingerprint).slice(0, 8)}`);
299 }
300 }
301
302 function emptyCheckpoint(database, targetHash, startedAt) {
303 return { version: 1, database, targetHash, startedAt, pass: 1, cursor: "", changed: 0, groups: {} };
304 }
305
306 function loadCheckpoint(file, expected) {
307 try {
308 const value = JSON.parse(readFileSync(file, "utf8"));
309 if (value.version !== 1 || value.database !== expected.database || value.targetHash !== expected.targetHash) {
310 throw new Error("checkpoint target does not match this migration");
311 }
312 return value;
313 } catch (error) {
314 if (error?.code === "ENOENT") return expected;
315 throw error;
316 }
317 }
318
319 function saveCheckpoint(file, value) {
320 const temporary = `${file}.tmp-${process.pid}`;
321 writeFileSync(temporary, `${JSON.stringify(value)}\n`, { mode: 0o600 });
322 chmodSync(temporary, 0o600);
323 renameSync(temporary, file);
324 chmodSync(file, 0o600);
325 }
326
327 function parseArgs(argv, projectDir) {
328 const apply = argv.includes("--apply");
329 const verifyOnly = argv.includes("--verify-only");
330 if (apply && verifyOnly) throw new Error("--apply and --verify-only are mutually exclusive");
331 const checkpointArg = argv.find((arg) => arg.startsWith("--checkpoint="));
332 return {
333 mode: apply ? "apply" : verifyOnly ? "verify" : "dry-run",
334 reset: argv.includes("--reset-checkpoint"),
335 checkpoint: checkpointArg ? path.resolve(checkpointArg.slice("--checkpoint=".length))
336 : path.join(projectDir, ".firebase-crash-migration-state.json"),
337 };
338 }
339
340 async function dryRun(projectDir, database, now) {
341 let cursor = "";
342 const counts = { active: 0, compacted: 0, archived: 0 };
343 const assessmentCounts = createMigrationCapacityAssessment();
344 let estimatedBytes = 0;
345 let contentBytes = 0;
346 while (true) {
347 const page = readPage(projectDir, database, cursor);
348 if (!page.groups.length) break;
349 accumulateMigrationCapacityAssessment(assessmentCounts, page.groups, now);
350 const entries = buildFirebaseGroups(page.groups, page.reports, now);
351 for (const entry of entries.values()) {
352 counts[entry.state]++;
353 estimatedBytes += entry.reservedBytes;
354 if (entry.value !== null) contentBytes += Buffer.byteLength(canonicalJSONString(entry.value));
355 }
356 cursor = text(page.groups.at(-1).fingerprint);
357 }
358 const assessment = finalizeMigrationCapacityAssessment(assessmentCounts);
359 const explainedActive = Object.values(assessment.activeReasons).reduce((sum, value) => sum + value, 0);
360 if (explainedActive !== counts.active || assessment.automaticRetention.compacted !== counts.compacted ||
361 assessment.automaticRetention.archived !== counts.archived) {
362 throw new Error("Firebase capacity assessment disagrees with lifecycle classification");
363 }
364 const ageLine = (status) => {
365 const value = assessment.ageByStatus[status];
366 return `${status}=${value.under30d}/${value.days30to59d}/${value.days60plus}/${value.invalid}`;
367 };
368 const manualSavings = assessment.manualReview.open30to59d * (RESERVATIONS.active - RESERVATIONS.compacted) +
369 assessment.manualReview.open60dPlus * RESERVATIONS.active;
370 const statusTotals = `open=${assessment.statusTotals.open}, resolved=${assessment.statusTotals.resolved}, ` +
371 `ignored=${assessment.statusTotals.ignored}, other=${assessment.statusTotals.other}`;
372 const activeReasons = `open=${assessment.activeReasons.open}, other_status=${assessment.activeReasons.otherStatus}, ` +
373 `recent_resolved_or_ignored=${assessment.activeReasons.recentResolvedOrIgnored}, ` +
374 `invalid_resolved_or_ignored=${assessment.activeReasons.invalidResolvedOrIgnored}`;
375 const manualReview = `open_30_to_59d=${assessment.manualReview.open30to59d}, ` +
376 `open_60d_plus=${assessment.manualReview.open60dPlus}, ` +
377 `other_status_30d_plus=${assessment.manualReview.otherStatus30dPlus}, ` +
378 `potential_reduction=${(manualSavings / 1048576).toFixed(1)} MiB`;
379 console.log(`Groups: active=${counts.active}, compacted=${counts.compacted}, archived=${counts.archived}.`);
380 console.log(`Status totals: ${statusTotals}.`);
381 console.log(`Last-seen buckets (<30d/30-59d/>=60d/invalid): ${ageLine("open")}; ${ageLine("resolved")}; ${ageLine("ignored")}; ${ageLine("other")}.`);
382 console.log(`Active reasons: ${activeReasons}.`);
383 console.log(`Manual review only: ${manualReview}.`);
384 console.log(`Canonical Firebase content estimate: ${(contentBytes / 1048576).toFixed(1)} MiB.`);
385 const reservationPercent = estimatedBytes / STORAGE_BUDGET * 100;
386 console.log(`Conservative reservation: ${(estimatedBytes / 1048576).toFixed(1)} MiB / 700 MiB (${reservationPercent.toFixed(1)}%).`);
387 if (reservationPercent >= 80) console.log("Capacity warning: reservation is at or above the 80% review threshold; do not apply without operator review.");
388 if (estimatedBytes > STORAGE_BUDGET) throw new Error("Estimated Firebase reservation exceeds the 700 MiB safety budget");
389 }
390
391 async function migrate(projectDir, database, mode, checkpointFile, now) {
392 const email = process.env.FIREBASE_CLIENT_EMAIL;
393 const privateKey = process.env.FIREBASE_PRIVATE_KEY;
394 const rawURL = process.env.FIREBASE_DATABASE_URL;
395 if (!email || !privateKey || !rawURL) throw new Error("Firebase service-account environment variables are required");
396 const baseURL = databaseURL(rawURL);
397 const targetHash = createHash("sha256").update(baseURL).digest("hex");
398 let checkpoint = loadCheckpoint(checkpointFile, emptyCheckpoint(database, targetHash, now.toISOString()));
399 const token = await accessToken(email, privateKey);
400 const apply = mode === "apply";
401 for (; checkpoint.pass <= MAX_PASSES; checkpoint.pass++) {
402 let cursor = checkpoint.cursor;
403 let passChanged = Number(checkpoint.changed ?? 0);
404 while (true) {
405 const page = readPage(projectDir, database, cursor);
406 if (!page.groups.length) break;
407 const values = buildFirebaseGroups(page.groups, page.reports, now);
408 for (const row of page.groups) {
409 const fingerprint = validateFingerprint(text(row.fingerprint));
410 const entry = values.get(fingerprint);
411 const sourceHash = contentDigest({ state: entry.state, value: entry.value });
412 const previous = checkpoint.groups[fingerprint];
413 if (mode === "verify" || previous?.sourceHash !== sourceHash) {
414 const firebaseHash = await reconcileGroup(baseURL, token, fingerprint, entry, apply);
415 if (apply) runWrangler(projectDir, database, stateSQL(fingerprint, row, entry, now.toISOString()));
416 if (mode === "verify") verifyD1State(row, entry, page.states);
417 checkpoint.groups[fingerprint] = { sourceHash, firebaseHash, state: entry.state };
418 passChanged++;
419 console.log(`${mode === "verify" ? "Verified" : "Reconciled"} ${fingerprint.slice(0, 8)} ${sourceHash.slice(0, 12)}.`);
420 } else if (apply) {
421 await readAndVerify(baseURL, token, fingerprint, entry.value);
422 }
423 cursor = fingerprint;
424 checkpoint.cursor = cursor;
425 checkpoint.changed = passChanged;
426 saveCheckpoint(checkpointFile, checkpoint);
427 }
428 }
429 if (mode === "verify") {
430 checkpoint.cursor = "";
431 checkpoint.changed = 0;
432 checkpoint.completedAt = new Date().toISOString();
433 saveCheckpoint(checkpointFile, checkpoint);
434 console.log(`Verified ${Object.keys(checkpoint.groups).length} Firebase groups without writes.`);
435 return;
436 }
437 if (passChanged === 0) {
438 checkpoint.cursor = "";
439 checkpoint.changed = 0;
440 checkpoint.completedAt = new Date().toISOString();
441 saveCheckpoint(checkpointFile, checkpoint);
442 console.log(`Migration converged after ${checkpoint.pass} pass(es).`);
443 return;
444 }
445 checkpoint.cursor = "";
446 checkpoint.changed = 0;
447 saveCheckpoint(checkpointFile, checkpoint);
448 }
449 throw new Error("Firebase migration did not converge after 3 reconciliation passes; keep CRASH_STORAGE_MODE=d1");
450 }
451
452 async function main() {
453 const projectDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
454 const database = process.env.DIAGNOSTICS_D1_DATABASE || "reasonix-crash";
455 const args = parseArgs(process.argv.slice(2), projectDir);
456 if (args.reset) {
457 try { unlinkSync(args.checkpoint); } catch (error) { if (error?.code !== "ENOENT") throw error; }
458 }
459 const now = new Date();
460 await dryRun(projectDir, database, now);
461 if (args.mode === "dry-run") {
462 console.log("Dry run only. Use --apply to migrate or --verify-only to perform readback verification.");
463 return;
464 }
465 await migrate(projectDir, database, args.mode, args.checkpoint, now);
466 }
467
468 const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : "";
469 if (invokedPath === fileURLToPath(import.meta.url)) {
470 main().catch((error) => {
471 console.error(error instanceof Error ? error.message : String(error));
472 process.exitCode = 1;
473 });
474 }
475
475 lines Plain Text