Repository
A typed repository per table, compiled to one PostgREST request.
const betterSupabase = defineSupabase(schema);
const db = betterSupabase.connect(supabase); // any SupabaseClient, or an Executordb.<table> is created lazily for every table and view in the schema. Views
get the read methods only.
Reading
| Method | Returns |
|---|---|
findMany(args?) | Row[] |
findFirst(args?) | Row | null |
findOnly({ where }) | Row | null, or a multiple_rows error when more than one row matches |
findById(key, args?) | Row, or a not_found error |
count({ where? }) | number (a HEAD request) |
exists({ where? }) | boolean |
findUnique({ where }) | Row | null, by the primary key or any unique key; see unique keys |
paginate(args) | A page; see pagination |
findOnly is for a filter that should match at most one row but has no
unique key behind it, such as the active subscription of a customer. It
asks for two rows and returns a multiple_rows error (status 409) when it
gets both, where findFirst would return one of them. It returns null
when nothing matches, like supabase-js maybeSingle().
Every read takes select, where, include, orderBy, limit, offset
and signal. limit and offset must be non-negative integers; anything
else returns invalid_request before a request is sent. The result type follows select and include:
const row = await db.customers
.findById(id, {
select: ["name"],
include: { notes: { select: ["body"], limit: 3 } },
})
.orThrow();
// { name: string; notes: { body: string }[] }Composite keys take an object: db.customerTags.findById({ customerId, tagId }).
Timeouts and retries
Every read, write and $rpc call also takes timeout and retry:
const rows = await db.customers.findMany({
where: { status: "lead" },
timeout: 2000,
retry: false,
});timeout is in milliseconds. It is combined with signal (either one aborts
the request), and a call that runs past it fails with timeout (504) instead
of aborted. retry turns supabase-js retries on or off for the call: they
repeat GET and HEAD requests after a network error, a 503 or a 520, and
never repeat a write. Set both for a whole connection with
betterSupabase.connect(client, context, { timeout, retry }); a call's own
values win.
On the Postgres executor, timeout only stops a call
that has not started its statement yet, so use statement_timeout for long
queries, and retry does nothing.
Escape hatches
db.$clientis the client you passed in.db.$rpc('fn', args, { returns })calls a typed Postgres function, and can validate the result with a Standard Schema. Rows of a table andreturns table (...)records come back in the configured casing;{ raw: true }keeps database names.db.$with({ signal })returns a Db bound to a new request context.db.$table(name)returns the repository for a table key known only at runtime, and throws aTypeErrorfor unknown names.db.$withoutPlugins()returns the same connection without any plugins, for migrations and admin scripts.db.$withoutPlugins({ keep: ['otel'] })keeps the named plugins, such as tracing.db.$run(spec)runs a serializable query spec.db.$many([specA, specB])runs specs together and returns a tuple;db.$many(readSet, params)runs a read set as one request.db.$stats()returns{ calls, waves, tables, ms }for everything thisdb(and its$withcopies) ran.betterSupabase.connect(client, context, { stats })also reports into a sharedStatsRecorder. See Budget.explainPostgrest(op)shows the compiled request for debugging.
How queries compile
A call compiles to an intermediate representation, which a compiler turns
into a PostgREST request. Nothing runs through string templates you write:
values are quoted, and LIKE input used by contains, startsWith and
endsWith is escaped. A filter that can never match ({ in: [] }) skips
the request.
Last updated on