@obsidiane/meridiane 2.0.13 → 3.0.1

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.
Files changed (59) hide show
  1. package/README.md +33 -66
  2. package/cli.js +1 -10
  3. package/package.json +1 -1
  4. package/src/application/bridge-workflow.js +23 -66
  5. package/src/application/generate-bridge.js +32 -36
  6. package/src/application/run-build.js +8 -30
  7. package/src/application/run-dev.js +8 -63
  8. package/src/application/run-generate.js +8 -47
  9. package/src/domain/bridge-options.js +10 -63
  10. package/src/generators/bridge/write-bridge-files.js +11 -29
  11. package/src/generators/models/interface-schema.js +3 -20
  12. package/src/generators/models/model-selection.js +67 -106
  13. package/src/generators/models/oas-contract.js +1 -108
  14. package/src/generators/models/oas-schema-name.js +1 -19
  15. package/src/generators/models/openapi-to-models.js +51 -42
  16. package/src/generators/models/schema-utils.js +0 -34
  17. package/src/generators/models/type-resolver.js +2 -20
  18. package/src/generators/models/utils.js +0 -11
  19. package/src/generators/models/write-model-files.js +5 -15
  20. package/src/infrastructure/dev-project.js +0 -33
  21. package/src/infrastructure/files.js +15 -0
  22. package/src/infrastructure/local-package-install.js +2 -2
  23. package/src/infrastructure/logger.js +0 -2
  24. package/templates/angular-bridge/README.md +29 -281
  25. package/templates/angular-bridge/package.json +2 -2
  26. package/templates/angular-bridge/src/lib/bridge/rest/http-request.options.ts +10 -19
  27. package/templates/angular-bridge/src/lib/bridge/sse/mercure-hub.ts +449 -0
  28. package/templates/angular-bridge/src/lib/bridge/sse/mercure-url.ts +28 -0
  29. package/templates/angular-bridge/src/lib/bridge/sse/resource-topic.ts +83 -0
  30. package/templates/angular-bridge/src/lib/bridge-options.ts +21 -17
  31. package/templates/angular-bridge/src/lib/bridge.types.ts +22 -33
  32. package/templates/angular-bridge/src/lib/facades/bridge.facade.ts +58 -134
  33. package/templates/angular-bridge/src/lib/facades/facade.factory.ts +25 -25
  34. package/templates/angular-bridge/src/lib/facades/facade.interface.ts +4 -9
  35. package/templates/angular-bridge/src/lib/facades/resource.facade.ts +38 -61
  36. package/templates/angular-bridge/src/lib/interceptors/bearer-auth.interceptor.ts +8 -12
  37. package/templates/angular-bridge/src/lib/interceptors/bridge-defaults.interceptor.ts +19 -11
  38. package/templates/angular-bridge/src/lib/interceptors/content-type.interceptor.ts +5 -17
  39. package/templates/angular-bridge/src/lib/interceptors/singleflight.interceptor.ts +28 -24
  40. package/templates/angular-bridge/src/lib/ports/realtime.port.ts +34 -53
  41. package/templates/angular-bridge/src/lib/ports/resource-repository.port.ts +31 -21
  42. package/templates/angular-bridge/src/lib/provide-bridge.ts +14 -38
  43. package/templates/angular-bridge/src/lib/tokens.ts +4 -15
  44. package/templates/angular-bridge/src/lib/utils/request-url.ts +4 -6
  45. package/templates/angular-bridge/src/lib/utils/url.ts +32 -9
  46. package/templates/angular-bridge/src/public-api.ts +0 -1
  47. package/templates/angular-bridge/tsconfig.lib.json +0 -3
  48. package/src/README.md +0 -9
  49. package/src/infrastructure/json.js +0 -71
  50. package/templates/angular-bridge/src/lib/bridge/rest/api-platform.adapter.ts +0 -82
  51. package/templates/angular-bridge/src/lib/bridge/sse/eventsource-wrapper.ts +0 -70
  52. package/templates/angular-bridge/src/lib/bridge/sse/mercure-connections.ts +0 -255
  53. package/templates/angular-bridge/src/lib/bridge/sse/mercure-payload.ts +0 -82
  54. package/templates/angular-bridge/src/lib/bridge/sse/mercure-topic.mapper.ts +0 -48
  55. package/templates/angular-bridge/src/lib/bridge/sse/mercure-url.builder.ts +0 -39
  56. package/templates/angular-bridge/src/lib/bridge/sse/mercure.adapter.ts +0 -358
  57. package/templates/angular-bridge/src/lib/bridge/sse/ref-count-topic.registry.ts +0 -36
  58. package/templates/angular-bridge/src/lib/bridge/sse/subscription-claims.ts +0 -76
  59. package/templates/angular-bridge/src/lib/utils/iri.ts +0 -6
