feat: Integration Hub for external tool connectivity

- Add native config option: `new Brainy({ integrations: true })`
- OData integration for Excel Power Query and Power BI
- Google Sheets integration with Apps Script
- Server-Sent Events (SSE) for real-time streaming
- Webhooks for push notifications
- Zero-config with sensible defaults
- Full tree-shaking when disabled
This commit is contained in:
David Snelling 2026-01-20 16:21:11 -08:00
parent 24039e8a1a
commit b5bc9000cf
28 changed files with 8186 additions and 1 deletions

View file

@ -0,0 +1,313 @@
/**
* Integration Hub - Event Bus
*
* Central event emitter for real-time change propagation.
* Enables integrations to react to Brainy data changes.
*/
import {
BrainyEvent,
EventFilter,
EventHandler,
EventSubscription
} from './types.js'
import { Entity, Relation } from '../../types/brainy.types.js'
import { NounType, VerbType } from '../../types/graphTypes.js'
/**
* Central event bus for real-time Brainy events
*
* Features:
* - Pub/sub pattern for event distribution
* - Filtering by entity type, operation, noun/verb types
* - Sequence IDs for ordering and resumption
* - Optional event buffering for batch processing
* - Memory-efficient circular buffer for replay
*
* @example
* ```typescript
* const eventBus = new EventBus()
*
* // Subscribe to all noun creates
* eventBus.subscribe(
* { entityTypes: ['noun'], operations: ['create'] },
* (event) => console.log('New entity:', event.entityId)
* )
*
* // Emit event
* eventBus.emit({
* entityType: 'noun',
* operation: 'create',
* entityId: 'entity-123',
* nounType: NounType.Person
* })
* ```
*/
export class EventBus {
private subscriptions: Map<
string,
{ filter: EventFilter; handler: EventHandler }
> = new Map()
private sequenceCounter: bigint = 0n
private eventBuffer: BrainyEvent[] = []
private bufferSize: number
private subscriptionIdCounter = 0
/**
* Create a new EventBus
*
* @param options Configuration options
* @param options.bufferSize Size of replay buffer (default: 1000)
*/
constructor(options: { bufferSize?: number } = {}) {
this.bufferSize = options.bufferSize ?? 1000
}
/**
* Subscribe to events matching a filter
*
* @param filter Event filter criteria
* @param handler Function to call when matching events occur
* @returns Subscription that can be used to unsubscribe
*/
subscribe(filter: EventFilter, handler: EventHandler): EventSubscription {
const id = `sub-${++this.subscriptionIdCounter}`
this.subscriptions.set(id, { filter, handler })
// If filter has 'since', replay buffered events
if (filter.since !== undefined) {
this.replayEvents(filter, handler)
}
return {
id,
unsubscribe: () => {
this.subscriptions.delete(id)
}
}
}
/**
* Emit an event to all matching subscribers
*
* @param partialEvent Event data (id, timestamp, sequenceId auto-generated)
*/
emit(
partialEvent: Omit<BrainyEvent, 'id' | 'timestamp' | 'sequenceId'>
): BrainyEvent {
const event: BrainyEvent = {
...partialEvent,
id: this.generateEventId(),
timestamp: Date.now(),
sequenceId: ++this.sequenceCounter
}
// Add to buffer
this.addToBuffer(event)
// Dispatch to matching subscribers
this.dispatch(event)
return event
}
/**
* Emit a noun event
*/
emitNoun(
operation: 'create' | 'update' | 'delete',
entityId: string,
nounType: NounType,
options?: { service?: string; data?: Entity }
): BrainyEvent {
return this.emit({
entityType: 'noun',
operation,
entityId,
nounType,
service: options?.service,
data: options?.data
})
}
/**
* Emit a verb/relation event
*/
emitVerb(
operation: 'create' | 'update' | 'delete',
entityId: string,
verbType: VerbType,
options?: { service?: string; data?: Relation }
): BrainyEvent {
return this.emit({
entityType: 'verb',
operation,
entityId,
verbType,
service: options?.service,
data: options?.data
})
}
/**
* Emit a VFS event
*/
emitVFS(
operation: 'create' | 'update' | 'delete',
entityId: string,
options?: { service?: string; data?: Entity }
): BrainyEvent {
return this.emit({
entityType: 'vfs',
operation,
entityId,
service: options?.service,
data: options?.data
})
}
/**
* Get current sequence ID for resumption
*/
getCurrentSequenceId(): bigint {
return this.sequenceCounter
}
/**
* Get events since a sequence ID (from buffer)
*/
getEventsSince(sequenceId: bigint): BrainyEvent[] {
return this.eventBuffer.filter((event) => event.sequenceId > sequenceId)
}
/**
* Get subscription count
*/
getSubscriptionCount(): number {
return this.subscriptions.size
}
/**
* Clear all subscriptions
*/
clear(): void {
this.subscriptions.clear()
this.eventBuffer = []
this.sequenceCounter = 0n
}
/**
* Check if an event matches a filter
*/
private matchesFilter(event: BrainyEvent, filter: EventFilter): boolean {
// Check entity types
if (
filter.entityTypes &&
filter.entityTypes.length > 0 &&
!filter.entityTypes.includes(event.entityType)
) {
return false
}
// Check operations
if (
filter.operations &&
filter.operations.length > 0 &&
!filter.operations.includes(event.operation)
) {
return false
}
// Check noun types
if (
filter.nounTypes &&
filter.nounTypes.length > 0 &&
event.nounType &&
!filter.nounTypes.includes(event.nounType)
) {
return false
}
// Check verb types
if (
filter.verbTypes &&
filter.verbTypes.length > 0 &&
event.verbType &&
!filter.verbTypes.includes(event.verbType)
) {
return false
}
// Check service
if (filter.service && event.service !== filter.service) {
return false
}
// Check sequence ID
if (filter.since !== undefined && event.sequenceId <= filter.since) {
return false
}
return true
}
/**
* Dispatch event to matching subscribers
*/
private async dispatch(event: BrainyEvent): Promise<void> {
const promises: Promise<void>[] = []
for (const [_, subscription] of this.subscriptions) {
if (this.matchesFilter(event, subscription.filter)) {
const result = subscription.handler(event)
if (result instanceof Promise) {
promises.push(result)
}
}
}
// Wait for all async handlers (fire and forget for sync handlers)
if (promises.length > 0) {
await Promise.allSettled(promises)
}
}
/**
* Replay buffered events to a new subscriber
*/
private async replayEvents(
filter: EventFilter,
handler: EventHandler
): Promise<void> {
const eventsToReplay = this.eventBuffer.filter((event) =>
this.matchesFilter(event, filter)
)
for (const event of eventsToReplay) {
const result = handler(event)
if (result instanceof Promise) {
await result
}
}
}
/**
* Add event to circular buffer
*/
private addToBuffer(event: BrainyEvent): void {
this.eventBuffer.push(event)
// Maintain buffer size
while (this.eventBuffer.length > this.bufferSize) {
this.eventBuffer.shift()
}
}
/**
* Generate unique event ID
*/
private generateEventId(): string {
return `evt-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`
}
}

