Pre-1.0 hoardDB is pre-1.0. Expect breaking changes.

hoardDB Query Language (HQL) reference

HQL is what you type at the hoarddb prompt, or pipe into it. This page has not been rewritten for ring mode yet. It documents buckets with a single type, which ring mode does not have: in ring mode a bucket declares fields and a field can be a store. help all in a ring session (hoarddb -ring) lists the statements that run today, and Getting started walks through them. The rest of this page lists the statements it was written against and what each one prints. Every example on it was run against a real server, and the output shown is what that server printed. Where the CLI refuses a statement, the page quotes the refusal, because a refusal is usually the first thing you meet when you bring habits over from another database.

A statement works unless the page says otherwise, and most of this page is statements that work: the CLI accepts them and the server serves them. Two marks say otherwise:

  • Refused: the CLI or the server turns it down with a message that says what to do instead. These are quoted in full.
  • Planned: not accepted yet. Marked Planned wherever it appears, with a link to Limitations, which says when it is due.

The examples use a database called shop. Output lines are copied from the run; only the prompt differs between a terminal session and a piped one (see The interactive REPL).

Contents

Connect and authenticate

A server started with no configuration creates a root user on the first start from the password you choose; only its hash is kept, in data/users.json. It listens for clients on port 7433. Choose the password, start the server, then start the CLI; that is the shortest path to a prompt:

export HOARDDB_ROOT_PASSWORD='<choose one>'
hoarddb-server &
hoarddb -address 127.0.0.1:7433 -insecure
WARNING: TLS certificate verification is disabled (--insecure)
Authenticated as admin
Connected to 127.0.0.1:7433
┌─ hoardDB ────────────────────────────────────────────────────────────────┐
│ node        node-a                        version 0.1.0                  │
│ address     127.0.0.1:7433                uptime 5s                      │
│ cluster     standalone                    RF — no buckets yet            │
│ process mem 4.9 MiB of 28.7 GiB (0.0%)                                   │
│ cpus        16                            load 0.56 2.04 3.22            │
│ server ram  12.2 GiB of 28.7 GiB                                         │
│ session     admin — no database selected  2026-10-11 18:19:01            │
└──────────────────────────────────────────────────────────────────────────┘
Type 'help' for help, 'exit' to quit.

[h]oardDB>

-insecure skips certificate verification, and the warning says so every time. Use it against a local development server only. Without it, the first connection to a server asks you to confirm the server’s fingerprint and records it in ~/.hoarddb/known_servers:

The authenticity of server '127.0.0.1:7433' cannot be established.
ED25519 key fingerprint is SHA256:00feb48e7331de075a1607916ce804a397440d80c99c0c01b1a23624897d93c8.
Trust this server? [y/N]: y
Authenticated as admin
Connected to 127.0.0.1:7433

A script has nobody to answer that question, so a piped session refuses an unknown server instead of guessing:

The authenticity of server '127.0.0.1:7433' cannot be established.
ED25519 key fingerprint is SHA256:00feb48e7331de075a1607916ce804a397440d80c99c0c01b1a23624897d93c8.
Refusing to trust it non-interactively: pre-populate ~/.hoarddb/known_servers or pass --insecure to bypass.
Failed to connect to 127.0.0.1:7433: connect to 127.0.0.1:7433: dial 127.0.0.1:7433: server 127.0.0.1:7433 not trusted (fingerprint: SHA256:00feb48e7331de075a1607916ce804a397440d80c99c0c01b1a23624897d93c8)

Connect once from a terminal and answer y. Every later run, piped or not, then connects without asking.

Credentials

Every statement needs an authenticated session, so the CLI authenticates when it connects. It takes credentials from, in order: the -user/-password flags, the config file, and HOARDDB_ROOT_USER/HOARDDB_ROOT_PASSWORD. Run it from anywhere else with none of those set and it says what to do:

Failed to connect to 127.0.0.1:7433: no credentials
       pass -user <name> -password <password>, or set the environment:
         HOARDDB_ROOT_USER=admin HOARDDB_ROOT_PASSWORD=<password> hoarddb
       the server's root user comes from HOARDDB_ROOT_USER/HOARDDB_ROOT_PASSWORD
       when the server is started

A wrong password:

Failed to connect to 127.0.0.1:7433: authenticate as admin: authentication failed (CREDENTIALS): invalid username or password

Flags

FlagWhat it does
-address <host:port>The server’s client port. Default localhost:7433.
-user <name>, -password <pw>Credentials for this session.
-auth-token <token>Resume a session with a token instead of a password (also HOARDDB_AUTH_TOKEN).
-insecureSkip certificate verification. Local development only.
-extjsonPrint dates in every document the CLI prints as {"$date": "..."} instead of plain strings.
-prettyPrint each document on several lines, two spaces per level, instead of one JSON object per line. The layout changes; the values do not — a 64-bit integer or a decimal is not decoded and re-encoded to be indented.
-theme <path or plain>Prompt theme; plain turns colour off.
-no-pagerPrint long reports in full instead of a screen at a time. A piped run, or a terminal with TERM=dumb, is never paged.
-password-file <path>, -password-stdinWhere create user and alter user ... password read a new password in a script.
-config <path>A YAML configuration file.
-debugPrint each parsed statement before running it.
-v, -versionPrint the version and exit without connecting.
-h, -helpPrint the usage, with examples, and exit.

There is no flag that runs a single statement. Pipe statements into the CLI instead, as the next section shows.

A flag the CLI does not know, or a word on the command line that is not a flag or a verb (hoarddb "show databases"), is a usage error: the CLI prints what was wrong and exits 1 without connecting. It used to exit 2 for an unknown flag, the number dump and restore use for “the server refused”, and to ignore a stray word and start a session. The verbs dump, restore and ingest must come first; hoarddb -address host:7433 dump is refused with “a verb must come first”.

A session that cannot start says why in its exit number: 6 when the server could not be reached (nothing listening, a certificate that was not trusted, a server at its connection limit), 7 when the server refused the credentials, and 1 when there are no credentials to send. It used to exit 1 for all of them.

The session banner

A terminal session opens with a box describing the node it is connected to. The node lines (its id, address, version, uptime, replication factor and memory) come from the node’s own health reply. The cluster line is members N, where N is the number of members in the node’s own membership record (the count status shows on its Membership: line), not the number of peers it has heard from; it reads standalone only for a node with no cluster at all, and members unknown from a server that does not report the count. The server rows describe the machine the node runs on:

┌─ hoardDB ────────────────────────────────────────────────────────────────┐
│ node        node-a                        version 0.1.0                  │
│ address     127.0.0.1:7433                uptime 5s                      │
│ cluster     standalone                    RF — no buckets yet            │
│ process mem 4.9 MiB of 28.7 GiB (0.0%)                                   │
│ cpus        16                            load 0.56 2.04 3.22            │
│ server ram  12.2 GiB of 28.7 GiB                                         │
│ session     admin — no database selected  2026-10-11 18:19:01            │
└──────────────────────────────────────────────────────────────────────────┘
Type 'help' for help, 'exit' to quit.
  • cpus is the number of CPUs the node may use. When a container limit set it below the machine’s count, the row says 2 of 64 on the server.

  • load is the machine’s 1, 5 and 15 minute load averages, on the same row as cpus.

  • server ram is the server machine’s memory in use out of the total the node can use. When a container memory limit sets the total, the row also shows limit cgroup; otherwise the total is the machine’s own.

  • process mem is the hoardDB process’s own resident memory, out of the limit the node chose for it. It is not the machine’s memory; that is server ram.

A server that cannot report a row leaves it out. A server that is not on Linux reports only its CPU count, so the banner shows the cpus row with no load and no server ram row. An older server sends none of these figures, so the banner has no server rows at all. A load average that cannot be read leaves out load, and a memory total that is not known leaves out server ram. No row shows 0 B or n/a.

If the node cannot be asked at all, the session prints the two plain lines it always printed. Piped and redirected output is unchanged, and show banner draws the same box on demand.

How statements are read

The CLI reads one statement per line, from the terminal or from standard input:

hoarddb -address 127.0.0.1:7433 -insecure <<'EOF'
use shop
db.Users.count()
EOF

