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

Store Types

hoardDB has six stores, and you pick the one that matches how you read. In ring mode (hoarddb -ring) a bucket declares fields, and a field can be a store of any of the six types, so every document keeps its own queues, maps and files:

create bucket User { Name string, Photos lifoStore, Purchases fifoStore, Friends hashStore,
    Recipes btreeStore, Books heapStore, Data blobStore }

Quick reference

The word you write in HQL is the store’s name followed by Store: fifoStore, lifoStore, hashStore, btreeStore, heapStore and blobStore. The first column is what goes after the field name.

WriteWhat it isReads backUse it forVerbs that run in ring mode
fifoStorea queue (FIFO)the oldest element firstqueues, logs, event streamspush pop peek list count claim get
lifoStorea stack (LIFO)the newest element firstrecent items, undo, a feedpush pop peek list count claim get
hashStorea mapan element by its keyprofiles, settings, sessionsput get take list count
btreeStorean ordered mapelements by key, in key orderleaderboards, sorted data, time seriesput get take list count
heapStorea priority queueelements by prioritytask queues, alerts, schedulingpop peek list count get
blobStorelarge values and filesthe bytes you storedfiles, images, binary assetscount

Two stores do not run every verb yet: a heapStore has no push and a blobStore has no put in ring mode (Limitations). help types in a ring session always lists the verbs that run on the build you have.

Spell the word exactly. fifo, FIFO, heap and queue are names of the structures, not HQL words, and FifoStore is refused for its capital letter:

[h]oardDB [app]> create bucket Z { A heap }
CLI error: PARSE_ERROR: "heap" is not a type: write one of string, int32, int, double, decimal, number, bool, datetime, bsontimestamp, binary, uuid, objectid, object, array, regex, null, any, or a store: fifoStore, lifoStore, hashStore, btreeStore, heapStore, blobStore ('A string') (character 21). ...
[h]oardDB [app]> create bucket Z { A FifoStore }
CLI error: PARSE_ERROR: "FifoStore" is not a type word: type words are spelled exactly, so write "fifoStore" ('A fifoStore') (character 21). ...
[h]oardDB [app]> create bucket Z { A lifoStore }
OK  created bucket app.Z (1 fields)
Fields: A lifoStore

Getting started walks through each store, and Data types covers the scalar fields that sit beside them.

Buckets that have a type

The rest of this page has not been rewritten for ring mode yet: it describes buckets that are backed by one store type, which ring mode does not have, so read it as a description of how the six structures behave and not as commands that run.

Each bucket is backed by one store type, and a hash or btree bucket can also give each of its documents stores of its own: a field of a bucket can itself be a store of any of the six types, Purchases fifoStore on User being that user’s own queue. The store type determines the data structure and access semantics. Store fields or separate buckets says which to use.

Use each store’s own verbs. push and pop for a FIFO, LIFO or heap bucket; insert and get for a hash or btree bucket; put and get for a blob bucket. The server is permissive in one direction: it also accepts some of another type’s write verbs, and the next section says exactly which, and what each does. It refuses a verb a type has no meaning for, such as pop on a hash bucket: a hash has no order to take from, so the refusal says so and names the verbs that work (delete("<key>") to remove one, get("<key>") or find() to read). pop removes what it returns, so it is a write, not a read.

What each store type accepts

VerbFIFOLIFOHashB+TreeHeapBlob
insert (write one)pushespushesyesyespushes at priority 0stores the document as the payload
insert many (over the protocol; the shell inserts one at a time)yesyesyesyesyesrefused
pushyesyesstores under its _id_stores under its _id_yesrefused
pop, peekyesyesrefusedrefusedyesrefused
claim, ack, release, extend, dead, requeue, discardyesyesrefusedrefusedyesrefused
get, deleterefusedrefusedyesyesrefusedyes
exists (over the protocol; the shell has no exists)refusedrefusedyesyesrefusedyes
find, distinctyesyesyesyesyesrefused
searchfull scanfull scanindex, else full scanfull scanfull scanrefused
count, bucket statsyesyesyesyesyesyes

