diff --git a/docs/plans/ROADMAP.md b/docs/plans/ROADMAP.md index b183e3e9..fa0502e8 100644 --- a/docs/plans/ROADMAP.md +++ b/docs/plans/ROADMAP.md @@ -79,23 +79,25 @@ will *worsen* end-to-end latency. Functions and Firestore must move together. **Migration order (sequence is load-bearing)** -1. **Pre-flight**: `gcloud firestore export gs://the-postbox-game-backup-eu/pre-migration-$(date +%F)` (create the backup bucket in `europe-west2` first). Tag the repo, freeze deploys. +1. **Pre-flight**: a managed export/import bucket must be **co-located with the database**, and you change locations mid-migration, so you need **two** buckets. Create a **US** bucket for exporting out of `nam5` (`gcloud storage buckets create gs://the-postbox-game-backup-us --location=us`) and an **EU** bucket for importing into `eur3` (`gcloud storage buckets create gs://the-postbox-game-backup-eu --location=europe-west2`). Pre-migration backup → US bucket: `gcloud firestore export gs://the-postbox-game-backup-us/pre-migration-$(date +%F)`. Tag the repo, freeze deploys. 2. **Region-pin Cloud Functions** (code change, no deploy yet): - v2 callables (`nearbyPostboxes`, `startScoring`, `updateDisplayName`, `registerFcmToken`, `userClaimHistory`, `submitReport`, `reviewReport`, `routePostboxes`): add `{ region: "europe-west2" }` to `onCall` options. - v2 scheduler (`newDayScoreboard`): add `region: "europe-west2"` alongside `schedule` + `timeZone`. - v1 triggers (`onFriendAdded`, `onUserCreated`): wrap with `functionsV1.region("europe-west2")`. 3. **Firestore database move (disruptive — Path A recommended)**: 1. Maintenance mode via the existing `maintenance_mode` Remote Config flag (per `b721caa`); client renders "we'll be right back". - 2. Final export to `gs://…-backup-eu`. - 3. Delete `(default)` (delete protection is already `DISABLED`). - 4. `gcloud firestore databases create --location=eur3 --database='(default)'`. - 5. Re-deploy `firestore.rules` and `firestore.indexes.json`; wait for indexes to finish before reopening traffic. - 6. `gcloud firestore import` from the backup. - 7. Verify counts on `postbox`, `claims`, `users`, `leaderboards`, `fcmTokens`. + 2. Final export → **US** bucket: `gcloud firestore export gs://the-postbox-game-backup-us/final-$(date +%F-%H%M)`; note the printed `outputUriPrefix`. + 3. Baseline counts on the frozen `nam5` data: `cd functions && npm run verify-migration -- snapshot --out pre-nam5.json`. + 4. Copy the final export US→EU (eur3 can only import from an EU bucket): `gcloud storage cp -r gs://the-postbox-game-backup-us/final-… gs://the-postbox-game-backup-eu/final-…`. + 5. Delete `(default)` (delete protection is already `DISABLED`). + 6. `gcloud firestore databases create --location=eur3 --database='(default)'`. + 7. Re-deploy `firestore.rules` and `firestore.indexes.json`; wait for the 5 composite indexes to finish (`gcloud firestore indexes composite list`) before reopening traffic. + 8. `gcloud firestore import gs://the-postbox-game-backup-eu/final-…` (from the **EU** bucket). + 9. Verify counts match: `npm run verify-migration -- snapshot --out post-eur3.json && npm run verify-migration -- compare pre-nam5.json post-eur3.json` (exit 0 = safe). Covers `postbox`, `claims`, `users`, `leaderboards`, `fcmTokens`, `reports`, `reportQuotas` + nested groups. - Path B (named DB in `eur3`, dual-write, switch reads, retire `(default)`) is the fallback if Path A downtime is unacceptable; **avoid** unless forced, since every `admin.firestore()` call would need to target the non-default DB. -4. **Deploy new functions**: `firebase deploy --only functions` *creates* europe-west2 copies; us-central1 copies are not deleted automatically. Verify all 8 healthy. -5. **Pin Flutter client** to europe-west2 via a single helper `lib/firebase_functions_eu.dart` exposing an `appFunctions` getter. Refactor the 8 callable call sites in `lib/user_repository.dart`, `lib/wear/wear_compass_page.dart`, `lib/notification_service.dart`, `lib/nearby.dart`, `lib/wear/wear_claim_page.dart`, `lib/claim_history_screen.dart`, `lib/claim.dart`. Bump the app version. Keep both regions live until us-central1 invocations drop to zero in Cloud Logging. -6. **Storage bucket** (do last): create `the-postbox-game-eu` in `europe-west2`, regen SDK config via FlutterFire CLI, migrate any objects with `gsutil -m cp -r` (currently nothing referenced from client code). +4. **Deploy new functions**: `firebase deploy --only functions` *creates* europe-west2 copies; **decline** the prompt to delete the orphaned us-central1 copies — keep the 8 callables live for old installs. But the 3 event-driven functions (`onUserCreated`, `onFriendAdded`, `newDayScoreboard`) fire automatically and would double-run from both regions, so delete only those old copies: `firebase functions:delete onUserCreated onFriendAdded newDayScoreboard --region us-central1`. Verify all **11** healthy in `europe-west2` and that no us-central1 `newDayScoreboard` scheduler job remains. +5. **Pin Flutter client** to europe-west2 via a single helper `lib/firebase_functions_eu.dart` exposing an `appFunctions` getter (`instanceFor(region: 'europe-west2')`). Done in PR #159: all 11 call sites swapped (`nearby`, `claim_quiz_sheet`, `wear` ×2, `route` ×2, `reports`, `admin`, `user_repository`, `claim_history_screen`, `notification_service`), guarded by `test/firebase_functions_region_test.dart`. Bump the app version. Keep both regions live until us-central1 invocations drop to zero in Cloud Logging. +6. **Storage bucket** (do last): create `the-postbox-game-eu` in `europe-west2`, regen SDK config via FlutterFire CLI, migrate the existing objects with `gsutil -m cp -r` (the default bucket holds `report_photos/` and `osm_changesets/`). 7. **Decommission us-central1 functions** once Cloud Logging shows zero invocations for one release cycle: `firebase functions:delete --region us-central1` for each. **Risks & mitigations** @@ -113,7 +115,7 @@ will *worsen* end-to-end latency. Functions and Firestore must move together. - **Tests**: `cd functions && npm test` + `flutter test` both green. - **Logs**: Cloud Logging shows invocations only in `europe-west2` after the deprecation window. -**Rollback**: delete the new `(default)`, re-create in `nam5`, import the pre-migration backup, re-deploy us-central1 functions from the freeze tag, revert the Flutter client `instanceFor` change, ship a hotfix. +**Rollback**: delete the new `(default)`, re-create in `nam5`, import the pre-migration backup **from the US bucket** (`gs://the-postbox-game-backup-us/pre-migration-…`), re-deploy us-central1 functions from the freeze tag, leave the Flutter client unshipped (or revert the `instanceFor` change), ship a hotfix. ### Performance Monitoring custom traces (was #105) diff --git a/functions/package.json b/functions/package.json index 446ddc14..9c7afa3b 100644 --- a/functions/package.json +++ b/functions/package.json @@ -11,7 +11,8 @@ "deploy": "firebase deploy --only functions", "logs": "firebase functions:log", "test": "npm run build && nyc mocha lib/test/test.index.js --reporter spec", - "plan-route": "npm run build && node lib/scripts/plan_route.js" + "plan-route": "npm run build && node lib/scripts/plan_route.js", + "verify-migration": "npm run build && node lib/scripts/verify_migration.js" }, "engines": { "node": "22" diff --git a/functions/src/_migrationVerify.ts b/functions/src/_migrationVerify.ts new file mode 100644 index 00000000..ab68cc8a --- /dev/null +++ b/functions/src/_migrationVerify.ts @@ -0,0 +1,80 @@ +/* + * _migrationVerify.ts — pure helpers for the read-only Firestore migration + * verification CLI (scripts/verify_migration.ts). + * + * Kept separate from the CLI (and free of any firebase-admin / I/O) so the + * diff verdict — the part where a silent bug would falsely report "match" and + * let traffic reopen on incomplete data — is unit-testable. See the + * `diffSnapshots` tests in test/test.index.ts. + */ + +/** A point-in-time document-count snapshot of the `(default)` database. */ +export interface MigrationSnapshot { + project: string; + /** ISO-8601 timestamp the snapshot was taken. */ + generatedAt: string; + /** Root collection name -> document count. */ + roots: Record; + /** Collection-group id -> document count (across all nesting depths). */ + groups: Record; +} + +export type DiffScope = "root" | "group"; +export type DiffStatus = "ok" | "mismatch" | "missing"; + +export interface DiffRow { + name: string; + scope: DiffScope; + /** Count in the "before" snapshot, or null if the key is absent there. */ + before: number | null; + /** Count in the "after" snapshot, or null if the key is absent there. */ + after: number | null; + /** (after ?? 0) - (before ?? 0). */ + delta: number; + status: DiffStatus; +} + +export interface DiffResult { + /** True only if every row is "ok" (present on both sides, equal counts). */ + ok: boolean; + rows: DiffRow[]; +} + +function diffSection( + scope: DiffScope, + before: Record, + after: Record, +): DiffRow[] { + const names = Array.from( + new Set([...Object.keys(before), ...Object.keys(after)]), + ).sort(); + + return names.map((name) => { + const b = Object.prototype.hasOwnProperty.call(before, name) + ? before[name] + : null; + const a = Object.prototype.hasOwnProperty.call(after, name) + ? after[name] + : null; + const delta = (a ?? 0) - (b ?? 0); + const status: DiffStatus = + b === null || a === null ? "missing" : a === b ? "ok" : "mismatch"; + return { name, scope, before: b, after: a, delta, status }; + }); +} + +/** + * Compares two snapshots and returns a per-collection diff. A correct + * migration leaves every count unchanged, so any non-"ok" row means the + * import is incomplete (or grew) and traffic must NOT be reopened. + */ +export function diffSnapshots( + before: MigrationSnapshot, + after: MigrationSnapshot, +): DiffResult { + const rows = [ + ...diffSection("root", before.roots, after.roots), + ...diffSection("group", before.groups, after.groups), + ]; + return { ok: rows.every((r) => r.status === "ok"), rows }; +} diff --git a/functions/src/_notifications.ts b/functions/src/_notifications.ts index 72f0c9c1..5998d849 100644 --- a/functions/src/_notifications.ts +++ b/functions/src/_notifications.ts @@ -1,7 +1,8 @@ import "./adminInit"; import * as admin from "firebase-admin"; import * as functions from "firebase-functions"; -import * as functionsV1 from "firebase-functions/v1"; +import { onDocumentUpdated } from "firebase-functions/v2/firestore"; +import { FIRESTORE_TRIGGER_REGION } from "./_region"; import { getTodayLondon } from "./_dateUtils"; const database = admin.firestore(); @@ -286,23 +287,26 @@ export const registerFcmToken = functions.https.onCall(async (request) => { // ── Firestore trigger: friend added ────────────────────────────────────── -export const onFriendAdded = functionsV1.firestore - .document("users/{uid}") - .onUpdate(async (change, context) => { - const uid: string = context.params.uid; - const before: string[] = - (change.before.data()?.friends as string[] | undefined) ?? []; - const after: string[] = - (change.after.data()?.friends as string[] | undefined) ?? []; +// 2nd-gen Firestore trigger in europe-west4 (eur3's Eventarc region); eur3 has +// no Gen1 Firestore triggers. See FIRESTORE_TRIGGER_REGION in _region.ts. +export const onFriendAdded = onDocumentUpdated( + { document: "users/{uid}", region: FIRESTORE_TRIGGER_REGION }, + async (event) => { + const uid = event.params.uid; + const before = + (event.data?.before.data()?.friends as string[] | undefined) ?? []; + const after = + (event.data?.after.data()?.friends as string[] | undefined) ?? []; const newFriends = diffFriends(before, after); if (newFriends.length === 0) return; const adderDisplayName = - (change.after.data()?.displayName as string | undefined) || + (event.data?.after.data()?.displayName as string | undefined) || `Player_${uid.slice(0, 6)}`; await Promise.allSettled( newFriends.map((fuid) => notifyFriendOfAddition(fuid, adderDisplayName)) ); - }); + }, +); diff --git a/functions/src/_region.ts b/functions/src/_region.ts new file mode 100644 index 00000000..63f64ece --- /dev/null +++ b/functions/src/_region.ts @@ -0,0 +1,19 @@ +import { setGlobalOptions } from "firebase-functions/v2"; + +// UK-only audience reading the eur3 Firestore: pin every Cloud Function to +// europe-west2 so round-trips stay in-region. setGlobalOptions covers all v2 +// functions (callables + scheduler); the v1 triggers reference FUNCTION_REGION +// directly via .region(). See ROADMAP v1.3 (us-central1 -> europe-west2). +// +// Imported first in index.ts so setGlobalOptions runs before any function +// module is evaluated (and therefore before each function is defined). +export const FUNCTION_REGION = "europe-west2"; + +// eur3 has no Gen1 Firestore triggers at all (Gen1 deploys fail in every +// region with "...is in region eur3-europe-west1 which is not supported"), so +// onFriendAdded is a 2nd-gen trigger. Eventarc maps the eur3 multi-region to +// europe-west4, so the function must run there — NOT europe-west2/west1. +// (Auth triggers like onUserCreated are global and stay on FUNCTION_REGION.) +export const FIRESTORE_TRIGGER_REGION = "europe-west4"; + +setGlobalOptions({ region: FUNCTION_REGION }); diff --git a/functions/src/index.ts b/functions/src/index.ts index d1e9ab14..a274de11 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -1,3 +1,4 @@ +import "./_region"; import "./adminInit"; import { nearbyPostboxes } from "./nearbyPostboxes"; import { startScoring } from "./startScoring"; diff --git a/functions/src/onUserCreated.ts b/functions/src/onUserCreated.ts index 97bf082e..af15bfb2 100644 --- a/functions/src/onUserCreated.ts +++ b/functions/src/onUserCreated.ts @@ -1,6 +1,7 @@ import "./adminInit"; import * as admin from "firebase-admin"; import * as functions from "firebase-functions/v1"; +import { FUNCTION_REGION } from "./_region"; import { containsProfanity, MIN_DISPLAY_NAME_CHARS, @@ -16,7 +17,10 @@ export function sanitiseName(name: string, uid: string): string { return t; } -export const onUserCreated = functions.auth.user().onCreate(async (user) => { +export const onUserCreated = functions + .region(FUNCTION_REGION) + .auth.user() + .onCreate(async (user) => { const raw = user.displayName || (user.email diff --git a/functions/src/scripts/verify_migration.ts b/functions/src/scripts/verify_migration.ts new file mode 100644 index 00000000..b5410025 --- /dev/null +++ b/functions/src/scripts/verify_migration.ts @@ -0,0 +1,191 @@ +/* + * verify_migration.ts — READ-ONLY document-count snapshot + diff for the + * us-central1 -> europe-west2 / nam5 -> eur3 migration (ROADMAP v1.3). + * + * It only ever calls listCollections() and count() aggregations — it never + * reads document bodies and never writes. Run it against the live `(default)` + * database BEFORE the cut-over (nam5) to capture a baseline, and again AFTER + * the import (eur3); a correct migration leaves every count unchanged. + * + * Run after `npm run build` (from functions/): + * + * # Snapshot the current (default) DB to a JSON file + * node lib/scripts/verify_migration.js snapshot \ + * --project the-postbox-game --out pre.json + * + * # ... run the migration, then snapshot again ... + * node lib/scripts/verify_migration.js snapshot --out post.json + * + * # Diff the two (no DB access; exits non-zero on ANY mismatch) + * node lib/scripts/verify_migration.js compare pre.json post.json + * + * Options: + * --project Firebase project ID (default: the-postbox-game) + * --out Snapshot output file (default: migration-snapshot-.json) + * --groups a,b,c Collection-group ids to count + * (default: entries,countyStats,counties,periods) + * --help + * + * Auth: GOOGLE_APPLICATION_CREDENTIALS to a service-account JSON, or + * `gcloud auth application-default login` for ADC. Targets the `(default)` + * database (Path A keeps everything on the default DB). + */ + +import * as fs from "fs"; +import * as admin from "firebase-admin"; +import { + diffSnapshots, + type MigrationSnapshot, + type DiffRow, +} from "../_migrationVerify"; + +const DEFAULT_GROUPS = ["entries", "countyStats", "counties", "periods"]; + +type Options = { + mode: "snapshot" | "compare"; + projectId: string; + out: string | null; + groups: string[]; + files: string[]; + help: boolean; +}; + +function parseArgs(argv: string[]): Options { + const opts: Options = { + mode: "snapshot", + projectId: "the-postbox-game", + out: null, + groups: DEFAULT_GROUPS, + files: [], + help: false, + }; + const args = argv.slice(2); + let i = 0; + while (i < args.length) { + const a = args[i]; + if (a === "snapshot" || a === "compare") opts.mode = a; + else if (a === "--project") opts.projectId = args[++i]; + else if (a === "--out") opts.out = args[++i]; + else if (a === "--groups") opts.groups = args[++i].split(",").map((s) => s.trim()).filter(Boolean); + else if (a === "--help" || a === "-h") opts.help = true; + else if (!a.startsWith("-")) opts.files.push(a); + else { + process.stderr.write(`Unknown argument: ${a}\n`); + opts.help = true; + } + i++; + } + return opts; +} + +async function takeSnapshot( + db: admin.firestore.Firestore, + projectId: string, + groups: string[], +): Promise { + // Counts are independent reads, so fan them out in parallel. + const cols = await db.listCollections(); + const rootEntries = await Promise.all( + cols.map(async (col) => [col.id, (await col.count().get()).data().count] as const), + ); + const roots: Record = Object.fromEntries(rootEntries); + + const groupEntries = await Promise.all( + groups.map(async (g) => { + try { + return [g, (await db.collectionGroup(g).count().get()).data().count] as const; + } catch (err) { + // A group with no documents (or that never existed) is reported as 0; + // surface anything else so a counting failure can't masquerade as a match. + process.stderr.write( + ` warn: could not count collection-group '${g}': ` + + `${err instanceof Error ? err.message : String(err)}\n`, + ); + return [g, 0] as const; + } + }), + ); + const groupCounts: Record = Object.fromEntries(groupEntries); + + return { + project: projectId, + generatedAt: new Date().toISOString(), + roots, + groups: groupCounts, + }; +} + +function printSnapshot(snap: MigrationSnapshot): void { + process.stdout.write(`\nSnapshot of ${snap.project} (default) at ${snap.generatedAt}\n`); + const pad = (s: string, n: number) => s.padEnd(n); + const num = (n: number) => String(n).padStart(10); + process.stdout.write(` ${pad("COLLECTION", 28)}${pad("SCOPE", 8)}${"COUNT".padStart(10)}\n`); + let total = 0; + for (const [name, count] of Object.entries(snap.roots).sort()) { + process.stdout.write(` ${pad(name, 28)}${pad("root", 8)}${num(count)}\n`); + total += count; + } + for (const [name, count] of Object.entries(snap.groups).sort()) { + process.stdout.write(` ${pad(name, 28)}${pad("group", 8)}${num(count)}\n`); + } + process.stdout.write(` ${pad("(root docs total)", 36)}${num(total)}\n`); +} + +function printDiff(rows: DiffRow[]): void { + const pad = (s: string, n: number) => s.padEnd(n); + const num = (n: number | null) => (n === null ? "—" : String(n)).padStart(10); + process.stdout.write( + `\n ${pad("COLLECTION", 28)}${pad("SCOPE", 7)}${"BEFORE".padStart(10)}${"AFTER".padStart(10)}${"DELTA".padStart(10)} STATUS\n`, + ); + for (const r of rows) { + const flag = r.status === "ok" ? "ok" : `>> ${r.status.toUpperCase()}`; + process.stdout.write( + ` ${pad(r.name, 28)}${pad(r.scope, 7)}${num(r.before)}${num(r.after)}${num(r.delta)} ${flag}\n`, + ); + } +} + +async function main(): Promise { + const opts = parseArgs(process.argv); + if (opts.help) { + process.stdout.write( + "Usage: node lib/scripts/verify_migration.js [snapshot|compare] [options]\n" + + " snapshot --out read-only count of the (default) DB\n" + + " compare diff two snapshot files (exit 1 on mismatch)\n", + ); + return; + } + + if (opts.mode === "compare") { + if (opts.files.length !== 2) { + throw new Error("compare needs exactly two snapshot files: "); + } + const before = JSON.parse(fs.readFileSync(opts.files[0], "utf8")) as MigrationSnapshot; + const after = JSON.parse(fs.readFileSync(opts.files[1], "utf8")) as MigrationSnapshot; + const result = diffSnapshots(before, after); + printDiff(result.rows); + if (result.ok) { + process.stdout.write("\n✓ All counts match — safe to proceed.\n"); + } else { + const bad = result.rows.filter((r) => r.status !== "ok").length; + process.stdout.write(`\n✗ ${bad} collection(s) differ — DO NOT reopen traffic.\n`); + process.exit(1); + } + return; + } + + // snapshot mode + if (!admin.apps.length) admin.initializeApp({ projectId: opts.projectId }); + const db = admin.firestore(); + const snap = await takeSnapshot(db, opts.projectId, opts.groups); + printSnapshot(snap); + + const out = opts.out ?? `migration-snapshot-${snap.generatedAt.replace(/[:.]/g, "-")}.json`; + fs.writeFileSync(out, JSON.stringify(snap, null, 2) + "\n"); + process.stdout.write(`\nWrote ${out}\n`); +} + +main().catch((err) => { + process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`); + process.exit(1); +}); diff --git a/functions/src/test/test.index.ts b/functions/src/test/test.index.ts index e07ace0a..8c99d776 100644 --- a/functions/src/test/test.index.ts +++ b/functions/src/test/test.index.ts @@ -12,6 +12,7 @@ import { containsProfanity } from "../_profanityFilter"; import { sanitiseName } from "../onUserCreated"; import { checkTravelSpeed, MAX_METRES_PER_MIN } from "../_travelSpeed"; import { aggregateClaimHistory, periodStartDate } from "../userClaimHistory"; +import { diffSnapshots, type MigrationSnapshot } from "../_migrationVerify"; // ── Pure utility unit tests (no Firebase required) ──────────────────────────── @@ -29,6 +30,71 @@ describe("getPoints", () => { it("returns 2 (default) for empty string", () => assert.strictEqual(getPoints(""), 2)); }); +describe("diffSnapshots (migration verification)", () => { + const snap = ( + roots: Record, + groups: Record = {}, + ): MigrationSnapshot => ({ + project: "the-postbox-game", + generatedAt: "2026-06-14T00:00:00.000Z", + roots, + groups, + }); + + it("reports ok when every count matches", () => { + const r = diffSnapshots( + snap({ postbox: 100, claims: 50 }, { entries: 12 }), + snap({ postbox: 100, claims: 50 }, { entries: 12 }), + ); + assert.strictEqual(r.ok, true); + assert.ok(r.rows.every((row) => row.status === "ok")); + assert.ok(r.rows.every((row) => row.delta === 0)); + }); + + it("flags a changed count as a mismatch with the right delta", () => { + const r = diffSnapshots( + snap({ postbox: 100, claims: 50 }), + snap({ postbox: 100, claims: 47 }), + ); + assert.strictEqual(r.ok, false); + const claims = r.rows.find((row) => row.name === "claims"); + assert.strictEqual(claims?.status, "mismatch"); + assert.strictEqual(claims?.delta, -3); + assert.strictEqual( + r.rows.find((row) => row.name === "postbox")?.status, + "ok", + ); + }); + + it("flags a collection present on only one side as missing", () => { + const dropped = diffSnapshots( + snap({ postbox: 100, reportQuotas: 4 }), + snap({ postbox: 100 }), + ); + assert.strictEqual(dropped.ok, false); + const rq = dropped.rows.find((row) => row.name === "reportQuotas"); + assert.strictEqual(rq?.status, "missing"); + assert.strictEqual(rq?.before, 4); + assert.strictEqual(rq?.after, null); + + const added = diffSnapshots(snap({ postbox: 100 }), snap({ postbox: 100, surprise: 1 })); + assert.strictEqual(added.ok, false); + assert.strictEqual(added.rows.find((row) => row.name === "surprise")?.status, "missing"); + }); + + it("diffs collection groups the same way as root collections", () => { + const r = diffSnapshots( + snap({}, { entries: 30, countyStats: 9 }), + snap({}, { entries: 30, countyStats: 8 }), + ); + assert.strictEqual(r.ok, false); + const cs = r.rows.find((row) => row.name === "countyStats"); + assert.strictEqual(cs?.scope, "group"); + assert.strictEqual(cs?.status, "mismatch"); + assert.strictEqual(cs?.delta, -1); + }); +}); + describe("getTodayLondon", () => { it("returns a string matching YYYY-MM-DD", () => { const today = getTodayLondon(); @@ -816,6 +882,40 @@ describe("checkTravelSpeed", () => { const testEnv = test(); +// ── Region pinning (EU region migration, v1.3) ────────────────────────────── +// Every exported Cloud Function must be pinned to an EU region co-located with +// the eur3 Firestore so UK round-trips stay in-region. Most sit in +// europe-west2. onFriendAdded is the exception: eur3 has NO Gen1 Firestore +// triggers (neither europe-west2 nor europe-west1 deploys), so it must be a +// 2nd-gen trigger and Eventarc maps eur3 -> europe-west4. Auth triggers +// (onUserCreated) are global and stay on europe-west2. Iterating over all +// exports means a newly added function that forgets its region trips this guard. +describe("region pinning", () => { + const DEFAULT_REGION = "europe-west2"; + const REGION_OVERRIDES: Record = { + onFriendAdded: "europe-west4", + }; + type WithEndpoint = { __endpoint?: { region?: string[]; platform?: string } }; + + for (const name of Object.keys(myFunctions)) { + const expected = REGION_OVERRIDES[name] ?? DEFAULT_REGION; + it(`${name} is pinned to ${expected}`, () => { + const endpoint = (myFunctions as Record)[name].__endpoint; + assert.ok(endpoint, `${name} has no __endpoint — is it a Cloud Function?`); + assert.ok( + Array.isArray(endpoint.region) && endpoint.region.includes(expected), + `${name} region is ${JSON.stringify(endpoint.region)}, expected ${expected}`, + ); + }); + } + + it("onFriendAdded is a 2nd-gen trigger (eur3 has no Gen1 Firestore triggers)", () => { + const endpoint = (myFunctions as Record)["onFriendAdded"] + .__endpoint; + assert.strictEqual(endpoint?.platform, "gcfv2"); + }); +}); + describe("Cloud Functions", function (this: Mocha.Suite) { this.timeout(15000); diff --git a/lib/admin/admin_reports_screen.dart b/lib/admin/admin_reports_screen.dart index f88b98bc..1ec560b5 100644 --- a/lib/admin/admin_reports_screen.dart +++ b/lib/admin/admin_reports_screen.dart @@ -1,5 +1,6 @@ import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:cloud_functions/cloud_functions.dart'; +import 'package:postbox_game/firebase_functions_eu.dart'; import 'package:firebase_storage/firebase_storage.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -272,7 +273,7 @@ class _ReportCardState extends State<_ReportCard> { Future _call(Map payload, {bool isAccept = false}) async { setState(() => _busy = true); try { - final res = await FirebaseFunctions.instance.httpsCallable('reviewReport').call(payload); + final res = await appFunctions.httpsCallable('reviewReport').call(payload); if (!mounted) return; if (isAccept) { final m = Map.from(res.data as Map); diff --git a/lib/claim_history_screen.dart b/lib/claim_history_screen.dart index dec7ce81..67d0a423 100644 --- a/lib/claim_history_screen.dart +++ b/lib/claim_history_screen.dart @@ -1,6 +1,6 @@ import 'dart:math' as math; -import 'package:cloud_functions/cloud_functions.dart'; +import 'package:postbox_game/firebase_functions_eu.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:latlong2/latlong.dart'; @@ -123,7 +123,7 @@ class _HistoryTabState extends State<_HistoryTab> } Future> _fetch() async { - final result = await FirebaseFunctions.instance + final result = await appFunctions .httpsCallable('userClaimHistory') .call({'period': widget.period}); final data = Map.from(result.data as Map); diff --git a/lib/firebase_functions_eu.dart b/lib/firebase_functions_eu.dart new file mode 100644 index 00000000..4ff56f16 --- /dev/null +++ b/lib/firebase_functions_eu.dart @@ -0,0 +1,13 @@ +import 'package:cloud_functions/cloud_functions.dart'; + +/// Region-pinned [FirebaseFunctions] for the app's Cloud Functions. +/// +/// All callables are deployed to `europe-west2` (see `functions/src/_region.ts` +/// and ROADMAP v1.3). The default [FirebaseFunctions.instance] targets +/// `us-central1`, so every call site must go through this getter to stay +/// in-region — using the US default would add a cross-Atlantic round-trip. +/// +/// [FirebaseFunctions.instanceFor] caches per (app, region), so reading this +/// getter repeatedly is cheap. +FirebaseFunctions get appFunctions => + FirebaseFunctions.instanceFor(region: 'europe-west2'); diff --git a/lib/nearby.dart b/lib/nearby.dart index a10e4ae0..f1b2baec 100644 --- a/lib/nearby.dart +++ b/lib/nearby.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:cloud_functions/cloud_functions.dart'; +import 'package:postbox_game/firebase_functions_eu.dart'; import 'package:flutter/material.dart'; import 'package:flutter_compass/flutter_compass.dart'; import 'package:flutter_map/flutter_map.dart'; @@ -94,7 +95,7 @@ class _NearbyState extends State { } final HttpsCallable callable = - FirebaseFunctions.instance.httpsCallable('nearbyPostboxes'); + appFunctions.httpsCallable('nearbyPostboxes'); Future _startSearch() async { // Guard against concurrent calls (e.g. pull-to-refresh + Refresh button diff --git a/lib/notification_service.dart b/lib/notification_service.dart index 553be542..a01b3b48 100644 --- a/lib/notification_service.dart +++ b/lib/notification_service.dart @@ -1,6 +1,6 @@ import 'dart:async'; -import 'package:cloud_functions/cloud_functions.dart'; +import 'package:postbox_game/firebase_functions_eu.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:postbox_game/maintenance_guard.dart'; @@ -127,7 +127,7 @@ class NotificationService { // notifications are non-critical and the maintenance window is rare. if (MaintenanceGuard.isOn) return; try { - await FirebaseFunctions.instance + await appFunctions .httpsCallable('registerFcmToken') .call({'token': token}); } catch (_) { diff --git a/lib/reports/report_repository.dart b/lib/reports/report_repository.dart index 94f54515..40b98619 100644 --- a/lib/reports/report_repository.dart +++ b/lib/reports/report_repository.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'dart:typed_data'; -import 'package:cloud_functions/cloud_functions.dart'; +import 'package:postbox_game/firebase_functions_eu.dart'; import 'package:exif/exif.dart'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_storage/firebase_storage.dart'; @@ -214,7 +214,7 @@ class ReportRepository { } static Future _call(Map data) async { - final result = await FirebaseFunctions.instance.httpsCallable('submitReport').call(data); + final result = await appFunctions.httpsCallable('submitReport').call(data); final map = Map.from(result.data as Map); return map['reportId'] as String? ?? ''; } diff --git a/lib/route/live_route_screen.dart b/lib/route/live_route_screen.dart index a7630590..ca591515 100644 --- a/lib/route/live_route_screen.dart +++ b/lib/route/live_route_screen.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:cloud_functions/cloud_functions.dart'; +import 'package:postbox_game/firebase_functions_eu.dart'; import 'package:flutter/material.dart'; import 'package:flutter_compass/flutter_compass.dart'; import 'package:geolocator/geolocator.dart'; @@ -219,7 +220,7 @@ class _LiveRouteScreenState extends State { _nearby = widget.nearbyCallable ?? (Map payload) => - FirebaseFunctions.instance + appFunctions .httpsCallable('nearbyPostboxes') .call(payload); diff --git a/lib/route/route_preview_screen.dart b/lib/route/route_preview_screen.dart index 62362e7a..77645d6d 100644 --- a/lib/route/route_preview_screen.dart +++ b/lib/route/route_preview_screen.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:cloud_functions/cloud_functions.dart'; +import 'package:postbox_game/firebase_functions_eu.dart'; import 'package:flutter/material.dart'; import 'package:postbox_game/analytics_service.dart'; import 'package:postbox_game/app_preferences.dart'; @@ -77,7 +78,7 @@ class _RoutePreviewScreenState extends State { void initState() { super.initState(); _callableFn = widget.callableFn ?? - (payload) => FirebaseFunctions.instance + (payload) => appFunctions .httpsCallable('routePostboxes') .call(payload); // Fire the initial call immediately. diff --git a/lib/user_repository.dart b/lib/user_repository.dart index 431b5c64..379a082c 100644 --- a/lib/user_repository.dart +++ b/lib/user_repository.dart @@ -1,5 +1,5 @@ import 'package:cloud_firestore/cloud_firestore.dart'; -import 'package:cloud_functions/cloud_functions.dart'; +import 'package:postbox_game/firebase_functions_eu.dart'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:google_sign_in/google_sign_in.dart'; import 'package:postbox_game/maintenance_guard.dart'; @@ -88,7 +88,7 @@ class UserRepository { final name = Validators.isValidDisplayName(raw) ? raw : 'Player_${user.uid.substring(0, 6)}'; - final callable = FirebaseFunctions.instance.httpsCallable('updateDisplayName'); + final callable = appFunctions.httpsCallable('updateDisplayName'); await callable.call({'name': name}); await user.reload(); } catch (_) { @@ -109,7 +109,7 @@ class UserRepository { Future updateDisplayName(String newName) async { final user = _firebaseAuth.currentUser; if (user == null) return; - final callable = FirebaseFunctions.instance.httpsCallable('updateDisplayName'); + final callable = appFunctions.httpsCallable('updateDisplayName'); await callable.call({'name': newName}); // Reload so the in-memory Auth profile picks up the name set by the // Admin SDK on the server. diff --git a/lib/wear/wear_claim_page.dart b/lib/wear/wear_claim_page.dart index 78863160..ddbc9e28 100644 --- a/lib/wear/wear_claim_page.dart +++ b/lib/wear/wear_claim_page.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:cloud_functions/cloud_functions.dart'; +import 'package:postbox_game/firebase_functions_eu.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:postbox_game/analytics_service.dart'; @@ -56,9 +57,9 @@ class _WearClaimPageState extends State { String? _errorMessage; final HttpsCallable _nearbyCallable = - FirebaseFunctions.instance.httpsCallable('nearbyPostboxes'); + appFunctions.httpsCallable('nearbyPostboxes'); final HttpsCallable _claimCallable = - FirebaseFunctions.instance.httpsCallable('startScoring'); + appFunctions.httpsCallable('startScoring'); final StreakService _streakService = StreakService(); late final Stream _streakStream = _streakService.streakStream(); diff --git a/lib/wear/wear_compass_page.dart b/lib/wear/wear_compass_page.dart index f724cb80..33b5b873 100644 --- a/lib/wear/wear_compass_page.dart +++ b/lib/wear/wear_compass_page.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:math'; import 'package:cloud_functions/cloud_functions.dart'; +import 'package:postbox_game/firebase_functions_eu.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_compass/flutter_compass.dart'; @@ -43,7 +44,7 @@ class _WearCompassPageState extends State { String? _errorMessage; final HttpsCallable _callable = - FirebaseFunctions.instance.httpsCallable('nearbyPostboxes'); + appFunctions.httpsCallable('nearbyPostboxes'); @override void initState() { diff --git a/lib/widgets/claim_quiz_sheet.dart b/lib/widgets/claim_quiz_sheet.dart index d3b97bd3..31706a0b 100644 --- a/lib/widgets/claim_quiz_sheet.dart +++ b/lib/widgets/claim_quiz_sheet.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:cloud_functions/cloud_functions.dart'; +import 'package:postbox_game/firebase_functions_eu.dart'; import 'package:confetti/confetti.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -154,12 +155,12 @@ class ClaimQuizSheet extends StatefulWidget { final Stream? streakStream; /// Injectable stub for the `nearbyPostboxes` Firebase callable. - /// When null, defaults to [FirebaseFunctions.instance.httpsCallable]. + /// When null, defaults to [appFunctions.httpsCallable]. /// Inject in tests to avoid real Firebase initialisation. final NearbyPostboxesCallableFn? nearbyCallable; /// Injectable stub for the `startScoring` Firebase callable. - /// When null, defaults to [FirebaseFunctions.instance.httpsCallable]. + /// When null, defaults to [appFunctions.httpsCallable]. /// Inject in tests to avoid real Firebase initialisation. final StartScoringCallableFn? startScoringCallable; @@ -220,11 +221,11 @@ class _ClaimQuizSheetState extends State super.initState(); _nearbyCallable = widget.nearbyCallable ?? - (payload) => FirebaseFunctions.instance + (payload) => appFunctions .httpsCallable('nearbyPostboxes') .call(payload); _claimCallable = widget.startScoringCallable ?? - (payload) => FirebaseFunctions.instance + (payload) => appFunctions .httpsCallable('startScoring') .call(payload); _positionProvider = widget.positionProvider ?? getPosition; diff --git a/pubspec.lock b/pubspec.lock index e5c72664..b6dd8232 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -644,50 +644,50 @@ packages: dependency: "direct main" description: name: geolocator - sha256: "79939537046c9025be47ec645f35c8090ecadb6fe98eba146a0d25e8c1357516" + sha256: e146a6d63776582651e97a79cbe459f8e1211b100101fadcd84db83361fa599f url: "https://pub.dev" source: hosted - version: "14.0.2" + version: "14.0.3" geolocator_android: dependency: transitive description: name: geolocator_android - sha256: "179c3cb66dfa674fc9ccbf2be872a02658724d1c067634e2c427cf6df7df901a" + sha256: "86ea1654e4f61ff51466848e91c116b422d6010ea269fda0fbe1af7e9e742ce1" url: "https://pub.dev" source: hosted - version: "5.0.2" + version: "5.0.3" geolocator_apple: dependency: transitive description: name: geolocator_apple - sha256: dbdd8789d5aaf14cf69f74d4925ad1336b4433a6efdf2fce91e8955dc921bf22 + sha256: "853803d6bb1713c094e935b4a5ae5f19c0308acf81da13fa9ff84fb4c70c0b73" url: "https://pub.dev" source: hosted - version: "2.3.13" + version: "2.3.14" geolocator_linux: dependency: transitive description: name: geolocator_linux - sha256: b02993d59b753bf1967ed28862002841aec5cdd5090c1f495213b32ce488aced + sha256: "3da7420f11c3496511a5bd3c18fd67b88e5659f12e46b7ce00a788f6996e850a" url: "https://pub.dev" source: hosted - version: "0.2.5" + version: "0.2.6" geolocator_platform_interface: dependency: transitive description: name: geolocator_platform_interface - sha256: "30cb64f0b9adcc0fb36f628b4ebf4f731a2961a0ebd849f4b56200205056fe67" + sha256: cdb082e4f048b69da244117b7914cc60d2a8897546ffaa4f2529c786ded7aee2 url: "https://pub.dev" source: hosted - version: "4.2.6" + version: "4.2.8" geolocator_web: dependency: transitive description: name: geolocator_web - sha256: b1ae9bdfd90f861fde8fd4f209c37b953d65e92823cb73c7dee1fa021b06f172 + sha256: "19e485a0f8d6a88abcf9c53cba3a4105e14b7435ed8ac1c108c067b938fe8429" url: "https://pub.dev" source: hosted - version: "4.1.3" + version: "4.1.4" geolocator_windows: dependency: transitive description: @@ -1220,10 +1220,10 @@ packages: dependency: transitive description: name: shared_preferences_android - sha256: a2c49fc1fed7140cadd892d765bd47edbe4ac0b9c7e7e3c493dcb58126f99cf0 + sha256: "93ae5884a9df5d3bb696825bceb3a17590754548b5d740eba51500afc8d088f5" url: "https://pub.dev" source: hosted - version: "2.4.25" + version: "2.4.26" shared_preferences_foundation: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 0e4c5588..40fe4f77 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,7 +11,7 @@ description: Find postboxes for megapoints # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.2.0+13 +version: 1.3.0+14 environment: sdk: ">=3.0.0 <4.0.0" diff --git a/test/firebase_functions_region_test.dart b/test/firebase_functions_region_test.dart new file mode 100644 index 00000000..2d403f36 --- /dev/null +++ b/test/firebase_functions_region_test.dart @@ -0,0 +1,53 @@ +// Region-pinning guard for the Flutter client (ROADMAP v1.3, +// us-central1 -> europe-west2). Every Cloud Functions callable must go through +// the single europe-west2-pinned helper in lib/firebase_functions_eu.dart +// (`appFunctions`), never the US-default `FirebaseFunctions.instance`. A call +// site that reaches for `FirebaseFunctions.instance` would hit us-central1 and +// re-introduce the cross-Atlantic latency the migration removes — and would +// keep us-central1 invocations non-zero, blocking decommission of the old +// functions. +// +// Plain VM test (dart:io) — `flutter test` runs with the package root as the +// working directory, so the relative paths resolve. + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const helperPath = 'lib/firebase_functions_eu.dart'; + + test('appFunctions helper is pinned to europe-west2', () { + final helper = File(helperPath); + expect(helper.existsSync(), isTrue, + reason: 'missing $helperPath — the single region-pinned callable seam'); + final src = helper.readAsStringSync(); + expect(src.contains("instanceFor(region: 'europe-west2')"), isTrue, + reason: '$helperPath must expose a europe-west2 FirebaseFunctions'); + expect(src.contains('appFunctions'), isTrue, + reason: '$helperPath must expose the `appFunctions` getter'); + }); + + test('no callable bypasses the helper via FirebaseFunctions.instance', () { + // Matches `FirebaseFunctions.instance` (incl. multi-line `.httpsCallable` + // call sites and doc-comment references) but not `instanceFor` or + // `FirebaseFunctionsException`. + final offenderRe = RegExp(r'FirebaseFunctions\.instance(?!For)'); + final offenders = []; + for (final entity in Directory('lib').listSync(recursive: true)) { + if (entity is! File || !entity.path.endsWith('.dart')) continue; + // The helper itself is the one place allowed to name the default + // instance (its doc comment explains why callers must avoid it). + if (entity.path.endsWith('firebase_functions_eu.dart')) continue; + final content = entity.readAsStringSync(); + for (final m in offenderRe.allMatches(content)) { + final line = '\n'.allMatches(content.substring(0, m.start)).length + 1; + offenders.add('${entity.path}:$line'); + } + } + expect(offenders, isEmpty, + reason: 'these references use the US-default FirebaseFunctions.instance ' + 'instead of the europe-west2 `appFunctions` helper:\n' + '${offenders.join('\n')}'); + }); +}