2025-09-11 16:23:32 -07:00
import { GenericContainer , StartedTestContainer , Wait } from 'testcontainers' ;
import { Brainy } from '../../src/brainy' ;
export interface DistributedTestNode {
id : string ;
brain : Brainy ;
port : number ;
}
export interface DistributedClusterConfig {
nodeCount : number ;
redisHost? : string ;
redisPort? : number ;
useTestContainers? : boolean ;
}
export class DistributedTestCluster {
private nodes : DistributedTestNode [ ] = [ ] ;
private redisContainer? : StartedTestContainer ;
private config : DistributedClusterConfig ;
private redisEndpoint ? : { host : string ; port : number } ;
constructor ( config : DistributedClusterConfig ) {
this . config = {
useTestContainers : config.useTestContainers !== false ,
. . . config ,
nodeCount : config.nodeCount || 3
} ;
}
async start ( ) : Promise < void > {
console . log ( ` Starting distributed test cluster with ${ this . config . nodeCount } nodes... ` ) ;
// Start Redis if using test containers
if ( this . config . useTestContainers && ! this . config . redisHost ) {
await this . startRedis ( ) ;
} else {
this . redisEndpoint = {
host : this.config.redisHost || 'localhost' ,
port : this.config.redisPort || 6379
} ;
}
// Start cluster nodes
await this . startNodes ( ) ;
// Wait for nodes to discover each other
await this . waitForClusterFormation ( ) ;
console . log ( ` Distributed cluster ready with ${ this . nodes . length } nodes ` ) ;
}
private async startRedis ( ) : Promise < void > {
console . log ( 'Starting Redis test container...' ) ;
this . redisContainer = await new GenericContainer ( 'redis:7-alpine' )
. withExposedPorts ( 6379 )
. withCommand ( [ 'redis-server' , '--appendonly' , 'yes' ] )
. withWaitStrategy ( Wait . forLogMessage ( 'Ready to accept connections' ) )
. start ( ) ;
this . redisEndpoint = {
host : this.redisContainer.getHost ( ) ,
port : this.redisContainer.getMappedPort ( 6379 )
} ;
console . log ( ` Redis started on ${ this . redisEndpoint . host } : ${ this . redisEndpoint . port } ` ) ;
}
private async startNodes ( ) : Promise < void > {
const basePort = 8000 ;
for ( let i = 0 ; i < this . config . nodeCount ; i ++ ) {
const nodeId = ` node- ${ i + 1 } ` ;
const port = basePort + i ;
console . log ( ` Starting ${ nodeId } on port ${ port } ... ` ) ;
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
const brain = new Brainy ( { requireSubtype : false ,
2025-09-11 16:23:32 -07:00
storage : {
type : 'memory'
}
// distributed config is not part of BrainyConfig
// distributed: {
// enabled: true,
// nodeId: nodeId,
// redis: {
// host: this.redisEndpoint!.host,
// port: this.redisEndpoint!.port
// },
// server: {
// port: port,
// host: '0.0.0.0'
// },
// discovery: {
// interval: 1000,
// timeout: 500
// },
// sharding: {
// enabled: true,
// replicas: 2
// },
// consensus: {
// enabled: true,
// quorum: Math.floor(this.config.nodeCount / 2) + 1
// }
// }
} ) ;
await brain . init ( ) ;
this . nodes . push ( {
id : nodeId ,
brain : brain ,
port : port
} ) ;
}
}
private async waitForClusterFormation ( ) : Promise < void > {
console . log ( 'Waiting for cluster formation...' ) ;
const maxAttempts = 30 ;
const delayMs = 1000 ;
for ( let attempt = 0 ; attempt < maxAttempts ; attempt ++ ) {
const allNodesDiscovered = await this . checkClusterHealth ( ) ;
if ( allNodesDiscovered ) {
console . log ( 'All nodes have discovered each other' ) ;
return ;
}
await new Promise ( resolve = > setTimeout ( resolve , delayMs ) ) ;
}
throw new Error ( 'Cluster formation timeout - nodes failed to discover each other' ) ;
}
private async checkClusterHealth ( ) : Promise < boolean > {
for ( const node of this . nodes ) {
const health = await node . brain . health ( ) ;
if ( ! health . distributed || ! health . distributed . connectedNodes ) {
return false ;
}
// Each node should see all other nodes
const expectedNodeCount = this . config . nodeCount - 1 ;
if ( health . distributed . connectedNodes . length < expectedNodeCount ) {
return false ;
}
}
return true ;
}
async stop ( ) : Promise < void > {
console . log ( 'Stopping distributed test cluster...' ) ;
// Stop all nodes
for ( const node of this . nodes ) {
try {
if ( node . brain . close ) {
await node . brain . close ( ) ;
}
console . log ( ` Stopped ${ node . id } ` ) ;
} catch ( error ) {
console . warn ( ` Failed to stop ${ node . id } : ` , error ) ;
}
}
// Stop Redis container
if ( this . redisContainer ) {
await this . redisContainer . stop ( ) ;
console . log ( 'Redis container stopped' ) ;
}
this . nodes = [ ] ;
console . log ( 'Distributed cluster stopped' ) ;
}
getNodes ( ) : DistributedTestNode [ ] {
return this . nodes ;
}
getNode ( index : number ) : DistributedTestNode {
if ( index < 0 || index >= this . nodes . length ) {
throw new Error ( ` Invalid node index: ${ index } ` ) ;
}
return this . nodes [ index ] ;
}
getPrimaryNode ( ) : DistributedTestNode {
return this . nodes [ 0 ] ;
}
getSecondaryNodes ( ) : DistributedTestNode [ ] {
return this . nodes . slice ( 1 ) ;
}
async executeOnAllNodes < T > ( fn : ( brain : Brainy ) = > Promise < T > ) : Promise < T [ ] > {
return Promise . all ( this . nodes . map ( node = > fn ( node . brain ) ) ) ;
}
async executeOnNode < T > ( index : number , fn : ( brain : Brainy ) = > Promise < T > ) : Promise < T > {
const node = this . getNode ( index ) ;
return fn ( node . brain ) ;
}
async simulateNodeFailure ( index : number ) : Promise < void > {
const node = this . getNode ( index ) ;
console . log ( ` Simulating failure of ${ node . id } ... ` ) ;
if ( node . brain . close ) {
await node . brain . close ( ) ;
}
// Remove from active nodes
this . nodes . splice ( index , 1 ) ;
}
async addNode ( ) : Promise < DistributedTestNode > {
const nodeId = ` node- ${ this . nodes . length + 1 } ` ;
const port = 8000 + this . nodes . length ;
console . log ( ` Adding new node ${ nodeId } on port ${ port } ... ` ) ;
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
const brain = new Brainy ( { requireSubtype : false ,
2025-09-11 16:23:32 -07:00
storage : {
type : 'memory'
}
// distributed config is not part of BrainyConfig
// distributed: {
// enabled: true,
// nodeId: nodeId,
// redis: {
// host: this.redisEndpoint!.host,
// port: this.redisEndpoint!.port
// },
// server: {
// port: port,
// host: '0.0.0.0'
// },
// discovery: {
// interval: 1000,
// timeout: 500
// },
// sharding: {
// enabled: true,
// replicas: 2
// }
// }
} ) ;
await brain . init ( ) ;
const newNode = { id : nodeId , brain , port } ;
this . nodes . push ( newNode ) ;
// Wait for new node to join cluster
await this . waitForNodeDiscovery ( nodeId ) ;
return newNode ;
}
private async waitForNodeDiscovery ( nodeId : string ) : Promise < void > {
console . log ( ` Waiting for ${ nodeId } to join cluster... ` ) ;
const maxAttempts = 10 ;
const delayMs = 1000 ;
for ( let attempt = 0 ; attempt < maxAttempts ; attempt ++ ) {
const discovered = await this . isNodeDiscovered ( nodeId ) ;
if ( discovered ) {
console . log ( ` ${ nodeId } has joined the cluster ` ) ;
return ;
}
await new Promise ( resolve = > setTimeout ( resolve , delayMs ) ) ;
}
throw new Error ( ` Timeout waiting for ${ nodeId } to join cluster ` ) ;
}
private async isNodeDiscovered ( nodeId : string ) : Promise < boolean > {
// Check if other nodes can see the new node
for ( const node of this . nodes ) {
if ( node . id === nodeId ) continue ;
const health = await node . brain . health ( ) ;
if ( health . distributed ? . connectedNodes ? . includes ( nodeId ) ) {
return true ;
}
}
return false ;
}
}