← all branches

fix/bf-rollback

needs attentionviewing older commit
efb8a4f · incrementalPR #259reviewed 2026-07-03 21:20 UTC0H · 1M · 7L · 5I
The branch
Purpose
Fix the derived-concept backfill for prod deployment: add a proven rollback path and fix a real --rpu array-binding bug (postgres.js mis-serializes = ANY($array) / <> ALL($array) as malformed array literal).
Goal
Ops-safe derived-bill-concept backfill with a tested, reversible rollback tool and correct parameterized SQL for RPU-scoped runs.
Sub-goals
  • SG-1: Add rollback script (scripts/rollback-derived-bill-concepts.ts) — exact inverse of the backfill, same guards
  • SG-2: Fix --rpu and key binding — sql.join scalar params replace = ANY($array) / <> ALL($array)
  • SG-3: Extract shared guards/helpers to @batu/database/ops-guards — covered by @batu/database Vitest run in CI
  • SG-4: Unit-test all 8 exported functions in ops-guards
The changes (whole branch)
What
Three commits: (1) rollback script + SQL binding fix, (2) extract ops helpers to @batu/database/ops-guards shared module, (3) add full unit test suite for ops-guards. The branch started as a fix for the --rpu bug flagged in the loop-review on PR #257.
Why
The team needed a proven rollback before running the backfill on prod. Staging exposed the array-binding bug in the --rpu code path (full-table run was unaffected, scoped run was broken). Moving helpers to @batu/database ensures they run in CI.
Areas
scripts/+11174packages/database/+2730
Blast
6 files, +394/−74. Scripts only + @batu/database subpath export. No domain entities, no migrations, no API surface, no UI.
ops-scripts-only no-migration no-api-surface-change
ci· PAT scope does not allow statusCheckRollup APIcoderabbit· No .coderabbit.yaml present

Findings · 12

correctness1

low

parseLimit 'exits on negative' test does not assert the exit code

packages/database/src/__tests__/ops-guards.test.ts:59

The 'exits on negative' test asserts the mock throws but never asserts expect(exit).toHaveBeenCalledWith(1). If the implementation were changed to call process.exit(0) on negative input, this test would still pass. The sibling 'exits on non-integer' test does include the assertion; the negative case should too.

security1

info

No security findings

SQL values are bound as scalar params via sql.join (no injection surface). guardConnection uses exact string equality (=== not includes/startsWith) preventing prefix bypass. maskedUrl uses URL constructor (not regex) — fails closed to '<unparseable>' on parse error.

conventions4

low

File-level JSDoc describes 'what' and structure rather than 'why'

packages/database/src/ops-guards.ts:1

The opening 9-line block comment describes what the module does and where it lives ('Lives here (next to preview-db.ts)...'). These are derivable from reading the file and package layout. The only non-derivable content — that these functions must be tested because they are the safety gate — is legitimate, but buried. Convention: comments explain why, not what. Trim to just the safety-gate rationale.

info

guardConnection signature change is backward-compatible

packages/database/src/ops-guards.ts:63

New signature: guardConnection(expectRef, url = process.env.POSTGRES_URL ?? ''). Both callers (backfill line 63, rollback line 36) invoke it as guardConnection(EXPECT_REF) — one positional arg. The url default preserves existing behavior exactly. No breaking change.

info

ops-guards placement in packages/database is consistent with preview-db.ts

packages/database/src/ops-guards.ts:1

Correctly scoped alongside preview-db.ts. NOT re-exported from the main index barrel — only via ./ops-guards subpath. Mirrors ./preview-db pattern. FCIS/ADR-016 does not apply to CLI-context ops utilities by design.

info

Package exports entry is correct

packages/database/package.json:26

The ./ops-guards export has both 'types' and 'default' keys pointing to the source TypeScript file, consistent with all other subpath exports (./preview-db, ./rls, ./client, ./schema).

tests4

medium

console.error spy leaks between tests in parseLimit block

packages/database/src/__tests__/ops-guards.test.ts:54

Both 'exits on non-integer' and 'exits on negative' spy on console.error but only call exit.mockRestore(). The parseLimit describe block has no afterEach(() => vi.restoreAllMocks()), so the spy leaks into subsequent tests and silences real error output. The guardConnection block (which has the correct afterEach) ran after without issue only because Vitest happened to execute them in source order; any reorder or isolation change exposes the gap. Fix: add afterEach(() => vi.restoreAllMocks()) to the parseLimit describe block, matching the guardConnection pattern.

low

rpuInFilter tests don't assert generated SQL contains 'utility_contracts'

packages/database/src/__tests__/ops-guards.test.ts:127

The three rpuInFilter test cases verify parameter values and the presence of 'in (' but never assert the subquery table name. A refactor that removes or misspells 'utility_contracts' would pass all tests. Given this filter prevents wrong-scope writes, asserting the SQL structure (e.g. expect(q.sql).toContain('utility_contracts')) would close the gap.

low

No end-to-end test wiring parseRpus output into rpuInFilter

packages/database/src/__tests__/ops-guards.test.ts:123

Both functions are unit-tested independently, but no test exercises the actual ops code path: realistic argv → parseRpus → rpuInFilter → assert scalar params. This is the exact regression vector the PR fixes. A single integration-style test would document the contract and catch future re-breakage.

low

keyList and hasAnyKeyFilter not tested with empty arrays

packages/database/src/__tests__/ops-guards.test.ts:147

keyList([]) and hasAnyKeyFilter([], col) are never called. The implementation has no guard and sql.join([]) behavior is not documented. Since DERIVED_CONCEPT_KEYS is a compile-time non-empty const this is not a live risk, but a test documenting the empty behavior (or explicit guard) would close the gap.

improvement2

low

rpuInFilter name implies generic reuse but body hardcodes utility_contracts subquery

packages/database/src/ops-guards.ts:99

The function accepts a generic contractIdCol SQL fragment, signalling general-purpose intent, but the body hardcodes 'SELECT id FROM utility_contracts WHERE contract_number IN (...)'. Any future ops script with a different FK table can't reuse this without being silently wrong. Option A: rename to rpuContractFilter to surface the domain coupling. Option B: make the lookup table/column injectable. The mismatch between generic signature and hardcoded internals is the real risk.

info

console.error spy restoration gap noted by both correctness and improvement lenses

packages/database/src/__tests__/ops-guards.test.ts:53

Both lenses independently flagged the parseLimit describe block's missing afterEach(vi.restoreAllMocks). Signals this is the highest-confidence finding in this review.

History · 5 commits

  1. cbdca09safeincremental0H · 0M · 0L2026-07-03 22:30
  2. baca7afsafeincremental0H · 0M · 3L2026-07-03 21:27
  3. efb8a4fneeds attentionincremental0H · 1M · 7L2026-07-03 21:20current
  4. b62fb9aneeds attentionincremental3H · 4M · 5L2026-07-03 21:04
  5. 0fd71fbneeds attentionfull0H · 4M · 2L2026-07-03 20:49