Client API
Every exported name of ramose/db, with its signature and a short example. This page is for people who have built the first app and want the exact shape of every call to the Ramose server. The Ramose server is the one Cloudflare Worker that serves all your databases; Ramose’s code calls it the peer.
New here? Start with Getting started.
Rules of thumb
Section titled “Rules of thumb”- A
dbis a value.ramose.db(name, schema)makes no request, andasOfandhistoryare pure too. - A write is all-or-nothing — one write is one all-or-nothing change; Ramose calls it a transaction (glossary). It returns a
TxReport;report.dbAfterreads your own write with no second round trip. - Reads shrink, writes fail. Fields you may not read are absent; a write you may not make is
Unauthorized. - Live queries need a WebSocket.
db.liveanddb.livePullretry network trouble on their own. - Setup mistakes throw, they are not errors. A malformed URL or missing
fetchthrows fromconnect; aDbErroris always about a request. - Install is explicit.
db.install()writes the schema and is safe to run twice; naming a database never creates one.
Ramose is built on Effect. In React you rarely see it — the hooks run it for you; inside db.transact you write yield*. (Effect in five minutes)
Imports
Section titled “Imports”ramose/db is the portable half: browser, Worker, Node, tests. ramose re-exports all of it and adds the deploy half.
import * as Ramose from "ramose/db"; // schema, connect, db, query, errorsimport * as Ramose from "ramose"; // the same, plus Server, Database, Policy, authEnv, claims| entry | runtime names |
|---|---|
ramose/db | Attr · Namespace · Catalog · Instant · Uuid · UuidString · Ref · Long · Bytes · query · or · not · all · connect · layer · Databases · token · DATABASE_NAME_RE · isDatabaseName, the eight DbErrors below, and NotOne |
ramose (adds) | Server · Database · ReadWriteDatabases · ReadDatabases · ServerBinding · ServerHttp · providers · Providers · Policy · authEnv · internalSecret · AUTH_ENV_KEYS · DEFAULT_JWT_MAX_TTL · claims — see Deploy and Policy |
Two more entries carry the server Worker and the React hooks: ramose/worker is what main points at (Deploy), and ramose/react is the hooks (React). Sign-in adds ramose/better-auth and ramose/better-auth/client (Sign in).
Effect comes with Ramose
Section titled “Effect comes with Ramose”effect is a dependency of ramose, not something you install beside it, so import * as Schema from "effect/Schema" resolves in a project whose only dependency is ramose. That is deliberate: Effect types cross Ramose’s public API — a Db is parameterised by your schemas, a live query is a Stream — so two copies of effect in one tree are two mutually unassignable sets of types, and nothing typechecks. Declare effect in your own package.json only at a version that agrees with Ramose’s.
Two subpaths let you avoid naming it at all, which is what a resolver that refuses undeclared imports (pnpm without hoisting, Yarn PnP) needs:
import * as Schema from "ramose/schema"; // === effect/Schemaimport { Effect, Layer, Stream } from "ramose/effect";They re-export the module instances effect itself exports, so the two spellings are interchangeable — in the same file, and across a package boundary.
Schema
Section titled “Schema”The schema — your data model as one TypeScript value; Ramose calls it a catalog (glossary) — is built from three constructors.
import * as Ramose from "ramose/db";import * as Schema from "effect/Schema";
export const Todo = Ramose.Namespace("todo", { title: Ramose.Attr(Schema.String), done: Ramose.Attr(Schema.Boolean), createdAt: Ramose.Attr(Ramose.Instant),});
export const Todos = Ramose.Catalog({ todo: Todo });Ramose.Namespace(name, fields) — a record type, a named group of fields like a table (glossary). The result carries every field as a handle (Todo.title, full name :todo/title) plus Todo.id, the record’s numeric id (glossary), usable in where, select and orderBy.
Ramose.Attr(schema, options?) — one field (glossary).
Ramose.Attr(Schema.String) // one stringRamose.Attr(Schema.String, { unique: "identity" }) // a unique key: look records up by itRamose.Attr(Ramose.Ref(() => Label), { cardinality: "many" }) // a set of references| option | values | default |
|---|---|---|
cardinality | "one" · "many" | "one" |
unique | "identity" · "value" | — |
index | boolean | true when unique is set, else false |
isComponent | boolean | false |
doc | string | — |
valueType | a :db.type/* name | inferred; required for a custom Schema |
Ramose.Catalog({ key: Namespace, … }) — the whole schema. The key (todo) is what a policy’s ns: { todo: … } refers to. Type-level helpers: Ramose.Catalog.Any (the bound for generic code), typeof Todos.
Value helpers — plain Schema.String, Schema.Number (stored as a double) and Schema.Boolean need no helper. For everything else:
| helper | TypeScript value | stored as |
|---|---|---|
Ramose.Instant | Date | instant |
Ramose.Long | a whole number (a JavaScript double, so nothing above 2⁵³ is exact) | long |
Ramose.Bytes | Uint8Array | bytes |
Ramose.UuidString | canonical uuid string | uuid |
Ramose.Uuid | { vt: 6, v: string } (not a string, not bytes) | uuid |
Ramose.Ref(() => User) | number (the target’s id); enables Todo.owner.name and .reverse | ref |
Ramose.Ref.self | a reference to the same record type | ref |
Ramose.Ref (bare) | number, untargeted | ref |
priority: Ramose.Attr(Ramose.Long), // … createdAt: Ramose.Attr(Ramose.Instant), creator: Ramose.Attr(Ramose.Ref(() => User)), assignee: Ramose.Attr(Ramose.Ref(() => User)), labels: Ramose.Attr(Ramose.Ref(() => Label), { cardinality: "many" }),Client
Section titled “Client”Ramose.connect(options): Client — the client talks to the server for one page and hands out db handles (glossary).
interface ClientOptions { url: string; // the Ramose server's base URL token?: TokenSource | Effect<Redacted<string>, DbError>; fetch?: typeof fetch; // defaults to the ambient fetch webSocket?: typeof WebSocket; // defaults to the ambient WebSocket; needed for live}interface Client { db<C>(name: string, catalog: C): Db<C>; // pure: no request close(): Promise<void>; // safe to call twice; reads fail after it} const token = Ramose.token.jwt(() => authClient.ramose.token({ db: slug })); const cls = ((await token.claims()).ramose?.class ?? "viewer") as RamoseClass; const ramose = Ramose.connect({ url: RAMOSE_URL, token });Ramose.token.static(value) and Ramose.token.jwt(mint, options?) — the two token sources (glossary). jwt calls mint on first use, shares one in-flight mint, and re-mints within refreshMargin (default 2 minutes) of the token’s exp. mint may resolve to the token string or to { token }. A thrown Unauthorized passes through; any other throw becomes NetworkError.
const source = Ramose.token.static(import.meta.env.VITE_RAMOSE_TOKEN);const source = Ramose.token.jwt(() => fetch("/api/auth/ramose/token", { method: "POST", body }).then((r) => r.json()),);source.claims(); // decoded, NOT verified — UI hints onlysource.invalidate(); // forget the cached token (sign-out)Ramose.layer(options): Layer<Databases> and Ramose.Databases — the same client for Effect programs; the layer closes its sockets when its scope ends.
const ramose = yield* Ramose.Databases; // provide Ramose.layer({ url, token })const db = ramose.db("todos", Todos);Ramose.DATABASE_NAME_RE / Ramose.isDatabaseName(name) — the database name rule (glossary): /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/. A bad name never reaches the server; every call on it fails InvalidRequest.
if (!Ramose.isDatabaseName(slug)) throw new Error("letters, digits, . _ - only");ramose.db(name, catalog) returns a Db. db.asOf(t) and db.history return a ReadDb — everything below except principal, transact and install.
db.q(query, params?): Effect<Rows, QueryError> — run a query once. Bind Ramose.params as the second argument. .one() is one row or null; .oneOrFail() is one row, or fails with NotOne.
const mineQuery = Ramose.query(User) .where(User.sub.eq(me.id)) .select({ id: User.id }); const existing = yield* db.q(mineQuery);db.live(query, params?): Stream<Rows, QueryError> — a live query: it re-runs whenever the database changes and emits new rows (glossary). Identical results are not re-emitted. Network trouble is retried in place; the stream fails only on InvalidRequest, DatabaseNotFound, Unauthorized, QueryBudgetExceeded, NotOne or ParamError. On asOf(t) or history it emits once and completes. In React use useLive(db, query, params?).
const stream = db.live(boardQuery); // Stream<BoardRow[], DbError>db.pull(subject, shape): Effect<Pull | null, DbError> — read one record (Pull below).
export const pullTodo = (db: TodosDb, eid: TodoEid) => db.pull(eid, { title: Todo.title, done: Todo.done, createdAt: Todo.createdAt, });db.livePull(subject, shape): Stream<Pull | null, DbError> — the live form; a deleted record emits null and the stream stays open.
const stream = db.livePull({ id: issueId }, issueExtraShape);db.basis(): Effect<{ t: number }, DbError> — the version number t this view reads at (glossary); one request on a live view, no request on asOf(t).
const { t } = yield* db.basis();db.asOf(t): ReadDb — the database as it was right after write t. t is a version number, not a Date. Pure.
const beforeRows = yield* db .asOf(report.t - 1) .q(Ramose.query(User).select({ name: User.name })); const before = beforeRows.map((r) => r.name);db.history: ReadDb — a view that includes facts that were later removed. Pure.
const everything = useQuery(db.history, everyIssueEverQuery);db.principal(): Effect<{ eid: Eid | null; class: string }, DbError> — who this connection is: the signed-in user’s record (glossary) and role. eid is null until that user has a row; class is "admin" on a server with no policy.
const me = yield* db.principal(); // { eid: { id: 42 } | null, class: "member" }db.transact(function* (tx) { … }): Effect<TxReport, DbError | E> — one write. The body is a generator; yield* each step. A failure inside sends nothing.
export const addTodo = (db: TodosDb, title: string) => db.transact(function* (tx) { const t = yield* tx.entity(); yield* t.add(Todo.title, title); yield* t.add(Todo.done, false); yield* t.add(Todo.createdAt, new Date()); });
export const setDone = (db: TodosDb, eid: TodoEid, done: boolean) => db.transact(function* (tx) { yield* tx.add(eid.id, Todo.done, done); });
export const deleteTodo = (db: TodosDb, eid: TodoEid) => db.transact(function* (tx) { yield* tx.retractEntity(eid.id); });db.install(): Effect<TxReport, DbError> — install the schema into this database as one write; safe to repeat (glossary). Under a policy only admin may run it.
Effect.gen(function* () { yield* db.install();Transactions
Section titled “Transactions”Inside db.transact you get a tx; tx.entity() gives an Entity handle. Both have the same three verbs.
interface Tx<C> { entity(): Effect<Entity<C>>; // a new record entity(id: TxEntity<C>): Effect<Entity<C>>; // an existing one add(e: TxEntity<C>, attr, value): Effect<void>; retract(e: TxEntity<C>, attr, value?): Effect<void>; retractEntity(e: TxEntity<C>): Effect<void>;}interface Entity<C> { eid; add(attr, value); retract(attr, value?); retractEntity(); }TxEntity — not an exported name, just the shape these verbs accept — is a record id (number), an Entity handle, a lookup by unique key ([User.sub, "abc"]), or a temporary id string. attr is a handle (Issue.title) or its full name (":issue/title").
export const setDescription = (db: ReefDb, issueId: number, text: string) => db.transact(function* (tx) { if (text === "") yield* tx.retract(issueId, Issue.description); else yield* tx.add(issueId, Issue.description, text); }); db.transact(function* (tx) { if (on) yield* tx.add(issueId, Issue.labels, labelId); else yield* tx.retract(issueId, Issue.labels, labelId); });Rules:
- A single-value field:
addreplaces the old value. A many-valued field: oneaddper value;retract(attr, value)removes one value,retract(attr)removes them all. retractEntityremoves every fact of the record. History keeps them.- A reference field takes the target’s id (
issue.add(Issue.creator, myEid)). - There is no update-or-create helper and no
preseton the client; presets are a policy feature the server applies. TxReportis{ t, txEid, datomCount, dbAfter }. It has notempids; to learn a new record’s id, query for it —report.dbAfterreads your own write (glossary).
const report = yield* db.transact(function* (tx) { const user = yield* tx.entity(); yield* user.add(User.sub, me.id); yield* user.add(User.name, me.name); yield* user.add(User.email, me.email); }); const after = yield* report.dbAfter.q(mineQuery); return after[0]?.id;Query builder
Section titled “Query builder”Ramose.query(Namespace, params?) starts a query — a typed description of a read, built as a value (glossary). Pass the builder to db.q / db.live directly. Ramose.params({ … }) declares value holes; Ramose.optional(decl) marks one that may be unbound; Ramose.when(gate, …clauses) includes those clauses only while the gate is on.
Ramose.query(Todo) .where(...predicates) // any number; all must hold .select({ key: Todo.field, … }) // or Ramose.all(Todo); without it rows are { id } // ref.select(Ramose.again(n)) — same card, again, n hops .orderBy(Todo.createdAt, "asc", { empty: "last" }) // "asc" | "desc"; "first" | "last" .limit(20) .offset(0) .one() // at most one row — T | null; :limit 1 .oneOrFail(); // exactly one row — T, or NotOne; :limit 2
Ramose.query(Todo).where(…).count(); // numberRamose.query(Todo).aggregate({ n: Ramose.count(), … }); // one summary rowRamose.query(Todo).groupBy({ … }).aggregate({ … }); // one row per groupRamose.query(Todo).orderBy(…).limit(20).after(cursor); // keyset pageconst 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);Predicates — on every field handle:
| predicate | example |
|---|---|
eq ne lt lte gt gte | Issue.priority.gte(3) |
in([…]) | Issue.status.in(["todo", "doing"]) |
startsWith endsWith includes | Issue.title.startsWith("Fix") |
matches(re | string) | Issue.title.matches("^Bug") — a RegExp with flags throws |
exists() missing() | Issue.assignee.missing() |
is(eid | { id }) — references only | Issue.creator.is({ id: myEid }) |
some(p) every(p) none(p) — many-valued only | Issue.labels.some(Label.name.eq("bug")) |
.each — the element of a many-valued scalar, inside some/every/none | Todo.tags.some(Todo.tags.each.startsWith("a")) |
.where/.orderBy/.limit/.offset on a many-valued field, before its .select | Issue.labels.orderBy(Label.name).select(labelShape) |
Ramose.or(...preds) and Ramose.not(pred) combine predicates; or() with no arguments matches nothing. Ramose.when is a top-level .where clause only — not inside or / not.
Ramose.query(Todo).where( Todo.done.eq(false), Ramose.or(Todo.title.startsWith("a"), Ramose.not(Todo.owner.missing())),);Paths and shapes. A reference field points at another record (glossary); hop through it with Todo.owner.name, walk it backwards with Todo.owner.reverse (many-valued, unless the reference is { isComponent: true } — then the reverse is one record). The shape is the fields you ask for (glossary): direct fields, ref.select({ … }) for nested records, ref.select(Ramose.all(N)) for the target’s wildcard row (ident-keyed), ref.select(Ramose.again(n)) for the same card again under itself to a hop bound you name (1–16; last hop is { id }; a many-valued again edge needs .limit(n); the shape must select N.id), .optional for fields that may be absent, .orDefault(v) for a missing single-value scalar that should read as v. Ramose.all(Todo) is the wildcard shape — every attribute the matched record has, keyed by name.
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;Recursive trees. A thread is a window, not one fetch. again(4) and .limit(20) is the first paint; the { id } stub is the next root — same query, useLive(db, thread, { root: clicked.id }). Sibling “load more” pages that parent’s replies. The JSON: Read data.
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)), });Aggregates. After the filters, ask for a summary instead of rows: .count() / .countDistinct(attr) / .sum(attr) / .avg(attr) / .min(attr) / .max(attr) answer one value; .aggregate({ … }) computes several in one round trip (exactly one row, a one-element tuple); .groupBy({ … }).aggregate({ … }) answers one row per group. The constructors are Ramose.count(), Ramose.sum(attr), and so on.
const [totals] = yield* db.q( Ramose.query(Issue).aggregate({ n: Ramose.count(), top: Ramose.max(Issue.priority) }),); // { n: number; top: number | null }yield* db.q( Ramose.query(Issue).groupBy({ status: Issue.status }).aggregate({ n: Ramose.count() }),); // readonly { status: string; n: number }[]- Over no rows: the counts and
sumare0,avg/min/maxarenull, a grouped query is[]. - An aggregated or grouping path is single-valued, like a sort key;
sum/avgtake number fields. A ref group key is an id you can query on. - A row missing an aggregated field contributes nothing to that aggregate but still counts everywhere else; a row missing a group key belongs to no group.
- Aggregates replace the rows, so
.select/.orderBy/.limit/.one()do not combine with them.
Keyset paging. .after(cursor) pages a sorted query by position: the result becomes { rows, cursor }, and the cursor — opaque; the last row’s sort keys, record id as tie-breaker — is where the next page starts. Pass null for the first page; cursor is null when there is no next page. Unlike .offset, rows inserted before the cursor never shift the walk. Hold it in memory between pages; it is not designed to be serialized.
const pageQ = (after: Ramose.Cursor | null) => Ramose.query(Issue).orderBy(Issue.createdAt, "desc").limit(20).after(after).select(boardShape);const p1 = yield* db.q(pageQ(null));const p2 = yield* db.q(pageQ(p1.cursor)); // rows strictly after page oneRules the builder enforces:
- A select field must be a direct field of the queried record type, or a nested
ref.select({ … }).{ ownerName: Todo.owner.name }is rejected. Ramose.again(n)is only legal asref.select(Ramose.again(n)).nis a literal1–16. A many-valued again edge needs.limit(n). A shape that containsagainmust selectN.id. The recur edge must land in the same record type as the enclosing shape.orderByacross a many-valued hop throws; so doesorderBy(attr.each).- Everything — filter, order, limit, offset,
.one()/.oneOrFail(), aggregates, the cursor seek — runs on the server;limit(20)really is 20 rows, and.one()asks for one. .afterneeds an.orderBy, and does not combine with.offset(a cursor already is the offset) or.one().
Not shipped: having, ordering or paging groups, full-text search, cross-database joins.
db.pull(subject, shape) reads one record (glossary). The subject is { id } or a lookup by unique key [User.sub, "abc"] (glossary); a bare number is a type error. The shape is the same grammar as .select, including .orDefault(v), Ramose.all(N), and ref.select(Ramose.again(n)).
db.pull({ id: 17 }, { title: Issue.title, description: Issue.description.optional });db.pull({ id: 17 }, { done: Todo.done.orDefault(false) });db.pull({ id: 17 }, Ramose.all(Issue));db.pull([User.sub, "user_ada"], { name: User.name });Result rules:
nullwhen the record is missing or a required (non-.optional) field is absent — including a field the policy hides from you.- An unknown field in the shape is
InvalidRequest. Ramose.Pull<typeof Todos, typeof shape>names the result type.
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;| type | what it is |
|---|---|
Ramose.Row<typeof q> | one result row of a query (glossary) |
Ramose.Rows<typeof q> | readonly Row[] — what db.q resolves to |
Ramose.Pull<C, P> | the result of shape P |
Ramose.AllRow<N> / Ramose.AllShape<N> | one wildcard row from Ramose.all(N); the shape term itself |
Ramose.Again<n> / Ramose.RecurStub<S> | the again(n) shape term; the { id } stub after the bound |
Ramose.Db<C> / Ramose.ReadDb<C> | the handle; ReadDb has no transact, install, principal |
Ramose.Eid<C> | { readonly id: number } — a record id as data. Eid<N> over a namespace is the branded id number a select({ id: N.id }) cell carries; db.pull takes it uncast |
Ramose.LookupRef<C> | [UniqueAttr, value] |
Ramose.TxReport<C> | { t, txEid, datomCount, dbAfter } |
Ramose.TokenSource / Ramose.Claims | a token source; the decoded, unverified payload |
Ramose.DbError | the union of the eight request errors |
Ramose.NotOne | .oneOrFail() saw zero or two rows — not a DbError |
Ramose.ParamError | a required param is missing, a key is unknown, or a hole failed to normalize |
Ramose.QueryError<R> | DbError, plus NotOne when R is a .oneOrFail() row, plus ParamError when the query declares params |
export type BoardRow = Ramose.Row<typeof boardQuery>;Errors
Section titled “Errors”Eight tagged request errors, one union: TxRejected Unavailable InvalidRequest DatabaseNotFound Unauthorized QueryBudgetExceeded InternalError NetworkError, and DbError. .oneOrFail() can also fail with NotOne (the query succeeded; the count did not). Match by name with Effect.catchTags; each is described on Errors.
db.q(query).pipe( Effect.catchTags({ QueryBudgetExceeded: () => Effect.succeed([]) }),);