---
phase: 20-offline-mode-hardening
plan: 01
subsystem: database
tags: [sqlite, rusqlite, offline, pending-edits, slint, sync]

# Dependency graph
requires:
  - phase: 18-gh-issues-write-back-notes-and-card-cloud-storage
    provides: PendingEditFlusher, pending_edits SQLite table, SqliteStore pending edit CRUD

provides:
  - count_active_pending_edits() SqliteStore method (excludes dormant retry_count >= 15)
  - has_pending_edits_for_entity() SqliteStore method (D-07 conflict guard)
  - Conflict avoidance in run_sync_cycle card upsert loop (D-07)
  - Conflict avoidance in sync_products unit upsert loop (D-07)
  - pending-edit-count Slint property driven from SyncUpdateCallback after each sync (D-05)
  - pending-edit-count driven from flusher callback after each 60s flush pass (D-05)
  - Flusher startup moved to main.rs with post-window-creation window handle access

affects:
  - 20-02 (UI breathing animation reads pending-edit-count)
  - Any phase touching sync loop or PendingEditFlusher

# Tech tracking
tech-stack:
  added: []
  patterns:
    - Callback-based UI notification pattern (Box<dyn Fn(i32) + Send>) for flusher count updates
      avoids binary-only DashboardWindow type leaking into lib crate
    - D-07 conflict avoidance: check has_pending_edits_for_entity before any sync upsert, local edits always win

key-files:
  created: []
  modified:
    - crates/service/src/db/sqlite.rs
    - crates/app/src/live_client.rs
    - crates/app/src/dashboard/pending_edit_flusher.rs
    - crates/app/src/main.rs

key-decisions:
  - "Use Box<dyn Fn(i32) + Send> callback in start_flusher instead of Weak<DashboardWindow> — keeps flusher in lib crate, DashboardWindow is binary-only"
  - "Flusher startup moved from LiveClient::new to main.rs so callback can capture a live Weak<DashboardWindow>"
  - "Dormant edits (retry_count >= 15) excluded from both count_active_pending_edits and has_pending_edits_for_entity — dormant edits must not permanently block sync"

patterns-established:
  - "D-07 conflict avoidance: every sync upsert loop checks has_pending_edits_for_entity before writing"
  - "pending-edit-count has two update paths: SyncUpdateCallback (after sync) and flusher callback (after flush pass)"

requirements-completed: [OFFLINE-01, OFFLINE-02, OFFLINE-03]

# Metrics
duration: 45min
completed: 2026-04-10
---

# Phase 20 Plan 01: Offline Mode Hardening — SQLite pending-edit guards and UI count wiring

**SQLite conflict-avoidance guards (D-07) prevent sync from overwriting in-flight local edits, and `pending-edit-count` Slint property is now driven from both the sync callback and the flusher**

## Performance

- **Duration:** ~45 min
- **Started:** 2026-04-10T00:00:00Z
- **Completed:** 2026-04-10T00:45:00Z
- **Tasks:** 2
- **Files modified:** 4

## Accomplishments

- Added `count_active_pending_edits()` and `has_pending_edits_for_entity()` to SqliteStore with 7 unit tests covering empty table, active edits, dormant exclusion, and entity matching
- Sync cycle now skips `upsert_card` for any card with active pending edits (D-07 compliance)
- `sync_products` now skips `upsert_product_unit` for any serial with active pending edits (D-07 compliance)
- `pending-edit-count` Slint property updated after every sync cycle via `SyncUpdateCallback`
- `pending-edit-count` Slint property updated after every 60s flush pass via flusher callback
- Flusher startup moved from `LiveClient::new` to `main.rs` for post-window-creation `Weak<DashboardWindow>` capture

## Task Commits

1. **Task 1: Add SqliteStore pending-edit query methods and conflict avoidance in run_sync_cycle** - `de0de4c` (feat)
2. **Task 2: Wire pending-edit-count from flusher and sync callback** - `bcac95b` (feat)

## Files Created/Modified

- `crates/service/src/db/sqlite.rs` - Added `count_active_pending_edits()`, `has_pending_edits_for_entity()`, and 7 unit tests
- `crates/app/src/live_client.rs` - D-07 conflict avoidance in card and unit upsert loops; flusher removed from `LiveClient::new`; `gh_issues_client()` accessor added
- `crates/app/src/dashboard/pending_edit_flusher.rs` - `start_flusher` accepts `on_count_updated: Option<Box<dyn Fn(i32) + Send>>` callback; calls `count_active_pending_edits` after each flush pass
- `crates/app/src/main.rs` - Flusher started with `invoke_from_event_loop` count callback; `SyncUpdateCallback` sets `pending_edit_count` after each sync

## Decisions Made

- **Callback over Weak handle in flusher:** `DashboardWindow` is generated by `slint::include_modules!()` in the binary's `main.rs` — it is not in the lib crate. Using `Box<dyn Fn(i32) + Send>` as the notification interface keeps `pending_edit_flusher.rs` fully lib-compatible. The `invoke_from_event_loop` wiring lives in `main.rs` where `DashboardWindow` is in scope.
- **Flusher moved to main.rs:** `LiveClient::new` does not have a window handle. Moving flusher startup to after window creation enables the callback to capture `window.as_weak()` cleanly, with no need for a secondary `set_window` method.
- **Dormant edits excluded from conflict guard:** edits with `retry_count >= 15` are effectively abandoned and must not permanently prevent sync from updating cards. The `< 15` filter in both methods ensures abandoned edits don't hold sync hostage indefinitely.

## Deviations from Plan

### Auto-fixed Issues

**1. [Rule 3 - Blocking] Used callback instead of Weak<DashboardWindow> in start_flusher**
- **Found during:** Task 2 (update start_flusher signature)
- **Issue:** Plan suggested `Option<Weak<crate::DashboardWindow>>` but `DashboardWindow` is a binary-only Slint type not available in the lib crate — compile error `cannot find type DashboardWindow in the crate root`
- **Fix:** Changed parameter to `Option<Box<dyn Fn(i32) + Send + 'static>>`. Caller (main.rs) wraps `slint::invoke_from_event_loop` in the closure, keeping type-specific code in the binary.
- **Files modified:** `crates/app/src/dashboard/pending_edit_flusher.rs`, `crates/app/src/main.rs`
- **Verification:** `cargo build --package app` clean; all 188 app tests pass
- **Committed in:** `bcac95b` (Task 2 commit)

---

**Total deviations:** 1 auto-fixed (1 blocking)
**Impact on plan:** Required for compilation. The callback pattern is architecturally cleaner than the Weak handle approach — the flusher remains UI-type-agnostic. D-05 intent fully satisfied.

## Issues Encountered

None beyond the DashboardWindow type boundary issue described above.

## Known Stubs

None — all new methods have real implementations backed by SQLite queries.

## Threat Flags

No new network endpoints, auth paths, or trust boundary changes introduced. The two new SqliteStore methods are read-only queries on a local-only table (T-20-01 and T-20-02 already addressed in the plan's threat register).

## Next Phase Readiness

- `pending-edit-count` Slint property is now live-driven from both sync and flush paths
- Plan 20-02 can wire the breathing animation to `pending-edit-count` immediately
- D-07 conflict avoidance is fully operational — no further sync/persistence changes needed for offline integrity

---
*Phase: 20-offline-mode-hardening*
*Completed: 2026-04-10*
