@codeforbreakfast/eventsourcing-protocol-default
Version:
Default implementation of the event sourcing protocol over any transport - Standard protocol implementation with message serialization, command correlation, and subscription management
1,225 lines (1,156 loc) • 92.1 kB
text/typescript
import { describe, test, expect } from 'bun:test';
import {
Effect,
Stream,
Duration,
pipe,
TestClock,
TestContext,
Either,
Schema,
Fiber,
} from 'effect';
import {
ProtocolLive,
sendCommand,
subscribe,
Command,
CommandTimeoutError,
CommandResult,
Event,
} from './protocol';
import { ServerProtocolLive, ServerProtocol } from './server-protocol';
import { InMemoryAcceptor } from '@codeforbreakfast/eventsourcing-transport-inmemory';
import { makeTransportMessage } from '@codeforbreakfast/eventsourcing-transport-contracts';
import { EventStreamId, toStreamId } from '@codeforbreakfast/eventsourcing-store';
// ============================================================================
// Test Helpers
// ============================================================================
const createStreamId = (id: string) => pipe(id, Schema.decode(EventStreamId));
const unsafeCreateStreamId = (id: string) => Effect.runSync(createStreamId(id));
// ============================================================================
// Test Environment Setup
// ============================================================================
const setupTestEnvironment = pipe(
InMemoryAcceptor.make(),
Effect.flatMap((acceptor) => acceptor.start()),
Effect.flatMap((server) =>
pipe(
server.connector(),
Effect.flatMap((clientTransport) =>
pipe(
clientTransport.connectionState,
Stream.filter((state) => state === 'connected'),
Stream.take(1),
Stream.runDrain,
Effect.as({ server, clientTransport })
)
)
)
)
);
// ============================================================================
// Test Server Protocol - Handles Commands and Subscriptions
// ============================================================================
const createTestServerProtocol = (
server: any,
commandHandler: (cmd: Command) => CommandResult = () => ({
_tag: 'Success',
position: { streamId: unsafeCreateStreamId('test'), eventNumber: 1 },
}),
subscriptionHandler: (streamId: string) => Event[] = () => []
) =>
pipe(
server.connections,
Stream.take(1),
Stream.runCollect,
Effect.map((connections) => Array.from(connections)[0]!),
Effect.flatMap((serverConnection) =>
pipe(
serverConnection.transport.subscribe(),
Effect.flatMap((messageStream) =>
Effect.forkScoped(
Stream.runForEach(messageStream, (message) =>
pipe(
Effect.try(() => JSON.parse(message.payload)),
Effect.flatMap((parsedMessage) => {
if (parsedMessage.type === 'command') {
const result = commandHandler(parsedMessage);
const response = makeTransportMessage(
crypto.randomUUID(),
'command_result',
JSON.stringify({
type: 'command_result',
commandId: parsedMessage.id,
success: result._tag === 'Success',
...(result._tag === 'Success'
? { position: result.position }
: { error: result.error }),
})
);
return server.broadcast(response);
}
if (parsedMessage.type === 'subscribe') {
const events = subscriptionHandler(parsedMessage.streamId);
return Effect.forEach(
events,
(event) =>
server.broadcast(
makeTransportMessage(
crypto.randomUUID(),
'event',
JSON.stringify({
type: 'event',
streamId: parsedMessage.streamId,
position: event.position,
eventType: event.type,
data: event.data,
timestamp: event.timestamp.toISOString(),
})
)
),
{ discard: true }
);
}
return Effect.void;
}),
Effect.catchAll(() => Effect.void)
)
)
)
)
)
),
Effect.asVoid
);
describe('Protocol Behavior Tests', () => {
describe('Command Sending and Results', () => {
test('should send command and receive success result', async () => {
const program = pipe(
setupTestEnvironment,
Effect.flatMap(({ server, clientTransport }) =>
pipe(
createTestServerProtocol(server, (command) => ({
_tag: 'Success',
position: { streamId: unsafeCreateStreamId('user-123'), eventNumber: 42 },
})),
Effect.flatMap(() => {
const command: Command = {
id: crypto.randomUUID(),
target: 'user-123',
name: 'UpdateProfile',
payload: { name: 'John Doe' },
};
return pipe(
sendCommand(command),
Effect.tap((result) =>
Effect.sync(() => {
expect(result._tag).toBe('Success');
if (result._tag === 'Success') {
expect(result.position.streamId).toEqual(unsafeCreateStreamId('user-123'));
expect(result.position.eventNumber).toBe(42);
}
})
),
Effect.provide(ProtocolLive(clientTransport))
);
})
)
)
);
await Effect.runPromise(Effect.scoped(program) as Effect.Effect<void, never, never>);
});
test('should send command and receive failure result', async () => {
const program = pipe(
setupTestEnvironment,
Effect.flatMap(({ server, clientTransport }) =>
pipe(
createTestServerProtocol(server, (command) => ({
_tag: 'Failure',
error: 'Validation failed: Name is required',
})),
Effect.flatMap(() => {
const command: Command = {
id: crypto.randomUUID(),
target: 'user-123',
name: 'UpdateProfile',
payload: { name: '' },
};
return pipe(
sendCommand(command),
Effect.tap((result) =>
Effect.sync(() => {
expect(result._tag).toBe('Failure');
if (result._tag === 'Failure') {
expect(result.error).toBe('Validation failed: Name is required');
}
})
),
Effect.provide(ProtocolLive(clientTransport))
);
})
)
)
);
await Effect.runPromise(Effect.scoped(program) as Effect.Effect<void, never, never>);
});
test('should handle multiple concurrent commands with proper correlation', async () => {
const program = pipe(
setupTestEnvironment,
Effect.flatMap(({ server, clientTransport }) =>
pipe(
createTestServerProtocol(server, (command) => {
const isSuccess = command.name === 'CreateUser';
return isSuccess
? {
_tag: 'Success',
position: {
streamId: unsafeCreateStreamId(command.target),
eventNumber: Math.floor(Math.random() * 100),
},
}
: {
_tag: 'Failure',
error: 'Command failed',
};
}),
Effect.flatMap(() => {
const commands: Command[] = [
{
id: crypto.randomUUID(),
target: 'user-1',
name: 'CreateUser',
payload: { name: 'Alice' },
},
{
id: crypto.randomUUID(),
target: 'user-2',
name: 'DeleteUser',
payload: { id: 'user-2' },
},
{
id: crypto.randomUUID(),
target: 'user-3',
name: 'CreateUser',
payload: { name: 'Bob' },
},
];
return pipe(
Effect.all(
commands.map((cmd) => sendCommand(cmd)),
{ concurrency: 'unbounded' }
),
Effect.tap((results) =>
Effect.sync(() => {
expect(results).toHaveLength(3);
expect(results[0]!._tag).toBe('Success');
expect(results[1]!._tag).toBe('Failure');
expect(results[2]!._tag).toBe('Success');
})
),
Effect.provide(ProtocolLive(clientTransport))
);
})
)
)
);
await Effect.runPromise(Effect.scoped(program) as Effect.Effect<void, never, never>);
});
});
describe('Command Timeout Behavior', () => {
test('should timeout commands after 10 seconds', async () => {
const program = pipe(
setupTestEnvironment,
Effect.flatMap(({ clientTransport }) => {
const command: Command = {
id: crypto.randomUUID(),
target: 'user-123',
name: 'SlowCommand',
payload: { data: 'test' },
};
return pipe(
Effect.all(
[
pipe(
sendCommand(command),
Effect.either,
Effect.provide(ProtocolLive(clientTransport))
),
TestClock.adjust(Duration.seconds(11)),
],
{ concurrency: 'unbounded' }
),
Effect.map(([result, _]) => result),
Effect.tap((result) =>
Effect.sync(() => {
expect(Either.isLeft(result)).toBe(true);
if (Either.isLeft(result)) {
expect(result.left).toBeInstanceOf(CommandTimeoutError);
if (result.left instanceof CommandTimeoutError) {
expect(result.left.commandId).toBe(command.id);
expect(result.left.timeoutMs).toBe(10000);
}
}
})
)
);
})
);
await Effect.runPromise(
pipe(program, Effect.scoped, Effect.provide(TestContext.TestContext))
);
});
test('should not timeout when response arrives before deadline', async () => {
const program = pipe(
setupTestEnvironment,
Effect.flatMap(({ server, clientTransport }) =>
pipe(
createTestServerProtocol(server, (command) => ({
_tag: 'Success',
position: { streamId: unsafeCreateStreamId('user-123'), eventNumber: 1 },
})),
Effect.flatMap(() => {
const command: Command = {
id: crypto.randomUUID(),
target: 'user-123',
name: 'FastCommand',
payload: { data: 'test' },
};
return pipe(
sendCommand(command),
Effect.tap((result) =>
Effect.sync(() => {
expect(result._tag).toBe('Success');
})
),
Effect.provide(ProtocolLive(clientTransport))
);
})
)
)
);
await Effect.runPromise(Effect.scoped(program) as Effect.Effect<void, never, never>);
});
});
describe('Event Subscription', () => {
test('should successfully create subscriptions without timeout', async () => {
const program = pipe(
setupTestEnvironment,
Effect.flatMap(({ server, clientTransport }) =>
pipe(
createTestServerProtocol(server),
Effect.flatMap(() =>
pipe(
subscribe('user-123'),
Effect.flatMap((eventStream) =>
pipe(
// Just verify we can create the subscription and get a stream
eventStream,
Stream.take(0),
Stream.runDrain
)
),
Effect.provide(ProtocolLive(clientTransport))
)
)
)
)
);
await Effect.runPromise(Effect.scoped(program) as Effect.Effect<void, never, never>);
});
test('should receive events for subscribed streams', async () => {
const program = pipe(
setupTestEnvironment,
Effect.flatMap(({ server, clientTransport }) =>
pipe(
createTestServerProtocol(
server,
() => ({
_tag: 'Success',
position: { streamId: unsafeCreateStreamId('test'), eventNumber: 1 },
}),
(streamId) => [
// Return events immediately when subscribed
{
position: { streamId: unsafeCreateStreamId(streamId), eventNumber: 1 },
type: 'UserCreated',
data: { id: streamId, name: 'John Doe' },
timestamp: new Date('2024-01-01T10:00:00Z'),
},
{
position: { streamId: unsafeCreateStreamId(streamId), eventNumber: 2 },
type: 'UserEmailUpdated',
data: { id: streamId, email: 'john@example.com' },
timestamp: new Date('2024-01-01T10:01:00Z'),
},
]
),
Effect.flatMap(() =>
pipe(
subscribe('user-123'),
Effect.flatMap((eventStream) =>
pipe(
eventStream,
Stream.take(2),
Stream.runCollect,
Effect.tap((collectedEvents) =>
Effect.sync(() => {
const events = Array.from(collectedEvents);
// Verify we received exactly 2 events
expect(events).toHaveLength(2);
// Verify first event
expect(events[0]!.type).toBe('UserCreated');
expect(events[0]!.data).toEqual({ id: 'user-123', name: 'John Doe' });
expect(events[0]!.position.eventNumber).toBe(1);
// Verify second event
expect(events[1]!.type).toBe('UserEmailUpdated');
expect(events[1]!.data).toEqual({
id: 'user-123',
email: 'john@example.com',
});
expect(events[1]!.position.eventNumber).toBe(2);
// Verify timestamps are preserved
expect(events[0]!.timestamp).toEqual(new Date('2024-01-01T10:00:00Z'));
expect(events[1]!.timestamp).toEqual(new Date('2024-01-01T10:01:00Z'));
})
)
)
),
Effect.provide(ProtocolLive(clientTransport))
)
)
)
)
);
await Effect.runPromise(Effect.scoped(program) as Effect.Effect<void, never, never>);
});
test('should only receive events for the specific subscribed stream (filtering)', async () => {
const program = pipe(
setupTestEnvironment,
Effect.flatMap(({ server, clientTransport }) =>
pipe(
createTestServerProtocol(
server,
() => ({
_tag: 'Success',
position: { streamId: unsafeCreateStreamId('test'), eventNumber: 1 },
}),
(streamId) => {
// Return different events based on the stream being subscribed to
if (streamId === 'user-123') {
return [
{
position: { streamId: unsafeCreateStreamId(streamId), eventNumber: 1 },
type: 'UserCreated',
data: { id: streamId, name: 'John Doe' },
timestamp: new Date('2024-01-01T10:00:00Z'),
},
{
position: { streamId: unsafeCreateStreamId(streamId), eventNumber: 2 },
type: 'UserUpdated',
data: { id: streamId, name: 'John Updated' },
timestamp: new Date('2024-01-01T10:01:00Z'),
},
];
}
// For any other stream, return different events
return [
{
position: { streamId: unsafeCreateStreamId(streamId), eventNumber: 1 },
type: 'OtherUserCreated',
data: { id: streamId, name: 'Jane Doe' },
timestamp: new Date('2024-01-01T10:02:00Z'),
},
];
}
),
Effect.flatMap(() =>
pipe(
// Subscribe only to user-123 stream
subscribe('user-123'),
Effect.flatMap((eventStream) =>
pipe(
eventStream,
Stream.take(2), // Should only get 2 events for user-123
Stream.runCollect,
Effect.tap((collectedEvents) =>
Effect.sync(() => {
const events = Array.from(collectedEvents);
// Should only receive events for user-123 stream (filtering works)
expect(events).toHaveLength(2);
// Verify specific events received
expect(events[0]!.type).toBe('UserCreated');
expect(events[0]!.data).toEqual({
id: 'user-123',
name: 'John Doe',
});
expect(events[1]!.type).toBe('UserUpdated');
expect(events[1]!.data).toEqual({
id: 'user-123',
name: 'John Updated',
});
// Verify we did NOT receive events for other streams
const hasOtherStreamEvents = events.some(
(event) => event.type === 'OtherUserCreated'
);
expect(hasOtherStreamEvents).toBe(false);
})
)
)
),
Effect.provide(ProtocolLive(clientTransport))
)
)
)
)
);
await Effect.runPromise(Effect.scoped(program) as Effect.Effect<void, never, never>);
});
test('should handle basic event publishing and receiving', async () => {
const program = pipe(
setupTestEnvironment,
Effect.flatMap(({ server, clientTransport }) =>
pipe(
// Setup server protocol first
ServerProtocol,
Effect.flatMap((serverProtocol) =>
pipe(
// Subscribe to stream first to establish the subscription
subscribe('test-stream'),
Effect.flatMap((eventStream) =>
pipe(
// Start collecting events and publishing concurrently
Effect.all(
[
// Collect events from the stream
pipe(eventStream, Stream.take(1), Stream.runCollect),
// Publish an event after a small delay to ensure subscription is ready
pipe(
Effect.sleep(Duration.millis(50)),
Effect.flatMap(() =>
serverProtocol.publishEvent({
streamId: unsafeCreateStreamId('test-stream'),
position: {
streamId: unsafeCreateStreamId('test-stream'),
eventNumber: 1,
},
type: 'TestEvent',
data: { test: 'data', value: 42 },
timestamp: new Date('2024-01-01T12:00:00Z'),
})
),
Effect.asVoid
),
],
{ concurrency: 'unbounded' }
),
Effect.map(([events, _]) => events),
Effect.tap((collectedEvents) =>
Effect.sync(() => {
const events = Array.from(collectedEvents);
// Verify we received exactly 1 event
expect(events).toHaveLength(1);
// Verify event content
expect(events[0]!.type).toBe('TestEvent');
expect(events[0]!.data).toEqual({ test: 'data', value: 42 });
expect(events[0]!.position.streamId).toEqual(
unsafeCreateStreamId('test-stream')
);
expect(events[0]!.position.eventNumber).toBe(1);
expect(events[0]!.timestamp).toEqual(new Date('2024-01-01T12:00:00Z'));
})
)
)
),
Effect.provide(ProtocolLive(clientTransport))
)
),
Effect.provide(ServerProtocolLive(server))
)
)
);
await Effect.runPromise(Effect.scoped(program) as Effect.Effect<void, never, never>);
});
test('should handle receiving events while processing commands concurrently', async () => {
const program = pipe(
setupTestEnvironment,
Effect.flatMap(({ server, clientTransport }) =>
pipe(
createTestServerProtocol(
server,
(command) => ({
_tag: 'Success',
position: {
streamId: unsafeCreateStreamId(command.target),
eventNumber: 1,
},
}),
(streamId) => [
{
position: { streamId: unsafeCreateStreamId(streamId), eventNumber: 1 },
type: 'UserCreated',
data: { id: streamId, name: 'Concurrent User' },
timestamp: new Date('2024-01-01T10:00:00Z'),
},
{
position: { streamId: unsafeCreateStreamId(streamId), eventNumber: 2 },
type: 'UserUpdated',
data: { id: streamId, status: 'active' },
timestamp: new Date('2024-01-01T10:01:00Z'),
},
]
),
Effect.flatMap(() => {
const commands: Command[] = [
{
id: crypto.randomUUID(),
target: 'user-1',
name: 'CreateUser',
payload: { name: 'Alice' },
},
{
id: crypto.randomUUID(),
target: 'user-2',
name: 'UpdateUser',
payload: { name: 'Bob' },
},
];
return pipe(
Effect.all(
[
// Start subscription and collect events
pipe(
subscribe('user-stream'),
Effect.flatMap((eventStream) =>
pipe(eventStream, Stream.take(2), Stream.runCollect)
)
),
// Send commands concurrently while events are being received
pipe(
Effect.all(
commands.map((cmd) => sendCommand(cmd)),
{ concurrency: 'unbounded' }
)
),
],
{ concurrency: 'unbounded' }
),
Effect.tap(([events, commandResults]) =>
Effect.sync(() => {
// Verify events were received
const collectedEvents = Array.from(events);
expect(collectedEvents).toHaveLength(2);
expect(collectedEvents[0]!.type).toBe('UserCreated');
expect(collectedEvents[1]!.type).toBe('UserUpdated');
// Verify commands were processed successfully
expect(commandResults).toHaveLength(2);
expect(commandResults[0]!._tag).toBe('Success');
expect(commandResults[1]!._tag).toBe('Success');
})
),
Effect.provide(ProtocolLive(clientTransport))
);
})
)
)
);
await Effect.runPromise(Effect.scoped(program) as Effect.Effect<void, never, never>);
});
});
describe('Multiple Subscriptions', () => {
test('should handle multiple clients subscribing to the same stream', async () => {
const program = pipe(
setupTestEnvironment,
Effect.flatMap(({ server, clientTransport: client1Transport }) =>
pipe(
// Get a second client connection
server.connector(),
Effect.flatMap((client2Transport) =>
pipe(
client2Transport.connectionState,
Stream.filter((state) => state === 'connected'),
Stream.take(1),
Stream.runDrain,
Effect.as({ client1Transport, client2Transport })
)
),
Effect.flatMap(({ client1Transport, client2Transport }) =>
pipe(
createTestServerProtocol(
server,
() => ({
_tag: 'Success',
position: { streamId: unsafeCreateStreamId('test'), eventNumber: 1 },
}),
(streamId) => {
// Return the same events for any subscription to 'shared-stream'
if (streamId === 'shared-stream') {
return [
{
position: { streamId: unsafeCreateStreamId(streamId), eventNumber: 1 },
type: 'SharedEvent1',
data: { message: 'First shared event', clientId: 'all' },
timestamp: new Date('2024-01-01T10:00:00Z'),
},
{
position: { streamId: unsafeCreateStreamId(streamId), eventNumber: 2 },
type: 'SharedEvent2',
data: { message: 'Second shared event', value: 42 },
timestamp: new Date('2024-01-01T10:01:00Z'),
},
{
position: { streamId: unsafeCreateStreamId(streamId), eventNumber: 3 },
type: 'SharedEvent3',
data: { message: 'Third shared event', status: 'completed' },
timestamp: new Date('2024-01-01T10:02:00Z'),
},
];
}
return [];
}
),
Effect.flatMap(() => {
return pipe(
Effect.all(
[
// Client 1 subscribes to shared-stream and collects 3 events
pipe(
subscribe('shared-stream'),
Effect.flatMap((eventStream) =>
pipe(
eventStream,
Stream.take(3),
Stream.runCollect,
Effect.map((events) => ({
clientId: 'client1',
events: Array.from(events),
}))
)
),
Effect.provide(ProtocolLive(client1Transport))
),
// Client 2 subscribes to the same shared-stream and also collects 3 events
pipe(
subscribe('shared-stream'),
Effect.flatMap((eventStream) =>
pipe(
eventStream,
Stream.take(3),
Stream.runCollect,
Effect.map((events) => ({
clientId: 'client2',
events: Array.from(events),
}))
)
),
Effect.provide(ProtocolLive(client2Transport))
),
],
{ concurrency: 'unbounded' }
),
Effect.tap((clientResults) =>
Effect.sync(() => {
const client1Results = clientResults.find((r) => r.clientId === 'client1')!;
const client2Results = clientResults.find((r) => r.clientId === 'client2')!;
// Both clients should receive exactly the same events
expect(client1Results.events).toHaveLength(3);
expect(client2Results.events).toHaveLength(3);
// Verify client 1 received correct events
expect(client1Results.events[0]!.type).toBe('SharedEvent1');
expect(client1Results.events[0]!.data).toEqual({
message: 'First shared event',
clientId: 'all',
});
expect(client1Results.events[1]!.type).toBe('SharedEvent2');
expect(client1Results.events[1]!.data).toEqual({
message: 'Second shared event',
value: 42,
});
expect(client1Results.events[2]!.type).toBe('SharedEvent3');
expect(client1Results.events[2]!.data).toEqual({
message: 'Third shared event',
status: 'completed',
});
// Verify client 2 received identical events
expect(client2Results.events[0]!.type).toBe('SharedEvent1');
expect(client2Results.events[0]!.data).toEqual({
message: 'First shared event',
clientId: 'all',
});
expect(client2Results.events[1]!.type).toBe('SharedEvent2');
expect(client2Results.events[1]!.data).toEqual({
message: 'Second shared event',
value: 42,
});
expect(client2Results.events[2]!.type).toBe('SharedEvent3');
expect(client2Results.events[2]!.data).toEqual({
message: 'Third shared event',
status: 'completed',
});
// Verify event ordering and positions are consistent across clients
for (let i = 0; i < 3; i++) {
expect(client1Results.events[i]!.position.eventNumber).toBe(
client2Results.events[i]!.position.eventNumber
);
expect(client1Results.events[i]!.timestamp).toEqual(
client2Results.events[i]!.timestamp
);
expect(client1Results.events[i]!.type).toBe(
client2Results.events[i]!.type
);
}
// Verify timestamps are preserved correctly
expect(client1Results.events[0]!.timestamp).toEqual(
new Date('2024-01-01T10:00:00Z')
);
expect(client1Results.events[1]!.timestamp).toEqual(
new Date('2024-01-01T10:01:00Z')
);
expect(client1Results.events[2]!.timestamp).toEqual(
new Date('2024-01-01T10:02:00Z')
);
})
)
);
})
)
)
)
)
);
await Effect.runPromise(Effect.scoped(program) as Effect.Effect<void, never, never>);
});
test('should handle single client subscribing to multiple different streams', async () => {
const program = pipe(
setupTestEnvironment,
Effect.flatMap(({ server, clientTransport }) =>
pipe(
createTestServerProtocol(
server,
() => ({
_tag: 'Success',
position: { streamId: unsafeCreateStreamId('test'), eventNumber: 1 },
}),
(streamId) => {
// Return different events based on the stream being subscribed to
if (streamId === 'user-stream') {
return [
{
position: { streamId: unsafeCreateStreamId(streamId), eventNumber: 1 },
type: 'UserCreated',
data: { id: 'user-1', name: 'Alice' },
timestamp: new Date('2024-01-01T10:00:00Z'),
},
{
position: { streamId: unsafeCreateStreamId(streamId), eventNumber: 2 },
type: 'UserUpdated',
data: { id: 'user-1', status: 'active' },
timestamp: new Date('2024-01-01T10:01:00Z'),
},
];
}
if (streamId === 'order-stream') {
return [
{
position: { streamId: unsafeCreateStreamId(streamId), eventNumber: 1 },
type: 'OrderCreated',
data: { orderId: 'order-1', amount: 100 },
timestamp: new Date('2024-01-01T11:00:00Z'),
},
];
}
if (streamId === 'product-stream') {
return [
{
position: { streamId: unsafeCreateStreamId(streamId), eventNumber: 1 },
type: 'ProductAdded',
data: { productId: 'prod-1', name: 'Widget' },
timestamp: new Date('2024-01-01T12:00:00Z'),
},
{
position: { streamId: unsafeCreateStreamId(streamId), eventNumber: 2 },
type: 'ProductPriced',
data: { productId: 'prod-1', price: 25.99 },
timestamp: new Date('2024-01-01T12:01:00Z'),
},
{
position: { streamId: unsafeCreateStreamId(streamId), eventNumber: 3 },
type: 'ProductPublished',
data: { productId: 'prod-1', published: true },
timestamp: new Date('2024-01-01T12:02:00Z'),
},
];
}
return [];
}
),
Effect.flatMap(() => {
return pipe(
Effect.all(
[
// Subscribe to multiple streams concurrently
pipe(
subscribe('user-stream'),
Effect.flatMap((eventStream) =>
pipe(
eventStream,
Stream.take(2),
Stream.runCollect,
Effect.map((events) => ({
streamType: 'user',
events: Array.from(events),
}))
)
)
),
pipe(
subscribe('order-stream'),
Effect.flatMap((eventStream) =>
pipe(
eventStream,
Stream.take(1),
Stream.runCollect,
Effect.map((events) => ({
streamType: 'order',
events: Array.from(events),
}))
)
)
),
pipe(
subscribe('product-stream'),
Effect.flatMap((eventStream) =>
pipe(
eventStream,
Stream.take(3),
Stream.runCollect,
Effect.map((events) => ({
streamType: 'product',
events: Array.from(events),
}))
)
)
),
],
{ concurrency: 'unbounded' }
),
Effect.tap((streamResults) =>
Effect.sync(() => {
// Find results by stream type
const userResults = streamResults.find((r) => r.streamType === 'user')!;
const orderResults = streamResults.find((r) => r.streamType === 'order')!;
const productResults = streamResults.find((r) => r.streamType === 'product')!;
// Verify user stream events
expect(userResults.events).toHaveLength(2);
expect(userResults.events[0]!.type).toBe('UserCreated');
expect(userResults.events[0]!.data).toEqual({ id: 'user-1', name: 'Alice' });
expect(userResults.events[1]!.type).toBe('UserUpdated');
expect(userResults.events[1]!.data).toEqual({ id: 'user-1', status: 'active' });
// Verify order stream events
expect(orderResults.events).toHaveLength(1);
expect(orderResults.events[0]!.type).toBe('OrderCreated');
expect(orderResults.events[0]!.data).toEqual({
orderId: 'order-1',
amount: 100,
});
// Verify product stream events
expect(productResults.events).toHaveLength(3);
expect(productResults.events[0]!.type).toBe('ProductAdded');
expect(productResults.events[0]!.data).toEqual({
productId: 'prod-1',
name: 'Widget',
});
expect(productResults.events[1]!.type).toBe('ProductPriced');
expect(productResults.events[1]!.data).toEqual({
productId: 'prod-1',
price: 25.99,
});
expect(productResults.events[2]!.type).toBe('ProductPublished');
expect(productResults.events[2]!.data).toEqual({
productId: 'prod-1',
published: true,
});
// Verify stream isolation - no cross-contamination
const allUserEvents = userResults.events;
const hasOrderEvents = allUserEvents.some((e) => e.type.startsWith('Order'));
const hasProductEvents = allUserEvents.some((e) =>
e.type.startsWith('Product')
);
expect(hasOrderEvents).toBe(false);
expect(hasProductEvents).toBe(false);
const allOrderEvents = orderResults.events;
const hasUserEvents = allOrderEvents.some((e) => e.type.startsWith('User'));
expect(hasUserEvents).toBe(false);
const allProductEvents = productResults.events;
const hasUserEventsInProduct = allProductEvents.some((e) =>
e.type.startsWith('User')
);
const hasOrderEventsInProduct = allProductEvents.some((e) =>
e.type.startsWith('Order')
);
expect(hasUserEventsInProduct).toBe(false);
expect(hasOrderEventsInProduct).toBe(false);
// Verify timestamps are preserved correctly across streams
expect(userResults.events[0]!.timestamp).toEqual(
new Date('2024-01-01T10:00:00Z')
);
expect(orderResults.events[0]!.timestamp).toEqual(
new Date('2024-01-01T11:00:00Z')
);
expect(productResults.events[0]!.timestamp).toEqual(
new Date('2024-01-01T12:00:00Z')
);
})
),
Effect.provide(ProtocolLive(clientTransport))
);
})
)
)
);
await Effect.runPromise(Effect.scoped(program) as Effect.Effect<void, never, never>);
});
test('should continue receiving events after re-subscribing to a stream', async () => {
const program = pipe(
setupTestEnvironment,
Effect.flatMap(({ server, clientTransport }) =>
pipe(
createTestServerProtocol(
server,
() => ({
_tag: 'Success',
position: { streamId: unsafeCreateStreamId('test'), eventNumber: 1 },
}),
(streamId) => {
// Simulate a stream that has accumulated events over time
if (streamId === 'persistent-stream') {
return [
{
position: { streamId: unsafeCreateStreamId(streamId), eventNumber: 1 },
type: 'EventBeforeResubscribe1',
data: { message: 'First event before resubscribe', value: 1 },
timestamp: new Date('2024-01-01T10:00:00Z'),
},
{
position: { streamId: unsafeCreateStreamId(streamId), eventNumber: 2 },
type: 'EventBeforeResubscribe2',
data: { message: 'Second event before resubscribe', value: 2 },
timestamp: new Date('2024-01-01T10:01:00Z'),
},
{
position: { streamId: unsafeCreateStreamId(streamId), eventNumber: 3 },
type: 'EventAfterResubscribe1',
data: { message: 'First event after resubscribe', value: 3 },
timestamp: new Date('2024-01-01T10:02:00Z'),
},
{
position: { streamId: unsafeCreateStreamId(streamId), eventNumber: 4 },
type: 'EventAfterResubscribe2',
data: { message: 'Second event after resubscribe', value: 4 },
timestamp: new Date('2024-01-01T10:03:00Z'),
},
];
}
return [];
}
),
Effect.flatMap(() => {
return pipe(
// First subscription - get first 2 events
Effect.scoped(
pipe(
subscribe('persistent-stream'),
Effect.flatMap((eventStream) =>
pipe(
eventStream,
Stream.take(2),
Stream.runCollect,
Effect.map((events) => Array.from(events))
)
)
)
),
Effect.flatMap((firstBatchEvents) => {
// Verify we got the first batch correctly
return pipe(
Effect.sync(() => {
expect(firstBatchEvents).toHaveLength(2);
expect(firstBatchEvents[0]!.type).toBe('EventBeforeResubscribe1');
expect(firstBatchEvents[1]!.type).toBe('EventBeforeResubscribe2');
}),
Effect.flatMap(() =>
// Second subscription (re-subscribe) - should get all events again
pipe(
subscribe('persistent-stream'),
Effect.flatMap((newEventStream) =>
pipe(
newEventStream,
Stream.take(4), // Take all 4 events this time
Stream.runCollect,
Effect.map((events) => ({
firstBatch: firstBatchEvents,
resubscribeBatch: Array.from(events),
}))
)
)
)
)
);
}),
Effect.tap(({ firstBatch, resubscribeBatch }) =>
Effect.sync(() => {
// Verify first batch events (from before re-subscribe)
expect(firstBatch).toHaveLength(2);
expect(firstBatch[0]!.type).toBe('EventBeforeResubscribe1');
expect(firstBatch[0]!.data).toEqual({
message: 'First event before resubscribe',
value: 1,
});
expect(firstBatch[1]!.type).toBe('EventBeforeResubscribe2');
expect(firstBatch[1]!.data).toEqual({
message: 'Second event before resubscribe',
value: 2,
});
// Verify re-subscribe batch contains all events including new ones
expect(resubscribeBatch).toHaveLength(4);
// Events that were already seen before should be delivered again
expect(resubscribeBatch[0]!.type).toBe('EventBeforeResubscribe1');
expect(resubscribeBatch[1]!.type).toBe('EventBeforeResubscribe2');
// Plus the new events that accumulated while not subscribed
expect(resubscribeBatch[2]!.type).toBe('EventAfterResubscribe1');
expect(resubscribeBatch[2]!.data).toEqual({
message: 'First event after resubscribe',
value: 3,
});
expect(resubscribeBatch[3]!.type).toBe('EventAfterResubscribe2');
expect(resubscribeBatch[3]!.data).toEqual({
message: 'Second event after resubscribe',
value: 4,
});
// Verify event numbers are sequential and correct
expect(resubscribeBatch[0]!.position.eventNumber).toBe(1);
expect(resubscribeBatch[1]!.position.eventNumber).toBe(2);
expect(resubscribeBatch[2]!.position.eventNumber).toBe(3);
expect(resubscribeBatch[3]!.position.eventNumber).toBe(4);
// Verify timestamps are preserved correctly
expect(resubscribeBatch[0]!.timestamp).toEqual(
new Date('2024-01-01T10:00:00Z')
);
expect(resubscribeBatch[1]!.timestamp).toEqual(
new Date('2024-01-01T10:01:00Z')
);
expect(resubscribeBatch[2]!.timestamp).toEqual(
new Date('2024-01-01T10:02:00Z')
);
expect(resubscribeBatch[3]!.timestamp).toEqual(
new Date('2024-01-01T10:03:00Z')
);
})
),
Effect.provide(ProtocolLive(clientTransport))
);
})
)
)
);
await Effect.runPromise(Effect.scop