New Base func
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { eventBus } from '../bus'
|
||||
import type { JournalEntry } from '@/types'
|
||||
|
||||
const fakeEntry = { id: 'e1' } as JournalEntry
|
||||
|
||||
beforeEach(() => {
|
||||
eventBus.clear()
|
||||
})
|
||||
|
||||
describe('EventBus', () => {
|
||||
it('on() subscribes a handler and returns an unsubscribe function', () => {
|
||||
const handler = vi.fn()
|
||||
const unsub = eventBus.on('journal_entry.drafted', handler)
|
||||
|
||||
expect(typeof unsub).toBe('function')
|
||||
})
|
||||
|
||||
it('emit() calls all handlers for that event type', async () => {
|
||||
const handler1 = vi.fn()
|
||||
const handler2 = vi.fn()
|
||||
|
||||
eventBus.on('journal_entry.committed', handler1)
|
||||
eventBus.on('journal_entry.committed', handler2)
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'journal_entry.committed',
|
||||
payload: { entry: fakeEntry, userId: 'u1' },
|
||||
})
|
||||
|
||||
expect(handler1).toHaveBeenCalledWith({ entry: fakeEntry, userId: 'u1' })
|
||||
expect(handler2).toHaveBeenCalledWith({ entry: fakeEntry, userId: 'u1' })
|
||||
})
|
||||
|
||||
it('emit() uses Promise.allSettled — a failing handler does not crash others', async () => {
|
||||
const failingHandler = vi.fn().mockRejectedValue(new Error('boom'))
|
||||
const goodHandler = vi.fn()
|
||||
|
||||
eventBus.on('journal_entry.committed', failingHandler)
|
||||
eventBus.on('journal_entry.committed', goodHandler)
|
||||
|
||||
// Should not throw
|
||||
await eventBus.emit({
|
||||
type: 'journal_entry.committed',
|
||||
payload: { entry: fakeEntry, userId: 'u1' },
|
||||
})
|
||||
|
||||
expect(failingHandler).toHaveBeenCalled()
|
||||
expect(goodHandler).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('emit() with no handlers is a no-op', async () => {
|
||||
// Should not throw
|
||||
await eventBus.emit({
|
||||
type: 'journal_entry.drafted',
|
||||
payload: { entry: fakeEntry, userId: 'u1' },
|
||||
})
|
||||
})
|
||||
|
||||
it('unsubscribe removes the handler, future emits do not call it', async () => {
|
||||
const handler = vi.fn()
|
||||
const unsub = eventBus.on('journal_entry.committed', handler)
|
||||
|
||||
unsub()
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'journal_entry.committed',
|
||||
payload: { entry: fakeEntry, userId: 'u1' },
|
||||
})
|
||||
|
||||
expect(handler).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clear() removes all handlers', async () => {
|
||||
const handler1 = vi.fn()
|
||||
const handler2 = vi.fn()
|
||||
|
||||
eventBus.on('journal_entry.committed', handler1)
|
||||
eventBus.on('journal_entry.drafted', handler2)
|
||||
|
||||
eventBus.clear()
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'journal_entry.committed',
|
||||
payload: { entry: fakeEntry, userId: 'u1' },
|
||||
})
|
||||
await eventBus.emit({
|
||||
type: 'journal_entry.drafted',
|
||||
payload: { entry: fakeEntry, userId: 'u1' },
|
||||
})
|
||||
|
||||
expect(handler1).not.toHaveBeenCalled()
|
||||
expect(handler2).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handlers for different event types do not interfere', async () => {
|
||||
const committedHandler = vi.fn()
|
||||
const draftedHandler = vi.fn()
|
||||
|
||||
eventBus.on('journal_entry.committed', committedHandler)
|
||||
eventBus.on('journal_entry.drafted', draftedHandler)
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'journal_entry.committed',
|
||||
payload: { entry: fakeEntry, userId: 'u1' },
|
||||
})
|
||||
|
||||
expect(committedHandler).toHaveBeenCalledOnce()
|
||||
expect(draftedHandler).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { CoreEvent, CoreEventType, EventHandler } from './types'
|
||||
|
||||
// Internal handler type — loose enough for the Map, but type-safe at the public API
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type AnyHandler = (payload: any) => Promise<void> | void
|
||||
|
||||
/**
|
||||
* In-process event bus.
|
||||
*
|
||||
* - Handlers run concurrently via Promise.allSettled (failing handler never crashes emitter)
|
||||
* - Module-level singleton (persists across requests in same process)
|
||||
* - One-way: core services emit, extensions subscribe
|
||||
*/
|
||||
class EventBus {
|
||||
private handlers = new Map<string, Set<AnyHandler>>()
|
||||
|
||||
/**
|
||||
* Subscribe to an event type.
|
||||
* Returns an unsubscribe function.
|
||||
*/
|
||||
on<T extends CoreEventType>(
|
||||
eventType: T,
|
||||
handler: EventHandler<T>
|
||||
): () => void {
|
||||
if (!this.handlers.has(eventType)) {
|
||||
this.handlers.set(eventType, new Set())
|
||||
}
|
||||
|
||||
const handlerSet = this.handlers.get(eventType)!
|
||||
handlerSet.add(handler as AnyHandler)
|
||||
|
||||
return () => {
|
||||
handlerSet.delete(handler as AnyHandler)
|
||||
if (handlerSet.size === 0) {
|
||||
this.handlers.delete(eventType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event to all registered handlers.
|
||||
* Uses Promise.allSettled so a failing handler never crashes the emitter.
|
||||
*/
|
||||
async emit(event: CoreEvent): Promise<void> {
|
||||
const handlerSet = this.handlers.get(event.type)
|
||||
if (!handlerSet || handlerSet.size === 0) return
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
[...handlerSet].map((handler) => handler(event.payload))
|
||||
)
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') {
|
||||
console.error(
|
||||
`[EventBus] Handler failed for "${event.type}":`,
|
||||
result.reason
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all handlers (useful for testing).
|
||||
*/
|
||||
clear(): void {
|
||||
this.handlers.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/** Module-level singleton */
|
||||
export const eventBus = new EventBus()
|
||||
@@ -0,0 +1,8 @@
|
||||
export { eventBus } from './bus'
|
||||
export type {
|
||||
CoreEvent,
|
||||
CoreEventType,
|
||||
EventPayload,
|
||||
EventHandler,
|
||||
EventSubscription,
|
||||
} from './types'
|
||||
@@ -0,0 +1,83 @@
|
||||
import type {
|
||||
JournalEntry,
|
||||
Invoice,
|
||||
Transaction,
|
||||
Customer,
|
||||
FiscalPeriod,
|
||||
DocumentAttachment,
|
||||
Receipt,
|
||||
CreditNote,
|
||||
CAMT053Statement,
|
||||
CAMT054Notification,
|
||||
AuditSecurityEvent,
|
||||
} from '@/types'
|
||||
|
||||
// ============================================================
|
||||
// Core Event Types — discriminated union of all system events
|
||||
// ============================================================
|
||||
|
||||
export type CoreEvent =
|
||||
// Bookkeeping
|
||||
| { type: 'journal_entry.drafted'; payload: { entry: JournalEntry; userId: string } }
|
||||
| { type: 'journal_entry.committed'; payload: { entry: JournalEntry; userId: string } }
|
||||
| { type: 'journal_entry.corrected'; payload: { original: JournalEntry; storno: JournalEntry; corrected: JournalEntry; userId: string } }
|
||||
// Documents
|
||||
| { type: 'document.uploaded'; payload: { document: DocumentAttachment; userId: string } }
|
||||
// Invoicing
|
||||
| { type: 'invoice.created'; payload: { invoice: Invoice; userId: string } }
|
||||
| { type: 'invoice.sent'; payload: { invoice: Invoice; userId: string } }
|
||||
| { type: 'invoice.paid'; payload: { invoice: Invoice; transaction: Transaction; kursdifferens?: number; userId: string } }
|
||||
| { type: 'invoice.overdue'; payload: { invoice: Invoice; days: number; userId: string } }
|
||||
| { type: 'credit_note.created'; payload: { creditNote: CreditNote; userId: string } }
|
||||
// Banking
|
||||
| { type: 'transaction.synced'; payload: { transactions: Transaction[]; userId: string } }
|
||||
| { type: 'transaction.categorized'; payload: { transaction: Transaction; account: string; taxCode: string; userId: string } }
|
||||
| { type: 'bank.statement_received'; payload: { statement: CAMT053Statement; userId: string } }
|
||||
| { type: 'bank.payment_notification'; payload: { notification: CAMT054Notification; userId: string } }
|
||||
// Periods
|
||||
| { type: 'period.locked'; payload: { period: FiscalPeriod; userId: string } }
|
||||
| { type: 'period.year_closed'; payload: { period: FiscalPeriod; userId: string } }
|
||||
// Customers
|
||||
| { type: 'customer.created'; payload: { customer: Customer; userId: string } }
|
||||
| { type: 'customer.pseudonymized'; payload: { customerId: string; userId: string } }
|
||||
// Receipts
|
||||
| { type: 'receipt.extracted'; payload: {
|
||||
receipt: Receipt;
|
||||
documentId: string | null;
|
||||
confidence: number;
|
||||
userId: string;
|
||||
}}
|
||||
| { type: 'receipt.matched'; payload: {
|
||||
receipt: Receipt;
|
||||
transaction: Transaction;
|
||||
confidence: number;
|
||||
autoMatched: boolean;
|
||||
userId: string;
|
||||
}}
|
||||
| { type: 'receipt.confirmed'; payload: {
|
||||
receipt: Receipt;
|
||||
businessTotal: number;
|
||||
privateTotal: number;
|
||||
userId: string;
|
||||
}}
|
||||
// Audit
|
||||
| { type: 'audit.security_event'; payload: { event: AuditSecurityEvent; userId: string } }
|
||||
|
||||
// ============================================================
|
||||
// Helper Types
|
||||
// ============================================================
|
||||
|
||||
/** All possible event type strings */
|
||||
export type CoreEventType = CoreEvent['type']
|
||||
|
||||
/** Extract the payload type for a given event type */
|
||||
export type EventPayload<T extends CoreEventType> = Extract<CoreEvent, { type: T }>['payload']
|
||||
|
||||
/** Handler function for a specific event type */
|
||||
export type EventHandler<T extends CoreEventType> = (payload: EventPayload<T>) => Promise<void> | void
|
||||
|
||||
/** Subscription: event type + handler */
|
||||
export interface EventSubscription<T extends CoreEventType = CoreEventType> {
|
||||
eventType: T
|
||||
handler: EventHandler<T>
|
||||
}
|
||||
Reference in New Issue
Block a user