feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)
One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.
Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
transact() commit and once per single-operation write (storage hook), so
brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
(the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
and forces an index rebuild; refcounted pins gate compactHistory(), which
records a horizon (asOf below it throws GenerationCompactedError).
Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
(GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
{confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
generation; index-accelerated queries at historical generations throw
NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
plugin.ts: feature-detected, balanced pin/release in lockstep with Db
lifecycle, post-commit applier + replay-gap model documented.
Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.
Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
2026-06-10 14:14:07 -07:00
|
|
|
/**
|
|
|
|
|
* @module db/types
|
|
|
|
|
* @description Shared types for the 8.0 generational-MVCC Db API: the
|
|
|
|
|
* declarative transaction-operation union consumed by `brain.transact()`,
|
|
|
|
|
* the options bags for `transact()` / `compactHistory()`, the persisted
|
|
|
|
|
* record shapes of the generational record layer, and the narrow storage
|
|
|
|
|
* contract (`GenerationStorage`) the record layer needs from an adapter.
|
|
|
|
|
*
|
|
|
|
|
* Persisted layout (all paths relative to the storage root):
|
|
|
|
|
*
|
|
|
|
|
* ```
|
|
|
|
|
* _system/generation.json — { generation, updatedAt } (atomic tmp+rename)
|
|
|
|
|
* _system/manifest.json — { version, generation, committedAt, horizon }
|
|
|
|
|
* (atomic tmp+rename — the rename IS the commit point)
|
|
|
|
|
* _system/tx-log.jsonl — one JSON line per committed transact:
|
|
|
|
|
* { generation, timestamp, meta? } (append-only)
|
|
|
|
|
* _generations/<N>/tx.json — the generation-N delta: touched noun/verb ids + meta
|
|
|
|
|
* _generations/<N>/prev/<id>.json — immutable before-image of <id> as of commit N
|
|
|
|
|
* ```
|
|
|
|
|
*
|
|
|
|
|
* Design note: the manifest holds the committed watermark + compaction
|
|
|
|
|
* horizon rather than a full `id → latest record generation` map. The id
|
|
|
|
|
* mapping is distributed across the per-generation `tx.json` deltas, which
|
|
|
|
|
* makes each commit O(ids touched) instead of O(all ids) — see
|
|
|
|
|
* `docs/ADR-001-generational-mvcc.md` for the full justification.
|
|
|
|
|
*/
|
|
|
|
|
|
feat(8.0): temporal range verbs — diff, history, since(gen|Date), asOf{exclusive}, transactionLog window
asOf() answers "state AT a point"; these answer "what happened BETWEEN two
points" and "one entity's whole history", all on the existing generation records.
- Extract resolveAsOfGeneration() as the shared gen|Date→{generation,timestamp}
chokepoint (reachability + Date semantics identical everywhere). asOf()'s
inclusive path is byte-identical — db-mvcc still 25/25.
- asOf(target, { exclusive }) — strict-before (resolved−1, clamped at gen 0,
never a RangeError).
- db.since(Db | generation | Date) — overload; number/Date resolve via the shared
resolver (new DbHost.resolveGeneration); EXCLUSIVE lower bound;
since(db) === since(db.generation). Same-store guard via Db.belongsToStore.
- brain.diff(a, b) → { added, removed, modified } split by nouns/verbs. EARNS its
name: candidate set is ONLY changedBetween(gLow,gHigh), each classified by
existence at both endpoints + a key-order-insensitive value compare
(new src/db/stableEqual.ts) — a touched-but-reverted / born-and-died id is in
no bucket.
- brain.history(id, { from, to }) → every distinct version oldest→newest, each
value === asOf(version.generation).get(id); null = removal; kind auto-detected
(throws on UUID-space collision). New store helper generationsTouching().
- brain.transactionLog({ from, to, limit }) — INCLUSIVE generation/Date window
(contrast since's exclusive lower bound); limit applied last.
- Compaction policy locked: diff/since THROW GenerationCompactedError below the
horizon; history TRUNCATES to it.
Types DiffResult/HistoryVersion/EntityHistory exported from the package root.
Tests: tests/integration/db-temporal.test.ts (8 proofs incl. diff-earns-its-name,
history↔asOf cross-check, the composition proof, granularity, compaction contrast)
+ tests/unit/db/stableEqual.test.ts (6). docs/guides/snapshots-and-time-travel.md
+ RELEASES updated. 1477 unit + db-temporal 8 + db-mvcc 25 green.
2026-06-19 13:21:02 -07:00
|
|
|
import type { AddParams, UpdateParams, RelateParams, Entity, Relation } from '../types/brainy.types.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
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
// Transaction operations (brain.transact input)
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @description Create an entity. Carries the same parameters as
|
|
|
|
|
* `brain.add()`; supply `id` to choose the entity id (otherwise a UUID v4 is
|
|
|
|
|
* generated and returned in the operation's planning, exactly as `add()`
|
|
|
|
|
* does).
|
|
|
|
|
*/
|
|
|
|
|
export interface TxAddOperation<T = any> extends AddParams<T> {
|
|
|
|
|
/** Discriminator. */
|
|
|
|
|
op: 'add'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @description Update an entity. Carries the same parameters as
|
|
|
|
|
* `brain.update()` including per-entity CAS via `ifRev` — an `ifRev`
|
|
|
|
|
* conflict rejects the whole transaction before anything is applied.
|
|
|
|
|
*/
|
|
|
|
|
export interface TxUpdateOperation<T = any> extends UpdateParams<T> {
|
|
|
|
|
/** Discriminator. */
|
|
|
|
|
op: 'update'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-06-11 14:51:00 -07:00
|
|
|
* @description Remove an entity (and, exactly like `brain.remove()`, every
|
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
|
|
|
* relationship where it is source or target — the cascade is part of the
|
|
|
|
|
* same atomic batch).
|
|
|
|
|
*/
|
|
|
|
|
export interface TxRemoveOperation {
|
|
|
|
|
/** Discriminator. */
|
|
|
|
|
op: 'remove'
|
2026-06-11 14:51:00 -07:00
|
|
|
/** Id of the entity to remove. */
|
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
|
|
|
id: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @description Create a relationship. Carries the same parameters as
|
|
|
|
|
* `brain.relate()` (including `bidirectional`). Duplicate relationships
|
|
|
|
|
* (same from/to/type) are deduplicated exactly as `relate()` does — the
|
|
|
|
|
* operation becomes a no-op that resolves to the existing relationship id.
|
|
|
|
|
*/
|
|
|
|
|
export interface TxRelateOperation<T = any> extends RelateParams<T> {
|
|
|
|
|
/** Discriminator. */
|
|
|
|
|
op: 'relate'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @description Delete a relationship by id (mirror of `brain.unrelate()`).
|
|
|
|
|
*/
|
|
|
|
|
export interface TxUnrelateOperation {
|
|
|
|
|
/** Discriminator. */
|
|
|
|
|
op: 'unrelate'
|
|
|
|
|
/** Id of the relationship to delete. */
|
|
|
|
|
id: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @description One declarative operation inside a `brain.transact()` batch.
|
|
|
|
|
* The batch executes atomically: either every operation applies and the
|
|
|
|
|
* store advances exactly one generation, or none apply and the store is
|
|
|
|
|
* byte-identical to its pre-transaction state.
|
|
|
|
|
*/
|
|
|
|
|
export type TxOperation<T = any> =
|
|
|
|
|
| TxAddOperation<T>
|
|
|
|
|
| TxUpdateOperation<T>
|
|
|
|
|
| TxRemoveOperation
|
|
|
|
|
| TxRelateOperation<T>
|
|
|
|
|
| TxUnrelateOperation
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @description Options for `brain.transact()`.
|
|
|
|
|
*/
|
|
|
|
|
export interface TransactOptions {
|
|
|
|
|
/**
|
|
|
|
|
* Transaction metadata, reified Datomic-style: recorded in
|
|
|
|
|
* `_system/tx-log.jsonl` alongside the generation number and commit
|
|
|
|
|
* timestamp. Use it for audit fields (author, reason, request id) —
|
|
|
|
|
* it replaces commit messages from the pre-8.0 versioning API.
|
|
|
|
|
*/
|
|
|
|
|
meta?: Record<string, unknown>
|
|
|
|
|
/**
|
|
|
|
|
* Whole-store compare-and-swap. When provided, the transaction commits
|
|
|
|
|
* only if the store's current generation equals this value; otherwise it
|
|
|
|
|
* throws `GenerationConflictError` (with `expected`/`actual`) before any
|
|
|
|
|
* record is staged.
|
|
|
|
|
*/
|
|
|
|
|
ifAtGeneration?: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @description Per-operation results of a committed transaction, in input
|
|
|
|
|
* order. `add` resolves to the created entity id, `relate` to the created
|
|
|
|
|
* (or deduplicated existing) relationship id; `update`/`remove`/`unrelate`
|
|
|
|
|
* resolve to the id they acted on.
|
|
|
|
|
*/
|
|
|
|
|
export interface TransactReceipt {
|
|
|
|
|
/** The generation this transaction committed as. */
|
|
|
|
|
generation: number
|
|
|
|
|
/** Commit timestamp (ms since epoch) recorded in the tx-log. */
|
|
|
|
|
timestamp: number
|
|
|
|
|
/** Resolved id per input operation, in input order. */
|
|
|
|
|
ids: string[]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
// Compaction
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @description Options for `brain.compactHistory()`. When both options are
|
|
|
|
|
* supplied a generation must satisfy *both* retention rules to be reclaimed
|
|
|
|
|
* (the stricter retention wins). With no options, every generation not
|
|
|
|
|
* protected by a live pin is eligible.
|
|
|
|
|
*/
|
|
|
|
|
export interface CompactHistoryOptions {
|
|
|
|
|
/**
|
|
|
|
|
* Keep at least this many of the most recent committed generations
|
|
|
|
|
* (`0` = keep none beyond what live pins protect).
|
|
|
|
|
*/
|
|
|
|
|
retainGenerations?: number
|
|
|
|
|
/**
|
|
|
|
|
* Keep every generation committed within the last `retainMs` milliseconds.
|
|
|
|
|
*/
|
|
|
|
|
retainMs?: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @description Result of `brain.compactHistory()`.
|
|
|
|
|
*/
|
|
|
|
|
export interface CompactHistoryResult {
|
|
|
|
|
/** Number of generation record-sets reclaimed by this call. */
|
|
|
|
|
removedGenerations: number
|
|
|
|
|
/**
|
|
|
|
|
* The new compaction horizon — the highest generation whose record-set has
|
|
|
|
|
* been reclaimed. Generations BELOW the horizon are no longer reachable via
|
|
|
|
|
* `asOf()` (their reads would need the reclaimed before-images); the
|
|
|
|
|
* horizon itself stays reachable, resolved from the record-sets above it.
|
|
|
|
|
*/
|
|
|
|
|
horizon: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
// Db surfaces
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @description Result of `db.since(priorDb)` — the ids touched by committed
|
|
|
|
|
* transactions in the generation interval `(priorDb.generation,
|
|
|
|
|
* db.generation]`. Granularity note: single-operation writes performed
|
|
|
|
|
* outside `transact()` bump the generation counter but do not produce
|
|
|
|
|
* generation records, so they are not reflected here (documented 8.0
|
|
|
|
|
* history granularity — see `docs/ADR-001-generational-mvcc.md`).
|
|
|
|
|
*/
|
|
|
|
|
export interface ChangedIds {
|
|
|
|
|
/** Entity (noun) ids touched in the interval, sorted. */
|
|
|
|
|
nouns: string[]
|
|
|
|
|
/** Relationship (verb) ids touched in the interval, sorted. */
|
|
|
|
|
verbs: string[]
|
|
|
|
|
/** Exclusive lower bound of the interval. */
|
|
|
|
|
fromGeneration: number
|
|
|
|
|
/** Inclusive upper bound of the interval. */
|
|
|
|
|
toGeneration: number
|
|
|
|
|
}
|
|
|
|
|
|
feat(8.0): temporal range verbs — diff, history, since(gen|Date), asOf{exclusive}, transactionLog window
asOf() answers "state AT a point"; these answer "what happened BETWEEN two
points" and "one entity's whole history", all on the existing generation records.
- Extract resolveAsOfGeneration() as the shared gen|Date→{generation,timestamp}
chokepoint (reachability + Date semantics identical everywhere). asOf()'s
inclusive path is byte-identical — db-mvcc still 25/25.
- asOf(target, { exclusive }) — strict-before (resolved−1, clamped at gen 0,
never a RangeError).
- db.since(Db | generation | Date) — overload; number/Date resolve via the shared
resolver (new DbHost.resolveGeneration); EXCLUSIVE lower bound;
since(db) === since(db.generation). Same-store guard via Db.belongsToStore.
- brain.diff(a, b) → { added, removed, modified } split by nouns/verbs. EARNS its
name: candidate set is ONLY changedBetween(gLow,gHigh), each classified by
existence at both endpoints + a key-order-insensitive value compare
(new src/db/stableEqual.ts) — a touched-but-reverted / born-and-died id is in
no bucket.
- brain.history(id, { from, to }) → every distinct version oldest→newest, each
value === asOf(version.generation).get(id); null = removal; kind auto-detected
(throws on UUID-space collision). New store helper generationsTouching().
- brain.transactionLog({ from, to, limit }) — INCLUSIVE generation/Date window
(contrast since's exclusive lower bound); limit applied last.
- Compaction policy locked: diff/since THROW GenerationCompactedError below the
horizon; history TRUNCATES to it.
Types DiffResult/HistoryVersion/EntityHistory exported from the package root.
Tests: tests/integration/db-temporal.test.ts (8 proofs incl. diff-earns-its-name,
history↔asOf cross-check, the composition proof, granularity, compaction contrast)
+ tests/unit/db/stableEqual.test.ts (6). docs/guides/snapshots-and-time-travel.md
+ RELEASES updated. 1477 unit + db-temporal 8 + db-mvcc 25 green.
2026-06-19 13:21:02 -07:00
|
|
|
/**
|
|
|
|
|
* @description The result of {@link Brainy.diff}: ids that came into existence,
|
|
|
|
|
* went away, or changed value between two states, each split by kind. Unlike the
|
|
|
|
|
* raw touched-id set of {@link ChangedIds}, `diff` EARNS its name — it resolves
|
|
|
|
|
* each touched id at BOTH endpoints and classifies by existence (added/removed)
|
|
|
|
|
* and stable value comparison (modified). An id touched within the interval but
|
|
|
|
|
* whose endpoint states are identical (touched-then-reverted) appears in NONE of
|
|
|
|
|
* the three buckets. Orientation is `a → b`: `added` exists at `b` not `a`.
|
|
|
|
|
*/
|
|
|
|
|
export interface DiffResult {
|
|
|
|
|
/** Ids absent at `a`, present at `b`. */
|
|
|
|
|
added: { nouns: string[]; verbs: string[] }
|
|
|
|
|
/** Ids present at `a`, absent at `b`. */
|
|
|
|
|
removed: { nouns: string[]; verbs: string[] }
|
|
|
|
|
/** Ids present at both endpoints with a changed stored value. */
|
|
|
|
|
modified: { nouns: string[]; verbs: string[] }
|
|
|
|
|
/** The resolved generation of endpoint `a`. */
|
|
|
|
|
fromGeneration: number
|
|
|
|
|
/** The resolved generation of endpoint `b`. */
|
|
|
|
|
toGeneration: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @description One version of a single entity or relationship in its
|
|
|
|
|
* {@link EntityHistory} — the materialized state at a committed generation that
|
|
|
|
|
* changed it. `value` is `null` when the id did not exist at that version
|
|
|
|
|
* (created-after / removed). Granularity is `transact()` commits.
|
|
|
|
|
*/
|
|
|
|
|
export interface HistoryVersion<T = any> {
|
|
|
|
|
/** The committed generation that produced this version. */
|
|
|
|
|
generation: number
|
|
|
|
|
/** Commit timestamp of that generation (ms since epoch). */
|
|
|
|
|
timestamp: number
|
|
|
|
|
/** Materialized state at this version, or `null` if the id did not exist then. */
|
|
|
|
|
value: Entity<T> | Relation<T> | null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @description The result of {@link Brainy.history}: every distinct version of
|
|
|
|
|
* ONE id over a generation range, oldest → newest. Consecutive identical states
|
|
|
|
|
* are collapsed (one entry per distinct state). `kind` is auto-detected from the
|
|
|
|
|
* id's presence in the noun vs verb spaces.
|
|
|
|
|
*/
|
|
|
|
|
export interface EntityHistory<T = any> {
|
|
|
|
|
/** The id whose history this is. */
|
|
|
|
|
id: string
|
|
|
|
|
/** Whether `id` is an entity (`'noun'`) or relationship (`'verb'`). */
|
|
|
|
|
kind: 'noun' | 'verb'
|
|
|
|
|
/** Distinct versions, oldest first. */
|
|
|
|
|
versions: HistoryVersion<T>[]
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
// ============================================================================
|
|
|
|
|
// Persisted record shapes
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @description An immutable before-image of one entity or relationship,
|
|
|
|
|
* persisted under `_generations/<N>/prev/<id>.json` — the state of the id
|
|
|
|
|
* immediately before commit `N` touched it. `metadata`/`vector` hold the
|
|
|
|
|
* *raw stored objects* (exact bytes that live at the entity's canonical
|
|
|
|
|
* storage paths); `null` means the corresponding file did not exist (for
|
|
|
|
|
* before-images of created ids, both are `null`). Before-images are both
|
|
|
|
|
* the crash-recovery undo log and the point-in-time read source: the state
|
|
|
|
|
* of id X at pinned generation G is the before-image of the first committed
|
|
|
|
|
* generation after G that touched X.
|
|
|
|
|
*/
|
|
|
|
|
export interface GenerationRecord {
|
|
|
|
|
/** Whether this record images an entity (noun) or a relationship (verb). */
|
|
|
|
|
kind: 'noun' | 'verb'
|
|
|
|
|
/** Raw stored metadata object, or `null` if the metadata file was absent. */
|
|
|
|
|
metadata: unknown | null
|
|
|
|
|
/** Raw stored vector object, or `null` if the vector file was absent. */
|
|
|
|
|
vector: unknown | null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @description The per-generation delta persisted at
|
|
|
|
|
* `_generations/<N>/tx.json` — written and fsynced *before* any operation of
|
|
|
|
|
* the transaction executes, so crash recovery always knows exactly which ids
|
|
|
|
|
* to restore from before-images.
|
|
|
|
|
*/
|
|
|
|
|
export interface GenerationDelta {
|
|
|
|
|
/** The generation this delta belongs to. */
|
|
|
|
|
generation: number
|
|
|
|
|
/** Commit timestamp (ms since epoch). */
|
|
|
|
|
timestamp: number
|
|
|
|
|
/** Transaction metadata (mirrored into the tx-log on commit). */
|
|
|
|
|
meta?: Record<string, unknown>
|
|
|
|
|
/** Entity ids touched by this generation. */
|
|
|
|
|
nouns: string[]
|
|
|
|
|
/** Relationship ids touched by this generation. */
|
|
|
|
|
verbs: string[]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @description Shape of `_system/manifest.json`. Replaced via atomic
|
|
|
|
|
* tmp+rename on every commit — the rename is the commit point: a generation
|
|
|
|
|
* directory is committed if and only if its number is ≤ `generation`.
|
|
|
|
|
*/
|
|
|
|
|
export interface GenerationManifest {
|
|
|
|
|
/** Schema version of the manifest file. */
|
|
|
|
|
version: 1
|
|
|
|
|
/** Committed-transaction watermark. */
|
|
|
|
|
generation: number
|
|
|
|
|
/** ISO timestamp of the most recent commit (or compaction). */
|
|
|
|
|
committedAt: string
|
|
|
|
|
/** Compaction horizon — record-sets for generations ≤ this are reclaimed. */
|
|
|
|
|
horizon: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @description One line of `_system/tx-log.jsonl`.
|
|
|
|
|
*/
|
|
|
|
|
export interface TxLogEntry {
|
|
|
|
|
/** The committed generation. */
|
|
|
|
|
generation: number
|
|
|
|
|
/** Commit timestamp (ms since epoch). */
|
|
|
|
|
timestamp: number
|
|
|
|
|
/** Transaction metadata, when supplied to `transact()`. */
|
|
|
|
|
meta?: Record<string, unknown>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
// Storage contract
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @description The narrow storage contract the generational record layer
|
|
|
|
|
* needs. `BaseStorage` implements it structurally (both shipped adapters —
|
|
|
|
|
* filesystem and memory — inherit the implementation), so any
|
|
|
|
|
* `StorageAdapter` produced by the 8.0 storage factory satisfies it.
|
|
|
|
|
*
|
|
|
|
|
* Raw-object methods bypass branch scoping and operate on storage-root-
|
|
|
|
|
* relative paths (the record layer owns `_system/` + `_generations/`);
|
|
|
|
|
* entity-raw methods read/write the canonical entity files (branch-scoped,
|
|
|
|
|
* write-cache coherent) so before/after images capture exactly the bytes
|
|
|
|
|
* the live read paths see.
|
|
|
|
|
*/
|
|
|
|
|
export interface GenerationStorage {
|
|
|
|
|
/** Read a raw object at a storage-root-relative path (`null` if absent). */
|
|
|
|
|
readRawObject(path: string): Promise<any | null>
|
|
|
|
|
/** Write a raw object at a storage-root-relative path (atomic tmp+rename on disk). */
|
|
|
|
|
writeRawObject(path: string, data: any): Promise<void>
|
|
|
|
|
/** Delete a raw object (no-op if absent). */
|
|
|
|
|
deleteRawObject(path: string): Promise<void>
|
|
|
|
|
/** List raw object paths under a prefix (normalized, `.gz`-stripped). */
|
|
|
|
|
listRawObjects(prefix: string): Promise<string[]>
|
|
|
|
|
/** Remove every object under a prefix (and the directory itself on disk). */
|
|
|
|
|
removeRawPrefix(prefix: string): Promise<void>
|
|
|
|
|
/** Durability barrier: fsync the given object paths (no-op in memory). */
|
|
|
|
|
syncRawObjects(paths: string[]): Promise<void>
|
|
|
|
|
|
|
|
|
|
/** Read an entity's raw stored metadata+vector objects. */
|
|
|
|
|
readNounRaw(id: string): Promise<{ metadata: any | null; vector: any | null }>
|
|
|
|
|
/** Restore an entity's raw stored objects (`null` part ⇒ delete that file). */
|
|
|
|
|
writeNounRaw(id: string, record: { metadata: any | null; vector: any | null }): Promise<void>
|
|
|
|
|
/** Read a relationship's raw stored metadata+vector objects. */
|
|
|
|
|
readVerbRaw(id: string): Promise<{ metadata: any | null; vector: any | null }>
|
|
|
|
|
/** Restore a relationship's raw stored objects (`null` part ⇒ delete that file). */
|
|
|
|
|
writeVerbRaw(id: string, record: { metadata: any | null; vector: any | null }): Promise<void>
|
|
|
|
|
|
|
|
|
|
/** Append one line to `_system/tx-log.jsonl`. */
|
|
|
|
|
appendTxLogLine(line: string): Promise<void>
|
|
|
|
|
/** Read all lines of `_system/tx-log.jsonl` (empty array if absent). */
|
|
|
|
|
readTxLogLines(): Promise<string[]>
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Register the generation-bump hook invoked on every entity-visible
|
|
|
|
|
* single-operation write (see `BaseStorage.setGenerationBumpHook`).
|
|
|
|
|
*/
|
|
|
|
|
setGenerationBumpHook(hook: (() => void) | undefined): void
|
|
|
|
|
|
|
|
|
|
/** Snapshot the entire store to a directory (hard-link farm on disk). */
|
|
|
|
|
snapshotToDirectory(targetPath: string): Promise<void>
|
|
|
|
|
/** Replace the entire store's contents from a snapshot directory. */
|
|
|
|
|
restoreFromDirectory(sourcePath: string): Promise<void>
|
|
|
|
|
}
|