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 tests/integration/db-mvcc
|
|
|
|
|
* @description Credibility-bar proof suite for Brainy 8.0's generational MVCC
|
|
|
|
|
* storage core + Datomic-style Db API. Each test PROVES a stated guarantee
|
|
|
|
|
* (not merely exercises the API):
|
|
|
|
|
*
|
|
|
|
|
* 1. Isolation — a pinned Db reads its pinned state exactly, across 200
|
|
|
|
|
* subsequent transact mutations (updates + removes).
|
|
|
|
|
* 2. Atomicity — a batch with a failing later operation applies NOTHING
|
|
|
|
|
* (generation unchanged, no partial records), for both plan-time and
|
|
|
|
|
* execution-phase failures (the latter via real fault injection into the
|
|
|
|
|
* storage adapter, exercising the production rollback path).
|
|
|
|
|
* 3. CAS — `ifAtGeneration` commits when fresh, throws
|
|
|
|
|
* `GenerationConflictError` with correct expected/actual when stale.
|
|
|
|
|
* 4. Snapshot integrity — persist → mutate source heavily → `Brainy.load()`
|
|
|
|
|
* is byte-identical to the pre-mutation state (hard-link safety), and an
|
|
|
|
|
* in-memory brain persists to a loadable directory.
|
|
|
|
|
* 5. Compaction safety — pinned reads stay correct across `compactHistory()`;
|
|
|
|
|
* after release + compact the superseded record-sets are reclaimed.
|
|
|
|
|
* 6. with() — speculative overlays are visible on the overlay Db only; the
|
|
|
|
|
* underlying brain and the original Db are untouched.
|
|
|
|
|
* 7. Generation monotonicity across close/reopen.
|
|
|
|
|
* 8. Crash consistency — a simulated crash between record staging and the
|
|
|
|
|
* manifest rename (the commit point) recovers to the exact pre-transaction
|
|
|
|
|
* state on reopen, through the REAL production recovery path.
|
|
|
|
|
* 9. Versioned provider wiring — a provider implementing the 4-method
|
|
|
|
|
* `VersionedIndexProvider` contract receives balanced pin/release (and a
|
|
|
|
|
* visibility consult per pin) as Db values are created and released.
|
feat(8.0): full query surface at historical generations via ephemeral index materialization
Historical Db values (now()/asOf() pins that history has moved past) now
serve the COMPLETE query surface - vector/hybrid search, graph traversal,
cursor pagination, and aggregation - by materializing ephemeral in-memory
indexes over the exact at-generation record set. The historical-query
throw is gone; NotYetSupportedAtHistoricalGenerationError is deleted.
Materializer (Brainy.materializeAtGeneration):
- Copies the at-G record set (live bytes for ids untouched since the pin,
immutable before-images otherwise) into a fresh MemoryStorage; a final
reconciliation pass under the commit mutex makes the copy exact even
when transactions commit mid-build.
- Opens a read-only Brainy over the copy: init rebuilds the metadata and
graph-adjacency indexes from the records; the vector index is built by
inserting every at-G vector (the at-G HNSW graph never existed on disk,
so there is nothing to restore). Host embedder and aggregate definitions
are shared - no second model load, aggregates backfill at-G values.
- Cost is the documented contract: O(n at G) time and memory, ONCE per Db
(handle cached; freed by release(), with a FinalizationRegistry backstop
that also closes leaked readers). A native VersionedIndexProvider serves
the same reads from retained segments with no rebuild.
Db routing (src/db/db.ts): metadata-level find()/related() keep the free
record path; index-only dimensions (query/vector/near/connected/cursor/
aggregate/includeRelations/non-metadata modes) route to the cached
materialization; unsupported where-operators on the record path re-route
there too instead of erroring. Speculative with() overlays keep the one
honest boundary - SpeculativeOverlayError (overlay entities carry no
embeddings, so index reads over them would be silently incomplete);
metadata find()/get()/filter related() work on overlays.
UpdateParams.vector contract now honored: an explicit pre-computed vector
applies directly (with dimension validation) in update() and transact
update ops, re-indexing HNSW - previously it was silently ignored unless
data also changed.
GraphAdjacencyIndex: adjacency now derives from the two verb-id LSM trees
filtered through the live-verb tombstone set (entity->entity edge trees
deleted - they carried no verb ids, so removeVerb could never tombstone
them and traversal served stale neighbors forever). Neighbor reads batch-
load live verbs via the unified cache; addVerb seeds the cache.
Proofs (tests/integration/db-mvcc.test.ts, 24 green): historical vector
search finds old vector placement including since-deleted entities;
historical graph traversal walks the old wiring after a rewire; historical
aggregation computes at-G group values; asOf() pins get the same surface;
the materialization builds once per Db and release() closes the ephemeral
reader (it refuses reads afterwards); overlays throw the documented error.
ADR-001 updated to the no-throws historical model.
2026-06-11 08:12:11 -07:00
|
|
|
* 10. Historical full query surface — vector search, graph traversal, and
|
|
|
|
|
* aggregation at a past pinned generation return at-generation results
|
|
|
|
|
* via ephemeral index materialization (for `now()` and `asOf()` pins
|
|
|
|
|
* alike, built once per Db and cached); `release()` frees the
|
|
|
|
|
* materialization by closing the ephemeral reader; speculative overlays
|
|
|
|
|
* keep the documented `SpeculativeOverlayError` boundary.
|
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
|
|
|
*
|
|
|
|
|
* All entities carry explicit vectors so the suite never invokes the
|
feat(8.0): full query surface at historical generations via ephemeral index materialization
Historical Db values (now()/asOf() pins that history has moved past) now
serve the COMPLETE query surface - vector/hybrid search, graph traversal,
cursor pagination, and aggregation - by materializing ephemeral in-memory
indexes over the exact at-generation record set. The historical-query
throw is gone; NotYetSupportedAtHistoricalGenerationError is deleted.
Materializer (Brainy.materializeAtGeneration):
- Copies the at-G record set (live bytes for ids untouched since the pin,
immutable before-images otherwise) into a fresh MemoryStorage; a final
reconciliation pass under the commit mutex makes the copy exact even
when transactions commit mid-build.
- Opens a read-only Brainy over the copy: init rebuilds the metadata and
graph-adjacency indexes from the records; the vector index is built by
inserting every at-G vector (the at-G HNSW graph never existed on disk,
so there is nothing to restore). Host embedder and aggregate definitions
are shared - no second model load, aggregates backfill at-G values.
- Cost is the documented contract: O(n at G) time and memory, ONCE per Db
(handle cached; freed by release(), with a FinalizationRegistry backstop
that also closes leaked readers). A native VersionedIndexProvider serves
the same reads from retained segments with no rebuild.
Db routing (src/db/db.ts): metadata-level find()/related() keep the free
record path; index-only dimensions (query/vector/near/connected/cursor/
aggregate/includeRelations/non-metadata modes) route to the cached
materialization; unsupported where-operators on the record path re-route
there too instead of erroring. Speculative with() overlays keep the one
honest boundary - SpeculativeOverlayError (overlay entities carry no
embeddings, so index reads over them would be silently incomplete);
metadata find()/get()/filter related() work on overlays.
UpdateParams.vector contract now honored: an explicit pre-computed vector
applies directly (with dimension validation) in update() and transact
update ops, re-indexing HNSW - previously it was silently ignored unless
data also changed.
GraphAdjacencyIndex: adjacency now derives from the two verb-id LSM trees
filtered through the live-verb tombstone set (entity->entity edge trees
deleted - they carried no verb ids, so removeVerb could never tombstone
them and traversal served stale neighbors forever). Neighbor reads batch-
load live verbs via the unified cache; addVerb seeds the cache.
Proofs (tests/integration/db-mvcc.test.ts, 24 green): historical vector
search finds old vector placement including since-deleted entities;
historical graph traversal walks the old wiring after a rewire; historical
aggregation computes at-G group values; asOf() pins get the same surface;
the materialization builds once per Db and release() closes the ephemeral
reader (it refuses reads afterwards); overlays throw the documented error.
ADR-001 updated to the no-throws historical model.
2026-06-11 08:12:11 -07:00
|
|
|
* embedding engine (vector queries use find({ vector }); semantic string
|
|
|
|
|
* search on overlays is exercised only for its documented error).
|
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 { describe, it, expect, afterEach, vi } from 'vitest'
|
|
|
|
|
import * as fs from 'node:fs'
|
|
|
|
|
import * as os from 'node:os'
|
|
|
|
|
import * as path from 'node:path'
|
|
|
|
|
import { Brainy } from '../../src/brainy.js'
|
feat(8.0): full query surface at historical generations via ephemeral index materialization
Historical Db values (now()/asOf() pins that history has moved past) now
serve the COMPLETE query surface - vector/hybrid search, graph traversal,
cursor pagination, and aggregation - by materializing ephemeral in-memory
indexes over the exact at-generation record set. The historical-query
throw is gone; NotYetSupportedAtHistoricalGenerationError is deleted.
Materializer (Brainy.materializeAtGeneration):
- Copies the at-G record set (live bytes for ids untouched since the pin,
immutable before-images otherwise) into a fresh MemoryStorage; a final
reconciliation pass under the commit mutex makes the copy exact even
when transactions commit mid-build.
- Opens a read-only Brainy over the copy: init rebuilds the metadata and
graph-adjacency indexes from the records; the vector index is built by
inserting every at-G vector (the at-G HNSW graph never existed on disk,
so there is nothing to restore). Host embedder and aggregate definitions
are shared - no second model load, aggregates backfill at-G values.
- Cost is the documented contract: O(n at G) time and memory, ONCE per Db
(handle cached; freed by release(), with a FinalizationRegistry backstop
that also closes leaked readers). A native VersionedIndexProvider serves
the same reads from retained segments with no rebuild.
Db routing (src/db/db.ts): metadata-level find()/related() keep the free
record path; index-only dimensions (query/vector/near/connected/cursor/
aggregate/includeRelations/non-metadata modes) route to the cached
materialization; unsupported where-operators on the record path re-route
there too instead of erroring. Speculative with() overlays keep the one
honest boundary - SpeculativeOverlayError (overlay entities carry no
embeddings, so index reads over them would be silently incomplete);
metadata find()/get()/filter related() work on overlays.
UpdateParams.vector contract now honored: an explicit pre-computed vector
applies directly (with dimension validation) in update() and transact
update ops, re-indexing HNSW - previously it was silently ignored unless
data also changed.
GraphAdjacencyIndex: adjacency now derives from the two verb-id LSM trees
filtered through the live-verb tombstone set (entity->entity edge trees
deleted - they carried no verb ids, so removeVerb could never tombstone
them and traversal served stale neighbors forever). Neighbor reads batch-
load live verbs via the unified cache; addVerb seeds the cache.
Proofs (tests/integration/db-mvcc.test.ts, 24 green): historical vector
search finds old vector placement including since-deleted entities;
historical graph traversal walks the old wiring after a rewire; historical
aggregation computes at-G group values; asOf() pins get the same surface;
the materialization builds once per Db and release() closes the ephemeral
reader (it refuses reads afterwards); overlays throw the documented error.
ADR-001 updated to the no-throws historical model.
2026-06-11 08:12:11 -07:00
|
|
|
import { Db, type HistoricalQueryHandle } from '../../src/db/db.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 {
|
|
|
|
|
GenerationCompactedError,
|
|
|
|
|
GenerationConflictError,
|
feat(8.0): full query surface at historical generations via ephemeral index materialization
Historical Db values (now()/asOf() pins that history has moved past) now
serve the COMPLETE query surface - vector/hybrid search, graph traversal,
cursor pagination, and aggregation - by materializing ephemeral in-memory
indexes over the exact at-generation record set. The historical-query
throw is gone; NotYetSupportedAtHistoricalGenerationError is deleted.
Materializer (Brainy.materializeAtGeneration):
- Copies the at-G record set (live bytes for ids untouched since the pin,
immutable before-images otherwise) into a fresh MemoryStorage; a final
reconciliation pass under the commit mutex makes the copy exact even
when transactions commit mid-build.
- Opens a read-only Brainy over the copy: init rebuilds the metadata and
graph-adjacency indexes from the records; the vector index is built by
inserting every at-G vector (the at-G HNSW graph never existed on disk,
so there is nothing to restore). Host embedder and aggregate definitions
are shared - no second model load, aggregates backfill at-G values.
- Cost is the documented contract: O(n at G) time and memory, ONCE per Db
(handle cached; freed by release(), with a FinalizationRegistry backstop
that also closes leaked readers). A native VersionedIndexProvider serves
the same reads from retained segments with no rebuild.
Db routing (src/db/db.ts): metadata-level find()/related() keep the free
record path; index-only dimensions (query/vector/near/connected/cursor/
aggregate/includeRelations/non-metadata modes) route to the cached
materialization; unsupported where-operators on the record path re-route
there too instead of erroring. Speculative with() overlays keep the one
honest boundary - SpeculativeOverlayError (overlay entities carry no
embeddings, so index reads over them would be silently incomplete);
metadata find()/get()/filter related() work on overlays.
UpdateParams.vector contract now honored: an explicit pre-computed vector
applies directly (with dimension validation) in update() and transact
update ops, re-indexing HNSW - previously it was silently ignored unless
data also changed.
GraphAdjacencyIndex: adjacency now derives from the two verb-id LSM trees
filtered through the live-verb tombstone set (entity->entity edge trees
deleted - they carried no verb ids, so removeVerb could never tombstone
them and traversal served stale neighbors forever). Neighbor reads batch-
load live verbs via the unified cache; addVerb seeds the cache.
Proofs (tests/integration/db-mvcc.test.ts, 24 green): historical vector
search finds old vector placement including since-deleted entities;
historical graph traversal walks the old wiring after a rewire; historical
aggregation computes at-G group values; asOf() pins get the same surface;
the materialization builds once per Db and release() closes the ephemeral
reader (it refuses reads afterwards); overlays throw the documented error.
ADR-001 updated to the no-throws historical model.
2026-06-11 08:12:11 -07:00
|
|
|
SpeculativeOverlayError
|
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
|
|
|
} from '../../src/db/errors.js'
|
|
|
|
|
import type { GenerationStore } from '../../src/db/generationStore.js'
|
|
|
|
|
import { GraphAdjacencyIndex } from '../../src/graph/graphAdjacencyIndex.js'
|
|
|
|
|
import { RevisionConflictError } from '../../src/transaction/RevisionConflictError.js'
|
|
|
|
|
import type { StorageAdapter } from '../../src/coreTypes.js'
|
|
|
|
|
import { NounType, VerbType } from '../../src/types/graphTypes.js'
|
|
|
|
|
|
|
|
|
|
/** Deterministic 384-dim vector so no test ever invokes the embedder. */
|
|
|
|
|
function vec(seed: number): number[] {
|
|
|
|
|
return Array.from({ length: 384 }, (_, i) => ((seed * 31 + i * 7) % 100) / 100)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Map a readable label to a deterministic UUID-shaped id (entity ids must be
|
|
|
|
|
* UUIDs — the sharded storage layout derives the shard from the UUID hex).
|
|
|
|
|
* Two independent FNV-style mixes keep 7+ distinct labels collision-free.
|
|
|
|
|
*/
|
|
|
|
|
function uid(label: string): string {
|
|
|
|
|
let h1 = 0x811c9dc5
|
|
|
|
|
for (let i = 0; i < label.length; i++) {
|
|
|
|
|
h1 = Math.imul(h1 ^ label.charCodeAt(i), 0x01000193) >>> 0
|
|
|
|
|
}
|
|
|
|
|
let h2 = 0xdeadbeef
|
|
|
|
|
for (let i = label.length - 1; i >= 0; i--) {
|
|
|
|
|
h2 = Math.imul(h2 ^ label.charCodeAt(i), 0x85ebca6b) >>> 0
|
|
|
|
|
}
|
|
|
|
|
const hex = h1.toString(16).padStart(8, '0') + h2.toString(16).padStart(8, '0')
|
|
|
|
|
return `00000000-0000-4000-8000-${hex.slice(0, 12)}`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Typed access to the brain's private generation store (test injection point). */
|
|
|
|
|
function generationStoreOf(brain: Brainy): GenerationStore {
|
|
|
|
|
return (brain as unknown as { generationStore: GenerationStore }).generationStore
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('8.0 Db API — generational MVCC', () => {
|
|
|
|
|
const brains: Brainy[] = []
|
|
|
|
|
const tempDirs: string[] = []
|
|
|
|
|
|
|
|
|
|
/** Track a brain for afterEach teardown. */
|
|
|
|
|
async function openMemoryBrain(): Promise<Brainy> {
|
|
|
|
|
const brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } })
|
|
|
|
|
await brain.init()
|
|
|
|
|
brains.push(brain)
|
|
|
|
|
return brain
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Open (and track) a filesystem brain rooted at a fresh temp directory. */
|
|
|
|
|
async function openFsBrain(dir?: string): Promise<{ brain: Brainy; dir: string }> {
|
|
|
|
|
const rootDirectory = dir ?? makeTempDir()
|
|
|
|
|
const brain = new Brainy({
|
|
|
|
|
requireSubtype: false,
|
feat(8.0): API simplification — remove neural()/Db.search, one storage `path` key, integration→0
8.0 RC cleanup toward "one place per thing, zero-config, no deprecation":
- Remove the `brain.neural()` clustering namespace (ImprovedNeuralAPI + the dead
legacy NeuralAPI + the neural CLI + neural-only types). Similarity is `find({vector})`
/ `similar({to})`; attribute grouping is the aggregation `GROUP BY` engine. The separate
entity-extraction / smart-import feature (NeuralImport, NeuralEntityExtractor, SmartExtractor,
NaturalLanguageProcessor, `brain.extract()`/`brain.nlp()`) is kept.
- Remove `Db.search()`; `find()` is the one query verb (accepts a bare string or FindParams).
Fix the bundled MCP client, which called a non-existent `brain.search(query, limit)` →
now `find({ query, limit })`.
- Storage config: collapse to one canonical top-level `path` key. The pre-8.0 aliases
(`rootDirectory`, `options.*`, `fileSystemStorage.*`) are removed and now THROW with the
exact rename instead of silently defaulting to `./brainy-data` on upgrade. A single resolver
feeds createStorage, the 7.x→8.0 migration probe, and the plugin-factory handoff, so a native
storage provider resolves the identical root (no split-brain).
- Fix `similar({ threshold })`: the min-similarity filter was silently dropped; it is now
applied as a post-filter on `result.score` (the documented way to bound semantic results).
- Fix `vfs.rename()` on a directory: child path updates spread the entity vector into `update()`
and failed dimension validation; they are metadata-only updates now.
- Fix `vfs.move()`: copy+delete orphaned the content-addressed content blob (the destination
shared the source hash, then unlink removed it). `move()` now delegates to `rename()` — an
in-place path change that preserves the blob and the entity id, for files and directories.
- Fix streaming import: the bulk fast path never flushed mid-import nor signalled queryability.
Entity writes are now chunked by a progressive flush interval (100 → 1000 → 5000); each chunk
flushes and emits `progress.queryable`, so imported data is queryable during the import.
- Sweep all docs, comments, and JSDoc for the removed/changed APIs.
Integration suite: 49 files / 588 passed / 0 failed. Unit: 80 files / 1456 passed, no type errors.
2026-06-20 13:31:11 -07:00
|
|
|
storage: { type: 'filesystem', path: rootDirectory }
|
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 brain.init()
|
|
|
|
|
brains.push(brain)
|
|
|
|
|
return { brain, dir: rootDirectory }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function makeTempDir(): string {
|
|
|
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-db-mvcc-'))
|
|
|
|
|
tempDirs.push(dir)
|
|
|
|
|
return dir
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
afterEach(async () => {
|
|
|
|
|
for (const brain of brains.splice(0)) {
|
|
|
|
|
try {
|
|
|
|
|
await brain.close()
|
|
|
|
|
} catch {
|
|
|
|
|
// already closed by the test
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for (const dir of tempDirs.splice(0)) {
|
|
|
|
|
await fs.promises.rm(dir, { recursive: true, force: true })
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
// 1. Isolation
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
it('proof 1 — a pinned Db reads its pinned state exactly across 200 transact mutations', async () => {
|
|
|
|
|
const brain = await openMemoryBrain()
|
|
|
|
|
|
|
|
|
|
const ids = Array.from({ length: 20 }, (_, i) => uid(`iso-e${i}`))
|
|
|
|
|
await brain.transact(
|
|
|
|
|
ids.map((id, i) => ({
|
|
|
|
|
op: 'add' as const,
|
|
|
|
|
id,
|
|
|
|
|
type: NounType.Document,
|
|
|
|
|
data: `doc-${i}`,
|
|
|
|
|
vector: vec(i),
|
|
|
|
|
metadata: { v: 0, slot: i }
|
|
|
|
|
}))
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const db = brain.now()
|
|
|
|
|
const pinned = new Map<string, unknown>()
|
|
|
|
|
for (const id of ids) {
|
|
|
|
|
pinned.set(id, await db.get(id))
|
|
|
|
|
expect(pinned.get(id)).not.toBeNull()
|
|
|
|
|
}
|
|
|
|
|
const pinnedFind = await db.find({ type: NounType.Document, limit: 100 })
|
|
|
|
|
expect(pinnedFind.length).toBe(20)
|
|
|
|
|
|
|
|
|
|
// 200 mutations: 198 updates over the first 18 ids + 2 removes.
|
|
|
|
|
for (let i = 0; i < 200; i++) {
|
|
|
|
|
if (i === 100) {
|
|
|
|
|
await brain.transact([{ op: 'remove', id: uid('iso-e18') }])
|
|
|
|
|
} else if (i === 150) {
|
|
|
|
|
await brain.transact([{ op: 'remove', id: uid('iso-e19') }])
|
|
|
|
|
} else {
|
|
|
|
|
await brain.transact([
|
|
|
|
|
{ op: 'update', id: ids[i % 18], metadata: { v: i + 1, touchedAt: i } }
|
|
|
|
|
])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Live state moved…
|
|
|
|
|
const liveUpdated = await brain.get(ids[0])
|
|
|
|
|
expect((liveUpdated?.metadata as { v: number }).v).toBeGreaterThan(0)
|
|
|
|
|
expect(await brain.get(uid('iso-e18'))).toBeNull()
|
|
|
|
|
expect(await brain.get(uid('iso-e19'))).toBeNull()
|
|
|
|
|
|
|
|
|
|
// …but EVERY read through the pinned Db is exactly the pinned state,
|
|
|
|
|
// including the two entities removed after the pin.
|
|
|
|
|
for (const id of ids) {
|
|
|
|
|
expect(await db.get(id)).toEqual(pinned.get(id))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Set-level isolation: metadata find() at the pin still sees all 20.
|
|
|
|
|
const histFind = await db.find({ type: NounType.Document, limit: 100 })
|
|
|
|
|
expect(histFind.length).toBe(20)
|
|
|
|
|
for (const row of histFind) {
|
|
|
|
|
expect((row.metadata as { v: number }).v).toBe(0)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await db.release()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
// 2. Atomicity
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
it('proof 2a — a plan-time failure in a later op applies nothing', async () => {
|
|
|
|
|
const brain = await openMemoryBrain()
|
|
|
|
|
const g0 = brain.generation()
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
brain.transact([
|
|
|
|
|
{
|
|
|
|
|
op: 'add',
|
|
|
|
|
id: uid('atomic-a'),
|
|
|
|
|
type: NounType.Document,
|
|
|
|
|
data: 'a',
|
|
|
|
|
vector: vec(1),
|
|
|
|
|
metadata: { ok: true }
|
|
|
|
|
},
|
|
|
|
|
{ op: 'update', id: uid('never-created'), metadata: { boom: true } }
|
|
|
|
|
])
|
|
|
|
|
).rejects.toThrow(`Entity ${uid('never-created')} not found`)
|
|
|
|
|
|
|
|
|
|
expect(brain.generation()).toBe(g0)
|
|
|
|
|
expect(await brain.get(uid('atomic-a'))).toBeNull()
|
|
|
|
|
expect((await brain.find({ type: NounType.Document, limit: 10 })).length).toBe(0)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('proof 2b — an execution-phase failure rolls back every applied op (generation unchanged, zero partial records)', async () => {
|
|
|
|
|
const brain = await openMemoryBrain()
|
|
|
|
|
|
|
|
|
|
await brain.transact([
|
|
|
|
|
{
|
|
|
|
|
op: 'add',
|
|
|
|
|
id: uid('atomic-base'),
|
|
|
|
|
type: NounType.Document,
|
|
|
|
|
data: 'base',
|
|
|
|
|
vector: vec(0),
|
|
|
|
|
metadata: { base: true }
|
|
|
|
|
}
|
|
|
|
|
])
|
|
|
|
|
const g0 = brain.generation()
|
|
|
|
|
const storage = brain.storageAdapter
|
|
|
|
|
|
|
|
|
|
// Real fault injection: the verb-metadata write of the LAST operation
|
|
|
|
|
// fails once, after the earlier add operations have already executed —
|
|
|
|
|
// exercising the production TransactionManager rollback path.
|
|
|
|
|
const spy = vi
|
|
|
|
|
.spyOn(storage, 'saveVerbMetadata')
|
|
|
|
|
.mockImplementationOnce(async () => {
|
|
|
|
|
throw new Error('disk full (injected)')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
brain.transact([
|
|
|
|
|
{
|
|
|
|
|
op: 'add',
|
|
|
|
|
id: uid('atomic-b'),
|
|
|
|
|
type: NounType.Person,
|
|
|
|
|
data: 'b',
|
|
|
|
|
vector: vec(2),
|
|
|
|
|
metadata: { name: 'b' }
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
op: 'relate',
|
|
|
|
|
from: uid('atomic-b'),
|
|
|
|
|
to: uid('atomic-base'),
|
|
|
|
|
type: VerbType.RelatedTo
|
|
|
|
|
}
|
|
|
|
|
])
|
|
|
|
|
).rejects.toThrow('disk full (injected)')
|
|
|
|
|
spy.mockRestore()
|
|
|
|
|
|
|
|
|
|
// ZERO ops applied: generation unchanged, the add rolled back, no edge.
|
|
|
|
|
expect(brain.generation()).toBe(g0)
|
|
|
|
|
expect(await brain.get(uid('atomic-b'))).toBeNull()
|
2026-06-11 14:51:00 -07:00
|
|
|
expect((await brain.related({ from: uid('atomic-b') })).length).toBe(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
|
|
|
expect((await brain.find({ type: NounType.Person, limit: 10 })).length).toBe(0)
|
|
|
|
|
|
|
|
|
|
// No partial generation records: the staging directory was removed.
|
|
|
|
|
const records = await storage.listRawObjects(`_generations/${g0 + 1}`)
|
|
|
|
|
expect(records).toEqual([])
|
|
|
|
|
|
|
|
|
|
// The store remains fully writable and consistent after the rollback.
|
|
|
|
|
const db = await brain.transact([
|
|
|
|
|
{
|
|
|
|
|
op: 'add',
|
|
|
|
|
id: uid('atomic-c'),
|
|
|
|
|
type: NounType.Person,
|
|
|
|
|
data: 'c',
|
|
|
|
|
vector: vec(3),
|
|
|
|
|
metadata: { name: 'c' }
|
|
|
|
|
}
|
|
|
|
|
])
|
|
|
|
|
expect(db.generation).toBe(g0 + 1)
|
|
|
|
|
await db.release()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('proof 2c — an ifRev conflict on a later update rejects the whole batch', async () => {
|
|
|
|
|
const brain = await openMemoryBrain()
|
|
|
|
|
await brain.transact([
|
|
|
|
|
{
|
|
|
|
|
op: 'add',
|
|
|
|
|
id: uid('rev-e'),
|
|
|
|
|
type: NounType.Document,
|
|
|
|
|
data: 'rev',
|
|
|
|
|
vector: vec(4),
|
|
|
|
|
metadata: { v: 1 }
|
|
|
|
|
}
|
|
|
|
|
])
|
|
|
|
|
await brain.update({ id: uid('rev-e'), metadata: { v: 2 } }) // _rev is now 2
|
|
|
|
|
const g0 = brain.generation()
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
brain.transact([
|
|
|
|
|
{
|
|
|
|
|
op: 'add',
|
|
|
|
|
id: uid('rev-new'),
|
|
|
|
|
type: NounType.Document,
|
|
|
|
|
data: 'new',
|
|
|
|
|
vector: vec(5),
|
|
|
|
|
metadata: {}
|
|
|
|
|
},
|
|
|
|
|
{ op: 'update', id: uid('rev-e'), metadata: { v: 3 }, ifRev: 1 } // stale
|
|
|
|
|
])
|
|
|
|
|
).rejects.toThrow(RevisionConflictError)
|
|
|
|
|
|
|
|
|
|
expect(brain.generation()).toBe(g0)
|
|
|
|
|
expect(await brain.get(uid('rev-new'))).toBeNull()
|
|
|
|
|
expect(((await brain.get(uid('rev-e')))?.metadata as { v: number }).v).toBe(2)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
// 3. CAS (ifAtGeneration)
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
it('proof 3 — ifAtGeneration commits when fresh and conflicts when stale', async () => {
|
|
|
|
|
const brain = await openMemoryBrain()
|
|
|
|
|
const db = brain.now()
|
|
|
|
|
|
|
|
|
|
const committed = await brain.transact(
|
|
|
|
|
[
|
|
|
|
|
{
|
|
|
|
|
op: 'add',
|
|
|
|
|
id: uid('cas-e'),
|
|
|
|
|
type: NounType.Document,
|
|
|
|
|
data: 'cas',
|
|
|
|
|
vector: vec(6),
|
|
|
|
|
metadata: { n: 1 }
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
{ ifAtGeneration: db.generation }
|
|
|
|
|
)
|
|
|
|
|
expect(committed.generation).toBe(db.generation + 1)
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await brain.transact(
|
|
|
|
|
[{ op: 'update', id: uid('cas-e'), metadata: { n: 2 } }],
|
|
|
|
|
{ ifAtGeneration: db.generation } // stale — committed moved past it
|
|
|
|
|
)
|
|
|
|
|
expect.unreachable('should have thrown GenerationConflictError')
|
|
|
|
|
} catch (err) {
|
|
|
|
|
expect(err).toBeInstanceOf(GenerationConflictError)
|
|
|
|
|
expect((err as GenerationConflictError).expected).toBe(db.generation)
|
|
|
|
|
expect((err as GenerationConflictError).actual).toBe(committed.generation)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Nothing committed by the conflicting attempt.
|
|
|
|
|
expect(((await brain.get(uid('cas-e')))?.metadata as { n: number }).n).toBe(1)
|
|
|
|
|
expect(brain.generation()).toBe(committed.generation)
|
|
|
|
|
|
|
|
|
|
await db.release()
|
|
|
|
|
await committed.release()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
// 4. Snapshot integrity (persist → mutate → load)
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
it('proof 4a — a persisted snapshot is immune to heavy source mutation (hard-link safety)', async () => {
|
|
|
|
|
const { brain } = await openFsBrain()
|
|
|
|
|
|
|
|
|
|
const ids = Array.from({ length: 10 }, (_, i) => uid(`snap-e${i}`))
|
|
|
|
|
await brain.transact(
|
|
|
|
|
ids.map((id, i) => ({
|
|
|
|
|
op: 'add' as const,
|
|
|
|
|
id,
|
|
|
|
|
type: NounType.Document,
|
|
|
|
|
data: `snapshot-doc-${i}`,
|
|
|
|
|
vector: vec(i + 10),
|
|
|
|
|
metadata: { generation: 'pre', slot: i }
|
|
|
|
|
}))
|
|
|
|
|
)
|
|
|
|
|
const relDb = await brain.transact([
|
|
|
|
|
{ op: 'relate', from: ids[0], to: ids[1], type: VerbType.References },
|
|
|
|
|
{ op: 'relate', from: ids[1], to: ids[2], type: VerbType.References }
|
|
|
|
|
])
|
|
|
|
|
await relDb.release()
|
|
|
|
|
|
|
|
|
|
// Capture pre-mutation state, then snapshot.
|
|
|
|
|
const preState = new Map<string, unknown>()
|
|
|
|
|
for (const id of ids) {
|
|
|
|
|
preState.set(id, await brain.get(id))
|
|
|
|
|
}
|
2026-06-11 14:51:00 -07:00
|
|
|
const preRelations = await brain.related({ from: ids[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
|
|
|
expect(preRelations.length).toBe(1)
|
|
|
|
|
|
|
|
|
|
const snapDir = path.join(makeTempDir(), 'snapshot')
|
|
|
|
|
const db = brain.now()
|
|
|
|
|
await db.persist(snapDir)
|
|
|
|
|
await db.release()
|
|
|
|
|
|
|
|
|
|
// Mutate the source heavily: rewrite every entity, delete some, add new.
|
|
|
|
|
for (const [i, id] of ids.entries()) {
|
|
|
|
|
await brain.transact([
|
|
|
|
|
{ op: 'update', id, data: `mutated-${i}`, metadata: { generation: 'post' } }
|
|
|
|
|
])
|
|
|
|
|
}
|
|
|
|
|
await brain.transact([{ op: 'remove', id: ids[0] }])
|
|
|
|
|
await brain.transact([
|
|
|
|
|
{
|
|
|
|
|
op: 'add',
|
|
|
|
|
id: uid('snap-after'),
|
|
|
|
|
type: NounType.Document,
|
|
|
|
|
data: 'added after snapshot',
|
|
|
|
|
vector: vec(99),
|
|
|
|
|
metadata: { generation: 'post' }
|
|
|
|
|
}
|
|
|
|
|
])
|
|
|
|
|
await brain.flush()
|
|
|
|
|
|
|
|
|
|
// The snapshot opens as a self-contained store with the PRE state.
|
|
|
|
|
const loaded = await Brainy.load(snapDir)
|
|
|
|
|
for (const id of ids) {
|
|
|
|
|
const entity = await loaded.get(id)
|
|
|
|
|
expect(entity).toEqual(preState.get(id))
|
|
|
|
|
}
|
|
|
|
|
expect(await loaded.get(uid('snap-after'))).toBeNull()
|
|
|
|
|
const loadedFind = await loaded.find({ type: NounType.Document, limit: 100 })
|
|
|
|
|
expect(loadedFind.length).toBe(10)
|
|
|
|
|
for (const row of loadedFind) {
|
|
|
|
|
expect((row.metadata as { generation: string }).generation).toBe('pre')
|
|
|
|
|
}
|
|
|
|
|
const loadedRelations = await loaded.related({ from: ids[0] })
|
|
|
|
|
expect(loadedRelations.length).toBe(1)
|
|
|
|
|
expect(loadedRelations[0].to).toBe(ids[1])
|
|
|
|
|
await loaded.release()
|
|
|
|
|
|
|
|
|
|
// And asOf(path) is the instance-method route to the same snapshot.
|
|
|
|
|
const viaAsOf = await brain.asOf(snapDir)
|
|
|
|
|
expect(((await viaAsOf.get(ids[3])) as { metadata: { generation: string } }).metadata.generation).toBe('pre')
|
|
|
|
|
await viaAsOf.release()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('proof 4b — an in-memory brain persists to a directory loadable as a real store', async () => {
|
|
|
|
|
const brain = await openMemoryBrain()
|
|
|
|
|
await brain.transact([
|
|
|
|
|
{
|
|
|
|
|
op: 'add',
|
|
|
|
|
id: uid('mem-e1'),
|
|
|
|
|
type: NounType.Document,
|
|
|
|
|
data: 'memory-persisted',
|
|
|
|
|
vector: vec(21),
|
|
|
|
|
metadata: { from: 'memory' }
|
|
|
|
|
}
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
const snapDir = path.join(makeTempDir(), 'memory-snapshot')
|
|
|
|
|
const db = brain.now()
|
|
|
|
|
await db.persist(snapDir)
|
|
|
|
|
await db.release()
|
|
|
|
|
|
|
|
|
|
const loaded = await Brainy.load(snapDir)
|
|
|
|
|
const entity = await loaded.get(uid('mem-e1'))
|
|
|
|
|
expect(entity).not.toBeNull()
|
|
|
|
|
expect((entity?.metadata as { from: string }).from).toBe('memory')
|
|
|
|
|
await loaded.release()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('proof 4c — persist() refuses a view that history has moved past (honest boundary)', async () => {
|
|
|
|
|
const brain = await openMemoryBrain()
|
|
|
|
|
const db = brain.now()
|
|
|
|
|
await brain.transact([
|
|
|
|
|
{
|
|
|
|
|
op: 'add',
|
|
|
|
|
id: uid('stale-persist'),
|
|
|
|
|
type: NounType.Document,
|
|
|
|
|
data: 'x',
|
|
|
|
|
vector: vec(30),
|
|
|
|
|
metadata: {}
|
|
|
|
|
}
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
const snapDir = path.join(makeTempDir(), 'stale-snapshot')
|
feat(8.0): full query surface at historical generations via ephemeral index materialization
Historical Db values (now()/asOf() pins that history has moved past) now
serve the COMPLETE query surface - vector/hybrid search, graph traversal,
cursor pagination, and aggregation - by materializing ephemeral in-memory
indexes over the exact at-generation record set. The historical-query
throw is gone; NotYetSupportedAtHistoricalGenerationError is deleted.
Materializer (Brainy.materializeAtGeneration):
- Copies the at-G record set (live bytes for ids untouched since the pin,
immutable before-images otherwise) into a fresh MemoryStorage; a final
reconciliation pass under the commit mutex makes the copy exact even
when transactions commit mid-build.
- Opens a read-only Brainy over the copy: init rebuilds the metadata and
graph-adjacency indexes from the records; the vector index is built by
inserting every at-G vector (the at-G HNSW graph never existed on disk,
so there is nothing to restore). Host embedder and aggregate definitions
are shared - no second model load, aggregates backfill at-G values.
- Cost is the documented contract: O(n at G) time and memory, ONCE per Db
(handle cached; freed by release(), with a FinalizationRegistry backstop
that also closes leaked readers). A native VersionedIndexProvider serves
the same reads from retained segments with no rebuild.
Db routing (src/db/db.ts): metadata-level find()/related() keep the free
record path; index-only dimensions (query/vector/near/connected/cursor/
aggregate/includeRelations/non-metadata modes) route to the cached
materialization; unsupported where-operators on the record path re-route
there too instead of erroring. Speculative with() overlays keep the one
honest boundary - SpeculativeOverlayError (overlay entities carry no
embeddings, so index reads over them would be silently incomplete);
metadata find()/get()/filter related() work on overlays.
UpdateParams.vector contract now honored: an explicit pre-computed vector
applies directly (with dimension validation) in update() and transact
update ops, re-indexing HNSW - previously it was silently ignored unless
data also changed.
GraphAdjacencyIndex: adjacency now derives from the two verb-id LSM trees
filtered through the live-verb tombstone set (entity->entity edge trees
deleted - they carried no verb ids, so removeVerb could never tombstone
them and traversal served stale neighbors forever). Neighbor reads batch-
load live verbs via the unified cache; addVerb seeds the cache.
Proofs (tests/integration/db-mvcc.test.ts, 24 green): historical vector
search finds old vector placement including since-deleted entities;
historical graph traversal walks the old wiring after a rewire; historical
aggregation computes at-G group values; asOf() pins get the same surface;
the materialization builds once per Db and release() closes the ephemeral
reader (it refuses reads afterwards); overlays throw the documented error.
ADR-001 updated to the no-throws historical model.
2026-06-11 08:12:11 -07:00
|
|
|
await expect(db.persist(snapDir)).rejects.toThrow(GenerationConflictError)
|
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 db.release()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
// 5. Compaction safety
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
it('proof 5 — compaction never breaks pinned reads; after release the records are reclaimed', async () => {
|
|
|
|
|
const { brain } = await openFsBrain()
|
|
|
|
|
const storage = brain.storageAdapter
|
|
|
|
|
|
|
|
|
|
// Every transact() returns a PINNED Db — release the ones this test
|
|
|
|
|
// does not keep, so compaction eligibility is determined solely by `db`.
|
|
|
|
|
await (
|
|
|
|
|
await brain.transact([
|
|
|
|
|
{
|
|
|
|
|
op: 'add',
|
|
|
|
|
id: uid('compact-e'),
|
|
|
|
|
type: NounType.Document,
|
|
|
|
|
data: 'v1',
|
|
|
|
|
vector: vec(40),
|
|
|
|
|
metadata: { v: 1 }
|
|
|
|
|
}
|
|
|
|
|
])
|
|
|
|
|
).release()
|
|
|
|
|
await (
|
|
|
|
|
await brain.transact([{ op: 'update', id: uid('compact-e'), metadata: { v: 2 } }])
|
|
|
|
|
).release()
|
|
|
|
|
const db = brain.now() // pinned at v2
|
|
|
|
|
const pinnedEntity = await db.get(uid('compact-e'))
|
|
|
|
|
await (
|
|
|
|
|
await brain.transact([{ op: 'update', id: uid('compact-e'), metadata: { v: 3 } }])
|
|
|
|
|
).release()
|
|
|
|
|
await (
|
|
|
|
|
await brain.transact([{ op: 'update', id: uid('compact-e'), metadata: { v: 4 } }])
|
|
|
|
|
).release()
|
|
|
|
|
|
|
|
|
|
const recordsBefore = (await storage.listRawObjects('_generations')).length
|
|
|
|
|
expect(recordsBefore).toBeGreaterThan(0)
|
|
|
|
|
|
|
|
|
|
// Compact while pinned: record-sets above the pin survive, pinned reads stay correct.
|
|
|
|
|
const first = await brain.compactHistory()
|
|
|
|
|
expect(await db.get(uid('compact-e'))).toEqual(pinnedEntity)
|
|
|
|
|
expect(((await db.get(uid('compact-e')))?.metadata as { v: number }).v).toBe(2)
|
|
|
|
|
|
|
|
|
|
// After release, everything is reclaimable.
|
|
|
|
|
await db.release()
|
|
|
|
|
const second = await brain.compactHistory()
|
|
|
|
|
expect(first.removedGenerations + second.removedGenerations).toBeGreaterThan(0)
|
|
|
|
|
|
|
|
|
|
const recordsAfter = (await storage.listRawObjects('_generations')).length
|
|
|
|
|
expect(recordsAfter).toBeLessThan(recordsBefore)
|
|
|
|
|
expect(recordsAfter).toBe(0)
|
|
|
|
|
|
|
|
|
|
// Compacted generations are honestly unreachable.
|
|
|
|
|
await expect(brain.asOf(1)).rejects.toThrow(GenerationCompactedError)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
// 6. with() — speculative overlays
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
it('proof 6 — with() overlays are visible only on the overlay Db; brain and base Db untouched', async () => {
|
|
|
|
|
const brain = await openMemoryBrain()
|
|
|
|
|
await brain.transact([
|
|
|
|
|
{
|
|
|
|
|
op: 'add',
|
|
|
|
|
id: uid('with-a'),
|
|
|
|
|
type: NounType.Person,
|
|
|
|
|
data: 'Ada',
|
|
|
|
|
vector: vec(50),
|
|
|
|
|
metadata: { v: 1 }
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
op: 'add',
|
|
|
|
|
id: uid('with-b'),
|
|
|
|
|
type: NounType.Person,
|
|
|
|
|
data: 'Grace',
|
|
|
|
|
vector: vec(51),
|
|
|
|
|
metadata: { v: 1 }
|
|
|
|
|
}
|
|
|
|
|
])
|
|
|
|
|
const g0 = brain.generation()
|
|
|
|
|
const db = brain.now()
|
|
|
|
|
|
|
|
|
|
const spec = await db.with([
|
|
|
|
|
{ op: 'update', id: uid('with-a'), metadata: { v: 2 } },
|
|
|
|
|
{
|
|
|
|
|
op: 'add',
|
|
|
|
|
id: uid('with-new'),
|
|
|
|
|
type: NounType.Project,
|
|
|
|
|
data: 'Apollo',
|
|
|
|
|
metadata: { speculative: true }
|
|
|
|
|
},
|
|
|
|
|
{ op: 'remove', id: uid('with-b') },
|
|
|
|
|
{ op: 'relate', from: uid('with-a'), to: uid('with-new'), type: VerbType.WorksOn }
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
// The overlay view sees everything…
|
|
|
|
|
expect(spec.speculative).toBe(true)
|
|
|
|
|
expect(((await spec.get(uid('with-a')))?.metadata as { v: number }).v).toBe(2)
|
|
|
|
|
expect(await spec.get(uid('with-new'))).not.toBeNull()
|
|
|
|
|
expect(await spec.get(uid('with-b'))).toBeNull()
|
|
|
|
|
const specEdges = await spec.related({ from: uid('with-a') })
|
|
|
|
|
expect(specEdges.length).toBe(1)
|
|
|
|
|
expect(specEdges[0].to).toBe(uid('with-new'))
|
|
|
|
|
const specFind = await spec.find({ type: NounType.Project, limit: 10 })
|
|
|
|
|
expect(specFind.length).toBe(1)
|
|
|
|
|
|
|
|
|
|
// …the base Db sees none of it…
|
|
|
|
|
expect(db.speculative).toBe(false)
|
|
|
|
|
expect(((await db.get(uid('with-a')))?.metadata as { v: number }).v).toBe(1)
|
|
|
|
|
expect(await db.get(uid('with-new'))).toBeNull()
|
|
|
|
|
expect(await db.get(uid('with-b'))).not.toBeNull()
|
|
|
|
|
|
|
|
|
|
// …and the brain (disk, indexes, generation counter) is untouched.
|
|
|
|
|
expect(brain.generation()).toBe(g0)
|
|
|
|
|
expect(((await brain.get(uid('with-a')))?.metadata as { v: number }).v).toBe(1)
|
|
|
|
|
expect(await brain.get(uid('with-new'))).toBeNull()
|
2026-06-11 14:51:00 -07:00
|
|
|
expect((await brain.related({ from: uid('with-a') })).length).toBe(0)
|
feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)
One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.
Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
transact() commit and once per single-operation write (storage hook), so
brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
(the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
and forces an index rebuild; refcounted pins gate compactHistory(), which
records a horizon (asOf below it throws GenerationCompactedError).
Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
(GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
{confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
generation; index-accelerated queries at historical generations throw
NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
plugin.ts: feature-detected, balanced pin/release in lockstep with Db
lifecycle, post-commit applier + replay-gap model documented.
Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.
Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
2026-06-10 14:14:07 -07:00
|
|
|
|
|
|
|
|
await spec.release()
|
|
|
|
|
await db.release()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
// 7. Generation monotonicity across close/reopen
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
it('proof 7 — the generation counter never moves backwards across close/reopen', async () => {
|
|
|
|
|
const dir = makeTempDir()
|
|
|
|
|
const { brain: first } = await openFsBrain(dir)
|
|
|
|
|
|
|
|
|
|
await first.transact([
|
|
|
|
|
{
|
|
|
|
|
op: 'add',
|
|
|
|
|
id: uid('mono-e'),
|
|
|
|
|
type: NounType.Document,
|
|
|
|
|
data: 'v1',
|
|
|
|
|
vector: vec(60),
|
|
|
|
|
metadata: { v: 1 }
|
|
|
|
|
}
|
|
|
|
|
])
|
|
|
|
|
await first.transact([{ op: 'update', id: uid('mono-e'), metadata: { v: 2 } }])
|
|
|
|
|
// Single-operation write — bumps the counter outside transact.
|
|
|
|
|
await first.update({ id: uid('mono-e'), metadata: { v: 3 } })
|
|
|
|
|
const beforeClose = first.generation()
|
|
|
|
|
expect(beforeClose).toBeGreaterThanOrEqual(3)
|
|
|
|
|
await first.close()
|
|
|
|
|
|
|
|
|
|
const { brain: second } = await openFsBrain(dir)
|
|
|
|
|
expect(second.generation()).toBeGreaterThanOrEqual(beforeClose)
|
|
|
|
|
|
|
|
|
|
const db = await second.transact([{ op: 'update', id: uid('mono-e'), metadata: { v: 4 } }])
|
|
|
|
|
expect(db.generation).toBeGreaterThan(beforeClose)
|
|
|
|
|
await db.release()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
// 8. Crash consistency
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
it('proof 8 — a crash before the manifest rename recovers to the exact pre-transaction state', async () => {
|
|
|
|
|
const dir = makeTempDir()
|
|
|
|
|
const { brain: first } = await openFsBrain(dir)
|
|
|
|
|
|
|
|
|
|
await first.transact([
|
|
|
|
|
{
|
|
|
|
|
op: 'add',
|
|
|
|
|
id: uid('crash-e'),
|
|
|
|
|
type: NounType.Document,
|
|
|
|
|
data: 'stable',
|
|
|
|
|
vector: vec(70),
|
|
|
|
|
metadata: { v: 1 }
|
|
|
|
|
}
|
|
|
|
|
])
|
|
|
|
|
const committedGen = first.generation()
|
|
|
|
|
|
|
|
|
|
// Simulated crash at the worst point of the REAL commit protocol: every
|
|
|
|
|
// record is staged and the batch has executed, but the atomic manifest
|
|
|
|
|
// rename — the commit point — never happens. The throwing injector skips
|
|
|
|
|
// the abort cleanup exactly as a dead process would.
|
|
|
|
|
generationStoreOf(first).setCommitFaultInjector((phase) => {
|
|
|
|
|
if (phase === 'before-manifest-rename') {
|
|
|
|
|
throw new Error('simulated process crash')
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
await expect(
|
|
|
|
|
first.transact([
|
|
|
|
|
{ op: 'update', id: uid('crash-e'), metadata: { v: 2 } },
|
|
|
|
|
{
|
|
|
|
|
op: 'add',
|
|
|
|
|
id: uid('crash-new'),
|
|
|
|
|
type: NounType.Document,
|
|
|
|
|
data: 'uncommitted',
|
|
|
|
|
vector: vec(71),
|
|
|
|
|
metadata: {}
|
|
|
|
|
}
|
|
|
|
|
])
|
|
|
|
|
).rejects.toThrow('simulated process crash')
|
|
|
|
|
generationStoreOf(first).setCommitFaultInjector(undefined)
|
|
|
|
|
|
|
|
|
|
// The in-process state is dirty (batch executed) — close flushes it all,
|
|
|
|
|
// the realistic worst case for the recovery path.
|
|
|
|
|
await first.close()
|
|
|
|
|
|
|
|
|
|
// Reopen: recovery rolls the uncommitted generation back and rebuilds
|
|
|
|
|
// the indexes from the repaired records.
|
|
|
|
|
const { brain: second } = await openFsBrain(dir)
|
|
|
|
|
const recovered = await second.get(uid('crash-e'))
|
|
|
|
|
expect((recovered?.metadata as { v: number }).v).toBe(1)
|
|
|
|
|
expect(await second.get(uid('crash-new'))).toBeNull()
|
|
|
|
|
const found = await second.find({ type: NounType.Document, limit: 10 })
|
|
|
|
|
expect(found.length).toBe(1)
|
|
|
|
|
expect((found[0].metadata as { v: number }).v).toBe(1)
|
|
|
|
|
|
|
|
|
|
// The crashed generation number is never reissued.
|
|
|
|
|
expect(second.generation()).toBeGreaterThanOrEqual(committedGen + 1)
|
|
|
|
|
const db = await second.transact([{ op: 'update', id: uid('crash-e'), metadata: { v: 5 } }])
|
|
|
|
|
expect(db.generation).toBeGreaterThan(committedGen + 1)
|
|
|
|
|
await db.release()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
// 9. Versioned provider wiring
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
it('proof 9 — a VersionedIndexProvider receives balanced pin/release as Db values live and die', async () => {
|
|
|
|
|
const calls = {
|
|
|
|
|
pins: [] as bigint[],
|
|
|
|
|
releases: [] as bigint[],
|
|
|
|
|
visibility: [] as bigint[]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } })
|
|
|
|
|
brain.use({
|
|
|
|
|
name: 'test-versioned-graph-index',
|
|
|
|
|
activate: async (ctx) => {
|
|
|
|
|
ctx.registerProvider('graphIndex', (storage: StorageAdapter) =>
|
|
|
|
|
Object.assign(new GraphAdjacencyIndex(storage), {
|
|
|
|
|
generation: () => 0n,
|
|
|
|
|
isGenerationVisible: (g: bigint) => {
|
|
|
|
|
calls.visibility.push(g)
|
|
|
|
|
return true
|
|
|
|
|
},
|
|
|
|
|
pin: (g: bigint) => {
|
|
|
|
|
calls.pins.push(g)
|
|
|
|
|
},
|
|
|
|
|
release: (g: bigint) => {
|
|
|
|
|
calls.releases.push(g)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
await brain.init()
|
|
|
|
|
brains.push(brain)
|
|
|
|
|
|
|
|
|
|
const base = brain.generation()
|
|
|
|
|
const db1 = brain.now()
|
|
|
|
|
expect(calls.pins).toEqual([BigInt(base)])
|
|
|
|
|
expect(calls.visibility).toEqual([BigInt(base)]) // consulted at pin time
|
|
|
|
|
|
|
|
|
|
const db2 = await brain.transact([
|
|
|
|
|
{
|
|
|
|
|
op: 'add',
|
|
|
|
|
id: uid('vp-e'),
|
|
|
|
|
type: NounType.Document,
|
|
|
|
|
data: 'pinned',
|
|
|
|
|
vector: vec(80),
|
|
|
|
|
metadata: {}
|
|
|
|
|
}
|
|
|
|
|
])
|
|
|
|
|
expect(calls.pins).toEqual([BigInt(base), BigInt(base + 1)])
|
|
|
|
|
|
|
|
|
|
const db3 = await brain.asOf(base)
|
|
|
|
|
expect(calls.pins).toEqual([BigInt(base), BigInt(base + 1), BigInt(base)])
|
|
|
|
|
|
|
|
|
|
// A with() overlay takes its own pin on the same generation.
|
|
|
|
|
const spec = await db2.with([{ op: 'update', id: uid('vp-e'), metadata: { spec: true } }])
|
|
|
|
|
expect(calls.pins.length).toBe(4)
|
|
|
|
|
|
|
|
|
|
await db1.release()
|
|
|
|
|
await db2.release()
|
|
|
|
|
await db3.release()
|
|
|
|
|
await spec.release()
|
|
|
|
|
await spec.release() // idempotent — must NOT double-release the pin
|
|
|
|
|
|
|
|
|
|
expect(calls.releases.length).toBe(calls.pins.length)
|
|
|
|
|
expect([...calls.releases].sort()).toEqual([...calls.pins].sort())
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
// Supporting guarantees: asOf, since, receipts, tx-log, honest errors
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
it('asOf(generation) and asOf(Date) resolve historical state; future/negative are rejected', async () => {
|
|
|
|
|
const brain = await openMemoryBrain()
|
|
|
|
|
|
|
|
|
|
const tx1 = await brain.transact(
|
|
|
|
|
[
|
|
|
|
|
{
|
|
|
|
|
op: 'add',
|
|
|
|
|
id: uid('asof-e'),
|
|
|
|
|
type: NounType.Document,
|
|
|
|
|
data: 'v1',
|
|
|
|
|
vector: vec(90),
|
|
|
|
|
metadata: { v: 1 }
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
{ meta: { reason: 'first commit' } }
|
|
|
|
|
)
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 5))
|
|
|
|
|
const betweenCommits = new Date()
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 5))
|
|
|
|
|
await brain.transact([{ op: 'update', id: uid('asof-e'), metadata: { v: 2 } }])
|
|
|
|
|
|
|
|
|
|
// By generation.
|
|
|
|
|
const atFirst = await brain.asOf(tx1.generation)
|
|
|
|
|
expect(((await atFirst.get(uid('asof-e')))?.metadata as { v: number }).v).toBe(1)
|
|
|
|
|
expect(atFirst.timestamp).toBe(tx1.timestamp)
|
|
|
|
|
await atFirst.release()
|
|
|
|
|
|
|
|
|
|
// By timestamp: resolves to the newest commit at-or-before the instant.
|
|
|
|
|
const atInstant = await brain.asOf(betweenCommits)
|
|
|
|
|
expect(atInstant.generation).toBe(tx1.generation)
|
|
|
|
|
expect(((await atInstant.get(uid('asof-e')))?.metadata as { v: number }).v).toBe(1)
|
|
|
|
|
await atInstant.release()
|
|
|
|
|
|
|
|
|
|
// Honest range errors.
|
|
|
|
|
await expect(brain.asOf(brain.generation() + 100)).rejects.toThrow(RangeError)
|
|
|
|
|
await expect(brain.asOf(-1)).rejects.toThrow(RangeError)
|
|
|
|
|
await expect(brain.asOf('/no/such/snapshot/dir')).rejects.toThrow('not a snapshot directory')
|
|
|
|
|
|
|
|
|
|
// The transact meta was reified into the tx-log.
|
|
|
|
|
const lines = await brain.storageAdapter.readTxLogLines()
|
|
|
|
|
const entries = lines.map((line) => JSON.parse(line))
|
|
|
|
|
const first = entries.find((entry) => entry.generation === tx1.generation)
|
|
|
|
|
expect(first?.meta).toEqual({ reason: 'first commit' })
|
|
|
|
|
|
|
|
|
|
await tx1.release()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('receipts resolve ids in input order, including intra-batch references and relate dedupe', async () => {
|
|
|
|
|
const brain = await openMemoryBrain()
|
|
|
|
|
|
|
|
|
|
const db = await brain.transact([
|
|
|
|
|
{ op: 'add', id: uid('rcpt-aa'), type: NounType.Person, data: 'a', vector: vec(91), metadata: {} },
|
|
|
|
|
{ op: 'add', id: uid('rcpt-bb'), type: NounType.Person, data: 'b', vector: vec(92), metadata: {} },
|
|
|
|
|
{ op: 'relate', from: uid('rcpt-aa'), to: uid('rcpt-bb'), type: VerbType.Knows },
|
|
|
|
|
{ op: 'update', id: uid('rcpt-bb'), metadata: { seen: true } }
|
|
|
|
|
])
|
|
|
|
|
expect(db.receipt).toBeDefined()
|
|
|
|
|
expect(db.receipt!.generation).toBe(db.generation)
|
|
|
|
|
expect(db.receipt!.ids.length).toBe(4)
|
|
|
|
|
expect(db.receipt!.ids[0]).toBe(uid('rcpt-aa'))
|
|
|
|
|
expect(db.receipt!.ids[1]).toBe(uid('rcpt-bb'))
|
|
|
|
|
expect(db.receipt!.ids[3]).toBe(uid('rcpt-bb'))
|
|
|
|
|
const relationId = db.receipt!.ids[2]
|
2026-06-11 14:51:00 -07:00
|
|
|
expect((await brain.related({ from: uid('rcpt-aa') }))[0].id).toBe(relationId)
|
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
|
|
|
|
|
|
|
|
// Duplicate relate (same from/to/type) dedupes to the SAME id — both
|
|
|
|
|
// within a batch and against committed state, mirroring relate().
|
|
|
|
|
const dup = await brain.transact([
|
|
|
|
|
{ op: 'relate', from: uid('rcpt-aa'), to: uid('rcpt-bb'), type: VerbType.Knows }
|
|
|
|
|
])
|
|
|
|
|
expect(dup.receipt!.ids[0]).toBe(relationId)
|
2026-06-11 14:51:00 -07:00
|
|
|
expect((await brain.related({ from: uid('rcpt-aa') })).length).toBe(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
|
|
|
|
|
|
|
|
await db.release()
|
|
|
|
|
await dup.release()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('since() reports the ids changed between two pinned views', async () => {
|
|
|
|
|
const brain = await openMemoryBrain()
|
|
|
|
|
await brain.transact([
|
|
|
|
|
{ op: 'add', id: uid('since-a'), type: NounType.Document, data: 'a', vector: vec(93), metadata: {} }
|
|
|
|
|
])
|
|
|
|
|
const before = brain.now()
|
|
|
|
|
|
|
|
|
|
await brain.transact([
|
|
|
|
|
{ op: 'add', id: uid('since-b'), type: NounType.Document, data: 'b', vector: vec(94), metadata: {} },
|
|
|
|
|
{ op: 'relate', from: uid('since-a'), to: uid('since-b'), type: VerbType.References }
|
|
|
|
|
])
|
|
|
|
|
await brain.transact([{ op: 'update', id: uid('since-a'), metadata: { touched: true } }])
|
|
|
|
|
const after = brain.now()
|
|
|
|
|
|
|
|
|
|
const changed = await after.since(before)
|
|
|
|
|
expect([...changed.nouns].sort()).toEqual([uid('since-a'), uid('since-b')].sort())
|
|
|
|
|
expect(changed.verbs.length).toBe(1)
|
|
|
|
|
expect(changed.fromGeneration).toBe(before.generation)
|
|
|
|
|
expect(changed.toGeneration).toBe(after.generation)
|
|
|
|
|
|
|
|
|
|
// Direction is enforced.
|
|
|
|
|
await expect(before.since(after)).rejects.toThrow(RangeError)
|
|
|
|
|
|
|
|
|
|
await before.release()
|
|
|
|
|
await after.release()
|
|
|
|
|
})
|
|
|
|
|
|
feat(8.0): full query surface at historical generations via ephemeral index materialization
Historical Db values (now()/asOf() pins that history has moved past) now
serve the COMPLETE query surface - vector/hybrid search, graph traversal,
cursor pagination, and aggregation - by materializing ephemeral in-memory
indexes over the exact at-generation record set. The historical-query
throw is gone; NotYetSupportedAtHistoricalGenerationError is deleted.
Materializer (Brainy.materializeAtGeneration):
- Copies the at-G record set (live bytes for ids untouched since the pin,
immutable before-images otherwise) into a fresh MemoryStorage; a final
reconciliation pass under the commit mutex makes the copy exact even
when transactions commit mid-build.
- Opens a read-only Brainy over the copy: init rebuilds the metadata and
graph-adjacency indexes from the records; the vector index is built by
inserting every at-G vector (the at-G HNSW graph never existed on disk,
so there is nothing to restore). Host embedder and aggregate definitions
are shared - no second model load, aggregates backfill at-G values.
- Cost is the documented contract: O(n at G) time and memory, ONCE per Db
(handle cached; freed by release(), with a FinalizationRegistry backstop
that also closes leaked readers). A native VersionedIndexProvider serves
the same reads from retained segments with no rebuild.
Db routing (src/db/db.ts): metadata-level find()/related() keep the free
record path; index-only dimensions (query/vector/near/connected/cursor/
aggregate/includeRelations/non-metadata modes) route to the cached
materialization; unsupported where-operators on the record path re-route
there too instead of erroring. Speculative with() overlays keep the one
honest boundary - SpeculativeOverlayError (overlay entities carry no
embeddings, so index reads over them would be silently incomplete);
metadata find()/get()/filter related() work on overlays.
UpdateParams.vector contract now honored: an explicit pre-computed vector
applies directly (with dimension validation) in update() and transact
update ops, re-indexing HNSW - previously it was silently ignored unless
data also changed.
GraphAdjacencyIndex: adjacency now derives from the two verb-id LSM trees
filtered through the live-verb tombstone set (entity->entity edge trees
deleted - they carried no verb ids, so removeVerb could never tombstone
them and traversal served stale neighbors forever). Neighbor reads batch-
load live verbs via the unified cache; addVerb seeds the cache.
Proofs (tests/integration/db-mvcc.test.ts, 24 green): historical vector
search finds old vector placement including since-deleted entities;
historical graph traversal walks the old wiring after a rewire; historical
aggregation computes at-G group values; asOf() pins get the same surface;
the materialization builds once per Db and release() closes the ephemeral
reader (it refuses reads afterwards); overlays throw the documented error.
ADR-001 updated to the no-throws historical model.
2026-06-11 08:12:11 -07:00
|
|
|
it('historical vector search is served at the pinned generation via index materialization', async () => {
|
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 brain = await openMemoryBrain()
|
feat(8.0): full query surface at historical generations via ephemeral index materialization
Historical Db values (now()/asOf() pins that history has moved past) now
serve the COMPLETE query surface - vector/hybrid search, graph traversal,
cursor pagination, and aggregation - by materializing ephemeral in-memory
indexes over the exact at-generation record set. The historical-query
throw is gone; NotYetSupportedAtHistoricalGenerationError is deleted.
Materializer (Brainy.materializeAtGeneration):
- Copies the at-G record set (live bytes for ids untouched since the pin,
immutable before-images otherwise) into a fresh MemoryStorage; a final
reconciliation pass under the commit mutex makes the copy exact even
when transactions commit mid-build.
- Opens a read-only Brainy over the copy: init rebuilds the metadata and
graph-adjacency indexes from the records; the vector index is built by
inserting every at-G vector (the at-G HNSW graph never existed on disk,
so there is nothing to restore). Host embedder and aggregate definitions
are shared - no second model load, aggregates backfill at-G values.
- Cost is the documented contract: O(n at G) time and memory, ONCE per Db
(handle cached; freed by release(), with a FinalizationRegistry backstop
that also closes leaked readers). A native VersionedIndexProvider serves
the same reads from retained segments with no rebuild.
Db routing (src/db/db.ts): metadata-level find()/related() keep the free
record path; index-only dimensions (query/vector/near/connected/cursor/
aggregate/includeRelations/non-metadata modes) route to the cached
materialization; unsupported where-operators on the record path re-route
there too instead of erroring. Speculative with() overlays keep the one
honest boundary - SpeculativeOverlayError (overlay entities carry no
embeddings, so index reads over them would be silently incomplete);
metadata find()/get()/filter related() work on overlays.
UpdateParams.vector contract now honored: an explicit pre-computed vector
applies directly (with dimension validation) in update() and transact
update ops, re-indexing HNSW - previously it was silently ignored unless
data also changed.
GraphAdjacencyIndex: adjacency now derives from the two verb-id LSM trees
filtered through the live-verb tombstone set (entity->entity edge trees
deleted - they carried no verb ids, so removeVerb could never tombstone
them and traversal served stale neighbors forever). Neighbor reads batch-
load live verbs via the unified cache; addVerb seeds the cache.
Proofs (tests/integration/db-mvcc.test.ts, 24 green): historical vector
search finds old vector placement including since-deleted entities;
historical graph traversal walks the old wiring after a rewire; historical
aggregation computes at-G group values; asOf() pins get the same surface;
the materialization builds once per Db and release() closes the ephemeral
reader (it refuses reads afterwards); overlays throw the documented error.
ADR-001 updated to the no-throws historical model.
2026-06-11 08:12:11 -07:00
|
|
|
|
|
|
|
|
// Two one-hot vectors: orthogonal under cosine, so nearest-neighbor
|
|
|
|
|
// results are fully deterministic.
|
|
|
|
|
const axisA = Array.from({ length: 384 }, (_, i) => (i === 0 ? 1 : 0))
|
|
|
|
|
const axisB = Array.from({ length: 384 }, (_, i) => (i === 1 ? 1 : 0))
|
|
|
|
|
const axisC = Array.from({ length: 384 }, (_, i) => (i === 2 ? 1 : 0))
|
|
|
|
|
|
feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)
One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.
Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
transact() commit and once per single-operation write (storage hook), so
brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
(the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
and forces an index rebuild; refcounted pins gate compactHistory(), which
records a horizon (asOf below it throws GenerationCompactedError).
Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
(GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
{confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
generation; index-accelerated queries at historical generations throw
NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
plugin.ts: feature-detected, balanced pin/release in lockstep with Db
lifecycle, post-commit applier + replay-gap model documented.
Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.
Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
2026-06-10 14:14:07 -07:00
|
|
|
await brain.transact([
|
feat(8.0): full query surface at historical generations via ephemeral index materialization
Historical Db values (now()/asOf() pins that history has moved past) now
serve the COMPLETE query surface - vector/hybrid search, graph traversal,
cursor pagination, and aggregation - by materializing ephemeral in-memory
indexes over the exact at-generation record set. The historical-query
throw is gone; NotYetSupportedAtHistoricalGenerationError is deleted.
Materializer (Brainy.materializeAtGeneration):
- Copies the at-G record set (live bytes for ids untouched since the pin,
immutable before-images otherwise) into a fresh MemoryStorage; a final
reconciliation pass under the commit mutex makes the copy exact even
when transactions commit mid-build.
- Opens a read-only Brainy over the copy: init rebuilds the metadata and
graph-adjacency indexes from the records; the vector index is built by
inserting every at-G vector (the at-G HNSW graph never existed on disk,
so there is nothing to restore). Host embedder and aggregate definitions
are shared - no second model load, aggregates backfill at-G values.
- Cost is the documented contract: O(n at G) time and memory, ONCE per Db
(handle cached; freed by release(), with a FinalizationRegistry backstop
that also closes leaked readers). A native VersionedIndexProvider serves
the same reads from retained segments with no rebuild.
Db routing (src/db/db.ts): metadata-level find()/related() keep the free
record path; index-only dimensions (query/vector/near/connected/cursor/
aggregate/includeRelations/non-metadata modes) route to the cached
materialization; unsupported where-operators on the record path re-route
there too instead of erroring. Speculative with() overlays keep the one
honest boundary - SpeculativeOverlayError (overlay entities carry no
embeddings, so index reads over them would be silently incomplete);
metadata find()/get()/filter related() work on overlays.
UpdateParams.vector contract now honored: an explicit pre-computed vector
applies directly (with dimension validation) in update() and transact
update ops, re-indexing HNSW - previously it was silently ignored unless
data also changed.
GraphAdjacencyIndex: adjacency now derives from the two verb-id LSM trees
filtered through the live-verb tombstone set (entity->entity edge trees
deleted - they carried no verb ids, so removeVerb could never tombstone
them and traversal served stale neighbors forever). Neighbor reads batch-
load live verbs via the unified cache; addVerb seeds the cache.
Proofs (tests/integration/db-mvcc.test.ts, 24 green): historical vector
search finds old vector placement including since-deleted entities;
historical graph traversal walks the old wiring after a rewire; historical
aggregation computes at-G group values; asOf() pins get the same surface;
the materialization builds once per Db and release() closes the ephemeral
reader (it refuses reads afterwards); overlays throw the documented error.
ADR-001 updated to the no-throws historical model.
2026-06-11 08:12:11 -07:00
|
|
|
{ op: 'add', id: uid('mat-a'), type: NounType.Document, data: 'a', vector: axisA, metadata: { who: 'a' } },
|
|
|
|
|
{ op: 'add', id: uid('mat-b'), type: NounType.Document, data: 'b', vector: axisB, metadata: { who: 'b' } },
|
|
|
|
|
{ op: 'add', id: uid('mat-del'), type: NounType.Document, data: 'del', vector: axisC, metadata: { who: 'del' } }
|
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 db = brain.now()
|
|
|
|
|
|
feat(8.0): full query surface at historical generations via ephemeral index materialization
Historical Db values (now()/asOf() pins that history has moved past) now
serve the COMPLETE query surface - vector/hybrid search, graph traversal,
cursor pagination, and aggregation - by materializing ephemeral in-memory
indexes over the exact at-generation record set. The historical-query
throw is gone; NotYetSupportedAtHistoricalGenerationError is deleted.
Materializer (Brainy.materializeAtGeneration):
- Copies the at-G record set (live bytes for ids untouched since the pin,
immutable before-images otherwise) into a fresh MemoryStorage; a final
reconciliation pass under the commit mutex makes the copy exact even
when transactions commit mid-build.
- Opens a read-only Brainy over the copy: init rebuilds the metadata and
graph-adjacency indexes from the records; the vector index is built by
inserting every at-G vector (the at-G HNSW graph never existed on disk,
so there is nothing to restore). Host embedder and aggregate definitions
are shared - no second model load, aggregates backfill at-G values.
- Cost is the documented contract: O(n at G) time and memory, ONCE per Db
(handle cached; freed by release(), with a FinalizationRegistry backstop
that also closes leaked readers). A native VersionedIndexProvider serves
the same reads from retained segments with no rebuild.
Db routing (src/db/db.ts): metadata-level find()/related() keep the free
record path; index-only dimensions (query/vector/near/connected/cursor/
aggregate/includeRelations/non-metadata modes) route to the cached
materialization; unsupported where-operators on the record path re-route
there too instead of erroring. Speculative with() overlays keep the one
honest boundary - SpeculativeOverlayError (overlay entities carry no
embeddings, so index reads over them would be silently incomplete);
metadata find()/get()/filter related() work on overlays.
UpdateParams.vector contract now honored: an explicit pre-computed vector
applies directly (with dimension validation) in update() and transact
update ops, re-indexing HNSW - previously it was silently ignored unless
data also changed.
GraphAdjacencyIndex: adjacency now derives from the two verb-id LSM trees
filtered through the live-verb tombstone set (entity->entity edge trees
deleted - they carried no verb ids, so removeVerb could never tombstone
them and traversal served stale neighbors forever). Neighbor reads batch-
load live verbs via the unified cache; addVerb seeds the cache.
Proofs (tests/integration/db-mvcc.test.ts, 24 green): historical vector
search finds old vector placement including since-deleted entities;
historical graph traversal walks the old wiring after a rewire; historical
aggregation computes at-G group values; asOf() pins get the same surface;
the materialization builds once per Db and release() closes the ephemeral
reader (it refuses reads afterwards); overlays throw the documented error.
ADR-001 updated to the no-throws historical model.
2026-06-11 08:12:11 -07:00
|
|
|
// Mutate distinctively after the pin: swap a/b's vectors, delete 'del'.
|
|
|
|
|
await brain.transact([
|
|
|
|
|
{ op: 'update', id: uid('mat-a'), vector: axisB },
|
|
|
|
|
{ op: 'update', id: uid('mat-b'), vector: axisA },
|
|
|
|
|
{ op: 'remove', id: uid('mat-del') }
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
// Live index reflects the swap…
|
|
|
|
|
const liveNearA = await brain.find({ vector: axisA, limit: 1 })
|
|
|
|
|
expect(liveNearA[0].id).toBe(uid('mat-b'))
|
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): full query surface at historical generations via ephemeral index materialization
Historical Db values (now()/asOf() pins that history has moved past) now
serve the COMPLETE query surface - vector/hybrid search, graph traversal,
cursor pagination, and aggregation - by materializing ephemeral in-memory
indexes over the exact at-generation record set. The historical-query
throw is gone; NotYetSupportedAtHistoricalGenerationError is deleted.
Materializer (Brainy.materializeAtGeneration):
- Copies the at-G record set (live bytes for ids untouched since the pin,
immutable before-images otherwise) into a fresh MemoryStorage; a final
reconciliation pass under the commit mutex makes the copy exact even
when transactions commit mid-build.
- Opens a read-only Brainy over the copy: init rebuilds the metadata and
graph-adjacency indexes from the records; the vector index is built by
inserting every at-G vector (the at-G HNSW graph never existed on disk,
so there is nothing to restore). Host embedder and aggregate definitions
are shared - no second model load, aggregates backfill at-G values.
- Cost is the documented contract: O(n at G) time and memory, ONCE per Db
(handle cached; freed by release(), with a FinalizationRegistry backstop
that also closes leaked readers). A native VersionedIndexProvider serves
the same reads from retained segments with no rebuild.
Db routing (src/db/db.ts): metadata-level find()/related() keep the free
record path; index-only dimensions (query/vector/near/connected/cursor/
aggregate/includeRelations/non-metadata modes) route to the cached
materialization; unsupported where-operators on the record path re-route
there too instead of erroring. Speculative with() overlays keep the one
honest boundary - SpeculativeOverlayError (overlay entities carry no
embeddings, so index reads over them would be silently incomplete);
metadata find()/get()/filter related() work on overlays.
UpdateParams.vector contract now honored: an explicit pre-computed vector
applies directly (with dimension validation) in update() and transact
update ops, re-indexing HNSW - previously it was silently ignored unless
data also changed.
GraphAdjacencyIndex: adjacency now derives from the two verb-id LSM trees
filtered through the live-verb tombstone set (entity->entity edge trees
deleted - they carried no verb ids, so removeVerb could never tombstone
them and traversal served stale neighbors forever). Neighbor reads batch-
load live verbs via the unified cache; addVerb seeds the cache.
Proofs (tests/integration/db-mvcc.test.ts, 24 green): historical vector
search finds old vector placement including since-deleted entities;
historical graph traversal walks the old wiring after a rewire; historical
aggregation computes at-G group values; asOf() pins get the same surface;
the materialization builds once per Db and release() closes the ephemeral
reader (it refuses reads afterwards); overlays throw the documented error.
ADR-001 updated to the no-throws historical model.
2026-06-11 08:12:11 -07:00
|
|
|
// …the pinned view finds the OLD vector placement, including the
|
|
|
|
|
// since-deleted entity, via the at-generation materialization.
|
|
|
|
|
const pinnedNearA = await db.find({ vector: axisA, limit: 1 })
|
|
|
|
|
expect(pinnedNearA[0].id).toBe(uid('mat-a'))
|
|
|
|
|
const pinnedNearC = await db.find({ vector: axisC, limit: 1 })
|
|
|
|
|
expect(pinnedNearC[0].id).toBe(uid('mat-del'))
|
|
|
|
|
|
|
|
|
|
// Record-path reads on the same Db stay consistent with the materialization.
|
|
|
|
|
expect(((await db.get(uid('mat-del')))?.metadata as { who: string }).who).toBe('del')
|
|
|
|
|
|
|
|
|
|
// Released views refuse all reads (and free the materialization).
|
|
|
|
|
await db.release()
|
|
|
|
|
await expect(db.find({ vector: axisA, limit: 1 })).rejects.toThrow('released Db')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('historical graph traversal is served at the pinned generation via index materialization', async () => {
|
|
|
|
|
const brain = await openMemoryBrain()
|
|
|
|
|
await brain.transact([
|
|
|
|
|
{ op: 'add', id: uid('trav-a'), type: NounType.Person, data: 'a', vector: vec(110), metadata: {} },
|
|
|
|
|
{ op: 'add', id: uid('trav-b'), type: NounType.Document, data: 'b', vector: vec(111), metadata: {} },
|
|
|
|
|
{ op: 'add', id: uid('trav-c'), type: NounType.Document, data: 'c', vector: vec(112), metadata: {} },
|
|
|
|
|
{ op: 'relate', from: uid('trav-a'), to: uid('trav-b'), type: VerbType.References }
|
|
|
|
|
])
|
|
|
|
|
const db = brain.now()
|
|
|
|
|
|
|
|
|
|
// Rewire the graph after the pin: a→b becomes a→c.
|
2026-06-11 14:51:00 -07:00
|
|
|
const oldEdge = (await brain.related({ from: uid('trav-a') }))[0]
|
feat(8.0): full query surface at historical generations via ephemeral index materialization
Historical Db values (now()/asOf() pins that history has moved past) now
serve the COMPLETE query surface - vector/hybrid search, graph traversal,
cursor pagination, and aggregation - by materializing ephemeral in-memory
indexes over the exact at-generation record set. The historical-query
throw is gone; NotYetSupportedAtHistoricalGenerationError is deleted.
Materializer (Brainy.materializeAtGeneration):
- Copies the at-G record set (live bytes for ids untouched since the pin,
immutable before-images otherwise) into a fresh MemoryStorage; a final
reconciliation pass under the commit mutex makes the copy exact even
when transactions commit mid-build.
- Opens a read-only Brainy over the copy: init rebuilds the metadata and
graph-adjacency indexes from the records; the vector index is built by
inserting every at-G vector (the at-G HNSW graph never existed on disk,
so there is nothing to restore). Host embedder and aggregate definitions
are shared - no second model load, aggregates backfill at-G values.
- Cost is the documented contract: O(n at G) time and memory, ONCE per Db
(handle cached; freed by release(), with a FinalizationRegistry backstop
that also closes leaked readers). A native VersionedIndexProvider serves
the same reads from retained segments with no rebuild.
Db routing (src/db/db.ts): metadata-level find()/related() keep the free
record path; index-only dimensions (query/vector/near/connected/cursor/
aggregate/includeRelations/non-metadata modes) route to the cached
materialization; unsupported where-operators on the record path re-route
there too instead of erroring. Speculative with() overlays keep the one
honest boundary - SpeculativeOverlayError (overlay entities carry no
embeddings, so index reads over them would be silently incomplete);
metadata find()/get()/filter related() work on overlays.
UpdateParams.vector contract now honored: an explicit pre-computed vector
applies directly (with dimension validation) in update() and transact
update ops, re-indexing HNSW - previously it was silently ignored unless
data also changed.
GraphAdjacencyIndex: adjacency now derives from the two verb-id LSM trees
filtered through the live-verb tombstone set (entity->entity edge trees
deleted - they carried no verb ids, so removeVerb could never tombstone
them and traversal served stale neighbors forever). Neighbor reads batch-
load live verbs via the unified cache; addVerb seeds the cache.
Proofs (tests/integration/db-mvcc.test.ts, 24 green): historical vector
search finds old vector placement including since-deleted entities;
historical graph traversal walks the old wiring after a rewire; historical
aggregation computes at-G group values; asOf() pins get the same surface;
the materialization builds once per Db and release() closes the ephemeral
reader (it refuses reads afterwards); overlays throw the documented error.
ADR-001 updated to the no-throws historical model.
2026-06-11 08:12:11 -07:00
|
|
|
await brain.transact([
|
|
|
|
|
{ op: 'unrelate', id: oldEdge.id },
|
|
|
|
|
{ op: 'relate', from: uid('trav-a'), to: uid('trav-c'), type: VerbType.References }
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
// Live traversal sees the new wiring…
|
|
|
|
|
const liveConnected = await brain.find({ connected: { from: uid('trav-a') }, limit: 10 })
|
|
|
|
|
expect(liveConnected.map((row) => row.id)).toContain(uid('trav-c'))
|
|
|
|
|
expect(liveConnected.map((row) => row.id)).not.toContain(uid('trav-b'))
|
|
|
|
|
|
|
|
|
|
// …the pinned view traverses the OLD graph.
|
|
|
|
|
const pinnedConnected = await db.find({ connected: { from: uid('trav-a') }, limit: 10 })
|
|
|
|
|
expect(pinnedConnected.map((row) => row.id)).toContain(uid('trav-b'))
|
|
|
|
|
expect(pinnedConnected.map((row) => row.id)).not.toContain(uid('trav-c'))
|
|
|
|
|
|
|
|
|
|
// The record-path related() agrees with the materialized traversal.
|
|
|
|
|
const pinnedEdges = await db.related({ from: uid('trav-a') })
|
|
|
|
|
expect(pinnedEdges.length).toBe(1)
|
|
|
|
|
expect(pinnedEdges[0].to).toBe(uid('trav-b'))
|
|
|
|
|
|
|
|
|
|
await db.release()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('historical aggregation computes at-generation values via index materialization', async () => {
|
|
|
|
|
const brain = await openMemoryBrain()
|
|
|
|
|
brain.defineAggregate({
|
|
|
|
|
name: 'order_totals',
|
|
|
|
|
source: { type: NounType.Document },
|
|
|
|
|
groupBy: ['category'],
|
|
|
|
|
metrics: {
|
|
|
|
|
total: { op: 'sum', field: 'amount' },
|
|
|
|
|
orders: { op: 'count' }
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await brain.transact([
|
|
|
|
|
{ op: 'add', id: uid('agg-1'), type: NounType.Document, data: 'o1', vector: vec(120), metadata: { category: 'invoice', amount: 10 } },
|
|
|
|
|
{ op: 'add', id: uid('agg-2'), type: NounType.Document, data: 'o2', vector: vec(121), metadata: { category: 'invoice', amount: 20 } }
|
|
|
|
|
])
|
|
|
|
|
const db = brain.now()
|
|
|
|
|
|
|
|
|
|
// Mutate after the pin: add an order and inflate an existing one.
|
|
|
|
|
await brain.transact([
|
|
|
|
|
{ op: 'add', id: uid('agg-3'), type: NounType.Document, data: 'o3', vector: vec(122), metadata: { category: 'invoice', amount: 70 } },
|
|
|
|
|
{ op: 'update', id: uid('agg-1'), metadata: { amount: 100 } }
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
// Live aggregate reflects the mutations…
|
|
|
|
|
const liveRows = await brain.find({ aggregate: 'order_totals' })
|
|
|
|
|
const liveInvoice = liveRows.find((row) => (row.metadata as { category: string }).category === 'invoice')
|
|
|
|
|
expect((liveInvoice?.metadata as { total: number }).total).toBe(190)
|
|
|
|
|
expect((liveInvoice?.metadata as { orders: number }).orders).toBe(3)
|
|
|
|
|
|
|
|
|
|
// …the pinned view computes the aggregate over the at-G record set.
|
|
|
|
|
const pinnedRows = await db.find({ aggregate: 'order_totals' })
|
|
|
|
|
const pinnedInvoice = pinnedRows.find((row) => (row.metadata as { category: string }).category === 'invoice')
|
|
|
|
|
expect((pinnedInvoice?.metadata as { total: number }).total).toBe(30)
|
|
|
|
|
expect((pinnedInvoice?.metadata as { orders: number }).orders).toBe(2)
|
|
|
|
|
|
|
|
|
|
await db.release()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('asOf() pins serve the materialized surface; the build is cached per Db; release() closes the ephemeral reader', async () => {
|
|
|
|
|
const brain = await openMemoryBrain()
|
|
|
|
|
|
|
|
|
|
// Two one-hot vectors — orthogonal, fully deterministic neighbors.
|
|
|
|
|
const axisA = Array.from({ length: 384 }, (_, i) => (i === 0 ? 1 : 0))
|
|
|
|
|
const axisB = Array.from({ length: 384 }, (_, i) => (i === 1 ? 1 : 0))
|
|
|
|
|
|
|
|
|
|
const tx = await brain.transact([
|
|
|
|
|
{ op: 'add', id: uid('asof-mat-a'), type: NounType.Document, data: 'a', vector: axisA, metadata: {} },
|
|
|
|
|
{ op: 'add', id: uid('asof-mat-b'), type: NounType.Document, data: 'b', vector: axisB, metadata: {} }
|
|
|
|
|
])
|
|
|
|
|
const pinnedGeneration = tx.generation
|
|
|
|
|
await tx.release()
|
|
|
|
|
|
|
|
|
|
// Swap the vectors after the pin point, then open the past via asOf().
|
|
|
|
|
await brain.transact([
|
|
|
|
|
{ op: 'update', id: uid('asof-mat-a'), vector: axisB },
|
|
|
|
|
{ op: 'update', id: uid('asof-mat-b'), vector: axisA }
|
|
|
|
|
])
|
|
|
|
|
const db = await brain.asOf(pinnedGeneration)
|
|
|
|
|
|
|
|
|
|
// The asOf() view serves index-accelerated queries at its pinned
|
|
|
|
|
// generation — same materialized surface as a now() pin.
|
|
|
|
|
expect((await brain.find({ vector: axisA, limit: 1 }))[0].id).toBe(uid('asof-mat-b'))
|
|
|
|
|
expect((await db.find({ vector: axisA, limit: 1 }))[0].id).toBe(uid('asof-mat-a'))
|
|
|
|
|
|
|
|
|
|
// The O(n at G) build runs ONCE per Db: the second index query reuses
|
|
|
|
|
// the cached handle (typed access to the private cache, mirroring
|
|
|
|
|
// generationStoreOf above).
|
|
|
|
|
const internals = db as unknown as { materializedHandle?: Promise<HistoricalQueryHandle> }
|
|
|
|
|
expect(internals.materializedHandle).toBeDefined()
|
|
|
|
|
const handle = await internals.materializedHandle!
|
|
|
|
|
expect((await db.find({ vector: axisB, limit: 1 }))[0].id).toBe(uid('asof-mat-b'))
|
|
|
|
|
expect(await internals.materializedHandle).toBe(handle)
|
|
|
|
|
|
|
|
|
|
// release() frees the materialization by CLOSING the ephemeral reader —
|
|
|
|
|
// the reader itself refuses reads afterwards, proving its indexes and
|
|
|
|
|
// timers were torn down (not merely hidden behind the released-Db guard).
|
|
|
|
|
await db.release()
|
|
|
|
|
await db.release() // idempotent
|
|
|
|
|
await expect(db.find({ vector: axisA, limit: 1 })).rejects.toThrow('released Db')
|
|
|
|
|
await expect(handle.find({ vector: axisA, limit: 1 })).rejects.toThrow('not initialized')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('speculative overlays keep the one honest boundary: index queries and persist throw SpeculativeOverlayError', async () => {
|
|
|
|
|
const brain = await openMemoryBrain()
|
|
|
|
|
await brain.transact([
|
|
|
|
|
{ op: 'add', id: uid('spec-e'), type: NounType.Document, data: 'x', vector: vec(95), metadata: { v: 1 } }
|
|
|
|
|
])
|
|
|
|
|
const db = brain.now()
|
|
|
|
|
const spec = await db.with([{ op: 'update', id: uid('spec-e'), metadata: { v: 2 } }])
|
|
|
|
|
|
|
|
|
|
// Metadata reads on the overlay are fully supported…
|
|
|
|
|
expect(((await spec.get(uid('spec-e')))?.metadata as { v: number }).v).toBe(2)
|
|
|
|
|
expect((await spec.find({ type: NounType.Document, where: { v: 2 } })).length).toBe(1)
|
|
|
|
|
|
|
|
|
|
// …index-accelerated dimensions and persist throw the named error.
|
feat(8.0): API simplification — remove neural()/Db.search, one storage `path` key, integration→0
8.0 RC cleanup toward "one place per thing, zero-config, no deprecation":
- Remove the `brain.neural()` clustering namespace (ImprovedNeuralAPI + the dead
legacy NeuralAPI + the neural CLI + neural-only types). Similarity is `find({vector})`
/ `similar({to})`; attribute grouping is the aggregation `GROUP BY` engine. The separate
entity-extraction / smart-import feature (NeuralImport, NeuralEntityExtractor, SmartExtractor,
NaturalLanguageProcessor, `brain.extract()`/`brain.nlp()`) is kept.
- Remove `Db.search()`; `find()` is the one query verb (accepts a bare string or FindParams).
Fix the bundled MCP client, which called a non-existent `brain.search(query, limit)` →
now `find({ query, limit })`.
- Storage config: collapse to one canonical top-level `path` key. The pre-8.0 aliases
(`rootDirectory`, `options.*`, `fileSystemStorage.*`) are removed and now THROW with the
exact rename instead of silently defaulting to `./brainy-data` on upgrade. A single resolver
feeds createStorage, the 7.x→8.0 migration probe, and the plugin-factory handoff, so a native
storage provider resolves the identical root (no split-brain).
- Fix `similar({ threshold })`: the min-similarity filter was silently dropped; it is now
applied as a post-filter on `result.score` (the documented way to bound semantic results).
- Fix `vfs.rename()` on a directory: child path updates spread the entity vector into `update()`
and failed dimension validation; they are metadata-only updates now.
- Fix `vfs.move()`: copy+delete orphaned the content-addressed content blob (the destination
shared the source hash, then unlink removed it). `move()` now delegates to `rename()` — an
in-place path change that preserves the blob and the entity id, for files and directories.
- Fix streaming import: the bulk fast path never flushed mid-import nor signalled queryability.
Entity writes are now chunked by a progressive flush interval (100 → 1000 → 5000); each chunk
flushes and emits `progress.queryable`, so imported data is queryable during the import.
- Sweep all docs, comments, and JSDoc for the removed/changed APIs.
Integration suite: 49 files / 588 passed / 0 failed. Unit: 80 files / 1456 passed, no type errors.
2026-06-20 13:31:11 -07:00
|
|
|
await expect(spec.find({ query: 'anything' })).rejects.toThrow(SpeculativeOverlayError)
|
feat(8.0): full query surface at historical generations via ephemeral index materialization
Historical Db values (now()/asOf() pins that history has moved past) now
serve the COMPLETE query surface - vector/hybrid search, graph traversal,
cursor pagination, and aggregation - by materializing ephemeral in-memory
indexes over the exact at-generation record set. The historical-query
throw is gone; NotYetSupportedAtHistoricalGenerationError is deleted.
Materializer (Brainy.materializeAtGeneration):
- Copies the at-G record set (live bytes for ids untouched since the pin,
immutable before-images otherwise) into a fresh MemoryStorage; a final
reconciliation pass under the commit mutex makes the copy exact even
when transactions commit mid-build.
- Opens a read-only Brainy over the copy: init rebuilds the metadata and
graph-adjacency indexes from the records; the vector index is built by
inserting every at-G vector (the at-G HNSW graph never existed on disk,
so there is nothing to restore). Host embedder and aggregate definitions
are shared - no second model load, aggregates backfill at-G values.
- Cost is the documented contract: O(n at G) time and memory, ONCE per Db
(handle cached; freed by release(), with a FinalizationRegistry backstop
that also closes leaked readers). A native VersionedIndexProvider serves
the same reads from retained segments with no rebuild.
Db routing (src/db/db.ts): metadata-level find()/related() keep the free
record path; index-only dimensions (query/vector/near/connected/cursor/
aggregate/includeRelations/non-metadata modes) route to the cached
materialization; unsupported where-operators on the record path re-route
there too instead of erroring. Speculative with() overlays keep the one
honest boundary - SpeculativeOverlayError (overlay entities carry no
embeddings, so index reads over them would be silently incomplete);
metadata find()/get()/filter related() work on overlays.
UpdateParams.vector contract now honored: an explicit pre-computed vector
applies directly (with dimension validation) in update() and transact
update ops, re-indexing HNSW - previously it was silently ignored unless
data also changed.
GraphAdjacencyIndex: adjacency now derives from the two verb-id LSM trees
filtered through the live-verb tombstone set (entity->entity edge trees
deleted - they carried no verb ids, so removeVerb could never tombstone
them and traversal served stale neighbors forever). Neighbor reads batch-
load live verbs via the unified cache; addVerb seeds the cache.
Proofs (tests/integration/db-mvcc.test.ts, 24 green): historical vector
search finds old vector placement including since-deleted entities;
historical graph traversal walks the old wiring after a rewire; historical
aggregation computes at-G group values; asOf() pins get the same surface;
the materialization builds once per Db and release() closes the ephemeral
reader (it refuses reads afterwards); overlays throw the documented error.
ADR-001 updated to the no-throws historical model.
2026-06-11 08:12:11 -07:00
|
|
|
await expect(spec.find({ vector: vec(95) })).rejects.toThrow(SpeculativeOverlayError)
|
|
|
|
|
await expect(spec.find({ connected: { from: uid('spec-e') } })).rejects.toThrow(
|
|
|
|
|
SpeculativeOverlayError
|
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): full query surface at historical generations via ephemeral index materialization
Historical Db values (now()/asOf() pins that history has moved past) now
serve the COMPLETE query surface - vector/hybrid search, graph traversal,
cursor pagination, and aggregation - by materializing ephemeral in-memory
indexes over the exact at-generation record set. The historical-query
throw is gone; NotYetSupportedAtHistoricalGenerationError is deleted.
Materializer (Brainy.materializeAtGeneration):
- Copies the at-G record set (live bytes for ids untouched since the pin,
immutable before-images otherwise) into a fresh MemoryStorage; a final
reconciliation pass under the commit mutex makes the copy exact even
when transactions commit mid-build.
- Opens a read-only Brainy over the copy: init rebuilds the metadata and
graph-adjacency indexes from the records; the vector index is built by
inserting every at-G vector (the at-G HNSW graph never existed on disk,
so there is nothing to restore). Host embedder and aggregate definitions
are shared - no second model load, aggregates backfill at-G values.
- Cost is the documented contract: O(n at G) time and memory, ONCE per Db
(handle cached; freed by release(), with a FinalizationRegistry backstop
that also closes leaked readers). A native VersionedIndexProvider serves
the same reads from retained segments with no rebuild.
Db routing (src/db/db.ts): metadata-level find()/related() keep the free
record path; index-only dimensions (query/vector/near/connected/cursor/
aggregate/includeRelations/non-metadata modes) route to the cached
materialization; unsupported where-operators on the record path re-route
there too instead of erroring. Speculative with() overlays keep the one
honest boundary - SpeculativeOverlayError (overlay entities carry no
embeddings, so index reads over them would be silently incomplete);
metadata find()/get()/filter related() work on overlays.
UpdateParams.vector contract now honored: an explicit pre-computed vector
applies directly (with dimension validation) in update() and transact
update ops, re-indexing HNSW - previously it was silently ignored unless
data also changed.
GraphAdjacencyIndex: adjacency now derives from the two verb-id LSM trees
filtered through the live-verb tombstone set (entity->entity edge trees
deleted - they carried no verb ids, so removeVerb could never tombstone
them and traversal served stale neighbors forever). Neighbor reads batch-
load live verbs via the unified cache; addVerb seeds the cache.
Proofs (tests/integration/db-mvcc.test.ts, 24 green): historical vector
search finds old vector placement including since-deleted entities;
historical graph traversal walks the old wiring after a rewire; historical
aggregation computes at-G group values; asOf() pins get the same surface;
the materialization builds once per Db and release() closes the ephemeral
reader (it refuses reads afterwards); overlays throw the documented error.
ADR-001 updated to the no-throws historical model.
2026-06-11 08:12:11 -07:00
|
|
|
await expect(spec.persist(path.join(makeTempDir(), 'overlay-snap'))).rejects.toThrow(
|
|
|
|
|
SpeculativeOverlayError
|
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): full query surface at historical generations via ephemeral index materialization
Historical Db values (now()/asOf() pins that history has moved past) now
serve the COMPLETE query surface - vector/hybrid search, graph traversal,
cursor pagination, and aggregation - by materializing ephemeral in-memory
indexes over the exact at-generation record set. The historical-query
throw is gone; NotYetSupportedAtHistoricalGenerationError is deleted.
Materializer (Brainy.materializeAtGeneration):
- Copies the at-G record set (live bytes for ids untouched since the pin,
immutable before-images otherwise) into a fresh MemoryStorage; a final
reconciliation pass under the commit mutex makes the copy exact even
when transactions commit mid-build.
- Opens a read-only Brainy over the copy: init rebuilds the metadata and
graph-adjacency indexes from the records; the vector index is built by
inserting every at-G vector (the at-G HNSW graph never existed on disk,
so there is nothing to restore). Host embedder and aggregate definitions
are shared - no second model load, aggregates backfill at-G values.
- Cost is the documented contract: O(n at G) time and memory, ONCE per Db
(handle cached; freed by release(), with a FinalizationRegistry backstop
that also closes leaked readers). A native VersionedIndexProvider serves
the same reads from retained segments with no rebuild.
Db routing (src/db/db.ts): metadata-level find()/related() keep the free
record path; index-only dimensions (query/vector/near/connected/cursor/
aggregate/includeRelations/non-metadata modes) route to the cached
materialization; unsupported where-operators on the record path re-route
there too instead of erroring. Speculative with() overlays keep the one
honest boundary - SpeculativeOverlayError (overlay entities carry no
embeddings, so index reads over them would be silently incomplete);
metadata find()/get()/filter related() work on overlays.
UpdateParams.vector contract now honored: an explicit pre-computed vector
applies directly (with dimension validation) in update() and transact
update ops, re-indexing HNSW - previously it was silently ignored unless
data also changed.
GraphAdjacencyIndex: adjacency now derives from the two verb-id LSM trees
filtered through the live-verb tombstone set (entity->entity edge trees
deleted - they carried no verb ids, so removeVerb could never tombstone
them and traversal served stale neighbors forever). Neighbor reads batch-
load live verbs via the unified cache; addVerb seeds the cache.
Proofs (tests/integration/db-mvcc.test.ts, 24 green): historical vector
search finds old vector placement including since-deleted entities;
historical graph traversal walks the old wiring after a rewire; historical
aggregation computes at-G group values; asOf() pins get the same surface;
the materialization builds once per Db and release() closes the ephemeral
reader (it refuses reads afterwards); overlays throw the documented error.
ADR-001 updated to the no-throws historical model.
2026-06-11 08:12:11 -07:00
|
|
|
// The non-speculative base view is unaffected by the overlay boundary.
|
|
|
|
|
expect(((await db.get(uid('spec-e')))?.metadata as { v: number }).v).toBe(1)
|
|
|
|
|
|
|
|
|
|
await spec.release()
|
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 db.release()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('restore() requires confirmation and replaces state from a snapshot (generation floor preserved)', async () => {
|
|
|
|
|
const { brain } = await openFsBrain()
|
|
|
|
|
await brain.transact([
|
|
|
|
|
{ op: 'add', id: uid('restore-e'), type: NounType.Document, data: 'keep', vector: vec(96), metadata: { v: 1 } }
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
const snapDir = path.join(makeTempDir(), 'restore-snapshot')
|
|
|
|
|
const db = brain.now()
|
|
|
|
|
await db.persist(snapDir)
|
|
|
|
|
await db.release()
|
|
|
|
|
|
|
|
|
|
await brain.transact([{ op: 'update', id: uid('restore-e'), metadata: { v: 2 } }])
|
|
|
|
|
await brain.transact([
|
|
|
|
|
{ op: 'add', id: uid('restore-extra'), type: NounType.Document, data: 'extra', vector: vec(97), metadata: {} }
|
|
|
|
|
])
|
|
|
|
|
const beforeRestore = brain.generation()
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
brain.restore(snapDir, { confirm: false })
|
|
|
|
|
).rejects.toThrow('confirm')
|
|
|
|
|
|
|
|
|
|
await brain.restore(snapDir, { confirm: true })
|
|
|
|
|
expect(((await brain.get(uid('restore-e')))?.metadata as { v: number }).v).toBe(1)
|
|
|
|
|
expect(await brain.get(uid('restore-extra'))).toBeNull()
|
|
|
|
|
expect((await brain.find({ type: NounType.Document, limit: 10 })).length).toBe(1)
|
|
|
|
|
|
|
|
|
|
// The counter never moves backwards — pre-restore generations are not reissued.
|
|
|
|
|
expect(brain.generation()).toBeGreaterThanOrEqual(beforeRestore)
|
|
|
|
|
const after = await brain.transact([{ op: 'update', id: uid('restore-e'), metadata: { v: 9 } }])
|
|
|
|
|
expect(after.generation).toBeGreaterThan(beforeRestore)
|
|
|
|
|
await after.release()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('Db values are first-class: Brainy.open() + chained with() overlays compose', async () => {
|
|
|
|
|
const brain = await Brainy.open({ requireSubtype: false, storage: { type: 'memory' } })
|
|
|
|
|
brains.push(brain)
|
|
|
|
|
expect(brain.isInitialized).toBe(true)
|
|
|
|
|
|
|
|
|
|
await brain.transact([
|
|
|
|
|
{ op: 'add', id: uid('chain-e'), type: NounType.Document, data: 'x', vector: vec(98), metadata: { v: 1 } }
|
|
|
|
|
])
|
|
|
|
|
const db = brain.now()
|
|
|
|
|
const spec1 = await db.with([{ op: 'update', id: uid('chain-e'), metadata: { v: 2 } }])
|
|
|
|
|
const spec2 = await spec1.with([{ op: 'update', id: uid('chain-e'), metadata: { v: 3 } }])
|
|
|
|
|
|
|
|
|
|
expect(((await db.get(uid('chain-e')))?.metadata as { v: number }).v).toBe(1)
|
|
|
|
|
expect(((await spec1.get(uid('chain-e')))?.metadata as { v: number }).v).toBe(2)
|
|
|
|
|
expect(((await spec2.get(uid('chain-e')))?.metadata as { v: number }).v).toBe(3)
|
|
|
|
|
expect(spec2).toBeInstanceOf(Db)
|
|
|
|
|
|
|
|
|
|
await spec2.release()
|
|
|
|
|
await spec1.release()
|
|
|
|
|
await db.release()
|
|
|
|
|
})
|
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
|
|
|
|
|
|
|
|
it('transactionLog() returns committed entries newest first, with meta and limit', async () => {
|
|
|
|
|
const brain = await openMemoryBrain()
|
|
|
|
|
|
|
|
|
|
// Nothing committed yet — the log is empty (single-op writes do not log).
|
|
|
|
|
await brain.add({ id: uid('txlog-solo'), type: NounType.Document, data: 'solo', vector: vec(99), subtype: 'note' })
|
|
|
|
|
expect(await brain.transactionLog()).toEqual([])
|
|
|
|
|
|
|
|
|
|
const first = await brain.transact(
|
|
|
|
|
[{ op: 'add', id: uid('txlog-a'), type: NounType.Document, data: 'a', vector: vec(100), metadata: {} }],
|
|
|
|
|
{ meta: { author: 'job-1' } }
|
|
|
|
|
)
|
|
|
|
|
const second = await brain.transact(
|
|
|
|
|
[{ op: 'update', id: uid('txlog-a'), metadata: { v: 2 } }],
|
|
|
|
|
{ meta: { author: 'job-2' } }
|
|
|
|
|
)
|
|
|
|
|
const third = await brain.transact([{ op: 'update', id: uid('txlog-a'), metadata: { v: 3 } }])
|
|
|
|
|
|
|
|
|
|
const entries = await brain.transactionLog()
|
|
|
|
|
expect(entries.map((entry) => entry.generation)).toEqual([
|
|
|
|
|
third.generation,
|
|
|
|
|
second.generation,
|
|
|
|
|
first.generation
|
|
|
|
|
])
|
|
|
|
|
expect(entries[1].meta).toEqual({ author: 'job-2' })
|
|
|
|
|
expect(entries[2].meta).toEqual({ author: 'job-1' })
|
|
|
|
|
expect(entries[0].meta).toBeUndefined()
|
|
|
|
|
for (const entry of entries) {
|
|
|
|
|
expect(entry.timestamp).toBeGreaterThan(0)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const limited = await brain.transactionLog({ limit: 2 })
|
|
|
|
|
expect(limited.map((entry) => entry.generation)).toEqual([third.generation, second.generation])
|
|
|
|
|
|
|
|
|
|
await first.release()
|
|
|
|
|
await second.release()
|
|
|
|
|
await third.release()
|
|
|
|
|
})
|
2026-06-15 11:55:26 -07:00
|
|
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
// 9. asOf() / released-Db error paths (Y.15 spot-checks)
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
it('proof 9 — asOf() rejects out-of-range targets and a released Db throws on every read', async () => {
|
|
|
|
|
const brain = await openMemoryBrain()
|
|
|
|
|
await (
|
|
|
|
|
await brain.transact([
|
|
|
|
|
{ op: 'add', id: uid('asof-err'), type: NounType.Document, data: 'x', metadata: { v: 1 } }
|
|
|
|
|
])
|
|
|
|
|
).release()
|
|
|
|
|
const current = brain.generation()
|
|
|
|
|
|
|
|
|
|
// Negative, non-integer, and future generations are honest RangeErrors —
|
|
|
|
|
// never a silent clamp to a neighbouring generation.
|
|
|
|
|
await expect(brain.asOf(-1)).rejects.toThrow(RangeError)
|
|
|
|
|
await expect(brain.asOf(1.5)).rejects.toThrow(RangeError)
|
|
|
|
|
await expect(brain.asOf(current + 999)).rejects.toThrow(RangeError)
|
|
|
|
|
|
|
|
|
|
// A string that is not a snapshot directory is a descriptive error, not a crash.
|
|
|
|
|
await expect(brain.asOf('/no/such/snapshot/dir')).rejects.toThrow(/not a snapshot directory/)
|
|
|
|
|
|
|
|
|
|
// Use-after-release is rejected on every read method, naming the released generation.
|
|
|
|
|
const db = brain.now()
|
|
|
|
|
await db.release()
|
|
|
|
|
await expect(db.get(uid('asof-err'))).rejects.toThrow(/released Db/)
|
|
|
|
|
await expect(db.find({ where: { v: 1 } })).rejects.toThrow(/released Db/)
|
|
|
|
|
await expect(db.related(uid('asof-err'))).rejects.toThrow(/released Db/)
|
|
|
|
|
})
|
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
|
|
|
})
|