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
| Procedure | Input | Output | Description |
|---|---|---|---|
{name}.list | { where?, orderBy?, take?, skip?, include? } | Row[] | Filtered, ordered SELECT |
{name}.get | { <pk>: ..., include? } | Row | null | Fetch by primary key |
{name}.create | Row minus serial PK, defaults, and ownerColumn | Row | INSERT ... RETURNING * |
{name}.createMany | { data: createInput[] } (1–1000 rows) | Row[] | Batch INSERT ... RETURNING * |
{name}.update | { where: { <pk> }, data: Partial<createInput> } | Row | UPDATE ... RETURNING * |
{name}.delete | { <pk>: ... } | Row | DELETE ... 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, pluslt/lte/gt/gteon numeric and timestamp columns andcontains/startsWith/endsWithon text columns), combinable withAND,OR, andNOT.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(default30000) re-runs the permission check on that interval and ends the stream with401or403when 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 whenlistis"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(default100) 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:
| Situation | Response |
|---|---|
| Unique or foreign-key violation | 409 CONFLICT |
| Not-null or check violation | 400 BAD_REQUEST |
| Row not found on update or delete | 404 NOT_FOUND |
update with empty data | 400 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 withoutownerColumnthrows as soon as.resource()is called. - If any action's permission requires a session but
.auth()was never called,.build()throws and lists the offendingresource.actionnames.
Next steps
- Permissions — what
"owner"and the rest actually enforce - Database — the query surface
listexposes - Procedures — write the endpoints CRUD does not cover