Skip to content

fix(crashlytics): close the four top 0.9.19/0.9.20 production issues - #163

Merged
abdulsaheel merged 2 commits into
mainfrom
fix/crashlytics-0.9.20
Jul 28, 2026
Merged

fix(crashlytics): close the four top 0.9.19/0.9.20 production issues#163
abdulsaheel merged 2 commits into
mainfrom
fix/crashlytics-0.9.20

Conversation

@abdulsaheel

@abdulsaheel abdulsaheel commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

User description

Four production defects from the 0.9.19/0.9.20 Crashlytics dashboard, each one a foreground assumption or an unbounded read applied to a context that breaks it. No analytics output changes, so kAlgoVersion stays at 50.

1. Import/export OOM — java.lang.OutOfMemoryError

importFromDb ran src.query(t) per table and then held the entire result live for the duration of the insert loop. sqflite serialises a whole result set into Java objects on the platform side before any of it crosses the channel, so importing another device's decoded_onehz (86,400 rows per day of history) materialised the whole table on the 256 MB Dalvik heap at once (target footprint 268435456, growth limit 268435456). The OOM surfaced on whichever thread allocated next — which is why it was reported against a BLE binder callback rather than the import.

Both the import and export paths now page on rowid:

  • Keyset, not LIMIT/OFFSET — OFFSET re-scans the skipped prefix on every page, which is quadratic over a full history.
  • One transaction per page, not per table. The whole-table transaction could only ever commit if the entire table fit in memory first, which is the bug.
  • The orphan guard is unchanged in spirit: each decoded_onehz row's guard is still queued in the same transaction as the row it guards. The invariant is per-row, not per-table.
  • Interrupt-safe: every write stays INSERT OR REPLACE keyed on the row's own identity, so a re-run converges to the same state.
  • On export, each page's RR beats are pulled and written before the next page is read, so peak residency is one page of decoded_onehz plus its beats — not a whole day of both.

2. Unbounded RR fallback read

decodedRrByCounterRange has a degraded branch for when an endpoint row isn't in decoded_onehz (a prune, or an import's REPLACE + orphan-guard DELETE landing between the page read and the call). The caller's counters are then just a span — and because the strap's counter resets on reboot, a reboot-straddling page degenerates to 0 .. ~1200000, i.e. effectively the whole table, on the same heap that OOMed above.

Now capped. A page is 2000 frames and a second rarely carries more than a handful of beats, so the cap sits orders of magnitude above any legitimate page — reaching it means the degraded path is serving a range it was never meant to. Because a short read here shows up downstream as understated HRV rather than as an error, it is never silent: decodedRrFallbackTruncations counts it for the Diagnostics screen. (db.dart takes no telemetry dependency — it has to run inside compute isolates.)

3. Background derive timeouts — day_blocks_failed

TimeoutException: day-blocks computation timed out after 0:01:30, reported only from inside IosBgTask._run — i.e. only ever in background. Two foreground assumptions were being applied to a context that breaks both:

Foreground Background (OS-throttled)
Day-lanes 3 — real speedup from spare cores 1 — no spare cores to soak up; 3 lanes divide one budget three ways and make each day ~3× slower in wall-clock
Per-day budget 90 s 4 min — wall clock stops tracking work once the OS throttles us

Paired with a wall-clock timeout, the old tuning converted "3 days derived" into "3 days timed out". Serial lanes also cap peak memory at one day's substrate instead of three. background: true is set at construction on all four headless entries (iOS BGProcessingTask + BGAppRefreshTask, Android WorkManager, post-drain background sync) so a long-lived foreground engine can never inherit background tuning by accident.

4. Jank watchdog false positives

The watchdog gated on FrameTiming.totalSpan — vsync-start to raster-end, which includes time the engine did nothing, above all the gap across an app resume where a "frame" straddles however long the app sat in the background. The result:

Slow frame: 16358ms (build=0 raster=8)

A 16-second stutter in which the app did 8 ms of work. That single false-positive class became the top issue by impacted-user count on both platforms, burying real signal underneath it.

JankPolicy gates on build + raster — the two costs the app controls and the two a fix would actually move. The 700 ms threshold is unchanged but now means "the engine burned 700 ms on one frame" rather than "700 ms elapsed", which is orders of magnitude rarer and always actionable. totalSpan is still attached as context alongside a derived idle_ms, so a genuine scheduling-delay/thermal pattern stays visible — it just isn't a jank report.

Also: ImportScreen double-tap crash