These rules come from the parser, and each one was checked against it:

  • One statement per line, unless a brace or bracket is still open. A trailing ; is optional, and so are several of them (db.Users.count();; works). A ; in the middle does not separate statements: use shop; show buckets looks for a database called shop;.
  • A statement spans lines while a { or [ is open. The CLI keeps reading until the braces and brackets balance, then runs the lines as one statement, both piped and at the prompt (where the continuation prompt is ...>). A brace inside a quoted string does not count. A -- outside a quoted string runs to the end of its line, so a brace or bracket after it does not continue the statement either. (That is the continuation reader’s rule only: the statement parser still accepts no comments — see below.) A ( does not count either: db.Users.insert( alone on a line is refused, so open the parenthesis and the brace on the same line, as in db.Users.insert({. Ctrl-C at ...> throws the half statement away. A script that ends with a brace still open stops with an error naming the line it began on.
  • Put the ; straight after the closing parenthesis. db.Users.find({Name: "Ada"}) ;, with a space before the semicolon, is refused as expected parentheses.
  • Keywords and verbs are case-insensitive; names are not. SHOW DATABASES, DB.Users.count() and db.Users.COUNT() all work. db.users.count() looks for a bucket called users and does not find Users.
  • There are no comments. -- and /* */ are refused as parse: unknown command: -- and parse: unknown command: /*. (The CLI’s own help text still lists them as comments; the statement parser does not accept them.)
  • An argument can contain (. The method call starts at the first opening parenthesis after the bucket name, so ISODate("...") inside a document works; see Dates.
  • Documents accept relaxed syntax. Strict JSON, unquoted keys, single quotes and a trailing comma are all accepted:
[h]oardDB [shop]> db.Users.insert({"Name": "Grace", "Email": "grace@example.com", "Age": 17, "Status": "trial"})
OK  id=01a0de60-9e4c-77c4-aea1-1274ceba1389
[h]oardDB [shop]> db.Users.insert({Name: 'Linus', Email: 'linus@example.com', Age: 29, Status: 'banned',})
OK  id=01a0de60-9e4c-799e-8bee-d9b6b2dc4d1b

A script stops at the first failing statement. Piped input is non-interactive: the CLI prints CLI error: <reason>, runs nothing after it, and exits with status 1, so a script never carries on from a wrong assumption. At a terminal the same failure prints Error: <reason> and the session continues.

Databases

create database, use, show databases, describe database and drop database.

[h]oardDB> create database shop
OK  created database shop
Next: use shop
Then: create bucket <name> {type: hash | btree | fifo | lifo | heap | blob}   (hash is the default)
     create bucket Post {Photos lifoStore, Comments fifoStore}
     a store field is <name> <type>Store — hashStore, btreeStore, fifoStore, lifoStore, heapStore, blobStore
[h]oardDB> use shop
Switched to database 'shop'
[h]oardDB [shop]> show databases
DATABASES
---
NAME  BUCKETS
shop  0
[h]oardDB [shop]> describe database shop
DATABASE shop
---
Buckets: 0

show databases lists the databases you hold a role on, and every database when you hold a role on *; a user with no role on a database does not see its name, and use answers for it as for a database that does not exist.

show dbs and create db <name> are accepted spellings. Creating a database does not switch to it; run use next, as the hint says. use checks that the database exists, so a typo fails at once instead of at the next statement:

[h]oardDB> use Nope
Error: database "Nope" not found — this server has no databases yet; create one with 'create database Nope'
[h]oardDB> create database shop
Error: create database "shop": server error (2): database "shop" already exists

Creating a database that already exists is refused with the code ALREADY_EXISTS, so a mistyped name that happens to exist is not mistaken for a new database. Creating a bucket that already exists, with the same type, answers OK and changes nothing.

drop database <name> deletes the database and every bucket in it.

Refused: a replication factor on a database. The factor belongs to the bucket, and the refusal explains why the clause is not simply ignored:

[h]oardDB> create database app2 with replication_factor 3
Error: parse: replication_factor is not a database-level setting: this clause was accepted and ignored, so database "app2" would have been created with the node-wide factor and no extra copies. The factor belongs to the bucket — run 'create database app2', then 'create bucket <name> { replication_factor: N }'. A bucket created without that clause gets the node-wide factor, HOARDDB_REPLICATION_FACTOR

Buckets

A bucket has one store type of its own, chosen when you create it. There are six: hash, btree, fifo, lifo, heap and blob. A hash or btree bucket can also declare store fields, a store of any of the six types on each of its documents (below). Store types explains how to choose between them.

create bucket

The braces carry the type and, optionally, the fields to index:

create bucket <name> [in <database>] { type: <store type> [, indices: ["<field>", ...]] }
[h]oardDB [shop]> create bucket Users { type: hash, indices: ["Email"] }
OK  created bucket shop.Users (type hash, whole)
Indexes: [Email]
[h]oardDB [shop]> create bucket Ledger { type: btree }
OK  created bucket shop.Ledger (type btree)
[h]oardDB [shop]> create bucket Jobs { type: fifo }
OK  created bucket shop.Jobs (type fifo)
[h]oardDB [shop]> create bucket Undo { type: lifo }
OK  created bucket shop.Undo (type lifo)
[h]oardDB [shop]> create bucket Alerts { type: heap }
OK  created bucket shop.Alerts (type heap)
[h]oardDB [shop]> create bucket Files { type: blob }
OK  created bucket shop.Files (type blob)

A field declared unique creates a unique index, and the confirmation marks it. unique covers a bucket’s own fields only; a unique inside a store field’s braces is refused (see store fields):

[h]oardDB [shop]> create bucket Accounts { type: hash, placement: whole, Email string unique }
OK  created bucket shop.Accounts (type hash, whole)
Indexes: [Email (unique)]

Other forms the CLI accepts:

  • No braces: create bucket Cache makes a hash bucket, the default.
  • No braces, with a type: create bucket Events type fifo.
  • b+tree is accepted as a spelling of btree.
  • in <database> creates the bucket without use: create bucket Remote in shop { type: hash }.

A database name and a bucket name are each at most 255 bytes; a longer one is refused before anything is written, with an error that names the limit. See How big can one thing be.

An unknown type is refused, and the refusal lists the valid ones:

[h]oardDB [shop]> create bucket Bad { type: queue }
Error: unknown bucket type "queue" — valid types: hash, btree, fifo, lifo, heap, blob

Indexes are declared at creation and cannot be added later (see alter bucket). An index serves search, which is equality lookup.

Three traps in create bucket, all checked against this build:

  • Creating a bucket that already exists with the same type answers OK and changes nothing. create bucket Users { type: hash } on the existing hash bucket above prints OK bucket "Users" already exists (type: hash), so a script can run its setup twice. With a different type it is refused: create bucket Users { type: fifo } fails with the code ALREADY_EXISTS and says the bucket is a hash, because a bucket’s type cannot be changed in place. Use another name, or drop bucket Users (which deletes its data) and create it again.
  • replication_factor in the braces is honoured, on a cluster. create bucket Rf { type: hash, replication_factor: 3 } stores the factor with the bucket, and show buckets lists it as rf 3. A bucket created without the clause gets the node-wide factor (HOARDDB_REPLICATION_FACTOR). A standalone server refuses a factor above 1, and so does a cluster with fewer members than the factor. Raise it later with alter bucket: alter bucket Rf { replication_factor: 5 }.
  • A dotted name is one flat bucket. create bucket Post.Comments { type: fifo } creates a single top-level bucket whose name contains a dot, stored at data/shop/Post.Comments. It is not a sub-bucket of Post, and it is not the Comments store field of a Post document. Avoid dots in bucket names; for a store per document, declare a store field.

Store fields: a store on each document

A bucket can declare fields, and a field can itself be a store of any of the six types. The store belongs to each document, not to the bucket: with Purchases fifoStore on User, every user has their own purchases queue. Declare the fields in the braces of create bucket, on one line or across several:

create bucket <name> { <Field> <kind>, ... }

The six store kinds are fifoStore, lifoStore, hashStore, btreeStore, heapStore and blobStore. Scalar fields (Name string, Age int, CreatedDate datetime) may sit beside them as documentation:

[h]oardDB [app]> create bucket User { Name string, Age int, CreatedDate datetime, Purchases fifoStore, Photos lifoStore, Friends hashStore, Recipes btreeStore, Books heapStore, Data blobStore };
OK  created bucket app.User (type hash, whole)
Stores:  6 store field(s) declared
note: field types on scalar fields (Name string, Age int, CreatedDate datetime) are not enforced in this release; only store fields are created.
[h]oardDB [app]> db.User.insert({_id_: "ada", Name: "Ada", Age: 36})
OK  id=ada
[h]oardDB [app]> db.User.insert({_id_: "bob", Name: "Bob", Age: 41})
OK  id=bob

The same declaration across lines, as a script on standard input (the CLI keeps reading until the { closes; at the prompt the lines after the first show ...>):

$ cat schema.hql
use app
create bucket Shop {
    Name    string,
    Orders  fifoStore,
    Photos  lifoStore
};
$ hoarddb -address 127.0.0.1:7433 -insecure < schema.hql
Switched to database 'app'
OK  created bucket app.Shop (type hash, whole)
Stores:  2 store field(s) declared
note: field types on scalar fields (Name string) are not enforced in this release; only store fields are created.

Address a document’s store as db.<bucket>["<key>"].<Field>.<verb>(...). db.User["ada"].Purchases is Ada’s own purchases queue. The verbs are the ones the store type has everywhere else: push, pop and peek on a fifo, lifo or heap field; put, get and delete on a hash, btree or blob field; len and list on all of them.

[h]oardDB [app]> db.User["ada"].Purchases.push({item: "lamp", cents: 1999})
OK
[h]oardDB [app]> db.User["ada"].Purchases.push({item: "desk", cents: 24900})
OK
[h]oardDB [app]> db.User["ada"].Purchases.len()
2
[h]oardDB [app]> db.User["ada"].Purchases.pop()
{"_id_": "01a101b7-bbd9-733d-bd5b-6bb4f9a01896", "cents": 1999, "item": "lamp"}
[h]oardDB [app]> db.User["ada"].Purchases.pop()
{"_id_": "01a101b7-bbda-74a1-b94b-ea799c417427", "cents": 24900, "item": "desk"}
[h]oardDB [app]> db.User["ada"].Purchases.pop()
EMPTY
[h]oardDB [app]> db.User["ada"].Photos.push({file: "a.jpg"})
OK
[h]oardDB [app]> db.User["ada"].Photos.push({file: "b.jpg"})
OK
[h]oardDB [app]> db.User["ada"].Friends.put("bob", {since: 2020})
OK
[h]oardDB [app]> db.User["ada"].Friends.get("bob")
{"_id_": "01a101b7-bbe0-7e0e-bed9-e8d044971336", "since": 2020}
[h]oardDB [app]> db.User["ada"].Friends.list()
bob  {"_id_": "01a101b7-bbe0-7e0e-bed9-e8d044971336", "since": 2020}
[h]oardDB [app]> db.User["ada"].Recipes.put("pie", {minutes: 60})
OK
[h]oardDB [app]> db.User["ada"].Recipes.get("pie")
{"_id_": "01a101b7-bbe2-7b32-84db-ecbfade2fbee", "minutes": 60}
[h]oardDB [app]> db.User["ada"].Books.push({title: "SICP", priority: 1})
OK
[h]oardDB [app]> db.User["ada"].Books.push({title: "TAOCP", priority: 5})
OK
[h]oardDB [app]> db.User["ada"].Books.peek()
{"_id_": "01a101b7-bbe6-7043-b592-d114d44f6919", "priority": 5, "title": "TAOCP"}

The generated _id_ values differ on every run. A heap field orders by the priority of what you push, as a heap bucket does.

One user’s stores are not another’s. Bob has the same fields and none of Ada’s elements, and pushing to his queue leaves hers alone:

[h]oardDB [app]> db.User["bob"].Purchases.len()
0
[h]oardDB [app]> db.User["bob"].Purchases.push({item: "mug", cents: 800})
OK
[h]oardDB [app]> db.User["ada"].Purchases.len()
0
[h]oardDB [app]> db.User["bob"].Purchases.len()
1
[h]oardDB [app]> db.User["bob"].Friends.get("bob")
NOT FOUND
[h]oardDB [app]> db.User["bob"].Books.peek()
EMPTY

The document does not carry its stores; the terminal shows where they are. get and find return the document’s own fields; a store is read through its own address. On a terminal, the shell also names each store the bucket declares on the document, so you can see what there is to address:

[h]oardDB [app]> db.User.get("ada")
{"_id_": "ada", "Age": 36, "Name": "Ada", "Purchases": "fifoStore", "Photos": "lifoStore", "Friends": "hashStore", "Recipes": "btreeStore", "Books": "heapStore", "Data": "blobStore"}

The value of a store field is its declaration, not data: "Photos": "lifoStore" says this document has a lifo, and db.User["ada"].Photos is where its elements live. On a terminal _id_ prints first, then the document’s own fields in sorted order, then the declared stores in the order the bucket declared them. A document that holds a field of the same name keeps its own value. An element read from a store field, db.User["ada"].Photos.pop(), is an element and not a document of the bucket, so it shows none of this.

Piped output is unchanged. The display is for a person reading. When the shell’s input or output is not a terminal (a pipe, a redirect, a script, TERM unset or dumb), or the statement is a dump, every document prints exactly as before: its own fields only, keys sorted.

$ printf 'use app\ndb.User.get("ada")\n' | hoardDB -insecure 2>/dev/null | tail -1
{"Age": 36, "Name": "Ada", "_id_": "ada"}

The display costs one small call per bucket per session, answered without reading the bucket’s stores, so it takes the same time for a store that holds sixty thousand elements as for an empty one. The answer is kept for the session and dropped when a statement that can change a declaration runs (create, alter or drop of a bucket or database, restore, connect). If the call cannot be answered, for example by an older server or a user who may not read the bucket, the document prints without the stores and nothing is reported.

describe bucket lists the declared stores, with the element count summed over every document. The Replication factor row says how many copies the answering node believes the bucket has: 3 (declared) for a factor the bucket was created with, 1 (inherited) for the node’s own. A server that predates the row prints none:

[h]oardDB [app]> describe bucket User
BUCKET app.User
---
Type:               hash
Entries:            2
Logical size:       1.3 KiB (before compression and compaction)
Encrypted:          no (this node's store)
Replication factor: 1 (inherited)
Indexes:            (none — declare them when you create the bucket)
Stores:             (elements summed over every document)
  Purchases    fifoStore  1 element(s)
  Photos       lifoStore  2 element(s)
  Friends      hashStore  1 element(s)
  Recipes      btreeStore  1 element(s)
  Books        heapStore  2 element(s)
  Data         blobStore  1 element(s)

Add a store field later, never remove one. alter bucket adds store fields to an existing bucket; see alter bucket.

Counting a store field. len(), length() and count() are the same verb, and all three answer on a bucket and on a store field alike — whichever you write, you get the same number back:

[h]oardDB [app]> db.User["ada"].Purchases.len()
3
[h]oardDB [app]> db.User["ada"].Purchases.length()
3
[h]oardDB [app]> db.Jobs.count()
12

Write whichever reads best; nothing is refused for the spelling.

A store on a store’s elements. A field can declare stores for its own elements, one level down, addressed through the element’s position:

[h]oardDB [app]> create bucket Thread { Comments fifoStore { Flags fifoStore } };
OK  created bucket app.Thread (type hash, whole)
Stores:  1 store field(s) declared
[h]oardDB [app]> db.Thread.insert({_id_: "t1"})
OK  id=t1
[h]oardDB [app]> db.Thread["t1"].Comments.push({Text: "spam?"})
OK
[h]oardDB [app]> db.Thread["t1"].Comments[0].Flags.push({reason: "spam"})
OK
[h]oardDB [app]> db.Thread["t1"].Comments[0].Flags.len()
1

A scalar written inside those braces is not enforced, like a top-level one: the confirmation notes it, naming the store field it sits in (Comments.Text string). A unique modifier inside the braces is refused, because a unique index is maintained only for a bucket’s own fields — declare the field on the bucket, or give the elements their own bucket with a unique field on it.

help fields prints these forms at the prompt.

A blob field takes its bytes from data, as a blob bucket does: the data string is the content and every other key is the element’s metadata. get prints the text, or writes the content to a file with {out: "file"}. A data key on any other kind of field is an ordinary key of the element.

[h]oardDB [app]> create bucket Post { Caption string, Photos blobStore };
OK  created bucket app.Post (type hash, whole)
Stores:  1 store field(s) declared
[h]oardDB [app]> db.Post.insert({_id_: "p1", Caption: "hi"})
OK  id=p1
[h]oardDB [app]> db.Post["p1"].Photos.put("ph1", {data: "hello photo", name: "b.txt"})
OK
[h]oardDB [app]> db.Post["p1"].Photos.get("ph1")
Key:    ph1
Blob:   01a101e0-dcc1-7459-a116-4f4b62f44237
Size:   11 B
Metadata: {"name": "b.txt"}
Data:
hello photo
[h]oardDB [app]> db.Post["p1"].Photos.get("ph1", {out: "photo.txt"})
OK  wrote photo.txt (11 B)

Limits that hold in this build, each run. The refusals below were run on a Post bucket that declares Photos blobStore, Comments fifoStore and holds a document with the key p1.

  • The braces must close. A create bucket whose { is never closed is not run. Piped, the script stops with CLI error: parse: the statement that starts at line 15 is unfinished: its "{" is never closed. Close it with "}", or write the statement on one line, and creates nothing.

  • Only hash and btree buckets declare store fields. A queue entry has no key of its own to hang a store on:

    [h]oardDB [app]> create bucket Q { type: fifo, Comments fifoStore };
    Error: invalid store-field declaration: bucket "Q" is a fifo bucket, and only hash and btree buckets can declare store fields: a store field belongs to one document, found by the key you hold, and a fifo bucket's entries are not documents with keys of their own (a queue entry is addressed by its position, a heap entry is destroyed by the pop that reads it, and a blob has no document body). Declare the store on a hash bucket that references the fifo entry by key: create bucket QRefs { type: hash, Comments fifoStore }
    
  • The document must exist first. A document’s stores come with the document, and deleting the document deletes them:

    [h]oardDB [app]> db.Post["nope"].Comments.push({Author: "x"})
    Error: db.Post["nope"].Comments.push(): server error (1): parent document not found: No document with key "nope" in bucket "Post", so there is no "Comments" store to write. A store field exists for each document, so the document has to exist first.
    Run 'db.Post.find({_id_: "nope"})' to check the key
    
  • A verb has to fit the store, and the field has to be declared. Each refusal names what does exist:

    [h]oardDB [app]> db.Post["p1"].Comments.put("k", {a: 1})
    Error: db.Post["p1"].Comments.put(): server error (4): operation not supported by the field's engine: "put" is not an operation on "Comments", which is declared fifoStore. Fifo fields take push, pop and peek; put is one of put, get and delete, which are for hashStore, btreeStore and blobStore.
    Declared engines on "Post": Photos (blob), Comments (fifo)
    [h]oardDB [app]> db.Post["p1"].Tags.push({a: 1})
    Error: db.Post["p1"].Tags.push(): server error (1): store field not declared: Bucket "Post" in database "app" declares no store field named "Tags".
    It declares: Photos (blob), Comments (fifo).
    Run 'describe bucket Post in app' to see the declaration
    
  • Scalar field types are not enforced. Age int and CreatedDate datetime are accepted and noted, as the output above shows; the server keeps no document schema. Only the store fields are created.

  • Store elements are not indexed or searched. An index belongs to the bucket’s own documents (indices: ["Email"]), and find and search read those documents. A store field has no index of its own: read it through its address, with get, peek, pop or list.

  • peek({limit: N}) reads the next N elements without taking them; peek(N) is refused. peek() returns the one element pop() would take, and peek({limit: 2}) the next two, in pop order, so they are the two pop()s would return, in the order they would return them. list is different: it reads in storage order, oldest first, which is pop order on a fifo and the reverse of it on a lifo. Ada’s Photos is a lifo holding a.jpg, pushed first, and b.jpg:

    [h]oardDB [app]> db.User["ada"].Photos.peek({limit: 2})
    {"_id_": "01a1114e-059b-77ed-9104-27250bd5224c", "file": "b.jpg"}
    {"_id_": "01a1114e-059b-71c3-a082-985954ea58c1", "file": "a.jpg"}
    [h]oardDB [app]> db.User["ada"].Photos.list({limit: 2})
    {"_id_": "01a1114e-059b-71c3-a082-985954ea58c1", "file": "a.jpg"}
    {"_id_": "01a1114e-059b-77ed-9104-27250bd5224c", "file": "b.jpg"}
    

    A bare count is refused out loud rather than quietly answering with a single element, and the refusal shows the form to use:

    [h]oardDB [app]> db.User["ada"].Purchases.peek(3)
    Error: peek(3) is not supported: the count goes in an option document.
           Use db.User["ada"].Purchases.peek({limit: 3}) to read the next 3 without removing them.
    

    limit is a whole number from 1 to 1000, and the only option peek takes. Asking for more than the field holds returns what there is. A peek reads only what it returns, so its cost grows with N; past 1000 elements, or past the node’s result ceiling (HOARDDB_QUERY_MAX_RESULT_BYTES, 16 MiB by default), the whole request is refused and never answered short. The first element is always answered, as pop() would return it. The wire carries each peeked fifo or lifo element’s position with it, which is what addresses the stores nested under that element; the CLI prints the element document.

  • pop({limit: N}) removes the next N elements, in pop order, in one write; pop(N) is refused. It takes the same option document as peek, with a limit of 1 to 100, and prints one element per line. An element’s own nested stores go with it. Asking for more than the field holds removes what there is, and an empty field prints EMPTY. As on a bucket, nothing is removed when the answer would be too large, and the answer is the only copy of what was removed: if your program can lose it, pop fewer at a time.

    [h]oardDB [app]> db.User["ada"].Purchases.push({item: "lamp", cents: 1999})
    OK
    [h]oardDB [app]> db.User["ada"].Purchases.push({item: "desk", cents: 24900})
    OK
    [h]oardDB [app]> db.User["ada"].Purchases.push({item: "chair", cents: 8900})
    OK
    [h]oardDB [app]> db.User["ada"].Purchases.pop({limit: 2})
    {"_id_": "01a14917-3c68-71cc-b30f-3e7ec73d3a06", "cents": 1999, "item": "lamp"}
    {"_id_": "01a14917-3c68-7aaa-a1f7-7c111f88dec5", "cents": 24900, "item": "desk"}
    [h]oardDB [app]> db.User["ada"].Purchases.len()
    1
    [h]oardDB [app]> db.User["ada"].Purchases.pop({limit: 5})
    {"_id_": "01a14917-3c68-7f10-943a-ce2fb0162659", "cents": 8900, "item": "chair"}
    [h]oardDB [app]> db.User["ada"].Purchases.pop({limit: 2})
    EMPTY
    [h]oardDB [app]> db.User["ada"].Purchases.pop(3)
    Error: pop(3) is not supported: the count goes in an option document.
           Use db.User["ada"].Purchases.pop({limit: 3}) to remove and return the next 3.
    
  • dump carries store fields, except blob field payloads. A dump and restore round trip brought back the elements of the fifo, lifo, hash, btree and heap fields in their original order; the blob field’s element was reported as skipped (7 element(s) applied, 1 skipped in the run above), as blob bucket payloads are (Backup and restore).

  • A dotted bucket name is still one flat bucket, not a store of another bucket. create bucket Post.Comments { type: fifo } creates a bucket literally named Post.Comments, which db.Post["p1"].Comments does not address. Declare Comments fifoStore on Post for a queue per post.

show buckets, describe bucket, stats

[h]oardDB [shop]> show buckets
BUCKETS in shop
---
Encrypted: no (this node's store; the same for every bucket)
NAME    TYPE   ENTRIES
Alerts  heap   0
Files   blob   0
Jobs    fifo   0
Ledger  btree  0
Undo    lifo   0
Users   hash   0
[h]oardDB [shop]> describe bucket Users
BUCKET shop.Users
---
Type:               hash
Entries:            0
Logical size:       0 B (before compression and compaction)
Encrypted:          no (this node's store)
Replication factor: 1 (inherited)
Indexes:            Email_1

show buckets in <database> works without use, and so do describe bucket <name> in <database>, drop bucket <name> in <database> and alter bucket <name> in <database> { ... }: the database you name wins over the one use selected. Without in, these statements run in the use database, and with neither they refuse and name both ways to fix it. db.<bucket>.stats() prints the same facts as describe bucket. A bucket that does not exist is named in the refusal, with the command to create it:

[h]oardDB [shop]> describe bucket Nope
Error: describing bucket "Nope": server error (1): bucket not found: bucket "Nope" not found in database "shop". Run 'show buckets in shop' to list what exists, or 'create bucket Nope in shop { type: hash }' to create it

drop bucket

Dropping deletes the bucket’s data. The space comes back in the background:

[h]oardDB [shop]> drop bucket Cache
OK  bucket "Cache" dropped from "shop". Its space is reclaimed in the background; watch hoarddb_reclaim_pending_records to follow it.

drop bucket Cache in shop drops it from shop whatever use selected.

alter bucket

Adds store fields and raises a bucket’s replication factor; refused for everything else. alter bucket adds a store field to an existing bucket. Documents that already exist get the new store, empty:

[h]oardDB [app]> alter bucket Post { Tags hashStore }
OK  bucket "Post" now declares: Photos (blob), Comments (fifo), Tags (hash)
[h]oardDB [app]> alter bucket Post { Tags hashStore }
OK  bucket "Post" already declares these store fields

Removing a store field is refused, because it would destroy every document’s elements of it with no preview and no undo:

[h]oardDB [app]> alter bucket Post { Tags drop }
Error: removing a store field is not supported in 1.0 (Tags drop): it would destroy every document's elements of it in "Post" with no preview and no undo. alter bucket only adds store fields: alter bucket Post { Tags hashStore }

Raise a bucket’s replication factor with one option, replication_factor, a whole number of copies, 1 or more. It needs the db-manager role on the database, and it is sent to the bucket’s owner:

[h]oardDB [app]> alter bucket Sessions { replication_factor: 5 }
OK  bucket "Sessions" now asks for 5 copies (was 3). node-d and node-e will copy it;
    writes keep going to the 3 copies that hold it. Run 'show buckets' on node-a (its owner)
    to watch the count reach 5/5, and 'describe bucket Sessions' on node-d to see why a
    copy has not started.

The answer comes when the owner has recorded the change, not when the new copies have landed: the members named copy the bucket by themselves, one bucket’s copy at a time, and Replication says what happens in the meantime. The other answers:

OK  bucket "Sessions" now declares 3 copies (it inherited 3 from this node's default); a
    later change of the default no longer changes it.
OK  bucket "Sessions" already asks for 3 copies; nothing changed.

The first is a bucket that inherited its factor being given the value it inherited: it now declares it. No statement returns a bucket to inheriting.

Lower a bucket’s factor with a factor below its current one:

[h]oardDB [app]> alter bucket Sessions { replication_factor: 2 }
OK  bucket "Sessions" now asks for 2 copies (was 3). node-c's copy will be deleted once
    node-a and node-b are shown to hold everything it holds; its space is reclaimed in the
    background after that. Run 'show buckets' on node-a.

The answer comes when the owner has recorded the change. The copy is deleted on its own node later, and only after the remaining copies have shown that they hold everything it holds; the reply’s releasing field names the members that will delete one and previous_factor the factor before. When the cluster has fewer members than the factor, no copy is deleted, and the answer says so:

OK  bucket "Sessions" now asks for 2 copies (was 3). No copy is added or deleted: node-a and
    node-b are every member the cluster has, and each holds a copy at both factors. Run
    'show buckets' on node-a.

A lower that the remaining copies cannot yet cover is refused, and the refusal is a retry that changes nothing (exit code 8, UNAVAILABLE):

[h]oardDB [app]> alter bucket Sessions { replication_factor: 2 }
Error: alter bucket Sessions: server error (6): bucket "Sessions" cannot be lowered from
replication_factor 3 to 2: node-b has not shown that it holds everything node-c holds (its
copy is behind, or it did not answer). Run 'show buckets' on node-a and retry when the row
reads 3/3. If node-b is not coming back, run 'remove-node node-b' first.

The last sentence appears when the copy that is behind is not the owner’s.

The body holds exactly one option. Two are refused with a statement for each, and a store field and an option in one body are refused the same way:

[h]oardDB [app]> alter bucket Sessions { replication_factor: 3, indices: ["Email"] }
Error: alter bucket takes one change per statement, and the body for Sessions names 2 (indices, replication_factor): run each on its own:
         alter bucket Sessions { indices: ["Email"] }
         alter bucket Sessions { replication_factor: 3 }

The server refuses a raise, with the reason and what to do, when the cluster cannot hold it: a standalone server, more copies than members, a member on a version that does not change factors, a join in progress, a bucket whose owner has not taken it over yet, a membership change the owner is still recording, or an earlier raise whose copies have not landed on a member the new factor keeps. Each of those except the first two is a “retry” refusal (UNAVAILABLE, exit code 8 from the one-shot CLI): the text says when to run it again.

A bucket’s own type and its indexes are fixed at creation. The refusal names the workaround and its cost:

[h]oardDB [shop]> alter bucket Users { indices: ["Name"] }
Error: altering bucket "Users" is not supported yet
       declare indexes when you create the bucket:
         create bucket Users { type: hash, indices: ["Email"] }
       to change indexes on an existing bucket, drop and recreate it —
       note that dropping deletes its data: 'drop bucket Users'

Changing a bucket’s store type in place is planned for v1.1; see Limitations: bucket engines.

define bucket

Refused, by design. define bucket is the old name of create bucket. It is kept as a named error rather than removed, so that old scripts fail loudly:

[h]oardDB [shop]> define bucket Users { type: hash }
Error: parse: define was renamed: use 'create bucket <name> { type: hash }'
(run 'help' for the full command list)

Documents: hash and btree buckets

Every bucket verb has the form db.<bucket>.<verb>(<arguments>) and applies to the database chosen with use. Without one:

[h]oardDB> db.Users.find()
Error: no database selected — run 'use <database>' first ('show databases' lists what exists)

insert and _id_

insert stores a document and prints its key. The key is the _id_ field; leave it out and the server generates a time-ordered UUID:

[h]oardDB [shop]> db.Users.insert({_id_: "ada", Name: "Ada", Email: "ada@example.com", Age: 36, Status: "active"})
OK  id=ada
[h]oardDB [shop]> db.Users.insert({_id_: "alan", Name: "Alan", Email: "alan@example.com", Age: 41, Status: "active"})
OK  id=alan
[h]oardDB [shop]> db.Users.insert({"Name": "Grace", "Email": "grace@example.com", "Age": 17, "Status": "trial"})
OK  id=01a0de60-9e4c-77c4-aea1-1274ceba1389

_id_ must be a string. db.Users.insert({_id_: 7, n: 1}) answers OK with a generated key, and the 7 is not stored anywhere. Write _id_: "7".

An empty _id_ is refused, not replaced. db.Users.insert({_id_: "", n: 1}) answers INVALID_ARGUMENT, with a message that says an _id_ must be a non-empty string, or to omit it for a generated key. An empty string names a key nothing can find, so it is not the same as leaving _id_ out. The rule holds on every write that takes an _id_: insert, batch insert, push, a blob bucket’s put, and a store field’s push or put. (An _id_ of another type, like 7, is still replaced by a generated key as above.)

Only _id_ is the key on insert. A field called id or key is stored as an ordinary field, and that has a consequence for find, described next.

insert refuses an _id_ the bucket already holds. It never replaces a document, as an SQL INSERT of a taken primary key does not. The refusal is DOCUMENT_EXISTS, nothing is stored, and the document already there is left as it was:

[h]oardDB [shop]> db.Users.insert({_id_: "ada", Name: "Ada L."})
Error: insert failed: server error (18): Document "ada" already exists in bucket "Users" (database "shop"). To replace it, run db.Users.upsert({_id_: "ada", ...}); to change some fields, run db.Users.update("ada", {$set: {...}}). If you are retrying an insert whose answer you did not get, it may have been stored: retry with upsert.
  • To replace the document, use upsert; to change some of its fields, use update.
  • Retrying. An insert whose answer you did not get (a dropped connection, a timeout) may have been stored. Retry it with upsert, which is safe to send again: a second insert of the same _id_ would be refused.
  • A batch insert (BATCH_PUT) that names any _id_ the bucket holds is refused whole: nothing in it is stored, and the answer says which positions were held.
  • push on a hash or btree bucket is an insert too, and is refused the same way.

What each write verb does, by store type:

verbhash, btreefifo, lifoheapblob
insert, pushstores a new _id_; refuses one the bucket holdspushespushes (insert at priority 0)refused: use put
upsertstores, or replaces the document under its _id_refused: use pushrefused: use pushrefused: use put
updatechanges fields of a stored documentrefusedrefusedrefused
put(a store field’s put only)stores or replaces the blob

upsert

On hash and btree buckets, upsert stores a document under its _id_, and when a document is already stored under that _id_ it replaces the document’s scalar fields: a field the new document does not carry is gone. The document’s own stores (a fifoStore, hashStore and the rest declared on the bucket) are kept, with every element they hold.

[h]oardDB [shop]> db.Users.upsert({_id_: "ada", Name: "Ada Lovelace", Age: 36})
OK  id=ada

upsert needs a document: called with none, it prints a usage line with an example and sends nothing.

Refused on queues, heaps and blob buckets. A fifo, lifo or heap bucket has no key to replace, so db.Jobs.upsert({...}) is refused with INVALID_ARGUMENT and the words “a queue has no key to replace; use push”. A blob bucket is written with put, so upsert there is refused with “use put”. Nothing is stored by a refused upsert.

The wire forms are UPSERT and BATCH_UPSERT (many documents in one request, each an upsert of its own _id_).

find: one document by key

A bare key, or a document naming the key, is a point read:

[h]oardDB [shop]> db.Users.find("ada")
{"Age": 36, "Email": "ada@example.com", "Name": "Ada", "Status": "active", "_id_": "ada"}
[h]oardDB [shop]> db.Users.find({_id_: "alan"})
{"Age": 41, "Email": "alan@example.com", "Name": "Alan", "Status": "active", "_id_": "alan"}
[h]oardDB [shop]> db.Users.find("nobody")
NOT FOUND

get is the same point read on hash and btree buckets: db.Users.get("ada") prints the same line.

A filter that names _id_, id, key or _key_ is a point read, and its other fields are ignored. The CLI treats all four names as the key. find({Status: "active", _id_: "ada"}) returns Ada without checking Status. Worse, a document stored with a field called key cannot be filtered on it:

[h]oardDB [shop]> db.Users.insert({_id_: "sku-1", key: "blue", id: 7})
OK  id=sku-1
[h]oardDB [shop]> db.Users.find({key: "blue"})
NOT FOUND

That find looked up a document whose key is blue. Avoid id, key and _key_ as field names.

find: scan with a filter

With no argument, or a filter that names no key field, find scans the bucket and returns up to 100 documents:

[h]oardDB [shop]> db.Users.find()
{"Age": 17, "Email": "grace@example.com", "Name": "Grace", "Status": "trial", "_id_": "01a0de60-9e4c-77c4-aea1-1274ceba1389"}
{"Age": 29, "Email": "linus@example.com", "Name": "Linus", "Status": "banned", "_id_": "01a0de60-9e4c-799e-8bee-d9b6b2dc4d1b"}
{"Age": 36, "Email": "ada@example.com", "Name": "Ada", "Status": "active", "_id_": "ada"}
{"Age": 41, "Email": "alan@example.com", "Name": "Alan", "Status": "active", "_id_": "alan"}
[h]oardDB [shop]> db.Users.find({Status: "active"})
{"Age": 36, "Email": "ada@example.com", "Name": "Ada", "Status": "active", "_id_": "ada"}
{"Age": 41, "Email": "alan@example.com", "Name": "Alan", "Status": "active", "_id_": "alan"}
[h]oardDB [shop]> db.Users.find({Status: "nobody"})
NO MATCHES

find({}) is the same as find(). A filter needs no index: the server walks the bucket in its natural order and applies the filter itself. Hash and btree buckets walk by key ascending; fifo walks oldest first, lifo newest first, and heap highest priority first. On a large bucket that is a walk of the whole bucket, so use search on an indexed field for a hot query. The filter operators are listed under Query and update operators.

A bare value is an exact match. There is no wildcard. find({Name: "A*"}) looks for the literal two characters A* and prints NO MATCHES. Use $regex: find({Name: {$regex: "^A"}}).

Paging, sort and projection

A second document carries skip, limit, sort and projection. When more matches exist than the page holds, the CLI prints the exact total and the next command to run. With no limit, a find returns the server’s default page of 100 documents; a limit chooses another page size:

[h]oardDB [shop]> db.Users.find({}, {limit: 2})
{"Age": 17, "Email": "grace@example.com", "Name": "Grace", "Status": "trial", "_id_": "01a0de60-9e4c-77c4-aea1-1274ceba1389"}
{"Age": 29, "Email": "linus@example.com", "Name": "Linus", "Status": "banned", "_id_": "01a0de60-9e4c-799e-8bee-d9b6b2dc4d1b"}
showing 2 of 4 — next: find({}, {skip: 2})
[h]oardDB [shop]> db.Users.find({}, {sort: {Age: -1}, limit: 2})
{"Age": 41, "Email": "alan@example.com", "Name": "Alan", "Status": "active", "_id_": "alan"}
{"Age": 36, "Email": "ada@example.com", "Name": "Ada", "Status": "active", "_id_": "ada"}
showing 2 of 4 — next: find({}, {skip: 2})
[h]oardDB [shop]> db.Users.find({Status: "active"}, {projection: {Name: 1}})
{"Name": "Ada"}
{"Name": "Alan"}
[h]oardDB [shop]> db.Users.find({}, {projection: {Email: 0, Age: 0}, limit: 1})
{"Name": "Grace", "Status": "trial", "_id_": "01a0de60-9e4c-77c4-aea1-1274ceba1389"}
showing 1 of 4 — next: find({}, {skip: 1})

A page that is exactly full, with nothing after it, prints no next line. In sort, 1 means ascending and -1 descending. In projection, 1 includes a field and 0 excludes it. An inclusion projection leaves out _id_ unless you name it.

The sort order, when a field holds values of different types. Ascending, values sort by type first, then by value within the type:

  1. numbers (a NaN sorts below every other number);
  2. strings;
  3. dates;
  4. booleans (false before true);
  5. documents;
  6. arrays;
  7. every other type (an id, binary data, a decimal, a timestamp, a regex);
  8. a missing field, or null.

Documents whose values are equal, or of a type compared only by type (a document, an array, or one of the other types), keep their natural order. Descending reverses the types and the values, so a missing field or null comes first, but documents that compare equal still come back in their natural order. So the pages of a sorted find fit together with nothing repeated or left out, as long as nothing is written in between.

A sorted find keeps in memory the sort values of the documents up to the end of the page (skip plus limit of them) and the page itself, never the whole match set, and what it keeps is bounded by the node’s result ceiling (HOARDDB_QUERY_MAX_RESULT_BYTES, 16 MiB by default): past it the query is refused with RESULT_TOO_LARGE. Ask for a smaller limit, or page by the sort field (find({Age: {$gt: 36}}, {sort: {Age: 1}, limit: 100})) instead of with a large skip.

skip is a whole number of at least 0. sort maps fields to 1 or -1, and projection maps fields to 1 or 0. limit is a whole number of at least 1, and a key that is not skip, limit, sort or projection is refused. A store field’s list takes limit and after on the same terms. A mistyped value is an error, not the default page:

[h]oardDB [shop]> db.Users.find({}, {limit: "2"})
Error: find: "limit" must be a whole number of at least 1, got a string ("2"). Example: {limit: 3}
[h]oardDB [shop]> db.Users.find({}, {limt: 2})
Error: find: unknown option "limt". This call accepts: limit, projection, skip, sort. Example: {limit: 3}

Refused: sorting by more than one field.

[h]oardDB [shop]> db.Users.find({}, {sort: {Age: 1, Name: -1}})
Error: find failed: server error (5): sorting by more than one field is not supported yet: this query sorts by Age, Name. To fix it, sort by one field (for example {sort: {Age: 1}}), or sort the result in your client

sort does not make paging safe. There is no server-side cursor and no snapshot. Each page is a fresh scan that sorts the whole matched set and only then applies skip and limit, so a write between two pages renumbers every document after it. An insert ahead of the page boundary shows you a document twice, and a delete ahead of it hides one entirely. Stop writes before paging through a bucket you must read completely, or read it in one page with a limit above the match count. See Limitations: queries.

update

update takes the key, as a string or as {_id_: ...}, and an operator document. It prints the whole document after the change:

[h]oardDB [shop]> db.Users.update("ada", {$set: {City: "London"}, $inc: {Age: 1}})
OK  updated "ada"
{"Age": 37, "City": "London", "Email": "ada@example.com", "Name": "Ada", "Status": "active", "_id_": "ada"}
[h]oardDB [shop]> db.Users.update({_id_: "ada"}, {$unset: ["City"]})
OK  updated "ada"
{"Age": 37, "Email": "ada@example.com", "Name": "Ada", "Status": "active", "_id_": "ada"}

update changes one document, named by its key. There is no update by filter in the shell, and a driver that sends one (multi) is refused on a bucket recorded spread, which is a hash bucket created with placement: spread: update by _id_, find the documents and update each id, or create the bucket with placement: whole. A key that does not exist is refused, not created:

[h]oardDB [shop]> db.Users.update("ghost", {$set: {City: "Paris"}})
Error: document "ghost" not found in shop.Users — insert it first or use find to check the key

The operators are $set, $unset and $inc; see update operators for their rules and the ones that are refused. A key can name a value inside a value, "Address.City" or "Lines[0].Qty"; see paths.

delete

delete takes one key:

[h]oardDB [shop]> db.Users.delete("sku-1")
OK  deleted "sku-1"

Deleting a key that is not there answers NOT FOUND, not OK. The same holds on a hash, a btree and a blob bucket, and it is a result rather than an error: the statement succeeds and nothing was removed.

[h]oardDB [shop]> db.Users.delete("sku-1")
NOT FOUND  "sku-1"

A delete is safe to repeat. If the reply to a delete is lost and you send it again, the second answer is NOT FOUND even though the first one removed the key, so treat NOT FOUND after a retry as “it is gone”. On a cluster the delete is still sent to every copy, so a copy that had missed the key does not keep it. Over the wire the reply is success: false with the message document not found (blob not found on a blob bucket) and write_outcome: not_applied, and carries no error code.

Refused: delete by filter.

[h]oardDB [shop]> db.Users.delete({Status: "banned"})
Error: delete needs a key: db.Users.delete("my-key")

count, length, distinct

count and length are the same verb. Both take an optional filter:

[h]oardDB [shop]> db.Users.count()
4
[h]oardDB [shop]> db.Users.count({Status: "active"})
2
[h]oardDB [shop]> db.Users.distinct("Status")
"trial"
"banned"
"active"
[h]oardDB [shop]> db.Users.distinct("Status", {Age: {$gt: 20}})
"banned"
"active"

search: the indexed fast path

For equality on a declared index, search answers from an index instead of walking the bucket:

[h]oardDB [shop]> db.Users.search({Email: "alan@example.com"})
Found 1 results (via Email_1):
{"Age": 41, "Email": "alan@example.com", "Name": "Alan", "Status": "active", "_id_": "alan"}

On a field with no index, search falls back to a scan, as find does, and the result header says (full scan):

[h]oardDB [shop]> db.Users.search({Name: "Ada"})
Found 1 results (full scan):
{"Age": 36, "Email": "ada@example.com", "Name": "Ada", "Status": "active", "_id_": "ada"}

In the shell search sends no limit, so it returns every match and prints the number returned (Found N results). find with no limit returns the server’s default page of 100 instead, and prints showing N of M when more remain.

An index makes an equality query faster, not correct: to serve this query from an index, recreate the bucket with indices: ["Name"]. Operators do not work in search. $in and $gt on an indexed field are refused:

[h]oardDB [shop]> db.Users.search({Email: {$gt: "b"}})
Error: search failed: encoding query value for Email: value type cannot be a btree key or an indexed field: map[string]interface {} (supported: null, bool, number, date, string, binary)

(help operators lists range operators for search. That help text is wrong.)

Query and update operators

Filter operators

These work in find and count filters, and in the filter argument of distinct:

OperatorMeaningExample
bare valueexact match{Status: "active"}
$gt $gte $lt $ltecomparison: numbers numerically, strings in byte order{Age: {$gte: 36}}
$invalue is one of a list{Status: {$in: ["trial", "banned"]}}
$ninvalue is none of a list{Status: {$nin: ["active"]}}
$nenot equal{Status: {$ne: "active"}}
$existsfield present (true) or absent (false){Nick: {$exists: false}}
$regexGo regular expression, unanchored{Name: {$regex: "^A"}}
$andevery condition matches (top level){$and: [{Status: "active"}, {Age: {$gte: 40}}]}
$orany condition matches (top level){$or: [{Name: "Ada"}, {Age: 17}]}

Each row was run against the Users bucket above:

[h]oardDB [shop]> db.Users.find({$and: [{Status: "active"}, {Age: {$gte: 40}}]})
{"Age": 41, "Email": "alan@example.com", "Name": "Alan", "Status": "active", "_id_": "alan"}
[h]oardDB [shop]> db.Users.find({$or: [{Name: "Ada"}, {Age: 17}]})
{"Age": 17, "Email": "grace@example.com", "Name": "Grace", "Status": "trial", "_id_": "01a0de60-9e4c-77c4-aea1-1274ceba1389"}
{"Age": 36, "Email": "ada@example.com", "Name": "Ada", "Status": "active", "_id_": "ada"}
[h]oardDB [shop]> db.Users.find({Status: {$in: ["trial", "banned"]}})
{"Age": 17, "Email": "grace@example.com", "Name": "Grace", "Status": "trial", "_id_": "01a0de60-9e4c-77c4-aea1-1274ceba1389"}
{"Age": 29, "Email": "linus@example.com", "Name": "Linus", "Status": "banned", "_id_": "01a0de60-9e4c-799e-8bee-d9b6b2dc4d1b"}

Numbers compare by exact value, integers above 2^53 included. A NaN matches none of $gt, $gte, $lt and $lte; it equals only another NaN, and sorts before every other number.

$regex is unanchored, so {$regex: "o"} matches every value containing an o. Anchor it with ^ and $ to match a whole value.

Refused, by name. Any other operator is an error that lists what is supported, never an empty result. $eq, $not, $all, $elemMatch, $size and $nor are all refused:

[h]oardDB [shop]> db.Users.find({Age: {$eq: 36}})
Error: find failed: server error (5): unsupported filter operator "$eq": this version supports $gt, $gte, $lt, $lte, $in, $nin, $ne, $exists and $regex on a field
[h]oardDB [shop]> db.Users.find({$nor: [{Name: "Ada"}]})
Error: find failed: server error (5): unsupported filter operator "$nor": this version supports $and, $or, $ne, $exists, $nin at the top level

For $eq, write the bare value: {Age: 36}.

Dates

A date is written in its Extended JSON form, {"$date": "<RFC 3339>"}, or as ISODate("<RFC 3339>") or new Date("<RFC 3339>"), which mean the same thing. It is stored as a date, not a string, and compares by instant:

[h]oardDB [shop]> create bucket Events { type: hash }
OK  created bucket shop.Events (type hash, whole)
[h]oardDB [shop]> db.Events.insert({_id_: "e1", ts: {"$date": "2026-02-01T00:00:00Z"}})
OK  id=e1
[h]oardDB [shop]> db.Events.find({ts: {$gt: {"$date": "2026-01-01T00:00:00Z"}}})
{"_id_": "e1", "ts": "2026-02-01T00:00:00Z"}

A date field only matches a date operand. {ts: {$gt: "2026-01-01"}} compares a date with a string and prints NO MATCHES.

Dates print as RFC 3339 strings. With -extjson, dates print as {"$date": "..."} instead, which keeps their type when you copy the output back in. The flag applies to every command that prints a document: point reads (find("<key>"), get), scans and search, update, pop and peek, range, first and last, blob metadata, and the nested field-store reads.

The two function spellings work anywhere a date does, in a document you insert and in a filter. The value must be a full RFC 3339 timestamp; a bare date such as "2026-01-01" is refused:

[h]oardDB [shop]> db.Events.insert({_id_: "e1", ts: ISODate("2026-02-01T00:00:00Z")})
OK  id=e1
[h]oardDB [shop]> db.Events.find({ts: {$gt: ISODate("2026-01-01T00:00:00Z")}})
{"_id_": "e1", "ts": "2026-02-01T00:00:00Z"}

now() is the server’s clock. In an insert, a push or an update it stores the time the write reached the server, as a date. The server reads its clock once per write, before the write is logged, so every copy of the document holds the same instant and every now() in one request (a batch’s documents, an update’s operators) is that one instant:

[h]oardDB [shop]> db.Events.insert({_id_: "e3", at: now()})
OK  id=e3
[h]oardDB [shop]> db.Events.update("e3", {$set: {seen: now()}})
OK  updated "e3"
{"_id_": "e3", "at": "2026-10-04T17:44:41.269Z", "seen": "2026-10-04T17:44:41.27Z"}

Refused at the prompt: now() followed by a method, such as now().add(7, "d"). now() stands alone, and the statement says so rather than storing text:

[h]oardDB [shop]> db.Events.insert({_id_: "e4", at: now().add(7, "d")})
Error: insert: could not read document {_id_: "e4", at: now().add(7, "d")}: now() cannot be followed by a method such as .add(...)
       write now() alone for the server's time of this write, or a fixed date as ISODate("2026-09-20T10:30:00Z")

Numbers and other types

A document you type is read as Extended JSON, the same reader hoarddb ingest uses for a JSON Lines file, so a value means the same thing whichever way it arrives:

You writeStored as
36a 32-bit integer (int32)
3000000000a 64-bit integer (int64), because it does not fit 32 bits
36.0, 2.5, 1e3a double: a decimal point or an exponent makes one

A filter compares numbers by value, so {Age: 36} finds a document whose Age is the integer 36 and one whose Age is the double 36.0. An integer outside the int64 range (above 9223372036854775807) is refused; write it with a decimal point to store a double, or as {"$numberDecimal": "..."} to keep every digit.

These Extended JSON wrappers are read as their types, in an insert, a $set, a filter, a field-store element or anywhere else a document is typed:

TypeWrite
32-bit integer{"$numberInt": "42"}
64-bit integer{"$numberLong": "3000000000"}
double{"$numberDouble": "1.5"}
128-bit decimal{"$numberDecimal": "12.34"}
binary{"$binary": {"base64": "aGk=", "subType": "00"}}
UUID{"$uuid": "d9c3b3a0-1c3a-4f5e-8a3a-0123456789ab"}
object id{"$oid": "507f1f77bcf86cd799439011"}
regular expression{"$regularExpression": {"pattern": "^a", "options": "i"}}
BSON timestamp{"$timestamp": {"t": 1700000000, "i": 1}}
date{"$date": "2026-09-19T16:47:43Z"}
[h]oardDB [shop]> db.Users.insert({_id_: "lin", Id: {"$uuid": "d9c3b3a0-1c3a-4f5e-8a3a-0123456789ab"}, Price: {"$numberDecimal": "12.34"}})
OK  id=lin
[h]oardDB [shop]> db.Users.find({Id: {"$uuid": "d9c3b3a0-1c3a-4f5e-8a3a-0123456789ab"}})
{"Id": {"$uuid":"d9c3b3a0-1c3a-4f5e-8a3a-0123456789ab"}, "Price": {"$numberDecimal":"12.34"}, "_id_": "lin"}

A document prints these types as the same wrappers, so what you read is what you can type back. Dates and plain numbers print as before (see Dates).

A malformed wrapper is refused and named, with the form it takes:

[h]oardDB [shop]> db.Users.insert({_id_: "z", at: {"$oid": "zz"}})
CLI error: insert: $oid is malformed: it takes the form {"$oid": "507f1f77bcf86cd799439011"} (24 hex digits)

The deprecated BSON types ($symbol, $code, $scope, $dbPointer, $undefined, $minKey, $maxKey) are refused, and the error lists the wrappers above:

[h]oardDB [shop]> db.Users.insert({_id_: "y", at: {"$minKey": 1}})
CLI error: insert: $minKey is a deprecated BSON type and hoardDB does not store it
       the supported wrappers are $numberInt, $numberLong, $numberDouble, $numberDecimal, $binary, $uuid, $oid, $regularExpression, $timestamp, $date

Option documents (limit, skip, sort, projection, replication_factor) are plain JSON and do not take wrappers.

Update operators

OperatorFormRule
$set{$set: {field: value}}sets one or more fields
$unset{$unset: ["field", ...]}a list of field names
$inc{$inc: {field: N}}adds a whole number to a number field
$push{$push: {field: value}}appends one value to an array
$addToSet{$addToSet: {field: value}}appends one value unless the array already holds it
$mul{$mul: {field: N}}multiplies a number field; N can be a fraction
$min{$min: {field: value}}keeps the smaller of the stored value and value
$max{$max: {field: value}}keeps the larger of the stored value and value
$rename{$rename: {oldName: "newName"}}moves a field to a new name; the new name is a string

Every operator takes a document of fields except $unset, which takes a list. One statement can carry several operators, and a key can be a path (see Paths).

$unset takes a list, not a document:

[h]oardDB [shop]> db.Users.update("ada", {$unset: {City: ""}})
Error: $unset needs a list of field names: {$unset: ["a", "b"]}

$inc takes a whole number. The amount is sent as a 64-bit integer, so the statement refuses a fraction (or a number past the 64-bit range) before it is sent, and the document is unchanged. A whole double such as 2.0 is fine. To store a fractional result, $set it:

[h]oardDB [shop]> db.Users.update("ada", {$inc: {Age: 1.5}})
Error: $inc on "Age" needs a whole number that fits in 64 bits, got 1.5
       write a whole number, or $set the new value: {$set: {Age: <value>}}

$inc refuses a field that is not a number. A string, bool, array, sub-document, date or null is not overwritten: the update is refused, nothing in it is written, and the message names the field, the type found and the fix ($set the value, or $unset it first). A field that is absent is created holding the amount. An int32 or int64 field stays an integer (the answer is an int64), a whole-number double is read as an integer, and a fractional double stays a double: 2.5 plus 1 is 3.5. An integer sum past int64 is refused, not wrapped.

[h]oardDB [shop]> db.Users.update("ada", {$inc: {Name: 1}})
CLI error: update failed: server error (4): $inc needs a number: field "Name" of document "ada" in shop.Users holds a string, so the update was refused and nothing was changed. $inc works on int32, int64 and double fields; use $set to replace the value, or $unset it first.

An update is all or nothing: if one operator in it is refused, none of its other operators are written, and a multi-update that meets one refused document writes none of them.

The new operators have quiet corners. In each of these cases the statement answers OK:

  • $push always appends, so pushing a value that is already there stores it twice; $addToSet is the one that checks. A field that is missing becomes a one-element array, and a field that holds a single value becomes an array of that value and the new one.
  • $mul skips a field that is missing. (A field that holds a string is refused, as $inc refuses it.)
  • $min and $max set a field that is missing, and skip a stored value that cannot be compared with the one you gave (a string against a number).
  • $rename does nothing when newName already exists. The old field stays where it was.
[h]oardDB [shop]> db.Users.insert({_id_: "grace", Age: 36, Score: 10, Best: 10, Tags: ["new"], Nick: "gh"})
OK  id=grace
[h]oardDB [shop]> db.Users.update("grace", {$push: {Tags: "vip"}})
OK  updated "grace"
{"Age": 36, "Best": 10, "Nick": "gh", "Score": 10, "Tags": ["new","vip"], "_id_": "grace"}
[h]oardDB [shop]> db.Users.update("grace", {$push: {Tags: "vip"}, $addToSet: {Tags: "new"}})
OK  updated "grace"
{"Age": 36, "Best": 10, "Nick": "gh", "Score": 10, "Tags": ["new","vip","vip"], "_id_": "grace"}
[h]oardDB [shop]> db.Users.update("grace", {$mul: {Score: 1.5}, $max: {Best: 99}, $min: {Age: 30}})
OK  updated "grace"
{"Age": 30, "Best": 99, "Nick": "gh", "Score": 15, "Tags": ["new","vip","vip"], "_id_": "grace"}
[h]oardDB [shop]> db.Users.update("grace", {$rename: {Nick: "Handle"}})
OK  updated "grace"
{"Age": 30, "Best": 99, "Handle": "gh", "Score": 15, "Tags": ["new","vip","vip"], "_id_": "grace"}

A bad operand is refused before anything is sent, with the form that works:

[h]oardDB [shop]> db.Users.update("grace", {$rename: {Handle: 5}})
Error: $rename of "Handle" needs the new name as a string, got 5
       write {$rename: {oldName: "newName"}}
[h]oardDB [shop]> db.Users.update("grace", {$mul: {Score: "two"}})
Error: $mul on "Score" needs a number, got two
       write a number, or $set the new value: {$set: {Score: <value>}}

Refused: $pull, every other operator, and upsert. The refusal lists the nine that work:

[h]oardDB [shop]> db.Users.update("grace", {$pull: {Tags: "vip"}})
Error: $pull is not supported — supported update operators: $set, $unset, $inc, $push, $addToSet, $mul, $min, $max, $rename
[h]oardDB [shop]> db.Users.update("grace", {$set: {City: "Paris"}}, {upsert: true})
Error: upsert is not supported — supported update operators: $set, $unset, $inc, $push, $addToSet, $mul, $min, $max, $rename

Paths: a value inside a value

A field key in an update, a filter, a sort, a projection or distinct can be a path: names joined by . for a member of a sub-document, and a 0-based position in brackets for an element of an array. Address.City, Lines[3].Qty, Scores[0][2]. A path means the same thing everywhere, so what an update writes at Address.City is what a filter on Address.City finds. In the shell a path needs no quotes: {Lines[0].Qty: 3} is the key Lines[0].Qty. Only a position written as digits in brackets is read that way, in a key; {Lines[x].Qty: 3} is a syntax error, and a [ anywhere else still opens an array. The quoted form, {"Lines[0].Qty": 3}, means the same.

[h]oardDB [shop]> db.Orders.insert({_id_: "o1", Customer: "ada", Address: {City: "Tirana", Zip: "1001"}, Lines: [{Sku: "A-1", Qty: 2}, {Sku: "B-2", Qty: 1}]})
OK  id=o1
[h]oardDB [shop]> db.Orders.insert({_id_: "o2", Customer: "alan", Address: {City: "Paris"}, Lines: [{Sku: "B-2", Qty: 5}]})
OK  id=o2
[h]oardDB [shop]> db.Orders.update("o1", {$set: {Address.City: "Durres"}, $inc: {Lines[0].Qty: 3}})
OK  updated "o1"
{"Address": {"City":"Durres","Zip":"1001"}, "Customer": "ada", "Lines": [{"Qty":5,"Sku":"A-1"},{"Qty":1,"Sku":"B-2"}], "_id_": "o1"}
[h]oardDB [shop]> db.Orders.update("o1", {$unset: ["Address.Zip"]})
OK  updated "o1"
{"Address": {"City":"Durres"}, "Customer": "ada", "Lines": [{"Qty":5,"Sku":"A-1"},{"Qty":1,"Sku":"B-2"}], "_id_": "o1"}
[h]oardDB [shop]> db.Orders.update("o2", {$set: {"Contact.Email": "alan@example.com"}})
OK  updated "o2"
{"Address": {"City":"Paris"}, "Contact": {"Email":"alan@example.com"}, "Customer": "alan", "Lines": [{"Qty":5,"Sku":"B-2"}], "_id_": "o2"}
[h]oardDB [shop]> db.Orders.find({Address.City: "Paris"})
{"Address": {"City":"Paris"}, "Contact": {"Email":"alan@example.com"}, "Customer": "alan", "Lines": [{"Qty":5,"Sku":"B-2"}], "_id_": "o2"}
[h]oardDB [shop]> db.Orders.find({"Lines[0].Sku": "A-1"}, {projection: {"Lines[0].Qty": 1, Customer: 1}})
{"Customer": "ada", "Lines": [{"Qty":5}]}
[h]oardDB [shop]> db.Orders.find({}, {sort: {"Address.City": 1}, projection: {"Address.City": 1}})
{"Address": {"City":"Durres"}}
{"Address": {"City":"Paris"}}
[h]oardDB [shop]> db.Orders.distinct("Address.City")
"Durres"
"Paris"

The rules at the edges. Each of these is refused rather than guessed, and nothing of the update is written:

  • A missing sub-document is created by $set and $inc (and by $push, $addToSet, $min, $max and $rename’s target): $set of Contact.Email on a document without Contact writes Contact: {Email: …}, as above. Only sub-documents are created, never arrays. $unset (and $mul, and $rename’s source) of a missing path does nothing.
  • A path through a value that is not a sub-document or an array is refused, null included: nothing is replaced. Set the whole value instead.
  • A position must already exist. An update never extends an array or leaves a hole; append with $push. A missing array followed by a position is refused too.
  • $unset of a position is refused, because it would move every later element; $unset of a member of an element ("Lines[0].Qty") works. A sub-document that $unset empties stays, as {}.
  • $rename moves named members only: a position on either side is refused, and so is renaming a field into itself (Address to Address.Old).
[h]oardDB [shop]> db.Orders.update("o1", {$set: {"Customer.Name": "Ada"}})
CLI error: update failed: server error (4): $set of "Customer.Name" in bucket "Orders" (database "shop") was refused: Customer is a string, not a document, so it has no member Name. Nothing was changed.
To replace it, set the whole value, for example: db.Orders.update({_id_: "o1"}, {$set: {Customer: {Name: …}}})
[h]oardDB [shop]> db.Orders.update("o1", {$set: {"Lines[5].Qty": 1}})
CLI error: update failed: server error (4): $set of "Lines[5].Qty" in bucket "Orders" (database "shop") was refused: Lines has 2 elements (positions 0 to 1), and a position past the end is not added. Nothing was changed.
To add an element, use $push, for example: db.Orders.update({_id_: "o1"}, {$push: {Lines: …}})
[h]oardDB [shop]> db.Orders.update("o2", {$set: {"Tags[0]": "x"}})
CLI error: update failed: server error (4): $set of "Tags[0]" in bucket "Orders" (database "shop") was refused: Tags does not exist, and a missing array is not created by a position. Nothing was changed.
Create the array first: {$set: {Tags: [ … ]}}
[h]oardDB [shop]> db.Orders.update("o1", {$unset: ["Lines[0]"]})
CLI error: update failed: server error (4): $unset of "Lines[0]" in bucket "Orders" (database "shop") was refused: removing an array element would move every element after it. Nothing was changed.
Set the whole array without that element instead: {$set: {Lines: [ … ]}}

Keys that overlap are refused. One update may not write both a value and something inside it, in one operator or across two, because the result would depend on which ran first: {$set: {Address: {...}, "Address.City": ...}} and $set of Address.City with $unset of Address are refused. The same key in two operators is allowed and runs in a fixed order ($set, then $unset, then $inc), so {$set: {a: 1}, $inc: {a: 1}} leaves a at 2. Among $rename pairs, no two may share a name, source or target: {a: "b", b: "c"} and {a: "t", x: "t"} are refused. A duplicate in $unset’s list is harmless.

[h]oardDB [shop]> db.Orders.update("o1", {$set: {Address: {City: "Rome"}, "Address.City": "Rome"}})
CLI error: update failed: server error (4): This update to bucket "Orders" (database "shop") was refused: it writes both "Address" and "Address.City", and one is inside the other, so the result would depend on which is applied first. Nothing was changed.
Write one of them: set the whole Address, or set Address.City alone.

Filters do not match “any element”. {"Lines.Sku": "A-1"} names a member of Lines, and Lines is an array, so it matches nothing; write the position, {"Lines[0].Sku": "A-1"}. A missing member, a position past the end and a non-document on the way are all absent, so {$exists: false} matches them. A path in a projection keeps its parents ({Address: {City: …}}); several positions of one array come back as one array of those elements, in position order, without the positions themselves.

[h]oardDB [shop]> db.Orders.find({"Lines.Sku": "A-1"})
NO MATCHES

A top-level field name may not contain ., [ or ]. Such a field could never be named by a path, so every write that stores a whole document refuses it: insert, a batch, a push, a field-store element, ingest and restore. The refusal has its own code, FIELD_TYPE (0x10), so a program can tell it from a malformed request:

[h]oardDB [shop]> db.Orders.insert({_id_: "o3", "Address.City": "Tirana"})
CLI error: insert failed: server error (16): Field name "Address.City" in this document cannot be stored in bucket "Orders" (database "shop"): a top-level field name may not contain ".", "[" or "]", because hoardDB reads them as a path (Address.City is the member City of Address). Nothing was written.
Nest it instead, for example {Address: {City: …}}, or rename it without the character, for example Address_City.

A name with a dot inside a sub-document, {Address: {"a.b": 1}}, is stored, but no path can reach it: Address.a.b means the member b of the member a. Rename such members before you rely on querying them.

A path never enters a store field: a store’s elements live outside the document, so Comments[0] is absent in a filter on a document whose Comments is a store. An index cannot be declared on a path; see Indexes.

Queues: fifo and lifo

A fifo bucket returns the oldest entry first and a lifo bucket the newest. Both use the same verbs:

[h]oardDB [shop]> db.Jobs.push({task: "resize", img: "a.jpg"})
OK  id=01a0de61-859c-78cc-ab71-0306b893ec9f
[h]oardDB [shop]> db.Jobs.push({task: "resize", img: "b.jpg"})
OK  id=01a0de61-85d6-7146-8d62-ff10337a3d1d
[h]oardDB [shop]> db.Jobs.length()
2
[h]oardDB [shop]> db.Jobs.peek()
{"_id_": "01a0de61-859c-78cc-ab71-0306b893ec9f", "img": "a.jpg", "task": "resize"}
[h]oardDB [shop]> db.Jobs.pop()
{"_id_": "01a0de61-859c-78cc-ab71-0306b893ec9f", "img": "a.jpg", "task": "resize"}
[h]oardDB [shop]> db.Jobs.pop()
{"_id_": "01a0de61-85d6-7146-8d62-ff10337a3d1d", "img": "b.jpg", "task": "resize"}
[h]oardDB [shop]> db.Jobs.pop()
EMPTY

The same verbs on a lifo bucket return the newest entry first:

[h]oardDB [shop]> db.Undo.push({op: "type", text: "hello"})
OK  id=01a0de61-8864-705e-a7f7-111cacd00a0b
[h]oardDB [shop]> db.Undo.push({op: "delete", text: "o"})
OK  id=01a0de61-889c-7185-8dd9-a99c12b3a9a2
[h]oardDB [shop]> db.Undo.pop()
{"_id_": "01a0de61-889c-7185-8dd9-a99c12b3a9a2", "op": "delete", "text": "o"}

find() lists a queue without consuming it, in pop order, and insert on a queue is the same as push.

peek() looks at the next entry; peek({limit: N}) looks at the next N. Both read in pop order and remove nothing, so the N entries are the ones N pop()s would return, in that order: the oldest N of a fifo, the newest N of a lifo, the N highest-priority entries of a heap. The count goes in an option document, as find’s options do. Asking for more than the bucket holds returns what there is, and an empty bucket prints EMPTY:

[h]oardDB [shop]> db.Jobs.push({task: "resize"})
OK  id=01a1114d-e211-7d89-932c-49f1a5707877
[h]oardDB [shop]> db.Jobs.push({task: "email"})
OK  id=01a1114d-e212-7193-ad41-8a84a8bb0bd8
[h]oardDB [shop]> db.Jobs.push({task: "index"})
OK  id=01a1114d-e212-7476-b168-c67a8653f0bb
[h]oardDB [shop]> db.Jobs.peek({limit: 2})
{"_id_": "01a1114d-e211-7d89-932c-49f1a5707877", "task": "resize"}
{"_id_": "01a1114d-e212-7193-ad41-8a84a8bb0bd8", "task": "email"}
[h]oardDB [shop]> db.Jobs.peek({limit: 9})
{"_id_": "01a1114d-e211-7d89-932c-49f1a5707877", "task": "resize"}
{"_id_": "01a1114d-e212-7193-ad41-8a84a8bb0bd8", "task": "email"}
{"_id_": "01a1114d-e212-7476-b168-c67a8653f0bb", "task": "index"}
[h]oardDB [shop]> db.Stack.push({n: 1})
OK  id=01a1114d-e213-713d-9303-b09b3c0ca08d
[h]oardDB [shop]> db.Stack.push({n: 2})
OK  id=01a1114d-e213-7462-b594-27a70f24f439
[h]oardDB [shop]> db.Stack.push({n: 3})
OK  id=01a1114d-e213-7889-a300-4060770b360a
[h]oardDB [shop]> db.Stack.peek({limit: 2})
{"_id_": "01a1114d-e213-7889-a300-4060770b360a", "n": 3}
{"_id_": "01a1114d-e213-7462-b594-27a70f24f439", "n": 2}

limit is a whole number from 1 to 1000 and the only option peek takes. A peek of N reads N entries, so its cost grows with N. Past 1000, or past the node’s result ceiling (HOARDDB_QUERY_MAX_RESULT_BYTES, 16 MiB by default), the whole request is refused and never answered short; the first entry is always answered, as pop() would return it.

Refused: peek(N) with a bare count, a limit that is not a whole number of 1 or more, any other option, and get on a queue.

[h]oardDB [shop]> db.Jobs.peek(2)
Error: peek(2) is not supported: the count goes in an option document.
       Use db.Jobs.peek({limit: 2}) to read the next 2 without removing them.
[h]oardDB [shop]> db.Jobs.peek({limit: 0})
Error: peek's limit is a whole number of 1 or more, as in db.Jobs.peek({limit: 3}); got 0
[h]oardDB [shop]> db.Jobs.peek({limit: 5000})
Error: peek failed: server error (4): invalid peek limit: 5000 is above the peek maximum of 1000. To read more than that, find() the bucket (a fifo's storage order is its pop order) or pop() them
[h]oardDB [shop]> db.Jobs.get("x")
Error: db.Jobs.get() is not defined for fifo buckets — they are read by position:
       db.Jobs.peek()  shows the next entry without removing it
       db.Jobs.pop()   removes and returns the next entry

pop() removes the next entry; pop({limit: N}) removes the next N, in one write. The N entries are the ones N pop()s would return, in that order, and each prints on its own line, so after the peeks above:

[h]oardDB [shop]> db.Jobs.pop({limit: 2})
{"_id_": "01a1114d-e211-7d89-932c-49f1a5707877", "task": "resize"}
{"_id_": "01a1114d-e212-7193-ad41-8a84a8bb0bd8", "task": "email"}
[h]oardDB [shop]> db.Jobs.pop({limit: 9})
{"_id_": "01a1114d-e212-7476-b168-c67a8653f0bb", "task": "index"}
[h]oardDB [shop]> db.Jobs.pop({limit: 9})
EMPTY  queue is empty
[h]oardDB [shop]> db.Stack.pop({limit: 2})
{"_id_": "01a1114d-e213-7889-a300-4060770b360a", "n": 3}
{"_id_": "01a1114d-e213-7462-b594-27a70f24f439", "n": 2}

Asking for more than the bucket holds removes and returns what there is; it is not an error. limit is a whole number from 1 to 100 and the only option pop takes. A limit past 100 is refused, naming the number you sent, and removes nothing. A pop whose entries together would be more than the node’s result ceiling (HOARDDB_QUERY_MAX_RESULT_BYTES, 16 MiB by default) is refused with RESULT_TOO_LARGE and removes nothing: nothing is removed when the answer would be too large. The first entry is always answered, however large it is, as pop() always did. On a heap, pop({limit: N}) removes the N highest-priority entries.

A pop’s answer is the only copy of what it removed. If your program can lose the answer (the connection drops, or the client times out after the pop was applied), the removed entries are gone and never reached you, and with pop({limit: N}) that is up to N entries. If your program can lose the answer, pop fewer at a time. Run peek to see what is left after a lost answer. Read Limitations for the cases. For lossless processing of a bucket queue, use claim and ack instead (below): the entries stay in the store under a lease until you acknowledge them.

Refused: pop(N) with a bare count, a limit that is not a whole number of 1 or more, and any other option. Nothing is sent and nothing is removed:

[h]oardDB [shop]> db.Jobs.pop(2)
Error: pop(2) is not supported: the count goes in an option document.
       Use db.Jobs.pop({limit: 2}) to remove and return the next 2.
[h]oardDB [shop]> db.Jobs.pop({limit: 0})
Error: pop's limit is a whole number of 1 or more, as in db.Jobs.pop({limit: 3}); got 0
[h]oardDB [shop]> db.Jobs.pop({limit: 101})
Error: pop failed: server error (4): invalid pop limit: 101 is above the pop maximum of 100. A pop removes what it returns, so it takes at most 100 at a time: repeat pop({limit: 100}), or read them without removing any with peek({limit: 101})

Claim, ack and the dead list

claim takes entries under a lease instead of removing them; ack removes what you name; release puts a lease’s entries back at once; extend moves a lease’s deadline. They work on a fifo, lifo or heap bucket, and are the lossless way to process a bucket queue. A store field is pop-only in v1.0.

[h]oardDB [app]> db.Jobs.claim()
{"_id_": "01a0de61-859c-78cc-ab71-0306b893ec9f", "task": "resize"}
lease_id 9f3a0c1d5e7b2a4c6d8e0f1a2b3c4d5e6f708192 for 30s; item_ids ["00000000000000a1"]
[h]oardDB [app]> db.Jobs.claim({limit: 2, lease: "60s"})
{"_id_": "01a0de61-85d6-7146-8d62-ff10337a3d1d", "task": "email"}
{"_id_": "01a0de61-8864-705e-a7f7-111cacd00a0b", "task": "index"}
lease_id 1b2c3d4e5f60718293a4b5c6d7e8f90123456789 for 1m; item_ids ["00000000000000a2", "00000000000000a3"]
[h]oardDB [app]> db.Jobs.ack({lease_id: "1b2c3d4e5f60718293a4b5c6d7e8f90123456789", item_ids: ["00000000000000a2"]})
acked 1, remaining 1
[h]oardDB [app]> db.Jobs.extend({lease_id: "1b2c3d4e5f60718293a4b5c6d7e8f90123456789", lease: "60s"})
extended for 1m (until 2026-10-19T10:02:00Z, bucket clock)
[h]oardDB [app]> db.Jobs.release({lease_id: "1b2c3d4e5f60718293a4b5c6d7e8f90123456789"})
released 1
  • claim takes one entry by default, up to 100 with limit, under a lease of 30 seconds by default. lease is a duration with a unit, from "1s" to "1h"; a bare number is refused. Each entry prints as one JSON line, then the lease_id line. item_ids are the handles ack names, not the documents' _id_ values. deliveries appears only when some entry was delivered before. An empty claim prints EMPTY queue is empty, followed by the claimed, returned or dead counts when they are non-zero.
  • ack names a lease and either every entry of it or the item_ids given. It prints acked N, then missing M and remaining R when non-zero. An unknown or expired lease is Error: ... and a non-zero exit; nothing was acknowledged.
  • release puts every unacknowledged entry of the lease back at once and prints released N. A release counts toward the 5 deliveries.
  • extend moves the whole lease’s deadline and prints the granted length and the bucket-clock deadline. Extend as often as you need; the one-hour maximum is per extend, not per lease.
  • len() and peek() do not count claimed entries; they print the count beside the number: 12 (plus 3 claimed). When an entry is dead, len() at 0 adds (3 entries are dead-lettered; see db.Jobs.dead()), and an empty pop or claim says the same. peek does not show a claimed or dead entry; find and scan skip both.

Dead entries. An entry claimed 5 times without an acknowledgement, or a heap element whose _id_ was pushed again while it was claimed (collided), moves to the bucket’s dead list and is never delivered again. It is not in the queue: len() and peek do not count or show it.

[h]oardDB [app]> db.Jobs.dead()
{"dead_id": "00000000000004d2", "deliveries": 5, "reason": "max_deliveries", "dead_at": "2026-10-19T09:12:41Z", "data": {"_id_": "01a0de61-859c-78cc-ab71-0306b893ec9f", "task": "resize"}}
[h]oardDB [app]> db.Jobs.dead({limit: 2, after: "00000000000004d2"})
(no dead entries after "00000000000004d2")
[h]oardDB [app]> db.Jobs.requeue({dead_id: "00000000000004d2"})
requeued 1
[h]oardDB [app]> db.Jobs.discard({all: true})
discarded 1

dead() lists the first 10 entries, oldest first, and answers more: with the next page; limit is 1 to 1,000 and after is a dead_id from the page before. requeue puts one entry, or every one with all: true, back with its delivery count reset; discard drops one or every one for good. The all forms move at most 100 entries per request and print left N when entries remain (left includes entries a heap refuses because a live element holds the same _id_). On a heap, dead() shows the stored element {_id_, priority, value}; value is the document claim and pop print. The dead list is unbounded.

Refused: claim on a hash, btree or blob bucket (it has no order to take from), claim/ack/release/extend/dead/requeue/discard on a store field (use its pop), a claim over 100, a lease outside 1 second to 1 hour, an ack naming more than 100 item ids, and dead’s options on a non-queue. The refusals name the bucket, the verb and the fix. A claim of an entry over the size a claim can move is refused until the entry is taken with pop.

claim delivers at least once: an entry can reach a handler more than once, so a handler must be safe to run twice on the same entry.

Heap

A heap bucket returns the highest priority first. priority is a whole number that orders the entry; it is not stored in the document:

[h]oardDB [shop]> db.Alerts.push({priority: 5, msg: "High CPU", host: "node-2"})
OK  id=01a0de61-89c0-7d7c-8b50-9ff40ccfa833
[h]oardDB [shop]> db.Alerts.push({priority: 10, msg: "Disk full", host: "node-1"})
OK  id=01a0de61-89f9-7e08-bf1e-80403a1448cc
[h]oardDB [shop]> db.Alerts.push({priority: 8, msg: "Slow peer", host: "node-3"})
OK  id=01a0de61-8a2e-7143-a76f-e931773c5231
[h]oardDB [shop]> db.Alerts.peek()
{"_id_": "01a0de61-89f9-7e08-bf1e-80403a1448cc", "host": "node-1", "msg": "Disk full"}
[h]oardDB [shop]> db.Alerts.peek({limit: 2})
{"_id_": "01a0de61-89f9-7e08-bf1e-80403a1448cc", "host": "node-1", "msg": "Disk full"}
{"_id_": "01a0de61-8a2e-7143-a76f-e931773c5231", "host": "node-3", "msg": "Slow peer"}
[h]oardDB [shop]> db.Alerts.pop()
{"_id_": "01a0de61-89f9-7e08-bf1e-80403a1448cc", "host": "node-1", "msg": "Disk full"}
[h]oardDB [shop]> db.Alerts.pop()
{"_id_": "01a0de61-8a2e-7143-a76f-e931773c5231", "host": "node-3", "msg": "Slow peer"}

peek({limit: N}) returns the N highest-priority entries without removing them, as on a queue. Entries of equal priority come out in the order of their _id_: push order for ids the server generates (they are time-ordered), and _id_ order if you supply your own. peek and pop always agree on it. A push with no priority is accepted at priority 0. length(), count() and find() work as on a queue. get is refused, as on a queue: db.Alerts.get() is not defined for heap buckets — use db.Alerts.peek() for the highest-priority entry, or db.Alerts.pop() to remove it. A heap also answers claim, ack, release, extend and the dead verbs, with the same lease and dead rules as a queue (above); its pop order is highest priority first.

B-tree ranges

A btree bucket keeps its keys in order. insert, find, get, update, delete, count and the filters all work as on a hash bucket. It adds range, first and last:

[h]oardDB [shop]> db.Ledger.insert({_id_: "2026-01-03", amount: 120})
OK  id=2026-01-03
[h]oardDB [shop]> db.Ledger.insert({_id_: "2026-01-01", amount: 40})
OK  id=2026-01-01
[h]oardDB [shop]> db.Ledger.insert({_id_: "2026-01-02", amount: 75})
OK  id=2026-01-02
[h]oardDB [shop]> db.Ledger.insert({_id_: "2026-02-10", amount: 5})
OK  id=2026-02-10
[h]oardDB [shop]> db.Ledger.range({$gte: "2026-01-01", $lte: "2026-01-31"})
3 entries in range:
  2026-01-01           {"_id_": "2026-01-01", "amount": 40}
  2026-01-02           {"_id_": "2026-01-02", "amount": 75}
  2026-01-03           {"_id_": "2026-01-03", "amount": 120}
[h]oardDB [shop]> db.Ledger.range({$gte: "2026-02-01"})
1 entry in range:
  2026-02-10           {"_id_": "2026-02-10", "amount": 5}
[h]oardDB [shop]> db.Ledger.first()
first: 2026-01-01
{"_id_": "2026-01-01", "amount": 40}
[h]oardDB [shop]> db.Ledger.last()
last: 2026-02-10
{"_id_": "2026-02-10", "amount": 5}

The bounds of range apply to the key, and are written at the top level: {$gte: low, $lte: high}. Either one may be left out. Both are inclusive. Keys compare as strings, which is why dates written as YYYY-MM-DD sort correctly. A document inserted without an _id_ gets a generated key, and that key sorts among yours. A low above high is an empty range, not an error. Over the wire range also takes a limit: 0 or absent is the whole band, and a positive number stops after that many entries.

Keys that look like numbers are still strings. Every key you write is a string, and range compares bytes: with the keys 1, 2, 3, 5, 10 and 20, range({$gte: "2", $lte: "5"}) returns 2, 20, 3 and 5, because 20 sorts between 2 and 3, and 10 sorts before 2. To get numeric order, write the keys with leading zeros to one width (0002, 0010, 0020).

The field form, which names a field before the bounds, is refused:

[h]oardDB [shop]> db.Ledger.range({_id_: {$gte: "2026-01-01"}})
Error: range needs bounds: db.Ledger.range({$gte: "a", $lte: "z"})

To order by a field that is not the key, use find with that field in sort, or make that field the key. range, first and last on a bucket that is not a btree are refused with the bucket’s actual type:

[h]oardDB [shop]> db.Users.range({$gte: "a"})
Error: range failed: server error (4): bucket "Users" is type hash; this operation requires type btree

Blob

A blob bucket stores a payload and its metadata under a key. put takes the payload from the data field; every other field except the key is stored as metadata:

[h]oardDB [shop]> db.Files.put({_id_: "hello.txt", data: "hello, world", type: "text/plain"})
OK  key=hello.txt  blob=01a0de61-f88c-730a-b5c6-a00262986153  12 B
[h]oardDB [shop]> db.Files.get("hello.txt")
Key:    hello.txt
Blob:   01a0de61-f88c-730a-b5c6-a00262986153
Size:   12 B
Metadata: {"type": "text/plain"}
Data:
hello, world
[h]oardDB [shop]> db.Files.info("hello.txt")
Key:      hello.txt
Blob:     01a0de61-f88c-730a-b5c6-a00262986153
Size:     12 B
Metadata: {"type": "text/plain"}
[h]oardDB [shop]> db.Files.get({_id_: "hello.txt", out: "hello-copy.txt"})
OK  wrote hello-copy.txt (12 B)
[h]oardDB [shop]> db.Files.delete("hello.txt")
OK  deleted "hello.txt"
  • get prints the payload when it is text. For binary content it prints the size and asks you to re-run with {out: "file"}.
  • out writes the file on the machine running the CLI, relative to the directory you started it in, readable only by you (mode 0600).
  • Without data, the whole document is stored as the payload, as JSON. With no _id_, a key is generated.
  • find("<key>") on a blob bucket returns the metadata as a document, and count() counts the keys.
  • put is the one write a blob bucket takes, and a put of a key the bucket holds replaces it. insert and upsert on a blob bucket are refused with INVALID_ARGUMENT and the words “a blob bucket is written with put”.

Refused: scanning a blob bucket, and streaming.

[h]oardDB [shop]> db.Files.find()
Error: find failed: server error (4): db.Files.find() does not scan blob buckets: a blob's value is binary content, not a document. Use db.Files.info("<key>") for one key's metadata, or db.Files.get("<key>") to read the content. A blob bucket takes: get, exists, delete, put blob, get blob, count, stats.
[h]oardDB [shop]> db.Files.stream({_id_: "hello.txt"})
Error: streaming is not available yet
       the transport defines OpStreamOpen but the server does not implement
       chunked reads. Read the whole blob instead:
         db.Files.get({_id_: "key"})              print it
         db.Files.get({_id_: "key", out: "f.bin"}) write it to a file

A single request is capped at 32 MiB, so a larger payload has to be split before you put it. A dump does not capture blob payloads yet; see Backup and restore.

Verbs that are not what they look like

Each of these was run against this build. Most of them are also covered in the section for their verb.

  • push, pop and peek are the same three requests for every store type. The CLI always sends the queue requests, and the server decides by the bucket’s type. That is why one set of verbs serves fifo, lifo and heap. On other types the result varies. push on a hash bucket is an ordinary insert (OK id=..., and refused with DOCUMENT_EXISTS for an _id_ the bucket holds), while pop and peek on a hash bucket are refused with Error: pop failed: server error (4): pop is not defined for hash buckets: a hash has no order to take from, so there is no "next" to remove. Remove one with delete("<key>"), or read them with get("<key>") or find(). A hash bucket takes: put, insert, ... and the same for peek (an EMPTY line means the queue or heap really is empty). find("<key>") on a queue bucket is refused the same way. push on a blob bucket is refused with Error: push failed: server error (4): push is not supported for bucket type blob. A blob bucket takes: get, exists, delete, put blob, get blob, count, stats., like any other refused write: a piped script stops there and the CLI exits non-zero. Every verb a store type does not answer is refused this way (a server error 4, INVALID_ARGUMENT, that ends with the verbs the type takes).
  • get depends on the store type. On hash and btree it is a point read, the same as find("<key>"). On blob it returns the payload. On fifo, lifo and heap it is refused with the verb that works there.
  • search scans when no index serves the filter. On a field without an index it walks the bucket, as find does, and the answer’s header says (full scan). An index makes an equality query faster, not correct.
  • A filter naming _id_, id, key or _key_ is a point read. Its other fields are ignored. See find: one document by key.
  • define bucket is a rename error, kept on purpose so old scripts fail loudly. Use create bucket.
  • create bucket on an existing name answers OK and changes nothing.
  • A dotted bucket name is one flat bucket, not a sub-bucket.
  • get-token and rotate-token act on the machine you run the CLI on. They read and rewrite the cluster.token file in the CLI’s data directory (by default ./data). They do not ask the server. On a node with encryption at rest that file is sealed, and the CLI opens it with the encryption settings of its own configuration: run it with the same configuration file or HOARDDB_ENCRYPTION_* variables as the server.

Batches: many writes and reads in one request

batch { ... } sends many store-field writes and reads, to any buckets of the session database, as one request. It is how a fan-out (a post copied into every follower’s feed), a follow (two edges, two counters, a notification) or a load of feed entries and counters costs one round trip instead of one per statement. The statements go between braces, separated by ;, and the REPL keeps reading lines until the braces close:

[h]oardDB [social]> batch {
...>   db.User_7["7"].Posts.put("p-1001", {text: "hello"});
...>   db.User_42["42"].Feed.push({author: "7", post: "p-1001", text: "hello"});
...>   db.User_43["43"].Feed.push({author: "7", post: "p-1001", text: "hello"});
...>   db.User_7.update("7", {$inc: {posts_n: 1}});
...>   db.User_42["42"].Feed.len()
...> }
 #  statement                              result
 1  db.User_7["7"].Posts.put("p-1001", …)  OK
 2  db.User_42["42"].Feed.push(…)          OK
 3  db.User_43["43"].Feed.push(…)          ERROR: bucket not found: bucket "User_43" not found in database "social". Run 'show buckets in social' to list what exists, or 'create bucket User_43 in social { type: hash }' to create it
 4  db.User_7.update("7", …)               OK
 5  db.User_42["42"].Feed.len()            1
5 statements: 4 succeeded, 1 not applied (nothing stored for it; safe to resend). Statements in one batch are not applied together.

To run that example from scratch, create the buckets first:

create database social
use social
create bucket User_7 { Posts hashStore, Feed fifoStore }
create bucket User_42 { Feed fifoStore }
db.User_7.insert({_id_: "7", posts_n: 0})
db.User_42.insert({_id_: "42"})

A batch is not a transaction. Each statement is applied, refused or left unknown on its own, and its row says which, in the words the statement gives when it runs alone. Another client can see statement 4’s effect before statement 2’s. Read the summary line before resending anything:

summary saysmeaningwhat to do
succeededappliednothing
not appliednothing stored for itresend it
stored on fewer copies than askedstored; the write concern was not metdo not resend a push or an $inc
unknownit may be storedread it back before resending a push or an $inc
refused by the storethe statement’s own answer said no (an update of a key with no document)fix the cause; resending fails the same way
reads not answerednothing changedresend it

What a batch carries. A store field’s push, put, delete, get, len (or length, count) and peek, and an update of one key. Each runs exactly as it does alone; get, len and peek print their value in the result column. A pop is not carried (a lost reply would lose the popped entries; pop on its own), nor a list (its page can fill a reply by itself), nor an update with a filter, nor a bucket verb such as insert. A statement a batch does not carry is refused before anything is sent:

[h]oardDB [social]> batch { db.User_42["42"].Feed.push({a: 1}); db.User_42["42"].Feed.pop() }
Error: Item 2 of 2, db.User_42["42"].Feed.pop(), is a pop, which a batch does not carry: a lost reply would lose the popped entries. Send it on its own (run 'use social' first). Nothing in the batch was run.

Order. Statements naming the same bucket apply in the order written: a get after a put of the same bucket sees it. After a statement of a bucket that is not applied, the later statements of that bucket are not run, and say so (Not run: item 2 of 3 (db.User_7.update("ghost", …)), an earlier item for the same bucket, was not applied, and items of one bucket apply in order. Resend from item 2.); statements of other buckets are unaffected. Statements of different buckets have no order between them.

Limits. At most 1,000 statements and 16 MiB in one batch; a larger batch is refused with the split it needs (see Limits). A batch uses the session’s write concern (write-concern majority) for every write in it.

The session database only. db. names the session database, as it does outside a batch; switch with use to batch writes to another database.

Piped. A batch reports how it ended in the exit status: 0 when every statement succeeded, 2 when the batch was refused whole (nothing ran), 3 when some statement was not a success:

printf 'use social\nbatch { db.User_42["42"].Feed.push({text: "hi"}) }\n' | hoarddb

help batch prints this summary in the REPL.

Users and roles

User changes are served by the cluster’s master node, which a single server is for itself. Roles are reader, writer, db-manager, user-manager, cluster-manager, backup-operator, restore-operator and root. Each is bound to a database as role@database; a role with no database means every database. What each role grants, and how changes reach open sessions, are covered in User management.

create user

A password is never part of the statement. At a terminal the CLI asks twice, without echo:

[h]oardDB> create user alice roles [writer@shop, reader@app]
New password:
Repeat new password:
user "alice" created (config version 2)

In a piped script, put the password on the two lines after the statement (one line with -password-stdin), or start the CLI with -password-file <path> (a file readable only by you), and it reads the new password from there instead of asking. create user <name> with no roles creates a user who can do nothing until granted a role.

Refused: anything after the name other than roles [...], which includes a password or a braced options document:

[h]oardDB> create user bob { roles: ["writer@shop"] }
Error: parse: usage: create user <name> roles [role@db, ...]

Read users back

user list and show users are the same. Neither ever prints a password hash:

[h]oardDB> user list
USERS (master: node1 at 0.0.0.0:4433, config version 3)
---
admin: enabled, roles [root@*], epoch 0
alice: enabled, roles [reader@app, writer@shop], epoch 0
carol: enabled, roles [(none)], epoch 0
[h]oardDB> user describe alice
user:  alice
status: enabled
roles:  [reader@app, writer@shop]
epoch:  0
created: 2026-09-26T15:42:59Z
password changed: 2026-09-26T15:42:59Z

master prints the node that serves user changes. On a single server it is that server, here named after its host: cluster master for user changes: node1 at 0.0.0.0:4433.

grant and revoke

Each adds or removes one grant. The word role is optional:

[h]oardDB> grant role db-manager@shop to alice
granted db-manager@shop to "alice" (config version 4)
[h]oardDB> revoke role reader@app from alice
revoked reader@app from "alice" (config version 5)

Refused: the access form, and a role with an empty name.

[h]oardDB> grant access to "shop" for "alice"
Error: the 'access' form is not supported; grant a role instead:
  grant role writer@shop to alice
[h]oardDB> grant role @shop to alice
Error: parse: role "@shop" must be <role>@<database> or a bare <role>

alter user, disable, enable, drop

alter user <name> password                   -- prompted twice, or -password-file / -password-stdin
alter user <name> disable                    -- also: disable user <name>
alter user <name> enable                     -- also: enable user <name>
alter user <name> roles [role@db, ...]       -- replaces the whole role set
drop user <name>
[h]oardDB> alter user alice disable
user "alice" altered (config version 7)
[h]oardDB> enable user alice
user "alice" altered (config version 8)
[h]oardDB> drop user carol
user "carol" dropped
[h]oardDB> drop user carol
Error: dropping user "carol": server error (1): drop user "carol": user not found; run 'user list' to see the users

Refused: alter user <name> databases [...]. Database access is part of the role (writer@shop), so there is nothing separate to alter:

[h]oardDB> alter user alice databases ["shop"]
Error: usage: alter user <name> disable|enable|password|roles [role@db, ...]

Cluster and administration

status

The node’s identity, its counts, and a capacity row per resource:

[h]oardDB> status
STATUS
---
Node ID:      n1
Version:      0.1.0
Uptime:       11s
Buckets:      1
Healthy:      true
Capacity:
  fds         0.00  threshold 0.80  ok
  mappings    0.00  threshold 0.80  ok
  memory      0.00  threshold 0.80  ok
  disk        0.33  threshold 0.85  ok

What the capacity rows mean is covered in Is this node full?.

When the node’s store cannot persist writes (its data directory was removed, its disk is full or returns errors), Healthy: is false and the rows under it say why, naming the store directory and the cause:

Healthy:      false
              store: the node store at /data/store refuses writes: ...
Store:        REFUSING WRITES - the node store at /data/store refuses writes: it cannot persist them (...). Check that the directory exists, is writable and its disk has free space: ...

Every write is refused with the same message until the store can persist again; reads still work. Fix the disk or the directory; a restart replays the memtable logs still on disk.

status reports node totals only. For one bucket’s entry count, run describe bucket <name>.

encryption

Whether the node’s store is encrypted at rest, from its files and not from its configuration; see Checking a running node.

[h]oardDB> encryption
ENCRYPTION AT REST
---
Sealed:       yes (the store's files show it; checked when the node started)
Mode:         require
Keystore:     keyfile
KEK kid:      c4a82744
Node master:  f11ac094

Not sealed, whatever the mode:
  - keys/server.crt: the node's TLS certificate, which is public
  - root.password: the first-run credential, while it exists, mode 0600
  - membership.json: peer addresses
  - wal/incarnation.bson: positions and random ids, no names or data
  - the store's MANIFEST and the entry headers in its logs: table levels, lengths and expiry times
  - blob payload file names: slot, bucket id and blob id
  - the audit log: user names, source addresses and targets, mode 0600
  - dumps: written in plaintext whatever the mode

On a node that does not encrypt it says Sealed: no and how to start one that does. A server older than this command prints that it does not report its state, and is not called plaintext.

Cluster verbs

The examples in this section ran on a three-node cluster on one host, each node on its own ports. How to form a cluster is covered in Replication and Configuration.

nodes: works.

[h]oardDB> nodes
NODES
---
  n1                    127.0.0.1:14433            active      2026-09-26 15:44:57
  n2                    127.0.0.1:14443            active      2026-09-26 15:44:59

nodes prints the node records the node you are connected to holds, with their addresses, so it needs root or cluster-manager; anyone else is refused with the role to ask for. status stays open to every user. It is not the committed membership: right after a remove-node, a node can still list the removed member. The server log line cluster membership change committed is the authority.

join <address>: works on a cluster member. It adds a running node and starts rebalancing. Name the node as the cluster’s seed list does (id@host:port):

[h]oardDB> join n3@127.0.0.1:14453
Added n3@127.0.0.1:14453. added n3; cluster now at epoch 2 (data rebalancing)

remove-node <node-id>: works on a cluster member. Run it from a node other than the one you are removing:

[h]oardDB> remove-node n3
Removed n3. removed n3; cluster now at epoch 3. Its buckets are moving to their new owners; hoarddb_handoffs_in_progress reads 0 on every node once they have, which is not in the first seconds: until a node's placement pass has run, it reads 0 there too

hoardDB never removes a node on its own. See Limitations: cluster.

migrate <database> <bucket>: works on a cluster member. It pulls a bucket onto the node you are connected to, when that node is its owner:

[h]oardDB> migrate app Orders
bucket "Orders" in "app" is now served by n1

On a node that already holds the bucket and owns it, migrate pulls nothing and says so. A replica gives the same reply when its copy cannot be compared with its owner’s: the owner has not opened its copy yet, or holds another incarnation of the bucket. (When the owner cannot be reached, migrate on a replica fails with an error that names the owner; run it again once the owner answers.)

[h]oardDB> migrate app Orders
bucket "Orders" in "app": n1 already holds this bucket, so nothing was pulled. migrate copies a bucket onto a node that does not have it, and brings a copy in another term than its owner's in line; it does not repair a copy that is only behind. To find out whether this copy is behind, compare 'count' on each node. To repair a replica that is, stop it, delete the 'store' and 'wal' entries in its data directory (keep 'keys', 'cluster', 'cluster.token', 'membership.json' and 'users.json'), start it, and run 'migrate app Orders' on it again

On a replica whose copy follows its owner, it says that instead, with the same advice:

[h]oardDB> migrate app Orders
bucket "Orders" in "app" on n2 already follows its owner's term 4; nothing to do. To find out whether this copy is behind, compare 'count' on each node. To repair a replica that is, ...

Either reply is a success and the exit status is 0, because nothing is wrong with the node. It is not a sign that a replica was repaired: a copy that follows its owner but is behind is repaired only the way the reply says.

One case differs. With auto-rebalance off, after a node joins, the refusal of a schema change can list 'migrate app Orders' on n3 for a node that already holds the bucket: its copy does not follow the bucket’s new owner. Run it there, and migrate brings the copy in line with the owner. It may do that by dropping the node’s copy and pulling a fresh one from the owner; entries the owner does not have are not kept.

[h]oardDB> migrate app Orders
bucket "Orders" in "app" on n3 now follows its owner's term 4

If the copy has not caught up within the write-concern timeout, migrate is refused with a retryable error that names the command to run again. Its message reads:

bucket "Orders" in database "app" on n3 is in term 3 and its owner n2 is in term 4; this node is taking its owner's term. Run 'migrate app Orders' on n3 again in a moment

consistent-get <database> <bucket> <key>: works. It reads the freshest copy across a quorum of the bucket’s holders, and repairs a stale copy on the way. It prints the document in the same one-line form find uses, and -extjson applies to it:

[h]oardDB> consistent-get app Orders o-1
{"_id_": "o-1", "total": 42}

A read that repaired a stale copy prints note: this read repaired a replica after the document, as a note rather than as part of it, and a missing key prints not found.

write-concern <one|majority|all>: works. It sets how many copies must confirm each write for the rest of the session:

[h]oardDB [app]> write-concern all
Write concern set to all for subsequent writes.
[h]oardDB [app]> db.Orders.insert({_id_: "o-1", total: 42})
OK  id=o-1
[h]oardDB [app]> write-concern two
Error: unknown write concern "two" — use one, majority, or all

On a single server, join, remove-node and migrate are refused by the server, and the refusal says why:

[h]oardDB> join 127.0.0.1:59999
Error: join "127.0.0.1:59999": server error (5): replication is not enabled on this node; the node must be a cluster member to add another
[h]oardDB> migrate shop Users
Error: migrate shop Users: server error (5): replication is not enabled on this node; a standalone node has no bucket to migrate

(The CLI’s help lists join and remove-node as “not available yet”. They are available on a cluster; the help text is out of date.)

connect, get-token, rotate-token

  • connect <host:port> moves the session to another server and authenticates again. A failed connect leaves the session with no connection at all, not on the server it was on. The next statement answers not connected to server, so connect again to the address you came from.
  • get-token prints the cluster token from the cluster.token file in the CLI’s data directory. The token does not expire. rotate-token writes a new one there. Both work on the local file; see Verbs that are not what they look like.
  • rotate-token works only on a node outside a cluster, which does not use the token until it joins one. The old token stops working at once; there is no grace period.
  • On a cluster member, rotate-token refuses and changes nothing. Rotating a cluster’s token is not supported in this release. Every member must hold the same token and nothing would send the new one to the others; worse, a member seals its users.json and peppers its passwords with keys derived from the token, so a member started on another token cannot open its users at all. Changing the token means rebuilding the user store: stop every node, move each users.json aside, start every node with the new HOARDDB_CLUSTER_TOKEN and HOARDDB_ROOT_PASSWORD, and create every other user again.
[h]oardDB> rotate-token
Old token: 22f6d9ae1f5cb855cc75f46ee29d6cbd746713d5a7d571ae67218803f6fa757d
New token: 0e77be7f1680837c2717aed272e0acb668ef4c59638e19876fcf20b15bc192c9
Note: the old token stopped working on this node at once. A node outside a cluster does not use the token; a node joining a cluster must hold the cluster's token.

dump and restore

dump and restore each work both as a statement at the prompt and as a command of their own. Flags come before the database and bucket names, because flag parsing stops at the first name:

[h]oardDB> dump --out ./dump-shop shop
dumping shop/Alerts...
  shop/Alerts: 2 documents, 314 bytes, sha256:7481de010b1c99cc6827720fb30c1887d885e6e64638b8ebb3edce10fdce6888
(one pair of lines per bucket, then a warning that blob payloads are not captured)
dump: 1 database(s), 7 bucket(s), 17 document(s), 1780 byte(s)
[h]oardDB> restore --dir ./dump-shop --dry-run
(the same blob warning)
  shop/Alerts: would merge into existing bucket shop/Alerts (type heap)
(one line per bucket)
restore --dry-run: 1 database(s), 7 bucket(s), 0 document(s) applied, 0 skipped

The lines in parentheses stand in for output cut here for length.

hoarddb dump --out ./dump-one --insecure --json shop Users
{"ok":true,"databases":["shop"],"buckets":1,"documents":5,"users_recreated":0,"users_awaiting_password":0,"bytes":507,"errors":[]}

Written the other way round, dump shop --out ./dump-shop is refused with hoarddb dump [database [bucket]] [flags] — too many positional arguments. Every flag, and what a dump does and does not capture, is covered in Backup and restore.

The interactive REPL

Started from a terminal, the CLI is an interactive session with line editing, history and Tab completion of verbs and bucket names (db.Us then Tab becomes db.Users). The prompt shows the current database:

[h]oardDB> use shop
Switched to database 'shop'
[h]oardDB [shop]> db.Users.count()
4
[h]oardDB [shop]> bogus
Error: parse: unknown command: bogus
[h]oardDB [shop]> exit
Goodbye.
StatementWhat it does
helpThe command summary. help <topic> prints one topic’s page (hash, btree, fifo, lifo, heap, blob, claim, placement, fields, user, operators, config); an unknown topic lists the valid ones.
versionhoardDB version=devel commit= build_date= protocol=1, without asking the server.
themeThe prompt theme in use, where it came from, and whether colour is on.
exit, quitEnd the session (Goodbye.). Ctrl-D also ends it. Ctrl-C abandons the line you are typing and keeps the session.

An unknown statement is refused as parse: unknown command: <word>, an unknown bucket verb as unknown bucket method "<verb>" — run 'help' for the supported operations, and a verb without parentheses as parse: expected parentheses for method call.

The prompt is coloured on a terminal. Colour is off when output goes to a pipe, when NO_COLOR is set, when TERM=dumb or unset, or with -theme plain. Your own theme goes in ~/.hoarddb/theme.yml; see Configuration: prompt themes.

Up and Down walk what you have typed in this session. The history lives in memory and goes when the session does: nothing is read from or written to disk, so no record of your statements is left behind. A statement you abandon with Ctrl-C is not recorded, and a piped run has no history at all.

The three flags that change how output is printed — -pretty, -extjson and -no-pager — can also be changed while a session runs:

set pretty on|off      each document on several lines instead of one per line
set extjson on|off     dates as {"$date": "..."} instead of plain strings
set pager on|off       a report longer than a screen, one screen at a time

set <setting> on its own says what that setting is. The values themselves are never touched: pretty reflows the JSON the CLI already printed, so a 64-bit integer, a decimal and a date’s text are the same values on one line or on several. A piped run never pages whatever the setting says, because paging needs a terminal at both ends.

Paging a long report

A report longer than the terminal — help, status, a wide describe bucket, show, nodes history, theme — is shown one screen at a time when both ends of the session are a terminal and the command’s output is a report rather than work in progress (dump, restore and ingest stream and are never held, because showing a progress stream only at the end looks like a hang).

space, f, Page Down     the next screen
enter, j, Down          one line on
b, k, Page Up           one screen back
Up                      one line back
g, G                    the first and the last screen
q, Esc                  stop

The screen is the alternate one, so stopping leaves your scrollback exactly as it was, and a report that fits is printed in place rather than taking the screen to show less than a screen holds.

Paging is off whenever it cannot work, and there is no error when it is: a piped run, or an output redirected to a file, is never paged; neither is a terminal with TERM=dumb or no TERM set, which is a terminal saying it cannot take the escape sequences. Those sessions get the report in full.

The pager is not available outside Linux today (macOS is not a supported platform before v1.2), so every report prints in full there and set pager on changes nothing. Pipe a long report into less if you want to scroll it.

Terminals that cannot take escape sequences

With TERM=dumb, with no TERM set, or with output that is not a terminal (a file or a pipe), the CLI writes no escape sequences at all: no colour, no background query, no paging, and no cursor redraws while you type. The prompt is read as a plain line, so you edit with your terminal’s own line editing instead of Up, Down and Tab. With NO_COLOR set on a capable terminal you keep the line editor and lose only the colour.

Configuration as environment variables

The CLI itself reads these:

VariableUsed for
HOARDDB_ROOT_USER, HOARDDB_ROOT_PASSWORDCredentials when -user/-password are not given.
HOARDDB_AUTH_TOKENA session token instead of a password (-auth-token).
HOARDDB_CLUSTER_TOKENThe cluster token for cluster-management verbs (-token).
HOARDDB_THEMEThe prompt theme: a path, or plain.
NO_COLOR, TERM=dumb or unsetTurn the prompt’s colour off. TERM=dumb or unset also stops every other escape sequence.

These server settings decide what the statements on this page do:

VariableDefaultEffect on HQL
HOARDDB_TCP_LISTEN127.0.0.1:7433The address -address connects to.
HOARDDB_DATA_DIR./dataWhere databases, buckets and users.json live.
HOARDDB_REPLICATION_FACTOR1How many copies a bucket has when create bucket gives no replication_factor. A bucket created with replication_factor keeps its own.
HOARDDB_WRITE_CONCERNset by the server; the cluster above logged majorityThe default that write-concern overrides for a session.
HOARDDB_SEED_NODES, HOARDDB_SEED_FINGERPRINTS, HOARDDB_NODE_IDnoneCluster membership, which nodes, join and migrate need.

Every setting, with its YAML key, is in Configuration.

Where each statement is accepted

For contributors and for anyone checking this page against a build. The parser entry point is cli/parser.go:ParseCommand, and bucket verbs are dispatched by cli/handlers.go:handleBucket.

StatementParsed byHandled by
use, show, describecli/parser.go:parseUse, parseShow, parseDescribecli/handlers.go:handleUse, handleShow, handleDescribe
create database, drop databasecli/parser.go:parseCreate, parseDrop; refuseDatabaseReplicationFactorcli/handlers.go:handleCreateDatabase, handleDropDatabase
create bucketcli/parser.go:parseCreateBucketcli/handlers.go:handleCreateBucket, parseBucketSchema; types from cli/store_types.go:storeTypes
drop bucketcli/parser.go:parseDropcli/handlers.go:handleDropBucket
alter bucket (store fields added, replication_factor raised; the rest refused)cli/parser.go:parseAltercli/declare.go:handleAlterBucket, alterOptions; refusal cli/unsupported_features.go:errAlterBucket
store field declarations, db.<bucket>["<key>"].<Field>.<verb>()cli/declare.go:parseDeclarationBody, parseFieldAddresscli/declare.go:handleField; server/fieldstore.go
define (refused)cli/parser.go:ParseCommandtested by cli/create_bucket_test.go:TestDefineBucketIsARenameError
db.<bucket>.<verb>(...)cli/parser.go:parseBucketMethod, parseArgs; documents by cli/documents.go:parseDocumentcli/handlers.go:handleBucket
insertcli/handlers.go:handleInsert
find, getcli/handlers.go:handleFind, planFind, handleGet; key names in cli/documents.go:keyFieldNames
filter operatorsserver/scan_filter.go:compileFilter; server/scan_filter_document.go:compiledFilter.matchesDocument, operatorTest.matches
updatecli/handlers.go:handleUpdate, applyUpdateOperators
deletecli/handlers.go:handleDelete
count, length, distinctcli/handlers.go:handleCount, handleDistinct
searchcli/handlers.go:handleSearch; store/index_manager.go:IndexManager.Search
push, pop, peekcli/handlers.go:handlePush, handlePop, handlePeek; server/service.go:Server.PushCtx
range, first, lastcli/handlers.go:handleRange, handleEdge
put, info, blob getcli/handlers.go:handleBlobPut, handleBlobInfo, handleBlobContent
stream (refused)cli/unsupported_features.go:errStreamUnsupported
statscli/handlers.go:handleBucketStats
create usercli/parser.go:parseCreateUsercli/user_commands.go:handleUserCreate
alter user, disable, enablecli/parser.go:parseAlter, parseDisableEnablecli/user_commands.go:handleUserAlter
drop usercli/parser.go:parseDropcli/user_commands.go:handleUserDrop
grant, revokecli/parser.go:parseGrant, parseRevoke, parseRoleSpeccli/user_commands.go:handleUserGrant, handleUserRevoke
user list, user describe, show users, mastercli/parser.go:parseUser, parseShowcli/user_commands.go:handleUserList, handleUserDescribe, handleMaster
status, nodescli/parser.go:ParseCommandcli/handlers.go:handleStatus, handleNodes
encryptioncli/parser.go:ParseCommandcli/encryption.go:handleEncryption; server/encryption_status.go:healthEncryption
join, remove-node, migratecli/parser.go:parseJoin, parseRemoveNode, parseMigratecli/handlers.go:handleJoin, handleRemoveNode, handleMigrate
consistent-get, write-concerncli/parser.go:parseConsistentGet, parseWriteConcerncli/handlers.go:handleConsistentGet, handleWriteConcern
connectcli/parser.go:parseConnectcli/handlers.go:handleConnect
get-token, rotate-tokencli/parser.go:ParseCommandcli/handlers.go:handleGetToken, handleRotateToken
dump, restorecli/parser.go:ParseCommandcli/dump.go:handleDump, handleRestore; standalone RunDumpCommand, RunRestoreCommand
help, version, theme, exitcli/parser.go:ParseCommandcli/handlers.go:handleHelp; cli/cli.go:handle, printTheme
show bannercli/parser.go:parseShowcli/banner.go:showBanner; drawn by cli/cli.go:handle
setcli/parser.go:parseSetcli/settings.go:handleSet
batch { ... }cli/parser.go:isBatchStatement; cli/batch.go:parseBatchcli/batch.go:handleBatch; server/batch.go:Server.handleBatch

hoardDB is an independent implementation. HQL’s syntax is JSON-shaped, its wire protocol is its own, and it is not a compatibility layer for any other database. No other database’s tools connect to hoardDB.

Source: docs/user/hql-spec.md in the repository.