201 lines
8.5 KiB
TypeScript
201 lines
8.5 KiB
TypeScript
|
|
/**
|
|||
|
|
* @module tests/integration/temporal-vfs
|
|||
|
|
* @description VFS content joins the Model-B immutability model. Previously
|
|||
|
|
* the temporal model had a hole exactly where files were concerned: entity
|
|||
|
|
* records were versioned per write (before-images), but the content BYTES
|
|||
|
|
* lived under an eager refCount GC — `rm` could physically delete bytes that
|
|||
|
|
* in-window history still referenced, and overwrite never released the old
|
|||
|
|
* hash at all (an unbounded silent leak that only accidentally preserved
|
|||
|
|
* history). Now blob bytes are retention-protected: a content blob referenced
|
|||
|
|
* by any generation inside the retention window (or live) survives, and the
|
|||
|
|
* ONE reclamation point is history compaction.
|
|||
|
|
*
|
|||
|
|
* Pins:
|
|||
|
|
* - `readFile(path, { asOf })` returns each version's exact bytes.
|
|||
|
|
* - `history(path)` lists versions ascending with generation/hash/size.
|
|||
|
|
* - overwrite releases the superseded live reference (leak fixed) while
|
|||
|
|
* history protects the bytes; rm keeps bytes readable via asOf.
|
|||
|
|
* - compaction past the last referencing generation physically reclaims
|
|||
|
|
* bytes (zero live + zero history), and never reclaims in-window content —
|
|||
|
|
* including the cross-file dedup case where an old file's history and a
|
|||
|
|
* newer file's history share one hash.
|
|||
|
|
* - overwrite refreshes the entity's `data` (embedding text) — the stale
|
|||
|
|
* `.data` defect.
|
|||
|
|
* - the backfill/scrub recounts exactly from the generation records.
|
|||
|
|
*/
|
|||
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|||
|
|
import * as fs from 'node:fs'
|
|||
|
|
import * as os from 'node:os'
|
|||
|
|
import * as path from 'node:path'
|
|||
|
|
import { Brainy } from '../../src/brainy.js'
|
|||
|
|
|
|||
|
|
describe('temporal VFS — blob immutability under Model B', () => {
|
|||
|
|
let dir: string
|
|||
|
|
let brain: any
|
|||
|
|
|
|||
|
|
beforeEach(async () => {
|
|||
|
|
process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true'
|
|||
|
|
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-temporal-vfs-'))
|
|||
|
|
brain = new Brainy({
|
|||
|
|
requireSubtype: false,
|
|||
|
|
storage: { type: 'filesystem', path: dir },
|
|||
|
|
dimensions: 384,
|
|||
|
|
silent: true
|
|||
|
|
})
|
|||
|
|
await brain.init()
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
afterEach(async () => {
|
|||
|
|
await brain.close()
|
|||
|
|
fs.rmSync(dir, { recursive: true, force: true })
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
/** The file's blob metadata via the storage layer (test introspection). */
|
|||
|
|
async function blobMeta(hash: string) {
|
|||
|
|
return brain['storage'].blobStorage.getMetadata(hash)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
it('readFile(path, {asOf}) returns each version‘s exact bytes; history(path) lists them', async () => {
|
|||
|
|
const p = '/pages/home.json'
|
|||
|
|
await brain.vfs.writeFile(p, 'v1 — original')
|
|||
|
|
const g1 = brain.generationStore.generation()
|
|||
|
|
await brain.vfs.writeFile(p, 'v2 — edited')
|
|||
|
|
await brain.vfs.writeFile(p, 'v3 — final')
|
|||
|
|
|
|||
|
|
// Live read = newest.
|
|||
|
|
expect((await brain.vfs.readFile(p)).toString()).toBe('v3 — final')
|
|||
|
|
|
|||
|
|
// History lists the file's write-generations ascending, distinct hashes.
|
|||
|
|
const versions = await brain.vfs.history(p)
|
|||
|
|
expect(versions.length).toBeGreaterThanOrEqual(3)
|
|||
|
|
const gens = versions.map((v: any) => v.generation)
|
|||
|
|
expect([...gens].sort((a: number, b: number) => a - b)).toEqual(gens)
|
|||
|
|
|
|||
|
|
// Every listed version's exact bytes are readable.
|
|||
|
|
const texts: string[] = []
|
|||
|
|
for (const v of versions) {
|
|||
|
|
texts.push((await brain.vfs.readFile(p, { asOf: v.generation })).toString())
|
|||
|
|
}
|
|||
|
|
expect(texts).toContain('v1 — original')
|
|||
|
|
expect(texts).toContain('v2 — edited')
|
|||
|
|
expect(texts).toContain('v3 — final')
|
|||
|
|
|
|||
|
|
// Direct generation read too (the incident shape: recover the past bytes).
|
|||
|
|
expect((await brain.vfs.readFile(p, { asOf: g1 })).toString()).toBe('v1 — original')
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
it('overwrite releases the superseded LIVE reference (leak fixed) while history protects the bytes', async () => {
|
|||
|
|
const p = '/notes/leak.txt'
|
|||
|
|
await brain.vfs.writeFile(p, 'old content A')
|
|||
|
|
const oldHash = (await brain.vfs.history(p))[0].hash
|
|||
|
|
const g1 = brain.generationStore.generation()
|
|||
|
|
|
|||
|
|
await brain.vfs.writeFile(p, 'new content B')
|
|||
|
|
|
|||
|
|
// The old hash's LIVE reference is released (previously it leaked forever)…
|
|||
|
|
expect((await blobMeta(oldHash))?.refCount).toBe(0)
|
|||
|
|
// …but the bytes survive (history-protected) and asOf still reads them.
|
|||
|
|
expect((await brain.vfs.readFile(p, { asOf: g1 })).toString()).toBe('old content A')
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
it('rm keeps the bytes readable via asOf inside the window (no premature deletion)', async () => {
|
|||
|
|
const p = '/docs/doomed.txt'
|
|||
|
|
await brain.vfs.writeFile(p, 'still recoverable after rm')
|
|||
|
|
const g = brain.generationStore.generation()
|
|||
|
|
const hash = (await brain.vfs.history(p))[0].hash
|
|||
|
|
|
|||
|
|
await brain.vfs.unlink(p)
|
|||
|
|
|
|||
|
|
// Live path is gone…
|
|||
|
|
await expect(brain.vfs.readFile(p)).rejects.toThrow()
|
|||
|
|
// …the bytes are not: zero live refs, history-protected.
|
|||
|
|
expect((await blobMeta(hash))?.refCount).toBe(0)
|
|||
|
|
expect((await brain['storage'].blobStorage.read(hash)).toString()).toBe(
|
|||
|
|
'still recoverable after rm'
|
|||
|
|
)
|
|||
|
|
// (Path-level asOf resolution of a DELETED path is future work — the
|
|||
|
|
// bytes + entity history are intact; recovery reads go through the hash
|
|||
|
|
// or a pre-delete generation view.)
|
|||
|
|
void g
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
it('compaction is the ONE reclamation point: past-window bytes are reclaimed, in-window preserved', async () => {
|
|||
|
|
const p = '/pages/compact.json'
|
|||
|
|
await brain.vfs.writeFile(p, 'version ONE')
|
|||
|
|
const v1Hash = (await brain.vfs.history(p))[0].hash
|
|||
|
|
await brain.vfs.writeFile(p, 'version TWO')
|
|||
|
|
await brain.vfs.writeFile(p, 'version THREE')
|
|||
|
|
|
|||
|
|
// Sanity: v1's bytes exist (history-protected, live-released).
|
|||
|
|
expect(await blobMeta(v1Hash)).toBeDefined()
|
|||
|
|
expect((await blobMeta(v1Hash))?.refCount).toBe(0)
|
|||
|
|
|
|||
|
|
// Compact everything reclaimable (retain nothing beyond the live state).
|
|||
|
|
await brain.compactHistory({ maxGenerations: 0 })
|
|||
|
|
|
|||
|
|
// v1's bytes are physically GONE — zero live, zero history.
|
|||
|
|
expect(await blobMeta(v1Hash)).toBeUndefined()
|
|||
|
|
// The live version is untouched.
|
|||
|
|
expect((await brain.vfs.readFile(p)).toString()).toBe('version THREE')
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
it('cross-file dedup: a shared hash survives while ANY in-window generation references it', async () => {
|
|||
|
|
const shared = 'identical bytes shared across files and time'
|
|||
|
|
const pA = '/a.txt'
|
|||
|
|
const pB = '/b.txt'
|
|||
|
|
|
|||
|
|
// A holds the shared content, then moves off it (A's history references it).
|
|||
|
|
await brain.vfs.writeFile(pA, shared)
|
|||
|
|
await brain.vfs.writeFile(pA, 'A moved on')
|
|||
|
|
// B now holds the shared content live, then is removed (B's remove-gen
|
|||
|
|
// before-image references it too).
|
|||
|
|
await brain.vfs.writeFile(pB, shared)
|
|||
|
|
const gBLive = brain.generationStore.generation()
|
|||
|
|
const sharedHash = (await brain.vfs.history(pA))[0].hash
|
|||
|
|
await brain.vfs.unlink(pB)
|
|||
|
|
|
|||
|
|
// Live refs are zero (A moved off; B removed) — bytes survive on history.
|
|||
|
|
expect((await blobMeta(sharedHash))?.refCount).toBe(0)
|
|||
|
|
expect((await brain['storage'].blobStorage.read(sharedHash)).toString()).toBe(shared)
|
|||
|
|
|
|||
|
|
// B's content at its live generation is still exactly readable… via asOf
|
|||
|
|
// on A's early version too (same bytes, same blob).
|
|||
|
|
const versionsA = await brain.vfs.history(pA)
|
|||
|
|
const aV1 = versionsA[0]
|
|||
|
|
expect((await brain.vfs.readFile(pA, { asOf: aV1.generation })).toString()).toBe(shared)
|
|||
|
|
void gBLive
|
|||
|
|
|
|||
|
|
// Full compaction drops the last history references → bytes reclaimed.
|
|||
|
|
await brain.compactHistory({ maxGenerations: 0 })
|
|||
|
|
expect(await blobMeta(sharedHash)).toBeUndefined()
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
it('overwrite refreshes the entity data/embedding text (the stale-.data defect)', async () => {
|
|||
|
|
const p = '/pages/data-fresh.txt'
|
|||
|
|
await brain.vfs.writeFile(p, 'first words')
|
|||
|
|
await brain.vfs.writeFile(p, 'second words entirely')
|
|||
|
|
|
|||
|
|
const entityId = await brain.vfs['pathResolver'].resolve(p)
|
|||
|
|
const entity = await brain.get(entityId)
|
|||
|
|
expect(entity.data).toBe('second words entirely')
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
it('the scrub recounts history references exactly from the generation records', async () => {
|
|||
|
|
const p = '/pages/scrubbed.md'
|
|||
|
|
await brain.vfs.writeFile(p, 'scrub v1')
|
|||
|
|
await brain.vfs.writeFile(p, 'scrub v2')
|
|||
|
|
const v1Hash = (await brain.vfs.history(p))[0].hash
|
|||
|
|
await brain.flush()
|
|||
|
|
|
|||
|
|
const storage = brain['storage']
|
|||
|
|
const before = (await blobMeta(v1Hash))?.historyRefCount ?? 0
|
|||
|
|
expect(before).toBeGreaterThan(0)
|
|||
|
|
|
|||
|
|
// Corrupt the counter (simulates a legacy store / crash drift), then scrub.
|
|||
|
|
await storage.blobStorage.setHistoryRefCount(v1Hash, 0)
|
|||
|
|
const result = await storage.scrubBlobHistoryRefCounts()
|
|||
|
|
expect(result.records).toBeGreaterThan(0)
|
|||
|
|
expect((await blobMeta(v1Hash))?.historyRefCount).toBe(before)
|
|||
|
|
})
|
|||
|
|
})
|