Skip to content

Persistence

sling_gql_sqflite stores the cache in SQLite, so the second launch of an app paints the previous session’s data on its first frame — no skeleton, no request until you ask for one. It is a separate package on top of the persistence hooks of the runtime (Cache(initial:), onChange, changesSince); the runtime itself stays http-only.

Terminal window
flutter pub add sling_gql_sqflite
lib/main.dart
import 'package:sling_gql_sqflite/sling_gql_sqflite.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); // sqflite and the lifecycle flush need it
final persistence = await SqflitePersistence.open(
'sling_cache.db', // relative: in sqflite's getDatabasesPath()
schema: slingSchema,
);
final client = SlingClient<Query>(
endpoint: Uri.parse(endpoint),
schema: slingSchema,
cache: persistence.cache,
maxAge: const Duration(minutes: 5), // see "Restored data is stale" below
);
runApp(SlingScope<Query>(client: client, child: const App()));
}

open reads every row, drops what is past the bounds, builds the Cache (normalized on slingSchema.keyField) and returns a persistence that is already listening to it — there is no attach step. Build the client with persistence.cache and the app starts warm.

The example app does exactly this on iOS and Android (example/lib/persisted_cache.dart); its web build keeps an in-memory cache through a conditional import, and compiles no sqflite code.

Reads happen inside build(), so they must be synchronous: the cache stays the in-memory store every getter reads from, and SQLite is a durable copy of it, written behind. There is no lazy read-through — what is persisted is loaded, so memory is the persisted size. That is why the store is bounded, not the reads.

  • Saves are deltas. Each save writes cache.changesSince(savedVersion): the entities changed or removed since the last stored save, never a snapshot of everything.
  • When. One second after the last onChange (debounce), at most five seconds after the first one while changes keep coming (maxWait), when the app is hidden, paused or detached (an AppLifecycleListener), and on persistence.flush().
  • How. One transaction per save: upsert the changed rows, delete the removed ones, batch.commit(noResult: true). Saves are chained, never concurrent, so an older delta cannot land after a newer one.
  • Failures. A failed save rolls back and leaves savedVersion where it was; the next change (or flush()) saves everything since the last stored version. Background failures go to onError (default FlutterError.reportError); flush() throws them.
  • Compaction. After each stored save the persistence calls cache.compact(upTo: savedVersion): the cache drops the change records the database already holds. Later deltas scan only what changed since, and a big gc stays a delta — its removals become a few DELETE … WHERE key IN (…) statements. Pass compact: false when something else reads cache.changesSince from older versions (the example’s cache screen does): it would get full deltas.
  • Full deltas. After cache.clear() changesSince answers with every entity (few, the cache was just emptied): that save empties and rewrites sling_entities. Without compaction a cache also forgets its removals once there are more than a thousand, and more than live entities (a big gc), and the next save rewrites every live entity. Root-field rows are still reconciled field by field, so untouched ones keep their age.
Table Row Columns
sling_entities one per entity (Launch:launch-181) key, data, updated_at (ms)
sling_root_fields one per ROOT_QUERY field (launches_c0y93u18lig6v) field, data, updated_at (ms)
sling_meta format version, key field, codec id, schema hash and fields, writer key, value

data is the row’s JSON as text, or the codec’s bytes.

The query root is split per field because every response touches it: a page of launches rewrites its own root field (and the entities that changed), not every other query the app ever made. The runtime’s delta already reports the roots field by field (delta.changedFields / delta.removedFields), so a save copies, encodes and writes only the root fields that changed. Per-field rows are also what lets old queries age out one by one. ROOT_MUTATION and ROOT_SUBSCRIPTION are never stored.

Applied once, when the store is opened (null disables either):

  1. maxAge (default 7 days): root fields whose row was last written longer ago are dropped. A row is rewritten when its value changes, so a root field that a refetch confirms unchanged keeps its old updated_at.
  2. maxEntities (default 10 000): while the entities the remaining root fields reach are more than that, the oldest root fields are dropped (newest kept first; ties by name).
  3. Entities no kept root field reaches are dropped — the set cache.gc() would collect — without being decoded.

