Indexes
Every query runs through an index — HyperDB never does a full table scan. Indexes are declared on the table and determine which filters and orderings are possible.
Declaring indexes
Section titled “Declaring indexes”defineTable("tasks", { id: v.string(), projectId: v.string(), state: v.union(v.literal("todo"), v.literal("done")), orderToken: v.string(),}) .index("byProjectOrder", ["projectId", "orderToken"]) // composite btree .index("byState", ["state"], { type: "hash" }); // single-column hashThe built-in byId hash index on id is always present — you never declare it.
B-tree vs. hash
Section titled “B-tree vs. hash”| B-tree (default) | Hash | |
|---|---|---|
Equality (eq) | ✅ | ✅ |
Range (gt/gte/lt/lte) | ✅ | ❌ |
order("asc" | "desc") | ✅ | ❌ |
| Composite (multi-column) | ✅ | ❌ (exactly one column) |
Reach for a hash index when you only ever look up a column by exact value; use a btree index when you need ranges, ordering, or multi-column keys.
What can be indexed
Section titled “What can be indexed”Index columns must be indexable value types
and must exist in the schema. Index definitions are validated at defineTable
time, so an illegal index throws immediately rather than failing at query time.
Querying a composite index
Section titled “Querying a composite index”A composite B-tree index stores rows ordered by its columns left to right, like a
phone book ordered by (lastName, firstName). That ordering dictates which
filters are valid. Two rules:
- Equality prefix. You may constrain a column only if every column
before it in the index is constrained by equality (
eq). - One trailing range. After the equality prefix, you may apply a single
range (
gt/gte/lt/lte) on the next column. You cannot range on a column and then constrain a later one.
For byProjectOrder = ["projectId", "orderToken"]:
// ✅ equality on the prefix.where((q) => q.eq("projectId", "p1"))
// ✅ equality prefix + range on the next column.where((q) => q.eq("projectId", "p1").gte("orderToken", "m"))
// ✅ equality on both columns.where((q) => q.eq("projectId", "p1").eq("orderToken", "m"))
// ❌ skips the prefix: orderToken constrained without eq on projectId.where((q) => q.gte("orderToken", "m"))Things that throw
Section titled “Things that throw”The query builder validates bounds when the query is constructed and will throw for:
- A non-prefix column — using a column without
eqon all preceding columns (Cannot use column 'X' without specifying eq conditions for all preceding columns). eqmixed with a range on the same column — e.g.eq("c", 1).gt("c", 0)(Conflicting conditions for column 'c').- Two equality conditions on one column (
Multiple equality conditions). - A column that isn’t in the index (
Column 'X' not found in index). - No usable conditions at all.
Ordering with indexes
Section titled “Ordering with indexes”Because a B-tree index is physically ordered, order("asc") returns rows in the
index’s key order and order("desc") returns them reversed — no separate sort
step. Choose your index column order to match how you want to read the data. For
the byProjectOrder index, tasks come back ordered by orderToken within a
project for free.