Skip to content

offload data loss fixes - #235

Open
abdulsaheel wants to merge 14 commits into
mainfrom
integration/gen4-data-integrity
Open

offload data loss fixes#235
abdulsaheel wants to merge 14 commits into
mainfrom
integration/gen4-data-integrity

Conversation

@abdulsaheel

@abdulsaheel abdulsaheel commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

bunch of data loss fixes on the offload path, all in one branch.

  • decoded_onehz was keyed on counter, counter resets to ~0 on reboot so a new second could evict an older one. keyed on rec_ts now
  • raw_archive had the same problem, keyed it on hex
  • sync commit wasnt fsynced before we ack, so a power cut after the band trims loses it. FULL around that one commit only
  • burst shortfall logging, uses the received total not just the ones we bank
  • dont drain history when the phone clock looks wrong, we were dropping the bands records as "future" and then trimming them. just waits now

schema goes to 33.

Summary by CodeRabbit

  • Bug Fixes

    • Prevented historical data drains and clock corrections when strap and phone clocks appear significantly out of sync.
    • Improved recovery when clocks realign, with diagnostic status available during pauses.
    • Preserved RR data accurately across strap counter resets, reboots, imports, and migrations.
    • Improved archived-frame deduplication while retaining distinct records that reuse counters.
  • Reliability

    • Added more comprehensive burst-traffic diagnostics to identify possible missing packets without changing acknowledgment behavior.
    • Strengthened database synchronization and migration integrity.

…ed counter

raw_archive is the durable dead-letter box for undecodable frames — its whole
purpose is to never lose a frame until we can decode it. But it was
counter INTEGER PRIMARY KEY with IGNORE-on-conflict, and the strap resets its
record counter to ~0 on every reboot. So a post-reboot frame that reused a
still-present pre-reboot counter was silently DROPPED, even though its bytes
were completely different data.

Re-key the table off the volatile counter onto frame hex (content identity),
exactly like events/band_events already do: an identical re-flood (missed-ACK
redelivery) still dedups, but two genuinely distinct frames survive a counter
collision. counter is retained as a plain forensic column.

v32 migration rebuilds the table preserving every existing row (their counters
are unique, so the content-keyed copy loses nothing). Guarded because
raw_archive is created lazily in onOpen, not the ladder, so an old DB may not
have it yet at migration time — then a fresh hex-keyed create is all that's
needed.

Adds a regression test: two distinct frames sharing a reused counter both
survive (previously the second was lost).
… received-total signal)

Emit an honest, observation-only frame-loss signal at HISTORY_END without
touching the commit/ACK decision. The band's num_packets counts every frame
it transmitted (all types); the correct completeness comparison is against
totalTrafficPacketCount — the all-types received total — not the banked R24
subset (which fabricates a shortfall whenever console/event frames ride along
un-banked). This is type-agnostic and interleaving-immune.

New pure helper burstPacketShortfall() = expected - (received_all_types +
dropped_this_burst): a POSITIVE result is frames the band sent that never
reached us (true loss); zero is complete; negative is retries/dupes, not loss.
Gate-dropped (RecordGate) records are added back so plausibility rejections
never read as radio loss.

At burst end we now log a "would-flag" line and stamp burst_shortfall into the
existing mismatch ledger entry — LOG-ONLY. Commit-before-ACK, the verbatim
token echo, and the OK/FAIL decision are all unchanged. This is groundwork so
we can SEE true frame loss in telemetry before ever wiring a field-validated
FAIL gate; a hard FAIL/re-flood path is deliberately NOT included here.

Rejected alternative: gating on the per-revision counter gap — the counter is
a GLOBAL flash-log index sliced per revision, so gaps are the normal state and
would false-positive constantly.

Adds pure unit tests covering benign interleaving (no false positive), true
loss, the gate-dropped add-back, negative/retry case, and shortfall==0 ==
burstPacketCountMatches.
The DB runs WAL + synchronous=NORMAL, under which a commit is durable only
at the next checkpoint, not at commit. commitSyncBatch persists the sync
batch (raw_archive + samples + decoded + trim cursor) and returns; the
caller then writes the BLE batch-ACK and the band trims its flash. A kernel
panic / battery-yank AFTER the ACK but BEFORE the -wal is checkpointed lost
those just-committed rows from the phone while they were already gone from
the band. The commit-before-ACK ordering held; the durability did not.

Raise durability to synchronous=FULL (fsync AT commit) for this one commit
only, leaving every other path at NORMAL — they are all recomputable and
FULL everywhere is brutally slow. synchronous is per-connection and cannot
change mid-transaction, so it is set BEFORE db.transaction opens and reset
to NORMAL in a finally (a leaked FULL would fsync every later write on the
connection forever). Both the main and background-isolate drains funnel
through commitSyncBatch, each on its own connection, so this single bracket
covers both. PRAGMA synchronous returns no rows -> execute(), kept non-fatal
like the open-time PRAGMAs.

Adds a focused test (spies the FULL/NORMAL SQL bracket and reads resting
PRAGMA synchronous) covering both a normal commit and a throwing one.
A positive burst shortfall means frames the band counted that we did not count
as valid received traffic. CRC-failed frames also never enter
currentBurstTrafficCount, so a positive shortfall can be missing OR corrupted
traffic — it cannot by itself prove a frame never arrived. Soften the helper
doc, the would-flag log text, and the test name accordingly. Wording-only;
no behavior change (still log-only).
The strap resets its per-record `counter` to ~0 on every reboot, and
`decoded_onehz` was `counter INTEGER PRIMARY KEY`. So a post-reboot record
(counter=c, rec_ts=T2) REPLACE-evicted a still-present pre-reboot row
(counter=c, rec_ts=T1), silently deleting T1's only decoded 1 Hz row. Because
`raw_records` is dropped (not a live ledger), the decoded store is the sole
system of record, making the eviction UNRECOVERABLE. No orphan-guard patch can
restore an evicted row — the key itself has to change.

Re-key both decoded tables onto record time:
- decoded_onehz PK -> rec_ts; `counter` demoted to a NOT NULL forensic column
  (+ index), still the keyset-cursor tiebreak (never fires now rec_ts is unique).
- decoded_rr PK -> (rec_ts, beat_index); rr_ts_ms kept as the beat timestamp.
- Write path per second: REPLACE decoded_onehz(rec_ts,...); DELETE decoded_rr
  by rec_ts; insert the beats. Parent and child now share the rec_ts key, so the
  counter-based orphan guard and the prune orphan-sweep are deleted — a shrinking
  beat count can no longer strand stale high-index beats.

Caller audit (every counter-identity query rewritten to rec_ts):
- decodedRrByCounterRange -> decodedRrByRecTsRange (a clean PK range read; drops
  the degraded counter-span fallback + truncation counter that only existed to
  paper over the reboot reset).