Dropped rows are deleted from the database before open returns. persistence.loaded (a SqfliteLoadReport) tells what was kept, dropped and how long it took. The bounds hold at each launch: a long session can grow past maxEntities, and the next launch trims it.

maxEntities counts entities only. Inline data held directly by root fields (objects without an id, scalar root fields) and the number of root fields are bounded by maxAge alone — and not at all when it is null.

A file that is not a database (a damaged header, a truncated write) or a row that cannot be decoded (a damaged page, a hand edit, a codec that throws) never fails the launch: open reports the error to onError, deletes the file, creates a new one and starts cold (persistence.loaded.recovered). It is a cache, starting cold beats failing every launch. Only a failure to create that fresh database (permissions, a full disk) makes open throw, and so does a database another connection keeps locked (SQLITE_BUSY / SQLITE_LOCKED): that file is healthy and left alone. The example app then falls back to an in-memory cache.

The runtime’s freshness stamps (fetchedAt) live in memory and are not persisted: restored data has none, so it counts as stale under maxAge and cacheAndNetwork. That is what a persisted cache wants — with maxAge, the first frame shows the restored launches and a background request revalidates them (Fetch policies & freshness).

sling_gql_gen emits slingSchema.hash, a 64-bit FNV-1a of the generated file. It changes when the introspection, --key-field, a --scalar mapping or the generator’s output changes — any of which can change cache aliases, keyed types and lookups — and is the same on every run and platform. It also emits slingSchema.fields: every field of every object type with its signature (arguments with their defaults, then the type), the query root as ROOT_QUERY:

'Rocket': {'id': 'ID!', 'name': 'String!', 'launches': '(first: Int = 10, after: String) LaunchConnection!', …},

The store records both. When the app starts with another hash, the store is migrated rather than wiped: each cached field is kept only when its field has the same signature in both schemas, so the new code never reads a value of the wrong shape.

  • An entity whose type is gone is dropped (references to it read as missing).
  • An entity or inline object loses the fields that are gone or changed (renamed, another type, other arguments or defaults): those read as missing and are fetched again.
  • A root field whose field is gone or changed is dropped, and so is a field (root or not) holding an inline object whose type is gone.
  • Custom scalars are kept as they are; --scalar mappings parse the stored wire value.

Kept rows keep their age (maxAge), and restored data is revalidated as usual (below). persistence.loaded reports migrated and the incompatibleRootFields / incompatibleEntities it dropped.

Another key field, another format version (the package’s tables changed), another codec id, or a hash change where either side has no fields (a hand-written schema, a store from an older version) still wipes the database and the app starts cold.

A database file has one SqflitePersistence writing to it at a time:

  • In one isolate, opening a path that is already open (and not close()d) throws a StateError: sqflite shares one connection per path, so a second instance would share (and close) the first one’s.
  • Across isolates and processes (a background isolate, an app extension), the last open wins. It records itself as the writer in sling_meta; every save checks that record inside its write transaction, so the previous instance stores nothing more once it is taken over. It notices at its next save, reports a SqfliteSupersededException to its onError once, and persistence.superseded turns true; its cache stays usable, unpersisted. Reopen it (after close()) to read what the new writer stored.

persistence.mutationQueue keeps the client’s offline mutations in the same database, so a call queued while offline survives the app being killed:

