Fix stale equipment-set caches after deleting gear (#819) - #960
Conversation
Deleting a gear item that belonged to an equipment set left the set
unusable: editing it and saving died with
SqliteException(787): FOREIGN KEY constraint failed
INSERT INTO "equipment_set_items" ("set_id", "equipment_id")
The database was behaving correctly. equipment_set_items declares
onDelete: KeyAction.cascade and foreign keys are ON, so SQLite removed
the junction row. The stale membership lived in Riverpod:
EquipmentListNotifier.refresh() invalidated six equipment providers and
zero equipment-set providers, and none of the set providers carried an
invalidateSelfWhen. The edit form then seeded its selection from the
pre-delete snapshot (3 ids) while rendering checkboxes from the fresh
equipment list (2 tiles), so the dead id was selected, invisible, and
impossible to un-check. That is also why the detail view and the edit
form disagreed on the member count in the report.
The same flow exposed a second, more damaging defect: updateSet was not
transactional. It deleted every junction row, tombstoned each one for
sync, and only then re-inserted -- so the failing save left the set
EMPTY with the deletions already logged, propagating the emptiness to
the diver's other devices.
Repository:
- watchSetChanges(): a debounced tableUpdates stream over equipment_sets,
equipment_set_items, equipment and equipment_set_geofences. Watching
equipment_set_items catches gear deletes via drift's generated
WritePropagation for the cascade; that propagation is delete-only, so
equipment is watched directly for renames.
- updateSet/createSet are now transactional and reconcile membership as a
diff, mirroring DivePlanRepository.updatePlan, with sync bookkeeping
after the commit. Unchanged members no longer generate a tombstone plus
a live pending marker on every save -- the churn SyncService's
contradicted-key handling exists to absorb.
- Membership is pruned to ids that still have an equipment row, logged at
warning level. Not insertOrIgnore: this is a foreign-key violation, not
a uniqueness conflict, and swallowing it silently would hide exactly
the provider-staleness regression this fixes.
Providers:
- invalidateSelfWhen on equipmentSetsProvider and equipmentSetProvider;
the derived providers re-derive through them.
- equipmentSetWithItemsProvider now delegates to equipmentSetProvider. As
a separate provider it was a second cache that nothing ever
invalidated, so the dive-log set picker could render arbitrarily stale
membership.
- EquipmentSetListNotifier subscribes to the stream (a StateNotifier
cannot self-invalidate), reloading without dropping to loading so the
list does not flash, and only once the diver id has resolved -- a null
id means "no filter", which would briefly publish every diver's sets.
Edit page: reconcile the selection against equipment that still exists,
keeping retired members and any the diver-scoped provider does not
return.
dive_equipment_defaulter_test ran with PRAGMA foreign_keys = OFF and
built sets from equipment ids that had no equipment row at all -- a state
the database cannot hold in production. Seeded the rows.
Not addressed here: deleteEquipment does not tombstone the cascaded
equipment_set_items, dive_equipment, dive_plan_equipment or
equipment_attributes rows. SyncService's parentRefs marks those parents
non-nullable, so the parent-deletion guard already drops such inbound
records; worth a follow-up issue rather than enlarging this diff.
There was a problem hiding this comment.
Pull request overview
Fixes stale Riverpod caches and non-transactional equipment-set membership updates that could cause FK failures (SqliteException 787) and even propagate unintended empty sets via sync when gear is deleted.
Changes:
- Added a debounced Drift table-update “change tick” (
watchSetChanges) and wired equipment-set providers/notifier to refresh off it (covers sync/import paths that bypass notifiers). - Made
createSet/updateSettransactional and changed membership writes to a diff-based reconcile with post-commit sync bookkeeping; prunes missing equipment ids. - Updated the edit page to reconcile selected ids against currently-existing equipment; added regression tests reproducing #819 end-to-end.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| lib/features/equipment/data/repositories/equipment_set_repository_impl.dart | Adds debounced change-tick stream; makes set writes transactional + diff-based; prunes dead equipment ids and defers sync bookkeeping until after commit. |
| lib/features/equipment/presentation/providers/equipment_set_providers.dart | Self-invalidates set providers from repository change tick; removes stale independent cache; adds notifier subscription to refresh list counts without flashing loading. |
| lib/features/equipment/presentation/pages/equipment_set_edit_page.dart | Prunes deleted selections while the editor is open to prevent invisible selected ids. |
| test/features/equipment/data/repositories/equipment_set_repository_items_test.dart | New repository-level regression coverage for dead ids, diff behavior, and rollback safety. |
| test/features/equipment/presentation/providers/equipment_set_providers_reactivity_test.dart | New provider reactivity coverage for cascades/renames and notifier refresh behavior. |
| test/features/equipment/presentation/pages/equipment_set_edit_page_test.dart | Adds widget-level regression test for deleting a member while editing and successfully saving survivors. |
| test/features/equipment/data/services/dive_equipment_defaulter_test.dart | Seeds equipment rows so set-membership pruning behavior matches production invariants even with FKs disabled in this test. |
Suppressed comments (1)
lib/features/equipment/presentation/providers/equipment_set_providers.dart:139
- When the diver changes,
_diverResolvedstaystrueand_validatedDiverIdretains the previous diver until_initializeAndLoad()finishes. IfwatchSetChanges()emits during that window,_reloadSetsPreservingState()can reload and publish sets for the old diver (or briefly show unfiltered results if_validatedDiverIdis null), overwriting the loading state mid-switch.
if (previous != next) {
state = const AsyncValue.loading();
_ref.invalidate(validatedCurrentDiverIdProvider);
_ref.invalidate(equipmentSetsProvider);
_initializeAndLoad();
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
|
📦 Build artifacts for this PR · commit
Artifacts expire in 7 days. Downloading requires being signed in to GitHub. macOS needs two extractions: unzip the downloaded artifact, then unzip the Updated automatically on each push. |
Summary
Fixes #819. Deleting a gear item that belonged to an equipment set left the set unusable — editing it and saving died with
SqliteException(787): FOREIGN KEY constraint failedon anINSERT INTO "equipment_set_items".The database was behaving correctly.
equipment_set_itemsdeclaresonDelete: KeyAction.cascadeand foreign keys are ON, so SQLite removed the junction row. The stale membership lived in Riverpod:EquipmentListNotifier.refresh()invalidated six equipment providers and zero equipment-set providers, and none of the set providers carried aninvalidateSelfWhen. The edit form then seeded its selection from the pre-delete snapshot (3 ids) while rendering checkboxes from the fresh equipment list (2 tiles) — the deleted id was selected, invisible, and impossible to un-check. That mismatch is exactly why the reporter saw the detail view show 3 members and the edit form show 2.The same flow exposed a second, more damaging defect:
updateSetwas not transactional. It deleted every junction row, tombstoned each one for sync, and only then re-inserted — so the failing save left the set empty with the deletions already logged, propagating the emptiness to the diver's other devices. The error message understated what had happened.Changes
Repository (
equipment_set_repository_impl.dart)watchSetChanges()— a debounced (300 ms)tableUpdatesstream overequipment_sets,equipment_set_items,equipmentandequipment_set_geofences. Watchingequipment_set_itemscatches gear deletes through drift's generatedWritePropagationfor the cascade; that propagation carrieslimitUpdateKind: UpdateKind.delete, soequipmentis watched directly to pick up renames.updateSet/createSetare now transactional and reconcile membership as a diff, mirroringDivePlanRepository.updatePlan, with sync bookkeeping after the commit. Unchanged members no longer emit a tombstone plus a live pending marker on every save — the churnSyncService's contradicted-key handling exists to absorb.equipmentrow, logged atwarning. Deliberately notinsertOrIgnore: this is a foreign-key violation rather than a uniqueness conflict, and swallowing it silently would hide precisely the provider-staleness regression this fixes.Providers (
equipment_set_providers.dart)invalidateSelfWhenonequipmentSetsProviderandequipmentSetProvideronly; the derived providers re-derive through them. A stream rather than hand-written invalidations because sync, imports and dive-computer downloads all delete equipment without touching the notifier.equipmentSetWithItemsProvidernow delegates toequipmentSetProvider. Despite documenting itself as an alias it was a separate cache that nothing ever invalidated, so the dive-log "use set" picker could render arbitrarily stale membership.EquipmentSetListNotifiersubscribes to the stream (aStateNotifiercannot self-invalidate), reloading without dropping toAsyncValue.loadingso the list does not flash, and only once the diver id has resolved — a null id means "no filter", which would briefly publish every diver's sets.Edit page (
equipment_set_edit_page.dart)Test fixture
dive_equipment_defaulter_testran withPRAGMA foreign_keys = OFFand built sets from equipment ids that had noequipmentrow at all — a state the database cannot hold in production. Seeded the rows.Test Plan
flutter testpasses — 16076 passed, 15 skipped, 0 failedflutter analyzepasses — no issuesdart formatclean13 new tests, each verified failing against the pre-fix code — the repository tests reproduce the exact production
SqliteException(787):equipment_set_repository_items_test.dart(new) — the Equipment / Sets – Deleting items causes a problem in the equipment set #819 repro, rollback leaves membership and the deletion log untouched, unchanged membership writes no tombstones, only genuinely removed members are tombstoned.equipment_set_providers_reactivity_test.dart(new) — the cascade tick reaches the cache with no notifier involved (the sync path), renames surface, and the list/picker/notifier surfaces all settle.equipment_set_edit_page_test.dart— end-to-end: open the editor, delete a member, save succeeds and the survivors persist.One earlier full-suite run showed 2 failures in
saved_plans_sheet_testandshearwater_db_reader_test. Both pass in isolation, neither referencesEquipmentSet, and the shearwater one is a pure gzip decompression test that touches neither the database nor Riverpod. A clean re-run confirmed them as load-sensitive flakes.Follow-up (not in this PR)
deleteEquipmentdoes not tombstone the cascadedequipment_set_items,dive_equipment,dive_plan_equipmentorequipment_attributesrows.SyncService'sparentRefsmarks those parents non-nullable, so the parent-deletion guard already drops such inbound records — no reproduction, and bundling it would enlarge the diff into the riskiest file in the repo.