diff --git a/apps/studio/src/components/app-sidebar.tsx b/apps/studio/src/components/app-sidebar.tsx index 2c3570a47..074d6f51b 100644 --- a/apps/studio/src/components/app-sidebar.tsx +++ b/apps/studio/src/components/app-sidebar.tsx @@ -36,7 +36,7 @@ import { type LucideIcon, } from "lucide-react" import { useState, useEffect, useCallback, useMemo } from "react" -import { useClient } from '@objectstack/client-react'; +import { useClient, useMetadataSubscriptionCallback } from '@objectstack/client-react'; import type { InstalledPackage } from '@objectstack/spec/kernel'; import { @@ -260,6 +260,17 @@ export function AppSidebar({ useEffect(() => { loadMetadata(); }, [loadMetadata]); + // Subscribe to metadata changes for real-time updates + // Subscribe to all major metadata types for live sidebar updates + useMetadataSubscriptionCallback('object', loadMetadata); + useMetadataSubscriptionCallback('view', loadMetadata); + useMetadataSubscriptionCallback('app', loadMetadata); + useMetadataSubscriptionCallback('agent', loadMetadata); + useMetadataSubscriptionCallback('tool', loadMetadata); + useMetadataSubscriptionCallback('flow', loadMetadata); + useMetadataSubscriptionCallback('dashboard', loadMetadata); + useMetadataSubscriptionCallback('report', loadMetadata); + // Search helper const matchesSearch = (label: string, name: string) => !searchQuery || diff --git a/packages/client-react/src/index.tsx b/packages/client-react/src/index.tsx index be273f171..e7ae8d0f0 100644 --- a/packages/client-react/src/index.tsx +++ b/packages/client-react/src/index.tsx @@ -45,5 +45,15 @@ export { type UseMetadataResult } from './metadata-hooks'; +// Realtime Event Hooks +export { + useMetadataSubscription, + useDataSubscription, + useMetadataSubscriptionCallback, + useDataSubscriptionCallback, + useRealtimeConnection, + useAutoRefresh +} from './realtime-hooks'; + // Re-export ObjectStackClient and types from @objectstack/client export { ObjectStackClient, type ClientConfig } from '@objectstack/client'; diff --git a/packages/client-react/src/realtime-hooks.tsx b/packages/client-react/src/realtime-hooks.tsx new file mode 100644 index 000000000..470cb7477 --- /dev/null +++ b/packages/client-react/src/realtime-hooks.tsx @@ -0,0 +1,262 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Real-time Event Subscription Hooks + * + * Provides React hooks for subscribing to metadata and data events. + * Events are automatically cleaned up when components unmount. + */ + +import { useEffect, useState, useCallback } from 'react'; +import type { MetadataEvent, DataEvent } from '@objectstack/spec/api'; +import { useClient } from './context'; + +/** + * Hook to subscribe to metadata events + * + * @param type - Metadata type to subscribe to (e.g., 'object', 'view', 'agent') + * @param options - Optional filters (packageId) + * @returns Latest metadata event or null + * + * @example + * ```tsx + * function ObjectList() { + * const event = useMetadataSubscription('object'); + * + * useEffect(() => { + * if (event?.type === 'metadata.object.created') { + * console.log('New object:', event.name); + * // Refresh list + * } + * }, [event]); + * + * return
...
; + * } + * ``` + */ +export function useMetadataSubscription( + type: string, + options?: { packageId?: string } +): MetadataEvent | null { + const client = useClient(); + const [event, setEvent] = useState(null); + + useEffect(() => { + if (!client) return; + + const unsubscribe = client.events.subscribeMetadata( + type, + (e) => setEvent(e), + options + ); + + return () => { + unsubscribe(); + }; + }, [client, type, options?.packageId]); + + return event; +} + +/** + * Hook to subscribe to data record events + * + * @param object - Object name to subscribe to + * @param options - Optional filters (recordId for specific record) + * @returns Latest data event or null + * + * @example + * ```tsx + * function TaskDetail({ taskId }: { taskId: string }) { + * const event = useDataSubscription('project_task', { recordId: taskId }); + * + * useEffect(() => { + * if (event?.type === 'data.record.updated') { + * console.log('Task updated:', event.changes); + * // Refresh task data + * } + * }, [event]); + * + * return
...
; + * } + * ``` + */ +export function useDataSubscription( + object: string, + options?: { recordId?: string } +): DataEvent | null { + const client = useClient(); + const [event, setEvent] = useState(null); + + useEffect(() => { + if (!client) return; + + const unsubscribe = client.events.subscribeData( + object, + (e) => setEvent(e), + options + ); + + return () => { + unsubscribe(); + }; + }, [client, object, options?.recordId]); + + return event; +} + +/** + * Hook to subscribe to metadata events with a callback + * + * This variant doesn't store events in state, it just triggers a callback. + * Useful for triggering refetches or side effects without re-renders. + * + * @param type - Metadata type to subscribe to + * @param callback - Callback to invoke on events + * @param options - Optional filters + * + * @example + * ```tsx + * function ObjectList() { + * const { refetch } = useQuery(...); + * + * useMetadataSubscriptionCallback('object', () => { + * refetch(); // Refetch list when objects change + * }); + * + * return
...
; + * } + * ``` + */ +export function useMetadataSubscriptionCallback( + type: string, + callback: (event: MetadataEvent) => void, + options?: { packageId?: string } +): void { + const client = useClient(); + + useEffect(() => { + if (!client) return; + + const unsubscribe = client.events.subscribeMetadata( + type, + callback, + options + ); + + return () => { + unsubscribe(); + }; + }, [client, type, callback, options?.packageId]); +} + +/** + * Hook to subscribe to data events with a callback + * + * @param object - Object name to subscribe to + * @param callback - Callback to invoke on events + * @param options - Optional filters + * + * @example + * ```tsx + * function TaskList() { + * const { refetch } = useQuery(...); + * + * useDataSubscriptionCallback('project_task', () => { + * refetch(); // Refetch list when tasks change + * }); + * + * return
...
; + * } + * ``` + */ +export function useDataSubscriptionCallback( + object: string, + callback: (event: DataEvent) => void, + options?: { recordId?: string } +): void { + const client = useClient(); + + useEffect(() => { + if (!client) return; + + const unsubscribe = client.events.subscribeData( + object, + callback, + options + ); + + return () => { + unsubscribe(); + }; + }, [client, object, callback, options?.recordId]); +} + +/** + * Hook to get connection status of realtime events + * + * @returns Whether realtime is connected + * + * @example + * ```tsx + * function ConnectionIndicator() { + * const connected = useRealtimeConnection(); + * + * return ( + *
+ * {connected ? '🟢 Connected' : '🔴 Disconnected'} + *
+ * ); + * } + * ``` + */ +export function useRealtimeConnection(): boolean { + const client = useClient(); + const [connected, setConnected] = useState(true); + + useEffect(() => { + if (!client) { + setConnected(false); + return; + } + + // For now, assume always connected with in-memory adapter + // In production, this would listen to WebSocket connection events + setConnected(true); + }, [client]); + + return connected; +} + +/** + * Hook for auto-refreshing queries when data changes + * + * Combines data subscription with query refetch. + * + * @param object - Object name to watch + * @param refetch - Refetch function from useQuery + * @param options - Optional filters + * + * @example + * ```tsx + * function TaskList() { + * const { data, refetch } = useQuery('project_task', {}); + * + * useAutoRefresh('project_task', refetch); + * + * return
{data.map(...)}
; + * } + * ``` + */ +export function useAutoRefresh( + object: string, + refetch: () => void, + options?: { recordId?: string } +): void { + const handleEvent = useCallback((_event: DataEvent) => { + // Refetch on any data change + refetch(); + }, [refetch]); + + useDataSubscriptionCallback(object, handleEvent, options); +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index d9fc886f7..a4b37766b 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,9 +1,9 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { QueryAST, SortNode, AggregationNode, isFilterAST } from '@objectstack/spec/data'; -import { - BatchUpdateRequest, - BatchUpdateResponse, +import { + BatchUpdateRequest, + BatchUpdateResponse, UpdateManyRequest, DeleteManyRequest, BatchOptions, @@ -88,6 +88,7 @@ import { ApiRoutes, } from '@objectstack/spec/api'; import { Logger, createLogger } from '@objectstack/core'; +import { RealtimeAPI } from './realtime-api'; /** * Route types that the client can resolve. @@ -228,18 +229,22 @@ export class ObjectStackClient { private fetchImpl: (input: RequestInfo | URL, init?: RequestInit) => Promise; private discoveryInfo?: DiscoveryResult; private logger: Logger; + private realtimeAPI: RealtimeAPI; constructor(config: ClientConfig) { this.baseUrl = config.baseUrl.replace(/\/$/, ''); // Remove trailing slash this.token = config.token; this.fetchImpl = config.fetch || globalThis.fetch.bind(globalThis); - + // Initialize logger - this.logger = config.logger || createLogger({ + this.logger = config.logger || createLogger({ level: config.debug ? 'debug' : 'info', format: 'pretty' }); - + + // Initialize realtime API + this.realtimeAPI = new RealtimeAPI(this.baseUrl, this.token); + this.logger.debug('ObjectStack client created', { baseUrl: this.baseUrl }); } @@ -887,6 +892,14 @@ export class ObjectStackClient { }, }; + /** + * Event Subscription API + * Provides real-time event subscriptions for metadata and data changes + */ + get events() { + return this.realtimeAPI; + } + /** * Permissions Services */ @@ -1789,6 +1802,9 @@ export class ObjectStackClient { // Re-export type-safe query builder export { QueryBuilder, FilterBuilder, createQuery, createFilter } from './query-builder'; +// Re-export realtime API types +export { RealtimeAPI, RealtimeSubscriptionFilter, RealtimeEventHandler } from './realtime-api'; + // Re-export commonly used types from @objectstack/spec/api for convenience export type { BatchUpdateRequest, diff --git a/packages/client/src/realtime-api.ts b/packages/client/src/realtime-api.ts new file mode 100644 index 000000000..bb65de6f7 --- /dev/null +++ b/packages/client/src/realtime-api.ts @@ -0,0 +1,208 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Realtime API Module for ObjectStackClient + * + * Provides real-time event subscription capabilities using long-polling. + * For production WebSocket/SSE support, extend with transport adapters. + */ + +import type { RealtimeEventPayload } from '@objectstack/spec/contracts'; +import type { MetadataEvent, DataEvent } from '@objectstack/spec/api'; + +export interface RealtimeSubscriptionFilter { + /** Metadata/object type filter */ + type?: string; + /** Package ID filter */ + packageId?: string; + /** Event types to listen for */ + eventTypes?: string[]; + /** Record ID filter (for data events) */ + recordId?: string; +} + +export type RealtimeEventHandler = (event: RealtimeEventPayload) => void; + +/** + * Realtime API for subscribing to server events + * + * Note: Currently uses in-memory adapter. WebSocket/SSE transport planned for future. + */ +export class RealtimeAPI { + // @ts-expect-error - Reserved for future WebSocket/SSE implementation + private _baseUrl: string; + // @ts-expect-error - Reserved for future WebSocket/SSE implementation + private _token?: string; + private subscriptions = new Map(); + private pollInterval?: ReturnType; + private eventBuffer: RealtimeEventPayload[] = []; + + constructor(baseUrl: string, token?: string) { + this._baseUrl = baseUrl; + this._token = token; + } + + /** + * Subscribe to metadata events + * Returns an unsubscribe function + */ + subscribeMetadata( + type: string, + callback: (event: MetadataEvent) => void, + options?: { packageId?: string } + ): () => void { + const subscriptionId = `metadata-${type}-${Date.now()}`; + + this.subscriptions.set(subscriptionId, { + filter: { + type, + packageId: options?.packageId, + eventTypes: [ + `metadata.${type}.created`, + `metadata.${type}.updated`, + `metadata.${type}.deleted` + ] + }, + handler: (event) => { + // Type guard and filter + if (event.type.startsWith('metadata.')) { + callback(event as any as MetadataEvent); + } + } + }); + + // Start polling if not already started + this.startPolling(); + + // Return unsubscribe function + return () => { + this.subscriptions.delete(subscriptionId); + if (this.subscriptions.size === 0) { + this.stopPolling(); + } + }; + } + + /** + * Subscribe to data record events + * Returns an unsubscribe function + */ + subscribeData( + object: string, + callback: (event: DataEvent) => void, + options?: { recordId?: string } + ): () => void { + const subscriptionId = `data-${object}-${Date.now()}`; + + this.subscriptions.set(subscriptionId, { + filter: { + type: object, + recordId: options?.recordId, + eventTypes: [ + 'data.record.created', + 'data.record.updated', + 'data.record.deleted' + ] + }, + handler: (event) => { + // Type guard and filter + if (event.type.startsWith('data.') && event.object === object) { + if (!options?.recordId || (event.payload as any)?.recordId === options.recordId) { + callback(event as any as DataEvent); + } + } + } + }); + + // Start polling if not already started + this.startPolling(); + + // Return unsubscribe function + return () => { + this.subscriptions.delete(subscriptionId); + if (this.subscriptions.size === 0) { + this.stopPolling(); + } + }; + } + + /** + * Emit an event to all matching subscriptions (client-side only) + * This is used for in-process event delivery + */ + private emitEvent(event: RealtimeEventPayload): void { + for (const sub of this.subscriptions.values()) { + // Check if event matches subscription filters + const matchesType = !sub.filter.type || + event.type.includes(sub.filter.type) || + event.object === sub.filter.type; + + const matchesEventType = !sub.filter.eventTypes?.length || + sub.filter.eventTypes.includes(event.type); + + const matchesPackage = !sub.filter.packageId || + (event.payload as any)?.packageId === sub.filter.packageId; + + if (matchesType && matchesEventType && matchesPackage) { + try { + sub.handler(event); + } catch (error) { + console.error('Error in realtime event handler:', error); + } + } + } + } + + /** + * Start polling for events (fallback mechanism) + * In production, this would be replaced with WebSocket/SSE + */ + private startPolling(): void { + if (this.pollInterval) return; + + // For now, we rely on the in-memory adapter within the same process + // Events are delivered synchronously via the IRealtimeService + // This polling is a placeholder for future WebSocket/SSE implementation + + // Poll every 2 seconds for buffered events + this.pollInterval = setInterval(() => { + // Process any buffered events + while (this.eventBuffer.length > 0) { + const event = this.eventBuffer.shift(); + if (event) { + this.emitEvent(event); + } + } + }, 2000); + } + + /** + * Stop polling for events + */ + private stopPolling(): void { + if (this.pollInterval) { + clearInterval(this.pollInterval); + this.pollInterval = undefined; + } + } + + /** + * Internal method to buffer events from server + * This would be called by WebSocket/SSE handlers in production + */ + _bufferEvent(event: RealtimeEventPayload): void { + this.eventBuffer.push(event); + } + + /** + * Disconnect and clean up all subscriptions + */ + disconnect(): void { + this.stopPolling(); + this.subscriptions.clear(); + this.eventBuffer = []; + } +} diff --git a/packages/metadata/src/metadata-manager.ts b/packages/metadata/src/metadata-manager.ts index 03566ef29..d80c0cb5e 100644 --- a/packages/metadata/src/metadata-manager.ts +++ b/packages/metadata/src/metadata-manager.ts @@ -28,6 +28,8 @@ import type { MetadataImportOptions, MetadataImportResult, MetadataTypeInfo, + IRealtimeService, + RealtimeEventPayload, } from '@objectstack/spec/contracts'; import type { MetadataQuery, @@ -83,6 +85,9 @@ export class MetadataManager implements IMetadataService { // Dependency tracking: "type:name" -> dependencies private dependencies = new Map(); + // Realtime service for event publishing + private realtimeService?: IRealtimeService; + constructor(config: MetadataManagerOptions) { this.config = config; this.logger = createLogger({ level: 'info', format: 'pretty' }); @@ -139,6 +144,17 @@ export class MetadataManager implements IMetadataService { this.logger.info('DatabaseLoader configured', { datasource: this.config.datasource, tableName }); } + /** + * Set the realtime service for publishing metadata change events. + * Should be called after kernel resolves the realtime service. + * + * @param service - An IRealtimeService instance for event publishing + */ + setRealtimeService(service: IRealtimeService): void { + this.realtimeService = service; + this.logger.info('RealtimeService configured for metadata events'); + } + /** * Register a new metadata loader (data source) */ @@ -171,6 +187,28 @@ export class MetadataManager implements IMetadataService { await loader.save(type, name, data); } } + + // Publish metadata.{type}.created event to realtime service + if (this.realtimeService) { + const event: RealtimeEventPayload = { + type: `metadata.${type}.created`, + object: type, + payload: { + metadataType: type, + name, + definition: data, + packageId: (data as any)?.packageId, + }, + timestamp: new Date().toISOString(), + }; + + try { + await this.realtimeService.publish(event); + this.logger.debug(`Published metadata.${type}.created event`, { name }); + } catch (error) { + this.logger.warn(`Failed to publish metadata event`, { type, name, error }); + } + } } /** @@ -246,6 +284,26 @@ export class MetadataManager implements IMetadataService { } } } + + // Publish metadata.{type}.deleted event to realtime service + if (this.realtimeService) { + const event: RealtimeEventPayload = { + type: `metadata.${type}.deleted`, + object: type, + payload: { + metadataType: type, + name, + }, + timestamp: new Date().toISOString(), + }; + + try { + await this.realtimeService.publish(event); + this.logger.debug(`Published metadata.${type}.deleted event`, { name }); + } catch (error) { + this.logger.warn(`Failed to publish metadata event`, { type, name, error }); + } + } } /** diff --git a/packages/metadata/src/plugin.ts b/packages/metadata/src/plugin.ts index 802f0dd5d..c97c88ddc 100644 --- a/packages/metadata/src/plugin.ts +++ b/packages/metadata/src/plugin.ts @@ -125,5 +125,20 @@ export class MetadataPlugin implements Plugin { error: e.message, }); } + + // Bridge realtime service from kernel service registry to MetadataManager. + // RealtimeServicePlugin registers as 'realtime' service during init(). + // This enables MetadataManager to publish metadata change events. + try { + const realtimeService = ctx.getService('realtime'); + if (realtimeService && typeof realtimeService === 'object' && 'publish' in realtimeService) { + ctx.logger.info('[MetadataPlugin] Bridging realtime service to MetadataManager for event publishing'); + this.manager.setRealtimeService(realtimeService as any); + } + } catch (e: any) { + ctx.logger.debug('[MetadataPlugin] No realtime service found — metadata events will not be published', { + error: e.message, + }); + } } } diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 7f11038a4..b22e94e6e 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -1,17 +1,18 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { QueryAST, HookContext, ServiceObject } from '@objectstack/spec/data'; -import { +import { EngineQueryOptions, - DataEngineInsertOptions, - EngineUpdateOptions, + DataEngineInsertOptions, + EngineUpdateOptions, EngineDeleteOptions, EngineAggregateOptions, - EngineCountOptions + EngineCountOptions } from '@objectstack/spec/data'; import { ExecutionContext, ExecutionContextSchema } from '@objectstack/spec/kernel'; import { DriverInterface, IDataEngine, Logger, createLogger } from '@objectstack/core'; import { CoreServiceName } from '@objectstack/spec/system'; +import { IRealtimeService, RealtimeEventPayload } from '@objectstack/spec/contracts'; import { SchemaRegistry } from './registry.js'; export type HookHandler = (context: HookContext) => Promise | void; @@ -69,7 +70,7 @@ export class ObjectQL implements IDataEngine { private drivers = new Map(); private defaultDriver: string | null = null; private logger: Logger; - + // Per-object hooks with priority support private hooks: Map = new Map([ ['beforeFind', []], ['afterFind', []], @@ -86,10 +87,13 @@ export class ObjectQL implements IDataEngine { // Action registry: key = "objectName:actionName" private actions = new Map Promise | any; package?: string }>(); - + // Host provided context additions (e.g. Server router) private hostContext: Record = {}; + // Realtime service for event publishing + private realtimeService?: IRealtimeService; + constructor(hostContext: Record = {}) { this.hostContext = hostContext; // Use provided logger or create a new one @@ -514,8 +518,8 @@ export class ObjectQL implements IDataEngine { } this.drivers.set(driver.name, driver); - this.logger.info('Registered driver', { - driverName: driver.name, + this.logger.info('Registered driver', { + driverName: driver.name, version: driver.version }); @@ -525,6 +529,17 @@ export class ObjectQL implements IDataEngine { } } + /** + * Set the realtime service for publishing data change events. + * Should be called after kernel resolves the realtime service. + * + * @param service - An IRealtimeService instance for event publishing + */ + setRealtimeService(service: IRealtimeService): void { + this.realtimeService = service; + this.logger.info('RealtimeService configured for data events'); + } + /** * Helper to get object definition */ @@ -883,6 +898,42 @@ export class ObjectQL implements IDataEngine { hookContext.result = result; await this.triggerHooks('afterInsert', hookContext); + // Publish data.record.created event to realtime service + if (this.realtimeService) { + try { + if (Array.isArray(result)) { + // Bulk insert - publish event for each record + for (const record of result) { + const event: RealtimeEventPayload = { + type: 'data.record.created', + object, + payload: { + recordId: record.id, + after: record, + }, + timestamp: new Date().toISOString(), + }; + await this.realtimeService.publish(event); + } + this.logger.debug(`Published ${result.length} data.record.created events`, { object }); + } else { + const event: RealtimeEventPayload = { + type: 'data.record.created', + object, + payload: { + recordId: result.id, + after: result, + }, + timestamp: new Date().toISOString(), + }; + await this.realtimeService.publish(event); + this.logger.debug('Published data.record.created event', { object, recordId: result.id }); + } + } catch (error) { + this.logger.warn('Failed to publish data event', { object, error }); + } + } + return hookContext.result; } catch (e) { this.logger.error('Insert operation failed', e as Error, { object }); @@ -937,6 +988,29 @@ export class ObjectQL implements IDataEngine { hookContext.event = 'afterUpdate'; hookContext.result = result; await this.triggerHooks('afterUpdate', hookContext); + + // Publish data.record.updated event to realtime service + if (this.realtimeService) { + try { + const resultId = (typeof result === 'object' && result && 'id' in result) ? (result as any).id : undefined; + const recordId = String(hookContext.input.id || resultId || ''); + const event: RealtimeEventPayload = { + type: 'data.record.updated', + object, + payload: { + recordId, + changes: hookContext.input.data, + after: result, + }, + timestamp: new Date().toISOString(), + }; + await this.realtimeService.publish(event); + this.logger.debug('Published data.record.updated event', { object, recordId }); + } catch (error) { + this.logger.warn('Failed to publish data event', { object, error }); + } + } + return hookContext.result; } catch (e) { this.logger.error('Update operation failed', e as Error, { object }); @@ -990,6 +1064,27 @@ export class ObjectQL implements IDataEngine { hookContext.event = 'afterDelete'; hookContext.result = result; await this.triggerHooks('afterDelete', hookContext); + + // Publish data.record.deleted event to realtime service + if (this.realtimeService) { + try { + const resultId = (typeof result === 'object' && result && 'id' in result) ? (result as any).id : undefined; + const recordId = String(hookContext.input.id || resultId || ''); + const event: RealtimeEventPayload = { + type: 'data.record.deleted', + object, + payload: { + recordId, + }, + timestamp: new Date().toISOString(), + }; + await this.realtimeService.publish(event); + this.logger.debug('Published data.record.deleted event', { object, recordId }); + } catch (error) { + this.logger.warn('Failed to publish data event', { object, error }); + } + } + return hookContext.result; } catch (e) { this.logger.error('Delete operation failed', e as Error, { object }); diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index 4fef7aabe..e0f34d138 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -113,6 +113,21 @@ export class ObjectQLPlugin implements Plugin { ctx.logger.debug('Discovered and registered app service (legacy)', { serviceName: name }); } } + + // Bridge realtime service from kernel service registry to ObjectQL. + // RealtimeServicePlugin registers as 'realtime' service during init(). + // This enables ObjectQL to publish data change events. + try { + const realtimeService = ctx.getService('realtime'); + if (realtimeService && typeof realtimeService === 'object' && 'publish' in realtimeService) { + ctx.logger.info('[ObjectQLPlugin] Bridging realtime service to ObjectQL for event publishing'); + this.ql.setRealtimeService(realtimeService as any); + } + } catch (e: any) { + ctx.logger.debug('[ObjectQLPlugin] No realtime service found — data events will not be published', { + error: e.message, + }); + } } // Initialize drivers (calls driver.connect() which sets up persistence) diff --git a/packages/spec/src/api/events.zod.ts b/packages/spec/src/api/events.zod.ts new file mode 100644 index 000000000..568432d5e --- /dev/null +++ b/packages/spec/src/api/events.zod.ts @@ -0,0 +1,144 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; + +/** + * Metadata Event Types + * + * Triggered when metadata items are created, updated, or deleted. + * Follows the pattern: `metadata.{type}.{action}` + * + * Examples: + * - `metadata.object.created` - A new object was created + * - `metadata.view.updated` - A view was updated + * - `metadata.agent.deleted` - An agent was deleted + */ +export const MetadataEventType = z.enum([ + 'metadata.object.created', + 'metadata.object.updated', + 'metadata.object.deleted', + 'metadata.field.created', + 'metadata.field.updated', + 'metadata.field.deleted', + 'metadata.view.created', + 'metadata.view.updated', + 'metadata.view.deleted', + 'metadata.app.created', + 'metadata.app.updated', + 'metadata.app.deleted', + 'metadata.agent.created', + 'metadata.agent.updated', + 'metadata.agent.deleted', + 'metadata.tool.created', + 'metadata.tool.updated', + 'metadata.tool.deleted', + 'metadata.flow.created', + 'metadata.flow.updated', + 'metadata.flow.deleted', + 'metadata.action.created', + 'metadata.action.updated', + 'metadata.action.deleted', + 'metadata.workflow.created', + 'metadata.workflow.updated', + 'metadata.workflow.deleted', + 'metadata.dashboard.created', + 'metadata.dashboard.updated', + 'metadata.dashboard.deleted', + 'metadata.report.created', + 'metadata.report.updated', + 'metadata.report.deleted', + 'metadata.role.created', + 'metadata.role.updated', + 'metadata.role.deleted', + 'metadata.permission.created', + 'metadata.permission.updated', + 'metadata.permission.deleted', +]); + +export type MetadataEventType = z.infer; + +/** + * Data Event Types + * + * Triggered when data records are created, updated, or deleted. + * Follows the pattern: `data.record.{action}` + */ +export const DataEventType = z.enum([ + 'data.record.created', + 'data.record.updated', + 'data.record.deleted', + 'data.field.changed', +]); + +export type DataEventType = z.infer; + +/** + * Metadata Event Payload + * + * Represents a metadata change event (create, update, delete). + * Used for real-time synchronization of metadata across clients. + */ +export const MetadataEventSchema = z.object({ + /** Unique event identifier */ + id: z.string().uuid().describe('Unique event identifier'), + + /** Event type (metadata.{type}.{action}) */ + type: MetadataEventType.describe('Event type'), + + /** Metadata type (object, view, agent, tool, etc.) */ + metadataType: z.string().describe('Metadata type (object, view, agent, etc.)'), + + /** Metadata item name */ + name: z.string().describe('Metadata item name'), + + /** Package ID (if applicable) */ + packageId: z.string().optional().describe('Package ID'), + + /** Full definition (only for create/update events) */ + definition: z.unknown().optional().describe('Full definition (create/update only)'), + + /** User who triggered the event */ + userId: z.string().optional().describe('User who triggered the event'), + + /** Event timestamp (ISO 8601) */ + timestamp: z.string().datetime().describe('Event timestamp'), +}); + +export type MetadataEvent = z.infer; + +/** + * Data Event Payload + * + * Represents a data record change event (create, update, delete). + * Used for real-time synchronization of data records across clients. + */ +export const DataEventSchema = z.object({ + /** Unique event identifier */ + id: z.string().uuid().describe('Unique event identifier'), + + /** Event type (data.record.{action}) */ + type: DataEventType.describe('Event type'), + + /** Object name */ + object: z.string().describe('Object name'), + + /** Record ID */ + recordId: z.string().describe('Record ID'), + + /** Changed fields (update events only) */ + changes: z.record(z.string(), z.unknown()).optional().describe('Changed fields'), + + /** Record before update (update events only) */ + before: z.record(z.string(), z.unknown()).optional().describe('Before state'), + + /** Record after update (create/update events) */ + after: z.record(z.string(), z.unknown()).optional().describe('After state'), + + /** User who triggered the event */ + userId: z.string().optional().describe('User who triggered the event'), + + /** Event timestamp (ISO 8601) */ + timestamp: z.string().datetime().describe('Event timestamp'), +}); + +export type DataEvent = z.infer; diff --git a/packages/spec/src/api/index.ts b/packages/spec/src/api/index.ts index 1917be866..5f1b90503 100644 --- a/packages/spec/src/api/index.ts +++ b/packages/spec/src/api/index.ts @@ -16,6 +16,7 @@ export * from './contract.zod'; export * from './endpoint.zod'; export * from './discovery.zod'; +export * from './events.zod'; export * from './realtime-shared.zod'; export * from './realtime.zod'; export * from './websocket.zod'; diff --git a/packages/spec/src/contracts/realtime-service.ts b/packages/spec/src/contracts/realtime-service.ts index 70ddb5b05..3705eb8ef 100644 --- a/packages/spec/src/contracts/realtime-service.ts +++ b/packages/spec/src/contracts/realtime-service.ts @@ -44,6 +44,22 @@ export interface RealtimeSubscriptionOptions { filter?: Record; } +/** + * Enhanced subscription filter for metadata and data events + */ +export interface RealtimeSubscriptionFilter { + /** Metadata type filter (object, view, agent, tool, etc.) */ + type?: string; + /** Package ID filter */ + packageId?: string; + /** Event types to listen for */ + eventTypes?: string[]; + /** Record ID filter (for data events) */ + recordId?: string; + /** Field names filter (for data events) */ + fields?: string[]; +} + export interface IRealtimeService { /** * Publish an event to all subscribers @@ -72,4 +88,20 @@ export interface IRealtimeService { * @returns Standard Response object */ handleUpgrade?(request: Request): Promise; + + /** + * Subscribe to metadata events (convenience method) + * @param filter - Subscription filter + * @param handler - Event handler function + * @returns Subscription identifier for unsubscribing + */ + subscribeMetadata?(filter: RealtimeSubscriptionFilter, handler: RealtimeEventHandler): Promise; + + /** + * Subscribe to data events (convenience method) + * @param filter - Subscription filter + * @param handler - Event handler function + * @returns Subscription identifier for unsubscribing + */ + subscribeData?(filter: RealtimeSubscriptionFilter, handler: RealtimeEventHandler): Promise; }