View file

@ -0,0 +1,425 @@
/**
* Integration Hub - Integration Base Class
*
* Base class for all integration augmentations. Provides common functionality
* for event subscriptions, tabular export, and lifecycle management.
*/
import {
BaseAugmentation,
AugmentationContext
} from '../../augmentations/brainyAugmentation.js'
import { AugmentationManifest } from '../../augmentations/manifest.js'
import { EventBus } from './EventBus.js'
import { TabularExporter } from './TabularExporter.js'
import {
EventFilter,
EventHandler,
EventSubscription,
IntegrationConfig,
IntegrationHealthStatus,
TabularExporterConfig
} from './types.js'
import { Entity, Relation, FindParams } from '../../types/brainy.types.js'
/**
* Base class for all integration augmentations
*
* Provides:
* - Shared EventBus for real-time updates
* - TabularExporter for entity-to-row conversion
* - Common lifecycle methods (start/stop)
* - Health monitoring
* - Standard configuration patterns
*
* @example
* ```typescript
* class MySQLIntegration extends IntegrationBase {
* readonly name = 'sql'
* readonly category = 'integration'
*
* protected async onStart(): Promise<void> {
* // Start SQL server
* }
*
* protected async onStop(): Promise<void> {
* // Stop SQL server
* }
* }
* ```
*/
export abstract class IntegrationBase extends BaseAugmentation {
// Augmentation interface implementation
readonly timing = 'after' as const
readonly metadata = 'readonly' as const
readonly operations: ('all')[] = ['all']
readonly priority = 5 // Low priority - runs after main operations
category: 'internal' | 'core' | 'premium' | 'community' | 'external' = 'core'
// Shared infrastructure
protected eventBus: EventBus
protected exporter: TabularExporter
// Integration state
protected isRunning = false
protected startedAt?: number
protected requestCount = 0
protected errorCount = 0
protected lastError?: string
// Event subscriptions managed by this integration
private managedSubscriptions: EventSubscription[] = []
/**
* Create a new integration
*
* @param config Integration configuration
* @param exporterConfig Optional TabularExporter configuration
*/
constructor(
config?: IntegrationConfig,
exporterConfig?: TabularExporterConfig
) {
super(config)
this.eventBus = new EventBus()
this.exporter = new TabularExporter(exporterConfig)
}
/**
* Integration name (must be unique)
*/
abstract readonly name: string
/**
* Start the integration (implement in subclass)
*/
protected abstract onStart(): Promise<void>
/**
* Stop the integration (implement in subclass)
*/
protected abstract onStop(): Promise<void>
// BaseAugmentation lifecycle integration
protected async onInitialize(): Promise<void> {
// Auto-start if enabled
if (this.config.enabled !== false) {
await this.start()
}
}
protected async onShutdown(): Promise<void> {
await this.stop()
}
/**
* Execute augmentation (intercept operations to emit events)
*/
async execute<T>(
operation: string,
params: any,
next: () => Promise<T>
): Promise<T> {
// Execute main operation first
const result = await next()
// Emit events for data-changing operations
if (this.isRunning) {
this.emitOperationEvent(operation, params, result)
}
return result
}
// Public API
/**
* Start the integration
*/
async start(): Promise<void> {
if (this.isRunning) {
return
}
this.log(`Starting ${this.name} integration...`)
try {
await this.onStart()
this.isRunning = true
this.startedAt = Date.now()
this.log(`${this.name} integration started`)
} catch (error: any) {
this.lastError = error.message
this.log(`Failed to start ${this.name}: ${error.message}`, 'error')
throw error
}
}
/**
* Stop the integration
*/
async stop(): Promise<void> {
if (!this.isRunning) {
return
}
this.log(`Stopping ${this.name} integration...`)
try {
// Unsubscribe all managed subscriptions
for (const sub of this.managedSubscriptions) {
sub.unsubscribe()
}
this.managedSubscriptions = []
await this.onStop()
this.isRunning = false
this.log(`${this.name} integration stopped`)
} catch (error: any) {
this.lastError = error.message
this.log(`Error stopping ${this.name}: ${error.message}`, 'error')
throw error
}
}
/**
* Check if integration is running
*/
running(): boolean {
return this.isRunning
}
/**
* Get health status
*/
health(): IntegrationHealthStatus {
return {
name: this.name,
status: this.isRunning
? this.errorCount > 10
? 'degraded'
: 'healthy'
: 'stopped',
message: this.isRunning ? 'Running' : 'Stopped',
uptimeMs: this.startedAt ? Date.now() - this.startedAt : undefined,
requestCount: this.requestCount,
errorCount: this.errorCount,
lastError: this.lastError,
checkedAt: Date.now()
}
}
/**
* Get the shared EventBus
*/
getEventBus(): EventBus {
return this.eventBus
}
/**
* Get the TabularExporter
*/
getExporter(): TabularExporter {
return this.exporter
}
// Protected helpers for subclasses
/**
* Subscribe to Brainy events (auto-unsubscribed on stop)
*/
protected subscribeToChanges(
filter: EventFilter,
handler: EventHandler
): EventSubscription {
const subscription = this.eventBus.subscribe(filter, handler)
this.managedSubscriptions.push(subscription)
return subscription
}
/**
* Query entities using Brainy find()
*/
protected async queryEntities(params: FindParams): Promise<Entity[]> {
if (!this.context) {
throw new Error('Integration not initialized')
}
const results = await this.context.brain.find(params)
return results.map((r: any) => r.entity)
}
/**
* Query relations using Brainy getRelations()
*/
protected async queryRelations(params?: {
from?: string
to?: string
type?: any
limit?: number
offset?: number
}): Promise<Relation[]> {
if (!this.context) {
throw new Error('Integration not initialized')
}
return this.context.brain.getRelations(params)
}
/**
* Get a single entity by ID
*/
protected async getEntity(id: string): Promise<Entity | null> {
if (!this.context) {
throw new Error('Integration not initialized')
}
return this.context.brain.get(id)
}
/**
* Export entities to tabular format
*/
protected exportEntities(entities: Entity[]): any[] {
return this.exporter.entitiesToRows(entities)
}
/**
* Export relations to tabular format
*/
protected exportRelations(relations: Relation[]): any[] {
return this.exporter.relationsToRows(relations)
}
/**
* Record a successful request
*/
protected recordRequest(): void {
this.requestCount++
}
/**
* Record an error
*/
protected recordError(error: Error | string): void {
this.errorCount++
this.lastError = typeof error === 'string' ? error : error.message
}
/**
* Get manifest for this integration
* Subclasses should override this
*/
getManifest(): AugmentationManifest {
return {
id: this.name,
name: this.name,
version: '1.0.0',
description: `${this.name} integration`,
category: 'integration',
status: 'stable',
configSchema: {
type: 'object',
properties: {
enabled: {
type: 'boolean',
default: true,
description: 'Whether the integration is enabled'
}
}
},
configDefaults: {
enabled: true
}
}
}
// Private helpers
/**
* Emit events based on operation type
*/
private emitOperationEvent(operation: string, params: any, result: any): void {
// Map operations to event types
const opMap: Record<
string,
{ entityType: 'noun' | 'verb' | 'vfs'; op: 'create' | 'update' | 'delete' }
> = {
add: { entityType: 'noun', op: 'create' },
addNoun: { entityType: 'noun', op: 'create' },
update: { entityType: 'noun', op: 'update' },
delete: { entityType: 'noun', op: 'delete' },
relate: { entityType: 'verb', op: 'create' },
addVerb: { entityType: 'verb', op: 'create' },
unrelate: { entityType: 'verb', op: 'delete' },
deleteVerb: { entityType: 'verb', op: 'delete' }
}
const mapping = opMap[operation]
if (!mapping) {
return
}
// Extract entity ID from result or params
let entityId = result?.id || params?.id
if (!entityId && Array.isArray(result)) {
// Batch operation - emit for each
for (const item of result) {
if (item?.id) {
this.eventBus.emit({
entityType: mapping.entityType,
operation: mapping.op,
entityId: item.id,
nounType: params?.type || item?.type,
verbType: params?.type || item?.type,
service: params?.service || item?.service,
data: item
})
}
}
return
}
if (entityId) {
this.eventBus.emit({
entityType: mapping.entityType,
operation: mapping.op,
entityId,
nounType: params?.type,
verbType: params?.type,
service: params?.service,
data: result
})
}
}
}
/**
* Interface for integrations that expose HTTP endpoints
*/
export interface HTTPIntegration {
/** Port the server is listening on */
port: number
/** Base path for routes */
basePath: string
/** Get registered routes */
getRoutes(): Array<{
method: string
path: string
description: string
}>
}
/**
* Interface for integrations that support streaming
*/
export interface StreamingIntegration {
/** Subscribe to real-time events via callback */
stream(
filter: EventFilter,
callback: (event: any) => void
): { close: () => void }
}

