Sitelet https://github.com/async/db/blob/main/docs/prototype-to-production.md
Skip to content

Latest commit

 

History

History
512 lines (415 loc) · 14.9 KB

File metadata and controls

512 lines (415 loc) · 14.9 KB

Prototype To Production REST Guide

@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.

Prototype Defaults

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/:ref

Use /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.

Pick A Production Namespace

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.json

It also moves registered operation execution to:

POST /api/db/operations/GetUserProfile
POST /api/db/operations/users.profile.get

The operation templates themselves still use internal resource paths such as /users/{id}.json. The public route is the /operations/:ref endpoint.

Register Stable Operation Refs

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/GetUserProfile

The server looks up GetUserProfile, substitutes variables, and executes the registered REST template through normal @async/db shaping and validation.

Use Opaque Refs When You Need Them

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,email

and 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.json

Keep 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.json

Runtime 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.

Operation-Only Exposure

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 /users

Registered operations still run:

POST /api/db/operations/GetUserProfile
POST /api/db/operations/users.profile.get

The 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.

Turn Off Unused Endpoints

@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 --json

The 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.

Move To An Owned API Server

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 ./server

The generated server is the right direction when the data API needs its own repository, deploy process, migrations, and production storage.

Graduation Checklist

  • 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, or acceptRefs: 'name' for readable internal APIs.
  • Run async-db usage scan --production before 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: false and falcor.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.