Skip to content

Fix stale equipment-set caches after deleting gear (#819) - #960

Merged
ericgriffin merged 1 commit into
mainfrom
worktree-issue-819-equipment-set-orphans
Aug 10, 2026
Merged

Fix stale equipment-set caches after deleting gear (#819)#960
ericgriffin merged 1 commit into
mainfrom
worktree-issue-819-equipment-set-orphans

Conversation

@ericgriffin

@ericgriffin ericgriffin commented Aug 10, 2026

Copy link
Copy Markdown
Member

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 failed on an INSERT INTO "equipment_set_items".

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) — 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: 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. The error message understated what had happened.

Changes

Repository (equipment_set_repository_impl.dart)

  • watchSetChanges() — a debounced (300 ms) tableUpdates stream over equipment_sets, equipment_set_items, equipment and equipment_set_geofences. Watching equipment_set_items catches gear deletes through drift's generated WritePropagation for the cascade; that propagation carries limitUpdateKind: UpdateKind.delete, so equipment is watched directly to pick up 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 emit 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. Deliberately not insertOrIgnore: 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)

  • invalidateSelfWhen on equipmentSetsProvider and equipmentSetProvider only; 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.
  • equipmentSetWithItemsProvider now delegates to equipmentSetProvider. 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.
  • EquipmentSetListNotifier subscribes to the stream (a StateNotifier cannot self-invalidate), reloading without dropping to AsyncValue.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 (equipment_set_edit_page.dart)

  • Reconciles the selection against equipment that still exists, deliberately keeping retired members and any the diver-scoped provider does not return.

Test fixture

  • 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.

Test Plan

  • flutter test passes — 16076 passed, 15 skipped, 0 failed
  • flutter analyze passes — no issues
  • dart format clean
  • Manual testing on: not yet run

13 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_test and shearwater_db_reader_test. Both pass in isolation, neither references EquipmentSet, 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)

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 — no reproduction, and bundling it would enlarge the diff into the riskiest file in the repo.

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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/updateSet transactional 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, _diverResolved stays true and _validatedDiverId retains the previous diver until _initializeAndLoad() finishes. If watchSetChanges() emits during that window, _reloadSetsPreservingState() can reload and publish sets for the old diver (or briefly show unfiltered results if _validatedDiverId is 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.

@ericgriffin ericgriffin moved this from Backlog to In review in Submersion Release Tracker Aug 10, 2026
@ericgriffin ericgriffin added the bug Something isn't working label Aug 10, 2026
@ericgriffin ericgriffin self-assigned this Aug 10, 2026
@codecov

codecov Bot commented Aug 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.90722% with 3 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...ta/repositories/equipment_set_repository_impl.dart 97.40% 2 Missing ⚠️
...resentation/providers/equipment_set_providers.dart 93.33% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@github-actions

Copy link
Copy Markdown
Contributor

📦 Build artifacts for this PR · commit 83778b7

Platform Download
Android (APK) android-apk
macOS macos-build
Windows windows-build
Linux linux-build

Artifacts expire in 7 days. Downloading requires being signed in to GitHub. macOS needs two extractions: unzip the downloaded artifact, then unzip the submersion-macos.zip inside it to get a runnable submersion.app. The build is ad-hoc signed — right-click → Open on first launch.

Updated automatically on each push.

@ericgriffin
ericgriffin merged commit b7c94e0 into main Aug 10, 2026
26 checks passed
@github-project-automation github-project-automation Bot moved this from In review to Done in Submersion Release Tracker Aug 10, 2026
@ericgriffin
ericgriffin deleted the worktree-issue-819-equipment-set-orphans branch August 10, 2026 22:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Equipment / Sets – Deleting items causes a problem in the equipment set

2 participants