Skip to content
nikcli
Docs
Documentation

Storage

Where nikcli keeps persistent data: one SQLite database, the repository layer above it, migrations, and the files that stay on disk.

Overview

All durable runtime state lives in a single SQLite database — nikcli.db under Global.Path.data — accessed through Drizzle. Projects, sessions, messages, parts, diffs, todos, goals, permissions, loops, missions, routines, workspaces, accounts, mobile tokens, and the sync journal are all tables in that one file.

The JSON object store and the per-domain databases are gone

Earlier releases wrote JSON files under Global.Path.data/storage and kept separate users.db, accounts.db, mobile_auth.db, and workspaces.db files. Both models were consolidated into nikcli.db; the 20260611020000_import_legacy_databases and 20260611030000_import_json_storage migrations import the old data on first run. There is no Storage.Service and no src/storage/ directory any more.

Global.Path.data

Data root

Holds nikcli.db, log/, bin/, repos/, and snapshots. Override with NIKCLI_DATA_DIR, or isolate in tests with NIKCLI_TEST_HOME.

src/database/database.ts

One database

bun:sqlite in WAL mode behind a Drizzle client, opened once per filename and shared by every domain module. NIKCLI_DB overrides the filename.

*.sql.ts + *-repo.ts

Repositories

Each domain declares its tables in a .sql.ts file and its queries in a repository namespace. Domain services never touch Drizzle directly.

src/database/migration/

Migrations

Timestamp-named TypeScript migrations run inside a transaction and are recorded in a migration table, so each runs exactly once.

Path roots

@nikcli-ai/util/global owns the global directory model. On startup, initialize() creates the data, cache, config, state, log, bin, and repos directories. macOS and Linux follow XDG roots; Windows uses the native AppData layout instead of POSIX dotfiles.

PathDefault roleContents
Global.Path.dataLong-lived user datanikcli.db (plus its -wal/-shm siblings), auth.json, snapshot/, plans/.
Global.Path.logLogsdata/log. Where --print-logs output is also written.
Global.Path.binnikcli-managed binariesdata/bin: LSP downloads, language servers, support binaries.
Global.Path.reposHosted repositoriesdata/repos: imported or managed repositories.
Global.Path.cacheDisposable cacheModel lists, package cache, file search indexes. A cache-version bump clears the directory.
Global.Path.configUser confignikcli.json, managed config, global themes, global skills.
Global.Path.stateEphemeral stateLocks, prompt stash, plugin metadata, brain lock files.
Terminal
bash
NIKCLI_DATA_DIR=/srv/nikcli-data nikcli serve --hostname 0.0.0.0 --port 4096
NIKCLI_DB=:memory: bun test test/path/file.test.ts
NIKCLI_TEST_HOME=/tmp/nikcli-test bun test test/path/file.test.ts
Path holders must re-resolve

Global.Path.* are getters, and the test suite swaps NIKCLI_TEST_HOME per file. A singleton that captures a path at module load will keep writing to the previous home — resolve paths on access, not at import time.

The database

Database.path() resolves the filename: NIKCLI_DB when set (absolute, relative to the data root, or the literal :memory:), otherwise <data>/nikcli.db. Opening applies a fixed set of PRAGMAs, runs pending migrations, and checkpoints the WAL.

PRAGMAValueWhy
journal_modeWALConcurrent readers while a writer holds the file.
synchronousNORMALDurability that matches WAL without an fsync per commit.
busy_timeout5000Wait rather than fail when the TUI, server, and CLI write at once.
cache_size-64000 (~64 MB)Bounds the page cache explicitly.
foreign_keysONCascades and referential integrity are enforced, not advisory.
mmap_size0Keeps the process footprint from growing with the database file; the latency cost is dwarfed by model round-trips.

The client is a per-filename singleton, so every module in a process shares one connection. A background loop checkpoints the WAL periodically; Database.close() and closeAll() tear it down.

Entry pointUse
Database.syncDb()The shared Drizzle client. Safe from synchronous code — what repositories call.
Database.syncNative()The raw bun:sqlite handle for PRAGMAs and maintenance.
Database.defaultLayerThe Effect layer for code that resolves the service through a layer instead.
Database.layerFromPath(file)Bind a specific database file — used by tests and tooling.

Tables and owners

