| 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 |