@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 fails with Rejected at once and is never sent. Every write that isn't settled, and every rejected one, is in Device.status until a commit settles it or the app dismisses it. A reopened app that missed nothing reads nothing from storage.
Interface
| Name | What 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 |
Loess | state, a stream of LoessState; signUp, which gives the device and the recovery phrase; signIn.passkey; signIn.phrase(text); signOut; and forget |
Device | identity, circles, circle(label), join(invite, label), status, dismiss(write), devices, and unlock, which gives an Owner |
Circle | id, label, write, query, live, members, invite(role), remove(member), putBlob, readBlob, leave, signal, and signals |
WriteId, WriteStatus | a write on its own device, and where it is: unchecked, unsent, sent, or rejected with its Rejection |
Rows | Rows.make(name, schema) gives a table of typed rows over facts, with id, insert, update, delete, and live |
sql, Statement | a read-only statement over stored_facts, from the database package |
LoessError, SignInError, Rejected | the 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) commits a one-use invite link 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
| Entry | Gives |
|---|---|
@loess/sdk/browser | layerBrowser({ 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/memory | memoryNetwork(): 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. Each send takes every unsent write of the circle in one request to storage, made while the writes' blobs upload, 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. If a committer rejects a write with blob because its upload outlasted the committer's 60-second wait, the outbox writes the same changes again as a new write, so the app sees it as still sending. 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
devicesthat 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.