src/database/schema.ts is the single re-export point: every table used by the Drizzle client is listed there, and each one is declared in its own domain module. That is the fastest way to see the current database surface.

Table(s)Declared inPurpose
project src/project/project.sql.ts Known projects, git roots, and per-project metadata.
session_info src/session/session.sql.ts Session records; indexed columns plus a JSON `data` blob for the rest.
message_info, message_part src/session/message.sql.ts Messages and their parts — text, reasoning, tool calls, files.
session_entry src/session/v2/entry.sql.ts The flat v2 event-sourced session log and its persisted projection.
session_goal src/session/goal.sql.ts Per-session goal state driven by the goal tools.
session_diff src/session/diff.sql.ts File diffs backing summaries, the changes view, and revert.
session_pending src/session/pending.sql.ts Queued prompts and steering messages for a busy session.
todo_info src/session/todo.sql.ts Todo list state written by the todo tools.
instruction_blob, instruction_state src/session/instruction.sql.ts Instruction files and their per-session enable/disable state.
permission_ruleset src/permission/permission.sql.ts Persisted always-approvals and per-agent permission rulesets.
loop, loop_run src/loop/loop.sql.ts Loop definitions and run history. See Loops.
mission, mission_exec src/mission/mission.sql.ts Mission definitions and executions. See Missions.
routine src/mobile/routine.sql.ts Scheduled and API-triggered routines.
background_run src/background/run.sql.ts Background delegation runs and their status.
monitor src/monitor/monitor.sql.ts Long-lived process monitors and their logs.
artifact src/artifact/artifact.sql.ts Session artifacts surfaced in the TUI and mobile viewers.
session_share, local_share src/share/share.sql.ts Share records for public session links.
sync_event, sync_sequence, sync_snapshot, sync_outbox src/sync/sync.sql.ts The sync event journal, sequences, snapshots, and idempotent outbox.
workspace src/workspace/workspace.sql.ts Workspace records, target config, and status.
users, user_sessions, chat_contacts, chat_messages src/user/users.sql.ts Local server accounts and the chat surface.
account, config src/account/account.sql.ts nikcli account records, tokens, and the active account.
mobile_tokens src/mobile/auth.sql.ts Hashed mobile bearer tokens with creation, last-used, and expiry.

Repository pattern

Domain modules do not write SQL inline. Each domain pairs a schema file with a repository namespace — SessionRepo, MessageRepo, LoopRepo, PermissionRepo, and so on — that owns every query against its tables.

  1. Schema (*.sql.ts) declares the Drizzle table, its columns, and its indexes.
  2. Repository (repo.ts / *-repo.ts) exposes typed synchronous CRUD over Database.syncDb().
  3. Service (index.ts) adds business logic, publishes bus events, and is what routes and tools call.
Hot columns, cold JSON

Records with a large or evolving shape — sessions are the clearest case — promote the fields that get filtered or sorted into real columns and keep the rest in a JSON data column. When a field is later promoted, the column becomes the source of truth and the JSON blob may be stale for older rows; session_info.last_model is the worked example in src/session/repo.ts.

Transactions

Database.transaction(fn) runs a unit of work against a transaction handle. Nested calls join the outer transaction rather than starting a new one — SQLite has no real nesting — so a projector invoked inside a caller's transaction stays in that transaction.

Repository writes accept an executor (Database.TxOrDb) precisely so a sync projector can append its event and update its projection atomically. Reads always use the shared client; they are never part of a projection.

Post-commit effects

Database.effect(fn) queues work to run after the current transaction commits — never on rollback. Outside a transaction it runs immediately, because the caller's write has already landed. Publish bus events from there, not from inside the transaction body.

Migrations

Migrations are TypeScript modules under src/database/migration/, named <timestamp>_<slug>.ts, each exporting an id and an up(database) function. migration.gen.ts imports them in order.

DatabaseMigration.apply() creates a migration table, reads the applied ids, and runs each pending migration inside BEGIN IMMEDIATE — committing the migration and its bookkeeping row together, rolling both back on failure. Every open of the database runs this, so a fresh install and an upgrade converge on the same schema.