View file

@ -0,0 +1,368 @@
/**
* Integration Hub - Zero-Config Integration Manager
*
* The simplest way to enable external tool integrations.
* One line of code, all integrations ready.
*
* @example
* ```typescript
* // Zero-config: Enable all integrations
* const hub = await IntegrationHub.create(brain)
*
* // Get your endpoints
* console.log(hub.endpoints)
* // {
* // odata: '/odata', → Excel, Power BI, Tableau
* // sheets: '/sheets', → Google Sheets
* // sse: '/events', → Real-time streaming
* // webhooks: '/webhooks' → Push notifications
* // }
* ```
*/
import { IntegrationBase } from './IntegrationBase.js'
import { IntegrationLoader, IntegrationType, INTEGRATION_CATALOG } from './IntegrationLoader.js'
import { IntegrationConfig, IntegrationHealthStatus } from './types.js'
import { ODataIntegration } from '../odata/ODataIntegration.js'
import { GoogleSheetsIntegration } from '../sheets/GoogleSheetsIntegration.js'
import { SSEIntegration } from '../sse/SSEIntegration.js'
import { WebhookIntegration } from '../webhooks/WebhookIntegration.js'
/**
* Integration Hub configuration
*/
export interface IntegrationHubConfig {
/** Base path for all endpoints (default: '') */
basePath?: string
/** Which integrations to enable (default: all) */
enable?: IntegrationType[] | 'all'
/** Per-integration config overrides */
config?: {
odata?: IntegrationConfig & { basePath?: string }
sheets?: IntegrationConfig & { basePath?: string }
sse?: IntegrationConfig & { basePath?: string }
webhooks?: IntegrationConfig & { basePath?: string }
}
}
/**
* HTTP request for integration routing
*/
export interface IntegrationRequest {
method: string
path: string
query?: Record<string, string>
headers?: Record<string, string>
body?: any
}
/**
* HTTP response from integration
*/
export interface IntegrationResponse {
status: number
headers: Record<string, string>
body: any
isSSE?: boolean
}
/**
* Integration Hub - Zero-Configuration Integration Manager
*
* Provides instant access to:
* - OData API (Excel Power Query, Power BI, Tableau)
* - Google Sheets API (two-way sync)
* - SSE streaming (real-time dashboards)
* - Webhooks (push notifications)
*
* All integrations work in any environment with zero external dependencies.
*/
export class IntegrationHub {
private integrations: Map<IntegrationType, IntegrationBase> = new Map()
private config: Required<IntegrationHubConfig>
private _endpoints: Record<IntegrationType, string> = {} as any
/**
* Create and initialize the Integration Hub
*
* @example
* ```typescript
* // All integrations, default paths
* const hub = await IntegrationHub.create(brain)
*
* // Custom base path
* const hub = await IntegrationHub.create(brain, { basePath: '/api/v1' })
*
* // Only specific integrations
* const hub = await IntegrationHub.create(brain, { enable: ['odata', 'sheets'] })
* ```
*/
static async create(brain: any, config?: IntegrationHubConfig): Promise<IntegrationHub> {
const hub = new IntegrationHub(config)
await hub.initialize(brain)
return hub
}
private constructor(config?: IntegrationHubConfig) {
this.config = {
basePath: config?.basePath ?? '',
enable: config?.enable ?? 'all',
config: config?.config ?? {}
}
}
private async initialize(brain: any): Promise<void> {
const toEnable = this.config.enable === 'all'
? (['odata', 'sheets', 'sse', 'webhooks'] as IntegrationType[])
: this.config.enable
// Create context for integration initialization
const context = {
brain,
storage: brain.getStorage?.() || null,
config: {},
log: (message: string, level?: string) => {
// Silent logging - integrations handle their own logging
}
}
for (const type of toEnable) {
const integration = await this.createIntegration(type)
if (integration) {
// Initialize the integration with context (BaseAugmentation pattern)
await integration.initialize(context)
this.integrations.set(type, integration)
this._endpoints[type] = this.getBasePath(type)
}
}
}
private async createIntegration(type: IntegrationType): Promise<IntegrationBase | null> {
const basePath = this.config.basePath
const cfg = this.config.config?.[type]
switch (type) {
case 'odata':
return new ODataIntegration({
...cfg,
basePath: cfg?.basePath ?? `${basePath}/odata`
})
case 'sheets':
return new GoogleSheetsIntegration({
...cfg,
basePath: cfg?.basePath ?? `${basePath}/sheets`
})
case 'sse':
return new SSEIntegration({
...cfg,
basePath: cfg?.basePath ?? `${basePath}/events`
})
case 'webhooks':
return new WebhookIntegration(cfg)
default:
return null
}
}
private getBasePath(type: IntegrationType): string {
const basePath = this.config.basePath
const cfg = this.config.config?.[type] as any
switch (type) {
case 'odata':
return cfg?.basePath ?? `${basePath}/odata`
case 'sheets':
return cfg?.basePath ?? `${basePath}/sheets`
case 'sse':
return cfg?.basePath ?? `${basePath}/events`
case 'webhooks':
return `${basePath}/webhooks`
default:
return ''
}
}
/**
* Get all endpoint paths
*/
get endpoints(): Record<IntegrationType, string> {
return { ...this._endpoints }
}
/**
* Handle an HTTP request and route to the appropriate integration
*
* @example
* ```typescript
* // Express middleware
* app.use('/api', async (req, res) => {
* const response = await hub.handleRequest({
* method: req.method,
* path: req.path,
* query: req.query,
* headers: req.headers,
* body: req.body
* })
*
* if (response.isSSE) {
* // Handle SSE stream
* } else {
* res.status(response.status).set(response.headers).json(response.body)
* }
* })
* ```
*/
async handleRequest(request: IntegrationRequest): Promise<IntegrationResponse> {
const { path } = request
// Route to appropriate integration based on path
for (const [type, basePath] of Object.entries(this._endpoints)) {
if (path.startsWith(basePath) || path === basePath) {
const integration = this.integrations.get(type as IntegrationType)
if (integration && 'handleRequest' in integration) {
const handler = integration as any
const relativePath = path.slice(basePath.length) || '/'
return handler.handleRequest({
...request,
path: relativePath
})
}
}
}
return {
status: 404,
headers: { 'Content-Type': 'application/json' },
body: { error: 'Not found', path }
}
}
/**
* Get a specific integration
*/
get<T extends IntegrationBase>(type: IntegrationType): T | undefined {
return this.integrations.get(type) as T | undefined
}
/**
* Get the OData integration
*/
get odata(): ODataIntegration | undefined {
return this.integrations.get('odata') as ODataIntegration
}
/**
* Get the Google Sheets integration
*/
get sheets(): GoogleSheetsIntegration | undefined {
return this.integrations.get('sheets') as GoogleSheetsIntegration
}
/**
* Get the SSE integration
*/
get sse(): SSEIntegration | undefined {
return this.integrations.get('sse') as SSEIntegration
}
/**
* Get the Webhook integration
*/
get webhooks(): WebhookIntegration | undefined {
return this.integrations.get('webhooks') as WebhookIntegration
}
/**
* Check if an integration is enabled
*/
has(type: IntegrationType): boolean {
return this.integrations.has(type)
}
/**
* Get health status of all integrations
*/
health(): Record<IntegrationType, IntegrationHealthStatus> {
const result: Record<string, IntegrationHealthStatus> = {}
for (const [type, integration] of this.integrations) {
result[type] = integration.health()
}
return result as Record<IntegrationType, IntegrationHealthStatus>
}
/**
* Stop all integrations
*/
async stop(): Promise<void> {
for (const integration of this.integrations.values()) {
await integration.stop()
}
}
/**
* Get connection instructions for each tool
*/
getInstructions(): Record<string, string> {
const base = this.config.basePath || 'http://localhost:3000'
return {
excel: `
Excel Power Query:
1. Data Get Data From Other Sources From OData Feed
2. Enter URL: ${base}/odata
3. Click OK, then Load
`.trim(),
powerbi: `
Power BI:
1. Get Data OData Feed
2. Enter URL: ${base}/odata
3. Click OK, then Load
`.trim(),
googleSheets: `
Google Sheets:
1. Install the Brainy add-on from Google Workspace Marketplace
2. Open sidebar: Extensions Brainy Open
3. Connect to: ${base}/sheets
4. Use custom functions: =BRAINY_QUERY("type:Person", 100)
`.trim(),
realtime: `
Real-time Streaming (SSE):
const source = new EventSource('${base}/events')
source.onmessage = (event) => console.log(JSON.parse(event.data))
`.trim(),
webhooks: `
Webhooks:
POST ${base}/webhooks/register
{
"url": "https://your-server.com/webhook",
"events": { "entityTypes": ["noun"], "operations": ["create", "update"] }
}
`.trim()
}
}
}
/**
* Create an Integration Hub with zero configuration
*
* @example
* ```typescript
* const hub = await createIntegrationHub(brain)
* console.log(hub.endpoints)
* ```
*/
export async function createIntegrationHub(
brain: any,
config?: IntegrationHubConfig
): Promise<IntegrationHub> {
return IntegrationHub.create(brain, config)
}

