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.
373 lines
15 KiB
TypeScript
373 lines
15 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, Entity, Relation } 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 Remove an entity (and, exactly like `brain.remove()`, 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 remove. */
|
|
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
|
|
}
|
|
|
|
/**
|
|
* @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>[]
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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>
|
|
}
|