Skip to content

fix(db): add 8->9 migration; stop destructive fallback that wipes configs - #68

Merged
hawkff merged 2 commits into
mainfrom
fix/025-db-destructive-migration
Jun 26, 2026
Merged

hawkff merged 2 commits into
mainfrom
fix/025-db-destructive-migration

Conversation

@hawkff

@hawkff hawkff commented Jun 26, 2026 •

Copy link
Copy Markdown
Owner

Summary

SagerDatabase was declared version = 9 with auto-migrations only up to 7->8 and a blanket fallbackToDestructiveMigration(). On upgrade from a v8 install, Room could not find an 8->9 path and silently dropped/recreated the database, wiping all saved proxy configs, groups, and rules.

The v8->v9 delta is purely additive (two new nullable BLOB columns masterDnsVpnBean, awgBean on proxy_entities), so it is auto-migratable.

Changes

  • Add AutoMigration(from = 8, to = 9) so 3->9 is fully covered.
  • Remove the blanket fallbackToDestructiveMigration() from SagerDatabase and PublicDatabase so an unmatched version change can never silently wipe data.
  • Add an instrumented MigrationTestHelper test proving a v8 row survives 8->9 (DB migrated, not recreated).
  • Wire androidTest deps + testInstrumentationRunner.
  • Add .depot/workflows/android-instrumented.yml to run the instrumented test on a KVM-accelerated emulator (Depot CI). The APK build stays on Namespace.

Testing

  • Verified on Android: instrumented migration test runs green on an API 33 x86_64 emulator (Depot CI). Confirms the v8 row survives the 8->9 migration.
  • grep fallbackToDestructiveMigration in the database package returns nothing.

Notes

  • The :app androidTest variant compiles the whole app module (references libcore), so the Depot job builds libcore.aar from source (cached). The migration test itself uses no JNI.

Greptile Summary

This PR fixes a silent data-loss bug where SagerDatabase was declared at version 9 but had no 8->9 auto-migration, causing Room to invoke fallbackToDestructiveMigration() and wipe all proxy configs on upgrade. The fix adds the missing AutoMigration(from = 8, to = 9) and removes the blanket destructive fallback from both SagerDatabase and PublicDatabase.

  • Migration fix: AutoMigration(from = 8, to = 9) is added; the v8→v9 delta is purely additive (two nullable BLOB columns), making it safe for auto-migration.
  • Safety net removed: fallbackToDestructiveMigration() is dropped from both databases. PublicDatabase is at v1 with no prior versions so this is immediately safe; SagerDatabase now has a complete auto-migration chain from v3 to v9.
  • Instrumented test + CI: A MigrationTestHelper test proves an existing v8 row survives the migration, backed by a Depot CI workflow running it on a KVM-accelerated emulator.

Confidence Score: 4/5

The 8→9 migration fix is correct and well-tested; the only unhandled edge case is users on very old v1/v2 databases who will now see a crash instead of a silent wipe, as noted in the prior review.

The core change — adding AutoMigration(from=8,to=9) and removing fallbackToDestructiveMigration — is sound: the schema delta is purely additive (two nullable BLOBs), the instrumented test validates row preservation end-to-end, and PublicDatabase at v1 has no prior versions so removing destructive fallback there is safe. The remaining gap is that the auto-migration chain starts at v3 while schema files for v1 and v2 exist in the repo, meaning users on those ancient installs will crash on launch rather than wipe — a trade-off acknowledged in the prior review thread but not yet resolved.

app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt — the migration chain starting at v3 leaves v1/v2 databases with no upgrade path.

Important Files Changed

Filename Overview
app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt Core fix: adds AutoMigration(from=8, to=9) and removes fallbackToDestructiveMigration(); migration chain covers v3–v9 but v1/v2 databases (schema files exist) have no path and will crash rather than silently wipe (already noted in prior review).
app/src/main/java/io/nekohasekai/sagernet/database/preference/PublicDatabase.kt Removes fallbackToDestructiveMigration(); database has always been at version 1 with no prior schema versions, so there is no migration gap and the removal is safe.
app/src/androidTest/java/io/nekohasekai/sagernet/database/SagerDatabaseMigrationTest.kt Well-structured MigrationTestHelper test: creates a v8 database with all NOT NULL columns satisfied, runs runMigrationsAndValidate with validateDroppedTables=true, and asserts both row survival and new nullable columns exist.
.depot/workflows/android-instrumented.yml New Depot CI workflow for running the instrumented migration test on a KVM-accelerated emulator; the find-based libcore cache key computation was flagged in a prior review thread.
app/build.gradle.kts Adds the schemas directory as an androidTest asset source set and wires room-testing, junit, and runner dependencies under androidTestImplementation only.
buildSrc/src/main/kotlin/Helpers.kt Adds testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" to defaultConfig; standard runner, no production impact.
depot.json New file containing the Depot project ID; no logic changes.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    V1[DB version 1] -->|no migration path| CRASH[IllegalStateException on launch]
    V2[DB version 2] -->|no migration path| CRASH
    V3[DB version 3] --> M34[AutoMigration 3→4]
    M34 --> M45[AutoMigration 4→5]
    M45 --> M56[AutoMigration 5→6]
    M56 --> M67[AutoMigration 6→7]
    M67 --> M78[AutoMigration 7→8]
    M78 --> M89["AutoMigration 8→9 ✅ NEW"]
    M89 --> V9[DB version 9 current]

    style M89 fill:#22c55e,color:#fff
    style CRASH fill:#ef4444,color:#fff
    style V9 fill:#3b82f6,color:#fff
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    V1[DB version 1] -->|no migration path| CRASH[IllegalStateException on launch]
    V2[DB version 2] -->|no migration path| CRASH
    V3[DB version 3] --> M34[AutoMigration 3→4]
    M34 --> M45[AutoMigration 4→5]
    M45 --> M56[AutoMigration 5→6]
    M56 --> M67[AutoMigration 6→7]
    M67 --> M78[AutoMigration 7→8]
    M78 --> M89["AutoMigration 8→9 ✅ NEW"]
    M89 --> V9[DB version 9 current]

    style M89 fill:#22c55e,color:#fff
    style CRASH fill:#ef4444,color:#fff
    style V9 fill:#3b82f6,color:#fff
Loading

Comments Outside Diff (1)

  1. app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt, line 19-26 (link)

    P2 No migration path for v1 and v2 databases

    The auto-migration chain starts at from = 3, and there are no manual addMigrations() calls. Removing fallbackToDestructiveMigration() means that any user with a v1 or v2 database will now get an IllegalStateException (crash loop on launch) instead of the previous silent data wipe. Schema files for versions 1 and 2 are present in the repo, confirming those versions existed. While a crash is arguably more transparent than a silent wipe, it does leave those users stuck unless they clear app data. Worth adding addMigrations() entries or a targeted fallbackToDestructiveMigrationFrom(1, 2) to handle these legacy versions explicitly.

Reviews (2): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile

…r configs

SagerDatabase was declared version=9 with autoMigrations only up to 7->8 and a
blanket fallbackToDestructiveMigration(). On upgrade from a v8 device, Room could
not find an 8->9 path and silently dropped/recreated the database, wiping all saved
proxy configs, groups, and rules (the irreversible data loss AGENTS.md forbids).

The v8->v9 delta is purely additive (two new nullable BLOB columns masterDnsVpnBean,
awgBean on proxy_entities), so it is auto-migratable:

- Add AutoMigration(from=8, to=9) so 3->9 is fully covered.
- Remove the blanket fallbackToDestructiveMigration() from SagerDatabase and
  PublicDatabase so an unmatched version change can never silently wipe data.
- Add an instrumented MigrationTestHelper test proving v8 rows survive 8->9
  (DB migrated, not recreated). Wire androidTest deps + testInstrumentationRunner.
- Add a Depot CI workflow to run the instrumented test on a KVM-accelerated
  emulator (Depot CI sandboxes expose /dev/kvm). Namespace stays the APK build host.
@coderabbitai

coderabbitai Bot commented Jun 26, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds an instrumented Android Room migration test for SagerDatabase 8→9, wires Android test support into the build, removes destructive migration fallback from database builders, and adds a Depot workflow to run the emulator-based test.

Changes

Android Room migration validation

Layer / File(s) Summary
Test wiring
buildSrc/src/main/kotlin/Helpers.kt, app/build.gradle.kts
Sets the AndroidJUnitRunner default, exposes schemas to androidTest, and adds JUnit, Android test runner, and Room testing dependencies.
Database migration config
app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt, app/src/main/java/io/nekohasekai/sagernet/database/preference/PublicDatabase.kt
Adds the 8→9 Room auto-migration and removes destructive migration fallback from both Room database builders.
Instrumented migration test
app/src/androidTest/java/io/nekohasekai/sagernet/database/SagerDatabaseMigrationTest.kt
Creates a Room migration test that seeds a version-8 database, migrates to version 9, and checks preserved rows and nullable new columns.
Depot emulator workflow
.depot/workflows/android-instrumented.yml, depot.json
Adds a Depot workflow that prepares Android tooling, caches and builds libcore.aar, starts the emulator, and runs the connected migration test; depot.json adds the Depot id.