View file

@ -0,0 +1,279 @@
/**
* Integration Hub - Integration Loader
*
* Lazy-loads integrations with environment detection.
* Zero external dependencies - works everywhere.
*/
import { IntegrationBase } from './IntegrationBase.js'
import { IntegrationConfig } from './types.js'
/**
* Supported integration types
*/
export type IntegrationType =
| 'odata' // Excel Power Query, Power BI, Tableau
| 'sheets' // Google Sheets two-way sync
| 'sse' // Server-Sent Events streaming
| 'webhooks' // Push notifications to external URLs
/**
* Runtime environment
*/
export type RuntimeEnvironment =
| 'node'
| 'browser'
| 'deno'
| 'cloudflare'
| 'bun'
/**
* Integration metadata
*/
export interface IntegrationInfo {
id: IntegrationType
name: string
description: string
environments: RuntimeEnvironment[]
tools: string[] // What tools this works with
}
/**
* Integration catalog - all built-in, zero dependencies
*/
export const INTEGRATION_CATALOG: Record<IntegrationType, IntegrationInfo> = {
odata: {
id: 'odata',
name: 'OData 4.0 API',
description: 'REST API for spreadsheets and BI tools',
environments: ['node', 'browser', 'deno', 'cloudflare', 'bun'],
tools: ['Excel Power Query', 'Power BI', 'Tableau', 'Qlik', 'SAP']
},
sheets: {
id: 'sheets',
name: 'Google Sheets',
description: 'Two-way sync with Google Sheets',
environments: ['node', 'browser', 'deno', 'cloudflare', 'bun'],
tools: ['Google Sheets', 'Apps Script']
},
sse: {
id: 'sse',
name: 'Real-time Streaming',
description: 'Server-Sent Events for live updates',
environments: ['node', 'browser', 'deno', 'cloudflare', 'bun'],
tools: ['Dashboards', 'Live UIs', 'Monitoring']
},
webhooks: {
id: 'webhooks',
name: 'Webhooks',
description: 'Push events to external URLs',
environments: ['node', 'browser', 'deno', 'cloudflare', 'bun'],
tools: ['Zapier', 'IFTTT', 'n8n', 'Custom APIs']
}
}
/**
* Detect current runtime environment
*/
export function detectEnvironment(): RuntimeEnvironment {
// Deno
if (typeof (globalThis as any).Deno !== 'undefined') {
return 'deno'
}
// Bun
if (typeof (globalThis as any).Bun !== 'undefined') {
return 'bun'
}
// Cloudflare Workers
if (
typeof (globalThis as any).caches !== 'undefined' &&
typeof (globalThis as any).HTMLRewriter !== 'undefined'
) {
return 'cloudflare'
}
// Node.js
if (
typeof process !== 'undefined' &&
process.versions &&
process.versions.node
) {
return 'node'
}
// Browser
if (typeof window !== 'undefined') {
return 'browser'
}
return 'node'
}
/**
* Integration loader configuration
*/
export interface IntegrationLoaderConfig {
/** Which integrations to load: array of types, 'all', or 'none' */
integrations?: IntegrationType[] | 'all' | 'none'
/** Override configs per integration */
config?: Partial<Record<IntegrationType, IntegrationConfig>>
/** Custom integrations to add */
custom?: IntegrationBase[]
}
/**
* Lazy-loading integration manager
*
* @example
* ```typescript
* // Load all integrations (recommended)
* const loader = new IntegrationLoader({ integrations: 'all' })
* const integrations = await loader.load()
*
* // Load specific integrations
* const loader = new IntegrationLoader({
* integrations: ['odata', 'sheets']
* })
* ```
*/
export class IntegrationLoader {
private config: IntegrationLoaderConfig
private environment: RuntimeEnvironment
private loaded: Map<IntegrationType, IntegrationBase> = new Map()
constructor(config: IntegrationLoaderConfig = {}) {
this.config = {
integrations: config.integrations ?? 'none',
config: config.config ?? {},
custom: config.custom ?? []
}
this.environment = detectEnvironment()
}
/**
* Get current runtime environment
*/
getEnvironment(): RuntimeEnvironment {
return this.environment
}
/**
* Get all available integrations
*/
getAvailable(): IntegrationInfo[] {
return Object.values(INTEGRATION_CATALOG).filter((info) =>
info.environments.includes(this.environment)
)
}
/**
* Check if an integration is available
*/
isAvailable(type: IntegrationType): boolean {
const info = INTEGRATION_CATALOG[type]
return info?.environments.includes(this.environment) ?? false
}
/**
* Load and instantiate integrations
*/
async load(): Promise<IntegrationBase[]> {
const toLoad = this.resolveIntegrations()
const results: IntegrationBase[] = []
// Load integrations in parallel for speed
const loadPromises = toLoad.map(async (type) => {
try {
const integration = await this.loadOne(type)
if (integration) {
this.loaded.set(type, integration)
return integration
}
} catch (error: any) {
console.warn(`[Brainy] Failed to load ${type}: ${error.message}`)
}
return null
})
const loadedIntegrations = await Promise.all(loadPromises)
results.push(...loadedIntegrations.filter((i): i is IntegrationBase => i !== null))
// Add custom integrations
for (const custom of this.config.custom ?? []) {
results.push(custom)
}
return results
}
/**
* Get a loaded integration by type
*/
get<T extends IntegrationBase = IntegrationBase>(type: IntegrationType): T | undefined {
return this.loaded.get(type) as T | undefined
}
/**
* Check if an integration is loaded
*/
has(type: IntegrationType): boolean {
return this.loaded.has(type)
}
/**
* Get all loaded integrations
*/
all(): IntegrationBase[] {
return Array.from(this.loaded.values())
}
private resolveIntegrations(): IntegrationType[] {
const { integrations } = this.config
if (integrations === 'none') {
return []
}
if (integrations === 'all') {
return this.getAvailable().map((info) => info.id)
}
return (integrations || []).filter((type) => this.isAvailable(type))
}
private async loadOne(type: IntegrationType): Promise<IntegrationBase | null> {
const cfg = this.config.config?.[type]
switch (type) {
case 'odata': {
const { ODataIntegration } = await import('../odata/ODataIntegration.js')
return new ODataIntegration(cfg)
}
case 'sheets': {
const { GoogleSheetsIntegration } = await import('../sheets/GoogleSheetsIntegration.js')
return new GoogleSheetsIntegration(cfg)
}
case 'sse': {
const { SSEIntegration } = await import('../sse/SSEIntegration.js')
return new SSEIntegration(cfg)
}
case 'webhooks': {
const { WebhookIntegration } = await import('../webhooks/WebhookIntegration.js')
return new WebhookIntegration(cfg)
}
default:
return null
}
}
}
/**
* Create an integration loader
*/
export function createIntegrationLoader(config?: IntegrationLoaderConfig): IntegrationLoader {
return new IntegrationLoader(config)
}