_busy only went true once an import was already running, i.e. after the picker returned — so while the native sheet was still opening the three option cards stayed live. The sheet takes a beat to appear on a cold platform channel, so users do tap twice; the second tap called pickFiles again and file_picker threw PlatformException(already_active) out of an unawaited context, crashing the app. Guarded twice over: a _picking flag makes the cards inert for the whole picker lifetime, and the catch refuses to let any picker platform failure become fatal (already_active itself is silent — the first picker is still up and will deliver the user's choice).

Testing

  • flutter test --concurrency=1989 passed (up from 914; three new suites: db_paged_import_export_test, derive_pacing_test, jank_policy_test).
  • flutter analyze → clean, no issues.
  • The tuning and verdict decisions live in pure classes (DerivePacing, JankPolicy) so they are unit-testable without a database, an isolate, a real background slot, a SchedulerBinding, or a Firebase app.
  • Sibling pins untouched and verified: pubspec.yaml refs match pubspec.lock resolved-refs (protocol a98cd70, analytics f5ccae6), git provenance intact.

Not yet exercised on real hardware — items 3 and 4 are background/timing behaviours that only prove out on a device over a few OS-granted slots, so the Crashlytics rates for day_blocks_failed and jank_watchdog are what should confirm this after release.

🤖 Generated with Claude Code


PR Type

Bug fix, Tests


Description

  • Fix import/export OOM: replace unbounded SELECT * with keyset-paged reads (2000 rows/page, one transaction per page) on both import and export paths

  • Fix background derivation timeouts: introduce DerivePacing so background slots use 1 lane and a 4-minute per-day timeout instead of 3 lanes and 90 s

  • Fix false-positive jank reports: gate the watchdog on build+raster work time, not totalSpan (which includes app-resume idle gaps)

  • Fix double-tap already_active crash in ImportScreen: add _picking guard around the native file picker call


Diagram Walkthrough

flowchart LR
  A["ImportScreen\n(double-tap guard)"] -- "_picking flag\nprevents re-entry" --> B["FilePicker\n(platform channel)"]
  C["importFromDbFile\n/ exportDaysDb"] -- "keyset-paged\n2000 rows/txn" --> D["decoded_onehz\n/ decoded_rr\n(SQLite)"]
  E["DerivationEngine\n(background: bool)"] -- "delegates to" --> F["DerivePacing\n(concurrency + timeout)"]
  F -- "background=true\n→ 1 lane, 4 min" --> G["IosBgTask\n/ WorkManager\n/ bgsync-derive"]
  F -- "background=false\n→ ≤3 lanes, 90 s" --> H["Foreground derive"]
  I["FrameTiming\n(totalSpan)"] -- "replaced by\nbuild+raster" --> J["JankPolicy\n→ JankVerdict"]
  J -- "non-fatal only\nwhen work≥700ms" --> K["Crashlytics"]
Loading

File Walkthrough

Relevant files
Bug fix
8 files
db.dart
Keyset-paged import/export; bounded RR fallback read         
+200/-101
derivation_engine.dart
Wire background flag into concurrency and per-day timeout
+19/-11 
background_derivation.dart
Pass background=true to DerivationEngine in WorkManager entry
+2/-1     
background_sync.dart
Pass background=true to post-drain derive pass                     
+1/-0     
ios_bg_task.dart
Pass background=true to both iOS BGTask derive engines     
+4/-2     
jank_policy.dart
New pure class: jank verdict based on engine work, not totalSpan
+90/-0   
telemetry_service.dart
Replace totalSpan jank trigger with JankPolicy build+raster check
+30/-15 
import_screen.dart
Guard file picker against double-tap already_active crash
+48/-10 
Enhancement
1 files
derive_pacing.dart
New pure class: foreground vs background lane/timeout policy
+56/-0   
Tests
3 files
db_paged_import_export_test.dart
Tests: paged import/export correctness across page boundaries
+265/-0 
derive_pacing_test.dart
Tests: background vs foreground pacing concurrency and timeout
+66/-0   
jank_policy_test.dart
Tests: jank policy suppresses idle-span false positives   
+90/-0   
Configuration changes
1 files
AppStoreConnect.plist
Add App Store Connect export options plist for iOS archive
+18/-0   

Summary by CodeRabbit

  • New Features

    • Added adaptive compute pacing for foreground vs background derivations.
    • Improved jank detection to report engine work based on derived timing (work vs idle).
    • Added iOS App Store Connect export configuration.
  • Bug Fixes

    • Improved reliability of heavy/background sync derivations on iOS.
    • Bounded RR fallback reads and added import/export paging to prevent excessive memory use and reduce incomplete data outcomes.
    • Prevented duplicate file-picker actions and improved picker error handling.
  • Tests

    • Added regression coverage for pacing, database paging import/export integrity, and jank policy behavior.

Each of these was a real defect that a foreground assumption or an
unbounded read made invisible until it hit a user's device.

1. IMPORT/EXPORT OOM. `importFromDb` ran `src.query(t)` per table and
   held the whole result live for the insert loop. sqflite materialises
   an entire result set as Java objects BEFORE any of it crosses the
   channel, so importing another device's `decoded_onehz` (86,400 rows
   per day) exhausted the 256 MB Dalvik heap — the production
   `java.lang.OutOfMemoryError` blamed on a BLE binder callback, which
   only ever surfaced there because that was the next thread to
   allocate. Both paths now page on rowid (keyset, not OFFSET, which
   would re-scan the prefix quadratically), one transaction per page.
   Each decoded_onehz row's orphan guard still rides in the SAME
   transaction as the row it guards — that invariant is per-row, not
   per-table — and every write stays INSERT OR REPLACE keyed on the
   row's own identity, so an interrupted import converges on re-run.

2. UNBOUNDED RR FALLBACK. `decodedRrByCounterRange`'s degraded branch
   (endpoint row pruned) treats its arguments as a bare counter span,
   and the strap's counter resets on reboot — a reboot-straddling page
   degenerated to `0 .. ~1200000`, i.e. the whole table, on the same
   heap. Capped, with `decodedRrFallbackTruncations` counting any short
   read so truncated HRV input can never pass as a complete one.

3. BACKGROUND DERIVE TIMEOUTS. `day_blocks_failed` ("timed out after
   0:01:30") came only from `IosBgTask._run`. Three concurrent day-lanes
   win when there are spare cores; a throttled background slot has none,
   so the lanes divided one budget three ways and a wall-clock timeout
   turned "3 days derived" into "3 days timed out". Headless entries now
   take one lane and a 4-minute budget via a pure `DerivePacing`; serial
   lanes also cap peak memory at one day's substrate.

4. JANK FALSE POSITIVES. The watchdog gated on `FrameTiming.totalSpan`,
   which spans vsync-start to raster-end and therefore includes the gap
   across an app resume — producing "Slow frame: 16358ms (build=0
   raster=8)", a 16-second stutter in which the engine did 8 ms of work.
   That single class became the top issue by impacted users and buried
   real signal. `JankPolicy` gates on build+raster, the cost the app
   actually controls and the only one a fix would move; totalSpan is
   still attached as context, with idle_ms, so a scheduling-delay
   pattern stays visible.

Also fixes the ImportScreen double-tap crash: `_busy` only went true
once an import was RUNNING, leaving the option cards live while the
native sheet was still opening, so a second tap threw
`PlatformException(already_active)` from an unawaited context. Guarded
by a `_picking` flag plus a catch that refuses to let a picker failure
be fatal.

The three tuning/verdict decisions live in pure classes (DerivePacing,
JankPolicy) so they are unit-testable without an isolate, a background
slot, a SchedulerBinding or a Firebase app. 989 tests pass; analyze
clean. No analytics output changed, so kAlgoVersion stays at 50.
@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

(Review updated until commit 8994285)

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 4 🔵🔵🔵🔵⚪
🧪 PR contains tests
🔒 Security concerns

