Skip to content

Latest commit

 

History

History
1165 lines (942 loc) · 41.7 KB

File metadata and controls

1165 lines (942 loc) · 41.7 KB

Package API

This page covers the CLI, runtime API, HTTP client, and package exports.

CLI

With a package script like "db": "async-db":

pnpm run db -- sync
pnpm run db -- init --template data-first
pnpm run db -- init --template schema-first
pnpm run db -- init --template source-file
pnpm run db -- types
pnpm run db -- types --watch
pnpm run db -- types --out ./src/generated/db.types.d.ts
pnpm run db -- schema
pnpm run db -- schema users
pnpm run db -- schema infer users
pnpm run db -- schema infer users --out db/users.schema.jsonc
pnpm run db -- schema manifest --out ./src/generated/db.schema.json
pnpm run db -- schema validate
pnpm run db -- schema migrate inspect ./src --out ./src/generated/db.schema-migration.json
pnpm run db -- schema migrate generate --plan ./src/generated/db.schema-migration.json --schema-dir ./db --format mixed
pnpm run db -- viewer manifest --out ./src/generated/db.viewer.json
pnpm run db -- operations build
pnpm run db -- operations build --out ./src/generated/db.operations.json --refs-out ./src/generated/db.operation-refs.json
pnpm run db -- contracts check
pnpm run db -- contracts refs --out ./src/generated/db.contract-refs.json
pnpm run db -- usage scan ./src --production
pnpm run db -- usage scan ./src --production --out ./src/generated/db.usage.json
pnpm run db -- integrate inspect ./src --sqlite ./data/app.sqlite
pnpm run db -- integrate inspect ./src --sqlite ./data/app.sqlite --out ./src/generated/db.integration.json
pnpm run db -- integrate inspect ./src --sqlite ./data/app.sqlite --target-state ./data/app.asyncdb --out ./src/generated/db.integration.json
pnpm run db -- integrate inspect ./src --postgres
pnpm run db -- integrate inspect ./src --postgres --postgres-url-env DATABASE_URL --schema public --out ./src/generated/db.integration.json
pnpm run db -- integrate inspect ./src --postgres --postgres-url-env DATABASE_URL --target-postgres-table public._async_db_resources --out ./src/generated/db.integration.json
pnpm run db -- integrate generate importer --plan ./src/generated/db.integration.json --out ./scripts/import-legacy-sqlite.js
pnpm run db -- doctor
pnpm run db -- doctor --production
pnpm run db -- doctor --production --usage ./src --json
pnpm run db -- doctor --json
pnpm run db -- check --strict --production
pnpm run db -- create users '{"id":"u_2","name":"Grace Hopper","email":"grace@example.com"}'
pnpm run db -- serve
pnpm run db -- generate hono
pnpm run db -- generate hono --api rest,graphql --out ./server

Inside npm scripts, db resolves to the local dependency binary. Equivalent direct commands:

async-db init --template data-first
async-db sync
async-db types
async-db schema validate
async-db schema migrate inspect ./src --json
async-db schema migrate inspect ./src --out ./src/generated/db.schema-migration.json
async-db schema migrate generate --plan ./src/generated/db.schema-migration.json --schema-dir ./db --format mixed
async-db viewer manifest --out ./src/generated/db.viewer.json
async-db operations build
async-db usage scan ./src --production
async-db integrate inspect ./src --sqlite ./data/app.sqlite --json
async-db integrate inspect ./src --sqlite ./data/app.sqlite --target-state ./data/app.asyncdb --out ./src/generated/db.integration.json
async-db integrate inspect ./src --postgres
async-db integrate inspect ./src --postgres --postgres-url-env DATABASE_URL --schema public --json
async-db integrate inspect ./src --postgres --postgres-url-env DATABASE_URL --target-postgres-table public._async_db_resources --out ./src/generated/db.integration.json
async-db integrate generate importer --plan ./src/generated/db.integration.json --out ./scripts/import-legacy-sqlite.js
async-db doctor
async-db doctor --production
async-db doctor --production --usage ./src --json
async-db check --strict --production
async-db backup --out ./backups/db-backup.json
async-db restore featureFlags --list
async-db restore featureFlags --version latest
async-db restore --from ./backups/db-backup.json --dry-run
async-db serve
async-db generate hono

backup bundles every resource's JSON state (with content hashes) into one file and records backup recency for doctor --production. restore rolls a resource back to a version snapshot (requires stores.json.durability: 'versioned' for ongoing history) or restores from a backup bundle; both snapshot the current contents first so restores are undoable.

With pnpm and a "db": "async-db" script, pass arguments through pnpm run:

pnpm run db -- sync
pnpm run db -- schema validate
pnpm run db -- serve

With Deno, run @async/db through Deno's npm package support. From a Deno-only project, the first init can create deno.json tasks without creating a root package.json:

