feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)
One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.
Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
transact() commit and once per single-operation write (storage hook), so
brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
(the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
and forces an index rebuild; refcounted pins gate compactHistory(), which
records a horizon (asOf below it throws GenerationCompactedError).
Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
(GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
{confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
generation; index-accelerated queries at historical generations throw
NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
plugin.ts: feature-detected, balanced pin/release in lockstep with Db
lifecycle, post-commit applier + replay-gap model documented.
Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.
Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
2026-06-10 14:14:07 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* @module db/generationStore
|
|
|
|
|
|
* @description The generational record layer behind Brainy 8.0's MVCC Db API.
|
|
|
|
|
|
*
|
|
|
|
|
|
* One `GenerationStore` is attached to every writable `Brainy` instance. It
|
|
|
|
|
|
* owns:
|
|
|
|
|
|
*
|
|
|
|
|
|
* - the **monotonic generation counter** (`_system/generation.json`),
|
|
|
|
|
|
* reserved per `transact()` commit and bumped per single-operation write
|
|
|
|
|
|
* batch via the storage-layer hook;
|
|
|
|
|
|
* - the **commit protocol** for `brain.transact()`: capture before-images →
|
|
|
|
|
|
* write the generation delta → fsync → execute the operation batch →
|
|
|
|
|
|
* atomically rename `_system/manifest.json` (the rename IS the commit
|
|
|
|
|
|
* point) → append the tx-log line;
|
|
|
|
|
|
* - **crash recovery**: on open, any `_generations/<N>` directory with
|
|
|
|
|
|
* `N > manifest.generation` is an uncommitted transaction — its
|
|
|
|
|
|
* before-images are restored to the canonical entity paths and the
|
|
|
|
|
|
* directory is removed, so a crash anywhere before the manifest rename
|
|
|
|
|
|
* leaves the store byte-identical to its pre-transaction state;
|
|
|
|
|
|
* - **pin/release refcounting** for live `Db` values, which is what makes
|
|
|
|
|
|
* `compactHistory()` safe: a generation record-set is reclaimed only when
|
|
|
|
|
|
* no live pin could ever need it;
|
|
|
|
|
|
* - **point-in-time resolution**: the state of id X at pinned generation G
|
|
|
|
|
|
* is the before-image stored by the *first* committed generation after G
|
|
|
|
|
|
* that touched X — or the live storage state when nothing after G touched
|
|
|
|
|
|
* it (so current-generation reads stay on the existing fast paths,
|
|
|
|
|
|
* untouched).
|
|
|
|
|
|
*
|
|
|
|
|
|
* Durability and atomicity guarantees, failure modes, and the intellectual
|
|
|
|
|
|
* lineage (Datomic database-as-value, LMDB reader pins, LSM immutable
|
|
|
|
|
|
* segments) are specified in `docs/ADR-001-generational-mvcc.md`.
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
import { prodLog } from '../utils/logger.js'
|
|
|
|
|
|
import { GenerationCompactedError, GenerationConflictError } from './errors.js'
|
|
|
|
|
|
import type {
|
|
|
|
|
|
ChangedIds,
|
|
|
|
|
|
CompactHistoryOptions,
|
|
|
|
|
|
CompactHistoryResult,
|
|
|
|
|
|
GenerationDelta,
|
|
|
|
|
|
GenerationManifest,
|
|
|
|
|
|
GenerationRecord,
|
|
|
|
|
|
GenerationStorage,
|
|
|
|
|
|
TxLogEntry
|
|
|
|
|
|
} from './types.js'
|
|
|
|
|
|
|
|
|
|
|
|
/** Storage-root-relative path of the persisted generation counter. */
|
|
|
|
|
|
export const GENERATION_COUNTER_PATH = '_system/generation.json'
|
|
|
|
|
|
/** Storage-root-relative path of the commit manifest. */
|
|
|
|
|
|
export const MANIFEST_PATH = '_system/manifest.json'
|
|
|
|
|
|
/** Storage-root-relative prefix of the per-generation record directories. */
|
|
|
|
|
|
export const GENERATIONS_PREFIX = '_generations'
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description Phases of the {@link GenerationStore.commitTransaction} commit
|
|
|
|
|
|
* protocol at which a test-only fault injector can simulate a process crash.
|
|
|
|
|
|
* The phases map 1:1 onto the protocol steps documented on
|
|
|
|
|
|
* `commitTransaction`:
|
|
|
|
|
|
*
|
|
|
|
|
|
* - `'after-staging'` — before-images + the `tx.json` delta are written and
|
|
|
|
|
|
* fsynced; the operation batch has NOT executed yet.
|
|
|
|
|
|
* - `'after-execute'` — the operation batch has been applied to canonical
|
|
|
|
|
|
* storage and indexes; the manifest still points at the prior generation.
|
|
|
|
|
|
* - `'before-manifest-rename'` — the batch has executed and the counter is
|
|
|
|
|
|
* persisted; the atomic manifest rename — the commit point — has NOT
|
|
|
|
|
|
* happened. A crash here is the canonical "fully staged, never committed"
|
|
|
|
|
|
* case that recovery must roll back byte-identically.
|
|
|
|
|
|
* - `'after-manifest-rename'` — the manifest rename landed (the transaction
|
|
|
|
|
|
* IS committed); the tx-log append has NOT happened yet. A crash here must
|
|
|
|
|
|
* keep the transaction (the tx-log is advisory metadata, not the source of
|
|
|
|
|
|
* commit truth).
|
|
|
|
|
|
*/
|
|
|
|
|
|
export type CommitFaultPhase =
|
|
|
|
|
|
| 'after-staging'
|
|
|
|
|
|
| 'after-execute'
|
|
|
|
|
|
| 'before-manifest-rename'
|
|
|
|
|
|
| 'after-manifest-rename'
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description Identifies which ids a transaction touches, split by kind.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export interface TouchedIds {
|
|
|
|
|
|
/** Entity ids the transaction writes or deletes. */
|
|
|
|
|
|
nouns: string[]
|
|
|
|
|
|
/** Relationship ids the transaction writes or deletes. */
|
|
|
|
|
|
verbs: string[]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description Result of {@link GenerationStore.open} — how many uncommitted
|
|
|
|
|
|
* generations crash recovery rolled back (a non-zero value tells `Brainy` to
|
|
|
|
|
|
* force an index reconciliation, since derived index state may reference the
|
|
|
|
|
|
* rolled-back writes).
|
|
|
|
|
|
*/
|
|
|
|
|
|
export interface GenerationStoreOpenResult {
|
|
|
|
|
|
/** Count of uncommitted generation directories rolled back and removed. */
|
|
|
|
|
|
rolledBackGenerations: number
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description The generational MVCC record layer. See the module
|
|
|
|
|
|
* documentation for the full protocol; every public method documents its
|
|
|
|
|
|
* own contract.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export class GenerationStore {
|
|
|
|
|
|
private readonly storage: GenerationStorage
|
|
|
|
|
|
|
|
|
|
|
|
/** Latest reserved/observed generation (≥ {@link committed}). */
|
|
|
|
|
|
private counter = 0
|
|
|
|
|
|
/** Committed-transaction watermark (manifest generation). */
|
|
|
|
|
|
private committed = 0
|
|
|
|
|
|
/** Compaction horizon — record-sets ≤ this are reclaimed. */
|
|
|
|
|
|
private horizonGen = 0
|
|
|
|
|
|
|
|
|
|
|
|
/** Sorted list of committed generations whose record dirs exist. */
|
|
|
|
|
|
private committedGens: number[] = []
|
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.
2026-06-22 15:19:58 -07:00
|
|
|
|
/** Delta cache, keyed by generation (lazily loaded from `tx.json`). `bytes`
|
|
|
|
|
|
* is the serialized record-set size, summed by {@link historyBytes} for the
|
|
|
|
|
|
* `maxBytes` / adaptive retention caps (0 for generations written before
|
|
|
|
|
|
* byte accounting, or for un-flushed pending generations). */
|
feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)
One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.
Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
transact() commit and once per single-operation write (storage hook), so
brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
(the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
and forces an index rebuild; refcounted pins gate compactHistory(), which
records a horizon (asOf below it throws GenerationCompactedError).
Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
(GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
{confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
generation; index-accelerated queries at historical generations throw
NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
plugin.ts: feature-detected, balanced pin/release in lockstep with Db
lifecycle, post-commit applier + replay-gap model documented.
Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.
Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
2026-06-10 14:14:07 -07:00
|
|
|
|
private readonly deltaCache = new Map<
|
|
|
|
|
|
number,
|
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.
2026-06-22 15:19:58 -07:00
|
|
|
|
{ nouns: Set<string>; verbs: Set<string>; timestamp: number; bytes: number }
|
feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)
One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.
Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
transact() commit and once per single-operation write (storage hook), so
brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
(the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
and forces an index rebuild; refcounted pins gate compactHistory(), which
records a horizon (asOf below it throws GenerationCompactedError).
Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
(GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
{confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
generation; index-accelerated queries at historical generations throw
NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
plugin.ts: feature-detected, balanced pin/release in lockstep with Db
lifecycle, post-commit applier + replay-gap model documented.
Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.
Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
2026-06-10 14:14:07 -07:00
|
|
|
|
>()
|
|
|
|
|
|
|
2026-06-22 13:47:33 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* Per-id inverted history index (Model-B scalability) — `id → ascending
|
|
|
|
|
|
* generations that touched it`, one map per kind. {@link resolveAt} binary-
|
|
|
|
|
|
* searches the id's OWN chain (O(log chain)) for the first generation after
|
|
|
|
|
|
* the pin, instead of linearly scanning the global {@link committedGens}
|
|
|
|
|
|
* (which is O(database-age) — confirmed by the scalability spike: a read of an
|
|
|
|
|
|
* unchanged entity at an old pin scaled 11.9x for 10x history depth). This
|
|
|
|
|
|
* mirrors cor's `delta_history` chain. Built lazily once (under the mutex),
|
|
|
|
|
|
* maintained on commit, invalidated on compaction.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private readonly nounChains = new Map<string, number[]>()
|
|
|
|
|
|
private readonly verbChains = new Map<string, number[]>()
|
|
|
|
|
|
/** Whether {@link nounChains}/{@link verbChains} reflect every committed generation. */
|
|
|
|
|
|
private chainsReady = false
|
|
|
|
|
|
/** De-dupes concurrent first-callers of {@link ensureChains}. */
|
|
|
|
|
|
private chainsBuilding: Promise<void> | null = null
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Cap on resident {@link deltaCache} entries (Model-B RAM bound). The per-id
|
|
|
|
|
|
* {@link nounChains}/{@link verbChains} are the hot read structure; the raw
|
|
|
|
|
|
* deltas are only needed for range queries ({@link changedBetween}, `diff`,
|
|
|
|
|
|
* `since`) and chain (re)builds, and {@link getDelta} transparently re-reads an
|
|
|
|
|
|
* evicted delta from storage. Bounding this keeps a long-lived high-write
|
|
|
|
|
|
* process's heap O(cap) instead of O(generations) on the disk-backed path.
|
|
|
|
|
|
* Not `readonly` so tests can lower it to exercise the eviction/re-read path
|
|
|
|
|
|
* without building thousands of generations.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private deltaCacheMax = 4096
|
|
|
|
|
|
|
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.
2026-06-22 15:19:58 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* Model-B per-write group-commit — the in-memory PENDING tier.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Each single-operation write reserves its OWN generation (the locked
|
|
|
|
|
|
* cross-project contract: "every write versioned; a distinct generation per
|
|
|
|
|
|
* single-op"), captures byte-identical before-images, and buffers them here
|
|
|
|
|
|
* instead of paying a synchronous per-write manifest fsync (the 3-5x write
|
|
|
|
|
|
* regression). {@link flushPendingSingleOps} persists the whole buffer to
|
|
|
|
|
|
* disk in ONE fsync (durability batching — NOT a generation collapse).
|
|
|
|
|
|
*
|
|
|
|
|
|
* Crucially, these pending generations participate in point-in-time
|
|
|
|
|
|
* resolution EXACTLY like committed ones ({@link resolveAt}, the per-id
|
|
|
|
|
|
* chains, {@link changedBetween}, {@link hasCommittedAfter} all read the
|
|
|
|
|
|
* union via {@link reservedGens}; before-images resolve from this buffer
|
|
|
|
|
|
* while pending, from disk once flushed). That is what lets the SYNCHRONOUS
|
|
|
|
|
|
* `now()` pin freeze against un-flushed single-ops with no forced flush.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Durability boundary: a hard crash before a flush loses only the buffered
|
|
|
|
|
|
* *history* of un-flushed writes (live data is already in canonical storage);
|
|
|
|
|
|
* a crash mid-flush is recovered by drop-without-restore (see
|
|
|
|
|
|
* {@link GenerationDelta.groupCommit}).
|
|
|
|
|
|
*/
|
|
|
|
|
|
private pendingGens: number[] = []
|
|
|
|
|
|
private readonly pendingBuffer = new Map<
|
|
|
|
|
|
number,
|
|
|
|
|
|
{ nouns: Map<string, GenerationRecord>; verbs: Map<string, GenerationRecord>; timestamp: number }
|
|
|
|
|
|
>()
|
|
|
|
|
|
/** Pending timer-coalesced flush handle (cleared on flush/close). */
|
|
|
|
|
|
private pendingFlushTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Flush the pending tier once it holds this many generations (size trigger),
|
|
|
|
|
|
* complementing the {@link PENDING_FLUSH_DELAY_MS} timer trigger. Not
|
|
|
|
|
|
* `readonly` so tests can drive the boundary deterministically.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private pendingFlushThreshold = 256
|
|
|
|
|
|
/** Coalescing window (ms) before an idle pending tier is flushed. */
|
|
|
|
|
|
private static readonly PENDING_FLUSH_DELAY_MS = 50
|
|
|
|
|
|
|
feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)
One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.
Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
transact() commit and once per single-operation write (storage hook), so
brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
(the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
and forces an index rebuild; refcounted pins gate compactHistory(), which
records a horizon (asOf below it throws GenerationCompactedError).
Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
(GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
{confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
generation; index-accelerated queries at historical generations throw
NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
plugin.ts: feature-detected, balanced pin/release in lockstep with Db
lifecycle, post-commit applier + replay-gap model documented.
Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.
Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
2026-06-10 14:14:07 -07:00
|
|
|
|
/** Live pin refcounts, keyed by pinned generation. */
|
|
|
|
|
|
private readonly pins = new Map<number, number>()
|
|
|
|
|
|
|
|
|
|
|
|
/** Serializes transact commits, compaction, and counter persistence. */
|
|
|
|
|
|
private mutexTail: Promise<unknown> = Promise.resolve()
|
|
|
|
|
|
|
|
|
|
|
|
/** True while a transact batch is executing (suppresses the bump hook). */
|
|
|
|
|
|
private inTransact = false
|
|
|
|
|
|
|
|
|
|
|
|
/** Coalescing state for lazy persistence of single-op counter bumps. */
|
|
|
|
|
|
private counterPersistScheduled = false
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Test-only fault injector for the commit protocol (see
|
|
|
|
|
|
* {@link GenerationStore.setCommitFaultInjector}). `undefined` in production.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private commitFaultInjector?: (phase: CommitFaultPhase) => void
|
|
|
|
|
|
|
|
|
|
|
|
private opened = false
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @param storage - The storage adapter, narrowed to the
|
|
|
|
|
|
* {@link GenerationStorage} contract (`BaseStorage` implements it).
|
|
|
|
|
|
*/
|
|
|
|
|
|
constructor(storage: GenerationStorage) {
|
|
|
|
|
|
this.storage = storage
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
// Lifecycle
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description Load the persisted counter + manifest and run crash
|
|
|
|
|
|
* recovery. Must be called once, before indexes are built, because
|
|
|
|
|
|
* recovery may rewrite canonical entity files (restoring before-images of
|
|
|
|
|
|
* an uncommitted transaction).
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param options.readOnly - When true (reader-mode brains), recovery is
|
|
|
|
|
|
* skipped: readers never write. An un-repaired crashed directory is
|
|
|
|
|
|
* repaired by the next *writer* open.
|
|
|
|
|
|
* @returns How many uncommitted generations were rolled back.
|
|
|
|
|
|
*/
|
|
|
|
|
|
async open(options?: { readOnly?: boolean }): Promise<GenerationStoreOpenResult> {
|
|
|
|
|
|
const counterFile = (await this.storage.readRawObject(GENERATION_COUNTER_PATH)) as
|
|
|
|
|
|
| { generation?: number }
|
|
|
|
|
|
| null
|
|
|
|
|
|
const manifest = (await this.storage.readRawObject(MANIFEST_PATH)) as GenerationManifest | null
|
|
|
|
|
|
|
|
|
|
|
|
this.committed = manifest?.generation ?? 0
|
|
|
|
|
|
this.horizonGen = manifest?.horizon ?? 0
|
|
|
|
|
|
this.counter = Math.max(counterFile?.generation ?? 0, this.committed)
|
|
|
|
|
|
|
|
|
|
|
|
// Discover existing generation record directories.
|
|
|
|
|
|
const recordPaths = await this.storage.listRawObjects(GENERATIONS_PREFIX)
|
|
|
|
|
|
const seenGens = new Set<number>()
|
|
|
|
|
|
for (const p of recordPaths) {
|
|
|
|
|
|
const gen = parseGenerationFromPath(p)
|
|
|
|
|
|
if (gen !== null) seenGens.add(gen)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let rolledBack = 0
|
|
|
|
|
|
const committedGens: number[] = []
|
|
|
|
|
|
for (const gen of [...seenGens].sort((a, b) => a - b)) {
|
|
|
|
|
|
if (gen <= this.committed) {
|
|
|
|
|
|
committedGens.push(gen)
|
|
|
|
|
|
} else if (!options?.readOnly) {
|
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.
2026-06-22 15:19:58 -07:00
|
|
|
|
// Uncommitted (crashed) generation. A transact generation is rolled
|
|
|
|
|
|
// back by RESTORING its before-images (its before-image + execute are
|
|
|
|
|
|
// one atomic unit). A single-op group-commit generation is DROPPED
|
|
|
|
|
|
// WITHOUT RESTORE — its live write already landed and was acknowledged
|
|
|
|
|
|
// before the flush, so restoring would silently revert it. The branch
|
|
|
|
|
|
// is decided by the persisted delta's `groupCommit` flag.
|
|
|
|
|
|
await this.recoverUncommittedGeneration(gen)
|
feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)
One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.
Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
transact() commit and once per single-operation write (storage hook), so
brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
(the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
and forces an index rebuild; refcounted pins gate compactHistory(), which
records a horizon (asOf below it throws GenerationCompactedError).
Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
(GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
{confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
generation; index-accelerated queries at historical generations throw
NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
plugin.ts: feature-detected, balanced pin/release in lockstep with Db
lifecycle, post-commit applier + replay-gap model documented.
Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.
Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
2026-06-10 14:14:07 -07:00
|
|
|
|
rolledBack++
|
|
|
|
|
|
}
|
|
|
|
|
|
// Reader mode: leave orphan dirs alone; resolution ignores them because
|
|
|
|
|
|
// committedGens only includes generations ≤ the manifest watermark.
|
|
|
|
|
|
this.counter = Math.max(this.counter, gen)
|
|
|
|
|
|
}
|
|
|
|
|
|
this.committedGens = committedGens
|
2026-06-22 13:47:33 -07:00
|
|
|
|
// Chains are rebuilt lazily from the (possibly changed) generation set on
|
|
|
|
|
|
// the next historical read — covers reopen and reopen-after-restore.
|
|
|
|
|
|
this.invalidateChains()
|
feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)
One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.
Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
transact() commit and once per single-operation write (storage hook), so
brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
(the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
and forces an index rebuild; refcounted pins gate compactHistory(), which
records a horizon (asOf below it throws GenerationCompactedError).
Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
(GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
{confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
generation; index-accelerated queries at historical generations throw
NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
plugin.ts: feature-detected, balanced pin/release in lockstep with Db
lifecycle, post-commit applier + replay-gap model documented.
Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.
Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
2026-06-10 14:14:07 -07:00
|
|
|
|
|
|
|
|
|
|
if (rolledBack > 0) {
|
|
|
|
|
|
prodLog.warn(
|
|
|
|
|
|
`[GenerationStore] Crash recovery rolled back ${rolledBack} uncommitted ` +
|
|
|
|
|
|
`transaction(s); store restored to generation ${this.committed}.`
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.opened = true
|
|
|
|
|
|
|
|
|
|
|
|
// Hook single-op write batches so generation() is always meaningful.
|
|
|
|
|
|
// Suppressed while a transact batch executes (the batch is ONE generation).
|
|
|
|
|
|
if (!options?.readOnly) {
|
|
|
|
|
|
this.storage.setGenerationBumpHook(() => this.noteSingleOpWrite())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return { rolledBackGenerations: rolledBack }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description Detach the storage hook and persist the counter. Called
|
|
|
|
|
|
* from `brain.close()`.
|
|
|
|
|
|
*/
|
|
|
|
|
|
async close(): Promise<void> {
|
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.
2026-06-22 15:19:58 -07:00
|
|
|
|
// Persist any buffered single-op history before detaching, so a clean close
|
|
|
|
|
|
// never loses generations the caller already observed.
|
|
|
|
|
|
this.clearPendingFlushTimer()
|
|
|
|
|
|
await this.flushPendingSingleOps()
|
feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)
One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.
Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
transact() commit and once per single-operation write (storage hook), so
brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
(the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
and forces an index rebuild; refcounted pins gate compactHistory(), which
records a horizon (asOf below it throws GenerationCompactedError).
Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
(GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
{confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
generation; index-accelerated queries at historical generations throw
NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
plugin.ts: feature-detected, balanced pin/release in lockstep with Db
lifecycle, post-commit applier + replay-gap model documented.
Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.
Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
2026-06-10 14:14:07 -07:00
|
|
|
|
this.storage.setGenerationBumpHook(undefined)
|
|
|
|
|
|
await this.persistCounterNow()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description TEST-ONLY: install (or clear, with `undefined`) a fault
|
|
|
|
|
|
* injector that is invoked at each {@link CommitFaultPhase} of the commit
|
|
|
|
|
|
* protocol. A **throwing** injector simulates a process crash at that
|
|
|
|
|
|
* phase: the abort cleanup (staging-directory removal, generation-counter
|
|
|
|
|
|
* reservation return) is deliberately skipped — exactly as if the process
|
|
|
|
|
|
* had died — so crash-consistency tests exercise the REAL recovery path
|
|
|
|
|
|
* ({@link GenerationStore.open} rolling back the uncommitted generation on
|
|
|
|
|
|
* the next open). Never installed in production code; the injector exists
|
|
|
|
|
|
* solely so the durability protocol can be proven, not trusted.
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param injector - Callback invoked synchronously at each phase, or
|
|
|
|
|
|
* `undefined` to detach.
|
|
|
|
|
|
*/
|
|
|
|
|
|
setCommitFaultInjector(injector: ((phase: CommitFaultPhase) => void) | undefined): void {
|
|
|
|
|
|
this.commitFaultInjector = injector
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
// Generation counter
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
|
|
|
|
|
|
/** @returns The store's current generation (latest write watermark). */
|
|
|
|
|
|
generation(): number {
|
|
|
|
|
|
return this.counter
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** @returns The committed-transaction watermark (manifest generation). */
|
|
|
|
|
|
committedGeneration(): number {
|
|
|
|
|
|
return this.committed
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** @returns The compaction horizon (generations ≤ horizon are reclaimed). */
|
|
|
|
|
|
horizon(): number {
|
|
|
|
|
|
return this.horizonGen
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description Single-operation write hook (registered with the storage
|
|
|
|
|
|
* layer in {@link open}). Bumps the in-memory counter and schedules a
|
|
|
|
|
|
* coalesced persist of `_system/generation.json` — durable artifacts
|
|
|
|
|
|
* (records, manifests, snapshots) always persist the counter
|
|
|
|
|
|
* synchronously at their own commit points, so a crash inside the
|
|
|
|
|
|
* coalescing window can never move the counter backwards relative to
|
|
|
|
|
|
* anything durable.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private noteSingleOpWrite(): void {
|
|
|
|
|
|
if (this.inTransact) return
|
|
|
|
|
|
this.counter++
|
|
|
|
|
|
this.schedulePersistCounter()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Schedule one coalesced counter persist for the current write burst. */
|
|
|
|
|
|
private schedulePersistCounter(): void {
|
|
|
|
|
|
if (this.counterPersistScheduled) return
|
|
|
|
|
|
this.counterPersistScheduled = true
|
|
|
|
|
|
setImmediate(() => {
|
|
|
|
|
|
this.counterPersistScheduled = false
|
|
|
|
|
|
void this.withMutex(() => this.persistCounterUnlocked()).catch((err) => {
|
|
|
|
|
|
prodLog.warn(`[GenerationStore] generation counter persist failed: ${(err as Error).message}`)
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description Persist the generation counter now (atomic tmp+rename).
|
|
|
|
|
|
* Called from `flush()`, `close()`, snapshotting, and the commit protocol.
|
|
|
|
|
|
*/
|
|
|
|
|
|
async persistCounterNow(): Promise<void> {
|
|
|
|
|
|
if (!this.opened) return
|
|
|
|
|
|
await this.withMutex(() => this.persistCounterUnlocked())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private async persistCounterUnlocked(): Promise<void> {
|
|
|
|
|
|
await this.storage.writeRawObject(GENERATION_COUNTER_PATH, {
|
|
|
|
|
|
generation: this.counter,
|
|
|
|
|
|
updatedAt: new Date().toISOString()
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
// Pins
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description Pin a generation (refcounted). Compaction never reclaims a
|
|
|
|
|
|
* record-set a live pin could need (i.e. any generation directory `N`
|
|
|
|
|
|
* with `N > G` for some pinned `G`).
|
|
|
|
|
|
* @param gen - The generation to pin.
|
|
|
|
|
|
*/
|
|
|
|
|
|
pin(gen: number): void {
|
|
|
|
|
|
this.pins.set(gen, (this.pins.get(gen) ?? 0) + 1)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description Release one pin on a generation. Safe to call for a
|
|
|
|
|
|
* generation with no live pins (no-op with a warning).
|
|
|
|
|
|
* @param gen - The generation to release.
|
|
|
|
|
|
*/
|
|
|
|
|
|
release(gen: number): void {
|
|
|
|
|
|
const count = this.pins.get(gen)
|
|
|
|
|
|
if (count === undefined) {
|
|
|
|
|
|
prodLog.warn(`[GenerationStore] release(${gen}) called with no live pin at that generation`)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if (count <= 1) this.pins.delete(gen)
|
|
|
|
|
|
else this.pins.set(gen, count - 1)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** @returns Total number of live pins across all generations. */
|
|
|
|
|
|
activePinCount(): number {
|
|
|
|
|
|
let total = 0
|
|
|
|
|
|
for (const count of this.pins.values()) total += count
|
|
|
|
|
|
return total
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** @returns The smallest pinned generation, or `Infinity` with no pins. */
|
|
|
|
|
|
private minPinnedGeneration(): number {
|
|
|
|
|
|
let min = Infinity
|
|
|
|
|
|
for (const gen of this.pins.keys()) min = Math.min(min, gen)
|
|
|
|
|
|
return min
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
// Commit protocol
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description Commit one transaction. The caller (Brainy.transact) plans
|
|
|
|
|
|
* the batch up front — resolving every touched id, including generated
|
|
|
|
|
|
* entity/relationship ids and delete cascades — and hands this method the
|
|
|
|
|
|
* touched-id sets plus an `execute` closure that runs the already-built
|
|
|
|
|
|
* operation batch through the TransactionManager.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Protocol (under the commit mutex):
|
|
|
|
|
|
* 1. `ifAtGeneration` CAS check (rejects before anything is staged).
|
|
|
|
|
|
* 2. Reserve generation `N` (counter increment).
|
|
|
|
|
|
* 3. Capture before-images of every touched id and write them, plus the
|
|
|
|
|
|
* `tx.json` delta, under `_generations/N/`; fsync. From this point a
|
|
|
|
|
|
* crash anywhere is recoverable to the exact pre-transaction bytes.
|
|
|
|
|
|
* 4. Run `execute()`. On failure the TransactionManager has already
|
|
|
|
|
|
* rolled back its applied operations; the staging directory is removed
|
|
|
|
|
|
* and the reservation is returned (when no concurrent bump consumed a
|
|
|
|
|
|
* later number), so a failed transaction leaves the generation
|
|
|
|
|
|
* unchanged.
|
|
|
|
|
|
* 5. Persist the counter, then atomically rename the manifest to
|
|
|
|
|
|
* generation `N` — **the rename is the commit point** — and fsync it.
|
|
|
|
|
|
* 6. Append the tx-log line and update in-memory bookkeeping.
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param args.touched - Every id the batch writes or deletes.
|
|
|
|
|
|
* @param args.meta - Transaction metadata for the tx-log.
|
|
|
|
|
|
* @param args.ifAtGeneration - Whole-store CAS expectation.
|
|
|
|
|
|
* @param args.execute - Runs the planned operation batch atomically.
|
|
|
|
|
|
* @returns The committed generation and its commit timestamp.
|
|
|
|
|
|
* @throws GenerationConflictError when the CAS expectation fails.
|
|
|
|
|
|
*/
|
|
|
|
|
|
async commitTransaction(args: {
|
|
|
|
|
|
touched: TouchedIds
|
|
|
|
|
|
meta?: Record<string, unknown>
|
|
|
|
|
|
ifAtGeneration?: number
|
|
|
|
|
|
execute: () => Promise<void>
|
|
|
|
|
|
}): Promise<{ generation: number; timestamp: number }> {
|
|
|
|
|
|
return this.withMutex(async () => {
|
|
|
|
|
|
if (args.ifAtGeneration !== undefined && args.ifAtGeneration !== this.counter) {
|
|
|
|
|
|
throw new GenerationConflictError(args.ifAtGeneration, this.counter)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const nouns = [...new Set(args.touched.nouns)]
|
|
|
|
|
|
const verbs = [...new Set(args.touched.verbs)]
|
|
|
|
|
|
const gen = ++this.counter
|
|
|
|
|
|
const dir = `${GENERATIONS_PREFIX}/${gen}`
|
|
|
|
|
|
const timestamp = Date.now()
|
|
|
|
|
|
|
|
|
|
|
|
// Test-only crash simulation (see setCommitFaultInjector): a throwing
|
|
|
|
|
|
// injector must bypass the abort cleanup below, exactly as a real
|
|
|
|
|
|
// process death would, so recovery-on-open is what restores the store.
|
|
|
|
|
|
let crashSimulated = false
|
|
|
|
|
|
const faultPoint = (phase: CommitFaultPhase): void => {
|
|
|
|
|
|
if (!this.commitFaultInjector) return
|
|
|
|
|
|
try {
|
|
|
|
|
|
this.commitFaultInjector(phase)
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
crashSimulated = true
|
|
|
|
|
|
throw err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
// -- 3. Before-images + delta (the durable undo log) ------------------
|
|
|
|
|
|
const stagedPaths: string[] = []
|
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.
2026-06-22 15:19:58 -07:00
|
|
|
|
let recordBytes = 0 // serialized record-set size, for retention accounting
|
feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)
One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.
Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
transact() commit and once per single-operation write (storage hook), so
brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
(the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
and forces an index rebuild; refcounted pins gate compactHistory(), which
records a horizon (asOf below it throws GenerationCompactedError).
Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
(GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
{confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
generation; index-accelerated queries at historical generations throw
NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
plugin.ts: feature-detected, balanced pin/release in lockstep with Db
lifecycle, post-commit applier + replay-gap model documented.
Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.
Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
2026-06-10 14:14:07 -07:00
|
|
|
|
for (const id of nouns) {
|
|
|
|
|
|
const prev = await this.storage.readNounRaw(id)
|
|
|
|
|
|
const recordPath = `${dir}/prev/${id}.json`
|
|
|
|
|
|
const record: GenerationRecord = { kind: 'noun', metadata: prev.metadata, vector: prev.vector }
|
|
|
|
|
|
await this.storage.writeRawObject(recordPath, record)
|
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.
2026-06-22 15:19:58 -07:00
|
|
|
|
recordBytes += serializedBytes(record)
|
feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)
One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.
Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
transact() commit and once per single-operation write (storage hook), so
brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
(the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
and forces an index rebuild; refcounted pins gate compactHistory(), which
records a horizon (asOf below it throws GenerationCompactedError).
Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
(GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
{confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
generation; index-accelerated queries at historical generations throw
NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
plugin.ts: feature-detected, balanced pin/release in lockstep with Db
lifecycle, post-commit applier + replay-gap model documented.
Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.
Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
2026-06-10 14:14:07 -07:00
|
|
|
|
stagedPaths.push(recordPath)
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const id of verbs) {
|
|
|
|
|
|
const prev = await this.storage.readVerbRaw(id)
|
|
|
|
|
|
const recordPath = `${dir}/prev/${id}.json`
|
|
|
|
|
|
const record: GenerationRecord = { kind: 'verb', metadata: prev.metadata, vector: prev.vector }
|
|
|
|
|
|
await this.storage.writeRawObject(recordPath, record)
|
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.
2026-06-22 15:19:58 -07:00
|
|
|
|
recordBytes += serializedBytes(record)
|
feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)
One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.
Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
transact() commit and once per single-operation write (storage hook), so
brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
(the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
and forces an index rebuild; refcounted pins gate compactHistory(), which
records a horizon (asOf below it throws GenerationCompactedError).
Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
(GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
{confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
generation; index-accelerated queries at historical generations throw
NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
plugin.ts: feature-detected, balanced pin/release in lockstep with Db
lifecycle, post-commit applier + replay-gap model documented.
Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.
Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
2026-06-10 14:14:07 -07:00
|
|
|
|
stagedPaths.push(recordPath)
|
|
|
|
|
|
}
|
|
|
|
|
|
const delta: GenerationDelta = {
|
|
|
|
|
|
generation: gen,
|
|
|
|
|
|
timestamp,
|
|
|
|
|
|
...(args.meta && { meta: args.meta }),
|
|
|
|
|
|
nouns,
|
|
|
|
|
|
verbs
|
|
|
|
|
|
}
|
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.
2026-06-22 15:19:58 -07:00
|
|
|
|
delta.bytes = recordBytes + serializedBytes(delta)
|
feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)
One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.
Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
transact() commit and once per single-operation write (storage hook), so
brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
(the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
and forces an index rebuild; refcounted pins gate compactHistory(), which
records a horizon (asOf below it throws GenerationCompactedError).
Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
(GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
{confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
generation; index-accelerated queries at historical generations throw
NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
plugin.ts: feature-detected, balanced pin/release in lockstep with Db
lifecycle, post-commit applier + replay-gap model documented.
Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.
Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
2026-06-10 14:14:07 -07:00
|
|
|
|
const deltaPath = `${dir}/tx.json`
|
|
|
|
|
|
await this.storage.writeRawObject(deltaPath, delta)
|
|
|
|
|
|
stagedPaths.push(deltaPath)
|
|
|
|
|
|
await this.storage.syncRawObjects(stagedPaths)
|
|
|
|
|
|
faultPoint('after-staging')
|
|
|
|
|
|
|
|
|
|
|
|
// -- 4. Execute the planned batch -------------------------------------
|
|
|
|
|
|
this.inTransact = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
await args.execute()
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
this.inTransact = false
|
|
|
|
|
|
}
|
|
|
|
|
|
faultPoint('after-execute')
|
|
|
|
|
|
|
|
|
|
|
|
// -- 5. Counter + manifest rename (COMMIT POINT) ----------------------
|
|
|
|
|
|
await this.persistCounterUnlocked()
|
|
|
|
|
|
faultPoint('before-manifest-rename')
|
|
|
|
|
|
const manifest: GenerationManifest = {
|
|
|
|
|
|
version: 1,
|
|
|
|
|
|
generation: gen,
|
|
|
|
|
|
committedAt: new Date(timestamp).toISOString(),
|
|
|
|
|
|
horizon: this.horizonGen
|
|
|
|
|
|
}
|
|
|
|
|
|
await this.storage.writeRawObject(MANIFEST_PATH, manifest)
|
|
|
|
|
|
await this.storage.syncRawObjects([MANIFEST_PATH])
|
|
|
|
|
|
faultPoint('after-manifest-rename')
|
|
|
|
|
|
|
|
|
|
|
|
// -- 6. Post-commit bookkeeping ---------------------------------------
|
|
|
|
|
|
this.committed = gen
|
|
|
|
|
|
this.committedGens.push(gen)
|
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.
2026-06-22 15:19:58 -07:00
|
|
|
|
this.setDelta(gen, {
|
|
|
|
|
|
nouns: new Set(nouns),
|
|
|
|
|
|
verbs: new Set(verbs),
|
|
|
|
|
|
timestamp,
|
|
|
|
|
|
bytes: delta.bytes ?? 0
|
|
|
|
|
|
})
|
2026-06-22 13:47:33 -07:00
|
|
|
|
this.extendChains(gen, nouns, verbs)
|
feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)
One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.
Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
transact() commit and once per single-operation write (storage hook), so
brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
(the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
and forces an index rebuild; refcounted pins gate compactHistory(), which
records a horizon (asOf below it throws GenerationCompactedError).
Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
(GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
{confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
generation; index-accelerated queries at historical generations throw
NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
plugin.ts: feature-detected, balanced pin/release in lockstep with Db
lifecycle, post-commit applier + replay-gap model documented.
Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.
Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
2026-06-10 14:14:07 -07:00
|
|
|
|
const logEntry: TxLogEntry = { generation: gen, timestamp, ...(args.meta && { meta: args.meta }) }
|
|
|
|
|
|
await this.storage.appendTxLogLine(JSON.stringify(logEntry))
|
|
|
|
|
|
|
|
|
|
|
|
return { generation: gen, timestamp }
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
// Simulated crash (test-only fault injector): skip the abort cleanup
|
|
|
|
|
|
// entirely — a dead process cannot clean up. Recovery on the next
|
|
|
|
|
|
// open() is what must (and does) restore the store.
|
|
|
|
|
|
if (crashSimulated) {
|
|
|
|
|
|
throw err
|
|
|
|
|
|
}
|
|
|
|
|
|
// Failed before the manifest rename: nothing is committed. Remove the
|
|
|
|
|
|
// staging directory; the TransactionManager already restored any
|
|
|
|
|
|
// applied operation byte-identically.
|
|
|
|
|
|
try {
|
|
|
|
|
|
await this.storage.removeRawPrefix(dir)
|
|
|
|
|
|
} catch (cleanupErr) {
|
|
|
|
|
|
prodLog.warn(
|
|
|
|
|
|
`[GenerationStore] failed to remove staging dir ${dir} after aborted transaction: ` +
|
|
|
|
|
|
`${(cleanupErr as Error).message} (recovery will remove it on next open)`
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
// Return the reservation when no concurrent bump consumed a later
|
|
|
|
|
|
// number, so a failed transaction leaves generation() unchanged.
|
|
|
|
|
|
if (this.counter === gen) this.counter = gen - 1
|
|
|
|
|
|
throw err
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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.
2026-06-22 15:19:58 -07:00
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
// Single-operation commit (Model-B per-write group-commit)
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description Commit ONE single-operation write (`add`/`update`/`remove`/
|
|
|
|
|
|
* `relate`/`updateRelation`/`unrelate` and their `*Many` per-item calls) as
|
|
|
|
|
|
* its own immutable generation — the Model-B "every write versioned"
|
|
|
|
|
|
* contract. Structurally a one-operation {@link commitTransaction} with
|
|
|
|
|
|
* **deferred durability**:
|
|
|
|
|
|
*
|
|
|
|
|
|
* - Under the commit mutex it reserves generation `N`, captures byte-identical
|
|
|
|
|
|
* before-images of every touched id (so the pin/`asOf` read source is
|
|
|
|
|
|
* exact), runs `execute()` (the existing single-op `executeTransaction`
|
|
|
|
|
|
* batch) with the storage bump hook suppressed, then BUFFERS the
|
|
|
|
|
|
* before-images in the in-memory pending tier and returns — **no per-write
|
|
|
|
|
|
* manifest fsync** (that synchronous fsync is the 3-5x write regression this
|
|
|
|
|
|
* design avoids).
|
|
|
|
|
|
* - The generation is immediately visible to point-in-time reads (chains +
|
|
|
|
|
|
* {@link reservedGens}), so a synchronous `now()` pin freezes against it
|
|
|
|
|
|
* with no forced flush.
|
|
|
|
|
|
* - {@link flushPendingSingleOps} later persists the buffer to disk in one
|
|
|
|
|
|
* fsync (async group-commit). A crash before that flush loses only the
|
|
|
|
|
|
* buffered *history*; live data is already in canonical storage. A crash
|
|
|
|
|
|
* mid-flush is recovered by drop-without-restore
|
|
|
|
|
|
* (see {@link GenerationDelta.groupCommit}).
|
|
|
|
|
|
*
|
|
|
|
|
|
* The per-write generation is read by graph-index ops through the same
|
|
|
|
|
|
* `generation()` watermark `transact()` uses — because this method, like
|
|
|
|
|
|
* {@link commitTransaction}, holds the mutex across the counter bump AND
|
|
|
|
|
|
* `execute()`, so the watermark equals `N` for the duration of the write (a
|
|
|
|
|
|
* distinct generation threaded to cor per single-op, the locked contract).
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param args.touched - The ids this write creates/updates/deletes, by kind.
|
|
|
|
|
|
* @param args.execute - Runs the single-op's existing operation batch.
|
|
|
|
|
|
* @returns The reserved generation and its timestamp.
|
|
|
|
|
|
*/
|
|
|
|
|
|
async commitSingleOp(args: {
|
|
|
|
|
|
touched: { nouns?: string[]; verbs?: string[] }
|
|
|
|
|
|
execute: () => Promise<void>
|
|
|
|
|
|
}): Promise<{ generation: number; timestamp: number }> {
|
|
|
|
|
|
return this.withMutex(async () => {
|
|
|
|
|
|
const nouns = args.touched.nouns ? [...new Set(args.touched.nouns)] : []
|
|
|
|
|
|
const verbs = args.touched.verbs ? [...new Set(args.touched.verbs)] : []
|
|
|
|
|
|
const gen = ++this.counter
|
|
|
|
|
|
const timestamp = Date.now()
|
|
|
|
|
|
|
|
|
|
|
|
// Before-images BEFORE execute overwrites live storage (mirrors
|
|
|
|
|
|
// commitTransaction's staging, but buffered in memory — durability is
|
|
|
|
|
|
// deferred to the flush). readNounRaw/readVerbRaw on an absent id return
|
|
|
|
|
|
// {metadata:null, vector:null} = the create sentinel.
|
|
|
|
|
|
const nounBefore = new Map<string, GenerationRecord>()
|
|
|
|
|
|
for (const id of nouns) {
|
|
|
|
|
|
const prev = await this.storage.readNounRaw(id)
|
|
|
|
|
|
nounBefore.set(id, { kind: 'noun', metadata: prev.metadata, vector: prev.vector })
|
|
|
|
|
|
}
|
|
|
|
|
|
const verbBefore = new Map<string, GenerationRecord>()
|
|
|
|
|
|
for (const id of verbs) {
|
|
|
|
|
|
const prev = await this.storage.readVerbRaw(id)
|
|
|
|
|
|
verbBefore.set(id, { kind: 'verb', metadata: prev.metadata, vector: prev.vector })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Execute the live write. inTransact suppresses the storage bump hook so
|
|
|
|
|
|
// the counter is not double-advanced (this generation already reserved
|
|
|
|
|
|
// it) — the same suppression transact() uses.
|
|
|
|
|
|
this.inTransact = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
await args.execute()
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
this.inTransact = false
|
|
|
|
|
|
// Live write failed: nothing buffered, nothing on disk. Return the
|
|
|
|
|
|
// reservation (when no concurrent bump consumed a later number) so
|
|
|
|
|
|
// generation() is unchanged.
|
|
|
|
|
|
if (this.counter === gen) this.counter = gen - 1
|
|
|
|
|
|
throw err
|
|
|
|
|
|
}
|
|
|
|
|
|
this.inTransact = false
|
|
|
|
|
|
|
|
|
|
|
|
// Buffer the pending generation + make it instantly visible to reads.
|
|
|
|
|
|
this.pendingBuffer.set(gen, { nouns: nounBefore, verbs: verbBefore, timestamp })
|
|
|
|
|
|
this.pendingGens.push(gen)
|
|
|
|
|
|
this.extendChains(gen, nouns, verbs)
|
|
|
|
|
|
this.schedulePendingFlush()
|
|
|
|
|
|
return { generation: gen, timestamp }
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description Run a write WITHOUT creating a generation — for init-time /
|
|
|
|
|
|
* infrastructure writes (e.g. the VFS root directory) that constitute the
|
|
|
|
|
|
* generation-0 baseline of a freshly-materialized brain rather than a user
|
|
|
|
|
|
* mutation. The storage bump hook is suppressed (`inTransact`) so the
|
|
|
|
|
|
* generation counter does not advance, so a brand-new brain reports
|
|
|
|
|
|
* `generation() === 0` and an empty `transactionLog()`, and the first USER
|
|
|
|
|
|
* write is generation 1. Mirrors Datomic semantics where the empty database
|
|
|
|
|
|
* value is the starting point and bootstrap is not a transaction.
|
|
|
|
|
|
* @param execute - The infrastructure write to apply to the baseline.
|
|
|
|
|
|
*/
|
|
|
|
|
|
async runWithoutGeneration(execute: () => Promise<void>): Promise<void> {
|
|
|
|
|
|
this.inTransact = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
await execute()
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
this.inTransact = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description Persist the in-memory pending single-op generations to disk in
|
|
|
|
|
|
* ONE fsync (async group-commit) and advance the committed watermark. Called
|
|
|
|
|
|
* on the size/timer triggers and forced by `flush()`/`close()`/`transact()`/
|
|
|
|
|
|
* `compactHistory()` (so generations stay strictly ordered and durable before
|
|
|
|
|
|
* any of those). A no-op when the pending tier is empty.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Each pending generation is written as a normal `_generations/<N>/` record
|
|
|
|
|
|
* set (`prev/<id>.json` before-images + `tx.json` delta) but with the delta's
|
|
|
|
|
|
* `groupCommit: true` flag set — the drop-without-restore recovery marker.
|
|
|
|
|
|
* The single manifest rename to the highest generation commits the whole
|
|
|
|
|
|
* batch atomically (committed iff `gen ≤ manifest.generation`).
|
|
|
|
|
|
*/
|
|
|
|
|
|
async flushPendingSingleOps(): Promise<void> {
|
|
|
|
|
|
if (this.pendingGens.length === 0) return
|
|
|
|
|
|
return this.withMutex(async () => {
|
|
|
|
|
|
if (this.pendingGens.length === 0) return
|
|
|
|
|
|
this.clearPendingFlushTimer()
|
|
|
|
|
|
|
|
|
|
|
|
const gens = [...this.pendingGens].sort((a, b) => a - b)
|
|
|
|
|
|
const stagedPaths: string[] = []
|
|
|
|
|
|
const logEntries: TxLogEntry[] = []
|
|
|
|
|
|
const genBytes = new Map<number, number>() // for the post-commit setDelta
|
|
|
|
|
|
for (const gen of gens) {
|
|
|
|
|
|
const buf = this.pendingBuffer.get(gen)
|
|
|
|
|
|
if (!buf) continue
|
|
|
|
|
|
const dir = `${GENERATIONS_PREFIX}/${gen}`
|
|
|
|
|
|
const nounIds: string[] = []
|
|
|
|
|
|
const verbIds: string[] = []
|
|
|
|
|
|
let recordBytes = 0
|
|
|
|
|
|
for (const [id, record] of buf.nouns) {
|
|
|
|
|
|
const path = `${dir}/prev/${id}.json`
|
|
|
|
|
|
await this.storage.writeRawObject(path, record)
|
|
|
|
|
|
recordBytes += serializedBytes(record)
|
|
|
|
|
|
stagedPaths.push(path)
|
|
|
|
|
|
nounIds.push(id)
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const [id, record] of buf.verbs) {
|
|
|
|
|
|
const path = `${dir}/prev/${id}.json`
|
|
|
|
|
|
await this.storage.writeRawObject(path, record)
|
|
|
|
|
|
recordBytes += serializedBytes(record)
|
|
|
|
|
|
stagedPaths.push(path)
|
|
|
|
|
|
verbIds.push(id)
|
|
|
|
|
|
}
|
|
|
|
|
|
const delta: GenerationDelta = {
|
|
|
|
|
|
generation: gen,
|
|
|
|
|
|
timestamp: buf.timestamp,
|
|
|
|
|
|
groupCommit: true,
|
|
|
|
|
|
nouns: nounIds,
|
|
|
|
|
|
verbs: verbIds
|
|
|
|
|
|
}
|
|
|
|
|
|
delta.bytes = recordBytes + serializedBytes(delta)
|
|
|
|
|
|
genBytes.set(gen, delta.bytes)
|
|
|
|
|
|
const deltaPath = `${dir}/tx.json`
|
|
|
|
|
|
await this.storage.writeRawObject(deltaPath, delta)
|
|
|
|
|
|
stagedPaths.push(deltaPath)
|
|
|
|
|
|
logEntries.push({ generation: gen, timestamp: buf.timestamp })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ONE fsync for the whole window — the durability-batching win.
|
|
|
|
|
|
await this.storage.syncRawObjects(stagedPaths)
|
|
|
|
|
|
|
|
|
|
|
|
// Test-only crash simulation: a throwing injector here leaves the staged
|
|
|
|
|
|
// group-commit generation dirs on disk with NO manifest advance — the
|
|
|
|
|
|
// exact "crashed mid-flush" state recovery must DROP-WITHOUT-RESTORE
|
|
|
|
|
|
// (the pending in-memory state would be lost in a real crash; we abandon
|
|
|
|
|
|
// this store and recover from disk on the next open()).
|
|
|
|
|
|
if (this.commitFaultInjector) this.commitFaultInjector('before-manifest-rename')
|
|
|
|
|
|
|
|
|
|
|
|
// Commit point: persist the counter, then atomically rename the manifest
|
|
|
|
|
|
// to the highest pending generation — that commits ALL of them at once.
|
|
|
|
|
|
const highest = gens[gens.length - 1]
|
|
|
|
|
|
const highestTs = this.pendingBuffer.get(highest)?.timestamp ?? Date.now()
|
|
|
|
|
|
await this.persistCounterUnlocked()
|
|
|
|
|
|
const manifest: GenerationManifest = {
|
|
|
|
|
|
version: 1,
|
|
|
|
|
|
generation: highest,
|
|
|
|
|
|
committedAt: new Date(highestTs).toISOString(),
|
|
|
|
|
|
horizon: this.horizonGen
|
|
|
|
|
|
}
|
|
|
|
|
|
await this.storage.writeRawObject(MANIFEST_PATH, manifest)
|
|
|
|
|
|
await this.storage.syncRawObjects([MANIFEST_PATH])
|
|
|
|
|
|
|
|
|
|
|
|
// Move pending → committed. Chains already carry these gens; seed the
|
|
|
|
|
|
// bounded delta cache from the buffer so the next read avoids a re-read,
|
|
|
|
|
|
// then drop the in-memory before-images.
|
|
|
|
|
|
this.committed = highest
|
|
|
|
|
|
for (const gen of gens) {
|
|
|
|
|
|
const buf = this.pendingBuffer.get(gen)
|
|
|
|
|
|
if (!buf) continue
|
|
|
|
|
|
this.committedGens.push(gen)
|
|
|
|
|
|
this.setDelta(gen, {
|
|
|
|
|
|
nouns: new Set(buf.nouns.keys()),
|
|
|
|
|
|
verbs: new Set(buf.verbs.keys()),
|
|
|
|
|
|
timestamp: buf.timestamp,
|
|
|
|
|
|
bytes: genBytes.get(gen) ?? 0
|
|
|
|
|
|
})
|
|
|
|
|
|
this.pendingBuffer.delete(gen)
|
|
|
|
|
|
}
|
|
|
|
|
|
this.pendingGens = []
|
|
|
|
|
|
|
|
|
|
|
|
for (const entry of logEntries) {
|
|
|
|
|
|
await this.storage.appendTxLogLine(JSON.stringify(entry))
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Schedule a coalesced pending-tier flush (size trigger fires immediately on
|
|
|
|
|
|
* the next microtask; otherwise a {@link PENDING_FLUSH_DELAY_MS} timer). Both
|
|
|
|
|
|
* defer outside the current mutex section so the flush can re-acquire it. */
|
|
|
|
|
|
private schedulePendingFlush(): void {
|
|
|
|
|
|
if (this.pendingGens.length >= this.pendingFlushThreshold) {
|
|
|
|
|
|
void Promise.resolve()
|
|
|
|
|
|
.then(() => this.flushPendingSingleOps())
|
|
|
|
|
|
.catch((err) =>
|
|
|
|
|
|
prodLog.warn(`[GenerationStore] pending single-op flush failed: ${(err as Error).message}`)
|
|
|
|
|
|
)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if (this.pendingFlushTimer !== null) return
|
|
|
|
|
|
this.pendingFlushTimer = setTimeout(() => {
|
|
|
|
|
|
this.pendingFlushTimer = null
|
|
|
|
|
|
void this.flushPendingSingleOps().catch((err) =>
|
|
|
|
|
|
prodLog.warn(`[GenerationStore] pending single-op flush failed: ${(err as Error).message}`)
|
|
|
|
|
|
)
|
|
|
|
|
|
}, GenerationStore.PENDING_FLUSH_DELAY_MS)
|
|
|
|
|
|
// A background flush must not keep the process alive (Node only).
|
|
|
|
|
|
const t = this.pendingFlushTimer as unknown as { unref?: () => void }
|
|
|
|
|
|
if (t && typeof t.unref === 'function') t.unref()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Cancel any scheduled pending-tier flush timer. */
|
|
|
|
|
|
private clearPendingFlushTimer(): void {
|
|
|
|
|
|
if (this.pendingFlushTimer !== null) {
|
|
|
|
|
|
clearTimeout(this.pendingFlushTimer)
|
|
|
|
|
|
this.pendingFlushTimer = null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** @returns Committed ∪ pending generations, ascending. Pending generations
|
|
|
|
|
|
* are always greater than every committed one (reserved after the last
|
|
|
|
|
|
* commit; `transact()`/`compact()` flush the pending tier first), so the
|
|
|
|
|
|
* concatenation is already sorted. This is the union historical reads
|
|
|
|
|
|
* resolve over so un-flushed single-ops are visible to pins/`asOf`. */
|
|
|
|
|
|
private reservedGens(): number[] {
|
|
|
|
|
|
return this.pendingGens.length === 0
|
|
|
|
|
|
? this.committedGens
|
|
|
|
|
|
: [...this.committedGens, ...this.pendingGens]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)
One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.
Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
transact() commit and once per single-operation write (storage hook), so
brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
(the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
and forces an index rebuild; refcounted pins gate compactHistory(), which
records a horizon (asOf below it throws GenerationCompactedError).
Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
(GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
{confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
generation; index-accelerated queries at historical generations throw
NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
plugin.ts: feature-detected, balanced pin/release in lockstep with Db
lifecycle, post-commit applier + replay-gap model documented.
Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.
Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
2026-06-10 14:14:07 -07:00
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
// Point-in-time resolution
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description Whether any transaction committed after generation `gen`.
|
|
|
|
|
|
* O(1) — this is the fast-path check that lets a `Db` pinned at the
|
|
|
|
|
|
* current generation delegate every read to the live brain untouched.
|
|
|
|
|
|
* @param gen - The pinned generation.
|
|
|
|
|
|
*/
|
|
|
|
|
|
hasCommittedAfter(gen: number): boolean {
|
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.
2026-06-22 15:19:58 -07:00
|
|
|
|
// Pending single-op generations count: a now() pin must report itself
|
|
|
|
|
|
// historical the moment any later write (committed OR un-flushed) lands,
|
|
|
|
|
|
// else the Model-A "pin doesn't freeze against single-ops" hole reopens.
|
|
|
|
|
|
const last =
|
|
|
|
|
|
this.pendingGens.length > 0
|
|
|
|
|
|
? this.pendingGens[this.pendingGens.length - 1]
|
|
|
|
|
|
: this.committedGens[this.committedGens.length - 1]
|
feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)
One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.
Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
transact() commit and once per single-operation write (storage hook), so
brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
(the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
and forces an index rebuild; refcounted pins gate compactHistory(), which
records a horizon (asOf below it throws GenerationCompactedError).
Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
(GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
{confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
generation; index-accelerated queries at historical generations throw
NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
plugin.ts: feature-detected, balanced pin/release in lockstep with Db
lifecycle, post-commit applier + replay-gap model documented.
Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.
Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
2026-06-10 14:14:07 -07:00
|
|
|
|
return last !== undefined && last > gen
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description Resolve the state of an entity or relationship at pinned
|
|
|
|
|
|
* generation `gen`: the before-image stored by the first committed
|
|
|
|
|
|
* generation after `gen` that touched the id — or `{ source: 'current' }`
|
|
|
|
|
|
* when nothing after `gen` touched it (the live storage state *is* the
|
|
|
|
|
|
* state at `gen`).
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param kind - `'noun'` for entities, `'verb'` for relationships.
|
|
|
|
|
|
* @param id - The id to resolve.
|
|
|
|
|
|
* @param gen - The pinned generation.
|
|
|
|
|
|
* @returns `{ source: 'current' }`, `{ source: 'absent' }` (did not exist
|
|
|
|
|
|
* at `gen`), or `{ source: 'record', metadata, vector }` with the raw
|
|
|
|
|
|
* stored objects as of `gen`.
|
|
|
|
|
|
*/
|
|
|
|
|
|
async resolveAt(
|
|
|
|
|
|
kind: 'noun' | 'verb',
|
|
|
|
|
|
id: string,
|
|
|
|
|
|
gen: number
|
|
|
|
|
|
): Promise<
|
|
|
|
|
|
| { source: 'current' }
|
|
|
|
|
|
| { source: 'absent' }
|
|
|
|
|
|
| { source: 'record'; metadata: any; vector: any | null }
|
|
|
|
|
|
> {
|
2026-06-22 13:47:33 -07:00
|
|
|
|
await this.ensureChains()
|
|
|
|
|
|
const chain = (kind === 'noun' ? this.nounChains : this.verbChains).get(id)
|
|
|
|
|
|
if (chain === undefined) {
|
|
|
|
|
|
// The id was never touched by any committed generation → the live
|
|
|
|
|
|
// storage state IS its state at `gen`.
|
|
|
|
|
|
return { source: 'current' }
|
|
|
|
|
|
}
|
|
|
|
|
|
// The first committed generation strictly greater than `gen` that touched
|
|
|
|
|
|
// `id` holds the before-image of its state AS OF `gen` — O(log chain).
|
|
|
|
|
|
const candidate = firstGenerationAfter(chain, gen)
|
|
|
|
|
|
if (candidate === undefined) {
|
|
|
|
|
|
// Nothing after `gen` touched `id`; the live state is its state at `gen`.
|
|
|
|
|
|
return { source: 'current' }
|
feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)
One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.
Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
transact() commit and once per single-operation write (storage hook), so
brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
(the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
and forces an index rebuild; refcounted pins gate compactHistory(), which
records a horizon (asOf below it throws GenerationCompactedError).
Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
(GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
{confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
generation; index-accelerated queries at historical generations throw
NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
plugin.ts: feature-detected, balanced pin/release in lockstep with Db
lifecycle, post-commit applier + replay-gap model documented.
Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.
Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
2026-06-10 14:14:07 -07:00
|
|
|
|
}
|
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.
2026-06-22 15:19:58 -07:00
|
|
|
|
const record = await this.readBeforeImage(kind, candidate, id)
|
2026-06-22 13:47:33 -07:00
|
|
|
|
if (record === null) {
|
|
|
|
|
|
// The record-set exists (candidate is committed) but the id's
|
|
|
|
|
|
// before-image is missing — only possible through external tampering.
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
`Generation record missing: ${GENERATIONS_PREFIX}/${candidate}/prev/${id}.json ` +
|
|
|
|
|
|
`(store corrupted or records removed outside compactHistory())`
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (record.metadata === null && record.vector === null) {
|
|
|
|
|
|
return { source: 'absent' }
|
|
|
|
|
|
}
|
|
|
|
|
|
return { source: 'record', metadata: record.metadata, vector: record.vector }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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.
2026-06-22 15:19:58 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* @description Read the before-image of `id` stored by generation `gen` from
|
|
|
|
|
|
* the right tier: the in-memory pending buffer while the generation is
|
|
|
|
|
|
* un-flushed, or `_generations/<gen>/prev/<id>.json` on disk once committed.
|
|
|
|
|
|
* Returns `null` when the record-set has no before-image for `id`.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private async readBeforeImage(
|
|
|
|
|
|
kind: 'noun' | 'verb',
|
|
|
|
|
|
gen: number,
|
|
|
|
|
|
id: string
|
|
|
|
|
|
): Promise<GenerationRecord | null> {
|
|
|
|
|
|
const pending = this.pendingBuffer.get(gen)
|
|
|
|
|
|
if (pending) {
|
|
|
|
|
|
return (kind === 'noun' ? pending.nouns : pending.verbs).get(id) ?? null
|
|
|
|
|
|
}
|
|
|
|
|
|
return (await this.storage.readRawObject(
|
|
|
|
|
|
`${GENERATIONS_PREFIX}/${gen}/prev/${id}.json`
|
|
|
|
|
|
)) as GenerationRecord | null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-22 13:47:33 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* @description Ensure the per-id history chains ({@link nounChains} /
|
|
|
|
|
|
* {@link verbChains}) reflect every committed generation. Built once, lazily,
|
|
|
|
|
|
* under the commit mutex (so no concurrent commit mutates {@link committedGens}
|
|
|
|
|
|
* mid-build); thereafter maintained incrementally by {@link commit} and
|
|
|
|
|
|
* invalidated by {@link compact}. Concurrent first-callers share one build.
|
|
|
|
|
|
* O(committed generations) the first time; O(1) afterwards.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private async ensureChains(): Promise<void> {
|
|
|
|
|
|
if (this.chainsReady) return
|
|
|
|
|
|
if (!this.chainsBuilding) {
|
|
|
|
|
|
this.chainsBuilding = this.withMutex(async () => {
|
|
|
|
|
|
if (this.chainsReady) return
|
|
|
|
|
|
this.nounChains.clear()
|
|
|
|
|
|
this.verbChains.clear()
|
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.
2026-06-22 15:19:58 -07:00
|
|
|
|
// reservedGens (committed ∪ pending) is sorted ascending, so chains
|
|
|
|
|
|
// accrue in ascending order and un-flushed single-ops are indexed too.
|
|
|
|
|
|
for (const g of this.reservedGens()) {
|
2026-06-22 13:47:33 -07:00
|
|
|
|
const delta = await this.getDelta(g)
|
|
|
|
|
|
for (const nounId of delta.nouns) appendToChain(this.nounChains, nounId, g)
|
|
|
|
|
|
for (const verbId of delta.verbs) appendToChain(this.verbChains, verbId, g)
|
|
|
|
|
|
}
|
|
|
|
|
|
this.chainsReady = true
|
|
|
|
|
|
this.chainsBuilding = null
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
await this.chainsBuilding
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Record that generation `gen` (the newest) touched these ids — keeps chains
|
|
|
|
|
|
* current after a commit without a full rebuild. No-op until chains are built
|
|
|
|
|
|
* (the eventual build reads `gen` from {@link committedGens}). */
|
|
|
|
|
|
private extendChains(gen: number, nouns: Iterable<string>, verbs: Iterable<string>): void {
|
|
|
|
|
|
if (!this.chainsReady) return
|
|
|
|
|
|
for (const nounId of nouns) appendToChain(this.nounChains, nounId, gen)
|
|
|
|
|
|
for (const verbId of verbs) appendToChain(this.verbChains, verbId, gen)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Drop the chains so the next read rebuilds them from the surviving
|
|
|
|
|
|
* generations (called after compaction removes record-sets). */
|
|
|
|
|
|
private invalidateChains(): void {
|
|
|
|
|
|
this.chainsReady = false
|
|
|
|
|
|
this.chainsBuilding = null
|
|
|
|
|
|
this.nounChains.clear()
|
|
|
|
|
|
this.verbChains.clear()
|
feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)
One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.
Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
transact() commit and once per single-operation write (storage hook), so
brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
(the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
and forces an index rebuild; refcounted pins gate compactHistory(), which
records a horizon (asOf below it throws GenerationCompactedError).
Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
(GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
{confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
generation; index-accelerated queries at historical generations throw
NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
plugin.ts: feature-detected, balanced pin/release in lockstep with Db
lifecycle, post-commit applier + replay-gap model documented.
Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.
Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
2026-06-10 14:14:07 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description The ids touched by committed transactions in the interval
|
|
|
|
|
|
* `(fromGen, toGen]`. Used by `db.since()` and by historical `find()` to
|
|
|
|
|
|
* build its overlay of changed entities.
|
|
|
|
|
|
* @param fromGen - Exclusive lower bound.
|
|
|
|
|
|
* @param toGen - Inclusive upper bound.
|
|
|
|
|
|
*/
|
|
|
|
|
|
async changedBetween(fromGen: number, toGen: number): Promise<ChangedIds> {
|
|
|
|
|
|
const nouns = new Set<string>()
|
|
|
|
|
|
const verbs = new Set<string>()
|
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.
2026-06-22 15:19:58 -07:00
|
|
|
|
for (const gen of this.reservedGens()) {
|
feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)
One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.
Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
transact() commit and once per single-operation write (storage hook), so
brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
(the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
and forces an index rebuild; refcounted pins gate compactHistory(), which
records a horizon (asOf below it throws GenerationCompactedError).
Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
(GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
{confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
generation; index-accelerated queries at historical generations throw
NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
plugin.ts: feature-detected, balanced pin/release in lockstep with Db
lifecycle, post-commit applier + replay-gap model documented.
Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.
Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
2026-06-10 14:14:07 -07:00
|
|
|
|
if (gen <= fromGen || gen > toGen) continue
|
|
|
|
|
|
const delta = await this.getDelta(gen)
|
|
|
|
|
|
for (const id of delta.nouns) nouns.add(id)
|
|
|
|
|
|
for (const id of delta.verbs) verbs.add(id)
|
|
|
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
|
|
|
nouns: [...nouns].sort(),
|
|
|
|
|
|
verbs: [...verbs].sort(),
|
|
|
|
|
|
fromGeneration: fromGen,
|
|
|
|
|
|
toGeneration: toGen
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(8.0): temporal range verbs — diff, history, since(gen|Date), asOf{exclusive}, transactionLog window
asOf() answers "state AT a point"; these answer "what happened BETWEEN two
points" and "one entity's whole history", all on the existing generation records.
- Extract resolveAsOfGeneration() as the shared gen|Date→{generation,timestamp}
chokepoint (reachability + Date semantics identical everywhere). asOf()'s
inclusive path is byte-identical — db-mvcc still 25/25.
- asOf(target, { exclusive }) — strict-before (resolved−1, clamped at gen 0,
never a RangeError).
- db.since(Db | generation | Date) — overload; number/Date resolve via the shared
resolver (new DbHost.resolveGeneration); EXCLUSIVE lower bound;
since(db) === since(db.generation). Same-store guard via Db.belongsToStore.
- brain.diff(a, b) → { added, removed, modified } split by nouns/verbs. EARNS its
name: candidate set is ONLY changedBetween(gLow,gHigh), each classified by
existence at both endpoints + a key-order-insensitive value compare
(new src/db/stableEqual.ts) — a touched-but-reverted / born-and-died id is in
no bucket.
- brain.history(id, { from, to }) → every distinct version oldest→newest, each
value === asOf(version.generation).get(id); null = removal; kind auto-detected
(throws on UUID-space collision). New store helper generationsTouching().
- brain.transactionLog({ from, to, limit }) — INCLUSIVE generation/Date window
(contrast since's exclusive lower bound); limit applied last.
- Compaction policy locked: diff/since THROW GenerationCompactedError below the
horizon; history TRUNCATES to it.
Types DiffResult/HistoryVersion/EntityHistory exported from the package root.
Tests: tests/integration/db-temporal.test.ts (8 proofs incl. diff-earns-its-name,
history↔asOf cross-check, the composition proof, granularity, compaction contrast)
+ tests/unit/db/stableEqual.test.ts (6). docs/guides/snapshots-and-time-travel.md
+ RELEASES updated. 1477 unit + db-temporal 8 + db-mvcc 25 green.
2026-06-19 13:21:02 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* @description The committed generations in `(fromGen, toGen]` that touched a
|
|
|
|
|
|
* single id, ascending. The per-id companion to {@link changedBetween} — used
|
|
|
|
|
|
* by `history()` to walk one entity's change-points without scanning every id.
|
|
|
|
|
|
* No all-ids index: it consults the same cached per-generation deltas.
|
|
|
|
|
|
* @param kind - `'noun'` for entities, `'verb'` for relationships.
|
|
|
|
|
|
* @param id - The id to track.
|
|
|
|
|
|
* @param fromGen - Exclusive lower bound.
|
|
|
|
|
|
* @param toGen - Inclusive upper bound.
|
|
|
|
|
|
*/
|
|
|
|
|
|
async generationsTouching(
|
|
|
|
|
|
kind: 'noun' | 'verb',
|
|
|
|
|
|
id: string,
|
|
|
|
|
|
fromGen: number,
|
|
|
|
|
|
toGen: number
|
|
|
|
|
|
): Promise<number[]> {
|
|
|
|
|
|
const result: number[] = []
|
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.
2026-06-22 15:19:58 -07:00
|
|
|
|
for (const gen of this.reservedGens()) {
|
feat(8.0): temporal range verbs — diff, history, since(gen|Date), asOf{exclusive}, transactionLog window
asOf() answers "state AT a point"; these answer "what happened BETWEEN two
points" and "one entity's whole history", all on the existing generation records.
- Extract resolveAsOfGeneration() as the shared gen|Date→{generation,timestamp}
chokepoint (reachability + Date semantics identical everywhere). asOf()'s
inclusive path is byte-identical — db-mvcc still 25/25.
- asOf(target, { exclusive }) — strict-before (resolved−1, clamped at gen 0,
never a RangeError).
- db.since(Db | generation | Date) — overload; number/Date resolve via the shared
resolver (new DbHost.resolveGeneration); EXCLUSIVE lower bound;
since(db) === since(db.generation). Same-store guard via Db.belongsToStore.
- brain.diff(a, b) → { added, removed, modified } split by nouns/verbs. EARNS its
name: candidate set is ONLY changedBetween(gLow,gHigh), each classified by
existence at both endpoints + a key-order-insensitive value compare
(new src/db/stableEqual.ts) — a touched-but-reverted / born-and-died id is in
no bucket.
- brain.history(id, { from, to }) → every distinct version oldest→newest, each
value === asOf(version.generation).get(id); null = removal; kind auto-detected
(throws on UUID-space collision). New store helper generationsTouching().
- brain.transactionLog({ from, to, limit }) — INCLUSIVE generation/Date window
(contrast since's exclusive lower bound); limit applied last.
- Compaction policy locked: diff/since THROW GenerationCompactedError below the
horizon; history TRUNCATES to it.
Types DiffResult/HistoryVersion/EntityHistory exported from the package root.
Tests: tests/integration/db-temporal.test.ts (8 proofs incl. diff-earns-its-name,
history↔asOf cross-check, the composition proof, granularity, compaction contrast)
+ tests/unit/db/stableEqual.test.ts (6). docs/guides/snapshots-and-time-travel.md
+ RELEASES updated. 1477 unit + db-temporal 8 + db-mvcc 25 green.
2026-06-19 13:21:02 -07:00
|
|
|
|
if (gen <= fromGen || gen > toGen) continue
|
|
|
|
|
|
const delta = await this.getDelta(gen)
|
|
|
|
|
|
const touched = kind === 'noun' ? delta.nouns : delta.verbs
|
|
|
|
|
|
if (touched.has(id)) result.push(gen)
|
|
|
|
|
|
}
|
|
|
|
|
|
return result
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)
One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.
Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
transact() commit and once per single-operation write (storage hook), so
brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
(the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
and forces an index rebuild; refcounted pins gate compactHistory(), which
records a horizon (asOf below it throws GenerationCompactedError).
Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
(GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
{confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
generation; index-accelerated queries at historical generations throw
NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
plugin.ts: feature-detected, balanced pin/release in lockstep with Db
lifecycle, post-commit applier + replay-gap model documented.
Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.
Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
2026-06-10 14:14:07 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* @description Assert that generation `gen` is reachable (not compacted
|
|
|
|
|
|
* away) and within the committed range, then return it. Used by `asOf()`.
|
|
|
|
|
|
* Reachability boundary: reclaiming record-set `N` removes the
|
|
|
|
|
|
* before-images that reads at generations BELOW `N` depend on, so
|
|
|
|
|
|
* generations `< horizon` are unreachable while the horizon itself remains
|
|
|
|
|
|
* servable from the record-sets above it.
|
|
|
|
|
|
* @param gen - The requested generation.
|
|
|
|
|
|
* @throws GenerationCompactedError when `gen` is below the horizon.
|
|
|
|
|
|
* @throws RangeError when `gen` is negative or beyond the current counter.
|
|
|
|
|
|
*/
|
|
|
|
|
|
assertReachable(gen: number): number {
|
|
|
|
|
|
if (!Number.isInteger(gen) || gen < 0) {
|
|
|
|
|
|
throw new RangeError(`asOf(): generation must be a non-negative integer (got ${gen})`)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (gen > this.counter) {
|
|
|
|
|
|
throw new RangeError(
|
|
|
|
|
|
`asOf(): generation ${gen} is in the future (store is at generation ${this.counter})`
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (gen < this.horizonGen) {
|
|
|
|
|
|
throw new GenerationCompactedError(gen, this.horizonGen)
|
|
|
|
|
|
}
|
|
|
|
|
|
return gen
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description Resolve a wall-clock timestamp to the generation that was
|
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.
2026-06-22 15:19:58 -07:00
|
|
|
|
* committed at-or-before it, via the tx-log. Under Model-B every write —
|
|
|
|
|
|
* `transact()` AND single-op — has its own tx-log entry, so resolution is
|
|
|
|
|
|
* per-write (the tx-log includes un-flushed single-op generations too, so
|
|
|
|
|
|
* `asOf(Date)` is consistent with the rest of the temporal surface).
|
feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)
One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.
Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
transact() commit and once per single-operation write (storage hook), so
brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
(the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
and forces an index rebuild; refcounted pins gate compactHistory(), which
records a horizon (asOf below it throws GenerationCompactedError).
Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
(GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
{confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
generation; index-accelerated queries at historical generations throw
NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
plugin.ts: feature-detected, balanced pin/release in lockstep with Db
lifecycle, post-commit applier + replay-gap model documented.
Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.
Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
2026-06-10 14:14:07 -07:00
|
|
|
|
* @param timestampMs - Milliseconds since epoch.
|
|
|
|
|
|
* @returns The resolved generation (`0` when `timestampMs` predates the
|
|
|
|
|
|
* first commit) and the matched tx-log entry when one exists.
|
|
|
|
|
|
*/
|
|
|
|
|
|
async resolveTimestamp(
|
|
|
|
|
|
timestampMs: number
|
|
|
|
|
|
): Promise<{ generation: number; entry: TxLogEntry | null }> {
|
|
|
|
|
|
let best: TxLogEntry | null = null
|
feat(8.0)!: delete fork/branch/commit/history/versions — superseded by the Db API
The COW version-control surface (fork, branches, checkout, commit,
getHistory/streamHistory, asOfCommit, brain.versions) is gone, along with
its subsystems: src/versioning/, the COW object store (CommitLog,
CommitObject, RefManager, TreeObject), HistoricalStorageAdapter, and the
TypeAwareHNSWIndex path it kept alive. The Db API (now/transact/asOf/
with/persist/restore) is the one versioning model in 8.0.
Survivors and replacements:
- BlobStorage survives (the VFS stores file content through it), relocated
to src/storage/blobStorage.ts with binaryDataCodec.ts; its adapter
interface is now BlobStoreAdapter, slimmed to the consumed surface
(write/read/has/delete/getMetadata + MIME-aware compression policy).
- brain.migrate() backup branches are replaced by persist-before-migrate:
MigrateOptions.backupTo persists a hard-link snapshot of the current
generation before any transform runs; MigrationResult.backupPath reports
it, and brain.restore(path) brings it back wholesale.
- CLI: cow.ts (fork/branch/checkout/history/migrate) is replaced by
snapshot.ts — snapshot <path>, restore <path>, history (tx-log),
generation.
- New public read API: brain.transactionLog({limit}) exposes the reified
tx-log (generation/timestamp/meta, newest first) that backs the CLI
history command; TxLogEntry is exported.
Tests: superseded suites deleted; fork/commit blocks excised from shared
suites; BlobStorage tests relocated + reworked against the slimmed store;
migration tests now prove the backupTo snapshot/restore round trip; new
transactionLog coverage in db-mvcc.
2026-06-10 15:22:47 -07:00
|
|
|
|
for (const entry of await this.txLog()) {
|
|
|
|
|
|
if (entry.timestamp <= timestampMs) {
|
|
|
|
|
|
if (best === null || entry.generation > best.generation) best = entry
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return { generation: best?.generation ?? 0, entry: best }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description Read all committed tx-log entries, oldest first. Torn
|
|
|
|
|
|
* trailing lines from a crashed append are tolerated and skipped, and
|
|
|
|
|
|
* entries beyond the committed watermark are excluded — the tx-log is
|
|
|
|
|
|
* advisory metadata; the manifest rename is the source of commit truth.
|
|
|
|
|
|
* Compaction never rewrites the log, so entries may reference generations
|
|
|
|
|
|
* whose record-sets were already reclaimed.
|
|
|
|
|
|
* @returns Every committed {@link TxLogEntry}, in commit order.
|
|
|
|
|
|
*/
|
|
|
|
|
|
async txLog(): Promise<TxLogEntry[]> {
|
|
|
|
|
|
const lines = await this.storage.readTxLogLines()
|
|
|
|
|
|
const entries: TxLogEntry[] = []
|
feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)
One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.
Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
transact() commit and once per single-operation write (storage hook), so
brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
(the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
and forces an index rebuild; refcounted pins gate compactHistory(), which
records a horizon (asOf below it throws GenerationCompactedError).
Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
(GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
{confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
generation; index-accelerated queries at historical generations throw
NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
plugin.ts: feature-detected, balanced pin/release in lockstep with Db
lifecycle, post-commit applier + replay-gap model documented.
Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.
Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
2026-06-10 14:14:07 -07:00
|
|
|
|
for (const line of lines) {
|
|
|
|
|
|
let entry: TxLogEntry
|
|
|
|
|
|
try {
|
|
|
|
|
|
entry = JSON.parse(line) as TxLogEntry
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
continue // tolerate a torn trailing line from a crashed append
|
|
|
|
|
|
}
|
feat(8.0)!: delete fork/branch/commit/history/versions — superseded by the Db API
The COW version-control surface (fork, branches, checkout, commit,
getHistory/streamHistory, asOfCommit, brain.versions) is gone, along with
its subsystems: src/versioning/, the COW object store (CommitLog,
CommitObject, RefManager, TreeObject), HistoricalStorageAdapter, and the
TypeAwareHNSWIndex path it kept alive. The Db API (now/transact/asOf/
with/persist/restore) is the one versioning model in 8.0.
Survivors and replacements:
- BlobStorage survives (the VFS stores file content through it), relocated
to src/storage/blobStorage.ts with binaryDataCodec.ts; its adapter
interface is now BlobStoreAdapter, slimmed to the consumed surface
(write/read/has/delete/getMetadata + MIME-aware compression policy).
- brain.migrate() backup branches are replaced by persist-before-migrate:
MigrateOptions.backupTo persists a hard-link snapshot of the current
generation before any transform runs; MigrationResult.backupPath reports
it, and brain.restore(path) brings it back wholesale.
- CLI: cow.ts (fork/branch/checkout/history/migrate) is replaced by
snapshot.ts — snapshot <path>, restore <path>, history (tx-log),
generation.
- New public read API: brain.transactionLog({limit}) exposes the reified
tx-log (generation/timestamp/meta, newest first) that backs the CLI
history command; TxLogEntry is exported.
Tests: superseded suites deleted; fork/commit blocks excised from shared
suites; BlobStorage tests relocated + reworked against the slimmed store;
migration tests now prove the backupTo snapshot/restore round trip; new
transactionLog coverage in db-mvcc.
2026-06-10 15:22:47 -07:00
|
|
|
|
if (entry.generation <= this.committed) entries.push(entry)
|
feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)
One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.
Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
transact() commit and once per single-operation write (storage hook), so
brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
(the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
and forces an index rebuild; refcounted pins gate compactHistory(), which
records a horizon (asOf below it throws GenerationCompactedError).
Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
(GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
{confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
generation; index-accelerated queries at historical generations throw
NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
plugin.ts: feature-detected, balanced pin/release in lockstep with Db
lifecycle, post-commit applier + replay-gap model documented.
Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.
Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
2026-06-10 14:14:07 -07:00
|
|
|
|
}
|
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.
2026-06-22 15:19:58 -07:00
|
|
|
|
// Include un-flushed single-op generations so the tx-log is consistent with
|
|
|
|
|
|
// the rest of the temporal surface (`since`/`diff`/`asOf`/`changedBetween`
|
|
|
|
|
|
// already resolve over the pending tier). Reads are thus flush-agnostic —
|
|
|
|
|
|
// whether the async group-commit has run yet never changes results, only
|
|
|
|
|
|
// crash durability. Pending single-ops are always newer than every
|
|
|
|
|
|
// committed generation, so they extend the ascending list.
|
|
|
|
|
|
for (const gen of this.pendingGens) {
|
|
|
|
|
|
const buf = this.pendingBuffer.get(gen)
|
|
|
|
|
|
if (buf) entries.push({ generation: gen, timestamp: buf.timestamp })
|
|
|
|
|
|
}
|
feat(8.0)!: delete fork/branch/commit/history/versions — superseded by the Db API
The COW version-control surface (fork, branches, checkout, commit,
getHistory/streamHistory, asOfCommit, brain.versions) is gone, along with
its subsystems: src/versioning/, the COW object store (CommitLog,
CommitObject, RefManager, TreeObject), HistoricalStorageAdapter, and the
TypeAwareHNSWIndex path it kept alive. The Db API (now/transact/asOf/
with/persist/restore) is the one versioning model in 8.0.
Survivors and replacements:
- BlobStorage survives (the VFS stores file content through it), relocated
to src/storage/blobStorage.ts with binaryDataCodec.ts; its adapter
interface is now BlobStoreAdapter, slimmed to the consumed surface
(write/read/has/delete/getMetadata + MIME-aware compression policy).
- brain.migrate() backup branches are replaced by persist-before-migrate:
MigrateOptions.backupTo persists a hard-link snapshot of the current
generation before any transform runs; MigrationResult.backupPath reports
it, and brain.restore(path) brings it back wholesale.
- CLI: cow.ts (fork/branch/checkout/history/migrate) is replaced by
snapshot.ts — snapshot <path>, restore <path>, history (tx-log),
generation.
- New public read API: brain.transactionLog({limit}) exposes the reified
tx-log (generation/timestamp/meta, newest first) that backs the CLI
history command; TxLogEntry is exported.
Tests: superseded suites deleted; fork/commit blocks excised from shared
suites; BlobStorage tests relocated + reworked against the slimmed store;
migration tests now prove the backupTo snapshot/restore round trip; new
transactionLog coverage in db-mvcc.
2026-06-10 15:22:47 -07:00
|
|
|
|
return entries
|
feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)
One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.
Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
transact() commit and once per single-operation write (storage hook), so
brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
(the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
and forces an index rebuild; refcounted pins gate compactHistory(), which
records a horizon (asOf below it throws GenerationCompactedError).
Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
(GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
{confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
generation; index-accelerated queries at historical generations throw
NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
plugin.ts: feature-detected, balanced pin/release in lockstep with Db
lifecycle, post-commit applier + replay-gap model documented.
Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.
Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
2026-06-10 14:14:07 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description The commit timestamp of the newest committed generation at
|
|
|
|
|
|
* or below `gen`, when its record-set is still resident. Used to populate
|
|
|
|
|
|
* `Db.timestamp` for `asOf()` values.
|
|
|
|
|
|
* @param gen - The pinned generation.
|
|
|
|
|
|
*/
|
|
|
|
|
|
async commitTimestampAtOrBefore(gen: number): Promise<number | null> {
|
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.
2026-06-22 15:19:58 -07:00
|
|
|
|
// reservedGens (committed ∪ pending) is sorted ascending — binary-search
|
|
|
|
|
|
// the largest reserved generation ≤ gen (O(log n), not an O(database-age)
|
|
|
|
|
|
// backward scan). Pending single-op generations carry timestamps too.
|
|
|
|
|
|
const candidate = largestAtOrBefore(this.reservedGens(), gen)
|
2026-06-22 13:47:33 -07:00
|
|
|
|
if (candidate === undefined) return null
|
|
|
|
|
|
const delta = await this.getDelta(candidate)
|
|
|
|
|
|
return delta.timestamp
|
feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)
One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.
Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
transact() commit and once per single-operation write (storage hook), so
brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
(the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
and forces an index rebuild; refcounted pins gate compactHistory(), which
records a horizon (asOf below it throws GenerationCompactedError).
Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
(GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
{confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
generation; index-accelerated queries at historical generations throw
NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
plugin.ts: feature-detected, balanced pin/release in lockstep with Db
lifecycle, post-commit applier + replay-gap model documented.
Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.
Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
2026-06-10 14:14:07 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private async getDelta(
|
|
|
|
|
|
gen: number
|
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.
2026-06-22 15:19:58 -07:00
|
|
|
|
): Promise<{ nouns: Set<string>; verbs: Set<string>; timestamp: number; bytes: number }> {
|
feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)
One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.
Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
transact() commit and once per single-operation write (storage hook), so
brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
(the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
and forces an index rebuild; refcounted pins gate compactHistory(), which
records a horizon (asOf below it throws GenerationCompactedError).
Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
(GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
{confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
generation; index-accelerated queries at historical generations throw
NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
plugin.ts: feature-detected, balanced pin/release in lockstep with Db
lifecycle, post-commit applier + replay-gap model documented.
Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.
Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
2026-06-10 14:14:07 -07:00
|
|
|
|
const cached = this.deltaCache.get(gen)
|
|
|
|
|
|
if (cached) return cached
|
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.
2026-06-22 15:19:58 -07:00
|
|
|
|
// A pending (un-flushed) generation has no tx.json on disk yet — derive its
|
|
|
|
|
|
// delta from the in-memory before-image buffer. Not cached: the bounded
|
|
|
|
|
|
// cache is for the disk path; the buffer is the source of truth here.
|
|
|
|
|
|
// bytes = 0: pending generations are not on disk, so they do not count
|
|
|
|
|
|
// toward the on-disk history byte budget ({@link historyBytes}).
|
|
|
|
|
|
const pending = this.pendingBuffer.get(gen)
|
|
|
|
|
|
if (pending) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
nouns: new Set(pending.nouns.keys()),
|
|
|
|
|
|
verbs: new Set(pending.verbs.keys()),
|
|
|
|
|
|
timestamp: pending.timestamp,
|
|
|
|
|
|
bytes: 0
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)
One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.
Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
transact() commit and once per single-operation write (storage hook), so
brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
(the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
and forces an index rebuild; refcounted pins gate compactHistory(), which
records a horizon (asOf below it throws GenerationCompactedError).
Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
(GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
{confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
generation; index-accelerated queries at historical generations throw
NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
plugin.ts: feature-detected, balanced pin/release in lockstep with Db
lifecycle, post-commit applier + replay-gap model documented.
Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.
Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
2026-06-10 14:14:07 -07:00
|
|
|
|
const delta = (await this.storage.readRawObject(
|
|
|
|
|
|
`${GENERATIONS_PREFIX}/${gen}/tx.json`
|
|
|
|
|
|
)) as GenerationDelta | null
|
|
|
|
|
|
if (delta === null) {
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
`Generation delta missing: ${GENERATIONS_PREFIX}/${gen}/tx.json ` +
|
|
|
|
|
|
`(store corrupted or records removed outside compactHistory())`
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
const entry = {
|
|
|
|
|
|
nouns: new Set(delta.nouns),
|
|
|
|
|
|
verbs: new Set(delta.verbs),
|
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.
2026-06-22 15:19:58 -07:00
|
|
|
|
timestamp: delta.timestamp,
|
|
|
|
|
|
bytes: delta.bytes ?? 0
|
feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)
One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.
Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
transact() commit and once per single-operation write (storage hook), so
brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
(the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
and forces an index rebuild; refcounted pins gate compactHistory(), which
records a horizon (asOf below it throws GenerationCompactedError).
Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
(GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
{confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
generation; index-accelerated queries at historical generations throw
NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
plugin.ts: feature-detected, balanced pin/release in lockstep with Db
lifecycle, post-commit applier + replay-gap model documented.
Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.
Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
2026-06-10 14:14:07 -07:00
|
|
|
|
}
|
2026-06-22 13:47:33 -07:00
|
|
|
|
this.setDelta(gen, entry)
|
feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)
One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.
Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
transact() commit and once per single-operation write (storage hook), so
brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
(the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
and forces an index rebuild; refcounted pins gate compactHistory(), which
records a horizon (asOf below it throws GenerationCompactedError).
Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
(GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
{confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
generation; index-accelerated queries at historical generations throw
NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
plugin.ts: feature-detected, balanced pin/release in lockstep with Db
lifecycle, post-commit applier + replay-gap model documented.
Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.
Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
2026-06-10 14:14:07 -07:00
|
|
|
|
return entry
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-22 13:47:33 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* @description Insert a delta into {@link deltaCache}, evicting the oldest
|
|
|
|
|
|
* entries (FIFO, by insertion order) past {@link deltaCacheMax}. Eviction is
|
|
|
|
|
|
* safe: {@link getDelta} re-reads any evicted delta from storage on demand.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private setDelta(
|
|
|
|
|
|
gen: number,
|
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.
2026-06-22 15:19:58 -07:00
|
|
|
|
entry: { nouns: Set<string>; verbs: Set<string>; timestamp: number; bytes: number }
|
2026-06-22 13:47:33 -07:00
|
|
|
|
): void {
|
|
|
|
|
|
this.deltaCache.set(gen, entry)
|
|
|
|
|
|
while (this.deltaCache.size > this.deltaCacheMax) {
|
|
|
|
|
|
const oldest = this.deltaCache.keys().next().value
|
|
|
|
|
|
if (oldest === undefined) break
|
|
|
|
|
|
this.deltaCache.delete(oldest)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)
One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.
Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
transact() commit and once per single-operation write (storage hook), so
brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
(the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
and forces an index rebuild; refcounted pins gate compactHistory(), which
records a horizon (asOf below it throws GenerationCompactedError).
Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
(GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
{confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
generation; index-accelerated queries at historical generations throw
NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
plugin.ts: feature-detected, balanced pin/release in lockstep with Db
lifecycle, post-commit applier + replay-gap model documented.
Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.
Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
2026-06-10 14:14:07 -07:00
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
// Compaction
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
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.
2026-06-22 15:19:58 -07:00
|
|
|
|
* @description Total serialized bytes of the ON-DISK generational history —
|
|
|
|
|
|
* the sum of every committed generation's recorded `bytes`. Backs the
|
|
|
|
|
|
* `maxBytes` and adaptive retention caps. Reads each committed generation's
|
|
|
|
|
|
* delta (cached; a re-read only for cache-evicted ones) — O(committed
|
|
|
|
|
|
* generations), bounded by retention itself and invoked only at compaction
|
|
|
|
|
|
* time. Pending (un-flushed) generations are excluded (they are not on disk).
|
|
|
|
|
|
* @returns The total on-disk history byte count.
|
|
|
|
|
|
*/
|
|
|
|
|
|
async historyBytes(): Promise<number> {
|
|
|
|
|
|
let total = 0
|
|
|
|
|
|
for (const gen of this.committedGens) {
|
|
|
|
|
|
total += (await this.getDelta(gen)).bytes
|
|
|
|
|
|
}
|
|
|
|
|
|
return total
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description Reclaim generation record-sets per the retention CAPS. A
|
|
|
|
|
|
* generation is removed only when it is at or below **every** live pin
|
|
|
|
|
|
* (deleting `N` can only break readers pinned *below* `N`, because resolution
|
|
|
|
|
|
* reads before-images from generations strictly greater than the pin) — pins
|
|
|
|
|
|
* are ALWAYS exempt. Among the unpinned generations, the OLDEST are reclaimed
|
|
|
|
|
|
* while **ANY** supplied cap is exceeded:
|
|
|
|
|
|
*
|
|
|
|
|
|
* - `maxGenerations` — reclaim while the committed-generation count exceeds it.
|
|
|
|
|
|
* - `maxAge` — reclaim generations older than `now − maxAge`.
|
|
|
|
|
|
* - `maxBytes` — reclaim while total on-disk history bytes exceed it.
|
feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)
One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.
Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
transact() commit and once per single-operation write (storage hook), so
brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
(the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
and forces an index rebuild; refcounted pins gate compactHistory(), which
records a horizon (asOf below it throws GenerationCompactedError).
Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
(GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
{confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
generation; index-accelerated queries at historical generations throw
NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
plugin.ts: feature-detected, balanced pin/release in lockstep with Db
lifecycle, post-commit applier + replay-gap model documented.
Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.
Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
2026-06-10 14:14:07 -07:00
|
|
|
|
*
|
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.
2026-06-22 15:19:58 -07:00
|
|
|
|
* Because reclamation is oldest-first and every cap is monotone in age, once
|
|
|
|
|
|
* the oldest unpinned generation violates no cap, no newer one does either, so
|
|
|
|
|
|
* the scan stops. With no options, every unpinned generation is reclaimed.
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param options - Retention caps (see {@link CompactHistoryOptions}).
|
feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)
One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.
Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
transact() commit and once per single-operation write (storage hook), so
brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
(the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
and forces an index rebuild; refcounted pins gate compactHistory(), which
records a horizon (asOf below it throws GenerationCompactedError).
Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
(GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
{confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
generation; index-accelerated queries at historical generations throw
NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
plugin.ts: feature-detected, balanced pin/release in lockstep with Db
lifecycle, post-commit applier + replay-gap model documented.
Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.
Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
2026-06-10 14:14:07 -07:00
|
|
|
|
* @returns Count of removed record-sets and the new horizon.
|
|
|
|
|
|
*/
|
|
|
|
|
|
async compact(options?: CompactHistoryOptions): Promise<CompactHistoryResult> {
|
|
|
|
|
|
return this.withMutex(async () => {
|
|
|
|
|
|
const minPinned = this.minPinnedGeneration()
|
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.
2026-06-22 15:19:58 -07:00
|
|
|
|
const maxGenerations = options?.maxGenerations
|
|
|
|
|
|
const maxAge = options?.maxAge
|
|
|
|
|
|
const maxBytes = options?.maxBytes
|
|
|
|
|
|
const ageCutoff = maxAge !== undefined ? Date.now() - maxAge : undefined
|
|
|
|
|
|
const noCaps =
|
|
|
|
|
|
maxGenerations === undefined && maxAge === undefined && maxBytes === undefined
|
|
|
|
|
|
|
|
|
|
|
|
// Running totals the caps are evaluated against; updated as we reclaim.
|
|
|
|
|
|
let remainingCount = this.committedGens.length
|
|
|
|
|
|
let remainingBytes = maxBytes !== undefined ? await this.historyBytes() : 0
|
feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)
One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.
Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
transact() commit and once per single-operation write (storage hook), so
brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
(the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
and forces an index rebuild; refcounted pins gate compactHistory(), which
records a horizon (asOf below it throws GenerationCompactedError).
Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
(GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
{confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
generation; index-accelerated queries at historical generations throw
NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
plugin.ts: feature-detected, balanced pin/release in lockstep with Db
lifecycle, post-commit applier + replay-gap model documented.
Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.
Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
2026-06-10 14:14:07 -07:00
|
|
|
|
|
|
|
|
|
|
const removed: number[] = []
|
|
|
|
|
|
for (const gen of [...this.committedGens]) {
|
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.
2026-06-22 15:19:58 -07:00
|
|
|
|
// Pins are always exempt: never reclaim a generation a live pin needs.
|
|
|
|
|
|
if (gen > minPinned) break // committedGens ascending → nothing newer is eligible either
|
|
|
|
|
|
if (!noCaps) {
|
|
|
|
|
|
const violatesCount = maxGenerations !== undefined && remainingCount > maxGenerations
|
|
|
|
|
|
const violatesBytes = maxBytes !== undefined && remainingBytes > maxBytes
|
|
|
|
|
|
let violatesAge = false
|
|
|
|
|
|
if (ageCutoff !== undefined) {
|
|
|
|
|
|
const delta = await this.getDelta(gen)
|
|
|
|
|
|
violatesAge = delta.timestamp < ageCutoff
|
|
|
|
|
|
}
|
|
|
|
|
|
// Oldest-first: once the oldest unpinned gen trips no cap, none newer do.
|
|
|
|
|
|
if (!violatesCount && !violatesBytes && !violatesAge) break
|
feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)
One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.
Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
transact() commit and once per single-operation write (storage hook), so
brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
(the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
and forces an index rebuild; refcounted pins gate compactHistory(), which
records a horizon (asOf below it throws GenerationCompactedError).
Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
(GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
{confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
generation; index-accelerated queries at historical generations throw
NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
plugin.ts: feature-detected, balanced pin/release in lockstep with Db
lifecycle, post-commit applier + replay-gap model documented.
Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.
Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
2026-06-10 14:14:07 -07:00
|
|
|
|
}
|
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.
2026-06-22 15:19:58 -07:00
|
|
|
|
const genBytes = maxBytes !== undefined ? (await this.getDelta(gen)).bytes : 0
|
feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)
One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.
Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
transact() commit and once per single-operation write (storage hook), so
brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
(the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
and forces an index rebuild; refcounted pins gate compactHistory(), which
records a horizon (asOf below it throws GenerationCompactedError).
Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
(GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
{confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
generation; index-accelerated queries at historical generations throw
NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
plugin.ts: feature-detected, balanced pin/release in lockstep with Db
lifecycle, post-commit applier + replay-gap model documented.
Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.
Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
2026-06-10 14:14:07 -07:00
|
|
|
|
await this.storage.removeRawPrefix(`${GENERATIONS_PREFIX}/${gen}`)
|
|
|
|
|
|
this.deltaCache.delete(gen)
|
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.
2026-06-22 15:19:58 -07:00
|
|
|
|
remainingCount--
|
|
|
|
|
|
remainingBytes -= genBytes
|
feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)
One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.
Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
transact() commit and once per single-operation write (storage hook), so
brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
(the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
and forces an index rebuild; refcounted pins gate compactHistory(), which
records a horizon (asOf below it throws GenerationCompactedError).
Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
(GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
{confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
generation; index-accelerated queries at historical generations throw
NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
plugin.ts: feature-detected, balanced pin/release in lockstep with Db
lifecycle, post-commit applier + replay-gap model documented.
Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.
Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
2026-06-10 14:14:07 -07:00
|
|
|
|
removed.push(gen)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (removed.length > 0) {
|
|
|
|
|
|
const removedSet = new Set(removed)
|
|
|
|
|
|
this.committedGens = this.committedGens.filter((gen) => !removedSet.has(gen))
|
2026-06-22 13:47:33 -07:00
|
|
|
|
// Reclaimed generations leave the per-id chains stale → rebuild on next read.
|
|
|
|
|
|
this.invalidateChains()
|
feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)
One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.
Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
transact() commit and once per single-operation write (storage hook), so
brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
(the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
and forces an index rebuild; refcounted pins gate compactHistory(), which
records a horizon (asOf below it throws GenerationCompactedError).
Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
(GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
{confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
generation; index-accelerated queries at historical generations throw
NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
plugin.ts: feature-detected, balanced pin/release in lockstep with Db
lifecycle, post-commit applier + replay-gap model documented.
Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.
Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
2026-06-10 14:14:07 -07:00
|
|
|
|
this.horizonGen = Math.max(this.horizonGen, ...removed)
|
|
|
|
|
|
const manifest: GenerationManifest = {
|
|
|
|
|
|
version: 1,
|
|
|
|
|
|
generation: this.committed,
|
|
|
|
|
|
committedAt: new Date().toISOString(),
|
|
|
|
|
|
horizon: this.horizonGen
|
|
|
|
|
|
}
|
|
|
|
|
|
await this.storage.writeRawObject(MANIFEST_PATH, manifest)
|
|
|
|
|
|
await this.storage.syncRawObjects([MANIFEST_PATH])
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return { removedGenerations: removed.length, horizon: this.horizonGen }
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
// Restore support
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description Re-read all persisted state after `brain.restore()`
|
|
|
|
|
|
* replaced the store's contents from a snapshot, enforcing counter
|
|
|
|
|
|
* monotonicity: the counter never moves below `floorGeneration` (the
|
|
|
|
|
|
* pre-restore counter), so generation numbers observed before a restore
|
|
|
|
|
|
* are never reissued.
|
|
|
|
|
|
* @param floorGeneration - The counter value before the restore.
|
|
|
|
|
|
*/
|
|
|
|
|
|
async reopenAfterRestore(floorGeneration: number): Promise<void> {
|
|
|
|
|
|
await this.withMutex(async () => {
|
|
|
|
|
|
this.deltaCache.clear()
|
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.
2026-06-22 15:19:58 -07:00
|
|
|
|
// A wholesale state replacement invalidates any buffered single-op
|
|
|
|
|
|
// history — discard the pending tier (its live writes are gone with the
|
|
|
|
|
|
// replaced store).
|
|
|
|
|
|
this.clearPendingFlushTimer()
|
|
|
|
|
|
this.pendingGens = []
|
|
|
|
|
|
this.pendingBuffer.clear()
|
feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)
One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.
Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
transact() commit and once per single-operation write (storage hook), so
brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
(the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
and forces an index rebuild; refcounted pins gate compactHistory(), which
records a horizon (asOf below it throws GenerationCompactedError).
Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
(GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
{confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
generation; index-accelerated queries at historical generations throw
NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
plugin.ts: feature-detected, balanced pin/release in lockstep with Db
lifecycle, post-commit applier + replay-gap model documented.
Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.
Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
2026-06-10 14:14:07 -07:00
|
|
|
|
this.opened = false
|
|
|
|
|
|
// open() re-reads counter/manifest and re-registers the bump hook.
|
|
|
|
|
|
await this.open()
|
|
|
|
|
|
if (this.counter < floorGeneration) {
|
|
|
|
|
|
this.counter = floorGeneration
|
|
|
|
|
|
await this.persistCounterUnlocked()
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
// Internals
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
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.
2026-06-22 15:19:58 -07:00
|
|
|
|
* Recover one uncommitted (crashed) generation, branching on whether it is a
|
|
|
|
|
|
* single-op group-commit generation (drop-without-restore) or a `transact()`
|
|
|
|
|
|
* generation (restore before-images). The decision reads the generation's
|
|
|
|
|
|
* `tx.json` `groupCommit` flag:
|
|
|
|
|
|
*
|
|
|
|
|
|
* - **`groupCommit: true` → DROP** the directory, never restore. The single-op
|
|
|
|
|
|
* write already mutated canonical storage and was acknowledged to the caller
|
|
|
|
|
|
* before the flush; restoring its before-images would silently revert an
|
|
|
|
|
|
* acknowledged user write (the data-corruption trap). Only the *history* of
|
|
|
|
|
|
* the crashed flush window is lost — live data is correct as-is.
|
|
|
|
|
|
* - **absent/`false` (a transact generation) → RESTORE** then drop (the
|
|
|
|
|
|
* existing atomic-rollback path).
|
|
|
|
|
|
* - **`tx.json` unreadable/missing → DROP** (safe for both): a transact
|
|
|
|
|
|
* fsyncs `tx.json` BEFORE it executes, so an unreadable delta means the
|
|
|
|
|
|
* execute never ran → live storage is unchanged → dropping is a no-op on
|
|
|
|
|
|
* live state, while a partial group-commit must be dropped anyway.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private async recoverUncommittedGeneration(gen: number): Promise<void> {
|
|
|
|
|
|
const dir = `${GENERATIONS_PREFIX}/${gen}`
|
|
|
|
|
|
const delta = (await this.storage.readRawObject(`${dir}/tx.json`)) as GenerationDelta | null
|
|
|
|
|
|
if (delta === null || delta.groupCommit === true) {
|
|
|
|
|
|
// Drop-without-restore (group-commit, or an indeterminate partial dir).
|
|
|
|
|
|
await this.storage.removeRawPrefix(dir)
|
|
|
|
|
|
prodLog.warn(
|
|
|
|
|
|
`[GenerationStore] dropped uncommitted ${
|
|
|
|
|
|
delta === null ? 'indeterminate' : 'group-commit'
|
|
|
|
|
|
} generation ${gen} (live data left intact; history of that flush window discarded)`
|
|
|
|
|
|
)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
await this.rollBackUncommittedGeneration(gen)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Restore the before-images of an uncommitted `transact()` generation to the
|
|
|
|
|
|
* canonical entity paths, then remove its record directory. Restores are
|
|
|
|
|
|
* idempotent (writing identical bytes / deleting already-absent files), so
|
|
|
|
|
|
* recovery is safe at every crash point of the transact commit protocol.
|
feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)
One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.
Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
transact() commit and once per single-operation write (storage hook), so
brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
(the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
and forces an index rebuild; refcounted pins gate compactHistory(), which
records a horizon (asOf below it throws GenerationCompactedError).
Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
(GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
{confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
generation; index-accelerated queries at historical generations throw
NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
plugin.ts: feature-detected, balanced pin/release in lockstep with Db
lifecycle, post-commit applier + replay-gap model documented.
Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.
Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
2026-06-10 14:14:07 -07:00
|
|
|
|
*/
|
|
|
|
|
|
private async rollBackUncommittedGeneration(gen: number): Promise<void> {
|
|
|
|
|
|
const dir = `${GENERATIONS_PREFIX}/${gen}`
|
|
|
|
|
|
const prevPaths = await this.storage.listRawObjects(`${dir}/prev`)
|
|
|
|
|
|
for (const recordPath of prevPaths) {
|
|
|
|
|
|
const id = recordIdFromPath(recordPath)
|
|
|
|
|
|
if (id === null) continue
|
|
|
|
|
|
const record = (await this.storage.readRawObject(recordPath)) as GenerationRecord | null
|
|
|
|
|
|
if (record === null) continue
|
|
|
|
|
|
const image = { metadata: record.metadata, vector: record.vector }
|
|
|
|
|
|
if (record.kind === 'verb') {
|
|
|
|
|
|
await this.storage.writeVerbRaw(id, image)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
await this.storage.writeNounRaw(id, image)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
await this.storage.removeRawPrefix(dir)
|
|
|
|
|
|
prodLog.warn(
|
|
|
|
|
|
`[GenerationStore] rolled back uncommitted generation ${gen} ` +
|
|
|
|
|
|
`(${prevPaths.length} record(s) restored)`
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description Run a snapshot section serialized against transact commits,
|
|
|
|
|
|
* compaction, and counter persistence (the store's single commit mutex),
|
|
|
|
|
|
* with the generation counter durably persisted first. Used by
|
|
|
|
|
|
* `db.persist()`: the lock guarantees a snapshot can never interleave with
|
|
|
|
|
|
* a commit's record-write/manifest-rename sequence, and the up-front
|
|
|
|
|
|
* counter persist guarantees the snapshot's `_system/generation.json`
|
|
|
|
|
|
* reflects every single-operation bump (whose persistence is otherwise
|
|
|
|
|
|
* coalesced and may still be scheduled).
|
|
|
|
|
|
* @param fn - The exclusive snapshot section.
|
|
|
|
|
|
* @returns `fn`'s result.
|
|
|
|
|
|
*/
|
|
|
|
|
|
snapshotWith<R>(fn: () => Promise<R>): Promise<R> {
|
|
|
|
|
|
return this.withMutex(async () => {
|
|
|
|
|
|
await this.persistCounterUnlocked()
|
|
|
|
|
|
return fn()
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Run `fn` exclusively, chained behind every prior exclusive section. */
|
|
|
|
|
|
private withMutex<R>(fn: () => Promise<R>): Promise<R> {
|
|
|
|
|
|
const run = this.mutexTail.then(fn, fn)
|
|
|
|
|
|
// Keep the chain alive regardless of fn's outcome.
|
|
|
|
|
|
this.mutexTail = run.catch(() => {})
|
|
|
|
|
|
return run
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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.
2026-06-22 15:19:58 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* @description Approximate serialized byte size of a record or delta, for the
|
|
|
|
|
|
* Model-B retention byte caps (`maxBytes` / adaptive). The JSON string length
|
|
|
|
|
|
* (chars ≈ bytes) is a deliberately cheap soft estimate — retention is a budget,
|
|
|
|
|
|
* not exact accounting — and avoids a storage size API. Returns 0 on any
|
|
|
|
|
|
* serialization failure (e.g. a cyclic structure, which records never are).
|
|
|
|
|
|
* @param obj - The record or delta about to be persisted.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function serializedBytes(obj: unknown): number {
|
|
|
|
|
|
try {
|
|
|
|
|
|
return JSON.stringify(obj)?.length ?? 0
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return 0
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)
One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.
Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
transact() commit and once per single-operation write (storage hook), so
brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
(the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
and forces an index rebuild; refcounted pins gate compactHistory(), which
records a horizon (asOf below it throws GenerationCompactedError).
Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
(GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
{confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
generation; index-accelerated queries at historical generations throw
NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
plugin.ts: feature-detected, balanced pin/release in lockstep with Db
lifecycle, post-commit applier + replay-gap model documented.
Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.
Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
2026-06-10 14:14:07 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* @description Extract the generation number from a record path of the form
|
|
|
|
|
|
* `_generations/<N>/...`. Returns `null` for paths that don't match.
|
|
|
|
|
|
* @param path - A storage-root-relative object path.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function parseGenerationFromPath(path: string): number | null {
|
|
|
|
|
|
const match = /^_generations[/\\](\d+)[/\\]/.exec(path)
|
|
|
|
|
|
if (!match) return null
|
|
|
|
|
|
const gen = Number(match[1])
|
|
|
|
|
|
return Number.isSafeInteger(gen) ? gen : null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description Extract the record id (file basename without `.json`) from a
|
|
|
|
|
|
* `prev/` before-image record path. Returns `null` for non-record paths.
|
|
|
|
|
|
* @param path - A storage-root-relative object path.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function recordIdFromPath(path: string): string | null {
|
|
|
|
|
|
const match = /[/\\]prev[/\\]([^/\\]+)\.json$/.exec(path)
|
|
|
|
|
|
return match ? match[1] : null
|
|
|
|
|
|
}
|
2026-06-22 13:47:33 -07:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description Append `gen` to an id's ascending history chain in `chains`,
|
|
|
|
|
|
* creating the chain on first touch. Callers append in ascending generation
|
|
|
|
|
|
* order, so the chain stays sorted without a re-sort.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function appendToChain(chains: Map<string, number[]>, id: string, gen: number): void {
|
|
|
|
|
|
const chain = chains.get(id)
|
|
|
|
|
|
if (chain === undefined) {
|
|
|
|
|
|
chains.set(id, [gen])
|
|
|
|
|
|
} else if (chain[chain.length - 1] !== gen) {
|
|
|
|
|
|
chain.push(gen)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description Binary-search an ascending generation chain for the FIRST entry
|
|
|
|
|
|
* strictly greater than `gen` (the generation whose before-image holds the
|
|
|
|
|
|
* value as of `gen`). Returns `undefined` when every entry is ≤ `gen`. O(log n).
|
|
|
|
|
|
*/
|
|
|
|
|
|
function firstGenerationAfter(chain: number[], gen: number): number | undefined {
|
|
|
|
|
|
let lo = 0
|
|
|
|
|
|
let hi = chain.length // upper-bound search over [lo, hi)
|
|
|
|
|
|
while (lo < hi) {
|
|
|
|
|
|
const mid = (lo + hi) >>> 1
|
|
|
|
|
|
if (chain[mid] > gen) hi = mid
|
|
|
|
|
|
else lo = mid + 1
|
|
|
|
|
|
}
|
|
|
|
|
|
return lo < chain.length ? chain[lo] : undefined
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description Binary-search an ascending list for the LARGEST entry less than
|
|
|
|
|
|
* or equal to `gen`. Returns `undefined` when every entry is greater. O(log n).
|
|
|
|
|
|
*/
|
|
|
|
|
|
function largestAtOrBefore(sorted: number[], gen: number): number | undefined {
|
|
|
|
|
|
let lo = 0
|
|
|
|
|
|
let hi = sorted.length // first index whose value is > gen
|
|
|
|
|
|
while (lo < hi) {
|
|
|
|
|
|
const mid = (lo + hi) >>> 1
|
|
|
|
|
|
if (sorted[mid] <= gen) lo = mid + 1
|
|
|
|
|
|
else hi = mid
|
|
|
|
|
|
}
|
|
|
|
|
|
return lo > 0 ? sorted[lo - 1] : undefined
|
|
|
|
|
|
}
|