@obsidiane/meridiane 2.0.5 → 2.0.7

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.
@@ -1,9 +1,23 @@
1
1
  import {Inject, Injectable, OnDestroy, Optional, PLATFORM_ID} from '@angular/core';
2
2
  import {BehaviorSubject, defer, EMPTY, fromEvent, Observable, of, Subject} from 'rxjs';
3
3
  import {auditTime, concatMap, filter, finalize, map, share, takeUntil} from 'rxjs/operators';
4
- import {API_BASE_URL, BRIDGE_LOGGER, BRIDGE_WITH_CREDENTIALS, MERCURE_HUB_URL, MERCURE_TOPIC_MODE} from '../../tokens';
5
- import {BridgeLogger, MercureTopicMode} from '../../bridge.types';
6
- import {RealtimeEvent, RealtimePort, RealtimeStatus} from '../../ports/realtime.port';
4
+ import {
5
+ API_BASE_URL,
6
+ BRIDGE_LOGGER,
7
+ BRIDGE_WITH_CREDENTIALS,
8
+ MERCURE_CONNECTION_MODE,
9
+ MERCURE_HUB_URL,
10
+ MERCURE_MAX_URL_LENGTH,
11
+ MERCURE_TOPIC_MODE,
12
+ } from '../../tokens';
13
+ import {
14
+ BridgeLogger,
15
+ MercureConnectionMode,
16
+ MercureTopicMode,
17
+ RealtimeDiagnostics,
18
+ WatchConnectionOptions,
19
+ } from '../../bridge.types';
20
+ import {RealtimeEvent, RealtimePort, RealtimeStatus, SubscribeFilter} from '../../ports/realtime.port';
7
21
  import {EventSourceWrapper} from './eventsource-wrapper';
8
22
  import {Item} from '../../ports/resource-repository.port';
9
23
  import {isPlatformBrowser} from '@angular/common';
@@ -11,24 +25,66 @@ import {MercureUrlBuilder} from './mercure-url.builder';
11
25
  import {RefCountTopicRegistry} from './ref-count-topic.registry';
12
26
  import {MercureTopicMapper} from './mercure-topic.mapper';
13
27
 
28
+ type IncomingMessage = { type: string; data: string; lastEventId?: string };
29
+
30
+ type ManagedConnectionScope = 'shared' | 'dedicated';
31
+
32
+ type ManagedConnection = {
33
+ id: string;
34
+ key: string;
35
+ scope: ManagedConnectionScope;
36
+ groupId?: string;
37
+ topics: ReadonlySet<string>;
38
+ urlLength: number;
39
+ wrapper: EventSourceWrapper;
40
+ stop$: Subject<void>;
41
+ status: RealtimeStatus;
42
+ };
43
+
44
+ type SharedSubscriptionClaim = {
45
+ id: string;
46
+ topics: string[];
47
+ topicsKey: string;
48
+ stop$: Subject<void>;
49
+ released: boolean;
50
+ };
51
+
52
+ type DedicatedSubscriptionClaim = {
53
+ id: string;
54
+ groupId: string;
55
+ topics: string[];
56
+ topicsKey: string;
57
+ incoming: Subject<IncomingMessage>;
58
+ stop$: Subject<void>;
59
+ released: boolean;
60
+ };
14
61
 
15
62
  @Injectable({providedIn: 'root'})