deno run --allow-read=. --allow-write=. --allow-sys=hostname npm:@async/db init --workflow deno --template data-first

After a db task exists in deno.json, pass arguments directly; Deno tasks do not use npm or pnpm -- forwarding:

deno task db init --workflow deno --template data-first
deno task db sync
deno task db schema validate
deno task db:serve

Minimum Deno permissions are --allow-read=. --allow-write=. --allow-sys=hostname for sync, validation, and type generation, plus --allow-net=127.0.0.1 for local serve. If Deno resolves npm packages through a private or local registry, set NPM_CONFIG_REGISTRY=https://registry.npmjs.org for the command.

Supported Deno scope is local CLI development through the npm package: root, /client, /schema, and /git imports; sync; schema validate; generated types; local serve; JSON mirror state; schema helpers; client helpers; and Git source helpers. JSR dual-publish, Deno Deploy, and a Node-free edge runtime are not part of this release.

async-db init

Scaffold the smallest valid local project shape:

async-db init
async-db init --template schema-first
async-db init --template source-file
async-db init --workflow node
async-db init --workflow deno --template data-first
async-db init --dry-run --json

Templates:

Template Writes
data-first db/users.json, .gitignore, workflow scripts or tasks
schema-first db/users.schema.jsonc with empty seed
source-file db.config.js with stores.default: 'sourceFile' and db/appState.json

init refuses to overwrite existing files, runs sync after writing files, and prints follow-up commands. --workflow auto uses the Deno workflow only when deno.json exists and no package.json exists; otherwise it preserves the Node/package workflow. --workflow node creates or patches package.json scripts. --workflow deno creates or patches deno.json tasks and does not create a root package.json. When an existing package is not "type": "module", or when the Deno workflow has no package boundary, the source-file template writes the config with Node's explicit ESM module extension instead of .js, so init never changes a project's module type.

Runtime API

import { openDb } from '@async/db';

const db = await openDb({
  outputs: {
    stateDir: './.db',
  },
  stores: {
    default: 'json',
  },
});

const users = db.users;

await users.create({
  id: 'u_2',
  name: 'Grace Hopper',
  email: 'grace@example.com',
  role: 'user',
});

const ada = await users.get('u_1');
const hasGrace = await users.exists('u_2');

const recentAdmins = await users.find({
  where: { role: 'admin' },
  orderBy: '-createdAt',
  limit: 10,
});
const userCount = await users.count({ where: { active: true } });

await db.close();

Resource names are exposed as properties when you use generated DbTypes with openDb<DbTypes>(). Explicit lookup remains available through callable controls and through the reserved db._ namespace:

await db.users.find({ where: { role: 'admin' } });
await db.settings.get();
await db.collection('users').all();
await db._.collection('users').all();
await db._.document('settings').get();

Callable controls are also resource namespaces when a resource has the same name:

await db.collection('users').all(); // control call
await db.collection.find(); // resource named "collection"
await db.resourceNames(); // control call
await db.resourceNames.find(); // resource named "resourceNames"
await db._.close(); // explicit control namespace

Git Source API

@async/db/git exports the Git-backed source helpers used by config and schema files:

import { githubRemote, gitFiles, gitFile, gitCollectionFile } from '@async/db/git';

Use githubRemote() in db.config.js:

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

export default defineConfig({
  git: {
    remotes: {
      content: githubRemote({
        repo: 'acme/site-content',
        branch: 'main',
        mode: 'app',
      }),
    },
  },
});

Use gitFiles(), gitFile(), and gitCollectionFile() in schema modules to map repository files to Async DB resources. async-db sync reads the Git snapshot, validates through the normal schema path, writes generated schema/types/manifests, and hydrates the runtime mirror. Local API reads then use the mirror instead of making live GitHub calls per request.

mode: "token" can read GitHub tree snapshots directly with a token or public repository access. mode: "app", "actions-pull", and "actions-dispatch" are integration modes owned by @async/github-app; pass an injected client or bridge snapshot when those modes need to participate in sync before the GitHub integration package is installed in your app runtime.

The package also exposes db._.git.flush(), db._.git.pending(), db._.git.receipts(), and db._.git.reconcile() as the control surface for Git mirror/outbox work. In this slice the methods expose the API shape and mirror mode; the live branch, commit, PR, webhook, Actions bridge, and receipt mechanics are delegated to @async/github-app.

Receipt-mode mirrors reject direct package API, REST, and GraphQL writes unless a Git write driver owns the commit/receipt path. Use sqliteMirror({ writes: "through" }) when local writes should be accepted into a durable SQLite outbox before Git flush.

In-Memory Filesystem

openDb() can take a filesystem adapter when a tool, test, or embedded runtime needs to boot from virtual files and keep generated output out of the real project folder:

import { createMemoryFs, openDb } from '@async/db';

