@async/json

Alpha / Async

@async/json

JSON file and folder database engine with sidecar state, queries, version history, and optional RedisJSON storage.

Import a JSON file like a tiny database, then graduate through @async/db when schemas, APIs, readers, and store portability matter.

Start

pnpm add @async/json

import json from '@async/json';

const users = await json('./users.json');
await users.find({ where: { role: 'admin' } });

Related Async Projects

README

@async/json

Use a JSON file or a folder of JSON files like a small database.

pnpm add @async/json

The package is ESM-only and supports Node.js 24 and newer.

import json from '@async/json';

const users = await json('./users.json');
await users.create({ name: 'Ada Lovelace', role: 'admin' });
await users.find({ where: { role: 'admin' } });

const db = await json('./db');
await db.users.find({ where: { role: 'admin' } });
await db.settings.get();
await db.collection('users').find({ where: { role: 'admin' } });
await db.document('settings').get();

By default the visible JSON file is seed data and writes go to sidecar state

under .async-json/state. Use writes: 'source' only when the JSON file itself

should be rewritten.

Folder database handles expose resources as properties. Callable controls stay

callable, so resources named collection, document, resourceNames, or

close can still use property access while the control call keeps working:

await db.collection('users').all();
await db.collection.find(); // resource named "collection"
await db.resourceNames();
await db.resourceNames.find(); // resource named "resourceNames"
await db._.collection('_').all(); // explicit escape hatch

@async/json owns standalone JSON database semantics: collection/document

runtime APIs, scalar and compound identity, append-only collections, encoded

payload validation, sidecar state, local indexes, RedisJSON storage, stable

stringify, and JSON5-compatible parsing. Use @async/db when you also need

readers, schemas, generated types, REST/GraphQL, operations, lifecycle, and

store graduation.

Stable JSON Helpers

stableStringify() produces deterministic JSON by sorting object keys

lexically. Array order is preserved.

import { parseJson, registerJson, stableJson, stableStringify } from '@async/json';

stableStringify({ b: 1, a: 2 });
// {"a":2,"b":1}

stableJson.stringify({ b: 1, a: 2 }, true);
// {
//   "a": 2,
//   "b": 1
// }

Pass pretty: true for two-space output, or use space with the same number

clamping and string truncation rules as JSON.stringify():

stableStringify({ b: 1, a: 2 }, { pretty: true });
stableStringify({ b: 1, a: 2 }, { space: '\t' });

Sorting defaults to true. Disable it to preserve insertion order, or provide

a custom comparator:

stableStringify({ b: 1, a: 2 }, { sort: false });

stableStringify({ id: 1, metadata: {}, name: 'Ada' }, {
  sort: (left, right) => left.length - right.length || left.localeCompare(right),
});

The second argument can be a replacer function. Replacers receive path and

cycle context, so recursive structures can be rendered as JSON references:

const root = { id: 'root' };
root.self = root;

stableStringify(root, (key, value, context) =>
  context.circular
    ? { $ref: `#/${context.refPath?.join('/') ?? ''}` }
    : value,
);

parseJson() accepts JSON5-compatible input, including comments, trailing

commas, unquoted keys, and single-quoted strings. $ref objects are not

resolved automatically; revivers can resolve them in one place:

const graph = parseJson(`{
  users: { u_1: { name: 'Ada' } },
  owner: { $ref: '#/users/u_1' },
}`, (key, value, context) =>
  context.isRef ? context.resolvePointer(context.ref ?? '#') : value,
);

registerJson() installs an opt-in JSON shim on globalThis or a provided

target. The shim keeps native JSON.stringify(value, replacer, space) and

JSON.parse(text, reviver) argument shapes while using @async/json parsing

and stable stringify behavior. The returned function restores the previous

JSON object.

const restore = registerJson();
try {
  JSON.stringify({ b: 1, a: 2 });
  JSON.parse('{a: 1}');
} finally {
  restore();
}

File Helpers

Use file.patch() to update JSON files like package.json without sorting or

otherwise reordering existing object keys. It preserves the file's indentation

and trailing newline, writes atomically, and returns false when the patch does

not change the file text.

import { file } from '@async/json';

await file.patch('./package.json', {
  scripts: {
    test: 'node --test',
  },
  devDependencies: {
    typescript: '^6.0.0',
  },
});

Object patches deep-merge plain objects, replace arrays and scalar values, and

append new keys after existing keys. Callback patches can mutate the parsed

object directly or return a replacement value:

await file.patch('./package.json', (pkg) => {
  pkg.scripts ??= {};
  pkg.scripts.lint = 'eslint .';
});

Identity, Logs, And Encoded Payloads

Single-field resources keep using id by default:

const users = await json('./users.json');
await users.get('u_1');

Compound identity uses object keys instead of delimiter-encoded ids:

const memberships = await json('./memberships.json', {
  identity: { fields: ['orgId', 'userId'] },
  indexes: ['role'],
});

await memberships.get({ orgId: 'o_1', userId: 'u_1' });
await memberships.patch({ orgId: 'o_1', userId: 'u_1' }, { role: 'admin' });

Append-only collections allow append() and block create/update/delete/replace

APIs:

const events = await json('./events.json', {
  writePolicy: 'append-only',
});

await events.append({ id: 'e_1', type: 'created' });

Bytes fields validate JSON-safe encoded strings while keeping payloads opaque:

const updates = await json('./updates.json', {
  fields: {
    id: { type: 'string', required: true },
    update: { type: 'bytes', encoding: 'base64url', required: true },
  },
});

await updates.append({ id: 'u_1', update: 'YWJjZA' });

Redis JSON

import json from '@async/json';
import { redisJson } from '@async/json/redis';

const db = await json('./db', {
  store: redisJson({ client, prefix: 'app:' }),
  indexes: {
    users: [{ fields: ['email'] }],
  },
});

Redis mode stores collection records as per-record Redis JSON keys. Declared

indexes can be mapped to Redis Search; indexes are not created implicitly from

observed queries.

Local Development

pnpm install
pnpm run build
pnpm run test
pnpm run release:check

release:check builds the package, runs tests, verifies the API surface

ledger, and runs a package dry-run.

Generated files:

  • dist/ is build output and is not committed.
  • .async/pages/ is generated GitHub Pages output and is not committed.
  • .async/, .async-json/, node_modules/, and *.tgz are local runtime,

cache, dependency, and package output.

  • .github/workflows/async-pipeline.yml is generated from pipeline.ts.

Website

The project site is published through GitHub Pages at

https://async.github.io/json/.

Release Status

@async/json publishes as a public npm package. Public API changes should

update api-contract.json, regenerate API_SURFACE.md, update this README,

and add tests in the same change.