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;
}