Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion apps/studio/src/components/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Comment on lines +263 to +272
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sidebar subscriptions don’t pass the currently selected packageId, so any metadata change in any package will trigger loadMetadata() and re-fetch the sidebar data even when viewing a specific package. This can cause unnecessary network load as more packages/types are added.

Consider passing { packageId: selectedPackage?.manifest?.id } to each useMetadataSubscriptionCallback() so events are scoped to the active package when possible.

Suggested change
// 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);
const metadataSubscriptionScope = useMemo(
() => ({ packageId: selectedPackage?.manifest?.id }),
[selectedPackage?.manifest?.id]
);
// Subscribe to metadata changes for real-time updates
// Subscribe to all major metadata types for live sidebar updates
useMetadataSubscriptionCallback('object', loadMetadata, metadataSubscriptionScope);
useMetadataSubscriptionCallback('view', loadMetadata, metadataSubscriptionScope);
useMetadataSubscriptionCallback('app', loadMetadata, metadataSubscriptionScope);
useMetadataSubscriptionCallback('agent', loadMetadata, metadataSubscriptionScope);
useMetadataSubscriptionCallback('tool', loadMetadata, metadataSubscriptionScope);
useMetadataSubscriptionCallback('flow', loadMetadata, metadataSubscriptionScope);
useMetadataSubscriptionCallback('dashboard', loadMetadata, metadataSubscriptionScope);
useMetadataSubscriptionCallback('report', loadMetadata, metadataSubscriptionScope);

Copilot uses AI. Check for mistakes.

// Search helper
const matchesSearch = (label: string, name: string) =>
!searchQuery ||
Expand Down
10 changes: 10 additions & 0 deletions packages/client-react/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
262 changes: 262 additions & 0 deletions packages/client-react/src/realtime-hooks.tsx
Original file line number Diff line number Diff line change
@@ -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 <div>...</div>;
* }
* ```
*/
export function useMetadataSubscription(
type: string,
options?: { packageId?: string }
): MetadataEvent | null {
const client = useClient();
const [event, setEvent] = useState<MetadataEvent | null>(null);

useEffect(() => {
if (!client) return;

const unsubscribe = client.events.subscribeMetadata(
type,
(e) => setEvent(e),
options
);

return () => {
unsubscribe();
};
}, [client, type, options?.packageId]);

return event;
}
Comment on lines +37 to +59
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hooks are typed/documented as returning MetadataEvent/DataEvent with fields like event.name and event.changes, but the client realtime layer is built around RealtimeEventPayload where these live under event.payload. With the current shapes, the examples and return types will be wrong for actual emitted events.

Align the hook return type and docs with the real event shape (e.g. return RealtimeEventPayload or a correctly-mapped typed union) so consumers don’t rely on fields that won’t exist at runtime.

Copilot uses AI. Check for mistakes.

/**
* 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 <div>...</div>;
* }
* ```
*/
export function useDataSubscription(
object: string,
options?: { recordId?: string }
): DataEvent | null {
const client = useClient();
const [event, setEvent] = useState<DataEvent | null>(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 <div>...</div>;
* }
* ```
*/
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 <div>...</div>;
* }
* ```
*/
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 (
* <div>
* {connected ? '🟢 Connected' : '🔴 Disconnected'}
* </div>
* );
* }
* ```
*/
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 <div>{data.map(...)}</div>;
* }
* ```
*/
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);
}
28 changes: 22 additions & 6 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -228,18 +229,22 @@ export class ObjectStackClient {
private fetchImpl: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
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 });
}

Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading