krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
main: Docs/data-migration.txt · raw
1# DomainDig Data Migration Policy
2
3How DomainDig's persisted data evolves across app versions without losing or
4corrupting a user's on-device store.
5
6## What is persisted
7
8The store is a set of independent JSON blobs in `UserDefaults`, each under a
9stable key (see `DomainDataPortabilityService.StorageKey`):
10
11| Data | Key |
12|------|-----|
13| Tracked domains | `trackedDomains` (legacy: `watchedDomains`) |
14| Lookup history (snapshots) | `lookupHistory` |
15| Audit sessions | `domainAudits` |
16| Workflows | `domainWorkflows` |
17| Monitoring settings / logs | `monitoring.settings`, `monitoring.logs` |
18| App settings | `recentSearches`, `savedDomains`, resolver URL, density |
19| Feature metadata | `purchase.cachedEntitlement`, `usageCredits.ledger` |
20
21A **backup export** (`DomainDigBackup`) is a separate, self-describing file that
22bundles all of the above with its own `schemaVersion`.
23
24## Two version lines
25
26- **Store schema version** — `DataMigrationService.currentStoreSchemaVersion`,
27 persisted under `data.storeSchemaVersion`. Describes the shape of the
28 *on-device* `UserDefaults` store. Advanced by the migration runner.
29- **Backup schema version** — `DomainDigBackup.currentSchemaVersion`, written
30 into every exported file. Describes the shape of an *export*. Checked on import
31 by `DataValidationService`.
32
33They advance independently: a store migration that doesn't change the export
34shape need not bump the backup version, and vice versa.
35
36## How models evolve
37
38Prefer **additive, lenient decoding** — it needs no migration:
39
40- New optional field → add it with `decodeIfPresent(...) ?? default` in the
41 model's `init(from:)`. Old data simply lacks the key and falls back.
42- New value in a `String`-backed enum → decode unknown values to a safe default
43 rather than throwing.
44
45Reach for a **migration step** only when lenient decoding can't express the
46change:
47
48- Renaming or removing a storage key (e.g. `watchedDomains` → `trackedDomains`).
49- Re-normalizing existing rows (dedup, canonicalizing domain casing).
50- Reshaping a blob in a way old readers would misread.
51
52## The migration runner
53
54`DataMigrationService.migrateIfNeeded(defaults:)` runs at launch (and before any
55backup export/import). Its contract:
56
571. **Forward-only.** It reads the stored version and runs each step with a target
58 greater than it, in ascending order, up to `currentStoreSchemaVersion`,
59 stamping the new version after each step.
602. **Never downgrades.** A store stamped at a version *higher* than this build
61 understands (a user who ran a newer build first) is left untouched — no
62 rewrite, no data loss.
633. **Idempotent & safe on any state.** Every step must be safe to run on an empty
64 store and to re-run, because a downgrade-then-upgrade or a partial run can
65 replay it. v1 (the `watchedDomains` drop + dedup normalization) satisfies this
66 by loading through the deduplicating loaders and writing back.
674. **Pre-versioning installs.** Before this framework, a boolean marker
68 (`data.migrations.v3_4_0`) recorded that the v1 normalization had run. A set
69 marker is read as "already at version 1," so v1 never re-runs for those users.
70
71## Adding a migration
72
731. Add a `case N:` to `DataMigrationService.runMigration(to:defaults:)` and a
74 private helper that performs the change.
752. Bump `currentStoreSchemaVersion` to `N`.
763. Make the helper idempotent and safe on an empty/older store.
774. Add a `DataMigrationServiceTests` case that seeds a pre-`N` fixture, runs
78 `migrateIfNeeded`, and asserts the upgrade plus the version stamp.
795. If the change also alters the export shape, bump
80 `DomainDigBackup.currentSchemaVersion` and update `Docs/local-api.txt` /
81 backup validation as needed.
82
83## Backup import compatibility
84
85On import, `DataValidationService.validate(backup:)` compares the file's
86`schemaVersion` to the current one:
87
88- **Newer** than this build → surfaced as an error (the build can't safely read
89 it).
90- **Older** → imported under the same lenient decoders and merge/dedup rules that
91 govern the live store; a note is surfaced, not an error.
92
93Imported data flows through `migrateIfNeeded` and the same `save*` deduplication
94as everything else, so an old backup lands in the store already normalized.