feat(8.0): Model-B per-write generation-stamping + adaptive retention knob

Every write — transact() AND single-op add/update/remove/relate — is now its
own immutable generation (Model-B), so a now() pin always freezes and
asOf/since/diff/history/transactionLog reflect single-ops exactly like
transacts. Closes the Model-A hole where pins did not freeze against single-op
writes.

Generation-stamping:
- GenerationStore.commitSingleOp: a one-operation commitTransaction with
  deferred durability. Wired into add/update/remove/relate/updateRelation/
  unrelate + removeMany (the *Many and VFS paths delegate to these).
- Async group-commit (flushPendingSingleOps): the live write is acknowledged
  immediately; its before-image is buffered in an in-memory pending tier that
  resolveAt/chains/changedBetween/tx-log read like on-disk generations, so the
  synchronous now() freezes with no forced flush. One fsync per window
  (triggers: size / 50ms timer / flush / close / transact / compactHistory).
- Crash recovery is drop-without-restore for group-commit generations (marked
  groupCommit:true): a crash mid-flush discards the partial generation and
  never restores its before-images, which would otherwise revert the
  already-acknowledged live write.
- Init-time infrastructure (the VFS root) is the un-versioned generation-0
  baseline: a fresh brain reports generation()===0 and an empty
  transactionLog(); the first user write is generation 1.
- Historical find()/related() overlay bound is the full reserved watermark
  (generation()), so un-flushed single-op writes are overlaid too.

Retention knob:
- config `history` -> `retention`: 'all' | 'adaptive' |
  { maxGenerations?, maxAge?, maxBytes?, budgetBytes?, autoCompact? }; unset ->
  adaptive (disk/RAM pressure, zero-config). CompactHistoryOptions floors ->
  caps (retainGenerations->maxGenerations, retainMs->maxAge, +maxBytes):
  reclaim oldest-unpinned while ANY cap is exceeded; pins always exempt.
- brain.setRetentionBudget(bytes) drives the adaptive byte budget at runtime
  (a coordinator's fair-share input). Per-generation bytes recorded in each
  delta enable historyBytes() introspection without a storage size API.

Tests: per-write generation resolution, pin freeze vs add/update/remove,
drop-without-restore corruption-trap (fault injector), clean-reopen replay,
maxBytes/maxAge/no-cap reclamation, retention-then-reopen. 107 db/generation/
temporal tests green, tsc clean. Docs (ADR-001, consistency-model, snapshots
guide, api reference, RELEASES) updated to per-write granularity + retention.
This commit is contained in:
David Snelling 2026-06-22 15:19:58 -07:00
parent afac7f9662
commit 5c3bb2c864
15 changed files with 1207 additions and 218 deletions

View file

@ -901,16 +901,18 @@ that generation), once per `Db`, freed on `release()`.
**Throws:** `GenerationCompactedError` when the generation's records were reclaimed by `compactHistory()`.
**History granularity:** only `transact()` batches produce historical
records; single-operation writes advance the clock but stay visible through
earlier pins. See the [consistency model](../concepts/consistency-model.md).
**History granularity:** every write is its own immutable generation —
`transact()` batches AND single-operation writes — so a pin always freezes and
every write is addressable via `asOf()`. See the
[consistency model](../concepts/consistency-model.md).
---
### `transactionLog(options?)``Promise<TxLogEntry[]>`
Read the reified transaction log — one entry per committed `transact()`
batch, newest first: `{ generation, timestamp, meta? }`.
Read the reified transaction log — one entry per committed generation (every
`transact()` AND single-op write), newest first: `{ generation, timestamp,
meta? }`. Single-op generations carry no `meta` (it is a `transact()`-only field).
```typescript
const [latest] = await brain.transactionLog({ limit: 1 })
@ -921,13 +923,15 @@ latest.meta // { author: 'order-service', requestId: 'req-9f2' }
### `compactHistory(options?)``Promise<CompactHistoryResult>`
Reclaim historical record-sets that no retention rule and no live `Db` pin
protects. Pinned reads stay correct across compaction, always.
Reclaim historical record-sets that no retention cap and no live `Db` pin
protects. Pinned reads stay correct across compaction, always. (Auto-compaction
on `flush()`/`close()` is governed by the constructor `retention` knob — unset →
adaptive, `'all'` → unbounded, `{ … }` → explicit caps.)
```typescript
await brain.compactHistory({
retainGenerations: 100, // keep the 100 most recent commits
retainMs: 7 * 24 * 60 * 60 * 1000 // and everything from the last 7 days
maxGenerations: 100, // keep at most the 100 most recent generations
maxAge: 7 * 24 * 60 * 60 * 1000 // and only those from the last 7 days
})
```