For the nerds
Under the hood
omajot is written in Zig 0.17 and QML. This page shows the parts, most of them animated: the architecture, the CRDT, the edits that cross on a pipe, the hub, the limits, the one core that runs natively and in the browser, and the one binary.
Architecture
One hub stores and forwards changes. Every device keeps a full copy of all notes. The hub never merges anything: it only moves operations and attachments. The dots below are operations on their way.
Tailscale connects the devices. tailscale serve terminates HTTPS with a real certificate and adds the caller's identity as a header. The hub listens on loopback only and admits exactly one login. The plugin talks to the daemon over its standard input and output; omajot tui and the note commands use the daemon's unix socket. All of them speak the same JSON lines.
The CRDT
You can edit the same note on a laptop in a plane and on a phone at the same time. When both come online, omajot merges the edits. It uses a CRDT (conflict-free replicated data type), written from scratch in Zig. Step through it:
✓ The same text on both devices
- Text: an RGA sequence. Every inserted character has an id and remembers its left neighbour. Runs of typed text are one item, not one item per character.
- Order: inserts after the same neighbour are sorted by descending id, first the counter, then the replica. Every replica sorts the same way, so every replica gets the same text.
- Ids: Lamport counters shared by all replicas. An insert of n characters uses n counters.
- Deletes leave tombstones, so later operations can still point at deleted text.
- Folder, pin, trash, names: last-writer-wins registers, ordered by a hybrid logical clock, then by replica and counter.
- Folder moves: a move that would make a cycle is dropped. The folder becomes a top-level folder.
- Order of arrival does not matter: an operation that arrives before the operation it depends on waits inside the engine.
Operations are small JSON objects, for example {"v":1,"k":"ins","r":"…","c":42,"t":…,"n":"…","o":"…","s":"hello"}. The hub stores them without reading them.
Edits and patches
The editor and the engine run in different processes. You can type while a change from another device arrives. Then your edit and the remote patch cross on the pipe:
omajot solves this with two-party operational transformation, in the style of the Jupiter system:
- The client numbers its edits (
seq). The engine numbers its patches (pseq). - Each edit carries
ack: the last patch the client applied. The engine transforms the edit over newer patches. - Each patch carries
base: the last edit the engine applied. The client transforms the patch over newer edits. - Remote changes win ties on both sides, so both sides get the same text.
The reference is src/core/ot.zig. The QML plugin and the web app port it line by line and test their ports. Undo works on your own edits only. The built-in undo of a text field would also undo changes from other devices, so omajot replaces it.
The hub
The hub is a log with a doorbell. Watch one change arrive: the laptop pushes a batch, the hub appends it and calls fsync, rings the doorbell, the phone pulls, and a resent batch gets its old number back.
- Append-only log: every accepted batch of operations goes to
batches.jsonl. The hub callsfsyncbefore it replies. After a crash, it drops a torn last line and continues. - Idempotent pushes: each client numbers its batches. A repeated batch gets its original sequence number, so a client can resend after any failure.
- Pages: clients pull batches after their cursor, in pages of at most 1 MiB or 1 000 batches.
- SSE doorbell:
GET /api/eventssends only the newest sequence number. A client then pulls. A lost or repeated event is harmless, because clients compare numbers and never count events. Browsers reconnect withLast-Event-ID. - Blobs: attachments are stored under their SHA-256 hash. The hub checks the hash of every upload.
- The web app is part of the hub binary. A new hub serves a new web app, and the web app switches to it by itself.
Everything bounded
The hub runs on baz, a Zig web framework on the bounded/http engine (io_uring on Linux, kqueue on macOS). Both follow one rule: set explicit limits, and reserve memory and threads before the first request. When a limit is reached, the server refuses or waits. It does not grow.
A simulation with the real number of slots. Bursts fill every slot; then new requests wait. No slot and no buffer is ever added.
omajot sets its limits as named constants in the source. The build of this site reads them from the code:
| Limit | Value | Where |
|---|---|---|
| Connections | 24 | src/hub/hub.zig |
| Worker threads | 4 | src/hub/hub.zig |
| Waiting event streams | 64 | src/hub/hub.zig |
| Memory budget of the HTTP engine | 256 MiB | src/hub/hub.zig |
| Request body (one batch or one blob chunk) | 1 MiB | src/hub/store.zig, blobs.zig |
| Attachment | 16 MiB, in chunks of 1 MiB | src/hub/blobs.zig |
| Request deadline | 30 s | src/hub/hub.zig |
| Event stream route deadline | 600 s | src/hub/hub.zig |
| Idle keep-alive wait | 60 s | src/hub/hub.zig |
| Event stream ends before the deadline | 10 s | src/hub/hub.zig |
| Heartbeat on idle event streams | 15 s | src/hub/hub.zig |
| Web app files | 512 files, 128 MiB in total | src/hub/static.zig |
| Log loaded at start | 4 GiB | src/hub/store.zig |
Why request bodies are 1 MiB
bounded/http reserves the body buffers of all connections when it starts: two buffers of max_body per connection. With 24 connections:
So a batch has at most 1 MiB, and a large image goes up in 1 MiB chunks. The hub resumes an interrupted upload at the last good chunk.
Deadlines
In bounded/http, a deadline covers the whole request, also a long event stream. The hub uses three separate limits:
- Normal requests: 30 s. This covers one batch or one image chunk.
- The event stream: its own route deadline of 600 s (a baz route option). The hub ends each stream 10 s before this deadline, with a clean end of the stream. The client reconnects at once.
- Idle keep-alive connections: 60 s. A request's own deadline starts at its first byte, so the idle time before a request does not shorten it. A client that sends on a connection the hub just closed retries once on a new one.
The per-route deadlines and the separate idle timeout came into baz and bounded/http during the work on omajot.
One core, native and WASM
The core in src/core/ holds the CRDT, the note model, the protocol engine, the HTML-to-markdown converter and the QR encoder. It is pure Zig: no std.Io, no clock, no randomness, no global state. The caller passes the allocator and the time. Bytes go in, bytes come out.
core.wasm, ReleaseSmallcore.wasm- Native:
omajot daemonlinks the core. It adds files, the network and the clipboard around it. - WebAssembly:
zig build wasmcompiles the same core forwasm32-freestanding. The web app loadscore.wasmand adds IndexedDB andfetcharound it. - One vocabulary: the plugin sends JSON lines to the daemon; the TUI and the note commands use the daemon's socket. The web app sends the same JSON to
omj_call. All get the same replies and events. - One markdown renderer for the previews: the plugin runs the web app's renderer (
web/src/markdown.js) in Qt's JavaScript engine, so the plugin and the web app read notes the same way. - UTF-16 positions: QML, JavaScript and CodeMirror count text in UTF-16 units. The core stores text the same way, so no side converts positions.
- One QR encoder:
omajot qrand the hub print it in the terminal. The plugin draws it from the daemon's reply. The web app draws it fromcore.wasm.
// the whole wasm interface (src/wasm/wasm.zig)
omj_alloc(len) → ptr omj_free(ptr, len)
omj_engine_new(replica_lo, replica_hi) → handle
omj_call(handle, ptr, len, now_ms) → result // one request, reply + events
omj_ingest(handle, ptr, len) → result // operations from the hub
omj_take_new_ops(handle) → result // operations to send
omj_pending(handle) omj_engine_free(handle) omj_free_result(result)
One static binary
omajot is one program. The hub, the daemon, the terminal UI and every note command are subcommands of the same file, and the web app is inside it.
Servers
hubdaemonFor people
tuiqrstatus--versionNotes, for people and agents
lscatsearchnewwriteeditappendreplacemvrmmkdirrmdirtagshistoryrestoreexportInside
the Zig corethe web app (654 KB)libvaxis (TUI)baz · bounded/http- Static on Linux: the default builds use the musl ABI. The TUI dependency links libc, which musl includes statically. A root module's
link_libc = falsedoes not override its dependencies. Baz and the bounded/http engine permit applications with or without libc. - Release size: build with
zig build -Doptimize=safe -Dstrip=trueto retain safety checks and strip debug information. The table below records the earlier 0.16 binary sizes; it does not measure the 0.17 port. - Cross-compiled: one Linux runner builds all five release targets, in parallel. The release workflow pins their SHA-256 in
release.jsonand updates the Homebrew formula.tools/install-release.shand the plugin check a download against the pinned hash before they install it. - The same core, also in the browser: the pure core goes to
wasm32-freestandingtoo, ascore.wasm(170 KiB).
| Release target | Size (stripped, ReleaseSafe) |
|---|---|
x86_64-linux-musl | 3.53 MB |
aarch64-linux-musl | 3.17 MB |
aarch64-macos | 3.18 MB |
x86_64-macos | 3.51 MB |
x86_64-windows-gnu (experimental) | 3.98 MB |
Measured on 2026-09-29 with Zig 0.16.0, cross-compiled on one x86_64 Linux computer.
Tests
The most important property is convergence: replicas that got the same operations, in any order, show the same notes. The core tests that with random operations, delivered late, out of order and twice:
✓ 4 replicas, 1 text
- Convergence: 4 replicas make 150 random changes each, for 30 seeds. They exchange operations late, out of order and twice. At the end, every replica must show the same notes, folders and text.
- Editor and engine: for 100 seeds, a simulated editor types while remote patches cross its edits. The text in the editor must equal the text in the engine.
- Transform: the edit and patch transform converges under random delays, for 300 seeds.
- Replay: a replica that replays its own log gets the same state back.
- QR codes: a test encodes every length from 1 to 213 bytes, across all ten QR versions.
- End to end: two browsers sync through a real hub, go offline and come back; a new web app version reaches an open browser. Two daemons sync through the hub over Tailscale.
- The terminal UI: a private tmux drives
omajot tuiwith keys and mouse clicks: selection, search, the editor, checkboxes, links, a daemon restart, resizes, a panic and SIGTERM. The terminal must come back unchanged.
$ zig build test
$ npm test
$ cd web && npm test && npm run e2e
$ python3 tools/tui_e2e.py