MigrationWhat it does
20260610211500_initialCreates the base schema.
20260611020000_import_legacy_databasesImports users.db, accounts.db, mobile_auth.db, and workspaces.db into nikcli.db.
20260611030000_import_json_storageImports the old data/storage JSON object store — projects, sessions, messages, parts, diffs, todos.
20260611040000_import_sync_jsonImports the pre-SQL sync journal.
20260805000000_session_entry …Introduces the flat v2 session entry log and its id ordering; a later migration drops the interim session_v2_event table.
202608140*_*_sqlThe domain-by-domain move of loops, projects, goals, background runs, routines, diffs, workspaces, pending prompts, and instruction sync into SQL.
Adding a migration changes the journal list

test/database.test.ts asserts the applied migration list. A new migration is expected to change it — update the test in the same commit, and back up the data root before replaying migration state by hand.

Files outside the database

Some things are deliberately still files, because a file is the interface.

DataLocationWhy not SQL
Confignikcli.json in the config dir and project rootsUsers edit and version-control it. See Configuration.
Provider authauth.json under the data rootCredentials with their own read/write path and permissions.
Plans.nikcli/plans in the worktree, or data/plans for non-VCS sessionsMarkdown meant to be read, diffed, and committed.
Snapshotsdata/snapshotContent-addressed file trees for revert.
Skills, commands, agents, themesConfig directories and .nikcli/Authored as files by users and plugins.
Brain memory.github/instructions/memory.instruction.mdAn instruction file the model reads. See Brain.
Logsdata/logAppend-only and read with ordinary tooling.

Operational checks

Terminal
bash
nikcli debug paths

DB="$HOME/.local/share/nikcli/nikcli.db"
sqlite3 "$DB" ".tables"
sqlite3 "$DB" "select id, time_completed from migration order by id;"
sqlite3 "$DB" "select count(*) from session_info;"
sqlite3 "$DB" "select id, name, expires_at from mobile_tokens;"
sqlite3 "$DB" "pragma integrity_check;"

On macOS and Linux the default data root follows the XDG data location; on Windows it is the nikcli directory under Local AppData. If NIKCLI_DATA_DIR or NIKCLI_DB is set, inspect that path instead.

Failure modes

SymptomLikely causeFix
Session list is emptyWrong data root, or sessions belong to a different project/worktree.nikcli debug paths, then query session_info for the project id you expect.
Sessions vanished after upgradingThe JSON import migration did not run — usually a different NIKCLI_DATA_DIR than the old install used.Point the data root at the old location and restart so the import migration sees data/storage.
Startup throws from a migrationA migration failed and rolled back; it will retry on every open.Read the database-migration log lines, fix the source data or permissions, restart.
Writes wait or failAnother process holds the write lock past busy_timeout, or the file is on a filesystem that does not do WAL cleanly.Close competing processes; move the data root to a local filesystem.
Database is locked in testsA stray nikcli binary is still running against the same file.Kill leftover processes, remove dist/, re-run the file alone.
Mobile token works once then failsToken expired or was revoked.nikcli mobile token list, then issue a new token.
Manual SQL edits are not visibleThe process holds an open connection with its own page cache.Restart the process after editing the database out of band.

Source anchors

FileWhat to inspect
packages/nikcli/src/database/database.tsFilename resolution, PRAGMAs, singleton client, transactions, post-commit effects, WAL checkpoint loop.
packages/nikcli/src/database/schema.tsThe complete table surface, re-exported from every domain module.
packages/nikcli/src/database/migration.tsMigration table, applied-id set, transactional apply loop.
packages/nikcli/src/database/migration/The migrations themselves, including the legacy JSON and per-domain database imports.
@nikcli-ai/util/globalXDG/AppData path resolution, NIKCLI_DATA_DIR, NIKCLI_TEST_HOME, cache versioning.
packages/nikcli/src/session/repo.ts, session.sql.tsThe canonical repository + schema pair, including the hot-column/JSON-blob split.
packages/nikcli/src/session/message-repo.ts, message.sql.tsMessage and part persistence.
packages/nikcli/src/session/v2/The event-sourced entry log and its persisted projection. See Sessions.
packages/nikcli/src/sync/Event journal, projectors, snapshots, and the idempotent outbox. See Sync.
packages/nikcli/src/mobile/auth.ts, auth.sql.tsMobile token hashing, caching, and bearer parsing.
packages/nikcli/src/workspace/workspace.sql.tsWorkspace records, targets, and status.