/** * @module tests/integration/readonly-close-writes-nothing * @description A READ-ONLY BRAIN LEAVES `_system/` BYTE-IDENTICAL — the WHOLE * directory, not just the clean-shutdown marker. * * `readonly-close-no-marker` closed the marker half of this law and named the * rest as a known, out-of-scope residual: * * "Other files under `_system/` — e.g. the metadata field registry, which * stamps its own `lastUpdated` on every persist — are a pre-existing, * separate concern outside this fix's scope." * * This is that residual, closed. MEASURED on the base before the fix, a * read-only open → read → close rewrote FOUR files: * * _system/__metadata_field_registry__.json.gz * _system/type-statistics.json.gz * _system/subtype-statistics.json.gz * _system/verb-subtype-statistics.json.gz * * THE CAUSE was not the closes the marker fix guarded — it was Phase 1 of * `closeDurableSteps`, where every component flush ran unconditionally. A flush * is a write by definition: `MetadataIndexManager#flush()` saves the field * registry "even with no dirty fields" (its own comment), and the storage * adapter's count flush re-stamps the three statistics files. A session that * committed nothing re-stamped all four. Phase 2's closes were ungated too — * the graph index's close drains both LSM MemTables and stamps a watermark, * and the optional vector/metadata `close` hooks (unimplemented in the * reference engine, filled in by a native provider) persist buffered state. * * THE LAW. A reader writes nothing, anywhere under `_system/`, at open or at * close. It still RELEASES what it holds: the graph index's auto-flush interval * is cleared through `stopBackgroundFlush()`, the non-writing half of its * close, so nothing outlives the session. * * WHY IT MATTERS beyond tidiness: `_system/` is where a store keeps its * evidence about itself — what the writer committed, what the projections have * seen. A reader that rewrites any of it is vouching for a state it only * observed, and on shared or snapshot storage it mutates bytes another process * owns. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { mkdtempSync, rmSync, readdirSync, readFileSync, statSync } from 'node:fs' import { createHash } from 'node:crypto' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Brainy } from '../../src/brainy.js' import { NounType, VerbType } from '../../src/types/graphTypes.js' /** Recursively hash every regular file under `dir`, keyed by its path relative to `dir`. */ function snapshotDir(dir: string): Map { const out = new Map() const walk = (rel: string): void => { const abs = rel ? join(dir, rel) : dir let entries: string[] try { entries = readdirSync(abs) } catch { return } for (const name of entries) { const childRel = rel ? join(rel, name) : name const childAbs = join(dir, childRel) const st = statSync(childAbs) if (st.isDirectory()) { walk(childRel) } else if (st.isFile()) { out.set(childRel, createHash('sha256').update(readFileSync(childAbs)).digest('hex')) } } } walk('') return out } /** Every path where `after` differs from `before`, labelled — the failure message. */ function diff(before: Map, after: Map): string[] { const lines: string[] = [] for (const [path, hash] of after) { if (!before.has(path)) lines.push(`ADDED ${path}`) else if (before.get(path) !== hash) lines.push(`CHANGED ${path}`) } for (const path of before.keys()) if (!after.has(path)) lines.push(`REMOVED ${path}`) return lines.sort() } describe('a read-only brain writes nothing under `_system/`', () => { let dir: string let brain: Brainy | null = null beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'brainy-readonly-writes-')) }) afterEach(async () => { if (brain) { try { await brain.close() } catch { /* already closed */ } brain = null } try { rmSync(dir, { recursive: true, force: true }) } catch { /* ignore */ } }) const systemDir = () => join(dir, '_system') /** * A writer seeds a store with nouns, verbs and queryable metadata — enough * that the field registry, the statistics files and the graph index all hold * real content — then closes cleanly. */ async function seedStore(): Promise { const writer = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } }) await writer.init() for (let i = 0; i < 6; i++) { await writer.add({ id: `seed-${i}`, data: `seed entity ${i}`, type: i % 2 === 0 ? NounType.Concept : NounType.Document, metadata: { lane: i % 2 === 0 ? 'alpha' : 'beta', rank: i, tags: [`t${i}`, 'shared'] }, vector: [] }) } for (let i = 1; i < 6; i++) { await writer.relate({ from: 'seed-0', to: `seed-${i}`, type: VerbType.RelatedTo }) } await writer.flush() await writer.close() } it('open → read → close leaves every file under `_system/` byte-identical', async () => { await seedStore() const before = snapshotDir(systemDir()) expect(before.size, 'the writer left a populated `_system/`').toBeGreaterThan(0) brain = await Brainy.openReadOnly({ storage: { type: 'filesystem', path: dir } }) expect(brain.isReadOnly).toBe(true) // Exercise the read surface that drives each subsystem: statistics (counts), // a metadata filter (field index + registry), a graph walk (adjacency), a // vector search, and a direct get. await brain.stats() await brain.find({ where: { lane: 'alpha' }, limit: 10 } as any) await brain.find({ where: { tags: 'shared' }, limit: 10 } as any) await brain.find({ connected: { from: 'seed-0', direction: 'out' }, limit: 10 } as any) await brain.get('seed-1') await brain.close() brain = null const after = snapshotDir(systemDir()) const changes = diff(before, after) expect(changes, `a reader modified \`_system/\`:\n${changes.join('\n')}`).toEqual([]) }, 120_000) it('names the four files that used to change — the measured shape of the defect', async () => { await seedStore() const before = snapshotDir(systemDir()) // These are the exact paths the base rewrote. Naming them keeps the pin // honest about what it caught: if a future change reintroduces the write, // the test above fails and this one says which subsystem did it. const previouslyRewritten = [ '__metadata_field_registry__.json.gz', 'type-statistics.json.gz', 'subtype-statistics.json.gz', 'verb-subtype-statistics.json.gz' ] for (const name of previouslyRewritten) { expect(before.has(name), `fixture must contain ${name}`).toBe(true) } brain = await Brainy.openReadOnly({ storage: { type: 'filesystem', path: dir } }) await brain.stats() await brain.find({ where: { lane: 'alpha' }, limit: 10 } as any) await brain.close() brain = null const after = snapshotDir(systemDir()) for (const name of previouslyRewritten) { expect(after.get(name), `${name} was rewritten by a reader`).toBe(before.get(name)) } }, 120_000) it('a reader that only opens and closes — touching nothing — writes nothing', async () => { await seedStore() const before = snapshotDir(systemDir()) brain = await Brainy.openReadOnly({ storage: { type: 'filesystem', path: dir } }) await brain.close() brain = null const changes = diff(before, snapshotDir(systemDir())) expect(changes, `an idle reader modified \`_system/\`:\n${changes.join('\n')}`).toEqual([]) }, 120_000) it('two readers in sequence each leave the store exactly as they found it', async () => { await seedStore() const before = snapshotDir(systemDir()) for (let i = 0; i < 2; i++) { const reader = await Brainy.openReadOnly({ storage: { type: 'filesystem', path: dir } }) await reader.find({ where: { lane: 'beta' }, limit: 10 } as any) await reader.close() const changes = diff(before, snapshotDir(systemDir())) expect(changes, `reader ${i + 1} modified \`_system/\`:\n${changes.join('\n')}`).toEqual([]) } }, 120_000) it('the store outside `_system/` is untouched too — a reader writes nowhere', async () => { await seedStore() const before = snapshotDir(dir) brain = await Brainy.openReadOnly({ storage: { type: 'filesystem', path: dir } }) await brain.stats() await brain.find({ where: { lane: 'alpha' }, limit: 10 } as any) await brain.close() brain = null const changes = diff(before, snapshotDir(dir)) expect(changes, `a reader modified the store:\n${changes.join('\n')}`).toEqual([]) }, 120_000) it('a WRITER still persists on close — the guard did not disarm the write path', async () => { await seedStore() const before = snapshotDir(systemDir()) const writer = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } }) await writer.init() await writer.add({ id: 'after-reader', data: 'a new row', type: NounType.Concept, metadata: { lane: 'gamma', rank: 99 }, vector: [] }) await writer.close() // The writer's close DID move `_system/` — that is the whole point of the // asymmetry, and the guard must not have flattened it. expect(diff(before, snapshotDir(systemDir())).length).toBeGreaterThan(0) // And the row is really there on the next open. const reopened = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } }) await reopened.init() brain = reopened const hits = await reopened.find({ where: { lane: 'gamma' }, limit: 10 } as any) expect(hits.length).toBe(1) }, 120_000) })