Skip to content
Loess

@loess/sdk

What an app imports: sign-in, circles, writes that show at once, offline too, and live queries over SQL. It runs a device on the device-store and keys packages. The device reads storage and writes to it, follows the relay, runs each circle's program before it shows a write, and commits a circle when the device or its identity is a listed committer.

import { Loess, layerBrowser, sql } from '@loess/sdk';
 
const loess = yield * Loess.open({ storage: 'https://host.example' });
const { device, phrase } = yield * loess.signUp; // show the phrase once
const feed = yield * device.circle('moments');
yield * feed.write([{ id: 'post:20261010-k3', field: 'post/caption', value: 'Lake day' }]);
const posts = feed.live(
  sql(Post)`SELECT id, value FROM stored_facts WHERE field = ${'post/caption'} AND current`,
);

Architecture shows where the device sits.

What it promises

A write is kept and shown before write returns, offline too, and it survives a reload. The circle's program runs on it first, so a write it rejects is never sent. A write fails only with LoessError, never because it was rejected. Every write that isn't settled is in Device.status until a commit settles it, and every rejected one, from the program here or from a committer, stays there with its changes until the app dismisses it. A reopened app that missed nothing reads nothing from storage.

Interface

NameWhat it is
Loess.open(options)opens Loess on the Platform in context and runs the device signed in there until the scope closes. storage is the URL where a new identity and its circles start
Loessstate, a stream of LoessState; signUp, which gives the device and the recovery phrase; signIn.passkey; signIn.phrase(text); signOut; and forget
Deviceidentity, circles, circle(label), join(invite, label), status, dismiss(write), devices, and unlock, which gives an Owner
Circleid, label, write, query, live, members, invite(role), remove(member), putBlob, readBlob, leave, signal, and signals
WriteId, WriteStatusa write on its own device, and where it is: unchecked, unsent, sent, or rejected with its Rejection and its changes
RowsRows.make(name, schema) gives a table of typed rows over facts, with id, insert, update, delete, and live
sql, Statementa read-only statement over stored_facts, from the database package
LoessError, SignInErrorthe failures, each with a reason a screen can act on

Loess.open also takes program, the program object new circles get, and now, the clock. signOut keeps the device's keys, so signing in to the same identity opens it as the same device, and forget drops them.

Platform.connect(url) gives storage, the relay, and the storage's committer service. When a device creates its first circle, it asks the service for its keys, lists the service as a committer in each new circle, and asks it with serve to commit the circle once storage holds the circle's first commit.

Device.unlock.phrase(text) and Device.unlock.passkey give an Owner, whose removeDevice(member) takes a device out of the identity and gives new keys to each open circle the identity owns. When the identity's keys change, on this device or another, each open circle takes the new keys, so a Circle from before keeps reading, writing, and streaming.

Rows.make('post', Post) stores each row as facts with the row's id, and each field as one fact field, post/caption, so any app can read the fields as facts. posts.id() makes an id such as post:<time>-<random>, and posts.id({ author, tag }) makes post:<tag>~<author>, so under the author program only that member changes the row. posts.live with no order gives rows in the order of their ids, which is the order they were made for ids without a tag. update takes expected values, which become each change's replaces, so a change made from a stale value is rejected with the current one.

Circle.putBlob seals a photo or a file and gives its reference: blob: followed by the hex of its hash. A fact refers to it with the value { id: reference }, and the outbox uploads the blob before it sends that write.

Circle.invite(role, { count }) commits an invite link that admits count people, 1 by default, and gives its text, which Device.join opens on another device. The joining device opens the circle with the link's keys, then its outbox for the circle sends the join request while its identity's outbox sends the circle's listing, and join returns once storage answers the request, so the joiner reads the circle and live and query work before a committer admits the identity. Writes made before then show at once and wait in the outbox, and the head that admits the identity, which a committer makes with no owner online, seals them as the identity and sends them. putBlob waits for that head, because only a member signs an upload. If storage refuses the request, as for a link another person already used, join fails with refused and takes the listing back. If the link is refused after join returned, as when an owner removes it or a commit rejects the request, the circle fails with refused as on a reload, Device.circles lists it as failed, and its waiting writes leave Device.status. If the request fails on the network, join still returns the circle, and the outbox keeps the request and sends the same bytes again, as it does a write, also after a reload or a second join. Storage counts the same bytes once against the link, and a committer settles them once, so a request sent twice admits the identity once. The identity's other devices open a listed circle the same way, without a request. Circle.remove(identity) also removes the link that identity joined with, so the link's secret opens no later key version.

A circle the identity lists but can't open stays in Device.circles, and each of its calls fails with the error. write and putBlob fail with not-open once the identity can't open an open circle's newest head, as after a removal. query and live keep reading what the device already holds, also after a reload.

Platforms

EntryGives
@loess/sdk/browserlayerBrowser({ storeName, passkeyName, domain }): the device store and keys in IndexedDB, the browser's passkeys, storage over HTTP, the relay over a WebSocket, and a Web Lock, so one tab per store runs the device
@loess/sdk/memorymemoryNetwork(): storage and a relay in this process, with a device store, keys, and a passkey authenticator for each device it makes, a count of each device's storage calls, and an offline switch

How it works

Each open circle has a session: the circle's database over the newest head the device checked, the pending transactions written on top, the outbox in the device store's state, and a follow on the relay. A new circle opens from the objects the device made, and its outbox sends the first commit before any write. The outbox uploads a write's blobs and keeps the write unsent until storage stores them, and a later write that changes or references the same ids or blobs waits behind it. Each send takes every unsent write that isn't waiting, in one request to storage, and storage passes each write to the relay as it arrives, so other open devices show it after one relay hop. The device sends a write only to storage, and sends only signals to the relay. A cache keeps every sealed object, transaction, box, and blob in the device store's objects, and a reopen answers the head from the one it checked last, so it reads nothing.

Sign-up commits an identity's head 1 derived from the phrase, then a record that adds the passkey as an owner and this device as a writer and a committer. A passkey's user handle holds the identity's id, and the identity's storage is the URL the app passes, because a user handle holds at most 64 bytes. Signing in with a passkey prompts twice: to pick the passkey, which gives its user handle and PRF output, and to sign the record that adds the device. The device signs the head itself, because the record lists it as a committer. Signing in with a phrase builds the same head 1 to find the id, and adds the device with no prompt.

The identity's facts list its circles: circle:<id> with circle/label, and circle/link for a circle joined by link. Circle.leave retracts the label on every device of the identity.

Not built

  • Sign-up offline: sign-up and a new circle need storage, and writes to the circle then work offline.
  • Owner actions other than removeDevice, such as adding a passkey, and new keys for a circle the identity is in but doesn't own after a device is removed.
  • Sealing unsent writes again after a key change, so storage refuses such a write, and it leaves the view at once.
  • An owner moving an admitted joiner into the member list.
  • A devices that streams.
  • Checking a head's committer against the sealed member list. The device reads another member's write key once a session.
  • A test of layerBrowser's committer service against the host Worker.

Tests

pnpm vitest run --project sdk runs devices, storage, the relay, and committers in process over memoryNetwork, and counts each device's storage calls: a write without blobs is one request, a device back online sends its unsent writes to a circle in one request, and a reopened app that missed nothing makes none. One test runs two devices against the host Worker in the Workers runtime, with the relay over a WebSocket as in a browser, and checks that a signal reaches the other device with its author. layerBrowser isn't tested in a browser yet.