QueryX Persistence

queryx_persistence

Persistent cache adapters for QueryX with offline-first support.

pub.flutter-io.cn version pub.flutter-io.cn likes CI Pure Dart

Disk-backed caching for QueryX — Hive and SQLite storage, startup hydration, cache versioning, and write-through persistence.


Details

Persistent cache adapters for queryx — disk-backed CacheStorage implementations, plus the two things that turn "a place to save bytes" into actual offline-first behavior: hydrating the in-memory cache from disk at startup, and writing successful fetches back to disk.

Pure Dart, like queryx and queryx_dio — neither Hive's core package nor sqlite3's FFI bindings need Flutter, so this is unit-testable with plain dart test.

Backends

Backend Class Notes
Hive HiveCacheStorage Wraps an already-open Box. You own Hive.init(...)/Hive.openBox(...) — this class doesn't manage Hive's storage path, so it works the same in Flutter (with hive_flutter/path_provider) or plain Dart.
sqlite3 SqliteCacheStorage Wraps an already-open Database. Pure-Dart FFI, no platform channels.
In-memory InMemoryCacheStorage Doesn't actually persist across restarts — useful for tests, or as a reference for writing your own CacheStorage.

Isar isn't included yet (its code-generation step didn't fit this package's zero-build-step philosophy) — see CHANGELOG.md. The CacheStorage interface is small enough that adding it, or any other backend, is mostly copying SqliteCacheStorage's shape.

Setup

// Hive
await Hive.initFlutter(); // or Hive.init(path) outside Flutter
final storage = HiveCacheStorage(await Hive.openBox('queryx_cache'));

// or sqlite3
final storage = SqliteCacheStorage(sqlite3.open('queryx_cache.db'));

const cacheVersion = 1; // bump when a cached model's shape changes
await initializePersistentCache(storage: storage, version: cacheVersion);

initializePersistentCache is spec §71 (Cache Versioning): if the version on disk doesn't match, the whole store is wiped rather than risking a crash deserializing an old, incompatible shape. Call it once per app launch, before hydrating any individual query.

Hydration (disk → memory, on startup)

final client = QueryClient();

await hydrateQuery<List<User>>(
  client: client,
  key: QueryKey(['users']),
  storage: storage,
  serializer: Serializer(
    encode: (users) => {'users': users.map((u) => u.toJson()).toList()},
    decode: (json) => (json['users'] as List).map((u) => User.fromJson(u)).toList(),
  ),
);

// client.getQueryData<List<User>>(QueryKey(['users'])) is now populated —
// the first frame can show yesterday's data before any network request.

A corrupt or unexpectedly-shaped individual entry is dropped (and deleted from storage) rather than crashing startup — only that one key is affected, not the whole hydration pass.

Call hydrateQuery explicitly per query you want restored at launch, rather than one generic "hydrate everything" call — the spec's own "No Magic" principle (§91) applies here too: what gets restored from disk, and with what serializer, should be visible in your startup code, not implicit.

Write-through (memory → disk, on every successful fetch)

final users = client.query<List<User>>(
  QueryKey(['users']),
  persistedFetcher<List<User>>(
    key: QueryKey(['users']),
    fetcher: () => api.getUsers(),
    storage: storage,
    serializer: usersSerializer,
  ),
);

persistedFetcher wraps any fetcher; the query behaves exactly as it would without persistence (same dedup, retry, staleTime) — successful results are just also written to disk. A failing disk write never surfaces as a query failure; it's swallowed so a full disk degrades to "this one result didn't get persisted," not "the app's queries are now broken."

Full example

See example/lib/main.dart for hydration → fetch → write-through → simulated restart → hydration again, all against an in-memory sqlite3 db so it runs standalone with dart run example/lib/main.dart.

Installation

dependencies:
  queryx_persistence: ^0.1.0

Libraries

queryx_persistence
Persistent cache adapters for queryx.