This guide walks the example apps in order of ambition. The examples are the spine: each section points at real files, and the diff between the tier-1 and tier-2 examples is itself the answer to "what changes as my app grows up?"
- The tier ladder
- The session lifecycle every app shares
- Tier 1: one document (
examples/save-file) - Growing up: from tier 1 to tier 2
- Tier 2: collections and wallet login (
examples/notes) - Data rules that apply everywhere
- Testing your app
BYOE apps differ along several axes -- one resource vs many collections, app-private vs well-known interop collections, how much the app does before "Login with Wallet" -- but in practice those axes correlate into a ladder of tiers, each a superset of the one below:
| Tier | Shape | Example app | Library entry point |
|---|---|---|---|
| 1 | One app-private document, key-value read/write | A text editor; an Excalidraw-style canvas | defineDocumentApp / useAppDocument |
| 2 | Read/write CRUD on one or two well-known interop collections | A contacts manager; a notes app | createEntityStore + WasAppConfig |
| 3 | Several well-known and app-specific collections, encrypted and public-readable | A microblogging client | (planned) |
The tiers are library layers, not different app architectures: a tier-1 app is expressible without ever seeing entity stores, grants, or sync internals, and moving up a tier means reaching for a lower-level library entry point -- not rewriting your app.
@interop/was-react models "not connected to a wallet yet" as a first-class
product state, not a developer mode. The session moves through four states:
boot-- attempt to restore a persisted session, open the local encrypted store, hydrate. Both successor states are fully usable; render a splash or skeleton here.local-- fully functional with no wallet and no server: an encrypted local RxDB replica under an anonymous device-local identity. All hooks and stores work; there is just no sync. A tier-1 app may stay here forever.connected-- everything inlocal, plus wallet-derived identity, granted capabilities, and background replication to the user's Space.reconnect-- granted access expired or was revoked: the replica stays usable (reads and local writes), remote calls are paused until re-login.
"Login with Wallet" therefore attaches sync and remote identity; it does not gate the app -- unless you ask it to. Each app declares its onboarding style in its config:
onboarding: 'local-first'-- the router renders the app in thelocalstate; connecting is an affordance inside the app (tier 1, and tier-2 apps that want "try before you log in").onboarding: 'login-gated'-- the library'sProtectedRoutesends not-connected users to your login page (classic tier-2 behavior).
Two consequences of taking the local state seriously:
- Adoption. Data created before connecting is real data. On the first login,
the library offers to merge it into the newly granted collections
(per-document last-writer-wins), or to set it aside on this device. The notes
example wires this with
useHasLocalData+AdoptDialog; the save-file example's connect button adopts silently (one document, nothing to ask about). - Vocabulary. In
localmode there is no identity to log out of, so apps should never label the reset action "Logout." The save-file example calls it Clear Data: it destroys the local replica after a confirm dialog that offers a download first. Keeping "log out" (detach identity) and "clear data" (destroy the replica) as visibly separate operations is part of the point of BYOE -- users learn where their data actually lives.
Text Editor is a one-textbox app whose entire model is that text. The whole
was-react wiring is one call in
app.config.ts:
export interface TextDocument {
text: string
}
export const { config, registry, useAppDocument } =
defineDocumentApp<TextDocument>({
appName: 'Text Editor',
appOrigin: APP_ORIGIN,
document: {
collectionId: 'text-editor-document',
initial: { text: INITIAL_TEXT }
},
credential: {
credentialType: 'TextEditorAppKey',
vocabBase: 'urn:text-editor:vocab#'
},
dbName: 'text-editor',
storageKeyPrefix: 'text-editor:'
})main.tsx spreads the returned config and registry into the standard
WasSessionProvider, and every component consumes the typed hook:
const { doc, update, status, exportFile, importFile, connect, disconnect } =
useAppDocument()docis the current document (TextDocument), falling back todocument.initialonce boot completes;update(patch | fn)merges and persists.exportFile/importFilemove the document as a tagged JSON file, so the app is complete with zero WAS infrastructure -- the local / download / cloud triad users know from Excalidraw or draw.io.connect()runs a CHAPI wallet login requesting a grant for exactly one collection (the app's sandbox collection), so the wallet's consent screen shows a single legible request. The local document is adopted into the granted collection, and background sync takes over.statusmoves'local' -> 'connected'(or'syncing'/'reconnect').
What the tier-1 developer never sees: entity stores, the store registry, grants, replication internals. If your app is one document, this is the whole API -- the rest of the example is editor code.
Diff the two examples and every difference is one of these five moves:
- One document becomes named collections.
document: { collectionId }becomes aCOLLECTIONSlist in aWasAppConfig, mapping each app-sidekeyto a WAS collectionid. Interop lives in those ids:notesis deliberately generic, shared by any app that speaks "notes," where tier 1'stext-editor-documentwas deliberately app-private. useAppDocumentbecomes entity stores. Each collection gets acreateEntityStore<T>(key)(a zustandMap<uuid, T>hydrated from the encrypted replica) and aStoreRegistryentry wiring itshydrate/upsert/drop/clearhandlers, so login, remote sync, and logout can drive it.- LWW stamping becomes your job. The
useAppDocumentfacade stampedupdatedAt/clientIdfor you; entity payloads must carry them explicitly (see Data rules). connect()becomes a login page. Withonboarding: 'login-gated', the library'sProtectedRoutegates the app and your login page drivesuseLogin-- offeringAdoptDialogwhen local data exists.- The chrome grows. A tier-2 app wants the
SyncStatusChip, theReconnectBanner, and an explicit logout -- the notesAppShellis the reference wiring.
If a step up the ladder ever requires substantially different app code rather than a different library entry point, that is a library gap -- file an issue rather than working around it.
BYOE Notes is a CRUD app over one well-known collection. The moving parts, each one file:
src/app.config.ts-- env exports, theCOLLECTIONSlist, and the oneWasAppConfig, including theonboardingknob (wallet mode is login-gated; dev mode is local-first).src/stores/notes.ts-- theNotetype, its entity store, and theStoreRegistry.src/pages/LoginPage.tsx-- CHAPI login viauseLogin, with the adoption dialog when the anonymous replica holds data.src/pages/NotesPage.tsx-- list/add/edit/delete against the store'sinsert/update/removeverbs; every write stamps the LWW fields.src/components/AppShell.tsx-- top bar withSyncStatusChip,ReconnectBanner, logout.src/App.tsx-- the route table:/loginoutside the gate, everything else behindProtectedRoute.
Dev mode (VITE_AUTH_MODE=dev) is not a parallel code path: it is the same app
in the local session state, optionally "connected" to a real WAS server
through provisioned dev grants (pnpm run provision:dev +
VITE_WAS_DEV_SYNC=true) -- which exercises the same adoption and sync path a
wallet login drives, with CHAPI bypassed.
The notes README has the full renaming walkthrough for turning the example into your own app.
- Every entity payload carries
updatedAt(ISO timestamp) andclientId. Sync resolves conflicts last-writer-wins on that pair; a payload missing them loses every conflict. TheuseAppDocumentfacade stamps them for you; entity-store apps stamp them on every insert and update (get the device id from the library'sgetClientId()). - Collection ids are the interop surface. Use a generic, unprefixed id
(
notes,contacts) when you intend other apps to read the same data; use an app-named id (text-editor-document) for app-private sandbox data. The two examples show both: notes asks for the sharednotesid, the editor keeps its document to itself. - Local storage names are the opposite of the interop surface.
dbNameandstorageKeyPrefixname the encrypted replica and the device/seed keys in this browser, so give every app its own. Left at the library defaults, two apps served from one origin (two examples onlocalhost, say) collide on a single database, and the second to open it dead-ends inboot. Pin a distinct dev-server port per app for the same reason. - Everything is encrypted client-side. The WAS server stores opaque JWE
envelopes; it can neither read nor search plaintext. This also means the key
material for the anonymous
localreplica lives in browser storage -- on a shared machine, "Clear data" is the user's only real protection. - Ask only for the collections you use. The wallet consent screen shows exactly what the app requests; the library builds the request from your configured collections and nothing else.
Both examples ship the pattern worth copying -- test tiers ordered by infrastructure cost:
- Node unit tests (Vitest +
fake-indexeddb) for stores and pure logic. - Offline browser tests (Playwright, dev/local mode): the whole UI against the encrypted local replica, no servers. This is the CI tier -- and for a tier-1 app it covers nearly everything, because the app is fully functional offline.
- WAS-backed browser tests (notes only): boots a real local
was-teaching-server, provisions dev grants, and exercises real replication, multi-device convergence, and adoption -- still no wallet in the loop. - Wallet browser tests (notes only, local/manual): the full CHAPI Login With Wallet flow against a local wallet and WAS server.
Root pnpm run test:browser runs each example's offline tier; the heavier tiers
run from examples/notes (see its
testing docs).