transactions
| Subpath | @noy-db/hub/transactions |
| Factory | withTransactions() |
| Layer | with-commit |
What it does
By default, noy-db writes one record at a time: every put()/delete() on a collection commits on its own and is visible immediately. The transactions service adds multi-record atomic writes on top of that: db.transaction(async (tx) => { ... }) runs a callback whose body stages put/delete operations across any number of collections or vaults, and the hub commits every staged op together — or none of them — when the callback returns or throws. Reads inside the body see your own staged writes ("read-your-writes") even though nothing is persisted until commit. The service is a tree-shake seam: ~280 lines of transaction-executor code only reach your bundle once you opt in.
When you need it
- Cross-collection invariants (an invoice and its payment must both succeed, or neither does)
- Cross-vault writes (the same logical operation touching two tenants)
- Optimistic concurrency control via
expectedVersionon staged ops - Workflows where a partial write would corrupt downstream state
Prerequisites
- None —
withTransactions()alone enablesdb.transaction(fn). - For the amendment form: guard declarations (
guardStrategies) plusadmin,owner, orcustodianrole on every vault the body touches — see guards. - Atomicity note: true all-or-nothing storage atomicity needs a store that declares
txAtomicand a batch that is statically safe — see Atomicity guarantee below. Every other transaction falls back to the per-record OCC executor described in this page, with best-effort unwind.
Opt in
import { createNoydb } from '@noy-db/hub'
import { withTransactions } from '@noy-db/hub/transactions'
import { toMemory } from '@noy-db/to-memory'
const db = await createNoydb({
store: toMemory(),
user: 'alice',
secret: 'a-strong-secret',
transactionsStrategy: withTransactions(),
})
Usage
type Invoice = { amount: number; status: string }
type Payment = { invoiceId: string; amount: number; paidAt: string }
await db.openVault('acme')
const result = await db.transaction(async (tx) => {
const inv = tx.vault('acme').collection<Invoice>('invoices')
const pay = tx.vault('acme').collection<Payment>('payments')
inv.put('inv-1', { amount: 100, status: 'paid' })
pay.put('pay-1', { invoiceId: 'inv-1', amount: 100, paidAt: '2026-07-08T00:00:00Z' })
// Read-your-writes: sees the staged put above even though nothing
// has been committed yet.
const staged = await inv.get('inv-1')
return staged.amount // the body's return value flows through
})
// → 100
If the body throws before returning, nothing is persisted. If a staged op carries an expectedVersion that no longer matches the stored record, the transaction throws ConflictError before any writes happen (a pre-flight compare-and-swap check).
Commit-time invariants
withTransactions({ invariants: [...] }) declares set-level invariants that run at commit, over the change-set of an ordinary db.transaction() (and of amendment transactions — see guards). There is no role gate: this generalizes the guard service's amendment.invariant to plain transactions.
import { InvariantError } from '@noy-db/hub'
import { withTransactions } from '@noy-db/hub/transactions'
const db = await createNoydb({
store: toMemory(),
user: 'alice',
secret: 'a-strong-secret',
transactionsStrategy: withTransactions({
invariants: [{
scope: 'lines', // collection name
check: (changes, ctx) => {
const total = (side: 'before' | 'after') =>
changes.reduce((sum, c) => sum + ((c[side] as { amount?: number } | null)?.amount ?? 0), 0)
if (total('before') !== total('after')) {
throw new InvariantError('lines total must be preserved')
}
},
}],
}),
})
scope is the collection name the invariant watches — check only fires when the committed transaction wrote at least one record in that collection. check(changes, ctx) receives a ReadonlyArray<GuardChange> for that scope: before is the plaintext prior record (null on insert), after is the written record (null on delete). The plaintext before is captured before the writes execute, so invariants see the true prior state. A throw reverts the whole transaction and surfaces as InvariantError.
Atomicity guarantee
db.transaction(fn) commits all-or-nothing through one store.tx() batch when the store declares txAtomic (and implements tx()) and the batch is statically safe. Otherwise it falls back to the per-record OCC executor described above, with best-effort unwind — unchanged from prior behavior. The fallback is silent by design: nothing in the return value of db.transaction() tells you which path a given call took.
A batch is statically safe only when ALL of the following hold:
- No touched collection has a derivation or materialized-view source, at any lifecycle — lazy included.
- No touched collection is CRDT-mode, has unique constraints, or has refs on the write direction. Since hub
0.5.0, a delete no longer forces the OCC path by itself (noy-db#922): the eligibility check consults every source a delete cascade can fire from — lookup-ref edges, classic inbound refs, and managed-link endpoints — and only a collection one of those touches falls back to OCC (the cascades run during prepare, which is not abortable). A genuinely refs-free collection's deletes commit all-or-nothing alongside puts. - No live
onBeforeWritehook — the refusal-capable kind — is registered anywhere on theNoydbinstance (a db-global signal). Since hub0.5.0, after-write observers no longer forfeit the atomic path (noy-db#931): useronAfterWritehooks and theafterPut/afterDeleteobserve bus fire per op on the atomic path too, after each leg's finalize — so the auto-registered cross-tab write relay (by-tabs) or awithForget()subject index no longer pushes every batch onto OCC. Ordering note: on the atomic path these observers fire after the batch is durable (in staged order), where the OCC loop interleaves them per op. - The transaction is not amendment mode, and the batch has no duplicate
(vault, collection, id)target.
Schema gates (update-gate + fence) are asserted per-op the same way on both paths.
What differs between the two paths
- Commit ordering. On the atomic path, history snapshots, ledger appends, change events, after-write observers (
onAfterWritehooks + theafterPut/afterDeletebus, since hub0.5.0), and dirty-marking all fire per-op in staged order after the batch commits. On the OCC path they interleave with execution, per op, as before. Outcome-identical on success; observable to anything subscribing to events mid-transaction. - Failure behavior. A rejected atomic batch — including a store-side
expectedVersionCAS rejection — applies nothing: no ledger entries, no events, no revert pass, because nothing landed. A post-commit failure (finalize/invariant) reverts through the atomic-revert path on stores that support it. The OCC path keeps its existing best-effort revert — see Limits & gotchas. - Crash window. The batch write itself is atomic on the atomic path, but the audit artifacts (history/ledger) written after it are not covered by that same batch: a crash between commit and finalize can lose audit entries for a batch that already committed. That window is narrower than the OCC path's — where a crash mid-batch can leave a partially-applied write on disk — but it is a real window, not full audit atomicity, and worth stating plainly rather than implying otherwise.
Which stores currently qualify for the atomic path (and any per-store caveats) is tracked on the store capabilities matrix — treat "all-or-nothing" as a store-dependent property of db.transaction(), not a blanket one.
Since hub 0.5.0, collection.putMany({ atomic: true }) commits through the same one-batch store.tx() path on an eligible batch (noy-db#921) — see putMany().
API
withTransactions(options?)
withTransactions(options?: TransactionStrategyOptions): TxStrategy
// TransactionStrategyOptions = { invariants?: TransactionInvariant[] }
Builds the strategy passed to createNoydb({ transactionsStrategy }). options.invariants registers the commit-time changeset invariants described above; omit it for plain multi-record atomicity with no extra checks.
TxContext
The handle passed into the transaction body.
tx.txId— a stable ULID shared by every write the transaction performs.tx.vault(name)— scope subsequent calls to a named vault; returns aTxVault. The vault must already be open on theNoydbinstance (viadb.openVault()).
TxVault
.collection<T>(name)— scope subsequent calls to a named collection; returns aTxCollection<T>.
TxCollection<T>
.get(id): Promise<T | null>— reads the most recently staged value foridin this transaction if one exists, otherwise the current committed value..put(id, record, options?: { expectedVersion?: number; reason?: string }): void— stages a put; nothing is written until the body returns.reasonis forwarded to the resulting ledger entry..delete(id, options?: { expectedVersion?: number }): void— stages a delete.
AmendmentTxOptions
interface AmendmentTxOptions {
readonly amendment: true
readonly reason: string // required, recorded in the ledger entry
}
The options bag for db.transaction({ amendment: true, reason }, fn) — a privileged transaction form that skips ordinary guard checks in favor of a role-gated invariant review. Requires withTransactions() plus guardStrategies, and admin, owner, or custodian role on every vault the body touches. Full walkthrough on the guards page.
runTransaction, TxStrategy, and TransactionInvariant are also exported from @noy-db/hub/transactions for type annotations and custom strategy composition; application code normally only calls db.transaction(fn) and withTransactions().
Behavior when NOT opted in
db.transaction(fn),db.transaction({ amendment: true, reason }, fn), anddb.transaction({ dryRun: true }, fn)all throw immediately: "Multi-record transactions require the tx strategy. Import{ withTransactions }from"@noy-db/hub/transactions"and pass it tocreateNoydb({ transactionsStrategy: withTransactions() })."db.transaction(vaultName)— the sync-peer batch transaction (SyncTransaction) — is a distinct overload gated bywithSync(), not by this service; it is unaffected by whetherwithTransactions()is opted in.- None of the ~280 lines of transaction-executor code reach the bundle until you opt in.
Limits & gotchas
- Crash window (OCC path): on a store/batch that doesn't qualify for the atomic path (see Atomicity guarantee), commit is pre-flight-check, then execute-each-op, then best-effort revert on failure — it is not a single storage-layer transaction. If the process dies between two already-executed ops, on-disk state can be left partial. The atomic path has its own, narrower crash window — audit artifacts written after the batch commits, not the batch itself — see Atomicity guarantee.
- Revert is best-effort, not audited (OCC path): a mid-commit failure restores each already-written record to its prior envelope, but the ledger is not rewritten to erase the intermediate writes — the audit trail preserves the partial commit and the revert.
- Version numbers inside the body are pre-transaction:
tx.collection().get(id)returns the most-recently-staged value, but its version reflects the state before the transaction — staged puts don't have a version yet. - Cross-vault transactions need both vaults already open on the
Noydbinstance before the body runs. - Not cross-peer: a transaction commits against the primary store only; the sync engine still pushes on its normal schedule. For atomic writes across sync peers, use
db.transaction(vaultName)(SyncTransaction) instead. - Body errors propagate — wrap the call in try/catch if you want a graceful UI response instead of an unhandled rejection.
Pairs well with
- history — every committed op fires a ledger entry per op after commit
- sync — staged ops still flow through dirty tracking once committed
- crdt — staged ops on CRDT collections merge through the CRDT strategy
- guards — amendment-mode transactions (
db.transaction({ amendment: true }, fn)) are the privileged override path for guard-locked records - refs — cascade-delete (ref mode
'cascade') is transaction-atomic: cascaded child deletes register onto the active transaction and roll back with the parent on a mid-batch failure. See Foreign refs.
See also
- Vault & Collection model
- Foreign refs
- guards — amendment transactions, record locks, invariants
- history — the audit ledger transactions write to
- sync —
db.transaction(vaultName)cross-peer batches - store capabilities — which stores declare
txAtomic - Proven by showcase
20-with-transactions.