sequence
| Subpath | exported from @noy-db/hub root (no dedicated subpath) |
| Factory | withSequence() |
| Layer | with-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) makenext()throwSequenceOfflineError. 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
formataccepts{seq},{seq:0N}(zero-padded to widthN), and{partition.i}tokens; an unknown token or an out-of-rangepartition.ithrowsValidationErrorimmediately when you callvault.sequence(), not later atnext().
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
| Option | Type | Purpose |
|---|---|---|
partition | readonly (string | number)[] | Scopes the counter to this tuple of values; each distinct tuple is an independent series starting at 1. |
format | string | Render template; when set, next() resolves to { serial, formatted } instead of a bare number. |
SequenceHandle
| Method | Returns | Purpose |
|---|---|---|
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
| Error | Thrown when |
|---|---|
SequenceNotEnabledError | vault.sequence() is called without sequenceStrategy: withSequence() passed to createNoydb. |
SequenceOfflineError | next() or seedTo() is called against a store that does not advertise atomic compare-and-swap writes. |
SequenceContentionError | next() / seedTo() exhausted their 16-attempt retry budget under sustained contention on the same series; the counter is intact and the caller may retry. |
ValidationError | The 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.2at least. Counters are compare-and-swapped against the local store, and the_sequencescollection does not sync, so two devices replicating one vault issue the same numbers (measured upstream: device Anext()→ 1, A pushes, B pulls the whole vault, Bnext()→ 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()andseedTo()both throwSequenceOfflineErrorunless the store'scapabilities.casAtomicistrue. Among the built-in stores,to-memoryandto-browser-idbsupport it;to-filedoes 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
SequenceContentionErrorrather 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. formatis validated eagerly. A bad token throws atvault.sequence()time, not atnext()time, so a misconfigured template fails fast.- Deferred-numbering series are a different capability. A series declared via
withDeferredNumbering(...)(thenumberingoption oncreateNoydb) is numbered at a batch pass instead of via CAS, and does not supportformatorseedTo.
See also
- History & audit ledger — the append-only ledger uses the same optimistic-CAS retry pattern
- numbering — the deferred, batch-pass alternative for stores that
can't provide the atomic compare-and-swap
sequence()depends on - Vaults & collections
- Services overview · Orientation map
- Proven by showcase
102-formatted-sequence.