/** * @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//tx.json — the generation-N delta: touched noun/verb ids + meta * _generations//prev/.json — immutable before-image of 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 extends AddParams { /** 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 extends UpdateParams { /** 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 extends RelateParams { /** 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 = | TxAddOperation | TxUpdateOperation | TxRemoveOperation | TxRelateOperation | 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 /** * 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()` — explicit retention * **caps** (Model-B `retention` knob). Each supplied field is an upper bound: * the oldest unpinned generations are reclaimed while **ANY** supplied cap is * exceeded (predictable ops — the more you constrain, the more is reclaimed). * Live `Db` pins are ALWAYS exempt. With no options, every generation not * protected by a live pin is eligible. * * (8.0 rename: the pre-release `retainGenerations`/`retainMs` *floors* became * `maxGenerations`/`maxAge` *caps* — the `max*` naming signals the semantics.) */ export interface CompactHistoryOptions { /** * Keep at most this many of the most recent committed generations — reclaim * the oldest unpinned generations while the count exceeds it (`0` = reclaim * everything not protected by a live pin). */ maxGenerations?: number /** * Keep only generations committed within the last `maxAge` milliseconds — * reclaim unpinned generations older than the window. */ maxAge?: number /** * Keep total generational-history bytes at or below this — reclaim the oldest * unpinned generations while the total exceeds it. Byte accounting is the sum * of each surviving generation's serialized record set (`GenerationDelta.bytes`). */ maxBytes?: 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 * generations in the interval `(priorDb.generation, db.generation]`. Under * Model-B EVERY write is its own generation, so single-operation writes * (`add`/`update`/`remove`/`relate` outside `transact()`) ARE reflected here, * exactly like `transact()` commits. */ 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 per-write (every `transact()` AND * single-op write is its own generation — Model-B). */ export interface HistoryVersion { /** 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 | Relation | 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 { /** 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[] } // ============================================================================ // Persisted record shapes // ============================================================================ /** * @description An immutable before-image of one entity or relationship, * persisted under `_generations//prev/.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//tx.json`. For a `transact()` generation it is written and * fsynced *before* any operation of the transaction executes, so crash * recovery always knows exactly which ids to restore from before-images. For a * single-operation (Model-B) generation it is written at group-commit flush * time with `groupCommit: true` — see that flag. */ 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 /** Entity ids touched by this generation. */ nouns: string[] /** Relationship ids touched by this generation. */ verbs: string[] /** * `true` for a Model-B single-operation generation persisted by the async * group-commit flush (`GenerationStore.flushPendingSingleOps`). It marks the * **drop-without-restore** crash-recovery contract: the live write already * mutated canonical storage and was acknowledged to the caller BEFORE the * flush, so an uncommitted (crashed-mid-flush) generation with this flag set * must be DISCARDED — its before-images must NEVER be restored, or recovery * would silently revert acknowledged user writes. Absent/`false` on a * `transact()` generation, whose before-images ARE the durable undo log and * are restored on rollback (the before-image + execute are one atomic unit). */ groupCommit?: boolean /** * Serialized byte size of this generation's record set (the `prev/.json` * before-images plus this delta), recorded at write time so `maxBytes` / * adaptive retention can bound total history without a storage size API. Read * back lazily by `GenerationStore.historyBytes()`. Absent on generations * written before byte accounting existed (treated as 0). */ bytes?: number } /** * @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 } // ============================================================================ // 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 /** Write a raw object at a storage-root-relative path (atomic tmp+rename on disk). */ writeRawObject(path: string, data: any): Promise /** Delete a raw object (no-op if absent). */ deleteRawObject(path: string): Promise /** List raw object paths under a prefix (normalized, `.gz`-stripped). */ listRawObjects(prefix: string): Promise /** Remove every object under a prefix (and the directory itself on disk). */ removeRawPrefix(prefix: string): Promise /** Durability barrier: fsync the given object paths (no-op in memory). */ syncRawObjects(paths: string[]): Promise /** 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 /** 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 /** Append one line to `_system/tx-log.jsonl`. */ appendTxLogLine(line: string): Promise /** Read all lines of `_system/tx-log.jsonl` (empty array if absent). */ readTxLogLines(): Promise /** * 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 /** Replace the entire store's contents from a snapshot directory. */ restoreFromDirectory(sourcePath: string): Promise }