Skip to content

Commit b6ad4b9

Browse files
Copilotgarthvh
andauthored
Add implementation plan for automatic node database backup & restore
Phase 0-1 planning artifacts for feature 011-database-backup-system: - plan.md: Technical context, constitution check, project structure - research.md: 8 research decisions on SQLite copy safety, storage location, integrity verification, metadata format, lifecycle hooks - data-model.md: BackupEntry, BackupIndex, NodeBackupResult types - contracts/backup-service-api.md: NodeBackupManaging protocol contract - quickstart.md: Architecture overview and implementation order - Updated copilot-instructions.md agent context reference Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>
1 parent ccf1514 commit b6ad4b9

6 files changed

Lines changed: 600 additions & 1 deletion

File tree

.github/copilot-instructions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,5 +228,5 @@ CI is handled by Xcode Cloud via `ci_scripts/ci_pre_xcodebuild.sh`. Do not modif
228228
<!-- SPECKIT START -->
229229
For additional context about technologies to be used, project structure,
230230
shell commands, and other important information, read the current plan
231-
at `specs/009-docs-translation-pipeline/plan.md`
231+
at `specs/011-database-backup-system/plan.md`
232232
<!-- SPECKIT END -->
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
# Backup Service API Contract
2+
3+
**Feature**: 011-database-backup-system
4+
**Date**: 2025-07-14
5+
**Type**: Internal Swift API (no external network interface)
6+
7+
## Overview
8+
9+
The backup system is an internal service with no external-facing APIs. It exposes a Swift API consumed by the connection lifecycle within the app. This document defines the public interface contract for `NodeBackupManager`.
10+
11+
## NodeBackupManager Protocol
12+
13+
```swift
14+
/// Public contract for the node database backup/restore service.
15+
/// All methods are async and safe to call from any actor context.
16+
@MainActor
17+
protocol NodeBackupManaging: Sendable {
18+
19+
/// Creates a backup of the current database state for the specified node.
20+
///
21+
/// - Parameters:
22+
/// - nodeNum: The unique node number (`NodeInfoEntity.num`) to associate with the backup
23+
/// - nodeName: Optional display name for the node (for UI purposes)
24+
/// - Returns: Result indicating success or skip reason
25+
/// - Note: Retries once automatically on failure before returning `.skipped`
26+
func createBackup(forNode nodeNum: Int64, nodeName: String?) async -> NodeBackupResult
27+
28+
/// Restores a previously backed-up database for the specified node.
29+
///
30+
/// - Parameter nodeNum: The unique node number to restore backup for
31+
/// - Returns: Result indicating success, skip, or no backup found
32+
/// - Note: Validates checksum before restoring. Returns `.skipped` if corrupt.
33+
func restoreBackup(forNode nodeNum: Int64) async -> NodeBackupResult
34+
35+
/// Checks whether a backup exists for the specified node.
36+
///
37+
/// - Parameter nodeNum: The node number to check
38+
/// - Returns: `true` if a valid backup entry exists in the index
39+
func hasBackup(forNode nodeNum: Int64) -> Bool
40+
41+
/// Returns metadata for all existing backups.
42+
///
43+
/// - Returns: Array of backup entries sorted by most recent first
44+
func listBackups() -> [BackupEntry]
45+
46+
/// Deletes the backup for the specified node, freeing storage.
47+
///
48+
/// - Parameter nodeNum: The node number whose backup should be deleted
49+
/// - Returns: `true` if backup was found and deleted, `false` if no backup existed
50+
@discardableResult
51+
func deleteBackup(forNode nodeNum: Int64) -> Bool
52+
53+
/// Returns the total disk space consumed by all backups in bytes.
54+
var totalBackupSize: Int64 { get }
55+
}
56+
```
57+
58+
## Integration Points
59+
60+
### Backup Trigger (in node-switch flow)
61+
62+
```swift
63+
// Called in Connect.swift before clearDatabase()
64+
// Pseudo-code showing integration contract:
65+
66+
let currentNodeNum = accessoryManager.activeDeviceNum
67+
let currentNodeName = /* fetch from NodeInfoEntity if available */
68+
69+
// 1. Flush pending writes
70+
await MeshPackets.shared.flushDebouncedSaves()
71+
72+
// 2. Create backup (synchronous gate — blocks until complete)
73+
let backupResult = await NodeBackupManager.shared.createBackup(
74+
forNode: currentNodeNum,
75+
nodeName: currentNodeName
76+
)
77+
78+
// 3. Show toast if needed
79+
switch backupResult {
80+
case .success(let entry):
81+
Logger.backup.info("Backed up node \(entry.nodeNum)")
82+
case .skipped(let reason):
83+
showToast("Backup skipped: \(reason)")
84+
case .noBackupFound:
85+
break // Should not occur for create
86+
}
87+
88+
// 4. Proceed with clear
89+
await MeshPackets.shared.clearDatabase(includeRoutes: false)
90+
MeshPackets.recreateShared()
91+
```
92+
93+
### Restore Trigger (in node-connect flow)
94+
95+
```swift
96+
// Called after clearDatabase + recreateShared, before connection steps begin
97+
// Pseudo-code showing integration contract:
98+
99+
let targetNodeNum = /* node number being connected to */
100+
101+
// 1. Attempt restore
102+
let restoreResult = await NodeBackupManager.shared.restoreBackup(
103+
forNode: targetNodeNum
104+
)
105+
106+
// 2. If restored, recreate container to pick up new files
107+
switch restoreResult {
108+
case .success(let entry):
109+
MeshPackets.recreateShared()
110+
showToast("Restored \(entry.nodeName ?? "node") data")
111+
case .skipped(let reason):
112+
Logger.backup.warning("Restore skipped: \(reason)")
113+
showToast("Could not restore backup")
114+
case .noBackupFound:
115+
Logger.backup.debug("No backup for node \(targetNodeNum)")
116+
}
117+
118+
// 3. Proceed with connection steps
119+
```
120+
121+
## Error Handling Contract
122+
123+
| Scenario | Behavior | User Feedback |
124+
|----------|----------|---------------|
125+
| Backup succeeds | Proceed normally | Brief success indicator (optional) |
126+
| Backup fails, retry succeeds | Proceed normally | None |
127+
| Backup fails twice | Skip, proceed with connection | Non-blocking toast: "Backup skipped" |
128+
| Restore succeeds | Recreate container, proceed | Toast: "Restored {nodeName} data" |
129+
| Restore checksum mismatch | Skip, delete corrupt backup | Toast: "Could not restore backup" |
130+
| Restore fails twice | Skip, proceed normally | Toast: "Could not restore backup" |
131+
| Insufficient storage | Skip backup with reason | Toast: "Not enough storage for backup" |
132+
133+
## Thread Safety Guarantees
134+
135+
- `NodeBackupManager` is `@MainActor`-isolated for state consistency
136+
- File I/O operations run on a background thread via `Task.detached` with `.userInitiated` priority
137+
- All public methods are `async` — callers must `await` to ensure ordering
138+
- The backup index file is accessed exclusively through the manager (no concurrent writes)
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
# Data Model: Automatic Node Database Backup & Restore
2+
3+
**Feature**: 011-database-backup-system
4+
**Date**: 2025-07-14
5+
6+
## Entities
7+
8+
### BackupEntry (Codable struct — stored in JSON index)
9+
10+
Represents a single node's backup snapshot metadata.
11+
12+
| Field | Type | Description | Constraints |
13+
|-------|------|-------------|-------------|
14+
| `nodeNum` | `Int64` | Unique node identifier (from `NodeInfoEntity.num`) | Primary key; unique |
15+
| `nodeName` | `String?` | Human-readable node display name at time of backup | Optional; for UI display |
16+
| `createdAt` | `Date` | Timestamp when backup was created | Required |
17+
| `fileSize` | `Int64` | Total size of backup files in bytes | Required; ≥ 0 |
18+
| `checksum` | `String` | SHA-256 hex digest of the `.sqlite` file | Required; 64 chars |
19+
| `backupPath` | `String` | Relative path from `NodeBackups/` to backup directory | Required |
20+
21+
### BackupIndex (Codable struct — root of JSON file)
22+
23+
Top-level container for all backup metadata.
24+
25+
| Field | Type | Description | Constraints |
26+
|-------|------|-------------|-------------|
27+
| `version` | `Int` | Schema version of the index format | Required; currently `1` |
28+
| `entries` | `[Int64: BackupEntry]` | Map of node number to backup metadata | Required; may be empty |
29+
| `lastModified` | `Date` | Timestamp of last index modification | Required |
30+
31+
### NodeBackupResult (enum — in-memory only)
32+
33+
Represents the outcome of a backup or restore operation.
34+
35+
| Case | Associated Values | Description |
36+
|------|-------------------|-------------|
37+
| `.success` | `BackupEntry` | Operation completed successfully |
38+
| `.skipped(reason: String)` | `String` | Operation was skipped (no data, failed after retry) |
39+
| `.noBackupFound` || No existing backup for the target node |
40+
41+
## Relationships
42+
43+
```
44+
BackupIndex (1) ──contains──> (0..*) BackupEntry
45+
46+
BackupEntry.nodeNum ──references──> NodeInfoEntity.num (external, in SwiftData)
47+
48+
NodeBackups/
49+
├── backup-index.json (BackupIndex serialized)
50+
├── {nodeNum}/
51+
│ ├── Meshtastic.sqlite (SQLite database copy)
52+
│ ├── Meshtastic.sqlite-wal (WAL file, if present)
53+
│ └── Meshtastic.sqlite-shm (Shared memory file, if present)
54+
```
55+
56+
## Validation Rules
57+
58+
1. **nodeNum uniqueness**: Only one `BackupEntry` per `nodeNum` in the index (enforced by `Dictionary` key)
59+
2. **Checksum format**: Must be a valid 64-character hexadecimal string (SHA-256)
60+
3. **File existence**: `backupPath` must resolve to existing directory containing at least `Meshtastic.sqlite`
61+
4. **Size consistency**: `fileSize` should approximate the actual file sizes on disk (± WAL size variance)
62+
5. **Index version**: Must be `1` (future versions may require migration logic)
63+
64+
## State Transitions
65+
66+
### Backup Lifecycle
67+
68+
```
69+
┌─────────────┐ connect to ┌──────────────┐ file copy ┌─────────────┐
70+
│ Connected │ ──── new node ─────> │ Backing │ ── succeeds ──> │ Backed │
71+
│ to Node A │ │ Up │ │ Up │
72+
└─────────────┘ └──────────────┘ └─────────────┘
73+
│ │
74+
fails (retry) next switch
75+
│ │
76+
v v
77+
┌──────────────┐ (overwrites previous)
78+
│ Skipped │
79+
│ (toast) │
80+
└──────────────┘
81+
```
82+
83+
### Restore Lifecycle
84+
85+
```
86+
┌─────────────┐ connect to ┌──────────────┐ backup found ┌─────────────┐
87+
│ Connecting │ ──── Node A ──────> │ Check │ ── & valid ────> │ Restoring │
88+
│ │ │ Index │ │ │
89+
└─────────────┘ └──────────────┘ └─────────────┘
90+
│ │
91+
no backup / succeeds
92+
corrupt │
93+
│ v
94+
v ┌─────────────┐
95+
┌──────────────┐ │ Restored │
96+
│ Proceed │ │ (toast) │
97+
│ Normally │ └─────────────┘
98+
└──────────────┘
99+
```
100+
101+
## Storage Layout
102+
103+
```
104+
{App Support}/
105+
└── NodeBackups/
106+
├── backup-index.json
107+
├── 1234567890/
108+
│ ├── Meshtastic.sqlite
109+
│ ├── Meshtastic.sqlite-wal
110+
│ └── Meshtastic.sqlite-shm
111+
├── 9876543210/
112+
│ ├── Meshtastic.sqlite
113+
│ └── (WAL/SHM may not exist if checkpointed)
114+
└── ...
115+
```
116+
117+
## Swift Type Definitions (Preview)
118+
119+
```swift
120+
struct BackupEntry: Codable, Sendable {
121+
let nodeNum: Int64
122+
var nodeName: String?
123+
var createdAt: Date
124+
var fileSize: Int64
125+
var checksum: String
126+
var backupPath: String
127+
}
128+
129+
struct BackupIndex: Codable, Sendable {
130+
var version: Int = 1
131+
var entries: [Int64: BackupEntry] = [:]
132+
var lastModified: Date = .now
133+
}
134+
135+
enum NodeBackupResult: Sendable {
136+
case success(BackupEntry)
137+
case skipped(reason: String)
138+
case noBackupFound
139+
}
140+
```
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# Implementation Plan: Automatic Node Database Backup & Restore
2+
3+
**Branch**: `011-database-backup-system` | **Date**: 2025-07-14 | **Spec**: `specs/011-database-backup-system/spec.md`
4+
**Input**: Feature specification from `/specs/011-database-backup-system/spec.md`
5+
6+
## Summary
7+
8+
Implement an automatic database backup and restore system that snapshots the SQLite/SwiftData store file when switching between Meshtastic nodes, and restores a previous snapshot when reconnecting to a previously-used node. The approach uses direct SQLite file copy (via `FileManager`) since the database contains only one node's data at a time, with backup metadata tracked in a lightweight plist/JSON index. Integration hooks into the existing `AccessoryManager` connection lifecycle ensure backups happen synchronously before database clearing.
9+
10+
## Technical Context
11+
12+
**Language/Version**: Swift (latest stable), Swift Concurrency (`async`/`await`, `@MainActor`)
13+
**Primary Dependencies**: SwiftData, SwiftUI, Foundation (FileManager), OSLog
14+
**Storage**: SwiftData (`ModelContainer` with SQLite backing store), file-level SQLite snapshots for backups
15+
**Testing**: Swift Testing framework (`@Suite`, `@Test`, `#expect`, `#require`)
16+
**Target Platform**: iOS 17+, iPadOS 17+, macOS 14+ (via Mac Catalyst)
17+
**Project Type**: Mobile app (feature addition)
18+
**Performance Goals**: Backup/restore completes within 5 seconds for databases with up to 10,000 entities; non-blocking UI
19+
**Constraints**: Must not block main thread; single backup per node (1:1 mapping); local storage only
20+
**Scale/Scope**: 2–5 nodes per user typically; single SQLite file ~5–50MB per backup
21+
22+
## Constitution Check
23+
24+
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
25+
26+
| Principle | Status | Notes |
27+
|-----------|--------|-------|
28+
| I. SwiftUI-Native | ✅ PASS | Backup management UI will be pure SwiftUI in `Views/Settings/` |
29+
| II. SwiftData Persistence | ✅ PASS | Backup operates at file level below SwiftData; restore replaces the store file and recreates `ModelContainer`. No custom persistence outside SwiftData for app data. |
30+
| III. Protocol-Oriented Transport | ✅ PASS | Backup hooks into `AccessoryManager` connection lifecycle; no direct BLE/network calls |
31+
| IV. Structured Logging | ✅ PASS | Will use `Logger` with new `.backup` category |
32+
| V. Protobuf Contract Fidelity | ✅ PASS | No protobuf changes needed |
33+
| VI. Lint-Clean Commits | ✅ PASS | All code will pass SwiftLint |
34+
| VII. Platform Parity | ✅ PASS | FileManager APIs work cross-platform; UI uses SF Symbols |
35+
| VIII. Design Standards | ✅ PASS | Management UI will follow Meshtastic design standards |
36+
37+
**Gate Result**: PASS — no violations.
38+
39+
## Project Structure
40+
41+
### Documentation (this feature)
42+
43+
```text
44+
specs/011-database-backup-system/
45+
├── plan.md # This file
46+
├── research.md # Phase 0 output
47+
├── data-model.md # Phase 1 output
48+
├── quickstart.md # Phase 1 output
49+
├── contracts/ # Phase 1 output
50+
│ └── backup-service-api.md
51+
└── tasks.md # Phase 2 output (created by /speckit.tasks)
52+
```
53+
54+
### Source Code (repository root)
55+
56+
```text
57+
Meshtastic/
58+
├── Model/
59+
│ └── BackupMetadataEntity.swift # Backup index metadata (if SwiftData-backed)
60+
├── Persistence/
61+
│ └── NodeBackupManager.swift # Core backup/restore service
62+
├── Extensions/
63+
│ └── Logger+Backup.swift # Logger category for backup subsystem
64+
├── Views/
65+
│ └── Settings/
66+
│ └── BackupManagement/
67+
│ ├── BackupManagementView.swift # List of backups
68+
│ └── BackupRowView.swift # Individual backup row
69+
├── Accessory/
70+
│ └── Accessory Manager/
71+
│ └── AccessoryManager+Connect.swift # Modified: hook backup before clear
72+
73+
MeshtasticTests/
74+
└── NodeBackupManagerTests.swift # Unit tests for backup service
75+
```
76+
77+
**Structure Decision**: Mobile app feature addition following existing conventions. New files integrate into the established folder structure (`Persistence/` for service logic, `Model/` for entities, `Views/Settings/` for management UI). The backup service is a standalone manager class injected into the connection lifecycle.
78+
79+
## Complexity Tracking
80+
81+
> No constitution violations — this section is intentionally empty.
82+
83+
| Violation | Why Needed | Simpler Alternative Rejected Because |
84+
|-----------|------------|-------------------------------------|
85+
||||

0 commit comments

Comments
 (0)