Live OKF Context Sync
Sync a Google Open Knowledge Format (OKF) context folder to KalamDB and back — so the same Markdown files are available on every device or agent process that runs the sync worker.
This example is intentionally small: one schema.sql, one Node sync process, and no custom file server. KalamDB stores file bytes as real objects under the server storage directory (not as SQL BLOBs); the sync worker watches a local folder with chokidar for local edits and mirrors server state with a single ORM live subscription for remote edits.
For the full FILE upload and on-disk layout model, see /docs/server/architecture/file-upload-datatype.
Showcase only. KalamDB does not ship folder sync as a core product feature. This app shows how little code you need to layer OKF context sync on top of SQL +
FILE+liveTable()— one subscribe call, typed Drizzle rows, no polling loop.
Source: examples/live-okf-context-sync/
What is Google Open Knowledge Format (OKF)?
Google Open Knowledge Format (OKF) is a folder of Markdown files that agents and tools read as structured context: profiles, project notes, runbooks, policies, and preferences. Files often include YAML frontmatter so tools know what each document is for.
Teams using OKF usually need the same folder everywhere the user works — laptop agent, cloud agent, CI job, or second machine. This example keeps one canonical copy on the server and mirrors it to disk on each client.
What this example includes
| Piece | Role |
|---|---|
kalam/schema.sql | Single USER table — the only schema source of truth |
kalam dev | Starts KalamDB, applies schema, regenerates TypeScript types, runs the sync worker |
src/sync-app.ts | Tiny runnable entrypoint |
src/sync-engine.ts | Orchestrates push (chokidar), pull (liveTable), deletes, and one shared TaskQueue |
src/folder-watcher.ts | chokidar with ignoreInitial: false — rescans on every start |
src/remote-files.ts | ORM upsert (onConflictDoUpdate) + delete + FILE download |
data/ (default) | Your OKF Markdown files on disk |
data/.index/sync.db | Local SQLite cache (hashes + pending upload queue) — never uploaded |
seed/ | Starter files copied into an empty folder on first run only |
There is no web UI or demo agent in the current version. The focus is the sync loop itself: disk ↔ KalamDB ↔ one live query per client — no HTTP polling, no manual “refresh folder” timer.
Run it locally
Use three terminals: one for kalam dev, then two extra sync clients pointed at different local folders.
Terminal 1 — start KalamDB and the default sync worker
kalam dev starts the local KalamDB server, applies kalam/schema.sql, regenerates ORM types, and runs the configured sync worker on data/.
Wait until the logs include:
Terminal 2 — start a second folder as Alice
This command starts only another sync worker. It reuses the KalamDB server that kalam dev started in terminal 1.
Terminal 3 — start a third folder as Alice
Now you have three local folders (data/, data1/, data2/) syncing against the same user-scoped table in KalamDB.
Try add, edit, and delete
Run these commands from a fourth terminal, or edit the files in your editor:
Watch the three sync terminals while you do this:
That is the whole point of the example: one local folder change becomes a SQL row/file update, then liveTable() pushes the reconciled table state to every other sync process.
If a folder starts empty, it does not copy seed/ once the server already has files. It downloads the server files first, then starts watching for local changes.
One liveTable() subscription, full table state
The pull path is the heart of the example. After startup reconciliation, each sync worker opens exactly one live subscription:
That is the entire subscribe surface for server → disk updates. There is no second subscription for deletes, no per-file poll, and no setInterval against the REST API.
What KalamDB + @kalamdb/orm do under this call
context_files is a generated Drizzle table from kalam/schema.sql:
When you call liveTable(client, context_files, callback):
- SQL is compiled from the table definition —
@kalamdb/ormbuildsSELECT * FROM okf_sync.context_files(namespace comes from the client /kalam.tomlconnection). - Row typing is applied — WASM/live events are mapped through Drizzle column metadata (
file_ref→FileRef, timestamps →Date). - Primary key inference —
pathis the live reconciliation key (getKey: ['path']), so inserts, updates, and deletes merge into one materialized array in the Rust live core. - One WebSocket subscription — the first
liveTable()on a client opens the shared/v1/wssocket; this query registers once on that connection (client lifecycle). - Callbacks receive the full current row set — not individual
{ op: 'delete' }events you must fold yourself in simple mode. Every change re-delivers all rows that match the query right now, already deduplicated by primary key.
Conceptually:
This is Simple Realtime Mode: your application code works with rows[], the SDK applies initial_data_batch, insert, update, and delete internally. For a UI you render the array; for this example we diff path sets and mirror files.
Why a single subscribe is enough for folder sync
A naive design might subscribe per file path, poll GET /files/:id, or run a SQL query after every chokidar event. KalamDB instead keeps one table-level live query aligned with MVCC:
| Concern | How one liveTable() covers it |
|---|---|
| New file on another client | Row appears in rows; worker downloads bytes |
| Edit on another client | Row’s file_ref.sha256 changes; worker re-downloads |
| Delete on another client | Row disappears from rows; worker removes local file |
| Bulk delete (10 files) | One or few callbacks with shrinking rows; diff removes each path |
| Reconnect | Same subscription SQL resumes; SDK replays from checkpoint optional |
The worker stores the last known path set in remotePaths. On each callback:
Deletes are implied: if profile.md was in remotePaths but not in the latest rows, the server no longer has that row — remove the local Markdown file. You do not need a separate delete channel.
ORM vs raw SQL vs raw events
| API | What you write | What the callback receives |
|---|---|---|
client.liveEvents(sql, …) | SQL string | Low-level batches, acks, per-change events |
client.live(sql, …) | SQL string | Full materialized row array |
liveTable(client, table, …) | Drizzle context_files table | Same as live(), plus typed rows and compiled SQL |
The OKF example uses liveTable because:
- Schema lives in SQL →
kalam devregeneratesschema.generated.ts→ the subscription always matches the applied server schema. ContextFilestypes flow from ORM intopullRemoteRowwithout hand-written row parsers.file('file_ref')maps to KalamDB’sFILEcolumn type, so each live row carries a typedFileReffor download URLs.
If you needed raw events (metrics, incremental audit log), you would drop down to client.liveEvents(). For “keep this folder equal to this table,” liveTable is the intended API.
Serializing live with push (syncQueue)
The live callback can fire while chokidar is mid-push. The example shares one TaskQueue between:
- chokidar
onUpsert/onDelete→pushLocalFile/deleteRemoteFile liveTablecallback →handleLiveRows
So a live snapshot never interleaves with a half-finished FILE upload. FILE columns are versioned with MVCC; serializing avoids applying a file_ref from row version N while version N+1 is still uploading.
Multi-client correctness (3+ sync folders)
Run the same example three times — data/, data1/, data2/ — all as alice on the same server. Each process has its own liveTable() subscription and its own syncQueue, but all subscribe to the same logical query scoped to alice’s USER table partition.
When one client deletes many files:
- Local
unlink→deleteRemoteFile→ SQLDELETEon KalamDB. - Live core broadcasts a new materialized row set to every subscriber.
- Other clients diff
remotePaths→nextPathsand unlink locally.
The example adds tombstones (src/lib/sync-tombstones.ts) so a stale snapshot or a not-yet-updated folder cannot re-upload deleted bytes before the next live callback. That is example-level safety, not a KalamDB requirement — the important platform guarantee is still one query, one reconciled row array, pushed to all subscribers.
Architecture
Two stores, one logical folder:
- KalamDB — durable SQL metadata in RocksDB/Parquet plus file bytes as standalone objects under
kalam/server/data/storage/. Each row’sfile_refcolumn is aFileRefJSON pointer (id, sha256, size, mime, subfolder) — not the Markdown bytes themselves. Each INSERT/UPDATE creates a new MVCC row version with a new_seq;file_refis a normal column value inside that row. - Local disk — Markdown files you edit, plus
.index/sync.dbfor hashes and offline upload retries.
See FILE bytes on disk (not in SQL) for a sample storage path and how it differs from BLOB columns in other databases.
Startup order (current worker):
- Pull —
SELECTall remote rows once; download any missing files into an empty folder. - Push — chokidar initial scan + offline reconciliation; upsert local edits to KalamDB.
- Subscribe —
liveTable(context_files); all further server changes arrive through the single live callback.
Push-before-subscribe on the initial scan prevents a stale first snapshot from overwriting offline edits. Pull-before-push on empty local folders restores from the server without waiting for the first live batch.
FILE bytes on disk (not in SQL)
Most databases store uploaded content inside the row — PostgreSQL BYTEA, MySQL LONGBLOB, or similar. KalamDB does not. A FILE column holds a FileRef JSON document; the server writes the bytes to its configured storage folder or object store as a normal file.
Architecture reference: /docs/server/architecture/file-upload-datatype
What the sync worker sends
When you save data/profile.md, the worker upserts through Drizzle with kalamFile('upload', file). The driver sends multipart SQL; KalamDB finalizes the bytes under the table’s storage path and stores metadata in the row:
What you see in SQL vs on disk
After a push, query the row:
The file_ref column looks like JSON metadata (exact id values vary per upload):
The Markdown bytes are not in that JSON. They live as a separate object. With kalam dev, inspect the local storage tree (alice’s numeric userId comes from login — list it with kalam sql "SELECT user_id FROM system.users WHERE username = 'alice'" if needed):
You can open that file directly — it is the same content as data/profile.md on the client. Other clients download it through the file API using the FileRef (GET /v1/files/okf_sync/context_files/f0001/123…-profile.md), not by reading a BLOB out of a SELECT.
When you edit the same path again, MVCC appends a new row version with a new file_ref (often a new object path). Old objects may remain until cleanup; the live subscription always reconciles against the latest visible row set.
Schema
The whole server schema fits in one file:
path— relative OKF path (profile.md,notes/two-client-test.md).file_ref— server-managedFileRefJSON (id, sha256, size, mime, download path). Content hash lives on the ref; no duplicatesha256column in SQL.USER TABLE— rows and file downloads are scoped per user (Alice cannot read Bob’s files).
kalam.toml sets generate_types = true, so kalam dev regenerates src/models/schema.generated.ts from this SQL via @kalamdb/orm.
Source layout
Never synced: .index/, .git/, .DS_Store, SQLite WAL/SHM files.
How it works (detailed)
1. Startup (kalam dev)
kalam dev (from kalam.toml):
- Starts KalamDB locally (
kalam/server/data/) - Applies
kalam/schema.sqland regenerates TypeScript types - Runs
npm run dev -- data— the sync worker as user alice by default
On startup the worker:
- Ensures demo users
alice/bobexist - Logs in and loads existing remote paths from KalamDB (
select().from(context_files)) - Bootstrap: if
data/has no user files and the server has no rows, copiesseed/intodata/ - Initial pull: downloads server files missing on disk (empty folder / new sync dir restore)
- Initial push: chokidar scans
data/withignoreInitial: false, pushes changed files, reconciles offline deletions, flushes the pending upload queue - Live pull: one
liveTable(context_files, …)subscription — see One liveTable subscription - Watch: chokidar keeps running; push/delete events share the same
TaskQueueas live callbacks
Console output during initial sync:
2. Push — local edit → server
When you save or add a file under data/:
- chokidar fires
addorchange; the watcher logsfile '…' addedorupdated pushLocalFile(path)reads bytes and computes SHA-256- If local SQLite cache matches and nothing is pending, skip (no-op)
- Otherwise the worker upserts through Drizzle ORM with
kalamFile('upload', file)in.values()/.onConflictDoUpdate().set() - KalamDB stores bytes as a standalone object under
kalam/server/data/storage/and appends a new hot MVCC row with updatedfile_refJSON (see FILE bytes on disk) - Local SQLite records the hash; any pending-upload row is cleared
- Console logs
pushed path='…' _seq=… size=…
When you delete a file locally, chokidar fires unlink, logs file '…' deleted, and removes the row with db.delete(context_files).
If the server is down, the upload is queued in pending_uploads and retried on reconnect.
3. Offline edits (kalam dev stopped)
While the sync worker is not running you can add, edit, or delete files under data/. On the next kalam dev:
- Missing server files are downloaded first (initial pull)
- chokidar replays every file on disk (
ignoreInitial: false) and pushes local changes reconcileLocalDeletionsdeletes server rows for files removed while offlineliveTablestarts last — runtime sync is live-only after this point
4. Pull — server change → local disk (live subscription)
After startLivePull(), every server-side change to context_files flows through the same liveTable callback:
- SDK delivers
ContextFiles[]— full materialized query result - Paths gone since last callback →
removeLocalFile(live delete) - For each row, compare local SHA-256 with
file_ref.sha256 - Match → update local SQLite cache only
- Differ → download via
FileRef.getDownloadUrl(...), verify hash, write disk - Console logs
file '…' downloaded from server (size)orfile '…' deleted
The worker ignores the first empty callback (SDK warmup before initial_data_batch completes) so an empty rows array is never treated as “delete everything.”
See One liveTable subscription for the diff model and queue serialization.
5. Bootstrap rules
Local data/ | Server rows | What happens |
|---|---|---|
| Empty | Empty | Copy seed/ → push to server |
| Empty | Has rows | Download from server (no seed); then subscribe |
| Has files | Any | Download missing paths, push local changes, then liveTable reconciles |
Deleting data/ does not delete server data. On the next start, the worker pulls everything back from KalamDB.
6. Per-user isolation
Default sync user is alice (KALAM_USER / KALAM_PASSWORD). User-scoped rows and FILE downloads are enforced by KalamDB. Run a second worker as Bob:
Prerequisites
- Node.js 20+
- Kalam CLI with
kalam dev(CLI dev workflow)
Single-folder quick start
kalam dev starts the server and the sync worker on data/. Edit Markdown under data/ and watch the console for push/pull lines.
For the more useful multi-client demo, keep kalam dev running and start npm run dev -- data1 plus npm run dev -- data2 in two more terminals as shown in Run it locally.
Default credentials:
| Role | User | Password |
|---|---|---|
Server admin (kalam dev) | root | kalamdb123 |
| Default sync user | alice | alice123 |
| Second demo user | bob | bob123 |
Sync a different folder:
Verify it works manually
This is the most important end-to-end check: local folder is disposable; KalamDB is the source of truth.
Step 1 — Start with kalam dev
Wait until you see [sync] live subscription active in the logs.
Step 2 — Make changes under data/
In another terminal:
Confirm the sync worker logged push lines (for example pushed path='profile.md' _seq=… size=…). You can also query the server:
Step 3 — Stop the server
Press Ctrl+C in the terminal running kalam dev.
This stops the sync worker and the local KalamDB process started by the CLI. Server data remains on disk under kalam/server/data/.
Step 4 — Remove the local sync folder
You are deleting only the local OKF copy and its .index/sync.db. You are not deleting kalam/server/data/ — that is where KalamDB kept the SQL rows and the uploaded file objects under kalam/server/data/storage/ (see FILE bytes on disk).
Step 5 — Start again and confirm restore
Because the server still has rows (and seed/ is skipped when the server is non-empty), the worker:
- Starts with an empty
data/(except what it creates while pulling) - Initial pull downloads each server file (
pullInitialRemoteFiles) - Completes chokidar scan (usually no-op if nothing changed locally)
- Opens
liveTable— one subscription for all future updates
Expected result:
data/profile.mdcontains your edit from step 2data/notes/manual-test.mdexists with the same contentdata/.index/sync.dbis recreated locally- Console shows
file '…' downloaded from server (…)for restored files
Optional — offline edits while stopped
- Run
kalam dev, edit files, then stop with Ctrl+C - Add, edit, or delete files under
data/while the worker is stopped - Run
kalam devagain
Expected result: initial sync logs show added/updated/deleted counts; changed files are pushed before live pull starts.
If restore returns old content after multiple edits, ensure you are on a KalamDB build that includes the PK/MVCC read-path fixes (latest main or current release candidate).
Automated tests
Unit tests (no server):
Integration tests (server must be reachable on port 2900):
Notable integration cases:
delete local folder and restore from database— automates the manual restore walkthroughlocal changes while sync stopped are pushed on restart— add / edit / delete whilekalam devis stopped
Files worth reading
| File | Purpose |
|---|---|
kalam/schema.sql | USER table definition — only schema source |
kalam.toml | kalam dev wiring: schema, types, sync process |
src/sync-app.ts | Tiny CLI entrypoint: resolve folder + connection, then start the engine |
src/sync-engine.ts | Startup order, startLivePull, handleLiveRows, tombstones, shared TaskQueue |
src/folder-watcher.ts | chokidar with ignoreInitial: false |
src/remote-files.ts | ORM upsert (onConflictDoUpdate) + delete + download |
src/local-cache.ts | SQLite hashes and pending upload queue |
src/helpers.ts | Disk I/O, waits, serialized task queue |
src/sync-log.ts | Structured console logging |
src/lib/seed.ts | First-run copy from seed/ |
src/lib/paths.ts | Safe paths, ignore rules (.index, .git, etc.) |
src/lib/file-utils.ts | sha256, mime types, upload File builder |
src/models/schema.generated.ts | Generated ORM types for KalamDB |
src/models/schema.local.ts | Drizzle types for local SQLite |
KalamDB surface area used
| Feature | Usage in this example |
|---|---|
FILE column | Store OKF bytes; file_ref.sha256 for change detection |
USER TABLE | Per-user rows and download ACLs; live query scoped to sync user |
| MVCC rows | Each update = new row version; live materialization reads latest visible rows |
liveTable() (ORM) | One subscription → typed ContextFiles[] on every table change |
client.live() | Underlying transport; ORM compiles SQL + mapRow + primary key |
| Shared WebSocket | First live call opens /v1/ws; additional queries multiplex on same socket |
| Drizzle ORM | Reads, deletes, upsert (onConflictDoUpdate), and live table from one schema |
@kalamdb/orm kalamFile() + kalamDriver() | FILE upserts through normal Drizzle inserts |
kalam dev | Schema apply, typegen, local server, process supervisor |
End-to-end live path (reference)
Next steps
- Simple Realtime Mode —
live()/liveTable()materialized row sets - TypeScript ORM liveTable — Drizzle table → subscription SQL
- File datatype — how bytes are stored and downloaded
- Subscriptions — server-side live query engine
- Live query architecture — MVCC + push to subscribers
- kalam dev — one-command local workflow
- React AI Chat — browser client on the same live patterns