Sensitive information exposure:
ios/ExportOptions/AppStoreConnect.plist commits the Apple Developer Team ID (2U62X3RF3R) and sets destination: upload with signingStyle: automatic. If this repository is public or accessible to contributors outside the release team, the Team ID is now visible. The Team ID alone is not a secret, but combined with automatic signing and an upload destination, any CI pipeline that picks up this file could trigger an unintended App Store Connect submission. Consider keeping this file out of version control or replacing the Team ID with an environment variable reference.

⚡ Recommended focus areas for review

Import cursor bug

In the paged import loop, lastRowid is read from page.last[rowidKey] AFTER the transaction commits, but page at that point is the raw page from nextPage() which includes the _rowid key. However, the row built inside the transaction strips _rowid via cols.contains(e.key) — that is fine. The real issue is that lastRowid is advanced only AFTER the while (page.isNotEmpty) body, but the cursor read is page.last[rowidKey] where page is the raw result from src.rawQuery. The _rowid alias is '_rowid' (with underscore), and the raw query aliases it as rowidKey = '_rowid'. This is consistent. However, the ops counter inside the transaction increments for both the orphan-guard ops AND the insert, but _queueOrphanGuard returns the number of ops it queued (potentially 2: a DELETE + the guard). The if (++ops >= chunkOps) await flush() at the end increments ops once more for the insert itself, but the ops += _queueOrphanGuard(...) already advanced ops by however many ops the guard queued. The copied++ and if (++ops >= chunkOps) then add one more. This double-counting of ops relative to the old code is a minor concern but not a correctness bug for the flush threshold. More concretely: copied is incremented inside the transaction but counts[t] = copied is set outside — this is correct. No data loss identified here, but the _rowid column leaking into cols.contains check deserves verification: since no real table has a column named _rowid, the guard correctly excludes it.

var page = firstPage;
// ONE TRANSACTION PER PAGE, not per table. The whole-table transaction
// this replaces could only ever commit if the entire table fit in
// memory first, which is the bug. Per-page commits keep peak residency
// at one page, and the import stays safe to interrupt or repeat: every
// write is INSERT OR REPLACE keyed on the row's own identity, so a
// re-run converges to the same state, and each decoded_onehz row's
// orphan guard is still queued in the SAME transaction as the row it
// guards — the invariant that matters is per-row, not per-table.
while (page.isNotEmpty) {
  await db.transaction((txn) async {
    // CHUNKED, for the same reason commitSyncBatch chunks: sqflite
    // serialises a whole batch's args into ONE platform message, and
    // the orphan guard below adds an op per decoded_onehz row on top.
    const chunkOps = 4000;
    var batch = txn.batch();
    var ops = 0;
    Future<void> flush() async {
      if (ops == 0) return;
      await batch.commit(noResult: true);
      batch = txn.batch();
      ops = 0;
    }

    for (final r in page) {
      final row = <String, Object?>{
        for (final e in r.entries)
          if (cols.contains(e.key)) e.key: e.value,
      };
      if (row.isEmpty) continue;
      if (t == 'day_result' &&
          protectedKeys.contains(
            '${row['day_id']}|${row['algo_version']}',
          )) {
        continue; // locally finalized — never overwritten by an import
      }
      // ORPHAN GUARD ON THE IMPORT PATH. A plain replace-insert into
      // decoded_onehz bypasses _queueDecodedOneHz entirely, so a
      // foreign row colliding on UNIQUE(rec_ts) (different counter) or
      // on the `counter` PRIMARY KEY (different second) evicted a local
      // row and stranded its decoded_rr beats — the exact leak the
      // ingest path is guarded against, wide open here. Queue the SAME
      // guard, in the same batch/transaction, right before the row.
      if (t == 'decoded_onehz') {
        final counter = (row['counter'] as num?)?.toInt();
        final recTs = (row['rec_ts'] as num?)?.toInt();
        if (counter == null || recTs == null) continue;
        ops += _queueOrphanGuard(batch, counter: counter, recTs: recTs);
      }
      batch.insert(t, row, conflictAlgorithm: ConflictAlgorithm.replace);
      copied++;
      if (++ops >= chunkOps) await flush();
    }
    await flush();
  });
  // Advance past the last row this page actually delivered. Read the
  // cursor BEFORE dropping the page, and stop on a short page rather
  // than issuing one more query to discover the end.
  lastRowid = (page.last[rowidKey] as num).toInt();
  if (page.length < pageSize) break;
  page = await nextPage();
}
Export rowid alias collision

In copyPaged (the export path), the rowid is aliased as '_rowid' and stripped from clean rows via if (e.key != rowidKey). However, copyPaged calls src.rawQuery on the SOURCE database. If the source table happens to have a real column named _rowid (unlikely but possible in a user-supplied import file), that column would be silently dropped from the export. More practically: the rowidKey constant is defined twice — once inside copyPaged as a local const rowidKey = '_rowid' and once inside importFromDbFile as another local const rowidKey = '_rowid'. These are consistent but if one is ever changed independently the two paths diverge silently. This is a maintainability concern that could become a correctness bug.

const exportPageSize = 2000;
const rowidKey = '_rowid';

