Database
Every handler receives context.db: the full Kysely instance, typed to your latest schema, plus query (the Sola ORM) and transact for transactions.
Request context
type OuterRpcContext<TDB> = {
headers: Headers;
auth?: OuterAuth; // Better Auth instance; undefined if .auth() was not called
db: OuterDB<TDB>; // Kysely<TDB> + .query (Sola)
user: SessionUser | null; // resolved once per request by .auth()
session: UserSession | null; // null when anonymous, or when .auth() was never called
storage?: OuterStorage; // the object store passed to new Outer({ storage })
};
user and session need no middleware — see Read the session. storage is the same store .files() writes through, so a custom procedure can read and write blobs alongside the built-in file API.
Add your own fields with .middleware(), and they are merged into the context — and the types — of every procedure defined after it.
Raw queries with Kysely
context.db.insertInto("user").values({...}).execute()
context.db.selectFrom("session").where("userId", "=", id).selectAll().execute()
Transactions
context.db.transact(fn) runs fn inside a transaction and returns its result. The trx argument is a full context.db, so Kysely methods and trx.query (Sola) both participate. Throw inside fn to roll everything back.
const result = await context.db.transact(async (trx) => {
const { id } = await trx.insertInto("order").values({...}).returning("id").executeTakeFirstOrThrow();
await trx.updateTable("inventory").set(...).execute();
return trx.query.order.findFirst({ where: { id } });
});
The Sola ORM
context.db.query is a read-focused API over the same Kysely instance. Table names match the schema exactly, and stay singular: user, session.
findMany(args?)
const users = await context.db.query.user.findMany({
where: { email: { contains: "acme.com" } },
include: { session: { orderBy: [{ createdAt: "desc" }], take: 5 } },
orderBy: [{ createdAt: "desc" }],
take: 20,
skip: 0,
});
findFirst(args?)
Same as findMany, but applies LIMIT 1 and returns T | null.
findUnique({ where })
Looks a row up by exact field value and throws if none is found. where accepts direct values only, with no filter operators.
const user = await context.db.query.user.findUnique({ where: { id: "abc" } });
count(args?) and exists(args?)
const n = await context.db.query.user.count({ where: { emailVerified: true } });
const taken = await context.db.query.user.exists({ where: { email: "x@y.com" } });
Prefer exists when you only need a yes or no — it runs SELECT 1 ... LIMIT 1.
paginate(args)
orderBy and take are required. Pass skip for offset mode:
const page = await context.db.query.user.paginate({
orderBy: [{ createdAt: "desc" }],
take: 20,
skip: 40,
});
Pass after or before for cursor mode:
const page1 = await context.db.query.user.paginate({ orderBy: [{ id: "desc" }], take: 20 });
const page2 = await context.db.query.user.paginate({
orderBy: [{ id: "desc" }],
take: 20,
after: page1.pagination.endCursor!,
});
Both modes return the same shape:
{
data: T[],
pagination: {
count: number, // total matching rows
hasNext: boolean,
hasPrevious: boolean,
startCursor: string | null, // null in offset mode
endCursor: string | null, // null in offset mode
}
}
Cursors are opaque base64 strings derived from the orderBy column values. Multi-column orderBy uses correct row-comparison keyset semantics — always end orderBy with a unique column such as id to guarantee stable pages. A malformed or tampered cursor is rejected with 400 BAD_REQUEST rather than throwing.
where operators
| Operator | Types | SQL |
|---|---|---|
equals | all | = val |
not | all | != val |
in | all | IN (...) |
notIn | all | NOT IN (...) |
lt / lte / gt / gte | number, Date | < <= > >= |
contains | string | LIKE %val% |
startsWith | string | LIKE val% |
endsWith | string | LIKE %val |
isNull: true | nullable | IS NULL |
isNull: false | nullable | IS NOT NULL |
AND | — | implicit (multiple fields) or explicit array |
OR | — | OR(...) |
NOT | — | NOT(...) |
include
include loads relations you declared with .relation(). It runs one extra query per relation and merges the results in JS, Prisma-style.
hasManyandmanyToManyreturn an array.belongsToandhasOnereturn a single object ornull.
Nested include is not supported — one level of relations per query. manyToMany includes require pivotTable on the relation definition, and Outer performs the two-hop join through the pivot automatically.
Live queries
live() is findMany() as a stream: the full result set immediately, then again on every change that affects it. It takes identical arguments, and the SQL comes from the same code path, so a live stream and a one-shot read cannot drift apart.
It returns an AsyncIterable, so a handler returns it directly and oRPC streams it over SSE:
.procedure("post.live", (base) =>
base.handler(({ context, signal }) =>
context.db.query.post.live(
{ where: { done: false }, orderBy: [{ id: "desc" }], take: 20 },
{ signal },
),
),
)
That is the whole subscription. There is no publisher and no mutation path that has to remember to publish — the database is the source of truth, so a row changed by a migration, an admin action, or psql reaches subscribers just the same.
| Method | Emits |
|---|---|
live(args?, options?) | T[] |
liveCount(args?, options?) | number |
liveExists(args?, options?) | boolean |
Pass the procedure's signal as options.signal so a disconnecting client releases its subscription. Breaking out of a for await loop releases it too.
Emissions coalesce. The payload is a snapshot, not an event log, so ticks that arrive while the consumer is busy collapse into the newest one, and a slow client cannot build a backlog.
Two limits to know before you lean on live queries:
- It needs a
LiveProvider.pglite()ships one. Other dialects throwNOT_IMPLEMENTEDrather than quietly degrading to a one-shot read. Supply your own — PostgresLISTEN/NOTIFY, or polling — by passinglivealongsidedialectinnew Outer({ db }). includeis not supported. Relations load as separate queries, which one subscription cannot watch, so passingincludethrows. Subscribe to the related table separately.
Each subscription costs real database resources, and PGlite is a single embedded instance, so subscription count scales with connected clients. Prefer one broad subscription fanned out in your app over one per client.
To expose a live query without writing a procedure, use live: true on a resource.