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.
321 lines
13 KiB
TypeScript
321 lines
13 KiB
TypeScript
/**
|
|
* @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.
|
|
*/
|
|
|
|
import type { AddParams, UpdateParams, RelateParams } from '../types/brainy.types.js'
|
|
|
|
// ============================================================================
|
|
// 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'
|
|
}
|
|
|
|
/**
|
|
* @description Delete an entity (and, exactly like `brain.delete()`, every
|
|
* relationship where it is source or target — the cascade is part of the
|
|
* same atomic batch).
|
|
*/
|
|
export interface TxRemoveOperation {
|
|
/** Discriminator. */
|
|
op: 'remove'
|
|
/** Id of the entity to delete. */
|
|
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
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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>
|
|
}
|