Editing over the network
Create, change and delete documents through the API, one commit per write, without losing anyone's edit.
An editor creates, changes and deletes complete Markdown documents through the source routes. On a
native tenant every write is its own commit: when the response arrives the revision is durable, and
manifest, search and get already see it. Edits that belong together go through
transact instead, so they land as one revision or not at all.
There is a working client for everything on this page
langoneditor is a desktop editor, visualizer and import/export tool for OKF bundles, built on these routes, including what it does when a precondition fails.
Turning it on
tokens.json says which tokens may change a tenant, and the CLI daemon accepts their writes on
every native tenant. No folder has to be registered anywhere.
{ "editor-token": { "tenant": "acme", "write": true } }A tenant that does not exist yet is created by its first PUT with If-None-Match: *, so an agent
asked to persist a first note needs no setup. Only the tenant the token already grants is ever
created. A server started from the library is read-only until it is told otherwise; Running a
server covers writable.
Tenants from the folder store still edit their folder
A tenant that has not been migrated keeps its old workflow: it needs
its folder in sources.json, a write lands in that folder, and the snapshot follows on the
watcher's debounce or a sync. Everything below about commits applies once it is migrated.
The routes
GET /v1/{tenant}/source list documents with sizes, hashes and ids
GET /v1/{tenant}/source/{bundle}/{path} read one, ETag is its content hash
PUT /v1/{tenant}/source/{bundle}/{path} create or replace one, as its own commit
DELETE /v1/{tenant}/source/{bundle}/{path} delete one, as its own commit
DELETE /v1/{tenant}/bundles/{bundle} delete a whole bundle, as one commit
POST /v1/{tenant}/transact several changes, one commit
POST /v1/{tenant}/sync the current commit and its diagnosticsWriting the first document of a bundle creates the bundle, and deleting its last document removes it.
Every write names the version it replaces
There is no default
If-Match: "<hash>" to overwrite, or If-None-Match: * to insist the document is new. Neither
header is a 428. A stale hash is a 412.
Two people editing one document is the normal case for an editor, and a silently lost update is the worst failure this API could have. Requiring the header makes the unsafe call impossible rather than merely discouraged.
| Precondition | Document exists | Result |
|---|---|---|
If-None-Match: * | no | 204, created |
If-None-Match: * | yes | 412, concept already exists |
If-Match: "<hash>" | yes, same hash | 204, replaced |
If-Match: "<hash>" | yes, different | 412, concept changed since it was read |
If-Match: "<hash>" | no | 412, concept does not exist |
| none | either | 428 |
Each 412 message ends in re-read the concept and retry with its new hash. The hash in If-Match
is the full 64-character hex digest from the ETag or the listing; anything shorter is a 400. A
DELETE needs If-Match, since deleting has no create case to spend If-None-Match on.
A successful write answers 204 with the new hash in the ETag, so the client can chain edits
without re-reading.
What to do with a 412
Refusing the write is the server's whole job here. Deciding what happens next belongs to the client, and there are only two honest options: discard one side, or merge.
langoneditor shows a three-way merge rather than overwriting, and rebases all three resolutions onto the version it just observed, so a retry cannot fail for the same reason twice. It also polls the snapshot digest every few seconds and warns when the store moves underneath it.
Deleting a whole bundle carries no precondition. It is a single commit, so a mistake is undone by restoring the revision before it, for as long as collection keeps that revision.
Several changes, one commit
A rename is a delete and a write. A schema change can touch a table and every metric that reads it.
Sent as separate PUTs, each publishes its own snapshot and a reader can land between them. Sent
as one batch, they become visible together, and if any precondition in the batch fails, none of them
is published:
await knowledge.transact({
changes: [
{ operation: 'delete', bundle: 'sales', path: 'tables/orders.md', replaces: ordersHash },
{ operation: 'write', bundle: 'sales', path: 'tables/order_lines.md', content: renamed },
],
});Transactions covers the limits and what concurrent writers see.
Through the library
The precondition is an argument, enforced identically whether you are embedded or remote.
const knowledge = open('okf+https://host:7777?token=editor-token');
const before = await knowledge.readSource('sales', 'tables/orders.md');
await knowledge.writeSource('sales', 'tables/orders.md', edited, before?.hash);
await knowledge.writeSource('sales', 'metrics/new.md', created); // no hash: must not existEmbedded enforces the same rule on purpose. If only the server checked, an editor developed against a local path would lose edits the moment it was pointed at a server, which is exactly the bug this is meant to prevent.
The refusal does not look the same on both sides, though. Embedded, a stale writeSource throws a
ConflictError whose code is CONFLICT. Remote, it throws an Error whose message begins
langonrock server returned 412: followed by the same text, with no code, because the 412 body is
plain text. The routes that answer in JSON, transact and restore, do carry code on the remote
error, and an indeterminate commit carries revision as well.
The listing tells you which concept a file becomes
{
"bundle": "sales",
"path": "tables/orders.md",
"bytes": 586,
"hash": "3b3807a007b08a92991718ba790e3ff312793710a8d9ea141c7f19633deb5750",
"id": "orders"
}A client never has to reimplement the naming rule. A document with no id is a navigation file,
index.md or log.md, which never becomes a concept; its exact source still appears in the listing
and in an export. Everything else compiles, frontmatter or not, and the listing names it exactly as
the manifest will.
Creating a document can rename a concept nobody touched
Concept ids are the shortest unambiguous form of their path, so adding staging/orders.md turns
an existing orders into tables/orders. Re-read the listing or the manifest after a change
rather than assuming ids are stable.
Diagnostics are the lint
Every commit records what the compiler noticed on the way: a missing type, a link resolving to
nothing, a file compiled without frontmatter. That is what an editor should put in front of whoever
is writing. sync returns them for the current commit without creating another revision:
const { snapshot, diagnostics } = await knowledge.sync();Each diagnostic carries a level, the file path, and a message.
| Message | What it means |
|---|---|
no frontmatter, compiled as plain markdown, not an OKF concept | The file compiled with derived metadata |
missing required frontmatter field "type" | The kind cell will be - |
unresolved link "…" | A Markdown link points at no concept in the tenant |
stale_after is not a date (YYYY-MM-DD), ignored | The concept will never demote to stale |
invalid YAML frontmatter: … | The block did not parse, so every field was lost |
frontmatter is not a mapping | It parsed to a list or a scalar |
Every one of these is warn. The error level exists in the type and nothing emits it today, so
a document with warnings still commits. Use --strict on the CLI to make any diagnostic an exit
code instead.
What a write costs
Through the HTTP source routes, a read followed by a write naming its hash takes 14.1 ms at 500 concepts and 44.2 ms at 5,000, because the engine syncs every new artifact to disk before it publishes the commit. A write naming a stale hash is refused in 1.01 to 4.18 ms, since nothing is written.
Each commit publishes a complete snapshot, so batch related edits with transact rather than saving
them one PUT at a time.
From the command line
The same verbs work against any connection string, so you can edit a local store and a remote one the same way.
langonrock query "$DSN" source # list, with hashes
langonrock query "$DSN" read sales tables/orders.md # content out, hash on stderr
langonrock query "$DSN" write sales metrics/new.md --create < new.md
langonrock query "$DSN" write sales tables/orders.md --replaces "$HASH" < edited.md
langonrock query "$DSN" delete sales tables/old.md --force
langonrock query "$DSN" delete-bundle old-bundle
langonrock query "$DSN" sync--create, --replaces <hash> and --force are the command-line spelling of the precondition.
A write that names none of them is refused before anything is sent:
a write needs --replaces <hash> to update, --create for a new concept, or --force to overwrite whatever is thereThe CLI does not quietly read the current hash for you. --force does exactly that, and it is
named so that it reads like one.
What it rejects
Paths are validated before they reach the store, and rejected rather than cleaned up:
- forward slashes only, no backslashes, no null bytes, not absolute
- must end in
.md - no segment may start with a dot, since folder imports and the watcher skip dot paths and a document written there could never round-trip through a folder
- 200 characters maximum, because Windows caps a path at 260
- 1,000,000 bytes per document, and valid UTF-8 only
Bundle names match [a-z0-9][a-z0-9_-]{0,63}, the same rule as a tenant id.