/// Streams `table` (optionally filtered) into [out] one page at a time,
/// calling [onPage] with each page after it has been written.
///
/// [onPage] receives rows with the `$rowidKey` cursor column ALREADY
/// stripped, so a callback can insert what it is handed without tripping
/// over a column no destination table has. The cursor is read off the raw
/// page here and never leaves this function.
///
/// PAGING COLUMN: rowid, not the filtered column, so one helper serves
/// every table regardless of what it is filtered on. That means a filtered
/// page walks the rowid chain and tests the predicate per row rather than
/// driving off the `rec_ts`/`ts` index. It stays cheap because both factors
/// are small: `decoded_onehz` is bounded by `rawRetentionDays` (days, not
/// years — it is pruned behind the data edge), and the never-pruned tables
/// paged per day here are hundreds to thousands of rows. Measured on a real
/// 435k-row ledger the worst case — the exhaustion page that scans to the
/// end of the table — is ~10 ms. Revisit only if retention grows a lot;
/// per-table cursors would need a composite `(ts, rowid)` key for the
/// non-unique columns, which is not worth the complexity today.
Future<void> copyPaged(
  String table, {
  String? where,
  List<Object?> whereArgs = const [],
  Future<void> Function(List<Map<String, Object?>> page)? onPage,
}) async {
  var lastRowid = 0;
  while (true) {
    final clause = where == null ? '' : 'AND ($where) ';
    final page = await src.rawQuery(
      'SELECT rowid AS $rowidKey, * FROM $table '
      'WHERE rowid > ? $clause'
      'ORDER BY rowid ASC LIMIT ?',
      [lastRowid, ...whereArgs, exportPageSize],
    );
    if (page.isEmpty) return;
    final clean = [
      for (final row in page)
        <String, Object?>{
          for (final e in row.entries)
            if (e.key != rowidKey) e.key: e.value,
        },
    ];
    await out.transaction((txn) async {
      final batch = txn.batch();
      for (final row in clean) {
        batch.insert(
          table,
          row,
          conflictAlgorithm: ConflictAlgorithm.replace,
        );
      }
      await batch.commit(noResult: true);
    });
    if (onPage != null) await onPage(clean);
    lastRowid = (page.last[rowidKey] as num).toInt();
    if (page.length < exportPageSize) return;
  }
}
Stale engine reference

In the old code, engine was declared before the diff hunk shown (the old hunk removes final engine = DerivationEngine(...) and the new hunk adds it back with background: true). The new hunk at line 69-70 adds the construction correctly. However, the surrounding context shows await engine.run(profile, heavy: true) at line 71 — if there is any other reference to engine earlier in the same else if block that was NOT shown in the diff, it would still reference the old (non-background) construction. Based on the diff this appears clean, but the truncated context makes it impossible to fully verify there is no earlier engine variable in scope.

final engine = DerivationEngine(
    log: (m) => debugPrint('[bg-derive] $m'), background: true);
await engine.run(profile, heavy: true);
Team ID exposure

The file commits the Apple Developer Team ID (2U62X3RF3R) into the repository. While Team IDs are not secret credentials, committing distribution export options with a hardcoded Team ID into a public or shared repository means anyone with the repo can see the organization's Apple Developer account identifier. More importantly, this file was not previously tracked — adding it now means CI/CD pipelines will use it automatically, which could cause unintended App Store Connect uploads if the workflow is triggered unexpectedly.

<string>2U62X3RF3R</string>

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 23a8cf65-f09e-4a2e-acf2-b05aebc0cd49

📥 Commits

Reviewing files that changed from the base of the PR and between cacf029 and 8994285.

📒 Files selected for processing (2)
  • lib/data/db.dart
  • test/db_paged_import_export_test.dart
🚧 Files skipped from review as they are similar to previous changes (2)
  • test/db_paged_import_export_test.dart
  • lib/data/db.dart

📝 Walkthrough

Walkthrough

The PR adds background-aware derivation pacing, paged database import/export, work-based jank detection, guarded file-picker state, and App Store Connect export settings. It also adds regression and integration tests for pacing, database transfers, and telemetry behavior.

Changes

Derivation pacing

Layer / File(s) Summary
Pacing policy and engine integration
lib/compute/derive_pacing.dart, lib/compute/derivation_engine.dart
Derivation pacing selects concurrency and per-day timeouts based on foreground or background execution.
Background wiring and pacing validation
lib/compute/background_derivation.dart, lib/sync/background_sync.dart, lib/sync/ios_bg_task.dart, test/derive_pacing_test.dart
Background derivation entry points pass background: true, with tests covering concurrency and timeout behavior.

Paged database transfer

Layer / File(s) Summary
Bounded RR fallback
lib/data/db.dart
Fallback RR reads use a cap and expose a truncation diagnostic counter.
Paged database export
lib/data/db.dart, test/db_paged_import_export_test.dart
Exports stream rows by rowid and copy related RR beats in bounded query batches.
Paged database import and validation
lib/data/db.dart, test/db_paged_import_export_test.dart
Imports process transactional pages, filter columns, preserve finalized results, guard orphan rows, and test pagination, idempotency, and round trips.

Work-based jank telemetry

Layer / File(s) Summary
Jank policy contract and tests
lib/telemetry/jank_policy.dart, test/jank_policy_test.dart
Jank reporting is based on normalized build plus raster work, with derived diagnostics and boundary tests.
Watchdog integration
lib/telemetry/telemetry_service.dart
The watchdog records policy-derived jank verdicts and timing context.

Import picker state

Layer / File(s) Summary
Picker locking and error handling
lib/ui/import/import_screen.dart
Import options are disabled while picking or importing, picker re-entry is guarded, and platform errors are handled selectively.

App Store Connect export

Layer / File(s) Summary
Export options
ios/ExportOptions/AppStoreConnect.plist
Adds automatic App Store Connect upload settings with symbol upload and stripping enabled.

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

Sequence Diagram(s)

Background derivation pacing

sequenceDiagram
  participant BackgroundTask
  participant DerivationEngine
  participant DerivePacing
  BackgroundTask->>DerivationEngine: construct with background true
  DerivationEngine->>DerivePacing: calculate timeout and concurrency
  DerivePacing-->>DerivationEngine: return single-lane, extended pacing
  DerivationEngine-->>BackgroundTask: run derivation
Loading

Paged database transfer

sequenceDiagram
  participant LocalDb
  participant SourceDatabase
  participant DestinationDatabase
  LocalDb->>SourceDatabase: fetch rowid page
  LocalDb->>DestinationDatabase: insert filtered page in transaction
  DestinationDatabase-->>LocalDb: commit page
  LocalDb->>SourceDatabase: fetch next page
Loading

Work-based jank reporting

sequenceDiagram
  participant FrameTiming
  participant JankPolicy
  participant TelemetryService
  FrameTiming->>JankPolicy: provide build, raster, and total durations
  JankPolicy-->>TelemetryService: return report verdict and timing breakdown
  TelemetryService->>TelemetryService: emit breadcrumb and non-fatal event when reporting
Loading

Possibly related PRs

  • OpenStrap/edge#85: Both PRs modify the jank observability path in lib/telemetry/telemetry_service.dart.
🚥 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 is concise and clearly matches the PR’s goal of fixing four production issues in 0.9.19/0.9.20.
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.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/crashlytics-0.9.20

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

