How it works
SQLite on every device, an append-only op log with hybrid logical clocks, last-writer-wins fields, fractional ordering, and a markdown mirror.
nooklet has three parts: a server written in TypeScript on Node, a client that runs in a browser (and inside the desktop and iOS apps), and a shared core library both of them use. The design decisions, with the alternatives they beat, are in docs/adr.
SQLite everywhere#
The server keeps each graph in one SQLite file (graphs/<id>/graph.sqlite). Every client keeps its
own SQLite copy of the graph: the browser runs SQLite compiled to WebAssembly inside a worker and
stores the file in the origin-private file system (OPFS).
Both sides use the same schema and the same applyOps function from packages/core. When you
type, the client writes to its local database first. The screen never waits for the network.
The server also holds things clients do not: tokens, the audit log, embeddings, assets and the markdown mirror.
Every write is an op#
Every change, whether it comes from the editor, an agent over MCP, the HTTP API or an import,
becomes a list of small ops: page.create, page.rename, page.prop, page.delete,
block.create, block.place, block.text, block.prop, block.delete. Each op targets one
entity and one field.
The server appends every op it accepts to a log, numbered by seq. The tables you read from
(page, block, their properties) are a pure function of that log. nooklet verify proves it on
your data: it replays the whole log into a scratch database and diffs the result against live
state, row by row.
Hybrid logical clocks#
Each op carries a hybrid logical clock (HLC) stamp: wall-clock milliseconds, a counter for events in the same millisecond, and the device id. HLC stamps sort in a total order that respects cause and effect, even when device clocks disagree by a little.
The server refuses a push from a device whose clock runs more than 60 seconds ahead of its own. A clock that far ahead would win every conflict for as long as it stayed ahead. The device shows an error until you fix its clock.
Last-writer-wins, per field#
Each field of each block is its own register. When two devices change the same field, the op with the later HLC wins on every device. Different fields never conflict: you can retitle a block on your phone while your laptop moves it, and both changes survive.
A block's position (page, parent, order) is one field, so a move happens in one piece.
Text gets one extra step. If a device pulls a text change for a block it has also edited and not
yet pushed, it runs a three-way merge against the text both edits started from. Edits to different
parts of the block combine. If the edits overlap, the later one wins and the device keeps the other
text in a conflict_copy property on the block, so nothing disappears. (That presentation is
being replaced with something easier to read.)
- Buy oat milk
- scheduled:: 2026-10-05
- seq 40block.create
- seq 41block.text
- seq 42block.prop
- Buy oat milk
- scheduled:: 2026-10-05
Sibling order: fractional indexing#
Order among siblings is a short string key, not a linked list. To put a block between keys a0
and a1, a device mints a key that sorts between them, such as a0V. Moving a block changes only
that block's key, so two devices reordering different blocks never collide. Equal keys tie-break
on block id.
The server checks structure#
Some combinations of valid ops make an invalid tree. Two devices, offline, can move block A under B and block B under A. Each move is fine alone; together they make a cycle.
The server applies pushed ops in arrival order and rejects any move that would create a cycle in its own state. When it rejects one, it emits a corrective op with a fresh HLC that puts the block somewhere valid, and every device applies it on the next pull. While the correction is in flight, a device may show the block under an "Unplaced" heading for one round trip.
The same applies to names: if a device creates a page under a name another device took first, the server decides, and the losing device learns the outcome from its next pull.
Devices apply the server's log in seq order#
Devices sort their own fresh ops by HLC. Ops that come out of the server's log they apply in
the server's seq order, the same order the server applied them in (ADR 026).
This matters for the checks that depend on order, such as a page name that was freed and then
reused. Suppose device A's clock runs a few seconds behind, and A creates a page "Ideas" right
after the server deleted an older "Ideas". Sorted by HLC, A's create would come before the delete
and collide with the old page. Applied in seq order, every device meets the same state the
server met, and they all end with A's page. A property test with three devices, lagging clocks and
replicas pulling in random page sizes checks this.
- Saturday
- Return library books
- Call the plumber
- seq 126block.text
- seq 127block.place
- seq 128block.create
- Saturday
- Return library books
- Call the plumber
The sync protocol#
POST /sync/push: a device sends its queued ops. The server validates, applies, logs, and returns any corrections.GET /sync/pull?since=<seq>: a device fetches ops after its cursor, inseqorder.GET /sync/snapshot: a new device downloads the current state instead of the whole history./sync/live: a WebSocket that only carries "something changed" pokes. Data still moves through push and pull, so a dropped socket costs latency, not correctness.
A device writes an edit and its outbox entry in the same SQLite transaction. A crash between typing and syncing loses nothing.
Every graph lives under its own prefix, /g/<graph-id>/, so the paths above are
/g/<graph-id>/sync/push and so on. One server process hosts many graphs, each with its own file,
log and tokens.
The markdown mirror#
The server writes each page to graphs/<id>/pages/<Page name>.md and each journal day to
graphs/<id>/journals/, a moment after it changes. The format is an outline Logseq and Obsidian can
read:
type:: project
- TODO Read the ADR on sync ^1m433dkhgaxame
- started on the HLC part ^1m433dkhgaxamf
The ^id suffix is the block's stable id, in Obsidian's block-id syntax. Agents see the same
format when they read a page.
The mirror is one-way: nooklet writes the files and does not watch them. Edit through the app or
the API; an edit made in the file will be overwritten the next time that page changes. Delete
pages/ and journals/ and the server rewrites them. Turn the mirror off with
nooklet serve --no-mirror.
- block.create
- block.text
- block.create
- The Dispossessed ^1m433dkhgaxame - Piranesi, finished in March ^1m433dkhgaxamf - Klara and the Sun ^1m433dkhgaxamg
$ grep -rn Piranesi pages/ Reading list.md:2: Piranesi, finished… $ git log --oneline ● 9c41e0a reading list ● 51d2b7f journal ● e07a3c2 garden shed
Search and embeddings#
Every replica has a full-text index, so search works on the device. The server keeps a second index
with a trigram twin for substring matches, and optionally block embeddings in sqlite-vec. A block
is embedded with its breadcrumb (page and ancestors) and a little of its children, so a short
bullet like "call him back" still carries its context.
Hybrid search fuses keyword and vector results by reciprocal rank fusion in one SQL statement. Embeddings never sync to devices; devices ask the server.