Persistent Set

A persistent Set<T>-like collection for Flutter.

Features

  • Preserves insertion order and set uniqueness.
  • Persists mutations automatically.
  • Serializes mutations by storage and key, including across instances.
  • Refreshes persisted membership before every mutation to prevent stale writes.
  • Commits membership changes only after the storage operation completes successfully.
  • Retains existing element instances when their serialized values are unchanged.
  • Supports asynchronous membership transactions.

Getting started

Persistent Set requires Flutter 3.29.0 or newer.

Add the package to pubspec.yaml:

dependencies:
  persistent_set: ^1.0.0

Usage

Create a string set with PersistentStringSet:

import 'package:persistent_set/persistent_set.dart';

final favorites = await PersistentStringSet.create('user_favorites');

await favorites.add('item_123');
final isFavorite = favorites.contains('item_123');
final savedFavorites = favorites.toSet();

For another type, provide synchronous string codecs:

import 'package:persistent_set/persistent_set.dart';

final visitedPages = await PersistentSet.create<int>(
  'visited_pages',
  to: (value) => value.toString(),
  from: int.parse,
);

await visitedPages.add(42);

Treat the codecs as part of the persisted data schema. They must be deterministic and preserve set equality: from(to(value)) must compare equal to value and produce a compatible hashCode. Distinct values that may coexist in the set must encode to distinct strings. Keep existing encodings readable across app versions, or migrate stored values when changing a codec.

Use mutate when several changes or asynchronous work must be committed as one ordered operation:

final item = await loadFavoriteId();
final added = await favorites.mutate((draft) => draft.add(item));

Mutations using the same storage coordinator and key are serialized, including mutations from different PersistentSet instances. Every mutation refreshes the latest persisted membership before creating its draft, so a stale instance cannot overwrite values committed by an earlier operation. When operations conflict, transaction order determines the final set.

The storage-key lock remains held until an asynchronous mutate callback and its storage operation complete. Keep callbacks short and resolve independent data before calling mutate when practical. Re-entering the same storage key before the callback completes throws a StateError. The default async storage permits nested mutations of distinct keys when those keys are acquired in lexicographically ascending order, giving every nested operation a consistent lock order and preventing dependency cycles. The injected legacy SharedPreferences adapter rejects nested bundled-storage transactions because it must serialize reloads of the API's whole cache. Apply membership changes for the current set to the provided draft.

Sets created through PersistentSet.create and PersistentStringSet.create receive an insertion-ordered draft with independent membership. During the storage refresh, elements whose encoded values are unchanged reuse their existing in-memory instances; new or changed encoded values are decoded. If the callback or storage write fails, membership and iteration order remain unchanged. Subclasses that supply another standard Dart Set implementation retain that set's equality and iteration semantics in the draft.

Always await a mutating method before reading the updated membership. Changes become visible through the instance only after the storage operation completes successfully.

Every mutation performs a storage read before an optional write. Prefer one mutate call over several convenience mutations when applying a batch. The legacy SharedPreferences adapter also reloads the complete preferences cache before each transaction; the default async adapter performs a direct key read.

Instances are local views and are not updated when another instance commits. Call reload for an authoritative read that also updates the instance:

final latestFavorites = await favorites.reload();

The older toSet(reload: true) option is deprecated. It can only return the latest value already observed by that storage handle because its return type is synchronous.

The draft is shallow and contains the same element instances as the current set. Treat elements as immutable and change only the draft's membership; object mutations cannot be rolled back. Do not retain the draft. Calling another mutating method for the same storage key from inside the callback throws a StateError; mutate the provided draft instead.

Custom storage

Implement PersistentSetStorage and PersistentSetStorageTransaction when a set needs another backend or deterministic storage failures in tests, then pass it to either public factory:

import 'package:persistent_set/persistent_set.dart';
import 'package:persistent_set/persistent_set_storage.dart';

