@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.
- package/LICENSE +92 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +2346 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +2287 -0
- package/dist/index.mjs.map +1 -0
- package/dist/query.d.ts +154 -0
- package/dist/subscription.d.ts +123 -0
- package/dist/types.d.ts +124 -0
- package/dist/utils.d.ts +92 -0
- package/dist/world.d.ts +290 -0
- package/package.json +155 -0
- package/src/index.ts +58 -0
- package/src/query.ts +810 -0
- package/src/subscription.ts +910 -0
- package/src/types.ts +179 -0
- package/src/utils.ts +291 -0
- package/src/world.ts +1223 -0
|
@@ -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
|
+
}
|