brainy/tests/integration/cortex-compat.test.ts
David Snelling 780fb6444b feat(8.0)!: flip requireSubtype default to true (BRAINY-8.0-SUBTYPE-CONTRACT § C-1)
Brainy 8.0 makes subtype required by default on every public write path
(`add`, `addMany`, `update`, `relate`, `relateMany`, `updateRelation`,
import). Per the locked C-1 contract, every entity and relation gets a
non-empty subtype string by the time the storage layer sees it.

OPT-OUT REMAINS FULLY SUPPORTED

The runtime flag is still consumer-controlled. Three opt-out paths
cover migration / legacy fixtures / typed escape:

- `new Brainy({ requireSubtype: false })` — last-resort: turn off the
  contract entirely. Recommended only for migration windows or test
  fixtures that legitimately can't supply a subtype.
- `new Brainy({ requireSubtype: { except: [NounType.Thing, ...] } })` —
  per-type allowlist: strict everywhere except the listed types.
- `brain.requireSubtype(type, options)` — per-type registration with
  optional vocabulary. Composes with the brain-wide flag.

Default is now `true`. Opt-out is explicit and documented; nothing
silently degrades.

TEST SWEEP

Bulk-applied `requireSubtype: false` to every `new Brainy({...})` call
site across 120 test files. Three sed patterns covered the shapes:
  - `new Brainy({` → `new Brainy({ requireSubtype: false,`
  - `new Brainy<T>({` → `new Brainy<T>({ requireSubtype: false,`
  - `new Brainy()` → `new Brainy({ requireSubtype: false })`

tests/helpers/test-factory.ts → createTestConfig() defaults
`requireSubtype: false` so test files using the helper inherit the
opt-out without per-site edits.

The test sites that DO exercise subtype semantics (the
subtype-and-facets suite, the strict-mode-self-test suite, the verb-
subtype-and-enforcement suite, etc.) already pass real subtypes — they
were the 7.30.x acceptance tests for this contract. Those tests
continue to pass unchanged.

CHANGES

src/brainy.ts
- normalizeConfig() — `requireSubtype` default `false` → `true`.
  Comment refreshed to document the three opt-out paths.

tests/* (120 files)
- Bulk-edited brain construction sites. No functional test changes; the
  opt-out preserves the test author's original intent.

tests/helpers/test-factory.ts
- createTestConfig() base config gains `requireSubtype: false`.

NO-OP for consumers who were already passing subtype on every write.

For consumers who weren't, the upgrade path is one of the three opt-out
forms above. Migration recipe documented in 8.0 release notes (next
commit).

VERIFICATION

- npx tsc --noEmit: clean
- npm test: 1408 / 1409 (same pre-existing race-condition outstanding;
  no other regressions from the flip)
2026-06-09 14:58:25 -07:00

106 lines
4.2 KiB
TypeScript

/**
* Cortex compatibility test (BR-DEFENSIVE-INTERFACE regression).
*
* Brainy 7.21.0 added several optional storage-adapter methods
* (`supportsMultiProcessLocking`, `acquireWriterLock`, etc.). Older plugins
* (notably `@soulcraft/cortex@2.2.0`) bundle a pre-7.21 `BaseStorage` that
* doesn't have them. Calling them unconditionally crashed boot.
*
* 7.22.0 fix: every call site is gated by `hasStorageMethod(name)` so older
* adapters degrade with a single warning at init.
*
* This test constructs a deliberately-stripped storage adapter (no new
* methods) and proves Brainy boots, can add/find/close normally, and logs
* the expected one-line warning.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { Brainy } from '../../src/brainy.js'
import { MemoryStorage } from '../../src/storage/adapters/memoryStorage.js'
import { NounType } from '../../src/types/graphTypes.js'
/**
* Mock storage adapter modeling pre-7.21 Cortex: extends MemoryStorage
* (which has full storage semantics) but deletes the new methods so Brainy
* sees a "method missing" surface. Class name is masked so the warning
* message names it as `LegacyCortexLikeStorage`.
*/
class LegacyCortexLikeStorage extends MemoryStorage {
constructor() {
super()
// Strip the 7.21+ methods. `delete` on prototype methods is awkward
// across TS strictness levels; assigning `undefined` produces the same
// `typeof === 'function'` false that older adapters exhibit.
;(this as any).supportsMultiProcessLocking = undefined
;(this as any).acquireWriterLock = undefined
;(this as any).releaseWriterLock = undefined
;(this as any).readWriterLock = undefined
;(this as any).startFlushRequestWatcher = undefined
;(this as any).stopFlushRequestWatcher = undefined
;(this as any).requestFlushOverFilesystem = undefined
}
}
describe('Cortex compatibility (BR-DEFENSIVE-INTERFACE)', () => {
let brain: Brainy | null = null
let warnSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(async () => {
if (brain) {
try { await brain.close() } catch { /* may already be closed */ }
brain = null
}
warnSpy.mockRestore()
})
it('boots without throwing against an adapter missing the 7.21+ methods', async () => {
brain = new Brainy({ requireSubtype: false, storage: new LegacyCortexLikeStorage() as any })
await expect(brain.init()).resolves.toBeUndefined()
})
it('logs a one-line warning naming the older adapter', async () => {
brain = new Brainy({ requireSubtype: false, storage: new LegacyCortexLikeStorage() as any })
await brain.init()
const warnMessages = warnSpy.mock.calls
.map(call => String(call[0] ?? ''))
.join('\n')
expect(warnMessages).toMatch(/LegacyCortexLikeStorage|predates the 7\.21/i)
})
it('basic CRUD works normally (the missing methods are not on the hot path)', async () => {
brain = new Brainy({ requireSubtype: false, storage: new LegacyCortexLikeStorage() as any })
await brain.init()
const id = await brain.add({ data: 'hello', type: NounType.Concept })
const fetched = await brain.get(id)
expect(fetched).toBeTruthy()
expect(fetched!.id).toBe(id)
await brain.flush() // No-op for memory-backed, but must not throw.
})
it('requestFlush() returns false when storage does not implement the RPC', async () => {
brain = new Brainy({ requireSubtype: false, storage: new LegacyCortexLikeStorage() as any })
await brain.init()
// Same-process call would normally flush directly. Force the
// cross-process path by temporarily making this brain look read-only.
;(brain as any).operationalMode = { canWrite: false, validateOperation: () => {} }
const ok = await brain.requestFlush({ timeoutMs: 500 })
expect(ok).toBe(false)
})
it('stats() reports no writerLock for older adapters', async () => {
brain = new Brainy({ requireSubtype: false, storage: new LegacyCortexLikeStorage() as any })
await brain.init()
const stats = await brain.stats()
expect(stats.mode).toBe('writer')
expect(stats.writerLock).toBeUndefined()
})
})