const fs = createMemoryFs({
  cwd: '/virtual-app',
  files: {
    'db/users.json': JSON.stringify([
      { id: 'u_1', name: 'Ada Lovelace' },
    ]),
  },
});

const db = await openDb({
  cwd: '/virtual-app',
  fs,
  stores: {
    default: 'json',
  },
});

await db.collection('users').create({
  id: 'u_2',
  name: 'Grace Hopper',
});

const state = await fs.readFile('/virtual-app/.db/state/users.json', 'utf8');

The adapter is used for data-file reads, generated outputs, JSON runtime state, sourceFile writebacks, operations manifests, forks, branches, and snapshots. Executable local code such as db.config.js and .schema.js still runs through Node's module loader, so virtual projects should use inline options or JSON and .schema.json schema sources.

Collections also expose small store-neutral query helpers for local app reads: find({ where, orderBy, limit, offset }), count({ where }), and aggregate({ groupBy, metrics }). These helpers run over the collection API first, so JSON stores, Async DB-owned SQLite stores, and table-backed SQLite resources share one app-facing shape.

Minimal Collection Queries

Minimal queries are intended for local app reads, admin screens, dashboards, and small read models before you need a custom operation or store-specific query planner. The first implementation runs over collection.all(), which means it works for the default JSON-backed store without extra setup. Existing table-backed SQLite and Postgres resources expose the same helper shape.

const packages = db.collection('packages');

const blocked = await packages.find({
  where: {
    status: 'blocked',
    requestCount: { gte: 5 },
    name: { contains: '@company/' },
  },
  orderBy: [{ field: 'requestCount', direction: 'desc' }, 'name'],
  limit: 25,
  offset: 0,
});

const blockedCount = await packages.count({
  where: { status: 'blocked' },
});

where supports equality shorthand plus small operator objects:

await packages.find({
  where: {
    status: { in: ['blocked', 'review'] },
    requestCount: { gt: 0, lte: 100 },
    sourceRegistry: { ne: 'internal' },
  },
});

Supported operators are eq, ne, in, gt, gte, lt, lte, and contains. orderBy accepts a field name, a -field descending shorthand, a { field, direction } object, or an array of those entries.

Aggregates return plain rows. Group fields are copied onto each result row, and metrics are named by the keys you choose:

const byStatus = await packages.aggregate({
  where: { sourceRegistry: 'npm' },
  groupBy: 'status',
  metrics: {
    count: 'count',
    requests: { op: 'sum', field: 'requestCount' },
    maxRequests: { op: 'max', field: 'requestCount' },
  },
  orderBy: '-requests',
});

Supported aggregate operations are count, sum, min, max, and avg. Use registered operations or a store-specific adapter when a query needs indexes, joins, full SQL semantics, or must stay efficient over large collections.

Call db.close() when a long-running process is done with the database so stores with open handles, such as SQLite or Postgres clients, can release them.

Use createDbRuntime() when custom Node middleware should own the same development lifecycle as async-db serve: open the db, sync or hydrate, watch data files in db/, publish lifecycle events, expose request middleware, and clean everything up together.

import http from 'node:http';
import { createDbRuntime } from '@async/db';

const runtime = await createDbRuntime({
  cwd: process.cwd(),
  watch: true,
});

const server = http.createServer((request, response) => {
  runtime.handleRequest(request, response).then((handled) => {
    if (!handled) {
      response.writeHead(404).end();
    }
  });
});

server.once('close', () => {
  void runtime.close();
});

Use createDbRequestHandler(db, options) only when app code already owns the database lifecycle and file watching.

Use inspectSqliteIntegration() when an existing app already owns SQLite tables and you want an adoption plan before changing storage. Existing SQLite stays the source of truth by default. The inspector is read-only: it opens the SQLite file for schema metadata, scans source text for common SQLite usage, and returns a versioned report for people or coding agents.

import { inspectSqliteIntegration } from '@async/db';

const report = await inspectSqliteIntegration({
  cwd: process.cwd(),
  target: './src',
  sqliteFile: './data/app.sqlite',
  // Optional: only when planning an explicit import into Async DB-owned state.
  targetState: './data/local-registry.asyncdb',
});

console.log(report.recommendations);

The report has kind: "db.integrationReport" and includes:

  • SQLite inventory: tables, columns, primary keys, indexes, foreign keys, row counts, and table classifications.
  • Source inventory: high-confidence SQLite imports, prepared statements, raw SQL, migration files, low-level driver hints, and ORM/query-builder signals.
  • Recommendations: direct-resource, read-model, custom-store, app-owned-sql, or manual-review.
  • Suggestions: wrapper-first adoption guidance such as keeping existing SQLite as source of truth, using operations for compound keys, and exposing read models first.
  • Adoption paths: operation-wrapper, read-model, table-backed-adapter, or app-owned-sql with storage migration marked as optional or not recommended.
  • Import plan: only when targetState or CLI --target-state is provided, with explicit legacy-table to Async DB-owned resource mapping.
  • Suggested files: db.config.js, resource schemas, committed schema/viewer manifests, and optional adapter/read-model modules.
  • Agent instructions: a short next-step checklist that favors wrapping existing DB calls before replacing app-owned writes.

For existing SQLite apps, prefer this order:

  1. Wrap current DB facade methods with Async DB operations.
  2. Expose views, event logs, and dashboard tables as read-only resources.
  3. Use openSqliteDb({ tables }) for simple table-backed resources.
  4. Move storage only after app parity tests pass.

sqliteStore() is Async DB-owned SQLite storage for resource JSON envelopes. Use openSqliteDb({ tables }) when the app already owns relational SQLite tables and the database file must stay in place:

import { openSqliteDb } from '@async/db/sqlite';

const db = await openSqliteDb({
  cwd: process.cwd(),
  file: './data/app.sqlite',
  migrate: false,
  tables: {
    users: {
      table: 'app_users',
      columns: {
        id: 'user_id',
        name: 'full_name',
      },
      primaryKey: 'id',
    },
    packageVersions: {
      table: 'package_versions',
      primaryKey: ['name', 'version'],
    },
  },
});

await db.table('users').get('u_1');
await db.table('packageVersions').get({ name: '@async/db', version: '0.4.0' });

sqliteMirror() is the Git-backed resource mirror wrapper. It uses the same optional SQLite store machinery, creates internal Git sync/outbox/receipt tables, and can be selected globally for Git resources through git.mirror:

import { defineConfig } from '@async/db/config';
import { githubRemote } from '@async/db/git';
import { sqliteMirror } from '@async/db/sqlite';

export default defineConfig({
  git: {
    remotes: {
      content: githubRemote({
        repo: 'acme/marketing-content',
        branch: 'main',
        mode: 'app',
      }),
    },
    mirror: sqliteMirror({
      file: './.db/git-mirror.sqlite',
      writes: 'through',
    }),
  },
});

Without git.mirror, Git-backed resources use the default JSON mirror under .db/state with receipt-mode writes.

Use @async/db/sqlite/compat when transitional code already has a low-level SQLite driver handle. Compat supports node:sqlite, better-sqlite3, sqlite3, and the sqlite promise wrapper without adding mandatory dependencies:

import { openLegacySqlite, compoundKeyId } from '@async/db/sqlite/compat';

const legacy = await openLegacySqlite({
  driver: 'node:sqlite',
  file: './data/local-registry.sqlite',
  readOnly: true,
  tables: {
    packageVersions: {
      table: 'package_versions',
      primaryKey: ['name', 'version'],
    },
  },
});

const packageVersion = await legacy.table('packageVersions').get({
  name: '@async/db',
  version: '0.4.0',
});
const id = compoundKeyId(['name', 'version'], packageVersion);

For explicit import mode, generate a dry-run importer from an integration report:

async-db integrate inspect ./src --sqlite ./data/local-registry.sqlite \
  --target-state ./data/local-registry.asyncdb \
  --out ./src/generated/db.integration.json
async-db integrate generate importer \
  --plan ./src/generated/db.integration.json \
  --out ./scripts/import-legacy-sqlite.js
node ./scripts/import-legacy-sqlite.js
node ./scripts/import-legacy-sqlite.js --apply

Use inspectPostgresIntegration() when an existing app already owns Postgres tables and you want an adoption plan before changing storage. Existing Postgres stays the source of truth by default. Without postgresUrlEnv, the inspector does source-only guidance. With postgresUrlEnv, it performs read-only catalog inspection and redacts the URL value from reports and errors.

import { inspectPostgresIntegration } from '@async/db';

const report = await inspectPostgresIntegration({
  cwd: process.cwd(),
  target: './src',
  postgresUrlEnv: 'DATABASE_URL',
  schemas: ['public'],
  // Optional: only when planning an explicit import into Async DB-owned state.
  targetPostgresTable: 'public._async_db_resources',
});

console.log(report.postgres.mode);
console.log(report.recommendations);

The Postgres report has kind: "db.integrationReport" and includes:

  • Postgres mode: source-only, catalog, or partial.
  • Catalog inventory: schemas, tables, views, materialized views, columns, primary keys, unique indexes, foreign keys, triggers, RLS policies, and estimated row counts.
  • Source inventory: low-level driver imports, raw SQL, migration/schema ownership files, DB facade files, and ORM/query-builder signals.
  • Recommendations and adoption paths parallel to SQLite: wrapper, read-model, table-backed, app-owned SQL, and manual review.
  • Import plan: only when targetPostgresTable, targetState, or CLI import flags are provided.

