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)❌ 401❌ 403

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