Skip to content

Selectors

You read data by defining selectors: generator functions that describe what to read. Inside a selector you build queries with selectFrom and yield* them. Selectors can call other selectors but can never write; the runtime rejects any mutation emitted from a read.

Every read starts from a named index. That makes the access path explicit in your application code: selectFrom(tasksTable, "byProjectOrder") means “scan this table through this index”, then .where(...), .order(...), and .limit(...) describe the bounds and result shape.

import { selectFrom, v } from "@will-be-done/hyperdb";
import { selector } from "./builders"; // createSelector()
import { tasksTable } from "./schema";
export const projectTasks = selector({
name: "projectTasks",
args: { projectId: v.string() },
handler: function* ({ projectId }) {
return yield* selectFrom(tasksTable, "byProjectOrder")
.where((q) => q.eq("projectId", projectId))
.order("asc");
},
});

Object-form selectors accept:

FieldDescription
nameRequired display/debug name (shown in traces)
argsValidator map for the single args object
handlerGenerator function that does the reading
skipTracetrue, or { rootTrace, childTrace } to skip tracing
memoization{ root?, selfChild? } cache controls (see Reading Data)

You can also wrap a bare generator function instead of using the object form.

selectFrom(table, indexName) returns a builder. You always query through an index. The builder is immutable: each method returns a new builder. With HybridDB, the same indexed read checks the in-memory cache first; missing ranges fall through to the primary store and are cached for later reads.

selectFrom(tasksTable, "byProjectOrder")
.where((q) => q.eq("projectId", "p1"))
.order("desc")
.limit(20);

where takes a callback that receives a query object. Chain comparison methods on it; each column you constrain must belong to the index you selected.

.where((q) => q.eq("projectId", "p1"))
.where((q) => q.eq("projectId", "p1").gte("orderToken", "m"))

Available comparisons:

MethodMeaning
q.eq(col, val)equal to
q.gt(col, val)greater than
q.gte(col, val)greater than or equal
q.lt(col, val)less than
q.lte(col, val)less than or equal

Comparisons apply to index columns, and which combinations are legal depends on the column order of the index. The rules (equality prefix + one trailing range) are explained in detail in Indexes.

To express an OR, return an array of query branches from where, or use the or(...) helper. Each branch is scanned and the results are combined. With .order(...), the combined rows are ordered by the index globally, not by the order of the OR branches.

import { selectFrom, or } from "@will-be-done/hyperdb";
selectFrom(tasksTable, "byProjectState").where((q) =>
or(q.eq("projectId", "p1").eq("state", "todo"), q.eq("projectId", "p2")),
);
// equivalent, returning an array directly:
selectFrom(tasksTable, "byProjectState").where((q) => [
q.eq("projectId", "p1").eq("state", "todo"),
q.eq("projectId", "p2"),
]);

This is the idiom for batched lookups, for example fetching many rows by id in one query:

selectFrom(tasksTable, "byId").where((q) => ids.map((id) => q.eq("id", id)));

When reads go through a SubscribableDB, afterScan lifecycle hooks can observe each successful scan with the table, index, where clauses, select options, and returned rows.

.order("asc") // or "desc"; follows the index's key order
.limit(50) // cap the number of returned rows

Ordering follows the B-tree index’s natural key order; "desc" walks it in reverse. uniqhash indexes are for equality lookups and do not provide ordering.

yield*-ing a query returns an array of rows:

const tasks =
yield *
selectFrom(tasksTable, "byProjectOrder").where((q) =>
q.eq("projectId", projectId),
);

first() returns the first matching row or undefined. firstOr(fallback) returns a fallback instead of undefined.

const task =
yield *
selectFrom(tasksTable, "byId")
.where((q) => q.eq("id", taskId))
.first();
const stateOrDefault =
yield *
selectFrom(tasksTable, "byId")
.where((q) => q.eq("id", taskId))
.firstOr({ id: taskId, state: "todo" } as Task);

Both apply a limit(1) internally, so they stop after the first match.

Selectors call other selectors with yield*, which lets you build larger reads from smaller ones. The runtime tracks the index ranges scanned across the whole tree, so a composed selector stays just as precisely reactive as its parts.

import { selectFrom, v } from "@will-be-done/hyperdb";
import { selector } from "./builders"; // createSelector()
import { tasksTable } from "./schema";
export const projectTasks = selector({
name: "projectTasks",
args: { projectId: v.string() },
handler: function* ({ projectId }) {
return yield* selectFrom(tasksTable, "byProjectOrder")
.where((q) => q.eq("projectId", projectId))
.order("asc");
},
});
const projectDoneTasks = selector({
name: "projectDoneTasks",
args: { projectId: v.string() },
handler: function* ({ projectId }) {
return yield* selectFrom(tasksTable, "byProjectState").where((q) =>
q.eq("projectId", projectId).eq("state", "done"),
);
},
});
const projectSummary = selector({
name: "projectSummary",
args: { projectId: v.string() },
handler: function* ({ projectId }) {
const tasks = yield* projectTasks({ projectId });
const doneTasks = yield* projectDoneTasks({ projectId });
return {
total: tasks.length,
done: doneTasks.length,
};
},
});

In that example, projectDoneTasks should use an index such as byProjectState = ["projectId", "state"] instead of filtering the full project list in JavaScript. Composition keeps code ergonomic, but each selector should still choose the index that matches the data it needs.

To run selectors, cache selector results, or create subscribed sync/async selector stores, see Reading Data.