final favorites = await PersistentStringSet.create(
  'user_favorites',
  storage: storage,
);

The storage contracts use a separate, advanced entry point so they do not clutter the default package API. Most applications need only package:persistent_set/persistent_set.dart.

A storage implementation must serialize transactions that target the same logical backend and key, including transactions from different storage handles, and load the latest value before invoking each callback. It must invoke the callback exactly once and hold the lock until its synchronous or asynchronous result completes, including when it fails. Values returned by either read method must be detached snapshots, and implementations that retain values passed to write must snapshot them before its future completes. Custom storage owns its key namespace, so keyPrefix applies only to the bundled SharedPreferences adapters. Do not provide both storage and preferences.

The outer storage contract exposes only a synchronous last-observed read and transaction. Authoritative reads, writes, and removals happen through the transaction handle. This prevents callers from bypassing serialization and keeps custom backends focused on the operations used by PersistentSet.

Persistence

The package uses SharedPreferencesAsync, so failed or in-flight writes never appear in the legacy global SharedPreferences cache. On Android it explicitly selects the legacy SharedPreferences backend, and on every platform it retains the flutter. key prefix, so data written by package version 0.1 remains available. If the legacy API was configured with another prefix, pass the same prefix when creating the set:

final favorites = await PersistentStringSet.create(
  'user_favorites',
  keyPrefix: 'custom.',
);

Because the default adapter deliberately does not mutate the legacy singleton's cache, code that reads these keys through SharedPreferences.getInstance() may remain stale. Calling reload() refreshes that cache on platforms whose plugin does not retain a separate lower-level cache, but it is not a portable bridge between the two APIs. Prefer one preferences API for every key.

A persistence operation reported as unsuccessful causes a PersistentSetPersistenceException; other platform errors are propagated.

Migrating from 0.1

Replace imports of package:persistent_set/persistent_string_set.dart with the public entry point: package:persistent_set/persistent_set.dart. The legacy entry point remains as a deprecated compatibility shim.

Use seedIfMissing in new code. The deprecated seedIfEmpty name remains source-compatible for 1.0. On PersistentStringSet, that deprecated parameter also retains its old behavior of seeding an existing empty set; the new seedIfMissing parameter never replaces an existing value, including an empty one. Do not provide both names in the same call.

Concurrency boundary

The default coordinator is isolate-local. SharedPreferences does not provide an atomic compare-and-swap or read-modify-write transaction, so conflicting writes to the same key from another isolate, another process, native code, or code that bypasses the storage coordinator can still overwrite one another.

An isolate broker or advisory file lock could coordinate cooperating callers on some platforms, but it would not make separate preference reads and writes atomic or cover code that bypasses that lock. Removing the boundary requires a backend that owns an atomic transaction or compare-and-swap operation.

Give each persistent-set key a single coordinated owner when using the default backend. If the same key must be mutated across those boundaries, use storage with database transactions or revision-based conflict detection instead. External reads do not cause lost updates, but readers with their own caches may need to refresh before observing a completed write.

Testing

Run unit tests with flutter test. The example app contains a real-platform migration test for the legacy and async SharedPreferences APIs. CI runs it on Android, iOS, Linux, macOS, web, and Windows; local commands are described in example/README.md.

SharedPreferences.setMockInitialValues configures the legacy preferences API, not SharedPreferencesAsync. Pass the resulting instance to the persistent set when using that standard test helper:

SharedPreferences.setMockInitialValues({
  'user_favorites': <String>['item_123'],
});
final preferences = await SharedPreferences.getInstance();
final favorites = await PersistentStringSet.create(
  'user_favorites',
  preferences: preferences,
);

Omitting preferences continues to use the default asynchronous adapter.

Libraries

persistent_set
A persistent, transactional set backed by shared preferences.
persistent_set_storage
Advanced storage contracts for custom PersistentSet backends.
persistent_string_set
Backward-compatible entry point for PersistentStringSet.