Skip to content

Resources

.resource(name, options?) generates typed CRUD procedures for a table defined in the last .schema() call. It covers the endpoints you would otherwise write by hand for every table.

.resource("post", {
  permissions: {
    list: "public",
    get: "public",
    create: "authenticated",
    update: "owner",
    delete: "owner",
  },
  ownerColumn: "userId",
})
// Registers: post.list, post.get, post.create, post.createMany, post.update, post.delete

What gets generated

ProcedureInputOutputDescription
{name}.list{ where?, orderBy?, take?, skip?, include? }Row[]Filtered, ordered SELECT
{name}.get{ <pk>: ..., include? }Row | nullFetch by primary key
{name}.createRow minus serial PK, defaults, and ownerColumnRowINSERT ... RETURNING *
{name}.createMany{ data: createInput[] } (1–1000 rows)Row[]Batch INSERT ... RETURNING *
{name}.update{ where: { <pk> }, data: Partial<createInput> }RowUPDATE ... RETURNING *
{name}.delete{ <pk>: ... }RowDELETE ... RETURNING *

Outer derives the input types from your column definitions at build time. Serial primary keys and the ownerColumn are omitted from create input, because the database and the session own those. Columns with .default() are optional: omit one and the default applies, pass one and it wins.

Every client inherits these types — @outerjs/sdk in the browser and outer.client() on the server alike.

Query a list

list accepts the same query surface as the Sola ORM, described in Database:

  • where — plain equality values or filter objects (equals, not, in, notIn, isNull, plus lt/lte/gt/gte on numeric and timestamp columns and contains/startsWith/endsWith on text columns), combinable with AND, OR, and NOT.
  • orderBy — an array of { column: "asc" | "desc" }.
  • skip — an offset.
  • include — see Include relations.

list returns 50 rows by default and rejects a take above 100 with 400 BAD_REQUEST. Pass listLimit: { default, max } to change either bound. The cap keeps an unqualified .resource() call from becoming an unbounded SELECT *.

For cursor pagination with metadata, call context.db.query[table].paginate(...) in a custom procedure instead.

Include relations

list and get accept include: { relatedTable: true } for any relation declared on the table with .relation(). hasMany and manyToMany relations come back as arrays; hasOne and belongsTo come back as an object or null.

Unknown relation names are rejected with a 400. When the table has no relations, include is not part of the input schema.

Included rows are not checked against the related resource's permissions. Use the includable option to allow only the relations that are safe to expose.

Stream a list with live

Pass live: true to also register {name}.live — the same query as list, streamed, re-emitting on every change that affects the result set:

.resource("post", {
  permissions: { list: "owner" },
  ownerColumn: "userId",
  live: true,
})

It reuses the list permission, owner scoping included. There is no separate live permission to forget, and the owner filter is applied in SQL, so a subscriber only ever receives their own rows. It needs a dialect with a LiveProvider; pglite() has one. See live queries for the underlying Sola API.

Weigh two things before you expose it publicly:

  • A subscription outlives the check that opened it. An SSE connection can stay open for hours, so a user who signs out, is banned, or loses a role would keep receiving rows. revalidateMs (default 30000) re-runs the permission check on that interval and ends the stream with 401 or 403 when it fails. The timer races the wait for the next change, so an idle subscription is cut as promptly as a busy one. It is skipped entirely when list is "public".
  • Owner scoping filters rows, not wakeups. A write by any user re-runs every subscriber's query. Nothing leaks, but a busy table wakes every subscription — which is why max (default 100) caps concurrent subscribers per resource.

include and skip are not available on live.

Errors

create and update map common Postgres constraint violations to clean errors instead of a raw 500:

SituationResponse
Unique or foreign-key violation409 CONFLICT
Not-null or check violation400 BAD_REQUEST
Row not found on update or delete404 NOT_FOUND
update with empty data400 BAD_REQUEST

Unrecognized database errors still surface as a generic 500, with no internal details leaked.

Configuration is validated at build time

Mistakes throw while you build the chain, not on the first request:

  • Using "owner" on any action without ownerColumn throws as soon as .resource() is called.
  • If any action's permission requires a session but .auth() was never called, .build() throws and lists the offending resource.action names.

Next steps

  • Permissions — what "owner" and the rest actually enforce
  • Database — the query surface list exposes
  • Procedures — write the endpoints CRUD does not cover