Lang on Rock
Guides

Library

open(), the DatabaseConnection it returns, its errors, and the Node-compatible client.

import { open } from 'langonrock';

const knowledge = open('okf:///var/data?tenant=acme');

const manifest = await knowledge.manifest('sales');
const hits = await knowledge.search('order grain', { k: 5 });
const concepts = await knowledge.get(['orders', 'customers'], { section: 'schema' });
const passage = await knowledge.get(['pride_3'], { find: 'ten thousand a year' });

await knowledge.close();

open(dsn) returns the same interface for every connection mode. Develop embedded, deploy remote, change nothing at the call site.

Connection

interface Connection {
  readonly transport: Transport;
  snapshot: () => Promise<string>;
  manifest: (bundle?: string) => Promise<string>;
  get: (ids: string[], options?: GetOptions) => Promise<Map<string, ConceptSlice>>;
  search: (query: string, options?: SearchOptions) => Promise<string>;
  listSource: () => Promise<SourceEntry[]>;
  readSource: (bundle: string, path: string) => Promise<SourceFile | undefined>;
  writeSource: (bundle: string, path: string, content: string, replaces?: string) => Promise<string>;
  deleteSource: (bundle: string, path: string, replaces: string) => Promise<void>;
  deleteBundle: (bundle: string) => Promise<void>;
  sync: () => Promise<SyncResult>;
  close: () => Promise<void>;
}

interface DatabaseConnection extends Connection {
  transact: (request: TransactionRequest) => Promise<RevisionResult>;
  history: (options?: HistoryOptions) => Promise<RevisionPage>;
  restore: (request: RestoreRequest) => Promise<RevisionResult>;
}

open() and connect() both return a DatabaseConnection. manifest and search return TSV as a string, because that is what goes into a prompt. get returns a Map of slices keyed by id, with missing ids simply absent. Every read is a slice, even a whole-document one, so a caller always knows how much text exists beyond what it received; the library never truncates unless asked, and the 15,000-character default cap lives at the MCP boundary only.

interface GetOptions {
  section?: string; // a named section instead of the whole document
  offset?: number;  // character offset into the addressed text
  limit?: number;   // maximum characters returned; absent means all of it
  find?: string;    // literal, case-insensitive phrase; overrides offset
}

interface ConceptSlice {
  text: string;
  start: number;
  end: number;
  total: number;
  matches?: number[];   // offsets of find occurrences, capped at 20
  matchCount?: number;  // full occurrence count, uncapped
}

With find, the slice becomes a window around the first occurrence — 2,000 characters unless limit says otherwise — and matches is how a caller jumps to the occurrences it did not get.

Remote connections revalidate the manifest for you

A remote or socket connection keeps the last manifest body and its ETag, per bundle, and sends If-None-Match on every later call. An unchanged snapshot answers 304 and the cached string comes back with no transfer. Calling manifest() on every turn is therefore cheap, and you do not need to compare snapshot() yourself first.

interface SearchOptions {
  k?: number;      // ranked matches before expansion, default 8
  expand?: boolean; // one-hop link expansion, default true
  bundle?: string;  // rank and expand within one bundle
}

interface SourceEntry {
  bundle: string;
  path: string;
  bytes: number;
  hash: string;
  id?: string; // absent only for navigation files like index.md
}

interface SyncResult {
  snapshot: string;
  concepts: number;
  bundles: string[];
  diagnostics: Diagnostic[];
}

On a native tenant writeSource, deleteSource and deleteBundle each commit one revision before they return, and sync() reports the current commit without creating another. Editing over the network covers the preconditions they take.

Transactions and history

type DocumentChange =
  | { operation: 'write'; bundle: string; path: string; content: string; replaces?: string }
  | { operation: 'delete'; bundle: string; path: string; replaces: string };

interface TransactionRequest {
  changes: DocumentChange[]; // 1 to 1,000, at most 16 MiB of source text
  expectedRevision?: string;
}

interface RevisionResult extends SyncResult {
  revision: string;
}
const first = await knowledge.transact({
  changes: [
    {
      operation: 'write',
      bundle: 'notes',
      path: 'intro.md',
      content: '---\ntype: concept\n---\n# Introduction\nSaved atomically.\n',
    },
  ],
});

const previous = await knowledge.readSource('notes', 'intro.md');

if (previous !== undefined) {
  await knowledge.transact({
    expectedRevision: first.revision,
    changes: [
      {
        operation: 'write',
        bundle: 'notes',
        path: 'intro.md',
        replaces: previous.hash,
        content: `${previous.content}\nUpdated.\n`,
      },
      { operation: 'write', bundle: 'notes', path: 'second.md', content: '# Second document\n' },
    ],
  });
}

All changes in a batch become visible together, and a batch whose preconditions do not all hold publishes nothing. Omitting replaces asserts creation; expectedRevision additionally asserts that nobody committed anything to the tenant since. Transactions covers the rules.

interface RevisionInfo {
  revision: string;
  parent: string | null;
  snapshot: string;
  created: string;
  concepts: number;
  bundles: string[];
}

interface HistoryOptions {
  before?: string; // the `next` cursor of the previous page
  limit?: number;  // 1 to 100, default 20
}

interface RevisionPage {
  revisions: RevisionInfo[]; // newest first
  next?: string;
}

interface RestoreRequest {
  revision: string;
  expectedRevision: string;
}

restore() publishes the chosen revision's complete source state as a new commit, guarded by the revision you expect to be current, and rewrites nothing. History and restore covers paging and retention.

Errors

Switch on code, never on the message.

codeThrown whenWhat to do
CONFLICTA precondition or expectedRevision no longer holdsRe-read the hashes or the revision, then decide
LEGACY_TENANTThe tenant has not been migratedMigrate it before using transactions or history
INDETERMINATE_COMMITThe commit failed after the point it may be durableRead history; the error carries revision

Embedded, these are classes: ConflictError and IndeterminateCommitError are exported, and both carry code. Remote, every failure is a plain Error whose message begins langonrock server returned <status>: followed by the server's body; when that body is JSON, code and revision are copied onto the error. A stale writeSource over the network is a 412 with a plain-text body, so its error has no code and only the message tells you what happened.

Closing

On an embedded connection, close() releases every reader the connection cached, and any call after it throws database connection is closed. A remote connection holds no readers, so its close() does nothing, but calling it costs nothing either.

A reader you open directly with openTenant() owns a file descriptor. Close it in a finally:

import { openTenant } from 'langonrock';

const reader = await openTenant('/var/data', 'acme');

try {
  await reader.get(['orders'], { section: 'schema' });
} finally {
  reader.close?.();
}

A reader pins one revision. It takes the tenant's retention lock briefly while it opens that revision's snapshot, then keeps the file open, so a concurrent commit or collection never changes what it reads underneath it.

From an app that is not on Bun

The package above needs Bun. The store uses Bun.file, Bun.Glob, Bun.YAML and zstd, none of which exist on Node, and it loads a compiled native adapter for locks and durable writes. A desktop editor usually cannot import it, since Electron's main process is Node and Tauri's front end is a webview.

langonrock/client is the same DatabaseConnection over the network only, with nothing under it but fetch:

import { connect } from 'langonrock/client';

const knowledge = connect('okf+http://127.0.0.1:7777?token=...');
const before = await knowledge.readSource('sales', 'tables/orders.md');

await knowledge.writeSource('sales', 'tables/orders.md', edited, before?.hash);

It runs on Node, Deno, Electron, Tauri and the browser, and a test asserts that rather than trusting it. The test bundles the entry point for Node and fails on any Bun. reference or filesystem import. Installing the package does not build the native adapter, so an app that only imports langonrock/client needs neither Bun nor a C compiler.

A okf+unix: string works from this client too, through fetch's unix option. That is a Bun and Node extension, and a browser ignores it, which is correct since a browser has no socket to reach anyway.

connect() refuses an embedded okf:// string with an explanation rather than failing silently, since it has no filesystem to open.

The shape that works for a desktop app

One HTTP client with two configurations. Locally, ship the langonrock binary as a sidecar and spawn langonrock serve --socket <path>. Remotely, point the same client at a server with a token. Nothing at the call site changes.

langoneditor is that shape in production. It is a Tauri app, so its front end is a webview and it cannot import the store. It ships the langonrock binary as a sidecar, spawns langonrock serve on loopback with a freshly generated token when you open a local folder, and points the same client at a remote host with a bearer token otherwise. Tokens go to the system keychain when one is available.

Its own rule is worth copying: the editor derives no ids, resolves no links and reproduces no lint rules. It asks the server and shows the answer, so the two cannot drift apart.

Windows has no unix socket here

A local sidecar there means loopback TCP, and the server refuses TCP without tokens. Generate one per session and pass it in the connection string.

The pieces, if you want them instead

The compiler, the database operations, the reader, the watcher and search are all exported. The database operations take a target, { root, tenant }, rather than a connection.

import {
  compileBundle,
  compileTenant,
  discoverBundles,
  splitSections,
  openTenant,
  transact,
  history,
  restore,
  importFolder,
  importFolders,
  exportFolder,
  migrate,
  verify,
  repair,
  putTenantRoot,
  watchTenant,
  buildTenantIndex,
  searchTenant,
  serve,
  collect,
  collectAll,
} from 'langonrock';
ExportWhat it gives you
compileBundleOne directory to { concepts, diagnostics, tsv, bodies }, no store
compileTenantSeveral bundles merged, with ids and links rewritten
discoverBundlesThe immediate subdirectories of a source root
splitSectionsHeading byte ranges for one body
openTenantA reader pinned to the current revision
transact, history, restoreThe connection's database verbs, on a target
importFolder, importFoldersTracked import of one or several folders, with conflict detection
exportFolderThe current revision's exact Markdown, written to a new directory
migrateMove a folder-store tenant into the database, with a dry run
verify, repairCheck stored data independently of caches; select a verified root
putTenantRootImport every subdirectory of a source root, as sync <dir> does
watchTenantA watcher with ready, sync() and close()
buildTenantIndexA BM25 index over a reader
serveThe HTTP server, given PEM strings rather than paths
collect, collectAllRetention collection for one tenant, or every tenant under a root

Lower-level pieces are exported too: parseFrontmatter, deriveIds, resolveLinks, scanBundle, parseHeader, parseDir, encodeTnt, estimateTokens, platformDataDir, resolveDataDir, loadTokens, addToken, generateToken, loadSources, assertTenantId, createReaderCache, createSearchCache. The two cache factories expose close() as well.

Compiling without a store

import { compileBundle } from 'langonrock';

const { tsv, concepts, diagnostics, bodies } = await compileBundle('sources/acme/sales', {
  bundle: 'sales',
  summaryWidth: 120,
});

That is the cheapest useful version of the whole idea. Generate the manifest over your existing OKF, put it in the prompt prefix, and you have captured most of the token saving before storing anything.

On this page