Skip to content

Commit 32c339e

Browse files
authored
Merge pull request #1107 from objectstack-ai/claude/assess-frontend-backend-protocols
Implement real-time event subscriptions for metadata and data changes
2 parents 3609a80 + 7767442 commit 32c339e

12 files changed

Lines changed: 882 additions & 15 deletions

File tree

apps/studio/src/components/app-sidebar.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import {
3636
type LucideIcon,
3737
} from "lucide-react"
3838
import { useState, useEffect, useCallback, useMemo } from "react"
39-
import { useClient } from '@objectstack/client-react';
39+
import { useClient, useMetadataSubscriptionCallback } from '@objectstack/client-react';
4040
import type { InstalledPackage } from '@objectstack/spec/kernel';
4141

4242
import {
@@ -260,6 +260,17 @@ export function AppSidebar({
260260

261261
useEffect(() => { loadMetadata(); }, [loadMetadata]);
262262

263+
// Subscribe to metadata changes for real-time updates
264+
// Subscribe to all major metadata types for live sidebar updates
265+
useMetadataSubscriptionCallback('object', loadMetadata);
266+
useMetadataSubscriptionCallback('view', loadMetadata);
267+
useMetadataSubscriptionCallback('app', loadMetadata);
268+
useMetadataSubscriptionCallback('agent', loadMetadata);
269+
useMetadataSubscriptionCallback('tool', loadMetadata);
270+
useMetadataSubscriptionCallback('flow', loadMetadata);
271+
useMetadataSubscriptionCallback('dashboard', loadMetadata);
272+
useMetadataSubscriptionCallback('report', loadMetadata);
273+
263274
// Search helper
264275
const matchesSearch = (label: string, name: string) =>
265276
!searchQuery ||

packages/client-react/src/index.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,5 +45,15 @@ export {
4545
type UseMetadataResult
4646
} from './metadata-hooks';
4747

48+
// Realtime Event Hooks
49+
export {
50+
useMetadataSubscription,
51+
useDataSubscription,
52+
useMetadataSubscriptionCallback,
53+
useDataSubscriptionCallback,
54+
useRealtimeConnection,
55+
useAutoRefresh
56+
} from './realtime-hooks';
57+
4858
// Re-export ObjectStackClient and types from @objectstack/client
4959
export { ObjectStackClient, type ClientConfig } from '@objectstack/client';
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Real-time Event Subscription Hooks
5+
*
6+
* Provides React hooks for subscribing to metadata and data events.
7+
* Events are automatically cleaned up when components unmount.
8+
*/
9+
10+
import { useEffect, useState, useCallback } from 'react';
11+
import type { MetadataEvent, DataEvent } from '@objectstack/spec/api';
12+
import { useClient } from './context';
13+
14+
/**
15+
* Hook to subscribe to metadata events
16+
*
17+
* @param type - Metadata type to subscribe to (e.g., 'object', 'view', 'agent')
18+
* @param options - Optional filters (packageId)
19+
* @returns Latest metadata event or null
20+
*
21+
* @example
22+
* ```tsx
23+
* function ObjectList() {
24+
* const event = useMetadataSubscription('object');
25+
*
26+
* useEffect(() => {
27+
* if (event?.type === 'metadata.object.created') {
28+
* console.log('New object:', event.name);
29+
* // Refresh list
30+
* }
31+
* }, [event]);
32+
*
33+
* return <div>...</div>;
34+
* }
35+
* ```
36+
*/
37+
export function useMetadataSubscription(
38+
type: string,
39+
options?: { packageId?: string }
40+
): MetadataEvent | null {
41+
const client = useClient();
42+
const [event, setEvent] = useState<MetadataEvent | null>(null);
43+
44+
useEffect(() => {
45+
if (!client) return;
46+
47+
const unsubscribe = client.events.subscribeMetadata(
48+
type,
49+
(e) => setEvent(e),
50+
options
51+
);
52+
53+
return () => {
54+
unsubscribe();
55+
};
56+
}, [client, type, options?.packageId]);
57+
58+
return event;
59+
}
60+
61+
/**
62+
* Hook to subscribe to data record events
63+
*
64+
* @param object - Object name to subscribe to
65+
* @param options - Optional filters (recordId for specific record)
66+
* @returns Latest data event or null
67+
*
68+
* @example
69+
* ```tsx
70+
* function TaskDetail({ taskId }: { taskId: string }) {
71+
* const event = useDataSubscription('project_task', { recordId: taskId });
72+
*
73+
* useEffect(() => {
74+
* if (event?.type === 'data.record.updated') {
75+
* console.log('Task updated:', event.changes);
76+
* // Refresh task data
77+
* }
78+
* }, [event]);
79+
*
80+
* return <div>...</div>;
81+
* }
82+
* ```
83+
*/
84+
export function useDataSubscription(
85+
object: string,
86+
options?: { recordId?: string }
87+
): DataEvent | null {
88+
const client = useClient();
89+
const [event, setEvent] = useState<DataEvent | null>(null);
90+
91+
useEffect(() => {
92+
if (!client) return;
93+
94+
const unsubscribe = client.events.subscribeData(
95+
object,
96+
(e) => setEvent(e),
97+
options
98+
);
99+
100+
return () => {
101+
unsubscribe();
102+
};
103+
}, [client, object, options?.recordId]);
104+
105+
return event;
106+
}
107+
108+
/**
109+
* Hook to subscribe to metadata events with a callback
110+
*
111+
* This variant doesn't store events in state, it just triggers a callback.
112+
* Useful for triggering refetches or side effects without re-renders.
113+
*
114+
* @param type - Metadata type to subscribe to
115+
* @param callback - Callback to invoke on events
116+
* @param options - Optional filters
117+
*
118+
* @example
119+
* ```tsx
120+
* function ObjectList() {
121+
* const { refetch } = useQuery(...);
122+
*
123+
* useMetadataSubscriptionCallback('object', () => {
124+
* refetch(); // Refetch list when objects change
125+
* });
126+
*
127+
* return <div>...</div>;
128+
* }
129+
* ```
130+
*/
131+
export function useMetadataSubscriptionCallback(
132+
type: string,
133+
callback: (event: MetadataEvent) => void,
134+
options?: { packageId?: string }
135+
): void {
136+
const client = useClient();
137+
138+
useEffect(() => {
139+
if (!client) return;
140+
141+
const unsubscribe = client.events.subscribeMetadata(
142+
type,
143+
callback,
144+
options
145+
);
146+
147+
return () => {
148+
unsubscribe();
149+
};
150+
}, [client, type, callback, options?.packageId]);
151+
}
152+
153+
/**
154+
* Hook to subscribe to data events with a callback
155+
*
156+
* @param object - Object name to subscribe to
157+
* @param callback - Callback to invoke on events
158+
* @param options - Optional filters
159+
*
160+
* @example
161+
* ```tsx
162+
* function TaskList() {
163+
* const { refetch } = useQuery(...);
164+
*
165+
* useDataSubscriptionCallback('project_task', () => {
166+
* refetch(); // Refetch list when tasks change
167+
* });
168+
*
169+
* return <div>...</div>;
170+
* }
171+
* ```
172+
*/
173+
export function useDataSubscriptionCallback(
174+
object: string,
175+
callback: (event: DataEvent) => void,
176+
options?: { recordId?: string }
177+
): void {
178+
const client = useClient();
179+
180+
useEffect(() => {
181+
if (!client) return;
182+
183+
const unsubscribe = client.events.subscribeData(
184+
object,
185+
callback,
186+
options
187+
);
188+
189+
return () => {
190+
unsubscribe();
191+
};
192+
}, [client, object, callback, options?.recordId]);
193+
}
194+
195+
/**
196+
* Hook to get connection status of realtime events
197+
*
198+
* @returns Whether realtime is connected
199+
*
200+
* @example
201+
* ```tsx
202+
* function ConnectionIndicator() {
203+
* const connected = useRealtimeConnection();
204+
*
205+
* return (
206+
* <div>
207+
* {connected ? '🟢 Connected' : '🔴 Disconnected'}
208+
* </div>
209+
* );
210+
* }
211+
* ```
212+
*/
213+
export function useRealtimeConnection(): boolean {
214+
const client = useClient();
215+
const [connected, setConnected] = useState(true);
216+
217+
useEffect(() => {
218+
if (!client) {
219+
setConnected(false);
220+
return;
221+
}
222+
223+
// For now, assume always connected with in-memory adapter
224+
// In production, this would listen to WebSocket connection events
225+
setConnected(true);
226+
}, [client]);
227+
228+
return connected;
229+
}
230+
231+
/**
232+
* Hook for auto-refreshing queries when data changes
233+
*
234+
* Combines data subscription with query refetch.
235+
*
236+
* @param object - Object name to watch
237+
* @param refetch - Refetch function from useQuery
238+
* @param options - Optional filters
239+
*
240+
* @example
241+
* ```tsx
242+
* function TaskList() {
243+
* const { data, refetch } = useQuery('project_task', {});
244+
*
245+
* useAutoRefresh('project_task', refetch);
246+
*
247+
* return <div>{data.map(...)}</div>;
248+
* }
249+
* ```
250+
*/
251+
export function useAutoRefresh(
252+
object: string,
253+
refetch: () => void,
254+
options?: { recordId?: string }
255+
): void {
256+
const handleEvent = useCallback((_event: DataEvent) => {
257+
// Refetch on any data change
258+
refetch();
259+
}, [refetch]);
260+
261+
useDataSubscriptionCallback(object, handleEvent, options);
262+
}

packages/client/src/index.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { QueryAST, SortNode, AggregationNode, isFilterAST } from '@objectstack/spec/data';
4-
import {
5-
BatchUpdateRequest,
6-
BatchUpdateResponse,
4+
import {
5+
BatchUpdateRequest,
6+
BatchUpdateResponse,
77
UpdateManyRequest,
88
DeleteManyRequest,
99
BatchOptions,
@@ -88,6 +88,7 @@ import {
8888
ApiRoutes,
8989
} from '@objectstack/spec/api';
9090
import { Logger, createLogger } from '@objectstack/core';
91+
import { RealtimeAPI } from './realtime-api';
9192

9293
/**
9394
* Route types that the client can resolve.
@@ -228,18 +229,22 @@ export class ObjectStackClient {
228229
private fetchImpl: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
229230
private discoveryInfo?: DiscoveryResult;
230231
private logger: Logger;
232+
private realtimeAPI: RealtimeAPI;
231233

232234
constructor(config: ClientConfig) {
233235
this.baseUrl = config.baseUrl.replace(/\/$/, ''); // Remove trailing slash
234236
this.token = config.token;
235237
this.fetchImpl = config.fetch || globalThis.fetch.bind(globalThis);
236-
238+
237239
// Initialize logger
238-
this.logger = config.logger || createLogger({
240+
this.logger = config.logger || createLogger({
239241
level: config.debug ? 'debug' : 'info',
240242
format: 'pretty'
241243
});
242-
244+
245+
// Initialize realtime API
246+
this.realtimeAPI = new RealtimeAPI(this.baseUrl, this.token);
247+
243248
this.logger.debug('ObjectStack client created', { baseUrl: this.baseUrl });
244249
}
245250

@@ -887,6 +892,14 @@ export class ObjectStackClient {
887892
},
888893
};
889894

895+
/**
896+
* Event Subscription API
897+
* Provides real-time event subscriptions for metadata and data changes
898+
*/
899+
get events() {
900+
return this.realtimeAPI;
901+
}
902+
890903
/**
891904
* Permissions Services
892905
*/
@@ -1789,6 +1802,9 @@ export class ObjectStackClient {
17891802
// Re-export type-safe query builder
17901803
export { QueryBuilder, FilterBuilder, createQuery, createFilter } from './query-builder';
17911804

1805+
// Re-export realtime API types
1806+
export { RealtimeAPI, RealtimeSubscriptionFilter, RealtimeEventHandler } from './realtime-api';
1807+
17921808
// Re-export commonly used types from @objectstack/spec/api for convenience
17931809
export type {
17941810
BatchUpdateRequest,

0 commit comments

Comments
 (0)