@loess/device-store
What a device keeps between sessions, in two parts with different promises: a cache that may drop anything and never fails, and state that is never lost and changes only by compare-and-swap, so two tabs never lose each other's writes.
import { DeviceStore } from '@loess/device-store';
const store = yield * DeviceStore;
yield * store.objects.keep(pageKey, sealedPage); // may be dropped later
const page = yield * store.objects.read(pageKey); // undefined on a miss
yield * store.state.update('unsent', (stored) => Effect.succeed(appendAction(stored, action))); // reruns if another tab wroteInterface
class DeviceStore extends Context.Service<DeviceStore, {
objects: {
read(key: string): Effect<Uint8Array | undefined>
keep(key: string, bytes: Uint8Array): Effect<void>
}
state: {
read(key: string): Effect<Uint8Array | undefined, StoreError>
update<failure>(
key: string,
change: (stored: Uint8Array | undefined) => Effect<Uint8Array | undefined, failure>,
): Effect<void, StoreError | failure>
}
replaced: Effect<void> // completes once another tab upgraded or deleted the store
}>
// ./indexeddb
function layerIndexedDbDeviceStore(databaseName: string, versions: DeviceStoreVersions): Layer<DeviceStore, StoreError>
type DeviceStoreVersions = {
device: number // what only this device holds
migrations: Record<number, (state: ReadonlyMap<string, Uint8Array>) => ReadonlyMap<string, Uint8Array>>
cache: number // what the device can fetch again
isCache: (key: string) => boolean // the state keys that are cache
}What it promises
objectsis a cache of things that never change, like sealed pages. The store can drop any of them at any time, and its calls never fail, so a failure is a miss. A device without storage still works.stateholds what must never be lost, like unsent actions.update(key, change)swaps by compare-and-swap and rerunschangewhen another tab or fiber wrote first, sochangemust expect to run more than once. Returningundefineddeletes the key, and returningstoreditself writes nothing. A failure ofchangestores nothing, and every failure reaches the caller as aStoreErrorthat says why:full,replaced,blocked,newer, orfailed.- A key is a string the caller lays out. A bucket key works as is for a whole object, and the caller picks the keys of its own state. The two parts don't share keys.
- A full disk fails state writes and only drops objects.
Entries
.: theDeviceStoreservice andStoreError../test:testDeviceStore, the contract every adapter runs. It opens two tabs on one database and checks that an object kept with room reads back, that the two parts don't share keys, that updates never lose each other's writes, that a failingchangestores nothing, and that a full disk fails state writes and only drops objects../memory:layerMemoryDeviceStore, for tests. Each build of its layer is another tab on the same store, andquotamakes it run out of space../indexeddb:layerIndexedDbDeviceStore(databaseName, versions), for browsers. Each tab opens its own connection. When another tab upgrades or deletes the database, this tab closes its connection so the other doesn't wait,replacedcompletes, and its state calls fail asreplaced. Across deploys says what an open does with a store another deploy kept.
Across deploys
A caller keeps two kinds of data, and each has its own version in DeviceStoreVersions:
device: what only this device holds, which no host can give back. A change takes the next number and a step inmigrationsunder it, which turns every state key of the version before into the new one. An open runs the steps from the kept version up, in the one IndexedDB upgrade transaction, so a step that throws leaves the store as it was and the open fails asfailed. A version with no step fails the same way and changes nothing.cache: what the device can fetch again from a host: every object, and the state keys for whichisCacheis true. A change, up or down, drops all of it in that same transaction, and the device fetches what it opens again.
A store a newer deploy kept, at a higher device version, fails to open as newer and stays as it was, so the tab running old code reloads instead of losing what the newer code wrote. A store kept before the two versions were apart had one number for both, its IndexedDB version, so it reads as device version 1 with that number as its cache version. The versions live in the name of an empty object store, so an open reads them with its one request, and a full disk can't refuse to record them. A warm open is one request, and an upgrade is two.
What the SDK keeps, by kind (deviceStoreVersions in @loess/sdk is the list in code):
| Kept | Where | What it is | Kind |
|---|---|---|---|
| The sign-in | state account | The identity and storage this device is signed in to | device |
| A circle's outbox | state outbox/<circle> | The circle's unsettled and rejected writes, their unsent blobs, and the last counter | device |
| The device keys | the database <store name>-keys | The device's signing and sealing keys, in @loess/keys, with a version of their own | device |
| A circle's checked head | state held/<circle> | The newest head the device checked | cache |
| Objects, transactions, boxes, and blobs | every object | Sealed bytes under their storage keys | cache |
Dropping a checked head costs one guarantee: the device trusts storage's newest head again, as a new device does, instead of refusing one older than what it saw.
Place in the stack
It imports no @loess package and runs on devices. The SDK uses it and picks the adapter.