stable

periods

Accounting-style period closes with a write-guard: closePeriod() seals records, openPeriod() carries forward balances, and closes chain into the audit ledger.
Subpath@noy-db/hub/periods
FactorywithPeriods()
Layerwith-audit
ClusterE — Time & Audit
LOC cost~334 (off-bundle when not opted in)

What it does

Accounting and other regulated systems need to "close the books": once a fiscal quarter is finalized, nobody should be able to edit records that belong to it — not even by mistake. The periods service adds that seal to a vault — noy-db's encrypted, per-tenant document store (see Vaults & collections). Call vault.closePeriod({ name, endDate }) to freeze every record dated at or before endDate — by business date when you pass dateField, otherwise by write time (see Limits & gotchas); any later put or delete against a frozen record throws PeriodClosedError. vault.openPeriod() then carries forward opening balances into the next period. Every close and open is written as a normal record and, when the history service is also enabled, appended to the vault's hash-chained, tamper-evident audit trail (the ledger) — so period closures themselves become an auditable, chained sequence. Since 0.3.0-pre.8, a closed period also anchors a retention lifecycle: freeze it to physically reclaim the space held by converged sync delete markers, archive it to relocate its records to a cold storage tier, and target-purge it to sweep markers off push-only sync targets — each recorded as its own companion record + ledger entry, with the hash-chained period record itself staying byte-immutable. The whole service stays off your bundle unless you import withPeriods.

When you need it

  • Accounting / financial books that need year-end and quarter-end seals
  • Audit / regulatory workflows where post-close edits must be impossible
  • Inventory or reporting systems with closed periods

Prerequisites

  • None — withPeriods() alone enables close/freeze/reopen.
  • Optional: historyStrategy: withHistory() chains period events into the tamper-evident ledger; without it closes still work but are not ledger-chained.
  • For migratePeriod (hot→cold): the vault's store must be a routeStore() with a cold route — see routing.

Opt in

import { createNoydb } from '@noy-db/hub'
import { withPeriods } from '@noy-db/hub/periods'
import { withHistory } from '@noy-db/hub/history' // recommended — chains closes into the ledger
import { toMemory } from '@noy-db/to-memory'

const db = await createNoydb({
  store: toMemory(),
  user: 'firm',
  secret: 'firm-secret-2026',
  periodsStrategy: withPeriods(),
  historyStrategy: withHistory(), // optional — without it, closes/opens still work but nothing is chained into a ledger
})
const vault = await db.openVault('books')

Usage

Close a period

interface Invoice { amount: number; status: string; date: string }
const invoices = vault.collection<Invoice>('invoices')

await invoices.put('inv-1', { amount: 1000, status: 'draft', date: '2026-01-15' })

const q1 = await vault.closePeriod({
  name: 'FY2026-Q1',
  endDate: '2026-03-31',
  dateField: 'date',   // seal by business date, not write time — see Limits & gotchas
})
{
  "name": "FY2026-Q1",
  "kind": "closed",
  "endDate": "2026-03-31",
  "dateField": "date",
  "closedAt": "2026-07-08T09:14:03.412Z",
  "closedBy": "firm",
  "priorPeriodHash": ""
}

Any later write to a record whose business date falls inside the closed range now throws — even an edit that only touches an unrelated field:

await invoices.put('inv-1', { amount: 2000, status: 'paid', date: '2026-01-15' })
// → throws PeriodClosedError:
//   'Cannot modify record (last written existing[date]=2026-01-15) — sealed by closed period
//    "FY2026-Q1" (endDate: 2026-03-31). Post a compensating entry in a new period instead.'

List and look up periods

const periods = await vault.listPeriods()
[
  {
    "name": "FY2026-Q1",
    "kind": "closed",
    "endDate": "2026-03-31",
    "dateField": "date",
    "closedAt": "2026-07-08T09:14:03.412Z",
    "closedBy": "firm",
    "priorPeriodHash": ""
  }
]
await vault.getPeriod('FY2026-Q1')
// → the same PeriodRecord as above

await vault.getPeriod('NEVER')
// → null

Freeze a closed period — physically reclaim delete-marker space

