REST API
The server exposes a small JSON-over-HTTP API on its --addr. Every route
except /health and /version requires HTTP Basic auth when a password is set
(see Authentication & TLS).
POST /cypher (alias POST /query)
Run a Cypher query. Request body:
{
"query": "MATCH (n:Person) RETURN n.name AS name LIMIT 10",
"db": "default",
"at": null,
"consistency": "local"
}
| Field | Required | Meaning |
|---|---|---|
query | yes | The Cypher (or DDL) statement. |
db | no | Target database (default "default"). Overridden by USE. |
at | no | Engram id for a time-travel read (equivalent to USE … AS OF). |
consistency | no | "local" (default) or "linearizable". See below. |
consistency controls read freshness (ignored for writes):
"local"(default) — serve this node’s applied state. Fast, causally consistent within a database, eventually consistent across replicas."linearizable"— reflect every write committed before the read began, via a leader read-index barrier. Leader-only: a follower returns503with aleader_id/leader_addrhint (like a misrouted write). Costs one intra-cluster round-trip. See High Availability.
curl -s -u neo4j:secret -X POST http://127.0.0.1:21001/cypher \
-H 'content-type: application/json' \
-d '{"query":"CREATE (n:Person {name:\"Ada\"}) RETURN n"}'
Statement routing
The handler dispatches a statement in this order:
- Full-text DDL (
CREATE/DROP FULLTEXT INDEX,SHOW FULLTEXT INDEXES) — schema commands replicate through Raft;SHOWreads the local catalog and returns{"fulltextIndexes": [...]}. - Database DDL (
CREATE/DROP DATABASE→ replicated;SHOW DATABASES→ local registry read). - A leading
USE <db>selector (optionallyUSE <db> AS OF '<id>') picks the target database / time-travel point. - Otherwise: writes go through Raft and apply on every node; reads serve from the local applied graph.
The response is JSON with the result rows (column order and per-row alignment are preserved). Errors come back as a JSON error with an appropriate HTTP status.
GET /health
Liveness check. No auth. Returns OK when the node’s HTTP server is up.
GET /version
Server version string. No auth.
Management endpoints
On --addr, for cluster operations (see High Availability):
| Endpoint | Purpose |
|---|---|
POST /mgmt/init | Initialize a cluster (alternative to --bootstrap). |
POST /mgmt/add-learner | Add a node as a non-voting learner. |
POST /mgmt/change-membership | Promote learners to voters / change the voter set. |
GET /mgmt/metrics | Raft metrics (leader, term, membership, lag). |
GET /mgmt/cache | Multi-level cache governor state: budget + per-level/-scope bytes & hit rates (see below). |
GET /mgmt/snapshot | Stream a portable, leader-anchored backup of every database (see below). |
GET /mgmt/export | Stream one database as an import-compatible CSV tar (see below). |
POST /mgmt/restore | Restore a backup into the registry (leader-only, admin; see below). |
/raft/append, /raft/vote, /raft/snapshot are the internal peer RPC
receivers — not for client use.
GET /mgmt/cache (cache observability)
Reports the node-local, RAM-budget-governed cache hierarchy. Admin-only
(it is under /mgmt/). The JSON carries the resolved budget (bytes per region:
total, cache_limit, work_mem_limit, headroom, min_free), the overall
utilization, a governor block (admission + the free-floor-vs-cap eviction-byte
split), the per-levels resident bytes / hit rates with per-scope accounting
(which database, collection (label), or query shape is hot), a sankey read-flow
block, and the governor’s last free-RAM sample (mem_available_bytes, read from
/proc/meminfo). While caching is disabled (the default) it is a truthful
all-zeros “just the OS/redb page cache” report:
{
"enabled": false,
"budget": { "total": 0, "cache_limit": 0, "work_mem_limit": 0, "headroom": 0, "min_free": 0 },
"total_resident_bytes": 0,
"utilization": 0.0,
"governor": { "admissions": 0, "rejections": 0, "evicted_free_floor_bytes": 0, "evicted_cap_bytes": 0 },
"levels": [],
"sankey": { "total_lookups": 0, "misses": 0, "layers": [ { "name": "miss", "label": "miss → storage", "value": 0 } ] },
"mem_available_bytes": 12884901888
}
An enabled node fills levels (one entry per cache layer, each with
bytes/entries/hit_rate/utilization/avg_entry_bytes/evicted_entries/
gen_invalidations/scope_invalidations + hottest-first per-scope scopes rows)
and the sankey layers with per-layer absorbed hits.
The same accounting is available in-query as CALL patinadb.cache.stats() YIELD scope, kind, bytes, entries, hit_rate, generation, and as Prometheus gauges on
/metrics (patinadb_cache_bytes{level,db}, patinadb_cache_hit_ratio{level},
patinadb_cache_evictions_total, patinadb_mem_available_bytes, …). For the full
metric reference and a tuning playbook see
Cache Observability & Tuning.
GET /mgmt/snapshot (backup / export)
Streams a portable backup of the whole registry — every database’s graph
plus its schema and access-control surface — as a single downloadable JSON
document (Content-Type: application/json, Content-Disposition: attachment; filename="patinadb-backup.json").
Query parameters:
| Param | Default | Meaning |
|---|---|---|
history | false | When true, additionally stream each database’s full engram history (delta + snapshot bodies) for point-in-time restore (PITR). See “Point-in-time restore” below. |
The backup is built and streamed in O(chunk) memory, not O(graph): each database’s graph is serialized lazily straight from its scan iterators to a temp file (the same streaming serializer the Raft snapshot build uses), and that file is streamed back as the response body and deleted once sent. The payload carries no Raft metadata (no log id / membership), so it is a portable data dump — readable and restorable into any fresh registry:
{
"databases": { "<db>": <graph snapshot> }, // graph (HEAD state)
"fulltext": { "<db>": [<index def>] }, // full-text indexes
"vector": { "<db>": [<index def>] }, // vector indexes (trained centroids)
"constraints": { "<db>": [<unique constraint>] },
"schema_constraints":{ "<db>": [<existence / node-key constraint>] },
"tags": { "<db>": [["<name>", "<engram uuid>"]] }, // tag names
"users": [<rbac user>], // global: hash + per-db roles
"engram_history": { "<db>": <full engram history> }, // ONLY with ?history=true
"heads": { "<db>": "<engram uuid>" }, // informational
"taken_at_unix_ms": <millis> // informational
}
So a /mgmt/snapshot → /mgmt/restore round-trip reconstitutes each database
including its UNIQUE / existence / node-key constraints, compound / vector
/ full-text indexes, named tags, and the RBAC user directory (argon2
hashes + per-database role grants) — not just the graph. Every schema/RBAC field
is optional, so an older backup (from before these were carried) still restores.
(restore ignores the heads / taken_at_unix_ms fields — informational labels.)
Point-in-time restore (PITR) — ?history=true
By default (?history=true omitted) the backup is a compact HEAD-state
dump: it captures each database’s current graph plus its schema and RBAC, but
not its engram delta/snapshot history. After restoring a HEAD-only backup the
graph, constraints, indexes, users, and tag names are all back, but time-travel
over pre-backup history is gone — AS OF <old-engram-id> / CALL patinadb.diff
on engrams that predate the backup are unavailable, and every restored tag
resolves to the restored-HEAD engram.
Add ?history=true to make it a PITR archive: the backup additionally
streams each database’s full engram history (engram_history above — the
per-engram delta bodies and periodic snapshot bodies). A /mgmt/restore
of such a backup reconstitutes the whole timeline, so on the restored node:
USE <db> AS OF '<old-engram-id>'reconstructs that historical state (not HEAD),USE <db> AS OF TAG <name>resolves to the tagged engram’s point in history,CALL patinadb.diff('<engram-id>')returns that engram’s historical delta,- and HEAD is the live restored graph, as always.
The history is streamed body-by-body (one delta / snapshot at a time), so a
PITR backup keeps peak memory bounded rather than materialising the whole
timeline. The engram_history field is omitted entirely from a HEAD-only backup,
and it decodes as empty on restore (#[serde(default)]), so an old backup — or a
new HEAD-only one — still restores unchanged.
Cluster caveat.
/mgmt/restoreinstalls the engram history on the node that receives the request (the leader). The HEAD graph replicates to followers via Raft, but the delta/snapshot bodies do not — so PITR is a leader-local capability. A follower that later bootstraps purely from a Raft snapshot carries only the metadata summary (the pre-existing engram-log limitation). For a single-node restore (the documented use), full PITR works everywhere.
Leader-anchored, point-in-time labelled
The export takes a leader linearizability barrier (ensure_linearizable)
before it reads anything, so:
- It is leader-only. A follower (or any non-leader) refuses the backup with a
503naming the current leader ({ "leader_id", "leader_addr", … }), the same contract as a misrouted write or a linearizable read — you can never accidentally take a stale backup from a lagging replica. - It reflects every write committed before it began, and records as of what
point each database was captured: under the barrier it captures each database’s
HEAD engram id (
heads[<db>], matchingDataset::headon the leader) plus a wall-clocktaken_at_unix_ms.
Consistency caveat (be precise). The per-database graphs are still serialized from their live state, not reconstructed as-of their pinned HEAD. A full as-of reconstruction would have to materialise each graph in memory (O(graph)), which would break the O(chunk) streaming property, so it is intentionally not done here. The practical guarantee is therefore:
- No stale-follower backups (leader barrier), and each database is labelled with the exact HEAD it was captured at.
- Cross-database point-in-time consistency holds when writes are quiesced during
the export. If writes continue while the (multi-database) backup streams, a
write that lands after the barrier can still be included in a database that is
serialized later — the recorded per-db
headstell you the intended cut, but the live bytes may run slightly ahead of it. For a guaranteed cross-database snapshot, take the backup from a quiescent cluster.
Auth-protected like every other management route (it is a full data dump): when a password is set, valid HTTP Basic credentials are required.
curl -s -u neo4j:secret http://127.0.0.1:21001/mgmt/snapshot \
-o patinadb-backup.json
GET /mgmt/export (portable CSV export)
Streams one database’s graph as a portable, import-compatible dump — the
online counterpart of the CLI export command.
GET /mgmt/export?db=<name>&format=csv
db— which database to export (defaultdefault).format—csv(the only format this release).parquet/arrowreturn400; the columnar writers live in the CLI (arrow/parquetdeps the server deliberately avoids) and a server columnar export is a documented follow-up.
The response is a tar archive (Content-Type: application/x-tar,
Content-Disposition: attachment; filename="patinadb-export-<db>.tar")
containing, in the exact neo4j-admin convention the importer reads:
nodes_<Label>.csv— a:ID(uuid)column, one typed column per property (age:int,score:float, …), and a trailing:LABELcolumn.rels_<TYPE>.csv—:START_ID(uuid),:END_ID(uuid),:TYPE, and one typed column per edge property._schema.json— a sidecar carrying the database’s UNIQUE / existence / node-key constraint defs plus compound / full-text / vector index defs, so an exported database can be fully reconstituted. (/mgmt/snapshotnow also carries these — see above — so both paths preserve the schema;/mgmt/exportis single-database + CSV,/mgmt/snapshotis whole-registry + JSON and additionally carries RBAC users and tags.)
Because the id column is the raw vertex UUID under an :ID(uuid) header, a
re-import reproduces the same graph with byte-identical UUIDs (the importer
uses the cell literally instead of hashing it). Round-trip:
curl -s -u neo4j:secret \
"http://127.0.0.1:21001/mgmt/export?db=default&format=csv" -o export.tar
mkdir out && tar xf export.tar -C out
patinadb ./restored import out/nodes_*.csv out/rels_*.csv
Like /mgmt/snapshot, it takes a leader linearizability barrier (so it is
leader-only and reflects every committed write; a follower answers 503 with a
leader hint) and streams in O(chunk) memory (per-label CSVs are written
row-by-row to a temp dir, then tar’d to a temp file that is streamed back and
deleted). Auth-protected — it is a /mgmt/ route, so it needs admin
credentials.
POST /mgmt/restore (restore / import)
Restores a backup produced by GET /mgmt/snapshot into the registry. Send it
to the leader (every step is a replicated write, so a follower answers with a
503 leader hint) and it needs admin credentials (it’s a /mgmt/ route).
curl -s -u neo4j:secret -X POST http://127.0.0.1:21001/mgmt/restore \
-H 'content-type: application/json' \
--data-binary @patinadb-backup.json
# → {"databases": 2, "ops_applied": 12345, "users_restored": 3}
For each database in the payload it issues CREATE DATABASE (idempotent), then
streams the graph back as chunked replicated writes (so the restore commits
through Raft and appears on every node), then re-registers, in order: the
full-text index defs, the compound-index defs, the vector-index defs
(with their trained centroids), the UNIQUE + existence/node-key constraints
(rendered back to CREATE CONSTRAINT … IF NOT EXISTS DDL and replayed via the
same replicated path interactive constraint DDL uses), and the tag names
(re-created pointing at the restored HEAD). Finally it restores the RBAC user
directory — each user (argon2 hash replayed verbatim, so the original password
still works) plus its per-database role grants. Every step is a replicated,
idempotent write, so a multi-node cluster converges and re-running the same
backup is a no-op — ideal for loading a backup into a fresh (empty) cluster.
It does not wipe pre-existing data first, so restore into an empty registry
(or a database that doesn’t yet hold conflicting ids) for a clean result.
What restore does NOT bring back. The backup is a HEAD-state dump, not a PITR archive (see
/mgmt/snapshotabove): engram history is not carried, so after a restore, time-travel over pre-backup engrams and pre-backup diffs are gone, and each restored tag resolves to the single restored-HEAD engram (not its original point in history). The anamnesis-enabled flag is not re-toggled on restore (the<db>__anamnesiscompanion database itself rides the backup as ordinary data, but automatic projection must be re-enabled withCALL patinadb.anamnesis.enable()). The backup is leader-anchored and HEAD-labelled, but each database’s graph is serialized from live state rather than reconstructed as-of its pinned HEAD, so cross-database point-in-time consistency only holds when writes are quiesced during the export.