stable

numbering

Deferred numbering: gap-free, duplicate-free serial numbers assigned by an explicit batch pass, for stores without atomic compare-and-swap.
Subpathexported from @noy-db/hub root (no dedicated subpath)
FactorywithDeferredNumbering()
Layerwith-commit

What it does

Deferred numbering hands out gap-free, duplicate-free serial numbers — invoice numbers, fiscal receipt numbers, anything a regulator expects to increment by exactly one with no reuse — to records in a collection. The sequence service does the same job immediately, but it needs the store to support an atomic compare-and-swap (CAS) write: check the current counter and write the next value in one race-free operation. Not every backend can do that — @noy-db/to-file (local disk / USB storage), for example, has no CAS and would make sequence() throw SequenceOfflineError.

Deferred numbering works around the missing CAS. Instead of allocating a number the instant you ask, it records that a number is wanted for a given record, and later — when you explicitly run a numbering pass — it assigns numbers to every record whose write is provably finished, in the order the store actually committed them. The tradeoff: numbers are not available immediately, and you are responsible for triggering passes.

Prerequisites

  • A store clock — the store must implement getStoreTime() and advertise capabilities.serverWriteTime, or next() / runNumberingPass() throw NumberingUncertaintyError. Both toMemory() and toFile() provide it; check other adapters' docs.
  • A scheduled numbering pass — runNumberingPass() is never called automatically; your application triggers it (cron, queue worker, end of batch).

Opt in

Unlike most with*() capabilities, deferred numbering has no strategy option on createNoydb(). Instead, pass one withDeferredNumbering() descriptor per series in the numbering array:

import { createNoydb, withDeferredNumbering } from '@noy-db/hub'
import { toMemory } from '@noy-db/to-memory'

const db = await createNoydb({
  user: 'alice',
  secret: 's3cret',
  store: toMemory(), // any store with capabilities.serverWriteTime — see Limits below
  numbering: [
    withDeferredNumbering({
      series: 'invoices',
      collection: 'sales',
      field: 'fiscalNumber',
    }),
  ],
})

Usage

1. Enqueue a record for numbering

const vault = await db.openVault('acme')
const sales = vault.collection('sales')

await sales.put('order-42', { customer: 'Ada', total: 100 })

const pending = vault.sequence('invoices').next({ for: 'order-42' })
// pending is a Promise<number> — it does not resolve until a numbering pass runs

2. Run a numbering pass

const assignments = await vault.runNumberingPass('invoices')
// → [{ recordId: 'order-42', serial: 1 }]

const numbered = await sales.get('order-42')
// → { customer: 'Ada', total: 100, fiscalNumber: 1 }

await pending // 1 — the next() promise from step 1 now resolves

3. Peek at the last assigned serial

const last = await vault.sequence('invoices').peek()
// → 1

API reference

withDeferredNumbering(config)

Declares one deferred-numbering series. Pass one or more results in the numbering array of createNoydb().

function withDeferredNumbering(config: {
  series: string
  collection: string
  field: string
  settleWindowMs?: number
}): DeferredNumberingConfig
OptionTypeDefaultMeaning
seriesstring—The name passed to vault.sequence(series).
collectionstring—The collection holding the records to number.
fieldstring—The field on each record where the assigned serial is written.
settleWindowMsnumber0Accepted and stored on the config, but not currently read by runNumberingPass() — eligibility today is governed only by the store-clock commit-wait interval described in Limits below.

vault.sequence(series).next({ for: recordId })

On a series declared via withDeferredNumbering, next() requires a for (the id of the record to number) and returns Promise<number> that resolves once a numbering pass processes that record. Calling next() without for throws ValidationError.

vault.sequence(series).peek()

Returns Promise<number> — the last serial assigned to the series so far, or 0 if no pass has run yet. Read-only; never allocates a number.

vault.runNumberingPass(series)

function runNumberingPass(series: string): Promise<Assignment[]>

interface Assignment {
  recordId: string
  serial: number
}

Runs one numbering pass for series: finds every enqueued record whose commit-time interval is fully in the past relative to the store's current time, orders them by commit time (then by record id), assigns the next serials in order, writes the assigned field on each record through the collection layer (so caches, indexes, and materialized views stay coherent), and advances the series' last-serial marker with one atomic write. Returns the assignments made in this pass — [] if nothing was ready yet, or if a concurrent pass won the race (the next pass reconciles; no serial is lost or duplicated).

runNumberingPass is never called automatically — trigger it on whatever schedule your application needs (a cron job, a queue worker, the end of a batch import).

Limits & gotchas

  • Requires a store clock. The store must implement getStoreTime() and advertise capabilities.serverWriteTime; otherwise next() and runNumberingPass() throw NumberingUncertaintyError. Both toMemory() (@noy-db/to-memory) and toFile() (@noy-db/to-file) provide it; check a given adapter's own docs for others.
  • Passes are manual. noy-db never runs a numbering pass on its own.
  • No formatted serials, no seeding. The format render template and seedTo() that sequence() supports for CAS-backed series apply to CAS sequences only — calling either on a deferred series throws ValidationError.
  • A settle delay is inherent. An entry becomes eligible only once the store's current time interval starts after the entry's own commit interval ended, so there is a short, store-clock-uncertainty-bounded delay between enqueueing and eligibility. The settleWindowMs config option exists but is not read by the current pass logic — it has no effect on timing.
  • Deleted records don't burn a number. If a record is deleted before its pass runs, the pass skips it without assigning it a serial.

See also