New Base func

This commit is contained in:
Jakob Wennberg
2026-02-19 09:48:02 +01:00
parent afc9f69def
commit cdf1dcc4c8
73 changed files with 11036 additions and 51 deletions
+111
View File
@@ -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()
})
})
+71
View File
@@ -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()
+8
View File
@@ -0,0 +1,8 @@
export { eventBus } from './bus'
export type {
CoreEvent,
CoreEventType,
EventPayload,
EventHandler,
EventSubscription,
} from './types'
+83
View File
@@ -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>
}