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(...) |
Passing untrusted input
Values in a Sola query are always parameterized, so user-supplied values never lead to SQL injection. Column names in where, orderBy, and select are a different matter: .resource() and the admin API validate them against the schema, but a .procedure() that forwards a client-supplied where or orderBy straight into context.db.query lets a caller filter or sort by any column — including ones you never meant to expose (sorting on a secret column can leak its order). Validate the shape with Zod, or spread a fixed where over the caller’s, before handing it to Sola.
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.
For a custom LiveProvider (or anything that is callback-based rather than an async generator), wrap it with liveIterable from @outerjs/server so oRPC’s eventIterator accepts the handler return value.
Next steps
- Realtime — stream application events (
EventPublisher) instead of reactive row snapshots .resource()— the same query surface, generated- Client — consume streams from
@outerjs/sdk