For existing Postgres apps, prefer this order:

  1. Wrap current DB facade methods with Async DB operations.
  2. Expose views, materialized views, event logs, and dashboards as read-only resources.
  3. Use openPostgresDb({ tables }) for simple single non-generated primary-key tables.
  4. Move storage only after app parity tests pass.

postgresStore() is Async DB-owned JSONB envelope storage for resources. Use openPostgresDb({ tables }) when the app already owns relational Postgres tables and the schema must stay in place:

import { openPostgresDb } from '@async/db/postgres';
import { pool } from './src/postgres.js';

const db = await openPostgresDb({
  cwd: process.cwd(),
  client: pool,
  migrate: false,
  tables: {
    users: {
      schema: 'public',
      table: 'app_users',
      columns: {
        id: 'user_id',
        name: 'full_name',
      },
      primaryKey: 'id',
    },
    packageVersions: {
      schema: 'public',
      table: 'package_versions',
      primaryKey: ['name', 'version'],
      readOnly: true,
    },
  },
});

await db.table('users').get('u_1');
await db.table('packageVersions').get({ name: '@async/db', version: '0.5.1' });

Use @async/db/postgres/compat when transitional code already has a low-level Postgres client. Compat supports pg, postgres, Neon serverless, Vercel Postgres, and pg-promise without adding mandatory dependencies:

import { adaptPostgresClient, openLegacyPostgres, compoundKeyId } from '@async/db/postgres/compat';

const client = adaptPostgresClient(existingPool, { driver: 'pg' });
const legacy = await openLegacyPostgres({
  client,
  readOnly: true,
  tables: {
    packageVersions: {
      schema: 'public',
      table: 'package_versions',
      primaryKey: ['name', 'version'],
    },
  },
});

const packageVersion = await legacy.table('packageVersions').get({
  name: '@async/db',
  version: '0.5.1',
});
const id = compoundKeyId(['name', 'version'], packageVersion);

For explicit Postgres import mode, generate a dry-run importer from an integration report:

async-db integrate inspect ./src --postgres --postgres-url-env DATABASE_URL \
  --target-postgres-table public._async_db_resources \
  --out ./src/generated/db.integration.json
async-db integrate generate importer \
  --plan ./src/generated/db.integration.json \
  --out ./scripts/import-legacy-postgres.js
node ./scripts/import-legacy-postgres.js
node ./scripts/import-legacy-postgres.js --apply

Use --target-state ./data/app.asyncdb instead of --target-postgres-table ... when the import target should be an Async DB-owned local SQLite state file.

Event-log resources can use writePolicy: "append-only" and collection.append(record). Append-only collections reject create, patch, update, delete, and replace-all calls while still allowing append-style event writes.

The eventResource() helper is a small ergonomic convention over this existing append-only support, not append-only support itself. It removes repeated event boilerplate while still calling collection.append(record) under the hood:

import { eventResource } from '@async/db';

const localEvents = eventResource(db.collection('localEvents'), {
  id: () => crypto.randomUUID(),
  now: () => new Date().toISOString(),
  levels: ['info', 'warn', 'error'],
  typePattern: /^[a-z]+(?:\.[a-z]+)+$/,
});

await localEvents.append('app.registered', {
  owner: 'patrickjs',
  app: 'demo',
}, {
  level: 'info',
  message: 'Registered app demo',
});

The helper expands to the manual API that already works today:

await db.collection('localEvents').append({
  id: crypto.randomUUID(),
  type: 'app.registered',
  level: 'info',
  message: 'Registered app demo',
  payload: { owner: 'patrickjs', app: 'demo' },
  createdAt: new Date().toISOString(),
});

The helper accepts an append-only collection-like object, preserves the writePolicy: "append-only" contract by calling only append, fills conventional event fields such as id, type, level, message, payload, and createdAt, and can validate event type or level values through typePattern and levels. It does not add pub/sub, retries, projections, workflow orchestration, audit trails, or a second event bus.

Compound identity is declared with identity.fields; idField remains the single-field shorthand. Package API methods accept scalar ids for single-field identity and object keys for compound identity:

{
  "kind": "collection",
  "identity": { "fields": ["tenantId", "slug"] },
  "fields": {
    "tenantId": { "type": "string", "required": true },
    "slug": { "type": "string", "required": true },
    "name": { "type": "string" }
  }
}
const projects = db.collection('projects');
const key = { tenantId: 'tenant_acme', slug: 'homepage' };

await projects.get(key);
await projects.patch(key, { name: 'Homepage' });
await projects.delete(key);

Single-key REST routes keep /:id. Compound-key resources use /__key:

curl 'http://127.0.0.1:7331/db/projects/__key?tenantId=tenant_acme&slug=homepage'
curl -X PATCH http://127.0.0.1:7331/db/projects/__key \
  -H 'content-type: application/json' \
  -d '{"key":{"tenantId":"tenant_acme","slug":"homepage"},"patch":{"name":"Homepage"}}'

