@0xobelisk/graphql-client 1.2.0-pre.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.ts ADDED
@@ -0,0 +1,1209 @@
1
+ import {
2
+ ApolloClient,
3
+ InMemoryCache,
4
+ gql,
5
+ createHttpLink,
6
+ split,
7
+ NormalizedCacheObject,
8
+ WatchQueryOptions,
9
+ QueryOptions as ApolloQueryOptions,
10
+ SubscriptionOptions as ApolloSubscriptionOptions,
11
+ Observable,
12
+ from,
13
+ ApolloLink,
14
+ FetchPolicy,
15
+ OperationVariables,
16
+ } from '@apollo/client';
17
+ import { RetryLink } from '@apollo/client/link/retry';
18
+ import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
19
+ import { getMainDefinition } from '@apollo/client/utilities';
20
+ import { createClient } from 'graphql-ws';
21
+ import pluralize from 'pluralize';
22
+
23
+ import {
24
+ DubheClientConfig,
25
+ Connection,
26
+ BaseQueryParams,
27
+ OrderBy,
28
+ JsonPathOrder,
29
+ QueryOptions,
30
+ QueryResult,
31
+ SubscriptionResult,
32
+ SubscriptionOptions,
33
+ StringFilter,
34
+ NumberFilter,
35
+ DateFilter,
36
+ StoreTableRow,
37
+ TypedDocumentNode,
38
+ CachePolicy,
39
+ MultiTableSubscriptionConfig,
40
+ MultiTableSubscriptionResult,
41
+ MultiTableSubscriptionData,
42
+ ParsedTableInfo,
43
+ DubheMetadata,
44
+ } from './types';
45
+
46
+ // Convert cache policy type
47
+ function mapCachePolicyToFetchPolicy(cachePolicy: CachePolicy): FetchPolicy {
48
+ switch (cachePolicy) {
49
+ case 'cache-first':
50
+ return 'cache-first';
51
+ case 'network-only':
52
+ return 'network-only';
53
+ case 'cache-only':
54
+ return 'cache-only';
55
+ case 'no-cache':
56
+ return 'no-cache';
57
+ case 'standby':
58
+ return 'standby';
59
+ default:
60
+ return 'cache-first';
61
+ }
62
+ }
63
+
64
+ export class DubheGraphqlClient {
65
+ private apolloClient: ApolloClient<NormalizedCacheObject>;
66
+ private subscriptionClient?: any;
67
+ private dubheMetadata?: DubheMetadata;
68
+ private parsedTables: Map<string, ParsedTableInfo> = new Map();
69
+
70
+ constructor(config: DubheClientConfig) {
71
+ // Save dubhe metadata
72
+ this.dubheMetadata = config.dubheMetadata;
73
+
74
+ // If dubhe metadata is provided, parse table information
75
+ if (this.dubheMetadata) {
76
+ this.parseTableInfoFromConfig();
77
+ }
78
+
79
+ // Create HTTP Link
80
+ const httpLink = createHttpLink({
81
+ uri: config.endpoint,
82
+ headers: config.headers,
83
+ fetch: (input, init) => fetch(input, { ...config.fetchOptions, ...init }),
84
+ });
85
+
86
+ // Create retry link
87
+ const retryLink = new RetryLink({
88
+ delay: {
89
+ // Initial retry delay time (milliseconds)
90
+ initial: config.retryOptions?.delay?.initial || 300,
91
+ // Maximum retry delay time (milliseconds)
92
+ max: config.retryOptions?.delay?.max || 5000,
93
+ // Whether to add random jitter to avoid thundering herd, enabled by default
94
+ jitter: config.retryOptions?.delay?.jitter !== false,
95
+ },
96
+ attempts: {
97
+ // Maximum number of attempts (including initial request)
98
+ max: config.retryOptions?.attempts?.max || 5,
99
+ // Custom retry condition function
100
+ retryIf:
101
+ config.retryOptions?.attempts?.retryIf ||
102
+ ((error, _operation) => {
103
+ // Default retry strategy:
104
+ // 1. Network connection errors
105
+ // 2. Server errors but no GraphQL errors (indicates service temporarily unavailable)
106
+ return Boolean(
107
+ error &&
108
+ (error.networkError ||
109
+ (error.graphQLErrors && error.graphQLErrors.length === 0))
110
+ );
111
+ }),
112
+ },
113
+ });
114
+
115
+ // Combine HTTP link and retry link
116
+ const httpWithRetryLink = from([retryLink, httpLink]);
117
+
118
+ let link: ApolloLink = httpWithRetryLink;
119
+
120
+ // If subscription endpoint is provided, create WebSocket Link
121
+ if (config.subscriptionEndpoint) {
122
+ // Automatically import ws module in Node.js environment
123
+ let webSocketImpl;
124
+ try {
125
+ // Check if in Node.js environment
126
+ if (typeof window === 'undefined' && typeof global !== 'undefined') {
127
+ // Node.js environment, need to import ws
128
+ const wsModule = require('ws');
129
+ webSocketImpl = wsModule.default || wsModule;
130
+
131
+ // Set global WebSocket in Node.js environment to avoid apollo client internal errors
132
+ if (typeof (global as any).WebSocket === 'undefined') {
133
+ (global as any).WebSocket = webSocketImpl;
134
+ }
135
+ } else {
136
+ // Browser environment, use native WebSocket
137
+ webSocketImpl = WebSocket;
138
+ }
139
+ } catch (error) {
140
+ // Ignore ws import errors
141
+ }
142
+
143
+ const clientOptions: any = {
144
+ url: config.subscriptionEndpoint,
145
+ connectionParams: {
146
+ headers: config.headers,
147
+ },
148
+ };
149
+
150
+ // Only add webSocketImpl if in Node.js environment and ws was successfully imported
151
+ if (webSocketImpl && typeof window === 'undefined') {
152
+ clientOptions.webSocketImpl = webSocketImpl;
153
+ }
154
+
155
+ this.subscriptionClient = createClient(clientOptions);
156
+
157
+ const wsLink = new GraphQLWsLink(this.subscriptionClient);
158
+
159
+ // Use split to decide which link to use
160
+ link = split(
161
+ ({ query }) => {
162
+ const definition = getMainDefinition(query);
163
+ return (
164
+ definition.kind === 'OperationDefinition' &&
165
+ definition.operation === 'subscription'
166
+ );
167
+ },
168
+ wsLink,
169
+ httpWithRetryLink
170
+ );
171
+ }
172
+
173
+ // Create Apollo Client instance
174
+ this.apolloClient = new ApolloClient({
175
+ link,
176
+ cache:
177
+ config.cacheConfig?.paginatedTables &&
178
+ config.cacheConfig.paginatedTables.length > 0
179
+ ? new InMemoryCache({
180
+ typePolicies: {
181
+ // Configure cache strategy for Connection type
182
+ Query: {
183
+ fields: this.buildCacheFields(config.cacheConfig),
184
+ },
185
+ },
186
+ })
187
+ : new InMemoryCache(), // Use simple cache by default
188
+ defaultOptions: {
189
+ watchQuery: {
190
+ errorPolicy: 'all',
191
+ notifyOnNetworkStatusChange: true,
192
+ },
193
+ query: {
194
+ errorPolicy: 'all',
195
+ },
196
+ },
197
+ });
198
+ }
199
+
200
+ /**
201
+ * Execute GraphQL query
202
+ */
203
+ async query<
204
+ TData,
205
+ TVariables extends OperationVariables = OperationVariables,
206
+ >(
207
+ query: TypedDocumentNode<TData, TVariables>,
208
+ variables?: TVariables,
209
+ options?: QueryOptions
210
+ ): Promise<QueryResult<TData>> {
211
+ try {
212
+ const result = await this.apolloClient.query({
213
+ query,
214
+ variables,
215
+ fetchPolicy: options?.cachePolicy
216
+ ? mapCachePolicyToFetchPolicy(options.cachePolicy)
217
+ : 'no-cache',
218
+ // : 'cache-first',
219
+ notifyOnNetworkStatusChange: options?.notifyOnNetworkStatusChange,
220
+ pollInterval: options?.pollInterval,
221
+ });
222
+
223
+ return {
224
+ data: result.data,
225
+ loading: result.loading,
226
+ error: result.error,
227
+ networkStatus: result.networkStatus,
228
+ refetch: () => this.query(query, variables, options),
229
+ };
230
+ } catch (error) {
231
+ return {
232
+ data: undefined,
233
+ loading: false,
234
+ error: error as Error,
235
+ networkStatus: 8, // NetworkStatus.error
236
+ refetch: () => this.query(query, variables, options),
237
+ };
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Execute GraphQL subscription
243
+ */
244
+ subscribe<TData, TVariables extends OperationVariables = OperationVariables>(
245
+ subscription: TypedDocumentNode<TData, TVariables>,
246
+ variables?: TVariables,
247
+ options?: SubscriptionOptions
248
+ ): Observable<SubscriptionResult<TData>> {
249
+ return new Observable((observer: any) => {
250
+ const sub = this.apolloClient
251
+ .subscribe({
252
+ query: subscription,
253
+ variables,
254
+ })
255
+ .subscribe({
256
+ next: (result: any) => {
257
+ const subscriptionResult: SubscriptionResult<TData> = {
258
+ data: result.data,
259
+ loading: false,
260
+ error: result.errors?.[0] as Error,
261
+ };
262
+ observer.next(subscriptionResult);
263
+ options?.onData?.(result.data);
264
+ },
265
+ error: (error: any) => {
266
+ const subscriptionResult: SubscriptionResult<TData> = {
267
+ data: undefined,
268
+ loading: false,
269
+ error,
270
+ };
271
+ observer.next(subscriptionResult);
272
+ options?.onError?.(error);
273
+ },
274
+ complete: () => {
275
+ observer.complete();
276
+ options?.onComplete?.();
277
+ },
278
+ });
279
+
280
+ return () => sub.unsubscribe();
281
+ });
282
+ }
283
+
284
+ /**
285
+ * Query all table data - Adapted to API without store prefix
286
+ *
287
+ * OrderBy field name support:
288
+ * - camelCase: { field: 'updatedAt', direction: 'DESC' } → UPDATED_AT_DESC
289
+ * - snake_case: { field: 'updated_at', direction: 'DESC' } → UPDATED_AT_DESC
290
+ *
291
+ * Usage examples:
292
+ * ```ts
293
+ * // Using camelCase field names
294
+ * const result = await client.getAllTables('account', {
295
+ * orderBy: [{ field: 'updatedAt', direction: 'DESC' }]
296
+ * });
297
+ *
298
+ * // Using snake_case field names
299
+ * const result = await client.getAllTables('account', {
300
+ * orderBy: [{ field: 'updated_at', direction: 'DESC' }]
301
+ * });
302
+ *
303
+ * // Mixed usage
304
+ * const result = await client.getAllTables('account', {
305
+ * orderBy: [
306
+ * { field: 'updatedAt', direction: 'DESC' },
307
+ * { field: 'created_at', direction: 'ASC' }
308
+ * ]
309
+ * });
310
+ * ```
311
+ */
312
+ async getAllTables<T extends StoreTableRow>(
313
+ tableName: string,
314
+ params?: BaseQueryParams & {
315
+ filter?: Record<string, any>;
316
+ orderBy?: OrderBy[];
317
+ fields?: string[]; // Allow users to specify fields to query, auto-parse from dubhe config if not specified
318
+ }
319
+ ): Promise<Connection<T>> {
320
+ // Ensure using plural form of table name
321
+ const pluralTableName = this.getPluralTableName(tableName);
322
+
323
+ // Convert OrderBy to enum values
324
+ const orderByEnums = convertOrderByToEnum(params?.orderBy);
325
+
326
+ // Dynamically build query
327
+ const query = gql`
328
+ query GetAllTables(
329
+ $first: Int
330
+ $last: Int
331
+ $after: Cursor
332
+ $before: Cursor
333
+ $filter: ${this.getFilterTypeName(tableName)}
334
+ $orderBy: [${this.getOrderByTypeName(tableName)}!]
335
+ ) {
336
+ ${pluralTableName}(
337
+ first: $first
338
+ last: $last
339
+ after: $after
340
+ before: $before
341
+ filter: $filter
342
+ orderBy: $orderBy
343
+ ) {
344
+ totalCount
345
+ pageInfo {
346
+ hasNextPage
347
+ hasPreviousPage
348
+ startCursor
349
+ endCursor
350
+ }
351
+ edges {
352
+ cursor
353
+ node {
354
+ ${this.convertTableFields(tableName, params?.fields)}
355
+ }
356
+ }
357
+ }
358
+ }
359
+ `;
360
+
361
+ // Build query parameters using enum values
362
+ const queryParams = {
363
+ first: params?.first,
364
+ last: params?.last,
365
+ after: params?.after,
366
+ before: params?.before,
367
+ filter: params?.filter,
368
+ orderBy: orderByEnums,
369
+ };
370
+
371
+ // const result = await this.query(query, queryParams, {
372
+ // cachePolicy: 'no-cache',
373
+ // });
374
+
375
+ const result = await this.query(query, queryParams);
376
+
377
+ if (result.error) {
378
+ throw result.error;
379
+ }
380
+
381
+ return (
382
+ (result.data as any)?.[pluralTableName] || {
383
+ edges: [],
384
+ pageInfo: { hasNextPage: false, hasPreviousPage: false },
385
+ }
386
+ );
387
+ }
388
+
389
+ /**
390
+ * Get single table record by condition - Adapted to API without store prefix
391
+ */
392
+ async getTableByCondition<T extends StoreTableRow>(
393
+ tableName: string,
394
+ condition: Record<string, any>,
395
+ fields?: string[] // Allow users to specify fields to query
396
+ ): Promise<T | null> {
397
+ // Build query field name, e.g.: accountByAssetIdAndAccount
398
+ const conditionKeys = Object.keys(condition);
399
+
400
+ // Use singular form of table name for single record query
401
+ const singularTableName = this.getSingularTableName(tableName);
402
+
403
+ const query = gql`
404
+ query GetTableByCondition(${conditionKeys.map((key, index) => `$${key}: String!`).join(', ')}) {
405
+ ${singularTableName}(${conditionKeys.map((key) => `${key}: $${key}`).join(', ')}) {
406
+ ${this.convertTableFields(tableName, fields)}
407
+ }
408
+ }
409
+ `;
410
+
411
+ const result = await this.query(query, condition);
412
+
413
+ if (result.error) {
414
+ throw result.error;
415
+ }
416
+
417
+ return (result.data as any)?.[singularTableName] || null;
418
+ }
419
+
420
+ /**
421
+ * Subscribe to table data changes - Using PostGraphile's listen subscription feature
422
+ */
423
+ subscribeToTableChanges<T extends StoreTableRow>(
424
+ tableName: string,
425
+ options?: SubscriptionOptions & {
426
+ fields?: string[]; // Allow users to specify fields to subscribe to
427
+ initialEvent?: boolean; // Whether to trigger initial event immediately
428
+ first?: number; // Limit the number of returned records
429
+ topicPrefix?: string; // Custom topic prefix, defaults to table name
430
+ }
431
+ ): Observable<SubscriptionResult<{ listen: { query: any } }>> {
432
+ // PostGraphile automatically adds 'postgraphile:' prefix to all topics
433
+ // So here we use more concise topic naming
434
+ const topic = options?.topicPrefix
435
+ ? `${options.topicPrefix}${tableName}`
436
+ : `store_${this.getSingularTableName(tableName)}`;
437
+
438
+ const pluralTableName = this.getPluralTableName(tableName); // Ensure using plural form
439
+ const fields = this.convertTableFields(tableName, options?.fields);
440
+
441
+ const subscription = gql`
442
+ subscription ListenToTableChanges($topic: String!, $initialEvent: Boolean) {
443
+ listen(topic: $topic, initialEvent: $initialEvent) {
444
+ query {
445
+ ${pluralTableName}(first: ${options?.first || 10}, orderBy: UPDATED_AT_DESC) {
446
+ totalCount
447
+ nodes {
448
+ ${fields}
449
+ }
450
+ pageInfo {
451
+ hasNextPage
452
+ endCursor
453
+ }
454
+ }
455
+ }
456
+ }
457
+ }
458
+ `;
459
+
460
+ return this.subscribe(
461
+ subscription,
462
+ {
463
+ topic,
464
+ initialEvent: options?.initialEvent || false,
465
+ },
466
+ options
467
+ );
468
+ }
469
+
470
+ /**
471
+ * Advanced listen subscription - Support custom queries
472
+ */
473
+ subscribeWithListen<T = any>(
474
+ topic: string,
475
+ query: string,
476
+ options?: SubscriptionOptions & {
477
+ initialEvent?: boolean;
478
+ variables?: Record<string, any>;
479
+ }
480
+ ): Observable<SubscriptionResult<{ listen: { query: T } }>> {
481
+ const subscription = gql`
482
+ subscription CustomListenSubscription($topic: String!, $initialEvent: Boolean) {
483
+ listen(topic: $topic, initialEvent: $initialEvent) {
484
+ query {
485
+ ${query}
486
+ }
487
+ }
488
+ }
489
+ `;
490
+
491
+ return this.subscribe(
492
+ subscription,
493
+ {
494
+ topic,
495
+ initialEvent: options?.initialEvent || false,
496
+ ...options?.variables,
497
+ },
498
+ options
499
+ );
500
+ }
501
+
502
+ /**
503
+ * Subscribe to data changes with specific conditions
504
+ */
505
+ subscribeToFilteredTableChanges<T extends StoreTableRow>(
506
+ tableName: string,
507
+ filter?: Record<string, any>,
508
+ options?: SubscriptionOptions & {
509
+ fields?: string[];
510
+ initialEvent?: boolean;
511
+ orderBy?: OrderBy[];
512
+ first?: number;
513
+ topicPrefix?: string; // Custom topic prefix
514
+ }
515
+ ): Observable<SubscriptionResult<{ listen: { query: any } }>> {
516
+ // Improved topic naming, support custom prefix
517
+ const topic = options?.topicPrefix
518
+ ? `${options.topicPrefix}${tableName}`
519
+ : `store_${this.getSingularTableName(tableName)}`;
520
+
521
+ const pluralTableName = this.getPluralTableName(tableName); // Ensure using plural form
522
+ const fields = this.convertTableFields(tableName, options?.fields);
523
+ const orderByEnum = convertOrderByToEnum(options?.orderBy);
524
+ const first = options?.first || 10;
525
+
526
+ const subscription = gql`
527
+ subscription FilteredListenSubscription(
528
+ $topic: String!,
529
+ $initialEvent: Boolean,
530
+ $filter: ${this.getFilterTypeName(tableName)},
531
+ $orderBy: [${this.getOrderByTypeName(tableName)}!],
532
+ $first: Int
533
+ ) {
534
+ listen(topic: $topic, initialEvent: $initialEvent) {
535
+ query {
536
+ ${pluralTableName}(
537
+ first: $first,
538
+ filter: $filter,
539
+ orderBy: $orderBy
540
+ ) {
541
+ totalCount
542
+ nodes {
543
+ ${fields}
544
+ }
545
+ pageInfo {
546
+ hasNextPage
547
+ endCursor
548
+ }
549
+ }
550
+ }
551
+ }
552
+ }
553
+ `;
554
+
555
+ return this.subscribe(
556
+ subscription,
557
+ {
558
+ topic,
559
+ initialEvent: options?.initialEvent || false,
560
+ filter,
561
+ orderBy: orderByEnum,
562
+ first,
563
+ },
564
+ options
565
+ );
566
+ }
567
+
568
+ /**
569
+ * Subscribe to multiple table data changes - Support batch subscription of table name list
570
+ */
571
+ subscribeToMultipleTables<T extends StoreTableRow>(
572
+ tableConfigs: MultiTableSubscriptionConfig[],
573
+ globalOptions?: SubscriptionOptions
574
+ ): Observable<MultiTableSubscriptionData> {
575
+ return new Observable((observer: any) => {
576
+ const subscriptions: Array<{ tableName: string; subscription: any }> = [];
577
+ const latestData: MultiTableSubscriptionData = {};
578
+
579
+ // Create independent subscription for each table
580
+ tableConfigs.forEach(({ tableName, options }) => {
581
+ const subscription = this.subscribeToFilteredTableChanges<T>(
582
+ tableName,
583
+ options?.filter,
584
+ {
585
+ ...options,
586
+ onData: (data) => {
587
+ // Update latest data for this table
588
+ latestData[tableName] = data;
589
+
590
+ // Call table-level callback
591
+ if (options?.onData) {
592
+ options.onData(data);
593
+ }
594
+
595
+ // Call global callback
596
+ if (globalOptions?.onData) {
597
+ globalOptions.onData(latestData);
598
+ }
599
+
600
+ // Send complete multi-table data
601
+ observer.next({ ...latestData });
602
+ },
603
+ onError: (error) => {
604
+ // Call table-level error callback
605
+ if (options?.onError) {
606
+ options.onError(error);
607
+ }
608
+
609
+ // Call global error callback
610
+ if (globalOptions?.onError) {
611
+ globalOptions.onError(error);
612
+ }
613
+
614
+ // Send error
615
+ observer.error(error);
616
+ },
617
+ }
618
+ );
619
+
620
+ subscriptions.push({ tableName, subscription });
621
+ });
622
+
623
+ // Start all subscriptions
624
+ const activeSubscriptions = subscriptions.map(({ subscription }) =>
625
+ subscription.subscribe()
626
+ );
627
+
628
+ // Return cleanup function
629
+ return () => {
630
+ activeSubscriptions.forEach((sub) => sub.unsubscribe());
631
+
632
+ // Call completion callback
633
+ if (globalOptions?.onComplete) {
634
+ globalOptions.onComplete();
635
+ }
636
+ };
637
+ });
638
+ }
639
+
640
+ /**
641
+ * Simplified multi-table subscription - Support table name array and unified configuration
642
+ */
643
+ subscribeToTableList<T extends StoreTableRow>(
644
+ tableNames: string[],
645
+ options?: SubscriptionOptions & {
646
+ fields?: string[];
647
+ filter?: Record<string, any>;
648
+ initialEvent?: boolean;
649
+ first?: number;
650
+ topicPrefix?: string;
651
+ }
652
+ ): Observable<MultiTableSubscriptionData> {
653
+ const tableConfigs: MultiTableSubscriptionConfig[] = tableNames.map(
654
+ (tableName) => ({
655
+ tableName,
656
+ options: {
657
+ ...options,
658
+ // Use same configuration for each table
659
+ fields: options?.fields,
660
+ filter: options?.filter,
661
+ initialEvent: options?.initialEvent,
662
+ first: options?.first,
663
+ topicPrefix: options?.topicPrefix,
664
+ },
665
+ })
666
+ );
667
+
668
+ return this.subscribeToMultipleTables<T>(tableConfigs, options);
669
+ }
670
+
671
+ /**
672
+ * Build dynamic query - Adapted to API without store prefix
673
+ */
674
+ buildQuery(
675
+ tableName: string,
676
+ fields: string[],
677
+ params?: {
678
+ filter?: Record<string, any>;
679
+ orderBy?: OrderBy[];
680
+ first?: number;
681
+ after?: string;
682
+ }
683
+ ): TypedDocumentNode {
684
+ const pluralTableName = this.getPluralTableName(tableName); // Ensure using plural form
685
+ const fieldSelection = fields.join('\n ');
686
+
687
+ return gql`
688
+ query DynamicQuery(
689
+ $first: Int
690
+ $after: Cursor
691
+ $filter: ${this.getFilterTypeName(tableName)}
692
+ $orderBy: [${this.getOrderByTypeName(tableName)}!]
693
+ ) {
694
+ ${pluralTableName}(
695
+ first: $first
696
+ after: $after
697
+ filter: $filter
698
+ orderBy: $orderBy
699
+ ) {
700
+ totalCount
701
+ pageInfo {
702
+ hasNextPage
703
+ endCursor
704
+ }
705
+ edges {
706
+ cursor
707
+ node {
708
+ ${fieldSelection}
709
+ }
710
+ }
711
+ }
712
+ }
713
+ `;
714
+ }
715
+
716
+ /**
717
+ * Batch query multiple tables - Adapted to API without store prefix
718
+ */
719
+ async batchQuery<T extends Record<string, any>>(
720
+ queries: Array<{
721
+ key: string;
722
+ tableName: string;
723
+ params?: BaseQueryParams & {
724
+ filter?: Record<string, any>;
725
+ orderBy?: OrderBy[];
726
+ fields?: string[]; // Allow users to specify fields to query
727
+ };
728
+ }>
729
+ ): Promise<Record<string, Connection<StoreTableRow>>> {
730
+ const batchPromises = queries.map(async ({ key, tableName, params }) => {
731
+ const result = await this.getAllTables(tableName, params);
732
+ return { key, result };
733
+ });
734
+
735
+ const results = await Promise.all(batchPromises);
736
+
737
+ return results.reduce(
738
+ (acc, { key, result }) => {
739
+ acc[key] = result;
740
+ return acc;
741
+ },
742
+ {} as Record<string, Connection<StoreTableRow>>
743
+ );
744
+ }
745
+
746
+ /**
747
+ * Real-time data stream listener - Adapted to API without store prefix
748
+ */
749
+ createRealTimeDataStream<T extends StoreTableRow>(
750
+ tableName: string,
751
+ initialQuery?: BaseQueryParams & { filter?: Record<string, any> }
752
+ ): Observable<Connection<T>> {
753
+ return new Observable((observer: any) => {
754
+ // First execute initial query
755
+ this.getAllTables<T>(tableName, initialQuery)
756
+ .then((initialData) => {
757
+ observer.next(initialData);
758
+ })
759
+ .catch((error) => observer.error(error));
760
+
761
+ // Then subscribe to real-time updates
762
+ const subscription = this.subscribeToTableChanges<T>(tableName, {
763
+ onData: () => {
764
+ // When data changes, re-execute query
765
+ this.getAllTables<T>(tableName, initialQuery)
766
+ .then((updatedData) => {
767
+ observer.next(updatedData);
768
+ })
769
+ .catch((error) => observer.error(error));
770
+ },
771
+ onError: (error) => observer.error(error),
772
+ });
773
+
774
+ return () => subscription.subscribe().unsubscribe();
775
+ });
776
+ }
777
+
778
+ // Improved table name handling methods
779
+ private getFilterTypeName(tableName: string): string {
780
+ // Convert to singular form and apply PascalCase conversion
781
+ const singularName = this.getSingularTableName(tableName);
782
+ const pascalCaseName = this.toPascalCase(singularName);
783
+
784
+ // If already starts with Store, don't add Store prefix again
785
+ if (pascalCaseName.startsWith('Store')) {
786
+ return `${pascalCaseName}Filter`;
787
+ }
788
+
789
+ return `Store${pascalCaseName}Filter`;
790
+ }
791
+
792
+ private getOrderByTypeName(tableName: string): string {
793
+ // Convert to plural form and apply PascalCase conversion
794
+ const pluralName = this.getPluralTableName(tableName);
795
+ const pascalCaseName = this.toPascalCase(pluralName);
796
+
797
+ // If already starts with Store, don't add Store prefix again
798
+ if (pascalCaseName.startsWith('Store')) {
799
+ return `${pascalCaseName}OrderBy`;
800
+ }
801
+
802
+ return `Store${pascalCaseName}OrderBy`;
803
+ }
804
+
805
+ /**
806
+ * Convert singular table name to plural form (using pluralize library for correctness)
807
+ */
808
+ private getPluralTableName(tableName: string): string {
809
+ // First convert to camelCase
810
+ const camelCaseName = this.toCamelCase(tableName);
811
+
812
+ // Use pluralize library for pluralization
813
+ return pluralize.plural(camelCaseName);
814
+ }
815
+
816
+ /**
817
+ * Convert plural table name to singular form (using pluralize library for correctness)
818
+ */
819
+ private getSingularTableName(tableName: string): string {
820
+ // First convert to camelCase
821
+ const camelCaseName = this.toCamelCase(tableName);
822
+
823
+ // Use pluralize library for singularization
824
+ return pluralize.singular(camelCaseName);
825
+ }
826
+
827
+ /**
828
+ * Convert snake_case to camelCase
829
+ */
830
+ private toCamelCase(str: string): string {
831
+ return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
832
+ }
833
+
834
+ /**
835
+ * Convert snake_case to PascalCase
836
+ */
837
+ private toPascalCase(str: string): string {
838
+ const camelCase = this.toCamelCase(str);
839
+ return camelCase.charAt(0).toUpperCase() + camelCase.slice(1);
840
+ }
841
+
842
+ /**
843
+ * Convert camelCase or snake_case to SNAKE_CASE (for GraphQL enum values)
844
+ * Example: updatedAt -> UPDATED_AT, updated_at -> UPDATED_AT
845
+ */
846
+ private toSnakeCase(str: string): string {
847
+ // If already snake_case, convert to uppercase directly
848
+ if (str.includes('_')) {
849
+ return str.toUpperCase();
850
+ }
851
+
852
+ // If camelCase, first convert to snake_case then uppercase
853
+ return str
854
+ .replace(/([A-Z])/g, '_$1') // Add underscore before uppercase letters
855
+ .toLowerCase() // Convert to lowercase
856
+ .replace(/^_/, '') // Remove leading underscore
857
+ .toUpperCase(); // Convert to uppercase
858
+ }
859
+
860
+ // private buildSingleQueryName(
861
+ // tableName: string,
862
+ // conditionKeys: string[]
863
+ // ): string {
864
+ // // Use camelCase conversion
865
+ // const camelCaseTableName = this.toCamelCase(tableName);
866
+ // const capitalizedKeys = conditionKeys.map(
867
+ // (key) => key.charAt(0).toUpperCase() + key.slice(1)
868
+ // );
869
+ // return `${camelCaseTableName}By${capitalizedKeys.join('And')}`;
870
+ // }
871
+
872
+ /**
873
+ * Clear Apollo Client cache
874
+ */
875
+ async clearCache(): Promise<void> {
876
+ await this.apolloClient.clearStore();
877
+ }
878
+
879
+ /**
880
+ * Reset Apollo Client cache
881
+ */
882
+ async resetCache(): Promise<void> {
883
+ await this.apolloClient.resetStore();
884
+ }
885
+
886
+ /**
887
+ * Get Apollo Client instance (for advanced usage)
888
+ */
889
+ getApolloClient(): ApolloClient<NormalizedCacheObject> {
890
+ return this.apolloClient;
891
+ }
892
+
893
+ /**
894
+ * Close client connection
895
+ */
896
+ close(): void {
897
+ if (this.subscriptionClient) {
898
+ this.subscriptionClient.dispose();
899
+ }
900
+ }
901
+
902
+ /**
903
+ * Get Dubhe metadata
904
+ */
905
+ getDubheMetadata(): DubheMetadata | undefined {
906
+ return this.dubheMetadata;
907
+ }
908
+
909
+ /**
910
+ * Build dynamic cache field configuration
911
+ */
912
+ private buildCacheFields(
913
+ cacheConfig?: DubheClientConfig['cacheConfig']
914
+ ): Record<string, any> {
915
+ const fields: Record<string, any> = {};
916
+
917
+ // If no configuration, return empty field configuration
918
+ if (!cacheConfig) {
919
+ return fields;
920
+ }
921
+
922
+ // Create pagination cache strategy for each configured table
923
+ if (cacheConfig.paginatedTables) {
924
+ cacheConfig.paginatedTables.forEach((tableName) => {
925
+ // Ensure using plural form of table name
926
+ const pluralTableName = this.getPluralTableName(tableName);
927
+
928
+ // Check if there's a custom merge strategy
929
+ const customStrategy =
930
+ cacheConfig.customMergeStrategies?.[pluralTableName];
931
+
932
+ fields[pluralTableName] = {
933
+ keyArgs: customStrategy?.keyArgs || ['filter', 'orderBy'],
934
+ merge: customStrategy?.merge || this.defaultMergeStrategy,
935
+ };
936
+ });
937
+ }
938
+
939
+ // Apply custom merge strategies (if any)
940
+ if (cacheConfig.customMergeStrategies) {
941
+ Object.entries(cacheConfig.customMergeStrategies).forEach(
942
+ ([tableName, strategy]) => {
943
+ // If table name hasn't been configured yet, add it
944
+ if (!fields[tableName]) {
945
+ fields[tableName] = {
946
+ keyArgs: strategy.keyArgs || ['filter', 'orderBy'],
947
+ merge: strategy.merge || this.defaultMergeStrategy,
948
+ };
949
+ }
950
+ }
951
+ );
952
+ }
953
+
954
+ return fields;
955
+ }
956
+
957
+ /**
958
+ * Default pagination merge strategy
959
+ */
960
+ private defaultMergeStrategy(existing = { edges: [] }, incoming: any) {
961
+ // Safety check, ensure incoming has edges property
962
+ if (!incoming || !Array.isArray(incoming.edges)) {
963
+ return existing;
964
+ }
965
+ return {
966
+ ...incoming,
967
+ edges: [...(existing.edges || []), ...incoming.edges],
968
+ };
969
+ }
970
+
971
+ /**
972
+ * Parse table information from dubhe metadata
973
+ */
974
+ private parseTableInfoFromConfig(): void {
975
+ if (!this.dubheMetadata) {
976
+ return;
977
+ }
978
+
979
+ const { components = [], resources = [], enums = [] } = this.dubheMetadata;
980
+
981
+ // Process components array
982
+ components.forEach((componentObj: any) => {
983
+ Object.entries(componentObj).forEach(
984
+ ([componentName, componentData]: [string, any]) => {
985
+ this.processTableData(componentName, componentData, enums);
986
+ }
987
+ );
988
+ });
989
+
990
+ // Process resources array
991
+ resources.forEach((resourceObj: any) => {
992
+ Object.entries(resourceObj).forEach(
993
+ ([resourceName, resourceData]: [string, any]) => {
994
+ this.processTableData(resourceName, resourceData, enums);
995
+ }
996
+ );
997
+ });
998
+ }
999
+
1000
+ /**
1001
+ * Process data for a single table
1002
+ */
1003
+ private processTableData(
1004
+ tableName: string,
1005
+ tableData: any,
1006
+ enums: any[]
1007
+ ): void {
1008
+ const snakeTableName = this.toSnakeCase(tableName);
1009
+ const fields: string[] = [];
1010
+ const enumFields: Record<string, string[]> = {};
1011
+
1012
+ // Process fields array
1013
+ if (tableData.fields && Array.isArray(tableData.fields)) {
1014
+ tableData.fields.forEach((fieldObj: any) => {
1015
+ Object.entries(fieldObj).forEach(
1016
+ ([fieldName, fieldType]: [string, any]) => {
1017
+ const fieldNameCamelCase = this.toCamelCase(fieldName);
1018
+ fields.push(fieldNameCamelCase);
1019
+ // Check if it's an enum type
1020
+ // const typeStr = String(fieldType);
1021
+ // if (enums.length > 0) {
1022
+ // // Process enum types as needed here
1023
+ // // enumFields[fieldNameCamelCase] = [...];
1024
+ // }
1025
+ }
1026
+ );
1027
+ });
1028
+ }
1029
+
1030
+ // Add system fields
1031
+ fields.push('createdAt', 'updatedAt');
1032
+
1033
+ // Process primary keys
1034
+ const primaryKeys: string[] = tableData.keys.map((key: string) =>
1035
+ this.toCamelCase(key)
1036
+ );
1037
+
1038
+ const tableInfo: ParsedTableInfo = {
1039
+ tableName: snakeTableName,
1040
+ fields: [...new Set(fields)], // Remove duplicates
1041
+ primaryKeys,
1042
+ enumFields,
1043
+ };
1044
+
1045
+ this.parsedTables.set(snakeTableName, tableInfo);
1046
+ this.parsedTables.set(this.toCamelCase(snakeTableName), tableInfo);
1047
+ }
1048
+
1049
+ /**
1050
+ * Get table field information
1051
+ */
1052
+ getTableFields(tableName: string): string[] {
1053
+ // Use getMinimalFields directly for clearer logic
1054
+ return this.getMinimalFields(tableName);
1055
+ }
1056
+
1057
+ /**
1058
+ * Get table primary key information
1059
+ */
1060
+ getTablePrimaryKeys(tableName: string): string[] {
1061
+ const tableInfo =
1062
+ this.parsedTables.get(tableName) ||
1063
+ this.parsedTables.get(this.toSnakeCase(tableName));
1064
+ return tableInfo?.primaryKeys || [];
1065
+ }
1066
+
1067
+ /**
1068
+ * Get table enum field information
1069
+ */
1070
+ getTableEnumFields(tableName: string): Record<string, string[]> {
1071
+ const tableInfo =
1072
+ this.parsedTables.get(tableName) ||
1073
+ this.parsedTables.get(this.toSnakeCase(tableName));
1074
+ return tableInfo?.enumFields || {};
1075
+ }
1076
+
1077
+ /**
1078
+ * Get all parsed table information
1079
+ */
1080
+ getAllTableInfo(): Map<string, ParsedTableInfo> {
1081
+ return new Map(this.parsedTables);
1082
+ }
1083
+
1084
+ /**
1085
+ * Get table's minimal field set (for fallback)
1086
+ */
1087
+ getMinimalFields(tableName: string): string[] {
1088
+ // If there's configuration, use fields from configuration
1089
+ const tableInfo =
1090
+ this.parsedTables.get(tableName) ||
1091
+ this.parsedTables.get(this.toSnakeCase(tableName));
1092
+
1093
+ if (tableInfo) {
1094
+ return tableInfo.fields;
1095
+ }
1096
+
1097
+ return ['createdAt', 'updatedAt'];
1098
+ }
1099
+
1100
+ /**
1101
+ * Convert table fields to GraphQL query string
1102
+ */
1103
+ private convertTableFields(
1104
+ tableName: string,
1105
+ customFields?: string[]
1106
+ ): string {
1107
+ let fields: string[];
1108
+
1109
+ if (customFields && customFields.length > 0) {
1110
+ fields = customFields;
1111
+ } else {
1112
+ // Try to get fields from dubhe configuration
1113
+ const autoFields = this.getTableFields(tableName);
1114
+ if (autoFields.length > 0) {
1115
+ fields = autoFields;
1116
+ } else {
1117
+ fields = ['createdAt', 'updatedAt'];
1118
+ }
1119
+ }
1120
+
1121
+ // Field resolution debug logging disabled for cleaner output
1122
+
1123
+ return fields.join('\n ');
1124
+ }
1125
+ }
1126
+
1127
+ // Export convenience function
1128
+ export function createDubheGraphqlClient(
1129
+ config: DubheClientConfig
1130
+ ): DubheGraphqlClient {
1131
+ return new DubheGraphqlClient(config);
1132
+ }
1133
+
1134
+ // Export common GraphQL query builders
1135
+ export const QueryBuilders = {
1136
+ // Build basic query - Adapted to API without store prefix
1137
+ basic: (
1138
+ tableName: string,
1139
+ fields: string[] = ['createdAt', 'updatedAt']
1140
+ ) => gql`
1141
+ query Basic${tableName.charAt(0).toUpperCase() + tableName.slice(1)}Query(
1142
+ $first: Int
1143
+ $after: String
1144
+ $filter: ${tableName.charAt(0).toUpperCase() + tableName.slice(1)}Filter
1145
+ ) {
1146
+ ${tableName}(first: $first, after: $after, filter: $filter) {
1147
+ totalCount
1148
+ pageInfo {
1149
+ hasNextPage
1150
+ endCursor
1151
+ }
1152
+ edges {
1153
+ cursor
1154
+ node {
1155
+ ${fields.join('\n ')}
1156
+ }
1157
+ }
1158
+ }
1159
+ }
1160
+ `,
1161
+
1162
+ // Build subscription query - Adapted to API without store prefix
1163
+ subscription: (tableName: string) => gql`
1164
+ subscription ${tableName.charAt(0).toUpperCase() + tableName.slice(1)}Subscription {
1165
+ ${tableName.charAt(0).toLowerCase() + tableName.slice(1)}Changed {
1166
+ createdAt
1167
+ updatedAt
1168
+ }
1169
+ }
1170
+ `,
1171
+ };
1172
+
1173
+ /**
1174
+ * Helper function: Convert OrderBy format
1175
+ * Support camelCase and snake_case field names conversion to GraphQL enum values
1176
+ * Example: updatedAt -> UPDATED_AT_ASC, updated_at -> UPDATED_AT_ASC
1177
+ */
1178
+ function convertOrderByToEnum(orderBy?: OrderBy[]): string[] {
1179
+ if (!orderBy || orderBy.length === 0) {
1180
+ return ['NATURAL'];
1181
+ }
1182
+
1183
+ return orderBy.map((order) => {
1184
+ // Use unified conversion function to handle field names
1185
+ const field = toSnakeCaseForEnum(order.field);
1186
+ const direction = order.direction === 'DESC' ? 'DESC' : 'ASC';
1187
+
1188
+ // Combine field name and direction into enum value
1189
+ return `${field}_${direction}`;
1190
+ });
1191
+ }
1192
+
1193
+ /**
1194
+ * Convert camelCase or snake_case to SNAKE_CASE (for GraphQL enum values)
1195
+ * Example: updatedAt -> UPDATED_AT, updated_at -> UPDATED_AT
1196
+ */
1197
+ function toSnakeCaseForEnum(str: string): string {
1198
+ // If already snake_case, convert to uppercase directly
1199
+ if (str.includes('_')) {
1200
+ return str.toUpperCase();
1201
+ }
1202
+
1203
+ // If camelCase, first convert to snake_case then uppercase
1204
+ return str
1205
+ .replace(/([A-Z])/g, '_$1') // Add underscore before uppercase letters
1206
+ .toLowerCase() // Convert to lowercase
1207
+ .replace(/^_/, '') // Remove leading underscore
1208
+ .toUpperCase(); // Convert to uppercase
1209
+ }