Read data
A query is a typed description of a read — record type, filters, order, fields — built as a value you can run once, live, or in the past (glossary). This page is for anyone who has written some records and wants them back, shaped for a screen.
A query is a value
Section titled “A query is a value”Reef’s whole board is one query, built once at module scope:
export const boardQuery = Ramose.query(Issue) .orderBy(Issue.rank, "asc") .select(boardShape);Ramose.query(Issue) means “every issue”; everything after it narrows or shapes that set. Because it is a value, the same boardQuery runs once, live, or as the database was at an earlier version — unchanged.
Run it
Section titled “Run it”db.q(query) runs it once. Reef looks up your own user row this way when you enter a workspace:
const mineQuery = Ramose.query(User) .where(User.sub.eq(me.id)) .select({ id: User.id }); const existing = yield* db.q(mineQuery);In React, useQuery(db, query) gives you { data, error, loading } for a one-shot read. Hand the same value to useLive and it becomes a live query, one that re-runs itself when the database changes (glossary) — that is the next page. Outside React, await Effect.runPromise(db.q(query)).
Filter
Section titled “Filter”Every field carries its own predicates. where takes any number; all must hold:
const CommentP = Ramose.params({ issueId: Issue.id });export const commentsQuery = Ramose.query(Comment, CommentP) .where(Comment.issue.is(CommentP.issueId)) .orderBy(Comment.at, "asc") .select(commentShape);| on | predicates |
|---|---|
| any field | eq ne lt lte gt gte in([…]) exists() missing() |
| strings | startsWith endsWith includes matches (case-sensitive; matches takes a string or a RegExp with no flags) |
| references | is(id) — points at this record; hop through it and keep filtering: Issue.assignee.name.startsWith("A") |
| many-valued fields | some(p) every(p) none(p) — quantify a predicate over the values; for many-valued scalars the value cursor is .each (field.each.startsWith("a")) |
Absence is not a value: eq and the comparisons need the fact to be there, so ask with exists() or missing() when absence is the question. Ramose.or(…) and Ramose.not(…) combine predicates; a reference reads backwards with .reverse — Issue.creator.reverse from a user is “the issues that point at me” (glossary). That backlink is many-valued (an array in a shape). The exception is a { isComponent: true } reference: its reverse is one record, because a component has at most one owner.
Choose the fields
Section titled “Choose the fields”select decides both the rows you get and their TypeScript type — the shape (glossary). Reef’s board shape follows two references and one set:
export const boardShape = { id: Issue.id, title: Issue.title, status: Issue.status, priority: Issue.priority, rank: Issue.rank, createdAt: Issue.createdAt, creator: Issue.creator.select(personShape), assignee: Issue.assignee.select(personShape).optional, labels: Issue.labels.select(labelShape),} as const;- A field without
.optionalis required: an issue with notitleis dropped from the results. .optionalkeeps the row and types the field| undefined—assigneemay be unset..orDefault(v)keeps the row and reads a missing card-one scalar asv. Not stackable with.optional. A field a policy hides from you still has to be.optional.Issue.creator.select(personShape)follows the reference and returns{ id, name }inline; a many-valued reference (labels) returns an array. One round trip, not one per row.
The row type is inferred: Ramose.Row<typeof boardQuery> is one row, Ramose.Rows<…> the array; change the query and every consumer’s type moves with it. id: Issue.id is a plain number at runtime (key={row.id} in React), typed Ramose.Eid<typeof Issue> — branded with its namespace, so it feeds the next query or pull with no cast and an Issue id never passes for a Person id. A query with no .select returns { id } objects. Ramose.all(Issue) is the wildcard: every attribute the matched record has, keyed by name (":issue/title"), not by the keys you chose. The same term nests under a reference: Issue.creator.select(Ramose.all(Person)) is that person’s wildcard row.
When the same card repeats under itself — a comment thread, an org chart — write ref.select(Ramose.again(n)) on that edge. n is a hop bound you name (1 through 16): the same shape, again, that many full hops, then a stub { id }. A many-valued again edge needs an explicit .limit(n) (the server will not pick a default width for you). The shape must include N.id; a cycle or a hop that runs out of budget comes back as that id, still in the array, not dropped.
.select({ id: Comment.id, body: Comment.body, replies: Comment.replies .where(Comment.deleted.eq(false)) .orderBy(Comment.createdAt, "asc") .limit(50) .select(Ramose.again(4)),})A tree is a window
Section titled “A tree is a window”Without again, the inner .select is a thinner card and then it stops. Nested replies are not included unless you listed them:
.select({ id: Comment.id, body: Comment.body, replies: Comment.replies .where(Comment.deleted.eq(false)) .orderBy(Comment.createdAt, "asc") .limit(20) .select({ id: Comment.id, body: Comment.body }),}){ "id": 1, "body": "What should we ship?", "replies": [{ "id": 2, "body": "The window first." }]}again(n) re-applies the enclosing map, including replies, n full hops, then { id } stubs. Same roots, deeper tree. again(1) is one full-shape hop then stubs; again(4) is four full hops then stubs.
.select({ id: Comment.id, body: Comment.body, replies: Comment.replies .where(Comment.deleted.eq(false)) .orderBy(Comment.createdAt, "asc") .limit(20) .select(Ramose.again(1)),}){ "id": 1, "body": "What should we ship?", "replies": [ { "id": 2, "body": "The window first.", "replies": [{ "id": 3 }] } ]}A Reddit thread is a window, not one fetch. again(4) plus .limit(20) on replies is the first paint. The stub { id } is the “continue this thread” handle: the same module-scope query, rebound at the stub.
const P = Ramose.params({ root: Comment.id });const thread = Ramose.query(Comment, P) .where(Comment.id.is(P.root)) .select({ id: Comment.id, body: Comment.body, replies: Comment.replies .where(Comment.deleted.eq(false)) .orderBy(Comment.createdAt, "asc") .limit(20) .select(Ramose.again(4)), });// useLive(db, thread, { root: postId }) first paint// useLive(db, thread, { root: clicked.id }) continue this threadSibling “load more” pages that parent’s replies. It is not a deeper again.
A recursive T with replies: T[] would type-check .body on a stub — after a cycle, or after the budget. That is the lie. A literal bound makes replies[0].replies[0].body a type error on purpose (again(1): one full card, then { id }). An unbounded form would be T | { id } and a narrow. That is parked: a thousand-wide collection and no hop cap is how a shape melts — not because TypeScript cannot recurse.
Which records can reach which is a set of ids. again paints a tree. Graph walks are later.
Order, limit, page
Section titled “Order, limit, page”orderBy, limit, and offset run on the server: rows are sorted, then paged, then shaped — so limit(20) returns twenty records and the client never sees the rows a page dropped. limit bounds what you receive, not what the query scans. Required fields are enforced before the limit, so the page you get is the page you keep.
// Todo is the todos example's record type. .orderBy(Todo.createdAt, "asc", { empty: "last" }).limit(20).offset(0);empty: "first" | "last" places rows that have no value for the sort field; a missing fact is not a null.
.one() and .oneOrFail() unwrap that page to a single row, and they ask the server for one (or two) rows rather than pulling a page and discarding it.
const ada = Ramose.query(User).where(User.email.eq("ada@example")).one().select({ name: User.name,});// { name: string } | null — null when nobody matches
const theAda = Ramose.query(User) .where(User.email.eq("ada@example")) .oneOrFail() .select({ name: User.name });// { name: string } — fails with NotOne if there are zero or two matches.one() is at most one: two matches are not an error, and the rest are never fetched. .oneOrFail() is exactly one, and NotOne is not a DbError — the query succeeded, the count did not. When you already know the id, pull (next) is the other door.
.after(cursor) pages by position: the result is { rows, cursor }, and the next call (pass null first) returns the rows strictly after that last row. A row inserted before the cursor never shifts later pages — offset cannot promise that. The cursor is opaque (the last row’s sort keys, record id as tie-breaker) and meant to be held in memory, not serialized.
Count and group
Section titled “Count and group”When the answer is a number, not rows, end with .count(), .countDistinct(field), .sum(field), .avg(field), .min(field), .max(field) — several at once with .aggregate({ … }), one row per group with .groupBy({ … }).aggregate({ … }). The work runs on the server.
const open = yield* db.q(Ramose.query(Todo).where(Todo.done.eq(false)).count()); // numberconst perOwner = yield* db.q( Ramose.query(Todo).groupBy({ owner: Todo.owner }).aggregate({ n: Ramose.count() }),);A count is a number; avg / min / max are | null over no rows (count / sum answer 0); a grouped row is your keys plus your aggregates. Aggregates replace the rows, so they do not combine with .select or paging. Rules: Client API.
Read one record
Section titled “Read one record”When you know which record you want, pull it — one record by id or unique key, with a shape (glossary). Reef’s issue panel does this for the selected issue:
const extra = usePull(db, { id: issueId }, issueExtraShape).rows ?? null;export const issueExtraShape = { title: Issue.title, description: Issue.description.optional, // Read-masked for member/viewer (policy.ts): must be `.optional`, so for // them the row survives and the field is simply absent. privateNote: Issue.privateNote.optional,} as const;The subject is { id } or a lookup by unique key, [User.sub, "user_ada"]. The result is the shape or null — null when the record is missing or a required field is absent (including one permissions hid from you). Outside React it is db.pull(subject, shape).
src/app/components/IssueDetail.tsx:255-266 Read the past
Section titled “Read the past”Every query runs unchanged against an earlier version of the database. Reef’s time-travel slider is useQuery on db.asOf(t); its “deleted, still in history” strip is the same idea over db.history:
const past = useQuery(t === undefined ? db : db.asOf(t), boardQuery); const everything = useQuery(db.history, everyIssueEverQuery);t is a version number, not a date (glossary): the t a write returns, or useBasis(db) for the current one. Both views are read-only. How far back, what history includes, and the rest of the slider: Time travel.
Rules the builder enforces
Section titled “Rules the builder enforces”- A select field is a direct field of the queried record type or a nested
ref.select({…}); a hop in a select ({ creatorName: Issue.creator.name }) is a type error and a runtime error. Ramose.again(n)is a shape on a self-reference (ref.select(Ramose.again(n))), not a field and not the query’s own.select. The bound is a literal1–16. A many-valued again edge needs.limit(n). The shape must selectN.id.orderByacross a many-valued hop throws, and so doesorderBy(field.each)— sort by a single-valued field..eachonly works inside its own collection’ssome/every/noneor per-element constraints.- Everything runs on the server, under a memory limit per query; exceeding it fails with
QueryBudgetExceeded— narrow the query. See Query budget.