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");

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.

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.

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.
  • json fields 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.

Search documentation

Find pages across the ForgeDB docs