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
Original file line number Diff line number Diff line change
Expand Up @@ -111,12 +111,35 @@ import type {
} from '@constructive-io/graphql-query/runtime';
import { createFetch } from '@constructive-io/graphql-query/runtime';

import type {
ConnectionState,
ConnectionStateListener,
RealtimeConfig,
SubscribeOptions,
SubscriptionEvent,
SubscriptionFieldMeta,
Unsubscribe,
} from './realtime';
import { RealtimeManager } from './realtime';

export type {
GraphQLAdapter,
GraphQLError,
QueryResult,
} from '@constructive-io/graphql-query/runtime';

export type {
ConnectionState,
ConnectionStateListener,
RealtimeConfig,
SubscribeOptions,
SubscriptionEvent,
SubscriptionFieldMeta,
SubscriptionOperation,
Unsubscribe,
} from './realtime';
export { RealtimeManager } from './realtime';

/**
* Default adapter that uses fetch for HTTP requests.
*
Expand Down Expand Up @@ -213,6 +236,12 @@ export interface OrmClientConfig {
fetch?: typeof globalThis.fetch;
/** Custom adapter for GraphQL execution (overrides endpoint/headers/fetch) */
adapter?: GraphQLAdapter;
/**
* Optional realtime (WebSocket) configuration.
* When provided, enables subscription methods on models.
* The WebSocket connection is created lazily on first subscribe().
*/
realtime?: RealtimeConfig;
}

/**
Expand All @@ -231,6 +260,7 @@ export class GraphQLRequestError extends Error {

export class OrmClient {
private adapter: GraphQLAdapter;
private realtimeManager?: RealtimeManager;

constructor(config: OrmClientConfig) {
if (config.adapter) {
Expand All @@ -246,6 +276,10 @@ export class OrmClient {
'OrmClientConfig requires either an endpoint or a custom adapter',
);
}

if (config.realtime) {
this.realtimeManager = new RealtimeManager(config.realtime);
}
}

async execute<T>(
Expand All @@ -255,6 +289,34 @@ export class OrmClient {
return this.adapter.execute<T>(document, variables);
}

/**
* Subscribe to a GraphQL subscription operation.
* Used by generated model subscribe() methods.
* @throws Error if realtime is not configured
*/
subscribe<T>(
meta: SubscriptionFieldMeta,
document: string,
variables: Record<string, unknown>,
options: {
onEvent: (event: SubscriptionEvent<T>) => void;
onError?: (error: Error) => void;
onComplete?: () => void;
},
): Unsubscribe {
if (!this.realtimeManager) {
throw new Error(
'Realtime not configured. Pass a \`realtime\` option to createClient() to enable subscriptions.',
);
}
return this.realtimeManager.subscribe<T>(
meta,
document,
variables,
options,
);
}

/**
* Set headers for requests.
* Only works if the adapter supports headers.
Expand All @@ -272,6 +334,34 @@ export class OrmClient {
getEndpoint(): string {
return this.adapter.getEndpoint?.() ?? '';
}

/** Get current WebSocket connection state */
getConnectionState(): ConnectionState {
return this.realtimeManager?.getConnectionState() ?? 'disconnected';
}

/** Register a listener for WebSocket connection state changes */
onConnectionStateChange(
listener: ConnectionStateListener,
): Unsubscribe {
if (!this.realtimeManager) return () => {};
return this.realtimeManager.onConnectionStateChange(listener);
}

/** Number of active subscriptions */
getActiveSubscriptionCount(): number {
return this.realtimeManager?.getActiveSubscriptionCount() ?? 0;
}

/** Whether realtime is configured */
get isRealtimeEnabled(): boolean {
return this.realtimeManager !== undefined;
}

/** Dispose the realtime manager (close WebSocket) */
dispose(): void {
this.realtimeManager?.dispose();
}
}
"
`;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing

exports[`Connection State Hook Generator generateConnectionStateHook generates useConnectionState hook 1`] = `
"/**
* WebSocket connection state hook
* @generated by @constructive-io/graphql-codegen
* DO NOT EDIT - changes will be overwritten
*/

import { useState, useEffect } from "react";
import { getClient } from "../client";
import type { ConnectionState } from "../../orm/client";
export type { ConnectionState } from "../../orm/client";
/**
* Hook to observe the WebSocket connection state.
*
* Returns the current connection state of the realtime WebSocket.
* Returns 'disconnected' if realtime is not configured.
*
* @example
* \`\`\`tsx
* const state = useConnectionState();
* // state: 'disconnected' | 'connecting' | 'connected' | 'reconnecting'
* \`\`\`
*/
export function useConnectionState(): ConnectionState {
const [state, setState] = useState<ConnectionState>(() => getClient().getConnectionState());
useEffect(() => {
const client = getClient();
if (!client.isRealtimeEnabled) return;
const unsubscribe = client.onConnectionStateChange(setState);
return () => unsubscribe();
}, []);
return state;
}
"
`;

exports[`Subscription Barrel Generator generates barrel with subscription hooks and connection state 1`] = `
"/**
* Subscription hooks barrel export
* @generated by @constructive-io/graphql-codegen
* DO NOT EDIT - changes will be overwritten
*/
export * from "./useContactSubscription";
export * from "./useProjectSubscription";
export * from "./useConnectionState";"
`;

exports[`Subscription Hook Generator generateSubscriptionHook generates subscription hook for Contact table 1`] = `
"/**
* Subscription hook for Contact
* @generated by @constructive-io/graphql-codegen
* DO NOT EDIT - changes will be overwritten
*/