GraphQL uses the resource-specific key input metadata and a key argument for compound-key reads and mutations.

Encoded payload fields can be declared with field.bytes() in executable schemas or { "type": "bytes", "encoding": "hex" } in JSON schemas. Supported encodings are base64, base64url, and hex; validation rejects malformed payload strings while generated TypeScript represents the field as string.

Singleton document usage:

const settings = db.document('settings');

await settings.set('/theme', 'dark');
await settings.set(['ui', 'sidebar', 'collapsed'], true);
await settings.set('locale', 'en-US');

const value = await settings.get('/theme');
const collapsed = await settings.get(['ui', 'sidebar', 'collapsed']);

Document paths support JSON Pointer strings such as /ui/theme, exact array paths such as ['ui', 'theme'], and bare top-level string shorthand such as 'theme'. Use document.put(value) to replace the whole document.

Optimistic Concurrency With ETags

Collection update/patch/delete and document put/update accept an optional { ifMatch } precondition. The write only applies when the stored value's current tag matches; otherwise it fails with a 412 DB_PRECONDITION_FAILED error instead of overwriting a concurrent edit. recordEtag(value) computes the tag for a value you previously read:

import { openDb, recordEtag } from '@async/db';

const users = db.collection('users');
const ada = await users.get('u_1');
const etag = recordEtag(ada);

// Later: only apply the edit if nobody else changed the record meanwhile.
await users.patch('u_1', { name: 'Ada King' }, { ifMatch: etag });

REST exposes the same behavior over HTTP: single-record GET responses carry an ETag header, and item-level PATCH/DELETE plus document PUT/PATCH honor the If-Match request header (* means "must exist"). Mismatches answer 412 with the DB_PRECONDITION_FAILED error envelope. Bulk routes ignore If-Match.

Fork and branch usage:

const tenant = await db.forks.ensure('tenant_acme', {
  from: 'main',
  metadata: {
    purpose: 'tenant',
    plan: 'free',
  },
});

const snapshot = await tenant.snapshots.create({
  label: 'before-projects-migration',
  resources: ['projects'],
});

await tenant.migrations.start('projects-to-postgres', {
  resources: ['projects'],
  mode: 'read-only',
});
await tenant.resources.migrate('projects', {
  from: 'json',
  to: 'postgres',
});
await tenant.migrations.verify('projects-to-postgres', {
  resources: ['projects'],
  checks: ['count', 'checksum'],
});
await tenant.routing.set({
  projects: 'postgres',
});
await tenant.migrations.finish('projects-to-postgres');

void snapshot;

These are low-level database lifecycle APIs. App code decides whether a fork is a tenant, preview, debug copy, demo, or test environment.

Import generated DbTypes from .db/types/index.d.ts or from a committed output file when typed collection names and records should be available to TypeScript. Apps can also add a TypeScript paths alias such as #db/types for the committed generated file; see Generated Types.

Schema Contract API

Use loadDbSchema({ from }) when app code needs the schema contract without opening runtime stores or reading source records. from can point at a project root, a db/ folder, the root db.schema.js / db.schema.js, or one resource schema file.

import { loadDbSchema, openDb } from '@async/db';

const schema = await loadDbSchema({ from: './db.schema.js' });

const validateUserInput = schema.validator('users', {
  mode: 'create',
  unknownFields: 'strip',
});

const input = validateUserInput.assert(await request.json());

Validators reject computed and read-only fields. They default unknown fields to error; use strip, allow, or warn when an endpoint has a different input contract. mode: 'patch' allows partial records and mode: 'replace' keeps required-field checks strict.

Database-derived fields use serializable derived metadata and are also read-only. Use them for generated columns, identity columns, trigger-maintained timestamps, view columns, or externally-owned values:

{
  "type": "datetime",
  "readOnly": true,
  "derived": {
    "source": "database",
    "kind": "trigger"
  }
}

Executable schema files can use field.derived(field.datetime(), { source: 'database', kind: 'trigger' }). computed remains reserved for Async DB resolver-backed fields.

Call computed field resolvers directly when server code wants the same field logic that REST and GraphQL use:

const userResolvers = schema.resolver('users', {
  value: input,
  context: {
    locale: 'en-US',
    nameFormatter,
  },
});

const fullName = await userResolvers.fullName();

schema.resolver('users.fullName') returns one callable resolver. The resolver this value is a delegated context with this.get(name) and this.has(name). User context values win over internal values; this._internal exposes the unoverridden internal view when a resolver needs it. A resolver call can also pass ad hoc arguments, such as { record: input }, when the schema function is written to receive them.

Schema Declaration Migration API

Use schema migrate when a project already declares contracts through Prisma, Drizzle, SQL migrations, JSON Schema/OpenAPI, TypeBox, Zod, Valibot, ArkType, or ORM model files and wants reviewable Async DB schema drafts.

