This repository has been archived on 2026-09-03. You can view files and clone it, but you cannot make any changes to it's state, such as pushing and creating new issues, pull requests or comments.
open-brainy/src/storage/blobStorage.ts

620 lines
22 KiB
TypeScript
Raw Normal View History

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
/**
* @module storage/blobStorage
* @description Content-addressed blob store. Backs VFS file content with
* SHA-256 addressing (automatic deduplication), reference counting, zstd
* compression where it pays (MIME-aware: already-compressed media is stored
* raw), and an LRU read cache.
*
* The store persists through a narrow key-value bridge
* ({@link BlobStoreAdapter}) provided by `BaseStorage`, which roots all keys
* under the `_cas/` storage area. Key naming is an explicit type contract:
* `blob:<hash>` keys hold binary bytes, `blob-meta:<hash>` keys hold JSON
* metadata the key format decides how bytes are encoded, never content
* sniffing.
*/
import { createHash } from 'crypto'
import { unwrapBinaryData } from './binaryDataCodec.js'
fix: atomic ifAbsent/upsert inserts + exact blob reference counts under concurrency The fast-follow to the ifRev CAS fix: the sweep for the same check-then-act class found two more instances, both now closed with the same discipline — the decision runs at the serialization point that guards the apply. add({ifAbsent}) / add({upsert}): the absence check ran before the commit mutex, so N concurrent same-id creates could all pass it and all write — the second silently overwriting the first, violating ifAbsent's "return the existing id WITHOUT writing" contract and upsert's merge-never-clobber contract. The insert leg now carries a must-be-absent commit precondition (the same conditional-commit primitive ifRev uses), verified under the commit mutex against the authoritative before-image. A losing caller takes its documented resolution instead of overwriting: ifAbsent returns the existing id with zero writes; upsert merges into the now-existing entity via update() (shared param mapping in upsertMergeParams so the planning-time branch and the conflict retry can never drift), with a bounded retry if a concurrent delete lands between the conflict and the merge. The planning-time checks remain as fast-fails. transact()'s batch-level ifAbsent/upsert keep planning-time semantics (documented, converges to a valid entity). BlobStorage: write()'s dedup decision (exists → increment refCount, absent → create at 1) and delete()'s decrement-then-remove were unserialized read-modify-writes over blob-meta:<hash>. Concurrent writes of identical content could lose references, so a later delete removed bytes another file still referenced (data loss), or leaked unreferenced blobs. All reference-count-bearing mutations now serialize through a per-hash InMemoryMutex — distinct content never contends; incrementRefCount/ decrementRefCount are documented lock-assumed internals. Both fixes are complete by construction in-process: storage enforces single-writer-per-directory, so the process is the whole concurrency domain. Tests (tests/integration/ifabsent-upsert-blob-concurrency.test.ts): 8-way ifAbsent storm advances the store by exactly one generation with _rev 1; 8-way upsert storm on an absent id yields one create + seven merges (_rev === 8); sequential ifAbsent/upsert semantics pinned unchanged; N identical concurrent blob writes → refCount === N; the blob survives until the true last reference drops; interleaved write/delete storm never loses a landed reference.
2026-07-08 15:13:26 -07:00
import { InMemoryMutex } from '../utils/mutex.js'
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
/**
* @description Key-value bridge the blob store persists through. Implemented
* by `BaseStorage.initializeBlobStorage()` over the adapter's raw object
* primitives.
*/
export interface BlobStoreAdapter {
/** Read the bytes stored under `key`, or `undefined` when absent. */
get(key: string): Promise<Buffer | undefined>
/** Persist `data` under `key` (overwrites). */
put(key: string, data: Buffer): Promise<void>
/** Delete the value under `key`. Missing keys are ignored. */
delete(key: string): Promise<void>
/** List all keys starting with `prefix`. */
list(prefix: string): Promise<string[]>
}
/**
* @description Metadata persisted alongside each blob (under
* `blob-meta:<hash>`).
*/
export interface BlobMetadata {
/** SHA-256 content hash (the blob's identity). */
hash: string
/** Original (uncompressed) size in bytes. */
size: number
/** Stored size in bytes (after compression, if any). */
compressedSize: number
/** Compression applied to the stored bytes. */
compression: 'none' | 'zstd'
/** Creation timestamp (epoch ms). */
createdAt: number
feat: temporal VFS — file content joins the Model-B immutability model The temporal model had a hole exactly where files were concerned: every entity write is an immutable generation with before-images, but VFS content BYTES lived under an eager refCount GC left over from the pre-8.0 design — unlink could physically destroy bytes that in-window history still referenced, and overwrite never released the old hash at all (an unbounded silent leak whose accidental byproduct was the only thing "preserving" history). Reading the past could therefore return a stale field, a dangling hash, or nothing, depending on luck. Fix: blob reclamation becomes a HISTORY decision instead of a LIVENESS decision. Each blob's metadata now carries historyRefCount alongside the live refCount: - The commit seam counts one history reference per persisted before-image record carrying a content hash (commitTransaction staging and the group-commit flush), recorded BEFORE the record-set persists and carried in the generation delta (blobHashes — always present on new deltas, so compaction only falls back to reading records for pre-contract generations). An aborted transaction compensates best-effort. - unlink/rmdir/overwrite drop ONLY the live reference (BlobStorage.delete → release; overwrite finally releases the superseded hash — cancelling the dedup increment on same-content rewrites and closing the leak), and only AFTER the canonical mutation commits, so a failed delete can never leave a live file whose bytes compaction might reclaim. - History compaction is the ONE reclamation point: after deleting a generation's record-set it releases that set's references and physically reclaims any hash at zero live AND zero history references. Pins are exempt automatically. Crash ordering is over-count-only in every path (record before persist, release after delete), so a crash can leak until the scrub recounts but can never reclaim bytes a retained generation needs. scrubBlobHistoryRefCounts() restores exactness; existing stores get a one-time marker-gated backfill on open, failing into leak-safe mode (reclamation disabled) rather than guessing. On top of the protected history, the temporal API the generational model always implied: - vfs.readFile(path, { asOf }) — the exact bytes as of a generation or Date, materialized from the history (pinned view released so compaction is never blocked by a read). - vfs.history(path) — FileVersion[] ascending ({ generation, timestamp, hash, size, mimeType? }), the newest entry being the live state. - Overwrites now refresh the file entity's data/embedding text — semantic search and the data field previously served the FIRST version's text forever (the stale-field defect a consumer's incident recovery depended on by luck). Integration suite (temporal-vfs.test.ts): per-version exact reads + history listing, leak-fix + history protection on overwrite, rm keeps bytes readable, compaction reclaims past-window bytes and preserves in-window (including the cross-file dedup case where an old file's history and a newer file's removal share one hash), data freshness, and scrub exactness.
2026-07-10 16:43:48 -07:00
/** Number of LIVE logical references to this blob (deduplicated writes). */
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
refCount: number
feat: temporal VFS — file content joins the Model-B immutability model The temporal model had a hole exactly where files were concerned: every entity write is an immutable generation with before-images, but VFS content BYTES lived under an eager refCount GC left over from the pre-8.0 design — unlink could physically destroy bytes that in-window history still referenced, and overwrite never released the old hash at all (an unbounded silent leak whose accidental byproduct was the only thing "preserving" history). Reading the past could therefore return a stale field, a dangling hash, or nothing, depending on luck. Fix: blob reclamation becomes a HISTORY decision instead of a LIVENESS decision. Each blob's metadata now carries historyRefCount alongside the live refCount: - The commit seam counts one history reference per persisted before-image record carrying a content hash (commitTransaction staging and the group-commit flush), recorded BEFORE the record-set persists and carried in the generation delta (blobHashes — always present on new deltas, so compaction only falls back to reading records for pre-contract generations). An aborted transaction compensates best-effort. - unlink/rmdir/overwrite drop ONLY the live reference (BlobStorage.delete → release; overwrite finally releases the superseded hash — cancelling the dedup increment on same-content rewrites and closing the leak), and only AFTER the canonical mutation commits, so a failed delete can never leave a live file whose bytes compaction might reclaim. - History compaction is the ONE reclamation point: after deleting a generation's record-set it releases that set's references and physically reclaims any hash at zero live AND zero history references. Pins are exempt automatically. Crash ordering is over-count-only in every path (record before persist, release after delete), so a crash can leak until the scrub recounts but can never reclaim bytes a retained generation needs. scrubBlobHistoryRefCounts() restores exactness; existing stores get a one-time marker-gated backfill on open, failing into leak-safe mode (reclamation disabled) rather than guessing. On top of the protected history, the temporal API the generational model always implied: - vfs.readFile(path, { asOf }) — the exact bytes as of a generation or Date, materialized from the history (pinned view released so compaction is never blocked by a read). - vfs.history(path) — FileVersion[] ascending ({ generation, timestamp, hash, size, mimeType? }), the newest entry being the live state. - Overwrites now refresh the file entity's data/embedding text — semantic search and the data field previously served the FIRST version's text forever (the stale-field defect a consumer's incident recovery depended on by luck). Integration suite (temporal-vfs.test.ts): per-version exact reads + history listing, leak-fix + history protection on overwrite, rm keeps bytes readable, compaction reclaims past-window bytes and preserves in-window (including the cross-file dedup case where an old file's history and a newer file's removal share one hash), data freshness, and scrub exactness.
2026-07-10 16:43:48 -07:00
/**
* Number of persisted generation record-sets (Model-B before-images) that
* reference this hash the blob's membership in the temporal history.
* Bytes are physically reclaimed only when BOTH counts are zero, and only
* by history compaction: live references protect the present, history
* references protect every `asOf` read inside the retention window (pins
* ride generation pinning, which compaction already respects). Absent on
* metas written before the temporal contract existed (treated as 0; the
* one-time open-time backfill makes legacy stores exact).
*/
historyRefCount?: number
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
}
/**
* @description Options for {@link BlobStorage.write}.
*/
export interface BlobWriteOptions {
/**
* Compression strategy. `'auto'` (default) compresses payloads above 1 KB
* with zstd unless the MIME type says the bytes are already compressed.
* Explicit `'none'`/`'zstd'` is honoured as asserted by the caller.
*/
compression?: 'none' | 'zstd' | 'auto'
/**
* Content type of the payload (e.g. `image/jpeg`, `video/mp4`).
*
* When set on an `auto`-compression write, the store skips zstd for MIME
* types that are already heavily compressed (JPEG, PNG, WebP, MP4, WebM,
* MP3, ZIP, PDF, etc.). zstd over these formats wastes CPU and rarely
* shaves more than a single-digit percent usually it actually grows the
* payload because the entropy is already maximised by the format itself.
*
* Has no effect when `compression` is `'none'` or `'zstd'` explicitly
* the caller is asserting the choice and the store honours it.
*/
mimeType?: string
}
/**
* MIME types whose payload is already heavily compressed. zstd over these is
* almost always a CPU-only loss the bytes are already near entropy-maximal,
* so the output is the same size or slightly larger plus the cost of running
* the compressor. Used by `BlobStorage.selectCompression()` in `auto` mode.
*
* Conservative denylist (well-known formats only). Anything not in this set
* goes through the size heuristic. False negatives (compressing something we
* should have skipped) waste CPU; false positives (skipping something we
* could have compressed) waste a few percent of bytes. The denylist favours
* CPU-cycle safety because the formats listed here are the ones where
* gzip/zstd is reliably a net loss.
*/
const ALREADY_COMPRESSED_MIME_TYPES = new Set<string>([
// Images
'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp',
'image/avif', 'image/heic', 'image/heif', 'image/jp2',
// Video
'video/mp4', 'video/webm', 'video/x-matroska', 'video/quicktime',
'video/x-msvideo', 'video/mpeg', 'video/3gpp', 'video/x-ms-wmv',
// Audio
'audio/mpeg', 'audio/mp4', 'audio/aac', 'audio/ogg', 'audio/webm',
'audio/opus', 'audio/flac', 'audio/x-ms-wma',
// Archives
'application/zip', 'application/gzip', 'application/x-gzip',
'application/x-bzip2', 'application/x-7z-compressed',
'application/x-rar-compressed', 'application/x-xz', 'application/x-zstd',
'application/x-compress', 'application/vnd.rar',
// Documents with internal compression
'application/pdf', 'application/epub+zip',
// Office formats (zip-based)
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.oasis.opendocument.text',
'application/vnd.oasis.opendocument.spreadsheet',
'application/vnd.oasis.opendocument.presentation'
])
/**
* @description True when the MIME type names a payload format known to be
* already heavily compressed. Strips any `;charset=…` / `;boundary=…`
* parameters and lowercases the bare type/subtype before lookup, so
* `'IMAGE/JPEG; charset=binary'` and `'image/jpeg'` resolve identically.
* @param mimeType - MIME type string, or `undefined`.
* @returns Whether `auto` compression should skip zstd for this payload.
*/
export function isAlreadyCompressedMimeType(mimeType: string | undefined): boolean {
if (!mimeType) return false
const bare = mimeType.split(';', 1)[0].trim().toLowerCase()
return ALREADY_COMPRESSED_MIME_TYPES.has(bare)
}
/**
* LRU cache entry.
*/
interface CacheEntry {
data: Buffer
metadata: BlobMetadata
lastAccess: number
size: number
}
/**
* @description Content-addressed, deduplicating, reference-counted blob
* store with MIME-aware zstd compression and an LRU read cache. See the
* module doc for the persistence contract.
*
* @example
* const hash = await blobStorage.write(buffer, { mimeType: 'image/png' })
* const bytes = await blobStorage.read(hash) // verified against the hash
feat: temporal VFS — file content joins the Model-B immutability model The temporal model had a hole exactly where files were concerned: every entity write is an immutable generation with before-images, but VFS content BYTES lived under an eager refCount GC left over from the pre-8.0 design — unlink could physically destroy bytes that in-window history still referenced, and overwrite never released the old hash at all (an unbounded silent leak whose accidental byproduct was the only thing "preserving" history). Reading the past could therefore return a stale field, a dangling hash, or nothing, depending on luck. Fix: blob reclamation becomes a HISTORY decision instead of a LIVENESS decision. Each blob's metadata now carries historyRefCount alongside the live refCount: - The commit seam counts one history reference per persisted before-image record carrying a content hash (commitTransaction staging and the group-commit flush), recorded BEFORE the record-set persists and carried in the generation delta (blobHashes — always present on new deltas, so compaction only falls back to reading records for pre-contract generations). An aborted transaction compensates best-effort. - unlink/rmdir/overwrite drop ONLY the live reference (BlobStorage.delete → release; overwrite finally releases the superseded hash — cancelling the dedup increment on same-content rewrites and closing the leak), and only AFTER the canonical mutation commits, so a failed delete can never leave a live file whose bytes compaction might reclaim. - History compaction is the ONE reclamation point: after deleting a generation's record-set it releases that set's references and physically reclaims any hash at zero live AND zero history references. Pins are exempt automatically. Crash ordering is over-count-only in every path (record before persist, release after delete), so a crash can leak until the scrub recounts but can never reclaim bytes a retained generation needs. scrubBlobHistoryRefCounts() restores exactness; existing stores get a one-time marker-gated backfill on open, failing into leak-safe mode (reclamation disabled) rather than guessing. On top of the protected history, the temporal API the generational model always implied: - vfs.readFile(path, { asOf }) — the exact bytes as of a generation or Date, materialized from the history (pinned view released so compaction is never blocked by a read). - vfs.history(path) — FileVersion[] ascending ({ generation, timestamp, hash, size, mimeType? }), the newest entry being the live state. - Overwrites now refresh the file entity's data/embedding text — semantic search and the data field previously served the FIRST version's text forever (the stale-field defect a consumer's incident recovery depended on by luck). Integration suite (temporal-vfs.test.ts): per-version exact reads + history listing, leak-fix + history protection on overwrite, rm keeps bytes readable, compaction reclaims past-window bytes and preserves in-window (including the cross-file dedup case where an old file's history and a newer file's removal share one hash), data freshness, and scrub exactness.
2026-07-10 16:43:48 -07:00
* await blobStorage.release(hash) // drop one LIVE reference
* // bytes are physically reclaimed only by history compaction, once no
* // live reference AND no in-window generation references the hash
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
*/
export class BlobStorage {
private adapter: BlobStoreAdapter
private cache: Map<string, CacheEntry>
private cacheMaxSize: number
private currentCacheSize: number
// Compression (lazily loaded)
private zstdCompress?: (data: Buffer) => Promise<Buffer>
private zstdDecompress?: (data: Buffer) => Promise<Buffer>
private compressionReady = false
// Configuration
private readonly CACHE_MAX_SIZE = 100 * 1024 * 1024 // 100MB default
private readonly COMPRESSION_THRESHOLD = 1024 // 1KB - don't compress smaller
fix: atomic ifAbsent/upsert inserts + exact blob reference counts under concurrency The fast-follow to the ifRev CAS fix: the sweep for the same check-then-act class found two more instances, both now closed with the same discipline — the decision runs at the serialization point that guards the apply. add({ifAbsent}) / add({upsert}): the absence check ran before the commit mutex, so N concurrent same-id creates could all pass it and all write — the second silently overwriting the first, violating ifAbsent's "return the existing id WITHOUT writing" contract and upsert's merge-never-clobber contract. The insert leg now carries a must-be-absent commit precondition (the same conditional-commit primitive ifRev uses), verified under the commit mutex against the authoritative before-image. A losing caller takes its documented resolution instead of overwriting: ifAbsent returns the existing id with zero writes; upsert merges into the now-existing entity via update() (shared param mapping in upsertMergeParams so the planning-time branch and the conflict retry can never drift), with a bounded retry if a concurrent delete lands between the conflict and the merge. The planning-time checks remain as fast-fails. transact()'s batch-level ifAbsent/upsert keep planning-time semantics (documented, converges to a valid entity). BlobStorage: write()'s dedup decision (exists → increment refCount, absent → create at 1) and delete()'s decrement-then-remove were unserialized read-modify-writes over blob-meta:<hash>. Concurrent writes of identical content could lose references, so a later delete removed bytes another file still referenced (data loss), or leaked unreferenced blobs. All reference-count-bearing mutations now serialize through a per-hash InMemoryMutex — distinct content never contends; incrementRefCount/ decrementRefCount are documented lock-assumed internals. Both fixes are complete by construction in-process: storage enforces single-writer-per-directory, so the process is the whole concurrency domain. Tests (tests/integration/ifabsent-upsert-blob-concurrency.test.ts): 8-way ifAbsent storm advances the store by exactly one generation with _rev 1; 8-way upsert storm on an absent id yields one create + seven merges (_rev === 8); sequential ifAbsent/upsert semantics pinned unchanged; N identical concurrent blob writes → refCount === N; the blob survives until the true last reference drops; interleaved write/delete storm never loses a landed reference.
2026-07-08 15:13:26 -07:00
/**
* Per-hash write serialization. Every reference-count-bearing mutation
* (`write`'s dedup check-then-act, `delete`'s decrement-then-maybe-remove)
* is a read-modify-write over `blob-meta:<hash>` unserialized, two
* concurrent writes of identical content both saw "absent" and both wrote
* `refCount: 1` (one reference lost a later delete removed bytes another
* file still referenced), and concurrent increments/decrements could drop
* counts. Keyed by hash, so distinct content never contends; the process
* is the whole concurrency domain (storage enforces single-writer per
* directory).
*/
private readonly hashLocks = new InMemoryMutex()
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
/**
* @param adapter - Key-value bridge to persist through.
* @param options - `cacheMaxSize` bounds the LRU read cache (bytes,
* default 100 MB).
*/
constructor(adapter: BlobStoreAdapter, options?: { cacheMaxSize?: number }) {
this.adapter = adapter
this.cache = new Map()
this.cacheMaxSize = options?.cacheMaxSize ?? this.CACHE_MAX_SIZE
this.currentCacheSize = 0
}
/**
* Lazy-load the zstd compression module. Falls back to uncompressed
* storage when the optional dependency is unavailable.
*/
private async ensureCompressionReady(): Promise<void> {
if (this.compressionReady) return
try {
// Dynamic import to avoid loading if not needed
// @ts-ignore - Optional dependency, gracefully handled if missing
const zstd = await import('@mongodb-js/zstd')
this.zstdCompress = async (data: Buffer) => {
return Buffer.from(await zstd.compress(data, 3)) // Level 3 = fast
}
this.zstdDecompress = async (data: Buffer) => {
return Buffer.from(await zstd.decompress(data))
}
} catch (error) {
console.warn('zstd compression not available, falling back to uncompressed')
this.zstdCompress = undefined
this.zstdDecompress = undefined
}
this.compressionReady = true
}
/**
* @description Compute the SHA-256 content hash of `data`.
* @param data - Bytes to hash.
* @returns Hex-encoded SHA-256 hash.
*/
static hash(data: Buffer): string {
return createHash('sha256').update(data).digest('hex')
}
/**
* @description Write a blob. Content-addressed: the SHA-256 hash of the
* bytes is the storage key, so identical payloads deduplicate (the
* existing blob's reference count is incremented instead of rewriting).
*
* @param data - Blob bytes.
* @param options - Compression strategy and MIME hint (see
* {@link BlobWriteOptions}).
* @returns The blob's SHA-256 hash.
*/
async write(data: Buffer, options: BlobWriteOptions = {}): Promise<string> {
const hash = BlobStorage.hash(data)
fix: atomic ifAbsent/upsert inserts + exact blob reference counts under concurrency The fast-follow to the ifRev CAS fix: the sweep for the same check-then-act class found two more instances, both now closed with the same discipline — the decision runs at the serialization point that guards the apply. add({ifAbsent}) / add({upsert}): the absence check ran before the commit mutex, so N concurrent same-id creates could all pass it and all write — the second silently overwriting the first, violating ifAbsent's "return the existing id WITHOUT writing" contract and upsert's merge-never-clobber contract. The insert leg now carries a must-be-absent commit precondition (the same conditional-commit primitive ifRev uses), verified under the commit mutex against the authoritative before-image. A losing caller takes its documented resolution instead of overwriting: ifAbsent returns the existing id with zero writes; upsert merges into the now-existing entity via update() (shared param mapping in upsertMergeParams so the planning-time branch and the conflict retry can never drift), with a bounded retry if a concurrent delete lands between the conflict and the merge. The planning-time checks remain as fast-fails. transact()'s batch-level ifAbsent/upsert keep planning-time semantics (documented, converges to a valid entity). BlobStorage: write()'s dedup decision (exists → increment refCount, absent → create at 1) and delete()'s decrement-then-remove were unserialized read-modify-writes over blob-meta:<hash>. Concurrent writes of identical content could lose references, so a later delete removed bytes another file still referenced (data loss), or leaked unreferenced blobs. All reference-count-bearing mutations now serialize through a per-hash InMemoryMutex — distinct content never contends; incrementRefCount/ decrementRefCount are documented lock-assumed internals. Both fixes are complete by construction in-process: storage enforces single-writer-per-directory, so the process is the whole concurrency domain. Tests (tests/integration/ifabsent-upsert-blob-concurrency.test.ts): 8-way ifAbsent storm advances the store by exactly one generation with _rev 1; 8-way upsert storm on an absent id yields one create + seven merges (_rev === 8); sequential ifAbsent/upsert semantics pinned unchanged; N identical concurrent blob writes → refCount === N; the blob survives until the true last reference drops; interleaved write/delete storm never loses a landed reference.
2026-07-08 15:13:26 -07:00
// The dedup decision (exists → add a reference; absent → create with
// refCount 1) is check-then-act over the same metadata a concurrent
// same-content write mutates — serialized per hash so N concurrent
// writes of identical content yield exactly N references, never a lost
// count (a lost reference turns a later delete into premature removal
// of bytes another file still needs).
return this.hashLocks.runExclusive(hash, async () => {
// Deduplication: identical content already stored — just add a reference.
if (await this.has(hash)) {
await this.incrementRefCount(hash)
return hash
}
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
fix: atomic ifAbsent/upsert inserts + exact blob reference counts under concurrency The fast-follow to the ifRev CAS fix: the sweep for the same check-then-act class found two more instances, both now closed with the same discipline — the decision runs at the serialization point that guards the apply. add({ifAbsent}) / add({upsert}): the absence check ran before the commit mutex, so N concurrent same-id creates could all pass it and all write — the second silently overwriting the first, violating ifAbsent's "return the existing id WITHOUT writing" contract and upsert's merge-never-clobber contract. The insert leg now carries a must-be-absent commit precondition (the same conditional-commit primitive ifRev uses), verified under the commit mutex against the authoritative before-image. A losing caller takes its documented resolution instead of overwriting: ifAbsent returns the existing id with zero writes; upsert merges into the now-existing entity via update() (shared param mapping in upsertMergeParams so the planning-time branch and the conflict retry can never drift), with a bounded retry if a concurrent delete lands between the conflict and the merge. The planning-time checks remain as fast-fails. transact()'s batch-level ifAbsent/upsert keep planning-time semantics (documented, converges to a valid entity). BlobStorage: write()'s dedup decision (exists → increment refCount, absent → create at 1) and delete()'s decrement-then-remove were unserialized read-modify-writes over blob-meta:<hash>. Concurrent writes of identical content could lose references, so a later delete removed bytes another file still referenced (data loss), or leaked unreferenced blobs. All reference-count-bearing mutations now serialize through a per-hash InMemoryMutex — distinct content never contends; incrementRefCount/ decrementRefCount are documented lock-assumed internals. Both fixes are complete by construction in-process: storage enforces single-writer-per-directory, so the process is the whole concurrency domain. Tests (tests/integration/ifabsent-upsert-blob-concurrency.test.ts): 8-way ifAbsent storm advances the store by exactly one generation with _rev 1; 8-way upsert storm on an absent id yields one create + seven merges (_rev === 8); sequential ifAbsent/upsert semantics pinned unchanged; N identical concurrent blob writes → refCount === N; the blob survives until the true last reference drops; interleaved write/delete storm never loses a landed reference.
2026-07-08 15:13:26 -07:00
await this.ensureCompressionReady()
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
fix: atomic ifAbsent/upsert inserts + exact blob reference counts under concurrency The fast-follow to the ifRev CAS fix: the sweep for the same check-then-act class found two more instances, both now closed with the same discipline — the decision runs at the serialization point that guards the apply. add({ifAbsent}) / add({upsert}): the absence check ran before the commit mutex, so N concurrent same-id creates could all pass it and all write — the second silently overwriting the first, violating ifAbsent's "return the existing id WITHOUT writing" contract and upsert's merge-never-clobber contract. The insert leg now carries a must-be-absent commit precondition (the same conditional-commit primitive ifRev uses), verified under the commit mutex against the authoritative before-image. A losing caller takes its documented resolution instead of overwriting: ifAbsent returns the existing id with zero writes; upsert merges into the now-existing entity via update() (shared param mapping in upsertMergeParams so the planning-time branch and the conflict retry can never drift), with a bounded retry if a concurrent delete lands between the conflict and the merge. The planning-time checks remain as fast-fails. transact()'s batch-level ifAbsent/upsert keep planning-time semantics (documented, converges to a valid entity). BlobStorage: write()'s dedup decision (exists → increment refCount, absent → create at 1) and delete()'s decrement-then-remove were unserialized read-modify-writes over blob-meta:<hash>. Concurrent writes of identical content could lose references, so a later delete removed bytes another file still referenced (data loss), or leaked unreferenced blobs. All reference-count-bearing mutations now serialize through a per-hash InMemoryMutex — distinct content never contends; incrementRefCount/ decrementRefCount are documented lock-assumed internals. Both fixes are complete by construction in-process: storage enforces single-writer-per-directory, so the process is the whole concurrency domain. Tests (tests/integration/ifabsent-upsert-blob-concurrency.test.ts): 8-way ifAbsent storm advances the store by exactly one generation with _rev 1; 8-way upsert storm on an absent id yields one create + seven merges (_rev === 8); sequential ifAbsent/upsert semantics pinned unchanged; N identical concurrent blob writes → refCount === N; the blob survives until the true last reference drops; interleaved write/delete storm never loses a landed reference.
2026-07-08 15:13:26 -07:00
// Determine compression strategy
const compression = this.selectCompression(data, options)
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
fix: atomic ifAbsent/upsert inserts + exact blob reference counts under concurrency The fast-follow to the ifRev CAS fix: the sweep for the same check-then-act class found two more instances, both now closed with the same discipline — the decision runs at the serialization point that guards the apply. add({ifAbsent}) / add({upsert}): the absence check ran before the commit mutex, so N concurrent same-id creates could all pass it and all write — the second silently overwriting the first, violating ifAbsent's "return the existing id WITHOUT writing" contract and upsert's merge-never-clobber contract. The insert leg now carries a must-be-absent commit precondition (the same conditional-commit primitive ifRev uses), verified under the commit mutex against the authoritative before-image. A losing caller takes its documented resolution instead of overwriting: ifAbsent returns the existing id with zero writes; upsert merges into the now-existing entity via update() (shared param mapping in upsertMergeParams so the planning-time branch and the conflict retry can never drift), with a bounded retry if a concurrent delete lands between the conflict and the merge. The planning-time checks remain as fast-fails. transact()'s batch-level ifAbsent/upsert keep planning-time semantics (documented, converges to a valid entity). BlobStorage: write()'s dedup decision (exists → increment refCount, absent → create at 1) and delete()'s decrement-then-remove were unserialized read-modify-writes over blob-meta:<hash>. Concurrent writes of identical content could lose references, so a later delete removed bytes another file still referenced (data loss), or leaked unreferenced blobs. All reference-count-bearing mutations now serialize through a per-hash InMemoryMutex — distinct content never contends; incrementRefCount/ decrementRefCount are documented lock-assumed internals. Both fixes are complete by construction in-process: storage enforces single-writer-per-directory, so the process is the whole concurrency domain. Tests (tests/integration/ifabsent-upsert-blob-concurrency.test.ts): 8-way ifAbsent storm advances the store by exactly one generation with _rev 1; 8-way upsert storm on an absent id yields one create + seven merges (_rev === 8); sequential ifAbsent/upsert semantics pinned unchanged; N identical concurrent blob writes → refCount === N; the blob survives until the true last reference drops; interleaved write/delete storm never loses a landed reference.
2026-07-08 15:13:26 -07:00
// Compress if needed
let finalData = data
let compressedSize = data.length
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
fix: atomic ifAbsent/upsert inserts + exact blob reference counts under concurrency The fast-follow to the ifRev CAS fix: the sweep for the same check-then-act class found two more instances, both now closed with the same discipline — the decision runs at the serialization point that guards the apply. add({ifAbsent}) / add({upsert}): the absence check ran before the commit mutex, so N concurrent same-id creates could all pass it and all write — the second silently overwriting the first, violating ifAbsent's "return the existing id WITHOUT writing" contract and upsert's merge-never-clobber contract. The insert leg now carries a must-be-absent commit precondition (the same conditional-commit primitive ifRev uses), verified under the commit mutex against the authoritative before-image. A losing caller takes its documented resolution instead of overwriting: ifAbsent returns the existing id with zero writes; upsert merges into the now-existing entity via update() (shared param mapping in upsertMergeParams so the planning-time branch and the conflict retry can never drift), with a bounded retry if a concurrent delete lands between the conflict and the merge. The planning-time checks remain as fast-fails. transact()'s batch-level ifAbsent/upsert keep planning-time semantics (documented, converges to a valid entity). BlobStorage: write()'s dedup decision (exists → increment refCount, absent → create at 1) and delete()'s decrement-then-remove were unserialized read-modify-writes over blob-meta:<hash>. Concurrent writes of identical content could lose references, so a later delete removed bytes another file still referenced (data loss), or leaked unreferenced blobs. All reference-count-bearing mutations now serialize through a per-hash InMemoryMutex — distinct content never contends; incrementRefCount/ decrementRefCount are documented lock-assumed internals. Both fixes are complete by construction in-process: storage enforces single-writer-per-directory, so the process is the whole concurrency domain. Tests (tests/integration/ifabsent-upsert-blob-concurrency.test.ts): 8-way ifAbsent storm advances the store by exactly one generation with _rev 1; 8-way upsert storm on an absent id yields one create + seven merges (_rev === 8); sequential ifAbsent/upsert semantics pinned unchanged; N identical concurrent blob writes → refCount === N; the blob survives until the true last reference drops; interleaved write/delete storm never loses a landed reference.
2026-07-08 15:13:26 -07:00
if (compression === 'zstd' && this.zstdCompress) {
finalData = await this.zstdCompress(data)
compressedSize = finalData.length
}
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
fix: atomic ifAbsent/upsert inserts + exact blob reference counts under concurrency The fast-follow to the ifRev CAS fix: the sweep for the same check-then-act class found two more instances, both now closed with the same discipline — the decision runs at the serialization point that guards the apply. add({ifAbsent}) / add({upsert}): the absence check ran before the commit mutex, so N concurrent same-id creates could all pass it and all write — the second silently overwriting the first, violating ifAbsent's "return the existing id WITHOUT writing" contract and upsert's merge-never-clobber contract. The insert leg now carries a must-be-absent commit precondition (the same conditional-commit primitive ifRev uses), verified under the commit mutex against the authoritative before-image. A losing caller takes its documented resolution instead of overwriting: ifAbsent returns the existing id with zero writes; upsert merges into the now-existing entity via update() (shared param mapping in upsertMergeParams so the planning-time branch and the conflict retry can never drift), with a bounded retry if a concurrent delete lands between the conflict and the merge. The planning-time checks remain as fast-fails. transact()'s batch-level ifAbsent/upsert keep planning-time semantics (documented, converges to a valid entity). BlobStorage: write()'s dedup decision (exists → increment refCount, absent → create at 1) and delete()'s decrement-then-remove were unserialized read-modify-writes over blob-meta:<hash>. Concurrent writes of identical content could lose references, so a later delete removed bytes another file still referenced (data loss), or leaked unreferenced blobs. All reference-count-bearing mutations now serialize through a per-hash InMemoryMutex — distinct content never contends; incrementRefCount/ decrementRefCount are documented lock-assumed internals. Both fixes are complete by construction in-process: storage enforces single-writer-per-directory, so the process is the whole concurrency domain. Tests (tests/integration/ifabsent-upsert-blob-concurrency.test.ts): 8-way ifAbsent storm advances the store by exactly one generation with _rev 1; 8-way upsert storm on an absent id yields one create + seven merges (_rev === 8); sequential ifAbsent/upsert semantics pinned unchanged; N identical concurrent blob writes → refCount === N; the blob survives until the true last reference drops; interleaved write/delete storm never loses a landed reference.
2026-07-08 15:13:26 -07:00
// Record the ACTUAL compression state, not the intended one — prevents
// corruption if compression failed to initialize.
const actualCompression = finalData === data ? 'none' : compression
const metadata: BlobMetadata = {
hash,
size: data.length,
compressedSize,
compression: actualCompression,
createdAt: Date.now(),
refCount: 1
}
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
fix: atomic ifAbsent/upsert inserts + exact blob reference counts under concurrency The fast-follow to the ifRev CAS fix: the sweep for the same check-then-act class found two more instances, both now closed with the same discipline — the decision runs at the serialization point that guards the apply. add({ifAbsent}) / add({upsert}): the absence check ran before the commit mutex, so N concurrent same-id creates could all pass it and all write — the second silently overwriting the first, violating ifAbsent's "return the existing id WITHOUT writing" contract and upsert's merge-never-clobber contract. The insert leg now carries a must-be-absent commit precondition (the same conditional-commit primitive ifRev uses), verified under the commit mutex against the authoritative before-image. A losing caller takes its documented resolution instead of overwriting: ifAbsent returns the existing id with zero writes; upsert merges into the now-existing entity via update() (shared param mapping in upsertMergeParams so the planning-time branch and the conflict retry can never drift), with a bounded retry if a concurrent delete lands between the conflict and the merge. The planning-time checks remain as fast-fails. transact()'s batch-level ifAbsent/upsert keep planning-time semantics (documented, converges to a valid entity). BlobStorage: write()'s dedup decision (exists → increment refCount, absent → create at 1) and delete()'s decrement-then-remove were unserialized read-modify-writes over blob-meta:<hash>. Concurrent writes of identical content could lose references, so a later delete removed bytes another file still referenced (data loss), or leaked unreferenced blobs. All reference-count-bearing mutations now serialize through a per-hash InMemoryMutex — distinct content never contends; incrementRefCount/ decrementRefCount are documented lock-assumed internals. Both fixes are complete by construction in-process: storage enforces single-writer-per-directory, so the process is the whole concurrency domain. Tests (tests/integration/ifabsent-upsert-blob-concurrency.test.ts): 8-way ifAbsent storm advances the store by exactly one generation with _rev 1; 8-way upsert storm on an absent id yields one create + seven merges (_rev === 8); sequential ifAbsent/upsert semantics pinned unchanged; N identical concurrent blob writes → refCount === N; the blob survives until the true last reference drops; interleaved write/delete storm never loses a landed reference.
2026-07-08 15:13:26 -07:00
await this.adapter.put(`blob:${hash}`, finalData)
await this.adapter.put(`blob-meta:${hash}`, Buffer.from(JSON.stringify(metadata)))
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
fix: atomic ifAbsent/upsert inserts + exact blob reference counts under concurrency The fast-follow to the ifRev CAS fix: the sweep for the same check-then-act class found two more instances, both now closed with the same discipline — the decision runs at the serialization point that guards the apply. add({ifAbsent}) / add({upsert}): the absence check ran before the commit mutex, so N concurrent same-id creates could all pass it and all write — the second silently overwriting the first, violating ifAbsent's "return the existing id WITHOUT writing" contract and upsert's merge-never-clobber contract. The insert leg now carries a must-be-absent commit precondition (the same conditional-commit primitive ifRev uses), verified under the commit mutex against the authoritative before-image. A losing caller takes its documented resolution instead of overwriting: ifAbsent returns the existing id with zero writes; upsert merges into the now-existing entity via update() (shared param mapping in upsertMergeParams so the planning-time branch and the conflict retry can never drift), with a bounded retry if a concurrent delete lands between the conflict and the merge. The planning-time checks remain as fast-fails. transact()'s batch-level ifAbsent/upsert keep planning-time semantics (documented, converges to a valid entity). BlobStorage: write()'s dedup decision (exists → increment refCount, absent → create at 1) and delete()'s decrement-then-remove were unserialized read-modify-writes over blob-meta:<hash>. Concurrent writes of identical content could lose references, so a later delete removed bytes another file still referenced (data loss), or leaked unreferenced blobs. All reference-count-bearing mutations now serialize through a per-hash InMemoryMutex — distinct content never contends; incrementRefCount/ decrementRefCount are documented lock-assumed internals. Both fixes are complete by construction in-process: storage enforces single-writer-per-directory, so the process is the whole concurrency domain. Tests (tests/integration/ifabsent-upsert-blob-concurrency.test.ts): 8-way ifAbsent storm advances the store by exactly one generation with _rev 1; 8-way upsert storm on an absent id yields one create + seven merges (_rev === 8); sequential ifAbsent/upsert semantics pinned unchanged; N identical concurrent blob writes → refCount === N; the blob survives until the true last reference drops; interleaved write/delete storm never loses a landed reference.
2026-07-08 15:13:26 -07:00
// Write-through cache (caches the ORIGINAL bytes, not the compressed form)
this.addToCache(hash, data, metadata)
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
fix: atomic ifAbsent/upsert inserts + exact blob reference counts under concurrency The fast-follow to the ifRev CAS fix: the sweep for the same check-then-act class found two more instances, both now closed with the same discipline — the decision runs at the serialization point that guards the apply. add({ifAbsent}) / add({upsert}): the absence check ran before the commit mutex, so N concurrent same-id creates could all pass it and all write — the second silently overwriting the first, violating ifAbsent's "return the existing id WITHOUT writing" contract and upsert's merge-never-clobber contract. The insert leg now carries a must-be-absent commit precondition (the same conditional-commit primitive ifRev uses), verified under the commit mutex against the authoritative before-image. A losing caller takes its documented resolution instead of overwriting: ifAbsent returns the existing id with zero writes; upsert merges into the now-existing entity via update() (shared param mapping in upsertMergeParams so the planning-time branch and the conflict retry can never drift), with a bounded retry if a concurrent delete lands between the conflict and the merge. The planning-time checks remain as fast-fails. transact()'s batch-level ifAbsent/upsert keep planning-time semantics (documented, converges to a valid entity). BlobStorage: write()'s dedup decision (exists → increment refCount, absent → create at 1) and delete()'s decrement-then-remove were unserialized read-modify-writes over blob-meta:<hash>. Concurrent writes of identical content could lose references, so a later delete removed bytes another file still referenced (data loss), or leaked unreferenced blobs. All reference-count-bearing mutations now serialize through a per-hash InMemoryMutex — distinct content never contends; incrementRefCount/ decrementRefCount are documented lock-assumed internals. Both fixes are complete by construction in-process: storage enforces single-writer-per-directory, so the process is the whole concurrency domain. Tests (tests/integration/ifabsent-upsert-blob-concurrency.test.ts): 8-way ifAbsent storm advances the store by exactly one generation with _rev 1; 8-way upsert storm on an absent id yields one create + seven merges (_rev === 8); sequential ifAbsent/upsert semantics pinned unchanged; N identical concurrent blob writes → refCount === N; the blob survives until the true last reference drops; interleaved write/delete storm never loses a landed reference.
2026-07-08 15:13:26 -07:00
return hash
})
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
}
/**
* @description Read a blob: LRU cache first, then storage with
* decompression and integrity verification (the bytes are re-hashed and
* compared against the requested hash).
*
* @param hash - The blob's SHA-256 hash.
* @returns The original (decompressed) blob bytes.
* @throws Error when the blob is missing or fails integrity verification.
*/
async read(hash: string): Promise<Buffer> {
// Check cache first
const cached = this.getFromCache(hash)
if (cached) {
return cached.data
}
const metadataBuffer = await this.adapter.get(`blob-meta:${hash}`)
if (!metadataBuffer) {
throw new Error(`Blob metadata not found: ${hash}`)
}
// Unwrap before parsing (defense-in-depth): metadata should come back as
// JSON bytes, but an adapter might return the wrapped binary format.
const metadata: BlobMetadata = JSON.parse(unwrapBinaryData(metadataBuffer).toString())
const data = await this.adapter.get(`blob:${hash}`)
if (!data) {
throw new Error(`Blob not found: ${hash}`)
}
// Decompress if needed
let finalData = data
if (metadata.compression === 'zstd') {
if (!this.zstdDecompress) {
await this.ensureCompressionReady()
}
if (!this.zstdDecompress) {
throw new Error('zstd decompression not available')
}
finalData = await this.zstdDecompress(data)
}
// Defense-in-depth unwrap: even though the bridge unwraps, verify it
// happened and re-unwrap if needed. Hash verification must run on the
// original content bytes.
const unwrappedData = unwrapBinaryData(finalData)
// Integrity verification (always on — a content-addressed store that
// returns bytes not matching the address is corruption, not a result)
if (BlobStorage.hash(unwrappedData) !== hash) {
throw new Error(`Blob integrity check failed: ${hash}`)
}
this.addToCache(hash, unwrappedData, metadata)
return unwrappedData
}
/**
* @description Whether a blob with this hash exists (cache or storage).
* @param hash - The blob's SHA-256 hash.
* @returns True when the blob exists.
*/
async has(hash: string): Promise<boolean> {
if (this.cache.has(hash)) {
return true
}
const exists = await this.adapter.get(`blob:${hash}`)
return exists !== undefined
}
/**
feat: temporal VFS — file content joins the Model-B immutability model The temporal model had a hole exactly where files were concerned: every entity write is an immutable generation with before-images, but VFS content BYTES lived under an eager refCount GC left over from the pre-8.0 design — unlink could physically destroy bytes that in-window history still referenced, and overwrite never released the old hash at all (an unbounded silent leak whose accidental byproduct was the only thing "preserving" history). Reading the past could therefore return a stale field, a dangling hash, or nothing, depending on luck. Fix: blob reclamation becomes a HISTORY decision instead of a LIVENESS decision. Each blob's metadata now carries historyRefCount alongside the live refCount: - The commit seam counts one history reference per persisted before-image record carrying a content hash (commitTransaction staging and the group-commit flush), recorded BEFORE the record-set persists and carried in the generation delta (blobHashes — always present on new deltas, so compaction only falls back to reading records for pre-contract generations). An aborted transaction compensates best-effort. - unlink/rmdir/overwrite drop ONLY the live reference (BlobStorage.delete → release; overwrite finally releases the superseded hash — cancelling the dedup increment on same-content rewrites and closing the leak), and only AFTER the canonical mutation commits, so a failed delete can never leave a live file whose bytes compaction might reclaim. - History compaction is the ONE reclamation point: after deleting a generation's record-set it releases that set's references and physically reclaims any hash at zero live AND zero history references. Pins are exempt automatically. Crash ordering is over-count-only in every path (record before persist, release after delete), so a crash can leak until the scrub recounts but can never reclaim bytes a retained generation needs. scrubBlobHistoryRefCounts() restores exactness; existing stores get a one-time marker-gated backfill on open, failing into leak-safe mode (reclamation disabled) rather than guessing. On top of the protected history, the temporal API the generational model always implied: - vfs.readFile(path, { asOf }) — the exact bytes as of a generation or Date, materialized from the history (pinned view released so compaction is never blocked by a read). - vfs.history(path) — FileVersion[] ascending ({ generation, timestamp, hash, size, mimeType? }), the newest entry being the live state. - Overwrites now refresh the file entity's data/embedding text — semantic search and the data field previously served the FIRST version's text forever (the stale-field defect a consumer's incident recovery depended on by luck). Integration suite (temporal-vfs.test.ts): per-version exact reads + history listing, leak-fix + history protection on overwrite, rm keeps bytes readable, compaction reclaims past-window bytes and preserves in-window (including the cross-file dedup case where an old file's history and a newer file's removal share one hash), data freshness, and scrub exactness.
2026-07-10 16:43:48 -07:00
* @description Drop one LIVE reference to the blob. Never deletes bytes
* blob content is immutable under the temporal model, exactly like every
* other record: a past generation's `asOf` read may still need these bytes
* even when no live file references them. Physical reclamation happens in
* ONE place only history compaction via {@link reclaimIfUnreferenced},
* once no live reference AND no retained generation references the hash.
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
*
* @param hash - The blob's SHA-256 hash.
*/
feat: temporal VFS — file content joins the Model-B immutability model The temporal model had a hole exactly where files were concerned: every entity write is an immutable generation with before-images, but VFS content BYTES lived under an eager refCount GC left over from the pre-8.0 design — unlink could physically destroy bytes that in-window history still referenced, and overwrite never released the old hash at all (an unbounded silent leak whose accidental byproduct was the only thing "preserving" history). Reading the past could therefore return a stale field, a dangling hash, or nothing, depending on luck. Fix: blob reclamation becomes a HISTORY decision instead of a LIVENESS decision. Each blob's metadata now carries historyRefCount alongside the live refCount: - The commit seam counts one history reference per persisted before-image record carrying a content hash (commitTransaction staging and the group-commit flush), recorded BEFORE the record-set persists and carried in the generation delta (blobHashes — always present on new deltas, so compaction only falls back to reading records for pre-contract generations). An aborted transaction compensates best-effort. - unlink/rmdir/overwrite drop ONLY the live reference (BlobStorage.delete → release; overwrite finally releases the superseded hash — cancelling the dedup increment on same-content rewrites and closing the leak), and only AFTER the canonical mutation commits, so a failed delete can never leave a live file whose bytes compaction might reclaim. - History compaction is the ONE reclamation point: after deleting a generation's record-set it releases that set's references and physically reclaims any hash at zero live AND zero history references. Pins are exempt automatically. Crash ordering is over-count-only in every path (record before persist, release after delete), so a crash can leak until the scrub recounts but can never reclaim bytes a retained generation needs. scrubBlobHistoryRefCounts() restores exactness; existing stores get a one-time marker-gated backfill on open, failing into leak-safe mode (reclamation disabled) rather than guessing. On top of the protected history, the temporal API the generational model always implied: - vfs.readFile(path, { asOf }) — the exact bytes as of a generation or Date, materialized from the history (pinned view released so compaction is never blocked by a read). - vfs.history(path) — FileVersion[] ascending ({ generation, timestamp, hash, size, mimeType? }), the newest entry being the live state. - Overwrites now refresh the file entity's data/embedding text — semantic search and the data field previously served the FIRST version's text forever (the stale-field defect a consumer's incident recovery depended on by luck). Integration suite (temporal-vfs.test.ts): per-version exact reads + history listing, leak-fix + history protection on overwrite, rm keeps bytes readable, compaction reclaims past-window bytes and preserves in-window (including the cross-file dedup case where an old file's history and a newer file's removal share one hash), data freshness, and scrub exactness.
2026-07-10 16:43:48 -07:00
async release(hash: string): Promise<void> {
fix: atomic ifAbsent/upsert inserts + exact blob reference counts under concurrency The fast-follow to the ifRev CAS fix: the sweep for the same check-then-act class found two more instances, both now closed with the same discipline — the decision runs at the serialization point that guards the apply. add({ifAbsent}) / add({upsert}): the absence check ran before the commit mutex, so N concurrent same-id creates could all pass it and all write — the second silently overwriting the first, violating ifAbsent's "return the existing id WITHOUT writing" contract and upsert's merge-never-clobber contract. The insert leg now carries a must-be-absent commit precondition (the same conditional-commit primitive ifRev uses), verified under the commit mutex against the authoritative before-image. A losing caller takes its documented resolution instead of overwriting: ifAbsent returns the existing id with zero writes; upsert merges into the now-existing entity via update() (shared param mapping in upsertMergeParams so the planning-time branch and the conflict retry can never drift), with a bounded retry if a concurrent delete lands between the conflict and the merge. The planning-time checks remain as fast-fails. transact()'s batch-level ifAbsent/upsert keep planning-time semantics (documented, converges to a valid entity). BlobStorage: write()'s dedup decision (exists → increment refCount, absent → create at 1) and delete()'s decrement-then-remove were unserialized read-modify-writes over blob-meta:<hash>. Concurrent writes of identical content could lose references, so a later delete removed bytes another file still referenced (data loss), or leaked unreferenced blobs. All reference-count-bearing mutations now serialize through a per-hash InMemoryMutex — distinct content never contends; incrementRefCount/ decrementRefCount are documented lock-assumed internals. Both fixes are complete by construction in-process: storage enforces single-writer-per-directory, so the process is the whole concurrency domain. Tests (tests/integration/ifabsent-upsert-blob-concurrency.test.ts): 8-way ifAbsent storm advances the store by exactly one generation with _rev 1; 8-way upsert storm on an absent id yields one create + seven merges (_rev === 8); sequential ifAbsent/upsert semantics pinned unchanged; N identical concurrent blob writes → refCount === N; the blob survives until the true last reference drops; interleaved write/delete storm never loses a landed reference.
2026-07-08 15:13:26 -07:00
await this.hashLocks.runExclusive(hash, async () => {
feat: temporal VFS — file content joins the Model-B immutability model The temporal model had a hole exactly where files were concerned: every entity write is an immutable generation with before-images, but VFS content BYTES lived under an eager refCount GC left over from the pre-8.0 design — unlink could physically destroy bytes that in-window history still referenced, and overwrite never released the old hash at all (an unbounded silent leak whose accidental byproduct was the only thing "preserving" history). Reading the past could therefore return a stale field, a dangling hash, or nothing, depending on luck. Fix: blob reclamation becomes a HISTORY decision instead of a LIVENESS decision. Each blob's metadata now carries historyRefCount alongside the live refCount: - The commit seam counts one history reference per persisted before-image record carrying a content hash (commitTransaction staging and the group-commit flush), recorded BEFORE the record-set persists and carried in the generation delta (blobHashes — always present on new deltas, so compaction only falls back to reading records for pre-contract generations). An aborted transaction compensates best-effort. - unlink/rmdir/overwrite drop ONLY the live reference (BlobStorage.delete → release; overwrite finally releases the superseded hash — cancelling the dedup increment on same-content rewrites and closing the leak), and only AFTER the canonical mutation commits, so a failed delete can never leave a live file whose bytes compaction might reclaim. - History compaction is the ONE reclamation point: after deleting a generation's record-set it releases that set's references and physically reclaims any hash at zero live AND zero history references. Pins are exempt automatically. Crash ordering is over-count-only in every path (record before persist, release after delete), so a crash can leak until the scrub recounts but can never reclaim bytes a retained generation needs. scrubBlobHistoryRefCounts() restores exactness; existing stores get a one-time marker-gated backfill on open, failing into leak-safe mode (reclamation disabled) rather than guessing. On top of the protected history, the temporal API the generational model always implied: - vfs.readFile(path, { asOf }) — the exact bytes as of a generation or Date, materialized from the history (pinned view released so compaction is never blocked by a read). - vfs.history(path) — FileVersion[] ascending ({ generation, timestamp, hash, size, mimeType? }), the newest entry being the live state. - Overwrites now refresh the file entity's data/embedding text — semantic search and the data field previously served the FIRST version's text forever (the stale-field defect a consumer's incident recovery depended on by luck). Integration suite (temporal-vfs.test.ts): per-version exact reads + history listing, leak-fix + history protection on overwrite, rm keeps bytes readable, compaction reclaims past-window bytes and preserves in-window (including the cross-file dedup case where an old file's history and a newer file's removal share one hash), data freshness, and scrub exactness.
2026-07-10 16:43:48 -07:00
await this.decrementRefCount(hash)
})
}
fix: atomic ifAbsent/upsert inserts + exact blob reference counts under concurrency The fast-follow to the ifRev CAS fix: the sweep for the same check-then-act class found two more instances, both now closed with the same discipline — the decision runs at the serialization point that guards the apply. add({ifAbsent}) / add({upsert}): the absence check ran before the commit mutex, so N concurrent same-id creates could all pass it and all write — the second silently overwriting the first, violating ifAbsent's "return the existing id WITHOUT writing" contract and upsert's merge-never-clobber contract. The insert leg now carries a must-be-absent commit precondition (the same conditional-commit primitive ifRev uses), verified under the commit mutex against the authoritative before-image. A losing caller takes its documented resolution instead of overwriting: ifAbsent returns the existing id with zero writes; upsert merges into the now-existing entity via update() (shared param mapping in upsertMergeParams so the planning-time branch and the conflict retry can never drift), with a bounded retry if a concurrent delete lands between the conflict and the merge. The planning-time checks remain as fast-fails. transact()'s batch-level ifAbsent/upsert keep planning-time semantics (documented, converges to a valid entity). BlobStorage: write()'s dedup decision (exists → increment refCount, absent → create at 1) and delete()'s decrement-then-remove were unserialized read-modify-writes over blob-meta:<hash>. Concurrent writes of identical content could lose references, so a later delete removed bytes another file still referenced (data loss), or leaked unreferenced blobs. All reference-count-bearing mutations now serialize through a per-hash InMemoryMutex — distinct content never contends; incrementRefCount/ decrementRefCount are documented lock-assumed internals. Both fixes are complete by construction in-process: storage enforces single-writer-per-directory, so the process is the whole concurrency domain. Tests (tests/integration/ifabsent-upsert-blob-concurrency.test.ts): 8-way ifAbsent storm advances the store by exactly one generation with _rev 1; 8-way upsert storm on an absent id yields one create + seven merges (_rev === 8); sequential ifAbsent/upsert semantics pinned unchanged; N identical concurrent blob writes → refCount === N; the blob survives until the true last reference drops; interleaved write/delete storm never loses a landed reference.
2026-07-08 15:13:26 -07:00
feat: temporal VFS — file content joins the Model-B immutability model The temporal model had a hole exactly where files were concerned: every entity write is an immutable generation with before-images, but VFS content BYTES lived under an eager refCount GC left over from the pre-8.0 design — unlink could physically destroy bytes that in-window history still referenced, and overwrite never released the old hash at all (an unbounded silent leak whose accidental byproduct was the only thing "preserving" history). Reading the past could therefore return a stale field, a dangling hash, or nothing, depending on luck. Fix: blob reclamation becomes a HISTORY decision instead of a LIVENESS decision. Each blob's metadata now carries historyRefCount alongside the live refCount: - The commit seam counts one history reference per persisted before-image record carrying a content hash (commitTransaction staging and the group-commit flush), recorded BEFORE the record-set persists and carried in the generation delta (blobHashes — always present on new deltas, so compaction only falls back to reading records for pre-contract generations). An aborted transaction compensates best-effort. - unlink/rmdir/overwrite drop ONLY the live reference (BlobStorage.delete → release; overwrite finally releases the superseded hash — cancelling the dedup increment on same-content rewrites and closing the leak), and only AFTER the canonical mutation commits, so a failed delete can never leave a live file whose bytes compaction might reclaim. - History compaction is the ONE reclamation point: after deleting a generation's record-set it releases that set's references and physically reclaims any hash at zero live AND zero history references. Pins are exempt automatically. Crash ordering is over-count-only in every path (record before persist, release after delete), so a crash can leak until the scrub recounts but can never reclaim bytes a retained generation needs. scrubBlobHistoryRefCounts() restores exactness; existing stores get a one-time marker-gated backfill on open, failing into leak-safe mode (reclamation disabled) rather than guessing. On top of the protected history, the temporal API the generational model always implied: - vfs.readFile(path, { asOf }) — the exact bytes as of a generation or Date, materialized from the history (pinned view released so compaction is never blocked by a read). - vfs.history(path) — FileVersion[] ascending ({ generation, timestamp, hash, size, mimeType? }), the newest entry being the live state. - Overwrites now refresh the file entity's data/embedding text — semantic search and the data field previously served the FIRST version's text forever (the stale-field defect a consumer's incident recovery depended on by luck). Integration suite (temporal-vfs.test.ts): per-version exact reads + history listing, leak-fix + history protection on overwrite, rm keeps bytes readable, compaction reclaims past-window bytes and preserves in-window (including the cross-file dedup case where an old file's history and a newer file's removal share one hash), data freshness, and scrub exactness.
2026-07-10 16:43:48 -07:00
/**
* @description Record that one persisted generation record-set references
* this hash (called by the commit path BEFORE the record-set is written
* a crash between the two can only over-count, which leaks until the scrub
* recounts; it can never under-count, which would risk premature deletion).
* A missing meta (bytes never stored or already gone) is skipped with a
* warning counting it could not make its bytes readable.
* @param hash - The blob's SHA-256 hash.
*/
async recordHistoryReference(hash: string): Promise<void> {
await this.hashLocks.runExclusive(hash, async () => {
const metadata = await this.getMetadata(hash)
if (!metadata) {
console.warn(
`[BlobStorage] history reference recorded for absent blob ${hash} — skipped`
)
fix: atomic ifAbsent/upsert inserts + exact blob reference counts under concurrency The fast-follow to the ifRev CAS fix: the sweep for the same check-then-act class found two more instances, both now closed with the same discipline — the decision runs at the serialization point that guards the apply. add({ifAbsent}) / add({upsert}): the absence check ran before the commit mutex, so N concurrent same-id creates could all pass it and all write — the second silently overwriting the first, violating ifAbsent's "return the existing id WITHOUT writing" contract and upsert's merge-never-clobber contract. The insert leg now carries a must-be-absent commit precondition (the same conditional-commit primitive ifRev uses), verified under the commit mutex against the authoritative before-image. A losing caller takes its documented resolution instead of overwriting: ifAbsent returns the existing id with zero writes; upsert merges into the now-existing entity via update() (shared param mapping in upsertMergeParams so the planning-time branch and the conflict retry can never drift), with a bounded retry if a concurrent delete lands between the conflict and the merge. The planning-time checks remain as fast-fails. transact()'s batch-level ifAbsent/upsert keep planning-time semantics (documented, converges to a valid entity). BlobStorage: write()'s dedup decision (exists → increment refCount, absent → create at 1) and delete()'s decrement-then-remove were unserialized read-modify-writes over blob-meta:<hash>. Concurrent writes of identical content could lose references, so a later delete removed bytes another file still referenced (data loss), or leaked unreferenced blobs. All reference-count-bearing mutations now serialize through a per-hash InMemoryMutex — distinct content never contends; incrementRefCount/ decrementRefCount are documented lock-assumed internals. Both fixes are complete by construction in-process: storage enforces single-writer-per-directory, so the process is the whole concurrency domain. Tests (tests/integration/ifabsent-upsert-blob-concurrency.test.ts): 8-way ifAbsent storm advances the store by exactly one generation with _rev 1; 8-way upsert storm on an absent id yields one create + seven merges (_rev === 8); sequential ifAbsent/upsert semantics pinned unchanged; N identical concurrent blob writes → refCount === N; the blob survives until the true last reference drops; interleaved write/delete storm never loses a landed reference.
2026-07-08 15:13:26 -07:00
return
}
feat: temporal VFS — file content joins the Model-B immutability model The temporal model had a hole exactly where files were concerned: every entity write is an immutable generation with before-images, but VFS content BYTES lived under an eager refCount GC left over from the pre-8.0 design — unlink could physically destroy bytes that in-window history still referenced, and overwrite never released the old hash at all (an unbounded silent leak whose accidental byproduct was the only thing "preserving" history). Reading the past could therefore return a stale field, a dangling hash, or nothing, depending on luck. Fix: blob reclamation becomes a HISTORY decision instead of a LIVENESS decision. Each blob's metadata now carries historyRefCount alongside the live refCount: - The commit seam counts one history reference per persisted before-image record carrying a content hash (commitTransaction staging and the group-commit flush), recorded BEFORE the record-set persists and carried in the generation delta (blobHashes — always present on new deltas, so compaction only falls back to reading records for pre-contract generations). An aborted transaction compensates best-effort. - unlink/rmdir/overwrite drop ONLY the live reference (BlobStorage.delete → release; overwrite finally releases the superseded hash — cancelling the dedup increment on same-content rewrites and closing the leak), and only AFTER the canonical mutation commits, so a failed delete can never leave a live file whose bytes compaction might reclaim. - History compaction is the ONE reclamation point: after deleting a generation's record-set it releases that set's references and physically reclaims any hash at zero live AND zero history references. Pins are exempt automatically. Crash ordering is over-count-only in every path (record before persist, release after delete), so a crash can leak until the scrub recounts but can never reclaim bytes a retained generation needs. scrubBlobHistoryRefCounts() restores exactness; existing stores get a one-time marker-gated backfill on open, failing into leak-safe mode (reclamation disabled) rather than guessing. On top of the protected history, the temporal API the generational model always implied: - vfs.readFile(path, { asOf }) — the exact bytes as of a generation or Date, materialized from the history (pinned view released so compaction is never blocked by a read). - vfs.history(path) — FileVersion[] ascending ({ generation, timestamp, hash, size, mimeType? }), the newest entry being the live state. - Overwrites now refresh the file entity's data/embedding text — semantic search and the data field previously served the FIRST version's text forever (the stale-field defect a consumer's incident recovery depended on by luck). Integration suite (temporal-vfs.test.ts): per-version exact reads + history listing, leak-fix + history protection on overwrite, rm keeps bytes readable, compaction reclaims past-window bytes and preserves in-window (including the cross-file dedup case where an old file's history and a newer file's removal share one hash), data freshness, and scrub exactness.
2026-07-10 16:43:48 -07:00
metadata.historyRefCount = (metadata.historyRefCount ?? 0) + 1
await this.adapter.put(`blob-meta:${hash}`, Buffer.from(JSON.stringify(metadata)))
})
}
/**
* @description Drop one history reference (called by compaction AFTER the
* referencing generation record-set is deleted the safe ordering: a crash
* between the two over-counts, never under-counts). Floored at zero.
* @param hash - The blob's SHA-256 hash.
*/
async releaseHistoryReference(hash: string): Promise<void> {
await this.hashLocks.runExclusive(hash, async () => {
const metadata = await this.getMetadata(hash)
if (!metadata) return
metadata.historyRefCount = Math.max(0, (metadata.historyRefCount ?? 0) - 1)
await this.adapter.put(`blob-meta:${hash}`, Buffer.from(JSON.stringify(metadata)))
})
}
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
feat: temporal VFS — file content joins the Model-B immutability model The temporal model had a hole exactly where files were concerned: every entity write is an immutable generation with before-images, but VFS content BYTES lived under an eager refCount GC left over from the pre-8.0 design — unlink could physically destroy bytes that in-window history still referenced, and overwrite never released the old hash at all (an unbounded silent leak whose accidental byproduct was the only thing "preserving" history). Reading the past could therefore return a stale field, a dangling hash, or nothing, depending on luck. Fix: blob reclamation becomes a HISTORY decision instead of a LIVENESS decision. Each blob's metadata now carries historyRefCount alongside the live refCount: - The commit seam counts one history reference per persisted before-image record carrying a content hash (commitTransaction staging and the group-commit flush), recorded BEFORE the record-set persists and carried in the generation delta (blobHashes — always present on new deltas, so compaction only falls back to reading records for pre-contract generations). An aborted transaction compensates best-effort. - unlink/rmdir/overwrite drop ONLY the live reference (BlobStorage.delete → release; overwrite finally releases the superseded hash — cancelling the dedup increment on same-content rewrites and closing the leak), and only AFTER the canonical mutation commits, so a failed delete can never leave a live file whose bytes compaction might reclaim. - History compaction is the ONE reclamation point: after deleting a generation's record-set it releases that set's references and physically reclaims any hash at zero live AND zero history references. Pins are exempt automatically. Crash ordering is over-count-only in every path (record before persist, release after delete), so a crash can leak until the scrub recounts but can never reclaim bytes a retained generation needs. scrubBlobHistoryRefCounts() restores exactness; existing stores get a one-time marker-gated backfill on open, failing into leak-safe mode (reclamation disabled) rather than guessing. On top of the protected history, the temporal API the generational model always implied: - vfs.readFile(path, { asOf }) — the exact bytes as of a generation or Date, materialized from the history (pinned view released so compaction is never blocked by a read). - vfs.history(path) — FileVersion[] ascending ({ generation, timestamp, hash, size, mimeType? }), the newest entry being the live state. - Overwrites now refresh the file entity's data/embedding text — semantic search and the data field previously served the FIRST version's text forever (the stale-field defect a consumer's incident recovery depended on by luck). Integration suite (temporal-vfs.test.ts): per-version exact reads + history listing, leak-fix + history protection on overwrite, rm keeps bytes readable, compaction reclaims past-window bytes and preserves in-window (including the cross-file dedup case where an old file's history and a newer file's removal share one hash), data freshness, and scrub exactness.
2026-07-10 16:43:48 -07:00
/**
* @description Physically delete the blob's bytes + metadata IFF nothing
* references it: zero live references AND zero history references. The one
* reclamation point in the system, invoked by history compaction after it
* releases the reclaimed generations' references. Atomic per hash.
* @param hash - The blob's SHA-256 hash.
* @returns `true` when the bytes were reclaimed.
*/
async reclaimIfUnreferenced(hash: string): Promise<boolean> {
return this.hashLocks.runExclusive(hash, async () => {
const metadata = await this.getMetadata(hash)
if (!metadata) return false
if ((metadata.refCount ?? 0) > 0 || (metadata.historyRefCount ?? 0) > 0) {
return false
}
fix: atomic ifAbsent/upsert inserts + exact blob reference counts under concurrency The fast-follow to the ifRev CAS fix: the sweep for the same check-then-act class found two more instances, both now closed with the same discipline — the decision runs at the serialization point that guards the apply. add({ifAbsent}) / add({upsert}): the absence check ran before the commit mutex, so N concurrent same-id creates could all pass it and all write — the second silently overwriting the first, violating ifAbsent's "return the existing id WITHOUT writing" contract and upsert's merge-never-clobber contract. The insert leg now carries a must-be-absent commit precondition (the same conditional-commit primitive ifRev uses), verified under the commit mutex against the authoritative before-image. A losing caller takes its documented resolution instead of overwriting: ifAbsent returns the existing id with zero writes; upsert merges into the now-existing entity via update() (shared param mapping in upsertMergeParams so the planning-time branch and the conflict retry can never drift), with a bounded retry if a concurrent delete lands between the conflict and the merge. The planning-time checks remain as fast-fails. transact()'s batch-level ifAbsent/upsert keep planning-time semantics (documented, converges to a valid entity). BlobStorage: write()'s dedup decision (exists → increment refCount, absent → create at 1) and delete()'s decrement-then-remove were unserialized read-modify-writes over blob-meta:<hash>. Concurrent writes of identical content could lose references, so a later delete removed bytes another file still referenced (data loss), or leaked unreferenced blobs. All reference-count-bearing mutations now serialize through a per-hash InMemoryMutex — distinct content never contends; incrementRefCount/ decrementRefCount are documented lock-assumed internals. Both fixes are complete by construction in-process: storage enforces single-writer-per-directory, so the process is the whole concurrency domain. Tests (tests/integration/ifabsent-upsert-blob-concurrency.test.ts): 8-way ifAbsent storm advances the store by exactly one generation with _rev 1; 8-way upsert storm on an absent id yields one create + seven merges (_rev === 8); sequential ifAbsent/upsert semantics pinned unchanged; N identical concurrent blob writes → refCount === N; the blob survives until the true last reference drops; interleaved write/delete storm never loses a landed reference.
2026-07-08 15:13:26 -07:00
await this.adapter.delete(`blob:${hash}`)
await this.adapter.delete(`blob-meta:${hash}`)
this.removeFromCache(hash)
feat: temporal VFS — file content joins the Model-B immutability model The temporal model had a hole exactly where files were concerned: every entity write is an immutable generation with before-images, but VFS content BYTES lived under an eager refCount GC left over from the pre-8.0 design — unlink could physically destroy bytes that in-window history still referenced, and overwrite never released the old hash at all (an unbounded silent leak whose accidental byproduct was the only thing "preserving" history). Reading the past could therefore return a stale field, a dangling hash, or nothing, depending on luck. Fix: blob reclamation becomes a HISTORY decision instead of a LIVENESS decision. Each blob's metadata now carries historyRefCount alongside the live refCount: - The commit seam counts one history reference per persisted before-image record carrying a content hash (commitTransaction staging and the group-commit flush), recorded BEFORE the record-set persists and carried in the generation delta (blobHashes — always present on new deltas, so compaction only falls back to reading records for pre-contract generations). An aborted transaction compensates best-effort. - unlink/rmdir/overwrite drop ONLY the live reference (BlobStorage.delete → release; overwrite finally releases the superseded hash — cancelling the dedup increment on same-content rewrites and closing the leak), and only AFTER the canonical mutation commits, so a failed delete can never leave a live file whose bytes compaction might reclaim. - History compaction is the ONE reclamation point: after deleting a generation's record-set it releases that set's references and physically reclaims any hash at zero live AND zero history references. Pins are exempt automatically. Crash ordering is over-count-only in every path (record before persist, release after delete), so a crash can leak until the scrub recounts but can never reclaim bytes a retained generation needs. scrubBlobHistoryRefCounts() restores exactness; existing stores get a one-time marker-gated backfill on open, failing into leak-safe mode (reclamation disabled) rather than guessing. On top of the protected history, the temporal API the generational model always implied: - vfs.readFile(path, { asOf }) — the exact bytes as of a generation or Date, materialized from the history (pinned view released so compaction is never blocked by a read). - vfs.history(path) — FileVersion[] ascending ({ generation, timestamp, hash, size, mimeType? }), the newest entry being the live state. - Overwrites now refresh the file entity's data/embedding text — semantic search and the data field previously served the FIRST version's text forever (the stale-field defect a consumer's incident recovery depended on by luck). Integration suite (temporal-vfs.test.ts): per-version exact reads + history listing, leak-fix + history protection on overwrite, rm keeps bytes readable, compaction reclaims past-window bytes and preserves in-window (including the cross-file dedup case where an old file's history and a newer file's removal share one hash), data freshness, and scrub exactness.
2026-07-10 16:43:48 -07:00
return true
})
}
/**
* @description Set the history reference count to an absolute value the
* backfill/scrub primitive (recounts derived from the actual generation
* records replace whatever the incremental counters hold). Idempotent.
* @param hash - The blob's SHA-256 hash.
* @param count - The exact history reference count.
*/
async setHistoryRefCount(hash: string, count: number): Promise<void> {
await this.hashLocks.runExclusive(hash, async () => {
const metadata = await this.getMetadata(hash)
if (!metadata) return
metadata.historyRefCount = Math.max(0, count)
await this.adapter.put(`blob-meta:${hash}`, Buffer.from(JSON.stringify(metadata)))
fix: atomic ifAbsent/upsert inserts + exact blob reference counts under concurrency The fast-follow to the ifRev CAS fix: the sweep for the same check-then-act class found two more instances, both now closed with the same discipline — the decision runs at the serialization point that guards the apply. add({ifAbsent}) / add({upsert}): the absence check ran before the commit mutex, so N concurrent same-id creates could all pass it and all write — the second silently overwriting the first, violating ifAbsent's "return the existing id WITHOUT writing" contract and upsert's merge-never-clobber contract. The insert leg now carries a must-be-absent commit precondition (the same conditional-commit primitive ifRev uses), verified under the commit mutex against the authoritative before-image. A losing caller takes its documented resolution instead of overwriting: ifAbsent returns the existing id with zero writes; upsert merges into the now-existing entity via update() (shared param mapping in upsertMergeParams so the planning-time branch and the conflict retry can never drift), with a bounded retry if a concurrent delete lands between the conflict and the merge. The planning-time checks remain as fast-fails. transact()'s batch-level ifAbsent/upsert keep planning-time semantics (documented, converges to a valid entity). BlobStorage: write()'s dedup decision (exists → increment refCount, absent → create at 1) and delete()'s decrement-then-remove were unserialized read-modify-writes over blob-meta:<hash>. Concurrent writes of identical content could lose references, so a later delete removed bytes another file still referenced (data loss), or leaked unreferenced blobs. All reference-count-bearing mutations now serialize through a per-hash InMemoryMutex — distinct content never contends; incrementRefCount/ decrementRefCount are documented lock-assumed internals. Both fixes are complete by construction in-process: storage enforces single-writer-per-directory, so the process is the whole concurrency domain. Tests (tests/integration/ifabsent-upsert-blob-concurrency.test.ts): 8-way ifAbsent storm advances the store by exactly one generation with _rev 1; 8-way upsert storm on an absent id yields one create + seven merges (_rev === 8); sequential ifAbsent/upsert semantics pinned unchanged; N identical concurrent blob writes → refCount === N; the blob survives until the true last reference drops; interleaved write/delete storm never loses a landed reference.
2026-07-08 15:13:26 -07:00
})
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
}
/**
feat: temporal VFS — file content joins the Model-B immutability model The temporal model had a hole exactly where files were concerned: every entity write is an immutable generation with before-images, but VFS content BYTES lived under an eager refCount GC left over from the pre-8.0 design — unlink could physically destroy bytes that in-window history still referenced, and overwrite never released the old hash at all (an unbounded silent leak whose accidental byproduct was the only thing "preserving" history). Reading the past could therefore return a stale field, a dangling hash, or nothing, depending on luck. Fix: blob reclamation becomes a HISTORY decision instead of a LIVENESS decision. Each blob's metadata now carries historyRefCount alongside the live refCount: - The commit seam counts one history reference per persisted before-image record carrying a content hash (commitTransaction staging and the group-commit flush), recorded BEFORE the record-set persists and carried in the generation delta (blobHashes — always present on new deltas, so compaction only falls back to reading records for pre-contract generations). An aborted transaction compensates best-effort. - unlink/rmdir/overwrite drop ONLY the live reference (BlobStorage.delete → release; overwrite finally releases the superseded hash — cancelling the dedup increment on same-content rewrites and closing the leak), and only AFTER the canonical mutation commits, so a failed delete can never leave a live file whose bytes compaction might reclaim. - History compaction is the ONE reclamation point: after deleting a generation's record-set it releases that set's references and physically reclaims any hash at zero live AND zero history references. Pins are exempt automatically. Crash ordering is over-count-only in every path (record before persist, release after delete), so a crash can leak until the scrub recounts but can never reclaim bytes a retained generation needs. scrubBlobHistoryRefCounts() restores exactness; existing stores get a one-time marker-gated backfill on open, failing into leak-safe mode (reclamation disabled) rather than guessing. On top of the protected history, the temporal API the generational model always implied: - vfs.readFile(path, { asOf }) — the exact bytes as of a generation or Date, materialized from the history (pinned view released so compaction is never blocked by a read). - vfs.history(path) — FileVersion[] ascending ({ generation, timestamp, hash, size, mimeType? }), the newest entry being the live state. - Overwrites now refresh the file entity's data/embedding text — semantic search and the data field previously served the FIRST version's text forever (the stale-field defect a consumer's incident recovery depended on by luck). Integration suite (temporal-vfs.test.ts): per-version exact reads + history listing, leak-fix + history protection on overwrite, rm keeps bytes readable, compaction reclaims past-window bytes and preserves in-window (including the cross-file dedup case where an old file's history and a newer file's removal share one hash), data freshness, and scrub exactness.
2026-07-10 16:43:48 -07:00
* @description Enumerate every stored blob hash (from the metadata keys)
* the backfill/scrub walk. O(stored blobs).
* @returns All hashes with a stored metadata record.
*/
async listHashes(): Promise<string[]> {
const keys = await this.adapter.list('blob-meta:')
return keys.map((k) => k.slice('blob-meta:'.length))
}
/**
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
* @description Read a blob's metadata without reading its bytes.
* @param hash - The blob's SHA-256 hash.
* @returns The metadata, or `undefined` when the blob does not exist.
*/
async getMetadata(hash: string): Promise<BlobMetadata | undefined> {
const data = await this.adapter.get(`blob-meta:${hash}`)
if (data) {
return JSON.parse(unwrapBinaryData(data).toString())
}
return undefined
}
// ========== PRIVATE METHODS ==========
/**
* Select the compression strategy for a write (see
* {@link BlobWriteOptions.compression}).
*/
private selectCompression(
data: Buffer,
options: BlobWriteOptions
): 'none' | 'zstd' {
if (options.compression === 'none') {
return 'none'
}
if (options.compression === 'zstd') {
return this.zstdCompress ? 'zstd' : 'none'
}
// Auto mode
if (data.length < this.COMPRESSION_THRESHOLD) {
return 'none' // Too small to benefit
}
// Content-type policy: skip already-compressed media. zstd over
// JPEG / MP4 / ZIP etc. is a CPU loss for no measurable byte savings,
// and on hot save paths (image / video uploads) it's the difference
// between fast and slow. Applies only to `auto`; explicit `'zstd'` is
// honoured because the caller is asserting the choice.
if (isAlreadyCompressedMimeType(options.mimeType)) {
return 'none'
}
return this.zstdCompress ? 'zstd' : 'none'
}
/**
* Increment the reference count for an existing blob.
fix: atomic ifAbsent/upsert inserts + exact blob reference counts under concurrency The fast-follow to the ifRev CAS fix: the sweep for the same check-then-act class found two more instances, both now closed with the same discipline — the decision runs at the serialization point that guards the apply. add({ifAbsent}) / add({upsert}): the absence check ran before the commit mutex, so N concurrent same-id creates could all pass it and all write — the second silently overwriting the first, violating ifAbsent's "return the existing id WITHOUT writing" contract and upsert's merge-never-clobber contract. The insert leg now carries a must-be-absent commit precondition (the same conditional-commit primitive ifRev uses), verified under the commit mutex against the authoritative before-image. A losing caller takes its documented resolution instead of overwriting: ifAbsent returns the existing id with zero writes; upsert merges into the now-existing entity via update() (shared param mapping in upsertMergeParams so the planning-time branch and the conflict retry can never drift), with a bounded retry if a concurrent delete lands between the conflict and the merge. The planning-time checks remain as fast-fails. transact()'s batch-level ifAbsent/upsert keep planning-time semantics (documented, converges to a valid entity). BlobStorage: write()'s dedup decision (exists → increment refCount, absent → create at 1) and delete()'s decrement-then-remove were unserialized read-modify-writes over blob-meta:<hash>. Concurrent writes of identical content could lose references, so a later delete removed bytes another file still referenced (data loss), or leaked unreferenced blobs. All reference-count-bearing mutations now serialize through a per-hash InMemoryMutex — distinct content never contends; incrementRefCount/ decrementRefCount are documented lock-assumed internals. Both fixes are complete by construction in-process: storage enforces single-writer-per-directory, so the process is the whole concurrency domain. Tests (tests/integration/ifabsent-upsert-blob-concurrency.test.ts): 8-way ifAbsent storm advances the store by exactly one generation with _rev 1; 8-way upsert storm on an absent id yields one create + seven merges (_rev === 8); sequential ifAbsent/upsert semantics pinned unchanged; N identical concurrent blob writes → refCount === N; the blob survives until the true last reference drops; interleaved write/delete storm never loses a landed reference.
2026-07-08 15:13:26 -07:00
* Caller MUST hold the per-hash lock ({@link hashLocks}) this is a raw
* read-modify-write with no serialization of its own.
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
*/
private async incrementRefCount(hash: string): Promise<number> {
const metadata = await this.getMetadata(hash)
if (!metadata) {
throw new Error(`Cannot increment ref count, blob not found: ${hash}`)
}
metadata.refCount++
await this.adapter.put(`blob-meta:${hash}`, Buffer.from(JSON.stringify(metadata)))
return metadata.refCount
}
/**
* Decrement the reference count for a blob (floored at zero).
fix: atomic ifAbsent/upsert inserts + exact blob reference counts under concurrency The fast-follow to the ifRev CAS fix: the sweep for the same check-then-act class found two more instances, both now closed with the same discipline — the decision runs at the serialization point that guards the apply. add({ifAbsent}) / add({upsert}): the absence check ran before the commit mutex, so N concurrent same-id creates could all pass it and all write — the second silently overwriting the first, violating ifAbsent's "return the existing id WITHOUT writing" contract and upsert's merge-never-clobber contract. The insert leg now carries a must-be-absent commit precondition (the same conditional-commit primitive ifRev uses), verified under the commit mutex against the authoritative before-image. A losing caller takes its documented resolution instead of overwriting: ifAbsent returns the existing id with zero writes; upsert merges into the now-existing entity via update() (shared param mapping in upsertMergeParams so the planning-time branch and the conflict retry can never drift), with a bounded retry if a concurrent delete lands between the conflict and the merge. The planning-time checks remain as fast-fails. transact()'s batch-level ifAbsent/upsert keep planning-time semantics (documented, converges to a valid entity). BlobStorage: write()'s dedup decision (exists → increment refCount, absent → create at 1) and delete()'s decrement-then-remove were unserialized read-modify-writes over blob-meta:<hash>. Concurrent writes of identical content could lose references, so a later delete removed bytes another file still referenced (data loss), or leaked unreferenced blobs. All reference-count-bearing mutations now serialize through a per-hash InMemoryMutex — distinct content never contends; incrementRefCount/ decrementRefCount are documented lock-assumed internals. Both fixes are complete by construction in-process: storage enforces single-writer-per-directory, so the process is the whole concurrency domain. Tests (tests/integration/ifabsent-upsert-blob-concurrency.test.ts): 8-way ifAbsent storm advances the store by exactly one generation with _rev 1; 8-way upsert storm on an absent id yields one create + seven merges (_rev === 8); sequential ifAbsent/upsert semantics pinned unchanged; N identical concurrent blob writes → refCount === N; the blob survives until the true last reference drops; interleaved write/delete storm never loses a landed reference.
2026-07-08 15:13:26 -07:00
* Caller MUST hold the per-hash lock ({@link hashLocks}) this is a raw
* read-modify-write with no serialization of its own.
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
*/
private async decrementRefCount(hash: string): Promise<number> {
const metadata = await this.getMetadata(hash)
if (!metadata) {
return 0
}
metadata.refCount = Math.max(0, metadata.refCount - 1)
await this.adapter.put(`blob-meta:${hash}`, Buffer.from(JSON.stringify(metadata)))
return metadata.refCount
}
/**
* Add a blob to the LRU cache (evicting least-recently-used entries to
* stay under the size bound).
*/
private addToCache(hash: string, data: Buffer, metadata: BlobMetadata): void {
if (data.length > this.cacheMaxSize) {
return // Blob too large for cache
}
while (
this.currentCacheSize + data.length > this.cacheMaxSize &&
this.cache.size > 0
) {
this.evictLRU()
}
this.cache.set(hash, {
data,
metadata,
lastAccess: Date.now(),
size: data.length
})
this.currentCacheSize += data.length
}
/**
* Get a blob from the cache, refreshing its LRU position.
*/
private getFromCache(hash: string): CacheEntry | undefined {
const entry = this.cache.get(hash)
if (entry) {
entry.lastAccess = Date.now() // Update LRU
}
return entry
}
/**
* Remove a blob from the cache.
*/
private removeFromCache(hash: string): void {
const entry = this.cache.get(hash)
if (entry) {
this.cache.delete(hash)
this.currentCacheSize -= entry.size
}
}
/**
* Evict the least-recently-used cache entry.
*/
private evictLRU(): void {
let oldestHash: string | null = null
let oldestTime = Infinity
for (const [hash, entry] of this.cache.entries()) {
if (entry.lastAccess < oldestTime) {
oldestTime = entry.lastAccess
oldestHash = hash
}
}
if (oldestHash) {
this.removeFromCache(oldestHash)
}
}
}