Skip to content

Persistence Migrations

Every real app eventually needs to change the shape of its persisted data. Rename a field. Add a default. Nest something under a new object. Without migrations, a user who upgrades their app to a new schema wakes up to a crash or a wiped-out profile.

VersionedJsonSerializer<T> is a first-class migration layer on top of the existing persistence API. It embeds a schema version in the stored payload and runs your migrations in order on load.

Why embedded versions

Alternatives we considered and rejected:

  • Storage-key versioning (e.g. settings_v2). Leaks old keys on disk indefinitely, requires manual migration code, and forces you to care about the whole storage adapter.
  • Heuristic detection (sniff the shape of the JSON). Brittle, slow, silently wrong when two versions happen to parse equally.
  • Deleting-on-change. Loses user data. Never acceptable for settings, auth, or any state the user created.

Embedded versioning puts one extra field (_v) inside the serialized payload. The payload travels with its version; migrations are declarative; rollbacks are explicit errors instead of silent corruption.

Basic usage

dart
final settingsSerializer = VersionedJsonSerializer<Settings>(
  version: 2,
  fromJson: Settings.fromJson,
  toJson: (s) => s.toJson(),
  migrations: {
    // v0 (legacy, no _v field) -> v1: rename "dark" boolean to "themeMode" string
    1: (old) => {
      ...old,
      'themeMode': old.remove('dark') == true ? 'dark' : 'light',
    },
    // v1 -> v2: add an "analytics" opt-in, defaulting to true
    2: (old) => {...old, 'analytics': old['analytics'] ?? true},
  },
);

final settingsReacton = reacton<Settings>(
  Settings.defaults(),
  name: 'settings',
  options: ReactonOptions<Settings>(
    middleware: [
      PersistenceMiddleware<Settings>(
        storage: sharedPrefsStorage,
        serializer: settingsSerializer,
        key: 'settings',
      ),
    ],
  ),
);

On load:

  1. The serializer reads _v from the stored payload. Missing _v is treated as version 0.
  2. It walks migrations[stored+1], migrations[stored+2], ..., migrations[version] in order.
  3. The fully-migrated JSON is passed to fromJson.
  4. Writes always save at the current version.

API

dart
class VersionedJsonSerializer<T> implements Serializer<T> {
  const VersionedJsonSerializer({
    required int version,                                  // must be >= 1
    required T Function(Map<String, dynamic>) fromJson,
    required Map<String, dynamic> Function(T) toJson,
    Map<int, JsonMigration> migrations = const {},
    String versionKey = '_v',
  });
}

typedef JsonMigration = Map<String, dynamic> Function(
  Map<String, dynamic> oldData,
);
  • version — current schema version. Writes tag the payload with this.
  • fromJson / toJson — your normal JSON codec. Runs only on data already at the current version.
  • migrations[N] — takes data in shape N-1, returns data in shape N. Keyed by target version.
  • versionKey — the field embedded in the payload. Change only if _v conflicts with your domain.

Adopting versioning mid-flight

Existing apps will have pre-versioned data on disk. Treat those payloads as version 0 and ship your first versioned serializer at version: 1 with migrations[1] handling the legacy shape:

dart
VersionedJsonSerializer<User>(
  version: 1,
  fromJson: User.fromJson,
  toJson: (u) => u.toJson(),
  migrations: {
    1: (old) {
      // Legacy payloads used `full_name`; we now split into first/last.
      final parts = (old['full_name'] as String? ?? '').split(' ');
      return {
        'firstName': parts.isNotEmpty ? parts.first : '',
        'lastName': parts.length > 1 ? parts.sublist(1).join(' ') : '',
        ...old..remove('full_name'),
      };
    },
  },
);

Error handling

SituationBehavior
Stored version equals versionSkip migrations, decode directly.
Stored version less than version, all migrations presentApply each migration in order, decode.
Stored version less than version, any migration missingThrows StateError: Missing migration to version N so the omission is discovered during development.
Stored version greater than versionThrows StateError: Refusing to downgrade. This protects against users downgrading the app without a rollback plan.
Payload is not a JSON objectThrows FormatException.
_v field is not an intThrows FormatException.

Every error message includes the specific version pair so you can grep for it in crash logs.

Patterns

Adding a field with a default

dart
2: (old) => {...old, 'analytics': old['analytics'] ?? true},

Renaming a field

dart
3: (old) {
  final copy = Map<String, dynamic>.from(old);
  copy['themeMode'] = copy.remove('theme');
  return copy;
},

Nesting under a new key

dart
4: (old) => {
  'prefs': {
    'notifications': old.remove('notifications') ?? true,
    'sound': old.remove('sound') ?? false,
  },
  ...old,
},

Splitting one field into many

dart
5: (old) {
  final fullName = old['name'] as String? ?? '';
  final parts = fullName.split(' ');
  return {
    ...old..remove('name'),
    'firstName': parts.isNotEmpty ? parts.first : '',
    'lastName': parts.length > 1 ? parts.sublist(1).join(' ') : '',
  };
},

Dropping a field

dart
6: (old) => {...old..remove('deprecatedField')},

Testing your migrations

Migrations are pure Map<String, dynamic> -> Map<String, dynamic> functions. Unit-test them with literal JSON:

dart
test('v1 -> v2 adds analytics default', () {
  final v1 = {'themeMode': 'dark'};
  final v2 = migrations[2]!(v1);
  expect(v2['analytics'], isTrue);
  expect(v2['themeMode'], 'dark');
});

For end-to-end safety, also test a full upgrade path:

dart
test('upgrades legacy v0 data all the way to current', () {
  storage.write('settings', jsonEncode({'dark': true}));

  final store = ReactonStore();
  final restored = store.get(settingsReacton);

  expect(restored.themeMode, 'dark');
  expect(restored.analytics, isTrue);
});

Tips

  • Ship every migration forever. Once a migration is in production, removing it breaks users who haven't opened the app in a while.
  • Make migrations idempotent-ish. If a field is already in the new shape, the migration should tolerate it. Users sometimes restore backups from different versions.
  • Keep migrations tiny. One schema change per step is easier to reason about and test than one-shot mega-migrations.
  • Log the migration path in dev. When a user reports a weird settings bug, knowing which migrations ran and in what order is gold.
  • Never downgrade silently. The built-in "Refusing to downgrade" error is a feature, not a bug — users who flashed an older build should not overwrite new-schema data.

Released under the MIT License.