16
63
  export class MercureRealtimeAdapter implements RealtimePort, OnDestroy {
17
- private lastEventId?: string;
18
- private es?: EventSourceWrapper;
19
- private currentKey?: string;
64
+ private readonly connectionMode: MercureConnectionMode;
65
+ private readonly maxUrlLength: number;
20
66
 
21
67
  private readonly topicsRegistry = new RefCountTopicRegistry();
22
68
  private readonly urlBuilder: MercureUrlBuilder;
23
69
  private readonly topicMapper: MercureTopicMapper;
24
70
 
71
+ private readonly sharedConnections = new Map<string, ManagedConnection>();
72
+ private readonly connections = new Map<string, ManagedConnection>();
73
+ private readonly dedicatedGroups = new Map<string, Set<string>>();
74
+ private readonly lastEventIdByKey = new Map<string, string>();
75
+ private readonly sharedClaims = new Map<string, SharedSubscriptionClaim>();
76
+ private readonly sharedClaimQueueByTopicsKey = new Map<string, string[]>();
77
+ private readonly dedicatedClaims = new Map<string, DedicatedSubscriptionClaim>();
78
+ private readonly dedicatedClaimQueueByTopicsKey = new Map<string, string[]>();
79
+
25
80
  private readonly destroy$ = new Subject<void>();
26
- private connectionStop$ = new Subject<void>();
27
- private readonly rebuild$ = new Subject<void>();
81
+ private readonly sharedRebuild$ = new Subject<void>();
28
82
  private shuttingDown = false;
83
+ private sequence = 0;
29
84
 
30
85
  private readonly _status$ = new BehaviorSubject<RealtimeStatus>('closed');
31
- private readonly incoming$ = new Subject<{ type: string; data: string }>();
86
+ private readonly _diagnostics$ = new BehaviorSubject<RealtimeDiagnostics>(emptyDiagnostics('auto', 1900));
87
+ private readonly incoming$ = new Subject<IncomingMessage>();
32
88
 
33
89
  constructor(
34
90
  @Inject(API_BASE_URL) private readonly apiBase: string,
@@ -36,17 +92,23 @@ export class MercureRealtimeAdapter implements RealtimePort, OnDestroy {
36
92
  @Inject(PLATFORM_ID) private readonly platformId: object,
37
93
  @Optional() @Inject(MERCURE_HUB_URL) private readonly hubUrl?: string,
38
94
  @Optional() @Inject(MERCURE_TOPIC_MODE) topicMode?: MercureTopicMode,
95
+ @Optional() @Inject(MERCURE_CONNECTION_MODE) connectionMode?: MercureConnectionMode | null,
96
+ @Optional() @Inject(MERCURE_MAX_URL_LENGTH) maxUrlLength?: number | null,
39
97
  @Optional() @Inject(BRIDGE_LOGGER) private readonly logger?: BridgeLogger,
40
98
  ) {
99
+ this.connectionMode = connectionMode ?? 'auto';
100
+ this.maxUrlLength = normalizeMaxUrlLength(maxUrlLength, 1900);
101
+ this.emitDiagnostics();
102
+
41
103
  this.urlBuilder = new MercureUrlBuilder();
42
104
  // `topicMode` affects only the `topic=` query param sent to the hub.
43
105
  // Payload IRIs are always matched using same-origin relative IRIs (`/api/...`) when possible.
44
106
  this.topicMapper = new MercureTopicMapper(apiBase, topicMode ?? 'url');
45
107
 
46
- this.rebuild$
108
+ this.sharedRebuild$
47
109
  .pipe(
48
110
  auditTime(10),
49
- concatMap(() => this.rebuildOnce$()),
111
+ concatMap(() => this.rebuildSharedConnectionsOnce$()),
50
112
  )
51
113
  .subscribe();
52
114
 
@@ -67,8 +129,15 @@ export class MercureRealtimeAdapter implements RealtimePort, OnDestroy {
67
129
  return this._status$.asObservable();
68
130
  }
69
131
 
132
+ diagnostics$(): Observable<RealtimeDiagnostics> {
133
+ return this._diagnostics$.asObservable();
134
+ }
70
135
 
71
- subscribe$<T>(iris: string[], _filter?: { field?: string }): Observable<RealtimeEvent<T>> {
136
+ subscribe$<T>(
137
+ iris: string[],
138
+ subscribeFilter?: SubscribeFilter,
139
+ options?: WatchConnectionOptions
140
+ ): Observable<RealtimeEvent<T>> {
72
141
  return defer(() => {
73
142
  const inputIris = iris.filter((v): v is string => typeof v === 'string' && v.length > 0);
74
143
  if (inputIris.length === 0) return EMPTY;
@@ -78,34 +147,51 @@ export class MercureRealtimeAdapter implements RealtimePort, OnDestroy {
78
147
  return EMPTY;
79
148
  }
80
149
 
81
- // Canonicalise topics (ref-count + URL) to avoid duplicates like:
82
- // - "/api/conversations/1" and "http://localhost:8000/api/conversations/1"
83
150
  const registeredTopics = Array.from(new Set(inputIris.map((i) => this.topicMapper.toTopic(i))));
84
-
85
- this.topicsRegistry.addAll(registeredTopics);
86
- this.scheduleRebuild();
151
+ const source$ = this.buildIncomingStream$(registeredTopics, options);
87
152
 
88
153
  // Matching is done against the payload IRIs (typically "/api/...").
89
154
  const subscribed = inputIris.map((i) => normalizeIri(this.topicMapper.toPayloadIri(i)));
90
- const fieldPath = _filter?.field;
155
+ const fieldPaths = buildFieldPaths(subscribeFilter);
156
+ const includeSelf = resolveIncludeSelf(subscribeFilter, {hasFields: fieldPaths.length > 0});
91
157
 
92
- return this.incoming$.pipe(
158
+ return source$.pipe(
93
159
  map((evt) => this.safeParse(evt.data)),
94
- filter((raw): raw is Item => !!raw),
95
-
96
- filter((raw: any) => {
97
- if (fieldPath) {
98
- const relIris = this.extractRelationIris(raw, fieldPath).map((i) => normalizeIri(this.topicMapper.toPayloadIri(i)));
99
- return relIris.some((relIri) => matchesAnySubscribed(relIri, subscribed));
100
- }
101
- const rawId = raw?.['@id'];
102
- const id = typeof rawId === 'string' ? normalizeIri(this.topicMapper.toPayloadIri(rawId)) : undefined;
103
- return typeof id === 'string' && matchesAnySubscribed(id, subscribed);
160
+ filter((raw): raw is Item => !!raw && typeof raw === 'object' && !Array.isArray(raw)),
161
+ map((payload) => {
162
+ const topic = this.matchSubscribedTopic(payload, subscribed, {fieldPaths, includeSelf});
163
+ if (!topic) return undefined;
164
+
165
+ const rawId = payload?.['@id'];
166
+ const iri = typeof rawId === 'string' ? rawId : topic;
167
+ return {topic, iri, data: payload as T} as RealtimeEvent<T>;
104
168
  }),
105
- map((payload) => ({iri: payload['@id'] as string, data: payload as T})),
106
- finalize(() => {
107
- this.topicsRegistry.removeAll(registeredTopics);
108
- this.scheduleRebuild();
169
+ filter((e): e is RealtimeEvent<T> => !!e),
170
+ share()
171
+ );
172
+ });
173
+ }
174
+
175
+ subscribeAll$<T>(iris: string[], options?: WatchConnectionOptions): Observable<RealtimeEvent<T>> {
176
+ return defer(() => {
177
+ const inputIris = iris.filter((v): v is string => typeof v === 'string' && v.length > 0);
178
+ if (inputIris.length === 0) return EMPTY;
179
+
180
+ if (!this.hubUrl) {
181
+ this.logger?.debug?.('[Mercure] hubUrl not configured → realtime disabled');
182
+ return EMPTY;
183
+ }
184
+
185
+ const registeredTopics = Array.from(new Set(inputIris.map((i) => this.topicMapper.toTopic(i))));
186
+ const source$ = this.buildIncomingStream$(registeredTopics, options);
187
+
188
+ return source$.pipe(
189
+ map((evt) => this.safeParse(evt.data)),
190
+ filter((raw): raw is Item => !!raw && typeof raw === 'object' && !Array.isArray(raw)),
191
+ map((payload: any) => {
192
+ const rawId = payload?.['@id'];
193
+ const iri = typeof rawId === 'string' ? rawId : '';
194
+ return {iri, data: payload as T} as RealtimeEvent<T>;
109
195
  }),
110
196
  share()
111
197
  );
@@ -115,113 +201,519 @@ export class MercureRealtimeAdapter implements RealtimePort, OnDestroy {
115
201
  unsubscribe(iris: string[]): void {
116
202
  const inputIris = iris.filter((v): v is string => typeof v === 'string' && v.length > 0);
117
203
  if (inputIris.length === 0) return;
204
+
118
205
  const topics = Array.from(new Set(inputIris.map((i) => this.topicMapper.toTopic(i))));
119
- this.topicsRegistry.removeAll(topics);
120
- this.scheduleRebuild();
206
+ if (this.releaseOneClaimByTopics(topics)) return;
207
+
208
+ // Claims are the source of truth. Never drop shared topics blindly when claims still exist:
209
+ // it could disconnect another active consumer.
210
+ if (this.sharedClaims.size === 0 && this.dedicatedClaims.size === 0) {
211
+ this.topicsRegistry.removeAll(topics);
212
+ this.scheduleSharedRebuild();
213
+ return;
214
+ }
215
+
216
+ this.logger?.warn?.('[Mercure] unsubscribe ignored: no matching active claim for topics', {topics});
121
217
  }
122
218
 
123
219
  public shutdownBeforeExit(): void {
124
220
  if (this.shuttingDown) return;
125
221
  this.shuttingDown = true;
126
- this.teardownConnection();
222
+ this.teardownAllConnections();
127
223
  }
128
224
 
129
225
  ngOnDestroy(): void {
130
- this.teardownConnection();
226
+ this.teardownAllConnections();
131
227
  this._status$.complete();
228
+ this._diagnostics$.complete();
132
229
  this.incoming$.complete();
133
230
  this.destroy$.next();
134
231
  this.destroy$.complete();
135
- this.rebuild$.complete();
232
+ this.sharedRebuild$.complete();
136
233
  }
137
234
 
138
235
  // ───────────────── PRIVATE ─────────────────
139
236
 
140
- private scheduleRebuild(): void {
237
+ private buildIncomingStream$(topics: string[], options?: WatchConnectionOptions): Observable<IncomingMessage> {
238
+ return this.shouldUseDedicatedConnection(options)
239
+ ? this.subscribeViaDedicatedConnections$(topics)
240
+ : this.subscribeViaSharedConnections$(topics);
241
+ }
242
+
243
+ private shouldUseDedicatedConnection(options?: WatchConnectionOptions): boolean {
244
+ if (this.connectionMode === 'single') return true;
245
+ return options?.newConnection === true;
246
+ }
247
+
248
+ private subscribeViaSharedConnections$(topics: string[]): Observable<IncomingMessage> {
249
+ return defer(() => {
250
+ const uniqueTopics = Array.from(new Set(topics));
251
+ if (uniqueTopics.length === 0) return EMPTY;
252
+ const claim = this.createSharedClaim(uniqueTopics);
253
+
254
+ return this.incoming$.pipe(
255
+ takeUntil(claim.stop$),
256
+ finalize(() => {
257
+ this.releaseSharedClaim(claim.id);
258
+ })
259
+ );
260
+ });
261
+ }
262
+
263
+ private subscribeViaDedicatedConnections$(topics: string[]): Observable<IncomingMessage> {
264
+ return defer(() => {
265
+ if (!this.hubUrl) return EMPTY;
266
+
267
+ const uniqueTopics = Array.from(new Set(topics));
268
+ if (uniqueTopics.length === 0) return EMPTY;
269
+
270
+ const groupId = `dedicated-${++this.sequence}`;
271
+ const incoming = new Subject<IncomingMessage>();
272
+ const claim = this.createDedicatedClaim({groupId, topics: uniqueTopics, incoming});
273
+
274
+ this.dedicatedGroups.set(groupId, new Set());
275
+
276
+ try {
277
+ const shards = this.buildTopicShards(uniqueTopics, {allowSplit: this.connectionMode === 'auto'});
278
+
279
+ for (const shard of shards) {
280
+ const shardSet = new Set(shard);
281
+ const key = this.computeConnectionKey(shardSet, 'dedicated', groupId);
282
+ const conn = this.openConnection({key, scope: 'dedicated', groupId, topics: shardSet, target: incoming});
283
+ this.dedicatedGroups.get(groupId)?.add(conn.id);
284
+ }
285
+ } catch (err) {
286
+ this.logger?.error?.('[Mercure] dedicated connection setup failed:', err);
287
+ this.releaseDedicatedClaim(claim.id);
288
+ return EMPTY;
289
+ }
290
+
291
+ return incoming.asObservable().pipe(
292
+ takeUntil(claim.stop$),
293
+ finalize(() => {
294
+ this.releaseDedicatedClaim(claim.id);
295
+ })
296
+ );
297
+ });
298
+ }
299
+
300
+ private scheduleSharedRebuild(): void {
141
301
  if (this.shuttingDown) return;
142
- this.rebuild$.next();
302
+ this.sharedRebuild$.next();
143
303
  }
144
304
 
145
- private rebuildOnce$() {
305
+ private rebuildSharedConnectionsOnce$(): Observable<void> {
146
306
  return defer(() => {
147
307
  if (this.shuttingDown) return of(void 0);
148
308
 
149
309
  try {
150
- if (!this.hubUrl) {
151
- this.currentKey = undefined;
152
- this._status$.next('closed');
310
+ if (!this.hubUrl || !this.topicsRegistry.hasAny()) {
311
+ this.closeAllSharedConnections();
153
312
  return of(void 0);
154
313
  }
155
314
 
156
- const hasTopics = this.topicsRegistry.hasAny();
157
- const key = hasTopics ? this.topicsRegistry.computeKey(this.hubUrl, this.withCredentialsDefault) : undefined;
315
+ const shards = this.buildTopicShards(this.topicsRegistry.snapshot(), {allowSplit: this.connectionMode === 'auto'});
316
+ const desired = new Map<string, ReadonlySet<string>>();
158
317
 
159
- if (!hasTopics) {
160
- if (this.es) this.teardownConnection();
161
- this.currentKey = undefined;
162
- this._status$.next('closed');
163
- return of(void 0);
318
+ for (const shard of shards) {
319
+ const topics = new Set(shard);
320
+ const key = this.computeConnectionKey(topics, 'shared');
321
+ desired.set(key, topics);
164
322
  }
165
323
 
166
- if (key && key === this.currentKey) {
167
- return of(void 0);
324
+ for (const [key, conn] of Array.from(this.sharedConnections.entries())) {
325
+ if (!desired.has(key)) {
326
+ this.closeConnection(conn.id);
327
+ }
328
+ }
329
+
330
+ for (const [key, topics] of desired.entries()) {
331
+ if (this.sharedConnections.has(key)) continue;
332
+ const conn = this.openConnection({key, scope: 'shared', topics, target: this.incoming$});
333
+ this.sharedConnections.set(key, conn);
334
+ }
335
+ } catch (err) {
336
+ this.logger?.error?.('[Mercure] shared rebuild failed:', err);
337
+ }
338
+
339
+ return of(void 0);
340
+ });
341
+ }
342
+
343
+ private openConnection(params: {
344
+ key: string;
345
+ scope: ManagedConnectionScope;
346
+ topics: ReadonlySet<string>;
347
+ target: Subject<IncomingMessage>;
348
+ groupId?: string;
349
+ }): ManagedConnection {
350
+ if (!this.hubUrl) {
351
+ throw new Error('Mercure hub URL is required to open a realtime connection.');
352
+ }
353
+
354
+ const id = `conn-${++this.sequence}`;
355
+ const lastEventId = this.lastEventIdByKey.get(params.key);
356
+ const url = this.urlBuilder.build(this.hubUrl, params.topics, lastEventId);
357
+
358
+ this.logger?.debug?.('[Mercure] connect', {
359
+ mode: this.connectionMode,
360
+ scope: params.scope,
361
+ key: params.key,
362
+ urlLength: url.length,
363
+ topics: Array.from(params.topics),
364
+ lastEventId,
365
+ });
366
+
367
+ const wrapper = new EventSourceWrapper(url, {withCredentials: this.withCredentialsDefault}, this.logger);
368
+ const stop$ = new Subject<void>();
369
+
370
+ const conn: ManagedConnection = {
371
+ id,
372
+ key: params.key,
373
+ scope: params.scope,
374
+ groupId: params.groupId,
375
+ topics: params.topics,
376
+ urlLength: url.length,
377
+ wrapper,
378
+ stop$,
379
+ status: 'connecting',
380
+ };
381
+
382
+ this.connections.set(conn.id, conn);
383
+
384
+ wrapper.status$
385
+ .pipe(takeUntil(stop$), takeUntil(this.destroy$))
386
+ .subscribe((st) => {
387
+ conn.status = st;
388
+ this.updateGlobalStatus();
389
+ });
390
+
391
+ wrapper.events$
392
+ .pipe(takeUntil(stop$), takeUntil(this.destroy$))
393
+ .subscribe((e) => {
394
+ const lastEventIdValue = e.lastEventId;
395
+ if (typeof lastEventIdValue === 'string' && lastEventIdValue.length > 0) {
396
+ this.lastEventIdByKey.set(params.key, lastEventIdValue);
168
397
  }
398
+ params.target.next({type: e.type, data: e.data, lastEventId: e.lastEventId});
399
+ });
169
400
 
170
- this.teardownConnection();
401
+ wrapper.open();
402
+ this.updateGlobalStatus();
171
403
 
172
- const url = this.urlBuilder.build(this.hubUrl, this.topicsRegistry.snapshot(), this.lastEventId);
173
- this.logger?.debug?.('[Mercure] connect', {hubUrl: this.hubUrl, topics: this.topicsRegistry.snapshot(), lastEventId: this.lastEventId});
174
- this.es = new EventSourceWrapper(url, {withCredentials: this.withCredentialsDefault}, this.logger);
404
+ return conn;
405
+ }
175
406
 
176
- this.connectionStop$ = new Subject<void>();
407
+ private closeAllSharedConnections(): void {
408
+ for (const conn of Array.from(this.sharedConnections.values())) {
409
+ this.closeConnection(conn.id);
410
+ }
411
+ }
177
412
 
178
- this.es.status$
179
- .pipe(takeUntil(this.connectionStop$), takeUntil(this.destroy$))
180
- .subscribe((st) => this.updateGlobalStatus(st));
413
+ private createSharedClaim(topics: string[]): SharedSubscriptionClaim {
414
+ const normalizedTopics = Array.from(new Set(topics));
415
+ const claim: SharedSubscriptionClaim = {
416
+ id: `shared-claim-${++this.sequence}`,
417
+ topics: normalizedTopics,
418
+ topicsKey: this.computeTopicsKey(normalizedTopics),
419
+ stop$: new Subject<void>(),
420
+ released: false,
421
+ };
181
422
 
182
- this.es.events$
183
- .pipe(takeUntil(this.connectionStop$), takeUntil(this.destroy$))
184
- .subscribe((e) => {
185
- this.lastEventId = e.lastEventId ?? this.lastEventId;
186
- this.incoming$.next(e);
187
- });
423
+ this.sharedClaims.set(claim.id, claim);
424
+ this.queueClaim(this.sharedClaimQueueByTopicsKey, claim.topicsKey, claim.id);
425
+ this.topicsRegistry.addAll(claim.topics);
426
+ this.scheduleSharedRebuild();
188
427
 
189
- this._status$.next('connecting');
190
- this.es.open();
191
- this.currentKey = key;
192
- } catch (err) {
193
- this.logger?.error?.('[Mercure] rebuild failed:', err);
194
- this.currentKey = undefined;
195
- this._status$.next(this.topicsRegistry.hasAny() ? 'connecting' : 'closed');
428
+ return claim;
429
+ }
430
+
431
+ private createDedicatedClaim(params: {
432
+ groupId: string;
433
+ topics: string[];
434
+ incoming: Subject<IncomingMessage>;
435
+ }): DedicatedSubscriptionClaim {
436
+ const normalizedTopics = Array.from(new Set(params.topics));
437
+ const claim: DedicatedSubscriptionClaim = {
438
+ id: `dedicated-claim-${++this.sequence}`,
439
+ groupId: params.groupId,
440
+ topics: normalizedTopics,
441
+ topicsKey: this.computeTopicsKey(normalizedTopics),
442
+ incoming: params.incoming,
443
+ stop$: new Subject<void>(),
444
+ released: false,
445
+ };
446
+
447
+ this.dedicatedClaims.set(claim.id, claim);
448
+ this.queueClaim(this.dedicatedClaimQueueByTopicsKey, claim.topicsKey, claim.id);
449
+ return claim;
450
+ }
451
+
452
+ private releaseOneClaimByTopics(topics: string[]): boolean {
453
+ const topicsKey = this.computeTopicsKey(topics);
454
+ if (!topicsKey) return false;
455
+
456
+ if (this.connectionMode === 'single') {
457
+ return this.releaseOneDedicatedClaimByTopics(topicsKey) || this.releaseOneSharedClaimByTopics(topicsKey);
458
+ }
459
+
460
+ return this.releaseOneSharedClaimByTopics(topicsKey) || this.releaseOneDedicatedClaimByTopics(topicsKey);
461
+ }
462
+
463
+ private releaseOneSharedClaimByTopics(topicsKey: string): boolean {
464
+ const queue = this.sharedClaimQueueByTopicsKey.get(topicsKey);
465
+ if (!queue || queue.length === 0) return false;
466
+
467
+ while (queue.length > 0) {
468
+ const claimId = queue.shift();
469
+ if (!claimId) continue;
470
+ if (this.stopSharedClaim(claimId)) {
471
+ if (queue.length === 0) this.sharedClaimQueueByTopicsKey.delete(topicsKey);
472
+ return true;
196
473
  }
474
+ }
197
475
 
198
- return of(void 0);
199
- });
476
+ this.sharedClaimQueueByTopicsKey.delete(topicsKey);
477
+ return false;
478
+ }
479
+
480
+ private releaseOneDedicatedClaimByTopics(topicsKey: string): boolean {
481
+ const queue = this.dedicatedClaimQueueByTopicsKey.get(topicsKey);
482
+ if (!queue || queue.length === 0) return false;
483
+
484
+ while (queue.length > 0) {
485
+ const claimId = queue.shift();
486
+ if (!claimId) continue;
487
+ if (this.stopDedicatedClaim(claimId)) {
488
+ if (queue.length === 0) this.dedicatedClaimQueueByTopicsKey.delete(topicsKey);
489
+ return true;
490
+ }
491
+ }
492
+
493
+ this.dedicatedClaimQueueByTopicsKey.delete(topicsKey);
494
+ return false;
495
+ }
496
+
497
+ private stopSharedClaim(claimId: string): boolean {
498
+ const claim = this.sharedClaims.get(claimId);
499
+ if (!claim || claim.released) return false;
500
+ claim.stop$.next();
501
+ claim.stop$.complete();
502
+ return true;
503
+ }
504
+
505
+ private stopDedicatedClaim(claimId: string): boolean {
506
+ const claim = this.dedicatedClaims.get(claimId);
507
+ if (!claim || claim.released) return false;
508
+ claim.stop$.next();
509
+ claim.stop$.complete();
510
+ return true;
511
+ }
512
+
513
+ private releaseSharedClaim(claimId: string): void {
514
+ const claim = this.sharedClaims.get(claimId);
515
+ if (!claim || claim.released) return;
516
+
517
+ claim.released = true;
518
+ this.sharedClaims.delete(claim.id);
519
+ this.removeClaimFromQueue(this.sharedClaimQueueByTopicsKey, claim.topicsKey, claim.id);
520
+
521
+ this.topicsRegistry.removeAll(claim.topics);
522
+ this.scheduleSharedRebuild();
523
+ }
524
+
525
+ private releaseDedicatedClaim(claimId: string): void {
526
+ const claim = this.dedicatedClaims.get(claimId);
527
+ if (!claim || claim.released) return;
528
+
529
+ claim.released = true;
530
+ this.dedicatedClaims.delete(claim.id);
531
+ this.removeClaimFromQueue(this.dedicatedClaimQueueByTopicsKey, claim.topicsKey, claim.id);
532
+
533
+ this.closeDedicatedGroup(claim.groupId);
534
+ claim.incoming.complete();
535
+ }
536
+
537
+ private queueClaim(queueByKey: Map<string, string[]>, topicsKey: string, claimId: string): void {
538
+ const queue = queueByKey.get(topicsKey) ?? [];
539
+ queue.push(claimId);
540
+ queueByKey.set(topicsKey, queue);
541
+ }
542
+
543
+ private removeClaimFromQueue(queueByKey: Map<string, string[]>, topicsKey: string, claimId: string): void {
544
+ const queue = queueByKey.get(topicsKey);
545
+ if (!queue || queue.length === 0) return;
546
+ const next = queue.filter((id) => id !== claimId);
547
+ if (next.length === 0) {
548
+ queueByKey.delete(topicsKey);
549
+ return;
550
+ }
551
+ queueByKey.set(topicsKey, next);
552
+ }
553
+
554
+ private computeTopicsKey(topics: Iterable<string>): string {
555
+ return Array.from(new Set(topics)).sort().join('|');
200
556
  }
201
557
 
202
- private teardownConnection(): void {
203
- this.es?.close();
204
- this.es = undefined;
205
- this.connectionStop$.next();
206
- this.connectionStop$.complete();
558
+ private closeDedicatedGroup(groupId: string): void {
559
+ const connectionIds = this.dedicatedGroups.get(groupId);
560
+ if (!connectionIds) return;
561
+
562
+ for (const connectionId of Array.from(connectionIds)) {
563
+ this.closeConnection(connectionId);
564
+ }
565
+
566
+ this.dedicatedGroups.delete(groupId);
207
567
  }
208
568
 
209
- private updateGlobalStatus(sse: RealtimeStatus): void {
210
- if (sse === 'connected') {
569
+ private closeConnection(connectionId: string): void {
570
+ const conn = this.connections.get(connectionId);
571
+ if (!conn) return;
572
+
573
+ conn.status = 'closed';
574
+ conn.wrapper.close();
575
+ conn.stop$.next();
576
+ conn.stop$.complete();
577
+
578
+ this.connections.delete(connectionId);
579
+
580
+ if (conn.scope === 'shared') {
581
+ this.sharedConnections.delete(conn.key);
582
+ }
583
+
584
+ if (conn.scope === 'dedicated' && conn.groupId) {
585
+ const groupConnections = this.dedicatedGroups.get(conn.groupId);
586
+ groupConnections?.delete(conn.id);
587
+ if (groupConnections && groupConnections.size === 0) {
588
+ this.dedicatedGroups.delete(conn.groupId);
589
+ }
590
+ this.lastEventIdByKey.delete(conn.key);
591
+ }
592
+
593
+ this.updateGlobalStatus();
594
+ }
595
+
596
+ private teardownAllConnections(): void {
597
+ for (const claimId of Array.from(this.sharedClaims.keys())) {
598
+ this.stopSharedClaim(claimId);
599
+ }
600
+ for (const claimId of Array.from(this.dedicatedClaims.keys())) {
601
+ this.stopDedicatedClaim(claimId);
602
+ }
603
+
604
+ for (const conn of Array.from(this.connections.values())) {
605
+ this.closeConnection(conn.id);
606
+ }
607
+
608
+ this.sharedClaims.clear();
609
+ this.sharedClaimQueueByTopicsKey.clear();
610
+ this.dedicatedClaims.clear();
611
+ this.dedicatedClaimQueueByTopicsKey.clear();
612
+ this.sharedConnections.clear();
613
+ this.dedicatedGroups.clear();
614
+ this.updateGlobalStatus();
615
+ }
616
+
617
+ private updateGlobalStatus(): void {
618
+ if (this.connections.size === 0) {
619
+ this._status$.next('closed');
620
+ this.emitDiagnostics();
621
+ return;
622
+ }
623
+
624
+ const statuses = Array.from(this.connections.values()).map((c) => c.status);
625
+ if (statuses.includes('connected')) {
211
626
  this._status$.next('connected');
627
+ this.emitDiagnostics();
212
628
  return;
213
629
  }
214
- if (sse === 'connecting') {
630
+
631
+ if (statuses.includes('connecting')) {
215
632
  this._status$.next('connecting');
633
+ this.emitDiagnostics();
216
634
  return;
217
635
  }
218
- this._status$.next(this.topicsRegistry.hasAny() ? 'connecting' : 'closed');
636
+
637
+ this._status$.next('closed');
638
+ this.emitDiagnostics();
639
+ }
640
+
641
+ private emitDiagnostics(): void {
642
+ const connections = Array.from(this.connections.values())
643
+ .map((conn) => ({
644
+ id: conn.id,
645
+ scope: conn.scope,
646
+ status: conn.status,
647
+ topicCount: conn.topics.size,
648
+ topics: Array.from(conn.topics).sort(),
649
+ urlLength: conn.urlLength,
650
+ }))
651
+ .sort((a, b) => {
652
+ if (a.scope !== b.scope) return a.scope.localeCompare(b.scope);
653
+ return a.id.localeCompare(b.id);
654
+ });
655
+
656
+ const sharedConnections = connections.filter((c) => c.scope === 'shared').length;
657
+ const dedicatedConnections = connections.length - sharedConnections;
658
+
659
+ this._diagnostics$.next({
660
+ mode: this.connectionMode,
661
+ maxUrlLength: this.maxUrlLength,
662
+ totalConnections: connections.length,
663
+ sharedConnections,
664
+ dedicatedConnections,
665
+ connections,
666
+ });
667
+ }
668
+
669
+ private buildTopicShards(topics: Iterable<string>, opts: { allowSplit: boolean }): string[][] {
670
+ const uniqueSortedTopics = Array.from(new Set(topics)).sort();
671
+ if (uniqueSortedTopics.length === 0) return [];
672
+
673
+ if (!opts.allowSplit || !this.hubUrl) {
674
+ return [uniqueSortedTopics];
675
+ }
676
+
677
+ const shards: string[][] = [];
678
+ let current: string[] = [];
679
+
680
+ for (const topic of uniqueSortedTopics) {
681
+ if (current.length === 0) {
682
+ current = [topic];
683
+ continue;
684
+ }
685
+
686
+ const candidate = [...current, topic];
687
+ const urlLength = this.urlBuilder.build(this.hubUrl, new Set(candidate)).length;
688
+ if (urlLength > this.maxUrlLength) {
689
+ shards.push(current);
690
+ current = [topic];
691
+ continue;
692
+ }
693
+
694
+ current = candidate;
695
+ }
696
+
697
+ if (current.length > 0) {
698
+ shards.push(current);
699
+ }
700
+
701
+ return shards;
702
+ }
703
+
704
+ private computeConnectionKey(topics: ReadonlySet<string>, scope: ManagedConnectionScope, groupId?: string): string {
705
+ const topicsSorted = Array.from(topics).sort().join('|');
706
+ const hub = this.hubUrl ?? '';
707
+ const creds = this.withCredentialsDefault ? '1' : '0';
708
+ const mode = this.connectionMode;
709
+ const group = groupId ?? '';
710
+ return `${scope}::${group}::${hub}::${creds}::${mode}::${topicsSorted}`;
219
711
  }
220
712
 
221
713
  private safeParse(raw: string): Item | undefined {
222
714
  try {
223
715
  return JSON.parse(raw) as Item;
224
- } catch (err) {
716
+ } catch {
225
717
  this.logger?.debug?.('[Mercure] invalid JSON payload ignored', {raw});
226
718
  return undefined;
227
719
  }
@@ -246,16 +738,69 @@ export class MercureRealtimeAdapter implements RealtimePort, OnDestroy {
246
738
  return normalize(readPath(raw, path));
247
739
  }
248
740
 
741
+ private matchSubscribedTopic(
742
+ raw: any,
743
+ subscribed: string[],
744
+ opts: { fieldPaths: string[]; includeSelf: boolean }
745
+ ): string | undefined {
746
+ for (const fieldPath of opts.fieldPaths) {
747
+ const relIris = this.extractRelationIris(raw, fieldPath).map((i) => normalizeIri(this.topicMapper.toPayloadIri(i)));
748
+ for (const relIri of relIris) {
749
+ const match = findMatchingSubscribedTopic(relIri, subscribed);
750
+ if (match) return match;
751
+ }
752
+ }
753
+
754
+ if (opts.includeSelf) {
755
+ const rawId = raw?.['@id'];
756
+ const id = typeof rawId === 'string' ? normalizeIri(this.topicMapper.toPayloadIri(rawId)) : undefined;
757
+ if (typeof id === 'string') {
758
+ const match = findMatchingSubscribedTopic(id, subscribed);
759
+ if (match) return match;
760
+ }
761
+ }
762
+
763
+ return undefined;
764
+ }
249
765
  }
250
766
 
251
767
  function normalizeIri(iri: string): string {
252
768
  return iri.endsWith('/') ? iri.slice(0, -1) : iri;
253
769
  }
254
770
 
255
- function matchesAnySubscribed(candidate: string, subscribed: string[]): boolean {
771
+ function findMatchingSubscribedTopic(candidate: string, subscribed: string[]): string | undefined {
772
+ let best: string | undefined;
256
773
  for (const iri of subscribed) {
257
- if (candidate === iri) return true;
258
- if (candidate.startsWith(`${iri}/`)) return true;
774
+ if (candidate !== iri && !candidate.startsWith(`${iri}/`)) continue;
775
+ if (!best || iri.length > best.length) best = iri;
259
776
  }
260
- return false;
777
+ return best;
778
+ }
779
+
780
+ function buildFieldPaths(filter?: SubscribeFilter): string[] {
781
+ const fields = filter?.fields?.filter((f): f is string => typeof f === 'string' && f.length > 0) ?? [];
782
+ const field = typeof filter?.field === 'string' && filter.field.length > 0 ? [filter.field] : [];
783
+ return [...fields, ...field];
784
+ }
785
+
786
+ function resolveIncludeSelf(filter: SubscribeFilter | undefined, ctx: { hasFields: boolean }): boolean {
787
+ if (typeof filter?.includeSelf === 'boolean') return filter.includeSelf;
788
+ return !ctx.hasFields;
789
+ }
790
+
791
+ function normalizeMaxUrlLength(value: number | null | undefined, fallback: number): number {
792
+ if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
793
+ const rounded = Math.floor(value);
794
+ return rounded >= 256 ? rounded : fallback;
795
+ }
796
+
797
+ function emptyDiagnostics(mode: MercureConnectionMode, maxUrlLength: number): RealtimeDiagnostics {
798
+ return {
799
+ mode,
800
+ maxUrlLength,
801
+ totalConnections: 0,
802
+ sharedConnections: 0,
803
+ dedicatedConnections: 0,
804
+ connections: [],
805
+ };
261
806
  }