Remote / foldkit-remote
Persistence and hydration
A snapshot is { entities, connections }. The entity cache is disposable and
always goes; a connection goes only if named, and keeps its edges alone — never
a cursor, which may name server state that is gone. Session machinery (live
cursors, optimistic layers, mutation bookkeeping, gaps, retention roots) is
never in one.
import { RemotePersistence } from 'foldkit-remote'
// A cache carried in the page: prefetch on the server, embed, then hydrate.
// `snapshotOf` names the connections that survive; none, here. For a page
// the browser resumes with foldkit-ssr, see Server rendering below.
const text = RemotePersistence.dehydrate(
RemotePersistence.snapshotOf(loaded.remote),
{ scope: userId },
)
const restored =
RemotePersistence.hydrate(text, { scope: userId }) ?? RemotePersistence.emptySnapshot
Data.reduce(model, {
_tag: 'Hydrated',
entities: restored.entities,
connections: restored.connections,
merge: 'preserve-existing',
})
// Or persist through Effect's KeyValueStore.
RemotePersistence.save(RemotePersistence.snapshotOf(model.remote), {
key: 'remote-cache',
scope: userId,
maxBytes: 512_000,
})
RemotePersistence.restore({
key: 'remote-cache',
scope: userId,
maxBytes: 512_000,
})Snapshots are deterministic, versioned, scoped, and optionally size-bounded. A
snapshot from another version/scope, an oversized snapshot, or malformed data is
discarded and the planner refetches. Hydrated is itself a Remote Message, so
hydration still changes the application through the reducer.
A cache kept across reloads: Data.persistence
The calls above are the parts. Data.persistence puts them together as a
wiring: it restores the snapshot stored under a key, and saves the cache under
that key whenever it changes.
const persistence = Data.persistence({
key: model => `remote-cache:${model.userId}`,
scope: model => model.userId,
maxBytes: 512_000,
})
// Assembled beside `Data.wiring`; the runtime provides a `KeyValueStore`,
// such as `BrowserKeyValueStore.layerLocalStorage`.
const placements = Page.assemble(Data.wiring(actives), persistence)The restore and the save each run as Subscriptions, when the key changes or the cache does:
key -> restore -> Hydrated { from: key } -> restoredFrom = key -> save on each change- A restore keeps what the Model already holds. It hydrates with
merge: 'preserve-existing', so a fact resumed from the server, or read since the page started, wins over the older snapshot. - So a change of principal is
Data.forget, in the sameupdate. What one person read, left in memory, would be kept over the next one's snapshot and then saved under their key;forgetclears it as the key changes. Thescopecheck refuses a snapshot written for another reader; it cannot see facts that are only in memory. - Nothing is saved before its own key is restored.
Hydratedrecords the key it came from inmodel.remote.restoredFrom. Until that key is restored, nothing is saved, so the empty store at startup cannot overwrite the snapshot it has not read yet. When the key changes (a different principal), saving stops again until the new key's snapshot is in. - A store that cannot be read restores nothing, and so nothing is saved over it. A stored snapshot from another version or scope, or one that will not decode, is removed, and an empty snapshot is restored in its place.
- Saving is debounced (
debounce, 250 ms by default) and best-effort: a full store keeps the snapshot it had, and the next change tries again. The write waits; the snapshot does not: it is serialized on each change of the cache, so under a steady stream of live changes keep it small, withmaxBytesor asnapshotthat stores only what a page shows. A cache grown pastmaxBytesremoves its key, so an older, smaller snapshot cannot outlive it.
snapshot replaces snapshotOf when an application keeps something else, such
as only the edges a page shows.