Epicenter architecture

September 1, 2026 ยท View on GitHub

Epicenter is a local-first personal data platform. An application holds a complete replica of its own data and reads it synchronously; a hosted or self-hosted authority keeps a person's devices converged while they sleep.

This page is the five-minute map. Durable decisions live in docs/adr. Shared vocabulary lives in docs/CONTEXT.md. Package-owned current behavior belongs in package READMEs and code. For how this replaced the previous stack, verb by verb, see the store and what it replaced.

One runtime

A desktop SPA in a WebView, over a store the client owns (ADR-0227). The Bun host serves bundles and brokers credentials. It owns no application data and constructs no database (ADR-0226).

Serving that same bundle over HTTP is not a second runtime, because there is no platform seam left to differ: every build opens its own store. What ADR-0227 refused was a hosted surface that reached a host-owned replica instead.

The stack

+---------------------------------------------------------------------------+
| APPS                                                                      |
|                                                                           |
| honeycrisp   whispering   vocab   skills   epicenter   sync-lab           |
| api          self-host    landing  matter  local-books  local-mail        |
+---------------------------------------------------------------------------+
                                     |
                                     v
+---------------------------------------------------------------------------+
| SURFACE                                                                   |
|                                                                           |
| @epicenter/ui        @epicenter/app-shell     @epicenter/svelte         |
| @epicenter/chat      @epicenter/blobs         @epicenter/skills           |
+---------------------------------------------------------------------------+
                                     |
                                     v
+---------------------------------------------------------------------------+
| CORE                                                                      |
|                                                                           |
| @epicenter/data      the store and its definition, opener, sync, and SQL surfaces |
| @epicenter/field     release-local field declarations                     |
| @epicenter/sqlite    one engine seam over bun:sqlite and sqlite-wasm      |
| @epicenter/sync      route contracts a browser can import                 |
| @epicenter/server    the shared Hono library both deployables consume     |
+---------------------------------------------------------------------------+

@epicenter/data splits by what a caller has to load: . for the opened data surface, ./definition for defineData and parseData, ./browser for the one opener a person's data lands in, ./memory for test support, ./sync for the transport, ./direct for the construction seam, and ./artifact for the files a person keeps. The openers are separate because the memory opener imports bun:sqlite and the browser opener imports idb, and neither belongs in a barrel the other has to load. There is no ./projection: the packaged SQL follower was deleted (ADR-0269), and a derived index is now app-owned, in memory, and rebuilt on read (ADR-0307).

@epicenter/server and the core packages above it are AGPL. See licensing strategy.

An application has one database document

One database Y.Doc per application is persisted under the application log name app (ADR-0257). Its current top-level roots are the bare named root kv and one tables:<name> root for each declared table. Each table declares ordinary value fields and one required content codec.

Y.Doc "app"
 |- get("kv")               one value: this application's settings
 |- get("tables:notes")
 |   |- <rowId>             a nested Y.Type; holding it IS existing
 |   |   |- title           a field is an attribute on the row
 |   |   `- folderId
 |   `- <rowId> ...
 `- get("tables:folders")

A row is an attribute on its table root rather than a root of its own. That is not a style choice: Item.write scans doc.share linearly, so one root per row makes encoding quadratic, measured at 5,417 ms against 13 ms at 20,000 rows. Deletion removes the row's attribute outright and the whole subtree goes with it, which leaves one deleted map key rather than a permanent corpse. The row is flat at the public API: id, its value fields, and one live content node.

Content is one live node on the row

The content codec only maps that node to and from the artifact body:

const row = data.tables.notes.get(noteId);
row?.title;
row?.content; // the live Y.Type an editor binds to directly

Storage mints an empty content node when a row is created without one, and deleting the row removes the node with the row. Lists and previews read value fields without opening another document; editors bind the row's live node.

What granularity an edit has

editmerge
two devices, different fields of one rowboth survive
two devices, one value fieldlast write wins
two devices, one array or object fieldlast write wins on the WHOLE value
two devices, an edit inside a row's content nodeper character

The third row is a decision, not a gap (ADR-0228). A field is one value, which is one sentence of semantics instead of a per-field CRDT type system. The cost is that a set several devices append to concurrently loses an addition, and the answer is that such a collection wants to be a table, where each element is its own row and nothing collides.

Data definitions never migrate user data

A data definition is a release-local view over durable JSON (ADR-0255). A release may add a field, remove one, or change validation. Rows that no longer conform stay exactly as written and surface as nonconforming for that release. Nothing copies a database, runs an upcaster, or reinterprets an old write.

Prevention is not available and asking for it is the wrong axis. A declaration is release-local and rows arrive from NEWER releases, so no discipline in this release stops a future one retyping a field. What exists instead is the material to heal: rows and nonconforming are separate table reads, each failure carries its address, machine-readable issues, the conforming survivors and the unmodified raw, and repair is an ordinary update because a patch validates only the values it supplies. A derived index cannot help here: it is built from rows that conformed, so a repair surface finds its subjects through nonconforming rather than through SQL.

durable JSON stays unchanged
        |
        +-- old release's declaration -> one interpretation
        `-- new release's declaration -> typed rows plus nonconforming diagnostics

