Skip to content

Commit 11b5f54

Browse files
authored
Merge pull request #1099 from constructive-io/feat/orm-realtime-integrated
feat: add ORM-integrated realtime subscription support (Variation F)
2 parents 364ab3b + 90481ed commit 11b5f54

17 files changed

Lines changed: 1805 additions & 3 deletions

File tree

graphql/codegen/src/__tests__/codegen/__snapshots__/client-generator.test.ts.snap

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,12 +111,35 @@ import type {
111111
} from '@constructive-io/graphql-query/runtime';
112112
import { createFetch } from '@constructive-io/graphql-query/runtime';
113113
114+
import type {
115+
ConnectionState,
116+
ConnectionStateListener,
117+
RealtimeConfig,
118+
SubscribeOptions,
119+
SubscriptionEvent,
120+
SubscriptionFieldMeta,
121+
Unsubscribe,
122+
} from './realtime';
123+
import { RealtimeManager } from './realtime';
124+
114125
export type {
115126
GraphQLAdapter,
116127
GraphQLError,
117128
QueryResult,
118129
} from '@constructive-io/graphql-query/runtime';
119130
131+
export type {
132+
ConnectionState,
133+
ConnectionStateListener,
134+
RealtimeConfig,
135+
SubscribeOptions,
136+
SubscriptionEvent,
137+
SubscriptionFieldMeta,
138+
SubscriptionOperation,
139+
Unsubscribe,
140+
} from './realtime';
141+
export { RealtimeManager } from './realtime';
142+
120143
/**
121144
* Default adapter that uses fetch for HTTP requests.
122145
*
@@ -213,6 +236,12 @@ export interface OrmClientConfig {
213236
fetch?: typeof globalThis.fetch;
214237
/** Custom adapter for GraphQL execution (overrides endpoint/headers/fetch) */
215238
adapter?: GraphQLAdapter;
239+
/**
240+
* Optional realtime (WebSocket) configuration.
241+
* When provided, enables subscription methods on models.
242+
* The WebSocket connection is created lazily on first subscribe().
243+
*/
244+
realtime?: RealtimeConfig;
216245
}
217246
218247
/**
@@ -231,6 +260,7 @@ export class GraphQLRequestError extends Error {
231260
232261
export class OrmClient {
233262
private adapter: GraphQLAdapter;
263+
private realtimeManager?: RealtimeManager;
234264
235265
constructor(config: OrmClientConfig) {
236266
if (config.adapter) {
@@ -246,6 +276,10 @@ export class OrmClient {
246276
'OrmClientConfig requires either an endpoint or a custom adapter',
247277
);
248278
}
279+
280+
if (config.realtime) {
281+
this.realtimeManager = new RealtimeManager(config.realtime);
282+
}
249283
}
250284
251285
async execute<T>(
@@ -255,6 +289,34 @@ export class OrmClient {
255289
return this.adapter.execute<T>(document, variables);
256290
}
257291
292+
/**
293+
* Subscribe to a GraphQL subscription operation.
294+
* Used by generated model subscribe() methods.
295+
* @throws Error if realtime is not configured
296+
*/
297+
subscribe<T>(
298+
meta: SubscriptionFieldMeta,
299+
document: string,
300+
variables: Record<string, unknown>,
301+
options: {
302+
onEvent: (event: SubscriptionEvent<T>) => void;
303+
onError?: (error: Error) => void;
304+
onComplete?: () => void;
305+
},
306+
): Unsubscribe {
307+
if (!this.realtimeManager) {
308+
throw new Error(
309+
'Realtime not configured. Pass a \`realtime\` option to createClient() to enable subscriptions.',
310+
);
311+
}
312+
return this.realtimeManager.subscribe<T>(
313+
meta,
314+
document,
315+
variables,
316+
options,
317+
);
318+
}
319+
258320
/**
259321
* Set headers for requests.
260322
* Only works if the adapter supports headers.
@@ -272,6 +334,34 @@ export class OrmClient {
272334
getEndpoint(): string {
273335
return this.adapter.getEndpoint?.() ?? '';
274336
}
337+
338+
/** Get current WebSocket connection state */
339+
getConnectionState(): ConnectionState {
340+
return this.realtimeManager?.getConnectionState() ?? 'disconnected';
341+
}
342+
343+
/** Register a listener for WebSocket connection state changes */
344+
onConnectionStateChange(
345+
listener: ConnectionStateListener,
346+
): Unsubscribe {
347+
if (!this.realtimeManager) return () => {};
348+
return this.realtimeManager.onConnectionStateChange(listener);
349+
}
350+
351+
/** Number of active subscriptions */
352+
getActiveSubscriptionCount(): number {
353+
return this.realtimeManager?.getActiveSubscriptionCount() ?? 0;
354+
}
355+
356+
/** Whether realtime is configured */
357+
get isRealtimeEnabled(): boolean {
358+
return this.realtimeManager !== undefined;
359+
}
360+
361+
/** Dispose the realtime manager (close WebSocket) */
362+
dispose(): void {
363+
this.realtimeManager?.dispose();
364+
}
275365
}
276366
"
277367
`;
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
2+
3+
exports[`Connection State Hook Generator generateConnectionStateHook generates useConnectionState hook 1`] = `
4+
"/**
5+
* WebSocket connection state hook
6+
* @generated by @constructive-io/graphql-codegen
7+
* DO NOT EDIT - changes will be overwritten
8+
*/
9+
10+
import { useState, useEffect } from "react";
11+
import { getClient } from "../client";
12+
import type { ConnectionState } from "../../orm/client";
13+
export type { ConnectionState } from "../../orm/client";
14+
/**
15+
* Hook to observe the WebSocket connection state.
16+
*
17+
* Returns the current connection state of the realtime WebSocket.
18+
* Returns 'disconnected' if realtime is not configured.
19+
*
20+
* @example
21+
* \`\`\`tsx
22+
* const state = useConnectionState();
23+
* // state: 'disconnected' | 'connecting' | 'connected' | 'reconnecting'
24+
* \`\`\`
25+
*/
26+
export function useConnectionState(): ConnectionState {
27+
const [state, setState] = useState<ConnectionState>(() => getClient().getConnectionState());
28+
useEffect(() => {
29+
const client = getClient();
30+
if (!client.isRealtimeEnabled) return;
31+
const unsubscribe = client.onConnectionStateChange(setState);
32+
return () => unsubscribe();
33+
}, []);
34+
return state;
35+
}
36+
"
37+
`;
38+
39+
exports[`Subscription Barrel Generator generates barrel with subscription hooks and connection state 1`] = `
40+
"/**
41+
* Subscription hooks barrel export
42+
* @generated by @constructive-io/graphql-codegen
43+
* DO NOT EDIT - changes will be overwritten
44+
*/
45+
export * from "./useContactSubscription";
46+
export * from "./useProjectSubscription";
47+
export * from "./useConnectionState";"
48+
`;
49+
50+
exports[`Subscription Hook Generator generateSubscriptionHook generates subscription hook for Contact table 1`] = `
51+
"/**
52+
* Subscription hook for Contact
53+
* @generated by @constructive-io/graphql-codegen
54+
* DO NOT EDIT - changes will be overwritten
55+
*/
56+
57+
import { useEffect, useRef, useCallback } from "react";
58+
import { useQueryClient } from "@tanstack/react-query";
59+
import type { QueryClient } from "@tanstack/react-query";
60+
import { getClient } from "../client";
61+
import type { SubscriptionEvent, SubscriptionFieldMeta, Unsubscribe } from "../../orm/client";
62+
import type { Contact } from "../../orm/input-types";
63+
import { contactKeys } from "../query-keys";
64+
export type { SubscriptionEvent, Unsubscribe } from "../../orm/client";
65+
const SUBSCRIPTION_DOCUMENT = "subscription OnContactChanged {\\n onContactChanged {\\n event\\n contact { __typename }\\n timestamp\\n }\\n}";
66+
const FIELD_META: SubscriptionFieldMeta = {
67+
fieldName: "onContactChanged",
68+
tableName: "contact",
69+
dataFieldName: "contact"
70+
};
71+
export interface ContactSubscriptionOptions {
72+
onEvent: (event: SubscriptionEvent<Contact>) => void;
73+
onError?: (error: Error) => void;
74+
enabled?: boolean;
75+
invalidateQueries?: boolean;
76+
}
77+
/**
78+
* Subscription hook for Contact realtime events
79+
*
80+
* Subscribes to realtime changes on the server and automatically
81+
* invalidates React Query cache when events are received.
82+
*
83+
* @example
84+
* \`\`\`tsx
85+
* useContactSubscription({
86+
* onEvent: (event) => {
87+
* console.log(event.operation, event.data);
88+
* },
89+
* });
90+
* \`\`\`
91+
*/
92+
export function useContactSubscription(options: ContactSubscriptionOptions): void {
93+
const queryClient = useQueryClient();
94+
const optionsRef = useRef(options);
95+
optionsRef.current = options;
96+
useEffect(() => {
97+
if (options.enabled === false) return;
98+
const client = getClient();
99+
if (!client.isRealtimeEnabled) return;
100+
const unsubscribe = client.subscribe(FIELD_META, SUBSCRIPTION_DOCUMENT, {}, {
101+
onEvent: event => {
102+
optionsRef.current.onEvent(event);
103+
if (optionsRef.current.invalidateQueries !== false) queryClient.invalidateQueries({
104+
queryKey: contactKeys.all
105+
});
106+
},
107+
onError: err => {
108+
optionsRef.current?.onError(err);
109+
}
110+
});
111+
return () => unsubscribe();
112+
}, [options.enabled, queryClient]);
113+
}
114+
"
115+
`;
116+
117+
exports[`Subscription Hook Generator generateSubscriptionHook generates subscription hook for Project table 1`] = `
118+
"/**
119+
* Subscription hook for Project
120+
* @generated by @constructive-io/graphql-codegen
121+
* DO NOT EDIT - changes will be overwritten
122+
*/
123+
124+
import { useEffect, useRef, useCallback } from "react";
125+
import { useQueryClient } from "@tanstack/react-query";
126+
import type { QueryClient } from "@tanstack/react-query";
127+
import { getClient } from "../client";
128+
import type { SubscriptionEvent, SubscriptionFieldMeta, Unsubscribe } from "../../orm/client";
129+
import type { Project } from "../../orm/input-types";
130+
import { projectKeys } from "../query-keys";
131+
export type { SubscriptionEvent, Unsubscribe } from "../../orm/client";
132+
const SUBSCRIPTION_DOCUMENT = "subscription OnProjectChanged {\\n onProjectChanged {\\n event\\n project { __typename }\\n timestamp\\n }\\n}";
133+
const FIELD_META: SubscriptionFieldMeta = {
134+
fieldName: "onProjectChanged",
135+
tableName: "project",
136+
dataFieldName: "project"
137+
};
138+
export interface ProjectSubscriptionOptions {
139+
onEvent: (event: SubscriptionEvent<Project>) => void;
140+
onError?: (error: Error) => void;
141+
enabled?: boolean;
142+
invalidateQueries?: boolean;
143+
}
144+
/**
145+
* Subscription hook for Project realtime events
146+
*
147+
* Subscribes to realtime changes on the server and automatically
148+
* invalidates React Query cache when events are received.
149+
*
150+
* @example
151+
* \`\`\`tsx
152+
* useProjectSubscription({
153+
* onEvent: (event) => {
154+
* console.log(event.operation, event.data);
155+
* },
156+
* });
157+
* \`\`\`
158+
*/
159+
export function useProjectSubscription(options: ProjectSubscriptionOptions): void {
160+
const queryClient = useQueryClient();
161+
const optionsRef = useRef(options);
162+
optionsRef.current = options;
163+
useEffect(() => {
164+
if (options.enabled === false) return;
165+
const client = getClient();
166+
if (!client.isRealtimeEnabled) return;
167+
const unsubscribe = client.subscribe(FIELD_META, SUBSCRIPTION_DOCUMENT, {}, {
168+
onEvent: event => {
169+
optionsRef.current.onEvent(event);
170+
if (optionsRef.current.invalidateQueries !== false) queryClient.invalidateQueries({
171+
queryKey: projectKeys.all
172+
});
173+
},
174+
onError: err => {
175+
optionsRef.current?.onError(err);
176+
}
177+
});
178+
return () => unsubscribe();
179+
}, [options.enabled, queryClient]);
180+
}
181+
"
182+
`;

0 commit comments

Comments
 (0)