@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Latest suggestions up to 8994285
Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
General
Static counter silently breaks across isolate boundaries

decodedRrFallbackTruncations is a static field incremented on the main isolate,
which is correct per the comment. However, decodedRrByCounterRange is an instance
method on LocalDb and can be called from any context. If DerivationEngine._prepare
is ever moved to run inside Isolate.run (as AGENTS.md §3.10 requires for heavy
compute), increments would land in the spawned isolate's memory and the main
isolate's counter would silently stay at zero — the diagnostic would stop working
with no error. The comment acknowledges this risk but leaves a latent trap. The
counter should be returned as part of the method's result or via a port rather than
relying on the static, or at minimum the method should assert it is on the root
isolate. Since the codebase already uses Isolate.run for compute, this is a real
forward-compatibility defect.

More immediately: rows.length >= fallbackBeatCap fires when the result is exactly
fallbackBeatCap rows, which may not be a truncation — the table could have exactly
that many rows. The condition should be rows.length == fallbackBeatCap to match the
semantics described ("hit its row cap"), but >= is used defensively. This is fine as
written since limit: fallbackBeatCap means sqflite returns at most that many rows,
so > is impossible and == and >= are equivalent. No change needed on that specific
line.

The actionable fix: since _prepare runs on the main isolate and ships data over a
send port, thread the truncation count back through the existing result structure
rather than relying on a static that silently breaks if the call site moves.

lib/data/db.dart [2665-2668]

+// Return the truncation flag alongside the rows so callers that run
+// inside Isolate.run can report it back over the port rather than
+// relying on a static that silently stops working across isolate boundaries.
 if (rows.length >= fallbackBeatCap) {
   decodedRrFallbackTruncations++;
+  // TODO: propagate via result record when _prepare moves to Isolate.run
 }
 return rows;
Suggestion importance[1-10]: 4

__

Why: The concern about decodedRrFallbackTruncations breaking silently if _prepare moves to an isolate is valid and acknowledged in the PR's own comments. However, the improved_code only adds a TODO comment without actually fixing the issue, making this more of a documentation suggestion than a real fix.

Low
Cursor alias collides with real column names

*The rowidKey column (_rowid) is stripped from page rows by the cols.contains(e.key)
guard only when cols does not contain _rowid. However, lastRowid is read from
page.last[rowidKey], which is the raw page from nextPage() — but nextPage() returns
rows that still have the _rowid key. The cursor advance is correct. The real problem
is that copied++ increments for every row inserted, including the _queueOrphanGuard
ops, but _queueOrphanGuard does not insert a decoded_onehz row — it inserts a DELETE
guard. However, copied is incremented inside the loop after batch.insert(t, row,
...), which is correct. The actual bug is that counts[t] = copied is set after the
while loop, but copied is declared before the while and accumulates across all pages
— this is correct. No issue here on that path. The real defect: page.last[rowidKey]
will be null if the cols filter stripped _rowid from the page rows before the cursor
read. But page here is the raw result from nextPage() (not clean), so _rowid is
still present. This is safe. However, the firstPage rows passed into the first
iteration of while (page.isNotEmpty) also come directly from nextPage() and retain
_rowid, so the cursor advance is correct on all pages.

The actual defect: copied counts rows passed to batch.insert but the decoded_onehz
orphan-guard path uses continue to skip rows where counter or rec_ts is null — those
rows are skipped from batch.insert too, but copied is incremented unconditionally
after batch.insert. Wait — copied++ is after batch.insert, so it only runs when
insert runs. That is correct.

The real issue: counts[t] = copied is set outside the while loop but copied is never
reset between tables — it is declared fresh per table inside the for (final t in
tables) loop. That is correct too.

The genuine defect: in copyPaged, lastRowid is read as (page.last[rowidKey] as
num).toInt() but page is the raw sqflite result which uses Map<String, Object?>
rowidKey is '_rowid' and sqflite returns INTEGER columns as int, so the cast is
safe. However, if the source table has a column literally named _rowid (unlikely but
possible), the SELECT rowid AS _rowid, projection would produce a duplicate column
name and sqflite would return only one of them, silently corrupting the cursor. Use
a less collision-prone alias such as pg_rowid for the cursor column.

lib/data/db.dart [3385-3447]

-var page = firstPage;
-// ONE TRANSACTION PER PAGE, not per table. ...
-while (page.isNotEmpty) {
-  await db.transaction((txn) async {
-    ...
-    for (final r in page) {
-      final row = <String, Object?>{
-        for (final e in r.entries)
-          if (cols.contains(e.key)) e.key: e.value,
-      };
-      if (row.isEmpty) continue;
-      ...
-    }
-    await flush();
-  });
-  // Advance past the last row this page actually delivered. Read the
-  // cursor BEFORE dropping the page, and stop on a short page rather
-  // than issuing one more query to discover the end.
-  lastRowid = (page.last[rowidKey] as num).toInt();
-  if (page.length < pageSize) break;
-  page = await nextPage();
-}
+const rowidKey = '__pg_rowid__';
Suggestion importance[1-10]: 3

__

Why: The suggestion to use a less collision-prone alias like __pg_rowid__ instead of _rowid is theoretically valid but extremely unlikely to matter in practice — no real SQLite table would have a column named _rowid. The improved_code only shows a one-line constant change which is a minor style improvement at best.

Low
Duplicated page-size constants will silently diverge

nextPage() is a closure that captures lastRowid by reference, so each call reads the
current value of lastRowid at call time — this is correct for the paging loop.
However, nextPage is defined before lastRowid is initialized (var lastRowid = 0
appears just above), and Dart closures capture variables, not values, so this is
safe. The real issue: subsequent calls to nextPage() after the first page (inside
while (page.isNotEmpty)) are made with page = await nextPage() but lastRowid is only
updated AFTER the transaction commits. If the transaction throws, lastRowid is not
advanced and the next nextPage() call re-fetches the same page — causing an infinite
loop on a persistent transaction error rather than propagating the failure. The
rethrow on the first page is correct, but subsequent page reads have no equivalent
guard: a DatabaseException on page N>1 would propagate out of the while loop and
unwind correctly via the outer try/finally, so this is actually safe. No infinite
loop is possible because the exception exits the loop. This is fine as written.