On a synced vault, deletes converge as delete markers that accumulate until explicitly purged — purging one too early would let an offline peer resurrect the deleted record on its next push. A closed period is the operator-asserted safe-point that makes the purge sound: vault.freezePeriod(name) (since 0.3.0-pre.8) physically purges the delete markers whose write-time falls inside the closed period's window, records a _period_freezes/<name> companion record plus a tamper-evident ledger entry, and leaves the hash-chained _periods/<name> record byte-immutable:

const frozen = await vault.freezePeriod('FY2026-Q1')
// frozen.frozenAt / frozen.frozenBy / frozen.purgedMarkerCount
// merged onto the returned PeriodRecord from the companion — the stored
// _periods record itself is never rewritten

Freezing is terminal (a frozen period stays frozen) and idempotent (a second call is a no-op returning the same merged record, without re-purging or re-appending a ledger entry). It purges delete markers only: forget-tombstones (crypto-shred erasure evidence), _history versions, and live records are untouched by construction. A period whose purge window reaches into the future is refused with ValidationError — very recent delete markers may not have converged to every peer yet. Freeze purges the local store only; markers already pushed to sync targets survive there (benign — they still read as deleted) and a re-imported marker is reclaimed by the next period's freeze.

Archive a closed period to cold storage

vault.archivePeriod(name) (since 0.3.0-pre.8) relocates a closed period's in-window records from the hot store to a configured cold tier, driving the routing service's hot→cold migration. It requires the vault's store to be a routeStore() with a cold route (age: { cold }) — advertised as the coldArchival store capability — and throws ValidationError otherwise:

import { routeStore } from '@noy-db/hub'
import { toMemory } from '@noy-db/to-memory'

const hot = toMemory()
const cold = toMemory()
// `coldAfterDays` may be omitted: `age: { cold }` alone means period-driven
// archival only — archivePeriod() supplies the cutoff per call.
const store = routeStore({ default: hot, age: { cold } })

// … createNoydb({ store, … }), then close FY2026-Q1 as above …

const archived = await vault.archivePeriod('FY2026-Q1')
// archived.archivedAt / archived.archivedBy / archived.archivedRecordCount

Archival is non-destructive — routed reads fall through to the cold tier on a hot miss, so an archived record still reads normally — which is why it is gated only on closed, not frozen (it re-opens no resurrection window). It records a _period_archives/<name> companion + ledger entry, is idempotent, and composes with freeze in either order. Records are bounded by write-time _ts, not business date: a late-booked record archives at the next period's archival, the same rule freeze uses for late-booked delete markers.

Sweep markers off push-only sync targets

vault.purgePeriodTargets(name) (since 0.3.0-pre.8) extends freeze's local marker purge to the vault's own remote sinks: it sweeps the period's delete markers off every push-only sync target ('backup' / 'archive' roles — see sync), reclaiming remote marker space the local freeze cannot reach:

await vault.freezePeriod('FY2026-Q1')          // required first: closed → frozen → target-purged
const purged = await vault.purgePeriodTargets('FY2026-Q1')
// purged.targetsPurgedAt / purged.targetsPurgedBy
// purged.targetsPurged → [{ role: 'backup', purgedCount: 3 }]

'sync-peer' (bidirectional) targets are deliberately skipped — purging a marker from a live peer could re-open the resurrection window for a client that has been offline since before the cutoff, an assertion no single vault can verify. Push-only targets are safe because they are never pulled from. The call requires the period to be frozen first, records a _period_target_purges/<name> companion + ledger entry, and is idempotent once run; a vault with no push-only targets writes no companion and stays re-runnable, so a target added later is still swept. This is single-vault only — fleet-wide purging across many vaults is the companion control plane's concern (klum-db, over @noy-db/hub/cargo).

Open the next period and carry forward opening balances

const q2 = await vault.openPeriod({
  name: 'FY2026-Q2',
  startDate: '2026-04-01',
  fromPeriod: 'FY2026-Q1',
  carryForward: async (ctx) => {
    const closing = await ctx.collection<Invoice>('invoices').list()
    const opening: Record<string, Invoice> = {}
    for (const entry of closing) {
      if (entry.date <= ctx.priorEndDate) {
        opening[`ob-${entry.date}`] = { ...entry, status: 'carried-forward', date: '2026-04-01' }
      }
    }
    return { invoices: opening }
  },
})
{
  "name": "FY2026-Q2",
  "kind": "opened",
  "startDate": "2026-04-01",
  "endDate": "2026-03-31",
  "closedAt": "2026-07-08T09:15:11.048Z",
  "closedBy": "firm",
  "priorPeriodName": "FY2026-Q1",
  "priorPeriodHash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
  "openingCollections": ["invoices"]
}