View file

@ -0,0 +1,574 @@
/**
* Integration Hub - Tabular Exporter
*
* Converts Brainy entities to tabular formats (rows/columns) for use in
* spreadsheets, SQL databases, and BI tools.
*/
import { Entity, Relation } from '../../types/brainy.types.js'
import {
TabularRow,
RelationTabularRow,
TabularExporterConfig
} from './types.js'
/**
* Converts Brainy entities to tabular formats
*
* Used by SQL, OData, Google Sheets, and CSV integrations to maintain
* consistent entity-to-row mapping across all external tools.
*
* @example
* ```typescript
* const exporter = new TabularExporter({
* flattenMetadata: true,
* includeVectors: false
* })
*
* const rows = exporter.entitiesToRows(entities)
* const csv = exporter.toCSV(entities)
* const odata = exporter.toOData(entities)
* ```
*/
export class TabularExporter {
private config: Required<TabularExporterConfig>
constructor(config: TabularExporterConfig = {}) {
this.config = {
flattenMetadata: config.flattenMetadata ?? true,
metadataPrefix: config.metadataPrefix ?? 'Metadata_',
includeVectors: config.includeVectors ?? false,
dateFormat: config.dateFormat ?? 'ISO8601',
jsonStringify: config.jsonStringify ?? ['data'],
maxFlattenDepth: config.maxFlattenDepth ?? 1, // Flatten one level, stringify deeper
excludeColumns: config.excludeColumns ?? []
}
}
/**
* Convert entities to tabular rows
*/
entitiesToRows(entities: Entity[]): TabularRow[] {
return entities.map((entity) => this.entityToRow(entity))
}
/**
* Convert a single entity to a tabular row
*/
entityToRow(entity: Entity): TabularRow {
const row: TabularRow = {
Id: entity.id,
Type: entity.type,
CreatedAt: this.formatDate(entity.createdAt),
UpdatedAt: entity.updatedAt
? this.formatDate(entity.updatedAt)
: this.formatDate(entity.createdAt),
Confidence: entity.confidence ?? null,
Weight: entity.weight ?? null,
Service: entity.service ?? null,
Data: this.config.jsonStringify.includes('data')
? JSON.stringify(entity.data ?? null)
: entity.data ?? null
}
// Include vector if configured
if (this.config.includeVectors && entity.vector) {
row.Vector = JSON.stringify(Array.from(entity.vector))
}
// Flatten metadata
if (this.config.flattenMetadata && entity.metadata) {
const flatMetadata = this.flattenObject(
entity.metadata,
this.config.metadataPrefix,
this.config.maxFlattenDepth
)
Object.assign(row, flatMetadata)
} else if (entity.metadata) {
row.Metadata = JSON.stringify(entity.metadata)
}
// Remove excluded columns
for (const col of this.config.excludeColumns) {
delete row[col]
}
return row
}
/**
* Convert relations to tabular rows
*/
relationsToRows(relations: Relation[]): RelationTabularRow[] {
return relations.map((rel) => this.relationToRow(rel))
}
/**
* Convert a single relation to a tabular row
*/
relationToRow(relation: Relation): RelationTabularRow {
return {
Id: relation.id,
FromId: relation.from,
ToId: relation.to,
Type: relation.type,
Weight: relation.weight ?? null,
Confidence: relation.confidence ?? null,
CreatedAt: this.formatDate(relation.createdAt),
UpdatedAt: relation.updatedAt
? this.formatDate(relation.updatedAt)
: this.formatDate(relation.createdAt),
Service: relation.service ?? null,
Metadata: relation.metadata ? JSON.stringify(relation.metadata) : null
}
}
/**
* Convert entities to CSV string
*/
toCSV(entities: Entity[], options?: { delimiter?: string }): string {
const delimiter = options?.delimiter ?? ','
const rows = this.entitiesToRows(entities)
if (rows.length === 0) {
return ''
}
// Get all columns from all rows
const columns = this.getAllColumns(rows)
// Header row
const header = columns.map((col) => this.escapeCSV(col)).join(delimiter)
// Data rows
const dataRows = rows.map((row) =>
columns
.map((col) => {
const value = row[col]
return this.escapeCSV(
value === null || value === undefined ? '' : String(value)
)
})
.join(delimiter)
)
return [header, ...dataRows].join('\n')
}
/**
* Convert relations to CSV string
*/
relationsToCSV(
relations: Relation[],
options?: { delimiter?: string }
): string {
const delimiter = options?.delimiter ?? ','
const rows = this.relationsToRows(relations)
if (rows.length === 0) {
return ''
}
const columns: (keyof RelationTabularRow)[] = [
'Id',
'FromId',
'ToId',
'Type',
'Weight',
'Confidence',
'CreatedAt',
'UpdatedAt',
'Service',
'Metadata'
]
const header = columns.join(delimiter)
const dataRows = rows.map((row) =>
columns
.map((col) => {
const value = row[col]
return this.escapeCSV(
value === null || value === undefined ? '' : String(value)
)
})
.join(delimiter)
)
return [header, ...dataRows].join('\n')
}
/**
* Convert entities to OData format (JSON with annotations)
*/
toOData(
entities: Entity[],
options?: {
context?: string
count?: number
nextLink?: string
}
): object {
const rows = this.entitiesToRows(entities)
const result: any = {
'@odata.context': options?.context ?? '$metadata#Entities'
}
if (options?.count !== undefined) {
result['@odata.count'] = options.count
}
result.value = rows.map((row) => this.rowToODataEntity(row))
if (options?.nextLink) {
result['@odata.nextLink'] = options.nextLink
}
return result
}
/**
* Convert relations to OData format
*/
relationsToOData(
relations: Relation[],
options?: {
context?: string
count?: number
nextLink?: string
}
): object {
const rows = this.relationsToRows(relations)
const result: any = {
'@odata.context': options?.context ?? '$metadata#Relationships'
}
if (options?.count !== undefined) {
result['@odata.count'] = options.count
}
result.value = rows
if (options?.nextLink) {
result['@odata.nextLink'] = options.nextLink
}
return result
}
/**
* Parse CSV string to entity-like objects
*/
parseCSV(
csv: string,
options?: { delimiter?: string }
): Partial<Entity>[] {
const delimiter = options?.delimiter ?? ','
const lines = csv.split('\n').filter((line) => line.trim())
if (lines.length < 2) {
return []
}
const headers = this.parseCSVLine(lines[0], delimiter)
const entities: Partial<Entity>[] = []
for (let i = 1; i < lines.length; i++) {
const values = this.parseCSVLine(lines[i], delimiter)
const row: Record<string, any> = {}
for (let j = 0; j < headers.length; j++) {
row[headers[j]] = values[j] ?? ''
}
entities.push(this.rowToEntity(row))
}
return entities
}
/**
* Get schema from entities (column names and types)
*/
getSchema(entities: Entity[]): Array<{
name: string
type: 'string' | 'number' | 'boolean' | 'datetime' | 'json'
nullable: boolean
}> {
const rows = this.entitiesToRows(entities.slice(0, 100)) // Sample first 100
const columns = this.getAllColumns(rows)
const schema: Array<{
name: string
type: 'string' | 'number' | 'boolean' | 'datetime' | 'json'
nullable: boolean
}> = []
for (const col of columns) {
let type: 'string' | 'number' | 'boolean' | 'datetime' | 'json' = 'string'
let nullable = false
for (const row of rows) {
const value = row[col]
if (value === null || value === undefined) {
nullable = true
continue
}
const inferredType = this.inferType(value)
if (type === 'string') {
type = inferredType
} else if (type !== inferredType) {
// Mixed types, fall back to string
type = 'string'
break
}
}
schema.push({ name: col, type, nullable })
}
return schema
}
// Private helper methods
private formatDate(timestamp: number): string {
switch (this.config.dateFormat) {
case 'unix':
return Math.floor(timestamp / 1000).toString()
case 'unix_ms':
return timestamp.toString()
case 'ISO8601':
default:
return new Date(timestamp).toISOString()
}
}
private flattenObject(
obj: Record<string, any>,
prefix: string,
maxDepth: number,
currentDepth = 0
): Record<string, any> {
const result: Record<string, any> = {}
for (const [key, value] of Object.entries(obj)) {
const newKey = `${prefix}${key}`
if (value === null || value === undefined) {
result[newKey] = null
} else if (Array.isArray(value)) {
// Arrays are always JSON stringified
result[newKey] = JSON.stringify(value)
} else if (typeof value === 'object') {
// Objects: flatten if under depth limit, otherwise stringify
if (currentDepth < maxDepth - 1) {
Object.assign(
result,
this.flattenObject(value, `${newKey}_`, maxDepth, currentDepth + 1)
)
} else {
// Max depth reached - stringify the object
result[newKey] = JSON.stringify(value)
}
} else {
// Primitives: use as-is
result[newKey] = value
}
}
return result
}
private getAllColumns(rows: TabularRow[]): string[] {
const columnSet = new Set<string>()
// Standard columns first
const standardColumns = [
'Id',
'Type',
'CreatedAt',
'UpdatedAt',
'Confidence',
'Weight',
'Service',
'Data'
]
for (const col of standardColumns) {
columnSet.add(col)
}
// Add all other columns from rows
for (const row of rows) {
for (const key of Object.keys(row)) {
columnSet.add(key)
}
}
return Array.from(columnSet)
}
private escapeCSV(value: string): string {
// Escape quotes and wrap in quotes if contains special characters
if (
value.includes(',') ||
value.includes('"') ||
value.includes('\n') ||
value.includes('\r')
) {
return `"${value.replace(/"/g, '""')}"`
}
return value
}
private parseCSVLine(line: string, delimiter: string): string[] {
const result: string[] = []
let current = ''
let inQuotes = false
for (let i = 0; i < line.length; i++) {
const char = line[i]
if (inQuotes) {
if (char === '"') {
if (line[i + 1] === '"') {
current += '"'
i++
} else {
inQuotes = false
}
} else {
current += char
}
} else {
if (char === '"') {
inQuotes = true
} else if (char === delimiter) {
result.push(current)
current = ''
} else {
current += char
}
}
}
result.push(current)
return result
}
private rowToEntity(row: Record<string, any>): Partial<Entity> {
const entity: Partial<Entity> = {}
// Map standard columns
if (row.Id) entity.id = row.Id
if (row.Type) entity.type = row.Type as any
if (row.Service) entity.service = row.Service
if (row.Confidence) entity.confidence = parseFloat(row.Confidence)
if (row.Weight) entity.weight = parseFloat(row.Weight)
// Parse timestamps
if (row.CreatedAt) {
entity.createdAt = this.parseDate(row.CreatedAt)
}
if (row.UpdatedAt) {
entity.updatedAt = this.parseDate(row.UpdatedAt)
}
// Parse data
if (row.Data) {
try {
entity.data = JSON.parse(row.Data)
} catch {
entity.data = row.Data
}
}
// Collect metadata from prefixed columns
const metadata: Record<string, any> = {}
for (const [key, value] of Object.entries(row)) {
if (key.startsWith(this.config.metadataPrefix)) {
const metaKey = key.slice(this.config.metadataPrefix.length)
try {
metadata[metaKey] = JSON.parse(value as string)
} catch {
metadata[metaKey] = value
}
}
}
if (Object.keys(metadata).length > 0) {
entity.metadata = metadata
}
return entity
}
private parseDate(value: string): number {
// Try parsing as number (unix timestamp)
const num = Number(value)
if (!isNaN(num)) {
// If it looks like seconds (< year 3000 in seconds)
if (num < 32503680000) {
return num * 1000
}
return num
}
// Try parsing as ISO date
const date = new Date(value)
if (!isNaN(date.getTime())) {
return date.getTime()
}
return Date.now()
}
private rowToODataEntity(row: TabularRow): object {
const result: any = {}
for (const [key, value] of Object.entries(row)) {
if (value === null) {
result[key] = null
} else if (key === 'CreatedAt' || key === 'UpdatedAt') {
// OData datetime format
result[key] = value
} else {
result[key] = value
}
}
return result
}
private inferType(
value: any
): 'string' | 'number' | 'boolean' | 'datetime' | 'json' {
if (typeof value === 'number') {
return 'number'
}
if (typeof value === 'boolean') {
return 'boolean'
}
if (typeof value === 'string') {
// Check if it's a date
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value)) {
return 'datetime'
}
// Check if it's JSON
if (
(value.startsWith('{') && value.endsWith('}')) ||
(value.startsWith('[') && value.endsWith(']'))
) {
try {
JSON.parse(value)
return 'json'
} catch {
// Not valid JSON
}
}
}
return 'string'
}
}