The genuine defect: nextPage closes over t and lastRowid which are both declared
inside the for (final t in tables) loop body — Dart's for-in loop creates a new
binding per iteration, so t is correctly scoped. lastRowid is also declared inside
the loop body (var lastRowid = 0). This is correct. No bug here.

The actual actionable issue: the const rowidKey = '_rowid' and const pageSize = 2000
are re-declared inside the import loop body on every iteration, shadowing the outer
const rowidKey = '_rowid' and const exportPageSize = 2000 declared in copyPaged.
These are separate scopes so there is no collision, but the duplication means a
future change to one constant will not propagate to the other, silently diverging
the two paging implementations. Both should reference a single shared constant.

lib/data/db.dart [3342-3346]

-Future<List<Map<String, Object?>>> nextPage() => src.rawQuery(
-      'SELECT rowid AS $rowidKey, * FROM $t '
-      'WHERE rowid > ? ORDER BY rowid ASC LIMIT ?',
-      [lastRowid, pageSize],
-    );
+// Hoist to a single shared constant visible to both copyPaged and the
+// import loop, so a tuning change applies to both paths at once.
+const _importExportPageSize = 2000;
+const _importExportRowidKey = '__pg_rowid__';
 
-List<Map<String, Object?>> firstPage;
-try {
-  firstPage = await nextPage();
-} on DatabaseException catch (e) {
-  if (e.isNoSuchTableError()) continue;
-  rethrow;
-}
-
Suggestion importance[1-10]: 3

__

Why: The observation that pageSize/rowidKey are re-declared inside the import loop while exportPageSize/rowidKey exist in copyPaged is accurate, but the improved_code introduces top-level constants that don't reflect how the existing code is structured (they're in different scopes/methods). The risk of divergence is real but low given both are set to 2000 and _rowid.

Low

Previous suggestions

Suggestions up to commit cacf029
CategorySuggestion                                                                                                                                    Impact
Possible issue
Static counter invisible across isolate boundaries

decodedRrFallbackTruncations is a static mutable integer on LocalDb. db.dart runs
inside compute isolates (as the comment above it acknowledges), and each isolate has
its own memory space, so increments inside an isolate are invisible to the UI
isolate that reads the Diagnostics screen. The counter will always read zero on the
UI side regardless of how many truncations occur. The count needs to be persisted
(e.g. via a PRAGMA user_version slot, a dedicated key-value row, or passed back
through the isolate return value) to be observable across the isolate boundary.

lib/data/db.dart [2587]

-static int decodedRrFallbackTruncations = 0;
+// Replace the static int with a persistent store. Simplest approach:
+// write the count into a dedicated row in the existing key-value table
+// (or return it as part of the isolate result) so the UI isolate can read it.
+// Example using an existing kv helper (adjust to the real API):
+static Future<void> recordRrFallbackTruncation(Database db) async {
+  await db.rawUpdate(
+    "INSERT INTO compute_state (key, value) VALUES ('rr_fallback_truncations', 1) "
+    "ON CONFLICT(key) DO UPDATE SET value = value + 1",
+  );
+}
Suggestion importance[1-10]: 6

__

Why: The suggestion correctly identifies that a static variable in Dart isolates is not shared across isolate boundaries, so decodedRrFallbackTruncations incremented inside a compute isolate would be invisible to the UI isolate. However, the code comment says "db.dart takes no telemetry dependency (it has to run inside compute isolates)" suggesting the authors are aware of the limitation and intend it as a diagnostic counter for the main isolate context only. The improved_code diverges significantly from the existing pattern and introduces a new persistence mechanism that may not fit the codebase.

Low
Null rowid cursor advance crashes import silently

The lastRowid variable is declared inside the for (final t in tables) loop but
nextPage closes over it as a captured variable. However, lastRowid is never reset
between tables because it is declared once per table iteration — that part is fine.
The real issue is that page.last[rowidKey] will throw a NoSuchElementException /
cast error if the last row in a page somehow lacks the aliased _rowid key (e.g. a
table whose schema shadows the alias). More critically, the _rowid key is filtered
out by cols.contains(e.key) in the insert loop, but it is read from
page.last[rowidKey] AFTER the transaction — if page.last is a row whose _rowid value
is null (possible if the source DB was created WITHOUT ROWID), the as num cast
crashes the import silently. Add a null guard before advancing the cursor.

lib/data/db.dart [3313-3409]

-var lastRowid = 0;
-Future<List<Map<String, Object?>>> nextPage() => src.rawQuery(
-      'SELECT rowid AS $rowidKey, * FROM $t '
-      'WHERE rowid > ? ORDER BY rowid ASC LIMIT ?',
-      [lastRowid, pageSize],
-    );
+final rawRowid = page.last[rowidKey];
+if (rawRowid == null) break; // WITHOUT ROWID table or alias collision — stop safely
+lastRowid = (rawRowid as num).toInt();
+if (page.length < pageSize) break;
+page = await nextPage();
 
-List<Map<String, Object?>> firstPage;
-try {
-  firstPage = await nextPage();
-} catch (_) {
-  continue; // table absent in the source export
-}
-...
-while (page.isNotEmpty) {
-  await db.transaction((txn) async {
-    ...
-  });
-  lastRowid = (page.last[rowidKey] as num).toInt();
-  if (page.length < pageSize) break;
-  page = await nextPage();
-}
-
Suggestion importance[1-10]: 5

__

Why: The suggestion raises a valid concern about a potential null cast crash when page.last[rowidKey] is null (e.g. for WITHOUT ROWID tables). The code comment in the PR already notes "none of these tables is WITHOUT ROWID", but a defensive null check would still improve robustness. The improved_code accurately reflects the suggested change and is a reasonable guard.

Low
Boolean latch cleared prematurely on already_active path

When already_active is caught, _picking is reset to false in the finally block, but
the FIRST picker that is still active will eventually return — and when it does,
_picking is already false, so a third tap can open yet another picker before the
first one resolves. The _picking flag should remain true until the active picker
actually delivers its result. Since the second call returns early with [], the flag
should NOT be cleared in finally for the already_active case; instead it should only
be cleared when this invocation was the one that actually opened the picker.