A refused verb answers in the same call with a message that names the bucket type; nothing is written. These are the surprising ones, each with a command to try:

  • claim/ack are the lossless way to consume a FIFO, LIFO or heap bucket. claim takes entries under a lease, ack removes them, release puts them back, extend moves the deadline, and dead/requeue/discard manage entries that died. A hash, btree or blob bucket has no order to take from and refuses them. A store field is pop-only in v1.0.

  • insert on a FIFO, LIFO or heap bucket pushes. The key (or the document’s _id_) is kept only as the element’s _id_, you read the element back with pop or peek and not by key, and on a heap it goes in at priority 0, so every such element ties. Use push with a priority on a heap.

    db.Jobs.insert({_id_: "j1", Task: "resize"});   -- Jobs is a fifo bucket: this is a push
    db.Jobs.peek();                                  -- the next element, _id_ "j1"
    
  • insert on a blob bucket stores the document itself as the blob. The document is encoded as BSON and kept as the blob’s bytes, with no metadata. It is not the way to store a file: put takes the content (and a type) and records its size and metadata. A blob written with insert reads back as those BSON bytes: in the shell, db.Photos.get("note") reports them as binary (40 B), and db.Photos.get({_id_: "note", out: "note.bson"}) saves them to a file.

    db.Photos.insert({_id_: "note", Caption: "Sunset"});          -- the document becomes the payload
    db.Photos.put({_id_: "sunset", data: "hello photo", type: "text/plain"});   -- the file verb
    
  • push on a hash or B+Tree bucket is a keyed insert. The document is stored under its _id_, or under a UUID the server mints when it has none, exactly as insert would. pop on the same bucket is still refused, because a hash has no order to take from.

    db.Profiles.push({_id_: "jane", Name: "Jane"});   -- Profiles is a hash bucket
    db.Profiles.get("jane");                           -- found
    
  • get and exists on a blob bucket answer, but get is the payload verb in the shell. Over the protocol, GET returns the stored document when the blob’s bytes are BSON, or a small document {_id_, blob_id, size, ...metadata} when they are not; the content itself is read with the blob read verb, which is what db.Photos.get("sunset") sends.

  • search on a bucket with no index scans it. A hash bucket answers from an index when one serves the filter (Indexes); a FIFO, LIFO, B+Tree or heap bucket has no indexes, and a hash bucket whose indexes do not serve the filter has none to use, so the server walks every document and says so in the answer (index_used: "none — full scan"). It costs a read of the whole bucket, the same as find. A blob bucket refuses search.

    db.Leaderboard.search({User: "jane"});   -- Leaderboard is a B+Tree bucket: a full scan
    

Two answers that used to be wrong are now refusals. Over the protocol, exists on a FIFO, LIFO or heap bucket, or with an empty key, is an INVALID_ARGUMENT error (it used to answer exists: false, which means “looked, not there”), and find on a blob bucket answers found: false with a message pointing at get and info. An insert-many on a FIFO or LIFO bucket now reports the count and the ids it pushed (it used to say 0).

Size limits

One document is limited: 24 MiB of encoded bytes by default, which an operator can lower to 4 KiB or raise to at most 30 MiB (HOARDDB_MAX_DOCUMENT_BYTES, see Configuration). A larger document is refused with the limit in the message. Put a list that grows in a nested store field, whose elements are stored separately, and a large value in a blob bucket or behind a pointer document (Large file stores). A blob has its own, smaller cap, described under Blob below. What a bucket and a document are, and every limit, is on the Limits page.


FIFO (First In, First Out)

Append-only queue. Items are pushed to the tail and popped from the head. O(1) push and pop.

When to Use

  • Message queues
  • Event streams and log ingestion
  • Comment threads (chronological)
  • Time-series data
  • Job queues (FIFO processing order)

Operations

-- Push: append to tail
db.Comments.push({Author: "Bob", Text: "Nice photo!"});

-- Pop: remove and return from head (oldest first)
db.Comments.pop();
-- Returns: {Author: "Bob", Text: "Nice photo!"}

-- Pop the next 3, oldest first, in one write
db.Comments.pop({limit: 3});

-- Peek: look at head without removing
db.Comments.peek();

-- Peek the next 3, oldest first, without removing them
db.Comments.peek({limit: 3});

