Skip to Content
Live OKF Context Sync

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

PieceRole
kalam/schema.sqlSingle USER table — the only schema source of truth
kalam devStarts KalamDB, applies schema, regenerates TypeScript types, runs the sync worker
src/sync-app.tsTiny runnable entrypoint
src/sync-engine.tsOrchestrates push (chokidar), pull (liveTable), deletes, and one shared TaskQueue
src/folder-watcher.tschokidar with ignoreInitial: false — rescans on every start
src/remote-files.tsORM upsert (onConflictDoUpdate) + delete + FILE download
data/ (default)Your OKF Markdown files on disk
data/.index/sync.dbLocal 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

BASH
cd examples/live-okf-context-syncnpm installkalam dev

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:

TEXT
[sync] initial sync for folder '.../data' completed: ...[sync] live subscription active

Terminal 2 — start a second folder as Alice

BASH
cd examples/live-okf-context-syncnpm run dev -- data1

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

BASH
cd examples/live-okf-context-syncnpm run dev -- data2

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:

BASH
cd examples/live-okf-context-sync # Add a file in data1. It should appear in data/ and data2.mkdir -p data1/notescat > data1/notes/two-client-test.md <<'EOF'# Two-client test This file was created in data1 and should sync through KalamDB.EOF # Wait until liveTable has delivered the file to data2.while [ ! -f data2/notes/two-client-test.md ]; do  sleep 0.2done # Edit it from data2. data/ and data1 should receive the new content.cat >> data2/notes/two-client-test.md <<'EOF' Edited from data2.EOF # Wait until the edit has reached data/.while ! { [ -f data/notes/two-client-test.md ] && grep -q "Edited from data2." data/notes/two-client-test.md; }; do  sleep 0.2done # Delete it from data/. data1 and data2 should remove it too.rm data/notes/two-client-test.md

Watch the three sync terminals while you do this:

TEXT
[sync] pushed path='notes/two-client-test.md' _seq=123456789 size=96 B[sync] file 'notes/two-client-test.md' downloaded from server (...)[sync] file 'notes/two-client-test.md' deleted

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:

typescript
private async startLivePull(): Promise<void> {  this.liveUnsub = await liveTable(this.client, context_files, (rows) => {    this.syncQueue.enqueue(() => this.handleLiveRows(rows));  });  console.log('[sync] live subscription active');}

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:

typescript
export const context_files = kTable.user('context_files', {  path: text('path').primaryKey(),  file_ref: file('file_ref').notNull(),  created_at: timestamp('created_at', { mode: 'date' }),  updated_at: timestamp('updated_at', { mode: 'date' }),});export type ContextFiles = typeof context_files.$inferSelect;

When you call liveTable(client, context_files, callback):

  1. SQL is compiled from the table definition@kalamdb/orm builds SELECT * FROM okf_sync.context_files (namespace comes from the client / kalam.toml connection).
  2. Row typing is applied — WASM/live events are mapped through Drizzle column metadata (file_refFileRef, timestamps → Date).
  3. Primary key inferencepath is the live reconciliation key (getKey: ['path']), so inserts, updates, and deletes merge into one materialized array in the Rust live core.
  4. One WebSocket subscription — the first liveTable() on a client opens the shared /v1/ws socket; this query registers once on that connection (client lifecycle).
  5. 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:

TEXT
  KalamDB                           @kalamdb/client + ORM              sync-engine.ts  ───────                           ─────────────────────              ───────────  INSERT / UPDATE / DELETE          live core materializes rows        handleLiveRows(rows)  on okf_sync.context_files   ──►   mapRow → ContextFiles[]      ──►   diff paths → disk I/O  (MVCC row versions)               callback(rows)                     (download / unlink)

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:

ConcernHow one liveTable() covers it
New file on another clientRow appears in rows; worker downloads bytes
Edit on another clientRow’s file_ref.sha256 changes; worker re-downloads
Delete on another clientRow disappears from rows; worker removes local file
Bulk delete (10 files)One or few callbacks with shrinking rows; diff removes each path
ReconnectSame subscription SQL resumes; SDK replays from checkpoint optional

The worker stores the last known path set in remotePaths. On each callback:

typescript
private async handleLiveRows(rows: ContextFiles[]): Promise<void> {  const nextPaths = new Set(rows.map((row) => row.path).filter(isSafeSyncPath));   // … skip empty first snapshot (SDK warmup) …   for (const path of this.remotePaths) {    if (!nextPaths.has(path)) {      await this.removeLocalFile(path);   // live delete → local unlink    }  }  this.remotePaths = nextPaths;   for (const row of rows) {    await this.pullRemoteRow(row);        // insert/update → download if hash differs  }}

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

APIWhat you writeWhat the callback receives
client.liveEvents(sql, …)SQL stringLow-level batches, acks, per-change events
client.live(sql, …)SQL stringFull materialized row array
liveTable(client, table, …)Drizzle context_files tableSame as live(), plus typed rows and compiled SQL

The OKF example uses liveTable because:

  • Schema lives in SQL → kalam dev regenerates schema.generated.ts → the subscription always matches the applied server schema.
  • ContextFiles types flow from ORM into pullRemoteRow without hand-written row parsers.
  • file('file_ref') maps to KalamDB’s FILE column type, so each live row carries a typed FileRef for 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 / onDeletepushLocalFile / deleteRemoteFile
  • liveTable callback → handleLiveRows
typescript
// folder watcher enqueues onto the same queue passed from the sync engine{ taskQueue: this.syncQueue } // live pull enqueues toothis.syncQueue.enqueue(() => this.handleLiveRows(rows));

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:

  1. Local unlinkdeleteRemoteFile → SQL DELETE on KalamDB.
  2. Live core broadcasts a new materialized row set to every subscriber.
  3. Other clients diff remotePathsnextPaths and 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

TEXT
  data/                         KalamDB (okf_sync.context_files)  ├── index.md                  ┌─────────────────────────────────┐  ├── profile.md                │ path TEXT PRIMARY KEY           │  └── .index/                   │ created_at, updated_at          │      └── sync.db (local only)  └─────────────────────────────────┘           │                              ▲           │  push (chokidar + upsert)    │  pull (liveTable + download)           └──────────────────────────────┘                    sync-engine.ts

Two stores, one logical folder:

  1. KalamDB — durable SQL metadata in RocksDB/Parquet plus file bytes as standalone objects under kalam/server/data/storage/. Each row’s file_ref column is a FileRef JSON 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_ref is a normal column value inside that row.
  2. Local disk — Markdown files you edit, plus .index/sync.db for 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):

  1. PullSELECT all remote rows once; download any missing files into an empty folder.
  2. Push — chokidar initial scan + offline reconciliation; upsert local edits to KalamDB.
  3. SubscribeliveTable(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:

typescript
await db  .insert(context_files)  .values({    path: 'profile.md',    file_ref: kalamFile('upload', upload),    updated_at: now,  })  .onConflictDoUpdate({    target: context_files.path,    set: { file_ref: kalamFile('upload', upload), updated_at: now },  });

What you see in SQL vs on disk

After a push, query the row:

BASH
kalam sql "SELECT path, file_ref FROM okf_sync.context_files WHERE path = 'profile.md'"

The file_ref column looks like JSON metadata (exact id values vary per upload):

JSON
{  "id": "1234567890123456789",  "sub": "f0001",  "name": "profile.md",  "size": 142,  "mime": "text/markdown",  "sha256": "7f83b1657ff1fc53b92dc18148a1d65dfc2d4b1fa3d677284addd200126d9069"}

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

TEXT
examples/live-okf-context-sync/kalam/server/data/├── rocksdb/                         # hot SQL rows (FileRef JSON, not file bytes)└── storage/                         # FILE objects (actual bytes)    └── okf_sync/context_files/{userId}/        └── f0001/            └── 1234567890123456789-profile.md   # real file on disk

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:

SQL
CREATE NAMESPACE IF NOT EXISTS okf_sync; CREATE USER TABLE okf_sync.context_files (  path TEXT PRIMARY KEY,  file_ref FILE NOT NULL,  created_at TIMESTAMP DEFAULT NOW(),  updated_at TIMESTAMP DEFAULT NOW());
  • path — relative OKF path (profile.md, notes/two-client-test.md).
  • file_ref — server-managed FileRef JSON (id, sha256, size, mime, download path). Content hash lives on the ref; no duplicate sha256 column 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

TEXT
examples/live-okf-context-sync/  data/                         # default sync folder (gitignored)    .index/      sync.db                   # local SQLite — never synced    index.md    profile.md  seed/                         # copied into data/ on first run only    index.md    profile.md  kalam/    schema.sql                  # source of truth    server/data/                # KalamDB server data (persists across restarts)  src/    sync-app.ts                 # tiny runnable entrypoint    sync-engine.ts              # startup order, push/pull/live orchestration    folder-watcher.ts           # chokidar (ignoreInitial: false)    remote-files.ts             # ORM upsert/delete/select + FILE download    local-cache.ts              # SQLite metadata + pending queue    helpers.ts                  # disk I/O, waits, TaskQueue    sync-log.ts                 # structured console logging    lib/      paths.ts                  # safe paths, ignore rules      seed.ts                   # first-run copy from seed/      file-utils.ts             # sha256, mime type, upload File builder      sync-tombstones.ts        # block stale re-upload/re-download after delete    db/                         # Kalam client + local SQLite    models/schema.generated.ts  # generated — do not edit    models/schema.local.ts      # Drizzle types for local SQLite

Never synced: .index/, .git/, .DS_Store, SQLite WAL/SHM files.

How it works (detailed)

1. Startup (kalam dev)

kalam dev (from kalam.toml):

  1. Starts KalamDB locally (kalam/server/data/)
  2. Applies kalam/schema.sql and regenerates TypeScript types
  3. Runs npm run dev -- data — the sync worker as user alice by default

On startup the worker:

  1. Ensures demo users alice / bob exist
  2. Logs in and loads existing remote paths from KalamDB (select().from(context_files))
  3. Bootstrap: if data/ has no user files and the server has no rows, copies seed/ into data/
  4. Initial pull: downloads server files missing on disk (empty folder / new sync dir restore)
  5. Initial push: chokidar scans data/ with ignoreInitial: false, pushes changed files, reconciles offline deletions, flushes the pending upload queue
  6. Live pull: one liveTable(context_files, …) subscription — see One liveTable subscription
  7. Watch: chokidar keeps running; push/delete events share the same TaskQueue as live callbacks

Console output during initial sync:

TEXT
[sync] initial sync for folder '.../data' started ...[sync] file 'profile.md' downloaded from server (142 B)[sync] downloaded 1 file(s) from server[sync] file 'profile.md' added[sync] initial sync for folder '.../data' completed: 1 added, 0 updated, 0 deleted, 0 pushed, 1 downloaded (1 total changes)[sync] live subscription active

2. Push — local edit → server

When you save or add a file under data/:

  1. chokidar fires add or change; the watcher logs file '…' added or updated
  2. pushLocalFile(path) reads bytes and computes SHA-256
  3. If local SQLite cache matches and nothing is pending, skip (no-op)
  4. Otherwise the worker upserts through Drizzle ORM with kalamFile('upload', file) in .values() / .onConflictDoUpdate().set()
  5. KalamDB stores bytes as a standalone object under kalam/server/data/storage/ and appends a new hot MVCC row with updated file_ref JSON (see FILE bytes on disk)
  6. Local SQLite records the hash; any pending-upload row is cleared
  7. 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:

  1. Missing server files are downloaded first (initial pull)
  2. chokidar replays every file on disk (ignoreInitial: false) and pushes local changes
  3. reconcileLocalDeletions deletes server rows for files removed while offline
  4. liveTable starts 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:

  1. SDK delivers ContextFiles[] — full materialized query result
  2. Paths gone since last callback → removeLocalFile (live delete)
  3. For each row, compare local SHA-256 with file_ref.sha256
  4. Match → update local SQLite cache only
  5. Differ → download via FileRef.getDownloadUrl(...), verify hash, write disk
  6. Console logs file '…' downloaded from server (size) or file '…' 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 rowsWhat happens
EmptyEmptyCopy seed/ → push to server
EmptyHas rowsDownload from server (no seed); then subscribe
Has filesAnyDownload 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:

BASH
KALAM_USER=bob KALAM_PASSWORD=bob123 npm run dev -- data-bob

Prerequisites

Single-folder quick start

BASH
cd examples/live-okf-context-syncnpm installkalam dev

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:

RoleUserPassword
Server admin (kalam dev)rootkalamdb123
Default sync useralicealice123
Second demo userbobbob123

Sync a different folder:

BASH
npm run dev -- test1/# orKALAM_SYNC_DIR=test1 npm run dev

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

BASH
cd examples/live-okf-context-syncnpm installkalam dev

Wait until you see [sync] live subscription active in the logs.

Step 2 — Make changes under data/

In another terminal:

BASH
cd examples/live-okf-context-sync # Edit an existing fileecho "" >> data/profile.mdecho "Updated at $(date -u +%Y-%m-%dT%H:%M:%SZ)" >> data/profile.md # Or add a new notemkdir -p data/notescat > data/notes/manual-test.md <<'EOF'# Manual restore test This file should come back after deleting data/.EOF

Confirm the sync worker logged push lines (for example pushed path='profile.md' _seq=… size=…). You can also query the server:

BASH
kalam sql "SELECT path, updated_at FROM okf_sync.context_files ORDER BY path"

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

BASH
rm -rf data/

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

BASH
kalam dev

Because the server still has rows (and seed/ is skipped when the server is non-empty), the worker:

  1. Starts with an empty data/ (except what it creates while pulling)
  2. Initial pull downloads each server file (pullInitialRemoteFiles)
  3. Completes chokidar scan (usually no-op if nothing changed locally)
  4. Opens liveTable — one subscription for all future updates

Expected result:

  • data/profile.md contains your edit from step 2
  • data/notes/manual-test.md exists with the same content
  • data/.index/sync.db is recreated locally
  • Console shows file '…' downloaded from server (…) for restored files

Optional — offline edits while stopped

  1. Run kalam dev, edit files, then stop with Ctrl+C
  2. Add, edit, or delete files under data/ while the worker is stopped
  3. Run kalam dev again

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

BASH
npm test

Integration tests (server must be reachable on port 2900):

BASH
# Terminal 1kalam dev # Terminal 2KALAM_INTEGRATION=1 npm test

Notable integration cases:

  • delete local folder and restore from database — automates the manual restore walkthrough
  • local changes while sync stopped are pushed on restart — add / edit / delete while kalam dev is stopped

Files worth reading

FilePurpose
kalam/schema.sqlUSER table definition — only schema source
kalam.tomlkalam dev wiring: schema, types, sync process
src/sync-app.tsTiny CLI entrypoint: resolve folder + connection, then start the engine
src/sync-engine.tsStartup order, startLivePull, handleLiveRows, tombstones, shared TaskQueue
src/folder-watcher.tschokidar with ignoreInitial: false
src/remote-files.tsORM upsert (onConflictDoUpdate) + delete + download
src/local-cache.tsSQLite hashes and pending upload queue
src/helpers.tsDisk I/O, waits, serialized task queue
src/sync-log.tsStructured console logging
src/lib/seed.tsFirst-run copy from seed/
src/lib/paths.tsSafe paths, ignore rules (.index, .git, etc.)
src/lib/file-utils.tssha256, mime types, upload File builder
src/models/schema.generated.tsGenerated ORM types for KalamDB
src/models/schema.local.tsDrizzle types for local SQLite

KalamDB surface area used

FeatureUsage in this example
FILE columnStore OKF bytes; file_ref.sha256 for change detection
USER TABLEPer-user rows and download ACLs; live query scoped to sync user
MVCC rowsEach 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 WebSocketFirst live call opens /v1/ws; additional queries multiplex on same socket
Drizzle ORMReads, deletes, upsert (onConflictDoUpdate), and live table from one schema
@kalamdb/orm kalamFile() + kalamDriver()FILE upserts through normal Drizzle inserts
kalam devSchema apply, typegen, local server, process supervisor

End-to-end live path (reference)

Next steps

Last updated on