diff options
| author | Christian Cleberg <[email protected]> | 2026-07-24 23:35:18 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-07-24 23:37:00 -0500 |
| commit | 99e4623af1b08f36120a01b67cbe60df99668651 (patch) | |
| tree | 07c8365f0c0054837f2b63611d71d1fd9c2f4e6c /DomainDataPortabilityService.swift | |
| parent | a52dee116d4066d1b59bd90b4ebc4def4e1597d6 (diff) | |
| download | domain-dig-99e4623af1b08f36120a01b67cbe60df99668651.tar.gz domain-dig-99e4623af1b08f36120a01b67cbe60df99668651.tar.bz2 domain-dig-99e4623af1b08f36120a01b67cbe60df99668651.zip | |
feat: versioned store-migration policy for persisted data (v5 step 2)
Third v5.0.0 roadmap item: define and implement a migration policy for the
on-device persisted store (tracked domains, history/snapshots, audits,
workflows, monitoring, settings), so data upgrades cleanly across app versions
instead of relying on a one-shot marker.
- DataMigrationService is reworked from a single boolean marker
(`data.migrations.v3_4_0`) into a versioned runner keyed by an integer store
schema version (`data.storeSchemaVersion`). It runs each step once in
ascending order up to `currentStoreSchemaVersion`, stamping the version as it
goes. Adding a future migration is now a `case N:` plus a version bump.
Policy guarantees, all covered by tests:
- Forward-only and idempotent; every step must be safe on an empty/older store.
- Never downgrades: a store written by a newer build (higher version) is left
byte-for-byte untouched.
- Pre-versioning installs are handled: a set legacy boolean marker reads as
"already at v1", so the v1 normalization never re-runs for them.
v1 is the existing normalization pass (dedup + drop the legacy `watchedDomains`
key + sanitize monitoring settings), now expressed as migration step 1.
- Docs/data-migration.md documents the persisted surface, the two independent
version lines (store vs. backup export), when to use lenient decoding vs. a
migration step, the runner contract, an "adding a migration" checklist, and
backup-import compatibility. Linked from the README.
- DataMigrationServiceTests: 6 tests over legacy fixtures — fresh-store stamping,
legacy `watchedDomains` migration + key drop, in-place dedup of the stored
blob, idempotence, legacy-marker-as-v1, and the no-downgrade guard. Full unit
suite: 58 passing.
Diffstat (limited to 'DomainDataPortabilityService.swift')
| -rw-r--r-- | DomainDataPortabilityService.swift | 63 |
1 files changed, 59 insertions, 4 deletions
diff --git a/DomainDataPortabilityService.swift b/DomainDataPortabilityService.swift index b6f9eee..bfca546 100644 --- a/DomainDataPortabilityService.swift +++ b/DomainDataPortabilityService.swift @@ -342,12 +342,69 @@ enum DataPortabilityCSV { } } +/// Versioned migration runner for the on-device persisted store. +/// +/// The store is a set of independent JSON blobs in `UserDefaults` (tracked +/// domains, history, audits, workflows, monitoring settings/logs, app settings). +/// Most model evolution is handled additively by the models' own lenient +/// decoders (`decodeIfPresent` with defaults), which need no migration at all. +/// This runner exists only for changes lenient decoding can't express: dropping +/// a renamed storage key, re-normalizing existing rows, or reshaping a blob. +/// +/// `currentStoreSchemaVersion` is bumped whenever such a step is added. Each step +/// runs exactly once, in ascending order, and must be safe to run on any prior +/// state — including an empty store. A store written by a newer build (a higher +/// version than this build knows) is left untouched; migrations never downgrade. +/// The policy is documented in `Docs/data-migration.md`. enum DataMigrationService { - private static let migrationMarkerKey = "data.migrations.v3_4_0" + /// The schema version this build expects the on-device store to be at. + static let currentStoreSchemaVersion = 1 + + /// UserDefaults key holding the store's current schema version. + static let storeSchemaVersionKey = "data.storeSchemaVersion" + + /// Pre-versioning installs recorded that the one-shot v1 normalization had + /// run using this boolean marker; `true` means the store is already at v1. + private static let legacyNormalizationMarkerKey = "data.migrations.v3_4_0" + + /// The store's current schema version. Absent on pre-versioning installs: a + /// set legacy marker means v1 already ran, otherwise the store is fresh or + /// never-migrated at v0. + static func storeSchemaVersion(defaults: UserDefaults = .standard) -> Int { + if let version = defaults.object(forKey: storeSchemaVersionKey) as? Int { + return version + } + return defaults.bool(forKey: legacyNormalizationMarkerKey) ? 1 : 0 + } static func migrateIfNeeded(defaults: UserDefaults = .standard) { - guard !defaults.bool(forKey: migrationMarkerKey) else { return } + // At or ahead of this build's version: nothing to do, and never rewrite + // a store a newer build may have reshaped (forward compatibility). + guard storeSchemaVersion(defaults: defaults) < currentStoreSchemaVersion else { return } + + var version = storeSchemaVersion(defaults: defaults) + while version < currentStoreSchemaVersion { + let target = version + 1 + runMigration(to: target, defaults: defaults) + defaults.set(target, forKey: storeSchemaVersionKey) + version = target + } + } + + private static func runMigration(to version: Int, defaults: UserDefaults) { + switch version { + case 1: + normalizeAllStores(defaults: defaults) + default: + break + } + } + /// v1: load every store through its deduplicating loader and write it back. + /// This consolidates duplicate rows, drops the legacy `watchedDomains` key + /// (via `saveTrackedDomains`), and sanitizes monitoring settings against the + /// surviving tracked domains. Safe on an empty store (every step is a no-op). + private static func normalizeAllStores(defaults: UserDefaults) { let trackedDomains = DomainDataPortabilityService.loadTrackedDomains(defaults: defaults) DomainDataPortabilityService.saveTrackedDomains(trackedDomains, defaults: defaults) @@ -366,8 +423,6 @@ enum DataMigrationService { let monitoringLogs = DomainDataPortabilityService.loadMonitoringLogs(defaults: defaults) DomainDataPortabilityService.saveMonitoringLogs(monitoringLogs, defaults: defaults) - - defaults.set(true, forKey: migrationMarkerKey) } } |
