Bucket reads are not HTTP: they are SurrealQL over the sync socket, one at a
time behind the app’s own queries, so a cover that is re-read on every screen
is paid for in full every time. bucket().read() goes through a local blob
cache instead. Give it a directory and the bytes stay on disk under
<directory>/<user>/<bucket>/<path>; a warm read never touches the socket, and
spooky_flutter’s BucketImage adds Flutter’s own decoded-image cache on top,
so a shelf scrolled back into view paints on its first frame.
lib/sync/database.dart
// pubspec.yaml// dependencies:// spooky_core:// git: { url: https://github.com/mono424/sp00ky.git, path: packages/spooky_core }// spooky_flutter:// git: { url: https://github.com/mono424/sp00ky.git, path: packages/spooky_flutter }final client = Sp00kyClient(Sp00kyConfig( database: DatabaseConfig(endpoint: endpoint, namespace: 'app', database: 'app'), schema: spookySchema, schemaSurql: surqlSchema, // Keep bucket files between runs. Without a directory the cache is // in-memory: reads still dedupe, nothing survives a restart. blobCache: BlobCacheConfig( directory: '${(await getApplicationSupportDirectory()).path}/sp00ky-blobs', ),));// Anywhere in the widget tree:BucketImage( bucket: client.bucket('puzzle_covers'), path: '${coverKey}_t.webp', fallback: const CoverFallback(), fit: BoxFit.cover,)// Or the bytes, for anything that is not an <img>:final Uint8List? pdf = await client.bucket('exports').read('report.pdf');
BucketImage shows a cached image as soon as it has decoded, with no
placeholder flash and no fade; only a load that outlives instantWindow
(120 ms by default, in practice a first download) paints the fallback and
fades the image in over it.
The cache is the same shape as the web client’s (buckets):
files never expire by age, the least recently used are evicted past
maxBytes (default 256 MB), files are kept per signed-in user, and
put/delete on a path invalidate it. A missing file is remembered for the
process, so a row pointing at a cover that was never uploaded costs one remote
read, not one per rebuild. bucket.evict(path) and bucket.prefetch(paths)
give the app manual control; clearOnSignOut: true wipes a user’s files when
they sign out.
Startup
await client.init() restores the circuit from a snapshot of the local store
and reconciles it against the cached rows, without touching the network, so a
query registered right after it paints from cache. The core writes that
snapshot itself while the circuit has changed (shortly after boot, then every
circuitCheckpointMs, default 30 s); also call client.checkpoint() when the
app is paused, since it is free when nothing changed. A store without a
snapshot still boots, from its rows, and gets one on the next checkpoint.
To have a screen’s first frame already hold its rows, keep that query open
from startup (subscribe once, share the latest rows) rather than subscribing
when the screen mounts.