@async/db

Server And Local Data Explorer

async-db serve starts a local development server. It syncs on startup, watches data files in db/, serves REST and GraphQL endpoints, and exposes the built-in local data explorer.

Local Trust Boundary

The server binds to 127.0.0.1 by default. It is intended for local development and tests, not public hosting.

Important write surfaces:

  • REST writes update runtime state.
  • GraphQL mutations update runtime state.
  • Explorer CSV import writes CSV files into the configured dbDir.
  • Resources bound to the sourceFile store may write supported changes back to source JSON files.

Config and schema JavaScript are trusted project code. Do not treat .schema.js, .schema.js, or config hooks as untrusted data.

Request Tracing

Enable request tracing when a local page appears stuck or slow and you need to

see where a DB request spent time:

import { defineConfig } from '@async/db/config';

export default defineConfig({
  server: {
    trace: true,
  },
});

Tracing adds an x-async-db-request-id response header, emits

request-trace events through GET /__db/log, and prints concise console

lines unless disabled. Traces include method, pathname, query keys, route

family, resource, operation, id when known, status, handled state, duration,

slow status, safe error code/message, and phase timings for clear boundaries

such as route matching, mock behavior, REST handling, Hono hooks, collection or

document reads/writes, response shaping, registered operations, batch items, and

fork dispatch.

Trace data intentionally omits request bodies, response bodies, cookie headers,

authorization headers, and query values. Query strings are represented as keys

only, for example ["select", "expand"].

Local Data Explorer

Open the built-in local data explorer after starting the server:

http://127.0.0.1:7331/__db

The local data explorer opens at the default dev-tool route base, /__db. Change it with

server.apiBase when an app needs a different reserved path; schema, batch,

import, events, log, and fork routes move with that base.

Opening http://127.0.0.1:7331/ in a browser shows a small index with links to the local data explorer, schema, GraphQL endpoint, and resource routes. API-style requests to / keep returning JSON discovery data by default.

The explorer is a dark, dense local database-console shell. It keeps a narrow

app rail for major areas, a contextual resource explorer, and a main workspace

that switches panels without writing project files.

The app rail includes:

  • Connections for the local @async/db runtime and safe route summaries
  • Data for resource grids, JSON detail, API, relations, and diagnostics
  • Query for REST and GraphQL examples/runners, with SQL and Explain disabled

unless a future safe capability explicitly enables them

  • Schema for generated schema and field inspection
  • Operations for registered operation availability summaries
  • Logs for local event/log endpoints and source diagnostics
  • Settings for browser-local viewer state and safe store summaries

The contextual explorer groups real manifest objects, including collections,

documents, stores, recent browser-local resources, and diagnostics. Resource

rows use manifest-provided store mappings and action availability to show

read/write state; they do not include seed records or fake activity.

The Data area fetches records through the manifest resource routes. Collections

load a bounded page with offset and limit query parameters, show schema

fields before drift keys found on the loaded page, mark identity fields, render

nested arrays/objects through detail actions, and use relation hints for related

record links. Documents render as a document workspace instead of a collection

grid.

Write controls are driven by the manifest action summaries. Read-only, static,

registered-only, or disabled resources show disabled write state instead of

active create/patch/delete controls. When a resource is writable, edits are

staged in browser state from the selected JSON panel and Apply/Discard controls

appear only while the staged value differs from the loaded record. Applying an

edit calls the existing REST route for that resource so normal validation,

route exposure, policy, and store behavior still own the result.

The Query area has five modes:

  • Resource builds REST-shaped read URLs from the selected resource route,

select, expand, offset, and limit. It disables execution when direct

REST is disabled or configured as registered-only.

  • Operation posts variables to the public registered-operation endpoint/ref

shape when operations are enabled and client-safe refs or summaries exist.

The viewer does not receive or render server operation templates.

  • GraphQL uses the configured GraphQL endpoint only when GraphQL is enabled and

exposed. It supports a query editor, variables editor, optional operation

name, SDL loading, and examples derived from resource metadata.

  • SQL and Explain are visible product slots but disabled by default. A driver

name such as SQLite or Postgres is not enough to enable arbitrary SQL or plan

