From 0cc61d3295366d213a80820eaab1ebd5fa00691f Mon Sep 17 00:00:00 2001 From: Dread Date: Mon, 6 Jul 2026 22:14:40 -0500 Subject: [PATCH] feat(cutover): --account-ids cohort filter for phased prepare MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runbook's phased cutover (internal accounts first, then beta cohort) needed a way to prepare a subset — `prepare` discovered ALL unlocked accounts. Adds an optional accountIds cohort: when set, only those accounts are prepared (preflight, plan, and records all scope to the cohort); everything else is left untouched. When omitted, behavior is unchanged (whole population). - prepare.ts: optional accountIds param filters discoveries right after discovery; requested-but-not-found ids are surfaced as cohortNotFound so the operator catches typos / locked / missing accounts. - CLI: `--account-ids ` on the prepare command. Closes the last cutover code gap for runbook-faithful phased prod cutover. 3 tests (whole population, cohort subset, not-found reporting); 82 cutover tests green. --- src/app/cash-wallet-cutover/prepare.ts | 25 +++++- src/scripts/cash-wallet-cutover.ts | 11 +++ .../prepare-cohort.spec.ts | 84 +++++++++++++++++++ 3 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 test/flash/unit/app/cash-wallet-cutover/prepare-cohort.spec.ts diff --git a/src/app/cash-wallet-cutover/prepare.ts b/src/app/cash-wallet-cutover/prepare.ts index 35793b208..f4834de54 100644 --- a/src/app/cash-wallet-cutover/prepare.ts +++ b/src/app/cash-wallet-cutover/prepare.ts @@ -16,6 +16,9 @@ type PreparePrimaryCashWalletCutoverResult = { report: CashWalletCutoverPreflightReport plannedMigrations: PrimaryCashWalletMigrationPlan[] migrations: CashWalletMigration[] + // Cohort accountIds that were requested but not found among unlocked + // accounts — surfaced so the operator notices typos/locked/missing ids. + cohortNotFound?: AccountId[] } export const preparePrimaryCashWalletCutover = async ({ @@ -24,18 +27,32 @@ export const preparePrimaryCashWalletCutover = async ({ accountsRepo, walletsRepo, migrationsRepo, + accountIds, }: { cutoverVersion: number runId: string accountsRepo: Pick walletsRepo: Pick migrationsRepo: CashWalletMigrationRecordsRepository + // Phased cutover (runbook: internal accounts first, then beta cohort). + // When set, only these accounts are prepared; everything else is left + // untouched. When omitted, the whole unlocked population is prepared. + accountIds?: AccountId[] }): Promise => { - const discoveries = await discoverCashWalletCutoverAccounts({ + const allDiscoveries = await discoverCashWalletCutoverAccounts({ accountsRepo, walletsRepo, }) - if (discoveries instanceof Error) return discoveries + if (allDiscoveries instanceof Error) return allDiscoveries + + let discoveries = allDiscoveries + let cohortNotFound: AccountId[] | undefined + if (accountIds !== undefined) { + const requested = new Set(accountIds) + discoveries = allDiscoveries.filter((d) => requested.has(d.accountId)) + const found = new Set(discoveries.map((d) => d.accountId)) + cohortNotFound = accountIds.filter((id) => !found.has(id)) + } const report = buildCashWalletCutoverPreflightReport({ cutoverVersion, @@ -44,7 +61,7 @@ export const preparePrimaryCashWalletCutover = async ({ }) if (!report.canStart) { - return { report, plannedMigrations: [], migrations: [] } + return { report, plannedMigrations: [], migrations: [], cohortNotFound } } const plannedMigrations = buildPrimaryCashWalletMigrationPlan({ @@ -59,5 +76,5 @@ export const preparePrimaryCashWalletCutover = async ({ }) if (migrations instanceof Error) return migrations - return { report, plannedMigrations, migrations } + return { report, plannedMigrations, migrations, cohortNotFound } } diff --git a/src/scripts/cash-wallet-cutover.ts b/src/scripts/cash-wallet-cutover.ts index 0a45500f7..cae95d1a7 100644 --- a/src/scripts/cash-wallet-cutover.ts +++ b/src/scripts/cash-wallet-cutover.ts @@ -57,6 +57,10 @@ const args = yargs(hideBin(process.argv)) .option("max-provision-attempts", { type: "number", default: 5 }) .option("dry-run", { type: "boolean", default: false }) .option("account-id", { type: "string" }) + .option("account-ids", { + type: "string", + describe: "comma-separated accountIds — prepare only this cohort (phased cutover)", + }) .option("reason", { type: "string", default: "" }) .option("lock-stale-seconds", { type: "number", default: 300 }) .option("configPath", { type: "string", demandOption: true }) @@ -115,12 +119,19 @@ const run = async () => { } case "prepare": { + const cohort = args["account-ids"] + ? (args["account-ids"] + .split(",") + .map((s) => s.trim()) + .filter(Boolean) as AccountId[]) + : undefined const result = await CashWalletCutover.preparePrimaryCashWalletCutover({ cutoverVersion, runId, accountsRepo: AccountsRepository(), walletsRepo: WalletsRepository(), migrationsRepo: repository, + accountIds: cohort, }) if (result instanceof Error) throw result toJson(result) diff --git a/test/flash/unit/app/cash-wallet-cutover/prepare-cohort.spec.ts b/test/flash/unit/app/cash-wallet-cutover/prepare-cohort.spec.ts new file mode 100644 index 000000000..b8fe40e1e --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/prepare-cohort.spec.ts @@ -0,0 +1,84 @@ +const discoverMock = jest.fn() +jest.mock("@app/cash-wallet-cutover/discovery", () => ({ + discoverCashWalletCutoverAccounts: (...args: unknown[]) => discoverMock(...args), +})) + +const upsertMock = jest.fn() +jest.mock("@app/cash-wallet-cutover/migration-records", () => ({ + upsertPrimaryCashWalletMigrationRecords: (args: { plans: unknown[] }) => { + upsertMock(args) + // echo plans back as "migrations" so prepare returns cleanly + return Promise.resolve(args.plans) + }, +})) + +import { preparePrimaryCashWalletCutover } from "@app/cash-wallet-cutover/prepare" + +const legacyDefault = (id: string) => ({ + status: "legacy_default" as const, + accountId: id as AccountId, + legacyUsdWalletId: `${id}-usd` as WalletId, + destinationUsdtWalletId: `${id}-usdt` as WalletId, + previousDefaultWalletId: `${id}-usd` as WalletId, +}) + +const repos = { + accountsRepo: {} as never, + walletsRepo: {} as never, + migrationsRepo: {} as never, +} + +describe("preparePrimaryCashWalletCutover — cohort filter (phased cutover)", () => { + beforeEach(() => { + jest.clearAllMocks() + discoverMock.mockResolvedValue([ + legacyDefault("acc-1"), + legacyDefault("acc-2"), + legacyDefault("acc-3"), + ]) + }) + + it("prepares the whole population when no cohort is given", async () => { + const result = await preparePrimaryCashWalletCutover({ + cutoverVersion: 1, + runId: "run-1", + ...repos, + }) + if (result instanceof Error) throw result + expect(result.plannedMigrations.map((m) => m.accountId).sort()).toEqual([ + "acc-1", + "acc-2", + "acc-3", + ]) + expect(result.cohortNotFound).toBeUndefined() + }) + + it("prepares only the requested cohort, leaving the rest untouched", async () => { + const result = await preparePrimaryCashWalletCutover({ + cutoverVersion: 1, + runId: "run-1", + accountIds: ["acc-1", "acc-3"] as AccountId[], + ...repos, + }) + if (result instanceof Error) throw result + expect(result.plannedMigrations.map((m) => m.accountId).sort()).toEqual([ + "acc-1", + "acc-3", + ]) + expect(result.cohortNotFound).toEqual([]) + }) + + it("reports requested ids that were not found among unlocked accounts", async () => { + const result = await preparePrimaryCashWalletCutover({ + cutoverVersion: 1, + runId: "run-1", + accountIds: ["acc-2", "acc-missing"] as AccountId[], + ...repos, + }) + if (result instanceof Error) throw result + expect(result.plannedMigrations.map((m) => m.accountId)).toEqual(["acc-2"]) + expect(result.cohortNotFound).toEqual(["acc-missing"]) + // the preflight report is scoped to the cohort, not the full population + expect(result.report.totalAccounts).toBe(1) + }) +})