Reads are synchronous

Opening a store is the only asynchronous operation in an application. It is real I/O: a file or an IndexedDB read, and the replay of a durable log. Everything after it is a property access on a document already in memory.

const { data, error } = await openDatabase(honeycrispDefinition, {
	generation,
});
if (error !== null) throw error;

const rows = data.tables.notes.rows;
const nonconforming = data.tables.notes.nonconforming;
data.tables.notes.update(noteId, { title: 'x' }); // a transaction
data.tables.notes.subscribe(() => { ... });       // a table commit touched

subscribe names the rows a commit touched (ADR-0221), so a view refreshes what moved rather than everything. SQL, when an application wants it, is a follower it composes over this surface, rebuilt from the live document at the next read (ADR-0241). The package shipped one and nothing composed it, so it was deleted (ADR-0269): a person who wants to read their data outside the app reads the export, which is Markdown files (ADR-0268).

Where the durable facts live

The store keeps the update log and the authority positions a crash cannot reconstruct (ADR-0238, amended by ADR-0300). Each update record carries its authority position, so the outbox and cursor are read from the same updates store. There is no worker and no OPFS, and nothing derived is restored, only rebuilt.

History lives outside the CRDT (ADR-0214). The document runs with garbage collection on, which is what collapses a field edited five thousand times to two structs.

The authority owns availability, not meaning

One Durable Object per principal, application, and generation, named principals/<id>/data/<dataId>/generations/<generation> (ADR-0292, ADR-0298). It appends opaque bytes and reads nothing about their meaning.

Being signed in is the whole of the sharing model. The route stamps the principal from the bearer and addresses one Durable Object by it, so every device on one account converges without anything being paired or invited.

The host supplies only dial, a function that makes a socket. The library owns the cursor, attach and detach, reconnect on close and on needsResync, and the unacknowledged-submission watchdog (ADR-0222).

Blobs are a separate plane and were never CRDT-backed. They are content addressed bytes logged against the server, with local ones queued until they are uploaded.

Two deployables, one library

packages/server is the shared Hono library. apps/api is the hosted personal cloud and apps/self-host is the self-hosted single-partition instance reference, which is community-supported rather than Epicenter-operated. They differ by principal resolver: an instance resolves every valid bearer to the literal instance principal (ADR-0075, amended by ADR-0092). Billing is hosted-only and lives in apps/api/worker/billing/.

What is broken right now

ADR-0227 was executed as a clean break, so the applications that had not moved are broken on purpose and their data on the old stack is gone: apps/whispering, apps/vocab, apps/skills, apps/epicenter, packages/chat, packages/skills, and packages/app-shell's agent chat. Green: packages/data, packages/sync, packages/sqlite, packages/svelte-utils, apps/api, apps/self-host, apps/honeycrisp, and apps/sync-lab.