- derive_prepare.addDecodedPage groups RR by rec_ts, not counter (a counter reuse
  within a page had mis-joined two seconds' beats).
- deleteDays / pruneDecodedBeforeRecTs / export copyRawRange / importFromDb all
  select decoded_rr by rec_ts; import derives rec_ts from rr_ts_ms for legacy
  (counter-keyed, no rec_ts) backups.

Migration v33 (`_rekeyDecodedStoreByRecTs`): rebuilds BOTH decoded tables FROM
THE EXISTING decoded tables only (never from the dropped raw_records — that would
zero the store), rename-aside, deterministic newest-wins by rec_ts, idempotent,
pure INSERT..SELECT so the iOS 999-var limit never applies. The frozen v11/v17/v19
steps are made schema-adaptive so the ladder still completes.

NOTE: base is origin/main at schemaVersion 31; PR #231 (pending) bumps to 32, so
this uses 33 — a trivial schemaVersion rebase is expected when they merge.
The headless drain (background_sync.dart) is the iOS CoreBluetooth-restoration
recovery path and runs in the MAIN isolate on the same shared _db connection —
not a separate per-isolate connection as the prior comment claimed. The bracket
is safe not because of isolation but because BandOwnership + the single-flight
offload processor guarantee the two drains never overlap on one connection.
Document that as the load-bearing invariant so a future concurrent caller does
not silently defeat the FULL window.
…-PK DB

The v32 migration (RENAME → drop-index → hex-PK create → INSERT OR IGNORE
SELECT → drop-old) had no coverage — the archive test only exercises the
fresh onCreate schema, and the ladder test never touched raw_archive. Seed a
populated v31 counter-PK table, open it through the REAL ladder, and assert:
distinct frames survive, an exact-duplicate hex collapses (5 rows → 4), a
reused counter no longer drops a distinct frame (hex-PK proven end-to-end),
and an identical re-flood still dedups on content.
The v33 re-key touches frozen migration steps (v11/v17/v19), but no ladder
test seeded a genuinely OLD counter-keyed decoded store. The riskiest path is
a user installed at v19..31: raw_records is already dropped by then, so the
rekey is the SOLE copy of their 1 Hz data with no raw-backfill safety net.
Seed that exact origin/main schema at v31, run the real ladder, and assert
every second/beat survives, counter is preserved as the forensic column, the
PK moved to rec_ts, and no temp tables leak.
# Conflicts:
#	lib/data/db.dart
#	test/db_migration_ladder_test.dart
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds phone-clock-aware BLE history deferral and burst shortfall telemetry. It also migrates decoded and raw archive storage from counter-based identity to timestamp or frame-content identity, updates compute and database flows, and adds migration, durability, and integrity tests.

Changes

BLE reliability telemetry

Layer / File(s) Summary
Phone-clock suspicion and history gating
lib/sync/sync_policy.dart, lib/ble/ble_engine.dart, test/sync_policy_test.dart, test/ble_engine_test.dart
ClockPolicy.phoneClockSuspect detects future strap clocks. BLE setup and history refresh defer clock correction and historical drains until clocks agree.
Burst shortfall diagnostics
lib/ble/ble_engine.dart, test/ble_engine_test.dart
Burst telemetry uses all received traffic and plausibility-gated drops. Positive shortfalls are logged and persisted without changing commits or ACKs.

Timestamp-keyed persistence

Layer / File(s) Summary
Schema keys and migrations
lib/data/db.dart, test/db_migration_ladder_test.dart, test/raw_archive_test.dart
Schema version 33 keys decoded rows by rec_ts. Raw archive rows use frame hex. Migrations preserve data across counter reuse and deduplicate identical frames.
Durable decoded and archive writes
lib/data/db.dart, pubspec.yaml, test/ack_commit_sync_full_test.dart
Decoded writes replace rows and RR beats by timestamp. Sync commits restore SQLite synchronous mode after success or failure.
Timestamp-based compute and database flows
lib/compute/*.dart, lib/data/db.dart, test/db_integrity_test.dart, test/db_p0_fixes_test.dart, test/db_paged_import_export_test.dart, test/db_storage_hygiene_test.dart, test/local_persistence_test.dart
RR derivation, reads, imports, exports, deletion, pruning, and integrity checks use rec_ts.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

Suggested reviewers: localhoop, brackyt

Sequence Diagram(s)

sequenceDiagram
  participant BleEngine
  participant Strap
  participant ClockPolicy
  BleEngine->>Strap: Read strap clock
  Strap-->>BleEngine: Return RTC timestamp
  BleEngine->>ClockPolicy: Evaluate phoneClockSuspect
  ClockPolicy-->>BleEngine: Return clock state
  BleEngine->>BleEngine: Defer or resume history offload
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the primary changes that fix data loss in the offload path.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Latest suggestions up to 8573d7e

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Guard against empty page before indexing

When decodedRows is empty, accessing .first and .last will throw a StateError at
runtime. The existing counter-based code had the same shape, but the new rec_ts path
is equally exposed. Guard against an empty page before indexing into it.

lib/compute/derivation_engine.dart [1842-1849]

-final firstRecTs = (decodedRows.first['rec_ts'] as num?)?.toInt();
-final lastRecTs = (decodedRows.last['rec_ts'] as num?)?.toInt();
+final firstRecTs = decodedRows.isEmpty ? null : (decodedRows.first['rec_ts'] as num?)?.toInt();
+final lastRecTs = decodedRows.isEmpty ? null : (decodedRows.last['rec_ts'] as num?)?.toInt();
 final rrRows = firstRecTs == null || lastRecTs == null
     ? const <Map<String, dynamic>>[]
     : await LocalDb.decodedRrByRecTsRange(
         fromRecTs: firstRecTs,
         toRecTs: lastRecTs,
       );
Suggestion importance[1-10]: 7

__

Why: Accessing .first and .last on an empty decodedRows list would throw a StateError at runtime. The suggestion correctly adds an isEmpty guard before indexing, which is a valid defensive improvement, though in practice the paging logic may guarantee non-empty pages.

Medium
Prevent double re-key on upgrade paths from before v19

_rekeyDecodedStoreByRecTs is called in both the oldV < 19 branch and the oldV < 33
branch. A database upgrading from v19–v31 will execute the oldV < 33 branch
(correct), but a database upgrading from v11–v18 will execute BOTH branches
sequentially — the first call converts the counter-keyed tables to rec_ts-keyed, and
the second call then runs _rekeyDecodedStoreByRecTs again on the already-converted
tables. While _rekeyDecodedStoreByRecTs calls _createDecodedStore (which is now a
no-op via IF NOT EXISTS on the new schema), the INSERT OR REPLACE copy from
decoded_onehz into _decoded_onehz_v33 will still execute and is wasteful and
potentially risky. The oldV < 33 branch should guard against re-running when the
store was already re-keyed in the same upgrade session (i.e., skip if oldV < 19).

lib/data/db.dart [471-477]

-if (oldV < 19) {
-      // The v17 step (or a v11-16 origin) may leave OLD counter-keyed decoded
-      // tables here; the backfill below writes through the rec_ts-keyed
-      // _queueDecodedOneHz, so convert to the current schema first (preserving
-      // any existing rows), then reconstruct the rest from raw_records.
-      await _rekeyDecodedStoreByRecTs(db);
-      await _backfillDecodedStore(db);
-      ...
-    }
-    ...
-    if (oldV < 33) {
+if (oldV < 33 && oldV >= 19) {
       // RE-KEY the decoded ledger off the volatile record `counter` onto
       // rec_ts. The counter resets to ~0 on every reboot, so counter-as-PK
       // let a post-reboot second REPLACE-evict a pre-reboot one — silently,
       // unrecoverably deleting a 1 Hz row (raw_records is dropped).
+      // (Databases upgrading from < v19 already ran _rekeyDecodedStoreByRecTs
+      // in the oldV < 19 branch above; skip to avoid a redundant double-rekey.)
       await _rekeyDecodedStoreByRecTs(db);
     }
Suggestion importance[1-10]: 6

__

Why: The concern is valid: a DB upgrading from v11–v18 runs _rekeyDecodedStoreByRecTs twice in the same session. However, _rekeyDecodedStoreByRecTs is designed to be idempotent (it calls _createDecodedStore with IF NOT EXISTS, drops temp tables up front, and uses INSERT OR REPLACE), so the double-run is safe though wasteful. The fix is reasonable but the risk is low given the idempotency guarantees.

Low
Reset offload latch when sendInit throws during connect

When drainOnInit is false, _setOffloadActive(false) is called but if sendInit
subsequently throws, the catch block at the end of _doConnect returns false without
clearing _offloadActive — however since it was set to false already that is fine.
The real issue is: when drainOnInit is true and sendInit throws, _offloadActive was
set to true by _setOffloadActive(true) but the catch path does not call
_setOffloadActive(false). This is the sticky boolean latch pattern documented in
AGENTS.md §4.3 — _offloadActive stays true after a failed sendInit, wedging future
sync attempts. The _setOffloadActive(true) call should be moved inside a try block
with a finally that resets it on failure, or the catch block should reset it.

lib/ble/ble_engine.dart [1445-1461]

-final drainOnInit = !_phoneClockSuspect;
-  if (!drainOnInit) {
-    _clockPausedOffloads++;
-    _log(
-      '[SYNC] INIT drain DEFERRED — phone clock appears wrong relative to '
-      'the strap RTC; not draining history until they agree '
-      '(deferred_total=$_clockPausedOffloads).',
-    );
-  }
-  _setOffloadActive(drainOnInit);
+_setOffloadActive(drainOnInit);
   // Only a real drain spends the backfill floor; a deferred one leaves it
   // open so a foreground trigger can retry as soon as the phone corrects.
   if (drainOnInit) _lastBackfillAt = _wallSecs();
-  await sendInit(drain: drainOnInit); // seq4 triggers the offload flood
+  try {
+    await sendInit(drain: drainOnInit); // seq4 triggers the offload flood
+  } catch (e) {
+    _setOffloadActive(false);
+    rethrow;
+  }
   return true;
 } catch (e) {
   _log('connect setup failed: $e');
Suggestion importance[1-10]: 5

__

Why: The concern about _offloadActive staying true if sendInit throws is legitimate — this could wedge future sync attempts. However, the outer catch (e) block in _doConnect likely handles cleanup and returns false, and the existing code structure may already handle this via other mechanisms. The suggested fix is reasonable but the improved_code introduces a nested try/catch that interacts awkwardly with the outer catch block structure shown.

Low
General
Align RR beat winner selection with onehz row winner during re-key

The comment says "Deterministic newest-wins: ORDER BY rec_ts, counter so INSERT OR
REPLACE on the rec_ts PK keeps the highest-counter (latest-offloaded) row per
second." However, INSERT OR REPLACE with ORDER BY rec_ts ASC, counter ASC means the
LAST row inserted for each rec_ts wins — which is the row with the HIGHEST counter
(ascending order, last inserted wins via REPLACE). This is correct. But the comment
says "highest-counter (latest-offloaded)" which is the intended semantic. The issue
is that counter ASC means lower counters are inserted first and higher counters
overwrite them — this is correct behavior. However, for the decoded_rr copy, ORDER
BY rr_ts_ms ASC, beat_index ASC is used, but the source table may have a counter
column (old schema) and beats from different counters for the same rec_ts could
interleave. The RR copy should also order by counter to ensure the same winning
counter's beats are kept last, matching the onehz winner selection.

lib/data/db.dart [2371-2374]

 await db.execute(
-  'INSERT OR REPLACE INTO _decoded_onehz_v33 '
-  '(rec_ts, counter, hr, ax, ay, az, spo2_red_raw, spo2_ir_raw, skin_temp_raw) '
-  'SELECT rec_ts, counter, hr, ax, ay, az, spo2_red_raw, spo2_ir_raw, skin_temp_raw '
-  'FROM decoded_onehz ORDER BY rec_ts ASC, counter ASC',
+  'INSERT OR REPLACE INTO _decoded_rr_v33 (rec_ts, beat_index, rr_ts_ms, rr_ms) '
+  'SELECT rr_ts_ms / 1000, beat_index, rr_ts_ms, rr_ms '
+  'FROM decoded_rr ORDER BY rr_ts_ms ASC, beat_index ASC, counter ASC',
 );
Suggestion importance[1-10]: 3

__

Why: The suggestion proposes adding counter ASC to the decoded_rr ORDER BY during re-key to align beat winner selection with the onehz winner. However, the old decoded_rr schema uses PRIMARY KEY (counter, beat_index) — beats from different counters for the same rec_ts are already distinct by beat_index, and the re-key derives rec_ts from rr_ts_ms / 1000. The counter column may not exist in all source schemas (the code guards for this), making the suggestion potentially incorrect for some upgrade paths.

Low

Previous suggestions

Suggestions up to commit 90f9588
CategorySuggestion                                                                                                                                    Impact
General
Guard missing counter column in legacy import path

When importing a legacy decoded_rr row (no rec_ts column), rec_ts is derived from
rr_ts_ms and then the row is inserted with ConflictAlgorithm.replace. However, the
row still carries the old counter column from the foreign export, but the new schema
has rec_ts as PRIMARY KEY and counter as a plain NOT NULL column. If the legacy row
has no counter field at all (schema predates it), the insert will fail the NOT NULL
constraint. Additionally, the row map may contain the old counter column but not
rec_ts, so the insert could also fail if the new schema's rec_ts NOT NULL PK is not
satisfied for non-legacy rows. A guard should ensure counter is present (defaulting
to 0 as a forensic placeholder) when importing legacy rows that lack it.

lib/data/db.dart [4046-4051]

 if (t == 'decoded_rr' &&
     row['rec_ts'] == null &&
     row['rr_ts_ms'] != null) {
   row['rec_ts'] = ((row['rr_ts_ms'] as num).toInt()) ~/ 1000;
+  row['counter'] ??= 0; // legacy export may lack counter; 0 is a forensic placeholder
 }
 batch.insert(t, row, conflictAlgorithm: ConflictAlgorithm.replace);
Suggestion importance[1-10]: 5

__

Why: The concern about a legacy decoded_rr row lacking a counter column is valid — if the old schema predates the counter column in decoded_rr, the insert into the new schema (which has counter NOT NULL in decoded_onehz but not in decoded_rr) could fail. However, decoded_rr in the new schema does NOT have a counter column, so this concern only applies to decoded_onehz. The suggestion's scope is slightly off but the underlying concern about missing columns in legacy imports is worth considering.

Low
Boolean latch lacks reset on all failure paths

_phoneClockSuspect is a boolean latch that is set in the clock_epoch handler and
cleared when a subsequent read agrees. However, if _startHistoricalRefresh returns
early here (deferred path), _setOffloadActive(false) is called but there is no
try/finally around the early-return block. If a future code path between
_setOffloadActive(true) (called by the caller of _startHistoricalRefresh) and this
early return throws before reaching _setOffloadActive(false), the latch stays set
and sync wedges — the same sticky-latch pattern the repo's own commit history flags
as recurring. The _setOffloadActive(false) call on the defer path should be in a
finally block (or the caller must guarantee it), consistent with the pattern used on
the normal drain path.

lib/ble/ble_engine.dart [1613-1622]

 if (_phoneClockSuspect) {
   _clockPausedOffloads++;
   _log(
     '[SYNC] refresh($reason) DEFERRED — phone clock appears wrong relative '
     'to the strap RTC; not draining history until they agree '
     '(deferred_total=$_clockPausedOffloads).',
   );
   _setOffloadActive(false);
   return;
 }
+// NOTE: wrap the remainder of _startHistoricalRefresh in try/finally
+// so _setOffloadActive(false) is guaranteed on every exit path, not
+// only the clock-defer branch — matching the pattern the repo requires
+// for every boolean latch (see AGENTS.md §4.3).
Suggestion importance[1-10]: 2

__

Why: The improved_code is essentially the same as the existing_code with only a comment added, which means no actual code change is proposed. The suggestion identifies a theoretical risk but the existing_code and improved_code are functionally identical, making this a documentation-only suggestion that doesn't resolve the stated concern.

Low
Possible issue
Migration excludes orphan RR beats via counter join

The comment says "newest-wins: ORDER BY rec_ts, counter so INSERT OR REPLACE keeps
the highest-counter row per second," but ORDER BY rec_ts ASC, counter ASC inserts in
ascending counter order, meaning the LAST inserted (highest counter) wins via
REPLACE — that is correct. However, the decoded_rr copy derives rec_ts from rr_ts_ms
/ 1000 and uses INSERT OR REPLACE ordered by rr_ts_ms ASC, beat_index ASC. If the
old schema had orphan beats (owning counter evicted, so rr_ts_ms maps to a rec_ts
that now belongs to a different counter's row), those beats will be silently
imported under the wrong second. The migration should join against the surviving
decoded_onehz rows to exclude orphan beats, exactly as _rebuildCanonicalDecodedStore
did with its counter-join.

lib/data/db.dart [2371-2374]

 await db.execute(
-  'INSERT OR REPLACE INTO _decoded_onehz_v33 '
-  '(rec_ts, counter, hr, ax, ay, az, spo2_red_raw, spo2_ir_raw, skin_temp_raw) '
-  'SELECT rec_ts, counter, hr, ax, ay, az, spo2_red_raw, spo2_ir_raw, skin_temp_raw '
-  'FROM decoded_onehz ORDER BY rec_ts ASC, counter ASC',
+  'INSERT OR REPLACE INTO _decoded_rr_v33 (rec_ts, beat_index, rr_ts_ms, rr_ms) '
+  'SELECT rr.rr_ts_ms / 1000, rr.beat_index, rr.rr_ts_ms, rr.rr_ms '
+  'FROM decoded_rr rr '
+  'JOIN decoded_onehz d ON d.counter = rr.counter '
+  'ORDER BY rr.rr_ts_ms ASC, rr.beat_index ASC',
 );
Suggestion importance[1-10]: 4

__

Why: The suggestion raises a valid concern about orphan beats in the old counter-keyed schema being imported under wrong seconds. However, the PR's comment explicitly states "Pre-fix orphan beats (owning row evicted) re-home onto their real second here" — the design intentionally re-homes them by rr_ts_ms / 1000 rather than excluding them. The join approach would silently drop those beats, which may be worse than re-homing them. The suggestion is debatable in correctness.

Low
Suggestions up to commit 25a75f4
CategorySuggestion                                                                                                                                    Impact
General
Guard re-key migration against already-migrated schema

The v32 migration runs _createRawArchive(db) which uses CREATE TABLE IF NOT EXISTS,
but the old raw_archive table was just renamed to _raw_archive_old — so the IF NOT
EXISTS guard will create a fresh hex-keyed table correctly. However,
_createRawArchive also creates an index named idx_raw_archive_captured; the comment
warns about the "leaked-_new-index footgun" but the DROP INDEX IF EXISTS
idx_raw_archive_captured only runs in the hasArchive branch before
_createRawArchive. In the else branch (no existing table), _createRawArchive is
called directly and will create the index fresh — that is fine. But if
_repairOpenSchema previously created a counter-keyed raw_archive with
idx_raw_archive_captured already present, and then this migration runs, the DROP
INDEX before _createRawArchive is correct. The real gap: counter is declared NOT
NULL in the new _createRawArchive schema per the diff (counter INTEGER, — actually
nullable), but the INSERT copies counter from the old table where it was INTEGER
PRIMARY KEY (always non-null). This is fine. The actual bug:
_rekeyDecodedStoreByRecTs calls _createDecodedStore(db) at its top, which uses
CREATE TABLE IF NOT EXISTS with the NEW rec_ts-keyed schema. If the old
counter-keyed tables already exist (the v33 path), IF NOT EXISTS is a no-op and the
old schema stays — the subsequent INSERT SELECT then reads from the old schema into
the new temp tables correctly. But after DROP TABLE decoded_onehz and RENAME
_decoded_onehz_v33 TO decoded_onehz, the _createDecodedStore call at the top already
created nothing (tables existed). This is correct. No runtime bug here either. The
genuine issue: _rekeyDecodedStoreByRecTs is called from BOTH oldV < 19 (the backfill
path) AND oldV < 33. On a fresh install going through oldV < 19, _createDecodedStore
is called first (new schema), then _rekeyDecodedStoreByRecTs is called — which calls
_createDecodedStore again (no-op), creates temp tables, copies from the (empty)
new-schema tables, drops and renames. This is a wasteful but harmless no-op on fresh
installs. On the oldV < 33 path it is the real migration. No critical bug, but the
double-call on the oldV < 19 path is non-idempotent in the sense that it runs the
full rename dance on empty tables unnecessarily. The _rebuildCanonicalDecodedStore
early-exit guard (checking for counter column absence) should also be applied to
_rekeyDecodedStoreByRecTs to skip the rename dance when the store is already
rec_ts-keyed (e.g. fresh install going through oldV < 19 then oldV < 33).

lib/data/db.dart [2329-2335]

-if (oldV < 32) {
-      ...
-      if (hasArchive) {
-        await db.execute('ALTER TABLE raw_archive RENAME TO _raw_archive_old');
-        await db.execute('DROP INDEX IF EXISTS idx_raw_archive_captured');
-        await _createRawArchive(db);
-        await db.execute(
-          'INSERT OR IGNORE INTO raw_archive '
-          '(hex, counter, packet_type, rec_ts, captured_at, reason) '
-          'SELECT hex, counter, packet_type, rec_ts, captured_at, reason '
-          'FROM _raw_archive_old',
-        );
-        await db.execute('DROP TABLE _raw_archive_old');
-      } else {
-        await _createRawArchive(db);
-      }
-    }
-    if (oldV < 33) {
-      await _rekeyDecodedStoreByRecTs(db);
-    }
+static Future<void> _rekeyDecodedStoreByRecTs(Database db) async {
+  // Skip if already rec_ts-keyed (fresh install or already migrated).
+  final rrCols = await db.rawQuery('PRAGMA table_info(decoded_rr)');
+  if (rrCols.isNotEmpty && !rrCols.any((c) => c['name'] == 'counter')) return;
+  await _createDecodedStore(db);
+  await db.execute('DROP TABLE IF EXISTS _decoded_onehz_v33');
+  await db.execute('DROP TABLE IF EXISTS _decoded_rr_v33');
+  // ... rest of the method unchanged
Suggestion importance[1-10]: 6

__

Why: The suggestion correctly identifies that _rekeyDecodedStoreByRecTs is called from both the oldV < 19 and oldV < 33 paths, causing a wasteful rename dance on empty tables for fresh installs. Adding an early-exit guard (similar to _rebuildCanonicalDecodedStore) would make the function idempotent and avoid unnecessary operations, which is a meaningful correctness/efficiency improvement.

Low
Fix non-deterministic beat survivor on reboot-collision re-key

The comment says "newest-wins: ORDER BY rec_ts, counter so INSERT OR REPLACE keeps
the highest-counter row per second," but ORDER BY rec_ts ASC, counter ASC inserts in
ascending counter order, meaning the LAST inserted (highest counter) wins via
REPLACE — this is correct only because REPLACE on a PK overwrites the previous row.
However, if the intent is truly "highest counter wins," the ordering should be
counter ASC so the highest counter is inserted last and survives. With counter ASC
the highest counter is indeed last, so the logic is accidentally correct, but the
comment is misleading and the sort key should be explicit: ORDER BY rec_ts ASC,
counter ASC does produce highest-counter-wins via REPLACE, so no runtime bug exists
here. The real issue is in _rekeyDecodedStoreByRecTs for decoded_rr: beats are
re-homed via rr_ts_ms / 1000, but the old schema's decoded_rr is keyed by (counter,
beat_index) — if two counters share the same rr_ts_ms second (the exact
reboot-collision case being fixed), their beats collide on (rec_ts, beat_index) and
only one survives. The INSERT should use ORDER BY rr_ts_ms ASC, counter ASC,
beat_index ASC to make the winner deterministic (highest counter's beats survive),
matching the onehz table's newest-wins policy.

lib/data/db.dart [2371-2374]

 await db.execute(
-  'INSERT OR REPLACE INTO _decoded_onehz_v33 '
-  '(rec_ts, counter, hr, ax, ay, az, spo2_red_raw, spo2_ir_raw, skin_temp_raw) '
-  'SELECT rec_ts, counter, hr, ax, ay, az, spo2_red_raw, spo2_ir_raw, skin_temp_raw '
-  'FROM decoded_onehz ORDER BY rec_ts ASC, counter ASC',
+  'INSERT OR REPLACE INTO _decoded_rr_v33 (rec_ts, beat_index, rr_ts_ms, rr_ms) '
+  'SELECT rr_ts_ms / 1000, beat_index, rr_ts_ms, rr_ms '
+  'FROM decoded_rr ORDER BY rr_ts_ms ASC, beat_index ASC, counter ASC',
 );
Suggestion importance[1-10]: 4

__

Why: The suggestion identifies a potential non-determinism in beat ordering during the _rekeyDecodedStoreByRecTs migration when two counters share the same rr_ts_ms second. However, the improved_code adds counter ASC to the ORDER BY but the old decoded_rr schema (counter-keyed) does have a counter column available during migration, making this a valid concern. The impact is limited to the migration path and the current code is "accidentally correct" per the suggestion's own analysis, so the fix is a minor improvement for clarity and determinism.

Low
Ensure FULL pragma is inside the finally-guarded try block

If the PRAGMA synchronous=FULL call throws and is swallowed, the connection remains
at NORMAL — but the finally block still executes PRAGMA synchronous=NORMAL, which is
a harmless no-op. However, if PRAGMA synchronous=FULL succeeds and then the
db.transaction(...) call itself throws synchronously before entering the try body
(e.g. the db object is in a bad state), the finally correctly restores NORMAL. The
real gap: the PRAGMA synchronous=FULL is outside the try/finally that restores it.
If PRAGMA synchronous=FULL succeeds but the subsequent db.transaction(...) call
throws before the finally is established — this cannot happen in Dart since
try/finally is established before any code in the try block runs. The structure is
actually correct. The genuine issue is that if PRAGMA synchronous=FULL succeeds but
then db.transaction is never entered (impossible in this structure), FULL would
leak. The structure is sound. No critical bug here. However, the PRAGMA
synchronous=FULL should be inside the outer try so that if it succeeds, the finally
is guaranteed to run the restore — which is already the case since the finally is on
the outer try that wraps db.transaction. Move PRAGMA synchronous=FULL inside the
outer try block so the finally is guaranteed to restore NORMAL even if the FULL
pragma itself partially succeeds on some SQLite implementations.

lib/data/db.dart [1271-1361]

 try {
-  await db.execute('PRAGMA synchronous=FULL');
-} catch (_) {
-  /* durability upgrade is best-effort — NORMAL still commits correctly */
-}
-try {
+  try {
+    await db.execute('PRAGMA synchronous=FULL');
+  } catch (_) {
+    /* durability upgrade is best-effort — NORMAL still commits correctly */
+  }
   await db.transaction((txn) async {
-    ...
+    // ... unchanged transaction body
   });
 } finally {
-  // ALWAYS restore NORMAL — even if the commit threw — so a leaked FULL does
-  // not fsync every subsequent write on this connection. Non-fatal.
   try {
     await db.execute('PRAGMA synchronous=NORMAL');
   } catch (_) {
-    /* non-fatal — see open-time PRAGMA discipline */
+    /* non-fatal */
   }
 }
