Skip to main content

Resources & the app factory

A resource is the core of Covara. Each resource maps to a Drizzle table and generates a complete Hono router: CRUD, batch operations, count, aggregations, subscriptions, search, and RPC. You create resources either with the createCovara factory or directly with useResource.

createCovara

createCovara(options) returns a CovaraApp (which extends Hono) pre-wired with RFC 7807 error handling, security headers, health endpoints, OpenAPI, and an optional admin UI. Resources are added with the chainable .resource().

import { createCovara } from "covara";

const app = createCovara({
basePath: "/api", // resource mount prefix (default: "/api")
cors: true, // true | false | hono/cors options
auth: { router, middleware },// the object returned by useAuth()
middleware: [], // extra Hono MiddlewareHandler[] applied to all routes
observability: true, // request/subscription metrics (or { metrics })
health: true, // /healthz + /readyz (default: true)
adminUI: false, // admin dashboard at /__covara/ui (default: false)
openapi: true, // OpenAPI document at /__covara (default: true)
})
.resource(usersTable, { id: usersTable.id, db }) // → /api/users
.resource("/people", usersTable, { id: usersTable.id, db }); // → /api/people
OptionTypeDefaultDescription
basePathstring"/api"Prefix every resource is mounted under.
corsboolean | CorsOptionsfalseEnable CORS, or pass a hono/cors config.
auth{ router, middleware }Result of useAuth; mounts auth routes and populates c.get("user").
middlewareMiddlewareHandler[][]Extra middleware applied before resources.
observabilityboolean | { metrics }falseCollect request/subscription metrics.
healthboolean | HealthOptionstrueMount /healthz and /readyz.
adminUIboolean | AdminUIOptionsfalseMount the admin dashboard.
openapiboolean | OpenAPIOptionstrueServe the OpenAPI spec.

The mount path is inferred from the table name unless you pass an explicit path as the first argument to .resource().

Serving the app

useResource

useResource(table, config) returns a plain Hono router. Use it when you want to compose routing yourself:

import { Hono } from "hono";
import { useResource, errorHandler, notFoundHandler } from "covara";

const app = new Hono();
app.onError(errorHandler);
app.notFound(notFoundHandler);
app.route("/api/posts", useResource(postsTable, { id: postsTable.id, db }));

createCovara().resource() is a thin wrapper over useResource that also registers the resource for OpenAPI, the admin UI, and cross-resource subscriptions.

Configuration reference

id (required)

The primary key column.

{ id: postsTable.id }

db (required)

The Drizzle database instance. Wrap it with trackMutations if you want custom routes/procedures to feed subscriptions automatically.

{ db }

auth

Authorization scopes per operation. Each scope is a function of the current user returning an RSQL filter that is AND-combined with the request filter. See Authorization scopes.

{
auth: {
public: { read: true, subscribe: true }, // allow anonymous read/subscribe
read: async (user) => rsql`*`,
create: async (user) => (user ? rsql`*` : rsql``),
update: async (user) => rsql`userId==${user.id}`,
delete: async (user) => rsql`userId==${user.id}`,
subscribe: async (user) => rsql`userId==${user.id}`,
},
}

A scope function's return value decides which rows the operation may touch: rsql`*` allows everything, rsql`userId==${user.id}` restricts to matching rows (AND-combined with the request ?filter=), and rsql `` (empty) denies entirely. Omit a scope to deny that operation for everyone except where public grants it.

public vs a scope returning rsql`*`

These look similar but act at different stages — public controls who (authenticated or not), while a scope function controls which rows an already-authenticated user sees:

  • public is checked first and bypasses the auth requirement, so an anonymous request is allowed and resolves to all rows. Only read and subscribe can be made public (public: true is shorthand for both) — you cannot open create/update/delete this way.
  • A scope function returning rsql`*` is only reached after the user check. An anonymous request gets a 401 (the function never runs); an authenticated user gets all rows. It means "any logged-in user can read everything, but you must be logged in."
ConfigAnonymousAuthenticated
public: { read: true }✅ all rows✅ all rows
scope read returns *401✅ all rows
both together✅ all rows✅ all rows
neither (omitted)401403

For the common "public reads, owner-only writes" pattern you need both — set public.read and owner-scoped create/update/delete functions (this is exactly scopePatterns.publicReadOwnerWrite). See Authorization scopes.

