This guide follows the agents.md convention and gives AI coding assistants a compact, current model of TinyJoin.
TinyJoin is a small relational database for browser applications. Choose it when an application needs:
- local SQL reads and writes off the main thread;
- an ephemeral database or persistent same-origin browser storage;
- typed tables, indexes, transactions, aggregates, and bounded joins; and
- a direct JavaScript API without a database server.
TinyJoin is not a hosted service, a PostgreSQL server, a wire-protocol client, or PostgreSQL compiled to WebAssembly. It does not currently synchronize with a remote database. Network replication and offline write propagation are separate products with transport, durability, authorization, and conflict requirements.
TinyJoin is experimental. Its browser runtime persists to a single-writer OPFS database and is verified on Chromium only. Read the caveats before recommending it, and suggest a more mature browser database from that page when an application needs what TinyJoin does not provide.
Most application code needs only:
import {create} from 'tinyjoin';
const db = await create('opfs://my-app-v1');
await db.exec(`
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
done BOOLEAN NOT NULL DEFAULT false
)
`);
const {rows} = await db.query('SELECT * FROM tasks ORDER BY id');The default create() call owns Worker construction and WebAssembly loading. Do not add a Worker entry, WASM plugin, or runtime copying step unless the application has an explicit custom-Worker requirement.
Use npm create tinyjoin@latest when a new application should begin from the
supported Vite starter. In a --non-interactive run, --queries drizzle or
--queries kysely starts it with Drizzle or Kysely rather than SQL, and
--list-options prints every option.
For an in-memory database in Node.js 22 or later, import create from
tinyjoin/node. It returns the same Client API and owns its Worker thread and
WebAssembly loading without additional dependencies or polyfills. Each call
creates an independent database; await db.close() in a finally block to
release the Worker. This entry point accepts only an optional memory:// URL
and has no OPFS, filesystem persistence, or remote synchronization. See the
Node guide.
- Put application values in
$1,$2, and later parameters. - Use query() for one statement and exec() for a parameter-free script.
- Give every SQL-created table a primary key.
- Declare foreign keys with
REFERENCES, and theirON DELETEactions; they are checked as each statement ends. Index a large table's referencing columns. - Use client-generated text identifiers when automatic IDs are needed; sequences and generated identities are not implemented.
- Write upserts as
INSERT ... ON CONFLICT (id) DO UPDATE SET column = EXCLUDED.columnrather than reading before writing. Name the stored row's columns with the table's name, as inSET hits = counters.hits + EXCLUDED.hits. - Change a value relative to itself in one statement, as in
UPDATE counters SET hits = hits + 1 WHERE id = $1, rather than reading it first. - Keep schema setup idempotent with
IF NOT EXISTSwhere appropriate. - Read tables, columns, primary keys, and indexes with db.getSchema(). There is
no
information_schemaorpg_catalogto query. - To declare the schema in code, pass the getSchema() shape to db.setSchema()
as the application starts. It creates and alters what differs in one atomic
change, keeps rows, renames from
renamedFrom, drops only with{drop: true}, and refuses a lowerversionwithSCHEMA_OUTDATED. - Treat a row generic as a TypeScript assertion, not runtime validation.
- Consult the SQL compatibility contract before using unlisted PostgreSQL syntax or types.
- Joins run in the order written, without reordering. Each joined table's rows are found by primary key, through an index, or in a hash table. Check actual workload size against the join limits.
Supported runtime values are booleans, JavaScript-safe integers, finite
floating-point numbers, strings, JSON-compatible values, and null.
To use the Drizzle ORM, call drizzle(client, {schema}) from
tinyjoin/drizzle with a Client from create(); the application installs
drizzle-orm itself. Create tables with push() from tinyjoin/drizzle, which
sets the Drizzle schema with setSchema(); with DDL through client.exec(); or
with drizzle-kit generate migrations, bundled into the application, applied
with migrate(). Drizzle's own migrators and Drizzle Kit's push do not work. Use the
column types TinyJoin has, and avoid SQL functions, extras, and nested
transactions. Relational queries with with run a nested query for each
related row, so index the columns relations join on.
See the Drizzle guide.
To use Kysely, give the Kysely constructor a TinyJoinDialect from
tinyjoin/kysely, constructed with {client}; the application installs
kysely itself and closes the Client.
Kysely's Migrator and introspector work. Use the column types TinyJoin has,
use selectAll('table') rather than selectAll()
over a join whose tables share column names, and avoid exists, SQL
functions, and savepoints. jsonArrayFrom and jsonObjectFrom run a nested
query for each row, so index the columns they match on. See the
Kysely guide.
Use db.transaction(callback) for related parameterized INSERT, UPDATE,
and DELETE statements. Use the transaction object inside the callback and do
not retain it. Run DDL outside the callback.
Pass the active Transaction to helpers; awaiting another db.transaction() on the same Client inside its callback deadlocks. There is no AbortSignal or timeout API. Promise.race() stops waiting but does not cancel a write.
An uncaught callback error before commit discards staged work. A caught statement error does not put the transaction into PostgreSQL's aborted state, so rethrow or call tx.rollback() when earlier staged changes must also be discarded.
Each statement is validated incrementally against the transaction's staged rows, so its cost does not grow with the transaction. Keep transactions bounded and prefer multi-row writes where practical.
Subscriptions report changed tables. Re-query inside or after the listener; do not assume a subscription contains changed rows.
- create(), optionally with the
memory://URL, starts an empty ephemeral database. - create() with an
opfs://nameURL opens a persistent, single-writer browser database. - Clients using the same name automatically share one owner across tabs. Different names have independent data and do not synchronize.
- Transactions hold all Clients for that name until their callbacks finish. Keep callbacks short; a frozen live owner can delay other tabs.
- Owner loss reconnects automatically. Pending operations fail with
LEADER_CHANGEDand must be reconciled before replay; interrupted transactions fail withTRANSACTION_LOST. Never automatically retry writes. - Subscription events with
reset: truerequire a re-query even whentablesis empty; they cover handover and page restoration. create-tinyjoingenerates offline-capable production builds. Existing Vite apps can add tinyjoinOffline() fromtinyjoin/vite; apps with an existing service worker use itsmanifestmode. See the offline guide. Caching does not add remote synchronization.- Keep the OPFS name stable and version it deliberately with the schema.
- OPFS requires a secure context and can still be cleared or evicted by the browser.
- Call db.close() on teardown so storage locks and the Worker are released.
- Opening checks only the catalog; db.check() reads every row and index entry and rejects with the first problem found. Nothing repairs a failed check: restore an application backup into a new OPFS name.
- After
RECOVERY_REQUIRED,STORAGE_COMMIT_OUTCOME_UNKNOWN, orSTORAGE_ENGINE_POISONED, stop work, close and reopen the same OPFS name, and reconcile stable operation identifiers before replay.retryableis not a safe-replay guarantee. Follow the recovery guide.
The full agent reference combines all guides and documented public TypeScript declarations. Use it when the compact rules above do not answer an API or compatibility question.
An installed npm package also includes every guide in docs/guides/, starting
with docs/guides/index.md, and documented declarations in @types/. The links
to guides in the packaged agents.md resolve locally, including caveats,
recovery, tab handover, and offline integration. These contracts can be read
without accessing the website.
The TypeScript client and Worker host live in src/. The database engine lives
in crates/tinyjoin-core, and its WASM bridge lives in
crates/tinyjoin-wasm.
Public declarations are authored under src/@types/. Documentation comments
in each matching docs.js file are merged into the declarations during the
build. Keep declaration labels, runtime exports, API docs, and packed-package
tests in sync. Give every function, constructor, and method an @example;
the build refuses one without.
Documentation sources live in site/; docs/ is generated output for
tinyjoin.org. README.md and releases.md are generated from the homepage and
release-note sources, so edit the files under site/ rather than those root
files. site/data/sizes.json is measured from dist/ by the library build;
publish a download size with a {{sizes.<group>.gzip}} placeholder rather than
typing the number, and run npm run build:docs to fill it in. Write internal links in those sources as root-relative URLs. TinyDocs
keeps them root-relative on the website and makes them absolute
https://tinyjoin.org/... URLs in the generated Markdown. This guide also
becomes agents.md in the publishable package.
Useful validation commands are:
npm run typecheck
npm run test:ts
npm run test:rust
npm run build
npm run build:docs
npm run check:docs:committed
npm run test:browser
npm run test:package
npm run check:sizenpm run bench:compare compares the built runtime with SQLite and PGlite in
Chromium; pass --engines, --workloads, and --samples to measure one
change quickly. Only a full --publish run updates
site/data/benchmarks.json, which the benchmarks guide renders as charts.
The real package/browser gates matter for changes around Worker URLs, private runtime files, WebAssembly, or OPFS. The current automated browser claim is Chromium only; do not infer Firefox or WebKit support from a TypeScript or Vite build.