12 KiB
Feature Specification: Core Database (Room KMP Persistence)
Feature Branch: 015-core-database
Created: 2026-07-27
Status: Migrated
Input: Brownfield migration — reverse-engineered from existing core/database module
Summary
Core Database provides the Room KMP persistence layer for Meshtastic-Android. It defines the MeshtasticDatabase schema (11 entities, 7 DAOs, 35 auto-migrations from v3→v38), a DatabaseManager with per-device database instances and LRU eviction, per-platform DatabaseBuilder implementations, and type converters. The module enables per-device data isolation — each connected Meshtastic device gets its own Room database file — with configurable cache limits and automatic eviction of least-recently-used database files.
Goals
- Per-device persistence — maintain separate Room database files per Meshtastic device address for data isolation.
- Schema evolution — support forward migration via Room auto-migrations across 35+ schema versions.
- LRU eviction — automatically close and delete least-recently-used database files when the cache limit is exceeded.
- Cross-platform — provide
DatabaseBuilderimplementations for Android, JVM/Desktop, and iOS via expect/actual. - Reactive access — expose the current database as
StateFlow<MeshtasticDatabase>for reactive consumers.
Non-Goals
- Business logic for reading/writing data — handled by
core/datarepositories. - Domain model definitions — handled by
core/model. - Query logic beyond DAO definitions — complex queries are in the repository layer.
- Database encryption — not currently implemented.
User Scenarios & Testing (mandatory)
User Story 1 — Per-Device Database Switching (Priority: P1)
When the user connects to a different Meshtastic device, the DatabaseManager switches the active database to the one associated with the new device's address. A new database is created if none exists for that address.
Why this priority: Per-device isolation prevents data cross-contamination between devices.
Independent Test: Can be tested by switching addresses and verifying different databases are active.
Acceptance Scenarios:
- Given the app connects to device with address "AA:BB:CC", When
switchActiveDatabase("AA:BB:CC")is called, Then a database namedmeshtastic_AA_BB_CCis opened (or created). - Given the active DB is device A, When switching to device B, Then
currentDbemits the new database and the previous is not closed synchronously (race-safe). - Given the same address is requested twice, When
switchActiveDatabaseis called again, Then it is a no-op (fast path). - Given a null/blank address, When
switchActiveDatabase(null)is called, Then the default database is used.
User Story 2 — LRU Cache Eviction (Priority: P2)
The database manager enforces a configurable cache limit. When the number of per-device databases exceeds the limit, the least-recently-used database files are closed and deleted.
Why this priority: Without eviction, device storage fills up as users connect to many different nodes.
Independent Test: Testable by creating N databases, setting limit to M < N, and verifying eviction.
Acceptance Scenarios:
- Given the cache limit is 5 and 6 databases exist, When eviction runs, Then the least-recently-used database is closed and its file is deleted.
- Given the active database would be evicted by LRU order, When eviction runs, Then the active database is protected from eviction.
- Given the cache limit is changed from 5 to 3, When
setCacheLimit(3)is called, Then eviction runs asynchronously and removes excess databases. - Given the cache limit is set to a value outside bounds, When clamped, Then it is constrained to
[MIN_CACHE_LIMIT, MAX_CACHE_LIMIT].
User Story 3 — Schema Migration (Priority: P1)
The Room database supports forward migration from schema version 3 through 38 using auto-migrations. Special migrations handle table deletions and column removals.
Why this priority: Schema migration failures cause data loss. The 35-version migration chain must be reliable.
Independent Test: Android-only instrumented test (MigrationTest).
Acceptance Scenarios:
- Given a database at schema version N (3 ≤ N < 38), When the app opens it, Then Room auto-migrates to version 38 without data loss.
- Given auto-migration from v12→v13, When the migration runs, Then the legacy
NodeInfoandMyNodeInfotables are deleted. - Given auto-migration from v29→v30, When the migration runs, Then the
reply_idcolumn is removed frompacket. - Given a destructive migration is required, When
fallbackToDestructiveMigration(dropAllTables = false)is configured, Then only the affected tables are recreated.
User Story 4 — Database Access via withDb() (Priority: P1)
Consumers access the active database through DatabaseManager.withDb(), which provides the current database instance. It tolerates connection-pool-closed races during database switching by retrying once.
Why this priority: withDb() is the single entry point for all database access. Race safety is critical.
Independent Test: Can be tested by simulating a database switch during a withDb() call.
Acceptance Scenarios:
- Given an active database, When
withDb { db.nodeInfoDao().getAll() }is called, Then the query executes on the current database. - Given a database switch occurs between capturing
dband executing the query, When "Connection pool is closed" is thrown, ThenwithDbretries once with the new database instance. - Given no database is active (
_currentDbis null), WhenwithDbis called, Then it returnsnullimmediately. - Given concurrent
withDbcalls, When executing, Then parallelism is limited to 4 vialimitedIodispatcher.
Edge Cases
- What happens when deleting a database file fails (permission error)? The error is logged but does not crash;
runCatchingis used for best-effort cleanup. - What happens when a legacy database exists after migration?
cleanupLegacyDbIfNeededdeletes it on first switch, then marks cleanup as complete via DataStore. - What happens when
hasDatabaseFor()checks for a non-existent address? It checks bothdbNameanddbName.dbfile paths (platform-agnostic). - What happens when the database directory doesn't exist?
listExistingDbNames()returns an empty list.
Architecture
Key Components
| Component | File | Purpose |
|---|---|---|
MeshtasticDatabase |
MeshtasticDatabase.kt (141 LOC) |
Room database definition: 11 entities, 7 DAOs, 35 auto-migrations |
DatabaseManager |
DatabaseManager.kt (301 LOC) |
Per-device DB management: switch, LRU eviction, withDb(), legacy cleanup |
DatabaseProvider |
DatabaseProvider.kt |
Interface for database access (currentDb, withDb) |
DatabaseBuilder |
DatabaseBuilder.kt (expect/actual) |
Platform-specific Room database builder |
DatabaseConstants |
DatabaseConstants.kt |
DB prefix, limits, legacy name constants |
Converters |
Converters.kt |
Room type converters (ByteString ↔ ByteArray, proto ↔ blob) |
MeshtasticDatabaseConstructor |
MeshtasticDatabaseConstructor.kt |
Room KMP database constructor |
CoreDatabaseModule |
di/CoreDatabaseModule.kt |
Koin DI module |
| Entities | ||
NodeEntity |
entity/NodeEntity.kt |
Mesh node persistence |
MyNodeEntity |
entity/MyNodeEntity.kt |
Local node identity |
Packet |
entity/Packet.kt |
Message/packet persistence |
MeshLog |
entity/MeshLog.kt |
Debug mesh log entries |
QuickChatAction |
entity/QuickChatAction.kt |
Quick chat shortcuts |
FirmwareReleaseEntity |
entity/FirmwareReleaseEntity.kt |
Cached firmware releases |
DeviceHardwareEntity |
entity/DeviceHardwareEntity.kt |
Cached hardware catalog |
TracerouteNodePositionEntity |
entity/TracerouteNodePositionEntity.kt |
Traceroute position snapshots |
| DAOs | ||
NodeInfoDao |
dao/NodeInfoDao.kt |
Node CRUD with reactive queries |
PacketDao |
dao/PacketDao.kt |
Message queries with paging support |
MeshLogDao |
dao/MeshLogDao.kt |
Log insertion and paging |
QuickChatActionDao |
dao/QuickChatActionDao.kt |
Quick chat CRUD |
DeviceHardwareDao |
dao/DeviceHardwareDao.kt |
Hardware catalog CRUD |
FirmwareReleaseDao |
dao/FirmwareReleaseDao.kt |
Firmware release CRUD |
TracerouteNodePositionDao |
dao/TracerouteNodePositionDao.kt |
Traceroute position CRUD |
Requirements (mandatory)
Functional Requirements
- FR-001: System MUST define a Room database with 11 entities and 7 DAOs.
- FR-002: System MUST support auto-migration from schema version 3 to 38 (35 migration steps).
- FR-003: System MUST provide per-device database instances keyed by Bluetooth address.
- FR-004: System MUST implement LRU eviction with configurable cache limit (min/max bounds).
- FR-005: System MUST expose the active database as
StateFlow<MeshtasticDatabase>. - FR-006: System MUST provide
withDb()with connection-pool-closed retry logic. - FR-007: System MUST clean up legacy database files on first use after migration.
- FR-008: System MUST provide platform-specific
DatabaseBuildervia expect/actual. - FR-009: System MUST provide
hasDatabaseFor(address)to check if a device has persisted data. - FR-010: System MUST track database last-used timestamps via DataStore preferences.
- FR-011: System MUST protect the active database from LRU eviction.
- FR-012: System MUST configure Room with
fallbackToDestructiveMigration(dropAllTables = false). - FR-013: System MUST set query coroutine context to
ioDispatcherfor all Room operations. - FR-014: System MUST support paging via
PagingSourceDaoReturnTypeConverter.
Non-Functional Requirements
- NFR-001: All entity, DAO, and database definitions MUST reside in
commonMain(Constitution §I). - NFR-002: Database switching MUST be serialized via
Mutexto prevent race conditions. - NFR-003:
withDb()parallelism MUST be limited to 4 concurrent operations vialimitedParallelism. - NFR-004: Database file eviction MUST be best-effort — failures are logged, not propagated.
- NFR-005: Legacy database cleanup MUST be idempotent (DataStore flag prevents repeat cleanup).
Source-Set Impact
| Source Set | Impact | Justification |
|---|---|---|
commonMain |
23 files (~2,800 LOC) | Database, entities, DAOs, manager, converters |
commonTest |
3 files (~300 LOC) | NodeInfoDao, PacketDao, eviction tests |
androidDeviceTest |
2 files (~200 LOC) | Full DB test, legacy cleanup test |
androidHostTest |
1 file (~100 LOC) | Migration test |
androidMain |
2 files (~100 LOC) | Android DatabaseBuilder, Android DI module |
jvmMain |
1 file (~50 LOC) | JVM DatabaseBuilder |
iosMain |
1 file (~50 LOC) | iOS DatabaseBuilder |
Privacy Assessment
- Database files contain mesh node data (addresses, positions) — stored locally only
- Database file names derived from Bluetooth MAC are anonymized in logs
- No database exports or cloud sync
Success Criteria (mandatory)
Measurable Outcomes
- SC-001: All 35 auto-migrations execute without data loss from v3→v38.
- SC-002:
DatabaseManagercorrectly switches between 3+ per-device databases. - SC-003: LRU eviction removes exactly the right number of databases when cache limit is exceeded.
- SC-004: Active database is never evicted regardless of LRU order.
- SC-005:
withDb()retries successfully after a connection-pool-closed race. - SC-006: All 6 existing test files pass.
- SC-007: Database builder compiles and produces valid Room instances on all 3 platforms.
Assumptions
- Room KMP (
androidx.room3) is the persistence library. - Per-device database naming convention:
meshtastic_{sanitized_address}. - Default cache limit is defined in
DatabaseConstants.DEFAULT_CACHE_LIMIT. ioDispatcheris the coroutine context for all Room query execution.- DataStore preferences are used for cache limit and legacy cleanup tracking.
- Schema exports are enabled (
exportSchema = true) for migration validation.