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>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
59d935f2cc
commit
ef5a84a5d5
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createExtensionContext } from '../context-factory'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { createMockSupabase } from '@/tests/helpers'
|
||||
|
||||
vi.mock('@/lib/transactions/ingest', () => ({
|
||||
ingestTransactions: vi.fn().mockResolvedValue({
|
||||
imported: 0, duplicates: 0, reconciled: 0,
|
||||
auto_categorized: 0, auto_matched_invoices: 0, errors: 0,
|
||||
transaction_ids: [],
|
||||
}),
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
eventBus.clear()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('createExtensionContext', () => {
|
||||
it('returns context with correct userId and extensionId', () => {
|
||||
const { supabase } = createMockSupabase()
|
||||
const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext')
|
||||
|
||||
expect(ctx.userId).toBe('user-1')
|
||||
expect(ctx.extensionId).toBe('test-ext')
|
||||
})
|
||||
|
||||
it('provides supabase client', () => {
|
||||
const { supabase } = createMockSupabase()
|
||||
const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext')
|
||||
|
||||
expect(ctx.supabase).toBe(supabase)
|
||||
})
|
||||
|
||||
it('emit() delegates to eventBus.emit()', async () => {
|
||||
const { supabase } = createMockSupabase()
|
||||
const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext')
|
||||
|
||||
const handler = vi.fn()
|
||||
eventBus.on('journal_entry.committed', handler)
|
||||
|
||||
await ctx.emit({
|
||||
type: 'journal_entry.committed',
|
||||
payload: { entry: { id: 'e1' } as never, userId: 'user-1' },
|
||||
})
|
||||
|
||||
expect(handler).toHaveBeenCalledWith({ entry: { id: 'e1' }, userId: 'user-1' })
|
||||
})
|
||||
|
||||
it('log methods prefix with extensionId', () => {
|
||||
const { supabase } = createMockSupabase()
|
||||
const ctx = createExtensionContext(supabase as never, 'user-1', 'my-ext')
|
||||
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
ctx.log.info('hello', 42)
|
||||
ctx.log.warn('caution')
|
||||
ctx.log.error('oops')
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith('[my-ext]', 'hello', 42)
|
||||
expect(warnSpy).toHaveBeenCalledWith('[my-ext]', 'caution')
|
||||
expect(errorSpy).toHaveBeenCalledWith('[my-ext]', 'oops')
|
||||
|
||||
logSpy.mockRestore()
|
||||
warnSpy.mockRestore()
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('settings.get() queries extension_data table', async () => {
|
||||
const { supabase, mockResult } = createMockSupabase()
|
||||
mockResult({ data: { value: { autoOcr: true } }, error: null })
|
||||
|
||||
const ctx = createExtensionContext(supabase as never, 'user-1', 'receipt-ocr')
|
||||
const result = await ctx.settings.get<{ autoOcr: boolean }>('settings')
|
||||
|
||||
expect(result).toEqual({ autoOcr: true })
|
||||
expect(supabase.from).toHaveBeenCalledWith('extension_data')
|
||||
})
|
||||
|
||||
it('settings.get() without key defaults to "settings"', async () => {
|
||||
const { supabase, mockResult } = createMockSupabase()
|
||||
mockResult({ data: { value: { foo: 'bar' } }, error: null })
|
||||
|
||||
const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext')
|
||||
const result = await ctx.settings.get<{ foo: string }>()
|
||||
|
||||
expect(result).toEqual({ foo: 'bar' })
|
||||
})
|
||||
|
||||
it('settings.get() returns null when no data', async () => {
|
||||
const { supabase, mockResult } = createMockSupabase()
|
||||
mockResult({ data: null, error: null })
|
||||
|
||||
const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext')
|
||||
const result = await ctx.settings.get('missing-key')
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('settings.set() upserts into extension_data table', async () => {
|
||||
const { supabase, mockResult } = createMockSupabase()
|
||||
mockResult({ data: null, error: null })
|
||||
|
||||
const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext')
|
||||
await ctx.settings.set('my-key', { value: 123 })
|
||||
|
||||
expect(supabase.from).toHaveBeenCalledWith('extension_data')
|
||||
})
|
||||
|
||||
it('storage.getPublicUrl() returns URL string', () => {
|
||||
const { supabase } = createMockSupabase()
|
||||
const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext')
|
||||
|
||||
const url = ctx.storage.getPublicUrl('documents', 'path/to/file.pdf')
|
||||
expect(typeof url).toBe('string')
|
||||
})
|
||||
|
||||
it('services.ingestTransactions is a function', () => {
|
||||
const { supabase } = createMockSupabase()
|
||||
const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext')
|
||||
|
||||
expect(typeof ctx.services.ingestTransactions).toBe('function')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,14 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { extensionRegistry } from '../registry'
|
||||
import { extensionRegistry, setContextFactory } from '../registry'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import type { Extension } from '../types'
|
||||
import type { Extension, ExtensionContext } from '../types'
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn().mockResolvedValue({
|
||||
from: vi.fn(),
|
||||
storage: { from: vi.fn() },
|
||||
}),
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
extensionRegistry.clear()
|
||||
@@ -40,7 +47,8 @@ describe('ExtensionRegistry', () => {
|
||||
payload: { entry: { id: 'e1' } as never, userId: 'u1' },
|
||||
})
|
||||
|
||||
expect(handler).toHaveBeenCalledWith({ entry: { id: 'e1' }, userId: 'u1' })
|
||||
expect(handler).toHaveBeenCalled()
|
||||
expect(handler.mock.calls[0][0]).toEqual({ entry: { id: 'e1' }, userId: 'u1' })
|
||||
})
|
||||
|
||||
it('register() skips duplicate registration (same id)', () => {
|
||||
@@ -126,4 +134,28 @@ describe('ExtensionRegistry', () => {
|
||||
expect(handler1).not.toHaveBeenCalled()
|
||||
expect(handler2).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handler receives ExtensionContext when context factory is set', async () => {
|
||||
const handler = vi.fn()
|
||||
const mockCtx = { extensionId: 'ctx-ext', userId: 'u1' } as ExtensionContext
|
||||
|
||||
setContextFactory((_supabase, _userId, _extId) => mockCtx)
|
||||
|
||||
const ext = makeExtension({
|
||||
id: 'ctx-ext',
|
||||
eventHandlers: [{ eventType: 'journal_entry.committed', handler }],
|
||||
})
|
||||
|
||||
extensionRegistry.register(ext)
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'journal_entry.committed',
|
||||
payload: { entry: { id: 'e1' } as never, userId: 'u1' },
|
||||
})
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
{ entry: { id: 'e1' }, userId: 'u1' },
|
||||
mockCtx
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { CoreEvent } from '@/lib/events/types'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { ingestTransactions } from '@/lib/transactions/ingest'
|
||||
import type {
|
||||
ExtensionContext,
|
||||
ExtensionLogger,
|
||||
ExtensionSettings,
|
||||
ExtensionStorage,
|
||||
ExtensionServices,
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
* Create a prefixed logger for an extension.
|
||||
*/
|
||||
function createLogger(extensionId: string): ExtensionLogger {
|
||||
const prefix = `[${extensionId}]`
|
||||
return {
|
||||
info: (message: string, ...args: unknown[]) => console.log(prefix, message, ...args),
|
||||
warn: (message: string, ...args: unknown[]) => console.warn(prefix, message, ...args),
|
||||
error: (message: string, ...args: unknown[]) => console.error(prefix, message, ...args),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a settings accessor scoped to a specific extension.
|
||||
*/
|
||||
function createSettings(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
extensionId: string
|
||||
): ExtensionSettings {
|
||||
return {
|
||||
async get<T>(key?: string): Promise<T | null> {
|
||||
const lookupKey = key ?? 'settings'
|
||||
const { data } = await supabase
|
||||
.from('extension_data')
|
||||
.select('value')
|
||||
.eq('user_id', userId)
|
||||
.eq('extension_id', extensionId)
|
||||
.eq('key', lookupKey)
|
||||
.single()
|
||||
|
||||
return (data?.value as T) ?? null
|
||||
},
|
||||
|
||||
async set<T>(key: string, value: T): Promise<void> {
|
||||
await supabase
|
||||
.from('extension_data')
|
||||
.upsert(
|
||||
{
|
||||
user_id: userId,
|
||||
extension_id: extensionId,
|
||||
key,
|
||||
value,
|
||||
},
|
||||
{ onConflict: 'user_id,extension_id,key' }
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a storage accessor wrapping Supabase storage.
|
||||
*/
|
||||
function createStorage(supabase: SupabaseClient): ExtensionStorage {
|
||||
return {
|
||||
async download(bucket: string, path: string) {
|
||||
const { data, error } = await supabase.storage
|
||||
.from(bucket)
|
||||
.download(path)
|
||||
return { data, error: error?.message }
|
||||
},
|
||||
|
||||
async upload(bucket: string, path: string, data: ArrayBuffer, options?: { contentType?: string }) {
|
||||
const { error } = await supabase.storage
|
||||
.from(bucket)
|
||||
.upload(path, data, options ? { contentType: options.contentType } : undefined)
|
||||
if (error) return { path: '', error: error.message }
|
||||
return { path }
|
||||
},
|
||||
|
||||
getPublicUrl(bucket: string, path: string): string {
|
||||
const { data } = supabase.storage
|
||||
.from(bucket)
|
||||
.getPublicUrl(path)
|
||||
return data.publicUrl
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create core services exposed to extensions.
|
||||
*/
|
||||
function createServices(): ExtensionServices {
|
||||
return {
|
||||
ingestTransactions,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fully populated ExtensionContext.
|
||||
*
|
||||
* The context gives extensions access to Supabase, event emission, settings,
|
||||
* storage, logging, and core services — without importing from core modules.
|
||||
*/
|
||||
export function createExtensionContext(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
extensionId: string
|
||||
): ExtensionContext {
|
||||
return {
|
||||
userId,
|
||||
extensionId,
|
||||
supabase,
|
||||
emit: (event: CoreEvent) => eventBus.emit(event),
|
||||
settings: createSettings(supabase, userId, extensionId),
|
||||
storage: createStorage(supabase),
|
||||
log: createLogger(extensionId),
|
||||
services: createServices(),
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,25 @@
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import type { CoreEventType } from '@/lib/events/types'
|
||||
import type { Extension } from './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.
|
||||
@@ -15,6 +34,10 @@ class ExtensionRegistry {
|
||||
|
||||
/**
|
||||
* 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)) {
|
||||
@@ -28,8 +51,24 @@ class ExtensionRegistry {
|
||||
const unsubs: (() => void)[] = []
|
||||
if (extension.eventHandlers) {
|
||||
for (const { eventType, handler } of extension.eventHandlers) {
|
||||
// Cast is safe: the handler is stored by eventType key, so it only receives matching payloads
|
||||
const unsub = eventBus.on(eventType as CoreEventType, handler)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
+37
-5
@@ -1,5 +1,6 @@
|
||||
import type { CoreEventType } from '@/lib/events/types'
|
||||
import type { EntityType } from '@/types'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { CoreEvent, CoreEventType } from '@/lib/events/types'
|
||||
import type { EntityType, RawTransaction, IngestResult } from '@/types'
|
||||
|
||||
// ============================================================
|
||||
// Extension Marketplace Types
|
||||
@@ -63,7 +64,7 @@ export interface RouteDefinition {
|
||||
export interface ApiRouteDefinition {
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
|
||||
path: string
|
||||
handler: (request: Request) => Promise<Response>
|
||||
handler: (request: Request, ctx?: ExtensionContext) => Promise<Response>
|
||||
}
|
||||
|
||||
/** Sidebar navigation item added by an extension */
|
||||
@@ -112,13 +113,44 @@ export interface MappingRuleTypeDefinition {
|
||||
export interface ExtensionEventHandler {
|
||||
eventType: CoreEventType
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
handler: (payload: any) => Promise<void> | void
|
||||
handler: (payload: any, ctx?: ExtensionContext) => Promise<void> | void
|
||||
}
|
||||
|
||||
/** Context passed to extension lifecycle hooks */
|
||||
/** Logger interface for extensions */
|
||||
export interface ExtensionLogger {
|
||||
info(message: string, ...args: unknown[]): void
|
||||
warn(message: string, ...args: unknown[]): void
|
||||
error(message: string, ...args: unknown[]): void
|
||||
}
|
||||
|
||||
/** Settings accessor for extension-scoped key-value data */
|
||||
export interface ExtensionSettings {
|
||||
get<T>(key?: string): Promise<T | null>
|
||||
set<T>(key: string, value: T): Promise<void>
|
||||
}
|
||||
|
||||
/** Storage accessor wrapping Supabase storage */
|
||||
export interface ExtensionStorage {
|
||||
download(bucket: string, path: string): Promise<{ data: Blob | null; error?: string }>
|
||||
upload(bucket: string, path: string, data: ArrayBuffer, options?: { contentType?: string }): Promise<{ path: string; error?: string }>
|
||||
getPublicUrl(bucket: string, path: string): string
|
||||
}
|
||||
|
||||
/** Core services exposed to extensions */
|
||||
export interface ExtensionServices {
|
||||
ingestTransactions(supabase: SupabaseClient, userId: string, raw: RawTransaction[]): Promise<IngestResult>
|
||||
}
|
||||
|
||||
/** Context passed to extension lifecycle hooks and event handlers */
|
||||
export interface ExtensionContext {
|
||||
userId: string
|
||||
extensionId: string
|
||||
supabase: SupabaseClient
|
||||
emit(event: CoreEvent): Promise<void>
|
||||
settings: ExtensionSettings
|
||||
storage: ExtensionStorage
|
||||
log: ExtensionLogger
|
||||
services: ExtensionServices
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user