For the nerds
Under the hood
omajot is written in Zig 0.16 and QML. This page explains the parts: the architecture, the CRDT, the hub, the limits, and the one core that runs natively and in the browser.
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.
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 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.
- 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.
- Ids: Lamport counters shared by all replicas. An insert of n characters uses n counters.
- 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 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 the text field would also undo changes from other devices, so omajot replaces it.
The hub
- 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.
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.
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, 16 MiB bodies would reserve 24 × 2 × 16 MiB = 768 MiB. 1 MiB bodies reserve 48 MiB. 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.
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.
This makes one core possible for all devices:
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 web app sends the same JSON to
omj_call. Both get the same replies and events. - 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: omajot hub, omajot daemon and omajot qr are subcommands of the same binary.
- Static on Linux: native Linux builds use the musl ABI (
build.zig:default_target .abi = .musl). There are no glibc shared objects to match.lddreports "not a dynamic executable", andfilereports "statically linked". - Small: a release build (
zig build -Doptimize=ReleaseSafe -Dstrip=true) is about 2 MB. Safety checks stay on in ReleaseSafe. - Cross-compiled: one Linux runner builds all five release targets. The release workflow pins their SHA-256 in
release.json.tools/install-release.shand the plugin check a download against it before they install it. - libc: omajot's own code does not need libc. baz and bounded/http still link libc. Upstream changes to remove this are in progress. Then Linux builds can drop libc completely.
- The same core, also in the browser: the pure core goes to
wasm32-freestandingtoo, ascore.wasm(199 KiB).
| Release target | Size (stripped, ReleaseSafe) |
|---|---|
x86_64-linux-musl | 1.89 MB |
aarch64-linux-musl | 1.58 MB |
aarch64-macos | 1.49 MB |
x86_64-macos | 1.69 MB |
x86_64-windows-gnu (experimental) | 2.18 MB |
Measured on 2026-09-25 with Zig 0.16.0, cross-compiled on one x86_64 Linux computer.
Tests
- 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. Two daemons sync through the hub over Tailscale.
$ zig build test
$ npm test
$ cd web && npm test && npm run e2e