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.
The core cannot see the device’s connectivity or lifecycle, so the app reports
both. client.setOffline(true) makes a preload that never resolved on this
device return at once instead of waiting for the server, so an app that holds
its first screen on a preload still opens when launched without a network.
Report it before the first preload. client.wake() on a resume or a returning
network reconnects at once instead of waiting out the backoff:
// connectivity_plus here; any source of the same signal worksbool none(List<ConnectivityResult> r) => r.every((c) => c == ConnectivityResult.none);client.setOffline(none(await Connectivity().checkConnectivity()));Connectivity().onConnectivityChanged.listen((r) { client.setOffline(none(r)); if (!none(r)) client.wake();});// and from your WidgetsBindingObserver@overridevoid didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) client.wake(); if (state == AppLifecycleState.paused) client.checkpoint();}
Once connected, the client registers its queries with the server concurrently:
remote reads (registrations, membership reads, row fetches, queryRemote)
share a pool of DatabaseConfig.maxConcurrentQueries requests on the one
socket, default 6. Writes from the outbox go one at a time, in order, in a lane
of their own, so a pending write never waits behind a startup’s registrations.
Peer to peer channels
Tables marked -- @channel can be carried over WebRTC between devices, so a
peer’s write (a cursor, a chat message, a move) shows up in tens of
milliseconds instead of after a sync round. Pass spooky_flutter’s
flutterWebRtc as ChannelsConfig(rtc: ...) and open one with
client.channel(query, peers). See Peer to peer channels.
Push notifications
The spooky_push plugin adds native push: APNs on iOS, FCM on Android, from the
same push: rules as the web, with no Firebase setup in the app. db.push in
spooky_core is the Dart side of it. See Native push.