@@ -25,46 +25,35 @@ export interface BridgeDefaults {
25
25
  }
26
26
 
27
27
  /**
28
- * Controls how Mercure topics are sent in the hub URL.
29
- * - `url`: topics are absolute URLs (recommended default)
30
- * - `iri`: topics are same-origin relative IRIs (e.g. `/api/...`)
28
+ * State of the Mercure connections.
29
+ * - `closed`: no active subscription (or realtime disabled)
30
+ * - `connecting`: at least one connection is opening, reconnecting, or waiting before a retry
31
+ * - `connected`: the hub accepted every connection and the streams are open
31
32
  */
32
- export type MercureTopicMode = 'url' | 'iri';
33
+ export type RealtimeStatus = 'closed' | 'connecting' | 'connected';
33
34
 
34
- /**
35
- * Controls how SSE connections are managed.
36
- * - `single`: each watch subscription opens its own SSE connection
37
- * - `auto`: subscriptions are shared and split into multiple connections only when URL length is too large
38
- */
39
- export type MercureConnectionMode = 'single' | 'auto';
40
-
41
- /**
42
- * Per-subscription realtime override.
43
- * - `true`: force an isolated SSE connection for this watch call
44
- * - `false`/`undefined`: use adapter strategy (`single` or `auto`)
45
- */
46
- export interface WatchConnectionOptions {
47
- newConnection?: boolean;
35
+ export interface RealtimeSubscriptionDiagnostics {
36
+ match: string;
37
+ type: 'exact' | 'urlpattern';
38
+ /** Number of active `watch$()` streams sharing this subscription. */
39
+ consumers: number;
48
40
  }
49
41
 
50
- export type RealtimeConnectionScope = 'shared' | 'dedicated';
51
-
52
- export type RealtimeConnectionStatus = 'connecting' | 'connected' | 'closed';
53
-
54
42
  export interface RealtimeConnectionDiagnostics {
55
- id: string;
56
- scope: RealtimeConnectionScope;
57
- status: RealtimeConnectionStatus;
58
- topicCount: number;
59
- topics: string[];
60
- urlLength: number;
43
+ /** `shared`, `dedicated`, or the name given with `watch$(..., {connection})`. */
44
+ connection: string;
45
+ status: RealtimeStatus;
46
+ /** Subscribe URL (without `last_event_id`). */
47
+ url?: string;
48
+ subscriptions: RealtimeSubscriptionDiagnostics[];
49
+ /** Last update ID received on this connection, used to request replay after a reconnection. */
50
+ lastEventId?: string;
51
+ /** Consecutive connections refused by the hub (authentication, authorization, limits). */
52
+ failures: number;
61
53
  }
62
54
 
63
55
  export interface RealtimeDiagnostics {
64
- mode: MercureConnectionMode;
65
- maxUrlLength: number;
66
- totalConnections: number;
67
- sharedConnections: number;
68
- dedicatedConnections: number;
56
+ status: RealtimeStatus;
57
+ /** One entry per `EventSource`: subscriptions are split when a subscribe URL would exceed the limits. */
69
58
  connections: RealtimeConnectionDiagnostics[];
70
59
  }
@@ -1,165 +1,100 @@
1
- import {Inject, Injectable} from '@angular/core';
1
+ import {inject, Injectable} from '@angular/core';
2
+ import {DOCUMENT} from '@angular/common';
2
3
  import {HttpClient} from '@angular/common/http';
3
- import {Observable} from 'rxjs';
4
- import {filter, map, share} from 'rxjs/operators';
4
+ import {map, Observable} from 'rxjs';
5
5
  import {toHttpParams} from '../bridge/rest/query-builder';
6
6
  import {buildHttpRequestOptions, resolveHttpCallOptions} from '../bridge/rest/http-request.options';
7
- import {MercureRealtimeAdapter} from '../bridge/sse/mercure.adapter';
7
+ import {MercureHub} from '../bridge/sse/mercure-hub';
8
8
  import {API_BASE_URL, BRIDGE_HTTP_WITH_CREDENTIALS} from '../tokens';
9
- import {AnyQuery, Collection, HttpCallOptions, HttpRequestConfig, Iri, IriRequired, Item} from '../ports/resource-repository.port';
10
- import {RealtimeDiagnosticsPort, SubscribeFilter} from '../ports/realtime.port';
11
- import {resolveUrl} from '../utils/url';
12
- import {toIriList} from '../utils/iri';
13
- import {RealtimeDiagnostics, WatchConnectionOptions} from '../bridge.types';
14
-
15
- export type TypedEvent<TType extends string = string, TPayload = unknown> = {
16
- resourceType: TType;
17
- payload: TPayload;
18
- };
19
-
20
- export type WatchTypesResult<R extends Record<string, any>> =
21
- { [K in keyof R]: TypedEvent<K & string, R[K]> }[keyof R];
22
-
23
- export type WatchTypesConfig = {
24
- /** Field name used as discriminator. Default: `@type` (JSON-LD). */
25
- discriminator?: string;
26
- };
27
-
28
- export type WatchSubscriptionOptions = WatchConnectionOptions;
9
+ import {AnyQuery, Collection, HttpCallOptions, HttpRequestConfig, Iri, Item} from '../ports/resource-repository.port';
10
+ import {MercureSubscription, RealtimePort, RealtimeSubscribeOptions} from '../ports/realtime.port';
11
+ import {resolveUrl, toAbsoluteUrl} from '../utils/url';
12
+ import {RealtimeDiagnostics, RealtimeStatus} from '../bridge.types';
13
+
14
+ /** Mercure selector of `BridgeFacade.watch$()`: an IRI / URL, or a URL Pattern. Relative values use `baseUrl`. */
15
+ export interface RealtimeSelector {
16
+ match: string;
17
+ /** Default: `exact`. */
18
+ type?: MercureSubscription['type'];
19
+ }
29
20
 
30
21
  /**
31
- * High-level facade for ad-hoc HTTP calls and Mercure subscriptions.
22
+ * HTTP client of the bridge (every call resolves its IRI against `baseUrl`), and access to the Mercure connections.
32
23
  *
33
24
  * Prefer `FacadeFactory` + `ResourceFacade<T>` when you want a resource-oriented API.
34
25
  */
35
26
  @Injectable({providedIn: 'root'})
36
27
  export class BridgeFacade {
37
- constructor(
38
- private readonly http: HttpClient,
39
- @Inject(MercureRealtimeAdapter) private readonly realtime: RealtimeDiagnosticsPort,
40
- @Inject(API_BASE_URL) private readonly apiBase: string,
41
- @Inject(BRIDGE_HTTP_WITH_CREDENTIALS) private readonly httpWithCredentialsDefault: boolean,
42
- ) {
28
+ private readonly http = inject(HttpClient);
29
+ private readonly realtime: RealtimePort = inject(MercureHub);
30
+ private readonly apiBase = inject(API_BASE_URL);
31
+ private readonly httpWithCredentialsDefault = inject(BRIDGE_HTTP_WITH_CREDENTIALS);
32
+ /** Mercure topics are absolute URLs: a relative `baseUrl` is resolved against the document base. */
33
+ private readonly topicBaseUrl = toAbsoluteUrl(this.apiBase, inject(DOCUMENT).baseURI);
34
+
35
+ get$<R = unknown>(url: Iri, options?: HttpCallOptions): Observable<R> {
36
+ return this.http.get<R>(this.resolveUrl(url), resolveHttpCallOptions(options, this.httpWithCredentialsDefault));
43
37
  }
44
38
 
45
- get$<R = unknown>(url: IriRequired, options?: HttpCallOptions): Observable<R> {
46
- return this.http.get<R>(
47
- this.resolveUrl(url),
48
- resolveHttpCallOptions(options, this.httpWithCredentialsDefault)
49
- );
50
- }
51
-
52
- getCollection$<T extends Item = Item>(
53
- url: IriRequired,
54
- query?: AnyQuery,
55
- options?: HttpCallOptions
56
- ): Observable<Collection<T>> {
39
+ getCollection$<T extends Item = Item>(url: Iri, query?: AnyQuery, options?: HttpCallOptions): Observable<Collection<T>> {
57
40
  return this.http.get<Collection<T>>(this.resolveUrl(url), {
58
41
  ...resolveHttpCallOptions(options, this.httpWithCredentialsDefault),
59
42
  params: toHttpParams(query),
60
43
  });
61
44
  }
62
45
 
63
- post$<R = unknown, B = unknown>(url: IriRequired, payload: B, options?: HttpCallOptions): Observable<R> {
64
- return this.http.post<R>(
65
- this.resolveUrl(url),
66
- payload,
67
- resolveHttpCallOptions(options, this.httpWithCredentialsDefault)
68
- );
46
+ post$<R = unknown, B = unknown>(url: Iri, payload: B, options?: HttpCallOptions): Observable<R> {
47
+ return this.http.post<R>(this.resolveUrl(url), payload, resolveHttpCallOptions(options, this.httpWithCredentialsDefault));
69
48
  }
70
49
 
71
- patch$<R = unknown, B = unknown>(url: IriRequired, changes: B, options?: HttpCallOptions): Observable<R> {
72
- return this.http.patch<R>(
73
- this.resolveUrl(url),
74
- changes,
75
- resolveHttpCallOptions(options, this.httpWithCredentialsDefault)
76
- );
50
+ patch$<R = unknown, B = unknown>(url: Iri, changes: B, options?: HttpCallOptions): Observable<R> {
51
+ return this.http.patch<R>(this.resolveUrl(url), changes, resolveHttpCallOptions(options, this.httpWithCredentialsDefault));
77
52
  }
78
53
 
79
- put$<R = unknown, B = unknown>(url: IriRequired, payload: B, options?: HttpCallOptions): Observable<R> {
80
- return this.http.put<R>(
81
- this.resolveUrl(url),
82
- payload,
83
- resolveHttpCallOptions(options, this.httpWithCredentialsDefault)
84
- );
54
+ put$<R = unknown, B = unknown>(url: Iri, payload: B, options?: HttpCallOptions): Observable<R> {
55
+ return this.http.put<R>(this.resolveUrl(url), payload, resolveHttpCallOptions(options, this.httpWithCredentialsDefault));
85
56
  }
86
57
 
87
- delete$(url: IriRequired, options?: HttpCallOptions): Observable<void> {
88
- return this.http.delete<void>(
89
- this.resolveUrl(url),
90
- resolveHttpCallOptions(options, this.httpWithCredentialsDefault)
91
- );
58
+ delete$(url: Iri, options?: HttpCallOptions): Observable<void> {
59
+ return this.http.delete<void>(this.resolveUrl(url), resolveHttpCallOptions(options, this.httpWithCredentialsDefault));
92
60
  }
93
61
 
62
+ /** Low-level escape hatch for non-standard endpoints (custom controllers, uploads, other formats...). */
94
63
  request$<R = unknown, B = unknown>(request: HttpRequestConfig<B>): Observable<R> {
95
- const {method, url} = request;
96
- const targetUrl = this.resolveUrl(url);
97
-
98
- const mergedOptions = buildHttpRequestOptions(request, {withCredentialsDefault: this.httpWithCredentialsDefault});
99
- return this.http.request<R>(method, targetUrl, mergedOptions as {observe: 'body'});
100
- }
101
-
102
- watch$<T = Item>(
103
- iri: Iri | Iri[],
104
- subscribeFilter?: SubscribeFilter,
105
- options?: WatchSubscriptionOptions
106
- ): Observable<T> {
107
- const iris = toIriList(iri);
108
- return this.realtime
109
- .subscribe$<T>(iris, subscribeFilter, options)
110
- .pipe(
111
- map((event) => event.data),
112
- filter((data): data is T => data !== undefined),
113
- share()
114
- );
64
+ return this.http.request<R>(
65
+ request.method,
66
+ this.resolveUrl(request.url),
67
+ buildHttpRequestOptions(request, this.httpWithCredentialsDefault)
68
+ );
115
69
  }
116
70
 
117
71
  /**
118
- * Watches a "multi-entity" topic and emits only payloads whose discriminator matches `resourceTypes`.
119
- *
120
- * Important:
121
- * - filtering is done on payload content (e.g. `@type`), not on network topic provenance.
122
- * - in shared mode (`connectionMode: 'auto'` without `newConnection`), events from another shared topic
123
- * can be emitted if they carry an allowed discriminator.
72
+ * Streams the JSON payloads of a custom Mercure subscription, without filtering them.
124
73
  *
125
- * For strict single-topic isolation, use a dedicated connection:
126
- * `watchTypes$(..., ..., ..., { newConnection: true })`
127
- * (or `connectionMode: 'single'` globally).
74
+ * The default connection is `dedicated`: every update received on it belongs to this subscription. On a
75
+ * shared or named connection, the stream also carries the updates of the other subscriptions of that connection.
128
76
  */
129
- watchTypes$<R extends Record<string, any>>(
130
- iri: Iri | Iri[],
131
- resourceTypes: (keyof R & string)[],
132
- config?: WatchTypesConfig,
133
- options?: WatchSubscriptionOptions
134
- ): Observable<WatchTypesResult<R>> {
135
- const iris = toIriList(iri);
136
- const discriminator = config?.discriminator ?? '@type';
137
- const allowedTypes = new Set(
138
- resourceTypes.filter((type): type is string => typeof type === 'string' && type.length > 0)
139
- );
140
-
77
+ watch$<T = unknown>(selector: RealtimeSelector, options?: RealtimeSubscribeOptions): Observable<T> {
78
+ const subscription: MercureSubscription = {
79
+ match: resolveUrl(this.topicBaseUrl, selector.match),
80
+ type: selector.type ?? 'exact',
81
+ };
141
82
  return this.realtime
142
- .subscribeAll$<unknown>(iris, options)
143
- .pipe(
144
- map((event): WatchTypesResult<R> | undefined => {
145
- const raw = event.data;
146
-
147
- const resolvedType = readDiscriminator(raw, discriminator);
148
- if (typeof resolvedType !== 'string') return undefined;
149
- if (!allowedTypes.has(resolvedType)) return undefined;
83
+ .subscribe$(subscription, {connection: options?.connection ?? 'dedicated'})
84
+ .pipe(map((update) => update.data as T));
85
+ }
150
86
 
151
- const key = resolvedType as keyof R & string;
152
- return {resourceType: key, payload: raw as R[keyof R]} as WatchTypesResult<R>;
153
- }),
154
- filter((event): event is WatchTypesResult<R> => !!event),
155
- share()
156
- );
87
+ /** Renews the Mercure authorization and reopens every connection, e.g. after login or logout. */
88
+ reconnectRealtime(): void {
89
+ this.realtime.reconnect();
157
90
  }
158
91
 
159
- unwatch(iri: Iri | Iri[]): void {
160
- this.realtime.unsubscribe(toIriList(iri));
92
+ /** State of the Mercure connections. */
93
+ realtimeStatus$(): Observable<RealtimeStatus> {
94
+ return this.realtime.status$();
161
95
  }
162
96
 
97
+ /** Mercure connections, their subscriptions and resume positions. */
163
98
  realtimeDiagnostics$(): Observable<RealtimeDiagnostics> {
164
99
  return this.realtime.diagnostics$();
165
100
  }
@@ -169,14 +104,3 @@ export class BridgeFacade {
169
104
  return resolveUrl(this.apiBase, path);
170
105
  }
171
106
  }
172
-
173
- function readDiscriminator(payload: unknown, discriminator: string): string | undefined {
174
- if (!payload || typeof payload !== 'object') return undefined;
175
- const value = (payload as Record<string, unknown>)[discriminator];
176
- if (typeof value === 'string') return value;
177
- if (Array.isArray(value)) {
178
- const first = value.find((v) => typeof v === 'string');
179
- return typeof first === 'string' ? first : undefined;
180
- }
181
- return undefined;
182
- }
@@ -1,37 +1,37 @@
1
- import {EnvironmentInjector, inject, Injectable, runInInjectionContext} from '@angular/core';
2
- import {HttpClient} from '@angular/common/http';
1
+ import {inject, Injectable} from '@angular/core';
2
+ import {DOCUMENT} from '@angular/common';
3
3
  import {RealtimePort} from '../ports/realtime.port';
4
- import {Item, ResourceRepository} from '../ports/resource-repository.port';
5
- import {API_BASE_URL, BRIDGE_HTTP_WITH_CREDENTIALS} from '../tokens';
6
- import {MercureRealtimeAdapter} from '../bridge/sse/mercure.adapter';
7
- import {ApiPlatformRestRepository} from '../bridge/rest/api-platform.adapter';
4
+ import {Item} from '../ports/resource-repository.port';
5
+ import {API_BASE_URL} from '../tokens';
6
+ import {MercureHub} from '../bridge/sse/mercure-hub';
7
+ import {ResourceTopic} from '../bridge/sse/resource-topic';
8
+ import {resolveUrl, toAbsoluteUrl, trimTrailingSlash} from '../utils/url';
9
+ import {BridgeFacade} from './bridge.facade';
8
10
  import {ResourceFacade} from './resource.facade';
9
11
 
10
- export type FacadeConfig<T> = {
12
+ export type FacadeConfig = {
13
+ /** Collection path of the resource (e.g. `/api/books`). */
11
14
  url: string;
12
- repo?: ResourceRepository<T>;
15
+ /**
16
+ * URL Pattern of the resource item IRIs, i.e. the Mercure topics API Platform publishes on.
17
+ * Default: `{url}/:id`. Relative patterns are resolved against `baseUrl`.
18
+ */
19
+ topic?: string;
13
20
  realtime?: RealtimePort;
14
21
  };
15
22
 
16
23
  @Injectable({providedIn: 'root'})
17
24
  export class FacadeFactory {
18
- private readonly env = inject(EnvironmentInjector);
19
- private readonly http = inject(HttpClient);
20
- private readonly baseUrl = inject(API_BASE_URL);
21
- private readonly httpWithCredentials = inject(BRIDGE_HTTP_WITH_CREDENTIALS);
22
- private readonly mercure = inject(MercureRealtimeAdapter);
23
-
24
- /**
25
- * Creates a `ResourceFacade<T>`.
26
- *
27
- * Important: `ResourceFacade` uses `toSignal()`, which requires an injection context.
28
- * This factory ensures that by using `runInInjectionContext`.
29
- */
30
- create<T extends Item>(config: FacadeConfig<T>): ResourceFacade<T> {
31
- const url = config.url;
32
- const repo = config.repo ?? new ApiPlatformRestRepository<T>(this.http, this.baseUrl, url, this.httpWithCredentials);
33
- const realtime = config.realtime ?? this.mercure;
25
+ private readonly bridge = inject(BridgeFacade);
26
+ private readonly mercure = inject(MercureHub);
27
+ /** Mercure topics are absolute URLs: a relative `baseUrl` is resolved against the document base. */
28
+ private readonly topicBaseUrl = toAbsoluteUrl(inject(API_BASE_URL), inject(DOCUMENT).baseURI);
34
29
 
35
- return runInInjectionContext(this.env, () => new ResourceFacade<T>(repo, realtime));
30
+ create<T extends Item>(config: FacadeConfig): ResourceFacade<T> {
31
+ const topic = new ResourceTopic(
32
+ resolveUrl(this.topicBaseUrl, config.topic ?? `${trimTrailingSlash(config.url)}/:id`),
33
+ this.topicBaseUrl
34
+ );
35
+ return new ResourceFacade<T>(this.bridge, config.url, config.realtime ?? this.mercure, topic);
36
36
  }
37
37
  }
@@ -1,13 +1,8 @@
1
- import {
2
- Iri,
3
- Item,
4
- ResourceRepository,
5
- } from '../ports/resource-repository.port';
1
+ import {Iri, Item, ResourceRepository} from '../ports/resource-repository.port';
6
2
  import {Observable} from 'rxjs';
7
- import {WatchConnectionOptions} from '../bridge.types';
3
+ import {ResourceDeletion} from '../bridge/sse/resource-topic';
4
+ import {RealtimeSubscribeOptions} from '../ports/realtime.port';
8
5
 
9
6
  export interface Facade<T extends Item> extends ResourceRepository<T> {
10
- watch$(iri: Iri | Iri[], options?: WatchConnectionOptions): Observable<T>;
11
-
12
- unwatch(iri: Iri | Iri[]): void;
7
+ watch$(iri?: Iri, options?: RealtimeSubscribeOptions): Observable<T | ResourceDeletion>;
13
8
  }
@@ -1,95 +1,72 @@
1
1
  import {Signal} from '@angular/core';
2
- import {
3
- ResourceRepository,
4
- AnyQuery,
5
- HttpCallOptions,
6
- Iri,
7
- IriRequired,
8
- Collection,
9
- Item,
10
- HttpRequestConfig,
11
- } from '../ports/resource-repository.port';
12
- import {RealtimePort, RealtimeStatus} from '../ports/realtime.port';
13
- import {toSignal} from '@angular/core/rxjs-interop';
14
2
  import {filter, map, Observable, share} from 'rxjs';
3
+ import {AnyQuery, Collection, HttpCallOptions, HttpRequestConfig, Iri, Item} from '../ports/resource-repository.port';
4
+ import {RealtimePort, RealtimeSubscribeOptions} from '../ports/realtime.port';
5
+ import {RealtimeStatus} from '../bridge.types';
6
+ import {ResourceDeletion, ResourceTopic} from '../bridge/sse/resource-topic';
7
+ import {BridgeFacade} from './bridge.facade';
15
8
  import {Facade} from './facade.interface';
16
- import {WatchConnectionOptions} from '../bridge.types';
17
- import {toIriList} from '../utils/iri';
18
9
 
10
+ export {isResourceDeletion} from '../bridge/sse/resource-topic';
11
+ export type {ResourceDeletion} from '../bridge/sse/resource-topic';
12
+
13
+ /** API of one API Platform resource: its collection path for HTTP, its URL Pattern for Mercure. */
19
14
  export class ResourceFacade<T extends Item> implements Facade<T> {
20
15
  readonly connectionStatus: Signal<RealtimeStatus>;
21
16
 
22
17
  constructor(
23
- protected readonly repo: ResourceRepository<T>,
24
- protected readonly realtime: RealtimePort
18
+ protected readonly bridge: BridgeFacade,
19
+ protected readonly url: Iri,
20
+ protected readonly realtime: RealtimePort,
21
+ protected readonly topic: ResourceTopic,
25
22
  ) {
26
- this.connectionStatus = toSignal(this.realtime.status$(), {initialValue: 'closed'});
23
+ this.connectionStatus = realtime.status;
27
24
  }
28
25
 
29
26
  getCollection$(query?: AnyQuery, options?: HttpCallOptions): Observable<Collection<T>> {
30
- return this.repo.getCollection$(query, options);
31
- }
32
-
33
- get$(iri: IriRequired, options?: HttpCallOptions): Observable<T> {
34
- return this.repo.get$(iri, options);
27
+ return this.bridge.getCollection$<T>(this.url, query, options);
35
28
  }
36
29
 
37
- patch$(iri: IriRequired, changes: Partial<T>, options?: HttpCallOptions): Observable<T> {
38
- return this.repo.patch$(iri, changes, options);
30
+ get$(iri: Iri, options?: HttpCallOptions): Observable<T> {
31
+ return this.bridge.get$<T>(iri, options);
39
32
  }
40
33
 
41
34
  post$(payload: Partial<T>, options?: HttpCallOptions): Observable<T> {
42
- return this.repo.post$(payload, options);
35
+ return this.bridge.post$<T, Partial<T>>(this.url, payload, options);
43
36
  }
44
37
 
45
- put$(iri: IriRequired, payload: Partial<T>, options?: HttpCallOptions): Observable<T> {
46
- return this.repo.put$(iri, payload, options);
38
+ patch$(iri: Iri, changes: Partial<T>, options?: HttpCallOptions): Observable<T> {
39
+ return this.bridge.patch$<T, Partial<T>>(iri, changes, options);
47
40
  }
48
41
 
49
- delete$(iri: IriRequired, options?: HttpCallOptions): Observable<void> {
50
- return this.repo.delete$(iri, options);
42
+ put$(iri: Iri, payload: Partial<T>, options?: HttpCallOptions): Observable<T> {
43
+ return this.bridge.put$<T, Partial<T>>(iri, payload, options);
51
44
  }
52
45
 
53
- request$<R = unknown, B = unknown>(request: HttpRequestConfig<B>): Observable<R> {
54
- return this.repo.request$<R, B>(request);
46
+ delete$(iri: Iri, options?: HttpCallOptions): Observable<void> {
47
+ return this.bridge.delete$(iri, options);
55
48
  }
56
49
 
57
- /**
58
- * Subscribes to real-time updates for one or many IRIs.
59
- * Undefined/empty values are ignored.
60
- */
61
- watch$(iri: Iri | Iri[], options?: WatchConnectionOptions): Observable<T> {
62
- return this.subscribeAndSync(toIriList(iri), options);
63
- }
64
- unwatch(iri: Iri | Iri[]): void {
65
- this.realtime.unsubscribe(toIriList(iri));
50
+ /** Low-level escape hatch; `url` defaults to the collection path of the resource. */
51
+ request$<R = unknown, B = unknown>(request: HttpRequestConfig<B>): Observable<R> {
52
+ return this.bridge.request$<R, B>({...request, url: request.url ?? this.url});
66
53
  }
67
54
 
68
55
  /**
69
- * Subscribes to updates of a related sub-resource published on the parent topic.
70
- * Example: subscribe to Message events on a Conversation topic, filtering by `message.conversation`.
56
+ * Streams changes of one item when an IRI is supplied, or of the resource's items otherwise.
57
+ * Payloads are forwarded unchanged, including API Platform deletion payloads.
58
+ *
59
+ * Item subscriptions use `match`; resource subscriptions use `match_urlpattern`. Updates are kept by `@id`,
60
+ * so `connection` (default `shared`) only chooses the SSE connection carrying the subscription.
61
+ * Unsubscribing releases this consumer; other consumers keep sharing the subscription.
71
62
  */
72
- watchSubResource$<R>(
73
- iri: Iri | Iri[],
74
- field: string,
75
- options?: WatchConnectionOptions
76
- ): Observable<R> {
77
- const iris = toIriList(iri);
78
- return this.realtime
79
- .subscribe$<R>(iris, {field}, options)
80
- .pipe(
81
- map((event) => event.data),
82
- filter((data): data is R => data !== undefined),
83
- share()
84
- );
85
- }
86
-
87
- protected subscribeAndSync(iris: string[], options?: WatchConnectionOptions): Observable<T> {
63
+ watch$(iri?: Iri, options?: RealtimeSubscribeOptions): Observable<T | ResourceDeletion> {
64
+ const topic = iri === undefined ? this.topic : this.topic.forIri(iri);
88
65
  return this.realtime
89
- .subscribe$<T>(iris, undefined, options)
66
+ .subscribe$({match: topic.match, type: topic.type}, options)
90
67
  .pipe(
91
- map((event) => event.data),
92
- filter((data): data is T => data !== undefined),
68
+ map((update) => topic.toResource<T>(update.data)),
69
+ filter((resource): resource is T | ResourceDeletion => resource !== undefined),
93
70
  share()
94
71
  );
95
72
  }
@@ -1,26 +1,22 @@
1
1
  import {HttpInterceptorFn} from '@angular/common/http';
2
2
  import {from, switchMap} from 'rxjs';
3
3
  import {BridgeAuth} from '../bridge-options';
4
- import {isRequestWithinBaseUrl} from '../utils/request-url';
5
4
 
6
5
  type TokenProvider = () => string | undefined | Promise<string | undefined>;
7
6
 
8
- export function createAuthInterceptors(auth: BridgeAuth | undefined, baseUrl: string): HttpInterceptorFn[] {
9
- if (!auth) return [];
10
- if (typeof auth === 'function') return [auth];
11
-
12
- const bearer = typeof auth === 'string' ? {type: 'bearer' as const, token: auth} : auth;
13
- if (bearer.type !== 'bearer') return [];
7
+ /** Builds the auth interceptor; `provideBridge` limits it to requests targeting the API. */
8
+ export function createAuthInterceptor(auth: BridgeAuth | undefined): HttpInterceptorFn | undefined {
9
+ if (!auth) return undefined;
10
+ if (typeof auth === 'function') return auth;
14
11
 
12
+ const bearer = typeof auth === 'string' ? {token: auth} : auth;
15
13
  const getToken: TokenProvider = 'token' in bearer ? () => bearer.token : bearer.getToken;
16
- return [createBearerAuthInterceptor(baseUrl, getToken)];
14
+ return createBearerAuthInterceptor(getToken);
17
15
  }
18
16
 
19
- function createBearerAuthInterceptor(baseUrl: string, getToken: TokenProvider): HttpInterceptorFn {
17
+ function createBearerAuthInterceptor(getToken: TokenProvider): HttpInterceptorFn {
20
18
  return (request, next) => {
21
- if (!isRequestWithinBaseUrl(request.url, baseUrl) || request.headers.has('Authorization')) {
22
- return next(request);
23
- }
19
+ if (request.headers.has('Authorization')) return next(request);
24
20
 
25
21
  return from(Promise.resolve(getToken())).pipe(
26
22
  switchMap((token) => {
@@ -1,6 +1,6 @@
1
- import {HttpInterceptorFn, HttpRequest} from '@angular/common/http';
1
+ import {HttpErrorResponse, HttpInterceptorFn, HttpRequest} from '@angular/common/http';
2
2
  import {inject} from '@angular/core';
3
- import {retry, timeout, timer} from 'rxjs';
3
+ import {retry, throwError, timer} from 'rxjs';
4
4
  import {BridgeDefaults} from '../bridge.types';
5
5
  import {BRIDGE_DEFAULTS, BRIDGE_LOGGER} from '../tokens';
6
6
 
@@ -18,22 +18,18 @@ type RetryPolicy = {
18
18
  export const bridgeDefaultsInterceptor: HttpInterceptorFn = (req, next) => {
19
19
  const defaults = inject(BRIDGE_DEFAULTS, {optional: true}) ?? {};
20
20
  const logger = inject(BRIDGE_LOGGER, {optional: true});
21
- const request = applyDefaultHeaders(req, defaults.headers);
22
-
23
- let response$ = next(request);
24
- if (typeof defaults.timeoutMs === 'number' && defaults.timeoutMs > 0) {
25
- response$ = response$.pipe(timeout({first: defaults.timeoutMs}));
26
- }
21
+ const request = applyTimeout(applyDefaultHeaders(req, defaults.headers), defaults.timeoutMs);
27
22
 
28
23
  const retryPolicy = resolveRetryPolicy(defaults.retries);
29
24
  if (!retryPolicy || !retryPolicy.methods.has(req.method.toUpperCase())) {
30
- return response$;
25
+ return next(request);
31
26
  }
32
27
 
33
- return response$.pipe(
28
+ return next(request).pipe(
34
29
  retry({
35
30
  count: retryPolicy.count,
36
- delay: (_error, retryIndex) => {
31
+ delay: (error, retryIndex) => {
32
+ if (!isRetryable(error)) return throwError(() => error);
37
33
  logger?.debug?.('[Bridge] retry', {url: req.urlWithParams, method: req.method, retryIndex});
38
34
  return timer(retryPolicy.delayMs);
39
35
  },
@@ -52,6 +48,18 @@ function applyDefaultHeaders(request: HttpRequest<unknown>, defaults?: Record<st
52
48
  return headers === request.headers ? request : request.clone({headers});
53
49
  }
54
50
 
51
+ /** Uses the native request timeout, which aborts the request; an explicit per-request timeout wins. */
52
+ function applyTimeout(request: HttpRequest<unknown>, timeoutMs?: number): HttpRequest<unknown> {
53
+ if (typeof timeoutMs !== 'number' || timeoutMs <= 0 || request.timeout !== undefined) return request;
54
+ return request.clone({timeout: timeoutMs});
55
+ }
56
+
57
+ /** Network errors (status 0), rate limiting and server errors are transient; other responses are final. */
58
+ function isRetryable(error: unknown): boolean {
59
+ if (!(error instanceof HttpErrorResponse)) return false;
60
+ return error.status === 0 || error.status === 429 || error.status >= 500;
61
+ }
62
+
55
63
  function resolveRetryPolicy(retries: BridgeDefaults['retries']): RetryPolicy | undefined {
56
64
  const count = typeof retries === 'number' ? retries : retries?.count;
57
65
  if (!count || count <= 0) return undefined;