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

property-sage × hoardDB — a case study

property-sage is a property-management application. Companies manage properties, tenants, leases, expenses, offers and commissions, invoices, chat, support and short-term rentals, and its public pages show vacancy listings. Its data is BSON documents. It is the first application being moved onto hoardDB, and this page records the model the move uses — written as statements hoarddb accepts — and the work the move leaves to the application.

The verbs are the ones on Store types; the store-field syntax is on HQL; indexes are on Indexes; the loading commands are on Load a file.

The model: one bucket per entity, stores on the document

hoardDB has no collection-per-view layer. A bucket holds documents, and a hash or btree bucket can declare store fields, a store of any of the six types on each document. An entity is one document, and its queues, lists and files are its own stores. That is the design this application uses.

create bucket Company {
    type: hash,
    Name string,
    Users hashStore,
    Vendors hashStore,
    Notifications lifoStore,
    Activity lifoStore,
    Audit fifoStore
};

create bucket Property {
    type: hash,
    CompanyID string,
    Name string,
    Expenses lifoStore,
    ExpenseByID hashStore,
    ExpenseDates btreeStore,
    Tickets heapStore,
    Comments fifoStore,
    Photos lifoStore,
    Documents blobStore,
    indices: ["CompanyID"]
};

create bucket Prospect {
    type: hash,
    CompanyID string,
    Stage string,
    Notes fifoStore,
    Activity lifoStore,
    Tasks heapStore,
    indices: ["CompanyID", "Stage"]
};

create bucket User { type: hash, placement: whole, Email string unique, Name string };

create bucket CreemCustomer {
    type: hash,
    CompanyID string,
    Transactions fifoStore,
    indices: ["CompanyID"]
};

create bucket CreemSubscription {
    type: hash,
    CustomerID string,
    Status string,
    indices: ["CustomerID"]
};

Company and Property are keyed by the company’s or property’s id. db.Property["p1"].Expenses is that property’s own expense stack; db.Property["p1"].Documents is that property’s own files. One property’s stores are not another’s. The document carries the declaration, not the data: get and find return only the document’s own fields, not its stores; the interactive terminal names the stores, and an element is read through the store’s address.

indices: is a secondary index: hash buckets only, top-level fields only, declared when the bucket is created, and it serves equality search. It does not sort and does not cover a field inside a store field. So a lookup by value (User by Email, Property by CompanyID) is an index, while a sorted listing (properties by name) is a btree bucket or a btree store field keyed in that order — a second copy.

Reads are peek, and pop removes

pop removes what it returns. A dashboard that refreshes must never call it: the numbers it shows are deleted by the call that shows them. Reads use peek or list, which leave the store alone. peek({limit: N}) returns the next N in the order pop would remove them, N is at most 1000, and peek() returns one element. pop({limit: N}) removes the next N in one write, N is at most 100, and the answer is the only copy of what it removed. list reads in storage order, oldest first; on a lifo that is the reverse of the order pop removes.

ViewhoardDB
Latest expenses added to a propertydb.Property["p1"].Expenses.peek({limit: 20})
Activity feed, newest firstdb.Company["c1"].Activity.peek({limit: 50})
Comment thread, oldest firstdb.Property["p1"].Comments.list({limit: 100})
Next ticket to handledb.Property["p1"].Tickets.peek()
One expense by iddb.Property["p1"].ExpenseByID.get("<expenseID>")
Expenses for a monthdb.Property["p1"].ExpenseDates.list({after: "2026-03-01/", limit: 100}), stopping when the date leaves the month
User by emaildb.User.search({Email: "jane@example.com"})
Audit logdb.Company["c1"].Audit.list({limit: 100})
One property’s documentsdb.Property["p1"].Documents.put(...) and .get("lease.pdf")
Import job worker queuea bucket-level fifo queue, consumed with claim/ack

A heap orders by priority: db.Property["p1"].Tickets.push({priority: 3, Subject: "Boiler"}) puts the highest priority first, and peek returns it without removing it. A heap bucket does not store priority in the document; a heap store field keeps it in the element, so the screen can show it.

A btree store field has no range verb. A key there is written <date>/<expenseID>, and list({after: "2026-03-01/", limit: N}) continues in key order from that key; the application stops when the date part leaves the month. Two bounds over a large set belong in a btree bucket keyed by date:

create bucket ExpenseByDate { type: btree };
-- key each document "2026-03-14/<expenseID>"; read with:
-- db.ExpenseByDate.range({$gte: "2026-03-01", $lte: "2026-03-31"})

A worker queue that must survive a crashed worker is a bucket-level queue, not a store field: a store field can only consume with pop, which removes the element, while claim/ack are the lossless loop on a bucket.

Each copy is an extra write

Three views of one expense need three places to put it. The same document goes to each; replace <expenseID> with the expense’s id:

db.Property["p1"].Expenses.push({Amount: 120, Category: "boiler", Date: "2026-03-14"});  -- newest-first feed
db.Property["p1"].ExpenseByID.put("<expenseID>", {Amount: 120, Category: "boiler", Date: "2026-03-14"});  -- the record, by id
db.Property["p1"].ExpenseDates.put("2026-03-14/<expenseID>", {Amount: 120, Category: "boiler", Date: "2026-03-14"});  -- month report

The copies are not free. A LIFO, FIFO or heap element has no key: get and delete are refused on it. So the queue can log an expense being added, but it cannot be the record you correct. A store field has no update verb at all: a hash or btree store field is written again with put under the same key. The hash copy is the current document, and it is what put and delete name; the btree copy is rewritten by key. A corrected amount is db.Property["p1"].ExpenseByID.put("<expenseID>", {Amount: 95, Category: "boiler", Date: "2026-03-14"}) on the hash copy, the same put on the btree copy, and a new element in the queue (or a rebuilt queue), and a deleted expense leaves its queue element behind until the queue is rebuilt. Treat the queue as a feed of events and the hash copy as the record, because hoardDB reconciles no copy for you. Where a lookup only needs equality on a top-level field, an indices: entry serves it without a copy; a sorted listing and a bounded date range cannot.

Billing

The application keeps billing in three Creem collections: creem_customers, creem_subscriptions and creem_transactions. They map to the buckets above. A customer is a CreemCustomer document keyed by the Creem customer id, found by company through its CompanyID index. Its transactions are an append-only log, so they are the customer’s own Transactions fifo store, written with push and read with list. A subscription is its own CreemSubscription document keyed by the Creem subscription id, because a billing event names the subscription and not the customer; its CustomerID index finds a customer’s subscriptions. That is a deliberate duplicate of the customer link: the application writes the subscription document and keeps Status in step with what Creem reports. A transaction is not looked up by id here; if it must be, it needs a hash copy like the expense in the next section.

Migration with hoarddb ingest

ingest loads documents; it does not design the model. What it does:

  1. One .bson (or JSON Lines) file becomes one bucket. A directory loads with --into-db, one bucket per file, named after the file:

    hoarddb ingest ./dump/app --into-db app
    

    A single file needs --into, for example hoarddb ingest properties.bson --into app.properties. A file whose name contains a dot (events.2024.bson) is refused without --allow-dots.

  2. The default bucket type is hash; --type chooses another type for the whole run, and it never changes a bucket that already exists.

  3. It reports the indexes in a <name>.metadata.json beside a file and creates none. To have one, create the bucket first with indices:, then run the same command again — ingest fills a bucket that exists.

  4. It does not analyse a dump, recommend a store type, or split a collection by company or property. Splitting is application work.

So the move has two steps. First load the collections as they are, then run an application migration that creates the buckets above and moves each document into its entity and its store fields. A store field exists only after its document does, so the entity document is written first and its elements are pushed, put or deleted through db.<bucket>["<key>"].<Field>.<verb>. Re-running ingest over a keyed bucket is safe (same documents, same keys); over a fifo or lifo it appends a second copy.

What stays the same, and what is not claimed

The move is designed to leave the outer layers alone. BSON stays the format end to end: dates, object ids and Decimal128 values keep their types (field order may change), and documents keep their ids. The Go API layer keeps its shape and the Vue frontend keeps its API contract, so the storage calls change beneath an unchanged API.

No benchmark of property-sage on hoardDB has been run, so this page makes no performance claim. The hash ring places a bucket on a node; it is not a cache. The goal is to run property-sage on hoardDB as the first real deployment target: dogfooding a real workload, proving the Go client against a real backend, and publishing what the move cost. Whether the model is faster than the current database is a question for a measurement that has not been made yet, not for this page.

Source: docs/user/case-study-property-sage.md in the repository.