brainy/tests/helpers/distributed-cluster.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

300 lines
No EOL
7.8 KiB
TypeScript

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}...`);
const brain = new Brainy({ requireSubtype: false,
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}...`);
const brain = new Brainy({ requireSubtype: false,
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;
}
}