Files
accounted/dev_docs/base_architecture/PART3_IMPLEMENTATION.md
T
Jakob Wennberg cdf1dcc4c8 New Base func
2026-02-19 09:48:02 +01:00

9.6 KiB

Part 3: Event Bus & Extension Registry — Implementation

What Was Built

An in-process event bus, extension registry with static discovery, database tables for extension data and event observability, and event emission retrofitted into all existing service and API route code paths. Backend only, no UI.

This is the foundation for all Tier 1 add-ons (receipt-ocr, ai-categorization, push-notifications, ne-bilaga, etc.). Without it, every add-on would need to be hardwired into core services.


New Files

Event Bus — lib/events/

lib/events/types.ts

Defines the CoreEvent discriminated union covering 18 event types across six domains:

Domain Events
Bookkeeping journal_entry.drafted, journal_entry.committed, journal_entry.corrected
Documents document.uploaded
Invoicing invoice.created, invoice.sent, invoice.paid, invoice.overdue, credit_note.created
Banking transaction.synced, transaction.categorized, bank.statement_received, bank.payment_notification
Periods period.locked, period.year_closed
Customers customer.created, customer.pseudonymized
Audit audit.security_event

Helper types for consuming events:

  • CoreEventType — string literal union of all event type names
  • EventPayload<T> — extracts the payload type for a given event type
  • EventHandler<T> — handler function signature for a specific event type
  • EventSubscription<T> — event type + handler pair

lib/events/bus.ts

The event bus singleton. Key design:

  • eventBus.on(eventType, handler) — subscribe, returns unsubscribe function
  • eventBus.emit(event) — runs all handlers via Promise.allSettled (a failing handler never crashes the emitter)
  • eventBus.clear() — remove all handlers (for testing)
  • Handlers run concurrently, errors logged to console
  • Module-level singleton (persists across requests in same Node.js process)

lib/events/index.ts — Barrel export.

Extension Registry — lib/extensions/

lib/extensions/types.ts

The Extension interface — the contract for all add-ons:

interface Extension {
  id: string
  name: string
  version: string

  // Surfaces
  routes?: RouteDefinition[]
  apiRoutes?: ApiRouteDefinition[]
  sidebarItems?: SidebarItem[]
  eventHandlers?: ExtensionEventHandler[]
  mappingRuleTypes?: MappingRuleTypeDefinition[]
  reportTypes?: ReportDefinition[]
  settingsPanel?: SettingsPanelDefinition
  taxCodes?: TaxCodeDefinition[]
  dimensionTypes?: DimensionDefinition[]

  // Lifecycle
  onInstall?(ctx: ExtensionContext): Promise<void>
  onUninstall?(ctx: ExtensionContext): Promise<void>
}

Supporting types: RouteDefinition, ApiRouteDefinition, SidebarItem, ReportDefinition, SettingsPanelDefinition, TaxCodeDefinition, DimensionDefinition, MappingRuleTypeDefinition, ExtensionEventHandler, ExtensionContext.

lib/extensions/registry.ts

The extensionRegistry singleton:

  • register(extension) — stores extension, wires event handlers to the bus
  • unregister(extensionId) — unhooks handlers, removes extension
  • getAll() — all registered extensions
  • get(id) — specific extension by ID
  • getByCapability(key) — extensions that have a specific surface (e.g. all extensions with reportTypes)
  • clear() — remove all (for testing)

lib/extensions/loader.ts

Static extension discovery. Next.js bundling requires explicit imports, not dynamic filesystem scanning. Contains an empty FIRST_PARTY_EXTENSIONS array — extensions are added here as they are built. loadExtensions() has an idempotency guard.

lib/extensions/index.ts — Barrel export.

Initialization — lib/init.ts

ensureInitialized() — calls loadExtensions() once. Called from API routes that emit events (at module scope, not per-request).

Example Extension — extensions/example-logger/index.ts

Minimal reference implementation that logs journal_entry.committed and document.uploaded events to console. Not wired into the loader by default — exists as a template for building real extensions.


Migration

supabase/migrations/20240101000020_extension_data.sql

Two tables:

extension_data — generic key-value store for extensions:

  • Columns: id, user_id, extension_id, key, value (jsonb), created_at, updated_at
  • UNIQUE(user_id, extension_id, key)
  • RLS: select, insert, update, delete own rows
  • Auto-update updated_at trigger

event_log — append-only event observability:

  • Columns: id, user_id, event_type, payload (jsonb), created_at
  • RLS: select + insert only (no update, no delete — append-only)
  • Indexes on (user_id, event_type) and created_at

Type Additions — types/index.ts

Placeholder types for event payloads not yet fully built:

Type Purpose
CreditNote Extends Invoice with required credited_invoice_id
CAMT053Statement Bank statement (CAMT parsing not yet implemented)
CAMT054Notification Payment notification (CAMT parsing not yet implemented)
AuditSecurityEvent Security event payload for audit events
ExtensionDataRecord Row type for the extension_data table

Retrofitted Event Emissions

The pattern is identical everywhere: import eventBus, call await eventBus.emit(...) after the successful operation. No control flow changes, no return type changes. All events include a userId field for RLS-scoped observability.

Phase A — Service Layer

File Function Event
lib/bookkeeping/engine.ts createDraftEntry() journal_entry.drafted
lib/bookkeeping/engine.ts commitEntry() journal_entry.committed
lib/bookkeeping/engine.ts createJournalEntry() journal_entry.committed
lib/bookkeeping/engine.ts reverseEntry() journal_entry.committed
lib/core/bookkeeping/storno-service.ts correctEntry() journal_entry.corrected
lib/core/documents/document-service.ts uploadDocument() document.uploaded
lib/core/bookkeeping/period-service.ts lockPeriod() period.locked
lib/core/bookkeeping/year-end-service.ts executeYearEndClosing() period.year_closed

Phase B — API Routes

File Event
app/api/invoices/route.ts (POST) invoice.created
app/api/invoices/route.ts (createCreditNote) credit_note.created
app/api/invoices/[id]/send/route.ts invoice.sent
app/api/customers/route.ts (POST) customer.created
app/api/transactions/[id]/categorize/route.ts transaction.categorized
app/api/banking/sync/route.ts transaction.synced

API routes also call ensureInitialized() at module scope to ensure extensions are loaded before events are emitted.

Deferred (Phase C)

These events are defined in the type system but not yet emitted because the underlying infrastructure doesn't exist:

Event Reason
invoice.paid Payment matching with kursdifferens not fully wired
invoice.overdue Needs cron-based detection
bank.statement_received CAMT053 parsing not implemented
bank.payment_notification CAMT054 parsing not implemented
customer.pseudonymized GDPR flow not implemented
audit.security_event Already logged at DB level; app-level TBD

Design Decisions

  1. In-process bus — architecture specifies "in-process handlers initially, add webhook dispatch when external plugin consumers exist." No message queue, no outbox pattern at the event bus level.

  2. Promise.allSettled — a failing handler never crashes the emitting service. Errors are logged to console. The emitter's control flow is never affected.

  3. Module-level singletonseventBus and extensionRegistry persist across requests in the same Node.js process. They are not per-request or per-user.

  4. Static extension imports — Next.js bundling requires explicit imports in loader.ts, not dynamic fs.readdirSync. Extensions are added to the FIRST_PARTY_EXTENSIONS array as they are built.

  5. One-way dependencylib/events/ depends on nothing except types/. Core services import from lib/events/. Extensions import from lib/core/, lib/events/, and lib/extensions/. The base never imports from extensions/.

  6. ensureInitialized() at module scope — API routes call this at the top of the file (not inside request handlers). This means extensions are loaded once when the module is first imported by Next.js, not on every request.

  7. Every event payload includes userId — enables RLS-scoped event logging and per-user extension behavior without needing to pass auth context through the bus.


How to Build an Extension

  1. Create a directory under extensions/your-extension/
  2. Export an object satisfying the Extension interface
  3. Import it in lib/extensions/loader.ts and add to FIRST_PARTY_EXTENSIONS

Example (see extensions/example-logger/index.ts):

import type { Extension } from '@/lib/extensions/types'
import type { EventPayload } from '@/lib/events/types'

export const myExtension: Extension = {
  id: 'my-extension',
  name: 'My Extension',
  version: '0.1.0',
  eventHandlers: [
    {
      eventType: 'journal_entry.committed',
      handler: async (payload: EventPayload<'journal_entry.committed'>) => {
        // Your logic here
      },
    },
  ],
}

Verification

  • npx tsc --noEmit — zero errors
  • All existing API routes unchanged in behavior — event emission is additive, never blocking
  • Extension system is fully wired but dormant (empty extension list) until extensions are added to the loader