async-db schema migrate inspect ./src --out ./src/generated/db.schema-migration.json
async-db schema migrate generate --plan ./src/generated/db.schema-migration.json --schema-dir ./db --format mixed

inspect does not execute app schema files. It emits kind: "db.schemaMigrationReport" with detected source matches, resource drafts, suggestions, and an output plan. generate writes db/<resource>.schema.jsonc drafts where possible and refuses to overwrite existing schema files unless --force is passed.

--format mixed is the default. It writes .schema.jsonc for static contracts and .schema.js drafts when executable validator behavior needs manual preservation. --format jsonc forces JSONC-only output and reports warnings for unsupported behavior.

Programmatic inspection is available from the root package:

import { inspectSchemaMigration } from '@async/db';

const report = await inspectSchemaMigration({
  cwd: process.cwd(),
  target: './src',
  schemaDir: './db',
  format: 'mixed',
});

console.log(report.kind);
console.log(report.resources.map((resource) => resource.output.file));

Pass a loaded schema to openDb({ schema }) when one process wants to inspect or validate the contract first, then open the runtime database from the same schema locator:

const schema = await loadDbSchema({ from: './db.schema.js' });
const db = await openDb({ schema });

loadDbSchema() is metadata-only by default and does not call content/data readers, runtime stores, or computed resolvers. openDb() defaults to runtime loading and reads the matching data/content sources.

JavaScript schema files can describe folder content sources with the helper exported from @async/db/schema:

import { collection, field, files } from '@async/db/schema';

export default collection({
  source: files('./**/*.mdx', { read: 'frontmatter' }),
  fields: {
    id: field.string({ required: true }),
    title: field.string({ required: true }),
    body: field.string(),
  },
});

Keep runtime store selection in db.config.js, for example resources.docs.store = 'static'.

HTTP Client

import { createDbClient } from '@async/db/client';

const client = createDbClient({
  baseUrl: 'http://127.0.0.1:7331',
  batching: true,
});

const users = await client.rest.get('/db/users.json');

await client.rest.post('/db/users', {
  id: 'u_2',
  name: 'Grace Hopper',
  email: 'grace@example.com',
});

const batch = await client.rest.batch([
  { method: 'GET', path: '/db/users.json' },
  { method: 'GET', path: '/db/settings.json' },
]);

When using createDbClient() directly against standalone async-db serve, use the app-facing /db routes. Scoped clients, such as the Vite virtual client or a fork client, can keep resource paths like /users because the client sets a restBasePath for you.

The client can batch requests made within a short timeout. The default batching window is 10ms. Identical REST GET requests are deduped by default. Writes are not deduped unless you explicitly choose dedupe: 'all'.

Enable the browser cache explicitly when app code should reuse normalized REST and GraphQL reads:

import { createDbClient, createIndexedDbCacheStorage } from '@async/db/client';

const client = createDbClient({
  baseUrl: 'http://127.0.0.1:7331',
  cache: {
    enabled: true,
    storage: 'memory',
    readPolicy: 'cache-first',
    writePolicy: 'merge-and-invalidate',
    eventPolicy: 'invalidate',
  },
});

await client.rest.get('/db/users.json?select=id,name', { cache: 'cache-first' });
await client.graphql('{ users { id name __typename } }', {}, { cache: 'cache-and-network' });

const stop = client.cache.watch(
  { kind: 'rest', method: 'GET', path: '/db/users.json?select=id,name' },
  (snapshot) => {
    render(snapshot.data);
  },
);

const persistedClient = createDbClient({
  baseUrl: 'http://127.0.0.1:7331',
  cache: {
    enabled: true,
    storage: createIndexedDbCacheStorage({ name: 'async-db' }),
  },
});

The cache is off by default. When enabled, the client fetches the viewer manifest once, normalizes collection records by resource id, normalizes documents by resource name, and keeps query results by canonical request key. Cacheable reads use exact in-flight dedupe outside the batching window. Runtime write events from /__db/log invalidate or refetch affected resources according to eventPolicy; data-file reload events from /__db/events refresh the manifest and invalidate cached queries. IndexedDB is explicit opt-in because it persists record data in the browser.

Run registered queries or literal operation templates through the same client. query() is the app-facing alias for operation():

import operationRefs from './generated/db.operation-refs.json' assert { type: 'json' };

await client.query('GetUser', { id: 'u_1' });

await client.query('/db/users/{id}.json?select=id,name', { id: 'u_1' });

await client.query({
  method: 'GET',
  path: '/db/users/{id}.json',
  query: {
    select: 'id,name',
  },
}, { id: 'u_1' });

await client.query({
  query: 'query GetUser($id: ID!) { user(id: $id) { id name } }',
  operationName: 'GetUser',
  variables: {
    id: '{id}',
  },
}, { id: 'u_1' });