final client = SlingClient<Query>(
endpoint: Uri.parse(endpoint),
schema: slingSchema,
cache: persistence.cache,
mutationQueue: persistence.mutationQueue,
);
  • A table of its own (sling_mutation_queue, one row per QueuedMutation, in insertion order): wiping the cache does not wipe it, and it is not part of sqfliteFormatVersion. Rows go through the codec like every other row.
  • Read at open, replayed by the client as soon as it is created. Entries are written at once (no debounce), on the same chain as the cache saves: an entry is stored before the save holding its optimistic values.
  • One writer per file applies: a superseded instance stores no entries.
  • A wiped cache (another format, key field, or schema without fields to migrate along) keeps the entries but drops their rollback logs — the optimistic values they would undo are gone with it. Another codec id drops them (they cannot be decoded); a row that does not decode is reported to onError and deleted; a recovered (deleted) file loses them.
  • persistence.clear() (logout) empties the cache and the stored queue. It cannot reach the client: call client.clearMutationQueue() first, which rolls the queued calls back, fails their futures and stops replaying them (see Sign-out) — queued mutations replay with the auth headers current when they are sent, so a queue left behind would run as the next user.

Any MutationQueueStore works (load, add, remove, clear; JSON via QueuedMutation.toJson/fromJson) — InMemoryMutationQueueStore is the default, and sharing one between two clients simulates a restart in a test.

Pass a codec: to transform every row value: an SqfliteCodec has an id and synchronous encode / decode of the row’s UTF-8 JSON bytes, so any cipher the app already uses fits (the package ships none and depends on none). Rows are then stored as blobs.

final class AesGcmCodec implements SqfliteCodec {
AesGcmCodec(this.key, this.keyId); // key from the platform keystore
final Uint8List key;
final String keyId;
@override
String get id => 'aes-gcm:$keyId';
@override
Uint8List encode(Uint8List bytes) => /* nonce + seal(bytes) */;
@override
Uint8List decode(Uint8List bytes) => /* open(bytes) */;
}

The id is stored: a store written under another id (another cipher, a rotated key) or without a codec is wiped at open instead of decoded. A decode that throws (a wrong key, a tampered row) is corruption: reported, wiped, cold start. With hydrateInIsolate the codec runs in the background isolate, so it must be sendable there (plain Dart fields, no native handles). For whole-file encryption, indexes and metadata included, pass an SQLCipher-backed DatabaseFactory (databaseFactory:) instead; the adapter only uses the sqflite_common API.

  • Logout: await persistence.clear() empties the cache (every mounted query refetches) and the database.
  • Other platforms / tests: the same parameter takes databaseFactoryFfi (sqflite_common_ffi, desktop and host tests) or sqflite_common_ffi_web. inMemoryDatabasePath gives a store that lasts one run.
  • Large stores: hydrateInIsolate: true decodes the rows and builds the cache in a background isolate (compute), keeping the decode off the UI thread.
  • Shutdown: persistence.close() saves what is pending and closes the database.

Host benchmark (flutter test benchmark/persistence_bench_test.dart in packages/sling_gql_sqflite; JIT on macOS through sqflite_common_ffi, ~600 B of JSON per entity, launches in pages of 50 per root field):

1 000 entities (0.6 MB) 10 000 entities (5.6 MB)
open: read the rows 4 ms 14 ms
open: decode + prune + hydrate 5–6 ms 46–76 ms
the same with hydrateInIsolate (off the UI thread) 6–8 ms 55–86 ms
changesSince: a new root field with 20 changed entities, in a session that wrote every entity 0.03 ms 0.03 ms
save of that delta 2 ms 2–3 ms
save after a gc of 60% of the entities 4–5 ms (6 ms without compact) 37–44 ms (70–92 ms without)
first save, or a full delta (every entity) 38–47 ms 165–190 ms

A save costs what changed: the entities and root fields written, plus a scan of the entities changed since the previous save. Opening is mostly jsonDecode of the rows (about 30 of the 50 ms at 10 000 entities); the decoded maps become the cache as they are, without a second copy. The heap holds about twice the JSON size, which maxEntities caps for entities (see Bounds).

Host tests run sqflite through sqflite_common_ffi (no simulator): pass databaseFactory: databaseFactoryFfi and a temporary path. In testWidgets, open, flush and close inside tester.runAsync, since the database answers on real time. An app’s own widget tests rarely need persistence at all: build the client with a plain Cache() (the example’s tests do), and nothing leaks from one run into the next.