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.)

Laptoponline
  • Buy oat milk
  • scheduled:: 2026-10-05
Outboxempty
Your server
  1. seq 40block.create
  2. seq 41block.text
  3. seq 42block.prop
 
Phoneonline
  • Buy oat milk
  • scheduled:: 2026-10-05
Outboxempty
Step 6 of 6: Both devices show “Buy oat milk” with the date. The edits touched different fields, so both survive.

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.

Phoneonline
  • Saturday
  • Return library books
  • Call the plumber
Outboxempty
Your server
  1. seq 126block.text
  2. seq 127block.place
  3. seq 128block.create
 
Laptoponline
  • Saturday
  • Return library books
  • Call the plumber
Outboxempty
Step 8 of 8: “Call the plumber” appears on the laptop. Both devices’ cursors read 128.

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, in seq order.
  • 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.

Server database
  1. block.create
  2. block.text
  3. block.create
the truth
pages/Reading list.md
- The Dispossessed ^1m433dkhgaxame
- Piranesi, finished in March ^1m433dkhgaxamf
- Klara and the Sun ^1m433dkhgaxamg
a copy, rewritten on change
Your tools
$ grep -rn Piranesi pages/
Reading list.md:2: Piranesi, finished…
$ git log --oneline
● 9c41e0a reading list
● 51d2b7f journal
● e07a3c2 garden shed
Step 5 of 5: The next op on that page rewrites the file from the database, and the hand edit is gone. Edit through the app or the API instead.

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.

Where to read more#