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'
|
2026-07-13 09:31:25 -07:00
|
|
|
|
import { GenerationCompactedError, GenerationConflictError, PendingFlushDurabilityError, StoreInconsistentError } from './errors.js'
|
fix: honest response when a transaction rollback cannot complete
When a transaction failed and its rollback ALSO failed to undo a canonical
write (retries exhausted), the old path logged, continued, threw
TransactionRollbackError, and set state='rolled_back' — while the record it
could not undo stayed durable on disk. The caller got an error implying the
write was undone; a read-back showed the record. A failed rollback had no
truthful response (the post-commit response lie).
Give a failed rollback a two-branch honest contract, decided by observation:
both commit paths already hold byte-identical before-images, so after a failed
rollback the store compares current canonical state to them and classifies each
touched record as reconciled, an additive orphan (present when it should be
gone), or a restorative loss (gone/wrong when it should have been restored).
- Adopt-forward: a single-op write whose only damage is a durably-present
orphan — the record the caller asked for — is committed forward (its
generation buffered) and returns success with a loud warning that the derived
index may be incomplete for that id until the next rebuild/repairIndex(). No
error, no double-write; the record is durable and get-able immediately.
- Fail loud + quarantine: a multi-op batch, or ANY restorative loss, throws the
new StoreInconsistentError naming every unreconciled record and its
disposition, and puts the brain into write-quarantine (reads keep working,
writes refused via assertWritable) until repairIndex() reconciles the derived
indexes against canonical and lifts it. The counter is not advanced, and
Transaction.rollback now reports state 'inconsistent' instead of the
'rolled_back' lie when any undo failed.
New: StoreInconsistentError + UnreconciledRecord exported from the root;
repairIndex() forces a rebuild and clears the quarantine. Regression
tests/integration/rollback-trapdoor.test.ts injects index-add-throws +
canonical-delete-undo-fails and pins adopt-forward (durable, get-able, not
quarantined), fail-loud (StoreInconsistentError + quarantine + reads work +
repairIndex lifts it), and the error's record naming.
2026-07-12 12:19:09 -07:00
|
|
|
|
import type { UnreconciledRecord } from './errors.js'
|
|
|
|
|
|
import { TransactionRollbackError } from '../transaction/errors.js'
|
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
|
|
|
|
import type {
|
|
|
|
|
|
ChangedIds,
|
|
|
|
|
|
CompactHistoryOptions,
|
|
|
|
|
|
CompactHistoryResult,
|
|
|
|
|
|
GenerationDelta,
|
|
|
|
|
|
GenerationManifest,
|
|
|
|
|
|
GenerationRecord,
|
|
|
|
|
|
GenerationStorage,
|
|
|
|
|
|
TxLogEntry
|
|
|
|
|
|
} from './types.js'
|
fix(log): acked writes survive power loss; rejected writes never silently commit — the kill-matrix goes 11/11 with zero .fails debt
Two release-blocking findings from the durability kill-matrix, both fixed
in the owning layer:
1. LOG-AUTHORITY REPLAY AT OPEN: durable-at-ack fsynced the fact before
the ack, but open() truncated every fact above the manifest — after a
power loss that takes the un-fsynced tmp+rename canonical bytes, the
acked write's ONLY durable copy was discarded. Now: under 'log'
authority, open() REPLAYS intact facts above the manifest into
canonical (FactLog.peekFactsAbove — CRC-gated, order-sorted) and
advances the manifest to cover them; tree-authority brains keep the
truncate contract they were promised. Pinned end to end: the power-loss
row constructs the exact disk state (fsynced log, vanished canonical
rename) and the acked write lives.
2. NO SILENT COMMIT: commitSingleOp buffered the generation BEFORE the
fact append; an append failure (ENOSPC) rejected the caller but the
next flush durably committed the generation with NO fact — a permanent
silent log gap. Now the failure path un-buffers and returns the counter
reservation: nothing commits, the log stays gap-free, and the canonical
execute-residue orphan is the documented crash-equivalent.
Plus: the kill-matrix itself (11 rows — every commit-path fault point ×
reopen-as-crash recovery contract, at-ack variants, disk-full row; five
new zero-cost faultPoint sites), the log-authority pin suite (oracle
green/red/state-differs, flip refusal, switch survives reopen, 9/9), and
the group-commit covering pins (5/5).
Gates: unit 2002/2002 (152 files) · integration 785 · conformance 27/27.
2026-08-10 09:29:21 -07:00
|
|
|
|
import { readLogAuthority } from './logAuthority.js'
|
2026-08-10 10:55:11 -07:00
|
|
|
|
import {
|
|
|
|
|
|
FactLog,
|
|
|
|
|
|
storageSupportsFactLog,
|
|
|
|
|
|
type CommitFact,
|
|
|
|
|
|
type FactOp,
|
feat(embedding): deferred-embed markers become log records — the sidecar recovery path is deleted
The private recovery discipline, applied to its own machinery: pending-
embed markers stop being sidecar files and become first-class log records
riding the write's OWN commit fact — embed.pending lands in the same
atomic append as its after-image (a marker can never be orphaned from its
write, or vice versa; in durable-at-ack mode it shares the write's
covering fsync — zero extra syncs), and the worker's landing commit rides
embed.landed with the inline vector. Crash recovery is now a FOLD of the
log (pending without a matching landed = recovered), skipped wholesale on
brains with no v2 history; the one-time legacy bridge folds existing
sidecar files in, migrates them as one fact, and deletes them —
idempotent under a crash mid-bridge. No code path writes the sidecar
again.
Plus the ENTITY-TRUTH digest law, found by this train's own pins:
canonical vector wrappers denormalize HNSW residue (connections + the
randomly-assigned node level) that the log deliberately does not carry —
the verification oracle digested it and would have reported false
state-differs on ~any nonzero-level node (a ~15% flake in the cutover pin
was the symptom). Both sides of every oracle comparison now normalize to
entity truth (nounEntityTruth); index residue has its own rebuild path
and is not entity state.
Pins: embed-markers-in-log 5/5 (same-generation marker, landed+fold-to-
zero, crash recovery via the log with the sidecar prefix EMPTY on disk,
legacy bridge, VFS hung-embedder ack) · deferred-embedding 5/5 unchanged
(the contract outlived its mechanism) · kill-matrix 11/11 · cutover 5/5
×10 runs (flake dead) · unit 2031/2031.
2026-08-10 11:27:07 -07:00
|
|
|
|
type FactIntMinter,
|
|
|
|
|
|
type FactMarkerRecord
|
2026-08-10 10:55:11 -07:00
|
|
|
|
} from './factLog.js'
|
2026-07-19 16:26:10 -07:00
|
|
|
|
import { GenerationSegmentStore, type FoldGeneration } from './generationSegments.js'
|
|
|
|
|
|
import { crc32c } from '../utils/crc32c.js'
|
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-07-08 14:53:34 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* The byte-identical before-images of every id a commit touches, read UNDER
|
|
|
|
|
|
* the commit mutex immediately before the write applies. Passed to a commit's
|
|
|
|
|
|
* optional `precommit` hook so callers can enforce compare-and-swap
|
|
|
|
|
|
* preconditions (e.g. the per-entity `ifRev` check) atomically with the apply
|
|
|
|
|
|
* — the same conditional-commit idea as `ifAtGeneration`, generalized to
|
|
|
|
|
|
* arbitrary per-record predicates. An absent id maps to the create sentinel
|
|
|
|
|
|
* (`metadata: null, vector: null`).
|
|
|
|
|
|
*/
|
|
|
|
|
|
export interface CommitBeforeImages {
|
|
|
|
|
|
nouns: Map<string, GenerationRecord>
|
|
|
|
|
|
verbs: Map<string, GenerationRecord>
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
/** 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'
|
2026-08-10 14:48:32 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* The clean-shutdown marker (log-authority recovery gate): written+fsynced at
|
|
|
|
|
|
* a clean close carrying the committed generation; CONSUMED at every open.
|
2026-08-12 16:56:08 -07:00
|
|
|
|
* Absent or generation-mismatched at open = unclean shutdown = the replay
|
|
|
|
|
|
* fold, bounded below by the fold checkpoint when one is stored (whole-log
|
|
|
|
|
|
* without one). Its absence is always safe (costs one fold, loses nothing).
|
2026-08-10 14:48:32 -07:00
|
|
|
|
*/
|
|
|
|
|
|
export const CLEAN_SHUTDOWN_PATH = '_system/clean-shutdown.json'
|
2026-08-12 16:56:08 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* The fold checkpoint (log-authority recovery BOUND): `{ generation: G }`
|
|
|
|
|
|
* asserts that every entity whose latest fact is ≤ G has durable canonical
|
|
|
|
|
|
* bytes — so an unclean open folds only `(G, head]` instead of the whole log.
|
|
|
|
|
|
* Stamped strictly AFTER a canonical-sync barrier over every live entity
|
|
|
|
|
|
* touched since the last stamp (stamp-after-data); absent or torn = fold from
|
|
|
|
|
|
* 0 (always safe, just bigger). The chain of stamps starts only at a provable
|
|
|
|
|
|
* point: an empty brain, or the end of a whole-log fold.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export const FOLD_CHECKPOINT_PATH = '_system/fold-checkpoint.json'
|
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
|
|
|
|
/** 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).
|
fix(log): acked writes survive power loss; rejected writes never silently commit — the kill-matrix goes 11/11 with zero .fails debt
Two release-blocking findings from the durability kill-matrix, both fixed
in the owning layer:
1. LOG-AUTHORITY REPLAY AT OPEN: durable-at-ack fsynced the fact before
the ack, but open() truncated every fact above the manifest — after a
power loss that takes the un-fsynced tmp+rename canonical bytes, the
acked write's ONLY durable copy was discarded. Now: under 'log'
authority, open() REPLAYS intact facts above the manifest into
canonical (FactLog.peekFactsAbove — CRC-gated, order-sorted) and
advances the manifest to cover them; tree-authority brains keep the
truncate contract they were promised. Pinned end to end: the power-loss
row constructs the exact disk state (fsynced log, vanished canonical
rename) and the acked write lives.
2. NO SILENT COMMIT: commitSingleOp buffered the generation BEFORE the
fact append; an append failure (ENOSPC) rejected the caller but the
next flush durably committed the generation with NO fact — a permanent
silent log gap. Now the failure path un-buffers and returns the counter
reservation: nothing commits, the log stays gap-free, and the canonical
execute-residue orphan is the documented crash-equivalent.
Plus: the kill-matrix itself (11 rows — every commit-path fault point ×
reopen-as-crash recovery contract, at-ack variants, disk-full row; five
new zero-cost faultPoint sites), the log-authority pin suite (oracle
green/red/state-differs, flip refusal, switch survives reopen, 9/9), and
the group-commit covering pins (5/5).
Gates: unit 2002/2002 (152 files) · integration 785 · conformance 27/27.
2026-08-10 09:29:21 -07:00
|
|
|
|
* - `'transact-after-fact-sync'` — the batch's fact is appended AND fsynced,
|
|
|
|
|
|
* but neither the counter nor the manifest advanced. A crash here must cost
|
|
|
|
|
|
* the whole batch: recovery restores the before-images and open() truncates
|
|
|
|
|
|
* the synced fact back to the manifest watermark.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Single-op (Model-B group-commit) phases — `commitSingleOp`:
|
|
|
|
|
|
*
|
|
|
|
|
|
* - `'singleop-after-execute'` — the live canonical write has applied (tmp+
|
|
|
|
|
|
* rename, not individually fsynced); no history, fact, or generation record
|
|
|
|
|
|
* exists yet. A crash here must cost only the never-returned ack — the
|
|
|
|
|
|
* baseline stays intact and the log stays at the committed watermark.
|
|
|
|
|
|
* - `'singleop-after-fact-append'` — the fact is appended (and, in at-ack
|
|
|
|
|
|
* mode, fsynced); the manifest never saw the generation. A crash here must
|
|
|
|
|
|
* cost the buffered history + the fact (open() truncates it back), never
|
|
|
|
|
|
* the baseline.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Pending-tier flush phases — `flushPendingSingleOps`:
|
|
|
|
|
|
*
|
|
|
|
|
|
* - `'flush-after-staging'` — the window's record-set dirs are written but not
|
|
|
|
|
|
* fsynced and the manifest never advanced. A crash here must cost only the
|
|
|
|
|
|
* window's HISTORY (drop-without-restore) — the acked live writes stay.
|
|
|
|
|
|
* - `'flush-before-manifest'` — staging is fsynced and the facts are fsynced,
|
|
|
|
|
|
* but the manifest never advanced. A crash here must cost only the window's
|
|
|
|
|
|
* history and its facts (truncated at open) — the acked live writes stay.
|
|
|
|
|
|
* - `'before-manifest-rename'` is ALSO fired by the flush path just before its
|
|
|
|
|
|
* commit point (see `flushPendingSingleOpsUnlocked`).
|
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
|
|
|
|
*/
|
|
|
|
|
|
export type CommitFaultPhase =
|
|
|
|
|
|
| 'after-staging'
|
|
|
|
|
|
| 'after-execute'
|
|
|
|
|
|
| 'before-manifest-rename'
|
|
|
|
|
|
| 'after-manifest-rename'
|
fix(log): acked writes survive power loss; rejected writes never silently commit — the kill-matrix goes 11/11 with zero .fails debt
Two release-blocking findings from the durability kill-matrix, both fixed
in the owning layer:
1. LOG-AUTHORITY REPLAY AT OPEN: durable-at-ack fsynced the fact before
the ack, but open() truncated every fact above the manifest — after a
power loss that takes the un-fsynced tmp+rename canonical bytes, the
acked write's ONLY durable copy was discarded. Now: under 'log'
authority, open() REPLAYS intact facts above the manifest into
canonical (FactLog.peekFactsAbove — CRC-gated, order-sorted) and
advances the manifest to cover them; tree-authority brains keep the
truncate contract they were promised. Pinned end to end: the power-loss
row constructs the exact disk state (fsynced log, vanished canonical
rename) and the acked write lives.
2. NO SILENT COMMIT: commitSingleOp buffered the generation BEFORE the
fact append; an append failure (ENOSPC) rejected the caller but the
next flush durably committed the generation with NO fact — a permanent
silent log gap. Now the failure path un-buffers and returns the counter
reservation: nothing commits, the log stays gap-free, and the canonical
execute-residue orphan is the documented crash-equivalent.
Plus: the kill-matrix itself (11 rows — every commit-path fault point ×
reopen-as-crash recovery contract, at-ack variants, disk-full row; five
new zero-cost faultPoint sites), the log-authority pin suite (oracle
green/red/state-differs, flip refusal, switch survives reopen, 9/9), and
the group-commit covering pins (5/5).
Gates: unit 2002/2002 (152 files) · integration 785 · conformance 27/27.
2026-08-10 09:29:21 -07:00
|
|
|
|
| 'transact-after-fact-sync'
|
|
|
|
|
|
| 'singleop-after-execute'
|
|
|
|
|
|
| 'singleop-after-fact-append'
|
|
|
|
|
|
| 'flush-after-staging'
|
|
|
|
|
|
| 'flush-before-manifest'
|
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 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
|
|
|
|
|
|
|
feat: generation fact log — after-image commit records, dual-written at every commit point
Every committed generation now also appends a FACT — an after-image
commit record (what each touched entity/relationship became, or a
body-less tombstone for a removal) — to an append-only, crc32c-framed
segment log under _generations/facts/. The before-image history and the
canonical tree remain authoritative; the fact log gives consumers ONE
sequential, self-verifying stream (index heals, incremental replays)
in place of a per-entity directory walk.
- Wire format: positional msgpack facts [generation, timestamp, ops,
meta, blobHashes]; op = [kind u8, id bin16, record | nil tombstone];
32-byte segment header (magic, formatVersion, firstGeneration,
zeroed+verified reserved); length+crc32c frame per fact; zero-padded
segment names so lexicographic order == generation order; JSON
manifest with an atomic rename flip, manifest-first rotation.
- Commit protocol: facts append+fsync BEFORE the commit point inside
the existing durability window, so a crash can only leave the log
AHEAD of committed truth — open() truncates back (torn tails detected
by CRC). Absent generation = never committed; a scan can never see an
uncommitted fact. transact() facts are durable-on-return; single-op
facts ride the group-commit flush exactly like buffered history. A
fact-append failure fails the write, loudly — a silent gap would be a
lie a later replay discovers.
- New public surface: brain.scanFacts() (sequential batches with heal
telemetry: head/segments/approx up front, per-batch generation range
+ bytes + segment id, loud abort on gaps, summary cross-check) and
brain.factSegmentPaths() (immutable sealed segments for zero-copy
consumers; the mutable tail excluded). Exported types CommitFact,
FactOp, FactScanBatch, FactScanHandle.
- Storage: optional binary raw-byte primitives (appendRawBytes,
readRawBytes, writeRawBytes, rawByteSize) on StorageAdapter —
feature-detected; filesystem + memory adapters implement them; an
adapter without them hosts no fact log. Fact segments are byte-copied
(never hard-linked) into snapshots. The _generations/facts/ namespace
is registered as a protected family (rebuildable: false): no sweeper
or GC may delete under it.
- New crc32c (Castagnoli) utility with RFC known-answer tests.
2026-07-15 10:49:02 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* The generation FACT LOG (dual-write transition) — an append-only,
|
|
|
|
|
|
* CRC-framed record of every committed generation as an AFTER-IMAGE fact.
|
|
|
|
|
|
* `null` when the storage layer lacks the binary raw-byte primitives.
|
|
|
|
|
|
* Appends ride the same commit protocol: a fact-append failure FAILS the
|
|
|
|
|
|
* write (loud — a silent fact gap would make the log a lie that a later
|
|
|
|
|
|
* replay discovers), and open() reconciles the log to committed truth.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private factLog: FactLog | null = null
|
|
|
|
|
|
|
feat(log): the guarded log-authority core — group-commit durable-at-ack, the per-brain switch, the verification oracle
The storage-authority adoption path, guarded shape: the canonical tree
stays authoritative by default ('tree'); a brain flips to 'log' only
through the verification oracle, and the flip is stored, per-brain,
checked at open only.
- FactLog.ensureSynced(): classic group commit — concurrent writers
append, then join ONE covering fsync (running + queued slots give the
covering guarantee: the sync a caller awaits always starts after its
append landed). Solo writer = immediate sync.
- GenerationStore.logDurability 'deferred' (default, byte-identical to
today: fact durability rides the group-commit flush, ack latency
unchanged) | 'at-ack' (log-authority mode: every single-op ack awaits a
covering log fsync — an acked write's fact survives power loss, by
contract). transact() was already durable-at-return in both modes.
- src/db/logAuthority.ts: the stored switch artifact
(_system/log-authority.json, absent = tree), readLogAuthority, and the
VERIFICATION ORACLE — replay the fact log, fold latest state per id
(digests, never bodies — memory-bounded), diff against the canonical
tree paged; verdict green iff every canonical row is exactly reproduced
AND the log claims nothing canonical denies. Divergences are NAMED by
class (pre-log-record → needs baseline backfill; state-differs;
log-live-canonical-absent; log-tombstone-canonical-present). The flip
REFUSES on red with the first divergence and the cure in the message.
- Brainy: authority read at open (log → durable-at-ack enabled);
logAuthority() / verifyLogAuthority() / adoptLogAuthority() public API.
Nothing flips by itself; nothing changes for existing brains.
2026-08-06 10:08:18 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* Fact-log durability mode. 'deferred' (default) = the fact becomes
|
|
|
|
|
|
* durable at the group-commit flush, together with the buffered history —
|
|
|
|
|
|
* the pre-log-authority contract, zero added ack latency. 'at-ack' =
|
|
|
|
|
|
* every single-op ack awaits a covering log fsync (shared via the log's
|
|
|
|
|
|
* group commit) — the log-authority contract: an acked write's fact
|
|
|
|
|
|
* survives power loss. Set by the owner from the stored authority switch
|
|
|
|
|
|
* at open; transact() is durable-at-return in BOTH modes (unchanged).
|
|
|
|
|
|
*/
|
|
|
|
|
|
private logDurability: 'deferred' | 'at-ack' = 'deferred'
|
|
|
|
|
|
|
|
|
|
|
|
/** Switch the fact-log durability mode (see {@link logDurability}). */
|
|
|
|
|
|
setLogDurability(mode: 'deferred' | 'at-ack'): void {
|
|
|
|
|
|
this.logDurability = mode
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-10 10:55:11 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* The fact log's v2 int minter — injected by the OWNER (brainy wires the
|
|
|
|
|
|
* metadata index's id mapper here right after the index is ready), because
|
|
|
|
|
|
* this store cannot know the mapper. With the minter installed, new fact
|
|
|
|
|
|
* segments write the v2 format and after-image records carry minted dense
|
|
|
|
|
|
* ints reproducible by an id-mapper rebuild. Survives reopen: `open()`
|
|
|
|
|
|
* re-installs it on the fresh {@link FactLog} instance.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private intMinter: FactIntMinter | null = null
|
|
|
|
|
|
|
|
|
|
|
|
/** Install the fact log's v2 int minter (see {@link intMinter}). */
|
|
|
|
|
|
setIntMinter(mint: FactIntMinter): void {
|
|
|
|
|
|
this.intMinter = mint
|
|
|
|
|
|
this.factLog?.setIntMinter(mint)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
/** 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
|
|
|
|
|
|
|
2026-08-12 16:56:08 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* Fold-checkpoint accumulator: every entity whose CANONICAL live bytes were
|
|
|
|
|
|
* (re)written since the last stamped checkpoint. Drained by
|
|
|
|
|
|
* {@link advanceFoldCheckpointUnlocked} — synced first, stamped after; on a
|
|
|
|
|
|
* failed barrier the drained ids merge back so the checkpoint can never
|
|
|
|
|
|
* advance past unsynced bytes. Fed only while the chain is valid (see
|
|
|
|
|
|
* {@link foldCheckpointChainValid}) so tree-authority brains never grow it.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private checkpointDirtyNouns = new Set<string>()
|
|
|
|
|
|
/** @see checkpointDirtyNouns — the verb half of the accumulator. */
|
|
|
|
|
|
private checkpointDirtyVerbs = new Set<string>()
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Whether the checkpoint chain is PROVABLY sound for this brain: true when
|
|
|
|
|
|
* a stored checkpoint exists (induction), the brain opened empty (vacuous),
|
|
|
|
|
|
* or a whole-log fold just re-applied every fact (base case). While false,
|
|
|
|
|
|
* checkpoints are never stamped and the fold bound stays 0 — the honest
|
|
|
|
|
|
* 10.0 contract, upgraded at the brain's first recovery fold.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private foldCheckpointChainValid = false
|
|
|
|
|
|
/** Last stamped fold-checkpoint generation (0 = none / fold from origin). */
|
|
|
|
|
|
private foldCheckpoint = 0
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Whether this brain's stored authority is the log — set from the stored
|
|
|
|
|
|
* artifact at open, or by {@link completeFoldCheckpointBootstrap} when an
|
|
|
|
|
|
* in-session adoption flips it. Checkpoints are only ever STAMPED under log
|
|
|
|
|
|
* authority (the artifact bounds the log fold, which only log-authority
|
|
|
|
|
|
* recovery runs); the dirty accumulator may fill slightly earlier, during
|
|
|
|
|
|
* an adoption in flight (see {@link beginFoldCheckpointBootstrap}).
|
|
|
|
|
|
*/
|
|
|
|
|
|
private authorityIsLog = false
|
|
|
|
|
|
|
2026-06-29 16:05:03 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* Committed generations whose record dirs exist, stored as a SORTED, DISJOINT,
|
|
|
|
|
|
* ascending list of INCLUSIVE `[start, end]` intervals (a run-length set).
|
|
|
|
|
|
*
|
|
|
|
|
|
* Committed generations come from the monotonic `counter++`, so they are dense
|
|
|
|
|
|
* contiguous integers EXCEPT where {@link compact} punches a hole (reclaiming
|
|
|
|
|
|
* an oldest contiguous prefix). Storing them as intervals makes the resident
|
|
|
|
|
|
* size O(number-of-compaction-gaps) — typically a SINGLE interval
|
|
|
|
|
|
* `[firstGen, lastGen]` — instead of one array element per generation. A
|
|
|
|
|
|
* billion-insert corpus (every single-op reserves a distinct generation) would
|
|
|
|
|
|
* otherwise hold a ~1B-element array resident for the process lifetime; the
|
|
|
|
|
|
* interval form collapses that to O(1). All access goes through the helpers
|
|
|
|
|
|
* ({@link appendCommittedGen}, {@link committedGensAsc}, {@link committedCount},
|
|
|
|
|
|
* {@link removeCommittedUpTo}, {@link lastCommittedGen},
|
|
|
|
|
|
* {@link largestReservedAtOrBefore}) so the multiset and ordering are identical
|
|
|
|
|
|
* to the old flat array at every point.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private committedRanges: Array<[number, 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
|
2026-06-29 16:05:03 -07:00
|
|
|
|
* the pin, instead of linearly scanning the global committed-generation set
|
|
|
|
|
|
* ({@link committedRanges})
|
2026-06-22 13:47:33 -07:00
|
|
|
|
* (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
|
perf(8.0): bound per-id generation history chains (O(W+L) resident, was O(N))
The MVCC time-travel layer kept nounChains/verbChains as unbounded
Map<id, number[]> — one resident chain per id ever touched across retained
history (O(N) RAM, defeating billion-scale time travel). Replace with a hot-tail
window + bounded cold LRU + a mutex-free bulk resolver, keeping resolveAt exactly
correct.
The most-recent W generations stay resident as full chains (the common recent-pin
read is O(log), zero scan); deeper pins reconstruct one id's chain on demand into
an L-bounded LRU. Reconstruction is LOCK-LIGHT — it holds no commit mutex, so a
historical read never stalls writers; correctness holds because a live pin's
answer is always > minPinnedGeneration, which compaction can never reclaim, and a
concurrently-reclaimed gen below that is skipped. materializeAtGeneration routes
through a new mutex-free resolveManyAt (one forward pass, O(R + |ids|)) — without
it a deep-pin materialize both regresses to O(N*R) and deadlocks on snapshotWith's
mutex. The cold cache is invalidated on re-touch to stay coherent. Resident RAM is
O(W*d + L), independent of N. 11-case test (oracle-vs-bruteforce, held-Db across
eviction + concurrent compaction, no-deadlock, write-path I/O-free); unit 1735 +
integration 613 (db-mvcc/db-temporal/db-asof green).
2026-06-30 10:30:17 -07:00
|
|
|
|
* mirrors cor's `delta_history` chain.
|
|
|
|
|
|
*
|
|
|
|
|
|
* BOUNDED-RAM design (Approach C — hot-tail window + cold LRU). The old
|
|
|
|
|
|
* unbounded `Map<id, number[]>` held ONE resident chain per id ever touched
|
|
|
|
|
|
* across retained history → O(distinct-ids-ever-touched) RAM, which defeats
|
|
|
|
|
|
* billion-scale time travel (the chains, not the data, become the heap floor).
|
|
|
|
|
|
* These three structures replace it with RAM bounded by the knobs W/L,
|
|
|
|
|
|
* INDEPENDENT of the corpus size:
|
|
|
|
|
|
*
|
|
|
|
|
|
* 1. {@link recentNounChains}/{@link recentVerbChains} — FULL per-id chains,
|
|
|
|
|
|
* but only over the HOT TAIL window `[windowLo, counter]` (the last
|
|
|
|
|
|
* {@link recentWindowGenerations} generations). Pins at-or-above
|
|
|
|
|
|
* `windowLo` (the overwhelming common case — recent history) resolve here
|
|
|
|
|
|
* with the same O(log chain) binary search as before.
|
|
|
|
|
|
* 2. {@link coldNounChains}/{@link coldVerbChains} — a bounded LRU of FULL
|
|
|
|
|
|
* per-id chains for DEEP pins (`gen < windowLo`). Reconstructed on demand
|
|
|
|
|
|
* from the persisted deltas ({@link reconstructColdChain}), cached so a
|
|
|
|
|
|
* re-read is O(log chain), evicted oldest-first past
|
|
|
|
|
|
* {@link coldChainLruMax}. Empty chains are cached too (bounded negative
|
|
|
|
|
|
* cache for untouched-in-cold-region ids).
|
|
|
|
|
|
* 3. {@link windowNounDeltas}/{@link windowVerbDeltas} — the window's INVERSE
|
|
|
|
|
|
* index (`gen → ids touched at that gen`), fed from the touched sets
|
|
|
|
|
|
* already handed to {@link extendChains}. It makes sliding `windowLo`
|
|
|
|
|
|
* forward an O(d̄) front-trim of exactly the ids leaving the window, with
|
|
|
|
|
|
* ZERO disk I/O — the write path never reads `tx.json` on a slide.
|
|
|
|
|
|
*
|
|
|
|
|
|
* INVARIANT — purely derived, NEVER persisted. All three structures (and the
|
|
|
|
|
|
* cold LRU) are reconstructable from the on-disk deltas at any time; eviction
|
|
|
|
|
|
* (cold LRU) and window slide-out drop entries that are then rebuilt on the
|
|
|
|
|
|
* next read that needs them. Correctness across eviction/reconstruction is
|
|
|
|
|
|
* guaranteed by pin-gating: a live historical `Db` pins at `g_old`, and the
|
|
|
|
|
|
* before-image a read needs lives at the FIRST generation after `g_old`
|
|
|
|
|
|
* (`> g_old ≥ minPinned`), which compaction can never reclaim while the pin is
|
|
|
|
|
|
* held — so a reconstructed chain yields the identical answer as the original.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private readonly recentNounChains = new Map<string, number[]>()
|
|
|
|
|
|
private readonly recentVerbChains = new Map<string, number[]>()
|
|
|
|
|
|
/** Window inverse index (`gen → ids touched`), one map per kind — the O(d̄),
|
|
|
|
|
|
* I/O-free slide-trim driver. Holds exactly the gens in `[windowLo, counter]`. */
|
|
|
|
|
|
private readonly windowNounDeltas = new Map<number, string[]>()
|
|
|
|
|
|
private readonly windowVerbDeltas = new Map<number, string[]>()
|
|
|
|
|
|
/** Bounded LRU of reconstructed deep-pin chains (`gen < windowLo`). JS Map
|
|
|
|
|
|
* insertion order is the LRU order: a hit re-inserts (delete+set) to mark
|
|
|
|
|
|
* MRU; eviction removes `keys().next().value` (the oldest) past the cap. */
|
|
|
|
|
|
private readonly coldNounChains = new Map<string, number[]>()
|
|
|
|
|
|
private readonly coldVerbChains = new Map<string, number[]>()
|
|
|
|
|
|
/** Inclusive lower bound of the resident hot-tail window (`0` until built). */
|
|
|
|
|
|
private windowLo = 0
|
|
|
|
|
|
/** Whether the hot-tail window + its inverse index are built and current. */
|
|
|
|
|
|
private windowReady = false
|
|
|
|
|
|
/** De-dupes concurrent first-callers of {@link ensureWindow}. */
|
|
|
|
|
|
private windowBuilding: Promise<void> | null = null
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Hot-tail window width W — how many of the newest generations keep a resident
|
|
|
|
|
|
* per-id chain ({@link recentNounChains}). Bounds recent-chain + window-delta
|
|
|
|
|
|
* RAM to O(W·d̄), independent of corpus size. Deep pins below the window fall to
|
|
|
|
|
|
* the cold LRU. Not `readonly` so tests can shrink it to exercise the
|
|
|
|
|
|
* cold/slide paths without building thousands of generations.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private recentWindowGenerations = 4096
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Cold-chain LRU capacity L — how many reconstructed deep-pin chains stay
|
|
|
|
|
|
* resident. Bounds cold-chain RAM to O(L·avg-chain-length), independent of how
|
|
|
|
|
|
* many distinct ids are deep-read. Not `readonly` so tests can shrink it to
|
|
|
|
|
|
* exercise eviction + reconstruction.
|
2026-06-22 13:47:33 -07:00
|
|
|
|
*/
|
perf(8.0): bound per-id generation history chains (O(W+L) resident, was O(N))
The MVCC time-travel layer kept nounChains/verbChains as unbounded
Map<id, number[]> — one resident chain per id ever touched across retained
history (O(N) RAM, defeating billion-scale time travel). Replace with a hot-tail
window + bounded cold LRU + a mutex-free bulk resolver, keeping resolveAt exactly
correct.
The most-recent W generations stay resident as full chains (the common recent-pin
read is O(log), zero scan); deeper pins reconstruct one id's chain on demand into
an L-bounded LRU. Reconstruction is LOCK-LIGHT — it holds no commit mutex, so a
historical read never stalls writers; correctness holds because a live pin's
answer is always > minPinnedGeneration, which compaction can never reclaim, and a
concurrently-reclaimed gen below that is skipped. materializeAtGeneration routes
through a new mutex-free resolveManyAt (one forward pass, O(R + |ids|)) — without
it a deep-pin materialize both regresses to O(N*R) and deadlocks on snapshotWith's
mutex. The cold cache is invalidated on re-touch to stay coherent. Resident RAM is
O(W*d + L), independent of N. 11-case test (oracle-vs-bruteforce, held-Db across
eviction + concurrent compaction, no-deadlock, write-path I/O-free); unit 1735 +
integration 613 (db-mvcc/db-temporal/db-asof green).
2026-06-30 10:30:17 -07:00
|
|
|
|
private coldChainLruMax = 4096
|
2026-06-22 13:47:33 -07:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Cap on resident {@link deltaCache} entries (Model-B RAM bound). The per-id
|
perf(8.0): bound per-id generation history chains (O(W+L) resident, was O(N))
The MVCC time-travel layer kept nounChains/verbChains as unbounded
Map<id, number[]> — one resident chain per id ever touched across retained
history (O(N) RAM, defeating billion-scale time travel). Replace with a hot-tail
window + bounded cold LRU + a mutex-free bulk resolver, keeping resolveAt exactly
correct.
The most-recent W generations stay resident as full chains (the common recent-pin
read is O(log), zero scan); deeper pins reconstruct one id's chain on demand into
an L-bounded LRU. Reconstruction is LOCK-LIGHT — it holds no commit mutex, so a
historical read never stalls writers; correctness holds because a live pin's
answer is always > minPinnedGeneration, which compaction can never reclaim, and a
concurrently-reclaimed gen below that is skipped. materializeAtGeneration routes
through a new mutex-free resolveManyAt (one forward pass, O(R + |ids|)) — without
it a deep-pin materialize both regresses to O(N*R) and deadlocks on snapshotWith's
mutex. The cold cache is invalidated on re-touch to stay coherent. Resident RAM is
O(W*d + L), independent of N. 11-case test (oracle-vs-bruteforce, held-Db across
eviction + concurrent compaction, no-deadlock, write-path I/O-free); unit 1735 +
integration 613 (db-mvcc/db-temporal/db-asof green).
2026-06-30 10:30:17 -07:00
|
|
|
|
* {@link recentNounChains}/{@link recentVerbChains} window is the hot read
|
|
|
|
|
|
* structure; the raw
|
2026-06-22 13:47:33 -07:00
|
|
|
|
* 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
|
|
|
|
|
|
|
2026-07-18 10:51:37 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* Running total of on-disk history bytes across committed generations —
|
|
|
|
|
|
* `null` until {@link historyBytes} pays its one seeding walk. Maintained
|
|
|
|
|
|
* incrementally at commit/reclaim so the adaptive retention check on every
|
|
|
|
|
|
* flush() is O(1), never a tail re-walk. Never updated by cache re-reads
|
|
|
|
|
|
* ({@link setDelta} inserts are cache population, not new history).
|
|
|
|
|
|
*/
|
|
|
|
|
|
private historyBytesTotal: number | null = null
|
|
|
|
|
|
|
2026-07-19 16:26:10 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* The packed tier (D1+D3): sealed segments holding folded cold
|
|
|
|
|
|
* generations. Null until {@link open} wires it (and on storage adapters
|
|
|
|
|
|
* without raw-byte primitives — the live tier then carries everything,
|
|
|
|
|
|
* exactly as before the packed tier existed).
|
|
|
|
|
|
*/
|
|
|
|
|
|
private segments: GenerationSegmentStore | null = null
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Live-tier window: generations newer than `committed - REPACK_LIVE_WINDOW`
|
|
|
|
|
|
* are never folded — the hot tail stays in the per-generation layout the
|
|
|
|
|
|
* write path owns. Matches the resident chain window's scale.
|
|
|
|
|
|
*/
|
|
|
|
|
|
static readonly REPACK_LIVE_WINDOW = 1024
|
|
|
|
|
|
|
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
|
2026-06-29 16:05:03 -07:00
|
|
|
|
* union via {@link reservedGensAsc}; before-images resolve from this buffer
|
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
|
|
|
|
* 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,
|
2026-08-17 16:21:25 -07:00
|
|
|
|
{
|
|
|
|
|
|
nouns: Map<string, GenerationRecord>
|
|
|
|
|
|
verbs: Map<string, GenerationRecord>
|
|
|
|
|
|
timestamp: number
|
|
|
|
|
|
/** Engine-origin stamp for the tx-log entry (absent = user write). */
|
|
|
|
|
|
origin?: 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
|
|
|
|
>()
|
|
|
|
|
|
/** 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
|
|
|
|
|
|
|
2026-07-13 09:31:25 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* Latched pending-flush durability failure. Set once background flushes have
|
|
|
|
|
|
* failed {@link PENDING_FLUSH_FAILURE_THRESHOLD} consecutive times (a real,
|
|
|
|
|
|
* persistent storage fault — not a blip); while set, writes are REFUSED with
|
|
|
|
|
|
* a {@link PendingFlushDurabilityError} rather than silently accumulate
|
|
|
|
|
|
* un-durable history in memory. Cleared the moment a flush finally succeeds
|
|
|
|
|
|
* (the store self-heals). `null` = history-durability path is healthy.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private pendingFlushError: Error | null = null
|
|
|
|
|
|
/** Consecutive failed background pending-flush attempts (resets on success). */
|
|
|
|
|
|
private pendingFlushFailures = 0
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Consecutive failed flush attempts before the durability latch trips and
|
|
|
|
|
|
* writes start refusing. A small tolerance so a single transient fault does
|
|
|
|
|
|
* not trip the alarm, while a genuinely stuck disk stops the bleed fast.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private static readonly PENDING_FLUSH_FAILURE_THRESHOLD = 3
|
|
|
|
|
|
/** Upper bound (ms) on the exponential retry backoff between failed flushes. */
|
|
|
|
|
|
private static readonly PENDING_FLUSH_RETRY_CAP_MS = 30_000
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
feat(log): log authority is the fleet default — adopt-at-open, oracle-gated; plus the power-cut throw-site cures and the loud torn-record contract
THE DEFAULT FLIP (ruled on proven evidence — at-ack survived 301/301
acked-writes-through-power-cut in block-layer fault injection; deferred
tree authority demonstrably loses flush-covered acks): a brain with NO
stored authority artifact now ADOPTS LOG AUTHORITY AT OPEN. The oracle
gates the flip exactly as the guarded adoption path always did — curable
divergences baseline-backfilled, the flip lands ONLY on a green verdict —
and a brain that cannot verify STAYS tree-authoritative loudly, with the
refusal recorded on the switch artifact so subsequent opens are cheap.
config logAuthority: 'defer' is the explicit documented opt-out (no
automatic adoption; declared flush-window loss; adoptLogAuthority() flips
later). A stored artifact always wins. RELEASES.md carries the posture.
Two standing .fails debt pins FLIP TO HOLDING under the default: the
at-ack crash-survival gap and the ack-at-log durability target — both now
permanent asserted truths, not aspirations.
POWER-CUT THROW SITES (fault-injection findings, brainy-alone config):
- A manifest-listed-but-unloadable column segment QUARANTINES at
discovery (loud once, counted always, quarantinedSegments() exposed for
the heal) and the field serves its remaining segments DEGRADED — never
a raw throw killing every query on the field. Real storage faults still
propagate untouched.
- Torn generation artifacts (NaN/garbage in manifest or counter) DISCARD
with narration at the store's open and recovery re-derives — plus a
defensive finite-integer guard at the init consumer. Never a RangeError
killing an open.
THE LOUD TORN-RECORD CONTRACT: an existing-but-unparseable stored record
now surfaces as a typed, counted TornRecordError on every entity-read
surface (including fifteen previously-blind per-item batch catches);
ENOENT stays clean-absent; artifact readers with designed absent-recovery
keep null-tolerance behind the loud floor. Disk corruption can no longer
read as silent data invisibility.
Suite migration: the default's pins inverted deliberately, generation
baselines made relative, quarantine-contract pins rewritten to the ruled
behavior.
Gates: tsc 0 · unit 2065/2065 (159 files) · integration 826 (93 files) ·
conformance 31/31 · kill-matrix 15/15 · torn-open guards 2/2.
2026-08-11 08:37:38 -07:00
|
|
|
|
// TORN-ARTIFACT VALIDATION (power-loss survivors): a torn manifest or
|
|
|
|
|
|
// counter can carry NaN/garbage where a generation belongs — unguarded,
|
|
|
|
|
|
// that NaN reaches BigInt() conversions at init and kills the open with
|
|
|
|
|
|
// a RangeError. A non-finite-integer generation is DISCARDED with
|
|
|
|
|
|
// narration (the conservative floor: 0 = re-derive from the record
|
|
|
|
|
|
// directories / fact log below, exactly the recovery machinery's job).
|
|
|
|
|
|
const finiteGen = (v: unknown, source: string): number => {
|
|
|
|
|
|
if (typeof v === 'number' && Number.isSafeInteger(v) && v >= 0) return v
|
|
|
|
|
|
if (v !== undefined && v !== null) {
|
|
|
|
|
|
prodLog.warn(
|
|
|
|
|
|
`[GenerationStore] ${source} carries a non-integer generation ` +
|
|
|
|
|
|
`(${String(v)}) — torn write survivor; discarding and re-deriving ` +
|
|
|
|
|
|
`from recovery (never a RangeError at open)`
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
return 0
|
|
|
|
|
|
}
|
|
|
|
|
|
this.committed = finiteGen(manifest?.generation, 'manifest')
|
|
|
|
|
|
this.horizonGen = finiteGen(manifest?.horizon, 'manifest horizon')
|
|
|
|
|
|
this.counter = Math.max(finiteGen(counterFile?.generation, 'generation counter'), this.committed)
|
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
|
|
|
|
|
|
|
|
|
|
// 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
|
2026-06-29 16:05:03 -07:00
|
|
|
|
// Coalesce the ascending on-disk committed gens into interval form: each
|
|
|
|
|
|
// contiguous run becomes a single `[start, end]` range (appendCommittedGen
|
|
|
|
|
|
// extends the last range on a +1 step, opens a new one across a gap).
|
|
|
|
|
|
this.committedRanges = []
|
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 gen of [...seenGens].sort((a, b) => a - b)) {
|
|
|
|
|
|
if (gen <= this.committed) {
|
2026-06-29 16:05:03 -07:00
|
|
|
|
this.appendCommittedGen(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
|
|
|
|
} 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
|
2026-06-29 16:05:03 -07:00
|
|
|
|
// committedRanges only includes generations ≤ the manifest watermark.
|
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.counter = Math.max(this.counter, gen)
|
|
|
|
|
|
}
|
perf(8.0): bound per-id generation history chains (O(W+L) resident, was O(N))
The MVCC time-travel layer kept nounChains/verbChains as unbounded
Map<id, number[]> — one resident chain per id ever touched across retained
history (O(N) RAM, defeating billion-scale time travel). Replace with a hot-tail
window + bounded cold LRU + a mutex-free bulk resolver, keeping resolveAt exactly
correct.
The most-recent W generations stay resident as full chains (the common recent-pin
read is O(log), zero scan); deeper pins reconstruct one id's chain on demand into
an L-bounded LRU. Reconstruction is LOCK-LIGHT — it holds no commit mutex, so a
historical read never stalls writers; correctness holds because a live pin's
answer is always > minPinnedGeneration, which compaction can never reclaim, and a
concurrently-reclaimed gen below that is skipped. materializeAtGeneration routes
through a new mutex-free resolveManyAt (one forward pass, O(R + |ids|)) — without
it a deep-pin materialize both regresses to O(N*R) and deadlocks on snapshotWith's
mutex. The cold cache is invalidated on re-touch to stay coherent. Resident RAM is
O(W*d + L), independent of N. 11-case test (oracle-vs-bruteforce, held-Db across
eviction + concurrent compaction, no-deadlock, write-path I/O-free); unit 1735 +
integration 613 (db-mvcc/db-temporal/db-asof green).
2026-06-30 10:30:17 -07:00
|
|
|
|
// The history window is rebuilt lazily from the (possibly changed) generation
|
|
|
|
|
|
// set on the next historical read — covers reopen and reopen-after-restore.
|
2026-06-22 13:47:33 -07:00
|
|
|
|
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
|
|
|
|
|
|
|
feat: generation fact log — after-image commit records, dual-written at every commit point
Every committed generation now also appends a FACT — an after-image
commit record (what each touched entity/relationship became, or a
body-less tombstone for a removal) — to an append-only, crc32c-framed
segment log under _generations/facts/. The before-image history and the
canonical tree remain authoritative; the fact log gives consumers ONE
sequential, self-verifying stream (index heals, incremental replays)
in place of a per-entity directory walk.
- Wire format: positional msgpack facts [generation, timestamp, ops,
meta, blobHashes]; op = [kind u8, id bin16, record | nil tombstone];
32-byte segment header (magic, formatVersion, firstGeneration,
zeroed+verified reserved); length+crc32c frame per fact; zero-padded
segment names so lexicographic order == generation order; JSON
manifest with an atomic rename flip, manifest-first rotation.
- Commit protocol: facts append+fsync BEFORE the commit point inside
the existing durability window, so a crash can only leave the log
AHEAD of committed truth — open() truncates back (torn tails detected
by CRC). Absent generation = never committed; a scan can never see an
uncommitted fact. transact() facts are durable-on-return; single-op
facts ride the group-commit flush exactly like buffered history. A
fact-append failure fails the write, loudly — a silent gap would be a
lie a later replay discovers.
- New public surface: brain.scanFacts() (sequential batches with heal
telemetry: head/segments/approx up front, per-batch generation range
+ bytes + segment id, loud abort on gaps, summary cross-check) and
brain.factSegmentPaths() (immutable sealed segments for zero-copy
consumers; the mutable tail excluded). Exported types CommitFact,
FactOp, FactScanBatch, FactScanHandle.
- Storage: optional binary raw-byte primitives (appendRawBytes,
readRawBytes, writeRawBytes, rawByteSize) on StorageAdapter —
feature-detected; filesystem + memory adapters implement them; an
adapter without them hosts no fact log. Fact segments are byte-copied
(never hard-linked) into snapshots. The _generations/facts/ namespace
is registered as a protected family (rebuildable: false): no sweeper
or GC may delete under it.
- New crc32c (Castagnoli) utility with RFC known-answer tests.
2026-07-15 10:49:02 -07:00
|
|
|
|
// Generation FACT LOG (dual-write transition): when the storage layer
|
|
|
|
|
|
// exposes the binary raw-byte primitives, open the after-image fact log
|
|
|
|
|
|
// and reconcile it to committed truth — facts are appended BEFORE the
|
|
|
|
|
|
// commit point, so a crash can only leave the log AHEAD; open truncates
|
|
|
|
|
|
// any fact beyond `committed`. Storage without the primitives simply
|
|
|
|
|
|
// hosts no fact log (readers fall back to canonical enumeration).
|
|
|
|
|
|
if (storageSupportsFactLog(this.storage)) {
|
|
|
|
|
|
this.factLog = new FactLog(this.storage)
|
2026-08-10 10:55:11 -07:00
|
|
|
|
if (this.intMinter) this.factLog.setIntMinter(this.intMinter)
|
fix(log): acked writes survive power loss; rejected writes never silently commit — the kill-matrix goes 11/11 with zero .fails debt
Two release-blocking findings from the durability kill-matrix, both fixed
in the owning layer:
1. LOG-AUTHORITY REPLAY AT OPEN: durable-at-ack fsynced the fact before
the ack, but open() truncated every fact above the manifest — after a
power loss that takes the un-fsynced tmp+rename canonical bytes, the
acked write's ONLY durable copy was discarded. Now: under 'log'
authority, open() REPLAYS intact facts above the manifest into
canonical (FactLog.peekFactsAbove — CRC-gated, order-sorted) and
advances the manifest to cover them; tree-authority brains keep the
truncate contract they were promised. Pinned end to end: the power-loss
row constructs the exact disk state (fsynced log, vanished canonical
rename) and the acked write lives.
2. NO SILENT COMMIT: commitSingleOp buffered the generation BEFORE the
fact append; an append failure (ENOSPC) rejected the caller but the
next flush durably committed the generation with NO fact — a permanent
silent log gap. Now the failure path un-buffers and returns the counter
reservation: nothing commits, the log stays gap-free, and the canonical
execute-residue orphan is the documented crash-equivalent.
Plus: the kill-matrix itself (11 rows — every commit-path fault point ×
reopen-as-crash recovery contract, at-ack variants, disk-full row; five
new zero-cost faultPoint sites), the log-authority pin suite (oracle
green/red/state-differs, flip refusal, switch survives reopen, 9/9), and
the group-commit covering pins (5/5).
Gates: unit 2002/2002 (152 files) · integration 785 · conformance 27/27.
2026-08-10 09:29:21 -07:00
|
|
|
|
// LOG-AUTHORITY REPLAY (durable-at-ack's recovery half): when this
|
|
|
|
|
|
// brain's stored authority is the log, an intact fact ABOVE the
|
|
|
|
|
|
// manifest is an ACKED write whose canonical bytes may not have
|
|
|
|
|
|
// survived the crash — its fsynced fact is the ONLY durable copy.
|
|
|
|
|
|
// Truncating it would lose an acked write; instead REPLAY it into
|
|
|
|
|
|
// canonical and advance the manifest to cover it. Tree-authority
|
|
|
|
|
|
// brains keep the truncate contract (their acks never promised the
|
|
|
|
|
|
// fact was durable). Derived indexes reconcile through the normal
|
|
|
|
|
|
// drift machinery at open — same as group-commit recovery.
|
|
|
|
|
|
const authority = await readLogAuthority(this.storage)
|
|
|
|
|
|
if (authority.authority === 'log') {
|
2026-08-12 16:56:08 -07:00
|
|
|
|
this.authorityIsLog = true
|
2026-08-10 14:48:32 -07:00
|
|
|
|
// TWO REPLAY TIERS, gated by the clean-shutdown marker:
|
|
|
|
|
|
//
|
|
|
|
|
|
// (1) ABOVE-MANIFEST (always): an intact fact above the manifest is
|
|
|
|
|
|
// an acked write whose canonical bytes may not have survived —
|
|
|
|
|
|
// replay it in and advance the manifest.
|
|
|
|
|
|
// (2) WHOLE-LOG (unclean shutdown only): power loss can ALSO vaporize
|
|
|
|
|
|
// canonical bytes BELOW the manifest — live entity writes are
|
|
|
|
|
|
// tmp+rename without per-file fsync; the group-commit flush syncs
|
|
|
|
|
|
// the staging copies and the manifest, never the live tree. The
|
|
|
|
|
|
// manifest therefore over-states canonical durability across a
|
|
|
|
|
|
// power cut, and facts ≤ manifest can be the ONLY durable copy
|
|
|
|
|
|
// of acked state (measured: 299 of 301 acks lost while the log
|
|
|
|
|
|
// held every fact scan-clean). Under log authority, recovery is
|
|
|
|
|
|
// REPLAY: an unclean open folds the ENTIRE log into canonical —
|
|
|
|
|
|
// whole-entity after-images are idempotent, so re-applying
|
|
|
|
|
|
// already-intact records is byte-safe. A clean close writes the
|
|
|
|
|
|
// marker and skips all of this (zero open cost on the happy
|
|
|
|
|
|
// path); crash recovery pays one narrated log fold — LC1 and
|
|
|
|
|
|
// LC5 are the same code, a crash is just bigger lag.
|
|
|
|
|
|
const cleanShutdown = await this.readCleanShutdownMarker()
|
fix(log): acked writes survive power loss; rejected writes never silently commit — the kill-matrix goes 11/11 with zero .fails debt
Two release-blocking findings from the durability kill-matrix, both fixed
in the owning layer:
1. LOG-AUTHORITY REPLAY AT OPEN: durable-at-ack fsynced the fact before
the ack, but open() truncated every fact above the manifest — after a
power loss that takes the un-fsynced tmp+rename canonical bytes, the
acked write's ONLY durable copy was discarded. Now: under 'log'
authority, open() REPLAYS intact facts above the manifest into
canonical (FactLog.peekFactsAbove — CRC-gated, order-sorted) and
advances the manifest to cover them; tree-authority brains keep the
truncate contract they were promised. Pinned end to end: the power-loss
row constructs the exact disk state (fsynced log, vanished canonical
rename) and the acked write lives.
2. NO SILENT COMMIT: commitSingleOp buffered the generation BEFORE the
fact append; an append failure (ENOSPC) rejected the caller but the
next flush durably committed the generation with NO fact — a permanent
silent log gap. Now the failure path un-buffers and returns the counter
reservation: nothing commits, the log stays gap-free, and the canonical
execute-residue orphan is the documented crash-equivalent.
Plus: the kill-matrix itself (11 rows — every commit-path fault point ×
reopen-as-crash recovery contract, at-ack variants, disk-full row; five
new zero-cost faultPoint sites), the log-authority pin suite (oracle
green/red/state-differs, flip refusal, switch survives reopen, 9/9), and
the group-commit covering pins (5/5).
Gates: unit 2002/2002 (152 files) · integration 785 · conformance 27/27.
2026-08-10 09:29:21 -07:00
|
|
|
|
const orphans = await this.factLog.peekFactsAbove(this.committed)
|
2026-08-10 14:48:32 -07:00
|
|
|
|
const uncleanOpen = cleanShutdown === null || cleanShutdown !== this.committed
|
2026-08-12 16:56:08 -07:00
|
|
|
|
// FOLD-CHECKPOINT BOUND: a stored checkpoint G proves every entity
|
|
|
|
|
|
// whose latest fact is ≤ G has durable canonical bytes (each stamp
|
|
|
|
|
|
// followed a canonical-sync barrier), so the unclean fold only needs
|
|
|
|
|
|
// (G, head] — entities untouched since G are already safe, entities
|
|
|
|
|
|
// touched after G get their latest after-image re-applied. Absent or
|
|
|
|
|
|
// invalid checkpoint = fold from 0 (the 10.0 whole-log contract).
|
|
|
|
|
|
const checkpoint = await this.readFoldCheckpoint()
|
|
|
|
|
|
const foldBound = checkpoint ?? 0
|
|
|
|
|
|
// Chain validity: induction (a stored stamp), vacuous truth (an empty
|
|
|
|
|
|
// brain has no bytes to assert), or — set below — the base case (a
|
|
|
|
|
|
// whole-log fold re-applies and re-syncs every entity in the log).
|
|
|
|
|
|
this.foldCheckpointChainValid = checkpoint !== null || this.committed === 0
|
|
|
|
|
|
this.foldCheckpoint = foldBound
|
|
|
|
|
|
if (uncleanOpen) this.foldCheckpointChainValid = true
|
2026-08-10 14:48:32 -07:00
|
|
|
|
const factsToReplay = uncleanOpen
|
2026-08-12 16:56:08 -07:00
|
|
|
|
? await this.factLog.peekFactsAbove(foldBound)
|
2026-08-10 14:48:32 -07:00
|
|
|
|
: orphans
|
|
|
|
|
|
if (factsToReplay.length > 0) {
|
|
|
|
|
|
let replayed = 0
|
|
|
|
|
|
for (const fact of factsToReplay) {
|
fix(log): acked writes survive power loss; rejected writes never silently commit — the kill-matrix goes 11/11 with zero .fails debt
Two release-blocking findings from the durability kill-matrix, both fixed
in the owning layer:
1. LOG-AUTHORITY REPLAY AT OPEN: durable-at-ack fsynced the fact before
the ack, but open() truncated every fact above the manifest — after a
power loss that takes the un-fsynced tmp+rename canonical bytes, the
acked write's ONLY durable copy was discarded. Now: under 'log'
authority, open() REPLAYS intact facts above the manifest into
canonical (FactLog.peekFactsAbove — CRC-gated, order-sorted) and
advances the manifest to cover them; tree-authority brains keep the
truncate contract they were promised. Pinned end to end: the power-loss
row constructs the exact disk state (fsynced log, vanished canonical
rename) and the acked write lives.
2. NO SILENT COMMIT: commitSingleOp buffered the generation BEFORE the
fact append; an append failure (ENOSPC) rejected the caller but the
next flush durably committed the generation with NO fact — a permanent
silent log gap. Now the failure path un-buffers and returns the counter
reservation: nothing commits, the log stays gap-free, and the canonical
execute-residue orphan is the documented crash-equivalent.
Plus: the kill-matrix itself (11 rows — every commit-path fault point ×
reopen-as-crash recovery contract, at-ack variants, disk-full row; five
new zero-cost faultPoint sites), the log-authority pin suite (oracle
green/red/state-differs, flip refusal, switch survives reopen, 9/9), and
the group-commit covering pins (5/5).
Gates: unit 2002/2002 (152 files) · integration 785 · conformance 27/27.
2026-08-10 09:29:21 -07:00
|
|
|
|
for (const op of fact.ops) {
|
|
|
|
|
|
const image =
|
|
|
|
|
|
op.record === null
|
|
|
|
|
|
? { metadata: null, vector: null }
|
|
|
|
|
|
: { metadata: op.record.metadata, vector: op.record.vector }
|
|
|
|
|
|
if (op.kind === 'verb') await this.storage.writeVerbRaw(op.id, image)
|
|
|
|
|
|
else await this.storage.writeNounRaw(op.id, image)
|
2026-08-12 16:56:08 -07:00
|
|
|
|
this.noteCheckpointDirty(op.kind, op.id)
|
fix(log): acked writes survive power loss; rejected writes never silently commit — the kill-matrix goes 11/11 with zero .fails debt
Two release-blocking findings from the durability kill-matrix, both fixed
in the owning layer:
1. LOG-AUTHORITY REPLAY AT OPEN: durable-at-ack fsynced the fact before
the ack, but open() truncated every fact above the manifest — after a
power loss that takes the un-fsynced tmp+rename canonical bytes, the
acked write's ONLY durable copy was discarded. Now: under 'log'
authority, open() REPLAYS intact facts above the manifest into
canonical (FactLog.peekFactsAbove — CRC-gated, order-sorted) and
advances the manifest to cover them; tree-authority brains keep the
truncate contract they were promised. Pinned end to end: the power-loss
row constructs the exact disk state (fsynced log, vanished canonical
rename) and the acked write lives.
2. NO SILENT COMMIT: commitSingleOp buffered the generation BEFORE the
fact append; an append failure (ENOSPC) rejected the caller but the
next flush durably committed the generation with NO fact — a permanent
silent log gap. Now the failure path un-buffers and returns the counter
reservation: nothing commits, the log stays gap-free, and the canonical
execute-residue orphan is the documented crash-equivalent.
Plus: the kill-matrix itself (11 rows — every commit-path fault point ×
reopen-as-crash recovery contract, at-ack variants, disk-full row; five
new zero-cost faultPoint sites), the log-authority pin suite (oracle
green/red/state-differs, flip refusal, switch survives reopen, 9/9), and
the group-commit covering pins (5/5).
Gates: unit 2002/2002 (152 files) · integration 785 · conformance 27/27.
2026-08-10 09:29:21 -07:00
|
|
|
|
}
|
2026-08-10 14:48:32 -07:00
|
|
|
|
replayed++
|
|
|
|
|
|
if (fact.generation > this.committed) {
|
|
|
|
|
|
this.committed = fact.generation
|
|
|
|
|
|
this.appendCommittedGen(fact.generation)
|
|
|
|
|
|
this.setDelta(fact.generation, {
|
|
|
|
|
|
nouns: new Set(fact.ops.filter((o) => o.kind === 'noun').map((o) => o.id)),
|
|
|
|
|
|
verbs: new Set(fact.ops.filter((o) => o.kind === 'verb').map((o) => o.id)),
|
|
|
|
|
|
timestamp: fact.timestamp,
|
|
|
|
|
|
bytes: 0
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
fix(log): acked writes survive power loss; rejected writes never silently commit — the kill-matrix goes 11/11 with zero .fails debt
Two release-blocking findings from the durability kill-matrix, both fixed
in the owning layer:
1. LOG-AUTHORITY REPLAY AT OPEN: durable-at-ack fsynced the fact before
the ack, but open() truncated every fact above the manifest — after a
power loss that takes the un-fsynced tmp+rename canonical bytes, the
acked write's ONLY durable copy was discarded. Now: under 'log'
authority, open() REPLAYS intact facts above the manifest into
canonical (FactLog.peekFactsAbove — CRC-gated, order-sorted) and
advances the manifest to cover them; tree-authority brains keep the
truncate contract they were promised. Pinned end to end: the power-loss
row constructs the exact disk state (fsynced log, vanished canonical
rename) and the acked write lives.
2. NO SILENT COMMIT: commitSingleOp buffered the generation BEFORE the
fact append; an append failure (ENOSPC) rejected the caller but the
next flush durably committed the generation with NO fact — a permanent
silent log gap. Now the failure path un-buffers and returns the counter
reservation: nothing commits, the log stays gap-free, and the canonical
execute-residue orphan is the documented crash-equivalent.
Plus: the kill-matrix itself (11 rows — every commit-path fault point ×
reopen-as-crash recovery contract, at-ack variants, disk-full row; five
new zero-cost faultPoint sites), the log-authority pin suite (oracle
green/red/state-differs, flip refusal, switch survives reopen, 9/9), and
the group-commit covering pins (5/5).
Gates: unit 2002/2002 (152 files) · integration 785 · conformance 27/27.
2026-08-10 09:29:21 -07:00
|
|
|
|
}
|
|
|
|
|
|
if (this.counter < this.committed) this.counter = this.committed
|
|
|
|
|
|
await this.persistCounterUnlocked()
|
|
|
|
|
|
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])
|
|
|
|
|
|
prodLog.warn(
|
2026-08-10 14:48:32 -07:00
|
|
|
|
`[GenerationStore] log-authority recovery replayed ${replayed} fact(s) into ` +
|
2026-08-12 16:56:08 -07:00
|
|
|
|
`canonical (${
|
|
|
|
|
|
uncleanOpen
|
|
|
|
|
|
? foldBound > 0
|
|
|
|
|
|
? `BOUNDED fold above checkpoint ${foldBound} — unclean shutdown`
|
|
|
|
|
|
: 'WHOLE-LOG fold — unclean shutdown'
|
|
|
|
|
|
: 'above-manifest'
|
|
|
|
|
|
}; committed at ${this.committed}) — an acked write is never lost`
|
fix(log): acked writes survive power loss; rejected writes never silently commit — the kill-matrix goes 11/11 with zero .fails debt
Two release-blocking findings from the durability kill-matrix, both fixed
in the owning layer:
1. LOG-AUTHORITY REPLAY AT OPEN: durable-at-ack fsynced the fact before
the ack, but open() truncated every fact above the manifest — after a
power loss that takes the un-fsynced tmp+rename canonical bytes, the
acked write's ONLY durable copy was discarded. Now: under 'log'
authority, open() REPLAYS intact facts above the manifest into
canonical (FactLog.peekFactsAbove — CRC-gated, order-sorted) and
advances the manifest to cover them; tree-authority brains keep the
truncate contract they were promised. Pinned end to end: the power-loss
row constructs the exact disk state (fsynced log, vanished canonical
rename) and the acked write lives.
2. NO SILENT COMMIT: commitSingleOp buffered the generation BEFORE the
fact append; an append failure (ENOSPC) rejected the caller but the
next flush durably committed the generation with NO fact — a permanent
silent log gap. Now the failure path un-buffers and returns the counter
reservation: nothing commits, the log stays gap-free, and the canonical
execute-residue orphan is the documented crash-equivalent.
Plus: the kill-matrix itself (11 rows — every commit-path fault point ×
reopen-as-crash recovery contract, at-ack variants, disk-full row; five
new zero-cost faultPoint sites), the log-authority pin suite (oracle
green/red/state-differs, flip refusal, switch survives reopen, 9/9), and
the group-commit covering pins (5/5).
Gates: unit 2002/2002 (152 files) · integration 785 · conformance 27/27.
2026-08-10 09:29:21 -07:00
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-08-12 16:56:08 -07:00
|
|
|
|
// A recovery fold re-applied (and the barrier below re-syncs) every
|
|
|
|
|
|
// entity in (bound, head] — stamp the checkpoint at the new committed
|
|
|
|
|
|
// watermark so the NEXT crash folds only its own tail. This is also
|
|
|
|
|
|
// the chain's base case: the first whole-log fold of a pre-checkpoint
|
|
|
|
|
|
// brain covers every entity in the log, so its stamp is total.
|
|
|
|
|
|
if (uncleanOpen) await this.advanceFoldCheckpointUnlocked()
|
2026-08-10 14:48:32 -07:00
|
|
|
|
// The marker is consumed: any session that can write invalidates it
|
|
|
|
|
|
// at first commit (see the commit paths); a clean close re-writes it.
|
|
|
|
|
|
await this.clearCleanShutdownMarker()
|
fix(log): acked writes survive power loss; rejected writes never silently commit — the kill-matrix goes 11/11 with zero .fails debt
Two release-blocking findings from the durability kill-matrix, both fixed
in the owning layer:
1. LOG-AUTHORITY REPLAY AT OPEN: durable-at-ack fsynced the fact before
the ack, but open() truncated every fact above the manifest — after a
power loss that takes the un-fsynced tmp+rename canonical bytes, the
acked write's ONLY durable copy was discarded. Now: under 'log'
authority, open() REPLAYS intact facts above the manifest into
canonical (FactLog.peekFactsAbove — CRC-gated, order-sorted) and
advances the manifest to cover them; tree-authority brains keep the
truncate contract they were promised. Pinned end to end: the power-loss
row constructs the exact disk state (fsynced log, vanished canonical
rename) and the acked write lives.
2. NO SILENT COMMIT: commitSingleOp buffered the generation BEFORE the
fact append; an append failure (ENOSPC) rejected the caller but the
next flush durably committed the generation with NO fact — a permanent
silent log gap. Now the failure path un-buffers and returns the counter
reservation: nothing commits, the log stays gap-free, and the canonical
execute-residue orphan is the documented crash-equivalent.
Plus: the kill-matrix itself (11 rows — every commit-path fault point ×
reopen-as-crash recovery contract, at-ack variants, disk-full row; five
new zero-cost faultPoint sites), the log-authority pin suite (oracle
green/red/state-differs, flip refusal, switch survives reopen, 9/9), and
the group-commit covering pins (5/5).
Gates: unit 2002/2002 (152 files) · integration 785 · conformance 27/27.
2026-08-10 09:29:21 -07:00
|
|
|
|
}
|
feat: generation fact log — after-image commit records, dual-written at every commit point
Every committed generation now also appends a FACT — an after-image
commit record (what each touched entity/relationship became, or a
body-less tombstone for a removal) — to an append-only, crc32c-framed
segment log under _generations/facts/. The before-image history and the
canonical tree remain authoritative; the fact log gives consumers ONE
sequential, self-verifying stream (index heals, incremental replays)
in place of a per-entity directory walk.
- Wire format: positional msgpack facts [generation, timestamp, ops,
meta, blobHashes]; op = [kind u8, id bin16, record | nil tombstone];
32-byte segment header (magic, formatVersion, firstGeneration,
zeroed+verified reserved); length+crc32c frame per fact; zero-padded
segment names so lexicographic order == generation order; JSON
manifest with an atomic rename flip, manifest-first rotation.
- Commit protocol: facts append+fsync BEFORE the commit point inside
the existing durability window, so a crash can only leave the log
AHEAD of committed truth — open() truncates back (torn tails detected
by CRC). Absent generation = never committed; a scan can never see an
uncommitted fact. transact() facts are durable-on-return; single-op
facts ride the group-commit flush exactly like buffered history. A
fact-append failure fails the write, loudly — a silent gap would be a
lie a later replay discovers.
- New public surface: brain.scanFacts() (sequential batches with heal
telemetry: head/segments/approx up front, per-batch generation range
+ bytes + segment id, loud abort on gaps, summary cross-check) and
brain.factSegmentPaths() (immutable sealed segments for zero-copy
consumers; the mutable tail excluded). Exported types CommitFact,
FactOp, FactScanBatch, FactScanHandle.
- Storage: optional binary raw-byte primitives (appendRawBytes,
readRawBytes, writeRawBytes, rawByteSize) on StorageAdapter —
feature-detected; filesystem + memory adapters implement them; an
adapter without them hosts no fact log. Fact segments are byte-copied
(never hard-linked) into snapshots. The _generations/facts/ namespace
is registered as a protected family (rebuildable: false): no sweeper
or GC may delete under it.
- New crc32c (Castagnoli) utility with RFC known-answer tests.
2026-07-15 10:49:02 -07:00
|
|
|
|
await this.factLog.open(this.committed)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this.factLog = null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-19 16:26:10 -07:00
|
|
|
|
// PACKED TIER (D1+D3): same capability gate as the fact log. Opening
|
|
|
|
|
|
// reads ONE manifest — never a listing of the packed backlog — and seeds
|
|
|
|
|
|
// committedRanges with the sealed ranges so packed generations resolve
|
|
|
|
|
|
// exactly like live ones.
|
|
|
|
|
|
if (storageSupportsFactLog(this.storage)) {
|
|
|
|
|
|
this.segments = new GenerationSegmentStore(this.storage)
|
|
|
|
|
|
await this.segments.open()
|
|
|
|
|
|
const packedRanges = this.segments
|
|
|
|
|
|
.segments()
|
|
|
|
|
|
.map((s): [number, number] => [s.firstGeneration, Math.min(s.lastGeneration, this.committed)])
|
|
|
|
|
|
.filter(([lo, hi]) => lo <= hi)
|
|
|
|
|
|
if (packedRanges.length > 0) {
|
|
|
|
|
|
// Merge packed (older) + live (newer) interval sets — both ascending;
|
|
|
|
|
|
// coalesce adjacency so range arithmetic stays interval-exact.
|
|
|
|
|
|
const merged: Array<[number, number]> = []
|
|
|
|
|
|
for (const r of [...packedRanges, ...this.committedRanges].sort((a, b) => a[0] - b[0])) {
|
|
|
|
|
|
const last = merged[merged.length - 1]
|
|
|
|
|
|
if (last && r[0] <= last[1] + 1) last[1] = Math.max(last[1], r[1])
|
|
|
|
|
|
else merged.push([r[0], r[1]])
|
|
|
|
|
|
}
|
|
|
|
|
|
this.committedRanges = merged
|
|
|
|
|
|
}
|
|
|
|
|
|
this.horizonGen = Math.max(this.horizonGen, this.segments.compactedBelow() - 1)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this.segments = null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
// 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()
|
2026-08-12 16:56:08 -07:00
|
|
|
|
// Fold-checkpoint barrier BEFORE the clean-shutdown marker: entities that
|
|
|
|
|
|
// reached the accumulator outside the pending tier (transact commits,
|
|
|
|
|
|
// aborted-write restores) get their canonical bytes synced and the stamp
|
|
|
|
|
|
// advanced, so the marker below never vouches for bytes the checkpoint
|
|
|
|
|
|
// chain hasn't proven durable.
|
|
|
|
|
|
await this.advanceFoldCheckpoint()
|
2026-08-10 14:48:32 -07:00
|
|
|
|
// Clean-shutdown marker (log-authority recovery gate): everything above
|
|
|
|
|
|
// is durable; stamp the committed generation so the next open can adopt
|
|
|
|
|
|
// instead of folding the log. Written LAST — a crash before this line is
|
|
|
|
|
|
// exactly the unclean case the marker's absence reports.
|
|
|
|
|
|
try {
|
|
|
|
|
|
await this.storage.writeRawObject(CLEAN_SHUTDOWN_PATH, { generation: this.committed })
|
|
|
|
|
|
await this.storage.syncRawObjects([CLEAN_SHUTDOWN_PATH])
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// A failed marker write only costs the next open a replay fold — safe.
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Read the clean-shutdown marker's generation, or null (absent/unreadable). */
|
|
|
|
|
|
private async readCleanShutdownMarker(): Promise<number | null> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const raw = (await this.storage.readRawObject(CLEAN_SHUTDOWN_PATH)) as {
|
|
|
|
|
|
generation?: number
|
|
|
|
|
|
} | null
|
|
|
|
|
|
return raw && Number.isSafeInteger(raw.generation) ? (raw.generation as number) : null
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Consume the clean-shutdown marker (every open; a clean close re-writes it). */
|
|
|
|
|
|
private async clearCleanShutdownMarker(): Promise<void> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await this.storage.deleteRawObject(CLEAN_SHUTDOWN_PATH)
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// Absent or undeletable: the conservative outcome is a future replay.
|
|
|
|
|
|
}
|
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-08-12 16:56:08 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* Read the fold checkpoint's generation, or `null` when absent, torn, or
|
|
|
|
|
|
* implausible (> committed) — every invalid shape degrades to the safe
|
|
|
|
|
|
* whole-log fold, never to a bound that could skip an acked write.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private async readFoldCheckpoint(): Promise<number | null> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const raw = (await this.storage.readRawObject(FOLD_CHECKPOINT_PATH)) as {
|
|
|
|
|
|
generation?: number
|
|
|
|
|
|
} | null
|
|
|
|
|
|
const gen = raw?.generation
|
|
|
|
|
|
if (!Number.isSafeInteger(gen) || (gen as number) < 0) return null
|
|
|
|
|
|
if ((gen as number) > this.committed) {
|
|
|
|
|
|
prodLog.warn(
|
|
|
|
|
|
`[GenerationStore] fold checkpoint ${gen} is ahead of the manifest ` +
|
|
|
|
|
|
`(${this.committed}) — ignoring it; recovery folds the whole log`
|
|
|
|
|
|
)
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
return gen as number
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Record that an entity's canonical live bytes were (re)written and are not
|
|
|
|
|
|
* yet covered by a checkpoint stamp. Gated on chain validity so brains
|
|
|
|
|
|
* without a sound chain (tree authority, or log authority before its first
|
|
|
|
|
|
* recovery fold) never accumulate — they keep the fold-from-0 contract.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private noteCheckpointDirty(kind: 'noun' | 'verb', id: string): void {
|
|
|
|
|
|
if (!this.foldCheckpointChainValid) return
|
|
|
|
|
|
if (kind === 'verb') this.checkpointDirtyVerbs.add(id)
|
|
|
|
|
|
else this.checkpointDirtyNouns.add(id)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* The canonical-sync barrier + checkpoint stamp (must run under the commit
|
|
|
|
|
|
* mutex or in single-threaded open). Drains the dirty accumulator, makes
|
|
|
|
|
|
* those entities' canonical bytes durable via the adapter barrier, and only
|
|
|
|
|
|
* THEN stamps `_system/fold-checkpoint.json` at the committed watermark —
|
|
|
|
|
|
* stamp-after-data, always. On any failure the drained ids merge back and
|
|
|
|
|
|
* the stored checkpoint stays where it was: the bound can lag (a bigger
|
|
|
|
|
|
* fold later) but can never overstate durability (a lost write, outlawed).
|
|
|
|
|
|
*/
|
|
|
|
|
|
private async advanceFoldCheckpointUnlocked(): Promise<void> {
|
|
|
|
|
|
if (!this.foldCheckpointChainValid || !this.authorityIsLog || !this.factLog) return
|
|
|
|
|
|
const nouns = [...this.checkpointDirtyNouns]
|
|
|
|
|
|
const verbs = [...this.checkpointDirtyVerbs]
|
|
|
|
|
|
const target = this.committed
|
|
|
|
|
|
if (nouns.length === 0 && verbs.length === 0 && target === this.foldCheckpoint) return
|
|
|
|
|
|
this.checkpointDirtyNouns = new Set()
|
|
|
|
|
|
this.checkpointDirtyVerbs = new Set()
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (nouns.length > 0 || verbs.length > 0) {
|
|
|
|
|
|
await this.storage.syncEntityCanonical?.(nouns, verbs)
|
|
|
|
|
|
}
|
|
|
|
|
|
await this.storage.writeRawObject(FOLD_CHECKPOINT_PATH, { generation: target })
|
|
|
|
|
|
await this.storage.syncRawObjects([FOLD_CHECKPOINT_PATH])
|
|
|
|
|
|
this.foldCheckpoint = target
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
for (const id of nouns) this.checkpointDirtyNouns.add(id)
|
|
|
|
|
|
for (const id of verbs) this.checkpointDirtyVerbs.add(id)
|
|
|
|
|
|
prodLog.warn(
|
|
|
|
|
|
`[GenerationStore] fold-checkpoint barrier failed at generation ${target} ` +
|
|
|
|
|
|
`(${(err as Error).message}) — checkpoint stays at ${this.foldCheckpoint}; ` +
|
|
|
|
|
|
`recovery would fold from there (bigger, never lossy). Will retry next flush.`
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description Public, mutex-serialized fold-checkpoint advance — called by
|
|
|
|
|
|
* `close()` after the final flush so entities touched by paths that do not
|
|
|
|
|
|
* ride the pending tier (e.g. `transact()`) are covered before the
|
|
|
|
|
|
* clean-shutdown marker is written.
|
|
|
|
|
|
*/
|
|
|
|
|
|
async advanceFoldCheckpoint(): Promise<void> {
|
|
|
|
|
|
return this.withMutex(() => this.advanceFoldCheckpointUnlocked())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description Adoption-time chain bootstrap, phase 1 — called by
|
|
|
|
|
|
* `adoptLogAuthority()` BEFORE its oracle/backfill passes. Only a FRESH
|
|
|
|
|
|
* brain (committed === 0) may bootstrap here: with no committed
|
|
|
|
|
|
* generations the chain's assertion is vacuously true, and arming it now
|
|
|
|
|
|
* means the baseline backfill's own re-commits feed the dirty accumulator,
|
|
|
|
|
|
* so the first stamp after the flip covers them. A non-fresh flip skips
|
|
|
|
|
|
* this (returns false) — its chain starts at the brain's first recovery
|
|
|
|
|
|
* fold instead, because only a whole-log fold can prove coverage of
|
|
|
|
|
|
* entities written before the log existed.
|
|
|
|
|
|
*/
|
|
|
|
|
|
beginFoldCheckpointBootstrap(): boolean {
|
|
|
|
|
|
if (this.committed !== 0 || this.foldCheckpointChainValid) {
|
|
|
|
|
|
return this.foldCheckpointChainValid
|
|
|
|
|
|
}
|
|
|
|
|
|
this.foldCheckpointChainValid = true
|
|
|
|
|
|
this.foldCheckpoint = 0
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description Adoption-time chain bootstrap, phase 2 — called after
|
|
|
|
|
|
* `flipToLogAuthority` records the flip. Opens the stamp gate; the next
|
|
|
|
|
|
* flush/close barrier writes the first checkpoint.
|
|
|
|
|
|
*/
|
|
|
|
|
|
completeFoldCheckpointBootstrap(): void {
|
|
|
|
|
|
this.authorityIsLog = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description Adoption-time chain bootstrap, abort — called when an
|
|
|
|
|
|
* adoption attempt throws or refuses after phase 1. Disarms the chain and
|
|
|
|
|
|
* drops the accumulator so a tree-authority brain never accumulates or
|
|
|
|
|
|
* stamps. (If the chain was valid BEFORE the attempt — a stored checkpoint
|
|
|
|
|
|
* exists — it stays valid; only a phase-1 arm is undone.)
|
|
|
|
|
|
*/
|
|
|
|
|
|
abandonFoldCheckpointBootstrap(): void {
|
|
|
|
|
|
if (this.authorityIsLog) return
|
|
|
|
|
|
this.foldCheckpointChainValid = false
|
|
|
|
|
|
this.checkpointDirtyNouns = new Set()
|
|
|
|
|
|
this.checkpointDirtyVerbs = new Set()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-18 10:51:37 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* @description Read-only history footprint for fleet audits: how much
|
|
|
|
|
|
* generational history this store holds on disk. `bytes` pays (and seeds)
|
|
|
|
|
|
* the one-time {@link historyBytes} walk on first call — subsequent calls
|
|
|
|
|
|
* are O(1). The oldest/newest timestamps come from those generations'
|
|
|
|
|
|
* deltas (cache-bounded reads).
|
|
|
|
|
|
* @returns Counts, bytes, generation range, and the compaction horizon.
|
|
|
|
|
|
*/
|
2026-07-19 16:26:10 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* @description D8 (gate-to-generation provenance): a deterministic content
|
|
|
|
|
|
* digest of the generation log THROUGH `g` — identical history ⇒ identical
|
|
|
|
|
|
* digest on any machine; any divergence (different records, different
|
|
|
|
|
|
* order, reclaimed range) ⇒ different digest. Composed from the packed
|
|
|
|
|
|
* tier's sealed-segment checksum chain (O(segments)) plus the live tier's
|
|
|
|
|
|
* per-generation delta digests (O(live window at most)). Release gates pin
|
|
|
|
|
|
* {generation, digest} and verify both at execution time.
|
|
|
|
|
|
* @param g - The generation to digest through (≤ committed).
|
|
|
|
|
|
* @returns A hex digest string, stable across reopen and repacking states
|
|
|
|
|
|
* ONLY for fully-packed prefixes — repacking changes representation, so
|
|
|
|
|
|
* the composed digest is defined over CONTENT: live-tier gens hash their
|
|
|
|
|
|
* delta + record ids, packed gens hash via frame CRCs. A gate should pin
|
|
|
|
|
|
* after a repack pass for long-term stability, or re-pin on repack.
|
|
|
|
|
|
*/
|
|
|
|
|
|
async generationDigest(g: number): Promise<string> {
|
|
|
|
|
|
if (!Number.isInteger(g) || g < 1 || g > this.committed) {
|
|
|
|
|
|
throw new RangeError(
|
|
|
|
|
|
`generationDigest(): generation ${g} is out of range [1, ${this.committed}]`
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (g <= this.horizonGen) {
|
|
|
|
|
|
throw new GenerationCompactedError(g, this.horizonGen)
|
|
|
|
|
|
}
|
|
|
|
|
|
let digest = 0
|
|
|
|
|
|
const enc = new TextEncoder()
|
|
|
|
|
|
if (this.segments) {
|
|
|
|
|
|
const packed = await this.segments.digestThroughPacked(g)
|
|
|
|
|
|
if (packed !== null) digest = packed
|
|
|
|
|
|
}
|
|
|
|
|
|
// Live-tier composition: every committed gen ≤ g not covered by a sealed
|
|
|
|
|
|
// segment hashes its delta content in ascending order.
|
|
|
|
|
|
for (const gen of this.committedGensAsc()) {
|
|
|
|
|
|
if (gen > g) break
|
|
|
|
|
|
if (this.segments?.hasGeneration(gen)) continue
|
|
|
|
|
|
const delta = await this.getDelta(gen)
|
|
|
|
|
|
digest = crc32c(
|
|
|
|
|
|
enc.encode(
|
|
|
|
|
|
`${digest}:${gen}:${delta.timestamp}:${[...delta.nouns].sort().join(',')}:${[...delta.verbs].sort().join(',')}`
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
return digest.toString(16).padStart(8, '0')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-18 10:51:37 -07:00
|
|
|
|
async historyStats(): Promise<{
|
|
|
|
|
|
generations: number
|
|
|
|
|
|
bytes: number
|
|
|
|
|
|
oldestGeneration: number | null
|
|
|
|
|
|
newestGeneration: number | null
|
|
|
|
|
|
oldestTimestamp: number | null
|
|
|
|
|
|
newestTimestamp: number | null
|
|
|
|
|
|
horizon: number
|
|
|
|
|
|
}> {
|
|
|
|
|
|
let oldest: number | null = null
|
|
|
|
|
|
let newest: number | null = null
|
|
|
|
|
|
for (const gen of this.committedGensAsc()) {
|
|
|
|
|
|
if (oldest === null) oldest = gen
|
|
|
|
|
|
newest = gen
|
|
|
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
|
|
|
generations: this.committedCount(),
|
|
|
|
|
|
bytes: await this.historyBytes(),
|
|
|
|
|
|
oldestGeneration: oldest,
|
|
|
|
|
|
newestGeneration: newest,
|
|
|
|
|
|
oldestTimestamp: oldest !== null ? (await this.getDelta(oldest)).timestamp : null,
|
|
|
|
|
|
newestTimestamp: newest !== null ? (await this.getDelta(newest)).timestamp : null,
|
|
|
|
|
|
horizon: this.horizonGen
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat: temporal VFS — file content joins the Model-B immutability model
The temporal model had a hole exactly where files were concerned: every
entity write is an immutable generation with before-images, but VFS content
BYTES lived under an eager refCount GC left over from the pre-8.0 design —
unlink could physically destroy bytes that in-window history still
referenced, and overwrite never released the old hash at all (an unbounded
silent leak whose accidental byproduct was the only thing "preserving"
history). Reading the past could therefore return a stale field, a dangling
hash, or nothing, depending on luck.
Fix: blob reclamation becomes a HISTORY decision instead of a LIVENESS
decision. Each blob's metadata now carries historyRefCount alongside the
live refCount:
- The commit seam counts one history reference per persisted before-image
record carrying a content hash (commitTransaction staging and the
group-commit flush), recorded BEFORE the record-set persists and carried
in the generation delta (blobHashes — always present on new deltas, so
compaction only falls back to reading records for pre-contract
generations). An aborted transaction compensates best-effort.
- unlink/rmdir/overwrite drop ONLY the live reference (BlobStorage.delete →
release; overwrite finally releases the superseded hash — cancelling the
dedup increment on same-content rewrites and closing the leak), and only
AFTER the canonical mutation commits, so a failed delete can never leave a
live file whose bytes compaction might reclaim.
- History compaction is the ONE reclamation point: after deleting a
generation's record-set it releases that set's references and physically
reclaims any hash at zero live AND zero history references. Pins are
exempt automatically. Crash ordering is over-count-only in every path
(record before persist, release after delete), so a crash can leak until
the scrub recounts but can never reclaim bytes a retained generation
needs. scrubBlobHistoryRefCounts() restores exactness; existing stores get
a one-time marker-gated backfill on open, failing into leak-safe mode
(reclamation disabled) rather than guessing.
On top of the protected history, the temporal API the generational model
always implied:
- vfs.readFile(path, { asOf }) — the exact bytes as of a generation or Date,
materialized from the history (pinned view released so compaction is
never blocked by a read).
- vfs.history(path) — FileVersion[] ascending ({ generation, timestamp,
hash, size, mimeType? }), the newest entry being the live state.
- Overwrites now refresh the file entity's data/embedding text — semantic
search and the data field previously served the FIRST version's text
forever (the stale-field defect a consumer's incident recovery depended
on by luck).
Integration suite (temporal-vfs.test.ts): per-version exact reads +
history listing, leak-fix + history protection on overwrite, rm keeps bytes
readable, compaction reclaims past-window bytes and preserves in-window
(including the cross-file dedup case where an old file's history and a
newer file's removal share one hash), data freshness, and scrub exactness.
2026-07-10 16:43:48 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* @description Read one generation's persisted before-image records — the
|
|
|
|
|
|
* compaction fallback for generations written before deltas carried
|
|
|
|
|
|
* `blobHashes`. O(that generation's records).
|
|
|
|
|
|
* @param gen - The generation whose `prev/` records to read.
|
|
|
|
|
|
* @returns The record-set (empty when absent).
|
|
|
|
|
|
*/
|
|
|
|
|
|
private async readGenerationRecords(gen: number): Promise<GenerationRecord[]> {
|
|
|
|
|
|
let paths: string[] = []
|
|
|
|
|
|
try {
|
|
|
|
|
|
paths = await this.storage.listRawObjects(`${GENERATIONS_PREFIX}/${gen}/prev`)
|
|
|
|
|
|
} catch {
|
2026-07-19 16:26:10 -07:00
|
|
|
|
paths = []
|
feat: temporal VFS — file content joins the Model-B immutability model
The temporal model had a hole exactly where files were concerned: every
entity write is an immutable generation with before-images, but VFS content
BYTES lived under an eager refCount GC left over from the pre-8.0 design —
unlink could physically destroy bytes that in-window history still
referenced, and overwrite never released the old hash at all (an unbounded
silent leak whose accidental byproduct was the only thing "preserving"
history). Reading the past could therefore return a stale field, a dangling
hash, or nothing, depending on luck.
Fix: blob reclamation becomes a HISTORY decision instead of a LIVENESS
decision. Each blob's metadata now carries historyRefCount alongside the
live refCount:
- The commit seam counts one history reference per persisted before-image
record carrying a content hash (commitTransaction staging and the
group-commit flush), recorded BEFORE the record-set persists and carried
in the generation delta (blobHashes — always present on new deltas, so
compaction only falls back to reading records for pre-contract
generations). An aborted transaction compensates best-effort.
- unlink/rmdir/overwrite drop ONLY the live reference (BlobStorage.delete →
release; overwrite finally releases the superseded hash — cancelling the
dedup increment on same-content rewrites and closing the leak), and only
AFTER the canonical mutation commits, so a failed delete can never leave a
live file whose bytes compaction might reclaim.
- History compaction is the ONE reclamation point: after deleting a
generation's record-set it releases that set's references and physically
reclaims any hash at zero live AND zero history references. Pins are
exempt automatically. Crash ordering is over-count-only in every path
(record before persist, release after delete), so a crash can leak until
the scrub recounts but can never reclaim bytes a retained generation
needs. scrubBlobHistoryRefCounts() restores exactness; existing stores get
a one-time marker-gated backfill on open, failing into leak-safe mode
(reclamation disabled) rather than guessing.
On top of the protected history, the temporal API the generational model
always implied:
- vfs.readFile(path, { asOf }) — the exact bytes as of a generation or Date,
materialized from the history (pinned view released so compaction is
never blocked by a read).
- vfs.history(path) — FileVersion[] ascending ({ generation, timestamp,
hash, size, mimeType? }), the newest entry being the live state.
- Overwrites now refresh the file entity's data/embedding text — semantic
search and the data field previously served the FIRST version's text
forever (the stale-field defect a consumer's incident recovery depended
on by luck).
Integration suite (temporal-vfs.test.ts): per-version exact reads +
history listing, leak-fix + history protection on overwrite, rm keeps bytes
readable, compaction reclaims past-window bytes and preserves in-window
(including the cross-file dedup case where an old file's history and a
newer file's removal share one hash), data freshness, and scrub exactness.
2026-07-10 16:43:48 -07:00
|
|
|
|
}
|
|
|
|
|
|
const records: GenerationRecord[] = []
|
|
|
|
|
|
for (const p of paths) {
|
|
|
|
|
|
const record = (await this.storage.readRawObject(p)) as GenerationRecord | null
|
|
|
|
|
|
if (record) records.push(record)
|
|
|
|
|
|
}
|
2026-07-19 16:26:10 -07:00
|
|
|
|
if (records.length > 0) return records
|
|
|
|
|
|
// Two-tier: folded generations serve their record-set from the segment.
|
|
|
|
|
|
const packed = await this.segments?.readRecords(gen)
|
|
|
|
|
|
return packed ? (packed.map((r) => r.record) as GenerationRecord[]) : []
|
feat: temporal VFS — file content joins the Model-B immutability model
The temporal model had a hole exactly where files were concerned: every
entity write is an immutable generation with before-images, but VFS content
BYTES lived under an eager refCount GC left over from the pre-8.0 design —
unlink could physically destroy bytes that in-window history still
referenced, and overwrite never released the old hash at all (an unbounded
silent leak whose accidental byproduct was the only thing "preserving"
history). Reading the past could therefore return a stale field, a dangling
hash, or nothing, depending on luck.
Fix: blob reclamation becomes a HISTORY decision instead of a LIVENESS
decision. Each blob's metadata now carries historyRefCount alongside the
live refCount:
- The commit seam counts one history reference per persisted before-image
record carrying a content hash (commitTransaction staging and the
group-commit flush), recorded BEFORE the record-set persists and carried
in the generation delta (blobHashes — always present on new deltas, so
compaction only falls back to reading records for pre-contract
generations). An aborted transaction compensates best-effort.
- unlink/rmdir/overwrite drop ONLY the live reference (BlobStorage.delete →
release; overwrite finally releases the superseded hash — cancelling the
dedup increment on same-content rewrites and closing the leak), and only
AFTER the canonical mutation commits, so a failed delete can never leave a
live file whose bytes compaction might reclaim.
- History compaction is the ONE reclamation point: after deleting a
generation's record-set it releases that set's references and physically
reclaims any hash at zero live AND zero history references. Pins are
exempt automatically. Crash ordering is over-count-only in every path
(record before persist, release after delete), so a crash can leak until
the scrub recounts but can never reclaim bytes a retained generation
needs. scrubBlobHistoryRefCounts() restores exactness; existing stores get
a one-time marker-gated backfill on open, failing into leak-safe mode
(reclamation disabled) rather than guessing.
On top of the protected history, the temporal API the generational model
always implied:
- vfs.readFile(path, { asOf }) — the exact bytes as of a generation or Date,
materialized from the history (pinned view released so compaction is
never blocked by a read).
- vfs.history(path) — FileVersion[] ascending ({ generation, timestamp,
hash, size, mimeType? }), the newest entry being the live state.
- Overwrites now refresh the file entity's data/embedding text — semantic
search and the data field previously served the FIRST version's text
forever (the stale-field defect a consumer's incident recovery depended
on by luck).
Integration suite (temporal-vfs.test.ts): per-version exact reads +
history listing, leak-fix + history protection on overwrite, rm keeps bytes
readable, compaction reclaims past-window bytes and preserves in-window
(including the cross-file dedup case where an old file's history and a
newer file's removal share one hash), data freshness, and scrub exactness.
2026-07-10 16:43:48 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
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 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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
fix(recovery): walks are healers — the typed/tolerant boundary redrawn where block-layer fault injection proved it belonged
The quiet-loss cure regressed recovery: the new typed torn-record error
was correct at identity-read time but threw inside init-time recovery
walks, killing opens that previously survived. The boundary, redrawn:
- IDENTITY READS (get-by-id of a specific record, CAS blob point-get):
typed TornRecordError, unchanged — a caller who asked for THAT record
can act on the answer.
- SET-SHAPED READS AND WALKS (enumeration, pagination, batch hydration —
the paths recovery rebuilds and finds page over): HEAL PAST the torn
victim. The adapter's loud floor (error log + counted gauge) fires at
the encounter; the walk serves the remaining rows. One crash casualty
can no longer kill every query on its shard — or the open itself.
- WRITES OVER TORN RECORDS ARE THE CURE: the save path's read-merge, the
commit path's before-image capture, and the operations' rollback
captures all treat a torn prior as the create sentinel, narrated — the
incoming bytes replace the unreadable ones, and history for the id
honestly restarts at that generation. Corruption can never block its
own heal.
- THE NaN SOURCE: torn mapper state (nextId/entries carrying garbage)
discards with narration and re-derives via the existing rebuild path;
the mint gains a source guard healing a non-integer counter from the
live map. The reopen and first-write RangeError shapes are dead at the
source, both authority branches.
Pinned with the exact fault-injection scenarios: a torn entity record
(including the VFS root) no longer kills the open — walks heal past it,
the keeper rows serve, and the identity read of the victim itself is
typed-or-healed; a torn mapper reopens and mints sanely on the first
post-recovery write.
Gates: tsc 0 · unit 2065/2065 · integration 828 · conformance 31/31.
2026-08-11 09:20:30 -07:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Torn-tolerant raw read for BEFORE-IMAGE contexts: a write landing on a
|
|
|
|
|
|
* TORN record (power-loss survivor) is a HEAL — the new after-image
|
|
|
|
|
|
* replaces the unreadable bytes. The before-image is unknowable, so it
|
|
|
|
|
|
* reads as the CREATE SENTINEL ({metadata:null, vector:null}) with
|
|
|
|
|
|
* narration: history for this id restarts at this generation (an asOf
|
|
|
|
|
|
* below it resolves absent for the id — the honest statement of what the
|
|
|
|
|
|
* crash destroyed). The adapter's loud floor (error + gauge) fired at
|
|
|
|
|
|
* throw time; real storage faults still propagate.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private async readRawForBeforeImage(
|
|
|
|
|
|
kind: 'noun' | 'verb',
|
|
|
|
|
|
id: string
|
|
|
|
|
|
): Promise<{ metadata: unknown | null; vector: unknown | null }> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
return kind === 'noun'
|
|
|
|
|
|
? await this.storage.readNounRaw(id)
|
|
|
|
|
|
: await this.storage.readVerbRaw(id)
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
if ((err as { code?: string }).code === 'TORN_RECORD') {
|
|
|
|
|
|
prodLog.warn(
|
|
|
|
|
|
`[GenerationStore] before-image of ${kind} ${id} is TORN — the incoming ` +
|
|
|
|
|
|
`write HEALS the record; its history restarts at this generation`
|
|
|
|
|
|
)
|
|
|
|
|
|
return { metadata: null, vector: null }
|
|
|
|
|
|
}
|
|
|
|
|
|
throw err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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 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.
|
|
|
|
|
|
*/
|
feat: generation fact log — after-image commit records, dual-written at every commit point
Every committed generation now also appends a FACT — an after-image
commit record (what each touched entity/relationship became, or a
body-less tombstone for a removal) — to an append-only, crc32c-framed
segment log under _generations/facts/. The before-image history and the
canonical tree remain authoritative; the fact log gives consumers ONE
sequential, self-verifying stream (index heals, incremental replays)
in place of a per-entity directory walk.
- Wire format: positional msgpack facts [generation, timestamp, ops,
meta, blobHashes]; op = [kind u8, id bin16, record | nil tombstone];
32-byte segment header (magic, formatVersion, firstGeneration,
zeroed+verified reserved); length+crc32c frame per fact; zero-padded
segment names so lexicographic order == generation order; JSON
manifest with an atomic rename flip, manifest-first rotation.
- Commit protocol: facts append+fsync BEFORE the commit point inside
the existing durability window, so a crash can only leave the log
AHEAD of committed truth — open() truncates back (torn tails detected
by CRC). Absent generation = never committed; a scan can never see an
uncommitted fact. transact() facts are durable-on-return; single-op
facts ride the group-commit flush exactly like buffered history. A
fact-append failure fails the write, loudly — a silent gap would be a
lie a later replay discovers.
- New public surface: brain.scanFacts() (sequential batches with heal
telemetry: head/segments/approx up front, per-batch generation range
+ bytes + segment id, loud abort on gaps, summary cross-check) and
brain.factSegmentPaths() (immutable sealed segments for zero-copy
consumers; the mutable tail excluded). Exported types CommitFact,
FactOp, FactScanBatch, FactScanHandle.
- Storage: optional binary raw-byte primitives (appendRawBytes,
readRawBytes, writeRawBytes, rawByteSize) on StorageAdapter —
feature-detected; filesystem + memory adapters implement them; an
adapter without them hosts no fact log. Fact segments are byte-copied
(never hard-linked) into snapshots. The _generations/facts/ namespace
is registered as a protected family (rebuildable: false): no sweeper
or GC may delete under it.
- New crc32c (Castagnoli) utility with RFC known-answer tests.
2026-07-15 10:49:02 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* The generation fact log, or `null` when the storage layer cannot host one.
|
|
|
|
|
|
* Consumers scan committed facts through it (`scanFacts` / `segmentPaths`).
|
|
|
|
|
|
*/
|
|
|
|
|
|
getFactLog(): FactLog | null {
|
|
|
|
|
|
return this.factLog
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description Build one commit's AFTER-IMAGE fact by reading canonical
|
|
|
|
|
|
* state back for every touched id — under the commit mutex, immediately
|
|
|
|
|
|
* after the operations applied, so canonical IS the after-image (and the
|
|
|
|
|
|
* reads are page-cache-warm: the operations just wrote these files). An
|
|
|
|
|
|
* absent id (both legs null) becomes a body-less TOMBSTONE — the delete
|
|
|
|
|
|
* fact needs no body, so removal never requires reading the removed thing.
|
|
|
|
|
|
* The fact's blobHashes are extracted from the AFTER records (the content
|
|
|
|
|
|
* this generation's state references), unlike the history path's
|
|
|
|
|
|
* before-image hashes.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private async buildCommitFact(args: {
|
|
|
|
|
|
generation: number
|
|
|
|
|
|
timestamp: number
|
|
|
|
|
|
nouns: string[]
|
|
|
|
|
|
verbs: string[]
|
|
|
|
|
|
meta?: Record<string, unknown>
|
feat(embedding): deferred-embed markers become log records — the sidecar recovery path is deleted
The private recovery discipline, applied to its own machinery: pending-
embed markers stop being sidecar files and become first-class log records
riding the write's OWN commit fact — embed.pending lands in the same
atomic append as its after-image (a marker can never be orphaned from its
write, or vice versa; in durable-at-ack mode it shares the write's
covering fsync — zero extra syncs), and the worker's landing commit rides
embed.landed with the inline vector. Crash recovery is now a FOLD of the
log (pending without a matching landed = recovered), skipped wholesale on
brains with no v2 history; the one-time legacy bridge folds existing
sidecar files in, migrates them as one fact, and deletes them —
idempotent under a crash mid-bridge. No code path writes the sidecar
again.
Plus the ENTITY-TRUTH digest law, found by this train's own pins:
canonical vector wrappers denormalize HNSW residue (connections + the
randomly-assigned node level) that the log deliberately does not carry —
the verification oracle digested it and would have reported false
state-differs on ~any nonzero-level node (a ~15% flake in the cutover pin
was the symptom). Both sides of every oracle comparison now normalize to
entity truth (nounEntityTruth); index residue has its own rebuild path
and is not entity state.
Pins: embed-markers-in-log 5/5 (same-generation marker, landed+fold-to-
zero, crash recovery via the log with the sidecar prefix EMPTY on disk,
legacy bridge, VFS hung-embedder ack) · deferred-embedding 5/5 unchanged
(the contract outlived its mechanism) · kill-matrix 11/11 · cutover 5/5
×10 runs (flake dead) · unit 2031/2031.
2026-08-10 11:27:07 -07:00
|
|
|
|
/** V2 marker records riding this fact (same generation, same append). */
|
|
|
|
|
|
records?: FactMarkerRecord[]
|
feat: generation fact log — after-image commit records, dual-written at every commit point
Every committed generation now also appends a FACT — an after-image
commit record (what each touched entity/relationship became, or a
body-less tombstone for a removal) — to an append-only, crc32c-framed
segment log under _generations/facts/. The before-image history and the
canonical tree remain authoritative; the fact log gives consumers ONE
sequential, self-verifying stream (index heals, incremental replays)
in place of a per-entity directory walk.
- Wire format: positional msgpack facts [generation, timestamp, ops,
meta, blobHashes]; op = [kind u8, id bin16, record | nil tombstone];
32-byte segment header (magic, formatVersion, firstGeneration,
zeroed+verified reserved); length+crc32c frame per fact; zero-padded
segment names so lexicographic order == generation order; JSON
manifest with an atomic rename flip, manifest-first rotation.
- Commit protocol: facts append+fsync BEFORE the commit point inside
the existing durability window, so a crash can only leave the log
AHEAD of committed truth — open() truncates back (torn tails detected
by CRC). Absent generation = never committed; a scan can never see an
uncommitted fact. transact() facts are durable-on-return; single-op
facts ride the group-commit flush exactly like buffered history. A
fact-append failure fails the write, loudly — a silent gap would be a
lie a later replay discovers.
- New public surface: brain.scanFacts() (sequential batches with heal
telemetry: head/segments/approx up front, per-batch generation range
+ bytes + segment id, loud abort on gaps, summary cross-check) and
brain.factSegmentPaths() (immutable sealed segments for zero-copy
consumers; the mutable tail excluded). Exported types CommitFact,
FactOp, FactScanBatch, FactScanHandle.
- Storage: optional binary raw-byte primitives (appendRawBytes,
readRawBytes, writeRawBytes, rawByteSize) on StorageAdapter —
feature-detected; filesystem + memory adapters implement them; an
adapter without them hosts no fact log. Fact segments are byte-copied
(never hard-linked) into snapshots. The _generations/facts/ namespace
is registered as a protected family (rebuildable: false): no sweeper
or GC may delete under it.
- New crc32c (Castagnoli) utility with RFC known-answer tests.
2026-07-15 10:49:02 -07:00
|
|
|
|
}): Promise<CommitFact> {
|
|
|
|
|
|
const ops: FactOp[] = []
|
|
|
|
|
|
const afterRecords: GenerationRecord[] = []
|
|
|
|
|
|
for (const id of args.nouns) {
|
|
|
|
|
|
const after = await this.storage.readNounRaw(id)
|
|
|
|
|
|
const absent = after.metadata === null && after.vector === null
|
|
|
|
|
|
ops.push({ kind: 'noun', id, record: absent ? null : after })
|
|
|
|
|
|
if (!absent) afterRecords.push({ kind: 'noun', metadata: after.metadata, vector: after.vector })
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const id of args.verbs) {
|
|
|
|
|
|
const after = await this.storage.readVerbRaw(id)
|
|
|
|
|
|
const absent = after.metadata === null && after.vector === null
|
|
|
|
|
|
ops.push({ kind: 'verb', id, record: absent ? null : after })
|
|
|
|
|
|
if (!absent) afterRecords.push({ kind: 'verb', metadata: after.metadata, vector: after.vector })
|
|
|
|
|
|
}
|
|
|
|
|
|
const blobHashes = this.storage.extractBlobHashesFromRecords
|
|
|
|
|
|
? this.storage.extractBlobHashesFromRecords(afterRecords)
|
|
|
|
|
|
: []
|
|
|
|
|
|
return {
|
|
|
|
|
|
generation: args.generation,
|
|
|
|
|
|
timestamp: args.timestamp,
|
|
|
|
|
|
ops,
|
|
|
|
|
|
...(args.meta ? { meta: args.meta } : {}),
|
feat(embedding): deferred-embed markers become log records — the sidecar recovery path is deleted
The private recovery discipline, applied to its own machinery: pending-
embed markers stop being sidecar files and become first-class log records
riding the write's OWN commit fact — embed.pending lands in the same
atomic append as its after-image (a marker can never be orphaned from its
write, or vice versa; in durable-at-ack mode it shares the write's
covering fsync — zero extra syncs), and the worker's landing commit rides
embed.landed with the inline vector. Crash recovery is now a FOLD of the
log (pending without a matching landed = recovered), skipped wholesale on
brains with no v2 history; the one-time legacy bridge folds existing
sidecar files in, migrates them as one fact, and deletes them —
idempotent under a crash mid-bridge. No code path writes the sidecar
again.
Plus the ENTITY-TRUTH digest law, found by this train's own pins:
canonical vector wrappers denormalize HNSW residue (connections + the
randomly-assigned node level) that the log deliberately does not carry —
the verification oracle digested it and would have reported false
state-differs on ~any nonzero-level node (a ~15% flake in the cutover pin
was the symptom). Both sides of every oracle comparison now normalize to
entity truth (nounEntityTruth); index residue has its own rebuild path
and is not entity state.
Pins: embed-markers-in-log 5/5 (same-generation marker, landed+fold-to-
zero, crash recovery via the log with the sidecar prefix EMPTY on disk,
legacy bridge, VFS hung-embedder ack) · deferred-embedding 5/5 unchanged
(the contract outlived its mechanism) · kill-matrix 11/11 · cutover 5/5
×10 runs (flake dead) · unit 2031/2031.
2026-08-10 11:27:07 -07:00
|
|
|
|
...(blobHashes.length > 0 ? { blobHashes } : {}),
|
|
|
|
|
|
...(args.records && args.records.length > 0 ? { records: args.records } : {})
|
feat: generation fact log — after-image commit records, dual-written at every commit point
Every committed generation now also appends a FACT — an after-image
commit record (what each touched entity/relationship became, or a
body-less tombstone for a removal) — to an append-only, crc32c-framed
segment log under _generations/facts/. The before-image history and the
canonical tree remain authoritative; the fact log gives consumers ONE
sequential, self-verifying stream (index heals, incremental replays)
in place of a per-entity directory walk.
- Wire format: positional msgpack facts [generation, timestamp, ops,
meta, blobHashes]; op = [kind u8, id bin16, record | nil tombstone];
32-byte segment header (magic, formatVersion, firstGeneration,
zeroed+verified reserved); length+crc32c frame per fact; zero-padded
segment names so lexicographic order == generation order; JSON
manifest with an atomic rename flip, manifest-first rotation.
- Commit protocol: facts append+fsync BEFORE the commit point inside
the existing durability window, so a crash can only leave the log
AHEAD of committed truth — open() truncates back (torn tails detected
by CRC). Absent generation = never committed; a scan can never see an
uncommitted fact. transact() facts are durable-on-return; single-op
facts ride the group-commit flush exactly like buffered history. A
fact-append failure fails the write, loudly — a silent gap would be a
lie a later replay discovers.
- New public surface: brain.scanFacts() (sequential batches with heal
telemetry: head/segments/approx up front, per-batch generation range
+ bytes + segment id, loud abort on gaps, summary cross-check) and
brain.factSegmentPaths() (immutable sealed segments for zero-copy
consumers; the mutable tail excluded). Exported types CommitFact,
FactOp, FactScanBatch, FactScanHandle.
- Storage: optional binary raw-byte primitives (appendRawBytes,
readRawBytes, writeRawBytes, rawByteSize) on StorageAdapter —
feature-detected; filesystem + memory adapters implement them; an
adapter without them hosts no fact log. Fact segments are byte-copied
(never hard-linked) into snapshots. The _generations/facts/ namespace
is registered as a protected family (rebuildable: false): no sweeper
or GC may delete under it.
- New crc32c (Castagnoli) utility with RFC known-answer tests.
2026-07-15 10:49:02 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
async commitTransaction(args: {
|
|
|
|
|
|
touched: TouchedIds
|
|
|
|
|
|
meta?: Record<string, unknown>
|
|
|
|
|
|
ifAtGeneration?: number
|
2026-07-08 14:53:34 -07:00
|
|
|
|
/** Optional compare-and-swap precondition over the {@link CommitBeforeImages},
|
|
|
|
|
|
* run under the commit mutex BEFORE anything is staged or applied — the
|
|
|
|
|
|
* per-record analogue of `ifAtGeneration`. A throw aborts the whole batch:
|
|
|
|
|
|
* the generation reservation is returned and no staging I/O has happened. */
|
|
|
|
|
|
precommit?: (before: CommitBeforeImages) => void
|
feat(embedding): deferred-embed markers become log records — the sidecar recovery path is deleted
The private recovery discipline, applied to its own machinery: pending-
embed markers stop being sidecar files and become first-class log records
riding the write's OWN commit fact — embed.pending lands in the same
atomic append as its after-image (a marker can never be orphaned from its
write, or vice versa; in durable-at-ack mode it shares the write's
covering fsync — zero extra syncs), and the worker's landing commit rides
embed.landed with the inline vector. Crash recovery is now a FOLD of the
log (pending without a matching landed = recovered), skipped wholesale on
brains with no v2 history; the one-time legacy bridge folds existing
sidecar files in, migrates them as one fact, and deletes them —
idempotent under a crash mid-bridge. No code path writes the sidecar
again.
Plus the ENTITY-TRUTH digest law, found by this train's own pins:
canonical vector wrappers denormalize HNSW residue (connections + the
randomly-assigned node level) that the log deliberately does not carry —
the verification oracle digested it and would have reported false
state-differs on ~any nonzero-level node (a ~15% flake in the cutover pin
was the symptom). Both sides of every oracle comparison now normalize to
entity truth (nounEntityTruth); index residue has its own rebuild path
and is not entity state.
Pins: embed-markers-in-log 5/5 (same-generation marker, landed+fold-to-
zero, crash recovery via the log with the sidecar prefix EMPTY on disk,
legacy bridge, VFS hung-embedder ack) · deferred-embedding 5/5 unchanged
(the contract outlived its mechanism) · kill-matrix 11/11 · cutover 5/5
×10 runs (flake dead) · unit 2031/2031.
2026-08-10 11:27:07 -07:00
|
|
|
|
/** Optional v2 marker records riding this batch's ONE commit fact (e.g.
|
|
|
|
|
|
* the deferred-embedding lifecycle markers) — same generation, same
|
|
|
|
|
|
* atomic append, same durability barrier as the batch itself, so a
|
|
|
|
|
|
* marker can never be orphaned from its write nor the write from its
|
|
|
|
|
|
* marker. Additive: omitted on every markerless path. */
|
|
|
|
|
|
records?: FactMarkerRecord[]
|
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
|
|
|
|
execute: () => Promise<void>
|
|
|
|
|
|
}): Promise<{ generation: number; timestamp: number }> {
|
|
|
|
|
|
return this.withMutex(async () => {
|
2026-07-13 09:31:25 -07:00
|
|
|
|
// A latched history-durability failure compromises the whole generation
|
2026-08-10 12:41:58 -07:00
|
|
|
|
// chain — refuse a transact too (advancing the manifest past stuck,
|
2026-07-13 09:31:25 -07:00
|
|
|
|
// un-durable single-op generations would be inconsistent). Same loud
|
|
|
|
|
|
// error; self-clears when the pending tier drains.
|
|
|
|
|
|
this.assertHistoryDurable()
|
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 (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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat: temporal VFS — file content joins the Model-B immutability model
The temporal model had a hole exactly where files were concerned: every
entity write is an immutable generation with before-images, but VFS content
BYTES lived under an eager refCount GC left over from the pre-8.0 design —
unlink could physically destroy bytes that in-window history still
referenced, and overwrite never released the old hash at all (an unbounded
silent leak whose accidental byproduct was the only thing "preserving"
history). Reading the past could therefore return a stale field, a dangling
hash, or nothing, depending on luck.
Fix: blob reclamation becomes a HISTORY decision instead of a LIVENESS
decision. Each blob's metadata now carries historyRefCount alongside the
live refCount:
- The commit seam counts one history reference per persisted before-image
record carrying a content hash (commitTransaction staging and the
group-commit flush), recorded BEFORE the record-set persists and carried
in the generation delta (blobHashes — always present on new deltas, so
compaction only falls back to reading records for pre-contract
generations). An aborted transaction compensates best-effort.
- unlink/rmdir/overwrite drop ONLY the live reference (BlobStorage.delete →
release; overwrite finally releases the superseded hash — cancelling the
dedup increment on same-content rewrites and closing the leak), and only
AFTER the canonical mutation commits, so a failed delete can never leave a
live file whose bytes compaction might reclaim.
- History compaction is the ONE reclamation point: after deleting a
generation's record-set it releases that set's references and physically
reclaims any hash at zero live AND zero history references. Pins are
exempt automatically. Crash ordering is over-count-only in every path
(record before persist, release after delete), so a crash can leak until
the scrub recounts but can never reclaim bytes a retained generation
needs. scrubBlobHistoryRefCounts() restores exactness; existing stores get
a one-time marker-gated backfill on open, failing into leak-safe mode
(reclamation disabled) rather than guessing.
On top of the protected history, the temporal API the generational model
always implied:
- vfs.readFile(path, { asOf }) — the exact bytes as of a generation or Date,
materialized from the history (pinned view released so compaction is
never blocked by a read).
- vfs.history(path) — FileVersion[] ascending ({ generation, timestamp,
hash, size, mimeType? }), the newest entry being the live state.
- Overwrites now refresh the file entity's data/embedding text — semantic
search and the data field previously served the FIRST version's text
forever (the stale-field defect a consumer's incident recovery depended
on by luck).
Integration suite (temporal-vfs.test.ts): per-version exact reads +
history listing, leak-fix + history protection on overwrite, rm keeps bytes
readable, compaction reclaims past-window bytes and preserves in-window
(including the cross-file dedup case where an old file's history and a
newer file's removal share one hash), data freshness, and scrub exactness.
2026-07-10 16:43:48 -07:00
|
|
|
|
// Temporal-blob contract: hashes this record-set references, recorded
|
|
|
|
|
|
// before staging; scoped outside the try so an abort can compensate.
|
|
|
|
|
|
let txBlobHashes: string[] = []
|
|
|
|
|
|
|
fix: honest response when a transaction rollback cannot complete
When a transaction failed and its rollback ALSO failed to undo a canonical
write (retries exhausted), the old path logged, continued, threw
TransactionRollbackError, and set state='rolled_back' — while the record it
could not undo stayed durable on disk. The caller got an error implying the
write was undone; a read-back showed the record. A failed rollback had no
truthful response (the post-commit response lie).
Give a failed rollback a two-branch honest contract, decided by observation:
both commit paths already hold byte-identical before-images, so after a failed
rollback the store compares current canonical state to them and classifies each
touched record as reconciled, an additive orphan (present when it should be
gone), or a restorative loss (gone/wrong when it should have been restored).
- Adopt-forward: a single-op write whose only damage is a durably-present
orphan — the record the caller asked for — is committed forward (its
generation buffered) and returns success with a loud warning that the derived
index may be incomplete for that id until the next rebuild/repairIndex(). No
error, no double-write; the record is durable and get-able immediately.
- Fail loud + quarantine: a multi-op batch, or ANY restorative loss, throws the
new StoreInconsistentError naming every unreconciled record and its
disposition, and puts the brain into write-quarantine (reads keep working,
writes refused via assertWritable) until repairIndex() reconciles the derived
indexes against canonical and lifts it. The counter is not advanced, and
Transaction.rollback now reports state 'inconsistent' instead of the
'rolled_back' lie when any undo failed.
New: StoreInconsistentError + UnreconciledRecord exported from the root;
repairIndex() forces a rebuild and clears the quarantine. Regression
tests/integration/rollback-trapdoor.test.ts injects index-add-throws +
canonical-delete-undo-fails and pins adopt-forward (durable, get-able, not
quarantined), fail-loud (StoreInconsistentError + quarantine + reads work +
repairIndex lifts it), and the error's record naming.
2026-07-12 12:19:09 -07:00
|
|
|
|
// Hoisted so the catch can reconcile canonical state against them after a
|
|
|
|
|
|
// failed rollback (the trapdoor). Empty until populated below.
|
|
|
|
|
|
const nounBefore = new Map<string, GenerationRecord>()
|
|
|
|
|
|
const verbBefore = new Map<string, GenerationRecord>()
|
|
|
|
|
|
|
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
|
|
|
|
try {
|
|
|
|
|
|
// -- 3. Before-images + delta (the durable undo log) ------------------
|
2026-07-08 14:53:34 -07:00
|
|
|
|
// Read every before-image FIRST, then run the caller's CAS
|
|
|
|
|
|
// precondition against them, and only then stage to disk — so a
|
|
|
|
|
|
// conflicting batch aborts with zero staging I/O. The maps hold the
|
|
|
|
|
|
// byte-identical records the staged files are written from.
|
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) {
|
fix(recovery): walks are healers — the typed/tolerant boundary redrawn where block-layer fault injection proved it belonged
The quiet-loss cure regressed recovery: the new typed torn-record error
was correct at identity-read time but threw inside init-time recovery
walks, killing opens that previously survived. The boundary, redrawn:
- IDENTITY READS (get-by-id of a specific record, CAS blob point-get):
typed TornRecordError, unchanged — a caller who asked for THAT record
can act on the answer.
- SET-SHAPED READS AND WALKS (enumeration, pagination, batch hydration —
the paths recovery rebuilds and finds page over): HEAL PAST the torn
victim. The adapter's loud floor (error log + counted gauge) fires at
the encounter; the walk serves the remaining rows. One crash casualty
can no longer kill every query on its shard — or the open itself.
- WRITES OVER TORN RECORDS ARE THE CURE: the save path's read-merge, the
commit path's before-image capture, and the operations' rollback
captures all treat a torn prior as the create sentinel, narrated — the
incoming bytes replace the unreadable ones, and history for the id
honestly restarts at that generation. Corruption can never block its
own heal.
- THE NaN SOURCE: torn mapper state (nextId/entries carrying garbage)
discards with narration and re-derives via the existing rebuild path;
the mint gains a source guard healing a non-integer counter from the
live map. The reopen and first-write RangeError shapes are dead at the
source, both authority branches.
Pinned with the exact fault-injection scenarios: a torn entity record
(including the VFS root) no longer kills the open — walks heal past it,
the keeper rows serve, and the identity read of the victim itself is
typed-or-healed; a torn mapper reopens and mints sanely on the first
post-recovery write.
Gates: tsc 0 · unit 2065/2065 · integration 828 · conformance 31/31.
2026-08-11 09:20:30 -07:00
|
|
|
|
const prev = await this.readRawForBeforeImage('noun', id)
|
2026-07-08 14:53:34 -07:00
|
|
|
|
nounBefore.set(id, { kind: 'noun', metadata: prev.metadata, vector: prev.vector })
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const id of verbs) {
|
fix(recovery): walks are healers — the typed/tolerant boundary redrawn where block-layer fault injection proved it belonged
The quiet-loss cure regressed recovery: the new typed torn-record error
was correct at identity-read time but threw inside init-time recovery
walks, killing opens that previously survived. The boundary, redrawn:
- IDENTITY READS (get-by-id of a specific record, CAS blob point-get):
typed TornRecordError, unchanged — a caller who asked for THAT record
can act on the answer.
- SET-SHAPED READS AND WALKS (enumeration, pagination, batch hydration —
the paths recovery rebuilds and finds page over): HEAL PAST the torn
victim. The adapter's loud floor (error log + counted gauge) fires at
the encounter; the walk serves the remaining rows. One crash casualty
can no longer kill every query on its shard — or the open itself.
- WRITES OVER TORN RECORDS ARE THE CURE: the save path's read-merge, the
commit path's before-image capture, and the operations' rollback
captures all treat a torn prior as the create sentinel, narrated — the
incoming bytes replace the unreadable ones, and history for the id
honestly restarts at that generation. Corruption can never block its
own heal.
- THE NaN SOURCE: torn mapper state (nextId/entries carrying garbage)
discards with narration and re-derives via the existing rebuild path;
the mint gains a source guard healing a non-integer counter from the
live map. The reopen and first-write RangeError shapes are dead at the
source, both authority branches.
Pinned with the exact fault-injection scenarios: a torn entity record
(including the VFS root) no longer kills the open — walks heal past it,
the keeper rows serve, and the identity read of the victim itself is
typed-or-healed; a torn mapper reopens and mints sanely on the first
post-recovery write.
Gates: tsc 0 · unit 2065/2065 · integration 828 · conformance 31/31.
2026-08-11 09:20:30 -07:00
|
|
|
|
const prev = await this.readRawForBeforeImage('verb', id)
|
2026-07-08 14:53:34 -07:00
|
|
|
|
verbBefore.set(id, { kind: 'verb', metadata: prev.metadata, vector: prev.vector })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Conditional commit: the per-record CAS analogue of the
|
|
|
|
|
|
// `ifAtGeneration` check above, but against authoritative
|
|
|
|
|
|
// before-images under the mutex. A throw lands in the catch below,
|
|
|
|
|
|
// which returns the generation reservation; nothing was applied.
|
|
|
|
|
|
args.precommit?.({ nouns: nounBefore, verbs: verbBefore })
|
|
|
|
|
|
|
feat: temporal VFS — file content joins the Model-B immutability model
The temporal model had a hole exactly where files were concerned: every
entity write is an immutable generation with before-images, but VFS content
BYTES lived under an eager refCount GC left over from the pre-8.0 design —
unlink could physically destroy bytes that in-window history still
referenced, and overwrite never released the old hash at all (an unbounded
silent leak whose accidental byproduct was the only thing "preserving"
history). Reading the past could therefore return a stale field, a dangling
hash, or nothing, depending on luck.
Fix: blob reclamation becomes a HISTORY decision instead of a LIVENESS
decision. Each blob's metadata now carries historyRefCount alongside the
live refCount:
- The commit seam counts one history reference per persisted before-image
record carrying a content hash (commitTransaction staging and the
group-commit flush), recorded BEFORE the record-set persists and carried
in the generation delta (blobHashes — always present on new deltas, so
compaction only falls back to reading records for pre-contract
generations). An aborted transaction compensates best-effort.
- unlink/rmdir/overwrite drop ONLY the live reference (BlobStorage.delete →
release; overwrite finally releases the superseded hash — cancelling the
dedup increment on same-content rewrites and closing the leak), and only
AFTER the canonical mutation commits, so a failed delete can never leave a
live file whose bytes compaction might reclaim.
- History compaction is the ONE reclamation point: after deleting a
generation's record-set it releases that set's references and physically
reclaims any hash at zero live AND zero history references. Pins are
exempt automatically. Crash ordering is over-count-only in every path
(record before persist, release after delete), so a crash can leak until
the scrub recounts but can never reclaim bytes a retained generation
needs. scrubBlobHistoryRefCounts() restores exactness; existing stores get
a one-time marker-gated backfill on open, failing into leak-safe mode
(reclamation disabled) rather than guessing.
On top of the protected history, the temporal API the generational model
always implied:
- vfs.readFile(path, { asOf }) — the exact bytes as of a generation or Date,
materialized from the history (pinned view released so compaction is
never blocked by a read).
- vfs.history(path) — FileVersion[] ascending ({ generation, timestamp,
hash, size, mimeType? }), the newest entry being the live state.
- Overwrites now refresh the file entity's data/embedding text — semantic
search and the data field previously served the FIRST version's text
forever (the stale-field defect a consumer's incident recovery depended
on by luck).
Integration suite (temporal-vfs.test.ts): per-version exact reads +
history listing, leak-fix + history protection on overwrite, rm keeps bytes
readable, compaction reclaims past-window bytes and preserves in-window
(including the cross-file dedup case where an old file's history and a
newer file's removal share one hash), data freshness, and scrub exactness.
2026-07-10 16:43:48 -07:00
|
|
|
|
// Temporal-blob contract: count this record-set's content-hash
|
|
|
|
|
|
// references BEFORE it is staged (over-count-only crash ordering —
|
|
|
|
|
|
// see flushPendingSingleOps' matching note).
|
|
|
|
|
|
txBlobHashes = this.storage.extractBlobHashesFromRecords
|
|
|
|
|
|
? this.storage.extractBlobHashesFromRecords([
|
|
|
|
|
|
...nounBefore.values(),
|
|
|
|
|
|
...verbBefore.values()
|
|
|
|
|
|
])
|
|
|
|
|
|
: []
|
|
|
|
|
|
if (txBlobHashes.length > 0 && this.storage.recordHistoryBlobReferences) {
|
|
|
|
|
|
await this.storage.recordHistoryBlobReferences(txBlobHashes)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-08 14:53:34 -07:00
|
|
|
|
const stagedPaths: string[] = []
|
|
|
|
|
|
let recordBytes = 0 // serialized record-set size, for retention accounting
|
|
|
|
|
|
for (const [id, record] of nounBefore) {
|
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 recordPath = `${dir}/prev/${id}.json`
|
|
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-07-08 14:53:34 -07:00
|
|
|
|
for (const [id, record] of verbBefore) {
|
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 recordPath = `${dir}/prev/${id}.json`
|
|
|
|
|
|
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,
|
feat: temporal VFS — file content joins the Model-B immutability model
The temporal model had a hole exactly where files were concerned: every
entity write is an immutable generation with before-images, but VFS content
BYTES lived under an eager refCount GC left over from the pre-8.0 design —
unlink could physically destroy bytes that in-window history still
referenced, and overwrite never released the old hash at all (an unbounded
silent leak whose accidental byproduct was the only thing "preserving"
history). Reading the past could therefore return a stale field, a dangling
hash, or nothing, depending on luck.
Fix: blob reclamation becomes a HISTORY decision instead of a LIVENESS
decision. Each blob's metadata now carries historyRefCount alongside the
live refCount:
- The commit seam counts one history reference per persisted before-image
record carrying a content hash (commitTransaction staging and the
group-commit flush), recorded BEFORE the record-set persists and carried
in the generation delta (blobHashes — always present on new deltas, so
compaction only falls back to reading records for pre-contract
generations). An aborted transaction compensates best-effort.
- unlink/rmdir/overwrite drop ONLY the live reference (BlobStorage.delete →
release; overwrite finally releases the superseded hash — cancelling the
dedup increment on same-content rewrites and closing the leak), and only
AFTER the canonical mutation commits, so a failed delete can never leave a
live file whose bytes compaction might reclaim.
- History compaction is the ONE reclamation point: after deleting a
generation's record-set it releases that set's references and physically
reclaims any hash at zero live AND zero history references. Pins are
exempt automatically. Crash ordering is over-count-only in every path
(record before persist, release after delete), so a crash can leak until
the scrub recounts but can never reclaim bytes a retained generation
needs. scrubBlobHistoryRefCounts() restores exactness; existing stores get
a one-time marker-gated backfill on open, failing into leak-safe mode
(reclamation disabled) rather than guessing.
On top of the protected history, the temporal API the generational model
always implied:
- vfs.readFile(path, { asOf }) — the exact bytes as of a generation or Date,
materialized from the history (pinned view released so compaction is
never blocked by a read).
- vfs.history(path) — FileVersion[] ascending ({ generation, timestamp,
hash, size, mimeType? }), the newest entry being the live state.
- Overwrites now refresh the file entity's data/embedding text — semantic
search and the data field previously served the FIRST version's text
forever (the stale-field defect a consumer's incident recovery depended
on by luck).
Integration suite (temporal-vfs.test.ts): per-version exact reads +
history listing, leak-fix + history protection on overwrite, rm keeps bytes
readable, compaction reclaims past-window bytes and preserves in-window
(including the cross-file dedup case where an old file's history and a
newer file's removal share one hash), data freshness, and scrub exactness.
2026-07-10 16:43:48 -07:00
|
|
|
|
verbs,
|
|
|
|
|
|
// Always present on new deltas (empty = "no blobs"), so compaction
|
|
|
|
|
|
// only falls back to record reads for pre-contract generations.
|
|
|
|
|
|
blobHashes: txBlobHashes
|
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
|
|
|
|
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 -------------------------------------
|
2026-07-12 08:54:14 -07:00
|
|
|
|
// Durability barrier: record every canonical write/delete the operations
|
|
|
|
|
|
// make, then fsync them BELOW (before the counter advances). Without
|
|
|
|
|
|
// this, a hard kill after commitTransaction returns could leave the
|
|
|
|
|
|
// fsync'd generation counter ahead of still-page-cached entity bytes —
|
|
|
|
|
|
// phantom progress for any generation-based consumer.
|
|
|
|
|
|
this.storage.beginWriteBarrier?.()
|
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.inTransact = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
await args.execute()
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
this.inTransact = false
|
|
|
|
|
|
}
|
2026-07-12 08:54:14 -07:00
|
|
|
|
// The transaction's entire canonical footprint is now durable, so the
|
|
|
|
|
|
// counter/manifest advance below can never outrun the entity bytes.
|
|
|
|
|
|
await this.storage.flushWriteBarrier?.()
|
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
|
|
|
|
faultPoint('after-execute')
|
|
|
|
|
|
|
feat: generation fact log — after-image commit records, dual-written at every commit point
Every committed generation now also appends a FACT — an after-image
commit record (what each touched entity/relationship became, or a
body-less tombstone for a removal) — to an append-only, crc32c-framed
segment log under _generations/facts/. The before-image history and the
canonical tree remain authoritative; the fact log gives consumers ONE
sequential, self-verifying stream (index heals, incremental replays)
in place of a per-entity directory walk.
- Wire format: positional msgpack facts [generation, timestamp, ops,
meta, blobHashes]; op = [kind u8, id bin16, record | nil tombstone];
32-byte segment header (magic, formatVersion, firstGeneration,
zeroed+verified reserved); length+crc32c frame per fact; zero-padded
segment names so lexicographic order == generation order; JSON
manifest with an atomic rename flip, manifest-first rotation.
- Commit protocol: facts append+fsync BEFORE the commit point inside
the existing durability window, so a crash can only leave the log
AHEAD of committed truth — open() truncates back (torn tails detected
by CRC). Absent generation = never committed; a scan can never see an
uncommitted fact. transact() facts are durable-on-return; single-op
facts ride the group-commit flush exactly like buffered history. A
fact-append failure fails the write, loudly — a silent gap would be a
lie a later replay discovers.
- New public surface: brain.scanFacts() (sequential batches with heal
telemetry: head/segments/approx up front, per-batch generation range
+ bytes + segment id, loud abort on gaps, summary cross-check) and
brain.factSegmentPaths() (immutable sealed segments for zero-copy
consumers; the mutable tail excluded). Exported types CommitFact,
FactOp, FactScanBatch, FactScanHandle.
- Storage: optional binary raw-byte primitives (appendRawBytes,
readRawBytes, writeRawBytes, rawByteSize) on StorageAdapter —
feature-detected; filesystem + memory adapters implement them; an
adapter without them hosts no fact log. Fact segments are byte-copied
(never hard-linked) into snapshots. The _generations/facts/ namespace
is registered as a protected family (rebuildable: false): no sweeper
or GC may delete under it.
- New crc32c (Castagnoli) utility with RFC known-answer tests.
2026-07-15 10:49:02 -07:00
|
|
|
|
// Fact log (dual-write): append + fsync this generation's AFTER-IMAGE
|
|
|
|
|
|
// fact BEFORE the commit point, inside the same durability window —
|
|
|
|
|
|
// so a crash can only leave the log AHEAD (open truncates), never a
|
|
|
|
|
|
// committed generation without its fact. A real abort below this point
|
|
|
|
|
|
// compensates via dropAbove in the catch. An append failure fails the
|
|
|
|
|
|
// write, loudly — a silent fact gap would be a lie a replay discovers.
|
|
|
|
|
|
if (this.factLog) {
|
|
|
|
|
|
const fact = await this.buildCommitFact({
|
|
|
|
|
|
generation: gen,
|
|
|
|
|
|
timestamp,
|
|
|
|
|
|
nouns,
|
|
|
|
|
|
verbs,
|
feat(embedding): deferred-embed markers become log records — the sidecar recovery path is deleted
The private recovery discipline, applied to its own machinery: pending-
embed markers stop being sidecar files and become first-class log records
riding the write's OWN commit fact — embed.pending lands in the same
atomic append as its after-image (a marker can never be orphaned from its
write, or vice versa; in durable-at-ack mode it shares the write's
covering fsync — zero extra syncs), and the worker's landing commit rides
embed.landed with the inline vector. Crash recovery is now a FOLD of the
log (pending without a matching landed = recovered), skipped wholesale on
brains with no v2 history; the one-time legacy bridge folds existing
sidecar files in, migrates them as one fact, and deletes them —
idempotent under a crash mid-bridge. No code path writes the sidecar
again.
Plus the ENTITY-TRUTH digest law, found by this train's own pins:
canonical vector wrappers denormalize HNSW residue (connections + the
randomly-assigned node level) that the log deliberately does not carry —
the verification oracle digested it and would have reported false
state-differs on ~any nonzero-level node (a ~15% flake in the cutover pin
was the symptom). Both sides of every oracle comparison now normalize to
entity truth (nounEntityTruth); index residue has its own rebuild path
and is not entity state.
Pins: embed-markers-in-log 5/5 (same-generation marker, landed+fold-to-
zero, crash recovery via the log with the sidecar prefix EMPTY on disk,
legacy bridge, VFS hung-embedder ack) · deferred-embedding 5/5 unchanged
(the contract outlived its mechanism) · kill-matrix 11/11 · cutover 5/5
×10 runs (flake dead) · unit 2031/2031.
2026-08-10 11:27:07 -07:00
|
|
|
|
...(args.meta ? { meta: args.meta } : {}),
|
|
|
|
|
|
...(args.records && args.records.length > 0 ? { records: args.records } : {})
|
feat: generation fact log — after-image commit records, dual-written at every commit point
Every committed generation now also appends a FACT — an after-image
commit record (what each touched entity/relationship became, or a
body-less tombstone for a removal) — to an append-only, crc32c-framed
segment log under _generations/facts/. The before-image history and the
canonical tree remain authoritative; the fact log gives consumers ONE
sequential, self-verifying stream (index heals, incremental replays)
in place of a per-entity directory walk.
- Wire format: positional msgpack facts [generation, timestamp, ops,
meta, blobHashes]; op = [kind u8, id bin16, record | nil tombstone];
32-byte segment header (magic, formatVersion, firstGeneration,
zeroed+verified reserved); length+crc32c frame per fact; zero-padded
segment names so lexicographic order == generation order; JSON
manifest with an atomic rename flip, manifest-first rotation.
- Commit protocol: facts append+fsync BEFORE the commit point inside
the existing durability window, so a crash can only leave the log
AHEAD of committed truth — open() truncates back (torn tails detected
by CRC). Absent generation = never committed; a scan can never see an
uncommitted fact. transact() facts are durable-on-return; single-op
facts ride the group-commit flush exactly like buffered history. A
fact-append failure fails the write, loudly — a silent gap would be a
lie a later replay discovers.
- New public surface: brain.scanFacts() (sequential batches with heal
telemetry: head/segments/approx up front, per-batch generation range
+ bytes + segment id, loud abort on gaps, summary cross-check) and
brain.factSegmentPaths() (immutable sealed segments for zero-copy
consumers; the mutable tail excluded). Exported types CommitFact,
FactOp, FactScanBatch, FactScanHandle.
- Storage: optional binary raw-byte primitives (appendRawBytes,
readRawBytes, writeRawBytes, rawByteSize) on StorageAdapter —
feature-detected; filesystem + memory adapters implement them; an
adapter without them hosts no fact log. Fact segments are byte-copied
(never hard-linked) into snapshots. The _generations/facts/ namespace
is registered as a protected family (rebuildable: false): no sweeper
or GC may delete under it.
- New crc32c (Castagnoli) utility with RFC known-answer tests.
2026-07-15 10:49:02 -07:00
|
|
|
|
})
|
|
|
|
|
|
await this.factLog.append(fact)
|
|
|
|
|
|
await this.factLog.sync()
|
|
|
|
|
|
}
|
fix(log): acked writes survive power loss; rejected writes never silently commit — the kill-matrix goes 11/11 with zero .fails debt
Two release-blocking findings from the durability kill-matrix, both fixed
in the owning layer:
1. LOG-AUTHORITY REPLAY AT OPEN: durable-at-ack fsynced the fact before
the ack, but open() truncated every fact above the manifest — after a
power loss that takes the un-fsynced tmp+rename canonical bytes, the
acked write's ONLY durable copy was discarded. Now: under 'log'
authority, open() REPLAYS intact facts above the manifest into
canonical (FactLog.peekFactsAbove — CRC-gated, order-sorted) and
advances the manifest to cover them; tree-authority brains keep the
truncate contract they were promised. Pinned end to end: the power-loss
row constructs the exact disk state (fsynced log, vanished canonical
rename) and the acked write lives.
2. NO SILENT COMMIT: commitSingleOp buffered the generation BEFORE the
fact append; an append failure (ENOSPC) rejected the caller but the
next flush durably committed the generation with NO fact — a permanent
silent log gap. Now the failure path un-buffers and returns the counter
reservation: nothing commits, the log stays gap-free, and the canonical
execute-residue orphan is the documented crash-equivalent.
Plus: the kill-matrix itself (11 rows — every commit-path fault point ×
reopen-as-crash recovery contract, at-ack variants, disk-full row; five
new zero-cost faultPoint sites), the log-authority pin suite (oracle
green/red/state-differs, flip refusal, switch survives reopen, 9/9), and
the group-commit covering pins (5/5).
Gates: unit 2002/2002 (152 files) · integration 785 · conformance 27/27.
2026-08-10 09:29:21 -07:00
|
|
|
|
// A crash here must cost the whole batch: the synced fact is truncated
|
|
|
|
|
|
// back at open() and the before-images are restored byte-identically.
|
|
|
|
|
|
faultPoint('transact-after-fact-sync')
|
feat: generation fact log — after-image commit records, dual-written at every commit point
Every committed generation now also appends a FACT — an after-image
commit record (what each touched entity/relationship became, or a
body-less tombstone for a removal) — to an append-only, crc32c-framed
segment log under _generations/facts/. The before-image history and the
canonical tree remain authoritative; the fact log gives consumers ONE
sequential, self-verifying stream (index heals, incremental replays)
in place of a per-entity directory walk.
- Wire format: positional msgpack facts [generation, timestamp, ops,
meta, blobHashes]; op = [kind u8, id bin16, record | nil tombstone];
32-byte segment header (magic, formatVersion, firstGeneration,
zeroed+verified reserved); length+crc32c frame per fact; zero-padded
segment names so lexicographic order == generation order; JSON
manifest with an atomic rename flip, manifest-first rotation.
- Commit protocol: facts append+fsync BEFORE the commit point inside
the existing durability window, so a crash can only leave the log
AHEAD of committed truth — open() truncates back (torn tails detected
by CRC). Absent generation = never committed; a scan can never see an
uncommitted fact. transact() facts are durable-on-return; single-op
facts ride the group-commit flush exactly like buffered history. A
fact-append failure fails the write, loudly — a silent gap would be a
lie a later replay discovers.
- New public surface: brain.scanFacts() (sequential batches with heal
telemetry: head/segments/approx up front, per-batch generation range
+ bytes + segment id, loud abort on gaps, summary cross-check) and
brain.factSegmentPaths() (immutable sealed segments for zero-copy
consumers; the mutable tail excluded). Exported types CommitFact,
FactOp, FactScanBatch, FactScanHandle.
- Storage: optional binary raw-byte primitives (appendRawBytes,
readRawBytes, writeRawBytes, rawByteSize) on StorageAdapter —
feature-detected; filesystem + memory adapters implement them; an
adapter without them hosts no fact log. Fact segments are byte-copied
(never hard-linked) into snapshots. The _generations/facts/ namespace
is registered as a protected family (rebuildable: false): no sweeper
or GC may delete under it.
- New crc32c (Castagnoli) utility with RFC known-answer tests.
2026-07-15 10:49:02 -07:00
|
|
|
|
|
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
|
|
|
|
// -- 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
|
2026-06-29 16:05:03 -07:00
|
|
|
|
this.appendCommittedGen(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-07-18 10:51:37 -07:00
|
|
|
|
if (this.historyBytesTotal !== null) {
|
|
|
|
|
|
this.historyBytesTotal += delta.bytes ?? 0
|
|
|
|
|
|
}
|
2026-06-22 13:47:33 -07:00
|
|
|
|
this.extendChains(gen, nouns, verbs)
|
2026-08-12 16:56:08 -07:00
|
|
|
|
// Fold-checkpoint accounting: the write barrier above already synced
|
|
|
|
|
|
// this batch's canonical footprint on adapters that have one, but the
|
|
|
|
|
|
// accumulator entry is the belt — an adapter without a write barrier
|
|
|
|
|
|
// still gets these ids covered by the next checkpoint barrier, and a
|
|
|
|
|
|
// redundant fsync of already-durable bytes is cheap and idempotent.
|
|
|
|
|
|
for (const id of nouns) this.noteCheckpointDirty('noun', id)
|
|
|
|
|
|
for (const id of verbs) this.noteCheckpointDirty('verb', id)
|
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
|
|
|
|
|
|
}
|
2026-08-12 16:56:08 -07:00
|
|
|
|
// Fold-checkpoint accounting: an abort's rollback restores are raw
|
|
|
|
|
|
// canonical writes that never reach the transaction write barrier
|
|
|
|
|
|
// (flushWriteBarrier only runs on the commit path) — feed them so the
|
|
|
|
|
|
// next checkpoint barrier syncs the restored bytes before any stamp
|
|
|
|
|
|
// vouches for them.
|
|
|
|
|
|
for (const id of nouns) this.noteCheckpointDirty('noun', id)
|
|
|
|
|
|
for (const id of verbs) this.noteCheckpointDirty('verb', id)
|
fix: honest response when a transaction rollback cannot complete
When a transaction failed and its rollback ALSO failed to undo a canonical
write (retries exhausted), the old path logged, continued, threw
TransactionRollbackError, and set state='rolled_back' — while the record it
could not undo stayed durable on disk. The caller got an error implying the
write was undone; a read-back showed the record. A failed rollback had no
truthful response (the post-commit response lie).
Give a failed rollback a two-branch honest contract, decided by observation:
both commit paths already hold byte-identical before-images, so after a failed
rollback the store compares current canonical state to them and classifies each
touched record as reconciled, an additive orphan (present when it should be
gone), or a restorative loss (gone/wrong when it should have been restored).
- Adopt-forward: a single-op write whose only damage is a durably-present
orphan — the record the caller asked for — is committed forward (its
generation buffered) and returns success with a loud warning that the derived
index may be incomplete for that id until the next rebuild/repairIndex(). No
error, no double-write; the record is durable and get-able immediately.
- Fail loud + quarantine: a multi-op batch, or ANY restorative loss, throws the
new StoreInconsistentError naming every unreconciled record and its
disposition, and puts the brain into write-quarantine (reads keep working,
writes refused via assertWritable) until repairIndex() reconciles the derived
indexes against canonical and lifts it. The counter is not advanced, and
Transaction.rollback now reports state 'inconsistent' instead of the
'rolled_back' lie when any undo failed.
New: StoreInconsistentError + UnreconciledRecord exported from the root;
repairIndex() forces a rebuild and clears the quarantine. Regression
tests/integration/rollback-trapdoor.test.ts injects index-add-throws +
canonical-delete-undo-fails and pins adopt-forward (durable, get-able, not
quarantined), fail-loud (StoreInconsistentError + quarantine + reads work +
repairIndex lifts it), and the error's record naming.
2026-07-12 12:19:09 -07:00
|
|
|
|
// The trapdoor for a batch: if rollback FAILED to fully apply, canonical
|
|
|
|
|
|
// storage may be inconsistent. A batch is never adopted forward (its
|
|
|
|
|
|
// other ops were rolled back — partial commit would break atomicity), so
|
|
|
|
|
|
// any unreconciled record is a fail-loud StoreInconsistentError. Compute
|
|
|
|
|
|
// it here (before the staging cleanup, which is always safe to run) and
|
|
|
|
|
|
// throw it in place of the raw error at the end.
|
|
|
|
|
|
let inconsistent: UnreconciledRecord[] = []
|
|
|
|
|
|
if (err instanceof TransactionRollbackError) {
|
|
|
|
|
|
inconsistent = await this.reconcileFailedRollback(nounBefore, verbBefore)
|
|
|
|
|
|
}
|
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
|
|
|
|
// 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)`
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
feat: temporal VFS — file content joins the Model-B immutability model
The temporal model had a hole exactly where files were concerned: every
entity write is an immutable generation with before-images, but VFS content
BYTES lived under an eager refCount GC left over from the pre-8.0 design —
unlink could physically destroy bytes that in-window history still
referenced, and overwrite never released the old hash at all (an unbounded
silent leak whose accidental byproduct was the only thing "preserving"
history). Reading the past could therefore return a stale field, a dangling
hash, or nothing, depending on luck.
Fix: blob reclamation becomes a HISTORY decision instead of a LIVENESS
decision. Each blob's metadata now carries historyRefCount alongside the
live refCount:
- The commit seam counts one history reference per persisted before-image
record carrying a content hash (commitTransaction staging and the
group-commit flush), recorded BEFORE the record-set persists and carried
in the generation delta (blobHashes — always present on new deltas, so
compaction only falls back to reading records for pre-contract
generations). An aborted transaction compensates best-effort.
- unlink/rmdir/overwrite drop ONLY the live reference (BlobStorage.delete →
release; overwrite finally releases the superseded hash — cancelling the
dedup increment on same-content rewrites and closing the leak), and only
AFTER the canonical mutation commits, so a failed delete can never leave a
live file whose bytes compaction might reclaim.
- History compaction is the ONE reclamation point: after deleting a
generation's record-set it releases that set's references and physically
reclaims any hash at zero live AND zero history references. Pins are
exempt automatically. Crash ordering is over-count-only in every path
(record before persist, release after delete), so a crash can leak until
the scrub recounts but can never reclaim bytes a retained generation
needs. scrubBlobHistoryRefCounts() restores exactness; existing stores get
a one-time marker-gated backfill on open, failing into leak-safe mode
(reclamation disabled) rather than guessing.
On top of the protected history, the temporal API the generational model
always implied:
- vfs.readFile(path, { asOf }) — the exact bytes as of a generation or Date,
materialized from the history (pinned view released so compaction is
never blocked by a read).
- vfs.history(path) — FileVersion[] ascending ({ generation, timestamp,
hash, size, mimeType? }), the newest entry being the live state.
- Overwrites now refresh the file entity's data/embedding text — semantic
search and the data field previously served the FIRST version's text
forever (the stale-field defect a consumer's incident recovery depended
on by luck).
Integration suite (temporal-vfs.test.ts): per-version exact reads +
history listing, leak-fix + history protection on overwrite, rm keeps bytes
readable, compaction reclaims past-window bytes and preserves in-window
(including the cross-file dedup case where an old file's history and a
newer file's removal share one hash), data freshness, and scrub exactness.
2026-07-10 16:43:48 -07:00
|
|
|
|
// Compensate the pre-staging history-reference increments. Best
|
|
|
|
|
|
// effort — a failure here only over-counts (a leak the scrub
|
|
|
|
|
|
// repairs). Reclaim inside is safe on an abort: the before-image
|
|
|
|
|
|
// hashes are the entities' still-live content, so live references
|
|
|
|
|
|
// block any physical delete.
|
|
|
|
|
|
if (txBlobHashes.length > 0 && this.storage.releaseHistoryBlobReferences) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await this.storage.releaseHistoryBlobReferences(txBlobHashes)
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// over-count-safe; the scrub restores exactness
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
feat: generation fact log — after-image commit records, dual-written at every commit point
Every committed generation now also appends a FACT — an after-image
commit record (what each touched entity/relationship became, or a
body-less tombstone for a removal) — to an append-only, crc32c-framed
segment log under _generations/facts/. The before-image history and the
canonical tree remain authoritative; the fact log gives consumers ONE
sequential, self-verifying stream (index heals, incremental replays)
in place of a per-entity directory walk.
- Wire format: positional msgpack facts [generation, timestamp, ops,
meta, blobHashes]; op = [kind u8, id bin16, record | nil tombstone];
32-byte segment header (magic, formatVersion, firstGeneration,
zeroed+verified reserved); length+crc32c frame per fact; zero-padded
segment names so lexicographic order == generation order; JSON
manifest with an atomic rename flip, manifest-first rotation.
- Commit protocol: facts append+fsync BEFORE the commit point inside
the existing durability window, so a crash can only leave the log
AHEAD of committed truth — open() truncates back (torn tails detected
by CRC). Absent generation = never committed; a scan can never see an
uncommitted fact. transact() facts are durable-on-return; single-op
facts ride the group-commit flush exactly like buffered history. A
fact-append failure fails the write, loudly — a silent gap would be a
lie a later replay discovers.
- New public surface: brain.scanFacts() (sequential batches with heal
telemetry: head/segments/approx up front, per-batch generation range
+ bytes + segment id, loud abort on gaps, summary cross-check) and
brain.factSegmentPaths() (immutable sealed segments for zero-copy
consumers; the mutable tail excluded). Exported types CommitFact,
FactOp, FactScanBatch, FactScanHandle.
- Storage: optional binary raw-byte primitives (appendRawBytes,
readRawBytes, writeRawBytes, rawByteSize) on StorageAdapter —
feature-detected; filesystem + memory adapters implement them; an
adapter without them hosts no fact log. Fact segments are byte-copied
(never hard-linked) into snapshots. The _generations/facts/ namespace
is registered as a protected family (rebuildable: false): no sweeper
or GC may delete under it.
- New crc32c (Castagnoli) utility with RFC known-answer tests.
2026-07-15 10:49:02 -07:00
|
|
|
|
// Fact-log compensation: a real (non-crash) abort after the fact was
|
|
|
|
|
|
// appended must take the fact back out — the generation never
|
|
|
|
|
|
// committed. A crash instead reaches open(), whose truncation does the
|
|
|
|
|
|
// same reconcile from disk.
|
|
|
|
|
|
if (this.factLog && this.factLog.headGeneration() >= gen) {
|
|
|
|
|
|
await this.factLog.dropAbove(gen - 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 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
|
fix: honest response when a transaction rollback cannot complete
When a transaction failed and its rollback ALSO failed to undo a canonical
write (retries exhausted), the old path logged, continued, threw
TransactionRollbackError, and set state='rolled_back' — while the record it
could not undo stayed durable on disk. The caller got an error implying the
write was undone; a read-back showed the record. A failed rollback had no
truthful response (the post-commit response lie).
Give a failed rollback a two-branch honest contract, decided by observation:
both commit paths already hold byte-identical before-images, so after a failed
rollback the store compares current canonical state to them and classifies each
touched record as reconciled, an additive orphan (present when it should be
gone), or a restorative loss (gone/wrong when it should have been restored).
- Adopt-forward: a single-op write whose only damage is a durably-present
orphan — the record the caller asked for — is committed forward (its
generation buffered) and returns success with a loud warning that the derived
index may be incomplete for that id until the next rebuild/repairIndex(). No
error, no double-write; the record is durable and get-able immediately.
- Fail loud + quarantine: a multi-op batch, or ANY restorative loss, throws the
new StoreInconsistentError naming every unreconciled record and its
disposition, and puts the brain into write-quarantine (reads keep working,
writes refused via assertWritable) until repairIndex() reconciles the derived
indexes against canonical and lifts it. The counter is not advanced, and
Transaction.rollback now reports state 'inconsistent' instead of the
'rolled_back' lie when any undo failed.
New: StoreInconsistentError + UnreconciledRecord exported from the root;
repairIndex() forces a rebuild and clears the quarantine. Regression
tests/integration/rollback-trapdoor.test.ts injects index-add-throws +
canonical-delete-undo-fails and pins adopt-forward (durable, get-able, not
quarantined), fail-loud (StoreInconsistentError + quarantine + reads work +
repairIndex lifts it), and the error's record naming.
2026-07-12 12:19:09 -07:00
|
|
|
|
// A failed rollback that left canonical records unreconciled surfaces as
|
|
|
|
|
|
// a loud StoreInconsistentError (brainy quarantines writes until repair);
|
|
|
|
|
|
// otherwise the raw error (clean abort, or a derived-only undo failure
|
|
|
|
|
|
// the egress guard + rebuild handle).
|
|
|
|
|
|
if (inconsistent.length > 0) {
|
|
|
|
|
|
throw new StoreInconsistentError(
|
|
|
|
|
|
inconsistent,
|
|
|
|
|
|
(err as TransactionRollbackError).originalError
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
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
|
|
|
|
throw err
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
fix: honest response when a transaction rollback cannot complete
When a transaction failed and its rollback ALSO failed to undo a canonical
write (retries exhausted), the old path logged, continued, threw
TransactionRollbackError, and set state='rolled_back' — while the record it
could not undo stayed durable on disk. The caller got an error implying the
write was undone; a read-back showed the record. A failed rollback had no
truthful response (the post-commit response lie).
Give a failed rollback a two-branch honest contract, decided by observation:
both commit paths already hold byte-identical before-images, so after a failed
rollback the store compares current canonical state to them and classifies each
touched record as reconciled, an additive orphan (present when it should be
gone), or a restorative loss (gone/wrong when it should have been restored).
- Adopt-forward: a single-op write whose only damage is a durably-present
orphan — the record the caller asked for — is committed forward (its
generation buffered) and returns success with a loud warning that the derived
index may be incomplete for that id until the next rebuild/repairIndex(). No
error, no double-write; the record is durable and get-able immediately.
- Fail loud + quarantine: a multi-op batch, or ANY restorative loss, throws the
new StoreInconsistentError naming every unreconciled record and its
disposition, and puts the brain into write-quarantine (reads keep working,
writes refused via assertWritable) until repairIndex() reconciles the derived
indexes against canonical and lifts it. The counter is not advanced, and
Transaction.rollback now reports state 'inconsistent' instead of the
'rolled_back' lie when any undo failed.
New: StoreInconsistentError + UnreconciledRecord exported from the root;
repairIndex() forces a rebuild and clears the quarantine. Regression
tests/integration/rollback-trapdoor.test.ts injects index-add-throws +
canonical-delete-undo-fails and pins adopt-forward (durable, get-able, not
quarantined), fail-loud (StoreInconsistentError + quarantine + reads work +
repairIndex lifts it), and the error's record naming.
2026-07-12 12:19:09 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* @description After a transaction's rollback FAILED to fully apply
|
|
|
|
|
|
* (`TransactionRollbackError`), determine which touched records are now
|
|
|
|
|
|
* unreconciled by comparing current canonical state to the before-images the
|
|
|
|
|
|
* commit captured. This observes reality rather than trusting the opaque undo
|
|
|
|
|
|
* closures — the authoritative signal for adopt-forward vs fail-loud.
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param nounBefore - Byte-identical entity before-images captured pre-execute.
|
|
|
|
|
|
* @param verbBefore - Byte-identical relationship before-images.
|
|
|
|
|
|
* @returns Every record whose canonical state no longer matches its
|
|
|
|
|
|
* before-image, tagged `'orphan'` (durably present when it should be gone —
|
|
|
|
|
|
* an add whose delete-undo failed) or `'loss'` (durably gone/wrong when it
|
|
|
|
|
|
* should have been restored — a remove/update whose restore-undo failed). An
|
|
|
|
|
|
* empty array means canonical storage is cleanly rolled back (only a derived
|
|
|
|
|
|
* index undo failed — the egress guard + a rebuild handle that).
|
|
|
|
|
|
*/
|
|
|
|
|
|
private async reconcileFailedRollback(
|
|
|
|
|
|
nounBefore: Map<string, GenerationRecord>,
|
|
|
|
|
|
verbBefore: Map<string, GenerationRecord>
|
|
|
|
|
|
): Promise<UnreconciledRecord[]> {
|
|
|
|
|
|
const out: UnreconciledRecord[] = []
|
|
|
|
|
|
const classify = (
|
|
|
|
|
|
before: GenerationRecord,
|
|
|
|
|
|
current: { metadata: unknown | null; vector: unknown | null }
|
|
|
|
|
|
): 'orphan' | 'loss' | null => {
|
|
|
|
|
|
const beforeEmpty = before.metadata == null && before.vector == null
|
|
|
|
|
|
const currentEmpty = current.metadata == null && current.vector == null
|
|
|
|
|
|
if (beforeEmpty && currentEmpty) return null // add cleanly undone
|
|
|
|
|
|
if (beforeEmpty) return 'orphan' // add's delete-undo failed → still present
|
|
|
|
|
|
if (currentEmpty) return 'loss' // remove's restore-undo failed → gone
|
|
|
|
|
|
// Both present: same value = reconciled; different = restore left a wrong value.
|
|
|
|
|
|
const same =
|
|
|
|
|
|
JSON.stringify({ m: before.metadata, v: before.vector }) ===
|
|
|
|
|
|
JSON.stringify({ m: current.metadata, v: current.vector })
|
|
|
|
|
|
return same ? null : 'loss'
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const [id, before] of nounBefore) {
|
|
|
|
|
|
const d = classify(before, await this.storage.readNounRaw(id))
|
|
|
|
|
|
if (d) out.push({ id, kind: 'noun', disposition: d })
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const [id, before] of verbBefore) {
|
|
|
|
|
|
const d = classify(before, await this.storage.readVerbRaw(id))
|
|
|
|
|
|
if (d) out.push({ id, kind: 'verb', disposition: d })
|
|
|
|
|
|
}
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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`/
|
2026-06-29 10:04:19 -07:00
|
|
|
|
* `relate`/`updateRelation`/`unrelate` and the per-item calls of `addMany`/
|
|
|
|
|
|
* `updateMany`/`relateMany`) as its own immutable generation — the Model-B
|
|
|
|
|
|
* "every write versioned" contract. (`removeMany` commits each delete *chunk*
|
|
|
|
|
|
* as a single generation for bulk efficiency — the one non-per-item path.) Structurally a one-operation {@link commitTransaction} with
|
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
|
|
|
|
* **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 +
|
2026-06-29 16:05:03 -07:00
|
|
|
|
* {@link reservedGensAsc}), so a synchronous `now()` pin freezes against it
|
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
|
|
|
|
* 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}).
|
|
|
|
|
|
*
|
2026-07-12 08:54:14 -07:00
|
|
|
|
* **Durability contract (single-op vs transact).** A single-op write's live
|
|
|
|
|
|
* canonical bytes are written via tmp+rename but NOT individually fsync'd —
|
|
|
|
|
|
* they are durable at the next {@link flushPendingSingleOps} or `close()`, not
|
|
|
|
|
|
* the instant the write resolves. On a hard kill before that flush, a
|
|
|
|
|
|
* single-op write can be lost even though the call returned; the group-commit
|
|
|
|
|
|
* counter is buffered alongside, so counter and data are lost together (no
|
|
|
|
|
|
* counter-ahead-of-state torn store). {@link commitTransaction} is the
|
|
|
|
|
|
* stronger contract: it runs a write barrier that fsyncs the whole batch's
|
|
|
|
|
|
* canonical footprint BEFORE advancing the generation counter, so a committed
|
|
|
|
|
|
* transact is durable on return. Callers that need per-write durability should
|
|
|
|
|
|
* use `transact()` (or `flush()` after a single-op); Model-B group-commit
|
|
|
|
|
|
* deliberately trades single-op fsync latency (a 3-5x write regression) for
|
|
|
|
|
|
* throughput.
|
|
|
|
|
|
*
|
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
|
|
|
|
* 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.
|
2026-07-08 14:53:34 -07:00
|
|
|
|
* @param args.precommit - Optional compare-and-swap precondition, invoked
|
|
|
|
|
|
* under the commit mutex with the just-read {@link CommitBeforeImages} and
|
|
|
|
|
|
* BEFORE `execute()`. A throw aborts the commit atomically: the generation
|
|
|
|
|
|
* reservation is returned and nothing is applied or buffered. This is the
|
|
|
|
|
|
* one place a per-record CAS (e.g. `ifRev`) is race-free — any check done
|
|
|
|
|
|
* before this mutex can interleave with a concurrent writer.
|
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
|
|
|
|
* @returns The reserved generation and its timestamp.
|
|
|
|
|
|
*/
|
|
|
|
|
|
async commitSingleOp(args: {
|
|
|
|
|
|
touched: { nouns?: string[]; verbs?: string[] }
|
|
|
|
|
|
execute: () => Promise<void>
|
2026-07-08 14:53:34 -07:00
|
|
|
|
precommit?: (before: CommitBeforeImages) => void
|
feat(embedding): deferred-embed markers become log records — the sidecar recovery path is deleted
The private recovery discipline, applied to its own machinery: pending-
embed markers stop being sidecar files and become first-class log records
riding the write's OWN commit fact — embed.pending lands in the same
atomic append as its after-image (a marker can never be orphaned from its
write, or vice versa; in durable-at-ack mode it shares the write's
covering fsync — zero extra syncs), and the worker's landing commit rides
embed.landed with the inline vector. Crash recovery is now a FOLD of the
log (pending without a matching landed = recovered), skipped wholesale on
brains with no v2 history; the one-time legacy bridge folds existing
sidecar files in, migrates them as one fact, and deletes them —
idempotent under a crash mid-bridge. No code path writes the sidecar
again.
Plus the ENTITY-TRUTH digest law, found by this train's own pins:
canonical vector wrappers denormalize HNSW residue (connections + the
randomly-assigned node level) that the log deliberately does not carry —
the verification oracle digested it and would have reported false
state-differs on ~any nonzero-level node (a ~15% flake in the cutover pin
was the symptom). Both sides of every oracle comparison now normalize to
entity truth (nounEntityTruth); index residue has its own rebuild path
and is not entity state.
Pins: embed-markers-in-log 5/5 (same-generation marker, landed+fold-to-
zero, crash recovery via the log with the sidecar prefix EMPTY on disk,
legacy bridge, VFS hung-embedder ack) · deferred-embedding 5/5 unchanged
(the contract outlived its mechanism) · kill-matrix 11/11 · cutover 5/5
×10 runs (flake dead) · unit 2031/2031.
2026-08-10 11:27:07 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* Optional v2 marker records riding this write's commit fact (e.g. the
|
|
|
|
|
|
* deferred-embedding lifecycle markers) — same generation, same atomic
|
|
|
|
|
|
* append, and in 'at-ack' log durability the SAME covering fsync as the
|
|
|
|
|
|
* write itself (zero extra sync). A marker can never be orphaned from
|
|
|
|
|
|
* its write nor the write from its marker. Additive: omitted on every
|
|
|
|
|
|
* markerless path. When the storage hosts no fact log the markers have
|
|
|
|
|
|
* no durable home — matching that storage's overall durability posture
|
|
|
|
|
|
* (it cannot host the log's crash guarantees either); callers own
|
|
|
|
|
|
* surfacing that honestly.
|
|
|
|
|
|
*/
|
|
|
|
|
|
records?: FactMarkerRecord[]
|
2026-08-17 16:21:25 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* Engine-origin stamp (`'system:embed-landing'`, `'system:adoption-backfill'`,
|
|
|
|
|
|
* `'system:reconcile'`). Rides the tx-log entry AND the commit fact's meta,
|
|
|
|
|
|
* so both records agree about WHO committed. Absent = user write.
|
|
|
|
|
|
*/
|
|
|
|
|
|
origin?: string
|
fix: honest response when a transaction rollback cannot complete
When a transaction failed and its rollback ALSO failed to undo a canonical
write (retries exhausted), the old path logged, continued, threw
TransactionRollbackError, and set state='rolled_back' — while the record it
could not undo stayed durable on disk. The caller got an error implying the
write was undone; a read-back showed the record. A failed rollback had no
truthful response (the post-commit response lie).
Give a failed rollback a two-branch honest contract, decided by observation:
both commit paths already hold byte-identical before-images, so after a failed
rollback the store compares current canonical state to them and classifies each
touched record as reconciled, an additive orphan (present when it should be
gone), or a restorative loss (gone/wrong when it should have been restored).
- Adopt-forward: a single-op write whose only damage is a durably-present
orphan — the record the caller asked for — is committed forward (its
generation buffered) and returns success with a loud warning that the derived
index may be incomplete for that id until the next rebuild/repairIndex(). No
error, no double-write; the record is durable and get-able immediately.
- Fail loud + quarantine: a multi-op batch, or ANY restorative loss, throws the
new StoreInconsistentError naming every unreconciled record and its
disposition, and puts the brain into write-quarantine (reads keep working,
writes refused via assertWritable) until repairIndex() reconciles the derived
indexes against canonical and lifts it. The counter is not advanced, and
Transaction.rollback now reports state 'inconsistent' instead of the
'rolled_back' lie when any undo failed.
New: StoreInconsistentError + UnreconciledRecord exported from the root;
repairIndex() forces a rebuild and clears the quarantine. Regression
tests/integration/rollback-trapdoor.test.ts injects index-add-throws +
canonical-delete-undo-fails and pins adopt-forward (durable, get-able, not
quarantined), fail-loud (StoreInconsistentError + quarantine + reads work +
repairIndex lifts it), and the error's record naming.
2026-07-12 12:19:09 -07:00
|
|
|
|
}): Promise<{ generation: number; timestamp: number; degraded?: 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
|
|
|
|
return this.withMutex(async () => {
|
2026-07-13 09:31:25 -07:00
|
|
|
|
// Refuse to accept a write whose history we cannot make durable: if the
|
|
|
|
|
|
// pending-tier flush has latched a persistent failure, accepting more
|
|
|
|
|
|
// writes would silently pile un-durable before-images into memory. Fail
|
|
|
|
|
|
// loud instead (the latch self-clears when a flush finally succeeds).
|
|
|
|
|
|
this.assertHistoryDurable()
|
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 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) {
|
fix(recovery): walks are healers — the typed/tolerant boundary redrawn where block-layer fault injection proved it belonged
The quiet-loss cure regressed recovery: the new typed torn-record error
was correct at identity-read time but threw inside init-time recovery
walks, killing opens that previously survived. The boundary, redrawn:
- IDENTITY READS (get-by-id of a specific record, CAS blob point-get):
typed TornRecordError, unchanged — a caller who asked for THAT record
can act on the answer.
- SET-SHAPED READS AND WALKS (enumeration, pagination, batch hydration —
the paths recovery rebuilds and finds page over): HEAL PAST the torn
victim. The adapter's loud floor (error log + counted gauge) fires at
the encounter; the walk serves the remaining rows. One crash casualty
can no longer kill every query on its shard — or the open itself.
- WRITES OVER TORN RECORDS ARE THE CURE: the save path's read-merge, the
commit path's before-image capture, and the operations' rollback
captures all treat a torn prior as the create sentinel, narrated — the
incoming bytes replace the unreadable ones, and history for the id
honestly restarts at that generation. Corruption can never block its
own heal.
- THE NaN SOURCE: torn mapper state (nextId/entries carrying garbage)
discards with narration and re-derives via the existing rebuild path;
the mint gains a source guard healing a non-integer counter from the
live map. The reopen and first-write RangeError shapes are dead at the
source, both authority branches.
Pinned with the exact fault-injection scenarios: a torn entity record
(including the VFS root) no longer kills the open — walks heal past it,
the keeper rows serve, and the identity read of the victim itself is
typed-or-healed; a torn mapper reopens and mints sanely on the first
post-recovery write.
Gates: tsc 0 · unit 2065/2065 · integration 828 · conformance 31/31.
2026-08-11 09:20:30 -07:00
|
|
|
|
const prev = await this.readRawForBeforeImage('noun', id)
|
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
|
|
|
|
nounBefore.set(id, { kind: 'noun', metadata: prev.metadata, vector: prev.vector })
|
|
|
|
|
|
}
|
|
|
|
|
|
const verbBefore = new Map<string, GenerationRecord>()
|
|
|
|
|
|
for (const id of verbs) {
|
fix(recovery): walks are healers — the typed/tolerant boundary redrawn where block-layer fault injection proved it belonged
The quiet-loss cure regressed recovery: the new typed torn-record error
was correct at identity-read time but threw inside init-time recovery
walks, killing opens that previously survived. The boundary, redrawn:
- IDENTITY READS (get-by-id of a specific record, CAS blob point-get):
typed TornRecordError, unchanged — a caller who asked for THAT record
can act on the answer.
- SET-SHAPED READS AND WALKS (enumeration, pagination, batch hydration —
the paths recovery rebuilds and finds page over): HEAL PAST the torn
victim. The adapter's loud floor (error log + counted gauge) fires at
the encounter; the walk serves the remaining rows. One crash casualty
can no longer kill every query on its shard — or the open itself.
- WRITES OVER TORN RECORDS ARE THE CURE: the save path's read-merge, the
commit path's before-image capture, and the operations' rollback
captures all treat a torn prior as the create sentinel, narrated — the
incoming bytes replace the unreadable ones, and history for the id
honestly restarts at that generation. Corruption can never block its
own heal.
- THE NaN SOURCE: torn mapper state (nextId/entries carrying garbage)
discards with narration and re-derives via the existing rebuild path;
the mint gains a source guard healing a non-integer counter from the
live map. The reopen and first-write RangeError shapes are dead at the
source, both authority branches.
Pinned with the exact fault-injection scenarios: a torn entity record
(including the VFS root) no longer kills the open — walks heal past it,
the keeper rows serve, and the identity read of the victim itself is
typed-or-healed; a torn mapper reopens and mints sanely on the first
post-recovery write.
Gates: tsc 0 · unit 2065/2065 · integration 828 · conformance 31/31.
2026-08-11 09:20:30 -07:00
|
|
|
|
const prev = await this.readRawForBeforeImage('verb', id)
|
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
|
|
|
|
verbBefore.set(id, { kind: 'verb', metadata: prev.metadata, vector: prev.vector })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-08 14:53:34 -07:00
|
|
|
|
// Conditional commit: the caller's CAS precondition runs here — under
|
|
|
|
|
|
// the mutex, against the authoritative before-images, before any write.
|
|
|
|
|
|
// A throw aborts cleanly: return the generation reservation (nothing was
|
|
|
|
|
|
// applied or buffered) and surface the conflict to the caller.
|
|
|
|
|
|
if (args.precommit) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
args.precommit({ nouns: nounBefore, verbs: verbBefore })
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
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
|
|
|
|
// 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
|
2026-08-12 16:56:08 -07:00
|
|
|
|
// Fold-checkpoint accounting: execute() ran, so canonical bytes for
|
|
|
|
|
|
// the touched ids changed — whether they now hold the new images, a
|
|
|
|
|
|
// restored rollback, or (the trapdoor) something indeterminate, the
|
|
|
|
|
|
// next checkpoint stamp must not assert their durability without a
|
|
|
|
|
|
// barrier over whatever is actually there.
|
|
|
|
|
|
for (const id of nouns) this.noteCheckpointDirty('noun', id)
|
|
|
|
|
|
for (const id of verbs) this.noteCheckpointDirty('verb', id)
|
fix: honest response when a transaction rollback cannot complete
When a transaction failed and its rollback ALSO failed to undo a canonical
write (retries exhausted), the old path logged, continued, threw
TransactionRollbackError, and set state='rolled_back' — while the record it
could not undo stayed durable on disk. The caller got an error implying the
write was undone; a read-back showed the record. A failed rollback had no
truthful response (the post-commit response lie).
Give a failed rollback a two-branch honest contract, decided by observation:
both commit paths already hold byte-identical before-images, so after a failed
rollback the store compares current canonical state to them and classifies each
touched record as reconciled, an additive orphan (present when it should be
gone), or a restorative loss (gone/wrong when it should have been restored).
- Adopt-forward: a single-op write whose only damage is a durably-present
orphan — the record the caller asked for — is committed forward (its
generation buffered) and returns success with a loud warning that the derived
index may be incomplete for that id until the next rebuild/repairIndex(). No
error, no double-write; the record is durable and get-able immediately.
- Fail loud + quarantine: a multi-op batch, or ANY restorative loss, throws the
new StoreInconsistentError naming every unreconciled record and its
disposition, and puts the brain into write-quarantine (reads keep working,
writes refused via assertWritable) until repairIndex() reconciles the derived
indexes against canonical and lifts it. The counter is not advanced, and
Transaction.rollback now reports state 'inconsistent' instead of the
'rolled_back' lie when any undo failed.
New: StoreInconsistentError + UnreconciledRecord exported from the root;
repairIndex() forces a rebuild and clears the quarantine. Regression
tests/integration/rollback-trapdoor.test.ts injects index-add-throws +
canonical-delete-undo-fails and pins adopt-forward (durable, get-able, not
quarantined), fail-loud (StoreInconsistentError + quarantine + reads work +
repairIndex lifts it), and the error's record naming.
2026-07-12 12:19:09 -07:00
|
|
|
|
// A failed rollback (TransactionRollbackError) may have left canonical
|
|
|
|
|
|
// storage inconsistent — the trapdoor. Reconcile against the
|
|
|
|
|
|
// before-images to decide the honest response (David's ruling:
|
|
|
|
|
|
// adopt-forward when safe, else fail loud).
|
|
|
|
|
|
if (err instanceof TransactionRollbackError) {
|
|
|
|
|
|
const records = await this.reconcileFailedRollback(nounBefore, verbBefore)
|
|
|
|
|
|
if (records.length > 0 && records.every((r) => r.disposition === 'orphan')) {
|
|
|
|
|
|
// ADOPT FORWARD: the durably-present orphan(s) are exactly what this
|
|
|
|
|
|
// single-op add wrote. Keep + buffer the generation so the record is
|
|
|
|
|
|
// legitimately committed and readable; the derived index may be
|
|
|
|
|
|
// incomplete for these ids until the next rebuild/repairIndex (the
|
|
|
|
|
|
// egress guard prevents wrong results meanwhile). Loud, honest,
|
|
|
|
|
|
// no double-write.
|
2026-08-17 16:21:25 -07:00
|
|
|
|
this.pendingBuffer.set(gen, { nouns: nounBefore, verbs: verbBefore, timestamp, ...(args.origin ? { origin: args.origin } : {}) })
|
fix: honest response when a transaction rollback cannot complete
When a transaction failed and its rollback ALSO failed to undo a canonical
write (retries exhausted), the old path logged, continued, threw
TransactionRollbackError, and set state='rolled_back' — while the record it
could not undo stayed durable on disk. The caller got an error implying the
write was undone; a read-back showed the record. A failed rollback had no
truthful response (the post-commit response lie).
Give a failed rollback a two-branch honest contract, decided by observation:
both commit paths already hold byte-identical before-images, so after a failed
rollback the store compares current canonical state to them and classifies each
touched record as reconciled, an additive orphan (present when it should be
gone), or a restorative loss (gone/wrong when it should have been restored).
- Adopt-forward: a single-op write whose only damage is a durably-present
orphan — the record the caller asked for — is committed forward (its
generation buffered) and returns success with a loud warning that the derived
index may be incomplete for that id until the next rebuild/repairIndex(). No
error, no double-write; the record is durable and get-able immediately.
- Fail loud + quarantine: a multi-op batch, or ANY restorative loss, throws the
new StoreInconsistentError naming every unreconciled record and its
disposition, and puts the brain into write-quarantine (reads keep working,
writes refused via assertWritable) until repairIndex() reconciles the derived
indexes against canonical and lifts it. The counter is not advanced, and
Transaction.rollback now reports state 'inconsistent' instead of the
'rolled_back' lie when any undo failed.
New: StoreInconsistentError + UnreconciledRecord exported from the root;
repairIndex() forces a rebuild and clears the quarantine. Regression
tests/integration/rollback-trapdoor.test.ts injects index-add-throws +
canonical-delete-undo-fails and pins adopt-forward (durable, get-able, not
quarantined), fail-loud (StoreInconsistentError + quarantine + reads work +
repairIndex lifts it), and the error's record naming.
2026-07-12 12:19:09 -07:00
|
|
|
|
this.pendingGens.push(gen)
|
|
|
|
|
|
this.extendChains(gen, nouns, verbs)
|
feat: generation fact log — after-image commit records, dual-written at every commit point
Every committed generation now also appends a FACT — an after-image
commit record (what each touched entity/relationship became, or a
body-less tombstone for a removal) — to an append-only, crc32c-framed
segment log under _generations/facts/. The before-image history and the
canonical tree remain authoritative; the fact log gives consumers ONE
sequential, self-verifying stream (index heals, incremental replays)
in place of a per-entity directory walk.
- Wire format: positional msgpack facts [generation, timestamp, ops,
meta, blobHashes]; op = [kind u8, id bin16, record | nil tombstone];
32-byte segment header (magic, formatVersion, firstGeneration,
zeroed+verified reserved); length+crc32c frame per fact; zero-padded
segment names so lexicographic order == generation order; JSON
manifest with an atomic rename flip, manifest-first rotation.
- Commit protocol: facts append+fsync BEFORE the commit point inside
the existing durability window, so a crash can only leave the log
AHEAD of committed truth — open() truncates back (torn tails detected
by CRC). Absent generation = never committed; a scan can never see an
uncommitted fact. transact() facts are durable-on-return; single-op
facts ride the group-commit flush exactly like buffered history. A
fact-append failure fails the write, loudly — a silent gap would be a
lie a later replay discovers.
- New public surface: brain.scanFacts() (sequential batches with heal
telemetry: head/segments/approx up front, per-batch generation range
+ bytes + segment id, loud abort on gaps, summary cross-check) and
brain.factSegmentPaths() (immutable sealed segments for zero-copy
consumers; the mutable tail excluded). Exported types CommitFact,
FactOp, FactScanBatch, FactScanHandle.
- Storage: optional binary raw-byte primitives (appendRawBytes,
readRawBytes, writeRawBytes, rawByteSize) on StorageAdapter —
feature-detected; filesystem + memory adapters implement them; an
adapter without them hosts no fact log. Fact segments are byte-copied
(never hard-linked) into snapshots. The _generations/facts/ namespace
is registered as a protected family (rebuildable: false): no sweeper
or GC may delete under it.
- New crc32c (Castagnoli) utility with RFC known-answer tests.
2026-07-15 10:49:02 -07:00
|
|
|
|
// The adopted generation is committed — it gets its fact like any
|
|
|
|
|
|
// other (durability rides the group-commit flush, same as the
|
|
|
|
|
|
// buffered history).
|
|
|
|
|
|
if (this.factLog) {
|
|
|
|
|
|
await this.factLog.append(
|
feat(embedding): deferred-embed markers become log records — the sidecar recovery path is deleted
The private recovery discipline, applied to its own machinery: pending-
embed markers stop being sidecar files and become first-class log records
riding the write's OWN commit fact — embed.pending lands in the same
atomic append as its after-image (a marker can never be orphaned from its
write, or vice versa; in durable-at-ack mode it shares the write's
covering fsync — zero extra syncs), and the worker's landing commit rides
embed.landed with the inline vector. Crash recovery is now a FOLD of the
log (pending without a matching landed = recovered), skipped wholesale on
brains with no v2 history; the one-time legacy bridge folds existing
sidecar files in, migrates them as one fact, and deletes them —
idempotent under a crash mid-bridge. No code path writes the sidecar
again.
Plus the ENTITY-TRUTH digest law, found by this train's own pins:
canonical vector wrappers denormalize HNSW residue (connections + the
randomly-assigned node level) that the log deliberately does not carry —
the verification oracle digested it and would have reported false
state-differs on ~any nonzero-level node (a ~15% flake in the cutover pin
was the symptom). Both sides of every oracle comparison now normalize to
entity truth (nounEntityTruth); index residue has its own rebuild path
and is not entity state.
Pins: embed-markers-in-log 5/5 (same-generation marker, landed+fold-to-
zero, crash recovery via the log with the sidecar prefix EMPTY on disk,
legacy bridge, VFS hung-embedder ack) · deferred-embedding 5/5 unchanged
(the contract outlived its mechanism) · kill-matrix 11/11 · cutover 5/5
×10 runs (flake dead) · unit 2031/2031.
2026-08-10 11:27:07 -07:00
|
|
|
|
await this.buildCommitFact({
|
|
|
|
|
|
generation: gen,
|
|
|
|
|
|
timestamp,
|
|
|
|
|
|
nouns,
|
|
|
|
|
|
verbs,
|
2026-08-17 16:21:25 -07:00
|
|
|
|
...(args.origin ? { meta: { origin: args.origin } } : {}),
|
feat(embedding): deferred-embed markers become log records — the sidecar recovery path is deleted
The private recovery discipline, applied to its own machinery: pending-
embed markers stop being sidecar files and become first-class log records
riding the write's OWN commit fact — embed.pending lands in the same
atomic append as its after-image (a marker can never be orphaned from its
write, or vice versa; in durable-at-ack mode it shares the write's
covering fsync — zero extra syncs), and the worker's landing commit rides
embed.landed with the inline vector. Crash recovery is now a FOLD of the
log (pending without a matching landed = recovered), skipped wholesale on
brains with no v2 history; the one-time legacy bridge folds existing
sidecar files in, migrates them as one fact, and deletes them —
idempotent under a crash mid-bridge. No code path writes the sidecar
again.
Plus the ENTITY-TRUTH digest law, found by this train's own pins:
canonical vector wrappers denormalize HNSW residue (connections + the
randomly-assigned node level) that the log deliberately does not carry —
the verification oracle digested it and would have reported false
state-differs on ~any nonzero-level node (a ~15% flake in the cutover pin
was the symptom). Both sides of every oracle comparison now normalize to
entity truth (nounEntityTruth); index residue has its own rebuild path
and is not entity state.
Pins: embed-markers-in-log 5/5 (same-generation marker, landed+fold-to-
zero, crash recovery via the log with the sidecar prefix EMPTY on disk,
legacy bridge, VFS hung-embedder ack) · deferred-embedding 5/5 unchanged
(the contract outlived its mechanism) · kill-matrix 11/11 · cutover 5/5
×10 runs (flake dead) · unit 2031/2031.
2026-08-10 11:27:07 -07:00
|
|
|
|
...(args.records && args.records.length > 0 ? { records: args.records } : {})
|
|
|
|
|
|
})
|
feat: generation fact log — after-image commit records, dual-written at every commit point
Every committed generation now also appends a FACT — an after-image
commit record (what each touched entity/relationship became, or a
body-less tombstone for a removal) — to an append-only, crc32c-framed
segment log under _generations/facts/. The before-image history and the
canonical tree remain authoritative; the fact log gives consumers ONE
sequential, self-verifying stream (index heals, incremental replays)
in place of a per-entity directory walk.
- Wire format: positional msgpack facts [generation, timestamp, ops,
meta, blobHashes]; op = [kind u8, id bin16, record | nil tombstone];
32-byte segment header (magic, formatVersion, firstGeneration,
zeroed+verified reserved); length+crc32c frame per fact; zero-padded
segment names so lexicographic order == generation order; JSON
manifest with an atomic rename flip, manifest-first rotation.
- Commit protocol: facts append+fsync BEFORE the commit point inside
the existing durability window, so a crash can only leave the log
AHEAD of committed truth — open() truncates back (torn tails detected
by CRC). Absent generation = never committed; a scan can never see an
uncommitted fact. transact() facts are durable-on-return; single-op
facts ride the group-commit flush exactly like buffered history. A
fact-append failure fails the write, loudly — a silent gap would be a
lie a later replay discovers.
- New public surface: brain.scanFacts() (sequential batches with heal
telemetry: head/segments/approx up front, per-batch generation range
+ bytes + segment id, loud abort on gaps, summary cross-check) and
brain.factSegmentPaths() (immutable sealed segments for zero-copy
consumers; the mutable tail excluded). Exported types CommitFact,
FactOp, FactScanBatch, FactScanHandle.
- Storage: optional binary raw-byte primitives (appendRawBytes,
readRawBytes, writeRawBytes, rawByteSize) on StorageAdapter —
feature-detected; filesystem + memory adapters implement them; an
adapter without them hosts no fact log. Fact segments are byte-copied
(never hard-linked) into snapshots. The _generations/facts/ namespace
is registered as a protected family (rebuildable: false): no sweeper
or GC may delete under it.
- New crc32c (Castagnoli) utility with RFC known-answer tests.
2026-07-15 10:49:02 -07:00
|
|
|
|
)
|
|
|
|
|
|
}
|
fix: honest response when a transaction rollback cannot complete
When a transaction failed and its rollback ALSO failed to undo a canonical
write (retries exhausted), the old path logged, continued, threw
TransactionRollbackError, and set state='rolled_back' — while the record it
could not undo stayed durable on disk. The caller got an error implying the
write was undone; a read-back showed the record. A failed rollback had no
truthful response (the post-commit response lie).
Give a failed rollback a two-branch honest contract, decided by observation:
both commit paths already hold byte-identical before-images, so after a failed
rollback the store compares current canonical state to them and classifies each
touched record as reconciled, an additive orphan (present when it should be
gone), or a restorative loss (gone/wrong when it should have been restored).
- Adopt-forward: a single-op write whose only damage is a durably-present
orphan — the record the caller asked for — is committed forward (its
generation buffered) and returns success with a loud warning that the derived
index may be incomplete for that id until the next rebuild/repairIndex(). No
error, no double-write; the record is durable and get-able immediately.
- Fail loud + quarantine: a multi-op batch, or ANY restorative loss, throws the
new StoreInconsistentError naming every unreconciled record and its
disposition, and puts the brain into write-quarantine (reads keep working,
writes refused via assertWritable) until repairIndex() reconciles the derived
indexes against canonical and lifts it. The counter is not advanced, and
Transaction.rollback now reports state 'inconsistent' instead of the
'rolled_back' lie when any undo failed.
New: StoreInconsistentError + UnreconciledRecord exported from the root;
repairIndex() forces a rebuild and clears the quarantine. Regression
tests/integration/rollback-trapdoor.test.ts injects index-add-throws +
canonical-delete-undo-fails and pins adopt-forward (durable, get-able, not
quarantined), fail-loud (StoreInconsistentError + quarantine + reads work +
repairIndex lifts it), and the error's record naming.
2026-07-12 12:19:09 -07:00
|
|
|
|
prodLog.warn(
|
|
|
|
|
|
`[GenerationStore] Recovered a failed rollback FORWARD: single-op write ` +
|
|
|
|
|
|
`committed as generation ${gen} because its canonical undo could not be ` +
|
|
|
|
|
|
`applied (record(s) ${records.map((r) => r.id).join(', ')} are durable). ` +
|
|
|
|
|
|
`The derived index may be incomplete for these ids — run repairIndex() to heal.`
|
|
|
|
|
|
)
|
|
|
|
|
|
return { generation: gen, timestamp, degraded: records.map((r) => r.id) }
|
|
|
|
|
|
}
|
|
|
|
|
|
if (records.length > 0) {
|
|
|
|
|
|
// FAIL LOUD: a loss (or mixed) cannot be safely adopted. Return the
|
|
|
|
|
|
// reservation and throw — brainy quarantines writes until repair.
|
|
|
|
|
|
if (this.counter === gen) this.counter = gen - 1
|
|
|
|
|
|
throw new StoreInconsistentError(records, err.originalError)
|
|
|
|
|
|
}
|
|
|
|
|
|
// records.length === 0: canonical is cleanly rolled back (only a
|
|
|
|
|
|
// derived undo failed) — fall through to the clean-abort path below.
|
|
|
|
|
|
}
|
|
|
|
|
|
// Live write failed with canonical cleanly rolled back: nothing buffered,
|
|
|
|
|
|
// nothing durable. Return the reservation (when no concurrent bump
|
|
|
|
|
|
// consumed a later number) so generation() is unchanged.
|
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
|
|
|
|
if (this.counter === gen) this.counter = gen - 1
|
|
|
|
|
|
throw err
|
|
|
|
|
|
}
|
|
|
|
|
|
this.inTransact = false
|
2026-08-12 16:56:08 -07:00
|
|
|
|
// Fold-checkpoint accounting: the live canonical write is applied — it
|
|
|
|
|
|
// must ride the next canonical-sync barrier before any stamp covers it.
|
|
|
|
|
|
for (const id of nouns) this.noteCheckpointDirty('noun', id)
|
|
|
|
|
|
for (const id of verbs) this.noteCheckpointDirty('verb', id)
|
fix(log): acked writes survive power loss; rejected writes never silently commit — the kill-matrix goes 11/11 with zero .fails debt
Two release-blocking findings from the durability kill-matrix, both fixed
in the owning layer:
1. LOG-AUTHORITY REPLAY AT OPEN: durable-at-ack fsynced the fact before
the ack, but open() truncated every fact above the manifest — after a
power loss that takes the un-fsynced tmp+rename canonical bytes, the
acked write's ONLY durable copy was discarded. Now: under 'log'
authority, open() REPLAYS intact facts above the manifest into
canonical (FactLog.peekFactsAbove — CRC-gated, order-sorted) and
advances the manifest to cover them; tree-authority brains keep the
truncate contract they were promised. Pinned end to end: the power-loss
row constructs the exact disk state (fsynced log, vanished canonical
rename) and the acked write lives.
2. NO SILENT COMMIT: commitSingleOp buffered the generation BEFORE the
fact append; an append failure (ENOSPC) rejected the caller but the
next flush durably committed the generation with NO fact — a permanent
silent log gap. Now the failure path un-buffers and returns the counter
reservation: nothing commits, the log stays gap-free, and the canonical
execute-residue orphan is the documented crash-equivalent.
Plus: the kill-matrix itself (11 rows — every commit-path fault point ×
reopen-as-crash recovery contract, at-ack variants, disk-full row; five
new zero-cost faultPoint sites), the log-authority pin suite (oracle
green/red/state-differs, flip refusal, switch survives reopen, 9/9), and
the group-commit covering pins (5/5).
Gates: unit 2002/2002 (152 files) · integration 785 · conformance 27/27.
2026-08-10 09:29:21 -07:00
|
|
|
|
// Test-only crash simulation (direct call — a throw propagates with no
|
|
|
|
|
|
// cleanup, exactly like a process death; recovery-on-open restores the
|
|
|
|
|
|
// contract). A crash here must cost only the never-returned ack: the
|
|
|
|
|
|
// live canonical write applied, but no history, fact, or generation
|
|
|
|
|
|
// record exists for it yet.
|
|
|
|
|
|
if (this.commitFaultInjector) this.commitFaultInjector('singleop-after-execute')
|
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
|
|
|
|
|
|
|
|
|
|
// Buffer the pending generation + make it instantly visible to reads.
|
2026-08-17 16:21:25 -07:00
|
|
|
|
this.pendingBuffer.set(gen, { nouns: nounBefore, verbs: verbBefore, timestamp, ...(args.origin ? { origin: args.origin } : {}) })
|
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.pendingGens.push(gen)
|
|
|
|
|
|
this.extendChains(gen, nouns, verbs)
|
feat: generation fact log — after-image commit records, dual-written at every commit point
Every committed generation now also appends a FACT — an after-image
commit record (what each touched entity/relationship became, or a
body-less tombstone for a removal) — to an append-only, crc32c-framed
segment log under _generations/facts/. The before-image history and the
canonical tree remain authoritative; the fact log gives consumers ONE
sequential, self-verifying stream (index heals, incremental replays)
in place of a per-entity directory walk.
- Wire format: positional msgpack facts [generation, timestamp, ops,
meta, blobHashes]; op = [kind u8, id bin16, record | nil tombstone];
32-byte segment header (magic, formatVersion, firstGeneration,
zeroed+verified reserved); length+crc32c frame per fact; zero-padded
segment names so lexicographic order == generation order; JSON
manifest with an atomic rename flip, manifest-first rotation.
- Commit protocol: facts append+fsync BEFORE the commit point inside
the existing durability window, so a crash can only leave the log
AHEAD of committed truth — open() truncates back (torn tails detected
by CRC). Absent generation = never committed; a scan can never see an
uncommitted fact. transact() facts are durable-on-return; single-op
facts ride the group-commit flush exactly like buffered history. A
fact-append failure fails the write, loudly — a silent gap would be a
lie a later replay discovers.
- New public surface: brain.scanFacts() (sequential batches with heal
telemetry: head/segments/approx up front, per-batch generation range
+ bytes + segment id, loud abort on gaps, summary cross-check) and
brain.factSegmentPaths() (immutable sealed segments for zero-copy
consumers; the mutable tail excluded). Exported types CommitFact,
FactOp, FactScanBatch, FactScanHandle.
- Storage: optional binary raw-byte primitives (appendRawBytes,
readRawBytes, writeRawBytes, rawByteSize) on StorageAdapter —
feature-detected; filesystem + memory adapters implement them; an
adapter without them hosts no fact log. Fact segments are byte-copied
(never hard-linked) into snapshots. The _generations/facts/ namespace
is registered as a protected family (rebuildable: false): no sweeper
or GC may delete under it.
- New crc32c (Castagnoli) utility with RFC known-answer tests.
2026-07-15 10:49:02 -07:00
|
|
|
|
// Fact log (dual-write): the acked write's AFTER-IMAGE fact, appended
|
|
|
|
|
|
// now (read back warm, under the mutex — group-commit means flush-time
|
|
|
|
|
|
// canonical only holds the LATEST state, so each generation's after-image
|
feat(log): the guarded log-authority core — group-commit durable-at-ack, the per-brain switch, the verification oracle
The storage-authority adoption path, guarded shape: the canonical tree
stays authoritative by default ('tree'); a brain flips to 'log' only
through the verification oracle, and the flip is stored, per-brain,
checked at open only.
- FactLog.ensureSynced(): classic group commit — concurrent writers
append, then join ONE covering fsync (running + queued slots give the
covering guarantee: the sync a caller awaits always starts after its
append landed). Solo writer = immediate sync.
- GenerationStore.logDurability 'deferred' (default, byte-identical to
today: fact durability rides the group-commit flush, ack latency
unchanged) | 'at-ack' (log-authority mode: every single-op ack awaits a
covering log fsync — an acked write's fact survives power loss, by
contract). transact() was already durable-at-return in both modes.
- src/db/logAuthority.ts: the stored switch artifact
(_system/log-authority.json, absent = tree), readLogAuthority, and the
VERIFICATION ORACLE — replay the fact log, fold latest state per id
(digests, never bodies — memory-bounded), diff against the canonical
tree paged; verdict green iff every canonical row is exactly reproduced
AND the log claims nothing canonical denies. Divergences are NAMED by
class (pre-log-record → needs baseline backfill; state-differs;
log-live-canonical-absent; log-tombstone-canonical-present). The flip
REFUSES on red with the first divergence and the cure in the message.
- Brainy: authority read at open (log → durable-at-ack enabled);
logAuthority() / verifyLogAuthority() / adoptLogAuthority() public API.
Nothing flips by itself; nothing changes for existing brains.
2026-08-06 10:08:18 -07:00
|
|
|
|
// exists only here).
|
|
|
|
|
|
//
|
|
|
|
|
|
// Durability is MODE-GOVERNED:
|
|
|
|
|
|
// - 'deferred' (default, the pre-log-authority behavior): durability
|
|
|
|
|
|
// rides the group-commit flush like the buffered history — a crash
|
|
|
|
|
|
// before the flush loses the fact AND the generation together, never
|
|
|
|
|
|
// a torn state.
|
|
|
|
|
|
// - 'at-ack' (log-authority mode): the ack awaits a covering fsync via
|
|
|
|
|
|
// the log's group-commit (many concurrent writers share ONE sync) —
|
|
|
|
|
|
// an acked write's fact survives power loss, by contract.
|
feat: generation fact log — after-image commit records, dual-written at every commit point
Every committed generation now also appends a FACT — an after-image
commit record (what each touched entity/relationship became, or a
body-less tombstone for a removal) — to an append-only, crc32c-framed
segment log under _generations/facts/. The before-image history and the
canonical tree remain authoritative; the fact log gives consumers ONE
sequential, self-verifying stream (index heals, incremental replays)
in place of a per-entity directory walk.
- Wire format: positional msgpack facts [generation, timestamp, ops,
meta, blobHashes]; op = [kind u8, id bin16, record | nil tombstone];
32-byte segment header (magic, formatVersion, firstGeneration,
zeroed+verified reserved); length+crc32c frame per fact; zero-padded
segment names so lexicographic order == generation order; JSON
manifest with an atomic rename flip, manifest-first rotation.
- Commit protocol: facts append+fsync BEFORE the commit point inside
the existing durability window, so a crash can only leave the log
AHEAD of committed truth — open() truncates back (torn tails detected
by CRC). Absent generation = never committed; a scan can never see an
uncommitted fact. transact() facts are durable-on-return; single-op
facts ride the group-commit flush exactly like buffered history. A
fact-append failure fails the write, loudly — a silent gap would be a
lie a later replay discovers.
- New public surface: brain.scanFacts() (sequential batches with heal
telemetry: head/segments/approx up front, per-batch generation range
+ bytes + segment id, loud abort on gaps, summary cross-check) and
brain.factSegmentPaths() (immutable sealed segments for zero-copy
consumers; the mutable tail excluded). Exported types CommitFact,
FactOp, FactScanBatch, FactScanHandle.
- Storage: optional binary raw-byte primitives (appendRawBytes,
readRawBytes, writeRawBytes, rawByteSize) on StorageAdapter —
feature-detected; filesystem + memory adapters implement them; an
adapter without them hosts no fact log. Fact segments are byte-copied
(never hard-linked) into snapshots. The _generations/facts/ namespace
is registered as a protected family (rebuildable: false): no sweeper
or GC may delete under it.
- New crc32c (Castagnoli) utility with RFC known-answer tests.
2026-07-15 10:49:02 -07:00
|
|
|
|
if (this.factLog) {
|
2026-08-12 16:09:48 -07:00
|
|
|
|
// TWO PHASES, TWO DISTINCT COMPENSATIONS (a production adoption
|
|
|
|
|
|
// proved the difference the hard way): rewinding the counter after
|
|
|
|
|
|
// a SUCCESSFUL append re-mints the same generation and every later
|
|
|
|
|
|
// append refuses non-monotonic — the write path wedges in a refusal
|
|
|
|
|
|
// loop. The counter may only rewind when the log provably does NOT
|
|
|
|
|
|
// carry the generation.
|
|
|
|
|
|
const unbuffer = (): void => {
|
|
|
|
|
|
this.pendingBuffer.delete(gen)
|
|
|
|
|
|
const idx = this.pendingGens.lastIndexOf(gen)
|
|
|
|
|
|
if (idx !== -1) this.pendingGens.splice(idx, 1)
|
|
|
|
|
|
this.invalidateChains()
|
|
|
|
|
|
}
|
|
|
|
|
|
// Phase 1 — APPEND. Failure = the log never took the fact: full
|
|
|
|
|
|
// compensation (un-buffer + counter rewind); a rejected write must
|
|
|
|
|
|
// not commit, and the next mint may safely reuse the number.
|
fix(log): acked writes survive power loss; rejected writes never silently commit — the kill-matrix goes 11/11 with zero .fails debt
Two release-blocking findings from the durability kill-matrix, both fixed
in the owning layer:
1. LOG-AUTHORITY REPLAY AT OPEN: durable-at-ack fsynced the fact before
the ack, but open() truncated every fact above the manifest — after a
power loss that takes the un-fsynced tmp+rename canonical bytes, the
acked write's ONLY durable copy was discarded. Now: under 'log'
authority, open() REPLAYS intact facts above the manifest into
canonical (FactLog.peekFactsAbove — CRC-gated, order-sorted) and
advances the manifest to cover them; tree-authority brains keep the
truncate contract they were promised. Pinned end to end: the power-loss
row constructs the exact disk state (fsynced log, vanished canonical
rename) and the acked write lives.
2. NO SILENT COMMIT: commitSingleOp buffered the generation BEFORE the
fact append; an append failure (ENOSPC) rejected the caller but the
next flush durably committed the generation with NO fact — a permanent
silent log gap. Now the failure path un-buffers and returns the counter
reservation: nothing commits, the log stays gap-free, and the canonical
execute-residue orphan is the documented crash-equivalent.
Plus: the kill-matrix itself (11 rows — every commit-path fault point ×
reopen-as-crash recovery contract, at-ack variants, disk-full row; five
new zero-cost faultPoint sites), the log-authority pin suite (oracle
green/red/state-differs, flip refusal, switch survives reopen, 9/9), and
the group-commit covering pins (5/5).
Gates: unit 2002/2002 (152 files) · integration 785 · conformance 27/27.
2026-08-10 09:29:21 -07:00
|
|
|
|
try {
|
|
|
|
|
|
await this.factLog.append(
|
feat(embedding): deferred-embed markers become log records — the sidecar recovery path is deleted
The private recovery discipline, applied to its own machinery: pending-
embed markers stop being sidecar files and become first-class log records
riding the write's OWN commit fact — embed.pending lands in the same
atomic append as its after-image (a marker can never be orphaned from its
write, or vice versa; in durable-at-ack mode it shares the write's
covering fsync — zero extra syncs), and the worker's landing commit rides
embed.landed with the inline vector. Crash recovery is now a FOLD of the
log (pending without a matching landed = recovered), skipped wholesale on
brains with no v2 history; the one-time legacy bridge folds existing
sidecar files in, migrates them as one fact, and deletes them —
idempotent under a crash mid-bridge. No code path writes the sidecar
again.
Plus the ENTITY-TRUTH digest law, found by this train's own pins:
canonical vector wrappers denormalize HNSW residue (connections + the
randomly-assigned node level) that the log deliberately does not carry —
the verification oracle digested it and would have reported false
state-differs on ~any nonzero-level node (a ~15% flake in the cutover pin
was the symptom). Both sides of every oracle comparison now normalize to
entity truth (nounEntityTruth); index residue has its own rebuild path
and is not entity state.
Pins: embed-markers-in-log 5/5 (same-generation marker, landed+fold-to-
zero, crash recovery via the log with the sidecar prefix EMPTY on disk,
legacy bridge, VFS hung-embedder ack) · deferred-embedding 5/5 unchanged
(the contract outlived its mechanism) · kill-matrix 11/11 · cutover 5/5
×10 runs (flake dead) · unit 2031/2031.
2026-08-10 11:27:07 -07:00
|
|
|
|
await this.buildCommitFact({
|
|
|
|
|
|
generation: gen,
|
|
|
|
|
|
timestamp,
|
|
|
|
|
|
nouns,
|
|
|
|
|
|
verbs,
|
2026-08-17 16:21:25 -07:00
|
|
|
|
...(args.origin ? { meta: { origin: args.origin } } : {}),
|
feat(embedding): deferred-embed markers become log records — the sidecar recovery path is deleted
The private recovery discipline, applied to its own machinery: pending-
embed markers stop being sidecar files and become first-class log records
riding the write's OWN commit fact — embed.pending lands in the same
atomic append as its after-image (a marker can never be orphaned from its
write, or vice versa; in durable-at-ack mode it shares the write's
covering fsync — zero extra syncs), and the worker's landing commit rides
embed.landed with the inline vector. Crash recovery is now a FOLD of the
log (pending without a matching landed = recovered), skipped wholesale on
brains with no v2 history; the one-time legacy bridge folds existing
sidecar files in, migrates them as one fact, and deletes them —
idempotent under a crash mid-bridge. No code path writes the sidecar
again.
Plus the ENTITY-TRUTH digest law, found by this train's own pins:
canonical vector wrappers denormalize HNSW residue (connections + the
randomly-assigned node level) that the log deliberately does not carry —
the verification oracle digested it and would have reported false
state-differs on ~any nonzero-level node (a ~15% flake in the cutover pin
was the symptom). Both sides of every oracle comparison now normalize to
entity truth (nounEntityTruth); index residue has its own rebuild path
and is not entity state.
Pins: embed-markers-in-log 5/5 (same-generation marker, landed+fold-to-
zero, crash recovery via the log with the sidecar prefix EMPTY on disk,
legacy bridge, VFS hung-embedder ack) · deferred-embedding 5/5 unchanged
(the contract outlived its mechanism) · kill-matrix 11/11 · cutover 5/5
×10 runs (flake dead) · unit 2031/2031.
2026-08-10 11:27:07 -07:00
|
|
|
|
...(args.records && args.records.length > 0 ? { records: args.records } : {})
|
|
|
|
|
|
})
|
fix(log): acked writes survive power loss; rejected writes never silently commit — the kill-matrix goes 11/11 with zero .fails debt
Two release-blocking findings from the durability kill-matrix, both fixed
in the owning layer:
1. LOG-AUTHORITY REPLAY AT OPEN: durable-at-ack fsynced the fact before
the ack, but open() truncated every fact above the manifest — after a
power loss that takes the un-fsynced tmp+rename canonical bytes, the
acked write's ONLY durable copy was discarded. Now: under 'log'
authority, open() REPLAYS intact facts above the manifest into
canonical (FactLog.peekFactsAbove — CRC-gated, order-sorted) and
advances the manifest to cover them; tree-authority brains keep the
truncate contract they were promised. Pinned end to end: the power-loss
row constructs the exact disk state (fsynced log, vanished canonical
rename) and the acked write lives.
2. NO SILENT COMMIT: commitSingleOp buffered the generation BEFORE the
fact append; an append failure (ENOSPC) rejected the caller but the
next flush durably committed the generation with NO fact — a permanent
silent log gap. Now the failure path un-buffers and returns the counter
reservation: nothing commits, the log stays gap-free, and the canonical
execute-residue orphan is the documented crash-equivalent.
Plus: the kill-matrix itself (11 rows — every commit-path fault point ×
reopen-as-crash recovery contract, at-ack variants, disk-full row; five
new zero-cost faultPoint sites), the log-authority pin suite (oracle
green/red/state-differs, flip refusal, switch survives reopen, 9/9), and
the group-commit covering pins (5/5).
Gates: unit 2002/2002 (152 files) · integration 785 · conformance 27/27.
2026-08-10 09:29:21 -07:00
|
|
|
|
)
|
|
|
|
|
|
} catch (err) {
|
2026-08-12 16:09:48 -07:00
|
|
|
|
unbuffer()
|
fix(log): acked writes survive power loss; rejected writes never silently commit — the kill-matrix goes 11/11 with zero .fails debt
Two release-blocking findings from the durability kill-matrix, both fixed
in the owning layer:
1. LOG-AUTHORITY REPLAY AT OPEN: durable-at-ack fsynced the fact before
the ack, but open() truncated every fact above the manifest — after a
power loss that takes the un-fsynced tmp+rename canonical bytes, the
acked write's ONLY durable copy was discarded. Now: under 'log'
authority, open() REPLAYS intact facts above the manifest into
canonical (FactLog.peekFactsAbove — CRC-gated, order-sorted) and
advances the manifest to cover them; tree-authority brains keep the
truncate contract they were promised. Pinned end to end: the power-loss
row constructs the exact disk state (fsynced log, vanished canonical
rename) and the acked write lives.
2. NO SILENT COMMIT: commitSingleOp buffered the generation BEFORE the
fact append; an append failure (ENOSPC) rejected the caller but the
next flush durably committed the generation with NO fact — a permanent
silent log gap. Now the failure path un-buffers and returns the counter
reservation: nothing commits, the log stays gap-free, and the canonical
execute-residue orphan is the documented crash-equivalent.
Plus: the kill-matrix itself (11 rows — every commit-path fault point ×
reopen-as-crash recovery contract, at-ack variants, disk-full row; five
new zero-cost faultPoint sites), the log-authority pin suite (oracle
green/red/state-differs, flip refusal, switch survives reopen, 9/9), and
the group-commit covering pins (5/5).
Gates: unit 2002/2002 (152 files) · integration 785 · conformance 27/27.
2026-08-10 09:29:21 -07:00
|
|
|
|
if (this.counter === gen) this.counter = gen - 1
|
|
|
|
|
|
throw err
|
feat(log): the guarded log-authority core — group-commit durable-at-ack, the per-brain switch, the verification oracle
The storage-authority adoption path, guarded shape: the canonical tree
stays authoritative by default ('tree'); a brain flips to 'log' only
through the verification oracle, and the flip is stored, per-brain,
checked at open only.
- FactLog.ensureSynced(): classic group commit — concurrent writers
append, then join ONE covering fsync (running + queued slots give the
covering guarantee: the sync a caller awaits always starts after its
append landed). Solo writer = immediate sync.
- GenerationStore.logDurability 'deferred' (default, byte-identical to
today: fact durability rides the group-commit flush, ack latency
unchanged) | 'at-ack' (log-authority mode: every single-op ack awaits a
covering log fsync — an acked write's fact survives power loss, by
contract). transact() was already durable-at-return in both modes.
- src/db/logAuthority.ts: the stored switch artifact
(_system/log-authority.json, absent = tree), readLogAuthority, and the
VERIFICATION ORACLE — replay the fact log, fold latest state per id
(digests, never bodies — memory-bounded), diff against the canonical
tree paged; verdict green iff every canonical row is exactly reproduced
AND the log claims nothing canonical denies. Divergences are NAMED by
class (pre-log-record → needs baseline backfill; state-differs;
log-live-canonical-absent; log-tombstone-canonical-present). The flip
REFUSES on red with the first divergence and the cure in the message.
- Brainy: authority read at open (log → durable-at-ack enabled);
logAuthority() / verifyLogAuthority() / adoptLogAuthority() public API.
Nothing flips by itself; nothing changes for existing brains.
2026-08-06 10:08:18 -07:00
|
|
|
|
}
|
2026-08-12 16:09:48 -07:00
|
|
|
|
// Phase 2 — the at-ack covering sync. Failure here means the fact
|
|
|
|
|
|
// IS in the log (append succeeded) but durability was not promised:
|
|
|
|
|
|
// try to remove it (dropAbove); only a SUCCESSFUL drop earns the
|
|
|
|
|
|
// counter rewind. If the drop itself fails (e.g. the fact was
|
|
|
|
|
|
// sealed by a racing rotation), the generation stays consumed and
|
|
|
|
|
|
// buffered — monotonicity holds, the flush path retries durability,
|
|
|
|
|
|
// and the caller still gets the loud failure.
|
|
|
|
|
|
if (this.logDurability === 'at-ack') {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await this.factLog.ensureSynced()
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await this.factLog.dropAbove(gen - 1)
|
|
|
|
|
|
unbuffer()
|
|
|
|
|
|
if (this.counter === gen) this.counter = gen - 1
|
|
|
|
|
|
} catch (dropErr) {
|
|
|
|
|
|
prodLog.warn(
|
|
|
|
|
|
`[GenerationStore] at-ack sync failed for generation ${gen} and the ` +
|
|
|
|
|
|
`appended fact could not be dropped (${(dropErr as Error).message}) — ` +
|
|
|
|
|
|
`the generation stays consumed and buffered; the flush path retries ` +
|
|
|
|
|
|
`durability. Never re-minting a number the log may carry.`
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
throw err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
feat: generation fact log — after-image commit records, dual-written at every commit point
Every committed generation now also appends a FACT — an after-image
commit record (what each touched entity/relationship became, or a
body-less tombstone for a removal) — to an append-only, crc32c-framed
segment log under _generations/facts/. The before-image history and the
canonical tree remain authoritative; the fact log gives consumers ONE
sequential, self-verifying stream (index heals, incremental replays)
in place of a per-entity directory walk.
- Wire format: positional msgpack facts [generation, timestamp, ops,
meta, blobHashes]; op = [kind u8, id bin16, record | nil tombstone];
32-byte segment header (magic, formatVersion, firstGeneration,
zeroed+verified reserved); length+crc32c frame per fact; zero-padded
segment names so lexicographic order == generation order; JSON
manifest with an atomic rename flip, manifest-first rotation.
- Commit protocol: facts append+fsync BEFORE the commit point inside
the existing durability window, so a crash can only leave the log
AHEAD of committed truth — open() truncates back (torn tails detected
by CRC). Absent generation = never committed; a scan can never see an
uncommitted fact. transact() facts are durable-on-return; single-op
facts ride the group-commit flush exactly like buffered history. A
fact-append failure fails the write, loudly — a silent gap would be a
lie a later replay discovers.
- New public surface: brain.scanFacts() (sequential batches with heal
telemetry: head/segments/approx up front, per-batch generation range
+ bytes + segment id, loud abort on gaps, summary cross-check) and
brain.factSegmentPaths() (immutable sealed segments for zero-copy
consumers; the mutable tail excluded). Exported types CommitFact,
FactOp, FactScanBatch, FactScanHandle.
- Storage: optional binary raw-byte primitives (appendRawBytes,
readRawBytes, writeRawBytes, rawByteSize) on StorageAdapter —
feature-detected; filesystem + memory adapters implement them; an
adapter without them hosts no fact log. Fact segments are byte-copied
(never hard-linked) into snapshots. The _generations/facts/ namespace
is registered as a protected family (rebuildable: false): no sweeper
or GC may delete under it.
- New crc32c (Castagnoli) utility with RFC known-answer tests.
2026-07-15 10:49:02 -07:00
|
|
|
|
}
|
fix(log): acked writes survive power loss; rejected writes never silently commit — the kill-matrix goes 11/11 with zero .fails debt
Two release-blocking findings from the durability kill-matrix, both fixed
in the owning layer:
1. LOG-AUTHORITY REPLAY AT OPEN: durable-at-ack fsynced the fact before
the ack, but open() truncated every fact above the manifest — after a
power loss that takes the un-fsynced tmp+rename canonical bytes, the
acked write's ONLY durable copy was discarded. Now: under 'log'
authority, open() REPLAYS intact facts above the manifest into
canonical (FactLog.peekFactsAbove — CRC-gated, order-sorted) and
advances the manifest to cover them; tree-authority brains keep the
truncate contract they were promised. Pinned end to end: the power-loss
row constructs the exact disk state (fsynced log, vanished canonical
rename) and the acked write lives.
2. NO SILENT COMMIT: commitSingleOp buffered the generation BEFORE the
fact append; an append failure (ENOSPC) rejected the caller but the
next flush durably committed the generation with NO fact — a permanent
silent log gap. Now the failure path un-buffers and returns the counter
reservation: nothing commits, the log stays gap-free, and the canonical
execute-residue orphan is the documented crash-equivalent.
Plus: the kill-matrix itself (11 rows — every commit-path fault point ×
reopen-as-crash recovery contract, at-ack variants, disk-full row; five
new zero-cost faultPoint sites), the log-authority pin suite (oracle
green/red/state-differs, flip refusal, switch survives reopen, 9/9), and
the group-commit covering pins (5/5).
Gates: unit 2002/2002 (152 files) · integration 785 · conformance 27/27.
2026-08-10 09:29:21 -07:00
|
|
|
|
// Test-only crash simulation. A crash here must cost the buffered
|
|
|
|
|
|
// history + the appended fact in 'deferred' mode (open() truncates it
|
|
|
|
|
|
// back to the manifest watermark) — while under 'log' authority the
|
|
|
|
|
|
// intact fact is REPLAYED at open, never the baseline or the applied
|
|
|
|
|
|
// live write.
|
|
|
|
|
|
if (this.commitFaultInjector) this.commitFaultInjector('singleop-after-fact-append')
|
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.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
|
2026-07-13 09:31:25 -07:00
|
|
|
|
// Centralize durability accounting here so EVERY failure path — the
|
|
|
|
|
|
// background scheduler AND an explicit flush()/close() — latches
|
|
|
|
|
|
// consistently, and success (from any caller) clears the latch. The
|
|
|
|
|
|
// scheduler owns only the retry cadence.
|
|
|
|
|
|
try {
|
|
|
|
|
|
await this.flushPendingSingleOpsUnlocked()
|
|
|
|
|
|
this.onPendingFlushSuccess()
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
this.recordPendingFlushFailure(err as Error)
|
|
|
|
|
|
throw err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** The actual pending-tier flush under the mutex (see {@link flushPendingSingleOps},
|
|
|
|
|
|
* which wraps this with durability accounting). */
|
|
|
|
|
|
private async flushPendingSingleOpsUnlocked(): 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
|
|
|
|
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}`
|
feat: temporal VFS — file content joins the Model-B immutability model
The temporal model had a hole exactly where files were concerned: every
entity write is an immutable generation with before-images, but VFS content
BYTES lived under an eager refCount GC left over from the pre-8.0 design —
unlink could physically destroy bytes that in-window history still
referenced, and overwrite never released the old hash at all (an unbounded
silent leak whose accidental byproduct was the only thing "preserving"
history). Reading the past could therefore return a stale field, a dangling
hash, or nothing, depending on luck.
Fix: blob reclamation becomes a HISTORY decision instead of a LIVENESS
decision. Each blob's metadata now carries historyRefCount alongside the
live refCount:
- The commit seam counts one history reference per persisted before-image
record carrying a content hash (commitTransaction staging and the
group-commit flush), recorded BEFORE the record-set persists and carried
in the generation delta (blobHashes — always present on new deltas, so
compaction only falls back to reading records for pre-contract
generations). An aborted transaction compensates best-effort.
- unlink/rmdir/overwrite drop ONLY the live reference (BlobStorage.delete →
release; overwrite finally releases the superseded hash — cancelling the
dedup increment on same-content rewrites and closing the leak), and only
AFTER the canonical mutation commits, so a failed delete can never leave a
live file whose bytes compaction might reclaim.
- History compaction is the ONE reclamation point: after deleting a
generation's record-set it releases that set's references and physically
reclaims any hash at zero live AND zero history references. Pins are
exempt automatically. Crash ordering is over-count-only in every path
(record before persist, release after delete), so a crash can leak until
the scrub recounts but can never reclaim bytes a retained generation
needs. scrubBlobHistoryRefCounts() restores exactness; existing stores get
a one-time marker-gated backfill on open, failing into leak-safe mode
(reclamation disabled) rather than guessing.
On top of the protected history, the temporal API the generational model
always implied:
- vfs.readFile(path, { asOf }) — the exact bytes as of a generation or Date,
materialized from the history (pinned view released so compaction is
never blocked by a read).
- vfs.history(path) — FileVersion[] ascending ({ generation, timestamp,
hash, size, mimeType? }), the newest entry being the live state.
- Overwrites now refresh the file entity's data/embedding text — semantic
search and the data field previously served the FIRST version's text
forever (the stale-field defect a consumer's incident recovery depended
on by luck).
Integration suite (temporal-vfs.test.ts): per-version exact reads +
history listing, leak-fix + history protection on overwrite, rm keeps bytes
readable, compaction reclaims past-window bytes and preserves in-window
(including the cross-file dedup case where an old file's history and a
newer file's removal share one hash), data freshness, and scrub exactness.
2026-07-10 16:43:48 -07:00
|
|
|
|
|
|
|
|
|
|
// Temporal-blob contract: count this record-set's content-hash
|
|
|
|
|
|
// references BEFORE persisting it (a crash between the two only
|
|
|
|
|
|
// over-counts — the scrub repairs a leak; under-counting could let
|
|
|
|
|
|
// compaction reclaim bytes a retained generation still needs).
|
|
|
|
|
|
const blobHashes = this.storage.extractBlobHashesFromRecords
|
|
|
|
|
|
? this.storage.extractBlobHashesFromRecords([
|
|
|
|
|
|
...buf.nouns.values(),
|
|
|
|
|
|
...buf.verbs.values()
|
|
|
|
|
|
])
|
|
|
|
|
|
: []
|
|
|
|
|
|
if (blobHashes.length > 0 && this.storage.recordHistoryBlobReferences) {
|
|
|
|
|
|
await this.storage.recordHistoryBlobReferences(blobHashes)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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 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,
|
feat: temporal VFS — file content joins the Model-B immutability model
The temporal model had a hole exactly where files were concerned: every
entity write is an immutable generation with before-images, but VFS content
BYTES lived under an eager refCount GC left over from the pre-8.0 design —
unlink could physically destroy bytes that in-window history still
referenced, and overwrite never released the old hash at all (an unbounded
silent leak whose accidental byproduct was the only thing "preserving"
history). Reading the past could therefore return a stale field, a dangling
hash, or nothing, depending on luck.
Fix: blob reclamation becomes a HISTORY decision instead of a LIVENESS
decision. Each blob's metadata now carries historyRefCount alongside the
live refCount:
- The commit seam counts one history reference per persisted before-image
record carrying a content hash (commitTransaction staging and the
group-commit flush), recorded BEFORE the record-set persists and carried
in the generation delta (blobHashes — always present on new deltas, so
compaction only falls back to reading records for pre-contract
generations). An aborted transaction compensates best-effort.
- unlink/rmdir/overwrite drop ONLY the live reference (BlobStorage.delete →
release; overwrite finally releases the superseded hash — cancelling the
dedup increment on same-content rewrites and closing the leak), and only
AFTER the canonical mutation commits, so a failed delete can never leave a
live file whose bytes compaction might reclaim.
- History compaction is the ONE reclamation point: after deleting a
generation's record-set it releases that set's references and physically
reclaims any hash at zero live AND zero history references. Pins are
exempt automatically. Crash ordering is over-count-only in every path
(record before persist, release after delete), so a crash can leak until
the scrub recounts but can never reclaim bytes a retained generation
needs. scrubBlobHistoryRefCounts() restores exactness; existing stores get
a one-time marker-gated backfill on open, failing into leak-safe mode
(reclamation disabled) rather than guessing.
On top of the protected history, the temporal API the generational model
always implied:
- vfs.readFile(path, { asOf }) — the exact bytes as of a generation or Date,
materialized from the history (pinned view released so compaction is
never blocked by a read).
- vfs.history(path) — FileVersion[] ascending ({ generation, timestamp,
hash, size, mimeType? }), the newest entry being the live state.
- Overwrites now refresh the file entity's data/embedding text — semantic
search and the data field previously served the FIRST version's text
forever (the stale-field defect a consumer's incident recovery depended
on by luck).
Integration suite (temporal-vfs.test.ts): per-version exact reads +
history listing, leak-fix + history protection on overwrite, rm keeps bytes
readable, compaction reclaims past-window bytes and preserves in-window
(including the cross-file dedup case where an old file's history and a
newer file's removal share one hash), data freshness, and scrub exactness.
2026-07-10 16:43:48 -07:00
|
|
|
|
verbs: verbIds,
|
|
|
|
|
|
// Always present on new deltas (empty = "no blobs"), so compaction
|
|
|
|
|
|
// only falls back to record reads for pre-contract generations.
|
|
|
|
|
|
blobHashes
|
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)
|
|
|
|
|
|
genBytes.set(gen, delta.bytes)
|
|
|
|
|
|
const deltaPath = `${dir}/tx.json`
|
|
|
|
|
|
await this.storage.writeRawObject(deltaPath, delta)
|
|
|
|
|
|
stagedPaths.push(deltaPath)
|
2026-08-17 16:21:25 -07:00
|
|
|
|
logEntries.push({ generation: gen, timestamp: buf.timestamp, ...(buf.origin ? { origin: buf.origin } : {}) })
|
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
|
|
|
|
}
|
|
|
|
|
|
|
fix(log): acked writes survive power loss; rejected writes never silently commit — the kill-matrix goes 11/11 with zero .fails debt
Two release-blocking findings from the durability kill-matrix, both fixed
in the owning layer:
1. LOG-AUTHORITY REPLAY AT OPEN: durable-at-ack fsynced the fact before
the ack, but open() truncated every fact above the manifest — after a
power loss that takes the un-fsynced tmp+rename canonical bytes, the
acked write's ONLY durable copy was discarded. Now: under 'log'
authority, open() REPLAYS intact facts above the manifest into
canonical (FactLog.peekFactsAbove — CRC-gated, order-sorted) and
advances the manifest to cover them; tree-authority brains keep the
truncate contract they were promised. Pinned end to end: the power-loss
row constructs the exact disk state (fsynced log, vanished canonical
rename) and the acked write lives.
2. NO SILENT COMMIT: commitSingleOp buffered the generation BEFORE the
fact append; an append failure (ENOSPC) rejected the caller but the
next flush durably committed the generation with NO fact — a permanent
silent log gap. Now the failure path un-buffers and returns the counter
reservation: nothing commits, the log stays gap-free, and the canonical
execute-residue orphan is the documented crash-equivalent.
Plus: the kill-matrix itself (11 rows — every commit-path fault point ×
reopen-as-crash recovery contract, at-ack variants, disk-full row; five
new zero-cost faultPoint sites), the log-authority pin suite (oracle
green/red/state-differs, flip refusal, switch survives reopen, 9/9), and
the group-commit covering pins (5/5).
Gates: unit 2002/2002 (152 files) · integration 785 · conformance 27/27.
2026-08-10 09:29:21 -07:00
|
|
|
|
// Test-only crash simulation. A crash here must cost only the window's
|
|
|
|
|
|
// HISTORY: un-fsynced record-set dirs may sit above the manifest, and
|
|
|
|
|
|
// recovery drops them WITHOUT restore — the acked live writes stay.
|
|
|
|
|
|
if (this.commitFaultInjector) this.commitFaultInjector('flush-after-staging')
|
|
|
|
|
|
|
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
|
|
|
|
// ONE fsync for the whole window — the durability-batching win.
|
|
|
|
|
|
await this.storage.syncRawObjects(stagedPaths)
|
|
|
|
|
|
|
feat: generation fact log — after-image commit records, dual-written at every commit point
Every committed generation now also appends a FACT — an after-image
commit record (what each touched entity/relationship became, or a
body-less tombstone for a removal) — to an append-only, crc32c-framed
segment log under _generations/facts/. The before-image history and the
canonical tree remain authoritative; the fact log gives consumers ONE
sequential, self-verifying stream (index heals, incremental replays)
in place of a per-entity directory walk.
- Wire format: positional msgpack facts [generation, timestamp, ops,
meta, blobHashes]; op = [kind u8, id bin16, record | nil tombstone];
32-byte segment header (magic, formatVersion, firstGeneration,
zeroed+verified reserved); length+crc32c frame per fact; zero-padded
segment names so lexicographic order == generation order; JSON
manifest with an atomic rename flip, manifest-first rotation.
- Commit protocol: facts append+fsync BEFORE the commit point inside
the existing durability window, so a crash can only leave the log
AHEAD of committed truth — open() truncates back (torn tails detected
by CRC). Absent generation = never committed; a scan can never see an
uncommitted fact. transact() facts are durable-on-return; single-op
facts ride the group-commit flush exactly like buffered history. A
fact-append failure fails the write, loudly — a silent gap would be a
lie a later replay discovers.
- New public surface: brain.scanFacts() (sequential batches with heal
telemetry: head/segments/approx up front, per-batch generation range
+ bytes + segment id, loud abort on gaps, summary cross-check) and
brain.factSegmentPaths() (immutable sealed segments for zero-copy
consumers; the mutable tail excluded). Exported types CommitFact,
FactOp, FactScanBatch, FactScanHandle.
- Storage: optional binary raw-byte primitives (appendRawBytes,
readRawBytes, writeRawBytes, rawByteSize) on StorageAdapter —
feature-detected; filesystem + memory adapters implement them; an
adapter without them hosts no fact log. Fact segments are byte-copied
(never hard-linked) into snapshots. The _generations/facts/ namespace
is registered as a protected family (rebuildable: false): no sweeper
or GC may delete under it.
- New crc32c (Castagnoli) utility with RFC known-answer tests.
2026-07-15 10:49:02 -07:00
|
|
|
|
// Fact log (dual-write): make the window's buffered facts durable in the
|
|
|
|
|
|
// same batch, BEFORE the commit point below — so a crash can only leave
|
|
|
|
|
|
// the log AHEAD of the counter (open truncates), never a committed
|
|
|
|
|
|
// generation without its durable fact.
|
|
|
|
|
|
await this.factLog?.sync()
|
|
|
|
|
|
|
fix(log): acked writes survive power loss; rejected writes never silently commit — the kill-matrix goes 11/11 with zero .fails debt
Two release-blocking findings from the durability kill-matrix, both fixed
in the owning layer:
1. LOG-AUTHORITY REPLAY AT OPEN: durable-at-ack fsynced the fact before
the ack, but open() truncated every fact above the manifest — after a
power loss that takes the un-fsynced tmp+rename canonical bytes, the
acked write's ONLY durable copy was discarded. Now: under 'log'
authority, open() REPLAYS intact facts above the manifest into
canonical (FactLog.peekFactsAbove — CRC-gated, order-sorted) and
advances the manifest to cover them; tree-authority brains keep the
truncate contract they were promised. Pinned end to end: the power-loss
row constructs the exact disk state (fsynced log, vanished canonical
rename) and the acked write lives.
2. NO SILENT COMMIT: commitSingleOp buffered the generation BEFORE the
fact append; an append failure (ENOSPC) rejected the caller but the
next flush durably committed the generation with NO fact — a permanent
silent log gap. Now the failure path un-buffers and returns the counter
reservation: nothing commits, the log stays gap-free, and the canonical
execute-residue orphan is the documented crash-equivalent.
Plus: the kill-matrix itself (11 rows — every commit-path fault point ×
reopen-as-crash recovery contract, at-ack variants, disk-full row; five
new zero-cost faultPoint sites), the log-authority pin suite (oracle
green/red/state-differs, flip refusal, switch survives reopen, 9/9), and
the group-commit covering pins (5/5).
Gates: unit 2002/2002 (152 files) · integration 785 · conformance 27/27.
2026-08-10 09:29:21 -07:00
|
|
|
|
// Test-only crash simulation. A crash here must cost only the window's
|
|
|
|
|
|
// history and its (already fsynced) facts — open() truncates the facts
|
|
|
|
|
|
// back to the manifest watermark and drops the staged group-commit dirs
|
|
|
|
|
|
// without restore; the acked live writes stay.
|
|
|
|
|
|
if (this.commitFaultInjector) this.commitFaultInjector('flush-before-manifest')
|
|
|
|
|
|
|
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
|
|
|
|
// 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
|
2026-06-29 16:05:03 -07:00
|
|
|
|
this.appendCommittedGen(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(buf.nouns.keys()),
|
|
|
|
|
|
verbs: new Set(buf.verbs.keys()),
|
|
|
|
|
|
timestamp: buf.timestamp,
|
|
|
|
|
|
bytes: genBytes.get(gen) ?? 0
|
|
|
|
|
|
})
|
2026-07-18 10:51:37 -07:00
|
|
|
|
if (this.historyBytesTotal !== null) {
|
|
|
|
|
|
this.historyBytesTotal += genBytes.get(gen) ?? 0
|
|
|
|
|
|
}
|
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.pendingBuffer.delete(gen)
|
|
|
|
|
|
}
|
|
|
|
|
|
this.pendingGens = []
|
|
|
|
|
|
|
|
|
|
|
|
for (const entry of logEntries) {
|
|
|
|
|
|
await this.storage.appendTxLogLine(JSON.stringify(entry))
|
|
|
|
|
|
}
|
2026-08-12 16:56:08 -07:00
|
|
|
|
|
|
|
|
|
|
// Fold-checkpoint barrier: the window's LIVE canonical bytes (the acked
|
|
|
|
|
|
// writes themselves — the staging sync above covered only their history
|
|
|
|
|
|
// copies) become durable here, and only then does the checkpoint stamp
|
|
|
|
|
|
// advance to the new committed watermark. This is what keeps crash
|
|
|
|
|
|
// recovery's log fold bounded to (checkpoint, head] instead of the
|
|
|
|
|
|
// whole log. A failure inside is absorbed by the barrier (it warns,
|
|
|
|
|
|
// retains the accumulator, and leaves the old bound standing) — history
|
|
|
|
|
|
// durability above already succeeded, so the flush itself is good.
|
|
|
|
|
|
await this.advanceFoldCheckpointUnlocked()
|
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
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 09:31:25 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* @description Reset the durability-failure state after a successful flush.
|
|
|
|
|
|
* If a failure was latched (writes were being refused), log the recovery so
|
|
|
|
|
|
* the transition out of the loud state is as visible as the transition in.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private onPendingFlushSuccess(): void {
|
|
|
|
|
|
if (this.pendingFlushError) {
|
|
|
|
|
|
prodLog.info(
|
|
|
|
|
|
`[GenerationStore] pending single-op history flush recovered after ` +
|
|
|
|
|
|
`${this.pendingFlushFailures} failed attempt(s); writes resume.`
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
this.pendingFlushFailures = 0
|
|
|
|
|
|
this.pendingFlushError = null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description Account for a failed pending-tier flush (called from
|
|
|
|
|
|
* {@link flushPendingSingleOps}' catch, so it fires on EVERY failure path —
|
|
|
|
|
|
* background scheduler and explicit flush()/close() alike). Escalates a
|
|
|
|
|
|
* transient blip (a warn, keep retrying) into a latched durability failure
|
|
|
|
|
|
* once {@link PENDING_FLUSH_FAILURE_THRESHOLD} consecutive attempts fail —
|
|
|
|
|
|
* from that point writes are refused with a {@link PendingFlushDurabilityError}
|
|
|
|
|
|
* until a flush finally succeeds. Always ensures a backoff retry is scheduled
|
|
|
|
|
|
* so a recovering disk self-heals without operator action, regardless of who
|
|
|
|
|
|
* triggered the failing flush. The pending before-images are retained (the
|
|
|
|
|
|
* failed flush never cleared them), so no history is lost — only not-yet-durable.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private recordPendingFlushFailure(err: Error): void {
|
|
|
|
|
|
this.pendingFlushFailures++
|
|
|
|
|
|
const attempts = this.pendingFlushFailures
|
|
|
|
|
|
if (attempts >= GenerationStore.PENDING_FLUSH_FAILURE_THRESHOLD) {
|
|
|
|
|
|
// Latch: refuse further writes rather than pile un-durable history.
|
|
|
|
|
|
this.pendingFlushError = err
|
|
|
|
|
|
prodLog.error(
|
|
|
|
|
|
`[GenerationStore] pending single-op history flush has FAILED ${attempts} ` +
|
|
|
|
|
|
`consecutive times (${err.message}). Generation history is not durable; ` +
|
|
|
|
|
|
`writes are now REFUSED until it drains. Live canonical data is intact. ` +
|
|
|
|
|
|
`Retrying with backoff; the store resumes writes when a flush succeeds.`
|
|
|
|
|
|
)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
prodLog.warn(
|
|
|
|
|
|
`[GenerationStore] pending single-op flush failed (attempt ${attempts}/` +
|
|
|
|
|
|
`${GenerationStore.PENDING_FLUSH_FAILURE_THRESHOLD}): ${err.message}; retrying with backoff.`
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
this.scheduleFlushRetry(attempts)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description (Re)arm the pending-flush timer at a capped exponential
|
|
|
|
|
|
* backoff after a failure, so a persistent fault is retried at a decaying
|
|
|
|
|
|
* cadence instead of hot-looping. Shares the single {@link pendingFlushTimer}
|
|
|
|
|
|
* slot with {@link schedulePendingFlush} (only one flush is ever scheduled).
|
|
|
|
|
|
* The retry's own failure is re-accounted inside {@link flushPendingSingleOps}.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private scheduleFlushRetry(attempt: number): void {
|
|
|
|
|
|
if (this.pendingFlushTimer !== null) return
|
|
|
|
|
|
const backoff = Math.min(
|
|
|
|
|
|
GenerationStore.PENDING_FLUSH_DELAY_MS * 2 ** Math.min(attempt, 6),
|
|
|
|
|
|
GenerationStore.PENDING_FLUSH_RETRY_CAP_MS
|
|
|
|
|
|
)
|
|
|
|
|
|
this.pendingFlushTimer = setTimeout(() => {
|
|
|
|
|
|
this.pendingFlushTimer = null
|
|
|
|
|
|
// flushPendingSingleOps records the failure + reschedules internally.
|
|
|
|
|
|
void this.flushPendingSingleOps().catch(() => {})
|
|
|
|
|
|
}, backoff)
|
|
|
|
|
|
const t = this.pendingFlushTimer as unknown as { unref?: () => void }
|
|
|
|
|
|
if (t && typeof t.unref === 'function') t.unref()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description Throw if the store has a latched history-durability failure.
|
|
|
|
|
|
* Called at the top of every write commit so a broken durability path fails
|
|
|
|
|
|
* loud and immediately instead of silently accumulating un-durable history.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private assertHistoryDurable(): void {
|
|
|
|
|
|
if (this.pendingFlushError) {
|
|
|
|
|
|
throw new PendingFlushDurabilityError(this.pendingFlushError, this.pendingFlushFailures)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
/** Schedule a coalesced pending-tier flush (size trigger fires immediately on
|
|
|
|
|
|
* the next microtask; otherwise a {@link PENDING_FLUSH_DELAY_MS} timer). Both
|
2026-07-13 09:31:25 -07:00
|
|
|
|
* defer outside the current mutex section so the flush can re-acquire it. A
|
|
|
|
|
|
* failed flush is accounted + rescheduled inside {@link flushPendingSingleOps}
|
|
|
|
|
|
* (latch-on-persistent-failure), never swallowed as a bare log line. */
|
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
|
|
|
|
private schedulePendingFlush(): void {
|
|
|
|
|
|
if (this.pendingGens.length >= this.pendingFlushThreshold) {
|
2026-07-13 09:31:25 -07:00
|
|
|
|
// Failure is recorded + retried inside flushPendingSingleOps.
|
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
|
|
|
|
void Promise.resolve()
|
|
|
|
|
|
.then(() => this.flushPendingSingleOps())
|
2026-07-13 09:31:25 -07:00
|
|
|
|
.catch(() => {})
|
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
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if (this.pendingFlushTimer !== null) return
|
|
|
|
|
|
this.pendingFlushTimer = setTimeout(() => {
|
|
|
|
|
|
this.pendingFlushTimer = null
|
2026-07-13 09:31:25 -07:00
|
|
|
|
void this.flushPendingSingleOps().catch(() => {})
|
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
|
|
|
|
}, 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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-29 16:05:03 -07:00
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
// Committed-generation interval set (run-length representation)
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description Record a newly committed generation. `gen` is ALWAYS greater
|
|
|
|
|
|
* than every committed generation (the monotonic `counter++`), so it either
|
|
|
|
|
|
* extends the last interval (the normal contiguous append, `gen ===
|
|
|
|
|
|
* lastEnd + 1`) or opens a fresh single-element interval (the first
|
|
|
|
|
|
* generation, or the first commit after a {@link compact} gap).
|
|
|
|
|
|
* @param gen - The just-committed generation.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private appendCommittedGen(gen: number): void {
|
|
|
|
|
|
const ranges = this.committedRanges
|
|
|
|
|
|
const last = ranges[ranges.length - 1]
|
|
|
|
|
|
if (last !== undefined && gen === last[1] + 1) {
|
|
|
|
|
|
last[1] = gen
|
|
|
|
|
|
} else {
|
|
|
|
|
|
ranges.push([gen, gen])
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** @returns The newest committed generation, or `undefined` when none exist. */
|
|
|
|
|
|
private lastCommittedGen(): number | undefined {
|
|
|
|
|
|
const ranges = this.committedRanges
|
|
|
|
|
|
return ranges.length ? ranges[ranges.length - 1][1] : undefined
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** @returns How many committed generations the interval set represents. */
|
|
|
|
|
|
private committedCount(): number {
|
|
|
|
|
|
let total = 0
|
|
|
|
|
|
for (const [start, end] of this.committedRanges) total += end - start + 1
|
|
|
|
|
|
return total
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description Yield every committed generation ascending — the exact multiset
|
|
|
|
|
|
* (and order) the old flat `committedGens` array held.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private *committedGensAsc(): IterableIterator<number> {
|
|
|
|
|
|
for (const [start, end] of this.committedRanges) {
|
|
|
|
|
|
for (let g = start; g <= end; g++) yield g
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description Yield 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
|
|
|
|
|
|
* committed-then-pending concatenation is already sorted — identical to the old
|
|
|
|
|
|
* `[...committedGens, ...pendingGens]`. This is the union historical reads
|
|
|
|
|
|
* resolve over so un-flushed single-ops are visible to pins/`asOf`.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private *reservedGensAsc(): IterableIterator<number> {
|
|
|
|
|
|
yield* this.committedGensAsc()
|
|
|
|
|
|
for (const g of this.pendingGens) yield g
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description Remove every committed generation `<= maxGen`. {@link compact}
|
|
|
|
|
|
* always reclaims an oldest CONTIGUOUS prefix of the committed gens, so this
|
|
|
|
|
|
* walk over the front of {@link committedRanges} is exact: drop whole ranges
|
|
|
|
|
|
* that end at or below `maxGen`, then clip the first surviving range up to
|
|
|
|
|
|
* `maxGen + 1` if `maxGen` falls inside it, and stop.
|
|
|
|
|
|
* @param maxGen - Inclusive upper bound of the reclaimed prefix.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private removeCommittedUpTo(maxGen: number): void {
|
|
|
|
|
|
const ranges = this.committedRanges
|
|
|
|
|
|
let drop = 0 // count of leading ranges fully reclaimed
|
|
|
|
|
|
while (drop < ranges.length) {
|
|
|
|
|
|
const range = ranges[drop]
|
|
|
|
|
|
if (range[1] <= maxGen) {
|
|
|
|
|
|
drop++ // whole range is at or below the cut → reclaim it
|
|
|
|
|
|
} else {
|
|
|
|
|
|
if (range[0] <= maxGen) range[0] = maxGen + 1 // clip the straddling range
|
|
|
|
|
|
break // this range survives; nothing newer is below the cut either
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (drop > 0) ranges.splice(0, drop)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description The largest reserved (committed ∪ pending) generation
|
|
|
|
|
|
* `<= gen` — exactly what `largestAtOrBefore([...committedGens, ...pendingGens],
|
|
|
|
|
|
* gen)` returned, in O(log ranges + scanned pending) instead of materializing
|
|
|
|
|
|
* the union. Pending generations are the newest reserved gens (all greater than
|
|
|
|
|
|
* every committed one), so the largest pending `<= gen`, when one exists,
|
|
|
|
|
|
* dominates every committed gen and is the answer; otherwise the answer is the
|
|
|
|
|
|
* largest committed gen `<= gen`, found by binary-searching {@link committedRanges}
|
|
|
|
|
|
* for the rightmost range whose `start <= gen` and clamping `gen` into it.
|
|
|
|
|
|
* @param gen - The upper bound (inclusive).
|
|
|
|
|
|
* @returns The largest reserved generation `<= gen`, or `undefined` when every
|
|
|
|
|
|
* reserved generation is greater than `gen`.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private largestReservedAtOrBefore(gen: number): number | undefined {
|
|
|
|
|
|
// Pending (ascending, all > every committed): the largest one ≤ gen wins.
|
|
|
|
|
|
let bestPending: number | undefined
|
|
|
|
|
|
for (const p of this.pendingGens) {
|
|
|
|
|
|
if (p <= gen) bestPending = p
|
|
|
|
|
|
else break
|
|
|
|
|
|
}
|
|
|
|
|
|
if (bestPending !== undefined) return bestPending
|
|
|
|
|
|
// No pending ≤ gen → the largest committed gen ≤ gen. Binary-search for the
|
|
|
|
|
|
// rightmost range whose start ≤ gen; the answer is min(gen, that range.end).
|
|
|
|
|
|
const ranges = this.committedRanges
|
|
|
|
|
|
let lo = 0
|
|
|
|
|
|
let hi = ranges.length // first range index whose start is > gen
|
|
|
|
|
|
while (lo < hi) {
|
|
|
|
|
|
const mid = (lo + hi) >>> 1
|
|
|
|
|
|
if (ranges[mid][0] <= gen) lo = mid + 1
|
|
|
|
|
|
else hi = mid
|
|
|
|
|
|
}
|
|
|
|
|
|
if (lo === 0) return undefined // every committed range starts above gen
|
|
|
|
|
|
const [, end] = ranges[lo - 1]
|
|
|
|
|
|
return Math.min(gen, end)
|
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
|
|
|
|
}
|
|
|
|
|
|
|
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]
|
2026-06-29 16:05:03 -07:00
|
|
|
|
: this.lastCommittedGen()
|
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 }
|
|
|
|
|
|
> {
|
perf(8.0): bound per-id generation history chains (O(W+L) resident, was O(N))
The MVCC time-travel layer kept nounChains/verbChains as unbounded
Map<id, number[]> — one resident chain per id ever touched across retained
history (O(N) RAM, defeating billion-scale time travel). Replace with a hot-tail
window + bounded cold LRU + a mutex-free bulk resolver, keeping resolveAt exactly
correct.
The most-recent W generations stay resident as full chains (the common recent-pin
read is O(log), zero scan); deeper pins reconstruct one id's chain on demand into
an L-bounded LRU. Reconstruction is LOCK-LIGHT — it holds no commit mutex, so a
historical read never stalls writers; correctness holds because a live pin's
answer is always > minPinnedGeneration, which compaction can never reclaim, and a
concurrently-reclaimed gen below that is skipped. materializeAtGeneration routes
through a new mutex-free resolveManyAt (one forward pass, O(R + |ids|)) — without
it a deep-pin materialize both regresses to O(N*R) and deadlocks on snapshotWith's
mutex. The cold cache is invalidated on re-touch to stay coherent. Resident RAM is
O(W*d + L), independent of N. 11-case test (oracle-vs-bruteforce, held-Db across
eviction + concurrent compaction, no-deadlock, write-path I/O-free); unit 1735 +
integration 613 (db-mvcc/db-temporal/db-asof green).
2026-06-30 10:30:17 -07:00
|
|
|
|
await this.ensureWindow()
|
|
|
|
|
|
// Snapshot windowLo, then read the chain synchronously (no await between):
|
|
|
|
|
|
// a concurrent commit can only slide the window at the `await` above, so the
|
|
|
|
|
|
// routing decision and the chain it reads are a consistent pair.
|
|
|
|
|
|
const windowLo = this.windowLo
|
|
|
|
|
|
let candidate: number | undefined
|
|
|
|
|
|
if (gen >= windowLo) {
|
|
|
|
|
|
// HOT path — every generation that could hold the before-image
|
|
|
|
|
|
// (`> gen ≥ windowLo`) is inside the resident window, so the recent chain
|
|
|
|
|
|
// is complete for this pin. O(log chain).
|
|
|
|
|
|
const chain = (kind === 'noun' ? this.recentNounChains : this.recentVerbChains).get(id)
|
|
|
|
|
|
if (chain === undefined) return { source: 'current' }
|
|
|
|
|
|
candidate = firstGenerationAfter(chain, gen)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// COLD path — a deep pin below the window. Serve from the bounded LRU,
|
|
|
|
|
|
// reconstructing (lock-light) on a miss. Caching empty chains too keeps
|
|
|
|
|
|
// repeated deep reads of untouched-in-cold-region ids O(1).
|
|
|
|
|
|
const cold = kind === 'noun' ? this.coldNounChains : this.coldVerbChains
|
|
|
|
|
|
let chain = cold.get(id)
|
|
|
|
|
|
if (chain !== undefined) {
|
|
|
|
|
|
cold.delete(id) // re-insert to mark most-recently-used
|
|
|
|
|
|
cold.set(id, chain)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
chain = await this.reconstructColdChain(kind, id)
|
|
|
|
|
|
cold.set(id, chain)
|
|
|
|
|
|
while (cold.size > this.coldChainLruMax) {
|
|
|
|
|
|
const oldest = cold.keys().next().value
|
|
|
|
|
|
if (oldest === undefined) break
|
|
|
|
|
|
cold.delete(oldest)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
candidate = firstGenerationAfter(chain, gen)
|
2026-06-22 13:47:33 -07:00
|
|
|
|
}
|
|
|
|
|
|
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 }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
perf(8.0): bound per-id generation history chains (O(W+L) resident, was O(N))
The MVCC time-travel layer kept nounChains/verbChains as unbounded
Map<id, number[]> — one resident chain per id ever touched across retained
history (O(N) RAM, defeating billion-scale time travel). Replace with a hot-tail
window + bounded cold LRU + a mutex-free bulk resolver, keeping resolveAt exactly
correct.
The most-recent W generations stay resident as full chains (the common recent-pin
read is O(log), zero scan); deeper pins reconstruct one id's chain on demand into
an L-bounded LRU. Reconstruction is LOCK-LIGHT — it holds no commit mutex, so a
historical read never stalls writers; correctness holds because a live pin's
answer is always > minPinnedGeneration, which compaction can never reclaim, and a
concurrently-reclaimed gen below that is skipped. materializeAtGeneration routes
through a new mutex-free resolveManyAt (one forward pass, O(R + |ids|)) — without
it a deep-pin materialize both regresses to O(N*R) and deadlocks on snapshotWith's
mutex. The cold cache is invalidated on re-touch to stay coherent. Resident RAM is
O(W*d + L), independent of N. 11-case test (oracle-vs-bruteforce, held-Db across
eviction + concurrent compaction, no-deadlock, write-path I/O-free); unit 1735 +
integration 613 (db-mvcc/db-temporal/db-asof green).
2026-06-30 10:30:17 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* @description Resolve the FIRST reserved generation after `gen` that touched
|
|
|
|
|
|
* each of `ids`, by kind — the bulk, MUTEX-FREE counterpart of
|
|
|
|
|
|
* {@link resolveAt}'s chain lookup. A SINGLE ascending pass over the reserved
|
|
|
|
|
|
* (committed ∪ pending) generations records `firstAfter[id] = g` for the first
|
|
|
|
|
|
* `g > gen` whose delta touched `id`, stopping early once every id is resolved.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Ids absent from the returned map were never touched after `gen` → their live
|
|
|
|
|
|
* storage state IS their state at `gen` (the {@link resolveAt} `'current'`
|
|
|
|
|
|
* answer). Ids present map to the generation whose before-image
|
|
|
|
|
|
* ({@link readGenerationRecord}) holds their state as of `gen`.
|
|
|
|
|
|
*
|
|
|
|
|
|
* WHY MUTEX-FREE (load-bearing): {@link Brainy.materializeAtGeneration} runs its
|
|
|
|
|
|
* final reconciliation inside {@link snapshotWith} (which HOLDS the commit
|
|
|
|
|
|
* mutex). Resolving ids one-by-one through {@link resolveAt} there would (a)
|
|
|
|
|
|
* re-enter the mutex via {@link ensureWindow} → DEADLOCK, and (b) regress an
|
|
|
|
|
|
* O(R) bulk copy to O(N·R). This method takes no lock and makes one pass, so a
|
|
|
|
|
|
* deep-pin materialize over N ids stays O(R·d̄ + |ids|) with O(R) `getDelta`
|
|
|
|
|
|
* calls. It iterates SNAPSHOTS of the small committed-range list (O(gaps)) and
|
|
|
|
|
|
* the pending list (O(pending)) — NOT a materialized O(R) gen array — so it is
|
|
|
|
|
|
* safe against concurrent commits and bounded in memory.
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param kind - `'noun'` for entities, `'verb'` for relationships.
|
|
|
|
|
|
* @param ids - The ids to resolve.
|
|
|
|
|
|
* @param gen - The pinned generation.
|
|
|
|
|
|
* @returns `id → first reserved generation after `gen` that touched it` for the
|
|
|
|
|
|
* subset of `ids` touched after `gen`.
|
|
|
|
|
|
*/
|
|
|
|
|
|
async resolveManyAt(
|
|
|
|
|
|
kind: 'noun' | 'verb',
|
|
|
|
|
|
ids: Set<string>,
|
|
|
|
|
|
gen: number
|
|
|
|
|
|
): Promise<Map<string, number>> {
|
|
|
|
|
|
const firstAfter = new Map<string, number>()
|
|
|
|
|
|
if (ids.size === 0) return firstAfter
|
|
|
|
|
|
// Snapshot the small range list + pending list (NOT the expanded gens) so the
|
|
|
|
|
|
// pass is immune to a concurrent compaction splice / commit append and stays
|
|
|
|
|
|
// O(gaps + pending) in memory.
|
|
|
|
|
|
const ranges = this.committedRanges.map(([s, e]) => [s, e] as [number, number])
|
|
|
|
|
|
const pending = [...this.pendingGens]
|
|
|
|
|
|
const record = async (g: number): Promise<boolean> => {
|
|
|
|
|
|
const delta = await this.getDelta(g)
|
|
|
|
|
|
const touched = kind === 'noun' ? delta.nouns : delta.verbs
|
|
|
|
|
|
for (const id of touched) {
|
|
|
|
|
|
if (ids.has(id) && !firstAfter.has(id)) firstAfter.set(id, g)
|
|
|
|
|
|
}
|
|
|
|
|
|
return firstAfter.size === ids.size
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const [s, e] of ranges) {
|
|
|
|
|
|
if (e <= gen) continue
|
|
|
|
|
|
for (let g = Math.max(s, gen + 1); g <= e; g++) {
|
|
|
|
|
|
if (await record(g)) return firstAfter
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const g of pending) {
|
|
|
|
|
|
if (g <= gen) continue
|
|
|
|
|
|
if (await record(g)) return firstAfter
|
|
|
|
|
|
}
|
|
|
|
|
|
return firstAfter
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description Read the before-image of `id` recorded by generation `gen` — the
|
|
|
|
|
|
* public, MUTEX-FREE accessor {@link Brainy.materializeAtGeneration}'s `copyAt`
|
|
|
|
|
|
* uses after {@link resolveManyAt} hands it the generation. Pairs with
|
|
|
|
|
|
* `resolveManyAt` so the bulk historical copy never touches the commit mutex
|
|
|
|
|
|
* (no deadlock inside {@link snapshotWith}, no per-id chain rebuild).
|
|
|
|
|
|
* @param kind - `'noun'` for entities, `'verb'` for relationships.
|
|
|
|
|
|
* @param gen - The generation whose before-image to read.
|
|
|
|
|
|
* @param id - The id to read.
|
|
|
|
|
|
* @returns The stored {@link GenerationRecord}, or `null` when the record-set
|
|
|
|
|
|
* has no before-image for `id`.
|
|
|
|
|
|
*/
|
|
|
|
|
|
async readGenerationRecord(
|
|
|
|
|
|
kind: 'noun' | 'verb',
|
|
|
|
|
|
gen: number,
|
|
|
|
|
|
id: string
|
|
|
|
|
|
): Promise<GenerationRecord | null> {
|
|
|
|
|
|
return this.readBeforeImage(kind, gen, id)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-07-19 16:26:10 -07:00
|
|
|
|
const live = (await this.storage.readRawObject(
|
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
|
|
|
|
`${GENERATIONS_PREFIX}/${gen}/prev/${id}.json`
|
|
|
|
|
|
)) as GenerationRecord | null
|
2026-07-19 16:26:10 -07:00
|
|
|
|
if (live) return live
|
|
|
|
|
|
// Two-tier: the packed tier serves folded generations (live-tier-wins).
|
|
|
|
|
|
if (this.segments?.hasGeneration(gen)) {
|
|
|
|
|
|
return (await this.segments.readRecord(gen, kind, id)) as GenerationRecord | null
|
|
|
|
|
|
}
|
|
|
|
|
|
return 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
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-22 13:47:33 -07:00
|
|
|
|
/**
|
perf(8.0): bound per-id generation history chains (O(W+L) resident, was O(N))
The MVCC time-travel layer kept nounChains/verbChains as unbounded
Map<id, number[]> — one resident chain per id ever touched across retained
history (O(N) RAM, defeating billion-scale time travel). Replace with a hot-tail
window + bounded cold LRU + a mutex-free bulk resolver, keeping resolveAt exactly
correct.
The most-recent W generations stay resident as full chains (the common recent-pin
read is O(log), zero scan); deeper pins reconstruct one id's chain on demand into
an L-bounded LRU. Reconstruction is LOCK-LIGHT — it holds no commit mutex, so a
historical read never stalls writers; correctness holds because a live pin's
answer is always > minPinnedGeneration, which compaction can never reclaim, and a
concurrently-reclaimed gen below that is skipped. materializeAtGeneration routes
through a new mutex-free resolveManyAt (one forward pass, O(R + |ids|)) — without
it a deep-pin materialize both regresses to O(N*R) and deadlocks on snapshotWith's
mutex. The cold cache is invalidated on re-touch to stay coherent. Resident RAM is
O(W*d + L), independent of N. 11-case test (oracle-vs-bruteforce, held-Db across
eviction + concurrent compaction, no-deadlock, write-path I/O-free); unit 1735 +
integration 613 (db-mvcc/db-temporal/db-asof green).
2026-06-30 10:30:17 -07:00
|
|
|
|
* @description Build the resident hot-tail window — the per-id chains
|
|
|
|
|
|
* ({@link recentNounChains}/{@link recentVerbChains}) and the inverse index
|
|
|
|
|
|
* ({@link windowNounDeltas}/{@link windowVerbDeltas}) over the last
|
|
|
|
|
|
* {@link recentWindowGenerations} reserved generations, `[windowLo, counter]`.
|
|
|
|
|
|
* Built once, lazily, under the commit mutex (so no concurrent commit mutates
|
|
|
|
|
|
* {@link committedRanges}/pending mid-build); thereafter maintained
|
|
|
|
|
|
* incrementally by {@link extendChains} and invalidated by {@link compact} /
|
|
|
|
|
|
* reopen. Concurrent first-callers share one build. O(W) `getDelta` reads the
|
|
|
|
|
|
* first time (only the window, NOT all of history); O(1) afterwards. Deep pins
|
|
|
|
|
|
* below `windowLo` are served by {@link reconstructColdChain} on demand.
|
2026-06-22 13:47:33 -07:00
|
|
|
|
*/
|
perf(8.0): bound per-id generation history chains (O(W+L) resident, was O(N))
The MVCC time-travel layer kept nounChains/verbChains as unbounded
Map<id, number[]> — one resident chain per id ever touched across retained
history (O(N) RAM, defeating billion-scale time travel). Replace with a hot-tail
window + bounded cold LRU + a mutex-free bulk resolver, keeping resolveAt exactly
correct.
The most-recent W generations stay resident as full chains (the common recent-pin
read is O(log), zero scan); deeper pins reconstruct one id's chain on demand into
an L-bounded LRU. Reconstruction is LOCK-LIGHT — it holds no commit mutex, so a
historical read never stalls writers; correctness holds because a live pin's
answer is always > minPinnedGeneration, which compaction can never reclaim, and a
concurrently-reclaimed gen below that is skipped. materializeAtGeneration routes
through a new mutex-free resolveManyAt (one forward pass, O(R + |ids|)) — without
it a deep-pin materialize both regresses to O(N*R) and deadlocks on snapshotWith's
mutex. The cold cache is invalidated on re-touch to stay coherent. Resident RAM is
O(W*d + L), independent of N. 11-case test (oracle-vs-bruteforce, held-Db across
eviction + concurrent compaction, no-deadlock, write-path I/O-free); unit 1735 +
integration 613 (db-mvcc/db-temporal/db-asof green).
2026-06-30 10:30:17 -07:00
|
|
|
|
private async ensureWindow(): Promise<void> {
|
|
|
|
|
|
if (this.windowReady) return
|
|
|
|
|
|
if (!this.windowBuilding) {
|
|
|
|
|
|
this.windowBuilding = this.withMutex(async () => {
|
|
|
|
|
|
if (this.windowReady) return
|
|
|
|
|
|
this.recentNounChains.clear()
|
|
|
|
|
|
this.recentVerbChains.clear()
|
|
|
|
|
|
this.windowNounDeltas.clear()
|
|
|
|
|
|
this.windowVerbDeltas.clear()
|
|
|
|
|
|
// The window is the newest W reserved generations, clamped above the
|
|
|
|
|
|
// compaction horizon (gens ≤ horizon are reclaimed — never resident).
|
|
|
|
|
|
this.windowLo = Math.max(this.horizonGen + 1, this.counter - this.recentWindowGenerations + 1)
|
|
|
|
|
|
// reservedGensAsc (committed ∪ pending) is ascending, so chains accrue in
|
|
|
|
|
|
// order and un-flushed single-ops in-window are indexed too.
|
2026-06-29 16:05:03 -07:00
|
|
|
|
for (const g of this.reservedGensAsc()) {
|
perf(8.0): bound per-id generation history chains (O(W+L) resident, was O(N))
The MVCC time-travel layer kept nounChains/verbChains as unbounded
Map<id, number[]> — one resident chain per id ever touched across retained
history (O(N) RAM, defeating billion-scale time travel). Replace with a hot-tail
window + bounded cold LRU + a mutex-free bulk resolver, keeping resolveAt exactly
correct.
The most-recent W generations stay resident as full chains (the common recent-pin
read is O(log), zero scan); deeper pins reconstruct one id's chain on demand into
an L-bounded LRU. Reconstruction is LOCK-LIGHT — it holds no commit mutex, so a
historical read never stalls writers; correctness holds because a live pin's
answer is always > minPinnedGeneration, which compaction can never reclaim, and a
concurrently-reclaimed gen below that is skipped. materializeAtGeneration routes
through a new mutex-free resolveManyAt (one forward pass, O(R + |ids|)) — without
it a deep-pin materialize both regresses to O(N*R) and deadlocks on snapshotWith's
mutex. The cold cache is invalidated on re-touch to stay coherent. Resident RAM is
O(W*d + L), independent of N. 11-case test (oracle-vs-bruteforce, held-Db across
eviction + concurrent compaction, no-deadlock, write-path I/O-free); unit 1735 +
integration 613 (db-mvcc/db-temporal/db-asof green).
2026-06-30 10:30:17 -07:00
|
|
|
|
if (g < this.windowLo) continue
|
2026-06-22 13:47:33 -07:00
|
|
|
|
const delta = await this.getDelta(g)
|
perf(8.0): bound per-id generation history chains (O(W+L) resident, was O(N))
The MVCC time-travel layer kept nounChains/verbChains as unbounded
Map<id, number[]> — one resident chain per id ever touched across retained
history (O(N) RAM, defeating billion-scale time travel). Replace with a hot-tail
window + bounded cold LRU + a mutex-free bulk resolver, keeping resolveAt exactly
correct.
The most-recent W generations stay resident as full chains (the common recent-pin
read is O(log), zero scan); deeper pins reconstruct one id's chain on demand into
an L-bounded LRU. Reconstruction is LOCK-LIGHT — it holds no commit mutex, so a
historical read never stalls writers; correctness holds because a live pin's
answer is always > minPinnedGeneration, which compaction can never reclaim, and a
concurrently-reclaimed gen below that is skipped. materializeAtGeneration routes
through a new mutex-free resolveManyAt (one forward pass, O(R + |ids|)) — without
it a deep-pin materialize both regresses to O(N*R) and deadlocks on snapshotWith's
mutex. The cold cache is invalidated on re-touch to stay coherent. Resident RAM is
O(W*d + L), independent of N. 11-case test (oracle-vs-bruteforce, held-Db across
eviction + concurrent compaction, no-deadlock, write-path I/O-free); unit 1735 +
integration 613 (db-mvcc/db-temporal/db-asof green).
2026-06-30 10:30:17 -07:00
|
|
|
|
const nouns = [...delta.nouns]
|
|
|
|
|
|
const verbs = [...delta.verbs]
|
|
|
|
|
|
for (const id of nouns) appendToChain(this.recentNounChains, id, g)
|
|
|
|
|
|
for (const id of verbs) appendToChain(this.recentVerbChains, id, g)
|
|
|
|
|
|
this.windowNounDeltas.set(g, nouns)
|
|
|
|
|
|
this.windowVerbDeltas.set(g, verbs)
|
2026-06-22 13:47:33 -07:00
|
|
|
|
}
|
perf(8.0): bound per-id generation history chains (O(W+L) resident, was O(N))
The MVCC time-travel layer kept nounChains/verbChains as unbounded
Map<id, number[]> — one resident chain per id ever touched across retained
history (O(N) RAM, defeating billion-scale time travel). Replace with a hot-tail
window + bounded cold LRU + a mutex-free bulk resolver, keeping resolveAt exactly
correct.
The most-recent W generations stay resident as full chains (the common recent-pin
read is O(log), zero scan); deeper pins reconstruct one id's chain on demand into
an L-bounded LRU. Reconstruction is LOCK-LIGHT — it holds no commit mutex, so a
historical read never stalls writers; correctness holds because a live pin's
answer is always > minPinnedGeneration, which compaction can never reclaim, and a
concurrently-reclaimed gen below that is skipped. materializeAtGeneration routes
through a new mutex-free resolveManyAt (one forward pass, O(R + |ids|)) — without
it a deep-pin materialize both regresses to O(N*R) and deadlocks on snapshotWith's
mutex. The cold cache is invalidated on re-touch to stay coherent. Resident RAM is
O(W*d + L), independent of N. 11-case test (oracle-vs-bruteforce, held-Db across
eviction + concurrent compaction, no-deadlock, write-path I/O-free); unit 1735 +
integration 613 (db-mvcc/db-temporal/db-asof green).
2026-06-30 10:30:17 -07:00
|
|
|
|
this.windowReady = true
|
|
|
|
|
|
this.windowBuilding = null
|
2026-06-22 13:47:33 -07:00
|
|
|
|
})
|
|
|
|
|
|
}
|
perf(8.0): bound per-id generation history chains (O(W+L) resident, was O(N))
The MVCC time-travel layer kept nounChains/verbChains as unbounded
Map<id, number[]> — one resident chain per id ever touched across retained
history (O(N) RAM, defeating billion-scale time travel). Replace with a hot-tail
window + bounded cold LRU + a mutex-free bulk resolver, keeping resolveAt exactly
correct.
The most-recent W generations stay resident as full chains (the common recent-pin
read is O(log), zero scan); deeper pins reconstruct one id's chain on demand into
an L-bounded LRU. Reconstruction is LOCK-LIGHT — it holds no commit mutex, so a
historical read never stalls writers; correctness holds because a live pin's
answer is always > minPinnedGeneration, which compaction can never reclaim, and a
concurrently-reclaimed gen below that is skipped. materializeAtGeneration routes
through a new mutex-free resolveManyAt (one forward pass, O(R + |ids|)) — without
it a deep-pin materialize both regresses to O(N*R) and deadlocks on snapshotWith's
mutex. The cold cache is invalidated on re-touch to stay coherent. Resident RAM is
O(W*d + L), independent of N. 11-case test (oracle-vs-bruteforce, held-Db across
eviction + concurrent compaction, no-deadlock, write-path I/O-free); unit 1735 +
integration 613 (db-mvcc/db-temporal/db-asof green).
2026-06-30 10:30:17 -07:00
|
|
|
|
await this.windowBuilding
|
2026-06-22 13:47:33 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
perf(8.0): bound per-id generation history chains (O(W+L) resident, was O(N))
The MVCC time-travel layer kept nounChains/verbChains as unbounded
Map<id, number[]> — one resident chain per id ever touched across retained
history (O(N) RAM, defeating billion-scale time travel). Replace with a hot-tail
window + bounded cold LRU + a mutex-free bulk resolver, keeping resolveAt exactly
correct.
The most-recent W generations stay resident as full chains (the common recent-pin
read is O(log), zero scan); deeper pins reconstruct one id's chain on demand into
an L-bounded LRU. Reconstruction is LOCK-LIGHT — it holds no commit mutex, so a
historical read never stalls writers; correctness holds because a live pin's
answer is always > minPinnedGeneration, which compaction can never reclaim, and a
concurrently-reclaimed gen below that is skipped. materializeAtGeneration routes
through a new mutex-free resolveManyAt (one forward pass, O(R + |ids|)) — without
it a deep-pin materialize both regresses to O(N*R) and deadlocks on snapshotWith's
mutex. The cold cache is invalidated on re-touch to stay coherent. Resident RAM is
O(W*d + L), independent of N. 11-case test (oracle-vs-bruteforce, held-Db across
eviction + concurrent compaction, no-deadlock, write-path I/O-free); unit 1735 +
integration 613 (db-mvcc/db-temporal/db-asof green).
2026-06-30 10:30:17 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* @description Reconstruct the FULL per-id chain for a deep pin (`gen <
|
|
|
|
|
|
* windowLo`) — every generation that touched `id`, ascending. Built from the
|
|
|
|
|
|
* persisted deltas BELOW the window plus the resident in-window tail, then
|
|
|
|
|
|
* cached in the cold LRU by the caller.
|
|
|
|
|
|
*
|
|
|
|
|
|
* LOCK-LIGHT — deliberately does NOT hold the commit mutex (a deep read must
|
|
|
|
|
|
* never stall writers). Correctness without the lock:
|
|
|
|
|
|
* - The recent tail and `windowLo` are snapshotted SYNCHRONOUSLY up front, so
|
|
|
|
|
|
* even if the window slides during the (awaited) cold scan, the chain is the
|
|
|
|
|
|
* one consistent with the snapshot. New writes during the scan land ABOVE the
|
|
|
|
|
|
* snapshot and cannot change any deep pin's first-after answer; the cold
|
|
|
|
|
|
* entry is also dropped by {@link extendChains} the moment `id` is next
|
|
|
|
|
|
* touched, so it is never served stale.
|
|
|
|
|
|
* - The cold scan iterates a SNAPSHOT of the small committed-range list
|
|
|
|
|
|
* (O(gaps) memory, immune to a concurrent compaction splice).
|
|
|
|
|
|
* - A generation concurrently reclaimed by compaction surfaces as a missing
|
|
|
|
|
|
* delta. Such a gen is always `≤ minPinned` (compaction never reclaims above
|
|
|
|
|
|
* the lowest live pin) and therefore NEVER a live pin's answer (`first-after
|
|
|
|
|
|
* > pin ≥ minPinned`), so it is skipped, not raised. A missing delta ABOVE
|
|
|
|
|
|
* `minPinned` is genuine corruption and still throws.
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param kind - `'noun'` for entities, `'verb'` for relationships.
|
|
|
|
|
|
* @param id - The id whose chain to reconstruct.
|
|
|
|
|
|
* @returns The id's full ascending generation chain (`[]` when never touched).
|
|
|
|
|
|
*/
|
|
|
|
|
|
private async reconstructColdChain(kind: 'noun' | 'verb', id: string): Promise<number[]> {
|
|
|
|
|
|
// Synchronous, consistent snapshot of the window state.
|
|
|
|
|
|
const windowLo = this.windowLo
|
|
|
|
|
|
const recentTail = (kind === 'noun' ? this.recentNounChains : this.recentVerbChains).get(id)
|
|
|
|
|
|
const recentSnapshot = recentTail ? [...recentTail] : []
|
|
|
|
|
|
const ranges = this.committedRanges.map(([s, e]) => [s, e] as [number, number])
|
|
|
|
|
|
const pendingCold = this.pendingGens.filter((g) => g < windowLo)
|
|
|
|
|
|
|
|
|
|
|
|
const cold: number[] = []
|
|
|
|
|
|
const scan = async (g: number): Promise<void> => {
|
|
|
|
|
|
let delta: { nouns: Set<string>; verbs: Set<string> }
|
|
|
|
|
|
try {
|
|
|
|
|
|
delta = await this.getDelta(g)
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
// Concurrent-compaction tolerance: a reclaimed gen (≤ minPinned, never a
|
|
|
|
|
|
// live pin's answer) is skipped; a missing gen above the lowest pin is
|
|
|
|
|
|
// real corruption and rethrows.
|
|
|
|
|
|
if (g <= this.minPinnedGeneration()) return
|
|
|
|
|
|
throw err
|
|
|
|
|
|
}
|
|
|
|
|
|
if ((kind === 'noun' ? delta.nouns : delta.verbs).has(id)) cold.push(g)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for (const [s, e] of ranges) {
|
|
|
|
|
|
if (s >= windowLo) break // ascending → nothing newer is below the window
|
|
|
|
|
|
const hi = Math.min(e, windowLo - 1)
|
|
|
|
|
|
for (let g = s; g <= hi; g++) await scan(g)
|
|
|
|
|
|
}
|
|
|
|
|
|
// Cold pending gens (only when more than W writes are buffered, i.e. tests
|
|
|
|
|
|
// with a small window) sit between the committed gens and the window.
|
|
|
|
|
|
for (const g of pendingCold) await scan(g)
|
|
|
|
|
|
|
|
|
|
|
|
// Cold gens (< windowLo) then the resident in-window tail (≥ windowLo) — both
|
|
|
|
|
|
// ascending and disjoint, so the concatenation is the full ascending chain.
|
|
|
|
|
|
return recentSnapshot.length ? cold.concat(recentSnapshot) : cold
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Record that generation `gen` (the newest) touched these ids and advance the
|
|
|
|
|
|
* hot-tail window. Keeps the resident window current after a commit without a
|
|
|
|
|
|
* rebuild. No-op until the window is built (the eventual build reads `gen` from
|
|
|
|
|
|
* {@link reservedGensAsc}).
|
|
|
|
|
|
*
|
|
|
|
|
|
* Three steps, all in-memory and I/O-FREE:
|
|
|
|
|
|
* 1. Append `gen` to each touched id's recent chain, and DROP each touched id
|
|
|
|
|
|
* from the cold LRU — a freshly-touched id's cached deep chain is now
|
|
|
|
|
|
* incomplete, so it must be reconstructed on the next deep read (else a
|
|
|
|
|
|
* deep pin whose first-after just became `gen` would wrongly read `current`).
|
|
|
|
|
|
* 2. Record the window inverse index entry `gen → touched ids` (the slide-trim
|
|
|
|
|
|
* driver).
|
|
|
|
|
|
* 3. Slide `windowLo` forward to `max(horizon+1, gen-W+1)`, front-trimming the
|
|
|
|
|
|
* chains of exactly the ids leaving the window using the inverse index — NO
|
|
|
|
|
|
* `getDelta`, so the write path never reads `tx.json` on a slide regardless
|
|
|
|
|
|
* of W vs the delta-cache size.
|
|
|
|
|
|
*/
|
2026-06-22 13:47:33 -07:00
|
|
|
|
private extendChains(gen: number, nouns: Iterable<string>, verbs: Iterable<string>): void {
|
perf(8.0): bound per-id generation history chains (O(W+L) resident, was O(N))
The MVCC time-travel layer kept nounChains/verbChains as unbounded
Map<id, number[]> — one resident chain per id ever touched across retained
history (O(N) RAM, defeating billion-scale time travel). Replace with a hot-tail
window + bounded cold LRU + a mutex-free bulk resolver, keeping resolveAt exactly
correct.
The most-recent W generations stay resident as full chains (the common recent-pin
read is O(log), zero scan); deeper pins reconstruct one id's chain on demand into
an L-bounded LRU. Reconstruction is LOCK-LIGHT — it holds no commit mutex, so a
historical read never stalls writers; correctness holds because a live pin's
answer is always > minPinnedGeneration, which compaction can never reclaim, and a
concurrently-reclaimed gen below that is skipped. materializeAtGeneration routes
through a new mutex-free resolveManyAt (one forward pass, O(R + |ids|)) — without
it a deep-pin materialize both regresses to O(N*R) and deadlocks on snapshotWith's
mutex. The cold cache is invalidated on re-touch to stay coherent. Resident RAM is
O(W*d + L), independent of N. 11-case test (oracle-vs-bruteforce, held-Db across
eviction + concurrent compaction, no-deadlock, write-path I/O-free); unit 1735 +
integration 613 (db-mvcc/db-temporal/db-asof green).
2026-06-30 10:30:17 -07:00
|
|
|
|
if (!this.windowReady) return
|
|
|
|
|
|
const nounArr = [...nouns]
|
|
|
|
|
|
const verbArr = [...verbs]
|
|
|
|
|
|
for (const id of nounArr) {
|
|
|
|
|
|
appendToChain(this.recentNounChains, id, gen)
|
|
|
|
|
|
this.coldNounChains.delete(id)
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const id of verbArr) {
|
|
|
|
|
|
appendToChain(this.recentVerbChains, id, gen)
|
|
|
|
|
|
this.coldVerbChains.delete(id)
|
|
|
|
|
|
}
|
|
|
|
|
|
this.windowNounDeltas.set(gen, nounArr)
|
|
|
|
|
|
this.windowVerbDeltas.set(gen, verbArr)
|
|
|
|
|
|
|
|
|
|
|
|
const newLo = Math.max(this.horizonGen + 1, gen - this.recentWindowGenerations + 1)
|
|
|
|
|
|
if (newLo <= this.windowLo) return
|
|
|
|
|
|
// Collect the ids leaving the window from the inverse index (O(slid·d̄)), then
|
|
|
|
|
|
// front-trim their chains to the new low watermark (each id once).
|
|
|
|
|
|
const trimNouns = new Set<string>()
|
|
|
|
|
|
const trimVerbs = new Set<string>()
|
|
|
|
|
|
for (let g = this.windowLo; g < newLo; g++) {
|
|
|
|
|
|
const n = this.windowNounDeltas.get(g)
|
|
|
|
|
|
if (n) for (const id of n) trimNouns.add(id)
|
|
|
|
|
|
this.windowNounDeltas.delete(g)
|
|
|
|
|
|
const v = this.windowVerbDeltas.get(g)
|
|
|
|
|
|
if (v) for (const id of v) trimVerbs.add(id)
|
|
|
|
|
|
this.windowVerbDeltas.delete(g)
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const id of trimNouns) dropChainFront(this.recentNounChains, id, newLo)
|
|
|
|
|
|
for (const id of trimVerbs) dropChainFront(this.recentVerbChains, id, newLo)
|
|
|
|
|
|
this.windowLo = newLo
|
2026-06-22 13:47:33 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
perf(8.0): bound per-id generation history chains (O(W+L) resident, was O(N))
The MVCC time-travel layer kept nounChains/verbChains as unbounded
Map<id, number[]> — one resident chain per id ever touched across retained
history (O(N) RAM, defeating billion-scale time travel). Replace with a hot-tail
window + bounded cold LRU + a mutex-free bulk resolver, keeping resolveAt exactly
correct.
The most-recent W generations stay resident as full chains (the common recent-pin
read is O(log), zero scan); deeper pins reconstruct one id's chain on demand into
an L-bounded LRU. Reconstruction is LOCK-LIGHT — it holds no commit mutex, so a
historical read never stalls writers; correctness holds because a live pin's
answer is always > minPinnedGeneration, which compaction can never reclaim, and a
concurrently-reclaimed gen below that is skipped. materializeAtGeneration routes
through a new mutex-free resolveManyAt (one forward pass, O(R + |ids|)) — without
it a deep-pin materialize both regresses to O(N*R) and deadlocks on snapshotWith's
mutex. The cold cache is invalidated on re-touch to stay coherent. Resident RAM is
O(W*d + L), independent of N. 11-case test (oracle-vs-bruteforce, held-Db across
eviction + concurrent compaction, no-deadlock, write-path I/O-free); unit 1735 +
integration 613 (db-mvcc/db-temporal/db-asof green).
2026-06-30 10:30:17 -07:00
|
|
|
|
/** Drop the resident window + cold LRU so the next read rebuilds from the
|
|
|
|
|
|
* surviving generations (called after compaction removes record-sets / on
|
|
|
|
|
|
* reopen). */
|
2026-06-22 13:47:33 -07:00
|
|
|
|
private invalidateChains(): void {
|
perf(8.0): bound per-id generation history chains (O(W+L) resident, was O(N))
The MVCC time-travel layer kept nounChains/verbChains as unbounded
Map<id, number[]> — one resident chain per id ever touched across retained
history (O(N) RAM, defeating billion-scale time travel). Replace with a hot-tail
window + bounded cold LRU + a mutex-free bulk resolver, keeping resolveAt exactly
correct.
The most-recent W generations stay resident as full chains (the common recent-pin
read is O(log), zero scan); deeper pins reconstruct one id's chain on demand into
an L-bounded LRU. Reconstruction is LOCK-LIGHT — it holds no commit mutex, so a
historical read never stalls writers; correctness holds because a live pin's
answer is always > minPinnedGeneration, which compaction can never reclaim, and a
concurrently-reclaimed gen below that is skipped. materializeAtGeneration routes
through a new mutex-free resolveManyAt (one forward pass, O(R + |ids|)) — without
it a deep-pin materialize both regresses to O(N*R) and deadlocks on snapshotWith's
mutex. The cold cache is invalidated on re-touch to stay coherent. Resident RAM is
O(W*d + L), independent of N. 11-case test (oracle-vs-bruteforce, held-Db across
eviction + concurrent compaction, no-deadlock, write-path I/O-free); unit 1735 +
integration 613 (db-mvcc/db-temporal/db-asof green).
2026-06-30 10:30:17 -07:00
|
|
|
|
this.windowReady = false
|
|
|
|
|
|
this.windowBuilding = null
|
|
|
|
|
|
this.recentNounChains.clear()
|
|
|
|
|
|
this.recentVerbChains.clear()
|
|
|
|
|
|
this.windowNounDeltas.clear()
|
|
|
|
|
|
this.windowVerbDeltas.clear()
|
|
|
|
|
|
this.coldNounChains.clear()
|
|
|
|
|
|
this.coldVerbChains.clear()
|
|
|
|
|
|
this.windowLo = 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 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>()
|
2026-06-29 16:05:03 -07:00
|
|
|
|
for (const gen of this.reservedGensAsc()) {
|
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[] = []
|
2026-06-29 16:05:03 -07:00
|
|
|
|
for (const gen of this.reservedGensAsc()) {
|
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> {
|
2026-06-29 16:05:03 -07:00
|
|
|
|
// The largest reserved (committed ∪ pending) generation ≤ gen, found in
|
|
|
|
|
|
// O(log ranges) over the interval set (not an O(database-age) backward
|
|
|
|
|
|
// scan). Pending single-op generations carry timestamps too.
|
|
|
|
|
|
const candidate = this.largestReservedAtOrBefore(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) {
|
2026-07-19 16:26:10 -07:00
|
|
|
|
// Two-tier read (D1+D3): not in the live tier → the packed tier.
|
|
|
|
|
|
// Live-tier-wins ordering (a crash mid-fold leaves a duplicate, never
|
|
|
|
|
|
// a gap), so the segment lookup runs only after the live miss.
|
|
|
|
|
|
const packed = await this.segments?.readDelta(gen)
|
|
|
|
|
|
if (packed) {
|
|
|
|
|
|
const d = packed.delta as GenerationDelta
|
|
|
|
|
|
const entry = {
|
|
|
|
|
|
nouns: new Set(d.nouns),
|
|
|
|
|
|
verbs: new Set(d.verbs),
|
|
|
|
|
|
timestamp: packed.timestamp,
|
|
|
|
|
|
bytes: d.bytes ?? 0
|
|
|
|
|
|
}
|
|
|
|
|
|
this.setDelta(gen, entry)
|
|
|
|
|
|
return 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
|
|
|
|
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
|
2026-07-18 10:51:37 -07:00
|
|
|
|
* `maxBytes` and adaptive retention caps. O(1) after the first call: the
|
|
|
|
|
|
* total is computed by ONE walk over committed deltas, then maintained
|
|
|
|
|
|
* incrementally at every commit (+bytes) and reclaim (−bytes) and dropped on
|
|
|
|
|
|
* a wholesale state replacement (restore). Without the running total, the
|
|
|
|
|
|
* adaptive auto-compaction on every flush() re-walked the ENTIRE history —
|
|
|
|
|
|
* O(committed generations) file reads per flush past the delta-cache bound —
|
|
|
|
|
|
* which is how a 70k-generation production brain turned every write into a
|
|
|
|
|
|
* full-tail scan (SELF-GENERATIONS-GROWTH). Pending (un-flushed) generations
|
|
|
|
|
|
* are excluded (they are not on disk).
|
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
|
|
|
|
* @returns The total on-disk history byte count.
|
|
|
|
|
|
*/
|
|
|
|
|
|
async historyBytes(): Promise<number> {
|
2026-07-18 10:51:37 -07:00
|
|
|
|
if (this.historyBytesTotal !== null) {
|
|
|
|
|
|
return this.historyBytesTotal
|
|
|
|
|
|
}
|
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 total = 0
|
2026-06-29 16:05:03 -07:00
|
|
|
|
for (const gen of this.committedGensAsc()) {
|
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
|
|
|
|
total += (await this.getDelta(gen)).bytes
|
|
|
|
|
|
}
|
2026-07-18 10:51:37 -07:00
|
|
|
|
this.historyBytesTotal = total
|
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
|
|
|
|
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.
|
|
|
|
|
|
*/
|
2026-07-19 16:26:10 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* @description The REPACKER (D1+D3+repacking): fold cold live-tier
|
|
|
|
|
|
* generations into sealed segments — re-representation, never deletion.
|
|
|
|
|
|
* Every record and delta stays readable (asOf/chains unchanged); the
|
|
|
|
|
|
* per-generation directories are deleted only AFTER their segment is
|
|
|
|
|
|
* durable (crash between = duplicate representation, resolved
|
|
|
|
|
|
* live-tier-wins by every reader; never a gap). This is the transform that
|
|
|
|
|
|
* takes a 70k-file history to tens of segment files, and the ONLY history
|
|
|
|
|
|
* transform permitted under the archival profile.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Folds oldest-first, contiguous from the packed boundary, in batches, and
|
|
|
|
|
|
* stops at the live window ({@link GenerationStore.REPACK_LIVE_WINDOW})
|
|
|
|
|
|
* or when `timeBudgetMs` is spent — an early stop is a consistent prefix;
|
|
|
|
|
|
* the next pass resumes.
|
|
|
|
|
|
*/
|
|
|
|
|
|
async repackHistory(options?: { timeBudgetMs?: number; batchGenerations?: number }): Promise<{
|
|
|
|
|
|
foldedGenerations: number
|
|
|
|
|
|
segmentsCreated: number
|
|
|
|
|
|
}> {
|
|
|
|
|
|
if (!this.segments) return { foldedGenerations: 0, segmentsCreated: 0 }
|
|
|
|
|
|
const segments = this.segments
|
|
|
|
|
|
return this.withMutex(async () => {
|
|
|
|
|
|
const deadline =
|
|
|
|
|
|
options?.timeBudgetMs !== undefined ? Date.now() + options.timeBudgetMs : undefined
|
|
|
|
|
|
const batchSize = options?.batchGenerations ?? 512
|
|
|
|
|
|
const coldCeiling = this.committed - GenerationStore.REPACK_LIVE_WINDOW
|
|
|
|
|
|
const packedThrough =
|
|
|
|
|
|
segments.segments().length > 0
|
|
|
|
|
|
? segments.segments()[segments.segments().length - 1].lastGeneration
|
|
|
|
|
|
: 0
|
|
|
|
|
|
|
|
|
|
|
|
// Cold, unpacked, committed generations — ascending, contiguous scan.
|
|
|
|
|
|
const eligible: number[] = []
|
|
|
|
|
|
for (const gen of this.committedGensAsc()) {
|
|
|
|
|
|
if (gen > coldCeiling) break
|
|
|
|
|
|
if (gen <= packedThrough) continue // already packed (dup fold barred)
|
|
|
|
|
|
if (this.pendingBuffer.has(gen)) continue // un-flushed = live by definition
|
|
|
|
|
|
eligible.push(gen)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let folded = 0
|
|
|
|
|
|
let segmentsCreated = 0
|
|
|
|
|
|
for (let i = 0; i < eligible.length; i += batchSize) {
|
|
|
|
|
|
if (deadline !== undefined && Date.now() >= deadline) break
|
|
|
|
|
|
const batch = eligible.slice(i, i + batchSize)
|
|
|
|
|
|
const foldInput: FoldGeneration[] = []
|
|
|
|
|
|
for (const gen of batch) {
|
|
|
|
|
|
const delta = (await this.storage.readRawObject(
|
|
|
|
|
|
`${GENERATIONS_PREFIX}/${gen}/tx.json`
|
|
|
|
|
|
)) as GenerationDelta | null
|
|
|
|
|
|
if (delta === null) {
|
|
|
|
|
|
// Already folded by a prior crashed pass whose dirs were removed,
|
|
|
|
|
|
// or damage — getDelta's two-tier read decides which, loudly,
|
|
|
|
|
|
// when someone asks. Skip; never fold a generation we cannot read.
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
const records: FoldGeneration['records'] = []
|
|
|
|
|
|
for (const [kind, ids] of [
|
|
|
|
|
|
['noun', delta.nouns] as const,
|
|
|
|
|
|
['verb', delta.verbs] as const
|
|
|
|
|
|
]) {
|
|
|
|
|
|
for (const id of ids) {
|
|
|
|
|
|
const record = await this.storage.readRawObject(
|
|
|
|
|
|
`${GENERATIONS_PREFIX}/${gen}/prev/${id}.json`
|
|
|
|
|
|
)
|
|
|
|
|
|
if (record) records.push({ kind, id, record })
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
foldInput.push({ generation: gen, timestamp: delta.timestamp, delta, records })
|
|
|
|
|
|
}
|
|
|
|
|
|
if (foldInput.length === 0) continue
|
|
|
|
|
|
await segments.fold(foldInput)
|
|
|
|
|
|
segmentsCreated++
|
|
|
|
|
|
// Segment + manifest durable → the live copies retire.
|
|
|
|
|
|
for (const g of foldInput) {
|
|
|
|
|
|
await this.storage.removeRawPrefix(`${GENERATIONS_PREFIX}/${g.generation}`)
|
|
|
|
|
|
}
|
|
|
|
|
|
folded += foldInput.length
|
|
|
|
|
|
}
|
|
|
|
|
|
if (folded > 0) {
|
|
|
|
|
|
prodLog.info(
|
|
|
|
|
|
`[GenerationStore] repacked ${folded} cold generation(s) into ${segmentsCreated} segment(s) — history preserved, file count reduced`
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
return { foldedGenerations: folded, segmentsCreated }
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
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
|
2026-07-19 12:04:39 -07:00
|
|
|
|
// Bounded maintenance pass (8.9.0): stop reclaiming once the budget is
|
|
|
|
|
|
// spent. Safe mid-loop — reclamation is oldest-first, so an early stop
|
|
|
|
|
|
// leaves a consistent contiguous prefix and the next pass resumes.
|
|
|
|
|
|
const deadline =
|
|
|
|
|
|
options?.timeBudgetMs !== undefined ? Date.now() + options.timeBudgetMs : undefined
|
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 noCaps =
|
|
|
|
|
|
maxGenerations === undefined && maxAge === undefined && maxBytes === undefined
|
|
|
|
|
|
|
|
|
|
|
|
// Running totals the caps are evaluated against; updated as we reclaim.
|
2026-06-29 16:05:03 -07:00
|
|
|
|
let remainingCount = this.committedCount()
|
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 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
|
|
|
|
|
2026-06-29 16:05:03 -07:00
|
|
|
|
// Snapshot the committed gens ascending so the reclaim loop iterates safely
|
|
|
|
|
|
// while removeCommittedUpTo mutates the interval set afterwards.
|
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[] = []
|
2026-06-29 16:05:03 -07:00
|
|
|
|
for (const gen of [...this.committedGensAsc()]) {
|
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.
|
2026-06-29 16:05:03 -07:00
|
|
|
|
if (gen > minPinned) break // committedGensAsc ascending → nothing newer is eligible either
|
2026-07-19 12:04:39 -07:00
|
|
|
|
if (deadline !== undefined && Date.now() >= deadline) break // budget spent — resume next pass
|
feat: temporal VFS — file content joins the Model-B immutability model
The temporal model had a hole exactly where files were concerned: every
entity write is an immutable generation with before-images, but VFS content
BYTES lived under an eager refCount GC left over from the pre-8.0 design —
unlink could physically destroy bytes that in-window history still
referenced, and overwrite never released the old hash at all (an unbounded
silent leak whose accidental byproduct was the only thing "preserving"
history). Reading the past could therefore return a stale field, a dangling
hash, or nothing, depending on luck.
Fix: blob reclamation becomes a HISTORY decision instead of a LIVENESS
decision. Each blob's metadata now carries historyRefCount alongside the
live refCount:
- The commit seam counts one history reference per persisted before-image
record carrying a content hash (commitTransaction staging and the
group-commit flush), recorded BEFORE the record-set persists and carried
in the generation delta (blobHashes — always present on new deltas, so
compaction only falls back to reading records for pre-contract
generations). An aborted transaction compensates best-effort.
- unlink/rmdir/overwrite drop ONLY the live reference (BlobStorage.delete →
release; overwrite finally releases the superseded hash — cancelling the
dedup increment on same-content rewrites and closing the leak), and only
AFTER the canonical mutation commits, so a failed delete can never leave a
live file whose bytes compaction might reclaim.
- History compaction is the ONE reclamation point: after deleting a
generation's record-set it releases that set's references and physically
reclaims any hash at zero live AND zero history references. Pins are
exempt automatically. Crash ordering is over-count-only in every path
(record before persist, release after delete), so a crash can leak until
the scrub recounts but can never reclaim bytes a retained generation
needs. scrubBlobHistoryRefCounts() restores exactness; existing stores get
a one-time marker-gated backfill on open, failing into leak-safe mode
(reclamation disabled) rather than guessing.
On top of the protected history, the temporal API the generational model
always implied:
- vfs.readFile(path, { asOf }) — the exact bytes as of a generation or Date,
materialized from the history (pinned view released so compaction is
never blocked by a read).
- vfs.history(path) — FileVersion[] ascending ({ generation, timestamp,
hash, size, mimeType? }), the newest entry being the live state.
- Overwrites now refresh the file entity's data/embedding text — semantic
search and the data field previously served the FIRST version's text
forever (the stale-field defect a consumer's incident recovery depended
on by luck).
Integration suite (temporal-vfs.test.ts): per-version exact reads +
history listing, leak-fix + history protection on overwrite, rm keeps bytes
readable, compaction reclaims past-window bytes and preserves in-window
(including the cross-file dedup case where an old file's history and a
newer file's removal share one hash), data freshness, and scrub exactness.
2026-07-10 16:43:48 -07:00
|
|
|
|
const delta = await this.getDelta(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
|
|
|
|
if (!noCaps) {
|
|
|
|
|
|
const violatesCount = maxGenerations !== undefined && remainingCount > maxGenerations
|
|
|
|
|
|
const violatesBytes = maxBytes !== undefined && remainingBytes > maxBytes
|
feat: temporal VFS — file content joins the Model-B immutability model
The temporal model had a hole exactly where files were concerned: every
entity write is an immutable generation with before-images, but VFS content
BYTES lived under an eager refCount GC left over from the pre-8.0 design —
unlink could physically destroy bytes that in-window history still
referenced, and overwrite never released the old hash at all (an unbounded
silent leak whose accidental byproduct was the only thing "preserving"
history). Reading the past could therefore return a stale field, a dangling
hash, or nothing, depending on luck.
Fix: blob reclamation becomes a HISTORY decision instead of a LIVENESS
decision. Each blob's metadata now carries historyRefCount alongside the
live refCount:
- The commit seam counts one history reference per persisted before-image
record carrying a content hash (commitTransaction staging and the
group-commit flush), recorded BEFORE the record-set persists and carried
in the generation delta (blobHashes — always present on new deltas, so
compaction only falls back to reading records for pre-contract
generations). An aborted transaction compensates best-effort.
- unlink/rmdir/overwrite drop ONLY the live reference (BlobStorage.delete →
release; overwrite finally releases the superseded hash — cancelling the
dedup increment on same-content rewrites and closing the leak), and only
AFTER the canonical mutation commits, so a failed delete can never leave a
live file whose bytes compaction might reclaim.
- History compaction is the ONE reclamation point: after deleting a
generation's record-set it releases that set's references and physically
reclaims any hash at zero live AND zero history references. Pins are
exempt automatically. Crash ordering is over-count-only in every path
(record before persist, release after delete), so a crash can leak until
the scrub recounts but can never reclaim bytes a retained generation
needs. scrubBlobHistoryRefCounts() restores exactness; existing stores get
a one-time marker-gated backfill on open, failing into leak-safe mode
(reclamation disabled) rather than guessing.
On top of the protected history, the temporal API the generational model
always implied:
- vfs.readFile(path, { asOf }) — the exact bytes as of a generation or Date,
materialized from the history (pinned view released so compaction is
never blocked by a read).
- vfs.history(path) — FileVersion[] ascending ({ generation, timestamp,
hash, size, mimeType? }), the newest entry being the live state.
- Overwrites now refresh the file entity's data/embedding text — semantic
search and the data field previously served the FIRST version's text
forever (the stale-field defect a consumer's incident recovery depended
on by luck).
Integration suite (temporal-vfs.test.ts): per-version exact reads +
history listing, leak-fix + history protection on overwrite, rm keeps bytes
readable, compaction reclaims past-window bytes and preserves in-window
(including the cross-file dedup case where an old file's history and a
newer file's removal share one hash), data freshness, and scrub exactness.
2026-07-10 16:43:48 -07:00
|
|
|
|
const violatesAge = ageCutoff !== undefined && delta.timestamp < ageCutoff
|
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
|
|
|
|
// 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: temporal VFS — file content joins the Model-B immutability model
The temporal model had a hole exactly where files were concerned: every
entity write is an immutable generation with before-images, but VFS content
BYTES lived under an eager refCount GC left over from the pre-8.0 design —
unlink could physically destroy bytes that in-window history still
referenced, and overwrite never released the old hash at all (an unbounded
silent leak whose accidental byproduct was the only thing "preserving"
history). Reading the past could therefore return a stale field, a dangling
hash, or nothing, depending on luck.
Fix: blob reclamation becomes a HISTORY decision instead of a LIVENESS
decision. Each blob's metadata now carries historyRefCount alongside the
live refCount:
- The commit seam counts one history reference per persisted before-image
record carrying a content hash (commitTransaction staging and the
group-commit flush), recorded BEFORE the record-set persists and carried
in the generation delta (blobHashes — always present on new deltas, so
compaction only falls back to reading records for pre-contract
generations). An aborted transaction compensates best-effort.
- unlink/rmdir/overwrite drop ONLY the live reference (BlobStorage.delete →
release; overwrite finally releases the superseded hash — cancelling the
dedup increment on same-content rewrites and closing the leak), and only
AFTER the canonical mutation commits, so a failed delete can never leave a
live file whose bytes compaction might reclaim.
- History compaction is the ONE reclamation point: after deleting a
generation's record-set it releases that set's references and physically
reclaims any hash at zero live AND zero history references. Pins are
exempt automatically. Crash ordering is over-count-only in every path
(record before persist, release after delete), so a crash can leak until
the scrub recounts but can never reclaim bytes a retained generation
needs. scrubBlobHistoryRefCounts() restores exactness; existing stores get
a one-time marker-gated backfill on open, failing into leak-safe mode
(reclamation disabled) rather than guessing.
On top of the protected history, the temporal API the generational model
always implied:
- vfs.readFile(path, { asOf }) — the exact bytes as of a generation or Date,
materialized from the history (pinned view released so compaction is
never blocked by a read).
- vfs.history(path) — FileVersion[] ascending ({ generation, timestamp,
hash, size, mimeType? }), the newest entry being the live state.
- Overwrites now refresh the file entity's data/embedding text — semantic
search and the data field previously served the FIRST version's text
forever (the stale-field defect a consumer's incident recovery depended
on by luck).
Integration suite (temporal-vfs.test.ts): per-version exact reads +
history listing, leak-fix + history protection on overwrite, rm keeps bytes
readable, compaction reclaims past-window bytes and preserves in-window
(including the cross-file dedup case where an old file's history and a
newer file's removal share one hash), data freshness, and scrub exactness.
2026-07-10 16:43:48 -07:00
|
|
|
|
const genBytes = maxBytes !== undefined ? delta.bytes : 0
|
|
|
|
|
|
|
|
|
|
|
|
// Temporal-blob contract: resolve the content-blob hashes this
|
|
|
|
|
|
// generation's record-set references BEFORE deleting it. New
|
|
|
|
|
|
// generations carry the multiset in their persisted delta (empty
|
|
|
|
|
|
// array when none — distinguishing "new format, no blobs" from a
|
|
|
|
|
|
// pre-contract delta); legacy generations fall back to reading the
|
|
|
|
|
|
// records themselves. Skipped entirely on non-blob-aware storage.
|
|
|
|
|
|
let blobHashes: string[] | undefined
|
|
|
|
|
|
if (this.storage.releaseHistoryBlobReferences) {
|
|
|
|
|
|
const rawDelta = (await this.storage.readRawObject(
|
|
|
|
|
|
`${GENERATIONS_PREFIX}/${gen}/tx.json`
|
|
|
|
|
|
)) as GenerationDelta | null
|
|
|
|
|
|
blobHashes = rawDelta?.blobHashes
|
|
|
|
|
|
if (blobHashes === undefined && this.storage.extractBlobHashesFromRecords) {
|
|
|
|
|
|
blobHashes = this.storage.extractBlobHashesFromRecords(
|
|
|
|
|
|
await this.readGenerationRecords(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
|
|
|
|
await this.storage.removeRawPrefix(`${GENERATIONS_PREFIX}/${gen}`)
|
|
|
|
|
|
this.deltaCache.delete(gen)
|
2026-07-18 10:51:37 -07:00
|
|
|
|
if (this.historyBytesTotal !== null) {
|
|
|
|
|
|
this.historyBytesTotal -= delta.bytes
|
|
|
|
|
|
}
|
feat: temporal VFS — file content joins the Model-B immutability model
The temporal model had a hole exactly where files were concerned: every
entity write is an immutable generation with before-images, but VFS content
BYTES lived under an eager refCount GC left over from the pre-8.0 design —
unlink could physically destroy bytes that in-window history still
referenced, and overwrite never released the old hash at all (an unbounded
silent leak whose accidental byproduct was the only thing "preserving"
history). Reading the past could therefore return a stale field, a dangling
hash, or nothing, depending on luck.
Fix: blob reclamation becomes a HISTORY decision instead of a LIVENESS
decision. Each blob's metadata now carries historyRefCount alongside the
live refCount:
- The commit seam counts one history reference per persisted before-image
record carrying a content hash (commitTransaction staging and the
group-commit flush), recorded BEFORE the record-set persists and carried
in the generation delta (blobHashes — always present on new deltas, so
compaction only falls back to reading records for pre-contract
generations). An aborted transaction compensates best-effort.
- unlink/rmdir/overwrite drop ONLY the live reference (BlobStorage.delete →
release; overwrite finally releases the superseded hash — cancelling the
dedup increment on same-content rewrites and closing the leak), and only
AFTER the canonical mutation commits, so a failed delete can never leave a
live file whose bytes compaction might reclaim.
- History compaction is the ONE reclamation point: after deleting a
generation's record-set it releases that set's references and physically
reclaims any hash at zero live AND zero history references. Pins are
exempt automatically. Crash ordering is over-count-only in every path
(record before persist, release after delete), so a crash can leak until
the scrub recounts but can never reclaim bytes a retained generation
needs. scrubBlobHistoryRefCounts() restores exactness; existing stores get
a one-time marker-gated backfill on open, failing into leak-safe mode
(reclamation disabled) rather than guessing.
On top of the protected history, the temporal API the generational model
always implied:
- vfs.readFile(path, { asOf }) — the exact bytes as of a generation or Date,
materialized from the history (pinned view released so compaction is
never blocked by a read).
- vfs.history(path) — FileVersion[] ascending ({ generation, timestamp,
hash, size, mimeType? }), the newest entry being the live state.
- Overwrites now refresh the file entity's data/embedding text — semantic
search and the data field previously served the FIRST version's text
forever (the stale-field defect a consumer's incident recovery depended
on by luck).
Integration suite (temporal-vfs.test.ts): per-version exact reads +
history listing, leak-fix + history protection on overwrite, rm keeps bytes
readable, compaction reclaims past-window bytes and preserves in-window
(including the cross-file dedup case where an old file's history and a
newer file's removal share one hash), data freshness, and scrub exactness.
2026-07-10 16:43:48 -07:00
|
|
|
|
|
|
|
|
|
|
// AFTER the record-set is gone (over-count-only crash ordering):
|
|
|
|
|
|
// release its history references and reclaim any blob left with zero
|
|
|
|
|
|
// live AND zero history references — the system's one byte-reclaim point.
|
|
|
|
|
|
if (blobHashes && blobHashes.length > 0 && this.storage.releaseHistoryBlobReferences) {
|
|
|
|
|
|
await this.storage.releaseHistoryBlobReferences(blobHashes)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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) {
|
2026-06-29 16:05:03 -07:00
|
|
|
|
// The reclaim loop walks committedGensAsc() oldest-first and stops at the
|
|
|
|
|
|
// first survivor, so `removed` is the oldest CONTIGUOUS prefix of the
|
|
|
|
|
|
// committed gens — every committed gen ≤ max(removed) was reclaimed, which
|
|
|
|
|
|
// is exactly what removeCommittedUpTo strips. Guard that prefix invariant.
|
|
|
|
|
|
const highestRemoved = Math.max(...removed)
|
|
|
|
|
|
const countBefore = this.committedCount()
|
|
|
|
|
|
this.removeCommittedUpTo(highestRemoved)
|
|
|
|
|
|
if (this.committedCount() !== countBefore - removed.length) {
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
`[GenerationStore] compaction invariant violated: reclaimed ${removed.length} ` +
|
|
|
|
|
|
`generation(s) but committedCount dropped by ${countBefore - this.committedCount()} ` +
|
|
|
|
|
|
`(the reclaimed set is not the oldest contiguous prefix)`
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-06-22 13:47:33 -07:00
|
|
|
|
// Reclaimed generations leave the per-id chains stale → rebuild on next read.
|
|
|
|
|
|
this.invalidateChains()
|
2026-06-29 16:05:03 -07:00
|
|
|
|
this.horizonGen = Math.max(this.horizonGen, highestRemoved)
|
2026-07-19 16:26:10 -07:00
|
|
|
|
// Packed-tier reclaim (D3): a packed generation's bytes live in a
|
|
|
|
|
|
// sealed segment — removeRawPrefix above was a no-op for it. Drop
|
|
|
|
|
|
// WHOLE segments now fully below the horizon; a partially-reclaimed
|
|
|
|
|
|
// segment keeps its bytes until the boundary passes it (the frozen
|
|
|
|
|
|
// partial-segments-wait rule; logical reclamation above still holds —
|
|
|
|
|
|
// the generations left committedRanges and asOf below the horizon
|
|
|
|
|
|
// throws regardless).
|
|
|
|
|
|
if (this.segments) {
|
|
|
|
|
|
await this.segments.dropSegmentsBelow(this.horizonGen + 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
|
|
|
|
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.
|
|
|
|
|
|
*/
|
2026-08-13 09:19:14 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* @description Run a wholesale state replacement (the restore swap)
|
|
|
|
|
|
* EXCLUSIVELY: under the commit mutex, with the pending flush timer
|
|
|
|
|
|
* disarmed and the pending tier + fold-checkpoint accumulator discarded
|
|
|
|
|
|
* FIRST — so no background flush can write into `_system/` while the
|
|
|
|
|
|
* replacement is removing and swapping directories. Observed without this:
|
|
|
|
|
|
* a checkpoint stamp raced restore's directory removal and the swap died
|
|
|
|
|
|
* ENOTEMPTY mid-flight. The discarded in-memory state describes the store
|
|
|
|
|
|
* being replaced — `reopenAfterRestore` (which the caller runs next)
|
|
|
|
|
|
* rebuilds everything from the restored bytes.
|
|
|
|
|
|
*/
|
|
|
|
|
|
async runStateReplacement(replace: () => Promise<void>): Promise<void> {
|
|
|
|
|
|
return this.withMutex(async () => {
|
|
|
|
|
|
this.clearPendingFlushTimer()
|
|
|
|
|
|
this.pendingGens = []
|
|
|
|
|
|
this.pendingBuffer.clear()
|
|
|
|
|
|
this.checkpointDirtyNouns = new Set()
|
|
|
|
|
|
this.checkpointDirtyVerbs = new Set()
|
|
|
|
|
|
await replace()
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
async reopenAfterRestore(floorGeneration: number): Promise<void> {
|
|
|
|
|
|
await this.withMutex(async () => {
|
|
|
|
|
|
this.deltaCache.clear()
|
2026-07-18 10:51:37 -07:00
|
|
|
|
// The running history-byte total describes the REPLACED store — drop it;
|
|
|
|
|
|
// the next historyBytes() re-seeds with one walk over the new state.
|
|
|
|
|
|
this.historyBytesTotal = 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
|
|
|
|
// 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()
|
2026-08-13 09:19:14 -07:00
|
|
|
|
// The fold-checkpoint accumulator described the replaced state too.
|
|
|
|
|
|
this.checkpointDirtyNouns = new Set()
|
|
|
|
|
|
this.checkpointDirtyVerbs = new Set()
|
|
|
|
|
|
this.foldCheckpointChainValid = false
|
|
|
|
|
|
this.foldCheckpoint = 0
|
|
|
|
|
|
// A RESTORE IS AN UNCLEAN EVENT, by construction: the snapshot's files
|
|
|
|
|
|
// were just bulk-copied WITHOUT per-file fsync, so a power cut here can
|
|
|
|
|
|
// tear them — yet the snapshot may CARRY the source brain's
|
|
|
|
|
|
// clean-shutdown marker and fold checkpoint, which would together
|
|
|
|
|
|
// suppress exactly the recovery fold that cures such a tear. Delete
|
|
|
|
|
|
// both BEFORE reopening: the open below then treats the store as
|
|
|
|
|
|
// uncleanly shut, folds the restored log into canonical, barrier-syncs
|
|
|
|
|
|
// what it re-applied, and stamps a FRESH checkpoint — the restored
|
|
|
|
|
|
// state becomes durably founded at restore time instead of inheriting
|
|
|
|
|
|
// the source brain's assertions about bytes this disk never synced.
|
|
|
|
|
|
try {
|
|
|
|
|
|
await this.storage.deleteRawObject(CLEAN_SHUTDOWN_PATH)
|
|
|
|
|
|
} catch { /* absent is fine — same outcome */ }
|
|
|
|
|
|
try {
|
|
|
|
|
|
await this.storage.deleteRawObject(FOLD_CHECKPOINT_PATH)
|
|
|
|
|
|
} catch { /* absent is fine — fold from 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
|
|
|
|
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`)
|
2026-08-12 16:56:08 -07:00
|
|
|
|
const restoredNouns: string[] = []
|
|
|
|
|
|
const restoredVerbs: string[] = []
|
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 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)
|
2026-08-12 16:56:08 -07:00
|
|
|
|
restoredVerbs.push(id)
|
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
|
|
|
|
} else {
|
|
|
|
|
|
await this.storage.writeNounRaw(id, image)
|
2026-08-12 16:56:08 -07:00
|
|
|
|
restoredNouns.push(id)
|
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-08-12 16:56:08 -07:00
|
|
|
|
// Make the restores durable IMMEDIATELY (this runs at open, before the
|
|
|
|
|
|
// fold-checkpoint chain state is even read): a restored before-image
|
|
|
|
|
|
// replaces bytes a stored checkpoint may already vouch for, so it must
|
|
|
|
|
|
// reach disk with the same certainty — otherwise a power cut could let
|
|
|
|
|
|
// the rolled-back write's bytes resurrect past a bounded fold.
|
|
|
|
|
|
if (restoredNouns.length > 0 || restoredVerbs.length > 0) {
|
|
|
|
|
|
await this.storage.syncEntityCanonical?.(restoredNouns, restoredVerbs)
|
|
|
|
|
|
}
|
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(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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
perf(8.0): bound per-id generation history chains (O(W+L) resident, was O(N))
The MVCC time-travel layer kept nounChains/verbChains as unbounded
Map<id, number[]> — one resident chain per id ever touched across retained
history (O(N) RAM, defeating billion-scale time travel). Replace with a hot-tail
window + bounded cold LRU + a mutex-free bulk resolver, keeping resolveAt exactly
correct.
The most-recent W generations stay resident as full chains (the common recent-pin
read is O(log), zero scan); deeper pins reconstruct one id's chain on demand into
an L-bounded LRU. Reconstruction is LOCK-LIGHT — it holds no commit mutex, so a
historical read never stalls writers; correctness holds because a live pin's
answer is always > minPinnedGeneration, which compaction can never reclaim, and a
concurrently-reclaimed gen below that is skipped. materializeAtGeneration routes
through a new mutex-free resolveManyAt (one forward pass, O(R + |ids|)) — without
it a deep-pin materialize both regresses to O(N*R) and deadlocks on snapshotWith's
mutex. The cold cache is invalidated on re-touch to stay coherent. Resident RAM is
O(W*d + L), independent of N. 11-case test (oracle-vs-bruteforce, held-Db across
eviction + concurrent compaction, no-deadlock, write-path I/O-free); unit 1735 +
integration 613 (db-mvcc/db-temporal/db-asof green).
2026-06-30 10:30:17 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* @description Drop the leading entries of an id's ascending chain that fall
|
|
|
|
|
|
* below `lo` (generations that slid out of the hot-tail window), deleting the
|
|
|
|
|
|
* chain entirely when it empties. Used by the O(d̄) window slide in
|
|
|
|
|
|
* {@link GenerationStore.extendChains}. Tolerates an already-trimmed front.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function dropChainFront(chains: Map<string, number[]>, id: string, lo: number): void {
|
|
|
|
|
|
const chain = chains.get(id)
|
|
|
|
|
|
if (chain === undefined) return
|
|
|
|
|
|
let i = 0
|
|
|
|
|
|
while (i < chain.length && chain[i] < lo) i++
|
|
|
|
|
|
if (i === chain.length) chains.delete(id)
|
|
|
|
|
|
else if (i > 0) chain.splice(0, i)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-22 13:47:33 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* @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
|
|
|
|
|
|
}
|