-- Length: number of items
db.Comments.length();

How It Works

Each pushed item gets the next position in the queue, and the queue keeps a head position and a tail position. A push writes the item at the tail; a pop reads the item at the head and removes it, moving the head on. peek() returns one item, the one a pop would return, and peek({limit: N}) the next N in the order N pops would return them, without removing any. A peek reads only what it returns (at most 1000 items), so its cost grows with N. pop({limit: N}) removes and returns the next N in one write (N is 1 to 100); a pop’s answer is the only copy of what it removed, so if your program can lose the answer, pop fewer at a time.

  • Push: one write at the tail, in order
  • Pop: one read at the head and a delete, with no scan of the items already consumed

Leases and dead entries

Every FIFO, LIFO and heap bucket also answers claim, ack, release, extend, dead, requeue and discard. claim takes entries under a lease and leaves them in the store, so a lost answer is a redelivery rather than a loss; ack removes the entries whose work finished; release puts a lease’s entries back at once; extend moves the deadline; dead lists entries that were claimed 5 times without an acknowledgement (or a heap element whose _id_ was pushed again), and requeue/discard retry or drop them. A hash, btree or blob bucket has no order to take from and refuses them. A store field is pop-only in v1.0.

The lease_id that ack, release and extend name is the one claim printed on its last line.

Where It Is Stored

A FIFO bucket is a range of keys in the node’s one store, like every other bucket: it has no directory, files or process of its own. See Where the data lives.


LIFO (Last In, First Out)

Stack. Items are pushed and popped from the same end. O(1) push and pop. It is stored like a FIFO bucket but pops from the tail instead of the head.

When to Use

  • Undo stacks
  • Session state (latest-first access)
  • Recent items (most recent first)
  • Browser history
  • Photo feeds (newest first)

Operations

-- Push: append to tail (same as FIFO)
db.Sessions.push({IP: "192.0.2.1", UA: "Chrome"});

-- Pop: remove and return from tail (newest first)
db.Sessions.pop();
-- Returns: {IP: "192.0.2.1", UA: "Chrome"}

-- Pop the newest 3, newest first, in one write
db.Sessions.pop({limit: 3});

-- Peek: look at tail without removing
db.Sessions.peek();

-- Peek the newest 3, newest first, without removing them
db.Sessions.peek({limit: 3});

-- Length
db.Sessions.length();

FIFO vs LIFO

OperationFIFOLIFO
WriteAppend to tailAppend to tail (identical)
ReadForward from headBackward from tail
PopRemoves the oldest itemRemoves the newest item

The API is identical — the store type determines behavior internally.


Hash (Key-Value)

Key-value table. O(1) average-case lookup, insert, and delete.

When to Use

  • User profiles and accounts
  • Configuration storage
  • Session data
  • Any exact key-value lookup
  • Metadata storage

Operations

-- Insert
db.User.insert({_id_: "jane_doe", Name: "Jane", Email: "jane@example.com"});

-- Find by key
db.User.find({_id_: "jane_doe"});
-- Returns: {Name: "Jane", Email: "jane@example.com"}

-- Find with filter
db.User.find({Email: "jane@example.com"});

-- Update
db.User.update({_id_: "jane_doe"}, {$set: {Name: "Janet"}});

-- Delete
db.User.delete({_id_: "jane_doe"});

-- Count
db.User.count();

How It Works

Each document is stored under its bucket’s key range, keyed by its _id_, as a BSON-encoded value. A lookup by _id_ is one read; a filter on another field scans the bucket unless the field is indexed.

Placement

In this build spread is opt-in. A hash bucket created with no placement is recorded whole for now, until the spread collection work completes; name placement: spread to get a spread bucket. The examples on this page run on either placement; a spread bucket differs in the ways listed below.

A hash bucket records a placement, spread or whole, when you create it. Leave it out and the bucket is whole in this build; name the one you want:

create bucket User { placement: spread };
create bucket Audit { placement: whole };

describe bucket User shows which. Only hash buckets have a placement; a FIFO, LIFO, heap, B+Tree or blob bucket is always whole, and placement: spread on one is refused.