lib/ui/import/import_screen.dart' [69-82]

+if (_picking) return const [];
+_set(() => _picking = true);
+try {
+  final res = await FilePicker.platform.pickFiles(
+    type: FileType.any,
+    allowMultiple: multiple,
+    withData: false,
+  );
+  if (res == null) return const [];
+  return [for (final f in res.files) if (f.path != null) f.path!];
 } on PlatformException catch (e) {
-  // `already_active` is the benign double-tap race — the first picker is
-  // still up and will deliver the user's choice, so say nothing. Anything
-  // else is worth showing.
-  if (e.code != 'already_active') {
-    _set(() => _error = 'Could not open the file picker: ${e.message ?? e.code}');
+  if (e.code == 'already_active') {
+    // The first picker is still open; leave _picking = true so no new
+    // picker can open until the active one resolves. Do NOT clear it here.
+    return const [];
   }
+  _set(() {
+    _picking = false;
+    _error = 'Could not open the file picker: ${e.message ?? e.code}';
+  });
   return const [];
 } catch (e) {
-  _set(() => _error = 'Could not open the file picker: $e');
+  _set(() {
+    _picking = false;
+    _error = 'Could not open the file picker: $e';
+  });
   return const [];
 } finally {
+  // Only reached on the normal (non-already_active) paths above because
+  // already_active returns early. Clear _picking after a successful pick.
   _set(() => _picking = false);
 }
Suggestion importance[1-10]: 4

__

Why: The suggestion identifies a subtle race: when already_active is caught, _picking is reset to false in finally, potentially allowing a third tap to open another picker. However, the already_active exception means the first picker is still open, and in practice the _locked guard plus the early return if (_picking) return const [] at the top of _pick already prevents re-entry during the active pick. The improved_code has a logical flaw — the finally block still runs after the already_active early return, contradicting the comment claiming it won't be reached.

Low

@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: 2

🤖 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/data/db.dart`:
- Around line 3016-3051: The copyPaged method currently pages filtered tables by
rowid, causing repeated scans of the remaining table. Update the day-ranged
export flow to use keyset pagination on the filtered indexed column: for
decoded_onehz use rec_ts as the cursor with rec_ts > lastRecTs and ORDER BY
rec_ts ASC, and for ts/start_ts tables use the appropriate (timestamp, rowid)
composite cursor. Preserve filtering, insertion, callbacks, and short-page
termination behavior.
- Around line 3321-3325: Update the first-page fetch error handling in the
import loop around nextPage() so only the specific missing-table
DatabaseException is treated as an absent source table and continues. Let
corruption, malformed data, I/O, and other read failures propagate, ensuring
counts[t] is not silently omitted and importEdgeBackup reports failure rather
than partial success.
🪄 Autofix (Beta)

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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 20b7c1a6-030c-4583-8109-6f634c92be66

📥 Commits

Reviewing files that changed from the base of the PR and between 01e706b and cacf029.

📒 Files selected for processing (13)
  • ios/ExportOptions/AppStoreConnect.plist
  • lib/compute/background_derivation.dart
  • lib/compute/derivation_engine.dart
  • lib/compute/derive_pacing.dart
  • lib/data/db.dart
  • lib/sync/background_sync.dart
  • lib/sync/ios_bg_task.dart
  • lib/telemetry/jank_policy.dart
  • lib/telemetry/telemetry_service.dart
  • lib/ui/import/import_screen.dart
  • test/db_paged_import_export_test.dart
  • test/derive_pacing_test.dart
  • test/jank_policy_test.dart

Comment thread lib/data/db.dart
Comment on lines +3016 to +3051
Future<void> copyPaged(
String table, {
String? where,
List<Object?> whereArgs = const [],
Future<void> Function(List<Map<String, Object?>> page)? onPage,
}) async {
final rows = await src.query(table, where: where, whereArgs: whereArgs);
if (rows.isEmpty) return;
await out.transaction((txn) async {
final batch = txn.batch();
for (final row in rows) {
batch.insert(
table,
Map<String, Object?>.from(row),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
await batch.commit(noResult: true);
});
}

Future<void> copyRawRange(int startSec, int endSec) async {
final decoded = await src.query(
'decoded_onehz',
where: 'rec_ts >= ? AND rec_ts < ?',
whereArgs: [startSec, endSec],
);
if (decoded.isNotEmpty) {
var lastRowid = 0;
while (true) {
final clause = where == null ? '' : 'AND ($where) ';
final page = await src.rawQuery(
'SELECT rowid AS $rowidKey, * FROM $table '
'WHERE rowid > ? $clause'
'ORDER BY rowid ASC LIMIT ?',
[lastRowid, ...whereArgs, exportPageSize],
);
if (page.isEmpty) return;
await out.transaction((txn) async {
final batch = txn.batch();
for (final row in decoded) {
for (final row in page) {
final clean = <String, Object?>{
for (final e in row.entries)
if (e.key != rowidKey) e.key: e.value,
};
batch.insert(
'decoded_onehz',
Map<String, Object?>.from(row),
table,
clean,
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
await batch.commit(noResult: true);
});
final counters = <Object?>[
for (final row in decoded)
if (row['counter'] != null) row['counter'],
];
// CHUNKED `IN (…)`: a full day is 86 400 counters, two orders of
// magnitude past SQLITE_MAX_VARIABLE_NUMBER — one giant statement can
// never bind. (This never surfaced only because the missing `version:`
// above aborted the export earlier.)
for (final chunk in _sqlVarChunks(counters)) {
final placeholders = List.filled(chunk.length, '?').join(',');
final rr = await src.rawQuery(
'SELECT * FROM decoded_rr WHERE counter IN ($placeholders)',
chunk,
);
if (rr.isEmpty) continue;
await out.transaction((txn) async {
final batch = txn.batch();
for (final row in rr) {
batch.insert(
'decoded_rr',
Map<String, Object?>.from(row),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
await batch.commit(noResult: true);
});
}
if (onPage != null) await onPage(page);
lastRowid = (page.last[rowidKey] as num).toInt();
if (page.length < exportPageSize) return;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Filtered rowid paging re-scans the table on every page.

WHERE rowid > ? AND (rec_ts >= ? AND rec_ts < ?) ORDER BY rowid ASC forces a rowid-ordered scan, so the idx_decoded_onehz_rects range index can't drive the query: each page walks the remaining decoded_onehz rows from lastRowid to the end of the table to collect its 2000 matches (and the last page always scans to the end). On a multi-year ledger that is a full-table scan per page, per exported day. Correctness is fine (a short page really does mean exhaustion), but the export cost is now O(days × pages × table).

For the day-ranged copies, keyset on the filtered column instead — WHERE rec_ts >= ? AND rec_ts < ? AND rec_ts > lastRecTs ORDER BY rec_ts ASC uses the existing index and stays linear. decoded_onehz is UNIQUE(rec_ts), so rec_ts is a valid unique cursor; the same shape works for the ts/start_ts-filtered tables with a (ts, rowid) composite cursor.

🤖 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 3016 - 3051, The copyPaged method currently
pages filtered tables by rowid, causing repeated scans of the remaining table.
Update the day-ranged export flow to use keyset pagination on the filtered
indexed column: for decoded_onehz use rec_ts as the cursor with rec_ts >
lastRecTs and ORDER BY rec_ts ASC, and for ts/start_ts tables use the
appropriate (timestamp, rowid) composite cursor. Preserve filtering, insertion,
callbacks, and short-page termination behavior.

Comment thread lib/data/db.dart
Addresses bot review on #163.

The import's first-page read caught EVERYTHING and treated it as "this
export doesn't carry that table" — so corruption, a truncated or
malformed source file, or an I/O error all skipped the table silently,
`counts[t]` was never set, and the summed total then reported a PARTIAL
import as a success. Silent partial success on someone's health history
is the worst available outcome. Now only `isNoSuchTableError()` skips;
everything else propagates.

Narrowing this had a real regression risk worth a test: the fixture only
creates decoded_onehz + decoded_rr, so every other table in the import
list already hits the missing-table path on every import test. Had
`isNoSuchTableError()` not matched sqflite's actual exception, importing
a partial export would have started THROWING instead of skipping — and
that would have ridden in on tests that appear to be about paging. Now
asserted by name. (Used sqflite's own predicate rather than matching
'no such table' in a `toString()`, which is not stable across platforms.)

Also, from the same review:

- `copyPaged` hands [onPage] rows with the `_rowid` cursor column already
  stripped, so a callback can insert what it is given. Today's only
  callback reads `row['counter']`, but the raw-row API was a standing
  invitation to a "no such column: _rowid" bug. Drops a redundant per-row
  map rebuild too.

- Documented why the static truncation counter is sound at this call site
  (every sqflite call needs the root isolate's platform channel, and the
  only caller reads on the main isolate and ships pages to the worker via
  `worker.send`) and what breaks it, because the previous comment implied
  the opposite and led a reviewer to a wrong conclusion.

- Documented the paging-column tradeoff where it lives. Paging on rowid
  rather than the filtered column means a filtered page walks the rowid
  chain instead of driving off the rec_ts index. It stays cheap because
  both factors are bounded — decoded_onehz is pruned to rawRetentionDays
  behind the data edge, and the never-pruned per-day tables are hundreds
  of rows. Measured ~10 ms for the worst case (the exhaustion page that
  scans to the end of the table) on a real 435k-row ledger.

990 tests pass; analyze clean.
@abdulsaheel

abdulsaheel commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

Fixed (8994285)

  • CodeRabbit, blanket catch (_) — valid, and the best catch of the review. Narrowed to DatabaseException.isNoSuchTableError() with everything else rethrowing. Used sqflite's own predicate rather than toString().contains('no such table'), which isn't stable across platforms. Added a test: the fixture only creates decoded_onehz + decoded_rr, so every other table already exercises the missing-table path on every import test — meaning if that predicate ever stops matching, partial imports would start throwing rather than skipping, riding in on tests that look like they're about paging. Asserted by name now.
  • PR Agent, _rowid reaching onPage — agreed it was latent rather than live, but the raw-row API was an open invitation, so onPage now receives rows with the cursor column already stripped.

Declining, with measurements

  • CodeRabbit, filtered rowid paging (🟠 Major) — the query-plan claim is correct and I verified it (SEARCH ... USING INTEGER PRIMARY KEY (rowid>?) vs the rec_ts index on your proposed shape). But the severity rests on "a multi-year ledger", and that premise doesn't hold for this table: rawRetentionDays = 3 (derivation_engine.dart:356) prunes decoded_onehz behind the data edge, so it holds days, not years. The never-pruned tables walked per-day by the for (final dayId in sorted) loop are hundreds to thousands of rows. Measured on a real 435k-row ledger, the worst case — the exhaustion page that scans to the end of the table — is ~10 ms (vs ~12 ms for the index-driven shape). Per-table cursors would need a composite (ts, rowid) key for the non-unique columns; that's real complexity on a path that exists to fix an OOM, for no measurable gain. Tradeoff and revisit condition are now documented on copyPaged.
  • PR Agent, static counter invisible across the isolate boundary — doesn't hold. The only caller is derivation_engine.dart:1270, which reads on the main isolate and ships each page to the compute worker via worker.send; every sqflite call needs the root isolate's platform channel regardless. Increments land in the same isolate that reads them. That said, the comment it was reasoning from was misleading, so I've rewritten it to state why a static is sound here specifically and what would break it.
  • PR Agent, confirm the export still writes version — confirmed present: db.dart:2988, version: schemaVersion, with the comment explaining that its absence is exactly why this export path had never once produced a file (fixed in fix: close data-loss, privacy and absent-vs-zero defects across the app #158).
  • PR Agent, verify no engine reuses the flag across contexts — verified, and it's correct by construction: app_state.dart:122 holds the long-lived foreground engine and never passes background:, so it defaults to false; only the four headless entries pass true. That's why the field is final and set at construction rather than per-run.
  • PR Agent, Team ID in ExportOptions — agreed with your own assessment that it isn't a credential. The signing material (Signing.xcconfig) stays gitignored.

990 tests pass, flutter analyze clean.

@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 8994285

@abdulsaheel
abdulsaheel merged commit ae07cf2 into main Jul 28, 2026
3 checks passed
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