Queries & live data
Read once. Or keep it live.
Section titled “Read once. Or keep it live.”Create a client with your schema, the Worker URL, a workspace identifier and a token callback. Your application provides these connection values.
import { createClient } from "tapedb";import schema from "./tapedb.schema";
const db = createClient({ schema, url, workspace, getToken });
const open = await db.task.findMany({ where: { done: false, due: { lt: Date.now() } }, orderBy: { due: "asc" }, take: 50, include: { column: { include: { board: true } } },});findMany returns every matching row unless you pass take; take and findPage sizes have no ceiling. Wide reads run as bounded statements under the hood: keyset chunks of 1,000 rows, with includes hydrated 1,000 parents at a time.
One row, a count, a page
Section titled “One row, a count, a page”const one = await db.task.findUnique({ where: { id } }); // row or null
const total = await db.task.count({ where: { done: false } });
const page = await db.task.findPage({ orderBy: { due: "asc" }, take: 50 });// { rows, hasMore, cursor } — pass cursor back as `after`
const first = await db.task.findFirst({ where: { done: false } }); // first match or nullPagination is keyset, so an insert above the window cannot repeat a row.
Select and include
Section titled “Select and include”select returns only the fields you name, and relations named in it are included with their own select:
const titles = await db.task.findMany({ select: { title: true, column: { select: { name: true } } },});// { title: string; column: { name: string } | null }[]The engine reads and sends only those columns, plus the row’s id, which live updates key on. A live select is only pushed a change when a selected field changed. select and include are not combined at one level.
Aggregates
Section titled “Aggregates”Aggregates take the same where and return only the measures asked for:
const stats = await db.task.aggregate({ where: { done: false }, _count: true, // or { _all: true, due: true } for non-null counts _sum: { points: true }, _avg: { points: true }, _min: { due: true }, _max: { due: true },});
const perColumn = await db.task.groupBy({ by: ["columnId"], _count: true, _sum: { points: true }, orderBy: { columnId: "asc" }, take: 20,});They run as one GROUP BY statement over the rows the caller may read. A filter() read rule is compiled into it; a model whose read rule is a function is folded row by row after the rule runs. Counts and aggregates are live with subscribe.
Filters
Section titled “Filters”Filters take not, in, notIn, gt, gte, lt, lte, and AND / OR / NOT over whole filters.
A to-one relation takes a filter on the row it points at; a to-many relation takes some, every or none:
await db.column.findMany({ where: { board: { archived: false } } });await db.board.findMany({ where: { columns: { some: { tasks: { none: {} } } } } });await db.task.findMany({ where: { column: null } }); // no parentThese compile to correlated subqueries, so linked rows are never fetched. The linked model’s read rule is compiled into the subquery; a model whose read rule is a function is refused as a filter target. Types follow one link; further hops are checked by the engine. Relation filters nest until SQLite’s expression depth, about eight hops; wide filters are not limited.
Everything the engine decides (permissions, a missing row, a conflict) surfaces when you await the query, or in a live query’s error.
A query becomes live when you subscribe to it:
const stop = db.task .findMany({ where: { done: false }, orderBy: { due: "asc" } }) .subscribe(({ data }) => renderTasks(data));
// When the view is disposed:stop();After a commit, only subscriptions whose query touches a changed model re-run, batched at 50 ms, and clients receive row-level diffs. Counts and aggregates stay live too. A live select is only pushed when a selected field changed.
React bindings (useLive, useLivePage, usePresence, useBroadcast) are documented in React.