SwiftData
The app uses SwiftData exclusively for persistence. Do not introduce SQLite, Realm, Core Data, or any other persistence library.
ModelContainer Setup
PersistenceController.shared (in Meshtastic/Persistence/PersistenceController.swift) creates and owns the ModelContainer. It is initialised once at app launch in MeshtasticApp.swift and injected via .modelContainer(PersistenceController.shared.container).
Autosave is disabled in production (container.mainContext.autosaveEnabled = false). All persistence is driven by explicit modelContext.save() calls so the app controls exactly when SQLite writes occur.
Save Strategy
The app uses two save patterns depending on packet frequency:
Immediate Saves
Config changes, messages, waypoints, and other low-frequency mutations call savePendingChanges() directly after updating the model graph. This helper is a thin wrapper around modelContext.save() with error logging.
Debounced Saves
High-frequency packets — positions and telemetry — use scheduleDebouncedSave() to coalesce writes. The debouncer waits 2 seconds of inactivity before flushing, with a hard ceiling of 5 seconds from the first dirty change. This prevents dozens of SQLite writes per second when the mesh is busy.
Position packet 1 → dirty flag set, 2s timer starts
Position packet 2 (200ms later) → timer resets to 2s
Position packet 3 (200ms later) → timer resets to 2s
...
No packets for 2s → save() fires
— OR —
5s since first dirty change → save() fires regardless
Debounced saves are flushed explicitly on disconnect so no data is lost.
Indexes
Frequently queried fields use @Attribute(.unique) to create a UNIQUE INDEX in the underlying SQLite store. This eliminates full table scans on the hottest lookup paths:
| Field | Entity | Why |
|---|---|---|
num | NodeInfoEntity | Looked up on every incoming packet |
num | UserEntity | Looked up on every message |
messageId | MessageEntity | ACK lookups, deduplication |
hwModel | DeviceHardwareEntity | Hardware image lookups |
Note —
@Attribute(.indexed)requires iOS 18+. The app targets iOS 17.5, so@Attribute(.unique)is used instead (it creates a UNIQUE INDEX which also serves as a regular index).
Using the ModelContext in Views
struct MyView: View {
@Environment(\.modelContext) private var context
@Query private var nodes: [NodeInfoEntity]
var body: some View { ... }
}
Use @Query for data that drives the view. Use context.insert(_:) / context.delete(_:) for mutations. Mutations on the main context are safe on the main actor.
Background Writes
For writes triggered by incoming radio packets (off the main thread), use the MeshPackets @ModelActor:
let actor = MeshPackets(modelContainer: PersistenceController.shared.container)
await actor.savePacket(packet)
Never write to the main ModelContext from a background thread.
Model Types
All model types live in Meshtastic/Model/. Each type is decorated with @Model:
@Model
final class NodeInfoEntity {
var num: Int64
var longName: String?
// ...
}
Key model types:
| Type | Description |
|---|---|
NodeInfoEntity | A node heard on the mesh |
MessageEntity | A channel or direct message |
PositionEntity | A GPS position update |
TelemetryEntity | Device/environment sensor data |
TraceRouteEntity | A recorded trace route |
WaypointEntity | A shared map waypoint |
Schema Migrations
When you add, rename, or remove properties on a @Model type, you must provide a migration. Schema files live in Meshtastic/Model/Schema/.
Adding a New Schema Version
- Create
Meshtastic/Model/Schema/MeshtasticSchemaV2.swiftwith the updated models:
enum MeshtasticSchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] { ... }
}
- Append
MeshtasticSchemaV2.selftoMeshtasticMigrationPlan.schemas(newest last). - Add a migration stage to
MeshtasticMigrationPlan.stages:
// Lightweight — SwiftData infers additive changes automatically (new optional properties)
static let migrateV1toV2 = MigrationStage.lightweight(
fromVersion: MeshtasticSchemaV1.self,
toVersion: MeshtasticSchemaV2.self
)
// Custom — when you need to transform or backfill data
static let migrateV1toV2 = MigrationStage.custom(
fromVersion: MeshtasticSchemaV1.self,
toVersion: MeshtasticSchemaV2.self,
willMigrate: { context in },
didMigrate: { context in
// Transform data, populate new fields, etc.
try context.save()
}
)
- Update
MeshtasticSchema.currentto point to the new version.
Warning — Never delete a
VersionedSchema. Migration history must be preserved or the migration plan will fail on devices that skipped intermediate versions.
Query Helpers
QuerySwiftData.swift contains helper functions for common fetches:
let node = getNodeInfo(id: nodeNum, context: context)
UpdateSwiftData.swift contains helpers for upsert patterns:
upsertNode(packet: packet, context: context)
Prefer these helpers over direct queries to keep logic consistent.
Data Caps
To prevent unbounded database growth, the app enforces per-node caps when inserting new records. Older rows beyond the cap are deleted in the same transaction:
| Relationship | Cap | Behaviour |
|---|---|---|
NodeInfoEntity.positions | 5 000 | Oldest positions deleted when exceeded |
NodeInfoEntity.telemetries | 5 000 per metrics type | Oldest telemetry of that type deleted |
MessageEntity (per channel) | 50 000 | Oldest messages in the channel deleted |
These caps are enforced in UpdateSwiftData.swift during the upsert path, so they run on every incoming packet without requiring a separate maintenance task.