Back to blog
EngineeringSep 28, 2026· min read

Bulk Operations and the Two-Pass Pattern

We shipped bulk theme reassignment for lists this week. The interesting part wasn't the feature — it was discovering that mock-only tests couldn't prove a critical assumption about nullable fields. Integration tests against the real database caught it.

We shipped bulk theme reassignment for lists this week. The interesting part wasn't the feature — it was discovering that mock-only tests couldn't prove a critical assumption about nullable fields. Integration tests against the real database caught it.

What Shipped

ListService.bulk_retheme lets you reassign themes for multiple lists atomically. The method is 96 lines implementing a two-pass pattern: validation first (verify all list IDs belong to the user), then updates. If validation fails, nothing changes. If an update fails mid-batch, the error includes how many succeeded and which list IDs were already applied.

The service layer grew from 646 to 761 lines. We added 3 new test files: 206 lines of TDD tests for bulk_retheme, 259 lines of integration tests against live Supabase, and 266 lines of new audit tests covering 9 findings.

Why Two-Pass

The naive implementation would be: loop through changes, update each list. If a list ID is invalid or belongs to another user, you find out halfway through the batch. Now you're in partial-failure state and the caller has no idea which updates succeeded.

The two-pass pattern trades one extra round-trip for cleaner semantics: validate everything upfront, bail if anything is wrong, then apply all updates. The method still isn't transactional — we're not wrapping it in BEGIN/COMMIT — but it's predictable. If validation passes, updates should succeed barring infrastructure failure. If they don't, the error context tells you exactly where it broke and what had already been applied.

Why Integration Tests

We have 877 lines of mock-based unit tests for ListService. They run fast, they're deterministic, they cover edge cases. But they couldn't prove one thing: when you call update_list with theme=None, does the database actually store NULL or does it ignore the field?

Mocks let you assert that you're passing the right data structure to Supabase. They don't let you verify what Supabase does with it. So we added 9 integration tests that run against a real Supabase instance when credentials are present. They auto-skip if you're running locally without environment variables. The tests create real records, update them, query them back, verify the database state matches what you expected.

The test that mattered: update_list with theme=None actually clears the field to NULL in the database. The mock tests couldn't prove this. The integration test can.

The Audit Findings

TDD audit tests caught 9 things before merge:

R2-F1: ListUpdate must reject explicit None for NOT NULL database columns (name, list_type, position, is_archived, metadata). You can omit them, but you can't set them to null.
R2-F5: ListUpdate.position should reject negative values (added ge=0 constraint).
R2-F6: BulkRethemeRequest should reject duplicate list_ids — last-write-wins is wasted work.
R2-F3: bulk_retheme error context should include count of successfully applied changes.
R2-F4: ListItemBatchUpdate should use model_fields_set for consistency with ListUpdate's validation pattern.

The rest covered error handling: DatabaseError wrapping when Supabase raises generic exceptions (F9), create_list with empty response (F10), archive_list not-found behavior (F11), and making ListNotFoundError a proper subclass of DatabaseError (F2).

What's Next

The Unified Lists feature is getting close to done at the service layer. We have CRUD for lists and items, name resolution with fuzzy matching, bulk operations for theme organization. What's left is higher-level operations: reordering, archiving with cascade behavior for items, and recurring list templates.

The integration test suite is new infrastructure. We'll keep it small — integration tests are slower and require real database credentials — but having them available means we can verify assumptions that mocks can't prove. That's useful for nullable field semantics, FK cascade behavior, and RLS policy enforcement.

The two-pass pattern will probably show up again. It's not transactional, but it's a reasonable middle ground when you need batch operations to be predictable without introducing the complexity of proper transaction management. For now, it's enough.