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.

ts
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.

ts
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 same update. What one person read, left in memory, would be kept over the next one's snapshot and then saved under their key; forget clears it as the key changes. The scope check 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. Hydrated records the key it came from in model.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, with maxBytes or a snapshot that stores only what a page shows. A cache grown past maxBytes removes 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.