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.
flutter pub add sling_gql_sqfliteSetup: open before the client
Section titled “Setup: open before the client”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.
The design: a write-behind copy
Section titled “The design: a write-behind copy”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 asnapshotof 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 (anAppLifecycleListener), and onpersistence.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
savedVersionwhere it was; the next change (orflush()) saves everything since the last stored version. Background failures go toonError(defaultFlutterError.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 biggcstays a delta — its removals become a fewDELETE … WHERE key IN (…)statements. Passcompact: falsewhen something else readscache.changesSincefrom older versions (the example’s cache screen does): it would get full deltas. - Full deltas. After
cache.clear()changesSinceanswers with every entity (few, the cache was just emptied): that save empties and rewritessling_entities. Without compaction a cache also forgets its removals once there are more than a thousand, and more than live entities (a biggc), 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.
Bounds
Section titled “Bounds”Applied once, when the store is opened (null disables either):
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 oldupdated_at.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).- 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.
Corruption
Section titled “Corruption”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.
Restored data is stale
Section titled “Restored data is stale”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).
Schema changes: migration
Section titled “Schema changes: migration”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;
--scalarmappings 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.
One writer per file
Section titled “One writer per file”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 aStateError: 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
openwins. It records itself as the writer insling_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 aSqfliteSupersededExceptionto itsonErroronce, andpersistence.supersededturns true; its cache stays usable, unpersisted. Reopen it (afterclose()) to read what the new writer stored.
The mutation queue
Section titled “The mutation queue”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 perQueuedMutation, in insertion order): wiping the cache does not wipe it, and it is not part ofsqfliteFormatVersion. Rows go through thecodeclike 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
onErrorand deleted; a recovered (deleted) file loses them. persistence.clear()(logout) empties the cache and the stored queue. It cannot reach the client: callclient.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.
Encryption
Section titled “Encryption”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, other platforms
Section titled “Logout, other platforms”- 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) orsqflite_common_ffi_web.inMemoryDatabasePathgives a store that lasts one run. - Large stores:
hydrateInIsolate: truedecodes 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.
Numbers
Section titled “Numbers”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).
Testing with a persisted cache
Section titled “Testing with a persisted cache”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.