Transactions and changes
Each TinyJoin query is atomic. Use a callback transaction when several related row mutations must commit together.
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:
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.
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. Each statement is validated against totals the transaction keeps for its staged rows, including unique-index constraints, so it costs what its own rows cost however many rows are already staged, whether it inserts, updates or deletes. Final commit still validates and publishes the complete write set.
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();
}
Repeated tx.exec() calls 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.
These limits apply cumulatively across the transaction, even when each individual statement is small. Transaction reads use the staged row view. A table with staged changes is read without its secondary indexes or key order, because its staged rows are not yet indexed; other tables are read as they would be outside the transaction.
Re-query after a commit
Subscriptions report changed table names rather than maintaining a live result object:
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.
This explicit re-query model keeps TinyJoin independent of UI frameworks and lets an application choose its own caching or rendering policy.
Refreshing individual rows
An event also names the primary keys that changed, keyed by table, so a listener can refresh individual rows rather than re-reading a whole table:
const unsubscribe = db.subscribe({tables: ['tasks']}, async (event) => {
const keys = event.keys.tasks;
if (!keys) {
render(await db.query('SELECT * FROM tasks ORDER BY id'));
return;
}
for (const {id} of keys) {
refreshRow(await db.query('SELECT * FROM tasks WHERE id = $1', [id]));
}
});
Each key is an object holding only that table's primary-key columns, so a composite key arrives as one object with one property per column. Keys are reported for writes from any connected Client, which makes a change made in another tab resolvable to individual rows.
Reporting is a bounded best effort, and tables stays authoritative. A table appears in keys only when every key it changed fits the bound of 1,000 keys per table. A table listed in tables but absent from keys changed more rows than an event can name, so its rows cannot be identified and it must be re-queried. keys is always empty alongside reset: true for the same reason. Always handle the absent case, as the example above does: a listener that assumes keys are present will silently miss large writes.