View file

@ -0,0 +1,64 @@
/**
* Integration Hub - Core Infrastructure
*
* Shared foundation for all integrations:
* - EventBus: Real-time change notifications
* - TabularExporter: Entity to rows/columns conversion
* - IntegrationBase: Base class for integrations
* - IntegrationHub: Zero-config integration manager
*/
// Event system
export { EventBus } from './EventBus.js'
// Tabular export
export { TabularExporter } from './TabularExporter.js'
// Base class
export {
IntegrationBase,
type HTTPIntegration,
type StreamingIntegration
} from './IntegrationBase.js'
// Integration loader
export {
IntegrationLoader,
createIntegrationLoader,
detectEnvironment,
INTEGRATION_CATALOG,
type IntegrationType,
type RuntimeEnvironment,
type IntegrationInfo,
type IntegrationLoaderConfig
} from './IntegrationLoader.js'
// Zero-config hub
export {
IntegrationHub,
createIntegrationHub,
type IntegrationHubConfig,
type IntegrationRequest,
type IntegrationResponse
} from './IntegrationHub.js'
// Types
export type {
// Events
BrainyEvent,
EventFilter,
EventHandler,
EventSubscription,
// Tabular
TabularRow,
RelationTabularRow,
TabularExporterConfig,
// Config
IntegrationConfig,
IntegrationHealthStatus,
// OData
ODataQueryOptions,
// Webhooks
WebhookRegistration,
WebhookDeliveryResult
} from './types.js'

