Skip to content

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

OperatorTypesSQL
equalsall= val
notall!= val
inallIN (...)
notInallNOT IN (...)
lt / lte / gt / gtenumber, Date< <= > >=
containsstringLIKE %val%
startsWithstringLIKE val%
endsWithstringLIKE %val
isNull: truenullableIS NULL
isNull: falsenullableIS NOT NULL
ANDimplicit (multiple fields) or explicit array
OROR(...)
NOTNOT(...)

include

include loads relations you declared with .relation(). It runs one extra query per relation and merges the results in JS, Prisma-style.

  • hasMany and manyToMany return an array.
  • belongsTo and hasOne return a single object or null.

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.

MethodEmits
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 throw NOT_IMPLEMENTED rather than quietly degrading to a one-shot read. Supply your own — Postgres LISTEN/NOTIFY, or polling — by passing live alongside dialect in new Outer({ db }).
  • include is not supported. Relations load as separate queries, which one subscription cannot watch, so passing include throws. 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.

Next steps

  • Realtime — stream application events instead of rows
  • Resources — the same query surface, generated