Skip to content

migration(db): hive to sqlite migration#1059

Closed
lollipopkit wants to merge 10 commits intomainfrom
fix/sqlite-migration-safety
Closed

migration(db): hive to sqlite migration#1059
lollipopkit wants to merge 10 commits intomainfrom
fix/sqlite-migration-safety

Conversation

@lollipopkit
Copy link
Owner

@lollipopkit lollipopkit commented Feb 28, 2026

Summary

  • migrate app stores from Hive to SqliteStore
  • add startup migrator to move legacy Hive data into SQLite/SQLCipher
  • harden migration flow to avoid false-success states

Issue

Closes #1037

What Changed

  • switched SettingStore / ServerStore / ContainerStore / PrivateKeyStore / SnippetStore / HistoryStore / ConnectionStatsStore from HiveStore to SqliteStore
  • replaced direct box usage in related pages and utils with store APIs (get/set/remove/keys/fetchOne)
  • updated backup restore merge path to use Mergeable.mergeStore with store timestamps
  • added HiveToSqliteMigrator and called it during app init
  • added drift and sqlcipher_flutter_libs dependencies and regenerated platform plugin registrants

Migration Safety Fixes (for #1037)

  • align legacy Hive path resolution with the old Hive runtime path rules
  • if any legacy box migration fails, do not set migrated flag and do not archive legacy files (retry on next launch)
  • support legacy Hive key fallback from pref keys (hive_key / flutter.hive_key) and sync it back to secure storage

Verification

  • flutter analyze lib/data/migration/hive_to_sqlite_migrator.dart
  • flutter test (194 tests, all passed)

Summary by CodeRabbit

  • New Features

    • One-time migrator to move legacy Hive data to encrypted SQLite; many stores now use SQLite.
    • New single-item fetch and keys APIs for servers, snippets, and keys; container default/type setter added.
    • Multi-hop SSH jump-chain context propagation during host-key checks.
  • Improvements

    • Unified timestamped backup-and-merge workflow.
    • Connection stats/history: SQLite backend, retention, grouping and richer reporting.
    • UI/initialization refinements and safer settings operations.
  • Chores

    • Added SQLite/SQLCipher packages and registered platform plugins.
  • Documentation

    • Updated storage docs to reflect SQLite/SQLCipher.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 28, 2026

📝 Walkthrough

Walkthrough

Replaces Hive with SQLite/SQLCipher across the app: multiple store classes now extend SqliteStore and use get/set/fetchOne APIs; serialization/deserialization helpers were centralized. Adds HiveToSqliteMigrator to detect, normalize, migrate, and optionally archive legacy Hive boxes. Updates lib/core/utils/server.dart to resolve jump hosts via Stores.server.fetchOne and to propagate jumpSpisById and visitedServerIds through genClient/ensureKnownHostKey. Registers SQLCipher plugin in platform registrants and adds drift/sqlcipher_flutter_libs dependencies to pubspec.yaml.

Possibly related PRs

  • PR 1058: Modifies lib/core/utils/server.dart to switch jump-host lookups to ServerStore.fetchOne and to thread jumpSpisById/visitedServerIds through genClient/ensureKnownHostKey.
  • PR 943: Touches the same server utilities and host-key verification flow, impacting recursive genClient calls and host-key verification logic.
  • PR 940: Adds an onVerifyHostKey callback and related SSH client/host-key verification changes that integrate with the genClient/ensureKnownHostKey call paths.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'migration(db): hive to sqlite migration' clearly summarizes the main objective—migrating from Hive to SQLite database storage.
Linked Issues check ✅ Passed The PR successfully implements all coding requirements from #1037: replaces Hive with SQLite across all seven stores, provides a safe repeatable migration with fallback handling, ensures correct legacy path and key handling, and prevents false-success states.
Out of Scope Changes check ✅ Passed All changes are directly related to the Hive-to-SQLite migration: store implementations, migration logic, data access API updates, and necessary plugin registrations. No unrelated changes detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/sqlite-migration-safety

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

Copy link

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 8 additional findings.

Open in Devin Review

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

Caution

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

⚠️ Outside diff range comments (1)
lib/main.dart (1)

57-63: ⚠️ Potential issue | 🟠 Major

Confirm migration placement; address unprotected initialization operations.

The migration call placement is correct—properly sequenced after Stores.init() (a dependency of the migrator) and before _doDbMigrate().

However, the migrator's exception handling is incomplete. While individual box migrations (_migrateOne()) are wrapped in try-catch, the following operations lack protection and could crash app startup:

  • Hive.initFlutter() (line 34)
  • Hive.registerAdapters() (line 35)
  • Store flush operations (lines 82-88)
  • _archiveLegacyFiles() (line 100)

If any of these throw an unhandled exception, app startup fails despite the migrated flag never being set (allowing retry on next launch). Consider wrapping the entire migration logic or at minimum these initialization and persistence steps in a try-catch block to ensure a corrupted legacy file doesn't block app launches.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/main.dart` around lines 57 - 63, Wrap the entire
HiveToSqliteMigrator.migrateIfNeeded() implementation (or at minimum the calls
to Hive.initFlutter(), Hive.registerAdapters(), the store flush loops in
_migrateOne()/Store.flush(), and _archiveLegacyFiles()) in a top-level try-catch
so any exception during initialization or persistence is caught; inside the
catch log the error and abort the migration gracefully without setting the
migrated flag, and ensure successful completion still sets the migrated flag and
performs cleanup—update migrateIfNeeded(), _migrateOne(), and any code that
calls _archiveLegacyFiles() to use this guarded flow so startup cannot crash
from unprotected init/flush/archive steps.
🧹 Nitpick comments (7)
lib/view/page/server/connection_stats.dart (1)

276-280: Extract size-string formatting into one helper.

The same bytes/KB/MB formatting is duplicated for both old and new sizes.

♻️ Proposed refactor
   void _showCompactDialog() {
     final path = '${Paths.doc}${Pfs.seperator}connection_stats.db';
     final file = File(path);
     final oldSize = file.existsSync() ? file.lengthSync() : 0;
-    final sizeStr = oldSize < 1000
-        ? '$oldSize B'
-        : oldSize < 1000 * 1000
-        ? '${(oldSize / 1000).toStringAsFixed(1)} KB'
-        : '${(oldSize / (1000 * 1000)).toStringAsFixed(1)} MB';
+    String formatSize(int bytes) => bytes < 1000
+        ? '$bytes B'
+        : bytes < 1000 * 1000
+        ? '${(bytes / 1000).toStringAsFixed(1)} KB'
+        : '${(bytes / (1000 * 1000)).toStringAsFixed(1)} MB';
+    final sizeStr = formatSize(oldSize);
@@
               await Stores.connectionStats.compact();
               final newSize = file.existsSync() ? file.lengthSync() : 0;
-              final newSizeStr = newSize < 1000
-                  ? '$newSize B'
-                  : newSize < 1000 * 1000
-                  ? '${(newSize / 1000).toStringAsFixed(1)} KB'
-                  : '${(newSize / (1000 * 1000)).toStringAsFixed(1)} MB';
+              final newSizeStr = formatSize(newSize);

Also applies to: 294-298

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/view/page/server/connection_stats.dart` around lines 276 - 280, Extract
the duplicated bytes→string logic into a single helper (e.g. String
formatSize(int bytes)) in connection_stats.dart and replace both sizeStr
assignments (the one using oldSize and the one using newSize) to call that
helper; implement formatSize to return "X B" for bytes<1000, "Y.Z KB" for
bytes<1000*1000, else "Y.Z MB" using toStringAsFixed(1), and update any
references to sizeStr/new size formatting accordingly (keep the same thresholds
and formatting behavior).
lib/data/store/snippet.dart (1)

17-34: Unify deserialization logic between fetch and fetchOne.

fetch() and _fromObj currently duplicate parser logic; this can drift and cause inconsistent behavior.

♻️ Proposed refactor
     for (final key in keys()) {
       final s = get<Snippet>(
         key,
-        fromObj: (val) {
-          if (val is Snippet) return val;
-          if (val is Map<dynamic, dynamic>) {
-            final map = val.toStrDynMap;
-            if (map == null) return null;
-            try {
-              final snippet = Snippet.fromJson(map as Map<String, dynamic>);
-              put(snippet);
-              return snippet;
-            } catch (e) {
-              dprint('Parsing Snippet from JSON', e);
-            }
-          }
-          return null;
-        },
+        fromObj: (val) {
+          final parsed = _fromObj(val);
+          if (parsed != null && val is Map<dynamic, dynamic>) {
+            put(parsed); // normalize legacy map payload
+          }
+          return parsed;
+        },
       );

Also applies to: 60-72

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/data/store/snippet.dart` around lines 17 - 34, The fromObj closure passed
to get<Snippet> duplicates parsing logic used by fetch() and fetchOne(); extract
the shared deserialization into a single helper (e.g., a private function like
_parseSnippetFromObj(dynamic val) or a static Snippet.tryFrom(dynamic obj)) and
call that helper from the fromObj closure and from fetch/fetchOne so behavior
stays consistent; ensure the helper handles Map<dynamic,dynamic> -> toStrDynMap
conversion, catches exceptions and logs via dprint, returns null on failure, and
still calls put(snippet) when a Snippet is successfully constructed.
lib/data/store/connection_stats.dart (1)

66-80: Reduce work in _buildServerStats by avoiding extra list sorts.

sortedStats is already newest-first, so latest success/failure timestamps can be found in one pass.

⚡ Proposed refactor
-    final successTimes = sortedStats.where((s) => s.result.isSuccess).map((s) => s.timestamp).toList();
-    final failureTimes = sortedStats.where((s) => !s.result.isSuccess).map((s) => s.timestamp).toList();
-
     DateTime? lastSuccessTime;
     DateTime? lastFailureTime;
-
-    if (successTimes.isNotEmpty) {
-      successTimes.sort((a, b) => b.compareTo(a));
-      lastSuccessTime = successTimes.first;
-    }
-
-    if (failureTimes.isNotEmpty) {
-      failureTimes.sort((a, b) => b.compareTo(a));
-      lastFailureTime = failureTimes.first;
+    for (final stat in sortedStats) {
+      if (lastSuccessTime == null && stat.result.isSuccess) {
+        lastSuccessTime = stat.timestamp;
+      }
+      if (lastFailureTime == null && !stat.result.isSuccess) {
+        lastFailureTime = stat.timestamp;
+      }
+      if (lastSuccessTime != null && lastFailureTime != null) break;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/data/store/connection_stats.dart` around lines 66 - 80, sortedStats is
already ordered newest-first, so avoid creating and sorting
successTimes/failureTimes; in _buildServerStats iterate sortedStats once and set
lastSuccessTime to the first s.timestamp where s.result.isSuccess and
lastFailureTime to the first s.timestamp where !s.result.isSuccess (stop early
once both are found), then remove the successTimes/failureTimes lists and the
extra sorts to reduce work.
lib/data/store/private_key.dart (1)

17-34: Consider extracting shared deserialization logic.

The inline fromObj in fetch() (lines 19-32) and the _fromObj helper (lines 51-63) have nearly identical deserialization logic. The only difference is that fetch() calls put(pki) to re-persist migrated data.

You could extract the parsing logic and have fetch() call the shared parser then conditionally persist:

♻️ Optional refactor to reduce duplication
 List<PrivateKeyInfo> fetch() {
   final ps = <PrivateKeyInfo>[];
   for (final key in keys()) {
     final s = get<PrivateKeyInfo>(
       key,
       fromObj: (val) {
-        if (val is PrivateKeyInfo) return val;
-        if (val is Map<dynamic, dynamic>) {
-          final map = val.toStrDynMap;
-          if (map == null) return null;
-          try {
-            final pki = PrivateKeyInfo.fromJson(map as Map<String, dynamic>);
-            put(pki);
-            return pki;
-          } catch (e) {
-            dprint('Parsing PrivateKeyInfo from JSON', e);
-          }
-        }
-        return null;
+        final pki = _fromObj(val);
+        if (pki != null && val is Map) {
+          put(pki); // Re-persist migrated data
+        }
+        return pki;
       },
     );

Also applies to: 51-63

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/data/store/private_key.dart` around lines 17 - 34, The deserialization
logic in the inline fromObj inside fetch() and the _fromObj helper is
duplicated; extract the shared parsing steps into a single private parser
function (e.g., _parsePrivateKeyInfo(Object? val) that performs the Map check,
uses PrivateKeyInfo.fromJson and returns a PrivateKeyInfo? ) then have both
fetch()'s fromObj and _fromObj call that parser; in fetch(), after calling the
parser, call put(pki) when the parser returns a non-null PrivateKeyInfo to
preserve the existing re-persist/migration behavior.
lib/data/migration/hive_to_sqlite_migrator.dart (1)

281-301: Robust normalization with graceful fallback.

The _normalizeRaw function handles primitives, enums, lists, maps, and objects with toJson() methods. The try-catch around dynamic toJson calls ensures unknown object types don't crash the migration—they're simply skipped (return null).

One note: objects without a toJson method will be silently dropped. Consider logging a warning for debugging purposes if unexpected types are encountered.

♻️ Optional: Add debug logging for unknown types
     try {
       final dynamic obj = raw;
       final jsonObj = obj.toJson();
       return _normalizeRaw(jsonObj);
     } catch (_) {
+      Loggers.app.fine('Skipping non-serializable object: ${raw.runtimeType}');
       return null;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/data/migration/hive_to_sqlite_migrator.dart` around lines 281 - 301, The
_normalizeRaw function currently swallows unknown object types by returning null
inside the catch block; update the catch to log a warning with the runtime type
and/or a brief description before returning null so unexpected types are visible
during migration; specifically modify _normalizeRaw's try-catch around the
dynamic obj.toJson() call to use your logger (e.g., processLogger,
migrationLogger, or existing logger in scope) to emit a warning including
raw.runtimeType (and the caught error message if available), then return null as
before.
lib/data/store/server.dart (1)

20-37: Similar duplication pattern as PrivateKeyStore.

The inline fromObj in fetch() (lines 22-36) duplicates the logic in _fromObj (lines 132-144), with the same difference that fetch() calls put(spi) to re-persist. Consider the same refactoring approach suggested for PrivateKeyStore if you want to reduce duplication.

Also applies to: 132-144

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/data/store/server.dart` around lines 20 - 37, The inline fromObj lambda
inside fetch() duplicates the parsing logic in _fromObj; refactor fetch() to
call the existing _fromObj(Map) or a shared helper that encapsulates
Spi.fromJson and dprint, and then have fetch() call put(spi) only when the
parsed spi is non-null to re-persist; specifically, replace the anonymous
fromObj in the get<Spi>(...) call with a reference to the shared _fromObj (or
extract a private helper used by both fetch() and _fromObj) and ensure fetch()
still invokes put(spi) after obtaining a non-null Spi, keeping Spi.fromJson,
put, and dprint usage unchanged.
lib/data/model/app/bak/backup.dart (1)

115-119: Consider making _withTs static.

The _withTs helper doesn't access any instance state (this), so it could be a static method for clarity.

♻️ Optional refactor
-  Map<String, Object?> _withTs(String tsKey, Map<String, Object?> data, int ts) {
+  static Map<String, Object?> _withTs(String tsKey, Map<String, Object?> data, int ts) {
     final map = <String, Object?>{...data};
     map[tsKey] = ts;
     return map;
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/data/model/app/bak/backup.dart` around lines 115 - 119, The helper method
_withTs does not use instance state; make it static by adding the static keyword
to its declaration (change "Map<String, Object?> _withTs(...)" to "static
Map<String, Object?> _withTs(...)") in the class that contains it; update any
internal call sites if they're using instance access to call it (prefer
ClassName._withTs(...) or, if called from within the same class, call
_withTs(...) which will resolve to the static method).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/data/store/connection_stats.dart`:
- Line 14: Verify ShortId.generate in fl_lib to confirm its character set and
whether it can emit '_' and, if it can, fix the fragile concatenation/parsing in
connection_stats.dart: either (A) change the delimiter used when building the
key (the line creating key = '${stat.serverId}_${ShortId.generate()}') to a
character guaranteed not emitted by ShortId (or a binary/UUID separator), (B)
sanitize/encode the ShortId output to remove/escape '_' (update ShortId.generate
or wrap its output), or (C) avoid string concatenation entirely and store
serverId and shortId as separate fields (or use a fixed-length suffix) and
update any parsing that uses lastIndexOf('_') to the new scheme; update all
occurrences referenced (the key creation and any parsing that relies on
lastIndexOf('_') in the 198-207 and 209-221 ranges) so parsing is unambiguous.

In `@lib/data/store/setting.dart`:
- Around line 11-12: The SettingStore currently extends SqliteStore (class
SettingStore and constructor SettingStore._()) which violates the repo policy
requiring Hive-backed stores under lib/data/store; either change SettingStore to
use the existing Hive store base (e.g., extend the HiveStore/appropriate
Hive-backed base and implement Hive box access) so persistence uses Hive, or, if
you intend to switch to SQLite, update the ADR/policy in this PR to explicitly
allow SqliteStore for settings and adjust any store-layer docs; ensure the
constructor and any storage-specific methods are updated to reference the Hive
API (or document the ADR change) to resolve the policy conflict.

In `@lib/view/page/private_key/list.dart`:
- Around line 71-90: The async read idRsaFile.readAsString() can allow the
widget to unmount before you call context.showRoundDialog /
PrivateKeyEditPage.route.go; after awaiting the file read, check that the
BuildContext is still mounted (e.g., if (!context.mounted) return;) before using
context, and only then construct PrivateKeyInfo and call
context.showRoundDialog; ensure you still await the file into a local variable
first (store key = await idRsaFile.readAsString()), then guard with
context.mounted, then create PrivateKeyInfo(id: 'system', key: key) and proceed
to show the dialog and navigate.

In `@lib/view/page/setting/entry.dart`:
- Around line 145-157: Replace the hard-coded section titles with localized
strings from libL10n: instead of literal 'App', 'AI', 'SSH', and 'SFTP' pass the
corresponding libL10n properties to CenterGreyTitle where the children list uses
CenterGreyTitle and the builders (_buildApp, _buildAskAiConfig, _buildSSH,
_buildSFTP) are placed; follow the existing pattern used for libL10n.server to
add/consume libL10n.app, libL10n.ai (or appropriate key names), libL10n.ssh, and
libL10n.sftp so titles come from localization resources rather than hard-coded
text.
- Around line 128-139: The four TextEditingController instances
(_sshOpacityCtrl, _sshBlurCtrl, _textScalerCtrl, _serverLogoCtrl) created on the
state should be disposed to avoid leaks: add an override of dispose() in
_AppSettingsPageState that calls dispose() on each controller and then calls
super.dispose(); locate the state class _AppSettingsPageState and ensure the
dispose method disposes _sshOpacityCtrl, _sshBlurCtrl, _textScalerCtrl, and
_serverLogoCtrl in that order (or any order) before calling super.dispose().

---

Outside diff comments:
In `@lib/main.dart`:
- Around line 57-63: Wrap the entire HiveToSqliteMigrator.migrateIfNeeded()
implementation (or at minimum the calls to Hive.initFlutter(),
Hive.registerAdapters(), the store flush loops in _migrateOne()/Store.flush(),
and _archiveLegacyFiles()) in a top-level try-catch so any exception during
initialization or persistence is caught; inside the catch log the error and
abort the migration gracefully without setting the migrated flag, and ensure
successful completion still sets the migrated flag and performs cleanup—update
migrateIfNeeded(), _migrateOne(), and any code that calls _archiveLegacyFiles()
to use this guarded flow so startup cannot crash from unprotected
init/flush/archive steps.

---

Nitpick comments:
In `@lib/data/migration/hive_to_sqlite_migrator.dart`:
- Around line 281-301: The _normalizeRaw function currently swallows unknown
object types by returning null inside the catch block; update the catch to log a
warning with the runtime type and/or a brief description before returning null
so unexpected types are visible during migration; specifically modify
_normalizeRaw's try-catch around the dynamic obj.toJson() call to use your
logger (e.g., processLogger, migrationLogger, or existing logger in scope) to
emit a warning including raw.runtimeType (and the caught error message if
available), then return null as before.

In `@lib/data/model/app/bak/backup.dart`:
- Around line 115-119: The helper method _withTs does not use instance state;
make it static by adding the static keyword to its declaration (change
"Map<String, Object?> _withTs(...)" to "static Map<String, Object?>
_withTs(...)") in the class that contains it; update any internal call sites if
they're using instance access to call it (prefer ClassName._withTs(...) or, if
called from within the same class, call _withTs(...) which will resolve to the
static method).

In `@lib/data/store/connection_stats.dart`:
- Around line 66-80: sortedStats is already ordered newest-first, so avoid
creating and sorting successTimes/failureTimes; in _buildServerStats iterate
sortedStats once and set lastSuccessTime to the first s.timestamp where
s.result.isSuccess and lastFailureTime to the first s.timestamp where
!s.result.isSuccess (stop early once both are found), then remove the
successTimes/failureTimes lists and the extra sorts to reduce work.

In `@lib/data/store/private_key.dart`:
- Around line 17-34: The deserialization logic in the inline fromObj inside
fetch() and the _fromObj helper is duplicated; extract the shared parsing steps
into a single private parser function (e.g., _parsePrivateKeyInfo(Object? val)
that performs the Map check, uses PrivateKeyInfo.fromJson and returns a
PrivateKeyInfo? ) then have both fetch()'s fromObj and _fromObj call that
parser; in fetch(), after calling the parser, call put(pki) when the parser
returns a non-null PrivateKeyInfo to preserve the existing re-persist/migration
behavior.

In `@lib/data/store/server.dart`:
- Around line 20-37: The inline fromObj lambda inside fetch() duplicates the
parsing logic in _fromObj; refactor fetch() to call the existing _fromObj(Map)
or a shared helper that encapsulates Spi.fromJson and dprint, and then have
fetch() call put(spi) only when the parsed spi is non-null to re-persist;
specifically, replace the anonymous fromObj in the get<Spi>(...) call with a
reference to the shared _fromObj (or extract a private helper used by both
fetch() and _fromObj) and ensure fetch() still invokes put(spi) after obtaining
a non-null Spi, keeping Spi.fromJson, put, and dprint usage unchanged.

In `@lib/data/store/snippet.dart`:
- Around line 17-34: The fromObj closure passed to get<Snippet> duplicates
parsing logic used by fetch() and fetchOne(); extract the shared deserialization
into a single helper (e.g., a private function like _parseSnippetFromObj(dynamic
val) or a static Snippet.tryFrom(dynamic obj)) and call that helper from the
fromObj closure and from fetch/fetchOne so behavior stays consistent; ensure the
helper handles Map<dynamic,dynamic> -> toStrDynMap conversion, catches
exceptions and logs via dprint, returns null on failure, and still calls
put(snippet) when a Snippet is successfully constructed.

In `@lib/view/page/server/connection_stats.dart`:
- Around line 276-280: Extract the duplicated bytes→string logic into a single
helper (e.g. String formatSize(int bytes)) in connection_stats.dart and replace
both sizeStr assignments (the one using oldSize and the one using newSize) to
call that helper; implement formatSize to return "X B" for bytes<1000, "Y.Z KB"
for bytes<1000*1000, else "Y.Z MB" using toStringAsFixed(1), and update any
references to sizeStr/new size formatting accordingly (keep the same thresholds
and formatting behavior).

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between bc69686 and 82684cf.

⛔ Files ignored due to path filters (1)
  • pubspec.lock is excluded by !**/*.lock
📒 Files selected for processing (26)
  • lib/core/utils/server.dart
  • lib/data/migration/hive_to_sqlite_migrator.dart
  • lib/data/model/app/bak/backup.dart
  • lib/data/res/store.dart
  • lib/data/store/connection_stats.dart
  • lib/data/store/container.dart
  • lib/data/store/history.dart
  • lib/data/store/private_key.dart
  • lib/data/store/server.dart
  • lib/data/store/setting.dart
  • lib/data/store/snippet.dart
  • lib/main.dart
  • lib/view/page/private_key/list.dart
  • lib/view/page/server/connection_stats.dart
  • lib/view/page/server/edit/actions.dart
  • lib/view/page/setting/entries/ai.dart
  • lib/view/page/setting/entries/app.dart
  • lib/view/page/setting/entries/editor.dart
  • lib/view/page/setting/entry.dart
  • linux/flutter/generated_plugin_registrant.cc
  • linux/flutter/generated_plugins.cmake
  • macos/Flutter/GeneratedPluginRegistrant.swift
  • packages/fl_lib
  • pubspec.yaml
  • windows/flutter/generated_plugin_registrant.cc
  • windows/flutter/generated_plugins.cmake

Comment on lines +11 to 12
class SettingStore extends SqliteStore {
SettingStore._() : super('setting');
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Store backend change conflicts with current repository policy.

This moves the settings store to SQLite, but the current store-layer guideline still requires Hive persistence. Please either align the implementation with policy or update the policy/ADR in this PR so they are not contradictory.

As per coding guidelines lib/data/store/**/*.dart: Persist state using Hive stores placed under lib/data/store/.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/data/store/setting.dart` around lines 11 - 12, The SettingStore currently
extends SqliteStore (class SettingStore and constructor SettingStore._()) which
violates the repo policy requiring Hive-backed stores under lib/data/store;
either change SettingStore to use the existing Hive store base (e.g., extend the
HiveStore/appropriate Hive-backed base and implement Hive box access) so
persistence uses Hive, or, if you intend to switch to SQLite, update the
ADR/policy in this PR to explicitly allow SqliteStore for settings and adjust
any store-layer docs; ensure the constructor and any storage-specific methods
are updated to reference the Hive API (or document the ADR change) to resolve
the policy conflict.

Comment on lines +145 to +157
[
const CenterGreyTitle('App'),
_buildApp(),
const CenterGreyTitle('AI'),
_buildAskAiConfig(),
],
[CenterGreyTitle(libL10n.server), _buildServer()],
[const CenterGreyTitle('SSH'), _buildSSH(), const CenterGreyTitle('SFTP'), _buildSFTP()],
[CenterGreyTitle(libL10n.container), _buildContainer(), CenterGreyTitle(libL10n.editor), _buildEditor()],
[
const CenterGreyTitle('SSH'),
_buildSSH(),
const CenterGreyTitle('SFTP'),
_buildSFTP(),
],
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Localize section titles instead of hard-coded strings.

'App', 'AI', 'SSH', and 'SFTP' should come from localization resources for consistency and translation coverage.

As per coding guidelines, "Prefer using libL10n strings before adding new ones to project l10n".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/view/page/setting/entry.dart` around lines 145 - 157, Replace the
hard-coded section titles with localized strings from libL10n: instead of
literal 'App', 'AI', 'SSH', and 'SFTP' pass the corresponding libL10n properties
to CenterGreyTitle where the children list uses CenterGreyTitle and the builders
(_buildApp, _buildAskAiConfig, _buildSSH, _buildSFTP) are placed; follow the
existing pattern used for libL10n.server to add/consume libL10n.app, libL10n.ai
(or appropriate key names), libL10n.ssh, and libL10n.sftp so titles come from
localization resources rather than hard-coded text.

@lollipopkit lollipopkit changed the title fix(migration): harden hive to sqlite migration migration(db): hive to sqlite migration Feb 28, 2026
@cloudflare-workers-and-pages
Copy link

cloudflare-workers-and-pages bot commented Feb 28, 2026

Deploying serverbox with  Cloudflare Pages  Cloudflare Pages

Latest commit: f36874b
Status: ✅  Deploy successful!
Preview URL: https://f91c3278.serverbox.pages.dev
Branch Preview URL: https://fix-sqlite-migration-safety.serverbox.pages.dev

View logs

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
lib/view/page/setting/entry.dart (1)

162-165: ⚠️ Potential issue | 🟡 Minor

Localize SSH/SFTP section titles too.

'SSH' and 'SFTP' are still hard-coded; please switch them to localization keys for full translation coverage consistency.

As per coding guidelines, "Prefer using libL10n strings before adding new ones to project l10n".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/view/page/setting/entry.dart` around lines 162 - 165, The section titles
are hard-coded; replace the literal 'SSH' and 'SFTP' passed to CenterGreyTitle
with the corresponding localized strings from your libL10n (e.g., use
AppLocalizations/GeneratedLocalizations getters) where these widgets are built
alongside _buildSSH() and _buildSFTP(); if the ssh/sftp keys are not yet defined
in the ARB/localization files, add them to libL10n and regenerate localization
so CenterGreyTitle receives the localized string via the localization accessor
instead of a hard-coded literal.
🧹 Nitpick comments (4)
lib/data/store/snippet.dart (1)

44-56: Redundant cast after toStrDynMap.

The toStrDynMap extension already returns Map<String, dynamic>?, so the cast at line 50 is unnecessary.

🔧 Suggested simplification
   static Snippet? _fromObj(Object? val) {
     if (val is Snippet) return val;
     if (val is Map<dynamic, dynamic>) {
       final map = val.toStrDynMap;
       if (map == null) return null;
       try {
-        return Snippet.fromJson(map as Map<String, dynamic>);
+        return Snippet.fromJson(map);
       } catch (e) {
         dprint('Parsing Snippet from JSON', e);
       }
     }
     return null;
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/data/store/snippet.dart` around lines 44 - 56, In _fromObj, remove the
redundant cast: when val is Map<dynamic,dynamic> use the toStrDynMap extension
(which already returns Map<String,dynamic>?) directly (assign to a variable like
map) and pass that map into Snippet.fromJson without the "as Map<String,
dynamic>" cast; keep the existing null check and catch block (dprint('Parsing
Snippet from JSON', e)) unchanged so behavior stays the same.
lib/data/migration/hive_to_sqlite_migrator.dart (1)

131-133: Clarify the null check for readability.

The condition normalized is! Object is functionally equivalent to normalized == null in Dart (since all non-null values are Objects), but it's not immediately obvious. Consider using explicit null check for clarity.

🔧 Suggested simplification
         final normalized = normalize(box.get(rawKey));
-        if (normalized is! Object) continue;
+        if (normalized == null) continue;
         target.set(rawKey, normalized);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/data/migration/hive_to_sqlite_migrator.dart` around lines 131 - 133, The
null-check is unclear: replace the type-test `normalized is! Object` with an
explicit null check to improve readability; in the block around
normalize(box.get(rawKey)), change the condition to `if (normalized == null)
continue;` and leave the subsequent `target.set(rawKey, normalized)` as-is so
only non-null normalized values are written (refer to the normalize function and
variables normalized, box.get, and target.set to locate the change).
lib/data/store/server.dart (1)

116-128: Redundant cast after toStrDynMap.

Same issue as in SnippetStore._fromObj: the cast at line 122 is unnecessary since toStrDynMap already returns Map<String, dynamic>?.

🔧 Suggested simplification
   static Spi? _fromObj(Object? val) {
     if (val is Spi) return val;
     if (val is Map<dynamic, dynamic>) {
       final map = val.toStrDynMap;
       if (map == null) return null;
       try {
-        return Spi.fromJson(map as Map<String, dynamic>);
+        return Spi.fromJson(map);
       } catch (e) {
         dprint('Parsing Spi from JSON', e);
       }
     }
     return null;
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/data/store/server.dart` around lines 116 - 128, The cast is redundant: in
Spi._fromObj remove the unnecessary "as Map<String, dynamic>" when passing map
to Spi.fromJson since toStrDynMap already returns Map<String, dynamic>?; update
the _fromObj function to use the map variable directly (e.g., Spi.fromJson(map))
and keep the existing try/catch and dprint handling unchanged.
lib/data/store/connection_stats.dart (1)

191-207: Redundant cast after toStrDynMap.

Same pattern as other stores: the cast at line 200 is unnecessary since toStrDynMap already returns Map<String, dynamic>?.

🔧 Suggested simplification
   ConnectionStat? _readStat(String key) {
     return get<ConnectionStat>(
       key,
       fromObj: (val) {
         if (val is ConnectionStat) return val;
         if (val is Map<dynamic, dynamic>) {
           final map = val.toStrDynMap;
           if (map == null) return null;
           try {
-            return ConnectionStat.fromJson(map as Map<String, dynamic>);
+            return ConnectionStat.fromJson(map);
           } catch (e) {
             dprint('Parsing ConnectionStat from JSON', e);
           }
         }
         return null;
       },
     );
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/data/store/connection_stats.dart` around lines 191 - 207, In _readStat,
remove the unnecessary cast of map to Map<String, dynamic> when calling
ConnectionStat.fromJson: toStrDynMap already returns Map<String, dynamic>?, so
use the map variable directly in ConnectionStat.fromJson(map) and adjust null
checks accordingly (keep the try/catch and dprint behavior intact); this change
affects the anonymous fromObj closure inside get<ConnectionStat> and ensures no
redundant casting before calling ConnectionStat.fromJson.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@lib/view/page/setting/entry.dart`:
- Around line 162-165: The section titles are hard-coded; replace the literal
'SSH' and 'SFTP' passed to CenterGreyTitle with the corresponding localized
strings from your libL10n (e.g., use AppLocalizations/GeneratedLocalizations
getters) where these widgets are built alongside _buildSSH() and _buildSFTP();
if the ssh/sftp keys are not yet defined in the ARB/localization files, add them
to libL10n and regenerate localization so CenterGreyTitle receives the localized
string via the localization accessor instead of a hard-coded literal.

---

Nitpick comments:
In `@lib/data/migration/hive_to_sqlite_migrator.dart`:
- Around line 131-133: The null-check is unclear: replace the type-test
`normalized is! Object` with an explicit null check to improve readability; in
the block around normalize(box.get(rawKey)), change the condition to `if
(normalized == null) continue;` and leave the subsequent `target.set(rawKey,
normalized)` as-is so only non-null normalized values are written (refer to the
normalize function and variables normalized, box.get, and target.set to locate
the change).

In `@lib/data/store/connection_stats.dart`:
- Around line 191-207: In _readStat, remove the unnecessary cast of map to
Map<String, dynamic> when calling ConnectionStat.fromJson: toStrDynMap already
returns Map<String, dynamic>?, so use the map variable directly in
ConnectionStat.fromJson(map) and adjust null checks accordingly (keep the
try/catch and dprint behavior intact); this change affects the anonymous fromObj
closure inside get<ConnectionStat> and ensures no redundant casting before
calling ConnectionStat.fromJson.

In `@lib/data/store/server.dart`:
- Around line 116-128: The cast is redundant: in Spi._fromObj remove the
unnecessary "as Map<String, dynamic>" when passing map to Spi.fromJson since
toStrDynMap already returns Map<String, dynamic>?; update the _fromObj function
to use the map variable directly (e.g., Spi.fromJson(map)) and keep the existing
try/catch and dprint handling unchanged.

In `@lib/data/store/snippet.dart`:
- Around line 44-56: In _fromObj, remove the redundant cast: when val is
Map<dynamic,dynamic> use the toStrDynMap extension (which already returns
Map<String,dynamic>?) directly (assign to a variable like map) and pass that map
into Snippet.fromJson without the "as Map<String, dynamic>" cast; keep the
existing null check and catch block (dprint('Parsing Snippet from JSON', e))
unchanged so behavior stays the same.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 82684cf and f36874b.

📒 Files selected for processing (11)
  • docs/src/content/docs/development/structure.md
  • docs/src/content/docs/zh/development/structure.md
  • lib/data/migration/hive_to_sqlite_migrator.dart
  • lib/data/model/app/bak/backup.dart
  • lib/data/store/connection_stats.dart
  • lib/data/store/private_key.dart
  • lib/data/store/server.dart
  • lib/data/store/snippet.dart
  • lib/view/page/private_key/list.dart
  • lib/view/page/server/connection_stats.dart
  • lib/view/page/setting/entry.dart
🚧 Files skipped from review as they are similar to previous changes (4)
  • lib/data/model/app/bak/backup.dart
  • lib/data/store/private_key.dart
  • lib/view/page/private_key/list.dart
  • lib/view/page/server/connection_stats.dart

devin-ai-integration[bot]

This comment was marked as resolved.

Copy link

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

View 14 additional findings in Devin Review.

Open in Devin Review


void _showCompactDialog() {
final path = '${Paths.doc}${Pfs.seperator}connection_stats_enc.hive';
final path = '${Paths.doc}${Pfs.seperator}connection_stats.db';

Choose a reason for hiding this comment

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

🟡 Compact dialog uses hardcoded .db file path that may not match SqliteStore's actual database file location

The compact dialog constructs a file path as connection_stats.db under Paths.doc, but the actual SQLite/SQLCipher database file created by SqliteStore may use a different naming convention, extension, or subdirectory. This would cause the dialog to always show 0 B for the database size (both before and after compaction), since File(path).existsSync() would return false.

Detailed Explanation

The old code referenced connection_stats_enc.hive, which matched the Hive naming convention. The new code changed it to connection_stats.db (lib/view/page/server/connection_stats.dart:273), but the actual file path depends entirely on how SqliteStore (from fl_lib) creates and names its database file.

For example, SqliteStore (likely built on drift + SQLCipher) might store files as:

  • connection_stats.sqlite
  • connection_stats.enc.db
  • Inside a subdirectory like databases/connection_stats.db

The vacuum operation at lib/view/page/server/connection_stats.dart:289 works correctly regardless (it calls Stores.connectionStats.compact() which internally knows the right file), but the UI display of before/after file sizes will be incorrect.

Impact: The compact dialog will show incorrect file sizes (likely 0 B), providing misleading information to the user.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

View 17 additional findings in Devin Review.

Open in Devin Review

Comment on lines 19 to 26
for (final id in keys()) {
final s = get<Spi>(
id,
fromObj: (val) {
if (val is Spi) return val;
if (val is Map<dynamic, dynamic>) {
final map = val.toStrDynMap;
if (map == null) return null;
try {
final spi = Spi.fromJson(map as Map<String, dynamic>);
put(spi);
return spi;
} catch (e) {
dprint('Parsing Spi from JSON', e);
}
}
return null;
},
);
final s = get<Spi>(id, fromObj: _fromObj);
if (s != null) {
put(s);
ss.add(s);
}
}
return ss;

Choose a reason for hiding this comment

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

🟡 ServerStore.fetch() creates unbounded duplicate entries for servers with empty IDs

In ServerStore.fetch(), every call unconditionally calls put(s) after deserializing each server. Because SQLite stores JSON (not typed Hive objects), _fromObj always goes through Spi.fromJson, which triggers Spi.parseId (server_private_info.dart:60-63). For any server with an empty id in the stored JSON, parseId generates a new random ShortId. Then put(s) writes this server under the new ID as key — while the original key (with empty id JSON) is never deleted.

Root cause and growth mechanics

In the old Hive code, fromObj checked if (val is Spi) return val; — the Hive adapter returned a typed Spi object directly, preserving the empty id without calling put. Only when the value was a raw Map did it call Spi.fromJson and put. With SQLite, stored values are always JSON Maps, so _fromObj always calls Spi.fromJson, which always triggers parseId.

The migrateIds() method (called every launch at main.dart:100) calls fetch(), which generates new IDs for legacy entries. But since those SPIs now have non-empty IDs, migrateId() returns null and the old key is never cleaned up:

String? migrateId() {
  if (id.isNotEmpty) return null; // Always true after fromJson
  ...
}

Each subsequent app launch adds +1 duplicate per affected server:

  • Launch 1: old key + new key A
  • Launch 2: old key + key A + new key B
  • Launch N: old key + N extra entries

Impact: Unbounded store growth on every app launch. Affected users: those upgrading from very old versions (before server IDs were introduced) who never ran an intermediate version that migrated IDs in Hive.

Prompt for agents
In lib/data/store/server.dart, the fetch() method (lines 17-27) unconditionally calls put(s) for every deserialized Spi. This creates duplicate entries when Spi.parseId generates a new ShortId for servers with empty IDs stored in SQLite JSON.

Fix: Only call put(s) when the deserialized Spi's id matches the store key. If they differ (i.e., parseId generated a new id for an empty one), handle migration explicitly by deleting the old key before inserting the new one:

  List<Spi> fetch() {
    final List<Spi> ss = [];
    for (final id in keys()) {
      final s = get<Spi>(id, fromObj: _fromObj);
      if (s != null) {
        if (s.id != id) {
          remove(id);
        }
        put(s);
        ss.add(s);
      }
    }
    return ss;
  }

Note: Since keys() is iterated while the store is mutated (removing old keys), ensure keys() returns a snapshot (e.g., .toList()) before iterating. Apply the same pattern to SnippetStore.fetch() and PrivateKeyStore.fetch() for consistency, though those stores likely don't auto-generate IDs.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
lib/data/store/private_key.dart (2)

38-40: Consider removing the redundant _fromObj wrapper.

_fromObj just delegates to _parsePrivateKeyInfo with no additional logic. You could use _parsePrivateKeyInfo directly in fetchOne.

Proposed simplification
   PrivateKeyInfo? fetchOne(String? id) {
     if (id == null) return null;
-    return get<PrivateKeyInfo>(id, fromObj: _fromObj);
+    return get<PrivateKeyInfo>(id, fromObj: _parsePrivateKeyInfo);
   }
 
   void delete(PrivateKeyInfo s) {
     remove(s.id);
   }
-
-  static PrivateKeyInfo? _fromObj(Object? val) {
-    return _parsePrivateKeyInfo(val);
-  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/data/store/private_key.dart` around lines 38 - 40, The helper _fromObj is
redundant because it simply forwards to _parsePrivateKeyInfo; remove _fromObj
and update call sites (notably fetchOne) to call _parsePrivateKeyInfo directly,
ensuring any nullable handling and imports remain the same and running tests to
confirm no other references to _fromObj exist.

19-22: Unconditional put() may cause unnecessary writes.

After the ID mismatch check and removal, put(s) is called unconditionally on line 22. When s.id == key, this re-writes existing data without changes. If fetch() is called frequently or with many keys, this could impact performance.

If this is intentional for data format normalization during migration, consider adding a brief comment to clarify.

Proposed optimization
       final s = get<PrivateKeyInfo>(key, fromObj: _parsePrivateKeyInfo);
       if (s != null) {
         if (s.id != key) {
           remove(key);
+          put(s); // Re-insert with correct key
         }
-        put(s);
         ps.add(s);
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/data/store/private_key.dart` around lines 19 - 22, The unconditional
put(s) causes unnecessary rewrites — change the logic so put(s) is only invoked
when needed (e.g., when s.id != key to perform the migration/rename) or when the
record actually changed; specifically, modify the block using s, key, remove()
and put() so that put(s) is conditional (for example: if (s.id != key) {
remove(key); put(s); } ), or add a comment explaining that the unconditional
write is intentional for format migration if you keep it.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@lib/data/store/private_key.dart`:
- Around line 38-40: The helper _fromObj is redundant because it simply forwards
to _parsePrivateKeyInfo; remove _fromObj and update call sites (notably
fetchOne) to call _parsePrivateKeyInfo directly, ensuring any nullable handling
and imports remain the same and running tests to confirm no other references to
_fromObj exist.
- Around line 19-22: The unconditional put(s) causes unnecessary rewrites —
change the logic so put(s) is only invoked when needed (e.g., when s.id != key
to perform the migration/rename) or when the record actually changed;
specifically, modify the block using s, key, remove() and put() so that put(s)
is conditional (for example: if (s.id != key) { remove(key); put(s); } ), or add
a comment explaining that the unconditional write is intentional for format
migration if you keep it.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b469c6d and 10d237d.

📒 Files selected for processing (3)
  • lib/data/store/private_key.dart
  • lib/data/store/server.dart
  • lib/data/store/snippet.dart

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
lib/data/store/private_key.dart (1)

19-22: Potential silent data loss on ID collision during cleanup.

If two entries are stored with different keys but have the same s.id, the second processed entry will overwrite the first. This may be intentional for deduplication, but silent data loss could be surprising. Consider logging when an overwrite occurs or verifying uniqueness before putting.

🔧 Optional: Add logging for visibility
        if (s.id != key) {
+         dprint('Migrating key mismatch: stored at "$key" but id is "${s.id}"');
          remove(key);
          put(s);
        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/data/store/private_key.dart` around lines 19 - 22, The current cleanup
branch in private_key.dart (the if block that checks s.id != key and then calls
remove(key) and put(s)) can silently overwrite an existing entry that shares
s.id; before calling put(s) check whether an entry already exists for s.id (or
for the key you intend to write) and if so either skip the overwrite or surface
it via logging; specifically, locate the block referencing s.id, key,
remove(...) and put(...), query the store for an existing entry with that id,
and if one exists and the stored key differs, emit a warning (or merge/skip)
instead of blindly remove(key)/put(s) so you avoid silent data loss.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@lib/data/store/private_key.dart`:
- Around line 19-22: The current cleanup branch in private_key.dart (the if
block that checks s.id != key and then calls remove(key) and put(s)) can
silently overwrite an existing entry that shares s.id; before calling put(s)
check whether an entry already exists for s.id (or for the key you intend to
write) and if so either skip the overwrite or surface it via logging;
specifically, locate the block referencing s.id, key, remove(...) and put(...),
query the store for an existing entry with that id, and if one exists and the
stored key differs, emit a warning (or merge/skip) instead of blindly
remove(key)/put(s) so you avoid silent data loss.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 10d237d and 0129fec.

📒 Files selected for processing (1)
  • lib/data/store/private_key.dart

devin-ai-integration[bot]

This comment was marked as resolved.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
lib/data/migration/hive_to_sqlite_migrator.dart (1)

161-181: Consider whether strict encrypted-only enforcement is necessary for this migration scenario.

At lines 161–181, the code attempts decrypted open first, then falls back to plain only if that fails. While encrypted should be preferred (more recent), the fallback is a reasonable recovery pattern for a one-time migration tool where an encrypted box file might be corrupted but a plain variant exists as backup.

The concern that both valid variants could coexist is unlikely here: file existence checks (lines 120–121) and the lack of any code maintaining dual variants suggest boxes exist in either encrypted or plain state, not both. The fallback only triggers if the encrypted open fails (exception), not if both are readable—in which case you already have an error condition (corrupted or mixed storage).

For production resilience, the current behavior prioritizes completing the migration. If you prefer stricter enforcement that rejects the migration when encrypted-box decryption fails (even if a plain backup exists), consider whether that trade-off is worth potential migration failures in recovery scenarios.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/data/migration/hive_to_sqlite_migrator.dart` around lines 161 - 181, The
current logic tries encrypted first then silently falls back to plain (using
hasEnc/hasPlain, Hive.openBox, openErrors and Loggers.app.warning), but we
should make this behavior configurable so callers can choose strict
encrypted-only migration; add a boolean option (e.g. enforceEncrypted or
migrationOptions.enforceEncrypted) to the migrator and update the open sequence:
if hasEnc is true attempt Hive.openBox('${boxName}_enc', ... ) as now, and if it
fails and enforceEncrypted is true propagate/throw the error (or return a
failing result) instead of falling back; if enforceEncrypted is false, keep the
existing fallback to plain (Hive.openBox(boxName,...)) and still record
openErrors and Loggers.app.warning; update any calling sites to pass the new
option or default it to false.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@pubspec.yaml`:
- Around line 33-34: Update dependencies in pubspec.yaml: bump drift to ^2.31.0
and replace the EOL sqlcipher_flutter_libs entry with the maintained sqlite3
family (use a compatible sqlite3 package/version, e.g., sqlite3 v3.x or the
appropriate sqlite3_flutter_libs/ffi wrapper your app uses). Ensure you update
any imports or initialization that referenced sqlcipher_flutter_libs to the new
sqlite3 package and run pub get and your test suite to confirm compatibility
(look for usages of the drift dependency and any initialization code that relied
on sqlcipher_flutter_libs).

---

Nitpick comments:
In `@lib/data/migration/hive_to_sqlite_migrator.dart`:
- Around line 161-181: The current logic tries encrypted first then silently
falls back to plain (using hasEnc/hasPlain, Hive.openBox, openErrors and
Loggers.app.warning), but we should make this behavior configurable so callers
can choose strict encrypted-only migration; add a boolean option (e.g.
enforceEncrypted or migrationOptions.enforceEncrypted) to the migrator and
update the open sequence: if hasEnc is true attempt
Hive.openBox('${boxName}_enc', ... ) as now, and if it fails and
enforceEncrypted is true propagate/throw the error (or return a failing result)
instead of falling back; if enforceEncrypted is false, keep the existing
fallback to plain (Hive.openBox(boxName,...)) and still record openErrors and
Loggers.app.warning; update any calling sites to pass the new option or default
it to false.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 813e487 and 3d7e16b.

⛔ Files ignored due to path filters (1)
  • pubspec.lock is excluded by !**/*.lock
📒 Files selected for processing (2)
  • lib/data/migration/hive_to_sqlite_migrator.dart
  • pubspec.yaml

Comment on lines +33 to +34
drift: ^2.29.0
sqlcipher_flutter_libs: ^0.6.8
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash

# Check drift package information from pub.dev API
echo "=== Drift Package Info ==="
curl -s https://pub.dev/api/packages/drift | jq -r '.latest | "\(.version) (published: \(.published))"' 2>/dev/null || echo "API call failed"

# Check sqlcipher_flutter_libs package information from pub.dev API
echo "=== SQLCipher Flutter Libs Package Info ==="
curl -s https://pub.dev/api/packages/sqlcipher_flutter_libs | jq -r '.latest | "\(.version) (published: \(.published))"' 2>/dev/null || echo "API call failed"

# Try to get all versions to see if specified versions exist
echo "=== Drift Available Versions ==="
curl -s https://pub.dev/api/packages/drift | jq -r '.versions[] | select(.version | test("2\\.29")) | .version' 2>/dev/null || echo "Could not fetch versions"

echo "=== SQLCipher Flutter Libs Available Versions ==="
curl -s https://pub.dev/api/packages/sqlcipher_flutter_libs | jq -r '.versions[] | select(.version | test("0\\.6")) | .version' 2>/dev/null || echo "Could not fetch versions"

Repository: lollipopkit/flutter_server_box

Length of output: 387


🌐 Web query:

drift package pub.dev version 2.29.0 security advisories

💡 Result:

No security advisories specific to the Dart/Flutter drift package (including version 2.29.0) appear to be published:

  • The upstream GitHub repository simolus3/drift shows “There aren’t any published security advisories.” [1]
  • Pub’s guidance for package vulnerabilities is to use/search GitHub Security Advisories for the package/repo (which, for drift, currently yields none). [2]

Related note (ecosystem/tooling, not drift itself): there is a recent CVE affecting dart pub / flutter pub package extraction in certain SDK versions, but it’s a Pub client issue rather than a drift package advisory. [3]

Sources: [1] [2] [3]


🌐 Web query:

sqlcipher_flutter_libs package pub.dev version 0.6.8 security advisories

💡 Result:

  • No published security advisories found specifically for sqlcipher_flutter_libs v0.6.8 in the upstream GitHub repo that hosts the package (simolus3/sqlite3.dart)—GitHub shows “There aren’t any published security advisories.” [1]
  • What changed in v0.6.8: the package updated SQLCipher to 4.10.0. [2]
  • Security-relevant note for SQLCipher 4.10.0: Zetetic states the release includes “enhancements and security updates” and that the bundled SQLite 3.50.4 contains several security fixes (they advise upgrading if your app allows untrusted schema modification). [3]
  • Status note: the package is now marked obsolete/EOL on pub.dev (newer versions “no longer do anything” and users are told to migrate to sqlite3 v3.x). [4]

Sources:
[1] GitHub security overview for simolus3/sqlite3.dart (github.com)
[2] sqlcipher_flutter_libs changelog (0.6.8) (pub.dev)
[3] Zetetic “SQLCipher 4.10.0 Release” (zetetic.net)
[4] pub.dev package page (EOL/obsolete notice) (pub.dev)

Citations:


Consider upgrading drift to v2.31.0 and sqlcipher_flutter_libs to a maintained alternative.

Both package versions are valid and free from published security advisories. However, sqlcipher_flutter_libs is marked as end-of-life (EOL) on pub.dev, with users directed to migrate to sqlite3 v3.x for continued support. Additionally, drift has a newer stable version (2.31.0, released Feb 3, 2026) available; consider upgrading to the latest to ensure compatibility with current Flutter/Dart updates.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pubspec.yaml` around lines 33 - 34, Update dependencies in pubspec.yaml: bump
drift to ^2.31.0 and replace the EOL sqlcipher_flutter_libs entry with the
maintained sqlite3 family (use a compatible sqlite3 package/version, e.g.,
sqlite3 v3.x or the appropriate sqlite3_flutter_libs/ffi wrapper your app uses).
Ensure you update any imports or initialization that referenced
sqlcipher_flutter_libs to the new sqlite3 package and run pub get and your test
suite to confirm compatibility (look for usages of the drift dependency and any
initialization code that relied on sqlcipher_flutter_libs).

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

proposal: hive -> sqlite migration

1 participant