Indexes
Generated secondary indexes and find_by_* / get_by_* probes — single scalar, foreign-key, composite, nullable, and ordered range indexes — with honest notes on the exact-match hash limit.
ForgeDB generates an in-memory secondary index and typed probe methods for each field you mark, so a lookup by that field is an index hit, not a table scan. The maps are generated per model over the existing storage; nothing reads your schema at runtime.
Declaring indexes#
Mark a field with the ^ index modifier (or & for a unique index) in your schema:
User {
id: +uuid
email: &string // unique index
status: ^string // secondary index
created_at: ^timestamp
}Single-scalar probes#
Each indexed scalar field gets find_by_<field> (returns all matches) and get_by_<field>
(returns the first), plus _at variants for snapshot reads:
let admins = db.user.find_by_status("admin"); // O(1) index lookup, not a scan
let user = db.user.get_by_email("a@b.com"); // Option<User>
// Point-in-time variants resolve the snapshot's version:
let snap = db.user.snapshot();
let past = db.user.find_by_status_at(&snap, "admin");The map is kept in step with the durable write boundary. An insert adds the id under
its value, an update removes the old value and adds the new one, and a delete drops it.
The update path is aware of the superseding version — the new committed row that
replaces the old one — so a stale bucket never lingers. On reopen the index is rebuilt
inside the same id-scan that rebuilds id_to_row, reading only the indexed columns
rather than making a separate pass. A probe is an O(1) map hit whose candidate ids
resolve through the version-aware read path, so it sees exactly the committed rows a
scan would.
Foreign-key indexes#
Foreign-key scalar fields (*Target required, ?Target optional) are always indexed —
a reverse one-to-many getter that would otherwise scan always exists. The generated reverse
getters probe the FK index instead of scanning:
let posts = db.user_posts(user_id); // O(matches) FK-index probe, not O(rows)When a child has multiple FKs back to one parent, the getter disambiguates by child field
(e.g. user_posts_by_author).
Composite indexes#
Declare a model-level @index(a, b) to index a combination of fields:
Event {
id: +uuid
tenant: ^uuid
status: string
@index(tenant, status)
}This generates find_by_<a>_and_<b> (and _at), keyed on the combination.
Composite hash indexes answer exact-match only
A composite index answers a = ? AND b = ?. It does not answer a prefix or a range over
its components — that is a B-tree feature and out of scope for the hash index.
The composite key is a length-prefixed concatenation of the component keys: each part is
written with its byte length ahead of it, so ("ab","c") and ("a","bc") produce
distinct keys and never collide in the shared map. The parts join in declared order into
one opaque hash key, which is why the index answers only the full a = ? AND b = ?
equality — there is no order within the key to walk for a prefix or a range. That makes
composite exact-match by construction, not by omission.
Nullable indexes#
Nullable scalar fields are indexable. Probe parameters are Option<T> (Option<&str> for
strings), so find_by_<field>(None) probes the unset bucket:
let unassigned = db.ticket.find_by_assignee(None);The index key is null-distinct and type-tagged, so None never collides with the literal
string "null".
Ordered / range indexes#
Hash indexes are exact-match only. For ordered range and top-N queries, an
ordered-eligible non-nullable indexed field also gets a parallel ordered (B-tree) index keyed
by the typed value. Eligible types are the Ord keys: u32, u64, i32, i64,
timestamp, and decimal.
Post {
id: +uuid
views: ^u64 // ordered-eligible → gets a range index
}This generates find_by_<field>_range:
// WHERE views >= 100 ORDER BY views DESC LIMIT 10
let top = db.post.find_by_views_range(Some(100), None, /* descending */ true, /* limit */ Some(10));Ordered-index exclusions
f64 is excluded (no clean total order). Nullable ordered indexes are deferred. Decimal
keys are normalized (scale-invariant), so 1.0 and 1.00 share a bucket.
A hash index keys by a tagged string, and string order is not value order, so it can serve
equality but never a range or an ordered top-N. The ordered index is a second, parallel map
— a BTreeMap keyed by the field's typed value — that sorts in value order, so
find_by_<field>_range walks it in O(offset + limit). It is added alongside the hash index,
not in place of it: the hash path still serves &unique, FK, and reverse-FK lookups, and the
extra per-write B-tree insert is negligible against the fsync barrier on the write path.
Eligibility is purely by field type; there is no directive to opt in or to choose between the
two.
Limits#
- Hash indexes are exact-match only — no prefix or range on the hash path; use an ordered index (above) for ranges over eligible types.
- A read-only reader handle shares the index maps by reference; the writer mutates a copy-on-write copy.
jsonfields are never indexable, filterable, or sortable (JSON has no total order).
Indexes also power the REST list endpoint's filter pushdown — see
Live queries & change feed for the shared closed-set matcher
and Point-in-time reads for the _at snapshot probes.