feat: verb subtype + updateRelation + requireSubtype enforcement
Brings verbs to first-class parity with nouns. The 7.29.0 subtype primitive
shipped for entities only; this release ships the symmetric verb mirror plus
the enforcement layer for ensuring every entity AND every relationship has
both type AND subtype.
Layer V1 — verb subtype mirror
- HNSWVerbWithMetadata.subtype + STANDARD_VERB_FIELDS set + resolveVerbField()
- Relation<T>.subtype, RelateParams<T>.subtype, UpdateRelationParams<T> extended,
GetRelationsParams.subtype, GraphConstraints.subtype (for find connected)
- relate() persists subtype on verbMetadata + GraphVerb + transaction ops
- getRelations({ type, subtype }) fast-path filter with set membership
- find({ connected: { via, subtype, depth } }) traversal filter (depth-1 on
the JS path; explicit error on depth > 1 pointing at Cortex native)
- verbsToRelations + storage destructure sites surface subtype to top-level
- All three graph-index fast-path queries (getVerbsBySource/ByTarget) enrich
with subtype from metadata
Layer V2 — updateRelation() closes a pre-7.30 gap
- New first-class verb update method (parallel to update() for nouns)
- Changes subtype/type/weight/confidence/data/metadata in place
- Re-indexes in graph adjacency when verb type changes; id preserved
- validateUpdateRelationParams enforces id + at-least-one-field-to-update
Layer V3 — verb subtype storage rollup
- verbSubtypeCountsByType: Map<number, Map<string, number>> on BaseStorage
- verbSubtypeByIdCache for self-heal during update/delete
- incrementVerbSubtypeCount + decrementVerbSubtypeCount maintain state
- loadVerbSubtypeStatistics + saveVerbSubtypeStatistics persist to
_system/verb-subtype-statistics.json (mirrors noun-side shape)
- rebuildVerbSubtypeCounts for poison recovery / explicit repair
- getVerbSubtypeCountsByType accessor for the public counts API
- Wired into init() / flushCounts() / saveVerbMetadata / deleteVerbMetadata
Layer V4 — verb counts API + relationshipSubtypesOf
- brain.counts.byRelationshipSubtype(verb, subtype?) — O(1) breakdown or point
- brain.counts.topRelationshipSubtypes(verb, n) — top N by count
- brain.relationshipSubtypesOf(verb) — sorted distinct subtypes
Layer V5 — migrateField extended to verbs
- New entityKind?: 'noun' | 'verb' | 'both' option (default 'noun')
- Mirror verb iteration via storage.getVerbs() with same path semantics
- verbToRelationLike + buildRelationMigrationUpdate helpers project the
storage verb shape onto the Entity<T>-shaped surface readPath understands
- Routes through new updateRelation() for the verb-side rewrite
Enforcement (opt-in in 7.30, default in 8.0)
- brain.requireSubtype(type, options) — unified API for NounType OR VerbType.
Registers per-type rules with optional values whitelist; composes with the
brain-wide flag.
- new Brainy({ requireSubtype: true }) — brain-wide strict mode. Every public
write path validates the pairing guarantee.
- { except: [NounType.Thing, ...] } form for catch-all type exemptions
- Atomic-fail semantics on addMany / relateMany — pre-validate every item
before any storage write, throw on first failure with item index
- Per-type rules + brain-wide flag both throw with descriptive messages
- VFS infrastructure bypass via metadata.isVFSEntity / isVFS markers so
brain's own VFS writes don't get rejected when strict mode is on
VFS labeling — concrete subtypes for infrastructure entities
- VFS root: NounType.Collection + subtype: 'vfs-root' (was bare Collection)
- VFS directories: subtype: 'vfs-directory'
- VFS files: subtype: 'vfs-file' (NounType still mime-based)
- VFS containment edges: VerbType.Contains + subtype: 'vfs-contains'
- Lets consumers cleanly enumerate VFS state via find({ subtype: 'vfs-file' })
and distinguish Brainy's VFS Collections from user-created Collections
Docs
- docs/guides/subtypes-and-facets.md extended with Layer V (Verbs) section +
Enforcement section. New full reference at the bottom split into Layer 1
(nouns), Layer V (verbs), Layer 2 (facets), Layer 3 (migration), Enforcement.
- docs/api/README.md adds updateRelation(), getRelations({ subtype }), the
three verb-side counts methods, requireSubtype(), and the brain-wide
constructor option. relate() params include subtype.
- docs/DATA_MODEL.md adds a Subtype-for-VerbType section + STANDARD_VERB_FIELDS
- docs/architecture/finite-type-system.md extends Principle 1a to verbs
- docs/QUERY_OPERATORS.md adds a verb-subtype filter section covering
getRelations and find({connected, subtype}) traversal
- README.md "Subtypes" section now shows both noun + verb in one example +
the enforcement APIs
- RELEASES.md v7.30.0 entry with the full noun/verb capability parity matrix
Tests
- tests/integration/verb-subtype-and-enforcement.test.ts — 30 new tests
covering V1 round-trips, V1 set membership, updateRelation in place,
updateRelation preservation, V2 counts breakdown + point + topN + distinct,
V2 decrements on unrelate, V2 re-routes on updateRelation, V3 depth-1
traversal filter, V3 depth>1 explicit error, V4 verb migration, V4 both
entity kinds, V4 readBoth preservation, V5 per-type required rejection,
V5 vocabulary rejection, V5 on-vocab acceptance, V5 verb-side enforcement,
V5 addMany atomic-fail, V5 relateMany atomic-fail, V5 update enforcement,
V5 updateRelation enforcement, V5 brain-wide strict mode, V5 except clause.
Verification
- Unit suite: 1468/1468 passing
- Noun subtype integration (7.29 carryover): 26/26 passing
- Verb subtype + enforcement integration: 30/30 passing
- Type-check: clean
- Build: clean
- Public closed-source reference audit: clean
Internal 8.0 spec
- .strategy/BRAINY-8.0-SUBTYPE-CONTRACT.md (gitignored, not in npm artifact)
documents the contract upgrade Cortex 3.0 implements against: required-by-
default subtype, SubtypeRegistry typing hook, native simplification,
multi-hop traversal native fast path, brain.fillSubtypes() migration helper.
Coordinated via PLATFORM-HANDOFF rows CTX-SUBTYPE-PARITY-V2 (7.30 parallel
work) and CTX-SUBTYPE-8.0-CONTRACT (8.0 spec).
This commit is contained in:
parent
8c68396e71
commit
c0d326b36d
14 changed files with 2180 additions and 68 deletions
518
tests/integration/verb-subtype-and-enforcement.test.ts
Normal file
518
tests/integration/verb-subtype-and-enforcement.test.ts
Normal file
|
|
@ -0,0 +1,518 @@
|
|||
/**
|
||||
* @module tests/integration/verb-subtype-and-enforcement
|
||||
* @description Integration coverage for the 7.30.0 verb subtype mirror +
|
||||
* opt-in enforcement primitives:
|
||||
*
|
||||
* - Layer V1: `subtype` on `relate()` / `Relation` / `RelateParams` round-trips
|
||||
* through `getRelations({ subtype })`, the new `updateRelation()` method, and
|
||||
* the persisted verb-subtype rollup.
|
||||
* - Layer V2: `brain.counts.byRelationshipSubtype` / `topRelationshipSubtypes`
|
||||
* / `brain.relationshipSubtypesOf` mirror the noun-side counts API.
|
||||
* - Layer V3: `find({ connected: { via, subtype } })` filters traversal edges.
|
||||
* - Layer V4: `migrateField({ entityKind: 'verb' \| 'both' })` rewrites verb
|
||||
* fields.
|
||||
* - Layer V5: `brain.requireSubtype(type, opts)` per-type rules + brain-wide
|
||||
* strict mode (`new Brainy({ requireSubtype: true })`) reject every write
|
||||
* path missing or off-vocabulary.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { Brainy } from '../../src/brainy'
|
||||
import { NounType, VerbType } from '../../src/types/graphTypes'
|
||||
|
||||
describe('Verb subtype + enforcement (7.30.0)', () => {
|
||||
let brain: Brainy<any>
|
||||
|
||||
beforeEach(async () => {
|
||||
brain = new Brainy({ storage: { type: 'memory' }, silent: true })
|
||||
await brain.init()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await brain.close()
|
||||
})
|
||||
|
||||
describe('Layer V1: subtype on relationships', () => {
|
||||
it('persists subtype on relate() and round-trips through getRelations()', async () => {
|
||||
const fromId = await brain.add({ data: 'Avery', type: NounType.Person })
|
||||
const toId = await brain.add({ data: 'Jordan', type: NounType.Person })
|
||||
|
||||
const relId = await brain.relate({
|
||||
from: fromId,
|
||||
to: toId,
|
||||
type: VerbType.ReportsTo,
|
||||
subtype: 'direct'
|
||||
})
|
||||
|
||||
const all = await brain.getRelations({ from: fromId })
|
||||
const found = all.find(r => r.id === relId)
|
||||
expect(found).toBeDefined()
|
||||
expect(found!.subtype).toBe('direct')
|
||||
expect(found!.type).toBe(VerbType.ReportsTo)
|
||||
})
|
||||
|
||||
it('getRelations({ subtype }) filters by single value', async () => {
|
||||
const a = await brain.add({ data: 'A', type: NounType.Person })
|
||||
const b = await brain.add({ data: 'B', type: NounType.Person })
|
||||
const c = await brain.add({ data: 'C', type: NounType.Person })
|
||||
|
||||
await brain.relate({ from: a, to: b, type: VerbType.ReportsTo, subtype: 'direct' })
|
||||
await brain.relate({ from: a, to: c, type: VerbType.ReportsTo, subtype: 'dotted-line' })
|
||||
|
||||
const direct = await brain.getRelations({ from: a, subtype: 'direct' })
|
||||
expect(direct).toHaveLength(1)
|
||||
expect(direct[0].to).toBe(b)
|
||||
expect(direct[0].subtype).toBe('direct')
|
||||
})
|
||||
|
||||
it('getRelations({ subtype: [...] }) filters by set membership', async () => {
|
||||
const a = await brain.add({ data: 'A', type: NounType.Person })
|
||||
const b = await brain.add({ data: 'B', type: NounType.Person })
|
||||
const c = await brain.add({ data: 'C', type: NounType.Person })
|
||||
const d = await brain.add({ data: 'D', type: NounType.Person })
|
||||
|
||||
await brain.relate({ from: a, to: b, type: VerbType.ReportsTo, subtype: 'direct' })
|
||||
await brain.relate({ from: a, to: c, type: VerbType.ReportsTo, subtype: 'dotted-line' })
|
||||
await brain.relate({ from: a, to: d, type: VerbType.ReportsTo, subtype: 'matrix' })
|
||||
|
||||
const both = await brain.getRelations({
|
||||
from: a,
|
||||
type: VerbType.ReportsTo,
|
||||
subtype: ['direct', 'dotted-line']
|
||||
})
|
||||
expect(both).toHaveLength(2)
|
||||
expect(both.map(r => r.subtype).sort()).toEqual(['direct', 'dotted-line'])
|
||||
})
|
||||
|
||||
it('updateRelation() changes the subtype in place', async () => {
|
||||
const a = await brain.add({ data: 'A', type: NounType.Person })
|
||||
const b = await brain.add({ data: 'B', type: NounType.Person })
|
||||
const relId = await brain.relate({
|
||||
from: a,
|
||||
to: b,
|
||||
type: VerbType.ReportsTo,
|
||||
subtype: 'direct'
|
||||
})
|
||||
|
||||
await brain.updateRelation({ id: relId, subtype: 'dotted-line' })
|
||||
|
||||
const after = await brain.getRelations({ from: a, type: VerbType.ReportsTo })
|
||||
const found = after.find(r => r.id === relId)
|
||||
expect(found!.subtype).toBe('dotted-line')
|
||||
})
|
||||
|
||||
it('updateRelation() preserves subtype when omitted', async () => {
|
||||
const a = await brain.add({ data: 'A', type: NounType.Person })
|
||||
const b = await brain.add({ data: 'B', type: NounType.Person })
|
||||
const relId = await brain.relate({
|
||||
from: a,
|
||||
to: b,
|
||||
type: VerbType.ReportsTo,
|
||||
subtype: 'direct'
|
||||
})
|
||||
|
||||
await brain.updateRelation({ id: relId, weight: 0.5 })
|
||||
|
||||
const after = await brain.getRelations({ from: a, type: VerbType.ReportsTo })
|
||||
const found = after.find(r => r.id === relId)
|
||||
expect(found!.subtype).toBe('direct')
|
||||
expect(found!.weight).toBe(0.5)
|
||||
})
|
||||
|
||||
it('updateRelation() rejects empty updates', async () => {
|
||||
const a = await brain.add({ data: 'A', type: NounType.Person })
|
||||
const b = await brain.add({ data: 'B', type: NounType.Person })
|
||||
const relId = await brain.relate({ from: a, to: b, type: VerbType.ReportsTo })
|
||||
|
||||
await expect(brain.updateRelation({ id: relId })).rejects.toThrow(/at least one field/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Layer V2: verb counts API', () => {
|
||||
beforeEach(async () => {
|
||||
const a = await brain.add({ data: 'A', type: NounType.Person })
|
||||
const b = await brain.add({ data: 'B', type: NounType.Person })
|
||||
const c = await brain.add({ data: 'C', type: NounType.Person })
|
||||
const d = await brain.add({ data: 'D', type: NounType.Person })
|
||||
await brain.relate({ from: a, to: b, type: VerbType.ReportsTo, subtype: 'direct' })
|
||||
await brain.relate({ from: a, to: c, type: VerbType.ReportsTo, subtype: 'direct' })
|
||||
await brain.relate({ from: a, to: d, type: VerbType.ReportsTo, subtype: 'dotted-line' })
|
||||
await brain.relate({ from: b, to: c, type: VerbType.WorksWith, subtype: 'collaborator' })
|
||||
})
|
||||
|
||||
it('counts.byRelationshipSubtype(verb) returns the breakdown', async () => {
|
||||
const manages = brain.counts.byRelationshipSubtype(VerbType.ReportsTo)
|
||||
expect(manages).toEqual({ direct: 2, 'dotted-line': 1 })
|
||||
})
|
||||
|
||||
it('counts.byRelationshipSubtype(verb, subtype) returns the O(1) point count', async () => {
|
||||
expect(brain.counts.byRelationshipSubtype(VerbType.ReportsTo, 'direct')).toBe(2)
|
||||
expect(brain.counts.byRelationshipSubtype(VerbType.ReportsTo, 'dotted-line')).toBe(1)
|
||||
expect(brain.counts.byRelationshipSubtype(VerbType.ReportsTo, 'never-existed')).toBe(0)
|
||||
})
|
||||
|
||||
it('counts.topRelationshipSubtypes ranks by count', async () => {
|
||||
const top = brain.counts.topRelationshipSubtypes(VerbType.ReportsTo, 10)
|
||||
expect(top).toEqual([
|
||||
['direct', 2],
|
||||
['dotted-line', 1]
|
||||
])
|
||||
})
|
||||
|
||||
it('brain.relationshipSubtypesOf returns sorted distinct subtypes', async () => {
|
||||
expect(brain.relationshipSubtypesOf(VerbType.ReportsTo)).toEqual([
|
||||
'direct',
|
||||
'dotted-line'
|
||||
])
|
||||
expect(brain.relationshipSubtypesOf(VerbType.WorksWith)).toEqual(['collaborator'])
|
||||
})
|
||||
|
||||
it('rollup decrements on unrelate()', async () => {
|
||||
const a = await brain.add({ data: 'X', type: NounType.Person })
|
||||
const b = await brain.add({ data: 'Y', type: NounType.Person })
|
||||
const relId = await brain.relate({
|
||||
from: a,
|
||||
to: b,
|
||||
type: VerbType.ReportsTo,
|
||||
subtype: 'direct'
|
||||
})
|
||||
expect(brain.counts.byRelationshipSubtype(VerbType.ReportsTo, 'direct')).toBe(3)
|
||||
|
||||
await brain.unrelate(relId)
|
||||
expect(brain.counts.byRelationshipSubtype(VerbType.ReportsTo, 'direct')).toBe(2)
|
||||
})
|
||||
|
||||
it('rollup re-routes on updateRelation() subtype change', async () => {
|
||||
const a = await brain.add({ data: 'X', type: NounType.Person })
|
||||
const b = await brain.add({ data: 'Y', type: NounType.Person })
|
||||
const relId = await brain.relate({
|
||||
from: a,
|
||||
to: b,
|
||||
type: VerbType.ReportsTo,
|
||||
subtype: 'direct'
|
||||
})
|
||||
expect(brain.counts.byRelationshipSubtype(VerbType.ReportsTo, 'direct')).toBe(3)
|
||||
expect(brain.counts.byRelationshipSubtype(VerbType.ReportsTo, 'dotted-line')).toBe(1)
|
||||
|
||||
await brain.updateRelation({ id: relId, subtype: 'dotted-line' })
|
||||
expect(brain.counts.byRelationshipSubtype(VerbType.ReportsTo, 'direct')).toBe(2)
|
||||
expect(brain.counts.byRelationshipSubtype(VerbType.ReportsTo, 'dotted-line')).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Layer V3: find({ connected, subtype }) traversal filter', () => {
|
||||
it('filters depth-1 traversal by edge subtype', async () => {
|
||||
const ceo = await brain.add({ data: 'Avery', type: NounType.Person })
|
||||
const vp1 = await brain.add({ data: 'Jordan', type: NounType.Person })
|
||||
const vp2 = await brain.add({ data: 'Sam', type: NounType.Person })
|
||||
const matrix = await brain.add({ data: 'Lee', type: NounType.Person })
|
||||
|
||||
await brain.relate({ from: ceo, to: vp1, type: VerbType.ReportsTo, subtype: 'direct' })
|
||||
await brain.relate({ from: ceo, to: vp2, type: VerbType.ReportsTo, subtype: 'direct' })
|
||||
await brain.relate({ from: ceo, to: matrix, type: VerbType.ReportsTo, subtype: 'dotted-line' })
|
||||
|
||||
const directReports = await brain.find({
|
||||
connected: {
|
||||
from: ceo,
|
||||
via: VerbType.ReportsTo,
|
||||
subtype: 'direct',
|
||||
depth: 1
|
||||
}
|
||||
})
|
||||
const ids = directReports.map(r => r.id).sort()
|
||||
expect(ids).toEqual([vp1, vp2].sort())
|
||||
})
|
||||
|
||||
it('rejects depth > 1 with subtype filter (Cortex native path will land it)', async () => {
|
||||
const a = await brain.add({ data: 'A', type: NounType.Person })
|
||||
await expect(
|
||||
brain.find({
|
||||
connected: { from: a, via: VerbType.ReportsTo, subtype: 'direct', depth: 2 }
|
||||
})
|
||||
).rejects.toThrow(/depth > 1/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Layer V4: migrateField({ entityKind })', () => {
|
||||
it("migrates verb metadata.kind → top-level subtype with entityKind: 'verb'", async () => {
|
||||
const a = await brain.add({ data: 'A', type: NounType.Person })
|
||||
const b = await brain.add({ data: 'B', type: NounType.Person })
|
||||
|
||||
const relId = await brain.relate({
|
||||
from: a,
|
||||
to: b,
|
||||
type: VerbType.RelatedTo,
|
||||
metadata: { kind: 'spouse' }
|
||||
})
|
||||
|
||||
const result = await brain.migrateField({
|
||||
from: 'metadata.kind',
|
||||
to: 'subtype',
|
||||
entityKind: 'verb'
|
||||
})
|
||||
expect(result.errors).toEqual([])
|
||||
expect(result.migrated).toBe(1)
|
||||
|
||||
const after = await brain.getRelations({ from: a })
|
||||
const found = after.find(r => r.id === relId)
|
||||
expect(found!.subtype).toBe('spouse')
|
||||
expect((found!.metadata as any).kind).toBeUndefined()
|
||||
})
|
||||
|
||||
it("migrates both noun and verb fields with entityKind: 'both'", async () => {
|
||||
const a = await brain.add({
|
||||
data: 'A',
|
||||
type: NounType.Person,
|
||||
metadata: { kind: 'employee' }
|
||||
})
|
||||
const b = await brain.add({
|
||||
data: 'B',
|
||||
type: NounType.Person,
|
||||
metadata: { kind: 'employee' }
|
||||
})
|
||||
await brain.relate({
|
||||
from: a,
|
||||
to: b,
|
||||
type: VerbType.RelatedTo,
|
||||
metadata: { kind: 'colleague' }
|
||||
})
|
||||
|
||||
const result = await brain.migrateField({
|
||||
from: 'metadata.kind',
|
||||
to: 'subtype',
|
||||
entityKind: 'both'
|
||||
})
|
||||
expect(result.errors).toEqual([])
|
||||
expect(result.migrated).toBe(3) // 2 nouns + 1 verb
|
||||
|
||||
const ea = await brain.get(a)
|
||||
expect(ea!.subtype).toBe('employee')
|
||||
const eb = await brain.get(b)
|
||||
expect(eb!.subtype).toBe('employee')
|
||||
const rels = await brain.getRelations({ from: a })
|
||||
expect(rels[0].subtype).toBe('colleague')
|
||||
})
|
||||
|
||||
it("readBoth: true on verb migration preserves the source field", async () => {
|
||||
const a = await brain.add({ data: 'A', type: NounType.Person })
|
||||
const b = await brain.add({ data: 'B', type: NounType.Person })
|
||||
const relId = await brain.relate({
|
||||
from: a,
|
||||
to: b,
|
||||
type: VerbType.RelatedTo,
|
||||
metadata: { kind: 'sibling' }
|
||||
})
|
||||
|
||||
const result = await brain.migrateField({
|
||||
from: 'metadata.kind',
|
||||
to: 'subtype',
|
||||
entityKind: 'verb',
|
||||
readBoth: true
|
||||
})
|
||||
expect(result.migrated).toBe(1)
|
||||
|
||||
const rels = await brain.getRelations({ from: a })
|
||||
const found = rels.find(r => r.id === relId)
|
||||
expect(found!.subtype).toBe('sibling')
|
||||
expect((found!.metadata as any).kind).toBe('sibling')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Layer V5: enforcement', () => {
|
||||
describe('per-type registration (brain.requireSubtype)', () => {
|
||||
it("requireSubtype(NounType, { required }) rejects add() without subtype", async () => {
|
||||
brain.requireSubtype(NounType.Person, { required: true })
|
||||
|
||||
await expect(
|
||||
brain.add({ data: 'no subtype', type: NounType.Person })
|
||||
).rejects.toThrow(/requires subtype/)
|
||||
})
|
||||
|
||||
it("requireSubtype with values rejects off-vocabulary on add()", async () => {
|
||||
brain.requireSubtype(NounType.Person, {
|
||||
values: ['employee', 'customer'],
|
||||
required: true
|
||||
})
|
||||
|
||||
await expect(
|
||||
brain.add({ data: 'bad', type: NounType.Person, subtype: 'vendor' })
|
||||
).rejects.toThrow(/not in registered vocabulary/)
|
||||
})
|
||||
|
||||
it("requireSubtype with values accepts on-vocabulary", async () => {
|
||||
brain.requireSubtype(NounType.Person, {
|
||||
values: ['employee', 'customer'],
|
||||
required: true
|
||||
})
|
||||
|
||||
const id = await brain.add({
|
||||
data: 'ok',
|
||||
type: NounType.Person,
|
||||
subtype: 'employee'
|
||||
})
|
||||
expect(id).toBeDefined()
|
||||
})
|
||||
|
||||
it("requireSubtype(VerbType, { required }) rejects relate() without subtype", async () => {
|
||||
const a = await brain.add({ data: 'A', type: NounType.Person })
|
||||
const b = await brain.add({ data: 'B', type: NounType.Person })
|
||||
brain.requireSubtype(VerbType.ReportsTo, { required: true })
|
||||
|
||||
await expect(
|
||||
brain.relate({ from: a, to: b, type: VerbType.ReportsTo })
|
||||
).rejects.toThrow(/requires subtype/)
|
||||
})
|
||||
|
||||
it("requireSubtype(VerbType, values) rejects off-vocabulary on relate()", async () => {
|
||||
const a = await brain.add({ data: 'A', type: NounType.Person })
|
||||
const b = await brain.add({ data: 'B', type: NounType.Person })
|
||||
brain.requireSubtype(VerbType.ReportsTo, {
|
||||
values: ['direct', 'dotted-line'],
|
||||
required: true
|
||||
})
|
||||
|
||||
await expect(
|
||||
brain.relate({ from: a, to: b, type: VerbType.ReportsTo, subtype: 'matrix' })
|
||||
).rejects.toThrow(/not in registered vocabulary/)
|
||||
})
|
||||
|
||||
it('enforcement applies to addMany() — atomic-fail, no partial writes', async () => {
|
||||
brain.requireSubtype(NounType.Person, { required: true })
|
||||
|
||||
await expect(
|
||||
brain.addMany({
|
||||
items: [
|
||||
{ data: 'ok', type: NounType.Person, subtype: 'employee' },
|
||||
{ data: 'missing', type: NounType.Person }
|
||||
]
|
||||
})
|
||||
).rejects.toThrow(/item\[1\] failed subtype enforcement/)
|
||||
|
||||
// Verify no partial writes — first item should NOT be persisted
|
||||
const persons = await brain.find({ type: NounType.Person, subtype: 'employee' })
|
||||
expect(persons).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('enforcement applies to relateMany() — atomic-fail', async () => {
|
||||
const a = await brain.add({ data: 'A', type: NounType.Person })
|
||||
const b = await brain.add({ data: 'B', type: NounType.Person })
|
||||
const c = await brain.add({ data: 'C', type: NounType.Person })
|
||||
brain.requireSubtype(VerbType.ReportsTo, { required: true })
|
||||
|
||||
await expect(
|
||||
brain.relateMany({
|
||||
items: [
|
||||
{ from: a, to: b, type: VerbType.ReportsTo, subtype: 'direct' },
|
||||
{ from: a, to: c, type: VerbType.ReportsTo }
|
||||
]
|
||||
})
|
||||
).rejects.toThrow(/item\[1\] failed subtype enforcement/)
|
||||
})
|
||||
|
||||
it('enforcement applies to update() when changing subtype', async () => {
|
||||
brain.requireSubtype(NounType.Person, {
|
||||
values: ['employee', 'customer'],
|
||||
required: true
|
||||
})
|
||||
const id = await brain.add({
|
||||
data: 'ok',
|
||||
type: NounType.Person,
|
||||
subtype: 'employee'
|
||||
})
|
||||
|
||||
await expect(
|
||||
brain.update({ id, subtype: 'vendor' })
|
||||
).rejects.toThrow(/not in registered vocabulary/)
|
||||
})
|
||||
|
||||
it('enforcement applies to updateRelation() when changing subtype', async () => {
|
||||
const a = await brain.add({ data: 'A', type: NounType.Person })
|
||||
const b = await brain.add({ data: 'B', type: NounType.Person })
|
||||
brain.requireSubtype(VerbType.ReportsTo, {
|
||||
values: ['direct', 'dotted-line'],
|
||||
required: true
|
||||
})
|
||||
|
||||
const relId = await brain.relate({
|
||||
from: a,
|
||||
to: b,
|
||||
type: VerbType.ReportsTo,
|
||||
subtype: 'direct'
|
||||
})
|
||||
|
||||
await expect(
|
||||
brain.updateRelation({ id: relId, subtype: 'matrix' })
|
||||
).rejects.toThrow(/not in registered vocabulary/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('brain-wide strict mode', () => {
|
||||
it('new Brainy({ requireSubtype: true }) rejects every add() without subtype', async () => {
|
||||
await brain.close()
|
||||
brain = new Brainy({
|
||||
storage: { type: 'memory' },
|
||||
silent: true,
|
||||
requireSubtype: true
|
||||
})
|
||||
await brain.init()
|
||||
|
||||
await expect(
|
||||
brain.add({ data: 'no subtype', type: NounType.Person })
|
||||
).rejects.toThrow(/requires subtype/)
|
||||
})
|
||||
|
||||
it('strict mode accepts writes with subtype', async () => {
|
||||
await brain.close()
|
||||
brain = new Brainy({
|
||||
storage: { type: 'memory' },
|
||||
silent: true,
|
||||
requireSubtype: true
|
||||
})
|
||||
await brain.init()
|
||||
|
||||
const id = await brain.add({
|
||||
data: 'ok',
|
||||
type: NounType.Person,
|
||||
subtype: 'employee'
|
||||
})
|
||||
expect(id).toBeDefined()
|
||||
})
|
||||
|
||||
it('strict mode rejects relate() without subtype', async () => {
|
||||
await brain.close()
|
||||
brain = new Brainy({
|
||||
storage: { type: 'memory' },
|
||||
silent: true,
|
||||
requireSubtype: true
|
||||
})
|
||||
await brain.init()
|
||||
|
||||
const a = await brain.add({ data: 'A', type: NounType.Person, subtype: 'employee' })
|
||||
const b = await brain.add({ data: 'B', type: NounType.Person, subtype: 'employee' })
|
||||
|
||||
await expect(
|
||||
brain.relate({ from: a, to: b, type: VerbType.ReportsTo })
|
||||
).rejects.toThrow(/requires subtype/)
|
||||
})
|
||||
|
||||
it('except clause allows listed types through without subtype', async () => {
|
||||
await brain.close()
|
||||
brain = new Brainy({
|
||||
storage: { type: 'memory' },
|
||||
silent: true,
|
||||
requireSubtype: { except: [NounType.Thing] }
|
||||
})
|
||||
await brain.init()
|
||||
|
||||
// Thing is exempt — should succeed without subtype
|
||||
const thing = await brain.add({ data: 'a generic thing', type: NounType.Thing })
|
||||
expect(thing).toBeDefined()
|
||||
|
||||
// Person is not exempt — should require subtype
|
||||
await expect(
|
||||
brain.add({ data: 'no subtype', type: NounType.Person })
|
||||
).rejects.toThrow(/requires subtype/)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue