stable

sequence

`vault.sequence(name).next()` hands out gap-free, duplicate-free serial numbers — 1, 2, 3, … — even when multiple callers on one store allocate at once.
Subpathexported from @noy-db/hub root (no dedicated subpath)
FactorywithSequence()
Layerwith-commit

What it does

Invoice numbers, receipt numbers, ticket numbers — anything that must count up by exactly 1 with no gaps and no repeats, even when two callers ask for the next number at the same instant. vault.sequence('invoice-2026').next() returns that number: each named sequence is an independent counter stored (encrypted, like any other record) at _sequences/<name> inside the vault (see Vaults & collections).

Concurrency safety comes from compare-and-swap (CAS): to allocate a value, the counter is read, incremented, and written back with a condition — "only accept this write if the counter is still at the value I read." If another caller won by writing first, the condition fails and the write is rejected instead of silently overwriting; sequence() then re-reads and retries with backoff, up to 16 attempts. This is the same optimistic-retry pattern the vault's audit ledger uses internally. Because it depends on the backing store enforcing that write condition, next() is explicitly online-only — it throws SequenceOfflineError on a store that cannot guarantee atomic writes (see Limits & gotchas).

Prerequisites

  • A store with atomic compare-and-swap (CAS) writes — allocation is a CAS loop, so backends without CAS (e.g. @noy-db/to-file) make next() throw SequenceOfflineError. Offline or CAS-less setups should use deferred numbering instead.

Opt in

Pass the factory to createNoydb; no collection-level configuration is needed:

import { createNoydb } from '@noy-db/hub'
import { withSequence } from '@noy-db/hub'

const db = await createNoydb({
  user: 'firm',                    // omitting `store` uses the built-in in-memory store;
  secret: 'firm-secret-2026',  // pass to-file / to-postgres / … in production
  sequenceStrategy: withSequence(),
})
const vault = await db.openVault('books')

Usage

Allocate the next number

const n1 = await vault.sequence('invoice-2026').next()
// → 1
const n2 = await vault.sequence('invoice-2026').next()
// → 2

Assign the returned value to its record in the same operation you allocated it in — if a caller discards an allocated value without using it, that value is skipped forever (a gap in usage, not in the counter itself).

Read without allocating

await vault.sequence('invoice-2026').peek()
// → 2

peek() returns 0 for a series that has never been used — it never allocates or writes.

Fast-forward after an import

await vault.sequence('invoice-2026').seedTo(500)

seedTo(n) is set-if-greater: it advances the counter to at least n and is a no-op if the counter is already there (so it never rewinds a series). Use it after importing a CSV or bundle of pre-numbered records, so the next next() call cannot re-issue a number that is already on a record.

Partitioned and formatted series

A sequence can be scoped to a tuple of values — e.g. numbering EU invoices separately from US invoices — and rendered straight to a serial string instead of a bare integer:

const invoiceSeq = vault.sequence('fatture', {
  partition: [2026, 'EU'],
  format: '{partition.0}/{partition.1}/{seq:04}',
})

await invoiceSeq.next()
// → { serial: 1, formatted: '2026/EU/0001' }

Each distinct partition tuple is its own counter, so a new partition always starts at

  1. format accepts {seq}, {seq:0N} (zero-padded to width N), and {partition.i} tokens; an unknown token or an out-of-range partition.i throws ValidationError immediately when you call vault.sequence(), not later at next().

API reference

vault.sequence(series, opts?)

Returns a handle bound to one named series. Calling it twice with the same series (and the same partition, if any) returns handles over the same underlying counter.

vault.sequence(series: string, opts?: SequenceOptions): SequenceHandle
vault.sequence(series: string, opts: SequenceOptions & { format: string }): FormattedSequenceHandle
OptionTypePurpose
partitionreadonly (string | number)[]Scopes the counter to this tuple of values; each distinct tuple is an independent series starting at 1.
formatstringRender template; when set, next() resolves to { serial, formatted } instead of a bare number.

SequenceHandle

MethodReturnsPurpose
next(opts?)Promise<number>Atomically allocate and return the next value.
peek()Promise<number>Read the current value without allocating; 0 if never used.
seedTo(n)Promise<void>Set-if-greater: advance the counter to at least n; no-op if already there or if n <= 0.

FormattedSequenceHandle (returned when format is set) has the same three methods; next() resolves to { serial: number; formatted: string } instead of a bare number, and peek() / seedTo() still operate on the underlying integer counter.

Errors

ErrorThrown when
SequenceNotEnabledErrorvault.sequence() is called without sequenceStrategy: withSequence() passed to createNoydb.
SequenceOfflineErrornext() or seedTo() is called against a store that does not advertise atomic compare-and-swap writes.
SequenceContentionErrornext() / seedTo() exhausted their 16-attempt retry budget under sustained contention on the same series; the counter is intact and the caller may retry.
ValidationErrorThe series name contains a null byte, or a format template references an unknown token or an out-of-range partition.i.

All are exported from @noy-db/hub.

Limits & gotchas

  • ⛔ Unique per DEVICE, not across synced devices — through hub 0.10.0-pre.2 at least. Counters are compare-and-swapped against the local store, and the _sequences collection does not sync, so two devices replicating one vault issue the same numbers (measured upstream: device A next() → 1, A pushes, B pulls the whole vault, B next() → 1). Do not use it for a fleet-unique invoice number until family#206 lands; until then allocate numbers on a single authority — one process against one store — and let the other devices receive the numbered records through sync.
  • Online-only, by design. Gap-free numbering requires one authority serializing writes to the counter, which an offline writer cannot guarantee. next() and seedTo() both throw SequenceOfflineError unless the store's capabilities.casAtomic is true. Among the built-in stores, to-memory and to-browser-idb support it; to-file does not (no atomic compare-and-swap at the filesystem layer) — check the capabilities of whichever store you use in production.
  • Gap-free means the counter never skips or repeats a value it hands out — it says nothing about values you allocate and then discard. Assign a next() result to its record in the same operation you allocated it.
  • Contention has a budget, not a queue. A burst of concurrent callers on the same series beyond the retry budget surfaces SequenceContentionError rather than blocking indefinitely — the caller decides whether to retry or queue.
  • A series name cannot contain a null byte (\x00) — that byte is the internal separator between a series name and its partition key.
  • format is validated eagerly. A bad token throws at vault.sequence() time, not at next() time, so a misconfigured template fails fast.
  • Deferred-numbering series are a different capability. A series declared via withDeferredNumbering(...) (the numbering option on createNoydb) is numbered at a batch pass instead of via CAS, and does not support format or seedTo.

See also