carryForward receives a read-only facade (ctx.collection(name).get() / .list()) over the vault's current state, plus ctx.priorEndDate — the just-closed period's endDate. Because closed records are frozen, current state and closing state are the same thing, so filtering by entry[dateField] <= ctx.priorEndDate selects exactly what closed. The records returned from carryForward are written through the normal collection path (encrypted, ledger-logged) with fresh timestamps, so they land outside every closed period.

API reference

withPeriods()

function withPeriods(): PeriodsStrategy

Builds the active periods strategy. Pass it as periodsStrategy to createNoydb — this is the only wiring step. Takes no options.

vault.closePeriod(options)

vault.closePeriod(options: ClosePeriodOptions): Promise<PeriodRecord>

Seals a period and returns the PeriodRecord that was written.

ClosePeriodOptions:

FieldTypePurpose
namestringUnique period name (e.g. 'FY2026-Q1'). Throws ValidationError if it collides with an existing period.
endDatestringInclusive ISO cutoff. A record is sealed once record[dateField] (or, when dateField is omitted, the record's write timestamp) is at or before this date.
dateField?stringRecord field carrying the business date used for sealing. Omit to fall back to write-time sealing (see Limits & gotchas).

vault.openPeriod(options)

vault.openPeriod<TCollections>(options: OpenPeriodOptions<TCollections>): Promise<PeriodRecord>

Opens the next period, materializes opening-balance records via carryForward, and returns the new PeriodRecord (kind: 'opened').

OpenPeriodOptions:

FieldTypePurpose
namestringUnique name for the new period.
startDatestringISO lower bound of the new period — usually the prior period's endDate + 1 day.
fromPeriodstringName of the prior closed period this one carries forward from. Throws ValidationError if it doesn't exist or isn't kind: 'closed'.
carryForward(ctx: CarryForwardContext) => Promise<TCollections> | TCollectionsComputes opening-balance records from the closing state. Return value is { [collectionName]: { [id]: record } }; each record is written via the normal collection path.

CarryForwardContext:

FieldTypePurpose
priorEndDatestringThe endDate of the period named in fromPeriod — the closing boundary to filter by.
collection(name)<T>(name: string) => { get(id): Promise<T | null>, list(): Promise<T[]> }Read-only view of the named collection's current state.

vault.freezePeriod(name)

vault.freezePeriod(name: string): Promise<PeriodRecord>

Since 0.3.0-pre.8. Purges the delete markers whose write-time falls inside the closed period's window and records the _period_freezes/<name> companion + ledger entry. Returns the PeriodRecord with frozenAt / frozenBy / purgedMarkerCount merged on (read-time merge — the stored _periods record is never mutated). Terminal and idempotent. Throws ValidationError when the period doesn't exist, isn't kind: 'closed', or its purge window has not fully elapsed yet (recent delete markers may not have converged).

vault.archivePeriod(name)

vault.archivePeriod(name: string): Promise<PeriodRecord>

Since 0.3.0-pre.8. Relocates the closed period's in-window records (write-time _ts inside the window) from the hot store to the routed cold tier, and records the _period_archives/<name> companion + ledger entry. Returns the PeriodRecord with archivedAt / archivedBy / archivedRecordCount merged on. Non-destructive (reads fall through to cold), gated only on closed, idempotent. Throws ValidationError without a routeStore cold route (routing).

vault.purgePeriodTargets(name)

vault.purgePeriodTargets(name: string): Promise<PeriodRecord>

Since 0.3.0-pre.8. Sweeps the period's delete markers off every push-only sync target ('backup' / 'archive'), skipping 'sync-peer' targets, and records the _period_target_purges/<name> companion + ledger entry. Returns the PeriodRecord with targetsPurgedAt / targetsPurgedBy / targetsPurged (per-target { label?, role, purgedCount }) merged on. Requires the period to be frozen first; idempotent once run; with no push-only targets it writes no companion and is re-runnable.

vault.listPeriods()

vault.listPeriods(): Promise<readonly PeriodRecord[]>

Returns every closed/opened period, ordered by closedAt.

vault.getPeriod(name)

vault.getPeriod(name: string): Promise<PeriodRecord | null>

Looks up one period by name; null if it doesn't exist.

PeriodRecord

The stored record for one closed or opened period, one entry per period:

FieldTypeMeaning
namestringUnique period name.
kind'closed' | 'opened'Whether this entry is a close or a carried-forward open.
endDatestringSealing boundary — inherited from the prior close on 'opened' records.
startDate?stringPresent on 'opened' records only.
dateField?stringThe business-date field this close sealed by, if any.
closedAtstringISO timestamp of the closePeriod() / openPeriod() call.
closedBystringThe user id of the keyring that made the close/open call.
priorPeriodName?stringName of the period this one chains from. Absent on the first period.
priorPeriodHashstringsha256(canonicalJson(priorPeriod)) — empty string for the first period.
openingCollections?readonly string[]'opened' records only — names of collections that received carried-forward records.
frozenAt? / frozenBy? / purgedMarkerCount?string / string / numberMerged at read time from the _period_freezes/<name> companion; absent until frozen. Never written into the stored _periods record (that would break the hash chain).
archivedAt? / archivedBy? / archivedRecordCount?string / string / numberMerged from the _period_archives/<name> companion; absent until archived.
targetsPurgedAt? / targetsPurgedBy? / targetsPurged?string / string / readonly { label?, role, purgedCount }[]Merged from the _period_target_purges/<name> companion; absent until target-purged (or when the vault has no push-only targets).

PeriodClosedError

import { PeriodClosedError } from '@noy-db/hub'

Thrown by put() / delete() when the target record falls inside a closed period. Carries periodName, endDate, and recordTs (the offending timestamp, or a field=value pair when sealed by dateField) as typed fields, so an audit UI can render "locked in FY2026-Q1 (closed 2026-03-31)" without parsing the message string.

Behavior when NOT opted in

  • vault.closePeriod() / vault.openPeriod() throw, pointing you at @noy-db/hub/periods.
  • vault.listPeriods() returns []; vault.getPeriod() returns null.
  • The write-guard is a no-op — every put/delete succeeds regardless of date.
  • None of the ~334 LOC ships in your bundle.

Limits & gotchas

  • dateField is recommended over the write-time fallback — accounting semantics use the record's business date, not when it happened to be written.
  • A late entry (write-time after close, business date inside the closed range) is the canonical correctness check: a write-time-only seal would silently let it through. dateField sealing catches it because it compares the business date on both the existing and incoming record — callers can't slide a record into a closed period by backdating it, either.
  • There's no partial re-opening of a closed period. The sanctioned way to correct a sealed record is a compensating entry in the next period via openPeriod()'s carryForward, not unsealing the old one.
  • Closing and opening are explicit, deliberate calls — nothing rolls a period over automatically. The same goes for the retention lifecycle: freeze, archive, and target-purge each run only when called.
  • The retention sequence is closed → frozen → target-purged; archival is independent of freeze and composes in either order. Freeze and target-purge reclaim delete-marker space; archival relocates records.
  • Freeze/archive/target-purge bound by write-time _ts, not business date — the store tier sees only encrypted envelopes. A late-booked record (or delete) is picked up by the next period's freeze/archival.
  • Archival's read cost depends on the read mode: with lazy per-id reads, archived records are truly cold (fetched only on access); in the default hydrated mode, loadAll merges the cold store, so archived records still load into RAM on vault open — hot-tier storage is reclaimed, RAM is not. Reserved _-prefixed summaries always stay hot.
  • A backup/restore round-trip re-materializes archived records into the hot tier, while the restored _period_archives companion keeps archivePeriod() an idempotent no-op. Reads stay correct; to reclaim hot space again, re-run the migration directly (store.compact(vault, { before }) — see routing).
  • Tampering with a stored PeriodRecord changes the payload hash embedded in its ledger entry, so it's caught by vault.verifyBackupIntegrity()'s chain-and-data cross-check — but only when the history service is also enabled. Without a ledger, there's nothing to cross-check the period record against.

See also