@0xobelisk/ecs 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.
@@ -0,0 +1,910 @@
1
+ // ECS subscription system implementation
2
+
3
+ import { Observable, Observer } from '@apollo/client';
4
+ import { DubheGraphqlClient } from '@0xobelisk/graphql-client';
5
+ import {
6
+ EntityId,
7
+ ComponentType,
8
+ ComponentCallback,
9
+ QueryChangeCallback,
10
+ QueryWatcher,
11
+ QueryChange,
12
+ SubscriptionOptions,
13
+ Unsubscribe,
14
+ ComponentChangeEvent,
15
+ } from './types';
16
+ import {
17
+ calculateDelta,
18
+ debounce,
19
+ isValidEntityId,
20
+ isValidComponentType,
21
+ formatError,
22
+ createTimestamp,
23
+ } from './utils';
24
+ import { ComponentDiscoverer } from './world';
25
+ import pluralize from 'pluralize';
26
+
27
+ /**
28
+ * ECS subscription result
29
+ */
30
+ export interface ECSSubscriptionResult<T = any> {
31
+ entityId: EntityId;
32
+ data: T | null;
33
+ changeType: 'added' | 'updated' | 'removed';
34
+ timestamp: number;
35
+ error?: Error;
36
+ }
37
+
38
+ /**
39
+ * ECS query change result
40
+ */
41
+ export interface QueryChangeResult {
42
+ changes: QueryChange;
43
+ error?: Error;
44
+ }
45
+
46
+ /**
47
+ * ECS subscription system core implementation
48
+ */
49
+ export class ECSSubscription {
50
+ private graphqlClient: DubheGraphqlClient;
51
+ private subscriptions = new Map<string, any>();
52
+ private queryWatchers = new Map<string, QueryWatcherImpl>();
53
+ private componentDiscoverer: ComponentDiscoverer | null = null;
54
+ private availableComponents: ComponentType[] = [];
55
+ // Component primary key cache - consistent with implementation in query.ts
56
+ private componentPrimaryKeys = new Map<ComponentType, string>();
57
+
58
+ constructor(
59
+ graphqlClient: DubheGraphqlClient,
60
+ componentDiscoverer?: ComponentDiscoverer
61
+ ) {
62
+ this.graphqlClient = graphqlClient;
63
+ this.componentDiscoverer = componentDiscoverer || null;
64
+ }
65
+
66
+ /**
67
+ * Set available component list
68
+ */
69
+ setAvailableComponents(componentTypes: ComponentType[]): void {
70
+ this.availableComponents = componentTypes;
71
+ }
72
+
73
+ /**
74
+ * Pre-parse and cache all component primary key information (consistent with query.ts)
75
+ */
76
+ initializeComponentMetadata(
77
+ componentMetadataList: Array<{ name: ComponentType; primaryKeys: string[] }>
78
+ ) {
79
+ this.componentPrimaryKeys.clear();
80
+
81
+ for (const metadata of componentMetadataList) {
82
+ if (metadata.primaryKeys.length === 1) {
83
+ this.componentPrimaryKeys.set(metadata.name, metadata.primaryKeys[0]);
84
+ }
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Get component's primary key field name (quickly retrieve from cache, consistent with query.ts)
90
+ */
91
+ getComponentPrimaryKeyField(componentType: ComponentType): string {
92
+ return this.componentPrimaryKeys.get(componentType) || 'entityId';
93
+ }
94
+
95
+ /**
96
+ * Set component discoverer
97
+ */
98
+ setComponentDiscoverer(discoverer: ComponentDiscoverer): void {
99
+ this.componentDiscoverer = discoverer;
100
+ }
101
+
102
+ /**
103
+ * Validate if component type is ECS-compliant
104
+ */
105
+ private isECSComponent(componentType: ComponentType): boolean {
106
+ return this.availableComponents.includes(componentType);
107
+ }
108
+
109
+ /**
110
+ * Get component field information (intelligent parsing)
111
+ */
112
+ private async getComponentFields(
113
+ componentType: ComponentType
114
+ ): Promise<string[]> {
115
+ if (this.componentDiscoverer) {
116
+ try {
117
+ const metadata =
118
+ this.componentDiscoverer.getComponentMetadata(componentType);
119
+ if (metadata) {
120
+ return metadata.fields.map((field: any) => field.name);
121
+ }
122
+ } catch (error) {
123
+ // Ignore error for now
124
+ }
125
+ }
126
+
127
+ // Return basic fields when unable to auto-parse
128
+ return ['createdAt', 'updatedAt'];
129
+ }
130
+
131
+ /**
132
+ * Get fields to use for queries (priority: user specified > dubhe config auto-parsed > default fields)
133
+ */
134
+ private async getQueryFields(
135
+ componentType: ComponentType,
136
+ userFields?: string[]
137
+ ): Promise<string[]> {
138
+ if (userFields && userFields.length > 0) {
139
+ return userFields;
140
+ }
141
+
142
+ // Use dubhe config auto-parsed fields, return default fields if failed
143
+ return this.getComponentFields(componentType);
144
+ }
145
+
146
+ /**
147
+ * Listen to component added events
148
+ */
149
+ onComponentAdded<T>(
150
+ componentType: ComponentType,
151
+ options?: SubscriptionOptions & { fields?: string[] }
152
+ ): Observable<ECSSubscriptionResult<T>> {
153
+ if (!isValidComponentType(componentType)) {
154
+ return new Observable((observer: Observer<ECSSubscriptionResult<T>>) => {
155
+ observer.error(new Error(`Invalid component type: ${componentType}`));
156
+ });
157
+ }
158
+
159
+ // Validate if it's an ECS-compliant component
160
+ if (!this.isECSComponent(componentType)) {
161
+ return new Observable((observer: Observer<ECSSubscriptionResult<T>>) => {
162
+ observer.error(
163
+ new Error(
164
+ `Component type ${componentType} is not ECS-compliant or not available`
165
+ )
166
+ );
167
+ });
168
+ }
169
+
170
+ return new Observable((observer: Observer<ECSSubscriptionResult<T>>) => {
171
+ let subscription: any = null;
172
+
173
+ // Asynchronously get fields and create subscription
174
+ this.getQueryFields(componentType, options?.fields)
175
+ .then((subscriptionFields) => {
176
+ const debouncedEmit = options?.debounceMs
177
+ ? debounce(
178
+ (result: ECSSubscriptionResult<T>) => observer.next(result),
179
+ options.debounceMs
180
+ )
181
+ : (result: ECSSubscriptionResult<T>) => observer.next(result);
182
+
183
+ const observable = this.graphqlClient.subscribeToTableChanges(
184
+ componentType,
185
+ {
186
+ initialEvent: options?.initialEvent ?? false,
187
+ fields: subscriptionFields,
188
+ onData: (data) => {
189
+ try {
190
+ // Process batch data
191
+ const pluralTableName =
192
+ this.getPluralTableName(componentType);
193
+ const nodes = data?.listen?.query?.[pluralTableName]?.nodes;
194
+ if (nodes && Array.isArray(nodes)) {
195
+ nodes.forEach((node: any) => {
196
+ if (node) {
197
+ const entityId =
198
+ node.entityId ||
199
+ this.extractEntityId(node, componentType);
200
+ if (entityId) {
201
+ const result: ECSSubscriptionResult<T> = {
202
+ entityId,
203
+ data: node as T,
204
+ changeType: 'added',
205
+ timestamp: Date.now(),
206
+ };
207
+ debouncedEmit(result);
208
+ }
209
+ }
210
+ });
211
+ }
212
+ } catch (error) {
213
+ observer.error(error);
214
+ }
215
+ },
216
+ onError: (error) => {
217
+ observer.error(error);
218
+ },
219
+ onComplete: () => {
220
+ observer.complete();
221
+ },
222
+ }
223
+ );
224
+
225
+ // Start subscription
226
+ subscription = observable.subscribe({});
227
+ })
228
+ .catch((error) => {
229
+ observer.error(error);
230
+ });
231
+
232
+ // Return cleanup function
233
+ return () => {
234
+ if (subscription) {
235
+ subscription.unsubscribe();
236
+ }
237
+ };
238
+ });
239
+ }
240
+
241
+ /**
242
+ * Listen to component removed events
243
+ */
244
+ onComponentRemoved<T>(
245
+ componentType: ComponentType,
246
+ options?: SubscriptionOptions & { fields?: string[] }
247
+ ): Observable<ECSSubscriptionResult<T>> {
248
+ if (!isValidComponentType(componentType)) {
249
+ return new Observable((observer: Observer<ECSSubscriptionResult<T>>) => {
250
+ observer.error(new Error(`Invalid component type: ${componentType}`));
251
+ });
252
+ }
253
+
254
+ // Validate if it's an ECS-compliant component
255
+ if (!this.isECSComponent(componentType)) {
256
+ return new Observable((observer: Observer<ECSSubscriptionResult<T>>) => {
257
+ observer.error(
258
+ new Error(
259
+ `Component type ${componentType} is not ECS-compliant or not available`
260
+ )
261
+ );
262
+ });
263
+ }
264
+
265
+ return new Observable((observer: Observer<ECSSubscriptionResult<T>>) => {
266
+ let subscription: any = null;
267
+ let lastKnownEntities = new Set<EntityId>();
268
+
269
+ try {
270
+ const debouncedEmit = options?.debounceMs
271
+ ? debounce(
272
+ (result: ECSSubscriptionResult<T>) => observer.next(result),
273
+ options.debounceMs
274
+ )
275
+ : (result: ECSSubscriptionResult<T>) => observer.next(result);
276
+
277
+ // First get current entity list
278
+ this.initializeLastKnownEntities(componentType, lastKnownEntities);
279
+
280
+ const observable = this.graphqlClient.subscribeToTableChanges(
281
+ componentType,
282
+ {
283
+ initialEvent: false,
284
+ fields: ['updatedAt'], // Removal detection only needs basic fields
285
+ onData: (data) => {
286
+ try {
287
+ // Get current entity list
288
+ const pluralTableName = this.getPluralTableName(componentType);
289
+ const nodes =
290
+ data?.listen?.query?.[pluralTableName]?.nodes || [];
291
+ const currentEntities = new Set<EntityId>(
292
+ nodes
293
+ .map((node: any) => {
294
+ const entityId =
295
+ node.entityId ||
296
+ this.extractEntityId(node, componentType);
297
+ return entityId;
298
+ })
299
+ .filter(Boolean)
300
+ );
301
+
302
+ // Find removed entities
303
+ const removedEntities = Array.from(lastKnownEntities).filter(
304
+ (entityId) => !currentEntities.has(entityId)
305
+ );
306
+
307
+ removedEntities.forEach((entityId) => {
308
+ const result: ECSSubscriptionResult<T> = {
309
+ entityId,
310
+ data: null,
311
+ changeType: 'removed',
312
+ timestamp: Date.now(),
313
+ };
314
+ debouncedEmit(result);
315
+ });
316
+
317
+ lastKnownEntities = currentEntities;
318
+ } catch (error) {
319
+ observer.error(error);
320
+ }
321
+ },
322
+ onError: (error) => {
323
+ observer.error(error);
324
+ },
325
+ onComplete: () => {
326
+ observer.complete();
327
+ },
328
+ }
329
+ );
330
+
331
+ // Start subscription
332
+ subscription = observable.subscribe({});
333
+ } catch (error) {
334
+ observer.error(error);
335
+ }
336
+
337
+ // Return cleanup function
338
+ return () => {
339
+ if (subscription) {
340
+ subscription.unsubscribe();
341
+ }
342
+ };
343
+ });
344
+ }
345
+
346
+ /**
347
+ * Listen to component changed events (added, removed, modified)
348
+ */
349
+ onComponentChanged<T>(
350
+ componentType: ComponentType,
351
+ options?: SubscriptionOptions & { fields?: string[] }
352
+ ): Observable<ECSSubscriptionResult<T>> {
353
+ if (!isValidComponentType(componentType)) {
354
+ return new Observable((observer: Observer<ECSSubscriptionResult<T>>) => {
355
+ observer.error(new Error(`Invalid component type: ${componentType}`));
356
+ });
357
+ }
358
+
359
+ // Validate if it's an ECS-compliant component
360
+ if (!this.isECSComponent(componentType)) {
361
+ return new Observable((observer: Observer<ECSSubscriptionResult<T>>) => {
362
+ observer.error(
363
+ new Error(
364
+ `Component type ${componentType} is not ECS-compliant or not available`
365
+ )
366
+ );
367
+ });
368
+ }
369
+
370
+ return new Observable((observer: Observer<ECSSubscriptionResult<T>>) => {
371
+ let subscription: any = null;
372
+
373
+ // Asynchronously get fields and create subscription
374
+ this.getQueryFields(componentType, options?.fields)
375
+ .then((subscriptionFields) => {
376
+ const debouncedEmit = options?.debounceMs
377
+ ? debounce(
378
+ (result: ECSSubscriptionResult<T>) => observer.next(result),
379
+ options.debounceMs
380
+ )
381
+ : (result: ECSSubscriptionResult<T>) => observer.next(result);
382
+
383
+ const observable = this.graphqlClient.subscribeToTableChanges(
384
+ componentType,
385
+ {
386
+ initialEvent: options?.initialEvent ?? false,
387
+ fields: subscriptionFields,
388
+ onData: (data) => {
389
+ try {
390
+ // Get plural table name correctly
391
+ const pluralTableName =
392
+ this.getPluralTableName(componentType);
393
+
394
+ const nodes = data?.listen?.query?.[pluralTableName]?.nodes;
395
+
396
+ if (nodes && Array.isArray(nodes)) {
397
+ nodes.forEach((node: any) => {
398
+ if (node) {
399
+ // Entity ID may be in different fields
400
+ const entityId =
401
+ node.entityId ||
402
+ this.extractEntityId(node, componentType);
403
+
404
+ if (entityId) {
405
+ const result: ECSSubscriptionResult<T> = {
406
+ entityId,
407
+ data: node as T,
408
+ changeType: 'updated',
409
+ timestamp: Date.now(),
410
+ };
411
+ debouncedEmit(result);
412
+ }
413
+ }
414
+ });
415
+ }
416
+ } catch (error) {
417
+ observer.error(error);
418
+ }
419
+ },
420
+ onError: (error) => {
421
+ observer.error(error);
422
+ },
423
+ onComplete: () => {
424
+ observer.complete();
425
+ },
426
+ }
427
+ );
428
+
429
+ // Start subscription
430
+ subscription = observable.subscribe({});
431
+ })
432
+ .catch((error) => {
433
+ observer.error(error);
434
+ });
435
+
436
+ // Return cleanup function
437
+ return () => {
438
+ if (subscription) {
439
+ subscription.unsubscribe();
440
+ }
441
+ };
442
+ });
443
+ }
444
+
445
+ /**
446
+ * Listen to component changes with specific conditions
447
+ */
448
+ onComponentCondition<T>(
449
+ componentType: ComponentType,
450
+ filter: Record<string, any>,
451
+ options?: SubscriptionOptions & { fields?: string[] }
452
+ ): Observable<ECSSubscriptionResult<T>> {
453
+ if (!isValidComponentType(componentType)) {
454
+ return new Observable((observer: Observer<ECSSubscriptionResult<T>>) => {
455
+ observer.error(new Error(`Invalid component type: ${componentType}`));
456
+ });
457
+ }
458
+
459
+ // Validate if it's an ECS-compliant component
460
+ if (!this.isECSComponent(componentType)) {
461
+ return new Observable((observer: Observer<ECSSubscriptionResult<T>>) => {
462
+ observer.error(
463
+ new Error(
464
+ `Component type ${componentType} is not ECS-compliant or not available`
465
+ )
466
+ );
467
+ });
468
+ }
469
+
470
+ return new Observable((observer: Observer<ECSSubscriptionResult<T>>) => {
471
+ let subscription: any = null;
472
+
473
+ // Asynchronously get fields and create subscription
474
+ this.getQueryFields(componentType, options?.fields)
475
+ .then((subscriptionFields) => {
476
+ const debouncedEmit = options?.debounceMs
477
+ ? debounce(
478
+ (result: ECSSubscriptionResult<T>) => observer.next(result),
479
+ options.debounceMs
480
+ )
481
+ : (result: ECSSubscriptionResult<T>) => observer.next(result);
482
+
483
+ const observable = this.graphqlClient.subscribeToFilteredTableChanges(
484
+ componentType,
485
+ filter,
486
+ {
487
+ initialEvent: options?.initialEvent ?? false,
488
+ fields: subscriptionFields,
489
+ onData: (data) => {
490
+ try {
491
+ const pluralTableName =
492
+ this.getPluralTableName(componentType);
493
+ const nodes =
494
+ data?.listen?.query?.[pluralTableName]?.nodes || [];
495
+
496
+ nodes.forEach((node: any) => {
497
+ if (node) {
498
+ const entityId =
499
+ node.entityId ||
500
+ this.extractEntityId(node, componentType);
501
+ if (entityId) {
502
+ const result: ECSSubscriptionResult<T> = {
503
+ entityId,
504
+ data: node as T,
505
+ changeType: 'updated',
506
+ timestamp: Date.now(),
507
+ };
508
+ debouncedEmit(result);
509
+ }
510
+ }
511
+ });
512
+ } catch (error) {
513
+ observer.error(error);
514
+ }
515
+ },
516
+ onError: (error) => {
517
+ observer.error(error);
518
+ },
519
+ onComplete: () => {
520
+ observer.complete();
521
+ },
522
+ }
523
+ );
524
+
525
+ // Start subscription
526
+ subscription = observable.subscribe({});
527
+ })
528
+ .catch((error) => {
529
+ observer.error(error);
530
+ });
531
+
532
+ // Return cleanup function
533
+ return () => {
534
+ if (subscription) {
535
+ subscription.unsubscribe();
536
+ }
537
+ };
538
+ });
539
+ }
540
+
541
+ /**
542
+ * Listen to query result changes
543
+ */
544
+ watchQuery(
545
+ componentTypes: ComponentType[],
546
+ options?: SubscriptionOptions
547
+ ): Observable<QueryChangeResult> {
548
+ const validTypes = componentTypes.filter(isValidComponentType);
549
+ if (validTypes.length === 0) {
550
+ return new Observable((observer: Observer<QueryChangeResult>) => {
551
+ observer.error(
552
+ new Error('No valid component types for query watching')
553
+ );
554
+ });
555
+ }
556
+
557
+ return new Observable((observer: Observer<QueryChangeResult>) => {
558
+ const watcher = new QueryWatcherImpl(
559
+ this.graphqlClient,
560
+ validTypes,
561
+ (changes: QueryChange) => {
562
+ const result: QueryChangeResult = {
563
+ changes,
564
+ };
565
+
566
+ if (options?.debounceMs) {
567
+ const debouncedEmit = debounce(
568
+ () => observer.next(result),
569
+ options.debounceMs
570
+ );
571
+ debouncedEmit();
572
+ } else {
573
+ observer.next(result);
574
+ }
575
+ },
576
+ options
577
+ );
578
+
579
+ // Return cleanup function
580
+ return () => {
581
+ watcher.dispose();
582
+ };
583
+ });
584
+ }
585
+
586
+ /**
587
+ * Create real-time data stream
588
+ */
589
+ createRealTimeStream<T>(
590
+ componentType: ComponentType,
591
+ initialFilter?: Record<string, any>
592
+ ): Observable<Array<{ entityId: EntityId; data: T }>> {
593
+ if (!isValidComponentType(componentType)) {
594
+ return new Observable(
595
+ (observer: Observer<Array<{ entityId: EntityId; data: T }>>) => {
596
+ observer.error(new Error(`Invalid component type: ${componentType}`));
597
+ }
598
+ );
599
+ }
600
+
601
+ return new Observable(
602
+ (observer: Observer<Array<{ entityId: EntityId; data: T }>>) => {
603
+ try {
604
+ const subscription = this.graphqlClient.createRealTimeDataStream(
605
+ componentType,
606
+ { filter: initialFilter }
607
+ );
608
+
609
+ const streamSubscription = subscription.subscribe({
610
+ next: (connection: any) => {
611
+ const results = connection.edges
612
+ .map((edge: any) => {
613
+ const node = edge.node as any;
614
+ const entityId =
615
+ node.nodeId ||
616
+ node.entityId ||
617
+ Object.values(node)[0] ||
618
+ '';
619
+ return {
620
+ entityId,
621
+ data: node as T,
622
+ };
623
+ })
624
+ .filter((result: any) => result.entityId);
625
+ observer.next(results);
626
+ },
627
+ error: (error: any) => {
628
+ observer.error(error);
629
+ },
630
+ complete: () => {
631
+ observer.complete();
632
+ },
633
+ });
634
+
635
+ // Return cleanup function
636
+ return () => {
637
+ streamSubscription.unsubscribe();
638
+ };
639
+ } catch (error) {
640
+ observer.error(error);
641
+ }
642
+ }
643
+ );
644
+ }
645
+
646
+ /**
647
+ * Initialize known entity list (for deletion detection)
648
+ */
649
+ private async initializeLastKnownEntities(
650
+ componentType: ComponentType,
651
+ lastKnownEntities: Set<EntityId>
652
+ ): Promise<void> {
653
+ try {
654
+ const connection = await this.graphqlClient.getAllTables(componentType, {
655
+ fields: ['updatedAt'],
656
+ });
657
+
658
+ connection.edges.forEach((edge) => {
659
+ const node = edge.node as any;
660
+ const entityId = node.nodeId || node.entityId || Object.values(node)[0];
661
+ if (entityId) {
662
+ lastKnownEntities.add(entityId);
663
+ }
664
+ });
665
+ } catch (error) {
666
+ // Ignore error for now
667
+ }
668
+ }
669
+
670
+ /**
671
+ * Convert singular table name to plural form (using pluralize library for correctness)
672
+ */
673
+ private getPluralTableName(tableName: string): string {
674
+ // First convert to camelCase
675
+ const camelCaseName = this.toCamelCase(tableName);
676
+
677
+ // Use pluralize library for pluralization
678
+ return pluralize.plural(camelCaseName);
679
+ }
680
+
681
+ /**
682
+ * Convert snake_case to camelCase
683
+ */
684
+ private toCamelCase(str: string): string {
685
+ return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
686
+ }
687
+
688
+ /**
689
+ * Extract entity ID from node (using component's primary key field information)
690
+ */
691
+ private extractEntityId(node: any, componentType: ComponentType): string {
692
+ if (!node || typeof node !== 'object') {
693
+ return '';
694
+ }
695
+
696
+ // Use component's primary key field (consistent with query.ts)
697
+ const primaryKeyField = this.getComponentPrimaryKeyField(componentType);
698
+
699
+ // First try using primary key field
700
+ if (node[primaryKeyField] && typeof node[primaryKeyField] === 'string') {
701
+ return node[primaryKeyField];
702
+ }
703
+
704
+ // If primary key field doesn't exist, fallback to default entityId field
705
+ if (
706
+ primaryKeyField !== 'entityId' &&
707
+ node.entityId &&
708
+ typeof node.entityId === 'string'
709
+ ) {
710
+ return node.entityId;
711
+ }
712
+
713
+ // Finally try getting first string value as fallback
714
+ const values = Object.values(node);
715
+ for (const value of values) {
716
+ if (typeof value === 'string' && value.length > 0) {
717
+ return value;
718
+ }
719
+ }
720
+
721
+ return '';
722
+ }
723
+
724
+ /**
725
+ * Unsubscribe all subscriptions
726
+ */
727
+ unsubscribeAll(): void {
728
+ // Unsubscribe regular subscriptions
729
+ this.subscriptions.forEach((subscription) => {
730
+ try {
731
+ subscription?.unsubscribe();
732
+ } catch (error) {
733
+ // Ignore error for now
734
+ }
735
+ });
736
+ this.subscriptions.clear();
737
+
738
+ // Unsubscribe query watchers
739
+ this.queryWatchers.forEach((watcher) => {
740
+ try {
741
+ watcher.dispose();
742
+ } catch (error) {
743
+ // Ignore error for now
744
+ }
745
+ });
746
+ this.queryWatchers.clear();
747
+ }
748
+
749
+ /**
750
+ * Dispose resources
751
+ */
752
+ dispose(): void {
753
+ this.unsubscribeAll();
754
+ }
755
+ }
756
+
757
+ /**
758
+ * Query watcher implementation
759
+ */
760
+ class QueryWatcherImpl {
761
+ private graphqlClient: DubheGraphqlClient;
762
+ private componentTypes: ComponentType[];
763
+ private callback: QueryChangeCallback;
764
+ private options?: SubscriptionOptions;
765
+ private subscriptions: any[] = [];
766
+ private currentResults: EntityId[] = [];
767
+ private isInitialized = false;
768
+
769
+ constructor(
770
+ graphqlClient: DubheGraphqlClient,
771
+ componentTypes: ComponentType[],
772
+ callback: QueryChangeCallback,
773
+ options?: SubscriptionOptions
774
+ ) {
775
+ this.graphqlClient = graphqlClient;
776
+ this.componentTypes = componentTypes;
777
+ this.callback = callback;
778
+ this.options = options;
779
+ this.initialize();
780
+ }
781
+
782
+ private async initialize(): Promise<void> {
783
+ try {
784
+ // Get initial results
785
+ await this.updateCurrentResults();
786
+
787
+ // Create subscription for each component type
788
+ this.componentTypes.forEach((componentType) => {
789
+ const observable = this.graphqlClient.subscribeToTableChanges(
790
+ componentType,
791
+ {
792
+ initialEvent: false,
793
+ onData: () => {
794
+ // When data changes, recalculate results
795
+ this.handleDataChange();
796
+ },
797
+ onError: (error) => {
798
+ // Ignore error for now
799
+ },
800
+ }
801
+ );
802
+
803
+ // Start subscription
804
+ const actualSubscription = observable.subscribe({});
805
+ this.subscriptions.push(actualSubscription);
806
+ });
807
+
808
+ this.isInitialized = true;
809
+
810
+ // Trigger initial event (if needed)
811
+ if (this.options?.initialEvent && this.currentResults.length > 0) {
812
+ this.callback({
813
+ added: this.currentResults,
814
+ removed: [],
815
+ current: this.currentResults,
816
+ });
817
+ }
818
+ } catch (error) {
819
+ // Ignore error for now
820
+ }
821
+ }
822
+
823
+ private async handleDataChange(): Promise<void> {
824
+ if (!this.isInitialized) return;
825
+
826
+ try {
827
+ const oldResults = [...this.currentResults];
828
+ await this.updateCurrentResults();
829
+
830
+ const changes = calculateDelta(oldResults, this.currentResults);
831
+
832
+ if (changes.added.length > 0 || changes.removed.length > 0) {
833
+ const debouncedCallback = this.options?.debounceMs
834
+ ? debounce(this.callback, this.options.debounceMs)
835
+ : this.callback;
836
+
837
+ debouncedCallback(changes);
838
+ }
839
+ } catch (error) {
840
+ // Ignore error for now
841
+ }
842
+ }
843
+
844
+ private async updateCurrentResults(): Promise<void> {
845
+ try {
846
+ if (this.componentTypes.length === 1) {
847
+ // Single component query
848
+ const connection = await this.graphqlClient.getAllTables(
849
+ this.componentTypes[0],
850
+ { fields: ['updatedAt'] }
851
+ );
852
+ this.currentResults = connection.edges
853
+ .map((edge) => {
854
+ const node = edge.node as any;
855
+ return node.nodeId || node.entityId || Object.values(node)[0] || '';
856
+ })
857
+ .filter(Boolean);
858
+ } else {
859
+ // Multi-component query (intersection)
860
+ const queries = this.componentTypes.map((type) => ({
861
+ key: type,
862
+ tableName: type,
863
+ params: {
864
+ fields: ['updatedAt'],
865
+ filter: {},
866
+ },
867
+ }));
868
+
869
+ const batchResult = await this.graphqlClient.batchQuery(queries);
870
+
871
+ // Calculate intersection
872
+ const entitySets = this.componentTypes.map((type) => {
873
+ const connection = batchResult[type];
874
+ return connection
875
+ ? connection.edges
876
+ .map((edge) => {
877
+ const node = edge.node as any;
878
+ return (
879
+ node.nodeId || node.entityId || Object.values(node)[0] || ''
880
+ );
881
+ })
882
+ .filter(Boolean)
883
+ : [];
884
+ });
885
+
886
+ this.currentResults = entitySets.reduce((intersection, currentSet) => {
887
+ const currentSetLookup = new Set(currentSet);
888
+ return intersection.filter((id) => currentSetLookup.has(id));
889
+ });
890
+ }
891
+ } catch (error) {
892
+ this.currentResults = [];
893
+ }
894
+ }
895
+
896
+ getCurrentResults(): EntityId[] {
897
+ return [...this.currentResults];
898
+ }
899
+
900
+ dispose(): void {
901
+ this.subscriptions.forEach((subscription) => {
902
+ try {
903
+ subscription?.unsubscribe();
904
+ } catch (error) {
905
+ // Ignore error for now
906
+ }
907
+ });
908
+ this.subscriptions = [];
909
+ }
910
+ }