Reference documentation
DewDB, from the first write to a sharded cluster.
A distributed JSON document database written in Rust, with replication, sharding, leader election, failover, secondary indexes, aggregation and change data capture built into the process.
1. Introduction
One binary, one JSON config file per node, local files for storage, HTTP as the protocol. There is no external coordinator, no separate metadata service, and no driver to install.
A shard owns an append-only write-ahead log, an in-memory sorted index with an inline value cache, snapshots and compaction. A router holds no data: it hashes collection:key, finds the owning group in its cluster view, and forwards. Each group replicates and fails over independently.
- JSON CRUD, merge patch, bulk writes, queries, aggregation and collection administration.
- WAL durability, crash recovery, index snapshots, background compaction and an inline read cache.
- Leader-driven replication, gap repair, snapshot resync, pre-vote elections and write concerns.
- Committed-only visibility, change streams, webhook delivery, health, JSON and Prometheus metrics, and tiered credentials.
2. Installation
Windows, PowerShell
iwr https://windows.dewdb.com -useb | iexThis installs DewDB for the current user under %LOCALAPPDATA%\DewDB and adds it to your user PATH, so it does not need Administrator privileges. Open a new terminal after installing to pick up the PATH change.
dewdb --version
dewdb init
dewdbWindows, GitHub Releases
Releases are published at github.com/dewdb/dewdb/releases/latest. Download the Windows x86_64 ZIP, extract it, and run:
.\dewdb.exe --version
.\dewdb.exe init
.\dewdb.exeEach release page carries a SHA256SUMS.txt if you want to verify the download before extracting.
Build from source
You need a Rust toolchain compatible with the Rust 2024 edition.
cargo build --release
./target/release/dewdb --config examples/dew.jsonexamples/dew.json is a ready-made single-node config on port 8090. With no --config, the binary reads dew.json in the working directory. --version and --help are answered before any config file is read, so they work before you have written one.
3. Quick start
dewdb init writes a starter dew.json. That is all a single node needs: it leads itself, commits on its own fsync, and is ready immediately.
{
"node_id": "n1",
"role": "shard",
"shard_role": "primary",
"listen_addr": "127.0.0.1:8081",
"data_dir": "./data"
}Collections are created on first use, with no schema and no create step.
curl -s -X PUT localhost:8081/collections/users/docs/u1 \
-H 'content-type: application/json' \
-d '{"value":{"name":"ada","age":36,"profile":{"city":"london"}}}'curl -s --get localhost:8081/collections/users/query \
--data-urlencode 'filter={"age":{"$gte":30},"profile.city":"london"}' \
--data-urlencode 'sort=age:desc' \
--data-urlencode 'fields=name,profile.city'{"items":[{"name":"ada","profile":{"city":"london"}}],"next_cursor":null}Ctrl-C shuts the node down cleanly: pending group-commit waiters are fsynced and the durable position is recorded before the process exits.
4. Configuration
One JSON object per node. Only node_id, role and listen_addr are required, plus shard_role for a shard that should serve as primary. The whole config tree rejects unknown fields, so a typo fails the boot instead of silently taking a default.
| Field | Required | Meaning |
|---|---|---|
node_id | Yes | Name used in logs and metrics, and the author of cluster view changes. |
role | Yes | shard or router. |
listen_addr | Yes | Bind address, and this node's identity in the cluster (host:port). |
shard_role | Shard | primary or replica. A primary with no peers bootstraps as leader. |
data_dir | No | Where WALs, indexes and metadata live. Defaults to ./data. |
membership_mode | No | voter by default; a learner never campaigns and counts in no quorum. |
primary_addr | Replica | Whose heartbeats to poll and whose snapshots to pull. |
replicas | Primary | Who receives frames, and whose acknowledgements count. |
peers | Shard | The voting set for elections. Lists the other nodes, never this one. |
ring / shard_map | Router | Consistent hashing, or explicit hash ranges. Mutually exclusive. |
auth | No | api_keys, admin_keys, internal_secret and upstream_api_key. |
Further blocks tune timing and limits: flow_control, maintenance, rebalance, data_movement, read_cache, changefeed, webhooks and logging. Each has a working default.
5. REST API
All bodies are JSON, and errors are {"error":"…"}.
Documents
| Method | Path | Purpose |
|---|---|---|
| POST | /collections/:name/docs | Create with a server-generated UUIDv4 key. |
| PUT | /collections/:name/docs/:id | Full replace. 201 when created, 200 when replaced. |
| PATCH | /collections/:name/docs/:id | JSON Merge Patch (RFC 7396). A null body is rejected. |
| GET | /collections/:name/docs/:id | The stored value. Accepts ?read=. |
| DELETE | /collections/:name/docs/:id | Answers whether the document existed. |
| POST | /collections/:name/docs/bulk | One group commit and one replication round, per-document results. |
| GET | /collections/:name/docs | An ownership-filtered page on one shard. |
| GET | /collections/:name/query | Filter, sort, project and page. |
| GET | /collections/:name/aggregate | Totals over the same filter. |
| GET | /collections/:name/changes | Committed changes as server-sent events. |
| GET | /collections/:name/changes/ws | The same feed over a WebSocket. |
Administration
| Method | Path | Purpose |
|---|---|---|
| GET | /collections | A union across every shard group. |
| DELETE | /collections/:name | A replicated log entry; takes ?w=. |
| POST | /collections/:name/compact | Force compaction on the leader. |
| POST | /collections/:name/snapshot | Write an index snapshot. Allowed on any node. |
| POST | /collections/:name/indexes | Define a secondary index on a dotted field. |
| GET | /collections/:name/indexes | Definitions with state, document and value counts. |
| DELETE | /collections/:name/indexes/:index | Drop an index. |
| POST | /collections/:name/webhooks | Register a signed CDC delivery endpoint. |
| POST | /cluster/members | Admit a node as a learner. |
| POST | /cluster/configuration | Move the voting set through joint consensus. |
| POST | /cluster/ring | Publish a ring. ?dry_run=true to inspect. |
| POST | /cluster/migrate | Copy-then-flip handover to a target ring. |
Status codes worth knowing
| Code | Meaning |
|---|---|
202 | Durable, but the write concern was not met. The body carries acks and required. |
400 | An unknown read preference, a filter or sort the engine cannot evaluate, or a cursor that does not belong to this query. |
403 | A direct write sent to a node that is not the leader. |
404 | No such document, or no such collection. Reads never create one. |
409 | This shard does not own the key; the body names the owner. A router follows that name and retries once. |
410 | A change-stream position older than the buffer still holds. The body names the resume_floor that works. |
413 | A body over the 2 MiB public ceiling, or a bulk write wider than the uncommitted bound. Not retriable. |
429 | Every scan slot on the node is taken. Retriable, and per node. |
503 | A replication backlog, a key mid-handover, a guaranteed read this node cannot answer, or a write whose leadership term went away. Retry-After is set. |
Nothing unrecognised is reinterpreted. An unknown ?w=, an unknown ?read=, an unparseable sort or a cursor that does not belong to its query is a 400 rather than a silent default.
6. Working with documents
Documents are arbitrary JSON values under string keys. POST mints a UUIDv4; PUT takes the key you supply and replaces the whole value; PATCH applies JSON Merge Patch, so a null field removes it, nested objects merge, and anything else replaces.
- A delete appends a tombstone, so it replicates, commits and recovers exactly like a write.
- A bulk write lands as one group commit, and ownership is checked for every key before anything is written — the batch is accepted whole or refused whole.
- Routing hashes
collection:key, so two collections distribute across a cluster independently. - A collection name is 1–128 characters of
a-z0-9._-, checked after percent-decoding and again before it becomes a directory. - Reads never create a collection. A read of one that does not exist is a
404.
7. Querying
GET /collections/users/query
?filter={"age":{"$gte":30}}
&sort=tier:asc,age:desc
&fields=name,profile.city
&start=u&end=v
&limit=50&cursor=…
&read=quorumOperators
| Family | Operators |
|---|---|
| Equality | $eq, $ne, $in, $nin |
| Comparison | $gt, $gte, $lt, $lte over numbers or strings |
| Shape | $exists, $type |
| Strings | $prefix, $suffix, $contains |
| Arrays | $all, $size, $elemMatch |
| Composition | $and, $or, $nor, and $not over one field's condition |
Conditions on one field and across fields are ANDed. A document missing the path does not match — $exists: false and $not are the two ways to select for absence. Ordered comparison stays inside one JSON type: $gt: 1 never admits a string, and $gt: "b" never admits a number.
Sorting, projection and paging
- Sort on up to eight dotted paths, each ascending or descending, using a total order across JSON types. The document key decides where they all tie.
fields=a,b.creturns only the named paths, rebuilding nested structure.- Every query returns an opaque
next_cursor. A sorted cursor is one position for the whole cluster; an unsorted cursor is a keyspace position pinned to the layout it began on. limitdefaults to 100 and is capped at 10 000.max_docsbounds the walk behind it: 100 000 documents per shard per request by default.keys=truereturns a parallelkeysarray;keys=embedreturns each row as{id, value}.
Read preferences
| Value | Meaning |
|---|---|
absent | Primary first, then replicas. |
primary | The answering node must believe it leads, or it refuses with 503. |
quorum | Linearizable: the leader establishes a read index first. |
replica | Prefer replicas, ranked by observed load, falling back to the primary. |
8. Secondary indexes
POST /collections/users/indexes {"name":"by_age","field":"age"}
GET /collections/users/indexes
DELETE /collections/users/indexes/by_age- A definition is a replicated log entry, so it commits under a quorum, survives a leader change and reaches a replica that was down for it.
- The postings are derived, never written to disk: a node that opens a collection rebuilds every index it holds a definition for.
- A build runs in the background, and the planner uses the index only once it is
ready— a query is never answered from a half-filled index. - An index narrows which keys are read, never which rows are returned. Equality,
$in, ordered comparison,$prefix, a single$typeand$exists: trueeach name a span of the postings. - A cluster-wide catalogue carries definitions to a shard group that gains the collection later, after a new shard, a handover, a rebalance or a restart.
- Eight indexes per collection, 64 bytes of index name, 256 bytes and 16 segments of field path.
9. Aggregation
GET /collections/orders/aggregate
?filter={"tier":"gold"}
&group=region,plan
&metrics=count,sum:amount,avg:amount,min:signed_up,max:signed_up
&read=quorum&max_docs=100000Totals are computed on the shard and merged through the totals rather than through the answers: every metric travels with the count behind it, so a router sums sum and count across groups and divides once. An average of shard averages is not an average, and this shape makes that unrepresentable.
count,sum,avg,minandmax, grouped on up to four dotted paths.- Every response carries
scannedbesidematched, so the cost of a request is in the answer. - At most 10 000 distinct groups and 16 metrics. A spent read budget refuses by default;
partial=trueopts into totals over what was read, labelledpartial. - The same filter, index use and read preference as a query.
10. Change streams and CDC
curl -N --get localhost:8081/collections/orders/changes \
--data-urlencode 'filter={"status":"paid"}'event: open
data: {"collection":"orders","position":412}
event: change
id: 413
data: {"lsn":413,"op":"insert","key":"o1","value":{"region":"eu","amount":35,"status":"paid"}}- Events are published where entries are applied, never where they are appended, so a subscriber never sees a write a leader change can take back.
- Four kinds:
insert,update,deleteanddrop. A drop is one event, not one delete per key. - Every event's
lsnis also its SSEid, so?after=and a browser'sLast-Event-IDboth resume exactly where the stream stopped. - A position older than the buffer is refused with
410and theresume_floorthat still works, rather than silently skipped. - The same feed is available over a WebSocket at
/changes/ws, with the same parameters and the same refusals. - A subscription may be opened before the collection exists, and captures its first writes.
Webhook delivery
For a consumer that is not running when the change happens, register an endpoint and the same feed is pushed to it.
- Each delivery carries
HMAC-SHA256(secret, "<timestamp>.<body>"), with the timestamp inside the signed string. The secret is write-only and never appears in a response. - Retried until acknowledged, with the same batch and delivery id, on a delay that doubles to a cap and carries a per-subscription spread.
- After a
2xxthe position advances through a majority-committed entry, so a restart or a promoted replica resumes where the endpoint got to. delivered,attempts,failures,gapsandlast_errorreport what has been happening.
11. Write concerns and durability
Every mutation is a CRC-checked frame appended to a write-ahead log, fsynced through group commit, staged as non-visible, and replicated. Quorum acknowledgements advance a per-collection commit index, and the committed prefix is applied to the read-visible index.
w=1waits for local leader durability.w=majoritywaits for a strict majority including the leader.w=allwaits for every configured acknowledgement.w=Nwaits for an explicit acknowledgement count.
wtimeout bounds the wait. A write that is durable but short of its concern answers 202 with acks and required in the body, so a client that asked for durability and was answered 200 got the durability it asked for.
Recovery truncates a torn tail rather than trusting it, and a restart replays what was committed. Index snapshots bound replay work and are written atomically through a temp file plus rename.
12. Replication and failover
Three nodes, each with its own port and data directory. The primary lists its replicas and peers; a replica is the same file with shard_role set to replica and primary_addr pointing at the leader.
{
"node_id": "n1",
"role": "shard",
"shard_role": "primary",
"listen_addr": "127.0.0.1:9501",
"data_dir": "./n1",
"replicas": ["http://127.0.0.1:9502", "http://127.0.0.1:9503"],
"peers": ["http://127.0.0.1:9502", "http://127.0.0.1:9503"]
}curl -s -X PUT 'localhost:9501/collections/users/docs/u1?w=majority' \
-H 'content-type: application/json' -d '{"value":{"name":"ada"}}'Stop the leader. Within the heartbeat timeout plus election jitter, a survivor takes the term and accepts writes, with no operator step and no external arbiter. A replica that fell behind repairs from WAL frames, or takes a full collection snapshot if it fell behind further than retention.
- Term, leadership and vote are persisted as one value, and a node never acts on either before it is durable.
- Pre-vote elections and log-freshness checks keep a node that cannot win from disturbing a healthy leader.
- Commit counts only entries from the current term, and membership moves through a joint configuration.
- Add a replica at runtime as a
learner, then promote it withPOST /cluster/configuration.
14. Security
{
"auth": {
"internal_secret": "cluster-secret",
"api_keys": ["client-key-1"],
"admin_keys": ["admin-key-1"],
"upstream_api_key": "client-key-1"
}
}- API keys or bearer tokens guard the public API. Send them as
x-api-keyorAuthorization: Bearer …. admin_keysis a separate tier over topology, collection drops, index definitions and webhook routes — listing webhooks included, because a listing names where a node posts documents.internal_secretguards/internal/*, the node-to-node routes.upstream_api_keyis what a router presents to the shards behind it./healthstays reachable without credentials;/metricsrequires a key when API keys are configured.- Credentials reload without a restart.
15. Monitoring
GET /health answers 200 ok, or 503 degraded with the reasons, which is what a load balancer wants. The body also carries role, leadership, term, cluster version, uptime and collection count.
{"status":"ok","node_id":"n1","role":"shard","leader":true,"term":0,
"cluster_version":1,"uptime_secs":3,"collections":0,"reasons":[]}GET /metrics returns JSON: per-collection documents, WAL bytes, live and dead bytes, last and applied LSN, cached documents, a change-feed block, a replication block, a cluster summary, router state, and per-route request counts with errors and p50/p95/p99 latency. ?format=prometheus exposes the same data as Prometheus text.
Logging is structured tracing output, text or one JSON object per line, both stamped with node_id and a subsystem target. RUST_LOG overrides logging.level.
16. Client snippets
There is no driver to install. Any HTTP client is a first-class client — this is the whole integration.
const base = 'http://localhost:8080';
const headers = { 'content-type': 'application/json', 'x-api-key': 'client-key-1' };
export async function put(collection, id, value, w = 'majority') {
const r = await fetch(`${base}/collections/${collection}/docs/${id}?w=${w}`, {
method: 'PUT', headers, body: JSON.stringify({ value }),
});
const body = await r.json();
if (r.status === 202) console.warn('write concern not met', body); // durable, under-replicated
else if (!r.ok) throw new Error(body.error ?? r.statusText);
return body;
}
export async function* queryAll(collection, filter) {
let cursor = null;
do {
const q = new URLSearchParams({ limit: '500', filter: JSON.stringify(filter) });
if (cursor) q.set('cursor', cursor);
const page = await fetch(`${base}/collections/${collection}/query?${q}`, { headers })
.then(r => r.json());
yield* page.items;
cursor = page.next_cursor;
} while (cursor);
}A browser normally reaches a change stream through your own backend, which relays the stream same-origin and forwards Last-Event-ID. The upstream first-application walkthrough has a minimal SSE proxy and the browser side that goes with it.
17. Troubleshooting
| Symptom | What it means |
|---|---|
| A write answers 202 | The write was durable but its concern was not met. Read acks and required in the body. |
| A write answers 403 | The target is not the leader. Send it to the primary, or through a router. |
| A write answers 409 | This shard does not own the key. The body names the owner; a router follows it for you. |
| A query answers 400 about a cursor | The cursor belongs to a different query, or the shard layout moved under an unsorted scan. Restart the scan. |
| A change stream answers 410 | The position is older than the buffer. Resume from the resume_floor in the body. |
| An aggregation answers 400 about the read budget | Narrow the range, raise max_docs, or pass partial=true to accept labelled partial totals. |
| A request answers 429 | Every scan slot on the node is taken. Retry; through a router it means every target in some group was busy. |