Files
accounted/lib/extensions/registry.ts
T
Jakob WennbergandClaude Opus 4.6 ef5a84a5d5 feat: make extension system packageable via enriched ExtensionContext
Enrich ExtensionContext with supabase, emit(), settings, storage, log,
and services so extensions can receive everything through dependency
injection instead of importing core modules directly.

- Add context factory and inject context into event handlers via registry
- Move supplier invoice journal entry creation to core event handler
- Add services.ingestTransactions to ExtensionContext for enable-banking
- Create catch-all API route for extension-declared apiRoutes
- Migrate 5 extensions to accept context with dynamic import fallbacks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 09:24:21 +01:00

119 lines
3.9 KiB
TypeScript

import { eventBus } from '@/lib/events/bus'
import type { CoreEventType } from '@/lib/events/types'
import type { Extension, ExtensionContext } from './types'
import type { SupabaseClient } from '@supabase/supabase-js'
/** Factory function type for creating extension contexts */
export type ContextFactory = (
supabase: SupabaseClient,
userId: string,
extensionId: string
) => ExtensionContext
/** Lazy-loaded context factory — set during initialization */
let contextFactory: ContextFactory | null = null
/**
* Set the context factory used to build ExtensionContext for event handlers.
* Called once during system initialization.
*/
export function setContextFactory(factory: ContextFactory): void {
contextFactory = factory
}
/**
* Extension Registry — singleton that manages extension lifecycle.
*
* - register() stores extension and wires event handlers to the bus
* - unregister() unhooks handlers and removes extension
* - getAll(), get(), getByCapability() for querying
*/
class ExtensionRegistry {
private extensions = new Map<string, Extension>()
private unsubscribers = new Map<string, (() => void)[]>()
/**
* Register an extension: store it and wire its event handlers to the bus.
*
* Each handler is wrapped so it receives `(payload, ctx)`. The context is
* built lazily when the event fires, using the userId from the payload and
* a Supabase client created from the current request cookies.
*/
register(extension: Extension): void {
if (this.extensions.has(extension.id)) {
console.warn(`[ExtensionRegistry] Extension "${extension.id}" already registered, skipping`)
return
}
this.extensions.set(extension.id, extension)
// Wire event handlers to the bus
const unsubs: (() => void)[] = []
if (extension.eventHandlers) {
for (const { eventType, handler } of extension.eventHandlers) {
// Wrap handler to inject ExtensionContext as second argument
const wrappedHandler = async (payload: { userId: string; [key: string]: unknown }) => {
let ctx: ExtensionContext | undefined
if (contextFactory && payload.userId) {
try {
// Dynamic import to avoid circular deps at module load time
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
ctx = contextFactory(supabase, payload.userId, extension.id)
} catch {
// Context creation failed (e.g. no request cookies in cron jobs).
// Handler still gets called — ctx will be undefined.
}
}
return handler(payload, ctx)
}
const unsub = eventBus.on(eventType as CoreEventType, wrappedHandler)
unsubs.push(unsub)
}
}
this.unsubscribers.set(extension.id, unsubs)
}
/**
* Unregister an extension: unhook all event handlers and remove.
*/
unregister(extensionId: string): void {
const unsubs = this.unsubscribers.get(extensionId)
if (unsubs) {
for (const unsub of unsubs) {
unsub()
}
this.unsubscribers.delete(extensionId)
}
this.extensions.delete(extensionId)
}
/** Get all registered extensions. */
getAll(): Extension[] {
return [...this.extensions.values()]
}
/** Get a specific extension by ID. */
get(id: string): Extension | undefined {
return this.extensions.get(id)
}
/** Get all extensions that have a specific capability. */
getByCapability(key: keyof Extension): Extension[] {
return [...this.extensions.values()].filter(
(ext) => ext[key] !== undefined && ext[key] !== null
)
}
/** Clear all extensions (useful for testing). */
clear(): void {
for (const id of this.extensions.keys()) {
this.unregister(id)
}
}
}
/** Module-level singleton */
export const extensionRegistry = new ExtensionRegistry()