whole keeps the bucket on the nodes that hold it, every document on every copy. Any copy answers a read, and at a replication factor of 2 or more the bucket stays readable while one node is down.

spread divides the bucket’s documents over the cluster. Each document belongs to one of 256 parts, chosen by a hash of its _id_, and each part is an ordinary bucket on the node the ring names for it, at the bucket’s replication factor. A read or write by key — get, insert, upsert, update, delete, a store-field verb, or a keyed BATCH item — reaches the document through any node, which sends it to the document’s part. find, count, distinct, search, describe and dump cover every part and answer as a whole bucket of the same documents does: find returns the same page in the same order, with or without a sort; count and describe the collection’s totals; distinct and search the same values and documents. A spread bucket’s name is at most 250 bytes (Limits), and a dump of it holds the collection, never a part (Backup and Restore).

Every operation on a spread bucket is answered through one node, its router, the node that owns the bucket’s name. While that node is down the bucket cannot be read or written at any replication factor, until it returns or remove-node hands the bucket’s name to a node that holds a copy of it; a whole bucket at factor 2 or more stays readable from its copies (Limitations).

A spread bucket refuses a unique field other than _id_ and an update by filter (multi from a driver). No part can see another part’s values, so neither can be checked. The refusals print the create that works, for example create bucket User in app { placement: whole, Name string, Email string unique }; update by _id_ one document at a time, or create the bucket placement: whole.

A spread bucket’s declaration is fixed when it is created. alter bucket and declaring new store fields are refused. migrate moves its parts with the rebalance rather than as one bucket. A bucket’s placement is fixed too. To change a whole bucket’s placement or declaration, dump it, drop it, create it with what you want and restore it (help placement prints the sequence; a dump does not carry blob payloads, so copy those out first). A spread bucket can be dropped: drop bucket removes every part and then the bucket’s name. From its first step the bucket refuses every other operation, and if a part’s node does not answer the drop is refused partway; run it again once that node returns or is removed. Its placement and declaration are fixed at create, so choose them before you load data; dump it first to keep a copy of the documents.

Where It Is Stored

In the node’s one store, as a key range of the bucket. See Where the data lives.


B+Tree (Sorted Key-Value)

Sorted key-value store. Keys are stored in sorted order, enabling efficient range queries and ordered iteration.

When to Use

  • Leaderboards and rankings
  • Alphabetical or numerical listings
  • Time-range queries on indexed fields
  • Any data requiring ordered iteration

Operations

-- Insert: the key is the sort order. Keys are strings, so write a score
-- to one width with leading zeros ("0092", not "92") to sort numerically.
db.Leaderboard.insert({_id_: "0100", User: "jane"});
db.Leaderboard.insert({_id_: "0085", User: "bob"});
db.Leaderboard.insert({_id_: "0092", User: "alice"});

-- Get by exact key
db.Leaderboard.get({_id_: "0092"});

-- Range query: the bounds apply to the key, written at the top level
db.Leaderboard.range({$gte: "0090"});
-- Returns the entries with key >= "0090", in key order: 0092 then 0100

-- First (lowest key)
db.Leaderboard.first();

-- Last (highest key)
db.Leaderboard.last();

-- Count
db.Leaderboard.count();

How It Works

Keys are stored in an order-preserving form, so the store’s own key order is the bucket’s order. Every key you write through the CLI or the wire is a string, even one that looks like a number, so a range over 1, 2, 10 puts 10 before 2. Write numeric keys with leading zeros to one width (0002, 0010) to get numeric order. A range, first or last lists each key exactly as you wrote it, so a key you put is the key you get back (the CLI shows a key holding a control character, such as a NUL or an escape code, in quotes). A range’s bounds are inclusive, and a low bound above the high bound is an empty range (the CLI prints (no entries in range)), not an error. HQL: B-tree ranges has the full range syntax.

Where It Is Stored

In the node’s one store, as a key range of the bucket. See Where the data lives.


Heap (Priority Queue)

Priority queue. The highest-priority item is always the next one out, and peek shows it without removing it.

When to Use

  • Task queues (most important first)
  • Alert prioritization
  • Job scheduling
  • Any “most important first” access pattern

Operations

