Skip to content
Loess

@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 wrote

Interface

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

  • objects is 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.
  • state holds what must never be lost, like unsent actions. update(key, change) swaps by compare-and-swap and reruns change when another tab or fiber wrote first, so change must expect to run more than once. Returning undefined deletes the key, and returning stored itself writes nothing. A failure of change stores nothing, and every failure reaches the caller as a StoreError that says why: full, replaced, blocked, newer, or failed.
  • 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

  • .: the DeviceStore service and StoreError.
  • ./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 failing change stores 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, and quota makes 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, replaced completes, and its state calls fail as replaced. 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 in migrations under 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 as failed. 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 which isCache is 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):

KeptWhereWhat it isKind
The sign-instate accountThe identity and storage this device is signed in todevice
A circle's outboxstate outbox/<circle>The circle's unsettled and rejected writes, their unsent blobs, and the last counterdevice
The device keysthe database <store name>-keysThe device's signing and sealing keys, in @loess/keys, with a version of their owndevice
A circle's checked headstate held/<circle>The newest head the device checkedcache
Objects, transactions, boxes, and blobsevery objectSealed bytes under their storage keyscache

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.