# TinyJoin full reference

All guides and documented public TypeScript declarations from this build. Source links identify the corresponding website pages.

# Getting started

Source: https://tinyjoin.org/guides/getting-started/

TinyJoin is a relational database that runs locally in a browser. The normal
setup is one import and one asynchronous call.

For an in-memory database in Node.js, use the separate
[`tinyjoin/node` entry point](https://tinyjoin.org/guides/node/).

## Install

```sh
npm install tinyjoin
```

TinyJoin publishes JavaScript, TypeScript declarations, its Worker runtime, and
the WebAssembly engine together. An application does not need Rust tooling or a
separate Worker plugin.

For a new application, `npm create tinyjoin@latest` generates a working Vite
starter. Its production build includes [offline loading](https://tinyjoin.org/guides/offline/).

For a complete noninteractive setup, supply every starter option:

```sh
npm create tinyjoin@latest -- --non-interactive \
  --projectName my-tinyjoin-app \
  --language typescript \
  --storage opfs \
  --installAndRun false
cd my-tinyjoin-app
npm install
npm run dev
```

Use `--language javascript` for JavaScript, or `--storage memory` for data that
starts fresh on each load. The starter's `--list-options` flag lists its options.

## Open a database

TinyJoin is experimental and verified on Chromium only. Browser storage can
be lost, so start with data you can reconstruct. Read the
[caveats](https://tinyjoin.org/guides/caveats/) before adopting persistent storage.

```ts
import {create} from 'tinyjoin';

const db = await create('opfs://my-app');
```

The Promise resolves after the Worker and database are ready. The string gives
the persistent database a stable local name. Call create() without an
argument while experimenting with an ephemeral database.

Use the same name in every tab. TinyJoin automatically shares one database
owner and delivers committed-change notifications across those Clients. See
[storage and tab handover](https://tinyjoin.org/guides/storage-and-lifecycle/) for lifecycle details.

## Create a table

Schema setup can be idempotent, so the same application startup works for a new
or existing database:

```ts
await db.exec(`
  CREATE TABLE IF NOT EXISTS notes (
    id TEXT PRIMARY KEY,
    body TEXT NOT NULL,
    pinned BOOLEAN NOT NULL DEFAULT false
  )
`);
```

exec() accepts a parameter-free script and commits all of it together. Use it
for schema setup. TinyJoin requires every SQL-created table to have a primary
key.

## Write with parameters

```ts
await db.query('INSERT INTO notes (id, body) VALUES ($1, $2)', [
  crypto.randomUUID(),
  'Hello from the browser',
]);
```

Keep application values in the parameter array. TinyJoin parameters are
one-based (`$1`, `$2`, and so on) and accept JSON-compatible values.

## Read typed rows

```ts
type Note = {
  id: string;
  body: string;
  pinned: boolean;
};

const {rows} = await db.query<Note>(
  'SELECT id, body, pinned FROM notes ORDER BY id',
);
```

The generic describes the expected result to TypeScript; it does not validate
rows at runtime. Use an explicit projection when callers require a stable
shape.

## Close cleanly

```ts
window.addEventListener(
  'pagehide',
  () => void db.close().catch(console.error),
  {once: true},
);
window.addEventListener('pageshow', (event) => {
  if (event.persisted) {
    window.location.reload();
  }
});
```

Closing releases this Client's prepared statements and Worker. Other Clients
for the same name continue, with automatic owner handover when needed. Closing
is asynchronous and safe to call more than once.

Closing cannot be undone. This example reloads a page restored from the
back/forward cache so startup creates a fresh Client. A browser does not await
pagehide cleanup; finish and await writes during normal use. Applications
with their own lifecycle can reopen and recreate their database state instead
of reloading. See [storage and lifecycle](https://tinyjoin.org/guides/storage-and-lifecycle/).

The [Todo starter demo](https://tinyjoin.org/demos/todo-starter/) puts these calls together in a
small browser application.

# Storage and lifecycle

Source: https://tinyjoin.org/guides/storage-and-lifecycle/

TinyJoin supports an ephemeral memory database and an opt-in persistent browser
database. Both use the same Worker and page-native engine.

## Memory

```ts
const db = await create();
// Equivalent to: await create('memory://')
```

Memory starts empty each time the Worker opens. It is useful for tests,
temporary views, and trying the API.

## Persistent browser storage

```ts
const db = await create('opfs://my-project-v1');
```

The name must contain 1-64 ASCII letters, numbers, dots, underscores, or
hyphens, and start with a letter or number. Include the application, dataset,
and schema generation in it. A name is a namespace, not an encryption or
access-control boundary.

OPFS persistence requires a secure browser context, a dedicated Worker, Web
Locks, and BroadcastChannel. Call create() with the same name in every tab:
TinyJoin elects one database-owning Worker, routes operations to it, and
broadcasts committed changes to all connected Clients. Multiple Clients in one
page work the same way. No additional configuration is needed.

Different names can open independently, but their rows and subscriptions are
separate. Coordination stays within the same origin and browser storage
partition; it does not connect other browser profiles or devices.

The owner retains an exclusive synchronous OPFS access handle. That remains
the final protection against concurrent mutation, including older TinyJoin
versions or custom code that does not participate in coordination. Such an
owner can still cause `STORAGE_LOCKED`; close the older application before
retrying. Mixed TinyJoin releases fail with `DATABASE_VERSION_MISMATCH`
instead of sharing an incompatible engine.

There is no silent fallback to memory if persistent storage is unavailable,
locked, corrupt, or out of quota.

An OPFS name identifies stored data; changing the name opens a different
database and leaves the old data in place. Check the
[release compatibility boundary](https://tinyjoin.org/guides/releases/#v0-1-0) before upgrading
TinyJoin: v0.1.0 cannot open the page format published in v0.0.5. Preserve any
needed data with the old version before moving to a new namespace.

The current persistent database is bounded to 65,536 4 KiB pages (256 MiB).
The [SQL compatibility guide](https://tinyjoin.org/guides/sql-compatibility/#hard-limits) lists the
independent query and mutation limits.

## Browser retention

OPFS is browser-managed storage. A user can clear it and a browser may evict
best-effort storage under pressure. An application that needs stronger local
retention can make a user-appropriate `navigator.storage.persist()` request.
TinyJoin does not make that product decision during startup.

## Application backups and restoration

TinyJoin has no general public export, import, list-databases, or
delete-database API. There is no SQL catalog introspection or portable binary
backup contract. Keep schemas and migration rules in your application. For
reconstructible data, fetching it again from its original source may be simpler
than a backup.

For data that must be retained, export explicit columns from each known table
to an application-owned format. Include a schema version, preserve primary
keys, and save the result outside this origin's browser storage, for example
as a user-downloaded file. A second OPFS name on the same origin is not an
independent backup. Verify the export by restoring and reading it before
removing the original.

Here is a bounded example for the `notes` schema in the
[getting-started guide](https://tinyjoin.org/guides/getting-started/#create-a-table). It exports at
most 1,000 small notes and restores into an empty table. Imported values are
validated before they become SQL parameters; a TypeScript row generic alone
does not validate a file.

```ts
import type {Client} from 'tinyjoin';

type SavedNote = {id: string; body: string; pinned: boolean};

async function backupNotes(db: Client): Promise<string> {
  const {rows} = await db.query<SavedNote>(
    'SELECT id, body, pinned FROM notes ORDER BY id LIMIT 1001',
  );
  if (rows.length > 1000) throw new Error('Export needs a larger-data policy');
  return JSON.stringify({schemaVersion: 1, notes: rows});
}

async function restoreNotes(db: Client, text: string): Promise<void> {
  const backup = JSON.parse(text);
  if (
    backup?.schemaVersion !== 1 || !Array.isArray(backup.notes) ||
    backup.notes.length > 1000 ||
    !backup.notes.every((note: SavedNote) =>
      note !== null && typeof note === 'object' &&
      typeof note.id === 'string' && typeof note.body === 'string' &&
      typeof note.pinned === 'boolean'
    )
  ) throw new Error('Invalid notes backup');

  await db.exec(`
    CREATE TABLE IF NOT EXISTS notes (
      id TEXT PRIMARY KEY,
      body TEXT NOT NULL,
      pinned BOOLEAN NOT NULL DEFAULT false
    )
  `);
  await db.transaction(async (tx) => {
    if ((await tx.query('SELECT id FROM notes LIMIT 1')).rows.length) {
      throw new Error('Restore requires an empty notes table');
    }
    for (const note of backup.notes) {
      await tx.query(
        'INSERT INTO notes (id, body, pinned) VALUES ($1, $2, $3)',
        [note.id, note.body, note.pinned],
      );
    }
  });
}
```

This is an application example, not an arbitrary database importer. Stop
application writes while exporting; for several tables, read them in one short
transaction when the full snapshot fits the limits. Large rows, result bytes,
and staged writes can reach the [engine limits](https://tinyjoin.org/guides/sql-compatibility/#hard-limits)
before the example's row cap. There is no streaming backup or cross-batch
snapshot API. Plan bounded batches and a stable maintenance window for larger
datasets. Restore schema DDL before the data transaction, including your known
indexes. If restoring fails, preserve the export and follow the uncertain-write
recovery rules below before retrying.

## Resetting and removing obsolete databases

A logical reset can use `DELETE FROM notes` to empty a known table, or
`DROP TABLE IF EXISTS notes` followed by the application's schema setup. First
preserve and verify any data that must survive, stop writes, and await close()
on **every Client in every tab or Worker** for this name. Closing only the owner
causes handover to another connected Client. Keep other pages closed, open one
maintenance Client, perform the deliberate reset, and close it before normal
startup resumes. A reset is destructive and is never an uncertain-write
recovery step.

Changing `opfs://my-app-v1` to `opfs://my-app-v2` leaves the first namespace on
disk. Record the names your application creates; TinyJoin does not list or
selectively delete them through its public API. A logical reset also leaves
the namespace allocated. Do not depend on private directory names or remove
individual engine files while a database is open.

To remove obsolete names and their physical storage with current public
facilities, use the browser's **clear site data** control for this origin:

1. Export or otherwise preserve all needed data for every database and other
   application sharing the origin, and verify those backups outside the origin.
2. Stop application work, await all Client closes, and close every page or
   Worker that could reopen these databases.
3. Clear storage for the origin in the browser's site settings. This also
   removes other origin data and offline application caches; it is not a
   TinyJoin-only operation.
4. Reopen the application online, create only its current namespaces, and
   restore the known schemas and retained data before accepting new writes.

If clearing the entire origin is inappropriate, retain the obsolete namespace
until the application has an independently reviewed storage-management policy.
Selective deletion and automatic migration remain separate API decisions.

## Space reuse and quota

`DELETE` and `DROP` free engine pages for internal reuse. The page file normally
keeps its high-water size: fewer rows do not mean a smaller physical file or
immediate browser-quota reclamation. TinyJoin has no public `VACUUM` or
compaction API. Opening a new namespace can temporarily require space for both
databases and does not reclaim the old one.

`navigator.storage.estimate()` can help monitor approximate origin-wide usage
and quota; it is not a per-database size or a reservation. Allow space for
writes and recovery, handle quota failures, and use the deliberate cleanup
procedure above when physical storage must be removed.

## Opening and closing

await create() is the simplest lifecycle: it returns only after
initialization succeeds. The returned Client also exposes `ready`, `waitReady`,
and `closed` for code that constructs a Client directly.

These properties describe initialization and explicit cleanup, not connection
health. `waitReady` resolves once; `ready` can remain `true`, `closed` can remain
`false`, and prepared handles can remain unsealed after a terminal Worker
failure. Operations still reject. Do not poll these properties to decide
whether a failed operation is safe to repeat.

Call close() during application teardown. It seals that Client's prepared
statements, detaches it, and terminates its Worker. Other Clients retain their
data and connection. If the departing Worker owned the database, another
connected Worker automatically opens it. Outstanding cleanup is shared by
repeated close() calls.

Closing is irreversible. A Client, its prepared statements, and its
subscriptions cannot resume after close(). If a `pagehide` handler closes the
database, a page restored from that cache must initialize a new Client and
recreate its statements and subscriptions before accepting work. The
[getting-started example](https://tinyjoin.org/guides/getting-started/#close-cleanly) uses a reload
on `pageshow` when `event.persisted` is true as a simple application policy.

A browser does not await asynchronous `pagehide` cleanup, and teardown events
are not guaranteed to run. Await writes while the application is active;
close() during navigation is not a final-save or cancellation guarantee.

### Terminal Worker failures

A Worker crash (`WORKER_ERROR`), unreadable message (`WORKER_MESSAGE_ERROR`), or
invalid protocol response (`PROTOCOL_MISMATCH`) disposes that connection.
Pending operations reject with the failure; subsequent database operations and
prepared executions reject with `WORKER_TERMINATED`. The Client does not
automatically replace a terminally failed Worker. Its statements' `closed`
properties report whether they were sealed, not whether the Worker is healthy.

Stop accepting work, retain the original error, and call close(). Cleanup seals
the prepared handles and completes Client cleanup even if its Promise rejects
because the Worker is already gone. Open a new Client, recreate statements and
subscriptions, and inspect stored operation identifiers before replaying writes:

```ts
// In the application's recovery path, after preserving the original error:
await db.close().catch((cleanupError) => console.error(cleanupError));
const reopened = await create('opfs://my-app'); // Use the original name.
// Reconcile the original operation using reopened before accepting more work.
```

A crash during a write can lose its acknowledgement after committing. Follow
[uncertain-write reconciliation](https://tinyjoin.org/guides/storage-and-lifecycle/#recovering-after-an-uncertain-write), even
when the error is a Worker failure instead of a storage code. Memory data is
lost with its Worker.

Temporary OPFS owner handover is different: `LEADER_CHANGED` rejects in-flight
operations, but the same Client reconnects and restores prepared statements for
subsequent work. Reconcile interrupted writes; an interrupted transaction ends
with `TRANSACTION_LOST`. The next section describes this recoverable lifecycle.

## Tab handover and subscriptions

The browser's locks elect the next owner when the previous Worker closes or
dies. New operations wait while that owner opens the database. Prepared
statements are restored automatically when needed. Notifications fan out to
every Client, including the one that wrote the data.

Operations already sent to a departing owner reject with `LEADER_CHANGED`.
Their effects may already have committed; TinyJoin never silently repeats
them. The Client reconnects for subsequent operations, so inspect the stored
outcome before retrying a write. A callback transaction interrupted by owner
loss cannot continue: subsequent transaction operations reject with
`TRANSACTION_LOST`. Start a new transaction after reconciliation.

After handover, or when a page becomes visible or resumes, subscriptions may
receive `{revision, tables: [], reset: true}`. Re-query on this notification
even though the precise changed tables are unknown. Filtered subscriptions
also receive it. A subscription is an invalidation signal, not a durable log
of every commit.

Transactions exclude other Clients for the entire callback. Keep callbacks
short and do not wait on work that needs another Client for the same name.
If a client disappears with a transaction open, the owner rolls it back. A
frozen but still live owner or transaction holder can delay other tabs until
it resumes or closes. TinyJoin does not steal a live storage lock based on a
timer, since doing so could let two engines write concurrently.

For offline reopening of the application itself, use the
[offline build integration](https://tinyjoin.org/guides/offline/). OPFS stores the data; a service
worker caches the application and database runtime files.

## Recovering after an uncertain write

A rejected write does not always prove that nothing committed. The following
ClientError codes mean the current engine must no longer be used:

| Code | Meaning |
| --- | --- |
| `RECOVERY_REQUIRED` | The engine cannot safely continue after a storage publication failure; reopening must establish the stored state. |
| `STORAGE_COMMIT_OUTCOME_UNKNOWN` | A storage write or a result after a possible commit could not be confirmed. The requested change may have committed. |
| `STORAGE_ENGINE_POISONED` | A previous uncertain or fatal result already made this engine unusable. |

Stop accepting database work, keep the original error, and close the Client.
For OPFS, open the **same database name** with a new create() call and inspect
the recovered rows before deciding whether to repeat the operation. If open
or inspection fails, keep the application in a recovery state and preserve the
stored data; changing the name or deleting the database would hide the state
you need to reconcile. A new memory database starts empty and cannot recover
the old Client's data.

Give an application operation a stable identifier before its first attempt
and record that identifier in the same transaction as its effects. After
reopening, query that record and compare the intended values. A matching
record means the operation already happened; a missing record may allow a
deliberate retry with the **same identifier**. An unexpected record needs
application reconciliation. Generating a fresh identifier on every retry can
apply an operation twice. TinyJoin has no automatic replay or `ON CONFLICT`
clause, so this policy belongs to the application.

The `retryable` property only says that a later attempt or reopen may succeed.
It is not a guarantee that replaying a write is safe, nor that the current
Client remains usable. A rollback attempted after an uncertain commit cannot
establish that the commit was absent. See also
[transaction error handling](https://tinyjoin.org/guides/transactions-and-changes/#errors-and-cancellation).

# SQL compatibility

Source: https://tinyjoin.org/guides/sql-compatibility/

TinyJoin implements its own deliberately bounded, PostgreSQL-shaped SQL
dialect. It is not PostgreSQL compiled to WebAssembly, a PostgreSQL server, or
a general PostgreSQL replacement. Familiar syntax is used where the smaller
runtime can give it clear and deterministic semantics.

This document is the compatibility contract for the current dialect. A form
not listed as supported here is unsupported, even if its keywords happen to be
accepted by PostgreSQL. Unsupported forms fail explicitly rather than being
silently reinterpreted.

## JavaScript entry point

SQL is the primary relational interface. The basic lifecycle has four calls:

```ts
import { create } from "tinyjoin";

const db = await create();

await db.exec(`
  CREATE TABLE tasks (
    id INTEGER PRIMARY KEY,
    title TEXT NOT NULL,
    done BOOLEAN NOT NULL DEFAULT false
  );
`);
await db.query("INSERT INTO tasks (id, title) VALUES ($1, $2)", [
  1,
  "Write the compatibility contract",
]);

const { rows } = await db.query<{ id: number; title: string }>(
  "SELECT id, title FROM tasks WHERE done = $1",
  [false],
);

await db.close();
```

Calling create() opens the Worker-backed database and resolves after
initialization. With no argument, or `memory://`, storage is ephemeral. A named
`opfs://database-name` data directory opts into persistent browser storage.
The client also exposes read-only `ready`, `waitReady`, and `closed`
properties.

query(sql, params?, options?) executes one read or write statement with
optional JSON-compatible `$1` parameters. exec(sql, options?) executes one or
more statements without parameters as one implicit transaction and returns one
result per statement. Both use `{rows, fields, affectedRows?, command?,
rowCount?}` results; TinyJoin adds `revision` and `tables`. `fields` contains
ordered `{name, dataTypeID}` entries, including for empty typed results.
`rowMode: "array"` returns values in that field order. The `sql` tagged template
is a parameterizing form of query(). `rowMode` is the only query option
implemented today; parser, serializer, notice, parameter-type, and blob
options are rejected. The tag accepts parameter values only and does not
provide raw-SQL, identifier, or nested-template helpers.
close() is asynchronous and idempotent.

Interactive atomicity uses transaction(callback), not SQL transaction
statements:

```ts
await db.transaction(async (tx) => {
  await tx.query("UPDATE tasks SET done = true WHERE id = $1", [1]);
  await tx.query("INSERT INTO tasks (id, title) VALUES ($1, $2)", [
    2,
    "Committed together",
  ]);
});
```

Invalidation subscriptions and Worker/storage configuration form the small
JavaScript control surface around the SQL-first engine; relational reads,
writes, and schema changes use SQL.

## Prepared statements

Use a prepared statement when the same parameterized read or row mutation will
execute repeatedly:

```ts
const tasksByDone = await db.prepare<{
  id: number;
  title: string;
  done: boolean;
}>("SELECT id, title, done FROM tasks WHERE done = $1 ORDER BY id");
const setTaskDone = await db.prepare(
  "UPDATE tasks SET done = $1 WHERE id = $2",
);

const { rows } = await tasksByDone.execute([false]);

await db.transaction(async (tx) => {
  await tx.execute(setTaskDone, [true, 1]);
  console.log((await tx.execute(tasksByDone, [true])).rows);
});

await tasksByDone.close();
await setTaskDone.close();
```

prepare<Row>(sql) parses and retains one `SELECT`, aggregate, join, `INSERT`,
`UPDATE`, or `DELETE` statement in the Worker. It rejects DDL, an empty string,
and more than one statement. The returned PreparedStatement<Row> has
execute(params?, options?), asynchronous idempotent close(), and a read-only
`closed` property. execute() returns the same Results<Row> shape as
query() and accepts the same sole option, `rowMode`.

The prepared parameter count is the highest referenced `$n`. Every execution
must provide exactly that many JSON-compatible values; a numbering gap still
occupies a slot. Binding or execution failure leaves the handle open for a later
valid execution. exec() remains parameter-free and is not a prepared-script
API.

Preparation retains parsed syntax and parameter positions, not a schema
snapshot. Every execution resolves tables and columns and validates types
against the current catalog and, inside a transaction, its current staged view.
A compatible DDL change is transparent. An incompatible change returns the
ordinary current table, column, constraint, or type error and does not make the
handle permanently stale. Consequently, `SELECT *` or `RETURNING *` can expose
new fields after `ALTER TABLE ... ADD COLUMN`, including a new positional value
in array row mode. Use an explicit projection when callers require a stable
result shape. As with query<Row>(), the Row generic is a compile-time cast,
not runtime result validation.

A prepared statement is session-local: it belongs to the client that created
it, is not stored in OPFS, cannot be used by another client, and does not survive
db.close() or a Worker restart. Prepare handles before entering a callback
transaction. Inside the callback, use
tx.execute(statement, params?, options?); it accepts only an open statement
from the same client and participates in the same staged commit or rollback as
tx.query(). Direct db.prepare(), statement.execute(), and
statement.close() calls are blocked while that client's transaction callback
is active.

Calling statement.close() seals it immediately, rejects new executions, waits
for executions that already started, and then releases its Worker resources.
Concurrent close calls share the same cleanup, and a cleanup failure does not
reopen the handle. Closing the database seals and releases every remaining
prepared statement. At most 128 handles and 8 MiB of conservatively accounted
prepared-statement state may be retained by one open database.

## How to read the matrices

- **Supported** means the exact form described here is implemented and tested.
- **Narrow** means TinyJoin implements a useful but intentionally smaller form
  than PostgreSQL.
- **No** means the form is rejected.

These labels do not claim compatibility with a particular PostgreSQL release.

## Statements and clauses

| Keyword or form | Status | TinyJoin form and boundary |
| --- | --- | --- |
| `SELECT ... FROM` | Narrow | One table, an aggregate over one table, or a left-deep join over two to eight typed table sources. A simple single-table projection is `*` or distinct plain column names. Join projections require explicit columns: neither `*` nor `table.*` is supported. Duplicate output names return `INVALID_QUERY`, including for empty results and `LIMIT 0`. There is no `SELECT` without `FROM`. |
| `WHERE` | Supported | Predicates described below, with SQL three-valued null logic. |
| `ORDER BY` | Narrow | Up to 32 plain columns for simple queries, projected output names for grouped/aggregate queries, and projected output names or qualified/unambiguous source columns for joins; `ASC`/`DESC` and `NULLS FIRST`/`LAST`. JSON values cannot be ordered. |
| `LIMIT`, `OFFSET` | Supported | Non-negative integer literal or `$n` parameter. `LIMIT` is at most 100,000; `OFFSET` and `OFFSET + LIMIT` are at most 4,294,967,295. `OFFSET` may appear alone; when both occur, `LIMIT` must precede `OFFSET`. |
| `GROUP BY` | Narrow | Up to 32 plain boolean, integer, float, or text columns (not JSON) on one typed table. Every selected non-aggregate column must be grouped explicitly. |
| `COUNT`, `SUM`, `AVG`, `MIN`, `MAX` | Narrow | Every aggregate query requires a typed column catalog, including `COUNT(*)`. Functions accept `COUNT(*)` or one plain column argument. `SUM`/`AVG` accept integer or float; `MIN`/`MAX` accept integer, float, or text. Up to 64 aggregate calls. |
| `HAVING`, aggregate `DISTINCT`, `FILTER`, windows | No | No post-group predicate, distinct aggregate, filter clause, or window form. |
| `JOIN`, `INNER JOIN` | Narrow | Adds one typed table to a left-deep chain of at most eight sources. Each `ON` has one or more column equalities joined by `AND`, with at most 32 across the query; every equality connects the incoming source to an earlier source. |
| `LEFT [OUTER] JOIN` | Narrow | The same bounded chain; an unmatched incoming source is represented by `NULL` columns. A later inner join can remove that null-extended row. |
| `RIGHT`, `FULL`, `CROSS`, `NATURAL`, `USING`, `LATERAL` | No | No additional join families, parenthesized/derived relations, or join reordering. |
| `AS` | Narrow | Output aliases on `SELECT` items in grouped/aggregate queries, plus table and projection-output aliases in joins. Ordinary single-table projections do not accept aliases. |
| `DISTINCT`, `WITH`, subqueries, `UNION`/`INTERSECT`/`EXCEPT` | No | No CTEs, subqueries, set operations, or distinct-row projection. |
| `CREATE TABLE [IF NOT EXISTS]` | Narrow | Typed columns and a required inline or table-level primary key. Up to 256 columns. |
| `PRIMARY KEY` | Narrow | One inline single-column declaration or one table-level column list (single or composite). It implies `NOT NULL`; JSON keys are rejected. |
| `NULL`, `NOT NULL`, `DEFAULT` | Narrow | String, number, boolean, or `NULL` literal defaults only. No default expressions, functions, sequences, or parameters. |
| `CREATE [UNIQUE] INDEX [IF NOT EXISTS]` | Narrow | One or more boolean, integer, or text columns. No methods, expressions, predicates, `INCLUDE`, ordering, or concurrent build. |
| `ALTER TABLE ... ADD [COLUMN] [IF NOT EXISTS]` | Narrow | Adds one non-primary-key column and atomically backfills its literal default or `NULL`. On a nonempty table, `NOT NULL` requires a non-null default. Other `ALTER` forms are rejected. |
| `DROP TABLE [IF EXISTS]` | Narrow | Drops the table and its indexes. No `CASCADE`/`RESTRICT` dependency model. |
| `DROP INDEX [IF EXISTS]` | Supported | Drops one globally named index. |
| `INSERT ... VALUES` | Narrow | Optional column list, up to 4,096 literal/parameter rows, per-cell `DEFAULT`, and optional `RETURNING`. |
| `INSERT ... DEFAULT VALUES` | Supported | Inserts one row using defaults and `NULL` values. |
| `INSERT ... SELECT`, `ON CONFLICT`, `MERGE` | No | No query-sourced insert, upsert clause, or merge statement. |
| `UPDATE ... SET ... [WHERE ...]` | Narrow | Assigns literals, parameters, or `DEFAULT`; optional `RETURNING`. No expressions or `UPDATE ... FROM`. |
| `DELETE FROM ... [WHERE ...]` | Narrow | Optional `RETURNING`. No `DELETE ... USING`. |
| `RETURNING` | Narrow | `*` or a list of distinct plain columns; no expressions or aliases. Duplicate names return `INVALID_QUERY` before any rows are changed, even when no rows match. |
| `BEGIN`, `COMMIT`, `ROLLBACK`, `SAVEPOINT` | No | Use the JavaScript callback transaction API. |
| `PREPARE`, `EXECUTE`, `DEALLOCATE` | No | SQL-level named statements are not implemented. Use the session-local JavaScript prepare() handle and its execute()/close() methods. |
| `COPY`, `TRUNCATE`, `EXPLAIN`, `VACUUM`, `ANALYZE` | No | No server maintenance or bulk-file SQL commands. |

query() accepts exactly one statement, with one optional trailing semicolon.
prepare() has the same one-statement and ordinary SQL text/token limits, but
accepts only the read and row-mutation statement families listed above.
exec() splits only top-level semicolons: strings, quoted identifiers, line
comments, nested block comments, and parentheses cannot accidentally terminate
a statement. A script contains at most 256 statements and 1 MiB of SQL text;
each statement retains the ordinary parser limits below.

## Predicates and expressions

| Form | Status | Semantics |
| --- | --- | --- |
| Strings, numbers, `TRUE`, `FALSE`, `NULL` | Supported | Single-quoted strings escape a single quote as `''`; numbers and booleans use their JSON-compatible scalar forms. |
| `$1`, `$2`, ... | Supported | One-based JSON-compatible parameters; at most 1,024. |
| `=`, `<>`, `!=`, `<`, `<=`, `>`, `>=` | Narrow | Strict scalar comparison, with integer/float cross-comparison. JSON supports structural equality/inequality only. |
| `AND`, `OR`, `NOT`, parentheses | Supported | Precedence is `NOT`, then `AND`, then `OR`; SQL unknown/null propagation is preserved. |
| `IS NULL`, `IS NOT NULL` | Supported | Tests the single runtime null value. |
| `IN (...)`, `NOT IN (...)` | Supported | One to 1,024 literals or parameters with SQL null behavior. |
| Arithmetic, concatenation, casts, scalar functions | No | Values are not a general expression language. |
| `LIKE`, `ILIKE`, `BETWEEN`, `IS DISTINCT FROM`, `ANY`, `ALL` | No | These PostgreSQL predicate families are not implemented. |
| JSON/path operators | No | JSON can be stored, returned, and compared for structural equality only. |

The right side of an ordinary predicate is a literal or parameter, not another
column or subquery. Column-to-column comparison exists only in a join's `ON`
equality terms.

## Runtime types

PostgreSQL type spellings map onto five TinyJoin runtime types. The spelling
does not import PostgreSQL's storage width, coercion, operator, or catalog
semantics.

| Accepted SQL spellings | TinyJoin value | Important difference |
| --- | --- | --- |
| `BOOLEAN`, `BOOL` | JavaScript boolean | No PostgreSQL coercions. |
| `SMALLINT`, `INTEGER`, `INT`, `INT2`, `INT4`, `BIGINT`, `INT8` | One JavaScript-safe integer type | Range is -9,007,199,254,740,991 through 9,007,199,254,740,991. `SMALLINT`/`INTEGER` are wider and `BIGINT` is narrower than PostgreSQL. |
| `REAL`, `FLOAT`, `FLOAT4`, `FLOAT8`, `DOUBLE PRECISION` | One finite binary64 JavaScript number | No real/double distinction, `NaN`, or infinity. |
| `TEXT`, `VARCHAR`, `CHARACTER VARYING` | JavaScript string | No length modifiers or database collation. Ordering is deterministic Unicode code-point ordering. |
| `JSON`, `JSONB` | The same JSON-compatible value (scalar, array, or object) | No textual/binary distinction, JSON operators, casts, or JSON index type. |

SQL `NULL` and a JSON scalar `null` are the same runtime value, including in a
JSON column. TinyJoin cannot distinguish them for `NOT NULL`, `IS NULL`,
aggregates, or defaults.

There are no implicit PostgreSQL casts. Notable unavailable types include
`NUMERIC`/`DECIMAL`, date/time/interval types, UUID, `BYTEA`, arrays,
serial/identity, enum/domain, and user-defined types. Type modifiers such as
`VARCHAR(100)` are rejected.

## Identifiers, comments, and table names

- Unquoted identifiers are folded to ASCII lower case. Double-quoted
  identifiers preserve case and use doubled quotes to escape a quote.
  A quoted column name containing a dot is usable in a single-table query,
  but a table containing any such column cannot be a join source, even when
  the column is not selected. Quoted join aliases cannot contain dots either.
- An unquoted identifier may begin with `_`, an ASCII letter, or any non-ASCII
  character. Later characters may additionally be ASCII digits or `$`.
- TinyJoin reserves these unquoted words case-insensitively: `SELECT`, `FROM`,
  `WHERE`, `AND`, `OR`, `IS`, `IN`, `LIMIT`, `OFFSET`, `ORDER`, `BY`, `ASC`,
  `DESC`, `NULLS`, `FIRST`, `LAST`, `NULL`, `TRUE`, `FALSE`, `CREATE`, `TABLE`,
  `IF`, `NOT`, `EXISTS`, `PRIMARY`, `KEY`, `DEFAULT`, `INSERT`, `INTO`,
  `VALUES`, `UPDATE`, `SET`, `DELETE`, `RETURNING`, `AS`, `JOIN`, `INNER`,
  `LEFT`, `OUTER`, `ON`, `GROUP`, and `HAVING`. Double-quote one to use it as
  an identifier. Other words used contextually by supported statements are not
  necessarily reserved.
- `--` line comments and nested `/* ... */` comments are supported.
- Single-quoted strings use doubled single quotes. Dollar-quoted strings are
  not supported.
- A two-part table name such as `public.tasks` is accepted as one flat catalog
  key. It does **not** create or resolve a PostgreSQL schema. `tasks` and
  `public.tasks` are different TinyJoin table names.
- There is no `CREATE SCHEMA`, `search_path`, `information_schema`, or
  `pg_catalog`. Index names are global catalog keys.

## Constraints and indexes

Every SQL-created table has a primary key. TinyJoin currently implements:

- primary-key uniqueness and non-nullability;
- column `NOT NULL`;
- scalar literal column defaults; and
- separate unique indexes.

It does not implement foreign keys, `CHECK`, exclusion constraints, generated
columns, sequences, triggers, or dependency cascades.

A row is identified by its primary key: each table is stored keyed by that
value, and an `UPDATE` which changes a primary key is applied as a removal at
the old key and an insertion at the new one rather than an edit in place. Treat
primary keys as stable, opaque identifiers.

Composite primary and secondary indexes are supported. A unique index omits a
key containing `NULL`, so multiple null-containing keys are allowed, matching
PostgreSQL's default `NULLS DISTINCT` behavior. Simple `SELECT`, `UPDATE`, and
`DELETE` use direct lookup for complete primary-key equality when the key types
and values permit an exact lookup. This also sees staged changes inside a
transaction, and the complete predicate is still checked. `SELECT` can use
secondary-index postings for complete equality on every indexed column. Other
`UPDATE` and `DELETE` predicates scan; partial composite matches, ranges,
`OR`, and `NOT` also fall back to scans.

## Aggregates and joins

Aggregate null behavior follows the familiar SQL rules: `COUNT(*)` counts
rows; other aggregates skip `NULL`; a global aggregate over no rows emits one
row with count zero and other aggregates `NULL`; an empty grouped input emits
no rows. Integer `SUM` fails beyond the JavaScript-safe range, and integer
`AVG` returns a floating-point value rather than PostgreSQL `numeric`.

Join keys containing `NULL` never match. Integer and float keys may compare;
JSON join keys are rejected. Every source requires a typed SQL catalog and a
unique alias, and the result must use distinct JSON object field names.
Unqualified columns are accepted only when exactly one source contains the
name. Without `ORDER BY`, row order is not part of the contract.

### Join projection and identifier boundaries

Join projections must name each output column explicitly. Wildcards `*` and
`table.*` are rejected. A join also rejects any source table whose catalog
contains a column name with a dot, even if the projection and `ON` clause do
not use that column. Quoting does not remove this restriction.

This small schema reproduces both boundaries:

```sql
CREATE TABLE left_items (id INTEGER PRIMARY KEY);
CREATE TABLE right_items (id INTEGER PRIMARY KEY);
CREATE TABLE dotted_items (id INTEGER PRIMARY KEY, "extra.value" TEXT);
INSERT INTO left_items VALUES (1);
INSERT INTO right_items VALUES (1);
INSERT INTO dotted_items VALUES (1, 'kept');
```

These single statements succeed:

```sql
SELECT left_items.id AS id
FROM left_items JOIN right_items ON left_items.id = right_items.id;
```

```sql
SELECT id, "extra.value" FROM dotted_items;
```

Each of the following statements is unsupported:

```sql
SELECT * FROM left_items JOIN right_items ON left_items.id = right_items.id;
```

```sql
SELECT left_items.* FROM left_items JOIN right_items ON left_items.id = right_items.id;
```

```sql
SELECT left_items.id AS id
FROM left_items JOIN dotted_items ON left_items.id = dotted_items.id;
```

The last query fails even though `"extra.value"` is never selected. Use column
names without dots for tables that will participate in joins.

### Join work budgets

Join chains are evaluated as written, from left to right, by a bounded nested
loop; TinyJoin does not reorder or optimize them. Each `ON` equality must
connect its newly introduced source to one of the sources already in scope.
Across the full chain, candidate-extension, retained-row, result-row, and byte
budgets are global rather than resetting for each `JOIN`. Aggregates over joins
are not supported. The engine counts actual candidate comparisons while
executing; it does not reject a join merely because the full Cartesian product
is large. Source row counts still enforce the scan and retained-build-row
limits before execution.

For example, three tables of 100 rows joined on unique matching identifiers
need about 20,000 candidate comparisons and return 100 rows. Such a selective
chain fits. Two tables of 1,001 rows whose join keys all match can exceed the
1,000,000-comparison budget even when a later `WHERE` removes every result.
Order and join shape therefore matter. An unordered `LIMIT` can stop early;
an ordered join must first collect its matches. Standalone queries and exec()
also charge scans and comparisons to their shared script-work budget, which
can be reached before the comparison-only limit.

Many-to-many relationships can use a bridge table with a composite primary
key, for example:

```sql
SELECT post.id AS post_id, tag.name AS tag_name
FROM posts AS post
JOIN post_tags AS post_tag ON post.id = post_tag.post_id
JOIN tags AS tag ON post_tag.tag_id = tag.id
ORDER BY post_id, tag_name
```

Foreign keys are not implemented, so TinyJoin does not enforce the bridge
table's references.

## Transactions and concurrency

Each query() or standalone prepared-statement write is atomic. A standalone
exec() script runs its supported reads, DDL, and DML against one page candidate
and publishes one durable generation only after every statement succeeds. A
callback transaction stages `INSERT`, `UPDATE`, and `DELETE` statements,
including through tx.execute(), exposes those staged rows to reads through its
transaction object, and publishes the complete result once.
transaction.exec() may group DML and reads as an atomic savepoint within that
staged transaction: a failure installs none of that script's changes. DDL is
rejected before any statement in a transaction script runs and must use a
standalone query() or exec() call.

If a transaction statement fails, that statement installs no partial change,
but the transaction is not put into PostgreSQL's aborted state. If the callback
catches the error, earlier staged writes may still commit. This is also true for
a prepared execution. Letting the error escape the callback rolls the
transaction back.

Calls to the same Client's transaction() queue in order. Awaiting one from
inside its own active callback deadlocks; pass the existing Transaction into
helpers instead. There is no AbortSignal or timeout option, and racing a
Promise against a timer does not cancel the work. See
[transaction composition and errors](https://tinyjoin.org/guides/transactions-and-changes/#composing-transaction-helpers).

Atomicity does not make every failed write's outcome knowable to its caller.
After `RECOVERY_REQUIRED`, `STORAGE_COMMIT_OUTCOME_UNKNOWN`, or
`STORAGE_ENGINE_POISONED`, stop using the Client, close and reopen it, and
reconcile stored state before replaying a write. The `retryable` flag is not a
safe-replay guarantee. See [storage recovery](https://tinyjoin.org/guides/storage-and-lifecycle/#recovering-after-an-uncertain-write).

Requests are serialized through one Worker. OPFS persistence permits one open
Worker for a database name; it is an exclusive writer rather than a
PostgreSQL-style set of concurrent sessions. There is no MVCC session model,
isolation-level selection, user-controlled savepoints, lock manager, or
deadlock detection. Different OPFS names are independent databases and do not
synchronize with one another.

## PostgreSQL facilities that are not present

TinyJoin has no PostgreSQL wire protocol, SQLSTATE-compatible error protocol,
server process, roles or grants, system catalogs, extensions, stored
procedures, triggers, notifications, WAL, replication, point-in-time recovery,
or PostgreSQL file-format compatibility. Rows and parameters are
JSON-compatible JavaScript values. Result fields use the closest stable
PostgreSQL OID as metadata: boolean `16`, integer `20`, text `25`, JSON `114`,
and float `701`. This mapping does not add PostgreSQL storage widths,
coercions, operators, parsers, or wire semantics.

Persistence is TinyJoin's own page format in memory or one browser OPFS file.
It is not a PostgreSQL data directory.

JavaScript prepared statements are Worker-owned parsed statements, not
PostgreSQL named prepared statements, server plan-cache entries, protocol
objects, or persistent database objects.

## Hard limits

Limits are part of the runtime contract: oversized work fails explicitly
rather than growing without bound.

| Resource | Current limit |
| --- | ---: |
| Physical database | 65,536 4 KiB pages (256 MiB) |
| Tables / indexes | 4,096 each |
| Columns per table or projection | 256 |
| Catalog name | 1,023 UTF-8 bytes |
| Complete encoded storage key | 1,024 bytes |
| Encoded logical row data | 1,048,568 bytes |
| Complete paged row / individual encoded JSON value | 1,048,576 bytes |
| JSON nesting | 64 levels |
| SQL text / tokens / parameters | 64 KiB / 4,096 / 1,024 |
| Expanded bound parameter values | 16 MiB per statement |
| Open prepared statements / retained prepared state | 128 / 8 MiB per open database |
| exec() script text / statements | 1 MiB / 256 |
| exec() row, index, scan, and join operations | 1,000,000 across the script |
| exec() retained result work | 16 MiB across the script |
| Predicate nodes / nesting / `IN` values | 256 / 32 / 1,024 |
| Rows in one `INSERT ... VALUES` | 4,096 |
| Explicit `LIMIT` / `OFFSET` / `OFFSET + LIMIT` | 100,000 / 4,294,967,295 / 4,294,967,295 |
| Rows scanned / returned by a query | 1,000,000 / 100,000 |
| Rows changed by one `UPDATE` or `DELETE` | 100,000 |
| Ordered matching rows | 100,000 |
| Transaction overlay | 100,000 keys and 16 MiB |
| Table sources in one joined `SELECT` | 8 total (one base plus seven `JOIN` clauses) |
| `ON` equalities in one joined `SELECT` | 32 across the chain |
| Join candidate row extensions / returned rows | 1,000,000 across the chain / 100,000 |
| Join retained build rows | 100,000 across the chain |
| Join working state / result data | 16 MiB / 16 MiB across the chain |
| Aggregate groups / calls | 100,000 / 64 |
| Aggregate cells (groups times aggregate calls) | 1,000,000 |
| Query, DML result, join, aggregate, or mutation working set | 16 MiB per operation-specific bound |

An ordered query can reach its materialization limit before applying a small
`LIMIT`. Join candidate-extension and retained-row bounds apply to the complete
left-deep chain, not separately to each step and not just to returned rows. The
candidate limit is enforced by a runtime counter, including comparisons that
fail the join condition. The
1,024-byte secondary-index key limit covers the complete encoded indexed tuple,
separator, and primary-key tuple together, not each component independently.
An individual JSON value remains subject to the smaller budget for the row that
contains it.

Every occurrence of a parameter in a statement counts toward the expanded
binding budget. Repeating one large `$1` value many times can therefore fail
with `RESOURCE_LIMIT` even when the supplied parameter array is small. This
check runs before the values are copied into the statement, including prepared
executions and queries with `LIMIT 0`. It is an independent allocation bound,
not a limit on the total memory used by the browser or WebAssembly instance.

# Caveats

Source: https://tinyjoin.org/guides/caveats/

TinyJoin is small on purpose, and being small has costs. This page collects
what an application signs up for, in one place, so that the decision gets made
before the schema does.

If any of these are unacceptable, there are
[more mature projects](https://tinyjoin.org/guides/caveats/#if-tinyjoin-is-not-the-right-fit) that solve the same
problem with different trade-offs.

## It is experimental

TinyJoin is at v0.1.0. The JavaScript API, the SQL dialect, the error codes,
and the page format can all change between releases, and a release may require
an application to recreate its persistent database rather than migrate it.

Version the OPFS name alongside the schema generation, as in
`opfs://my-app-v1`, so that a breaking change becomes a new database rather
than a broken one. The [release notes](https://tinyjoin.org/guides/releases/) record each release's
compatibility boundary.

## The SQL is a bounded subset

TinyJoin implements its own PostgreSQL-shaped dialect. It is not PostgreSQL
compiled to WebAssembly, and familiar syntax is implemented only where the
smaller runtime can give it clear and deterministic semantics. Unsupported
forms are rejected explicitly rather than silently reinterpreted.

The omissions most likely to matter are:

- No subqueries, CTEs, `UNION`/`INTERSECT`/`EXCEPT`, or `DISTINCT`.
- No arithmetic, concatenation, casts, or scalar functions. Values are not a
  general expression language.
- No `LIKE`/`ILIKE`, `BETWEEN`, `ANY`/`ALL`, or JSON path operators, so
  substring search and ranged text matching have to happen outside SQL.
- No `ON CONFLICT` upsert, `INSERT ... SELECT`, `MERGE`, or `UPDATE ... FROM`.
- No sequences, `SERIAL`, or generated identity. Generate text identifiers in
  the client.
- No `NUMERIC`/`DECIMAL`, date, time, interval, `UUID`, `BYTEA`, array, enum,
  or user-defined types. Five runtime types cover boolean, integer, float,
  text, and JSON.
- No `HAVING`, distinct or filtered aggregates, window functions,
  `RIGHT`/`FULL`/`CROSS` joins, views, or triggers.

The [SQL compatibility contract](https://tinyjoin.org/guides/sql-compatibility/) is the exact list.
Read it before designing a schema, not after.

## Multiple tabs share one writer

Clients using the same OPFS name automatically share one database-owning
Worker. Election, routing, prepared statement restoration, and cross-tab
notifications are internal. Different names and browser storage partitions
remain independent.

This is one serialized engine. An open transaction holds other Clients until
its callback finishes, and a frozen live owner or transaction holder can delay
other tabs until it resumes or closes. Keep callbacks short. Closing or losing
the owner triggers automatic election, but requests already sent to it fail
with `LEADER_CHANGED` and are never silently replayed. Incompatible releases
fail with `DATABASE_VERSION_MISMATCH`; old versions that do not coordinate
can still hold the underlying OPFS lock. See
[tab handover](https://tinyjoin.org/guides/storage-and-lifecycle/#tab-handover-and-subscriptions).

## It needs a modern browser

The browser entry point requires WebAssembly and dedicated module Workers, and persistence
additionally requires a secure context, OPFS synchronous access handles, Web
Locks, and BroadcastChannel. It
deliberately does not use `SharedArrayBuffer`, so a page does not need
cross-origin isolation headers.

The separate [Node entry point](https://tinyjoin.org/guides/node/) supports in-memory databases
in Node.js 22 or later. It does not provide OPFS or filesystem persistence.

The automated browser suite currently runs on Chromium only. Firefox and WebKit
are not verified, and a successful TypeScript or Vite build says nothing about
them. Test the browsers an application actually targets.

There is also no fallback to IndexedDB or memory when persistent storage is
unavailable, locked, corrupt, or out of quota. create() rejects instead.

TinyJoin does not automatically restore a Client closed during page teardown.
If the browser restores that page from its back/forward cache, the application
must reopen its Client and recreate statements and subscriptions, or reload
the page. The [lifecycle guide](https://tinyjoin.org/guides/storage-and-lifecycle/#opening-and-closing)
describes this boundary.

## Browser storage is not durable storage

OPFS is browser-managed. A user can clear it, and a browser may evict
best-effort storage under pressure. A `navigator.storage.persist()` request is
a request, not a guarantee, and TinyJoin does not make that product decision
during startup.

Treat a TinyJoin database as reconstructable local state. Data that has to
survive needs a copy the application controls.

A storage or commit-result failure can leave a write's outcome uncertain.
`RECOVERY_REQUIRED`, `STORAGE_COMMIT_OUTCOME_UNKNOWN`, and
`STORAGE_ENGINE_POISONED` require closing and reopening the Client. Reconcile
the recovered rows with stable operation identifiers before any replay;
`retryable` does not guarantee that replay is safe. The
[recovery guide](https://tinyjoin.org/guides/storage-and-lifecycle/#recovering-after-an-uncertain-write)
explains how to preserve that distinction.

## Transactions need bounded callbacks

Nested callback transactions are unsupported: awaiting another transaction()
on the same Client inside its callback deadlocks. Pass the active Transaction
to helpers. There is no AbortSignal or built-in timeout; Promise.race() stops
waiting without cancelling work, which can still commit. Read the
[transaction guide](https://tinyjoin.org/guides/transactions-and-changes/#composing-transaction-helpers)
before composing asynchronous application work.

## There is no server, and no sync

TinyJoin has no PostgreSQL wire protocol, server process, roles or grants,
system catalogs, extensions, stored procedures, WAL, replication, or
point-in-time recovery. It does not synchronize with a remote database and does
not propagate offline writes. Its storage is TinyJoin's own page format, not a
PostgreSQL data directory.

## The limits are hard limits

Oversized work fails explicitly rather than growing until the tab dies. A
persistent database is bounded to 256 MiB, one query returns at most 100,000
rows, a join chains at most eight table sources, and SQL text, parameters,
prepared statements, and working memory each have a named bound. The complete
list is in [hard limits](https://tinyjoin.org/guides/sql-compatibility/#hard-limits).

Multi-tab routing also bounds each Client's pending requests and the owner's
waiting queue to 256 requests and approximately 8 MiB each, with a small
reserved allowance for transaction cleanup. Hitting these bounds rejects with
`RESOURCE_LIMIT`; await work or batch related writes. Prepared engine limits
are shared across connected Clients; at most 128 Clients can attach to an owner.

That boundedness is deliberate, but it does mean TinyJoin is sized for
application state rather than for analytics over a large dataset.

## Performance depends on the workload

Keep transactions short and request only the data your UI needs. Measure
populated startup and larger batches with your application's data and target
devices.

The [transaction guide](https://tinyjoin.org/guides/transactions-and-changes/#inserting-many-rows)
explains batching and validation costs. The
[workload report](https://github.com/tinyplex/tinyjoin/tree/main/benchmarks)
records detailed measurements, runtime hashes, and reproduction commands for
mixed transactions, populated startup, and result materialization. Those
measurements cover one desktop and Chromium version.

## If TinyJoin is not the right fit

These projects are larger, more mature, or both, and are the better answer when
the caveats above are not acceptable:

- [PGlite](https://pglite.dev/) is real PostgreSQL compiled to WebAssembly,
  with the full dialect and extensions. Choose it when genuine PostgreSQL
  compatibility matters more than download size.
- [SQLite Wasm](https://sqlite.org/wasm/) is the official SQLite build for the
  browser, with an OPFS backend, decades of stability, and a much larger SQL
  surface.
- [wa-sqlite](https://github.com/rhashimoto/wa-sqlite) is SQLite for the
  browser with pluggable storage backends, including ones designed for
  concurrent tabs.
- [DuckDB-Wasm](https://github.com/duckdb/duckdb-wasm) is columnar analytics in
  the browser, for aggregate queries over large datasets.
- [Dexie](https://dexie.org/) is a typed IndexedDB wrapper, for when relational
  SQL is not the requirement and broad browser support is.
- [TinyBase](https://tinybase.org/) is a reactive data store with persistence
  and synchronization, for when the requirement is local-first sync rather than
  SQL.

None of that is a criticism of those projects. TinyJoin exists because a useful
subset of the same problem fits in a much smaller download.

# Transactions and changes

Source: https://tinyjoin.org/guides/transactions-and-changes/

Each TinyJoin query is atomic. Use a callback transaction when several related
row mutations must commit together.

```ts
await db.transaction(async (tx) => {
  await tx.query('UPDATE accounts SET balance = $1 WHERE id = $2', [40, 'a']);
  await tx.query('UPDATE accounts SET balance = $1 WHERE id = $2', [60, 'b']);
});
```

Reads inside the callback see staged rows. Use the transaction object for all
database work until its callback finishes; direct Client operations fail while
it is active, and a later transaction() call waits its turn. Letting an error
escape before commit discards the staged transaction.

For an OPFS database, other Clients using the same name wait for the entire
callback, including Clients in other tabs. They never read its uncommitted
rows. Do not await work on another Client for that name inside the callback:
that work needs the callback to finish first.

TinyJoin does not put a callback transaction into PostgreSQL's aborted state
after a statement failure. If application code catches that failure, earlier
staged writes may still commit. Call tx.rollback() or rethrow when the whole
unit should be discarded.

Run schema DDL such as `CREATE`, `ALTER`, and `DROP` outside the callback, using
a standalone query() or an atomic exec() script.

## Composing transaction helpers

Nested callback transactions are not supported. In particular, do not await
db.transaction() from inside that same Client's transaction callback: the
inner call queues behind the outer call, while the outer callback waits for
the inner call. Neither can finish, and there is no deadlock detector.

Pass the active Transaction to helpers instead of having each helper open a
transaction:

```ts
import type {Transaction} from 'tinyjoin';

const setBalance = async (tx: Transaction, id: string, balance: number) => {
  await tx.query('UPDATE accounts SET balance = $1 WHERE id = $2', [balance, id]);
};

await db.transaction(async (tx) => {
  await setBalance(tx, 'a', 40);
  await setBalance(tx, 'b', 60);
});
```

Use tx.query(), tx.exec(), or tx.execute() throughout the callback. Prepare
handles before entering it. tx.exec() gives a read/DML script its own atomic
failure boundary within the active transaction; it does not open a nested
transaction or expose general-purpose savepoints.

## Errors and cancellation

An ordinary statement failure changes none of that statement's staged rows.
Rethrow it or call tx.rollback() to discard earlier staged work too. A failure
while publishing a commit has a different boundary: `RECOVERY_REQUIRED`,
`STORAGE_COMMIT_OUTCOME_UNKNOWN`, or `STORAGE_ENGINE_POISONED` requires closing
the Client, reopening the same OPFS name, and reconciling the operation before
replay. Rejection of transaction() alone is not proof that a commit did not
happen. Follow the [recovery procedure](https://tinyjoin.org/guides/storage-and-lifecycle/#recovering-after-an-uncertain-write).

If the database-owning tab closes or crashes, an already sent operation rejects
with `LEADER_CHANGED` and may have committed. A still-running callback cannot
continue on the replacement owner: later transaction operations reject with
`TRANSACTION_LOST`. The Client reconnects automatically for new operations,
but never repeats writes or reruns the callback. Reconcile before retrying.

There is no AbortSignal, query timeout, or transaction timeout option.
Promise.race() with a timer only stops the caller waiting; the callback and
queued database work can continue and may commit. It is not cancellation or
evidence of rollback. Avoid waiting for network requests, user input, or a
nested transaction inside a callback. Keep the callback bounded, and request
an explicit tx.rollback() while it is active when application logic decides
to abandon staged work. Closing a Client is teardown, not a safe way to infer
the outcome of an in-flight write.

## Inserting many rows

Prepare a parameterized `INSERT` once and execute it through the transaction
object. Transactions that only append new primary keys validate each new
statement incrementally, including unique-index constraints. Final commit
still validates and publishes the complete write set.

```ts
const insert = await db.prepare('INSERT INTO tasks (id, title) VALUES ($1, $2)');
try {
  await db.transaction(async (tx) => {
    for (const title of titles) {
      await tx.execute(insert, [crypto.randomUUID(), title]);
    }
  });
} finally {
  await insert.close();
}
```

Updating, deleting, or revisiting a staged key switches that transaction to
complete write-set validation after each statement. Many individual writes
on that path can have quadratic staging cost. Repeated tx.exec() calls also
copy the current transaction state for script rollback. Keep transactions
bounded and prefer multi-row statements when the application can form them
within the [SQL limits](https://tinyjoin.org/guides/sql-compatibility/#hard-limits).

These limits apply cumulatively across the transaction, even when each
individual statement is small. Transaction reads use the staged row view;
secondary-index query acceleration is currently disabled inside callbacks.

## Re-query after a commit

Subscriptions report changed table names rather than maintaining a live result
object:

```ts
const unsubscribe = db.subscribe({tables: ['tasks']}, async () => {
  const {rows} = await db.query('SELECT * FROM tasks ORDER BY id');
  render(rows);
});
```

The listener runs after commits from any connected Client. Several statements
in one transaction produce one committed revision and one table-level
invalidation. Events arriving while this Client has a transaction open are
coalesced and delivered after its callback finishes, when re-querying is safe.
Call the returned function to unsubscribe before closing the database.

After tab handover or page restoration, a listener can also receive an event
with `reset: true` and an empty `tables` array. Its precise missed changes are
unknown, so every subscription is notified, even one filtered to specific
tables. The example above works for both normal changes and resets because it
always re-queries. See [tab handover](https://tinyjoin.org/guides/storage-and-lifecycle/#tab-handover-and-subscriptions).

This explicit re-query model keeps TinyJoin independent of UI frameworks and
lets an application choose its own caching or rendering policy.

# Custom Workers

Source: https://tinyjoin.org/guides/custom-workers/

Most applications should use create(). It constructs TinyJoin's packaged
module Worker and preserves the relative Worker, OPFS runtime, and WebAssembly
assets during the supported Vite build path.

For Node.js, use [`tinyjoin/node`](https://tinyjoin.org/guides/node/), which constructs its own
Worker thread and loads WebAssembly without a custom bootstrap.

An application that needs to own the Worker can provide a factory:

```ts
const db = await create({
  workerFactory: () =>
    new Worker(new URL('./tinyjoin.worker.ts', import.meta.url), {
      name: 'tinyjoin',
      type: 'module',
    }),
});
```

The Worker entry starts the same engine and automatic OPFS coordination:

```ts
import {startWorker} from 'tinyjoin/worker';

startWorker();
```

Use at most one of `worker`, `workerFactory`, or `workerUrl`. A custom Worker is
an advanced bundling boundary, not a requirement for persistent storage.

Tabs using the same OPFS name and compatible TinyJoin release share one owner,
even when their Workers use different bundle URLs. Keep startWorker() in the
entry to participate in that protocol. An arbitrary Worker-like replacement is
responsible for implementing its own storage and coordination behavior. The
packaged Worker also requests subscription refreshes when the page becomes
visible or resumes; custom lifecycle integration should re-query on restoration.

Synchronous OPFS access is available in a dedicated Worker, not a SharedWorker.
Ordinary browser Workers also cannot open arbitrary PostgreSQL TCP connections.
Network replication or offline write synchronization would require separately
designed transports, durability, authorization, and conflict semantics; none is
hidden in the current package.

## Production hosting

The default Worker and custom Worker paths are verified with Vite production
builds in Chromium. Other bundlers are not currently verified; a successful
TypeScript build alone does not check runtime asset resolution. Serve and test
the complete emitted build before relying on another bundler.

- Use HTTPS, or a trustworthy localhost origin for development. OPFS and
  service workers require a secure context.
- Keep the application, initial module Worker, service worker, and all runtime
  assets on the same origin. Serve the entire build directory at its configured
  Vite base, preserving the emitted relative paths and private lazy OPFS files.
- Return JavaScript as `text/javascript` and WASM as `application/wasm`.
  Return HTML only for navigation routes: a missing Worker or WASM URL must
  return an error rather than an HTML fallback. JSON manifests use
  `application/json`.
- Deploy the build as one version. Do not remove files still needed by running
  tabs. See [offline updates](https://tinyjoin.org/guides/offline/#safe-application-updates) for
  installation, cache integrity, and waiting-tab behavior.

### Content Security Policy

This restrictive starting policy is tested with the default Worker, OPFS,
two-tab access, and offline close/reopen in a production Vite fixture:

```http
Content-Security-Policy: default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self'; connect-src 'self'; style-src 'self'; img-src 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'
X-Content-Type-Options: nosniff
```

Send these headers on HTML **and Worker/service-worker JavaScript responses**.
A Worker has its own execution policy; an HTML policy alone does not establish
the Worker script's execution restrictions. Keep the headers on cached
responses too. The fixture checks an offline navigation using those responses.

`worker-src 'self'` permits same-origin Workers and service workers.
`connect-src 'self'` permits runtime and precache fetches.
`script-src 'self' 'wasm-unsafe-eval'` permits the emitted scripts and
WebAssembly compilation without enabling JavaScript `eval`. The narrower
WebAssembly permission is defined by
[Content Security Policy](https://www.w3.org/TR/CSP/#directive-script-src).
No `blob:`, `data:`, inline-script permission, or broad `unsafe-eval` permission
is required by the verified default Vite path.

Adapt styles, images, and other application resources deliberately: this example
uses external scripts and styles on the same origin. Test the actual application
with its response headers; custom Worker URLs and other bundlers may emit a
different asset graph. The production fixture runs with
`npm run build && npm run test:offline:built` in this repository.

# Node

Source: https://tinyjoin.org/guides/node/

Use `tinyjoin/node` to run an in-memory TinyJoin database in Node.js 22 or
later. It uses a dedicated Worker thread and the same WebAssembly engine and
Client API as the browser runtime.

## Get started

Install TinyJoin:

```sh
npm install tinyjoin
```

Save this ESM example as `main.mjs`:

```js
import {create as createNode} from 'tinyjoin/node';

const db = await createNode();
try {
  await db.exec(`
    CREATE TABLE notes (
      id INTEGER PRIMARY KEY,
      body TEXT NOT NULL
    )
  `);
  await db.query('INSERT INTO notes (id, body) VALUES ($1, $2)', [
    1,
    'Hello from Node',
  ]);
  const {rows} = await db.query('SELECT * FROM notes ORDER BY id');
  console.log(rows);
} finally {
  await db.close();
}
```

Run it with `node main.mjs`. No extra dependencies, bundler, Worker entry, or
polyfills are needed. The entry point constructs the Worker and loads the
packaged WebAssembly file from disk automatically.

## API and lifecycle

The Node entry point exports
[`create()`](https://tinyjoin.org/api/node/functions/lifecycle/create/). Call it without arguments,
or with `memory://`, to create a fresh database. The Promise resolves when the
Worker and engine are ready.

The returned Client supports the same parameterized queries, transactions,
prepared statements, subscriptions, and TypeScript row generics as the browser
Client. The [SQL compatibility rules](https://tinyjoin.org/guides/sql-compatibility/) and
[transaction behavior](https://tinyjoin.org/guides/transactions-and-changes/) also apply. The Node
entry point also exports ClientError and the shared Client API types, including
row, result, query-option, transaction, prepared-statement, and subscription
types:

```ts
import {type Client, type Row, create, ClientError} from 'tinyjoin/node';
```

ClientError is the same error class exported by the browser entry point.

Each call owns one Worker thread and an independent database. Always await
db.close() when finished, including error paths, so the Worker is released and
the Node process can exit. A closed Client cannot be reused.

## Storage boundaries

This entry point is experimental and supports memory only. Data is lost when
the Client closes or the process exits. Creating another Client starts with an
empty database, including when both calls use `memory://`.

There is no OPFS or filesystem persistence, shared database between Clients,
or remote synchronization. The Node entry point does not accept the browser
create() options or custom Worker configuration. Use the browser entry point
for [persistent browser storage](https://tinyjoin.org/guides/storage-and-lifecycle/).

# Agents guide

Source: https://tinyjoin.org/guides/agents-guide/

This guide follows the [agents.md](https://agents.md/) convention and gives AI
coding assistants a compact, current model of TinyJoin.

## Choose TinyJoin when

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](https://tinyjoin.org/guides/caveats/) before recommending it, and
suggest a more mature browser database from that page when an application
needs what TinyJoin does not provide.

## Start from the small API

Most application code needs only:

```ts
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.

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](https://tinyjoin.org/guides/node/).

## SQL rules that matter in application code

- 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.
- Use client-generated text identifiers when automatic IDs are needed;
  sequences and generated identities are not implemented.
- Keep schema setup idempotent with `IF NOT EXISTS` where appropriate.
- Treat a row generic as a TypeScript assertion, not runtime validation.
- Consult the
  [SQL compatibility contract](https://tinyjoin.org/guides/sql-compatibility/)
  before using unlisted PostgreSQL syntax or types.
- Joins run left to right as bounded nested loops, without reordering or
  index-based join lookup. 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`.

## Transactions and changes

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.

Append-only inserts validate incrementally; updates, deletes, or revisiting a
staged key switch to full write-set validation per statement. Keep mixed
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.

## Storage and cleanup

- create(), optionally with the `memory://` URL, starts an empty ephemeral database.
- create() with an `opfs://name` URL 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_CHANGED` and must be reconciled before replay; interrupted
  transactions fail with `TRANSACTION_LOST`. Never automatically retry writes.
- Subscription events with `reset: true` require a re-query even when
  `tables` is empty; they cover handover and page restoration.
- `create-tinyjoin` generates offline-capable production builds. Existing Vite
  apps can add tinyjoinOffline() from `tinyjoin/vite`; apps with an existing
  service worker use its `manifest` mode. See the
  [offline guide](https://tinyjoin.org/guides/offline/). 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.
- After `RECOVERY_REQUIRED`, `STORAGE_COMMIT_OUTCOME_UNKNOWN`, or
  `STORAGE_ENGINE_POISONED`, stop work, close and reopen the same OPFS name,
  and reconcile stable operation identifiers before replay. `retryable` is
  not a safe-replay guarantee. Follow the
  [recovery guide](https://tinyjoin.org/guides/storage-and-lifecycle/#recovering-after-an-uncertain-write).

The [full agent reference](https://tinyjoin.org/llms-full.txt) 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.

## Repository work

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.

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:

```sh
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:size
```

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.

# Offline

Source: https://tinyjoin.org/guides/offline/

OPFS persists database rows. Reopening the application without a network also
requires its HTML, JavaScript, CSS, Worker, and WASM files to be available.
TinyJoin's optional Vite plugin caches the complete production build, including
lazy assets that the first visit has not used yet.

## Vite setup

Add tinyjoinOffline to the application's Vite configuration:

```ts
import {defineConfig} from 'vite';
import {tinyjoinOffline} from 'tinyjoin/vite';

export default defineConfig({
  plugins: [tinyjoinOffline()],
});
```

The [starter](https://tinyjoin.org/guides/getting-started/#install) includes this plugin.
No extra Worker entry, asset-copying command, or registration code is needed.
The plugin is a Node-only build integration; importing tinyjoin in application
code does not include it in the browser runtime.

Run the production build and serve the complete output over HTTPS, or localhost
while testing. The Vite development server does not register an offline service
worker. To exercise offline behavior locally, use `vite build` followed by
`vite preview`.

Production hosts must preserve the emitted asset paths, JavaScript/WASM MIME
types, and security headers, including on Worker responses. The
[hosting and CSP checklist](https://tinyjoin.org/guides/custom-workers/#production-hosting) includes
a restrictive policy tested with offline OPFS startup and reopen. The default
and custom Worker paths are verified with Vite in Chromium; other bundlers are
not currently verified.

The first visit needs a network connection. The service worker downloads and
verifies every build file before installation succeeds. After
`navigator.serviceWorker.ready` resolves, a subsequent navigation can use that
installed application offline. The first page is not forcibly taken over while
it is running.

## What gets cached

The plugin produces these files:

| File | Purpose |
| --- | --- |
| `tinyjoin-sw.js` | Application service worker. |
| `tinyjoin-register.js` | External registration script inserted into built HTML. |
| `tinyjoin-precache.json` | Build version, base, and relative asset URLs with SHA-256 content revisions. |
| `tinyjoin-precache.js` | Service-worker helper containing the same manifest. |

Every regular file in the final Vite output is included, including public files,
unused dynamic chunks, and TinyJoin's private lazy OPFS runtime. The service
worker and its manifest/helper are maintained by the browser's service-worker
installation mechanism rather than included recursively in their own cache.

API requests, cross-origin URLs, and files created after the build are outside
this cache. Bundle necessary application resources or provide your own caching
policy for them. Cache Storage and OPFS have separate contents; installing or
updating the application cache does not migrate or delete database rows.
Browsers can still clear or evict either kind of storage. See
[storage retention](https://tinyjoin.org/guides/storage-and-lifecycle/#browser-retention).

The plugin supports Vite bases such as `/`, `/my-app/`, and `./`. Its service
worker only controls the application's base directory. A CDN URL as the Vite
base is not supported because this integration requires same-origin files.

Navigation uses a cached emitted HTML file when one matches. Otherwise it falls
back to `index.html` for client-side routes within the application scope. Change
that file with `tinyjoinOffline({navigationFallback: 'app.html'})`, or disable
the fallback with `tinyjoinOffline({navigationFallback: false})`.

## Safe application updates

A new build gets a separate cache. Its files must all match their recorded
content hashes before it can install. An interrupted or mixed deployment leaves
the previously installed application available.

The new service worker waits until every tab controlled by the old one closes.
Running tabs keep the old application and its old assets, including lazy files
they have not fetched before. Once the replacement activates, it removes this
application's previous TinyJoin caches. Close all application tabs and reopen to
finish an update; reloading only one tab while another remains open can continue
using the old build.

Do not add automatic `skipWaiting()` or `clients.claim()` calls to force updates.
They can make a running application use assets from a different build. Deploy
the complete build together and retain normal static-file MIME types. If a
cached entry is later missing, the helper tries to restore the exact recorded
content from the network. If that content is unavailable or has changed, the
request fails with HTTP 503 rather than mixing in a newer deployment. Close the
application's tabs and reopen online to finish installing a newer release or
repair an incomplete cache from the matching deployment.

Application updates and database schema changes are separate decisions. Keep
schema setup idempotent and observe the
[storage compatibility boundary](https://tinyjoin.org/guides/releases/#v0-1-0).

## Integrating an existing service worker

An application should have one owner for its service-worker lifecycle. Automatic
TinyJoin registration detects another worker already controlling the application
and leaves it alone, with a console message explaining the conflict.

Use manifest mode when an application already owns its service worker:

```ts
export default defineConfig({
  plugins: [tinyjoinOffline({mode: 'manifest'})],
});
```

This emits only `tinyjoin-precache.json` and `tinyjoin-precache.js`. It does not
insert registration code or create `tinyjoin-sw.js`. A classic service worker
beside the build output can use the generated helper:

```js
importScripts('./tinyjoin-precache.js');

const precache = self.createTinyjoinPrecache();

self.addEventListener('install', (event) => {
  event.waitUntil(precache.install());
});
self.addEventListener('activate', (event) => {
  event.waitUntil(precache.activate());
});
self.addEventListener('fetch', (event) => {
  const response = precache.match(event.request);
  if (response) {
    event.respondWith(response);
  }
  // Otherwise leave this request to the application's existing fetch policy.
});
```

Combine this branch with the existing fetch handler so that each request calls
`respondWith` at most once. Keep the native waiting lifecycle described above;
calling activate early would remove assets still needed by old tabs. Register
the existing worker with `updateViaCache: 'none'` so its imported manifest is
checked for changes. For a relative Vite base and a service worker registered
outside the application directory, pass that directory explicitly:
`self.createTinyjoinPrecache(new URL('./my-app/', self.location.origin))`.

An existing precaching system can instead consume the JSON manifest. Resolve
its asset URLs against the application's build base and preserve content
verification and coherent version activation in that integration.

Consume the **complete** asset list, including the private lazy OPFS runtime,
Worker, and WASM files even when the first visit only opens a memory database.
Caching the entry script or the files observed during one online visit is not
sufficient for a later persistent open without a network.

# Releases

Source: https://tinyjoin.org/guides/releases/

This is a reverse chronological summary of TinyJoin releases and their public
compatibility boundaries.

## v0.1.0

The new [Node entry point](https://tinyjoin.org/guides/node/), `tinyjoin/node`, opens in-memory
databases in Node.js 22 or later. It constructs a Worker thread and loads WASM
automatically, returning the same Client API without additional dependencies.
Each Client owns an independent database; filesystem persistence is not included.

**Persistent storage breaks compatibility with v0.0.5.** The npm v0.0.5
release uses page format 1; v0.1.0 uses page format 2. Opening a v0.0.5
database with v0.1.0 fails with `UNSUPPORTED_PAGE`. There is no automatic
migration, and changing the OPFS name does not copy existing data.

- For data that can be reconstructed, use a new OPFS namespace, for example
  change `opfs://my-app-v1` to `opfs://my-app-v2`, and rebuild the database.
  The old namespace remains intact.
- To retain existing data, export the application's known tables using a
  client pinned to `tinyjoin@0.0.5` before upgrading. Create the schema in a
  new namespace with v0.1.0, import rows with parameterized statements, and
  verify the data before retiring the old database. TinyJoin does not yet
  provide a general database export or migration API.
- New apps generated by create-tinyjoin v0.1.0 target `tinyjoin@^0.1.0` and
  use a `-db-v2` storage name. Updating the generator does not migrate
  applications it generated previously.

This release also makes predicate and assignment type validation consistent
across reads and writes, compares heterogeneous JSON values consistently,
and bounds shared parameter-graph traversal and expanded SQL bindings before
copying values. Append-only transactions now validate new row statements
incrementally, including unique-index checks; mixed writes retain complete
staged write-set validation.

Selective multi-table joins now use their actual comparison count rather than
a worst-case Cartesian estimate. Scan, build-row, result, memory, and shared
script-work limits remain in force.

Page checksum calculation is faster while preserving the stored checksum
values, storage format, and corruption checks. This improves mixed writes and
populated startup; the repository's [workload measurements](https://github.com/tinyplex/tinyjoin/tree/main/benchmarks)
retain the before/after samples and runtime hashes.

`UPDATE` and `DELETE` now use direct primary-key lookup for exact complete key
predicates, including staged transaction rows. Other predicates retain their
scan path, and statement validation and constraint checks remain in force.

Persistent startup avoids a redundant table scan for unique indexes whose
columns are all `NOT NULL`. Table validation and complete index-entry checks
remain in force; nullable indexes and indexes without uniqueness retain the
existing count scan.

Duplicate output names in simple projections and `RETURNING` now fail
consistently with `INVALID_QUERY` before execution. `LIMIT` and `OFFSET`
reject nonnumeric parameter values consistently in ordinary, aggregate, and
join queries, including JSON objects shaped like internal prepared placeholders.

Persistent Clients now coordinate automatically across tabs and within a page.
One elected Worker owns the database; operations, prepared statements, and
notifications follow it through handover. Callback transactions exclude other
Clients for their duration. In-flight operations interrupted by owner loss
reject without automatic replay, and incompatible releases reject explicitly.

The optional `tinyjoin/vite` build plugin precaches a complete production
application, including lazy Worker, OPFS, and WASM assets. New starter apps
enable it by default. Updates wait for old controlled tabs to close; existing
service workers can use its generated manifest and helper instead. See the
[offline guide](https://tinyjoin.org/guides/offline/).

## v0.0.5

This release establishes TinyJoin as a standalone, SQL-first browser database
package.

- create() opens the packaged dedicated Worker and WebAssembly engine with no
  application Worker boilerplate.
- Memory and persistent OPFS databases use one bounded page-native engine.
- The JavaScript client provides parameterized queries, atomic scripts,
  callback transactions, reusable prepared statements, and table-level change
  subscriptions.
- Typed tables support primary keys, maintained secondary and unique indexes,
  bounded schema additions, aggregates, and left-deep inner and left joins.
- Results use a familiar `rows`, `fields`, `affectedRows`, `command`, and
  `rowCount` shape, with TinyJoin revision and changed-table metadata.
- The published package includes its runtime, Worker, WASM, declarations,
  compatibility guide, README, release notes, and agent guidance.

TinyJoin remains experimental and deliberately smaller than PostgreSQL. This
release does not include a PostgreSQL server or wire protocol, hosted service,
remote replication, or offline-write synchronization.

# Guides

Source: https://tinyjoin.org/guides/

These guides start with the smallest useful TinyJoin application and reveal
storage, transactions, custom Workers, and compatibility details only when they
become relevant.

The [Node guide](https://tinyjoin.org/guides/node/) covers in-memory databases in Node.js with
the same Client API.

The [caveats](https://tinyjoin.org/guides/caveats/) collect what TinyJoin deliberately does not do,
and which projects to reach for when that matters.

For exact method and type signatures, use the [API reference](https://tinyjoin.org/api/).

# Public API: tinyjoin

Source: https://tinyjoin.org/api/tinyjoin/

````ts
/**
 * The tinyjoin module provides a small PostgreSQL-shaped relational database
 * that runs in a dedicated browser Worker and stores data in memory or OPFS.
 *
 * Start with the create function. It constructs the Worker and loads the
 * WebAssembly engine, so applications do not need to manage either directly.
 * @packageDocumentation
 * @module tinyjoin
 * @since v0.0.5
 */

/**
 * The JsonPrimitive type represents a scalar value accepted by TinyJoin.
 * @category Data types
 * @since v0.0.5
 */
export type JsonPrimitive = null | boolean | number | string;

/**
 * The JsonValue type represents a parameter or result value accepted by the
 * TinyJoin JavaScript API.
 * @category Data types
 * @since v0.0.5
 */
export type JsonValue =
  | JsonPrimitive
  | JsonValue[]
  | {[key: string]: JsonValue};

/**
 * The Row type represents the default object form of a result row.
 * @category Data types
 * @since v0.0.5
 */
export type Row = Record<string, JsonValue>;

/**
 * The DataDir type identifies the database storage mode.
 *
 * Use `memory://` for an ephemeral database, or `opfs://name` for a persistent
 * database. Calling create without a data directory also uses memory.
 * Clients using the same OPFS name automatically share an elected database
 * owner, including across tabs. Pending operations interrupted by owner loss
 * reject without replay; subsequent operations reconnect automatically.
 * Different names have independent data and do not synchronize.
 * @category Configuration
 * @since v0.0.5
 */
export type DataDir = string;

/**
 * The RowMode type selects object rows or positional array rows.
 * @category Query results
 * @since v0.0.5
 */
export type RowMode = 'array' | 'object';

/**
 * The QueryOptions interface configures the shape of query results.
 *
 * Only rowMode is supported. There is no AbortSignal or timeout option;
 * Promise.race with a timer does not cancel database work.
 * @category Query results
 * @since v0.0.5
 */
export interface QueryOptions {
  /**
   * The rowMode property selects object rows by default, or arrays whose values
   * follow the order of the fields property.
   * @category Option
   * @since v0.0.5
   */
  rowMode?: RowMode;
}

/**
 * The ResultField interface describes one projected result column.
 * @category Query results
 * @since v0.0.5
 */
export interface ResultField {
  /**
   * The name property contains the projected column name or alias.
   * @category Result
   * @since v0.0.5
   */
  name: string;

  /**
   * The dataTypeID property contains the closest stable PostgreSQL OID for the
   * TinyJoin runtime type.
   * @category Result
   * @since v0.0.5
   */
  dataTypeID: number;
}

/**
 * The Results interface is returned by queries, prepared statements, and
 * scripts.
 *
 * It follows the familiar PGlite result shape and adds the observed database
 * revision and the names of changed tables.
 * @category Query results
 * @since v0.0.5
 */
export interface Results<RowType = Row> {
  /**
   * The rows property contains the rows returned by the statement.
   * @category Result
   * @since v0.0.5
   */
  rows: RowType[];

  /**
   * The fields property describes the projected columns in result order.
   * @category Result
   * @since v0.0.5
   */
  fields: ResultField[];

  /**
   * The affectedRows property reports the number of rows changed by a write.
   * @category Result
   * @since v0.0.5
   */
  affectedRows?: number;

  /**
   * The command property contains the executed statement family.
   * @category Result
   * @since v0.0.5
   */
  command?: string;

  /**
   * The rowCount property reports the number of rows returned or changed.
   * @category Result
   * @since v0.0.5
   */
  rowCount?: number;

  /**
   * The revision property contains the database revision observed by the
   * statement.
   * @category Result
   * @since v0.0.5
   */
  revision: number;

  /**
   * The tables property contains the tables changed by the statement.
   * @category Result
   * @since v0.0.5
   */
  tables: string[];
}

/**
 * The SerializedError interface is the stable error envelope sent by the
 * Worker.
 * @category Errors
 * @since v0.0.5
 */
export interface SerializedError {
  /**
   * The code property identifies the TinyJoin error family.
   * @category Error
   * @since v0.0.5
   */
  code: string;

  /**
   * The message property explains the failure.
   * @category Error
   * @since v0.0.5
   */
  message: string;

  /**
   * The details property contains optional JSON-compatible context.
   * @category Error
   * @since v0.0.5
   */
  details?: JsonValue;

  /**
   * The retryable property indicates whether reopening or retrying may succeed.
   * It does not guarantee that replaying a write is safe or that the current
   * Client remains usable. Reconcile uncertain writes after reopening.
   * @category Error
   * @since v0.0.5
   */
  retryable?: boolean;
}

// Use the host's event shape when available, including DOM event methods in a
// browser, without requiring DOM declarations in Node applications.
/** @ignore */
type WorkerMessageEvent = typeof globalThis extends {
  MessageEvent: {prototype: infer Event};
}
  ? Omit<Event, 'data'> & {readonly data: unknown}
  : {readonly data: unknown};

/** @ignore */
type WorkerErrorEvent = typeof globalThis extends {
  ErrorEvent: {prototype: infer Event};
}
  ? Event
  : {readonly message: string};

/**
 * The WorkerLike interface is the minimal Worker surface accepted by a custom
 * Client configuration.
 *
 * Most applications should let create construct the packaged Worker instead.
 * @category Workers
 * @since v0.0.5
 */
export interface WorkerLike {
  /**
   * The postMessage method sends a request to the Worker.
   * @category Worker
   * @since v0.0.5
   */
  postMessage(message: unknown): void;

  /**
   * This addEventListener overload listens for Worker messages.
   * @category Worker
   * @since v0.0.5
   */
  addEventListener(
    type: 'message',
    listener: (event: WorkerMessageEvent) => void,
  ): void;

  /**
   * This addEventListener overload listens for message decoding failures.
   * @category Worker
   * @since v0.0.5
   */
  addEventListener(
    type: 'messageerror',
    listener: (event: WorkerMessageEvent) => void,
  ): void;

  /**
   * This addEventListener overload listens for Worker runtime failures.
   * @category Worker
   * @since v0.0.5
   */
  addEventListener(
    type: 'error',
    listener: (event: WorkerErrorEvent) => void,
  ): void;

  /**
   * This removeEventListener overload removes a message listener.
   * @category Worker
   * @since v0.0.5
   */
  removeEventListener(
    type: 'message',
    listener: (event: WorkerMessageEvent) => void,
  ): void;

  /**
   * This removeEventListener overload removes a message-error listener.
   * @category Worker
   * @since v0.0.5
   */
  removeEventListener(
    type: 'messageerror',
    listener: (event: WorkerMessageEvent) => void,
  ): void;

  /**
   * This removeEventListener overload removes a Worker error listener.
   * @category Worker
   * @since v0.0.5
   */
  removeEventListener(
    type: 'error',
    listener: (event: WorkerErrorEvent) => void,
  ): void;

  /**
   * The terminate method stops an application-owned Worker when available.
   * @category Worker
   * @since v0.0.5
   */
  terminate?: () => void;
}

/**
 * The ClientOptions interface configures storage or an application-owned
 * Worker.
 *
 * The zero-boilerplate default constructs TinyJoin's packaged module Worker.
 * Provide at most one of worker, workerFactory, or workerUrl.
 * @category Configuration
 * @since v0.0.5
 */
export interface ClientOptions {
  /**
   * The worker property provides an already-created Worker-compatible object.
   * @category Option
   * @since v0.0.5
   */
  worker?: WorkerLike;

  /**
   * The workerFactory property creates an application-owned Worker lazily.
   * @category Option
   * @since v0.0.5
   */
  workerFactory?: () => WorkerLike;

  /**
   * The workerUrl property identifies an application-owned module Worker.
   * @category Option
   * @since v0.0.5
   */
  workerUrl?: string | URL;

  /**
   * The dataDir property selects memory or a named OPFS database.
   * @category Option
   * @since v0.0.5
   */
  dataDir?: DataDir;
}

/**
 * The TablesChangedEvent interface describes one committed table-level
 * invalidation or a request to refresh after database-owner handover.
 * @category Subscriptions
 * @since v0.0.5
 */
export interface TablesChangedEvent {
  /**
   * The reset property is true when database-owner handover or page restoration
   * requires a re-query even though the changed tables are unknown. In that
   * case tables is empty and every subscription is notified, including filtered
   * ones. Normal committed-change events omit this property.
   * @category Event
   * @since v0.1.0
   */
  reset?: boolean;

  /**
   * The revision property contains the committed database revision.
   * @category Event
   * @since v0.0.5
   */
  revision: number;

  /**
   * The tables property contains the names of changed tables. It is empty when
   * reset is true, which means the subscriber should refresh its query anyway.
   * @category Event
   * @since v0.0.5
   */
  tables: string[];
}

/**
 * The SubscriptionOptions interface filters invalidations by table name.
 * @category Subscriptions
 * @since v0.0.5
 */
export interface SubscriptionOptions {
  /**
   * The tables property limits delivery to invalidations that include at least
   * one of these tables. Omit it to observe every table-change invalidation;
   * adjacent changes may be coalesced.
   * @category Option
   * @since v0.0.5
   */
  tables?: string[];
}

/**
 * The PreparedStatement interface represents one parsed SELECT, INSERT,
 * UPDATE, or DELETE statement retained by an open Client.
 * @category SQL
 * @since v0.0.5
 */
export interface PreparedStatement<RowType = Row> {
  /**
   * The execute method binds a complete parameter list and runs the statement.
   * @param params JSON-compatible values for `$1`, `$2`, and so on.
   * @param options Result-shape options.
   * @returns A Promise resolving to the statement results.
   * @category SQL
   * @since v0.0.5
   */
  execute(
    params?: JsonValue[],
    options?: QueryOptions,
  ): Promise<Results<RowType>>;

  /**
   * The close method seals the handle and releases its Worker resources after
   * already-started executions settle.
   * @category Lifecycle
   * @since v0.0.5
   */
  close(): Promise<void>;

  /**
   * The closed property indicates whether the handle has been sealed.
   * It is not a Worker health check. A terminal Worker failure can leave it
   * false while execute rejects. Closing the Client seals its handles.
   * @category Lifecycle
   * @since v0.0.5
   */
  readonly closed: boolean;
}

/**
 * The Transaction interface runs reads and row mutations against one isolated
 * staged database inside a Client.transaction callback.
 *
 * Do not retain this object after its callback completes. Run schema DDL in a
 * standalone Client.query call or Client.exec script.
 * Pass this object to helpers instead of awaiting another transaction on the
 * same Client, which would queue behind the active callback and deadlock.
 * @category Transactions
 * @since v0.0.5
 */
export interface Transaction {
  /**
   * The query method runs one parameterized statement against staged data.
   * @returns A Promise resolving to the statement results.
   * @category Transactions
   * @since v0.0.5
   */
  query<RowType = Row>(
    sql: string,
    params?: JsonValue[],
    options?: QueryOptions,
  ): Promise<Results<RowType>>;

  /**
   * The sql method is a parameterizing tagged-template form of query.
   * @returns A Promise resolving to the statement results.
   * @category Transactions
   * @since v0.0.5
   */
  sql<RowType = Row>(
    strings: TemplateStringsArray,
    ...params: JsonValue[]
  ): Promise<Results<RowType>>;

  /**
   * The exec method runs a parameter-free DML and read script as one savepoint.
   * @returns A Promise resolving to one result per statement, in script order.
   * @category Transactions
   * @since v0.0.5
   */
  exec(sql: string, options?: QueryOptions): Promise<Results[]>;

  /**
   * The execute method runs a prepared statement owned by the same Client.
   * @returns A Promise resolving to the statement results.
   * @category Transactions
   * @since v0.0.5
   */
  execute<RowType = Row>(
    statement: PreparedStatement<RowType>,
    params?: JsonValue[],
    options?: QueryOptions,
  ): Promise<Results<RowType>>;

  /**
   * The rollback method explicitly discards the staged transaction.
   * @category Transactions
   * @since v0.0.5
   */
  rollback(): Promise<void>;

  /**
   * The closed property indicates that the transaction is sealed, including
   * after an explicit rollback or after its callback finishes.
   * @category Lifecycle
   * @since v0.0.5
   */
  readonly closed: boolean;
}

/**
 * The Client class represents one open TinyJoin database and its dedicated
 * Worker.
 *
 * Prefer the async create function so initialization failures are reported
 * before the Client is returned.
 * @category Lifecycle
 * @since v0.0.5
 */
export class Client {
  /**
   * The constructor begins opening a Client immediately.
   *
   * Prefer create unless code specifically needs the waitReady lifecycle.
   * @category Lifecycle
   * @since v0.0.5
   */
  constructor(options?: ClientOptions);

  /**
   * The waitReady property resolves when the Worker and database are ready.
   * It settles once for initialization; it does not track later Worker health
   * or temporary OPFS owner handover.
   * @category Lifecycle
   * @since v0.0.5
   */
  readonly waitReady: Promise<void>;

  /**
   * The ready property indicates that initialization finished and closing has
   * not started.
   * It is not a health check: it can remain true after a terminal Worker error
   * while operations reject. Close and reopen the Client and reconcile writes;
   * see the [lifecycle guide](https://tinyjoin.org/guides/storage-and-lifecycle/#terminal-worker-failures).
   * Temporary OPFS owner handover reconnects the same Client automatically.
   * @category Lifecycle
   * @since v0.0.5
   */
  readonly ready: boolean;

  /**
   * The closed property indicates that Client cleanup has completed.
   * A terminal Worker failure does not itself complete Client cleanup. Call
   * close even when subsequent operations reject with WORKER_TERMINATED.
   * @category Lifecycle
   * @since v0.0.5
   */
  readonly closed: boolean;

  /**
   * The query method executes exactly one parameterized SQL statement.
   * @param sql A statement in the documented TinyJoin SQL subset.
   * @param params JSON-compatible values for `$1`, `$2`, and so on.
   * @param options Result-shape options.
   * @returns A Promise resolving to the statement results.
   * @category SQL
   * @essential Using a database
   * @since v0.0.5
   */
  query<RowType = Row>(
    sql: string,
    params?: JsonValue[],
    options?: QueryOptions,
  ): Promise<Results<RowType>>;

  /**
   * The sql method is a parameterizing tagged-template form of query.
   *
   * Interpolated values become `$n` parameters. It does not interpolate raw
   * identifiers or SQL fragments.
   * @returns A Promise resolving to the statement results.
   * @category SQL
   * @essential Using a database
   * @since v0.0.5
   */
  sql<RowType = Row>(
    strings: TemplateStringsArray,
    ...params: JsonValue[]
  ): Promise<Results<RowType>>;

  /**
   * The prepare method parses and retains one reusable read or row-mutation
   * statement in the Worker.
   * @returns A Promise resolving to a reusable PreparedStatement handle.
   * @category SQL
   * @since v0.0.5
   */
  prepare<RowType = Row>(sql: string): Promise<PreparedStatement<RowType>>;

  /**
   * The exec method runs one or more parameter-free statements as one implicit
   * transaction.
   * @returns A Promise resolving to one result per statement, in script order.
   * @category SQL
   * @essential Using a database
   * @since v0.0.5
   */
  exec(sql: string, options?: QueryOptions): Promise<Results[]>;

  /**
   * The transaction method stages row mutations and publishes them together
   * when the callback succeeds, unless it explicitly rolls back.
   *
   * Transaction calls on this Client queue in order. Do not await another transaction on
   * this Client inside the callback; pass its Transaction to helpers instead.
   * Use that object for all SQL in the callback and prepare handles beforehand.
   * Other Clients for the same OPFS name wait for the whole callback to finish.
   * Do not await work on those Clients from inside this callback.
   * An uncaught callback error before commit discards staged work. A caught
   * statement error leaves earlier writes staged unless rollback is called.
   *
   * There is no cancellation or timeout option. Promise.race only stops
   * waiting, and queued work can still commit. Rejection during commit can
   * leave its outcome uncertain; see the
   * [recovery guide](https://tinyjoin.org/guides/storage-and-lifecycle/#recovering-after-an-uncertain-write)
   * before replaying a failed write.
   * @returns A Promise resolving to the callback's result after the transaction
   * commits or explicitly rolls back.
   * @category Transactions
   * @essential Using a database
   * @since v0.0.5
   */
  transaction<Result>(
    callback: (transaction: Transaction) => Result | Promise<Result>,
  ): Promise<Result>;

  /**
   * The subscribe method listens for committed table changes and returns an
   * unsubscribe function. OPFS Clients receive changes from every connected
   * tab. After handover or page restoration, reset events notify every
   * subscriber to re-query even though tables is empty.
   * @returns A function that removes this subscription when called.
   * @category Subscriptions
   * @since v0.0.5
   */
  subscribe(
    options: SubscriptionOptions,
    listener: (event: TablesChangedEvent) => void,
  ): () => void;

  /**
   * The getRevision method returns the newest database revision observed by
   * this Client.
   * @returns The newest database revision observed by this Client.
   * @category Subscriptions
   * @since v0.0.5
   */
  getRevision(): number;

  /**
   * The close method detaches this Client and releases its prepared statements
   * and Worker. Other Clients for the same OPFS name stay connected; ownership
   * transfers automatically when necessary.
   * Repeated calls share the same asynchronous cleanup.
   * A closed Client cannot resume; create a new one and recreate its prepared
   * statements and subscriptions. Closing is not a cancellation or rollback
   * guarantee for a write already in flight.
   * @category Lifecycle
   * @since v0.0.5
   */
  close(): Promise<void>;
}

/**
 * The create function opens a TinyJoin database in a dedicated Worker.
 *
 * Calling it with no argument creates an ephemeral memory database. Pass a
 * stable `opfs://name` to persist the database in browser storage.
 * @returns A Promise resolving to a Client whose Worker and database are ready.
 * @example
 * ```ts
 * import {create} from 'tinyjoin';
 *
 * const db = await create('opfs://my-app');
 * await db.exec(`
 *   CREATE TABLE IF NOT EXISTS tasks (
 *     id INTEGER PRIMARY KEY,
 *     title TEXT NOT NULL
 *   )
 * `);
 * const {rows} = await db.query('SELECT * FROM tasks ORDER BY id');
 * await db.close();
 * ```
 * @category Lifecycle
 * @essential Using a database
 * @since v0.0.5
 */
export function create(): Promise<Client>;
export function create(options: ClientOptions): Promise<Client>;
export function create(
  dataDir: DataDir | undefined,
  options?: ClientOptions,
): Promise<Client>;

/**
 * The ClientError class extends JavaScript Error with a validated error
 * returned by the TinyJoin Worker.
 *
 * RECOVERY_REQUIRED, STORAGE_COMMIT_OUTCOME_UNKNOWN, and
 * STORAGE_ENGINE_POISONED mean the engine must no longer be used. Stop work,
 * close the Client, reopen the same OPFS name, and reconcile stored state
 * using stable operation identifiers before replaying a write. A rejected
 * operation may already have committed. See the
 * [recovery guide](https://tinyjoin.org/guides/storage-and-lifecycle/#recovering-after-an-uncertain-write).
 * @category Errors
 * @since v0.0.5
 */
export class ClientError extends Error {
  /**
   * The constructor creates an Error from its serialized Worker envelope.
   * @category Error
   * @since v0.0.5
   */
  constructor(error: SerializedError);

  /**
   * The code property identifies the TinyJoin error family.
   * @category Error
   * @since v0.0.5
   */
  readonly code: string;

  /**
   * The details property contains optional JSON-compatible context.
   * @category Error
   * @since v0.0.5
   */
  readonly details: SerializedError['details'];

  /**
   * The retryable property indicates whether reopening or retrying may succeed.
   * It is not a safe-replay guarantee. An uncertain write requires reopening
   * and reconciliation even when application code wants to retry it.
   * @category Error
   * @since v0.0.5
   */
  readonly retryable: boolean;
}
````

# Public API: tinyjoin/node

Source: https://tinyjoin.org/api/node/

````ts
/**
 * The node module opens ephemeral TinyJoin databases in Node.js 22 or later.
 * It constructs a Node Worker thread and loads the packaged WebAssembly engine
 * automatically, with no extra dependencies, bundler, or polyfills.
 *
 * The returned Client has the same SQL, transaction, prepared-statement, and
 * subscription APIs as the browser Client. The module also exports ClientError
 * and the shared Client API types, so Node applications can import their query,
 * result, transaction, and subscription contracts from this entry point.
 * ClientError is the same class exported by the browser entry point.
 * See the [Node guide](https://tinyjoin.org/guides/node/).
 * @packageDocumentation
 * @module node
 * @since v0.1.0
 */

import type {Client} from '../index.js';

/**
 * The node module re-exports the shared ClientError class.
 * @category Shared API
 */
export {ClientError} from '../index.js';

/**
 * The node module re-exports the shared Client API types.
 * @category Shared API
 */
export type {
  Client,
  JsonPrimitive,
  JsonValue,
  PreparedStatement,
  QueryOptions,
  ResultField,
  Results,
  Row,
  RowMode,
  SerializedError,
  SubscriptionOptions,
  TablesChangedEvent,
  Transaction,
} from '../index.js';

/**
 * The create function opens a fresh in-memory database in a dedicated Node
 * Worker thread. It resolves when the database is ready.
 *
 * Omit the argument or pass `memory://`. Each call owns an independent database
 * and Worker; close the Client to release the Worker. All data is lost when
 * the Client closes or the process exits. OPFS, filesystem persistence, and
 * remote synchronization are not supported by this entry point.
 * @param dataDir The optional `memory://` URL.
 * @returns A ready Client. Always await its close method when finished.
 * @example
 * ```ts
 * import {type Client, create} from 'tinyjoin/node';
 *
 * const db: Client = await create();
 * try {
 *   await db.exec('CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT)');
 *   await db.query('INSERT INTO notes VALUES ($1, $2)', [1, 'Hello from Node']);
 *   console.log((await db.query('SELECT * FROM notes ORDER BY id')).rows);
 * } finally {
 *   await db.close();
 * }
 * ```
 * @category Lifecycle
 * @since v0.1.0
 */
export function create(dataDir?: 'memory://'): Promise<Client>;
````

# Public API: tinyjoin/worker

Source: https://tinyjoin.org/api/worker/

````ts
/**
 * The worker module lets advanced applications start TinyJoin inside a Worker
 * that they construct and bundle themselves.
 *
 * Most applications should use create from the main tinyjoin module, which
 * constructs the packaged Worker automatically.
 * @packageDocumentation
 * @module worker
 * @since v0.0.5
 */

/**
 * The startWorker function starts the TinyJoin request host in the current
 * dedicated Worker.
 * Persistent databases participate in automatic same-name coordination across
 * tabs, just like the packaged Worker. Memory databases remain independent.
 * @example
 * ```ts
 * import {startWorker} from 'tinyjoin/worker';
 *
 * startWorker();
 * ```
 * @category Workers
 * @since v0.0.5
 */
export function startWorker(): void;
````

# Public API: tinyjoin/vite

Source: https://tinyjoin.org/api/vite/

````ts
/**
 * The vite module provides optional production offline caching for Vite
 * applications, including TinyJoin's lazily loaded Worker, OPFS, and WASM files.
 * This Node-only build integration is separate from the browser runtime.
 * @packageDocumentation
 * @module vite
 * @since v0.1.0
 */

import type {Plugin} from 'vite';

/**
 * The TinyjoinOfflineOptions interface configures production offline caching.
 * @category Offline
 * @since v0.1.0
 */
export interface TinyjoinOfflineOptions {
  /**
 * The mode property selects automatic service-worker registration or integration
 * with an existing service worker. The default is `service-worker`.
 *
 * The `manifest` mode emits `tinyjoin-precache.json` and
 * `tinyjoin-precache.js` without registering or replacing a service worker.
 * @category Offline
 * @since v0.1.0
 */
  mode?: 'service-worker' | 'manifest';
  /**
 * The navigationFallback property names the emitted HTML file served for
 * otherwise unmatched navigation requests within the application scope.
 * It defaults to `index.html`; use `false` to disable SPA navigation fallback.
 * @category Offline
 * @since v0.1.0
 */
  navigationFallback?: string | false;
}

/**
 * The tinyjoinOffline function returns a Vite plugin that precaches the complete
 * emitted production build, including lazy runtime assets, with verified content
 * hashes. It does not register a service worker during development.
 *
 * The first visit requires a network connection. Wait for
 * `navigator.serviceWorker.ready` before expecting a subsequent navigation to
 * work offline. Storage persistence and application-file caching remain separate:
 * OPFS stores the database, while the service worker stores the application.
 *
 * Updates wait until all tabs controlled by the old service worker close. The
 * plugin does not force a new worker onto running tabs. Serve the whole build
 * together at one same-origin root-relative or relative Vite base. External
 * requests and files created after the build are not cached.
 * @param options Optional offline-caching settings.
 * @returns A Vite plugin that configures production offline caching.
 * @example
 * ```ts
 * import {defineConfig} from 'vite';
 * import {tinyjoinOffline} from 'tinyjoin/vite';
 *
 * export default defineConfig({plugins: [tinyjoinOffline()]});
 * ```
 * @category Offline
 * @since v0.1.0
 */
export function tinyjoinOffline(options?: TinyjoinOfflineOptions): Plugin;
````