import { useEffect, useRef, useCallback } from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { QueryClient } from "@tanstack/react-query";
import { getClient } from "../client";
import type { SubscriptionEvent, SubscriptionFieldMeta, Unsubscribe } from "../../orm/client";
import type { Contact } from "../../orm/input-types";
import { contactKeys } from "../query-keys";
export type { SubscriptionEvent, Unsubscribe } from "../../orm/client";
const SUBSCRIPTION_DOCUMENT = "subscription OnContactChanged {\\n onContactChanged {\\n event\\n contact { __typename }\\n timestamp\\n }\\n}";
const FIELD_META: SubscriptionFieldMeta = {
fieldName: "onContactChanged",
tableName: "contact",
dataFieldName: "contact"
};
export interface ContactSubscriptionOptions {
onEvent: (event: SubscriptionEvent<Contact>) => void;
onError?: (error: Error) => void;
enabled?: boolean;
invalidateQueries?: boolean;
}
/**
* Subscription hook for Contact realtime events
*
* Subscribes to realtime changes on the server and automatically
* invalidates React Query cache when events are received.
*
* @example
* \`\`\`tsx
* useContactSubscription({
* onEvent: (event) => {
* console.log(event.operation, event.data);
* },
* });
* \`\`\`
*/
export function useContactSubscription(options: ContactSubscriptionOptions): void {
const queryClient = useQueryClient();
const optionsRef = useRef(options);
optionsRef.current = options;
useEffect(() => {
if (options.enabled === false) return;
const client = getClient();
if (!client.isRealtimeEnabled) return;
const unsubscribe = client.subscribe(FIELD_META, SUBSCRIPTION_DOCUMENT, {}, {
onEvent: event => {
optionsRef.current.onEvent(event);
if (optionsRef.current.invalidateQueries !== false) queryClient.invalidateQueries({
queryKey: contactKeys.all
});
},
onError: err => {
optionsRef.current?.onError(err);
}
});
return () => unsubscribe();
}, [options.enabled, queryClient]);
}
"
`;

exports[`Subscription Hook Generator generateSubscriptionHook generates subscription hook for Project table 1`] = `
"/**
* Subscription hook for Project
* @generated by @constructive-io/graphql-codegen
* DO NOT EDIT - changes will be overwritten
*/

import { useEffect, useRef, useCallback } from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { QueryClient } from "@tanstack/react-query";
import { getClient } from "../client";
import type { SubscriptionEvent, SubscriptionFieldMeta, Unsubscribe } from "../../orm/client";
import type { Project } from "../../orm/input-types";
import { projectKeys } from "../query-keys";
export type { SubscriptionEvent, Unsubscribe } from "../../orm/client";
const SUBSCRIPTION_DOCUMENT = "subscription OnProjectChanged {\\n onProjectChanged {\\n event\\n project { __typename }\\n timestamp\\n }\\n}";
const FIELD_META: SubscriptionFieldMeta = {
fieldName: "onProjectChanged",
tableName: "project",
dataFieldName: "project"
};
export interface ProjectSubscriptionOptions {
onEvent: (event: SubscriptionEvent<Project>) => void;
onError?: (error: Error) => void;
enabled?: boolean;
invalidateQueries?: boolean;
}
/**
* Subscription hook for Project realtime events
*
* Subscribes to realtime changes on the server and automatically
* invalidates React Query cache when events are received.
*
* @example
* \`\`\`tsx
* useProjectSubscription({
* onEvent: (event) => {
* console.log(event.operation, event.data);
* },
* });
* \`\`\`
*/
export function useProjectSubscription(options: ProjectSubscriptionOptions): void {
const queryClient = useQueryClient();
const optionsRef = useRef(options);
optionsRef.current = options;
useEffect(() => {
if (options.enabled === false) return;
const client = getClient();
if (!client.isRealtimeEnabled) return;
const unsubscribe = client.subscribe(FIELD_META, SUBSCRIPTION_DOCUMENT, {}, {
onEvent: event => {
optionsRef.current.onEvent(event);
if (optionsRef.current.invalidateQueries !== false) queryClient.invalidateQueries({
queryKey: projectKeys.all
});
},
onError: err => {
optionsRef.current?.onError(err);
}
});
return () => unsubscribe();
}, [options.enabled, queryClient]);
}
"
`;
Loading
Loading