Skip to content

Queries & live data

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.

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 null

Pagination is keyset, so an insert above the window cannot repeat a row.

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 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 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 parent

These 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.