From 99e4623af1b08f36120a01b67cbe60df99668651 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Fri, 24 Jul 2026 23:35:18 -0500 Subject: feat: versioned store-migration policy for persisted data (v5 step 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- DomainDataPortabilityService.swift | 63 +++++++++++++++++++++++++++++++++++--- 1 file changed, 59 insertions(+), 4 deletions(-) (limited to 'DomainDataPortabilityService.swift') 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) } } -- cgit v1.2.3