Suggestion importance[1-10]: 2

__

Why: The suggestion's own analysis concludes the existing structure is sound — the PRAGMA synchronous=FULL being outside the try/finally doesn't create a leak risk in Dart's execution model. The improved_code restructures the code but provides no actual safety improvement, and the suggestion itself acknowledges there is no critical bug here.

Low

…clock-skew P1)

The plausibility gate used the phone wall clock as ground truth. If the phone
clock ran >1 day slow (dead-battery reboot, bad NTP, manual set-back), the
strap's correctly-stamped records read as 'implausibly future', got dropped,
and a mixed-burst ACK then TRIMMED them off the band — silent, permanent loss.
Option A (trust the strap's GET_DATA_RANGE window instead) can't work: that
window is itself discarded via isCorruptFutureRtc against the same wrong phone
clock, so it's unavailable exactly when needed.

Fix (option D): don't drain-and-trim under an untrustworthy clock. Before each
history refresh, read the strap RTC and compare; if it reads a PLAUSIBLE time
but >1 day ahead of the phone (ClockPolicy.phoneClockSuspect), the phone clock
is likely slow, so DEFER the offload — the strap retains every record until the
clocks agree (the phone almost always self-corrects via NTP within minutes).
SET_CLOCK is deliberately NOT issued in this case: pushing the strap back to the
slow phone would corrupt a correct RTC. The strap-behind and unset-RTC cases are
unchanged (still corrected forward by shouldSetClock); only the future-skew case
defers. Exposes historyPausedForClock for the UI so the pause is visible.

Adds ClockPolicy.phoneClockSuspect unit coverage (agree / future-skew / behind /
unset boundaries).
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 90f9588

@abdulsaheel

Copy link
Copy Markdown
Collaborator Author

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
test/db_p0_fixes_test.dart (1)

396-432: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Add a shrinking-beat-count case to this import fixture.

The foreign export supplies three beats for collideTs and the local row also has three, so every beat_index is replaced and the assertion on line 414 passes.

The import path merges decoded_rr with INSERT OR REPLACE per row and performs no delete for the second, unlike _queueDecodedOneHz. A foreign export with fewer beats for a colliding second would leave the local high-index beats in place.

See the consolidated comment on lib/data/db.dart for the root cause.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/db_p0_fixes_test.dart` around lines 396 - 432, Extend the import fixture
around the collideTs case to cover a foreign export with fewer beats than the
local second, while retaining the existing collision assertions. Assert that the
imported beat set exactly matches the foreign beats and that no higher-index
local beats remain, then keep the orphan and timestamp consistency checks
intact.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/ble/ble_engine.dart`:
- Around line 1602-1622: Gate every history-start path on a session-bound,
completed GET_CLOCK response rather than the fixed delay and cached
_phoneClockSuspect flag: update _startHistoricalRefresh and the initial
connection flow around setClock/sendInit to await and apply the response before
any SET_CLOCK or historical-data trigger. Ensure delayed or missing responses
cannot proceed, preserve the defer behavior for a suspect phone clock, and add
regressions covering responses arriving after 120 ms and the first-connection
path.

In `@lib/compute/derivation_engine.dart`:
- Around line 1839-1849: Update the algorithm version constant kAlgoVersion from
62 to the next version, and add a changelog entry documenting the decoded RR
lookup change in the derivation engine so finalized days are recalculated with
RR data.

In `@lib/data/db.dart`:
- Around line 4041-4050: The import path in lib/data/db.dart lines 4041-4050
must replace each collided decoded_rr beat set rather than patching it: queue a
DELETE for every rec_ts represented by the page before its inserts, using the
same batch and transaction, and revise the comment to reflect that guard. Extend
test/db_p0_fixes_test.dart lines 396-432 so collideTs has fewer foreign beats
than local beats and assert only the foreign beat set remains.
- Around line 4041-4050: Update the decoded_rr legacy rec_ts derivation to
validate rr_ts_ms with the existing numeric-conversion approach used by
_PrepareAccumulator._num before converting it; only derive row['rec_ts'] for
values that are safely numeric, and avoid throwing for non-numeric strings
during the transaction.
- Around line 2537-2554: Update _queueDecodedOneHz to resolve recTs through the
existing _recTsFor fallback instead of using raw.recTs ?? decoded.tsEpoch, so an
explicit raw.recTs value of 0 falls back to decoded.tsEpoch before insertion
into decoded_onehz. Preserve nonzero stored timestamps unchanged.

In `@pubspec.yaml`:
- Around line 263-266: Update the sqflite_common dependency declaration used by
the ACK commit sync test to pin an exact version whose experimental
SqfliteDatabaseFactoryLogger constructor has been tested, or replace that
constructor usage with a stable logging mechanism. Keep the existing logger
symbols and test behavior otherwise unchanged.

In `@test/db_storage_hygiene_test.dart`:
- Around line 42-65: Update the test `rec_ts-range reads on decoded_rr are
served by the PK auto-index` to remove the assertion for the internal
`sqlite_autoindex_decoded_rr_1` name. Assert that the uppercased query-plan
detail contains `SEARCH`, does not contain `USE TEMP B-TREE`, and does not match
`SCAN TABLE DECODED_RR`, while preserving the existing planner-fallback
diagnostics.

---

Outside diff comments:
In `@test/db_p0_fixes_test.dart`:
- Around line 396-432: Extend the import fixture around the collideTs case to
cover a foreign export with fewer beats than the local second, while retaining
the existing collision assertions. Assert that the imported beat set exactly
matches the foreign beats and that no higher-index local beats remain, then keep
the orphan and timestamp consistency checks intact.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 8e60a82a-5d6e-44d5-99e5-580abc10e1b7

📥 Commits

Reviewing files that changed from the base of the PR and between 6bddec4 and 90f9588.

⛔ Files ignored due to path filters (1)
  • pubspec.lock is excluded by !**/*.lock
📒 Files selected for processing (16)
  • lib/ble/ble_engine.dart
  • lib/compute/derivation_engine.dart
  • lib/compute/derive_prepare.dart
  • lib/data/db.dart
  • lib/sync/sync_policy.dart
  • pubspec.yaml
  • test/ack_commit_sync_full_test.dart
  • test/ble_engine_test.dart
  • test/db_integrity_test.dart
  • test/db_migration_ladder_test.dart
  • test/db_p0_fixes_test.dart
  • test/db_paged_import_export_test.dart
  • test/db_storage_hygiene_test.dart
  • test/local_persistence_test.dart
  • test/raw_archive_test.dart
  • test/sync_policy_test.dart

Comment thread lib/ble/ble_engine.dart
Comment on lines +1839 to 1849
// The page is ordered rec_ts ASC, so first = min second, last = max.
// decoded_rr shares the rec_ts key, so this pulls exactly the page's
// beats — no counter span (which broke across the strap's reboot reset).
final firstRecTs = (decodedRows.first['rec_ts'] as num?)?.toInt();
final lastRecTs = (decodedRows.last['rec_ts'] as num?)?.toInt();
final rrRows = firstRecTs == null || lastRecTs == null
? const <Map<String, dynamic>>[]
: await LocalDb.decodedRrByCounterRange(
fromCounter: firstCounter,
toCounter: lastCounter,
: await LocalDb.decodedRrByRecTsRange(
fromRecTs: firstRecTs,
toRecTs: lastRecTs,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check whether kAlgoVersion changed in this PR and whether a changelog entry accompanies it.
set -euo pipefail

echo "== kAlgoVersion declaration =="
rg -nP --type=dart -C4 '\bkAlgoVersion\s*=' || echo "not found"

echo "== kAlgoVersion changes in this branch vs base =="
git diff origin/HEAD... -- '*.dart' 2>/dev/null | rg -n -C4 'kAlgoVersion' || echo "no diff hunks touching kAlgoVersion"

echo "== changelog files =="
fd -i -t f 'changelog' | head -20

echo "== changelog diff =="
git diff origin/HEAD... -- '*CHANGELOG*' 2>/dev/null | head -60 || echo "no changelog diff"

Repository: OpenStrap/edge

Length of output: 193


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Candidate analytics/version files =="
git ls-files | rg '(^|/)(derivation_engine\.dart|.*CHANGELOG.*|changelog.*)$|(^|/)pubspec\.yaml$'

echo "== Version declarations and changelog entries =="
rg -n -i -C3 'kAlgoVersion|algo.?version|changelog|change log' --glob '*.dart' --glob '*.md' --glob '*.yaml' . || true

echo "== Repository refs and diff summary =="
git branch --all --no-color
git diff --stat
git diff --stat origin/HEAD...HEAD 2>/dev/null || true

echo "== Relevant derivation-engine diff =="
git diff -- lib/compute/derivation_engine.dart

Repository: OpenStrap/edge

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Revisions =="
git rev-parse --show-toplevel
git rev-parse HEAD
git rev-parse origin/HEAD 2>/dev/null || true

echo "== Version/changelog section =="
sed -n '250,710p' lib/compute/derivation_engine.dart

echo "== Current change around the reviewed code =="
sed -n '1800,1870p' lib/compute/derivation_engine.dart

echo "== Version-related diff only =="
git diff --unified=3 origin/HEAD...HEAD -- lib/compute/derivation_engine.dart 2>/dev/null \
  | rg -n -C5 'kAlgoVersion|^[-+].*// v[0-9]+|^[-+].*version' || true

echo "== Reviewed-code diff only =="
git diff --unified=8 origin/HEAD...HEAD -- lib/compute/derivation_engine.dart 2>/dev/null \
  | rg -n -C12 'decodedRrByRecTsRange|counter|decodedRows' || true

Repository: OpenStrap/edge

Length of output: 36432


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import re
import subprocess

path = "lib/compute/derivation_engine.dart"
for label, rev in (("base", "origin/HEAD"), ("head", "HEAD")):
    text = subprocess.check_output(["git", "show", f"{rev}:{path}"], text=True)
    m = re.search(r"const\s+int\s+kAlgoVersion\s*=\s*(\d+)\s*;", text)
    print(f"{label}: kAlgoVersion={m.group(1) if m else 'not found'}")
PY

echo "== Derivation gate around finalized-day selection =="
sed -n '1100,1160p' lib/compute/derivation_engine.dart
sed -n '1310,1360p' lib/compute/derivation_engine.dart
sed -n '1900,1950p' lib/compute/derivation_engine.dart

echo "== Finalized-day lookup implementation =="
rg -n -C8 'finalizedDayIds|dayResultIds' lib/data/db.dart lib/compute/derivation_engine.dart

echo "== RR range implementations =="
rg -n -C12 'decodedRrByRecTsRange|decodedRrByCounterRange' lib/data/db.dart lib/compute/derivation_engine.dart

Repository: OpenStrap/edge

Length of output: 22826


Bump kAlgoVersion and add a changelog entry for the RR lookup change. Both base and head remain at version 62. Finalized days at version 62 will retain RR-less RMSSD, HRV, and readiness results.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/compute/derivation_engine.dart` around lines 1839 - 1849, Update the
algorithm version constant kAlgoVersion from 62 to the next version, and add a
changelog entry documenting the decoded RR lookup change in the derivation
engine so finalized days are recalculated with RR data.

Source: Coding guidelines

Comment thread lib/data/db.dart
Comment on lines 2537 to +2554
static int _queueDecodedOneHz(Batch batch, RawRecord raw, Sample? sample) {
final decoded = _decodeOneHzSample(raw, preferred: sample);
if (decoded == null) return 0;
final recTs = raw.recTs ?? decoded.tsEpoch;
// TIME-KEYED, NEWEST-WINS (noop/WHOOP-4 model: dedupe records by their
// embedded timestamp, not by a counter). decoded_onehz has a UNIQUE(rec_ts)
// index and decoded_rr a UNIQUE(rr_ts_ms, beat_index). We use REPLACE, not
// IGNORE: the strap's record `counter` RESETS to ~0 on every reboot, so a
// post-reboot record whose second already had a row would be SILENTLY DROPPED
// under IGNORE — quarantining everything after a reboot (observed: whole days
// present in raw_records but absent from the decoded substrate the engine
// reads → "not worn / metrics still computing / strain –"). REPLACE lets the
// freshly-offloaded record for a given second win, which is what we want.
//
// ORPHAN GUARD: decoded_rr rows are keyed by their record's own counter. When
// the REPLACE below evicts a DIFFERENT counter's row for this second, that
// loser's RR beats would stay behind under a counter with no decoded_onehz
// row — invisible to the counter-joined prune (permanent leak). The winner's
// REPLACE on UNIQUE(rr_ts_ms, beat_index) only overwrites overlapping beat
// indexes, so delete the evicted counter's beats explicitly, in the same
// batch/transaction (mirrors the v17 rebuild's decoded_onehz join).
// embedded timestamp, not by the volatile counter). decoded_onehz is keyed
// by rec_ts and decoded_rr by (rec_ts, beat_index). We use REPLACE, not
// IGNORE: a freshly-offloaded record for a given second should win over a
// stale one. Because rec_ts is the key, the strap's per-reboot counter reset
// can no longer make one second's record evict another's (the pre-fix
// counter-PK eviction that silently, unrecoverably deleted 1 Hz rows).
//
// …AND the COUNTER-PK eviction, which the guard used to miss entirely.
// `decoded_onehz` is `counter INTEGER PRIMARY KEY` as well as
// UNIQUE(rec_ts), and (per the comment above) the strap's counter RESETS to
// ~0 on every reboot — so this same REPLACE also silently DELETES the row
// of an OLDER SECOND that happened to reuse this counter. That older
// second's beats live under OUR counter carrying ITS rr_ts_ms, and only the
// overlapping beat_indexes get overwritten below: any beat at an index past
// the new record's beat count SURVIVES, still stamped days earlier. Neither
// prune path can ever see it (the counter-join finds a fresh rec_ts; the
// orphan sweep finds the counter present), so a later page's RR series was
// polluted with beats from another day — silently wrecking RMSSD/HRV.
// Drop every beat under this counter that is not stamped with THIS second.
var ops = _queueOrphanGuard(batch, counter: raw.counter, recTs: recTs);
// Clear this second's RR beats before reinserting so a SHRINKING beat count
// can't strand stale high-index beats — the parent+child share the rec_ts
// key, so this single DELETE replaces the old counter-based orphan guard.
batch.insert('decoded_onehz', {
'counter': raw.counter,
'rec_ts': recTs,
'counter': raw.counter,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Guard rec_ts against an explicit 0 before it becomes the primary key.

Line 2540 uses raw.recTs ?? decoded.tsEpoch, which substitutes only on null. _backfillDecodedStore (line 2600) builds RawRecord.recTs from the stored raw_records.rec_ts column, which is NOT NULL DEFAULT 0 for legacy rows. Every such row now writes rec_ts = 0.

Under the previous counter primary key those rows coexisted. Under the rec_ts primary key they REPLACE each other, so the backfill keeps only the last one. firstAndLastRecordTs and rawStats already filter rec_ts > 0, which documents that 0 is a real stored value.

Reuse the existing _recTsFor fallback so a 0 resolves to the decoded timestamp.

🐛 Proposed fix
-    final recTs = raw.recTs ?? decoded.tsEpoch;
+    // `?? ` substitutes on null only; an explicit 0 (legacy raw_records rows
+    // carry `rec_ts NOT NULL DEFAULT 0`) would become the rec_ts PRIMARY KEY
+    // and REPLACE-evict every other undated row.
+    final rawRecTs = raw.recTs;
+    final recTs =
+        (rawRecTs != null && rawRecTs > 0) ? rawRecTs : decoded.tsEpoch;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
static int _queueDecodedOneHz(Batch batch, RawRecord raw, Sample? sample) {
final decoded = _decodeOneHzSample(raw, preferred: sample);
if (decoded == null) return 0;
final recTs = raw.recTs ?? decoded.tsEpoch;
// TIME-KEYED, NEWEST-WINS (noop/WHOOP-4 model: dedupe records by their
// embedded timestamp, not by a counter). decoded_onehz has a UNIQUE(rec_ts)
// index and decoded_rr a UNIQUE(rr_ts_ms, beat_index). We use REPLACE, not
// IGNORE: the strap's record `counter` RESETS to ~0 on every reboot, so a
// post-reboot record whose second already had a row would be SILENTLY DROPPED
// under IGNORE — quarantining everything after a reboot (observed: whole days
// present in raw_records but absent from the decoded substrate the engine
// reads → "not worn / metrics still computing / strain –"). REPLACE lets the
// freshly-offloaded record for a given second win, which is what we want.
//
// ORPHAN GUARD: decoded_rr rows are keyed by their record's own counter. When
// the REPLACE below evicts a DIFFERENT counter's row for this second, that
// loser's RR beats would stay behind under a counter with no decoded_onehz
// row — invisible to the counter-joined prune (permanent leak). The winner's
// REPLACE on UNIQUE(rr_ts_ms, beat_index) only overwrites overlapping beat
// indexes, so delete the evicted counter's beats explicitly, in the same
// batch/transaction (mirrors the v17 rebuild's decoded_onehz join).
// embedded timestamp, not by the volatile counter). decoded_onehz is keyed
// by rec_ts and decoded_rr by (rec_ts, beat_index). We use REPLACE, not
// IGNORE: a freshly-offloaded record for a given second should win over a
// stale one. Because rec_ts is the key, the strap's per-reboot counter reset
// can no longer make one second's record evict another's (the pre-fix
// counter-PK eviction that silently, unrecoverably deleted 1 Hz rows).
//
// …AND the COUNTER-PK eviction, which the guard used to miss entirely.
// `decoded_onehz` is `counter INTEGER PRIMARY KEY` as well as
// UNIQUE(rec_ts), and (per the comment above) the strap's counter RESETS to
// ~0 on every reboot — so this same REPLACE also silently DELETES the row
// of an OLDER SECOND that happened to reuse this counter. That older
// second's beats live under OUR counter carrying ITS rr_ts_ms, and only the
// overlapping beat_indexes get overwritten below: any beat at an index past
// the new record's beat count SURVIVES, still stamped days earlier. Neither
// prune path can ever see it (the counter-join finds a fresh rec_ts; the
// orphan sweep finds the counter present), so a later page's RR series was
// polluted with beats from another day — silently wrecking RMSSD/HRV.
// Drop every beat under this counter that is not stamped with THIS second.
var ops = _queueOrphanGuard(batch, counter: raw.counter, recTs: recTs);
// Clear this second's RR beats before reinserting so a SHRINKING beat count
// can't strand stale high-index beats — the parent+child share the rec_ts
// key, so this single DELETE replaces the old counter-based orphan guard.
batch.insert('decoded_onehz', {
'counter': raw.counter,
'rec_ts': recTs,
'counter': raw.counter,
static int _queueDecodedOneHz(Batch batch, RawRecord raw, Sample? sample) {
final decoded = _decodeOneHzSample(raw, preferred: sample);
if (decoded == null) return 0;
// `?? ` substitutes on null only; an explicit 0 (legacy raw_records rows
// carry `rec_ts NOT NULL DEFAULT 0`) would become the rec_ts PRIMARY KEY
// and REPLACE-evict every other undated row.
final rawRecTs = raw.recTs;
final recTs =
(rawRecTs != null && rawRecTs > 0) ? rawRecTs : decoded.tsEpoch;
// TIME-KEYED, NEWEST-WINS (noop/WHOOP-4 model: dedupe records by their
// embedded timestamp, not by the volatile counter). decoded_onehz is keyed
// by rec_ts and decoded_rr by (rec_ts, beat_index). We use REPLACE, not
// IGNORE: a freshly-offloaded record for a given second should win over a
// stale one. Because rec_ts is the key, the strap's per-reboot counter reset
// can no longer make one second's record evict another's (the pre-fix
// counter-PK eviction that silently, unrecoverably deleted 1 Hz rows).
//
// Clear this second's RR beats before reinserting so a SHRINKING beat count
// can't strand stale high-index beats — the parent+child share the rec_ts
// key, so this single DELETE replaces the old counter-based orphan guard.
batch.insert('decoded_onehz', {
'rec_ts': recTs,
'counter': raw.counter,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/data/db.dart` around lines 2537 - 2554, Update _queueDecodedOneHz to
resolve recTs through the existing _recTsFor fallback instead of using raw.recTs
?? decoded.tsEpoch, so an explicit raw.recTs value of 0 falls back to
decoded.tsEpoch before insertion into decoded_onehz. Preserve nonzero stored
timestamps unchanged.

Comment thread lib/data/db.dart
Comment on lines +4041 to 4050
// Both decoded tables are now keyed by rec_ts, so a plain
// replace-insert merges cleanly (foreign-wins on a rec_ts
// collision) — no orphan guard needed. A LEGACY export's
// decoded_rr carries no rec_ts column; derive it from rr_ts_ms
// (= rec_ts*1000) so the NOT NULL PK column is always populated.
if (t == 'decoded_rr' &&
row['rec_ts'] == null &&
row['rr_ts_ms'] != null) {
row['rec_ts'] = ((row['rr_ts_ms'] as num).toInt()) ~/ 1000;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

A shrinking foreign beat set leaves stale local beats on import. The comment on lib/data/db.dart line 4041 states that a plain replace-insert merges cleanly and that no orphan guard is needed. That holds only when the foreign export supplies at least as many beats for a colliding rec_ts as the local database already has. The import writes decoded_rr row by row with ConflictAlgorithm.replace keyed on (rec_ts, beat_index), so it never removes a local beat whose beat_index the foreign export does not reach. _queueDecodedOneHz guards the same hazard on the write path with DELETE FROM decoded_rr WHERE rec_ts = ? before reinserting. The import path has no equivalent, so a restore can produce one second holding a mix of foreign and stale local beats, which corrupts RMSSD for that second.

  • lib/data/db.dart#L4041-L4050: before inserting a page's decoded_rr rows, delete the existing beats for each rec_ts the page carries, queued into the same batch and the same transaction as the inserts, so the second's beat set is replaced rather than patched. Then correct the comment, which currently asserts that no guard is needed.
  • test/db_p0_fixes_test.dart#L396-L432: extend the fixture so the foreign export supplies fewer beats for collideTs than the local row has (for example foreign [500] against local [700, 710, 720]), and assert that the collided second ends with exactly the foreign beat set.
📍 Affects 2 files
  • lib/data/db.dart#L4041-L4050 (this comment)
  • test/db_p0_fixes_test.dart#L396-L432
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/data/db.dart` around lines 4041 - 4050, The import path in
lib/data/db.dart lines 4041-4050 must replace each collided decoded_rr beat set
rather than patching it: queue a DELETE for every rec_ts represented by the page
before its inserts, using the same batch and transaction, and revise the comment
to reflect that guard. Extend test/db_p0_fixes_test.dart lines 396-432 so
collideTs has fewer foreign beats than local beats and assert only the foreign
beat set remains.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Type-check rr_ts_ms before the as num cast.

SQLite storage class is per value, not per column, so a foreign or older export can return a String where rr_ts_ms is declared INTEGER. The guard on line 4048 tests for null only. A non-numeric value then throws inside db.transaction and aborts the whole restore.

This file already documents the same hazard for decoded-page reads (_PrepareAccumulator._num in lib/compute/derive_prepare.dart). Apply the same defence here.

🛡️ Proposed fix
-              if (t == 'decoded_rr' &&
-                  row['rec_ts'] == null &&
-                  row['rr_ts_ms'] != null) {
-                row['rec_ts'] = ((row['rr_ts_ms'] as num).toInt()) ~/ 1000;
-              }
+              if (t == 'decoded_rr' && row['rec_ts'] == null) {
+                // Storage class is per-VALUE in SQLite: a foreign export can
+                // hand back a String where INTEGER is declared. Skip the row
+                // rather than throwing out of the whole import transaction.
+                final ms = row['rr_ts_ms'];
+                if (ms is! num) continue;
+                row['rec_ts'] = ms.toInt() ~/ 1000;
+              }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Both decoded tables are now keyed by rec_ts, so a plain
// replace-insert merges cleanly (foreign-wins on a rec_ts
// collision) — no orphan guard needed. A LEGACY export's
// decoded_rr carries no rec_ts column; derive it from rr_ts_ms
// (= rec_ts*1000) so the NOT NULL PK column is always populated.
if (t == 'decoded_rr' &&
row['rec_ts'] == null &&
row['rr_ts_ms'] != null) {
row['rec_ts'] = ((row['rr_ts_ms'] as num).toInt()) ~/ 1000;
}
if (t == 'decoded_rr' && row['rec_ts'] == null) {
// Storage class is per-VALUE in SQLite: a foreign export can
// hand back a String where INTEGER is declared. Skip the row
// rather than throwing out of the whole import transaction.
final ms = row['rr_ts_ms'];
if (ms is! num) continue;
row['rec_ts'] = ms.toInt() ~/ 1000;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/data/db.dart` around lines 4041 - 4050, Update the decoded_rr legacy
rec_ts derivation to validate rr_ts_ms with the existing numeric-conversion
approach used by _PrepareAccumulator._num before converting it; only derive
row['rec_ts'] for values that are safely numeric, and avoid throwing for
non-numeric strings during the transaction.

Comment thread pubspec.yaml
Comment on lines +263 to +266
# ack_commit_sync_full_test wraps the ffi factory in SqfliteDatabaseFactoryLogger
# to spy the PRAGMA synchronous=FULL/NORMAL bracket around the ACK-gating commit
# (transitive via sqflite_common_ffi; declared directly since test/ now imports it).
sqflite_common: ^2.5.0

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Is SqfliteDatabaseFactoryLogger in sqflite_common sqflite_logger.dart still experimental?

💡 Result:

Yes, SqfliteDatabaseFactoryLogger in the sqflite_common package is still considered an experimental feature [1][2]. While the logger is fully functional and documented for use, the developer of the package has explicitly categorized it as experimental in the official documentation and package issues [1][2]. It remains a standard way to implement logging for database activities by wrapping your existing database factory, but users should be aware of this experimental status when integrating it into their projects [1][3].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- relevant files ---'
git ls-files | rg '(^|/)(pubspec\.yaml|pubspec\.lock|ack_commit_sync_full_test\.dart)$|sqflite'
printf '%s\n' '--- dependency declaration ---'
rg -n -C 6 'sqflite_common|kAlgoVersion' pubspec.yaml
printf '%s\n' '--- test API usage ---'
rg -n -C 5 'sqflite_common|SqfliteDatabaseFactoryLogger|SqfliteLoggerOptions|SqfliteLoggerSqlEvent|experimental_member_use' test
printf '%s\n' '--- lockfile package entry ---'
if [ -f pubspec.lock ]; then
  rg -n -A 12 -B 2 '^  sqflite_common:' pubspec.lock || true
fi
printf '%s\n' '--- repository documentation or analyzer configuration ---'
rg -n -C 3 'experimental_member_use|SqfliteDatabaseFactoryLogger|SqfliteLoggerOptions|SqfliteLoggerSqlEvent' . --glob '!build/**' --glob '!dist/**' --glob '!node_modules/**' || true

Repository: OpenStrap/edge

Length of output: 45674


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
import io
import json
import tarfile
import urllib.request

package = "sqflite_common"
versions = ["2.5.0", "2.5.8"]

def fetch(url):
    request = urllib.request.Request(url, headers={"User-Agent": "review-evidence"})
    with urllib.request.urlopen(request, timeout=20) as response:
        return response.read()

metadata = json.loads(fetch(f"https://pub.dev/api/packages/{package}"))
listed = [v["version"] for v in metadata["versions"]]
print("latest:", metadata["latest"]["version"])
print("available requested versions:", {v: v in listed for v in versions})

for version in versions:
    archive = fetch(f"https://pub.dev/api/archives/{package}-{version}.tar.gz")
    with tarfile.open(fileobj=io.BytesIO(archive), mode="r:gz") as tf:
        names = tf.getnames()
        print(f"\n--- {package} {version} ---")
        print("logger file present:", any(n.endswith("/lib/sqflite_logger.dart") for n in names))
        for name in names:
            if name.endswith("/pubspec.yaml") or name.endswith("/lib/sqflite_logger.dart"):
                text = tf.extractfile(name).read().decode("utf-8", "replace")
                if name.endswith("pubspec.yaml"):
                    print("[pubspec.yaml]")
                    print(text)
                else:
                    print("[lib/sqflite_logger.dart: declarations/annotations]")
                    lines = text.splitlines()
                    for i, line in enumerate(lines, 1):
                        if any(symbol in line for symbol in (
                            "SqfliteDatabaseFactoryLogger",
                            "SqfliteLoggerOptions",
                            "SqfliteLoggerSqlEvent",
                            "`@experimental`",
                            "experimental",
                            "library ",
                            "export ",
                        )):
                            start = max(1, i - 2)
                            end = min(len(lines), i + 3)
                            print("\n".join(f"{j}: {lines[j-1]}" for j in range(start, end + 1)))
                            print()
PY

Repository: OpenStrap/edge

Length of output: 2475


🏁 Script executed:

#!/bin/bash
set -eu

for version in 2.5.0 2.5.8; do
  archive="/tmp/sqflite_common-${version}.tar.gz"
  curl -ksSfL "https://pub.dev/api/archives/sqflite_common-${version}.tar.gz" -o "$archive"
  printf '\n--- sqflite_common %s files ---\n' "$version"
  tar -tzf "$archive" | rg '(^|/)(pubspec\.yaml|lib/sqflite_logger\.dart)$'
  printf '%s\n' '--- declarations and annotations ---'
  tar -xOzf "$archive" "sqflite_common-${version}/lib/sqflite_logger.dart" |
    nl -ba |
    rg -n -C 3 'SqfliteDatabaseFactoryLogger|SqfliteLoggerOptions|SqfliteLoggerSqlEvent|`@experimental`|experimental|library |export '
done

Repository: OpenStrap/edge

Length of output: 427


🏁 Script executed:

#!/bin/bash
set -eu

for version in 2.5.0 2.5.8; do
  archive="/tmp/sqflite_common-${version}.tar.gz"
  [ -s "$archive" ] || curl -ksSfL "https://pub.dev/api/archives/sqflite_common-${version}.tar.gz" -o "$archive"
  member="$(tar -tzf "$archive" | rg '/lib/sqflite_logger\.dart$|^lib/sqflite_logger\.dart$' | head -n 1)"
  printf '\n--- sqflite_common %s member: %s ---\n' "$version" "$member"
  printf '%s\n' '--- declarations and annotations ---'
  tar -xOzf "$archive" "$member" |
    awk '{ printf "%d:%s\n", NR, $0 }' |
    rg -n -C 3 'SqfliteDatabaseFactoryLogger|SqfliteLoggerOptions|SqfliteLoggerSqlEvent|`@experimental`|experimental|library |export '
done

Repository: OpenStrap/edge

Length of output: 1358


🏁 Script executed:

#!/bin/bash
set -eu

for version in 2.5.0 2.5.8; do
  archive="/tmp/sqflite_common-${version}.tar.gz"
  member="$(tar -tzf "$archive" | rg '/src/logger/sqflite_logger\.dart$|^src/logger/sqflite_logger\.dart$' | head -n 1)"
  printf '\n--- sqflite_common %s member: %s ---\n' "$version" "$member"
  tar -xOzf "$archive" "$member" |
    awk '{ printf "%d:%s\n", NR, $0 }' |
    rg -n -C 5 'import |`@experimental`|experimental|class SqfliteDatabaseFactoryLogger|class SqfliteLoggerOptions|class SqfliteLoggerSqlEvent|typedef SqfliteDatabaseFactoryLogger|SqfliteDatabaseFactoryLogger\(|SqfliteLoggerOptions\('
done

Repository: OpenStrap/edge

Length of output: 7959


Do not treat the logger constructor as stable.

SqfliteDatabaseFactoryLogger, SqfliteLoggerOptions, and SqfliteLoggerSqlEvent are public exports in sqflite_common 2.5.0 and 2.5.8. The SqfliteDatabaseFactoryLogger constructor is marked @experimental in both versions. Pin an exact tested version or use a stable logging mechanism.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pubspec.yaml` around lines 263 - 266, Update the sqflite_common dependency
declaration used by the ACK commit sync test to pin an exact version whose
experimental SqfliteDatabaseFactoryLogger constructor has been tested, or
replace that constructor usage with a stable logging mechanism. Keep the
existing logger symbols and test behavior otherwise unchanged.

Comment on lines +42 to 65
test('rec_ts-range reads on decoded_rr are served by the PK auto-index', () async {
// decoded_rr shares the rec_ts key with decoded_onehz, so the derive read
// path (decodedRrByRecTsRange) is a PK range scan — never a full-table read.
final db = await LocalDb.instance;
for (final sql in const [
'EXPLAIN QUERY PLAN SELECT * FROM decoded_rr WHERE counter = 42 '
'ORDER BY beat_index',
'EXPLAIN QUERY PLAN SELECT * FROM decoded_rr WHERE counter BETWEEN 1 AND 9',
]) {
final detail = (await db.rawQuery(
sql,
)).map((r) => r['detail'].toString()).join(' | ');
expect(
detail.toUpperCase(),
contains('USING'),
reason: 'planner fell back to a full scan: $detail',
);
expect(
detail,
contains('sqlite_autoindex_decoded_rr_1'),
reason: 'expected the primary key auto-index: $detail',
);
}
});

test('rr_ts_ms range scans are still served by an index', () async {
final db = await LocalDb.instance;
final plan = await db.rawQuery(
'EXPLAIN QUERY PLAN SELECT * FROM decoded_rr WHERE rr_ts_ms < 1000 '
'ORDER BY rr_ts_ms ASC, beat_index ASC',
final detail = (await db.rawQuery(
'EXPLAIN QUERY PLAN SELECT * FROM decoded_rr WHERE rec_ts BETWEEN 1 AND 9 '
'ORDER BY rec_ts ASC, beat_index ASC',
)).map((r) => r['detail'].toString()).join(' | ');
expect(
detail.toUpperCase(),
contains('USING'),
reason: 'planner fell back to a full scan: $detail',
);
final detail = plan.map((r) => r['detail'].toString()).join(' | ');
expect(
detail,
contains('idx_decoded_rr_ts_beat_unique'),
reason: 'planner fell back to a scan: $detail',
contains('sqlite_autoindex_decoded_rr_1'),
reason: 'expected the primary key auto-index: $detail',
);
expect(
detail.toUpperCase(),
isNot(contains('USE TEMP B-TREE')),
reason: 'ordering should come from the index: $detail',
reason: 'ordering should come from the PK: $detail',
);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Report the SQLite version used by the test backend and the EQP text for the asserted query.
set -euo pipefail

echo "== sqlite3 CLI version (sandbox) =="
sqlite3 --version 2>/dev/null || echo "sqlite3 CLI unavailable"

echo "== EQP for the same shape =="
sqlite3 ":memory:" <<'SQL' 2>/dev/null || echo "could not run"
CREATE TABLE decoded_rr (
  rec_ts INTEGER NOT NULL,
  beat_index INTEGER NOT NULL,
  rr_ts_ms INTEGER NOT NULL,
  rr_ms INTEGER NOT NULL,
  PRIMARY KEY (rec_ts, beat_index)
);
EXPLAIN QUERY PLAN
SELECT * FROM decoded_rr WHERE rec_ts BETWEEN 1 AND 9
ORDER BY rec_ts ASC, beat_index ASC;
SQL

echo "== pinned ffi version =="
rg -nP 'sqflite_common_ffi|sqlite3_flutter_libs|sqlite3:' pubspec.yaml pubspec.lock 2>/dev/null || echo "not found"

Repository: OpenStrap/edge

Length of output: 638


🏁 Script executed:

set -euo pipefail

echo "== test/db_storage_hygiene_test.dart =="
sed -n '1,110p' test/db_storage_hygiene_test.dart

echo "== dependency versions =="
sed -n '1328,1370p' pubspec.lock
sed -n '240,275p' pubspec.yaml

echo "== SQLite EQP across available Python SQLite builds =="
python3 - <<'PY'
import sqlite3
print("python sqlite version:", sqlite3.sqlite_version)
db = sqlite3.connect(":memory:")
db.execute("""
CREATE TABLE decoded_rr (
  rec_ts INTEGER NOT NULL,
  beat_index INTEGER NOT NULL,
  rr_ts_ms INTEGER NOT NULL,
  rr_ms INTEGER NOT NULL,
  PRIMARY KEY (rec_ts, beat_index)
)
""")
query = """
SELECT * FROM decoded_rr WHERE rec_ts BETWEEN 1 AND 9
ORDER BY rec_ts ASC, beat_index ASC
"""
for row in db.execute("EXPLAIN QUERY PLAN " + query):
    print(row)
PY

Repository: OpenStrap/edge

Length of output: 7269


Reduce coupling to SQLite query-plan text

SQLite emits the expected SEARCH ... USING INDEX plan, but sqlite_autoindex_decoded_rr_1 is an internal name. Replace the index-name assertion with SEARCH and the absence of USE TEMP B-TREE. Avoid SCAN TABLE DECODED_RR; SQLite versions can emit different SCAN wording.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/db_storage_hygiene_test.dart` around lines 42 - 65, Update the test
`rec_ts-range reads on decoded_rr are served by the PK auto-index` to remove the
assertion for the internal `sqlite_autoindex_decoded_rr_1` name. Assert that the
uppercased query-plan detail contains `SEARCH`, does not contain `USE TEMP
B-TREE`, and does not match `SCAN TABLE DECODED_RR`, while preserving the
existing planner-fallback diagnostics.

@OpenStrap OpenStrap deleted a comment from github-actions Bot Aug 12, 2026
@abdulsaheel abdulsaheel changed the title Gen4 data-integrity: stop silent BLE-offload data loss (4 fixes) offload data loss fixes Aug 12, 2026
init seq4 is send_historical so every fresh connect drained + trimmed under the
bad clock anyway, and the unconditional set_clock before it clobbered the strap
rtc and made the gate always see agreeing clocks. read first, skip both if suspect.
@github-actions

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 5 🔵🔵🔵🔵🔵
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Migration ladder gap

The v33 re-key (_rekeyDecodedStoreByRecTs) is placed at if (oldV < 33), but the v19 branch now calls _rekeyDecodedStoreByRecTs BEFORE _backfillDecodedStore. A user upgrading from v18 or earlier will hit BOTH the oldV < 19 branch (which calls _rekeyDecodedStoreByRecTs then _backfillDecodedStore) AND the oldV < 33 branch (which calls _rekeyDecodedStoreByRecTs again). The second call is nominally idempotent (DROP IF EXISTS + CREATE IF NOT EXISTS), but _backfillDecodedStore runs between them and writes into the freshly-created rec_ts-keyed tables using the old counter-keyed INSERT path. If _backfillDecodedStore still inserts with counter as the key column, the second _rekeyDecodedStoreByRecTs at v33 will re-copy those rows correctly, but the intermediate state between v19 and v33 in a single onUpgrade call is a counter-keyed store that the v33 step then re-keys — this is only safe if _backfillDecodedStore was also updated to write rec_ts-keyed rows. If it was not, the v19 path silently produces a counter-keyed store that the v33 step must then fix, which is the intended design, but the comment says "convert to the current schema first … then reconstruct the rest from raw_records" — implying the rekey happens BEFORE the backfill, yet the backfill writes into the just-rekeyed tables using whatever schema _queueDecodedOneHz expects. Verify that _backfillDecodedStore writes through _queueDecodedOneHz (which now writes rec_ts-keyed rows) and not through a raw INSERT that assumes the old counter-PK layout.

if (oldV < 19) {
  // The v17 step (or a v11-16 origin) may leave OLD counter-keyed decoded
  // tables here; the backfill below writes through the rec_ts-keyed
  // _queueDecodedOneHz, so convert to the current schema first (preserving
  // any existing rows), then reconstruct the rest from raw_records.
  await _rekeyDecodedStoreByRecTs(db);
  await _backfillDecodedStore(db);
  await _dropRawStore(db);
PRAGMA synchronous race

The comment correctly notes that synchronous is per-connection and that the bracket is only safe because drains never overlap. However, the PRAGMA is set OUTSIDE the transaction (db.execute('PRAGMA synchronous=FULL') before db.transaction(...)) and reset in a finally AFTER the transaction. If a second concurrent caller of commitSyncBatch on the same connection were ever introduced (the comment warns against this), the two PRAGMA brackets would interleave: caller A sets FULL, caller B sets FULL, caller A's transaction commits and resets to NORMAL, caller B's transaction then commits under NORMAL — silently losing the durability guarantee for caller B's ACK-gating commit. The serialization guarantee is currently maintained by BLE engine design, but there is no assertion or lock enforcing it at the DB layer. This is a latent data-loss risk if the single-flight invariant is ever relaxed.

try {
  await db.execute('PRAGMA synchronous=FULL');
} catch (_) {
  /* durability upgrade is best-effort — NORMAL still commits correctly */
}
try {
  await db.transaction((txn) async {
    // Read the existing high-water THROUGH the txn — never via the global db
    // handle, which would deadlock against this same open transaction.
    var maxCounter = await _cursorIntVia(txn, 'counter_hw') ?? 0;
    var maxRecTs = await _cursorIntVia(txn, 'rec_ts_hw') ?? 0;
    // CHUNKED BATCH: sqflite serialises an ENTIRE batch's operations+args into
    // ONE platform-channel message, and the native side builds a single
    // ArrayList of every argument. A large backlog offload (raws in the
    // hundreds-of-thousands) blew the native heap in SqlCommand.getSqlArguments
    // → OutOfMemoryError (Crashlytics 0.9.13). Committing in bounded chunks
    // flushes and frees each message's args. These commits all happen INSIDE
    // the single `db.transaction` below, so the safe-trim invariant holds: the
    // whole offload (raw_archive + samples + decoded_onehz + decoded_rr +
    // cursor) is still one atomic transaction — every row is durable before the
    // caller echoes the HISTORY_END trim token, or none is.
    const chunkOps = 4000;
    var batch = txn.batch();
    var ops = 0;
    Future<void> flushChunk() async {
      if (ops == 0) return;
      await batch.commit(noResult: true);
      batch = txn.batch();
      ops = 0;
    }

    // SAFE-TRIM INVARIANT: archive the undecodable records in the SAME
    // transaction as the raw records + trim cursor, so they are durably set
    // aside BEFORE the caller writes the batch-ACK that lets the band trim.
    if (archives != null) {
      for (final a in archives) {
        batch.insert('raw_archive', {
          'counter': a.counter,
          'hex': a.hex,
          'packet_type': a.packetType,
          'rec_ts': a.recTs,
          'captured_at': a.capturedAt,
          'reason': a.reason,
        }, conflictAlgorithm: ConflictAlgorithm.ignore);
        if (++ops >= chunkOps) await flushChunk();
      }
    }
    for (var i = 0; i < raws.length; i++) {
      final raw = raws[i];
      final recTs = _recTsFor(raw);
      final sample = samples[i];
      if (sample != null) {
        batch.insert('samples', {
          'counter': raw.counter,
          ...sample.toDbMap(),
        }, conflictAlgorithm: ConflictAlgorithm.ignore);
        ops++;
      }
      ops += _queueDecodedOneHz(batch, raw, sample);
      if (raw.counter > maxCounter) maxCounter = raw.counter;
      if (recTs > maxRecTs) maxRecTs = recTs;
      if (ops >= chunkOps) await flushChunk();
    }
    checkpoint(
      'decoded_archive_queued raws=${raws.length} '
      'archives=${archives?.length ?? 0}',
    );
    await flushChunk();
    checkpoint('decoded_archive_committed');
    await setCursor('counter_hw', '$maxCounter', txn: txn);
    await setCursor('rec_ts_hw', '$maxRecTs', txn: txn);
    if (trimToken != null) await setCursor('strap_trim', trimToken, txn: txn);
    if (extraCursors != null) {
      for (final e in extraCursors.entries) {
        await setCursor(e.key, e.value, txn: txn);
      }
    }
    checkpoint(
      'cursor_advanced counter_hw=$maxCounter rec_ts_hw=$maxRecTs '
      'trim=${trimToken != null}',
    );
  });
} finally {
  // ALWAYS restore NORMAL — even if the commit threw — so a leaked FULL does
  // not fsync every subsequent write on this connection. Non-fatal.
  try {
    await db.execute('PRAGMA synchronous=NORMAL');
  } catch (_) {
    /* non-fatal — see open-time PRAGMA discipline */
  }
}
Boolean latch not reset on defer

In _startHistoricalRefresh, when _phoneClockSuspect is true the method calls _setOffloadActive(false) and returns early. If _offloadActive was already set to true before this check (e.g. by the caller or a prior path), it is correctly cleared here. However, in _doConnect, _setOffloadActive(drainOnInit) is called with drainOnInit = false when the clock is suspect — so _offloadActive is set to false on the deferred path. The concern is whether _phoneClockSuspect itself is ever reset on the failure/timeout path. It is only cleared when a subsequent clock_epoch read agrees (_phoneClockSuspect = ClockPolicy.phoneClockSuspect(dev, wall)). If the BLE session drops before a GET_CLOCK reply arrives (e.g. the getClock() call in _doConnect times out or the connection drops immediately after), _phoneClockSuspect retains its previous value into the next connection — which is the intended behavior (conservative). But if getClock() in _doConnect throws or the 120 ms delay is interrupted, _phoneClockSuspect may be stale-true from a prior session, permanently deferring all drains until a successful GET_CLOCK round-trip. Confirm there is a try/finally or reset on the getClock() failure path in _doConnect.

await getClock();
await Future.delayed(const Duration(milliseconds: 120));
if (!_phoneClockSuspect) await setClock();
sendInit drain-gate assumption

sendInit(drain: false) skips the last packet of initPackets by calling initPackets.take(initPackets.length - 1).toList(). This is correct only while the last packet is SEND_HISTORICAL_DATA. The test 'the last INIT packet is the historical drain' pins this, which is good. However, if initPackets is ever empty (length 0), take(-1) returns an empty iterable without error, silently sending nothing at all — not just skipping the drain. Add a length guard or assert inside sendInit to make this fail loudly rather than silently sending zero packets.

Future<void> sendInit({bool drain = true}) async {
  final pkts =
      drain ? initPackets : initPackets.take(initPackets.length - 1).toList();
  _log('Sending ${pkts.length}-packet INIT…');
  try {
    for (final pkt in pkts) {
      await _write(pkt);
      await Future.delayed(const Duration(milliseconds: 120));
    }

⚠️ Review coverage: The following files were not included in this review because of the token budget:

  • test/db_integrity_test.dart
  • test/db_storage_hygiene_test.dart
  • test/ack_commit_sync_full_test.dart
  • test/raw_archive_test.dart
  • lib/compute/derive_prepare.dart
  • lib/sync/sync_policy.dart
  • lib/compute/derivation_engine.dart
  • test/db_paged_import_export_test.dart
  • test/sync_policy_test.dart
  • test/local_persistence_test.dart
  • pubspec.yaml

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
lib/ble/ble_engine.dart (2)

1639-1647: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not consume the backfill floor for a deferred refresh.

_triggerBackfill sets _lastBackfillAt before this method runs. This return path sends no historical request, but it leaves that timestamp set and makes _triggerBackfill return true. A corrected phone clock can then remain blocked by the backfill floor.

Make _startHistoricalRefresh report whether it sent SEND_HISTORICAL_DATA. Update _lastBackfillAt only after that result is true. Add a regression for a deferred refresh followed by an immediate successful retry.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/ble/ble_engine.dart` around lines 1639 - 1647, Update
_startHistoricalRefresh to return whether SEND_HISTORICAL_DATA was actually
sent, returning false for the _phoneClockSuspect deferred path and true after
dispatch. In _triggerBackfill, assign _lastBackfillAt only when
_startHistoricalRefresh returns true, and add a regression covering a deferred
refresh followed immediately by a successful retry.

2293-2307: 🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

Do not correct the strap clock when the phone clock is suspect.

When Line 2300 sets _phoneClockSuspect to true, ClockPolicy.shouldSetClock(dev, wall) is also true for the same drift. Line 2340 then calls setClock() and writes the bad phone time to a plausible strap RTC. Its readback can clear the flag before INIT starts history draining.

Guard the automatic correction with !_phoneClockSuspect. Add a connection regression that verifies a plausible strap clock more than one day ahead sends neither SET_CLOCK nor SEND_HISTORICAL_DATA.

Proposed fix
-        if (ClockPolicy.shouldSetClock(dev, wall)) {
+        if (!_phoneClockSuspect && ClockPolicy.shouldSetClock(dev, wall)) {

As per coding guidelines, “When adding or changing a capability, cover every call path.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/ble/ble_engine.dart` around lines 2293 - 2307, Guard the automatic
strap-clock correction in the connection flow with !_phoneClockSuspect so a
plausible strap RTC more than one day ahead of the phone is not overwritten;
keep normal correction behavior when the phone clock is trusted. Add a
connection regression covering this drift case and assert that neither SET_CLOCK
nor SEND_HISTORICAL_DATA is sent, exercising the _phoneClockSuspect,
ClockPolicy.shouldSetClock, and history-start paths.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@lib/ble/ble_engine.dart`:
- Around line 1639-1647: Update _startHistoricalRefresh to return whether
SEND_HISTORICAL_DATA was actually sent, returning false for the
_phoneClockSuspect deferred path and true after dispatch. In _triggerBackfill,
assign _lastBackfillAt only when _startHistoricalRefresh returns true, and add a
regression covering a deferred refresh followed immediately by a successful
retry.
- Around line 2293-2307: Guard the automatic strap-clock correction in the
connection flow with !_phoneClockSuspect so a plausible strap RTC more than one
day ahead of the phone is not overwritten; keep normal correction behavior when
the phone clock is trusted. Add a connection regression covering this drift case
and assert that neither SET_CLOCK nor SEND_HISTORICAL_DATA is sent, exercising
the _phoneClockSuspect, ClockPolicy.shouldSetClock, and history-start paths.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 64d2fd1a-2f6d-41dd-bd9d-37e4b144d3f1

📥 Commits

Reviewing files that changed from the base of the PR and between 90f9588 and 8573d7e.

📒 Files selected for processing (2)
  • lib/ble/ble_engine.dart
  • test/ble_engine_test.dart

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant