@async/db starts with file-like local REST routes so a prototype can move quickly. When the contract settles, keep the same resource model but move app traffic behind an owned API namespace, registered operations, or a generated server.
Local development is intentionally open by default:
GET /db/users.json
GET /db/users/:id.json
POST /db/users
PATCH /db/users/:id
DELETE /db/users/:id
GET /__db
GET /__db/manifest.json
GET /__db/schema
POST /__db/batch
POST /__db/operations/:refUse /db/* while the data shape is still changing. It mirrors the db/
data folder, works well with .json reads, and keeps the local data explorer
and import tools under /__db.
This default is local development infrastructure. Do not expose async-db serve directly to customers as a public database API.
The JSON store can still be production-appropriate for small low-write resources such as settings, feature flags, content, templates, policy rules, and seed data. Treat it as a file-backed store behind your app API, not as a public hosted database. Use registered operations, app-owned auth, rate limits, and monitoring for production-facing traffic.
When a prototype turns into app-owned API surface, use an API namespace that fits the surrounding app:
| Namespace | Use when |
|---|---|
/api/db/{resource} |
The app already has other /api/* routes and db routes should be clearly grouped. |
/api/{resource} |
The API is dedicated to db resources and there is no need for an extra /db segment. |
/db/{resource} |
Local prototype and test routes. Avoid this as the customer-facing production namespace. |
For a production-like local server, move the file-backed data alias and dev-tool base together:
import { defineConfig } from '@async/db/config';
export default defineConfig({
server: {
dataPath: '/api/db',
apiBase: '/api/db',
},
});That makes raw app-facing reads look like:
GET /api/db/users.json
GET /api/db/users/u_1.jsonIt also moves registered operation execution to:
POST /api/db/operations/GetUserProfile
POST /api/db/operations/users.profile.getThe operation templates themselves still use internal resource paths such as
/users/{id}.json. The public route is the /operations/:ref endpoint.
Registered operations let app code call a named contract instead of exploring
raw resource URLs. Any non-path string can be the ref. Use refs that do not
start with / and do not look like GET /..., because client.query() treats
those as literal REST templates.
Server config:
import { defineConfig } from '@async/db/config';
export default defineConfig({
operations: {
enabled: true,
registry: {
GetUserProfile: {
method: 'GET',
path: '/users/{id}.json',
query: {
select: 'id,name,email',
},
},
},
},
server: {
apiBase: '/api/db',
dataPath: '/api/db',
},
});Client code:
import { createDbClient } from '@async/db/client';
const db = createDbClient({
baseUrl: 'https://example.com',
apiBase: '/api/db',
});
const user = await db.query('GetUserProfile', { id: 'u_1' });The client sends:
POST /api/db/operations/GetUserProfileThe server looks up GetUserProfile, substitutes variables, and executes the
registered REST template through normal @async/db shaping and validation.
Readable refs are convenient. Opaque refs are useful when you do not want customer-facing client bundles to reveal raw route names, selected fields, or query templates.
By default, generated refs are derived from the canonical operation contents:
/users/{id}.json?select=id,name,emailand become a stable ref like:
op_9d66...Build a server registry and client-safe refs from operation source files:
async-db operations build \
--out ./src/generated/db.operations.json \
--refs-out ./src/generated/db.operation-refs.jsonKeep db.operations.json server-side because it contains full templates. Ship
only names and callable refs from db.operation-refs.json to browser code.
The client file does not expose paths, query templates, variables, request
bodies, or the full server registry.
Operation names and refs must be unique; the build fails rather than silently generating refs that could resolve to a different registry entry.
import operationRefs from './generated/db.operation-refs.json' assert { type: 'json' };
await db.query(operationRefs.operations.GetUserProfile.ref, { id: 'u_1' });To make the exposed client contract reviewable in CI, print or check the deterministic contract:
async-db operations contract
async-db operations contract --check--check compares the current operation sources with outputs.operationRefs
or an explicit --out <file> and fails when exposed names or refs change.
For app-to-app sharing, put resource and operation limits under contracts.
Schema tags can help infer a starting point, but contracts are the enforced
API:
export default defineConfig({
contracts: {
public: {
resources: {
users: {
fields: ['id', 'name', 'avatarUrl'],
read: true,
write: false,
},
},
operations: ['GetPublicUser', 'SearchPublicUsers'],
},
admin: {
resources: {
users: {
fields: ['id', 'name', 'email', 'role'],
read: true,
write: ['create', 'patch'],
},
},
operations: ['GetUserAdmin', 'UpdateUserRole'],
},
},
});Generate or check contract-scoped refs:
async-db contracts infer --from-tags
async-db contracts infer --from-usage ./src
async-db contracts check
async-db contracts refs --out ./src/generated/db.contract-refs.jsonRuntime callers can pass the contract they are executing under:
await db.query(operationRefs.contracts.public.operations.GetPublicUser.ref, {
id: 'u_1'
}, {
contract: 'public'
});The runtime checks that the ref belongs to the contract, the REST operation touches allowed resources, selected fields stay inside the field list, and writes match the contract write policy.
By default, operations.*.ref is generated with hashOperation(). Set an
explicit ref in the operation source when the app wants a readable or
app-owned callable id:
{
"name": "GetUserProfile",
"ref": "users.profile.get",
"path": "/users/{id}.json",
"query": {
"select": "id,name"
}
}For opaque production clients, keep generated refs or generate your own refs
with hashOperation(), then accept only refs on the server:
export default defineConfig({
operations: {
acceptRefs: 'ref',
},
});If an app has its own registry build step or policy, keep generated client refs
simple and customize server lookup with operations.resolveRef or
operations.validateRef. Inline registries can use operation objects or string
REST templates:
registerDbRoutes(app, db, {
prefix: '/api/db',
operations: {
registry: {
GetUserProfile: '/users/{id}.json?select=id,name',
},
acceptRefs: 'name',
},
});If the generated outputs.operationRegistry is missing, invalid, or points at
the client-safe refs file instead of the server registry, operation execution
fails with OPERATION_REGISTRY_LOAD_FAILED so you can rebuild the registry or
fix the configured path. A loaded registry that simply lacks a ref still returns
OPERATION_NOT_FOUND.
Refs are allowlist identifiers, not secrets. They reduce route exploration and hide query shape from casual client inspection, but anyone who can call your API still needs normal auth, authorization, rate limits, and monitoring.
operations.enabled: true enables registered operation execution without
closing local REST or local data explorer routes. Once the app uses registered operation
refs as its public data contract, opt into operation-only exposure to block raw
REST exploration:
import { defineConfig } from '@async/db/config';
export default defineConfig({
outputs: {
operationRegistry: './src/generated/db.operations.json',
operationRefs: './src/generated/db.operation-refs.json',
},
operations: {
enabled: true,
acceptRefs: 'ref',
},
server: {
apiBase: '/api/db',
dataPath: '/api/db',
expose: {
rest: 'registered-only',
graphql: false,
viewer: 'dev',
schema: 'dev',
manifest: 'dev',
},
},
});registered-only is not a general hardening switch. It specifically means only
registered operations may use the REST data API. With that policy, raw routes
such as these are rejected:
GET /api/db/users.json
POST /api/db/batch
GET /usersRegistered operations still run:
POST /api/db/operations/GetUserProfile
POST /api/db/operations/users.profile.getThe built-in local viewer mirrors that policy. Resource query and direct REST controls are disabled for raw resource routes, while Operation mode can call a registered ref when operations are enabled and client-safe refs are available. The viewer still does not receive server operation templates.
registered-only does not make @async/db define production policy for your
app. The built-in server still starts if registered operations are disabled;
raw REST stays blocked and operation requests fail at request time. If you want
startup and async-db doctor to fail early when operations are missing or
unresolved, opt into that readiness check:
export default defineConfig({
operations: {
enabled: true,
strict: true,
acceptRefs: 'ref',
},
server: {
expose: {
rest: 'registered-only',
},
},
});With operations.strict: true, provide outputs.operationRegistry,
operations.registry, operations.resolveRef, or operation files under
operations.sourceDir.
Use server.expose.graphql: false when the production-facing API is REST-only.
If you use registered GraphQL operations, keep graphql.enabled on and use
server.expose.graphql: false to hide only the direct GraphQL endpoint.
@async/db keeps endpoint choices separate so each app can decide what production means. Use the smallest surface that matches the app code:
import { defineConfig } from '@async/db/config';
export default defineConfig({
rest: {
enabled: false,
},
graphql: {
enabled: false,
},
falcor: {
enabled: false,
},
server: {
expose: {
rest: 'registered-only',
graphql: false,
viewer: 'dev',
schema: 'dev',
manifest: 'dev',
},
},
});rest.enabled: false removes generated REST resource routes and REST batching.
Use server.expose.rest: 'registered-only' instead when registered operations
should keep working but raw REST should close. graphql.enabled: false
disables GraphQL execution entirely. If registered GraphQL operations still
need GraphQL execution, keep graphql.enabled: true and set
server.expose.graphql: false to hide the direct GraphQL endpoint.
falcor.enabled: false disables /model.json. Keep the local data explorer,
schema, and manifest exposure at 'dev' for local tools, or set them to false when a
production-facing mount should not serve those metadata routes.
Use the static usage scanner to review what the app appears to call:
async-db usage scan ./src --production
async-db usage scan ./src --production --out ./src/generated/db.usage.json
async-db usage scan ./src --production --check ./src/generated/db.usage.json
async-db doctor --production --usage ./src --jsonThe scanner reads source text only; it does not execute app files. The generated
db.usageManifest records package imports, client calls, route literals,
config toggles, and advisory endpoint recommendations. Treat the manifest as a
review aid, not telemetry or proof that dynamic code cannot call an endpoint.
For app-specific auth, permissions, sessions, logging, and rate limiting, mount db behind the framework that owns your production API.
With Hono route registration:
import { registerDbRoutes } from '@async/db/hono';
registerDbRoutes(app, db, {
prefix: '/api/db',
operations: true,
lifecycleHooks: {
beforeRequest({ c }) {
const session = readSession(c.req.header('authorization'));
if (!session) return c.json({ error: 'Unauthorized' }, 401);
c.set('session', session);
},
beforeWrite({ c }) {
if (c.get('session')?.role !== 'admin') {
return c.json({ error: 'Forbidden' }, 403);
}
},
},
});operations: true mounts POST /api/db/operations/:ref using
db.config.operations. Omit it to use automatic mounting when
db.config.operations.enabled is true, set operations: false to keep only
raw REST routes on that mount, or pass a local operation registry when this
Hono app owns a custom build step:
Hono registered operation routes run lifecycleHooks.beforeRequest with
method: 'operation' and the operation ref, so shared auth/session checks can
protect both resource routes and registered operations. Resource write hooks do
not run for registered operations; enforce operation-specific permissions in the
app layer or with operations.validateRef.
registerDbRoutes(app, db, {
prefix: '/api/db',
operations: {
registry: generatedOperations.operations,
acceptRefs: 'ref',
},
});Use /api instead of /api/db when the generated or registered API is only
for db resources:
registerDbRoutes(app, db, {
prefix: '/api',
});When data files and schemas have settled enough for a standalone service, use the Hono/SQLite starter:
async-db generate hono --api rest --out ./serverThe generated server is the right direction when the data API needs its own repository, deploy process, migrations, and production storage.
- Keep
/db/*for prototype and test traffic. - Move app-facing routes to
/api/db/*or/api/*. - Register string operation names or refs for stable app contracts.
- Import generated operation refs and call
.ref; set explicit refs when app-owned callable ids are clearer. - Use
acceptRefs: 'ref'for opaque public clients, oracceptRefs: 'name'for readable internal APIs. - Run
async-db usage scan --productionbefore choosing endpoint exposure. - Set
server.expose.rest: 'registered-only'before treating operation refs as the public contract. - Disable unused GraphQL and Falcor endpoints with
graphql.enabled: falseandfalcor.enabled: false. - Add app-owned auth, authorization, rate limits, and observability outside the registered operation registry.
- Generate a Hono/SQLite API when the endpoint needs production storage and deployment boundaries.
- Keep low-write control-plane resources in JSON when that is operationally appropriate, but move high-write, transactional, or multi-writer resources to SQLite, Postgres, or another app-owned store.