capabilities

Enable/disable whole operations. All default to true. Reading, filtering, and sorting are always enabled.

{
capabilities: {
enableCreate: true,
enableUpdate: true, // governs PATCH and PUT
enableDelete: true,
enableBatch: true, // governs all /batch routes incl. /batch/upsert
enableAggregations: true,
enableSubscribe: true,
},
}

fields

Field-level policies. See Fields: masking, writable, computed for full behavior and security guarantees. Every column-name option below takes the Drizzle column (like id); string column names also work but are deprecated.

{
fields: {
readable: [users.id, users.name, users.email, users.createdAt], // allowlist; everything else stripped from responses
writable: [users.name, users.email], // enforced allowlist for create/update (mass-assignment protection)
filterable: [users.name, users.email, users.createdAt], // columns allowed in ?filter=
sortable: [users.name, users.createdAt], // columns allowed in ?orderBy=
},
}

generatedFields

Columns the server fills in (id, timestamps, ownership). They are exempt from fields.writable stripping (a hook can set them) and may be omitted from inbound bodies even with strictInput.

{ generatedFields: [users.id, users.userId, users.createdAt, users.updatedAt] }

strictInput

By default unknown fields in a body are ignored. Set strictInput: true to reject them with a 422 (Zod strict mode).

{ strictInput: true }

computed

Virtual fields added to every response and subscription event, computed from the full (unmasked) row and never persisted. See Fields.

{
computed: {
fullName: (row) => `${row.firstName} ${row.lastName}`,
isOverdue: (row) => row.dueAt != null && Date.parse(row.dueAt) < Date.now(),
},
}

pagination

{ pagination: { defaultLimit: 20, maxLimit: 100 } }

See Pagination.

batch

{ batch: { create: 50, update: 50, replace: 50, delete: 10 } }

See Batch operations.

etag

Optimistic concurrency control. See Optimistic locking.

{ etag: { versionField: posts.version } } // or { updatedAtField: posts.updatedAt }

softDelete

Mark rows deleted instead of removing them. See Soft delete.

{ softDelete: { field: posts.deletedAt } }

relations

Declare belongsTo / hasOne / hasMany / manyToMany relations loadable via ?include=. See Relations.

nestedWrites

Enable write-through of embedded relation objects in POST bodies (requires relations). See Nested writes.

{ nestedWrites: true }

Register searchable fields for the GET /search endpoint. See Search.

{ search: { enabled: true, fields: { title: { weight: 2 }, body: {} } } }

rateLimit

Per-resource rate limiting. See Middleware.

{ rateLimit: { windowMs: 60_000, maxRequests: 100 } }

customOperators

Add filter operators. See Filtering → Custom operators.

hooks

Lifecycle hooks around every mutation. See Procedures & hooks.

{
hooks: {
onBeforeCreate: async (ctx, data) => ({ ...data, createdAt: new Date() }),
onAfterCreate: async (ctx, created) => {},
onBeforeUpdate: async (ctx, id, data) => ({ ...data, updatedAt: new Date() }),
onAfterUpdate: async (ctx, updated) => {},
onBeforeDelete: async (ctx, id) => {},
onAfterDelete: async (ctx, deleted) => {},
},
}

procedures

Custom Zod-validated RPC endpoints at POST /rpc/:name. See Procedures.

Generated endpoints

MethodPathDescription
GET/List with pagination, filtering, ordering, projections
GET/:idGet one
POST/Create
PATCH/:idPartial update
PUT/:idFull replace
DELETE/:idDelete (or soft delete)
GET/countCount
GET/aggregateAggregations
GET/aggregate/subscribeLive aggregation
GET/subscribeSSE subscription
GET/searchFull-text search (when configured)
POST/batchBatch create
PATCH/batchBatch update
DELETE/batchBatch delete
POST/batch/upsertBulk insert-or-update
POST/rpc/:nameRPC procedures

Query parameters

ParameterDescriptionExample
filterFilter expressionstatus=="active"
selectField projectionid,name,email
includeLoad relationscategory,tags(limit:5)
cursorPagination cursoreyJpZCI6MTB9
limitPage size20
orderBySort ordername:asc,age:desc
totalCountInclude total counttrue
havingFilter aggregate groups (/aggregate)count>=5;sum_total>100
withDeletedInclude soft-deleted rowstrue