fix/bf-rollback
needs attentionviewing older commit0fd71fb · fullPR #259reviewed 2026-07-03 20:49 UTC0H · 4M · 2L · 7I- Purpose
- Provide a proven, reviewed rollback path before running the derived-concept backfill on production
- Goal
- Add a tested rollback script and fix the --rpu array-binding bug in the backfill
- Sub-goals
- SG-1: New rollback script removes only powerFactorAdjustment + billingFrequency keys (append-only inverse)
- SG-2: Fix --rpu filter from ANY(jsArray) to sql.join IN-list to avoid postgres.js malformed array literal
- SG-3: Mirror backfill's --expect-ref, --dry-run, --rpu guards in rollback for operational consistency
- What
- Added scripts/rollback-derived-bill-concepts.ts (76 lines) and fixed the --rpu sql.join pattern in scripts/backfill-derived-bill-concepts.ts (5 lines)
- Why
- The --rpu bug was silently mis-serializing during the scoped staging run; without a rollback tool, running on prod carried unrecoverable risk
- Areas
- scripts/backfill-derived-bill-concepts.ts+6−1scripts/rollback-derived-bill-concepts.ts+76−0
- Blast
- 2 files, +82/-1; scripts/ only — no domain code, API, or infra touched
Findings · 13
correctness2
--rpu repeated-flag form silently drops all but the first value
scripts/rollback-derived-bill-concepts.ts:22
argValue(flag) uses process.argv.indexOf(flag) which returns only the first occurrence. Invoking --rpu R1 --rpu R2 results in RPUS=['R1'] — R2 is silently ignored. The comma-separated form --rpu R1,R2 works correctly. The same bug exists in the backfill script. An operator using the repeated-flag convention could run an incomplete rollback without any warning.
Before-count and UPDATE not wrapped in a transaction — log may report incorrect N
scripts/rollback-derived-bill-concepts.ts:54
The SELECT COUNT(*) for toRemove and the UPDATE run as separate statements with no transaction wrapper. A concurrent write between the two could cause '[done] stripped keys from N bills' to misreport actual affected rows. For a maintenance script on a quiesced DB this is cosmetic, but worth noting.
security3
RPUS values properly parameterized via sql template tag
scripts/rollback-derived-bill-concepts.ts:46
Each RPU is wrapped in sql`${r}` before joining, ensuring Drizzle treats them as bound parameters. No SQL injection risk.
KEYS are hardcoded constants — not user-input driven
scripts/rollback-derived-bill-concepts.ts:29
KEYS is a compile-time constant, never derived from argv or environment. No injection surface.
--expect-ref guard bypass depends on projectRefFromUrl correctness
scripts/rollback-derived-bill-concepts.ts:35
The production guard is contingent on projectRefFromUrl correctly parsing all URL forms in use (pooler vs direct vs custom domain). The function aborts on null (safe fallback); projectRefFromUrl is independently tested in packages/database/src/__tests__/preview-db.test.ts.
conventions1
Rollback uses db.execute() directly; backfill uses Database cast alias
scripts/rollback-derived-bill-concepts.ts:54
The backfill establishes const database = db as unknown as Database and calls database.execute(...) throughout. The rollback calls db.execute(...) with inline casts at call sites. Both work at runtime, but the divergence makes the two companion scripts harder to read side-by-side and may mask type errors under strict mode.
tests2
No automated regression test for the --rpu array-binding fix
scripts/backfill-derived-bill-concepts.ts:157
The ANY(jsArray) → sql.join IN(...) fix is validated only by staging round-trip. A unit test asserting the generated SQL contains 'IN ($1, $2)' rather than 'ANY' would protect against future Drizzle/postgres.js serialization regressions.
Rollback UPDATE logic tested only by staging round-trip
scripts/rollback-derived-bill-concepts.ts:61
The jsonb_agg/jsonb_array_elements strip expression is non-trivial but unautomated. Acceptable for an infrequently-run emergency tool — dry-run and post-update count provide runtime confidence.
improvement5
hasKeys JSONB filter hardcoded — diverges from KEYS array driving the strip
scripts/rollback-derived-bill-concepts.ts:52
KEYS = ['powerFactorAdjustment', 'billingFrequency'] defines what to strip, but the hasKeys sql fragment hardcodes both names as separate @> containment checks. If a third key is added to KEYS, bills carrying only that key won't be found by the filter — the UPDATE silently skips them while the count under-reports scope. The UPDATE's NOT IN (${keyList}) correctly drives from KEYS; the filter should too.
Duplicated boilerplate across backfill and rollback (argValue, RPUS, EXPECT_REF, rpuFilter)
scripts/rollback-derived-bill-concepts.ts:21
Both scripts contain identical argValue(), DRY_RUN, RPUS, EXPECT_REF parsing and the same rpuFilter sql.join construction — including the same block that just contained the ANY() bug fixed in this PR. Fixing in one script required a manual fix in the other. A scripts/lib/args.ts helper would make future fixes atomic.
Rollback guard omits maskedUrl logging present in backfill guard
scripts/rollback-derived-bill-concepts.ts:38
The backfill logs the masked database URL on guard success, giving operators a visible confirmation of which host they're targeting. The rollback only logs the project ref. For a destructive UPDATE this is a meaningful auditability gap — the operator can't visually confirm the host from the log output.
rpuFilter SQL fragment duplicated verbatim between both scripts
scripts/rollback-derived-bill-concepts.ts:46
The sql.join rpuFilter construction is copy-pasted identically. This was the exact fragment containing the ANY() bug; a shared helper would make future fixes apply once.
Inconsistent db access: rollback uses db.execute(), backfill uses Database wrapper
scripts/rollback-derived-bill-concepts.ts:54
Minor inconsistency; both work. Worth standardizing if a shared script lib is extracted.