Skip to content

async/json

Repository files navigation

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

About

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

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors