stable

transactions

Multi-record atomic writes: db.transaction() stages puts/deletes across collections and commits all-or-nothing, with optional changeset invariants.
Subpath@noy-db/hub/transactions
FactorywithTransactions()
Layerwith-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 expectedVersion on staged ops
  • Workflows where a partial write would corrupt downstream state

Prerequisites

  • None — withTransactions() alone enables db.transaction(fn).
  • For the amendment form: guard declarations (guardStrategies) plus admin, owner, or custodian role on every vault the body touches — see guards.
  • Atomicity note: true all-or-nothing storage atomicity needs a store that declares txAtomic and 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 onBeforeWrite hook — the refusal-capable kind — is registered anywhere on the Noydb instance (a db-global signal). Since hub 0.5.0, after-write observers no longer forfeit the atomic path (noy-db#931): user onAfterWrite hooks and the afterPut/afterDelete observe 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 a withForget() 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 (onAfterWrite hooks + the afterPut/afterDelete bus, since hub 0.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 expectedVersion CAS 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 a TxVault. The vault must already be open on the Noydb instance (via db.openVault()).

TxVault

  • .collection<T>(name) — scope subsequent calls to a named collection; returns a TxCollection<T>.

TxCollection<T>

  • .get(id): Promise<T | null> — reads the most recently staged value for id in 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. reason is 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), and db.transaction({ dryRun: true }, fn) all throw immediately: "Multi-record transactions require the tx strategy. Import { withTransactions } from "@noy-db/hub/transactions" and pass it to createNoydb({ transactionsStrategy: withTransactions() })."
  • db.transaction(vaultName) — the sync-peer batch transaction (SyncTransaction) — is a distinct overload gated by withSync(), not by this service; it is unaffected by whether withTransactions() 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 Noydb instance 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