How it works
This page is the on-device half: how a write lands in local SQLite and how reads stay reactive. The networked half — the sync loop, the encrypted envelope, and the relay — lives in Sync & the relay.
For the API you actually call, see Client API.
The three layers
SimplySync is three layers, each doing one job:
| Layer | Package | Job |
|---|---|---|
| Engine | @simplysync/engine |
Local SQLite store, the HLC clock, the outbox, the sync loop, reactive queries. The client API. |
| Protocol | @simplysync/protocol |
The crypto and wire primitives: identity derivation, the AES-GCM envelope, blob chunking, HLC encoding. |
| Relay | relays/* |
A dumb, opaque event store. Holds ciphertext + routing metadata. Optional and self-hostable. |
┌─ Engine (on device) ─────────────────────────────────┐ ┌─ Relay (optional) ─┐
│ insert/update ─▶ local SQLite row (source of truth) │ │ │
│ └▶ encrypt ─▶ outbox ──────────────────┼─push▶ opaque event store │
│ reactive query cache ◀─ refresh on change │ │ (ciphertext only) │
│ poll loop: pull ─▶ decrypt ─▶ apply (LWW) ─▶ row ◀───┼─pull─ │
└───────────────────────────────────────────────────────┘ └────────────────────┘
▲ this page ▲ Sync & the relay
The crucial property: all encryption and decryption happen in the engine, on the device. The relay only ever moves opaque bytes — covered in Sync & the relay.
The local database is the source of truth
The engine opens one SQLite database per name. From your schema it creates a
physical table per entry, with id as the primary key plus three system columns
appended to every table:
create table "entry" (
"id" text primary key,
"description" text,
"amountCents" real,
"categoryId" text,
"createdAt" text,
"updatedAt" text,
"isDeleted" integer default 0
);
Alongside your tables it maintains three internal ones — the entire sync state machine, all just rows you can inspect:
_sync_outbox(event_id text primary key, envelope_json text, pushed_at text)
_sync_seen(event_id text primary key) -- dedupe pulled events
_sync_meta(key text primary key, value text) -- HLC high-water mark + per-relay cursor
Reads go straight to your tables. Writes go to your tables and the outbox, in
the same step. (_sync_outbox, _sync_seen, and the cursor are consumed by the
sync loop.)
The Hybrid Logical Clock
Every write is stamped with an HLC so edits from different devices order
deterministically. createHlc(deviceId, now, counter) produces
base36(now,10)-base36(counter,4)-deviceId — a fixed-width string whose
lexicographic order is physical time, then counter, then device id (a stable
tiebreaker). compareHlc(null, x) returns -1, so "no value" always loses.
Monotonicity is the engine's job: it persists the last (time, counter) to
_sync_meta (hlc:time, hlc:counter) and, within the same millisecond,
increments the counter — so a backward wall clock or a restart can't mint an HLC
that sorts below existing rows (which would make last-writer-wins silently
discard a newer edit).
The write path
When you call engine.insert / update / upsert:
- Validate & coerce the values against the typed schema; an invalid value
returns
{ ok: false, error }and writes nothing. - Upsert the row into your local SQLite table (instant; the UI can read it
immediately), stamping
updatedAtwith a fresh HLC. - Encrypt an envelope for the change and insert
JSON.stringify(envelope)into_sync_outboxwithpushed_at = NULL. - Refresh affected reactive queries, so every
useQueryover that table re-renders (see the read path). - Kick a sync opportunistically, if a relay is configured.
The plaintext inside the envelope is a SyncPayload:
{ schemaVersion, table, op: "upsert" | "delete", rowId, hlc, value }. How it's
encrypted and pushed is in Sync & the relay.
Deletes are soft. A delete sets the isDeleted system column and emits a
normal envelope; queries filter where isDeleted = 0. You never hard-delete a
row — a real delete couldn't propagate or lose to a later edit.
The read path
Queries are built once and cached, and they re-render reactively when their data changes — from a local write or an incoming sync.
engine.createQuery((db) => …)compiles your Kysely builder to a SQL string- parameters once. The result is a
Query<R>whose row type is inferred.
- parameters once. The result is a
- The query cache keys each query by
sql::paramsand holds a stablerowsarray reference, so React'suseSyncExternalStoreonly re-renders when the data actually changes. It also records which tables each query touches (by scanning the compiled SQL). - On a change,
refreshQueries(changedTables)re-runs only the queries whose tables overlap the change, swaps theirrows, and fires their listeners. Mutations pass the table they wrote; a sync passes every table it applied. useQuery(query)subscribes to that entry;getQueryRowsreads the cached snapshot synchronously;loadQuerydoes a one-shot eager read.
So a write to entry re-runs every cached query that references "entry" — and
nothing else — then those components re-render. No manual invalidation, no
refetch wiring.
Building, testing, running
bun install
bun run test # turbo: all package tests
bun run typecheck # turbo: all typechecks
bun run demo # relay + two clients + sync, one shot
bun run relay:go # canonical Go relay (needs Go 1.25+)
bun run docs:dev # these docs + the browser Demo on :4200
bun run --cwd examples/cli blob-demo # encrypted image round-trip
Next: Sync & the relay → — how the engine pushes and pulls, the encrypted envelope, the relay's wire protocol, storage, and threat model.