View file

@ -0,0 +1,261 @@
/**
* Integration Hub - Shared Types
*
* Types for OData, Google Sheets, SSE, and Webhooks integrations.
* Zero external dependencies.
*/
import { Entity, Relation } from '../../types/brainy.types.js'
import { NounType, VerbType } from '../../types/graphTypes.js'
// ============================================================================
// Events - Real-time change notifications
// ============================================================================
/**
* Real-time event emitted when Brainy data changes
*/
export interface BrainyEvent {
/** Unique event identifier */
id: string
/** What changed: noun, verb, or VFS */
entityType: 'noun' | 'verb' | 'vfs'
/** What happened */
operation: 'create' | 'update' | 'delete'
/** The entity ID that was affected */
entityId: string
/** Unix timestamp in milliseconds */
timestamp: number
/** Monotonically increasing sequence ID for ordering/resumption */
sequenceId: bigint
/** NounType if entityType is 'noun' */
nounType?: NounType
/** VerbType if entityType is 'verb' */
verbType?: VerbType
/** Service (multi-tenancy) */
service?: string
/** Full entity data (if includeData is enabled) */
data?: Entity | Relation
}
/**
* Filter for subscribing to events
*/
export interface EventFilter {
/** Filter by entity types */
entityTypes?: ('noun' | 'verb' | 'vfs')[]
/** Filter by operations */
operations?: ('create' | 'update' | 'delete')[]
/** Filter by noun types */
nounTypes?: NounType[]
/** Filter by verb types */
verbTypes?: VerbType[]
/** Filter by service */
service?: string
/** Resume from this sequence ID */
since?: bigint
}
/**
* Event handler function
*/
export type EventHandler = (event: BrainyEvent) => void | Promise<void>
/**
* Event subscription handle
*/
export interface EventSubscription {
id: string
unsubscribe(): void
}
// ============================================================================
// Tabular Export - Entity to rows/columns conversion
// ============================================================================
/**
* Tabular row representation of an entity
*/
export interface TabularRow {
Id: string
Type: string
CreatedAt: string
UpdatedAt: string
Confidence: number | null
Weight: number | null
Service: string | null
Data: string | null
/** Flattened metadata columns (Metadata_*) */
[key: string]: any
}
/**
* Tabular row for relations
*/
export interface RelationTabularRow {
Id: string
FromId: string
ToId: string
Type: string
Weight: number | null
Confidence: number | null
CreatedAt: string
UpdatedAt: string
Service: string | null
Metadata: string | null
}
/**
* Configuration for TabularExporter
*/
export interface TabularExporterConfig {
/** Flatten metadata into separate columns (default: true) */
flattenMetadata?: boolean
/** Prefix for metadata columns (default: 'Metadata_') */
metadataPrefix?: string
/** Include vector embeddings (default: false) */
includeVectors?: boolean
/** Date format (default: 'ISO8601') */
dateFormat?: 'ISO8601' | 'unix' | 'unix_ms'
/** Fields to JSON.stringify (default: ['data']) */
jsonStringify?: string[]
/** Max depth for flattening nested objects (default: 2) */
maxFlattenDepth?: number
/** Columns to exclude */
excludeColumns?: string[]
}
// ============================================================================
// Integration Configuration
// ============================================================================
/**
* Base configuration for all integrations
*/
export interface IntegrationConfig {
/** Enable/disable the integration */
enabled?: boolean
/** Rate limiting */
rateLimit?: {
max: number
windowMs: number
}
/** Authentication */
auth?: {
required: boolean
apiKeys?: string[]
}
/** CORS settings */
cors?: {
origin: string | string[]
methods?: string[]
credentials?: boolean
}
}
/**
* Health status for an integration
*/
export interface IntegrationHealthStatus {
name: string
status: 'healthy' | 'degraded' | 'unhealthy' | 'stopped'
message?: string
uptimeMs?: number
requestCount?: number
errorCount?: number
lastError?: string
checkedAt: number
}
// ============================================================================
// OData - Excel Power Query, Power BI, Tableau
// ============================================================================
/**
* OData query options parsed from URL
*/
export interface ODataQueryOptions {
/** $filter expression */
filter?: string
/** $select columns */
select?: string[]
/** $orderby specification */
orderBy?: Array<{ field: string; direction: 'asc' | 'desc' }>
/** $top (limit) */
top?: number
/** $skip (offset) */
skip?: number
/** $expand relations */
expand?: string[]
/** $count - include total count */
count?: boolean
/** $search - full text search */
search?: string
}
// ============================================================================
// Webhooks - Push notifications
// ============================================================================
/**
* Webhook registration
*/
export interface WebhookRegistration {
id: string
url: string
events: EventFilter
secret?: string
active: boolean
retryPolicy?: {
maxRetries: number
backoffMultiplier: number
initialDelayMs: number
maxDelayMs: number
}
createdAt: number
lastDeliveryAt?: number
failureCount?: number
}
/**
* Webhook delivery result
*/
export interface WebhookDeliveryResult {
webhookId: string
eventId: string
success: boolean
statusCode?: number
error?: string
attempts: number
timestamp: number
}