inspection; a future safe capability contract must opt in explicitly.

The Schema area inspects only the manifest and generated schema payloads. It

shows resource kind, identity, store mapping, validation/unknown-field summary,

field types, required/nullable/default/enum metadata, read-only or

derived/computed flags, relation hints, and UI hints when those are already in

the manifest/schema output.

The Logs area combines safe local diagnostics and session activity:

  • manifest diagnostics;
  • live resource events from the configured events route when live events are

available;

  • request trace rows from the runtime log stream when the user connects it;
  • import status summaries;
  • batch request status summaries.

Log rows are classified as error, warning, info, live event, or request trace

and are rendered as summaries. They do not include request bodies, response

bodies, auth headers, cookies, source paths, runtime state paths, connection

strings, raw client objects, or source hashes.

The Settings area is read-only. It shows viewer routes, response formats,

custom viewer links, route exposure, store registry summaries, resource store

mappings, import/GraphQL/Falcor/operation availability, and browser-local keys.

Driver cards are capability summaries only; they do not install plugins or

mutate project config.

The explorer still includes drag-and-drop CSV import into the configured data

folder (db/), REST specs with copyable examples, a REST request runner,

GraphQL SDL and operation references, schema and field inspection, and source

diagnostics when one data file is broken.

Custom Viewer Manifest

The built-in local data explorer reads the same JSON manifest that custom viewer UIs can use:

GET /__db/manifest
GET /__db/manifest.json
GET /__db/manifest.html
GET /__db/manifest.md

/manifest.json returns JSON. /manifest.html returns the built-in formatted JSON viewer with dark mode by default, dark/light/system theme controls, copy, and pretty/raw formatting controls. /manifest.md returns Markdown with the manifest JSON in a fenced code block for AI clients. /manifest chooses from registered response formats using the request Accept header. If server.apiBase changes, the routes move with it, for example GET /_db/manifest.

The manifest includes:

  • API links for the local data explorer, manifest, manifest JSON/HTML/Markdown routes, schema, events, batch, import, GraphQL, and each REST resource
  • built-in and configured custom viewer links
  • resource and field metadata, including generated UI hints and relation hints
  • safe store summaries, effective resource store mappings, write modes, and capability booleans
  • route exposure summaries for REST, viewer, schema, manifest, GraphQL, Falcor, and registered operations
  • registered operation availability, accepted ref mode, contract names, and whether client-safe refs are configured
  • resource action availability for read, create, patch, delete, replace, batch, CSV import, operations, and GraphQL
  • active query modes a viewer can safely enable for each resource
  • UI capabilities such as writes, batching, CSV import, GraphQL, and live events
  • diagnostics suitable for display in a custom UI

The manifest does not include seed records, source paths, source hashes, runtime

state paths, raw client objects, connection details, server operation

templates, request bodies, response bodies, auth headers, cookie headers, or

GraphQL SDL. Custom viewers should fetch manifest.json for UI metadata and

route links, then fetch actual records from REST, GraphQL, or registered

operations only when the manifest says those modes are available. api.formats

lists the registered response formats, media types, and manifest paths for

custom viewers and tools.

Add custom viewer links when a project ships its own data UI:

Override the built-in Markdown renderer when a project needs a different shape:

import { defineConfig } from '@async/db/config';
import { stringify as stringifyYaml } from 'yaml';

export default defineConfig({
  server: {
    viewerLinks: [
      { label: 'App Data Viewer', href: 'http://127.0.0.1:5173/db' },
    ],
  },
});

You can also write the same shape to a committed artifact:

import { defineConfig } from '@async/db/config';

export default defineConfig({
  outputs: {
    viewerManifest: './src/generated/db.viewer.json',
  },
});
async-db viewer manifest --out ./src/generated/db.viewer.json

REST Routes

REST routes are enabled by default. Set rest.enabled: false to turn off

generated resource routes and REST batching while keeping dev-tool routes such

as the local data explorer, schema, manifest, import, events, and GraphQL available.

The app-facing REST route base defaults to /db, matching the data folder (db/).

For a data file at db/users.json, fetch the synced runtime resource with:

const users = await fetch('/db/users.json').then((response) => response.json());

Scoped REST remains available under the tool route base, such as

GET /__db/rest/users.json. Standalone async-db serve also keeps root REST

routes such as GET /users for local convenience. Set server.dataPath: false

to disable only the /db alias.

When a prototype route needs to become an app-owned API route, see the

Prototype To Production REST Guide for

/api/db/*, /api/*, registered operation refs, and route lockdown.

File-Like .json Routes

Use .json routes when you want the URL to resemble the source data file path:

db/users.json becomes GET /db/users.json. The server still reads the

synced runtime resource, not the source JSON file directly, so local writes

continue going to the selected runtime store.

Collections can use .json for list and record reads:

GET /db/users.json
GET /db/users/u_1.json

Singleton documents can use the same file-like read shape:

GET /db/settings.json

Use ?id= only with the explicit collection .json route:

GET /db/users.json?id=u_1

Extensionless REST routes keep normal REST semantics and return a structured

error for ?id=. Use GET /db/users/u_1.json or GET /db/users/u_1 there.

Query options such as select, expand, offset, and limit apply before

the response format renders. Sibling .html and .md routes use the same

shaped data for browser or Markdown views.

Collections:

GET     /db/users.json
GET     /db/users/:id.json
POST    /db/users
PATCH   /db/users/:id
DELETE  /db/users/:id

Singleton documents:

GET     /db/settings.json
PUT     /db/settings
PATCH   /db/settings

REST examples:

curl http://127.0.0.1:7331/db/users.json
curl 'http://127.0.0.1:7331/db/users.json?select=id,name&offset=0&limit=20'
curl 'http://127.0.0.1:7331/db/users.json?id=u_1&select=id,name'
curl http://127.0.0.1:7331/db/users/u_1.json
curl http://127.0.0.1:7331/db/settings.json
curl -X POST http://127.0.0.1:7331/db/users \
  -H 'content-type: application/json' \
  -d '{"id":"u_2","name":"Grace Hopper","email":"grace@example.com"}'
curl -X PATCH http://127.0.0.1:7331/db/users/u_2 \
  -H 'content-type: application/json' \
  -d '{"name":"Rear Admiral Grace Hopper"}'
curl -X DELETE http://127.0.0.1:7331/db/users/u_2

Schema-backed computed fields are resolved only when selected. For example,

GET /db/users/u_1.json?select=id,fullName calls the trusted resolver registered

by field.computed(...); default reads continue returning only stored data

fields.

REST Formats

Resource GET routes return JSON by default. The explicit .json extension uses the same shaped data:

GET /db/users
GET /db/users.json
GET /db/users.html
GET /db/users.md
GET /db/users/u_1
GET /db/users/u_1.json
GET /db/users/u_1.html
GET /db/users/u_1.md

.json, .html, and .md are built in. Config entries with the same extension override the built-in resource renderer, and object entries can also override manifest rendering. Extensionless resource and manifest routes negotiate registered media types from Accept; unsupported Accept values fall back to the configured default format. Format renderers receive data after normal REST shaping, so select, expand, offset, and limit apply before rendering.

import { defineConfig } from '@async/db/config';

export default defineConfig({
  rest: {
    formats: {
      default: 'json',
      md({ resourceName, data }) {
        return {
          body: `# ${resourceName}\n\n\`\`\`json\n${JSON.stringify(data, null, 2)}\n\`\`\`\n`,
          contentType: 'text/markdown; charset=utf-8',
        };
      },
      yaml: {
        mediaTypes: ['application/yaml', 'text/yaml'],
        contentType: 'application/yaml; charset=utf-8',
        render({ data }) {
          return stringifyYaml(data);
        },
        renderManifest({ data }) {
          return stringifyYaml(data);
        },
      },
    },
  },
});

Function shorthand is resource-only for compatibility. Use object syntax when a format needs media-type negotiation or manifest support, such as GET /__db/manifest.yaml. @async/db does not execute .jsx routes directly; JSX is a source/runtime choice for your renderer, while .html is the response format.

Relationship Expansion

Schema-backed scalar fields can declare relation metadata while data files keep plain ids:

{
  "authorId": {
    "type": "string",
    "required": true,
    "relation": {
      "name": "author",
      "to": "users",
      "toField": "id",
      "cardinality": "one"
    }
  }
}

Then REST can expand that to-one relation:

curl 'http://127.0.0.1:7331/db/posts/p_1.json?expand=author&select=id,title,author.name'

select supports top-level fields and one nested expanded relation field. Relation expansion is depth 1 in this MVP. Reverse to-many expansion is intentionally deferred.

REST Batching

REST batching is supported through the scoped tool endpoint and the standalone

development alias:

POST /__db/batch
POST /batch

If server.apiBase is changed, the batch endpoint follows that base, for

example POST /_db/batch.

[
  {
    "method": "GET",
    "path": "/db/users.json"
  },
  {
    "method": "PATCH",
    "path": "/db/settings",
    "body": {
      "theme": "dark"
    }
  }
]

REST batches execute sequentially and are intentionally non-transactional. If an earlier write succeeds and a later batch item fails, the earlier write stays committed.

Errors are shaped for humans and automation:

{
  "error": {
    "code": "REST_BATCH_INVALID_PATH",
    "message": "REST batch path must start with \"/\": users",
    "hint": "Use absolute local paths such as \"/users\", \"/settings\", or \"/__db/schema\".",
    "details": {
      "path": "users"
    }
  }
}

Canonical Resource Routes And Bulk Processing

Standalone async-db serve also exposes canonical resource aliases:

GET     /resources/users
GET     /resources/users/:id
POST    /resources/users
PATCH   /resources/users/:id
DELETE  /resources/users/:id

These aliases use the same runtime resources, schema validation, response

formats, and write behavior as /db/*, root REST, and /__db/rest/*.

Bulk create sends an array of records to the collection route:

curl -X POST http://127.0.0.1:7331/resources/users \
  -H 'content-type: application/json' \
  -d '[{"id":"u_1","name":"Ada"},{"id":"u_2","name":"Grace"}]'

Bulk patch can apply one patch to several ids:

{
  "ids": ["u_1", "u_2"],
  "patch": {
    "active": false
  }
}

It can also accept per-record patch items:

[
  { "id": "u_1", "patch": { "name": "Ada Lovelace" } },
  { "id": "u_2", "patch": { "name": "Grace Hopper" } }
]

Bulk replace uses PUT /resources/users with a records array. Only listed ids

are replaced; unlisted records are preserved. Replacement records must satisfy

the resource schema.

Bulk delete accepts repeated query ids or a body:

DELETE /resources/users?id=u_1&id=u_2
{ "ids": ["u_1", "u_2"] }

Bulk operations execute sequentially and do not roll back earlier successful

items. Responses include per-item status and a summary:

{
  "results": [
    { "index": 0, "id": "u_1", "status": 200, "body": { "id": "u_1" } }
  ],
  "summary": { "ok": 1, "errors": 0 }
}

Registered REST Operations

Registered queries are optional REST or GraphQL request templates with callable

refs and optional names. They let production-style apps allowlist specific

reads and writes while local data-file CRUD can stay open by default. The

underlying config and CLI still use the operations name.

GET /users/{id}.json?select=id,name
{
  "name": "GetUser",
  "method": "GET",
  "path": "/users/{id}.json",
  "query": {
    "select": "id,name"
  }
}

GraphQL templates use the same registry and ref execution route:

{
  "name": "GetUser",
  "query": "query GetUser($id: ID!) { user(id: $id) { id name } }",
  "operationName": "GetUser",
  "variables": {
    "id": "{id}"
  }
}

Build the registry and optional client-safe refs:

async-db operations build --out ./src/generated/db.operations.json --refs-out ./src/generated/db.operation-refs.json

At runtime, registered operation execution is always a POST to the dev-tool base:

curl -X POST http://127.0.0.1:7331/__db/operations/users.get \
  -H 'content-type: application/json' \
  -d '{"variables":{"id":"u_1"}}'

Readable names work as app-facing operation refs. Refs are allowlist identifiers, not

secrets. Generated refs default to hashOperation(template), and apps can set

their own ref in operation sources when they want readable or app-owned ids.

Keep the full registry server-side. Client refs contain names and callable refs

but not full request templates, variables, or request bodies. Use

async-db operations contract --check in CI when committed refs need approval

before the browser-facing operation contract changes. operations.acceptRefs

controls whether the server accepts refs, names, or both. Use operations.resolveRef or

operations.validateRef for custom server-side registry lookup or policy.

To block raw REST while allowing registered operations:

import { defineConfig } from '@async/db/config';

export default defineConfig({
  outputs: {
    operationRegistry: './src/generated/db.operations.json',
  },
  operations: {
    enabled: true,
    acceptRefs: 'ref',
  },
  server: {
    expose: {
      rest: 'registered-only',
      graphql: false,
      viewer: 'dev',
      schema: 'dev',
      manifest: 'dev',
    },
  },
});

registered-only blocks raw REST resource and batch routes. Registered REST

operation execution still uses normal REST shaping, including select,

formats, schema validation, and computed resolver projection. Registered

GraphQL operations execute through the same GraphQL executor as direct GraphQL

requests, and still require graphql.enabled !== false.

The viewer manifest mirrors these exposure choices under routeExposure and

per-resource actions. For example, server.expose.rest: 'registered-only'

keeps registered operations available but marks direct resource reads and batch

requests unavailable with the registered-only reason. A custom viewer should

disable controls from those summaries before issuing a request.

GraphQL Boundary

GraphQL is available at /graphql when you opt in with graphql.enabled: true.

A scoped alias is also available at /__db/graphql for embedded dev servers.

It supports aliases, variables, operationName, __typename, named and inline

fragments, @include/@skip, HTTP batching, and minimal

__schema/__type introspection for local tooling.

Set graphql.enabled: false when an app wants REST, schema, manifest, the local

data explorer, import, and events without a GraphQL endpoint. GraphQL is disabled

by default so the starter surface stays REST-first.

GraphQL HTTP batches execute sequentially and are intentionally non-transactional. If an earlier mutation succeeds and a later batch item fails, the earlier mutation stays committed.

REST remains the documented happy path because REST plus the local data explorer is the intended default workflow.

GraphQL selections use the same read projection/fanout path as REST for computed

fields. Registered GraphQL operations are fixed query templates; they are not a

direct database query language and do not expose backend SQL or Redis commands.

Use server.expose.graphql, server.expose.viewer, server.expose.schema, and

server.expose.manifest to keep non-REST surfaces open, dev, or disabled in

production-like servers.

Unsupported in v1:

  • subscriptions
  • full GraphQL spec introspection
  • general-purpose GraphQL validation beyond @async/db's local subset
  • relation traversal from schema relation metadata; GraphQL projects stored fields in v1

Falcor Boundary

Falcor is available at /model.json when you opt in with falcor.enabled: true

for browser clients using falcor.HttpDataSource('/model.json'). A scoped alias

is also available at /__db/model.json for embedded dev servers. Falcor is

disabled by default.

Supported v1 behavior:

get(pathSets)
set(jsonGraphEnvelope)
call(functionPath, args)

Collections expose JSONGraph list refs and by-id maps:

users.length
users[0] -> usersById.u_1
usersById.u_1.name

Singleton documents expose normal nested document paths, such as

settings.theme.

Falcor set is intentionally direct: it updates collection fields or document

paths through normal @async/db runtime writes and schema validation, then returns

the post-write JSONGraph for the written paths. Creates, deletes, reorders, and

multi-step workflows should use call.

Falcor call maps to registered operations:

{
  "method": "call",
  "callPath": ["operations", "users.get"],
  "arguments": [{ "id": "u_1" }]
}

This executes the registered operation ref or name users.get. If the operation

returns a JSONGraph envelope, @async/db passes it through. Otherwise the result

is wrapped under operations.users.get.result.

Set falcor.enabled: false when an app wants REST, GraphQL, schema, manifest,

the local data explorer, import, and events without a Falcor endpoint.

Watch Behavior

serve watches data files in db/, ignores .db/, reloads valid resources when files change, and surfaces file-specific diagnostics in the local data explorer without breaking unrelated resources.

If an app commits generated files under frontend source folders, Vite may still reload when those files genuinely change. Only ignore generated files that the browser does not need to hot reload.