From cb3bd3fc40334e2600e84164706c2fd78f2145cf Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sun, 10 May 2026 08:55:45 +0000 Subject: [PATCH 1/3] feat: add ORM-integrated realtime subscription support (Variation F) - Add RealtimeManager runtime template (orm-realtime.ts) with WebSocket lifecycle management, subscription multiplexing, and connection state - Extend OrmClient template with optional realtime config, subscribe(), getConnectionState(), onConnectionStateChange(), dispose() methods - Add type stub for realtime types (SubscriptionEvent, RealtimeConfig, etc.) - Create subscription hook generator producing per-table useXxxSubscription hooks with React Query cache invalidation bridge - Create useConnectionState hook generator for connection status UI - Add subscription naming utils (getSubscriptionHookName, etc.) - Wire realtime file into ORM orchestrator and subscription hooks into main codegen orchestrator with barrel exports - Add 25 tests for subscription generators (4 snapshots) - Update client-generator snapshot for realtime imports Zero overhead: apps without realtime config have no WebSocket dependency. All 339 tests pass across 20 suites. --- .../client-generator.test.ts.snap | 90 +++ .../subscription-hooks.test.ts.snap | 182 ++++++ .../codegen/subscription-hooks.test.ts | 245 ++++++++ graphql/codegen/src/core/codegen/barrel.ts | 34 ++ graphql/codegen/src/core/codegen/index.ts | 39 ++ .../src/core/codegen/orm/client-generator.ts | 15 + .../codegen/src/core/codegen/orm/client.ts | 69 +++ graphql/codegen/src/core/codegen/orm/index.ts | 7 +- .../codegen/src/core/codegen/subscriptions.ts | 566 ++++++++++++++++++ .../src/core/codegen/templates/orm-client.ts | 90 +++ .../core/codegen/templates/orm-realtime.ts | 291 +++++++++ graphql/codegen/src/core/codegen/utils.ts | 26 + 12 files changed, 1653 insertions(+), 1 deletion(-) create mode 100644 graphql/codegen/src/__tests__/codegen/__snapshots__/subscription-hooks.test.ts.snap create mode 100644 graphql/codegen/src/__tests__/codegen/subscription-hooks.test.ts create mode 100644 graphql/codegen/src/core/codegen/subscriptions.ts create mode 100644 graphql/codegen/src/core/codegen/templates/orm-realtime.ts diff --git a/graphql/codegen/src/__tests__/codegen/__snapshots__/client-generator.test.ts.snap b/graphql/codegen/src/__tests__/codegen/__snapshots__/client-generator.test.ts.snap index 96047e1426..ec4ad6d1dc 100644 --- a/graphql/codegen/src/__tests__/codegen/__snapshots__/client-generator.test.ts.snap +++ b/graphql/codegen/src/__tests__/codegen/__snapshots__/client-generator.test.ts.snap @@ -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. * @@ -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; } /** @@ -231,6 +260,7 @@ export class GraphQLRequestError extends Error { export class OrmClient { private adapter: GraphQLAdapter; + private realtimeManager?: RealtimeManager; constructor(config: OrmClientConfig) { if (config.adapter) { @@ -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( @@ -255,6 +289,34 @@ export class OrmClient { return this.adapter.execute(document, variables); } + /** + * Subscribe to a GraphQL subscription operation. + * Used by generated model subscribe() methods. + * @throws Error if realtime is not configured + */ + subscribe( + meta: SubscriptionFieldMeta, + document: string, + variables: Record, + options: { + onEvent: (event: SubscriptionEvent) => 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( + meta, + document, + variables, + options, + ); + } + /** * Set headers for requests. * Only works if the adapter supports headers. @@ -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(); + } } " `; diff --git a/graphql/codegen/src/__tests__/codegen/__snapshots__/subscription-hooks.test.ts.snap b/graphql/codegen/src/__tests__/codegen/__snapshots__/subscription-hooks.test.ts.snap new file mode 100644 index 0000000000..d252ab1d4a --- /dev/null +++ b/graphql/codegen/src/__tests__/codegen/__snapshots__/subscription-hooks.test.ts.snap @@ -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(() => 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) => 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) => 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]); +} +" +`; diff --git a/graphql/codegen/src/__tests__/codegen/subscription-hooks.test.ts b/graphql/codegen/src/__tests__/codegen/subscription-hooks.test.ts new file mode 100644 index 0000000000..8c9374ef66 --- /dev/null +++ b/graphql/codegen/src/__tests__/codegen/subscription-hooks.test.ts @@ -0,0 +1,245 @@ +/** + * Snapshot tests for subscription hook generators + * + * Tests the generated hook code for: + * - Per-table subscription hooks (useContactSubscription, etc.) + * - Connection state hook (useConnectionState) + * - Subscription barrel file + */ +import { generateSubscriptionsBarrel } from '../../core/codegen/barrel'; +import { + generateAllSubscriptionHooks, + generateConnectionStateHook, + generateSubscriptionHook, +} from '../../core/codegen/subscriptions'; +import { + getSubscriptionFieldName, + getSubscriptionFileName, + getSubscriptionHookName, +} from '../../core/codegen/utils'; +import type { FieldType, Relations, Table } from '../../types/schema'; + +const fieldTypes = { + uuid: { gqlType: 'UUID', isArray: false } as FieldType, + string: { gqlType: 'String', isArray: false } as FieldType, + int: { gqlType: 'Int', isArray: false } as FieldType, + datetime: { gqlType: 'Datetime', isArray: false } as FieldType, + boolean: { gqlType: 'Boolean', isArray: false } as FieldType, +}; + +const emptyRelations: Relations = { + belongsTo: [], + hasOne: [], + hasMany: [], + manyToMany: [], +}; + +function createTable(partial: Partial & { name: string }): Table { + return { + name: partial.name, + fields: partial.fields ?? [], + relations: partial.relations ?? emptyRelations, + query: partial.query, + inflection: partial.inflection, + constraints: partial.constraints, + }; +} + +const contactTable = createTable({ + name: 'Contact', + fields: [ + { name: 'id', type: fieldTypes.uuid }, + { name: 'firstName', type: fieldTypes.string }, + { name: 'lastName', type: fieldTypes.string }, + { name: 'email', type: fieldTypes.string }, + { name: 'createdAt', type: fieldTypes.datetime }, + ], + query: { + all: 'contacts', + one: 'contact', + create: 'createContact', + update: 'updateContact', + delete: 'deleteContact', + }, +}); + +const projectTable = createTable({ + name: 'Project', + fields: [ + { name: 'id', type: fieldTypes.uuid }, + { name: 'name', type: fieldTypes.string }, + { name: 'active', type: fieldTypes.boolean }, + { name: 'createdAt', type: fieldTypes.datetime }, + ], + query: { + all: 'projects', + one: 'project', + create: 'createProject', + update: 'updateProject', + delete: 'deleteProject', + }, +}); + +describe('Subscription naming utils', () => { + it('generates subscription hook name', () => { + expect(getSubscriptionHookName(contactTable)).toBe( + 'useContactSubscription', + ); + expect(getSubscriptionHookName(projectTable)).toBe( + 'useProjectSubscription', + ); + }); + + it('generates subscription file name', () => { + expect(getSubscriptionFileName(contactTable)).toBe( + 'useContactSubscription.ts', + ); + }); + + it('generates subscription field name', () => { + expect(getSubscriptionFieldName(contactTable)).toBe('onContactChanged'); + expect(getSubscriptionFieldName(projectTable)).toBe('onProjectChanged'); + }); +}); + +describe('Subscription Hook Generator', () => { + describe('generateSubscriptionHook', () => { + it('generates subscription hook for Contact table', () => { + const result = generateSubscriptionHook(contactTable); + expect(result.fileName).toBe('useContactSubscription.ts'); + expect(result.content).toMatchSnapshot(); + }); + + it('generates subscription hook for Project table', () => { + const result = generateSubscriptionHook(projectTable); + expect(result.fileName).toBe('useProjectSubscription.ts'); + expect(result.content).toMatchSnapshot(); + }); + + it('includes subscription document with correct field name', () => { + const result = generateSubscriptionHook(contactTable); + expect(result.content).toContain('onContactChanged'); + expect(result.content).toContain('SUBSCRIPTION_DOCUMENT'); + }); + + it('includes field metadata constant', () => { + const result = generateSubscriptionHook(contactTable); + expect(result.content).toContain('FIELD_META'); + expect(result.content).toContain('"onContactChanged"'); + expect(result.content).toContain('"contact"'); + }); + + it('imports from ORM client for types', () => { + const result = generateSubscriptionHook(contactTable); + expect(result.content).toContain('../../orm/client'); + expect(result.content).toContain('SubscriptionEvent'); + expect(result.content).toContain('Unsubscribe'); + }); + + it('imports query keys for cache invalidation', () => { + const result = generateSubscriptionHook(contactTable); + expect(result.content).toContain('contactKeys'); + expect(result.content).toContain('invalidateQueries'); + }); + + it('exports options interface', () => { + const result = generateSubscriptionHook(contactTable); + expect(result.content).toContain('ContactSubscriptionOptions'); + }); + + it('includes useEffect for subscription lifecycle', () => { + const result = generateSubscriptionHook(contactTable); + expect(result.content).toContain('useEffect'); + expect(result.content).toContain('useRef'); + }); + + it('checks isRealtimeEnabled before subscribing', () => { + const result = generateSubscriptionHook(contactTable); + expect(result.content).toContain('isRealtimeEnabled'); + }); + + it('re-exports SubscriptionEvent type', () => { + const result = generateSubscriptionHook(contactTable); + // Should re-export for consumer convenience + expect(result.content).toContain('SubscriptionEvent'); + }); + }); + + describe('generateAllSubscriptionHooks', () => { + it('generates hooks for all tables', () => { + const results = generateAllSubscriptionHooks([ + contactTable, + projectTable, + ]); + expect(results).toHaveLength(2); + expect(results[0].fileName).toBe('useContactSubscription.ts'); + expect(results[1].fileName).toBe('useProjectSubscription.ts'); + }); + + it('returns empty array for no tables', () => { + const results = generateAllSubscriptionHooks([]); + expect(results).toHaveLength(0); + }); + }); +}); + +describe('Connection State Hook Generator', () => { + describe('generateConnectionStateHook', () => { + it('generates useConnectionState hook', () => { + const result = generateConnectionStateHook(); + expect(result.fileName).toBe('useConnectionState.ts'); + expect(result.content).toMatchSnapshot(); + }); + + it('imports ConnectionState type from ORM client', () => { + const result = generateConnectionStateHook(); + expect(result.content).toContain('ConnectionState'); + expect(result.content).toContain('../../orm/client'); + }); + + it('uses useState and useEffect', () => { + const result = generateConnectionStateHook(); + expect(result.content).toContain('useState'); + expect(result.content).toContain('useEffect'); + }); + + it('calls getClient for connection state', () => { + const result = generateConnectionStateHook(); + expect(result.content).toContain('getClient'); + expect(result.content).toContain('getConnectionState'); + }); + + it('subscribes to connection state changes', () => { + const result = generateConnectionStateHook(); + expect(result.content).toContain('onConnectionStateChange'); + }); + + it('checks isRealtimeEnabled', () => { + const result = generateConnectionStateHook(); + expect(result.content).toContain('isRealtimeEnabled'); + }); + + it('re-exports ConnectionState type', () => { + const result = generateConnectionStateHook(); + expect(result.content).toContain('ConnectionState'); + }); + }); +}); + +describe('Subscription Barrel Generator', () => { + it('generates barrel with subscription hooks and connection state', () => { + const result = generateSubscriptionsBarrel([contactTable, projectTable]); + expect(result).toMatchSnapshot(); + }); + + it('includes connection state hook export', () => { + const result = generateSubscriptionsBarrel([contactTable]); + expect(result).toContain('./useConnectionState'); + }); + + it('includes per-table subscription hook exports', () => { + const result = generateSubscriptionsBarrel([contactTable, projectTable]); + expect(result).toContain('./useContactSubscription'); + expect(result).toContain('./useProjectSubscription'); + }); +}); diff --git a/graphql/codegen/src/core/codegen/barrel.ts b/graphql/codegen/src/core/codegen/barrel.ts index 7eb68110c6..12da70e643 100644 --- a/graphql/codegen/src/core/codegen/barrel.ts +++ b/graphql/codegen/src/core/codegen/barrel.ts @@ -13,6 +13,7 @@ import { getDeleteMutationHookName, getListQueryHookName, getSingleQueryHookName, + getSubscriptionHookName, getUpdateMutationHookName, hasValidPrimaryKey, } from './utils'; @@ -97,6 +98,8 @@ export function generateMutationsBarrel(tables: Table[]): string { */ export interface MainBarrelOptions { hasMutations?: boolean; + /** Whether subscriptions/ directory was generated */ + hasSubscriptions?: boolean; /** Whether query-keys.ts was generated */ hasQueryKeys?: boolean; /** Whether mutation-keys.ts was generated */ @@ -113,6 +116,7 @@ export function generateMainBarrel( const { hasMutations = true, + hasSubscriptions = false, hasQueryKeys = false, hasMutationKeys = false, hasInvalidation = false, @@ -147,6 +151,11 @@ export function generateMainBarrel( statements.push(exportAllFrom('./mutations')); } + // Subscription hooks + if (hasSubscriptions) { + statements.push(exportAllFrom('./subscriptions')); + } + // Add file header as leading comment on first statement if (statements.length > 0) { addJSDocComment(statements[0], [ @@ -283,6 +292,31 @@ export function generateMultiTargetBarrel(targetNames: string[]): string { return generateCode(statements); } +/** + * Generate the subscriptions/index.ts barrel file + */ +export function generateSubscriptionsBarrel(tables: Table[]): string { + const statements: t.Statement[] = []; + + for (const table of tables) { + const hookName = getSubscriptionHookName(table); + statements.push(exportAllFrom(`./${hookName}`)); + } + + // Connection state hook + statements.push(exportAllFrom('./useConnectionState')); + + if (statements.length > 0) { + addJSDocComment(statements[0], [ + 'Subscription hooks barrel export', + '@generated by @constructive-io/graphql-codegen', + 'DO NOT EDIT - changes will be overwritten', + ]); + } + + return generateCode(statements); +} + // ============================================================================ // Custom operation barrels (includes both table and custom hooks) // ============================================================================ diff --git a/graphql/codegen/src/core/codegen/index.ts b/graphql/codegen/src/core/codegen/index.ts index deb8a3106a..ef804f844f 100644 --- a/graphql/codegen/src/core/codegen/index.ts +++ b/graphql/codegen/src/core/codegen/index.ts @@ -38,6 +38,7 @@ import { generateMainBarrel, generateMutationsBarrel, generateQueriesBarrel, + generateSubscriptionsBarrel, } from './barrel'; import { generateClientFile } from './client'; import { generateAllCustomMutationHooks } from './custom-mutations'; @@ -47,6 +48,10 @@ import { generateMutationKeysFile } from './mutation-keys'; import { generateAllMutationHooks } from './mutations'; import { generateAllQueryHooks } from './queries'; import { generateQueryKeysFile } from './query-keys'; +import { + generateAllSubscriptionHooks, + generateConnectionStateHook, +} from './subscriptions'; import { generateSelectionFile } from './selection'; import { getTableNames } from './utils'; @@ -67,6 +72,7 @@ export interface GenerateResult { tables: number; queryHooks: number; mutationHooks: number; + subscriptionHooks: number; customQueryHooks: number; customMutationHooks: number; totalFiles: number; @@ -292,12 +298,38 @@ export function generate(options: GenerateOptions): GenerateResult { }); } + // 8b. Generate subscription hooks (subscriptions/*.ts) + const subscriptionHooks = generateAllSubscriptionHooks(tables); + for (const hook of subscriptionHooks) { + files.push({ + path: `subscriptions/${hook.fileName}`, + content: hook.content, + }); + } + + // 8c. Generate connection state hook + const connectionStateHook = generateConnectionStateHook(); + files.push({ + path: `subscriptions/${connectionStateHook.fileName}`, + content: connectionStateHook.content, + }); + + // 8d. Generate subscriptions/index.ts barrel + const hasSubscriptions = subscriptionHooks.length > 0; + if (hasSubscriptions) { + files.push({ + path: 'subscriptions/index.ts', + content: generateSubscriptionsBarrel(tables), + }); + } + // 9. Generate main index.ts barrel // No longer includes types.ts or schema-types.ts - hooks import from ORM directly files.push({ path: 'index.ts', content: generateMainBarrel(tables, { hasMutations, + hasSubscriptions, hasQueryKeys, hasMutationKeys, hasInvalidation, @@ -310,6 +342,7 @@ export function generate(options: GenerateOptions): GenerateResult { tables: tables.length, queryHooks: queryHooks.length, mutationHooks: mutationHooks.length, + subscriptionHooks: subscriptionHooks.length, customQueryHooks: customQueryHooks.length, customMutationHooks: customMutationHooks.length, totalFiles: files.length, @@ -327,6 +360,7 @@ export { generateMainBarrel, generateMutationsBarrel, generateQueriesBarrel, + generateSubscriptionsBarrel, } from './barrel'; export { generateClientFile } from './client'; export { @@ -351,3 +385,8 @@ export { generateSingleQueryHook, } from './queries'; export { generateQueryKeysFile } from './query-keys'; +export { + generateAllSubscriptionHooks, + generateConnectionStateHook, + generateSubscriptionHook, +} from './subscriptions'; diff --git a/graphql/codegen/src/core/codegen/orm/client-generator.ts b/graphql/codegen/src/core/codegen/orm/client-generator.ts index 9148cd13b9..a83c716dc7 100644 --- a/graphql/codegen/src/core/codegen/orm/client-generator.ts +++ b/graphql/codegen/src/core/codegen/orm/client-generator.ts @@ -69,6 +69,21 @@ export function generateOrmClientFile(): GeneratedClientFile { }; } +/** + * Generate the realtime.ts file (RealtimeManager + subscription types) + * + * Reads from the templates directory for proper type checking. + */ +export function generateRealtimeFile(): GeneratedClientFile { + return { + fileName: 'realtime.ts', + content: readTemplateFile( + 'orm-realtime.ts', + 'Realtime Manager - WebSocket subscription support', + ), + }; +} + /** * Generate the query-builder.ts file (runtime query builder) * diff --git a/graphql/codegen/src/core/codegen/orm/client.ts b/graphql/codegen/src/core/codegen/orm/client.ts index 194f5f9de1..6caca4cff6 100644 --- a/graphql/codegen/src/core/codegen/orm/client.ts +++ b/graphql/codegen/src/core/codegen/orm/client.ts @@ -101,6 +101,42 @@ export class FetchAdapter implements GraphQLAdapter { } } +export type SubscriptionOperation = 'INSERT' | 'UPDATE' | 'DELETE'; +export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting'; +export type ConnectionStateListener = (state: ConnectionState) => void; +export type Unsubscribe = () => void; + +export interface SubscriptionEvent { + operation: SubscriptionOperation; + data: T | null; + previousValues?: Partial; + timestamp: string; +} + +export interface SubscriptionFieldMeta { + fieldName: string; + tableName: string; + dataFieldName: string; +} + +export interface SubscribeOptions> { + filter?: TFilter; + onEvent: (event: SubscriptionEvent) => void; + onError?: (error: Error) => void; + onComplete?: () => void; +} + +export interface RealtimeConfig { + url: string; + getToken?: () => string | Promise; + connectionParams?: Record; + lazy?: boolean; + retryAttempts?: number; + retryWait?: number | ((retryCount: number) => number | Promise); + onConnected?: () => void; + onDisconnected?: (reason?: unknown) => void; +} + /** * Configuration for creating an ORM client. */ @@ -111,6 +147,8 @@ export interface OrmClientConfig { headers?: Record; /** Custom adapter for GraphQL execution (overrides endpoint/headers) */ adapter?: GraphQLAdapter; + /** Optional realtime (WebSocket) configuration */ + realtime?: RealtimeConfig; } export class GraphQLRequestError extends Error { @@ -146,6 +184,19 @@ export class OrmClient { return this.adapter.execute(document, variables); } + subscribe( + meta: SubscriptionFieldMeta, + document: string, + variables: Record, + options: { + onEvent: (event: SubscriptionEvent) => void; + onError?: (error: Error) => void; + onComplete?: () => void; + }, + ): Unsubscribe { + throw new Error('Realtime not configured'); + } + setHeaders(headers: Record): void { if (this.adapter.setHeaders) { this.adapter.setHeaders(headers); @@ -155,4 +206,22 @@ export class OrmClient { getEndpoint(): string { return this.adapter.getEndpoint?.() ?? ''; } + + getConnectionState(): ConnectionState { + return 'disconnected'; + } + + onConnectionStateChange(listener: ConnectionStateListener): Unsubscribe { + return () => {}; + } + + getActiveSubscriptionCount(): number { + return 0; + } + + get isRealtimeEnabled(): boolean { + return false; + } + + dispose(): void {} } diff --git a/graphql/codegen/src/core/codegen/orm/index.ts b/graphql/codegen/src/core/codegen/orm/index.ts index 1d95a681d1..2d2c9b866c 100644 --- a/graphql/codegen/src/core/codegen/orm/index.ts +++ b/graphql/codegen/src/core/codegen/orm/index.ts @@ -15,6 +15,7 @@ import { generateCreateClientFile, generateOrmClientFile, generateQueryBuilderFile, + generateRealtimeFile, generateSelectTypesFile, } from './client-generator'; import { @@ -74,10 +75,13 @@ export function generateOrm(options: GenerateOrmOptions): GenerateOrmResult { const hasCustomMutations = (customOperations?.mutations.length ?? 0) > 0; const typeRegistry = customOperations?.typeRegistry; - // 1. Generate runtime files (client, query-builder, select-types) + // 1. Generate runtime files (client, query-builder, select-types, realtime) const clientFile = generateOrmClientFile(); files.push({ path: clientFile.fileName, content: clientFile.content }); + const realtimeFile = generateRealtimeFile(); + files.push({ path: realtimeFile.fileName, content: realtimeFile.content }); + const queryBuilderFile = generateQueryBuilderFile(); files.push({ path: queryBuilderFile.fileName, @@ -193,6 +197,7 @@ export { generateModelsBarrel, generateTypesBarrel } from './barrel'; export { generateOrmClientFile, generateQueryBuilderFile, + generateRealtimeFile, generateSelectTypesFile, } from './client-generator'; export { diff --git a/graphql/codegen/src/core/codegen/subscriptions.ts b/graphql/codegen/src/core/codegen/subscriptions.ts new file mode 100644 index 0000000000..cf00ae7246 --- /dev/null +++ b/graphql/codegen/src/core/codegen/subscriptions.ts @@ -0,0 +1,566 @@ +/** + * Subscription hook generators - delegates to ORM client subscribe (Babel AST-based) + * + * Output structure: + * subscriptions/ + * useContactSubscription.ts - Subscription hook -> ORM client.subscribe() + * useConnectionState.ts - Connection state hook + */ +import * as t from '@babel/types'; + +import type { Table } from '../../types/schema'; +import { + addJSDocComment, + callExpr, + constDecl, + createFunctionParam, + createImportDeclaration, + createTypeReExport, + exportDeclareFunction, + exportFunction, + generateHookFileCode, + objectProp, + typeRef, +} from './hooks-ast'; +import { + getSubscriptionFieldName, + getSubscriptionFileName, + getSubscriptionHookName, + getTableNames, + lcFirst, +} from './utils'; + +export interface GeneratedSubscriptionFile { + fileName: string; + content: string; +} + +/** + * Generate a subscription hook for a table. + * + * Produces a React hook that calls `getClient().subscribe()` with the + * correct subscription document, field metadata, and typed callbacks. + * + * Example generated output: + * ```ts + * export function useContactSubscription(options: ContactSubscriptionOptions): Unsubscribe { + * ... + * } + * ``` + */ +export function generateSubscriptionHook( + table: Table, +): GeneratedSubscriptionFile { + const { typeName, singularName } = getTableNames(table); + const hookName = getSubscriptionHookName(table); + const subscriptionFieldName = getSubscriptionFieldName(table); + const keysName = `${lcFirst(typeName)}Keys`; + + const statements: t.Statement[] = []; + + // Imports + statements.push( + createImportDeclaration('react', ['useEffect', 'useRef', 'useCallback']), + ); + statements.push( + createImportDeclaration('@tanstack/react-query', ['useQueryClient']), + ); + statements.push( + createImportDeclaration('@tanstack/react-query', ['QueryClient'], true), + ); + statements.push(createImportDeclaration('../client', ['getClient'])); + statements.push( + createImportDeclaration( + '../../orm/client', + [ + 'SubscriptionEvent', + 'SubscriptionFieldMeta', + 'Unsubscribe', + ], + true, + ), + ); + statements.push( + createImportDeclaration( + '../../orm/input-types', + [typeName], + true, + ), + ); + statements.push(createImportDeclaration('../query-keys', [keysName])); + + // Re-export SubscriptionEvent for consumer convenience + statements.push( + createTypeReExport( + ['SubscriptionEvent', 'Unsubscribe'], + '../../orm/client', + ), + ); + + // Subscription document constant + const subscriptionDoc = `subscription On${typeName}Changed { + ${subscriptionFieldName} { + event + ${singularName} { __typename } + timestamp + } +}`; + const docDecl = constDecl( + 'SUBSCRIPTION_DOCUMENT', + t.stringLiteral(subscriptionDoc), + ); + statements.push(docDecl); + + // Field metadata constant + const metaDecl = t.variableDeclaration('const', [ + t.variableDeclarator( + t.identifier('FIELD_META'), + t.objectExpression([ + objectProp('fieldName', t.stringLiteral(subscriptionFieldName)), + objectProp('tableName', t.stringLiteral(singularName)), + objectProp('dataFieldName', t.stringLiteral(singularName)), + ]), + ), + ]); + // Add type annotation: SubscriptionFieldMeta + const metaId = metaDecl.declarations[0].id as t.Identifier; + metaId.typeAnnotation = t.tsTypeAnnotation( + typeRef('SubscriptionFieldMeta'), + ); + statements.push(metaDecl); + + // Options interface + const optionsTypeName = `${typeName}SubscriptionOptions`; + const optionsInterface = t.tsInterfaceDeclaration( + t.identifier(optionsTypeName), + null, + null, + t.tsInterfaceBody([ + (() => { + const p = t.tsPropertySignature( + t.identifier('onEvent'), + t.tsTypeAnnotation( + t.tsFunctionType( + null, + [ + createFunctionParam( + 'event', + typeRef('SubscriptionEvent', [typeRef(typeName)]), + ), + ], + t.tsTypeAnnotation(t.tsVoidKeyword()), + ), + ), + ); + return p; + })(), + (() => { + const p = t.tsPropertySignature( + t.identifier('onError'), + t.tsTypeAnnotation( + t.tsFunctionType( + null, + [createFunctionParam('error', typeRef('Error'))], + t.tsTypeAnnotation(t.tsVoidKeyword()), + ), + ), + ); + p.optional = true; + return p; + })(), + (() => { + const p = t.tsPropertySignature( + t.identifier('enabled'), + t.tsTypeAnnotation(t.tsBooleanKeyword()), + ); + p.optional = true; + return p; + })(), + (() => { + const p = t.tsPropertySignature( + t.identifier('invalidateQueries'), + t.tsTypeAnnotation(t.tsBooleanKeyword()), + ); + p.optional = true; + return p; + })(), + ]), + ); + statements.push(t.exportNamedDeclaration(optionsInterface)); + + // Hook implementation + const hookBody: t.Statement[] = []; + + // const queryClient = useQueryClient(); + hookBody.push( + constDecl('queryClient', callExpr('useQueryClient', [])), + ); + + // const optionsRef = useRef(options); + hookBody.push( + constDecl('optionsRef', callExpr('useRef', [t.identifier('options')])), + ); + + // optionsRef.current = options; + hookBody.push( + t.expressionStatement( + t.assignmentExpression( + '=', + t.memberExpression( + t.identifier('optionsRef'), + t.identifier('current'), + ), + t.identifier('options'), + ), + ), + ); + + // useEffect with subscribe + const effectBody: t.Statement[] = []; + + // if (options.enabled === false) return; + effectBody.push( + t.ifStatement( + t.binaryExpression( + '===', + t.memberExpression( + t.identifier('options'), + t.identifier('enabled'), + ), + t.booleanLiteral(false), + ), + t.returnStatement(null), + ), + ); + + // const client = getClient(); + effectBody.push( + constDecl('client', callExpr('getClient', [])), + ); + + // if (!client.isRealtimeEnabled) return; + effectBody.push( + t.ifStatement( + t.unaryExpression( + '!', + t.memberExpression( + t.identifier('client'), + t.identifier('isRealtimeEnabled'), + ), + ), + t.returnStatement(null), + ), + ); + + // const unsubscribe = client.subscribe(FIELD_META, SUBSCRIPTION_DOCUMENT, {}, { onEvent, onError, onComplete }); + const subscribeCall = t.callExpression( + t.memberExpression( + t.identifier('client'), + t.identifier('subscribe'), + ), + [ + t.identifier('FIELD_META'), + t.identifier('SUBSCRIPTION_DOCUMENT'), + t.objectExpression([]), + t.objectExpression([ + objectProp( + 'onEvent', + t.arrowFunctionExpression( + [t.identifier('event')], + t.blockStatement([ + // optionsRef.current.onEvent(event); + t.expressionStatement( + callExpr( + t.memberExpression( + t.memberExpression( + t.identifier('optionsRef'), + t.identifier('current'), + ), + t.identifier('onEvent'), + ), + [t.identifier('event')], + ), + ), + // if (optionsRef.current.invalidateQueries !== false) { queryClient.invalidateQueries({ queryKey: keysName.all }); } + t.ifStatement( + t.binaryExpression( + '!==', + t.memberExpression( + t.memberExpression( + t.identifier('optionsRef'), + t.identifier('current'), + ), + t.identifier('invalidateQueries'), + ), + t.booleanLiteral(false), + ), + t.expressionStatement( + callExpr( + t.memberExpression( + t.identifier('queryClient'), + t.identifier('invalidateQueries'), + ), + [ + t.objectExpression([ + objectProp( + 'queryKey', + t.memberExpression( + t.identifier(keysName), + t.identifier('all'), + ), + ), + ]), + ], + ), + ), + ), + ]), + ), + ), + objectProp( + 'onError', + t.arrowFunctionExpression( + [t.identifier('err')], + t.blockStatement([ + t.expressionStatement( + t.optionalCallExpression( + t.optionalMemberExpression( + t.memberExpression( + t.identifier('optionsRef'), + t.identifier('current'), + ), + t.identifier('onError'), + false, + true, + ), + [t.identifier('err')], + false, + ), + ), + ]), + ), + ), + ]), + ], + ); + effectBody.push(constDecl('unsubscribe', subscribeCall)); + + // return () => unsubscribe(); + effectBody.push( + t.returnStatement( + t.arrowFunctionExpression( + [], + t.callExpression(t.identifier('unsubscribe'), []), + ), + ), + ); + + // useEffect(() => { ... }, [options.enabled]); + const effectFn = t.arrowFunctionExpression( + [], + t.blockStatement(effectBody), + ); + hookBody.push( + t.expressionStatement( + callExpr('useEffect', [ + effectFn, + t.arrayExpression([ + t.memberExpression( + t.identifier('options'), + t.identifier('enabled'), + ), + t.identifier('queryClient'), + ]), + ]), + ), + ); + + // Hook declaration + const hookParam = createFunctionParam( + 'options', + typeRef(optionsTypeName), + ); + + const hookDecl = exportFunction( + hookName, + null, + [hookParam], + hookBody, + t.tsVoidKeyword(), + ); + addJSDocComment(hookDecl, [ + `Subscription hook for ${typeName} realtime events`, + '', + 'Subscribes to realtime changes on the server and automatically', + 'invalidates React Query cache when events are received.', + '', + '@example', + '```tsx', + `${hookName}({`, + ' onEvent: (event) => {', + ` console.log(event.operation, event.data);`, + ' },', + '});', + '```', + ]); + statements.push(hookDecl); + + return { + fileName: getSubscriptionFileName(table), + content: generateHookFileCode( + `Subscription hook for ${typeName}`, + statements, + ), + }; +} + +/** + * Generate the useConnectionState hook file. + * + * This hook exposes the WebSocket connection state from the ORM client + * so UI components can show connection indicators. + */ +export function generateConnectionStateHook(): GeneratedSubscriptionFile { + const statements: t.Statement[] = []; + + // Imports + statements.push( + createImportDeclaration('react', ['useState', 'useEffect']), + ); + statements.push(createImportDeclaration('../client', ['getClient'])); + statements.push( + createImportDeclaration( + '../../orm/client', + ['ConnectionState'], + true, + ), + ); + + // Re-export ConnectionState + statements.push( + createTypeReExport(['ConnectionState'], '../../orm/client'), + ); + + // Hook body + const hookBody: t.Statement[] = []; + + // const [state, setState] = useState(() => getClient().getConnectionState()); + const initFn = t.arrowFunctionExpression( + [], + callExpr( + t.memberExpression( + callExpr('getClient', []), + t.identifier('getConnectionState'), + ), + [], + ), + ); + const useStateCall = callExpr('useState', [initFn]); + // @ts-ignore - typeParameters on CallExpression for TS + useStateCall.typeParameters = t.tsTypeParameterInstantiation([ + typeRef('ConnectionState'), + ]); + hookBody.push( + t.variableDeclaration('const', [ + t.variableDeclarator( + t.arrayPattern([t.identifier('state'), t.identifier('setState')]), + useStateCall, + ), + ]), + ); + + // useEffect + const effectBody: t.Statement[] = []; + effectBody.push( + constDecl('client', callExpr('getClient', [])), + ); + + // if (!client.isRealtimeEnabled) return; + effectBody.push( + t.ifStatement( + t.unaryExpression( + '!', + t.memberExpression( + t.identifier('client'), + t.identifier('isRealtimeEnabled'), + ), + ), + t.returnStatement(null), + ), + ); + + // const unsubscribe = client.onConnectionStateChange(setState); + effectBody.push( + constDecl( + 'unsubscribe', + callExpr( + t.memberExpression( + t.identifier('client'), + t.identifier('onConnectionStateChange'), + ), + [t.identifier('setState')], + ), + ), + ); + + // return () => unsubscribe(); + effectBody.push( + t.returnStatement( + t.arrowFunctionExpression( + [], + t.callExpression(t.identifier('unsubscribe'), []), + ), + ), + ); + + hookBody.push( + t.expressionStatement( + callExpr('useEffect', [ + t.arrowFunctionExpression([], t.blockStatement(effectBody)), + t.arrayExpression([]), + ]), + ), + ); + + // return state; + hookBody.push(t.returnStatement(t.identifier('state'))); + + // Hook declaration + const hookDecl = exportFunction( + 'useConnectionState', + null, + [], + hookBody, + typeRef('ConnectionState'), + ); + addJSDocComment(hookDecl, [ + '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'", + '```', + ]); + statements.push(hookDecl); + + return { + fileName: 'useConnectionState.ts', + content: generateHookFileCode( + 'WebSocket connection state hook', + statements, + ), + }; +} + +/** + * Generate subscription hooks for all tables + */ +export function generateAllSubscriptionHooks( + tables: Table[], +): GeneratedSubscriptionFile[] { + return tables.map((table) => generateSubscriptionHook(table)); +} diff --git a/graphql/codegen/src/core/codegen/templates/orm-client.ts b/graphql/codegen/src/core/codegen/templates/orm-client.ts index a1b24dc980..8dae2f06a3 100644 --- a/graphql/codegen/src/core/codegen/templates/orm-client.ts +++ b/graphql/codegen/src/core/codegen/templates/orm-client.ts @@ -15,12 +15,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. * @@ -117,6 +140,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; } /** @@ -135,6 +164,7 @@ export class GraphQLRequestError extends Error { export class OrmClient { private adapter: GraphQLAdapter; + private realtimeManager?: RealtimeManager; constructor(config: OrmClientConfig) { if (config.adapter) { @@ -150,6 +180,10 @@ export class OrmClient { 'OrmClientConfig requires either an endpoint or a custom adapter', ); } + + if (config.realtime) { + this.realtimeManager = new RealtimeManager(config.realtime); + } } async execute( @@ -159,6 +193,34 @@ export class OrmClient { return this.adapter.execute(document, variables); } + /** + * Subscribe to a GraphQL subscription operation. + * Used by generated model subscribe() methods. + * @throws Error if realtime is not configured + */ + subscribe( + meta: SubscriptionFieldMeta, + document: string, + variables: Record, + options: { + onEvent: (event: SubscriptionEvent) => 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( + meta, + document, + variables, + options, + ); + } + /** * Set headers for requests. * Only works if the adapter supports headers. @@ -176,4 +238,32 @@ 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(); + } } diff --git a/graphql/codegen/src/core/codegen/templates/orm-realtime.ts b/graphql/codegen/src/core/codegen/templates/orm-realtime.ts new file mode 100644 index 0000000000..c4556f763e --- /dev/null +++ b/graphql/codegen/src/core/codegen/templates/orm-realtime.ts @@ -0,0 +1,291 @@ +/** + * ORM Realtime - WebSocket subscription manager + * + * This is the RUNTIME code that gets copied to generated output. + * Provides the WebSocket connection manager and subscription types + * for realtime subscriptions integrated into the ORM client. + * + * NOTE: This file is read at codegen time and written to output. + * Any changes here will affect all generated ORM clients. + */ + +import { createClient as createWsClient } from 'graphql-ws'; +import type { Client as WsClient } from 'graphql-ws'; + +// ============================================================================ +// Types +// ============================================================================ + +/** The DML operation that triggered the subscription event */ +export type SubscriptionOperation = 'INSERT' | 'UPDATE' | 'DELETE'; + +/** Connection state of the WebSocket */ +export type ConnectionState = + | 'disconnected' + | 'connecting' + | 'connected' + | 'reconnecting'; + +/** Listener for connection state changes */ +export type ConnectionStateListener = (state: ConnectionState) => void; + +/** Function returned by subscribe() to cancel the subscription */ +export type Unsubscribe = () => void; + +/** + * A realtime subscription event delivered to the client. + * + * @typeParam T - The row type of the subscribed table + */ +export interface SubscriptionEvent { + /** The DML operation that triggered this event */ + operation: SubscriptionOperation; + /** The current row data (null for DELETE if row is no longer visible) */ + data: T | null; + /** Previous field values (populated on UPDATE when available) */ + previousValues?: Partial; + /** Server-side timestamp of when the change occurred */ + timestamp: string; +} + +/** + * Options for creating a subscription. + * + * @typeParam T - The row type of the subscribed table + * @typeParam TFilter - The filter type for the table + */ +export interface SubscribeOptions< + T, + TFilter = Record, +> { + /** Server-side filter to limit which events are delivered */ + filter?: TFilter; + /** Called when a subscription event is received */ + onEvent: (event: SubscriptionEvent) => void; + /** Called when the subscription encounters an error */ + onError?: (error: Error) => void; + /** Called when the subscription completes (server-initiated close) */ + onComplete?: () => void; +} + +/** + * Metadata about a subscription field, used internally to map + * table names to GraphQL subscription field names and types. + */ +export interface SubscriptionFieldMeta { + /** The GraphQL subscription field name (e.g., 'onContactChanged') */ + fieldName: string; + /** The table name in the source schema (e.g., 'contact') */ + tableName: string; + /** The data field name inside the subscription payload (e.g., 'contact') */ + dataFieldName: string; +} + +/** + * Configuration for the realtime (WebSocket) connection. + * Pass this as the `realtime` option in OrmClientConfig. + */ +export interface RealtimeConfig { + /** WebSocket endpoint URL (e.g., 'wss://api.example.com/graphql') */ + url: string; + /** + * Returns the current auth token. Called on connection init and + * on reconnection so the client always sends a fresh token. + */ + getToken?: () => string | Promise; + /** + * Additional connection parameters sent during WebSocket handshake. + * Merged with the authorization header from getToken(). + */ + connectionParams?: Record; + /** + * Whether to connect lazily (on first subscribe) or eagerly. + * @default true + */ + lazy?: boolean; + /** + * Maximum number of reconnection attempts before giving up. + * @default 5 + */ + retryAttempts?: number; + /** + * Delay between reconnection attempts in milliseconds, + * or a function for custom backoff. + * @default 1000 + */ + retryWait?: number | ((retryCount: number) => number | Promise); + /** Called when the WebSocket connection is established */ + onConnected?: () => void; + /** Called when the WebSocket connection is closed */ + onDisconnected?: (reason?: unknown) => void; +} + +// ============================================================================ +// RealtimeManager +// ============================================================================ + +/** + * Manages a single graphql-ws WebSocket connection and multiplexes + * subscriptions over it. Created lazily by OrmClient when `realtime` + * config is provided. + */ +export class RealtimeManager { + private wsClient: WsClient; + private connectionState: ConnectionState = 'disconnected'; + private stateListeners: Set = new Set(); + private activeSubscriptions = 0; + + constructor(config: RealtimeConfig) { + const retryWait = async (retryCount: number): Promise => { + if (typeof config.retryWait === 'function') { + const result = config.retryWait(retryCount); + const ms = typeof result === 'number' ? result : await result; + await new Promise((resolve) => setTimeout(resolve, ms)); + } else { + const base = + typeof config.retryWait === 'number' ? config.retryWait : 1000; + await new Promise((resolve) => + setTimeout(resolve, base * Math.pow(2, retryCount)), + ); + } + }; + + this.wsClient = createWsClient({ + url: config.url, + lazy: config.lazy ?? true, + retryAttempts: config.retryAttempts ?? 5, + retryWait, + connectionParams: async () => { + const params: Record = { + ...config.connectionParams, + }; + if (config.getToken) { + const token = await config.getToken(); + params['authorization'] = `Bearer ${token}`; + } + return params; + }, + on: { + connecting: () => { + const newState = + this.connectionState === 'disconnected' + ? 'connecting' + : 'reconnecting'; + this.setConnectionState(newState); + }, + connected: () => { + this.setConnectionState('connected'); + config.onConnected?.(); + }, + closed: (event) => { + this.setConnectionState('disconnected'); + config.onDisconnected?.(event); + }, + }, + }); + } + + /** + * Subscribe to a GraphQL subscription operation. + * Models call this with typed metadata and documents. + */ + subscribe( + meta: SubscriptionFieldMeta, + document: string, + variables: Record, + options: { + onEvent: (event: SubscriptionEvent) => void; + onError?: (error: Error) => void; + onComplete?: () => void; + }, + ): Unsubscribe { + this.activeSubscriptions++; + let disposed = false; + + const cleanup = this.wsClient.subscribe>( + { query: document, variables }, + { + next: (result) => { + if (disposed) return; + if (result.errors) { + options.onError?.( + new Error( + result.errors.map((e) => e.message).join('; '), + ), + ); + return; + } + + const payload = result.data?.[meta.fieldName] as + | { event?: string; [key: string]: unknown } + | undefined; + + if (!payload) return; + + const event: SubscriptionEvent = { + operation: + (payload.event as SubscriptionOperation) ?? 'UPDATE', + data: (payload[meta.dataFieldName] as T) ?? null, + previousValues: payload.previousValues as + | Partial + | undefined, + timestamp: + (payload.timestamp as string) ?? new Date().toISOString(), + }; + options.onEvent(event); + }, + error: (err) => { + if (disposed) return; + options.onError?.( + err instanceof Error ? err : new Error(String(err)), + ); + }, + complete: () => { + if (disposed) return; + options.onComplete?.(); + }, + }, + ); + + return () => { + if (disposed) return; + disposed = true; + this.activeSubscriptions--; + cleanup(); + }; + } + + /** Register a listener for connection state changes */ + onConnectionStateChange(listener: ConnectionStateListener): Unsubscribe { + this.stateListeners.add(listener); + return () => { + this.stateListeners.delete(listener); + }; + } + + /** Get current connection state */ + getConnectionState(): ConnectionState { + return this.connectionState; + } + + /** Number of active subscriptions */ + getActiveSubscriptionCount(): number { + return this.activeSubscriptions; + } + + /** Dispose the manager and close the WebSocket connection */ + dispose(): void { + this.wsClient.dispose(); + this.stateListeners.clear(); + this.activeSubscriptions = 0; + this.setConnectionState('disconnected'); + } + + private setConnectionState(state: ConnectionState): void { + if (this.connectionState === state) return; + this.connectionState = state; + for (const listener of this.stateListeners) { + listener(state); + } + } +} diff --git a/graphql/codegen/src/core/codegen/utils.ts b/graphql/codegen/src/core/codegen/utils.ts index 5b8b3d5eee..02fc72639f 100644 --- a/graphql/codegen/src/core/codegen/utils.ts +++ b/graphql/codegen/src/core/codegen/utils.ts @@ -138,6 +138,32 @@ export function getDeleteMutationFileName(table: Table): string { return `${getDeleteMutationHookName(table)}.ts`; } +/** + * Generate hook function name for subscription + * e.g., "useContactSubscription" + */ +export function getSubscriptionHookName(table: Table): string { + const { singularName } = getTableNames(table); + return `use${ucFirst(singularName)}Subscription`; +} + +/** + * Generate file name for subscription hook + * e.g., "useContactSubscription.ts" + */ +export function getSubscriptionFileName(table: Table): string { + return `${getSubscriptionHookName(table)}.ts`; +} + +/** + * Generate the GraphQL subscription field name + * e.g., "onContactChanged" + */ +export function getSubscriptionFieldName(table: Table): string { + const { singularName } = getTableNames(table); + return `on${ucFirst(singularName)}Changed`; +} + // ============================================================================ // GraphQL operation names // ============================================================================ From 24fe3ee8b020b83f1cd27f0ee0452ea17367d0d1 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sun, 10 May 2026 09:15:32 +0000 Subject: [PATCH 2/3] fix: make graphql-ws a lazy dependency so ORM works without it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The realtime module is optional — apps that don't use subscriptions (like the CLI) should not need graphql-ws installed. Changed the top-level import to a TypeScript import() type (erased at compile time) and moved the require() call inside the RealtimeManager constructor so it only executes when realtime is actually used. --- .../codegen/src/core/codegen/templates/orm-realtime.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/graphql/codegen/src/core/codegen/templates/orm-realtime.ts b/graphql/codegen/src/core/codegen/templates/orm-realtime.ts index c4556f763e..b9ac8027ab 100644 --- a/graphql/codegen/src/core/codegen/templates/orm-realtime.ts +++ b/graphql/codegen/src/core/codegen/templates/orm-realtime.ts @@ -9,8 +9,9 @@ * Any changes here will affect all generated ORM clients. */ -import { createClient as createWsClient } from 'graphql-ws'; -import type { Client as WsClient } from 'graphql-ws'; +// graphql-ws is loaded lazily so that importing this module does not +// throw when the package is absent (e.g. CLI-only consumers). +type WsClient = import('graphql-ws').Client; // ============================================================================ // Types @@ -136,6 +137,9 @@ export class RealtimeManager { private activeSubscriptions = 0; constructor(config: RealtimeConfig) { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { createClient: createWsClient } = require('graphql-ws') as typeof import('graphql-ws'); + const retryWait = async (retryCount: number): Promise => { if (typeof config.retryWait === 'function') { const result = config.retryWait(retryCount); From 90481ed06fc1febdd48312f03bf6e985deb3cea3 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sun, 10 May 2026 10:15:10 +0000 Subject: [PATCH 3/3] feat: gate subscription hook generation on @realtime smart tag - Add smartTags field to Table interface in both query and codegen packages - Parse smart tags from PostGraphile @-prefixed description lines in inferTablesFromIntrospection - Export parseSmartTags utility from @constructive-io/graphql-query - Gate subscription hook generation: only tables with @realtime smart tag get hooks - Gate useConnectionState + subscriptions barrel: only emitted when at least one table has @realtime - Add comprehensive Smart Tag Gating test suite (5 new tests, all passing) - All 344 codegen tests + 18 query tests passing, typecheck clean --- .../codegen/subscription-hooks.test.ts | 103 ++++++++++++++++++ graphql/codegen/src/core/codegen/index.ts | 24 ++-- graphql/codegen/src/types/schema.ts | 2 + graphql/query/src/index.ts | 2 +- graphql/query/src/introspect/infer-tables.ts | 6 +- graphql/query/src/types/schema.ts | 2 + graphql/query/src/utils.ts | 31 ++++++ 7 files changed, 158 insertions(+), 12 deletions(-) diff --git a/graphql/codegen/src/__tests__/codegen/subscription-hooks.test.ts b/graphql/codegen/src/__tests__/codegen/subscription-hooks.test.ts index 8c9374ef66..90ade1b295 100644 --- a/graphql/codegen/src/__tests__/codegen/subscription-hooks.test.ts +++ b/graphql/codegen/src/__tests__/codegen/subscription-hooks.test.ts @@ -7,6 +7,7 @@ * - Subscription barrel file */ import { generateSubscriptionsBarrel } from '../../core/codegen/barrel'; +import { generate } from '../../core/codegen/index'; import { generateAllSubscriptionHooks, generateConnectionStateHook, @@ -42,6 +43,7 @@ function createTable(partial: Partial
& { name: string }): Table { query: partial.query, inflection: partial.inflection, constraints: partial.constraints, + smartTags: partial.smartTags, }; } @@ -63,6 +65,25 @@ const contactTable = createTable({ }, }); +const contactTableWithRealtime = createTable({ + name: 'Contact', + fields: [ + { name: 'id', type: fieldTypes.uuid }, + { name: 'firstName', type: fieldTypes.string }, + { name: 'lastName', type: fieldTypes.string }, + { name: 'email', type: fieldTypes.string }, + { name: 'createdAt', type: fieldTypes.datetime }, + ], + query: { + all: 'contacts', + one: 'contact', + create: 'createContact', + update: 'updateContact', + delete: 'deleteContact', + }, + smartTags: { '@realtime': true }, +}); + const projectTable = createTable({ name: 'Project', fields: [ @@ -80,6 +101,24 @@ const projectTable = createTable({ }, }); +const projectTableWithRealtime = createTable({ + name: 'Project', + fields: [ + { name: 'id', type: fieldTypes.uuid }, + { name: 'name', type: fieldTypes.string }, + { name: 'active', type: fieldTypes.boolean }, + { name: 'createdAt', type: fieldTypes.datetime }, + ], + query: { + all: 'projects', + one: 'project', + create: 'createProject', + update: 'updateProject', + delete: 'deleteProject', + }, + smartTags: { '@realtime': true }, +}); + describe('Subscription naming utils', () => { it('generates subscription hook name', () => { expect(getSubscriptionHookName(contactTable)).toBe( @@ -243,3 +282,67 @@ describe('Subscription Barrel Generator', () => { expect(result).toContain('./useProjectSubscription'); }); }); + +describe('Smart Tag Gating', () => { + const minConfig = { + tables: { include: [], exclude: [], systemExclude: [] }, + queries: { include: [], exclude: [], systemExclude: [] }, + mutations: { include: [], exclude: [], systemExclude: [] }, + codegen: { skipQueryField: false }, + reactQuery: true, + } as any; + + it('does not generate subscription hooks when no tables have @realtime', () => { + const result = generate({ + tables: [contactTable, projectTable], + config: minConfig, + }); + expect(result.stats.subscriptionHooks).toBe(0); + const subFiles = result.files.filter((f) => f.path.startsWith('subscriptions/')); + expect(subFiles).toHaveLength(0); + }); + + it('generates subscription hooks only for tables with @realtime', () => { + const result = generate({ + tables: [contactTableWithRealtime, projectTable], + config: minConfig, + }); + expect(result.stats.subscriptionHooks).toBe(1); + const subFiles = result.files.filter((f) => f.path.startsWith('subscriptions/')); + expect(subFiles.some((f) => f.path.includes('useContactSubscription'))).toBe(true); + expect(subFiles.some((f) => f.path.includes('useProjectSubscription'))).toBe(false); + expect(subFiles.some((f) => f.path.includes('useConnectionState'))).toBe(true); + expect(subFiles.some((f) => f.path === 'subscriptions/index.ts')).toBe(true); + }); + + it('generates subscription hooks for all @realtime tables', () => { + const result = generate({ + tables: [contactTableWithRealtime, projectTableWithRealtime], + config: minConfig, + }); + expect(result.stats.subscriptionHooks).toBe(2); + const subFiles = result.files.filter((f) => f.path.startsWith('subscriptions/')); + expect(subFiles.some((f) => f.path.includes('useContactSubscription'))).toBe(true); + expect(subFiles.some((f) => f.path.includes('useProjectSubscription'))).toBe(true); + }); + + it('does not emit useConnectionState or barrel when no @realtime tables', () => { + const result = generate({ + tables: [contactTable], + config: minConfig, + }); + const subFiles = result.files.filter((f) => f.path.startsWith('subscriptions/')); + expect(subFiles).toHaveLength(0); + const mainBarrel = result.files.find((f) => f.path === 'index.ts'); + expect(mainBarrel?.content).not.toContain('./subscriptions'); + }); + + it('emits subscriptions barrel in main index when @realtime tables exist', () => { + const result = generate({ + tables: [contactTableWithRealtime], + config: minConfig, + }); + const mainBarrel = result.files.find((f) => f.path === 'index.ts'); + expect(mainBarrel?.content).toContain('./subscriptions'); + }); +}); diff --git a/graphql/codegen/src/core/codegen/index.ts b/graphql/codegen/src/core/codegen/index.ts index ef804f844f..219330dc98 100644 --- a/graphql/codegen/src/core/codegen/index.ts +++ b/graphql/codegen/src/core/codegen/index.ts @@ -299,7 +299,11 @@ export function generate(options: GenerateOptions): GenerateResult { } // 8b. Generate subscription hooks (subscriptions/*.ts) - const subscriptionHooks = generateAllSubscriptionHooks(tables); + // Only generate for tables with the @realtime smart tag + const realtimeTables = tables.filter( + (t) => t.smartTags?.['@realtime'] !== undefined, + ); + const subscriptionHooks = generateAllSubscriptionHooks(realtimeTables); for (const hook of subscriptionHooks) { files.push({ path: `subscriptions/${hook.fileName}`, @@ -307,19 +311,19 @@ export function generate(options: GenerateOptions): GenerateResult { }); } - // 8c. Generate connection state hook - const connectionStateHook = generateConnectionStateHook(); - files.push({ - path: `subscriptions/${connectionStateHook.fileName}`, - content: connectionStateHook.content, - }); - - // 8d. Generate subscriptions/index.ts barrel + // 8c. Generate connection state hook + barrel only if any table has @realtime const hasSubscriptions = subscriptionHooks.length > 0; if (hasSubscriptions) { + const connectionStateHook = generateConnectionStateHook(); + files.push({ + path: `subscriptions/${connectionStateHook.fileName}`, + content: connectionStateHook.content, + }); + + // 8d. Generate subscriptions/index.ts barrel files.push({ path: 'subscriptions/index.ts', - content: generateSubscriptionsBarrel(tables), + content: generateSubscriptionsBarrel(realtimeTables), }); } diff --git a/graphql/codegen/src/types/schema.ts b/graphql/codegen/src/types/schema.ts index bc16a3e422..cb84188614 100644 --- a/graphql/codegen/src/types/schema.ts +++ b/graphql/codegen/src/types/schema.ts @@ -18,6 +18,8 @@ export interface Table { query?: TableQueryNames; /** Constraint information */ constraints?: TableConstraints; + /** Smart tags parsed from PostGraphile @-prefixed comment directives */ + smartTags?: Record; } /** diff --git a/graphql/query/src/index.ts b/graphql/query/src/index.ts index ad0e5f2f2e..d4694a51c4 100644 --- a/graphql/query/src/index.ts +++ b/graphql/query/src/index.ts @@ -40,4 +40,4 @@ export * from './client'; export * from './introspect'; // Utility functions -export { stripSmartComments } from './utils'; +export { parseSmartTags, stripSmartComments } from './utils'; diff --git a/graphql/query/src/introspect/infer-tables.ts b/graphql/query/src/introspect/infer-tables.ts index f8d097533f..c78f84cb84 100644 --- a/graphql/query/src/introspect/infer-tables.ts +++ b/graphql/query/src/introspect/infer-tables.ts @@ -14,7 +14,7 @@ */ import { lcFirst, pluralize, singularize, ucFirst } from 'inflekt'; -import { stripSmartComments } from '../utils'; +import { parseSmartTags, stripSmartComments } from '../utils'; import type { IntrospectionField, @@ -324,6 +324,9 @@ function buildCleanTable( // Extract description from entity type (PostgreSQL COMMENT), strip smart comments const description = commentsEnabled ? stripSmartComments(entityType.description) : undefined; + // Parse smart tags from raw description before they are stripped + const smartTags = parseSmartTags(entityType.description); + return { table: { name: entityName, @@ -333,6 +336,7 @@ function buildCleanTable( inflection, query, constraints, + ...(smartTags ? { smartTags } : {}), }, hasRealOperation, }; diff --git a/graphql/query/src/types/schema.ts b/graphql/query/src/types/schema.ts index bc16a3e422..cb84188614 100644 --- a/graphql/query/src/types/schema.ts +++ b/graphql/query/src/types/schema.ts @@ -18,6 +18,8 @@ export interface Table { query?: TableQueryNames; /** Constraint information */ constraints?: TableConstraints; + /** Smart tags parsed from PostGraphile @-prefixed comment directives */ + smartTags?: Record; } /** diff --git a/graphql/query/src/utils.ts b/graphql/query/src/utils.ts index c6f0cb8168..bcbe64b00c 100644 --- a/graphql/query/src/utils.ts +++ b/graphql/query/src/utils.ts @@ -73,3 +73,34 @@ export function stripSmartComments( return result; } + +/** + * Parse PostGraphile smart tags from a description string. + * + * Smart tags are lines starting with `@` in PostgreSQL COMMENTs. + * Each line is parsed as `@tagName` (boolean true) or `@tagName value`. + * + * Returns undefined if no smart tags are found. + */ +export function parseSmartTags( + description: string | null | undefined, +): Record | undefined { + if (!description) return undefined; + + const lines = description.split('\n'); + let tags: Record | undefined; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed.startsWith('@')) continue; + + const spaceIdx = trimmed.indexOf(' '); + const tagName = spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx); + const tagValue = spaceIdx === -1 ? true : trimmed.slice(spaceIdx + 1).trim(); + + if (!tags) tags = {}; + tags[tagName] = tagValue || true; + } + + return tags; +}