brainy/tests/integration/find-limits.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

191 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @module tests/integration/find-limits
* @description Integration coverage for the 7.30.2 `find({ limit })` cap
* recalibration + two-tier enforcement (warn-then-throw). See
* `BR-MAXLIMIT-9000` in PLATFORM-HANDOFF.md for the original incident report
* and `docs/guides/find-limits.md` for the consumer-facing guide.
*
* Coverage:
*
* - Below cap: silent pass.
* - Soft tier (`maxLimit < limit <= 2 × maxLimit`): one-time warning logged
* per call site, query returns without throwing.
* - Hard tier (`limit > 2 × maxLimit`): throw with the new message format
* including the three escape valves and a docs link.
* - Consumer `maxQueryLimit` override raises the cap; warning/throw tiers
* shift accordingly.
* - Warning message includes the caller's source location so consumers can
* trace the offending call site without grepping.
*
* @since 7.30.2
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { Brainy } from '../../src/brainy'
import { NounType } from '../../src/types/graphTypes'
import {
ValidationConfig,
resetLimitWarningCache,
validateFindParams
} from '../../src/utils/paramValidation'
import * as logger from '../../src/utils/logger'
describe('find({ limit }) two-tier enforcement (7.30.2)', () => {
let brain: Brainy<any>
let warnSpy: ReturnType<typeof vi.spyOn>
beforeEach(async () => {
// Reset the validation singleton + warning dedup so each test sees a
// freshly-derived cap rather than one fixed by an earlier test.
ValidationConfig.reset()
resetLimitWarningCache()
// Spy directly on `prodLog.warn` — the call site the limit enforcement
// actually uses. Spying on `console.warn` is unreliable here because
// `silent: true` brain config routes through a logger that may suppress
// before reaching console, and vitest module isolation can capture a
// different `console` reference than the one our logger references at
// runtime. The prodLog.warn entry point is what we control, so that's
// what we observe.
warnSpy = vi.spyOn(logger.prodLog, 'warn').mockImplementation(() => undefined)
})
afterEach(async () => {
if (brain) await brain.close()
warnSpy.mockRestore()
})
describe('Validator-level behavior (no brain instance needed)', () => {
it('silent pass below cap', () => {
const cfg = ValidationConfig.getInstance({ maxQueryLimit: 1000 })
expect(() => validateFindParams({ limit: cfg.maxLimit })).not.toThrow()
expect(warnSpy).not.toHaveBeenCalled()
})
it('soft tier warns once per call site without throwing', () => {
ValidationConfig.getInstance({ maxQueryLimit: 1000 })
// The dedup key is `(caller, limit)`. To exercise the dedup honestly we
// need both invocations to hit the SAME source line — extracting them
// into a wrapper that lives at one location is the deterministic way.
const callFromOneSite = () => validateFindParams({ limit: 1500 })
expect(() => callFromOneSite()).not.toThrow()
expect(warnSpy).toHaveBeenCalledTimes(1)
// Same source line + same limit → dedup, no second warning
expect(() => callFromOneSite()).not.toThrow()
expect(warnSpy).toHaveBeenCalledTimes(1)
})
it('warning message names the recipe + docs link', () => {
ValidationConfig.getInstance({ maxQueryLimit: 1000 })
validateFindParams({ limit: 1500 })
const message = String(warnSpy.mock.calls[0][0])
expect(message).toMatch(/find\(\{ limit: 1500 \}\)/)
expect(message).toMatch(/exceeds the auto-configured query limit of 1000/)
expect(message).toMatch(/new Brainy\(\{ maxQueryLimit:/)
expect(message).toMatch(/new Brainy\(\{ reservedQueryMemory:/)
expect(message).toMatch(/Paginate:/)
expect(message).toMatch(/Docs: https:\/\/soulcraft\.com\/docs\/guides\/find-limits/)
})
it('warning message includes the caller location from the stack', () => {
ValidationConfig.getInstance({ maxQueryLimit: 1000 })
validateFindParams({ limit: 1500 })
const message = String(warnSpy.mock.calls[0][0])
// The caller is THIS test file; the formatter strips the leading `at `
// and emits an ` at <location>` line in the rendered message.
expect(message).toMatch(/at .*find-limits\.test\.ts/)
})
it('hard tier throws with the same message format', () => {
ValidationConfig.getInstance({ maxQueryLimit: 1000 })
// Above 2× cap = real OOM territory = throw
expect(() => validateFindParams({ limit: 2001 })).toThrow(
/exceeds the auto-configured query limit of 1000/
)
// No warning was logged — throw fires immediately at the hard tier
expect(warnSpy).not.toHaveBeenCalled()
})
it('hard tier message names all three escape valves', () => {
ValidationConfig.getInstance({ maxQueryLimit: 1000 })
try {
validateFindParams({ limit: 5000 })
throw new Error('expected throw')
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
expect(message).toMatch(/maxQueryLimit/)
expect(message).toMatch(/reservedQueryMemory/)
expect(message).toMatch(/Paginate:/)
expect(message).toMatch(/Docs: https:\/\/soulcraft\.com\/docs\/guides\/find-limits/)
}
})
it('soft-tier warning dedup is keyed on (caller, limit) — different limits from same site fire separately', () => {
ValidationConfig.getInstance({ maxQueryLimit: 1000 })
const call = (limit: number) => validateFindParams({ limit })
call(1500)
call(1500)
expect(warnSpy).toHaveBeenCalledTimes(1)
// Different limit value → new dedup key → second warning
call(1800)
expect(warnSpy).toHaveBeenCalledTimes(2)
})
})
describe('Consumer override via Brainy constructor', () => {
it('maxQueryLimit raises the cap; warning/throw tiers shift accordingly', async () => {
brain = new Brainy({ requireSubtype: false,
storage: { type: 'memory' },
silent: true,
maxQueryLimit: 50_000
})
await brain.init()
// Brainy's init path emits a one-time `prodLog.warn` for the
// entityIdMapper system-resource notice on first-mount; clear the spy
// history so we only observe limit-enforcement warnings below.
warnSpy.mockClear()
// The old auto-derived cap would have rejected this; the override accepts it
expect(() => validateFindParams({ limit: 10_000 })).not.toThrow()
expect(warnSpy).not.toHaveBeenCalled()
// 50_000 + 1 = soft tier under the new cap → warn, not throw
expect(() => validateFindParams({ limit: 60_000 })).not.toThrow()
expect(warnSpy).toHaveBeenCalled()
// Above 2× the override (100 001) → throw
// (Note: maxQueryLimit is hard-clamped at 100k in ValidationConfig, so the
// effective cap is 50_000; 2× = 100_000; we cross at 100_001.)
expect(() => validateFindParams({ limit: 100_001 })).toThrow(/exceeds/)
})
it('pre-7.30.2 regression scenario: limit: 10_000 passes silently on a memory-derived cap', async () => {
// Simulate a box where the auto-config picks a cap below 10_000 — the
// canonical pre-7.30.2 scenario where production booking flows 500'd
// because `validateFindParams` threw synchronously. With the
// 25 KB-per-result calibration the cap is ~4× more generous on the
// same hardware, but more importantly the soft tier no longer throws
// when consumers exceed the auto-cap.
ValidationConfig.reconfigure({ maxQueryLimit: 9000 })
// Pre-7.30.2 this threw. Post-7.30.2 it warns + passes.
expect(() => validateFindParams({
type: NounType.Event,
where: { status: 'open' },
limit: 10_000
})).not.toThrow()
expect(warnSpy).toHaveBeenCalled()
})
})
})