-- Push with priority
db.Alerts.push({priority: 10, Msg: "Disk full", Host: "node-1"});
db.Alerts.push({priority: 5, Msg: "High CPU", Host: "node-2"});
db.Alerts.push({priority: 8, Msg: "Network latency", Host: "node-3"});

-- Pop: remove and return highest priority
db.Alerts.pop();
-- Returns: {Msg: "Disk full", Host: "node-1"}

-- Pop the next 2 by priority, highest first, in one write
db.Alerts.pop({limit: 2});

-- Peek: see highest priority without removing
db.Alerts.peek();
-- Returns: {Msg: "Network latency", Host: "node-3"}

-- Peek the next 2 by priority, highest first, without removing them
db.Alerts.peek({limit: 2});

-- Length
db.Alerts.length();

On a heap, priority orders the element and is not stored in the document, so pop() and peek() return the document without it. Keep a copy in another field if you need to read it back. A push with no priority is accepted at priority 0. On every other bucket type, priority is an ordinary field and is stored like any other.

How It Works

Each element is stored under a key that starts with its inverted priority, so the store’s own key order puts the highest priority first. peek() reads the first key, peek({limit: N}) the first N keys in order, and pop reads the first and deletes it (pop({limit: N}) reads and deletes the first N in one write). Entries of equal priority come out in _id_ order, which is push order for ids the server generates; peek and pop always agree on it. There is no in-memory index to rebuild: opening a heap bucket costs nothing, however many elements it holds.

Complexity

OperationCost
Pushone ordered write, O(log n)
Popa read of the first key and a delete, O(log n)
Peeka read of the first key
Lengtha stored count, maintained as items are pushed and popped

Where It Is Stored

In the node’s one store, as a key range of the bucket. See Where the data lives.


Blob (Binary Storage)

Raw binary data storage. Content under 64 KiB is stored inline with its metadata in the node’s shared store; larger content is a file in one payload tree shared by the whole node, not a directory per bucket.

When to Use

  • File storage (images, PDFs, audio, video)
  • Binary assets alongside metadata
  • Any data where raw bytes are the primary content

Operations

-- Put: store a blob under a key. The content is the data string and the
-- rest is metadata. Leave the key out and the server mints a UUID.
db.Photos.put({_id_: "sunset", data: "hello photo", type: "text/plain"});

-- Get: retrieve the content and its metadata
db.Photos.get("sunset");

-- Info: metadata only (no data transfer)
db.Photos.info("sunset");

-- Get, written straight to a file instead of printed (there is no chunked
-- streaming read yet — this still moves the whole blob in one round trip):
db.Photos.get({_id_: "sunset", out: "sunset.txt"});

-- Delete
db.Photos.delete("sunset");

How It Works

  • Content under 64 KiB is stored inline, as part of the blob’s own metadata, in the node’s shared store; only content at or above that boundary becomes a file
  • Blob files, when written, are raw binary on disk — no encoding, no transformation
  • A SHA-256 checksum is stored with the blob’s metadata and checked on every read, so corrupted content is refused rather than returned
  • A blob is capped at 16 MiB by default; an operator can raise the cap to at most 31.9 MiB (HOARDDB_MAX_BLOB_BYTES, see Configuration). A larger blob is refused with the limit in the message. For larger files, keep the file on your own storage and store a pointer document, see Large file stores
  • There is no streaming API: put and get move a blob’s full content in one round trip, buffered in memory on both ends. get({..., out: "path"}) writes the content straight to a file instead of printing it, but does not avoid loading it first
  • References in BSON documents store the blob’s key — the _id_ you pass to put(), or the UUID the server mints for you when you omit one — not the blob data

On-Disk Layout

A blob bucket writes to two node-wide places — never a directory per bucket:

<data_dir>/
├── store/      # every database and bucket: documents, indexes, queue
│               # entries and blob metadata (name, type, size, checksum,
│               # and inline content under 64 KiB), keyed by bucket
└── blobs/      # blob payloads at or above 64 KiB, fanned out by hash —
                # or HOARDDB_BLOB_DIR, if it is set, instead of <data_dir>/blobs

