feat: add v5.8.0 features - transactions, pagination, and comprehensive docs
**Transaction System (TIER 1.2)** - Atomic operations with automatic rollback - 36 unit tests + 35 integration tests passing - Full documentation in docs/transactions.md **Duplicate Check Optimization (TIER 1.4)** - Optimized from O(n) to O(log n) using GraphAdjacencyIndex - Uses LSM-tree for efficient lookups - Tests verify performance improvements **GraphIndex Pagination (TIER 1.5)** - Production-scale pagination for high-degree nodes - Backward compatible API - 18 pagination tests passing **Comprehensive Filter Documentation (TIER 1.6)** - Complete operator reference (15 operators) - Compound filters (anyOf, allOf, nested logic) - Common query patterns and troubleshooting guide - 642 lines of new documentation **README Updates** - Added Filter & Query Syntax Guide to Essential Reading - Added Transactions to Core Concepts section All changes tested and production-ready for v5.8.0 release. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
52e961760d
commit
e40fee39d8
21 changed files with 5657 additions and 182 deletions
396
tests/transaction/Transaction.test.ts
Normal file
396
tests/transaction/Transaction.test.ts
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
/**
|
||||
* Transaction Core Tests
|
||||
*
|
||||
* Tests the Transaction class for:
|
||||
* - Basic execution flow
|
||||
* - Rollback on failure
|
||||
* - State management
|
||||
* - Timeout handling
|
||||
* - Retry logic
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { Transaction } from '../../src/transaction/Transaction.js'
|
||||
import type { Operation, RollbackAction } from '../../src/transaction/types.js'
|
||||
import {
|
||||
InvalidTransactionStateError,
|
||||
TransactionExecutionError,
|
||||
TransactionTimeoutError
|
||||
} from '../../src/transaction/errors.js'
|
||||
|
||||
describe('Transaction', () => {
|
||||
describe('Basic Execution', () => {
|
||||
it('should execute operations in order and commit', async () => {
|
||||
const executionOrder: number[] = []
|
||||
|
||||
const op1: Operation = {
|
||||
name: 'Operation1',
|
||||
execute: async () => {
|
||||
executionOrder.push(1)
|
||||
return async () => { /* rollback */ }
|
||||
}
|
||||
}
|
||||
|
||||
const op2: Operation = {
|
||||
name: 'Operation2',
|
||||
execute: async () => {
|
||||
executionOrder.push(2)
|
||||
return async () => { /* rollback */ }
|
||||
}
|
||||
}
|
||||
|
||||
const tx = new Transaction()
|
||||
tx.addOperation(op1)
|
||||
tx.addOperation(op2)
|
||||
|
||||
await tx.execute()
|
||||
|
||||
expect(executionOrder).toEqual([1, 2])
|
||||
expect(tx.getState()).toBe('committed')
|
||||
})
|
||||
|
||||
it('should track operation count', () => {
|
||||
const tx = new Transaction()
|
||||
|
||||
expect(tx.getOperationCount()).toBe(0)
|
||||
|
||||
tx.addOperation({ execute: async () => undefined })
|
||||
expect(tx.getOperationCount()).toBe(1)
|
||||
|
||||
tx.addOperation({ execute: async () => undefined })
|
||||
expect(tx.getOperationCount()).toBe(2)
|
||||
})
|
||||
|
||||
it('should record execution time', async () => {
|
||||
const tx = new Transaction()
|
||||
tx.addOperation({
|
||||
execute: async () => {
|
||||
// Small delay to ensure measurable time
|
||||
await new Promise(resolve => setTimeout(resolve, 1))
|
||||
return async () => {}
|
||||
}
|
||||
})
|
||||
|
||||
await tx.execute()
|
||||
|
||||
const executionTime = tx.getExecutionTimeMs()
|
||||
expect(executionTime).toBeGreaterThanOrEqual(0) // Can be 0 for very fast operations
|
||||
expect(executionTime).toBeLessThan(1000) // Should be reasonably quick
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rollback on Failure', () => {
|
||||
it('should rollback all operations when one fails', async () => {
|
||||
const rollbackOrder: number[] = []
|
||||
|
||||
const op1: Operation = {
|
||||
name: 'Operation1',
|
||||
execute: async () => {
|
||||
return async () => { rollbackOrder.push(1) }
|
||||
}
|
||||
}
|
||||
|
||||
const op2: Operation = {
|
||||
name: 'Operation2',
|
||||
execute: async () => {
|
||||
throw new Error('Operation 2 failed')
|
||||
}
|
||||
}
|
||||
|
||||
const op3: Operation = {
|
||||
name: 'Operation3',
|
||||
execute: async () => {
|
||||
return async () => { rollbackOrder.push(3) }
|
||||
}
|
||||
}
|
||||
|
||||
const tx = new Transaction()
|
||||
tx.addOperation(op1)
|
||||
tx.addOperation(op2)
|
||||
tx.addOperation(op3)
|
||||
|
||||
await expect(tx.execute()).rejects.toThrow(TransactionExecutionError)
|
||||
|
||||
expect(tx.getState()).toBe('rolled_back')
|
||||
// Only op1 executed before failure, so only op1 should rollback
|
||||
expect(rollbackOrder).toEqual([1])
|
||||
})
|
||||
|
||||
it('should rollback operations in reverse order', async () => {
|
||||
const rollbackOrder: number[] = []
|
||||
|
||||
const op1: Operation = {
|
||||
execute: async () => {
|
||||
return async () => { rollbackOrder.push(1) }
|
||||
}
|
||||
}
|
||||
|
||||
const op2: Operation = {
|
||||
execute: async () => {
|
||||
return async () => { rollbackOrder.push(2) }
|
||||
}
|
||||
}
|
||||
|
||||
const op3: Operation = {
|
||||
execute: async () => {
|
||||
throw new Error('op3 failed')
|
||||
}
|
||||
}
|
||||
|
||||
const tx = new Transaction()
|
||||
tx.addOperation(op1)
|
||||
tx.addOperation(op2)
|
||||
tx.addOperation(op3)
|
||||
|
||||
await expect(tx.execute()).rejects.toThrow()
|
||||
|
||||
// Rollback in reverse order: op2, then op1
|
||||
expect(rollbackOrder).toEqual([2, 1])
|
||||
})
|
||||
|
||||
it('should retry failed rollback operations', async () => {
|
||||
let rollbackAttempts = 0
|
||||
|
||||
const op: Operation = {
|
||||
execute: async () => {
|
||||
return async () => {
|
||||
rollbackAttempts++
|
||||
if (rollbackAttempts < 2) {
|
||||
throw new Error('Rollback failed')
|
||||
}
|
||||
// Success on 2nd attempt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const failingOp: Operation = {
|
||||
execute: async () => {
|
||||
throw new Error('Operation failed')
|
||||
}
|
||||
}
|
||||
|
||||
const tx = new Transaction()
|
||||
tx.addOperation(op)
|
||||
tx.addOperation(failingOp)
|
||||
|
||||
await expect(tx.execute()).rejects.toThrow()
|
||||
|
||||
// Should have retried rollback
|
||||
expect(rollbackAttempts).toBe(2)
|
||||
})
|
||||
|
||||
it('should continue rollback even if one rollback fails', async () => {
|
||||
const rollbackOrder: number[] = []
|
||||
|
||||
const op1: Operation = {
|
||||
execute: async () => {
|
||||
return async () => { rollbackOrder.push(1) }
|
||||
}
|
||||
}
|
||||
|
||||
const op2: Operation = {
|
||||
execute: async () => {
|
||||
return async () => {
|
||||
rollbackOrder.push(2)
|
||||
throw new Error('Rollback 2 failed')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const op3: Operation = {
|
||||
execute: async () => {
|
||||
throw new Error('op3 failed')
|
||||
}
|
||||
}
|
||||
|
||||
const tx = new Transaction({ maxRollbackRetries: 1 })
|
||||
tx.addOperation(op1)
|
||||
tx.addOperation(op2)
|
||||
tx.addOperation(op3)
|
||||
|
||||
await expect(tx.execute()).rejects.toThrow()
|
||||
|
||||
// Should attempt both rollbacks despite op2 failing
|
||||
expect(rollbackOrder).toContain(1)
|
||||
expect(rollbackOrder).toContain(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('State Management', () => {
|
||||
it('should prevent adding operations after execution starts', async () => {
|
||||
const tx = new Transaction()
|
||||
tx.addOperation({ execute: async () => undefined })
|
||||
|
||||
const executePromise = tx.execute()
|
||||
|
||||
expect(() => {
|
||||
tx.addOperation({ execute: async () => undefined })
|
||||
}).toThrow(InvalidTransactionStateError)
|
||||
|
||||
await executePromise
|
||||
})
|
||||
|
||||
it('should prevent double execution', async () => {
|
||||
const tx = new Transaction()
|
||||
tx.addOperation({ execute: async () => undefined })
|
||||
|
||||
await tx.execute()
|
||||
|
||||
await expect(tx.execute()).rejects.toThrow(InvalidTransactionStateError)
|
||||
})
|
||||
|
||||
it('should transition through states correctly', async () => {
|
||||
const states: string[] = []
|
||||
|
||||
const tx = new Transaction()
|
||||
states.push(tx.getState())
|
||||
|
||||
tx.addOperation({
|
||||
execute: async () => {
|
||||
states.push(tx.getState())
|
||||
return async () => {}
|
||||
}
|
||||
})
|
||||
|
||||
await tx.execute()
|
||||
states.push(tx.getState())
|
||||
|
||||
expect(states).toEqual(['pending', 'executing', 'committed'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Timeout Handling', () => {
|
||||
it('should timeout long-running transactions with many operations', async () => {
|
||||
const tx = new Transaction({ timeout: 50 })
|
||||
|
||||
// Add many operations that cumulatively exceed timeout
|
||||
for (let i = 0; i < 10; i++) {
|
||||
tx.addOperation({
|
||||
execute: async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
return async () => {}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
await expect(tx.execute()).rejects.toThrow(TransactionTimeoutError)
|
||||
})
|
||||
|
||||
it('should not timeout fast transactions', async () => {
|
||||
const tx = new Transaction({ timeout: 1000 })
|
||||
|
||||
tx.addOperation({
|
||||
execute: async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
return async () => {}
|
||||
}
|
||||
})
|
||||
|
||||
await expect(tx.execute()).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Options', () => {
|
||||
it('should respect custom timeout', async () => {
|
||||
const tx = new Transaction({ timeout: 50 })
|
||||
|
||||
// Many operations to ensure cumulative time exceeds timeout
|
||||
for (let i = 0; i < 10; i++) {
|
||||
tx.addOperation({
|
||||
execute: async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
return async () => {}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const error = await tx.execute().catch(e => e)
|
||||
expect(error).toBeInstanceOf(TransactionTimeoutError)
|
||||
})
|
||||
|
||||
it('should respect maxRollbackRetries', async () => {
|
||||
let attempts = 0
|
||||
|
||||
const tx = new Transaction({ maxRollbackRetries: 2 })
|
||||
|
||||
tx.addOperation({
|
||||
execute: async () => {
|
||||
return async () => {
|
||||
attempts++
|
||||
throw new Error('Rollback failed')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
tx.addOperation({
|
||||
execute: async () => {
|
||||
throw new Error('Operation failed')
|
||||
}
|
||||
})
|
||||
|
||||
await expect(tx.execute()).rejects.toThrow()
|
||||
|
||||
// Should try maxRollbackRetries times
|
||||
expect(attempts).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error Context', () => {
|
||||
it('should include operation name in error', async () => {
|
||||
const tx = new Transaction()
|
||||
|
||||
tx.addOperation({
|
||||
name: 'MySpecialOperation',
|
||||
execute: async () => {
|
||||
throw new Error('Failed')
|
||||
}
|
||||
})
|
||||
|
||||
const error = await tx.execute().catch(e => e)
|
||||
expect(error).toBeInstanceOf(TransactionExecutionError)
|
||||
expect(error.operationName).toBe('MySpecialOperation')
|
||||
})
|
||||
|
||||
it('should include operation index in error', async () => {
|
||||
const tx = new Transaction()
|
||||
|
||||
tx.addOperation({ execute: async () => undefined })
|
||||
tx.addOperation({ execute: async () => undefined })
|
||||
tx.addOperation({
|
||||
execute: async () => {
|
||||
throw new Error('Failed')
|
||||
}
|
||||
})
|
||||
|
||||
const error = await tx.execute().catch(e => e)
|
||||
expect(error).toBeInstanceOf(TransactionExecutionError)
|
||||
expect(error.operationIndex).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Empty Transactions', () => {
|
||||
it('should handle empty transaction', async () => {
|
||||
const tx = new Transaction()
|
||||
|
||||
await tx.execute()
|
||||
|
||||
expect(tx.getState()).toBe('committed')
|
||||
expect(tx.getOperationCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Operations without Rollback', () => {
|
||||
it('should handle operations that return no rollback action', async () => {
|
||||
const tx = new Transaction()
|
||||
|
||||
tx.addOperation({
|
||||
execute: async () => {
|
||||
return undefined // No rollback needed
|
||||
}
|
||||
})
|
||||
|
||||
await expect(tx.execute()).resolves.toBeUndefined()
|
||||
expect(tx.getState()).toBe('committed')
|
||||
})
|
||||
})
|
||||
})
|
||||
328
tests/transaction/TransactionManager.test.ts
Normal file
328
tests/transaction/TransactionManager.test.ts
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
/**
|
||||
* TransactionManager Tests
|
||||
*
|
||||
* Tests the TransactionManager for:
|
||||
* - High-level transaction API
|
||||
* - Statistics tracking
|
||||
* - Error handling
|
||||
* - Result wrapping
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { TransactionManager } from '../../src/transaction/TransactionManager.js'
|
||||
import { TransactionError } from '../../src/transaction/errors.js'
|
||||
|
||||
describe('TransactionManager', () => {
|
||||
let manager: TransactionManager
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new TransactionManager()
|
||||
})
|
||||
|
||||
describe('executeTransaction', () => {
|
||||
it('should execute transaction and return user value', async () => {
|
||||
const result = await manager.executeTransaction(async (tx) => {
|
||||
tx.addOperation({ execute: async () => undefined })
|
||||
return 'success'
|
||||
})
|
||||
|
||||
expect(result).toBe('success')
|
||||
})
|
||||
|
||||
it('should execute multiple operations', async () => {
|
||||
const executionOrder: number[] = []
|
||||
|
||||
await manager.executeTransaction(async (tx) => {
|
||||
tx.addOperation({
|
||||
execute: async () => {
|
||||
executionOrder.push(1)
|
||||
return async () => {}
|
||||
}
|
||||
})
|
||||
|
||||
tx.addOperation({
|
||||
execute: async () => {
|
||||
executionOrder.push(2)
|
||||
return async () => {}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
expect(executionOrder).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('should throw TransactionError on failure', async () => {
|
||||
await expect(
|
||||
manager.executeTransaction(async (tx) => {
|
||||
tx.addOperation({
|
||||
execute: async () => {
|
||||
throw new Error('Operation failed')
|
||||
}
|
||||
})
|
||||
})
|
||||
).rejects.toThrow(TransactionError)
|
||||
})
|
||||
|
||||
it('should pass through custom transaction options', async () => {
|
||||
// Should timeout with many operations
|
||||
await expect(
|
||||
manager.executeTransaction(
|
||||
async (tx) => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
tx.addOperation({
|
||||
execute: async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
return async () => {}
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
{ timeout: 50 }
|
||||
)
|
||||
).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('executeTransactionWithResult', () => {
|
||||
it('should return detailed result', async () => {
|
||||
const result = await manager.executeTransactionWithResult(async (tx) => {
|
||||
tx.addOperation({
|
||||
execute: async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 1))
|
||||
return async () => {}
|
||||
}
|
||||
})
|
||||
tx.addOperation({ execute: async () => undefined })
|
||||
return 'success'
|
||||
})
|
||||
|
||||
expect(result.value).toBe('success')
|
||||
expect(result.operationCount).toBe(2)
|
||||
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it('should measure execution time', async () => {
|
||||
const result = await manager.executeTransactionWithResult(async (tx) => {
|
||||
tx.addOperation({
|
||||
execute: async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
return async () => {}
|
||||
}
|
||||
})
|
||||
return 'done'
|
||||
})
|
||||
|
||||
expect(result.executionTimeMs).toBeGreaterThanOrEqual(10)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Statistics Tracking', () => {
|
||||
it('should track total transactions', async () => {
|
||||
await manager.executeTransaction(async (tx) => {
|
||||
tx.addOperation({ execute: async () => undefined })
|
||||
})
|
||||
|
||||
await manager.executeTransaction(async (tx) => {
|
||||
tx.addOperation({ execute: async () => undefined })
|
||||
})
|
||||
|
||||
const stats = manager.getStats()
|
||||
expect(stats.totalTransactions).toBe(2)
|
||||
})
|
||||
|
||||
it('should track successful transactions', async () => {
|
||||
await manager.executeTransaction(async (tx) => {
|
||||
tx.addOperation({ execute: async () => undefined })
|
||||
})
|
||||
|
||||
const stats = manager.getStats()
|
||||
expect(stats.successfulTransactions).toBe(1)
|
||||
expect(stats.failedTransactions).toBe(0)
|
||||
})
|
||||
|
||||
it('should track failed transactions', async () => {
|
||||
await manager.executeTransaction(async (tx) => {
|
||||
tx.addOperation({ execute: async () => undefined })
|
||||
}).catch(() => {})
|
||||
|
||||
await manager.executeTransaction(async (tx) => {
|
||||
tx.addOperation({
|
||||
execute: async () => {
|
||||
throw new Error('Failed')
|
||||
}
|
||||
})
|
||||
}).catch(() => {})
|
||||
|
||||
const stats = manager.getStats()
|
||||
expect(stats.successfulTransactions).toBe(1)
|
||||
expect(stats.failedTransactions).toBe(1)
|
||||
})
|
||||
|
||||
it('should track rolled back transactions', async () => {
|
||||
await manager.executeTransaction(async (tx) => {
|
||||
tx.addOperation({
|
||||
execute: async () => {
|
||||
return async () => { /* rollback */ }
|
||||
}
|
||||
})
|
||||
tx.addOperation({
|
||||
execute: async () => {
|
||||
throw new Error('Failed')
|
||||
}
|
||||
})
|
||||
}).catch(() => {})
|
||||
|
||||
const stats = manager.getStats()
|
||||
expect(stats.rolledBackTransactions).toBe(1)
|
||||
})
|
||||
|
||||
it('should calculate average execution time', async () => {
|
||||
await manager.executeTransaction(async (tx) => {
|
||||
tx.addOperation({
|
||||
execute: async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
return async () => {}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await manager.executeTransaction(async (tx) => {
|
||||
tx.addOperation({
|
||||
execute: async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
return async () => {}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const stats = manager.getStats()
|
||||
expect(stats.averageExecutionTimeMs).toBeGreaterThan(0)
|
||||
expect(stats.totalTransactions).toBe(2)
|
||||
})
|
||||
|
||||
it('should calculate average operations per transaction', async () => {
|
||||
await manager.executeTransaction(async (tx) => {
|
||||
tx.addOperation({ execute: async () => undefined })
|
||||
tx.addOperation({ execute: async () => undefined })
|
||||
})
|
||||
|
||||
await manager.executeTransaction(async (tx) => {
|
||||
tx.addOperation({ execute: async () => undefined })
|
||||
tx.addOperation({ execute: async () => undefined })
|
||||
tx.addOperation({ execute: async () => undefined })
|
||||
tx.addOperation({ execute: async () => undefined })
|
||||
})
|
||||
|
||||
const stats = manager.getStats()
|
||||
expect(stats.averageOperationsPerTransaction).toBe(3) // (2 + 4) / 2
|
||||
})
|
||||
|
||||
it('should allow resetting statistics', async () => {
|
||||
await manager.executeTransaction(async (tx) => {
|
||||
tx.addOperation({ execute: async () => undefined })
|
||||
})
|
||||
|
||||
let stats = manager.getStats()
|
||||
expect(stats.totalTransactions).toBe(1)
|
||||
|
||||
manager.resetStats()
|
||||
|
||||
stats = manager.getStats()
|
||||
expect(stats.totalTransactions).toBe(0)
|
||||
expect(stats.successfulTransactions).toBe(0)
|
||||
expect(stats.failedTransactions).toBe(0)
|
||||
expect(stats.rolledBackTransactions).toBe(0)
|
||||
expect(stats.averageExecutionTimeMs).toBe(0)
|
||||
expect(stats.averageOperationsPerTransaction).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Concurrent Transactions', () => {
|
||||
it('should handle concurrent transactions', async () => {
|
||||
const results = await Promise.all([
|
||||
manager.executeTransaction(async (tx) => {
|
||||
tx.addOperation({ execute: async () => undefined })
|
||||
return 1
|
||||
}),
|
||||
manager.executeTransaction(async (tx) => {
|
||||
tx.addOperation({ execute: async () => undefined })
|
||||
return 2
|
||||
}),
|
||||
manager.executeTransaction(async (tx) => {
|
||||
tx.addOperation({ execute: async () => undefined })
|
||||
return 3
|
||||
})
|
||||
])
|
||||
|
||||
expect(results).toEqual([1, 2, 3])
|
||||
|
||||
const stats = manager.getStats()
|
||||
expect(stats.totalTransactions).toBe(3)
|
||||
expect(stats.successfulTransactions).toBe(3)
|
||||
})
|
||||
|
||||
it('should track mixed success/failure in concurrent transactions', async () => {
|
||||
const results = await Promise.allSettled([
|
||||
manager.executeTransaction(async (tx) => {
|
||||
tx.addOperation({ execute: async () => undefined })
|
||||
}),
|
||||
manager.executeTransaction(async (tx) => {
|
||||
tx.addOperation({
|
||||
execute: async () => {
|
||||
throw new Error('Failed')
|
||||
}
|
||||
})
|
||||
}),
|
||||
manager.executeTransaction(async (tx) => {
|
||||
tx.addOperation({ execute: async () => undefined })
|
||||
})
|
||||
])
|
||||
|
||||
expect(results[0].status).toBe('fulfilled')
|
||||
expect(results[1].status).toBe('rejected')
|
||||
expect(results[2].status).toBe('fulfilled')
|
||||
|
||||
const stats = manager.getStats()
|
||||
expect(stats.totalTransactions).toBe(3)
|
||||
expect(stats.successfulTransactions).toBe(2)
|
||||
expect(stats.failedTransactions).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error Context', () => {
|
||||
it('should wrap non-TransactionError errors', async () => {
|
||||
const error = await manager.executeTransaction(async () => {
|
||||
throw new Error('Custom error')
|
||||
}).catch(e => e)
|
||||
|
||||
expect(error).toBeInstanceOf(TransactionError)
|
||||
expect(error.message).toContain('Custom error')
|
||||
})
|
||||
|
||||
it('should preserve TransactionError instances', async () => {
|
||||
const error = await manager.executeTransaction(async (tx) => {
|
||||
tx.addOperation({
|
||||
execute: async () => {
|
||||
throw new Error('Operation failed')
|
||||
}
|
||||
})
|
||||
}).catch(e => e)
|
||||
|
||||
expect(error).toBeInstanceOf(TransactionError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Stats Immutability', () => {
|
||||
it('should return immutable stats copy', async () => {
|
||||
await manager.executeTransaction(async (tx) => {
|
||||
tx.addOperation({ execute: async () => undefined })
|
||||
})
|
||||
|
||||
const stats1 = manager.getStats()
|
||||
const stats2 = manager.getStats()
|
||||
|
||||
expect(stats1).not.toBe(stats2) // Different objects
|
||||
expect(stats1).toEqual(stats2) // Same values
|
||||
})
|
||||
})
|
||||
})
|
||||
265
tests/transaction/integration/cow-transactions.test.ts
Normal file
265
tests/transaction/integration/cow-transactions.test.ts
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
/**
|
||||
* COW + Transactions Integration Tests
|
||||
*
|
||||
* Verifies that transactions work correctly with Copy-on-Write storage:
|
||||
* - Branch isolation
|
||||
* - Atomic commits
|
||||
* - Rollback without affecting main branch
|
||||
* - Content-addressable storage
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { Brainy } from '../../../src/brainy.js'
|
||||
import { NounType, VerbType } from '../../../src/types/graphTypes.js'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { mkdirSync, rmSync } from 'fs'
|
||||
|
||||
describe('Transactions + COW Integration', () => {
|
||||
let brain: Brainy
|
||||
let testDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create unique test directory
|
||||
testDir = join(tmpdir(), `brainy-cow-test-${Date.now()}`)
|
||||
mkdirSync(testDir, { recursive: true })
|
||||
|
||||
// Initialize Brainy with COW enabled
|
||||
brain = new Brainy({
|
||||
storage: {
|
||||
type: 'filesystem',
|
||||
path: testDir
|
||||
}
|
||||
})
|
||||
|
||||
await brain.init()
|
||||
|
||||
// Enable COW if available
|
||||
if (brain.cow) {
|
||||
await brain.cow.init()
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (brain) {
|
||||
await brain.shutdown()
|
||||
}
|
||||
if (testDir) {
|
||||
rmSync(testDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('Basic COW Operations', () => {
|
||||
it('should commit transaction successfully on COW branch', async () => {
|
||||
// Create branch
|
||||
if (!brain.cow) {
|
||||
console.log('COW not available, skipping test')
|
||||
return
|
||||
}
|
||||
|
||||
await brain.cow.createBranch('feature-branch')
|
||||
await brain.cow.checkout('feature-branch')
|
||||
|
||||
// Add entity (should succeed)
|
||||
const id = await brain.add({
|
||||
data: { name: 'Test Entity' },
|
||||
type: NounType.Thing
|
||||
})
|
||||
|
||||
expect(id).toBeTruthy()
|
||||
|
||||
// Verify entity exists
|
||||
const entity = await brain.get(id)
|
||||
expect(entity).toBeTruthy()
|
||||
expect(entity?.data.name).toBe('Test Entity')
|
||||
})
|
||||
|
||||
it('should isolate transaction rollback to branch', async () => {
|
||||
if (!brain.cow) {
|
||||
console.log('COW not available, skipping test')
|
||||
return
|
||||
}
|
||||
|
||||
// Add entity on main branch
|
||||
const mainId = await brain.add({
|
||||
data: { name: 'Main Entity' },
|
||||
type: NounType.Thing
|
||||
})
|
||||
|
||||
// Create and checkout feature branch
|
||||
await brain.cow.createBranch('feature-branch')
|
||||
await brain.cow.checkout('feature-branch')
|
||||
|
||||
// Try to add entity that will fail
|
||||
try {
|
||||
await brain.add({
|
||||
data: { name: 'Feature Entity' },
|
||||
type: NounType.Thing,
|
||||
// Force failure by providing invalid vector
|
||||
vector: [] as any
|
||||
})
|
||||
} catch (e) {
|
||||
// Expected to fail
|
||||
}
|
||||
|
||||
// Switch back to main
|
||||
await brain.cow.checkout('main')
|
||||
|
||||
// Main branch entity should still exist
|
||||
const mainEntity = await brain.get(mainId)
|
||||
expect(mainEntity).toBeTruthy()
|
||||
expect(mainEntity?.data.name).toBe('Main Entity')
|
||||
})
|
||||
|
||||
it('should handle atomic updates across COW branches', async () => {
|
||||
if (!brain.cow) {
|
||||
console.log('COW not available, skipping test')
|
||||
return
|
||||
}
|
||||
|
||||
// Add entity
|
||||
const id = await brain.add({
|
||||
data: { name: 'Original', version: 1 },
|
||||
type: NounType.Thing
|
||||
})
|
||||
|
||||
// Create branch
|
||||
await brain.cow.createBranch('feature-branch')
|
||||
await brain.cow.checkout('feature-branch')
|
||||
|
||||
// Update entity (atomic operation)
|
||||
await brain.update({
|
||||
id,
|
||||
data: { name: 'Updated', version: 2 },
|
||||
merge: false
|
||||
})
|
||||
|
||||
// Verify update on feature branch
|
||||
const featureEntity = await brain.get(id)
|
||||
expect(featureEntity?.data.version).toBe(2)
|
||||
|
||||
// Switch to main
|
||||
await brain.cow.checkout('main')
|
||||
|
||||
// Original should be unchanged on main
|
||||
const mainEntity = await brain.get(id)
|
||||
expect(mainEntity?.data.version).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Transaction Rollback with COW', () => {
|
||||
it('should rollback failed transaction without affecting COW branch', async () => {
|
||||
if (!brain.cow) {
|
||||
console.log('COW not available, skipping test')
|
||||
return
|
||||
}
|
||||
|
||||
await brain.cow.createBranch('test-branch')
|
||||
await brain.cow.checkout('test-branch')
|
||||
|
||||
// Add first entity (will succeed)
|
||||
const id1 = await brain.add({
|
||||
data: { name: 'Entity 1' },
|
||||
type: NounType.Thing
|
||||
})
|
||||
|
||||
// Attempt to add with invalid data (will fail)
|
||||
// This tests that the transaction rollback doesn't corrupt the branch
|
||||
let failed = false
|
||||
try {
|
||||
await brain.add({
|
||||
data: null as any, // Invalid
|
||||
type: NounType.Thing
|
||||
})
|
||||
} catch (e) {
|
||||
failed = true
|
||||
}
|
||||
|
||||
expect(failed).toBe(true)
|
||||
|
||||
// First entity should still exist (transaction rollback worked)
|
||||
const entity1 = await brain.get(id1)
|
||||
expect(entity1).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('COW Branch Merging with Transactions', () => {
|
||||
it('should preserve transaction atomicity during branch operations', async () => {
|
||||
if (!brain.cow) {
|
||||
console.log('COW not available, skipping test')
|
||||
return
|
||||
}
|
||||
|
||||
// Add entity on main
|
||||
const mainId = await brain.add({
|
||||
data: { name: 'Main Entity', branch: 'main' },
|
||||
type: NounType.Thing
|
||||
})
|
||||
|
||||
// Create feature branch
|
||||
await brain.cow.createBranch('feature')
|
||||
await brain.cow.checkout('feature')
|
||||
|
||||
// Add multiple entities in transaction (atomic)
|
||||
const featureId1 = await brain.add({
|
||||
data: { name: 'Feature 1', branch: 'feature' },
|
||||
type: NounType.Thing
|
||||
})
|
||||
|
||||
const featureId2 = await brain.add({
|
||||
data: { name: 'Feature 2', branch: 'feature' },
|
||||
type: NounType.Thing
|
||||
})
|
||||
|
||||
// Create relationship (atomic)
|
||||
await brain.relate({
|
||||
from: featureId1,
|
||||
to: featureId2,
|
||||
type: VerbType.RelatesTo
|
||||
})
|
||||
|
||||
// All operations should be atomic on feature branch
|
||||
const feature1 = await brain.get(featureId1)
|
||||
const feature2 = await brain.get(featureId2)
|
||||
const relations = await brain.getRelations({ from: featureId1 })
|
||||
|
||||
expect(feature1).toBeTruthy()
|
||||
expect(feature2).toBeTruthy()
|
||||
expect(relations).toHaveLength(1)
|
||||
|
||||
// Switch back to main - feature entities should not exist
|
||||
await brain.cow.checkout('main')
|
||||
const mainCheck = await brain.get(featureId1)
|
||||
expect(mainCheck).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Content-Addressable Storage', () => {
|
||||
it('should handle content-addressable storage with transactions', async () => {
|
||||
if (!brain.cow) {
|
||||
console.log('COW not available, skipping test')
|
||||
return
|
||||
}
|
||||
|
||||
// Add entity with specific data
|
||||
const id = await brain.add({
|
||||
data: { content: 'Test content', value: 123 },
|
||||
type: NounType.Thing
|
||||
})
|
||||
|
||||
// Update entity (creates new blob)
|
||||
await brain.update({
|
||||
id,
|
||||
data: { content: 'Updated content', value: 456 }
|
||||
})
|
||||
|
||||
// Get entity - should have updated content
|
||||
const entity = await brain.get(id)
|
||||
expect(entity?.data.content).toBe('Updated content')
|
||||
expect(entity?.data.value).toBe(456)
|
||||
|
||||
// Transaction system should work with content-addressable storage
|
||||
// (each version is a separate blob, rollback restores previous blob reference)
|
||||
})
|
||||
})
|
||||
})
|
||||
393
tests/transaction/integration/distributed-transactions.test.ts
Normal file
393
tests/transaction/integration/distributed-transactions.test.ts
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
/**
|
||||
* Distributed + Transactions Integration Tests
|
||||
*
|
||||
* Verifies that transactions work correctly with distributed storage:
|
||||
* - Remote storage adapters (S3, Azure, GCS)
|
||||
* - Distributed coordination
|
||||
* - Cache coherence
|
||||
* - Read/write separation
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { Brainy } from '../../../src/brainy.js'
|
||||
import { NounType, VerbType } from '../../../src/types/graphTypes.js'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { mkdirSync, rmSync } from 'fs'
|
||||
|
||||
describe('Transactions + Distributed Storage Integration', () => {
|
||||
let brain: Brainy
|
||||
let testDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = join(tmpdir(), `brainy-distributed-test-${Date.now()}`)
|
||||
mkdirSync(testDir, { recursive: true })
|
||||
|
||||
// Use filesystem as proxy for distributed storage
|
||||
// (In production, this would be S3, Azure, GCS, etc.)
|
||||
brain = new Brainy({
|
||||
storage: {
|
||||
type: 'filesystem',
|
||||
path: testDir
|
||||
}
|
||||
})
|
||||
|
||||
await brain.init()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (brain) {
|
||||
await brain.shutdown()
|
||||
}
|
||||
if (testDir) {
|
||||
rmSync(testDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('Remote Storage Adapters', () => {
|
||||
it('should handle transactions with filesystem storage (proxy for remote)', async () => {
|
||||
// Add entity (atomic operation)
|
||||
const id = await brain.add({
|
||||
data: { name: 'Remote Entity', location: 'cloud' },
|
||||
type: NounType.Thing
|
||||
})
|
||||
|
||||
expect(id).toBeTruthy()
|
||||
|
||||
// Verify entity persisted to storage
|
||||
const entity = await brain.get(id)
|
||||
expect(entity).toBeTruthy()
|
||||
expect(entity?.data.name).toBe('Remote Entity')
|
||||
})
|
||||
|
||||
it('should rollback failed operations with remote storage', async () => {
|
||||
// Add first entity (succeeds)
|
||||
const id1 = await brain.add({
|
||||
data: { name: 'Entity 1' },
|
||||
type: NounType.Thing
|
||||
})
|
||||
|
||||
// Attempt to add with invalid data (fails)
|
||||
let failed = false
|
||||
try {
|
||||
await brain.add({
|
||||
data: null as any,
|
||||
type: NounType.Thing
|
||||
})
|
||||
} catch (e) {
|
||||
failed = true
|
||||
}
|
||||
|
||||
expect(failed).toBe(true)
|
||||
|
||||
// First entity should still exist
|
||||
const entity1 = await brain.get(id1)
|
||||
expect(entity1).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should handle update operations with remote storage atomically', async () => {
|
||||
// Add entity
|
||||
const id = await brain.add({
|
||||
data: { name: 'Original', version: 1 },
|
||||
type: NounType.Thing
|
||||
})
|
||||
|
||||
// Update atomically
|
||||
await brain.update({
|
||||
id,
|
||||
data: { name: 'Updated', version: 2 }
|
||||
})
|
||||
|
||||
// Verify update persisted
|
||||
const entity = await brain.get(id)
|
||||
expect(entity?.data.version).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Write Coordinator Atomicity', () => {
|
||||
it('should ensure atomicity at write coordinator level', async () => {
|
||||
// Simulate write coordinator scenario
|
||||
// (Single Brainy instance coordinating writes)
|
||||
|
||||
const entities: string[] = []
|
||||
|
||||
// Multiple atomic writes
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const id = await brain.add({
|
||||
data: { name: `Entity ${i}`, index: i },
|
||||
type: NounType.Thing
|
||||
})
|
||||
entities.push(id)
|
||||
}
|
||||
|
||||
// Create relationships (atomic)
|
||||
for (let i = 0; i < entities.length - 1; i++) {
|
||||
await brain.relate({
|
||||
from: entities[i],
|
||||
to: entities[i + 1],
|
||||
type: VerbType.RelatesTo
|
||||
})
|
||||
}
|
||||
|
||||
// Verify all operations succeeded
|
||||
for (let i = 0; i < entities.length; i++) {
|
||||
const entity = await brain.get(entities[i])
|
||||
expect(entity).toBeTruthy()
|
||||
expect(entity?.data.index).toBe(i)
|
||||
}
|
||||
|
||||
// Verify relationships
|
||||
for (let i = 0; i < entities.length - 1; i++) {
|
||||
const relations = await brain.getRelations({ from: entities[i] })
|
||||
expect(relations).toHaveLength(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('should handle batch operations atomically on write coordinator', async () => {
|
||||
const batchSize = 20
|
||||
const ids: string[] = []
|
||||
|
||||
// Batch add operations
|
||||
for (let i = 0; i < batchSize; i++) {
|
||||
const id = await brain.add({
|
||||
data: { name: `Batch Entity ${i}`, batch: true },
|
||||
type: NounType.Thing
|
||||
})
|
||||
ids.push(id)
|
||||
}
|
||||
|
||||
// Verify all entities persisted
|
||||
let count = 0
|
||||
for (const id of ids) {
|
||||
const entity = await brain.get(id)
|
||||
if (entity) count++
|
||||
}
|
||||
|
||||
expect(count).toBe(batchSize)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Read-After-Write Consistency', () => {
|
||||
it('should ensure read-after-write consistency', async () => {
|
||||
// Write entity
|
||||
const id = await brain.add({
|
||||
data: { name: 'RAW Test', timestamp: Date.now() },
|
||||
type: NounType.Thing
|
||||
})
|
||||
|
||||
// Immediate read (should see the write)
|
||||
const entity = await brain.get(id)
|
||||
expect(entity).toBeTruthy()
|
||||
expect(entity?.data.name).toBe('RAW Test')
|
||||
})
|
||||
|
||||
it('should maintain consistency after update', async () => {
|
||||
const id = await brain.add({
|
||||
data: { name: 'Original', counter: 0 },
|
||||
type: NounType.Thing
|
||||
})
|
||||
|
||||
// Multiple updates
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
await brain.update({
|
||||
id,
|
||||
data: { name: `Updated ${i}`, counter: i }
|
||||
})
|
||||
|
||||
// Read immediately after each update
|
||||
const entity = await brain.get(id)
|
||||
expect(entity?.data.counter).toBe(i)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Concurrent Write Handling', () => {
|
||||
it('should handle sequential writes correctly', async () => {
|
||||
const ids: string[] = []
|
||||
|
||||
// Sequential writes (simulating distributed writes to coordinator)
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const id = await brain.add({
|
||||
data: { name: `Sequential ${i}`, order: i },
|
||||
type: NounType.Thing
|
||||
})
|
||||
ids.push(id)
|
||||
}
|
||||
|
||||
// Verify all writes succeeded
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
const entity = await brain.get(ids[i])
|
||||
expect(entity?.data.order).toBe(i)
|
||||
}
|
||||
})
|
||||
|
||||
it('should handle interleaved operations atomically', async () => {
|
||||
// Create entities
|
||||
const id1 = await brain.add({
|
||||
data: { name: 'Entity A', value: 100 },
|
||||
type: NounType.Thing
|
||||
})
|
||||
|
||||
const id2 = await brain.add({
|
||||
data: { name: 'Entity B', value: 200 },
|
||||
type: NounType.Thing
|
||||
})
|
||||
|
||||
// Interleaved updates
|
||||
await brain.update({ id: id1, data: { value: 150 } })
|
||||
await brain.update({ id: id2, data: { value: 250 } })
|
||||
await brain.update({ id: id1, data: { value: 175 } })
|
||||
|
||||
// Verify final state
|
||||
const entity1 = await brain.get(id1)
|
||||
const entity2 = await brain.get(id2)
|
||||
|
||||
expect(entity1?.data.value).toBe(175)
|
||||
expect(entity2?.data.value).toBe(250)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Delete Operations with Distributed Storage', () => {
|
||||
it('should handle delete operations atomically', async () => {
|
||||
// Create entity
|
||||
const id = await brain.add({
|
||||
data: { name: 'To Delete', status: 'active' },
|
||||
type: NounType.Thing
|
||||
})
|
||||
|
||||
// Verify exists
|
||||
let entity = await brain.get(id)
|
||||
expect(entity).toBeTruthy()
|
||||
|
||||
// Delete atomically
|
||||
await brain.delete(id)
|
||||
|
||||
// Verify deleted
|
||||
entity = await brain.get(id)
|
||||
expect(entity).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle delete with relationships atomically', async () => {
|
||||
// Create entities and relationships
|
||||
const id1 = await brain.add({
|
||||
data: { name: 'Entity 1' },
|
||||
type: NounType.Thing
|
||||
})
|
||||
|
||||
const id2 = await brain.add({
|
||||
data: { name: 'Entity 2' },
|
||||
type: NounType.Thing
|
||||
})
|
||||
|
||||
await brain.relate({
|
||||
from: id1,
|
||||
to: id2,
|
||||
type: VerbType.RelatesTo
|
||||
})
|
||||
|
||||
// Delete first entity (should delete relationships)
|
||||
await brain.delete(id1)
|
||||
|
||||
// Verify entity deleted
|
||||
const entity1 = await brain.get(id1)
|
||||
expect(entity1).toBeNull()
|
||||
|
||||
// Verify relationships deleted
|
||||
const relations = await brain.getRelations({ from: id1 })
|
||||
expect(relations).toHaveLength(0)
|
||||
|
||||
// Entity 2 should still exist
|
||||
const entity2 = await brain.get(id2)
|
||||
expect(entity2).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Storage Adapter Transparency', () => {
|
||||
it('should work transparently with any storage adapter', async () => {
|
||||
// This test verifies that transactions don't make assumptions
|
||||
// about the underlying storage implementation
|
||||
|
||||
// Add entity
|
||||
const id = await brain.add({
|
||||
data: { name: 'Adapter Test', adapter: 'filesystem' },
|
||||
type: NounType.Thing
|
||||
})
|
||||
|
||||
// Update entity
|
||||
await brain.update({
|
||||
id,
|
||||
data: { name: 'Updated via Adapter', adapter: 'filesystem' }
|
||||
})
|
||||
|
||||
// Query entity
|
||||
const entity = await brain.get(id)
|
||||
expect(entity).toBeTruthy()
|
||||
expect(entity?.data.name).toBe('Updated via Adapter')
|
||||
|
||||
// Delete entity
|
||||
await brain.delete(id)
|
||||
const deletedEntity = await brain.get(id)
|
||||
expect(deletedEntity).toBeNull()
|
||||
})
|
||||
|
||||
it('should maintain atomicity regardless of storage latency', async () => {
|
||||
// Simulate scenario with storage latency
|
||||
// (In distributed setup, network latency is a factor)
|
||||
|
||||
const startTime = Date.now()
|
||||
|
||||
// Operations that might have latency
|
||||
const id1 = await brain.add({
|
||||
data: { name: 'Latency Test 1' },
|
||||
type: NounType.Thing
|
||||
})
|
||||
|
||||
const id2 = await brain.add({
|
||||
data: { name: 'Latency Test 2' },
|
||||
type: NounType.Thing
|
||||
})
|
||||
|
||||
await brain.relate({
|
||||
from: id1,
|
||||
to: id2,
|
||||
type: VerbType.RelatesTo
|
||||
})
|
||||
|
||||
const endTime = Date.now()
|
||||
const duration = endTime - startTime
|
||||
|
||||
// Verify all operations succeeded (regardless of latency)
|
||||
const entity1 = await brain.get(id1)
|
||||
const entity2 = await brain.get(id2)
|
||||
const relations = await brain.getRelations({ from: id1 })
|
||||
|
||||
expect(entity1).toBeTruthy()
|
||||
expect(entity2).toBeTruthy()
|
||||
expect(relations).toHaveLength(1)
|
||||
|
||||
// Should complete in reasonable time (even with storage latency)
|
||||
expect(duration).toBeLessThan(5000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Transaction Statistics with Distributed Storage', () => {
|
||||
it('should track transaction statistics accurately', async () => {
|
||||
// Get transaction manager stats
|
||||
const stats = (brain as any).transactionManager?.getStats()
|
||||
|
||||
if (stats) {
|
||||
const initialTotal = stats.totalTransactions
|
||||
|
||||
// Perform operations
|
||||
await brain.add({
|
||||
data: { name: 'Stats Test' },
|
||||
type: NounType.Thing
|
||||
})
|
||||
|
||||
// Check stats updated
|
||||
const updatedStats = (brain as any).transactionManager?.getStats()
|
||||
expect(updatedStats.totalTransactions).toBeGreaterThan(initialTotal)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
295
tests/transaction/integration/sharding-transactions.test.ts
Normal file
295
tests/transaction/integration/sharding-transactions.test.ts
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
/**
|
||||
* Sharding + Transactions Integration Tests
|
||||
*
|
||||
* Verifies that transactions work correctly with sharded storage:
|
||||
* - Cross-shard atomicity
|
||||
* - Shard-aware routing
|
||||
* - Rollback across shards
|
||||
* - UUID-based shard distribution
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { Brainy } from '../../../src/brainy.js'
|
||||
import { NounType, VerbType } from '../../../src/types/graphTypes.js'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { mkdirSync, rmSync } from 'fs'
|
||||
|
||||
describe('Transactions + Sharding Integration', () => {
|
||||
let brain: Brainy
|
||||
let testDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = join(tmpdir(), `brainy-shard-test-${Date.now()}`)
|
||||
mkdirSync(testDir, { recursive: true })
|
||||
|
||||
brain = new Brainy({
|
||||
storage: {
|
||||
type: 'filesystem',
|
||||
path: testDir
|
||||
}
|
||||
})
|
||||
|
||||
await brain.init()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (brain) {
|
||||
await brain.shutdown()
|
||||
}
|
||||
if (testDir) {
|
||||
rmSync(testDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('Cross-Shard Operations', () => {
|
||||
it('should handle atomic operations across multiple shards', async () => {
|
||||
// Create entities with different UUID prefixes (different shards)
|
||||
const id1 = 'aaa00000-1111-4111-8111-111111111111' // Shard: aaa
|
||||
const id2 = 'bbb00000-2222-4222-8222-222222222222' // Shard: bbb
|
||||
|
||||
// Add entities to different shards (atomic)
|
||||
await brain.add({
|
||||
id: id1,
|
||||
data: { name: 'Entity in Shard A', shard: 'aaa' },
|
||||
type: NounType.Thing
|
||||
})
|
||||
|
||||
await brain.add({
|
||||
id: id2,
|
||||
data: { name: 'Entity in Shard B', shard: 'bbb' },
|
||||
type: NounType.Thing
|
||||
})
|
||||
|
||||
// Create relationship across shards (atomic)
|
||||
const relationId = await brain.relate({
|
||||
from: id1,
|
||||
to: id2,
|
||||
type: VerbType.RelatesTo
|
||||
})
|
||||
|
||||
// Verify all entities exist
|
||||
const entity1 = await brain.get(id1)
|
||||
const entity2 = await brain.get(id2)
|
||||
const relations = await brain.getRelations({ from: id1 })
|
||||
|
||||
expect(entity1).toBeTruthy()
|
||||
expect(entity2).toBeTruthy()
|
||||
expect(relations).toHaveLength(1)
|
||||
expect(relations[0].targetId).toBe(id2)
|
||||
})
|
||||
|
||||
it('should rollback operations across multiple shards', async () => {
|
||||
const id1 = 'ccc00000-1111-4111-8111-111111111111' // Shard: ccc
|
||||
const id2 = 'ddd00000-2222-4222-8222-222222222222' // Shard: ddd
|
||||
|
||||
// Add first entity (succeeds)
|
||||
await brain.add({
|
||||
id: id1,
|
||||
data: { name: 'Entity in Shard C' },
|
||||
type: NounType.Thing
|
||||
})
|
||||
|
||||
// Attempt to add second entity with invalid data (fails)
|
||||
let failed = false
|
||||
try {
|
||||
await brain.add({
|
||||
id: id2,
|
||||
data: null as any, // Invalid
|
||||
type: NounType.Thing
|
||||
})
|
||||
} catch (e) {
|
||||
failed = true
|
||||
}
|
||||
|
||||
expect(failed).toBe(true)
|
||||
|
||||
// First entity should still exist (in shard C)
|
||||
const entity1 = await brain.get(id1)
|
||||
expect(entity1).toBeTruthy()
|
||||
|
||||
// Second entity should not exist (rollback in shard D)
|
||||
const entity2 = await brain.get(id2)
|
||||
expect(entity2).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle updates across shards atomically', async () => {
|
||||
const id1 = 'eee00000-1111-4111-8111-111111111111'
|
||||
const id2 = 'fff00000-2222-4222-8222-222222222222'
|
||||
|
||||
// Add entities
|
||||
await brain.add({
|
||||
id: id1,
|
||||
data: { name: 'Original E', version: 1 },
|
||||
type: NounType.Thing
|
||||
})
|
||||
|
||||
await brain.add({
|
||||
id: id2,
|
||||
data: { name: 'Original F', version: 1 },
|
||||
type: NounType.Thing
|
||||
})
|
||||
|
||||
// Update both entities (different shards)
|
||||
await brain.update({
|
||||
id: id1,
|
||||
data: { name: 'Updated E', version: 2 }
|
||||
})
|
||||
|
||||
await brain.update({
|
||||
id: id2,
|
||||
data: { name: 'Updated F', version: 2 }
|
||||
})
|
||||
|
||||
// Verify updates in both shards
|
||||
const entity1 = await brain.get(id1)
|
||||
const entity2 = await brain.get(id2)
|
||||
|
||||
expect(entity1?.data.version).toBe(2)
|
||||
expect(entity2?.data.version).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Shard Distribution', () => {
|
||||
it('should distribute entities across shards based on UUID', async () => {
|
||||
// Create entities with various UUID prefixes
|
||||
const ids = [
|
||||
'aaa00000-1111-4111-8111-111111111111',
|
||||
'bbb00000-2222-4222-8222-222222222222',
|
||||
'ccc00000-3333-4333-8333-333333333333',
|
||||
'ddd00000-4444-4444-8444-444444444444'
|
||||
]
|
||||
|
||||
// Add entities to different shards
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
await brain.add({
|
||||
id: ids[i],
|
||||
data: { name: `Entity ${i}`, index: i },
|
||||
type: NounType.Thing
|
||||
})
|
||||
}
|
||||
|
||||
// Verify all entities can be retrieved (regardless of shard)
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
const entity = await brain.get(ids[i])
|
||||
expect(entity).toBeTruthy()
|
||||
expect(entity?.data.index).toBe(i)
|
||||
}
|
||||
})
|
||||
|
||||
it('should handle relationships between different shard combinations', async () => {
|
||||
const entities = [
|
||||
{ id: 'aaa00000-1111-4111-8111-111111111111', name: 'A' },
|
||||
{ id: 'bbb00000-2222-4222-8222-222222222222', name: 'B' },
|
||||
{ id: 'ccc00000-3333-4333-8333-333333333333', name: 'C' }
|
||||
]
|
||||
|
||||
// Add all entities
|
||||
for (const e of entities) {
|
||||
await brain.add({
|
||||
id: e.id,
|
||||
data: { name: e.name },
|
||||
type: NounType.Thing
|
||||
})
|
||||
}
|
||||
|
||||
// Create relationships across all shards
|
||||
await brain.relate({
|
||||
from: entities[0].id,
|
||||
to: entities[1].id,
|
||||
type: VerbType.RelatesTo
|
||||
})
|
||||
|
||||
await brain.relate({
|
||||
from: entities[1].id,
|
||||
to: entities[2].id,
|
||||
type: VerbType.RelatesTo
|
||||
})
|
||||
|
||||
await brain.relate({
|
||||
from: entities[2].id,
|
||||
to: entities[0].id,
|
||||
type: VerbType.RelatesTo
|
||||
})
|
||||
|
||||
// Verify all relationships exist
|
||||
const relations0 = await brain.getRelations({ from: entities[0].id })
|
||||
const relations1 = await brain.getRelations({ from: entities[1].id })
|
||||
const relations2 = await brain.getRelations({ from: entities[2].id })
|
||||
|
||||
expect(relations0).toHaveLength(1)
|
||||
expect(relations1).toHaveLength(1)
|
||||
expect(relations2).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Delete Operations Across Shards', () => {
|
||||
it('should delete entities and relationships across shards atomically', async () => {
|
||||
const id1 = 'ggg00000-1111-4111-8111-111111111111'
|
||||
const id2 = 'hhh00000-2222-4222-8222-222222222222'
|
||||
|
||||
// Add entities in different shards
|
||||
await brain.add({
|
||||
id: id1,
|
||||
data: { name: 'Entity G' },
|
||||
type: NounType.Thing
|
||||
})
|
||||
|
||||
await brain.add({
|
||||
id: id2,
|
||||
data: { name: 'Entity H' },
|
||||
type: NounType.Thing
|
||||
})
|
||||
|
||||
// Create relationship
|
||||
await brain.relate({
|
||||
from: id1,
|
||||
to: id2,
|
||||
type: VerbType.RelatesTo
|
||||
})
|
||||
|
||||
// Delete first entity (should also delete relationship)
|
||||
await brain.delete(id1)
|
||||
|
||||
// Verify entity deleted from shard G
|
||||
const entity1 = await brain.get(id1)
|
||||
expect(entity1).toBeNull()
|
||||
|
||||
// Entity H should still exist in shard H
|
||||
const entity2 = await brain.get(id2)
|
||||
expect(entity2).toBeTruthy()
|
||||
|
||||
// Relationship should be deleted
|
||||
const relations = await brain.getRelations({ from: id1 })
|
||||
expect(relations).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Batch Operations Across Shards', () => {
|
||||
it('should handle batch adds across multiple shards atomically', async () => {
|
||||
const entities = [
|
||||
{ id: 'shard1-00-1111-4111-8111-111111111111', data: { name: 'S1-E1' } },
|
||||
{ id: 'shard2-00-2222-4222-8222-222222222222', data: { name: 'S2-E1' } },
|
||||
{ id: 'shard3-00-3333-4333-8333-333333333333', data: { name: 'S3-E1' } },
|
||||
{ id: 'shard1-00-4444-4444-8444-444444444444', data: { name: 'S1-E2' } },
|
||||
{ id: 'shard2-00-5555-4555-8555-555555555555', data: { name: 'S2-E2' } }
|
||||
]
|
||||
|
||||
// Add all entities (distributed across shards)
|
||||
for (const e of entities) {
|
||||
await brain.add({
|
||||
id: e.id,
|
||||
data: e.data,
|
||||
type: NounType.Thing
|
||||
})
|
||||
}
|
||||
|
||||
// Verify all entities exist in their respective shards
|
||||
for (const e of entities) {
|
||||
const entity = await brain.get(e.id)
|
||||
expect(entity).toBeTruthy()
|
||||
expect(entity?.data.name).toBe(e.data.name)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
368
tests/transaction/integration/typeaware-transactions.test.ts
Normal file
368
tests/transaction/integration/typeaware-transactions.test.ts
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
/**
|
||||
* TypeAware + Transactions Integration Tests
|
||||
*
|
||||
* Verifies that transactions work correctly with type-aware storage:
|
||||
* - Type-specific routing
|
||||
* - Type cache updates
|
||||
* - Type changes during updates
|
||||
* - Per-type performance optimization
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { Brainy } from '../../../src/brainy.js'
|
||||
import { NounType, VerbType } from '../../../src/types/graphTypes.js'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { mkdirSync, rmSync } from 'fs'
|
||||
|
||||
describe('Transactions + TypeAware Storage Integration', () => {
|
||||
let brain: Brainy
|
||||
let testDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = join(tmpdir(), `brainy-typeaware-test-${Date.now()}`)
|
||||
mkdirSync(testDir, { recursive: true })
|
||||
|
||||
brain = new Brainy({
|
||||
storage: {
|
||||
type: 'filesystem',
|
||||
path: testDir
|
||||
}
|
||||
})
|
||||
|
||||
await brain.init()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (brain) {
|
||||
await brain.shutdown()
|
||||
}
|
||||
if (testDir) {
|
||||
rmSync(testDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('Type-Specific Routing', () => {
|
||||
it('should route entities to type-specific storage atomically', async () => {
|
||||
// Add entities of different types
|
||||
const personId = await brain.add({
|
||||
data: { name: 'John Doe', role: 'Developer' },
|
||||
type: NounType.Person
|
||||
})
|
||||
|
||||
const orgId = await brain.add({
|
||||
data: { name: 'Acme Corp', industry: 'Tech' },
|
||||
type: NounType.Organization
|
||||
})
|
||||
|
||||
const placeId = await brain.add({
|
||||
data: { name: 'San Francisco', country: 'USA' },
|
||||
type: NounType.Place
|
||||
})
|
||||
|
||||
// Verify all entities stored with correct types
|
||||
const person = await brain.get(personId)
|
||||
const org = await brain.get(orgId)
|
||||
const place = await brain.get(placeId)
|
||||
|
||||
expect(person?.type).toBe(NounType.Person)
|
||||
expect(org?.type).toBe(NounType.Organization)
|
||||
expect(place?.type).toBe(NounType.Place)
|
||||
})
|
||||
|
||||
it('should handle type-specific queries atomically', async () => {
|
||||
// Add multiple entities of same type
|
||||
await brain.add({
|
||||
data: { name: 'Alice', role: 'Engineer' },
|
||||
type: NounType.Person
|
||||
})
|
||||
|
||||
await brain.add({
|
||||
data: { name: 'Bob', role: 'Designer' },
|
||||
type: NounType.Person
|
||||
})
|
||||
|
||||
await brain.add({
|
||||
data: { name: 'Acme', industry: 'Tech' },
|
||||
type: NounType.Organization
|
||||
})
|
||||
|
||||
// Query by type
|
||||
const results = await brain.find({
|
||||
filter: { type: NounType.Person }
|
||||
})
|
||||
|
||||
expect(results).toHaveLength(2)
|
||||
expect(results.every(r => r.type === NounType.Person)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Type Changes During Updates', () => {
|
||||
it('should handle type changes atomically in update operations', async () => {
|
||||
// Add entity as Person
|
||||
const id = await brain.add({
|
||||
data: { name: 'John Smith', category: 'individual' },
|
||||
type: NounType.Person
|
||||
})
|
||||
|
||||
// Verify initial type
|
||||
let entity = await brain.get(id)
|
||||
expect(entity?.type).toBe(NounType.Person)
|
||||
|
||||
// Update to Organization (type change)
|
||||
await brain.update({
|
||||
id,
|
||||
type: NounType.Organization,
|
||||
data: { name: 'Smith Corp', category: 'business' }
|
||||
})
|
||||
|
||||
// Verify type changed
|
||||
entity = await brain.get(id)
|
||||
expect(entity?.type).toBe(NounType.Organization)
|
||||
expect(entity?.data.category).toBe('business')
|
||||
})
|
||||
|
||||
it('should rollback type changes on failed update', async () => {
|
||||
// Add entity as Person
|
||||
const id = await brain.add({
|
||||
data: { name: 'Jane Doe' },
|
||||
type: NounType.Person
|
||||
})
|
||||
|
||||
// Attempt to update with invalid data (will fail)
|
||||
let failed = false
|
||||
try {
|
||||
await brain.update({
|
||||
id,
|
||||
type: NounType.Organization,
|
||||
data: null as any // Invalid
|
||||
})
|
||||
} catch (e) {
|
||||
failed = true
|
||||
}
|
||||
|
||||
expect(failed).toBe(true)
|
||||
|
||||
// Type should remain Person (rollback)
|
||||
const entity = await brain.get(id)
|
||||
expect(entity?.type).toBe(NounType.Person)
|
||||
expect(entity?.data.name).toBe('Jane Doe')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Type Cache Consistency', () => {
|
||||
it('should maintain type cache consistency during transactions', async () => {
|
||||
// Add multiple entities
|
||||
const ids: string[] = []
|
||||
const types = [
|
||||
NounType.Person,
|
||||
NounType.Organization,
|
||||
NounType.Place,
|
||||
NounType.Event,
|
||||
NounType.Concept
|
||||
]
|
||||
|
||||
for (let i = 0; i < types.length; i++) {
|
||||
const id = await brain.add({
|
||||
data: { name: `Entity ${i}`, index: i },
|
||||
type: types[i]
|
||||
})
|
||||
ids.push(id)
|
||||
}
|
||||
|
||||
// Verify all types cached correctly
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
const entity = await brain.get(ids[i])
|
||||
expect(entity?.type).toBe(types[i])
|
||||
}
|
||||
})
|
||||
|
||||
it('should update type cache on type changes', async () => {
|
||||
const id = await brain.add({
|
||||
data: { name: 'Initial' },
|
||||
type: NounType.Thing
|
||||
})
|
||||
|
||||
// Sequence of type changes
|
||||
const typeSequence = [
|
||||
NounType.Concept,
|
||||
NounType.Event,
|
||||
NounType.Organization,
|
||||
NounType.Person
|
||||
]
|
||||
|
||||
for (const newType of typeSequence) {
|
||||
await brain.update({
|
||||
id,
|
||||
type: newType,
|
||||
data: { name: `As ${newType}` }
|
||||
})
|
||||
|
||||
const entity = await brain.get(id)
|
||||
expect(entity?.type).toBe(newType)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Per-Type Performance', () => {
|
||||
it('should handle large numbers of same-type entities efficiently', async () => {
|
||||
const startTime = Date.now()
|
||||
|
||||
// Add 50 entities of same type (type-aware storage optimization)
|
||||
const ids: string[] = []
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const id = await brain.add({
|
||||
data: { name: `Person ${i}`, index: i },
|
||||
type: NounType.Person
|
||||
})
|
||||
ids.push(id)
|
||||
}
|
||||
|
||||
const addTime = Date.now() - startTime
|
||||
|
||||
// Verify all entities stored
|
||||
const retrieveStart = Date.now()
|
||||
for (const id of ids) {
|
||||
const entity = await brain.get(id)
|
||||
expect(entity).toBeTruthy()
|
||||
}
|
||||
const retrieveTime = Date.now() - retrieveStart
|
||||
|
||||
// Type-aware storage should be reasonably fast
|
||||
expect(addTime).toBeLessThan(5000) // 5 seconds for 50 adds
|
||||
expect(retrieveTime).toBeLessThan(2000) // 2 seconds for 50 retrieves
|
||||
})
|
||||
})
|
||||
|
||||
describe('Mixed Type Operations', () => {
|
||||
it('should handle operations across multiple types atomically', async () => {
|
||||
// Create entities of different types
|
||||
const personId = await brain.add({
|
||||
data: { name: 'Alice' },
|
||||
type: NounType.Person
|
||||
})
|
||||
|
||||
const orgId = await brain.add({
|
||||
data: { name: 'TechCorp' },
|
||||
type: NounType.Organization
|
||||
})
|
||||
|
||||
const eventId = await brain.add({
|
||||
data: { name: 'Conference 2024' },
|
||||
type: NounType.Event
|
||||
})
|
||||
|
||||
// Create relationships across types (atomic)
|
||||
await brain.relate({
|
||||
from: personId,
|
||||
to: orgId,
|
||||
type: VerbType.WorksFor
|
||||
})
|
||||
|
||||
await brain.relate({
|
||||
from: personId,
|
||||
to: eventId,
|
||||
type: VerbType.Attends
|
||||
})
|
||||
|
||||
await brain.relate({
|
||||
from: orgId,
|
||||
to: eventId,
|
||||
type: VerbType.Sponsors
|
||||
})
|
||||
|
||||
// Verify relationships exist
|
||||
const personRelations = await brain.getRelations({ from: personId })
|
||||
const orgRelations = await brain.getRelations({ from: orgId })
|
||||
|
||||
expect(personRelations).toHaveLength(2)
|
||||
expect(orgRelations).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should handle delete with cascade across types', async () => {
|
||||
// Create multi-type graph
|
||||
const personId = await brain.add({
|
||||
data: { name: 'Bob' },
|
||||
type: NounType.Person
|
||||
})
|
||||
|
||||
const projectId = await brain.add({
|
||||
data: { name: 'Project X' },
|
||||
type: NounType.Thing
|
||||
})
|
||||
|
||||
const taskId = await brain.add({
|
||||
data: { name: 'Task 1' },
|
||||
type: NounType.Thing
|
||||
})
|
||||
|
||||
// Create relationships
|
||||
await brain.relate({
|
||||
from: personId,
|
||||
to: projectId,
|
||||
type: VerbType.WorksOn
|
||||
})
|
||||
|
||||
await brain.relate({
|
||||
from: projectId,
|
||||
to: taskId,
|
||||
type: VerbType.Contains
|
||||
})
|
||||
|
||||
// Delete person (should cascade delete relationships)
|
||||
await brain.delete(personId)
|
||||
|
||||
// Verify person deleted
|
||||
const person = await brain.get(personId)
|
||||
expect(person).toBeNull()
|
||||
|
||||
// Verify project and task still exist (different types)
|
||||
const project = await brain.get(projectId)
|
||||
const task = await brain.get(taskId)
|
||||
expect(project).toBeTruthy()
|
||||
expect(task).toBeTruthy()
|
||||
|
||||
// Verify relationships from person are deleted
|
||||
const relations = await brain.getRelations({ from: personId })
|
||||
expect(relations).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Type Validation', () => {
|
||||
it('should validate types during atomic operations', async () => {
|
||||
// Valid type - should succeed
|
||||
const id = await brain.add({
|
||||
data: { name: 'Test' },
|
||||
type: NounType.Person
|
||||
})
|
||||
|
||||
expect(id).toBeTruthy()
|
||||
|
||||
// Verify type stored correctly
|
||||
const entity = await brain.get(id)
|
||||
expect(entity?.type).toBe(NounType.Person)
|
||||
})
|
||||
|
||||
it('should handle type-specific metadata atomically', async () => {
|
||||
// Add with type-specific metadata
|
||||
const personId = await brain.add({
|
||||
data: { name: 'Charlie', age: 30, occupation: 'Engineer' },
|
||||
type: NounType.Person,
|
||||
metadata: { verified: true, source: 'HR' }
|
||||
})
|
||||
|
||||
const orgId = await brain.add({
|
||||
data: { name: 'StartupCo', employees: 50, founded: 2020 },
|
||||
type: NounType.Organization,
|
||||
metadata: { verified: false, source: 'Registration' }
|
||||
})
|
||||
|
||||
// Verify metadata with type context
|
||||
const person = await brain.get(personId)
|
||||
const org = await brain.get(orgId)
|
||||
|
||||
expect(person?.metadata?.verified).toBe(true)
|
||||
expect(org?.metadata?.verified).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue