feat(01.1-01): define domain events with typed payloads and tests
- Add ProcessSpawnedEvent, ProcessStoppedEvent, ProcessCrashedEvent - Add ServerStartedEvent, ServerStoppedEvent - Add LogEntryEvent for stdout/stderr capture - Create DomainEventMap union type for type-safe handling - Add comprehensive tests for emit/on, once, off, multiple handlers - Verify typed event payloads work correctly
This commit is contained in:
995
package-lock.json
generated
995
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
201
src/events/bus.test.ts
Normal file
201
src/events/bus.test.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
/**
|
||||||
|
* EventBus Tests
|
||||||
|
*
|
||||||
|
* Tests for the EventEmitterBus adapter implementation.
|
||||||
|
* Verifies the event bus pattern works correctly with typed events.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { EventEmitterBus, createEventBus } from './index.js';
|
||||||
|
import type {
|
||||||
|
DomainEvent,
|
||||||
|
ProcessSpawnedEvent,
|
||||||
|
ServerStartedEvent,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
describe('EventEmitterBus', () => {
|
||||||
|
let bus: EventEmitterBus;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
bus = new EventEmitterBus();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('emit/on pattern', () => {
|
||||||
|
it('should deliver events to subscribed handlers', () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const event: DomainEvent = {
|
||||||
|
type: 'test:event',
|
||||||
|
timestamp: new Date(),
|
||||||
|
payload: { data: 'test' },
|
||||||
|
};
|
||||||
|
|
||||||
|
bus.on('test:event', handler);
|
||||||
|
bus.emit(event);
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledOnce();
|
||||||
|
expect(handler).toHaveBeenCalledWith(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not deliver events to handlers of different types', () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const event: DomainEvent = {
|
||||||
|
type: 'test:event',
|
||||||
|
timestamp: new Date(),
|
||||||
|
payload: { data: 'test' },
|
||||||
|
};
|
||||||
|
|
||||||
|
bus.on('other:event', handler);
|
||||||
|
bus.emit(event);
|
||||||
|
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('once', () => {
|
||||||
|
it('should fire handler only once', () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const event: DomainEvent = {
|
||||||
|
type: 'test:event',
|
||||||
|
timestamp: new Date(),
|
||||||
|
payload: { data: 'test' },
|
||||||
|
};
|
||||||
|
|
||||||
|
bus.once('test:event', handler);
|
||||||
|
bus.emit(event);
|
||||||
|
bus.emit(event);
|
||||||
|
bus.emit(event);
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('off', () => {
|
||||||
|
it('should remove handler from event subscription', () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const event: DomainEvent = {
|
||||||
|
type: 'test:event',
|
||||||
|
timestamp: new Date(),
|
||||||
|
payload: { data: 'test' },
|
||||||
|
};
|
||||||
|
|
||||||
|
bus.on('test:event', handler);
|
||||||
|
bus.emit(event);
|
||||||
|
expect(handler).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
bus.off('test:event', handler);
|
||||||
|
bus.emit(event);
|
||||||
|
expect(handler).toHaveBeenCalledOnce(); // Still only once
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('multiple handlers', () => {
|
||||||
|
it('should deliver events to all handlers for the same event type', () => {
|
||||||
|
const handler1 = vi.fn();
|
||||||
|
const handler2 = vi.fn();
|
||||||
|
const handler3 = vi.fn();
|
||||||
|
const event: DomainEvent = {
|
||||||
|
type: 'test:event',
|
||||||
|
timestamp: new Date(),
|
||||||
|
payload: { data: 'test' },
|
||||||
|
};
|
||||||
|
|
||||||
|
bus.on('test:event', handler1);
|
||||||
|
bus.on('test:event', handler2);
|
||||||
|
bus.on('test:event', handler3);
|
||||||
|
bus.emit(event);
|
||||||
|
|
||||||
|
expect(handler1).toHaveBeenCalledOnce();
|
||||||
|
expect(handler2).toHaveBeenCalledOnce();
|
||||||
|
expect(handler3).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('typed events', () => {
|
||||||
|
it('should work with ProcessSpawnedEvent', () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const event: ProcessSpawnedEvent = {
|
||||||
|
type: 'process:spawned',
|
||||||
|
timestamp: new Date(),
|
||||||
|
payload: {
|
||||||
|
processId: 'proc-1',
|
||||||
|
pid: 12345,
|
||||||
|
command: 'node server.js',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
bus.on<ProcessSpawnedEvent>('process:spawned', handler);
|
||||||
|
bus.emit(event);
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledWith(event);
|
||||||
|
const receivedEvent = handler.mock.calls[0][0] as ProcessSpawnedEvent;
|
||||||
|
expect(receivedEvent.payload.processId).toBe('proc-1');
|
||||||
|
expect(receivedEvent.payload.pid).toBe(12345);
|
||||||
|
expect(receivedEvent.payload.command).toBe('node server.js');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with ServerStartedEvent', () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const event: ServerStartedEvent = {
|
||||||
|
type: 'server:started',
|
||||||
|
timestamp: new Date(),
|
||||||
|
payload: {
|
||||||
|
port: 3847,
|
||||||
|
host: '127.0.0.1',
|
||||||
|
pid: 54321,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
bus.on<ServerStartedEvent>('server:started', handler);
|
||||||
|
bus.emit(event);
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledWith(event);
|
||||||
|
const receivedEvent = handler.mock.calls[0][0] as ServerStartedEvent;
|
||||||
|
expect(receivedEvent.payload.port).toBe(3847);
|
||||||
|
expect(receivedEvent.payload.host).toBe('127.0.0.1');
|
||||||
|
expect(receivedEvent.payload.pid).toBe(54321);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('timestamp', () => {
|
||||||
|
it('should preserve event timestamp through emit/on cycle', () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const timestamp = new Date('2026-01-30T12:00:00Z');
|
||||||
|
const event: DomainEvent = {
|
||||||
|
type: 'test:event',
|
||||||
|
timestamp,
|
||||||
|
payload: { data: 'test' },
|
||||||
|
};
|
||||||
|
|
||||||
|
bus.on('test:event', handler);
|
||||||
|
bus.emit(event);
|
||||||
|
|
||||||
|
const receivedEvent = handler.mock.calls[0][0] as DomainEvent;
|
||||||
|
expect(receivedEvent.timestamp).toBe(timestamp);
|
||||||
|
expect(receivedEvent.timestamp.toISOString()).toBe(
|
||||||
|
'2026-01-30T12:00:00.000Z'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createEventBus', () => {
|
||||||
|
it('should create an EventEmitterBus instance', () => {
|
||||||
|
const bus = createEventBus();
|
||||||
|
expect(bus).toBeInstanceOf(EventEmitterBus);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return a working EventBus', () => {
|
||||||
|
const bus = createEventBus();
|
||||||
|
const handler = vi.fn();
|
||||||
|
const event: DomainEvent = {
|
||||||
|
type: 'test:event',
|
||||||
|
timestamp: new Date(),
|
||||||
|
payload: { data: 'test' },
|
||||||
|
};
|
||||||
|
|
||||||
|
bus.on('test:event', handler);
|
||||||
|
bus.emit(event);
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledWith(event);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,18 @@
|
|||||||
// Port interface (what consumers depend on)
|
// Port interface (what consumers depend on)
|
||||||
export type { EventBus, DomainEvent } from './types.js';
|
export type { EventBus, DomainEvent } from './types.js';
|
||||||
|
|
||||||
|
// Domain event types
|
||||||
|
export type {
|
||||||
|
ProcessSpawnedEvent,
|
||||||
|
ProcessStoppedEvent,
|
||||||
|
ProcessCrashedEvent,
|
||||||
|
ServerStartedEvent,
|
||||||
|
ServerStoppedEvent,
|
||||||
|
LogEntryEvent,
|
||||||
|
DomainEventMap,
|
||||||
|
DomainEventType,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
// Adapter implementation
|
// Adapter implementation
|
||||||
export { EventEmitterBus } from './bus.js';
|
export { EventEmitterBus } from './bus.js';
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,100 @@ export interface DomainEvent {
|
|||||||
payload: unknown;
|
payload: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event Bus Port Interface
|
||||||
|
*
|
||||||
|
* All modules communicate through this interface.
|
||||||
|
* Can be swapped for external systems (RabbitMQ, WebSocket forwarding) later.
|
||||||
|
*/
|
||||||
|
// =============================================================================
|
||||||
|
// Domain Event Types - Typed payloads for each event
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process Events
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ProcessSpawnedEvent extends DomainEvent {
|
||||||
|
type: 'process:spawned';
|
||||||
|
payload: {
|
||||||
|
processId: string;
|
||||||
|
pid: number;
|
||||||
|
command: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProcessStoppedEvent extends DomainEvent {
|
||||||
|
type: 'process:stopped';
|
||||||
|
payload: {
|
||||||
|
processId: string;
|
||||||
|
pid: number;
|
||||||
|
exitCode: number | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProcessCrashedEvent extends DomainEvent {
|
||||||
|
type: 'process:crashed';
|
||||||
|
payload: {
|
||||||
|
processId: string;
|
||||||
|
pid: number;
|
||||||
|
signal: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server Events
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ServerStartedEvent extends DomainEvent {
|
||||||
|
type: 'server:started';
|
||||||
|
payload: {
|
||||||
|
port: number;
|
||||||
|
host: string;
|
||||||
|
pid: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerStoppedEvent extends DomainEvent {
|
||||||
|
type: 'server:stopped';
|
||||||
|
payload: {
|
||||||
|
uptime: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log Events
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface LogEntryEvent extends DomainEvent {
|
||||||
|
type: 'log:entry';
|
||||||
|
payload: {
|
||||||
|
processId: string;
|
||||||
|
stream: 'stdout' | 'stderr';
|
||||||
|
data: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Union of all domain events - enables type-safe event handling
|
||||||
|
*/
|
||||||
|
export type DomainEventMap =
|
||||||
|
| ProcessSpawnedEvent
|
||||||
|
| ProcessStoppedEvent
|
||||||
|
| ProcessCrashedEvent
|
||||||
|
| ServerStartedEvent
|
||||||
|
| ServerStoppedEvent
|
||||||
|
| LogEntryEvent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event type literal union for type checking
|
||||||
|
*/
|
||||||
|
export type DomainEventType = DomainEventMap['type'];
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Event Bus Port Interface
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Event Bus Port Interface
|
* Event Bus Port Interface
|
||||||
*
|
*
|
||||||
|
|||||||
46
src/trpc/context.ts
Normal file
46
src/trpc/context.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* tRPC Context
|
||||||
|
*
|
||||||
|
* Defines the context available to all tRPC procedures.
|
||||||
|
* Context is injected into each procedure call.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { EventBus, DomainEvent } from '../events/types.js';
|
||||||
|
|
||||||
|
// Re-export for convenience
|
||||||
|
export type { EventBus, DomainEvent };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context available to all tRPC procedures.
|
||||||
|
*/
|
||||||
|
export interface TRPCContext {
|
||||||
|
/** Event bus for inter-module communication */
|
||||||
|
eventBus: EventBus;
|
||||||
|
/** When the server started (null if not yet started) */
|
||||||
|
serverStartedAt: Date | null;
|
||||||
|
/** Number of managed processes */
|
||||||
|
processCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for creating the tRPC context.
|
||||||
|
*/
|
||||||
|
export interface CreateContextOptions {
|
||||||
|
eventBus: EventBus;
|
||||||
|
serverStartedAt: Date | null;
|
||||||
|
processCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the tRPC context for procedure calls.
|
||||||
|
*
|
||||||
|
* @param options - Context creation options
|
||||||
|
* @returns The tRPC context
|
||||||
|
*/
|
||||||
|
export function createContext(options: CreateContextOptions): TRPCContext {
|
||||||
|
return {
|
||||||
|
eventBus: options.eventBus,
|
||||||
|
serverStartedAt: options.serverStartedAt,
|
||||||
|
processCount: options.processCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
16
src/trpc/index.ts
Normal file
16
src/trpc/index.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* tRPC Module
|
||||||
|
*
|
||||||
|
* Type-safe RPC layer for CLI-server communication.
|
||||||
|
* Same interface for CLI and future WebUI clients.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Re-export router components
|
||||||
|
export { router, publicProcedure, middleware, createCallerFactory } from './router.js';
|
||||||
|
|
||||||
|
// Re-export context
|
||||||
|
export { createContext } from './context.js';
|
||||||
|
export type { TRPCContext, CreateContextOptions } from './context.js';
|
||||||
|
export type { EventBus, DomainEvent } from './context.js';
|
||||||
|
|
||||||
|
// AppRouter type will be exported after procedures are defined in Task 2
|
||||||
37
src/trpc/router.ts
Normal file
37
src/trpc/router.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* tRPC Router
|
||||||
|
*
|
||||||
|
* Type-safe RPC router for CLI-server communication.
|
||||||
|
* Uses Zod for runtime validation of procedure inputs/outputs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { initTRPC } from '@trpc/server';
|
||||||
|
import type { TRPCContext } from './context.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize tRPC with our context type.
|
||||||
|
* This creates the tRPC instance that all procedures will use.
|
||||||
|
*/
|
||||||
|
const t = initTRPC.context<TRPCContext>().create();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base router - used to create the app router.
|
||||||
|
*/
|
||||||
|
export const router = t.router;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public procedure - no authentication required.
|
||||||
|
* All current procedures are public since this is a local-only server.
|
||||||
|
*/
|
||||||
|
export const publicProcedure = t.procedure;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware builder for custom middleware.
|
||||||
|
*/
|
||||||
|
export const middleware = t.middleware;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create caller factory for testing.
|
||||||
|
* Allows calling procedures directly without HTTP transport.
|
||||||
|
*/
|
||||||
|
export const createCallerFactory = t.createCallerFactory;
|
||||||
Reference in New Issue
Block a user