periods
| Subpath | @noy-db/hub/periods |
| Factory | withPeriods() |
| Layer | with-audit |
| Cluster | E — 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 arouteStore()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:
| Field | Type | Purpose |
|---|---|---|
name | string | Unique period name (e.g. 'FY2026-Q1'). Throws ValidationError if it collides with an existing period. |
endDate | string | Inclusive 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? | string | Record 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:
| Field | Type | Purpose |
|---|---|---|
name | string | Unique name for the new period. |
startDate | string | ISO lower bound of the new period — usually the prior period's endDate + 1 day. |
fromPeriod | string | Name 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> | TCollections | Computes opening-balance records from the closing state. Return value is { [collectionName]: { [id]: record } }; each record is written via the normal collection path. |
CarryForwardContext:
| Field | Type | Purpose |
|---|---|---|
priorEndDate | string | The 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:
| Field | Type | Meaning |
|---|---|---|
name | string | Unique period name. |
kind | 'closed' | 'opened' | Whether this entry is a close or a carried-forward open. |
endDate | string | Sealing boundary — inherited from the prior close on 'opened' records. |
startDate? | string | Present on 'opened' records only. |
dateField? | string | The business-date field this close sealed by, if any. |
closedAt | string | ISO timestamp of the closePeriod() / openPeriod() call. |
closedBy | string | The user id of the keyring that made the close/open call. |
priorPeriodName? | string | Name of the period this one chains from. Absent on the first period. |
priorPeriodHash | string | sha256(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 / number | Merged 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 / number | Merged 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()returnsnull.- 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
dateFieldis 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.
dateFieldsealing 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()'scarryForward, 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,
loadAllmerges 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_archivescompanion keepsarchivePeriod()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
PeriodRecordchanges the payload hash embedded in its ledger entry, so it's caught byvault.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
- Services overview · Orientation map
- history — the ledger period closes/opens append their entries to
- guards — record-level locks; periods lock by business-date range instead of by record
- sync — the delete markers freeze and target-purge reclaim, and the target roles they respect
- routing — the
routeStore()cold routearchivePeriod()drives - Vaults & collections — the vault/collection model this service extends
- Proven by showcase
18-with-periods.