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
| edit | merge |
|---|---|
| two devices, different fields of one row | both survive |
| two devices, one value field | last write wins |
| two devices, one array or object field | last write wins on the WHOLE value |
| two devices, an edit inside a row's content node | per 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.