There is no Photos/ directory and no per-bucket index file: every bucket in the database shares the one store, and every blob bucket on the node shares the one payload tree.

Referencing Blobs

BSON documents store a blob’s key — the same string passed to get() and delete() — not the data itself:

{
  "_id_": "0192a3b4-c5d6-7890-abcd-ef1234567895",
  "Caption": "Sunset over the hills",
  "Photos": [
    "0192a3b4-c5d6-7890-abcd-ef12345678a1",
    "0192a3b4-c5d6-7890-abcd-ef12345678a2"
  ]
}

To retrieve the actual image, call db.Photos.get({_id_: "<key>"}) with the key stored in Photos.


Where the data lives

Every bucket, of every store type, and every store field on every document, is a range of keys in the one store of the node that holds it. A bucket has no directory, no files and no process of its own, so creating another costs its catalog entry and the data you put in it, and a node can hold thousands of buckets. The only data kept outside that store is the content of blobs at or above 64 KiB, which is a file in one payload tree shared by the whole node. Limits gives the numbers.

<data_dir>/
├── store/      # every database's every bucket and document store:
│               # documents, indexes, queue entries, heap elements and
│               # blob metadata, keyed by bucket, not by directory
└── blobs/      # blob payloads at or above 64 KiB — or
                # HOARDDB_BLOB_DIR, if set, instead of <data_dir>/blobs

Every store type is equally durable: a node answers a write only once it is on disk, so an acknowledged write survives a crash of the process, a kernel crash and a power cut. A node in a cluster with other members syncs its write-ahead log before it applies the write; any other node, a single node included, syncs its store before it answers, one sync shared by the writes that arrive together. Storage durability has the detail, and the one setting that trades this away for benchmarks.


L1 vs L2 Bucket Decision Guide

A bucket declares its fields, and a field can itself be a store of any of the six types, on each document. Choosing a store type is therefore two choices: which store the bucket is, and which stores each of its documents carries. HQL: store fields shows the syntax.

Levels

  • L1 — a bucket. Like a SQL table or a document-database collection. It has a key of its own, so you can look any of its documents up directly.
  • L2 — a store field of one document: Comments fifoStore on Post gives every post its own comments queue, user.Friends is that user’s own hash. It is addressed through the document, db.Post["p1"].Comments, and it is deleted with the document.
  • L3 — a store on the elements of an L2 field (Comments fifoStore { Flags fifoStore }). Keep it shallow; L1 and L2 cover most designs. A scalar inside those braces is noted as not enforced (and the note names the store field it sits in); a unique inside them is refused, because an index covers the bucket’s own fields only. Put the unique field on the bucket, or give the elements their own bucket.

Decision Rules

DataWhere it goesReason
User’s first namea field of the User documentFixed size, read with the document
User’s friendsFriends hashStore on UserBelongs to one user, looked up by friend id
User’s postsa Post bucketA post has an identity of its own: you list, index and look up posts across users
Post’s commentsComments fifoStore on PostBelongs to one post, read in order
Post’s photosPhotos blobStore on PostBelongs to one post
Post’s captiona field of the Post documentFixed size, read with the document

The Critical Rule

If you will look a thing up, list it or index it on its own, across its owners, it is a bucket. If it only ever exists as part of one document, it is a store field of that document.

If one document’s store field is read so often that its node is the bottleneck, raise the bucket’s replication factor (alter bucket <name> { replication_factor: 3 }) so more nodes hold, and answer reads of, the copy. Do not add a level of nesting: a store field lives inside its document on the same nodes, so nesting spreads nothing.

A store field is reached through its document’s key, so it has no index and find and search do not see it. A Post you want to search by Author belongs in a bucket with indices: ["Author"]; the comments under one post belong in that post. Many small stores are what the design is for: one queue per post, one hash per user.

L1  User                          ← bucket
    └── L2  Friends (hashStore)   ← one per user: db.User["jane"].Friends

L1  Post                          ← its own bucket, one document per post
    ├── L2  Comments (fifoStore)  ← one per post: db.Post["p1"].Comments
    └── L2  Photos (blobStore)    ← one per post: db.Post["p1"].Photos

L2 → L1 Migration