Sequence Diagram(s)

sequenceDiagram
  participant Workflow as android-instrumented.yml
  participant GradleTask as connectedOssDebugAndroidTest
  participant MigrationTest as SagerDatabaseMigrationTest
  participant Helper as MigrationTestHelper
  participant Database as SagerDatabase
  Workflow->>GradleTask: runs connectedOssDebugAndroidTest
  GradleTask->>MigrationTest: executes migrate8To9_preservesExistingRows()
  MigrationTest->>Helper: createDatabase(TEST_DB, 8)
  MigrationTest->>Database: runMigrationsAndValidate(..., 9)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • hawkff/NekoBoxForAndroid#59: Touches the same Room database migration wiring in SagerDatabase.kt and related generated migration plumbing.

Poem

I thumped through the schema fields tonight,
with emulator ears and a hopper's might.
One row stayed put, the new columns gleamed,
and libcore cached exactly as dreamed.
🐰 Hop hop—migration passed in the light.

🚥 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
Title check ✅ Passed The title clearly summarizes the main change: adding the 8→9 migration and removing destructive fallback.
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.
Description check ✅ Passed The description clearly matches the migration fix, test additions, and CI workflow changes in the pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

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

Comment on lines +63 to +67
run: |
find buildScript libcore/*.sh buildScript/lib/core/get_source_env.sh \
| sort | xargs cat | sha1sum | awk '{print $1}' > libcore_status
git ls-files libcore | sort | xargs cat | sha1sum | awk '{print $1}' >> libcore_status
cat libcore_status

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 find buildScript without -type f may include directories in the hash

find buildScript recurses into the directory and emits both files and subdirectories. xargs cat silently errors on directory entries (exit 1 per entry), so the SHA-1 is computed only over files cat succeeds on — but the error output is discarded, making the cache key non-deterministic if the directory list changes between runs. Adding -type f ensures only regular file contents are hashed.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@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 @.depot/workflows/android-instrumented.yml:
- Around line 62-84: The libcore cache fingerprint is missing the ./run wrapper,
so changes to that script can reuse a stale app/libs/libcore.aar. Update the
libcore status generation in the android-instrumented workflow to include ./run
in the hashed inputs used by the libcore-cache key, alongside the existing
buildScript and libcore files, so the Build libcore (Go + gomobile) step
invalidates the cache when the wrapper changes.
- Around line 64-66: The fingerprint generation in the workflow is currently
including directories from the `find` output, which causes the `xargs cat` step
to fail in the cache hash pipeline. Update the hashing logic in the workflow
step that builds `libcore_status` to only operate on regular files by filtering
with `find ... -type f` (or by replacing the `find | xargs cat` pattern with an
`-exec cat`-based approach), while keeping the existing `git ls-files` hash
computation intact.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: da6c24f8-dd47-4706-9af8-9ed7c94de23f

📥 Commits

Reviewing files that changed from the base of the PR and between ff59da3 and 6d67635.

📒 Files selected for processing (7)
  • .depot/workflows/android-instrumented.yml
  • app/build.gradle.kts
  • app/src/androidTest/java/io/nekohasekai/sagernet/database/SagerDatabaseMigrationTest.kt
  • app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt
  • app/src/main/java/io/nekohasekai/sagernet/database/preference/PublicDatabase.kt
  • buildSrc/src/main/kotlin/Helpers.kt
  • depot.json
💤 Files with no reviewable changes (1)
  • app/src/main/java/io/nekohasekai/sagernet/database/preference/PublicDatabase.kt

Comment on lines +62 to +84
- name: libcore status
run: |
find buildScript libcore/*.sh buildScript/lib/core/get_source_env.sh \
| sort | xargs cat | sha1sum | awk '{print $1}' > libcore_status
git ls-files libcore | sort | xargs cat | sha1sum | awk '{print $1}' >> libcore_status
cat libcore_status

- name: libcore cache
id: libcore-cache
uses: actions/cache@v4
with:
path: app/libs/libcore.aar
key: depot-libcore-${{ env.GO_VERSION }}-${{ env.NDK_VERSION }}-${{ hashFiles('libcore_status') }}

- name: Install Go
if: steps.libcore-cache.outputs.cache-hit != 'true'
uses: actions/setup-go@v6
with:
go-version: ${{ env.GO_VERSION }}

- name: Build libcore (Go + gomobile)
if: steps.libcore-cache.outputs.cache-hit != 'true'
run: ./run lib core

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Include ./run in the libcore cache fingerprint.

Line 84 builds the AAR through ./run lib core, but the cache key only fingerprints buildScript and libcore. A wrapper-only change will hit the old cache and compile against a stale app/libs/libcore.aar.

Suggested fix
-          find buildScript libcore/*.sh buildScript/lib/core/get_source_env.sh -type f -print0 \
+          find run buildScript libcore/*.sh buildScript/lib/core/get_source_env.sh -type f -print0 \
             | sort -z | xargs -0 cat | sha1sum | awk '{print $1}' > libcore_status
🤖 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 @.depot/workflows/android-instrumented.yml around lines 62 - 84, The libcore
cache fingerprint is missing the ./run wrapper, so changes to that script can
reuse a stale app/libs/libcore.aar. Update the libcore status generation in the
android-instrumented workflow to include ./run in the hashed inputs used by the
libcore-cache key, alongside the existing buildScript and libcore files, so the
Build libcore (Go + gomobile) step invalidates the cache when the wrapper
changes.

Comment on lines +64 to +66
find buildScript libcore/*.sh buildScript/lib/core/get_source_env.sh \
| sort | xargs cat | sha1sum | awk '{print $1}' > libcore_status
git ls-files libcore | sort | xargs cat | sha1sum | awk '{print $1}' >> libcore_status

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Non-file paths currently emitted by the workflow input set:"
find buildScript libcore/*.sh buildScript/lib/core/get_source_env.sh ! -type f -print

echo
echo "If anything prints above, the current xargs cat pipeline is unsafe."

Repository: hawkff/NekoBoxForAndroid

Length of output: 417


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the relevant workflow lines and surrounding context.
nl -ba .depot/workflows/android-instrumented.yml | sed -n '50,80p'

echo
# Reproduce the exact input set and the resulting xargs behavior in a read-only way.
find buildScript libcore/*.sh buildScript/lib/core/get_source_env.sh | sort | xargs -r cat >/dev/null
status=$?
echo "xargs cat exit status: $status"

Repository: hawkff/NekoBoxForAndroid

Length of output: 202


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Relevant workflow lines:"
sed -n '58,70p' .depot/workflows/android-instrumented.yml | cat -n

echo
echo "Entries emitted by the first find expression that are not regular files:"
find buildScript libcore/*.sh buildScript/lib/core/get_source_env.sh ! -type f -print

echo
echo "Exit status of the exact first pipeline:"
set +e
find buildScript libcore/*.sh buildScript/lib/core/get_source_env.sh | sort | xargs cat >/dev/null
status=$?
set -e
echo "$status"

Repository: hawkff/NekoBoxForAndroid

Length of output: 1455


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Relevant workflow lines:"
sed -n '58,70p' .depot/workflows/android-instrumented.yml | cat -n

echo
echo "Entries emitted by the first find expression that are not regular files:"
find buildScript libcore/*.sh buildScript/lib/core/get_source_env.sh ! -type f -print

echo
echo "Exit status of the exact first pipeline:"
set +e
find buildScript libcore/*.sh buildScript/lib/core/get_source_env.sh | sort | xargs cat >/dev/null
status=$?
set -e
echo "$status"

Repository: hawkff/NekoBoxForAndroid

Length of output: 1455


Limit the fingerprint pipeline to regular files. find buildScript ... emits directories like buildScript and buildScript/lib/core, so xargs cat exits non-zero and the cache step fails under the workflow shell. Add -type f before hashing, or switch to an -exec cat pipeline.

🤖 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 @.depot/workflows/android-instrumented.yml around lines 64 - 66, The
fingerprint generation in the workflow is currently including directories from
the `find` output, which causes the `xargs cat` step to fail in the cache hash
pipeline. Update the hashing logic in the workflow step that builds
`libcore_status` to only operate on regular files by filtering with `find ...
-type f` (or by replacing the `find | xargs cat` pattern with an `-exec
cat`-based approach), while keeping the existing `git ls-files` hash computation
intact.

…e-migration

# Conflicts:
#	app/build.gradle.kts
@hawkff
hawkff merged commit e54cc14 into main Jun 26, 2026
8 checks passed
@hawkff
hawkff deleted the fix/025-db-destructive-migration branch June 26, 2026 16:50
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.

1 participant