await client.query({ name: 'GetUser', ref: 'users.get' }, { id: 'u_1' });

await client.query(operationRefs.operations.GetUser.ref, { id: 'u_1' });

Package/runtime callers can also pass contract context for enforcement:

await db.query(operationRefs.operations.GetUser.ref, {
  id: 'u_1',
}, {
  contract: 'public',
});

String values passed to query() that start with /, or with an HTTP method followed by /, are literal REST templates. Other strings are registered query refs, such as an operation name or explicit ref, and call POST /__db/operations/:ref. Object REST templates execute as normal REST requests. Object GraphQL templates are inferred when an object has query and no REST path, and execute as normal GraphQL requests. The server looks up registered refs, substitutes variables, and runs REST templates through normal REST shaping or GraphQL templates through the GraphQL executor.

Generated operation refs include .name and .ref. .ref is the value app code should call. It defaults to hashOperation(template) unless the operation source provides an explicit ref. Server acceptance is controlled separately with operations.acceptRefs.

Package Exports

Export Use
@async/db Runtime API such as openDb, schema loading, and inspectSchemaMigration.
@async/db/schema .schema.js and .schema.js authoring helpers, including field.derived.
@async/db/config defineConfig, env.var, env.secret, profile config authoring, and manifest helpers.
@async/db/client HTTP client with REST, GraphQL, and batching helpers.
@async/db/json Compatibility exports for the standalone @async/json json() runtime, file database capabilities, and safe JSON state helpers.
@async/db/vite Optional Vite dev server plugin.
@async/db/hono Optional Hono route registration helpers.
@async/db/sqlite Optional SQLite adapter helpers.
@async/db/sqlite/compat Low-level SQLite driver adapters for migration wrappers and generated importers.
@async/db/postgres Optional Postgres runtime store helpers using an injected client.
@async/db/kv Optional generic KV runtime store helpers using an injected get/set client.
@async/db/redis Optional Redis-named KV helper plus additive RedisJSON store adapter.

See Profiles Vs Modes Vs Env Values for when to use top-level config profiles, scoped mode settings, and runtime environment helpers.

The core package stays dependency-light. Optional integrations use dynamic imports, generated app dependencies, or injected database clients.

@async/json owns the standalone JSON file/folder database engine, including runtime collection/document behavior for identity keys, append-only resources, bytes validation, indexes, sidecar state, and RedisJSON key layout. @async/db/json keeps the existing compatibility subpath for the json() entrypoint, JSON store capability metadata, and safe file-state helpers used by tooling, diagnostics, exports, and migrations. Most app code should still use openDb(), createDbClient(), and registered operations so resources can graduate from JSON to RedisJSON, SQLite, Postgres, or custom stores without changing client calls.

@async/db/redis keeps the existing redisStore() KV-style resource store and adds redisJson() for Redis JSON runtimes. redisJson() uses per-record JSON keys for collections and maps only explicit resources.<name>.indexes metadata to Redis Search indexes.

The root export also includes hashOperation(), buildOperationManifest(), and createDbOperationHandler() for tools and framework adapters that want to build or execute registered operation registries without shelling out to the CLI.

createDbOperationHandler(db, options?) returns a small operation executor:

const handler = createDbOperationHandler(db, {
  registry: generatedOperations.operations,
  acceptRefs: 'ref',
});

const result = await handler.execute(operationRefs.operations.GetUser.ref, {
  id: 'u_1',
}, {
  contract: 'public',
});

Use execute(ref, variables, options) for direct calls or executeRequest(ref, body, options) when adapting an HTTP request body shaped as { variables, contract }. Framework adapters should pass registry, acceptRefs, resolveRef, or validateRef at handler creation time, and pass the request contract at execution time.

Use buildContractRefsManifest(), checkContracts(), inferContractsFromTags(), and inferContractsFromUsage() for build-tool workflows that need the same contract logic without shelling out to the CLI.

Inline registries can use full operation objects or string REST templates. The registry key is used as the fallback name and ref, so custom build steps can keep a small manual registry:

const handler = createDbOperationHandler(db, {
  registry: {
    GetUser: '/users/{id}.json?select=id,name',
  },
  acceptRefs: 'name',
});

Repo Example Launcher

Run every repo example and open an index of their local data explorers:

pnpm run examples

The examples index runs on one loopback port and starts each example runtime lazily when you open its demo or /__db local data explorer.

To get an HTTPS URL for the examples index inside your tailnet, opt in to Tailscale Serve:

pnpm run examples -- --tailscale-serve

This runs tailscale serve --bg <port> after the local examples host starts. @async/db does not call tailscale cert, manage local certificate files, or change tailnet settings directly. If MagicDNS or HTTPS certificates still need admin setup, the Tailscale CLI output is shown so you can follow its prompt.