When an L2 bucket has grown too large to live inside its parent, the application can migrate:

  1. Create a new L1 bucket (e.g., Post_Comments_{postId})
  2. Pop all entries from the L2 and push to the new L1
  3. Update the parent’s reference to point to the new L1
  4. Delete the empty L2

Example: PhotoJournal Schema

Two entities, User and Post, each a bucket, and the parts that belong to one document are store fields of it:

create database PhotoJournal;

create bucket User {
    placement: whole,
    Email   string unique,
    Friends hashStore
};

create bucket Post {
    Caption  string,
    Photos   blobStore,
    Comments fifoStore
};

User’s documents carry FirstName, LastName and Email, and Post’s a Caption and the Author’s user id. Email string unique is a unique index on Email, the same as indices: [{field: "Email", unique: true}], and it needs placement: whole (see Placement); it is the one scalar keyword that is enforced, and any other (Caption string) is accepted and noted, not enforced. See indexes.md.

Each user has a Friends hash of their own, and each post has its own Photos and Comments:

[h]oardDB [PhotoJournal]> db.User.insert({_id_: "jane", FirstName: "Jane", Email: "jane@example.com"})
OK  id=jane
[h]oardDB [PhotoJournal]> db.User.insert({_id_: "sam", FirstName: "Sam", Email: "sam@example.com"})
OK  id=sam
[h]oardDB [PhotoJournal]> db.User["jane"].Friends.put("sam", {since: 2021})
OK
[h]oardDB [PhotoJournal]> db.User["jane"].Friends.get("sam")
{"_id_": "01a101bb-59fb-7b53-86f4-2605a706ec60", "since": 2021}
[h]oardDB [PhotoJournal]> db.Post.insert({_id_: "p1", Author: "jane", Caption: "Sunset over the hills"})
OK  id=p1
[h]oardDB [PhotoJournal]> db.Post["p1"].Comments.push({Author: "sam", Text: "Lovely light"})
OK
[h]oardDB [PhotoJournal]> db.Post["p1"].Comments.push({Author: "jane", Text: "Thanks"})
OK
[h]oardDB [PhotoJournal]> db.Post["p1"].Comments.pop()
{"Author": "sam", "Text": "Lovely light", "_id_": "01a101bb-59fc-73c2-88cf-20253a89985f"}
[h]oardDB [PhotoJournal]> db.Post["p1"].Comments.len()
1
[h]oardDB [PhotoJournal]> db.User.search({Email: "jane@example.com"})
Found 1 results (via Email_1):
{"Email": "jane@example.com", "FirstName": "Jane", "_id_": "jane"}

describe bucket Post lists the stores, with their element counts summed over every post:

[h]oardDB [PhotoJournal]> describe bucket Post
BUCKET PhotoJournal.Post
---
Type:               hash
Entries:            1
Logical size:       355 B (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)
  Photos       blobStore  1 element(s)
  Comments     fifoStore  1 element(s)

Photos blobStore is declared the same way. Its put takes the content from the data string and the rest as metadata, and get prints it or writes it to a file with {out: "file"}:

[h]oardDB [PhotoJournal]> db.Post["p1"].Photos.put("ph1", {data: "hello photo", name: "b.txt"})
OK
[h]oardDB [PhotoJournal]> db.Post["p1"].Photos.get("ph1")
Key:    ph1
Blob:   01a101e0-dcc1-7459-a116-4f4b62f44237
Size:   11 B
Metadata: {"name": "b.txt"}
Data:
hello photo

See HQL: store fields. A photo of your own is a file, so the post can also keep the key of a blob bucket’s entry instead, as in Referencing Blobs.

Referential design: Post is its own bucket, so a post points at its author with the author’s user id, and the application resolves it with a get on User. Store type is chosen per field as well as per bucket: a document’s queue, table or heap is declared on it, not kept in a separate bucket that points back. Keep a separate bucket only for something that is its own entity, such as Post, or a dedicated heap of alerts that you read across all users.

Where it is stored: both buckets, and every Friends, Photos and Comments store on their documents, live in the node’s one shared store, as described in Where the data lives. User’s Email index is entries kept alongside User’s own documents, not a file of its own.

Source: docs/user/store-types.md in the repository.