@obsidiane/meridiane 2.0.5 → 2.0.6

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.
@@ -31,3 +31,40 @@ export interface BridgeDefaults {
31
31
  */
32
32
  export type MercureTopicMode = 'url' | 'iri';
33
33
 
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;
48
+ }
49
+
50
+ export type RealtimeConnectionScope = 'shared' | 'dedicated';
51
+
52
+ export type RealtimeConnectionStatus = 'connecting' | 'connected' | 'closed';
53
+
54
+ export interface RealtimeConnectionDiagnostics {
55
+ id: string;
56
+ scope: RealtimeConnectionScope;
57
+ status: RealtimeConnectionStatus;
58
+ topicCount: number;
59
+ topics: string[];
60
+ urlLength: number;
61
+ }
62
+
63
+ export interface RealtimeDiagnostics {
64
+ mode: MercureConnectionMode;
65
+ maxUrlLength: number;
66
+ totalConnections: number;
67
+ sharedConnections: number;
68
+ dedicatedConnections: number;
69
+ connections: RealtimeConnectionDiagnostics[];
70
+ }
@@ -9,6 +9,22 @@ import {API_BASE_URL, BRIDGE_WITH_CREDENTIALS} from '../tokens';
9
9
  import {AnyQuery, Collection, HttpCallOptions, HttpRequestConfig, Iri, IriRequired, Item} from '../ports/resource-repository.port';
10
10
  import {SubscribeFilter} from '../ports/realtime.port';
11
11
  import {resolveUrl} from '../utils/url';
12
+ import {RealtimeDiagnostics, WatchConnectionOptions} from '../bridge.types';
13
+
14
+ export type TypedEvent<TType extends string = string, TPayload = unknown> = {
15
+ resourceType: TType;
16
+ payload: TPayload;
17
+ };
18
+
19
+ export type WatchTypesResult<R extends Record<string, any>> =
20
+ { [K in keyof R]: TypedEvent<K & string, R[K]> }[keyof R];
21
+
22
+ export type WatchTypesConfig = {
23
+ /** Field name used as discriminator. Default: `@type` (JSON-LD). */
24
+ discriminator?: string;
25
+ };
26
+
27
+ export type WatchSubscriptionOptions = WatchConnectionOptions;
12
28
 
13
29
  /**
14
30
  * High-level facade for ad-hoc HTTP calls and Mercure subscriptions.
@@ -85,10 +101,14 @@ export class BridgeFacade {
85
101
 
86
102
  // ──────────────── SSE / Mercure ────────────────
87
103
 
88
- watch$<T = Item>(iri: Iri | Iri[], subscribeFilter?: SubscribeFilter): Observable<T> {
104
+ watch$<T = Item>(
105
+ iri: Iri | Iri[],
106
+ subscribeFilter?: SubscribeFilter,
107
+ options?: WatchSubscriptionOptions
108
+ ): Observable<T> {
89
109
  const iris = (Array.isArray(iri) ? iri : [iri]).filter((v): v is string => typeof v === 'string' && v.length > 0);
90
110
  return this.realtime
91
- .subscribe$<T>(iris, subscribeFilter)
111
+ .subscribe$<T>(iris, subscribeFilter, options)
92
112
  .pipe(
93
113
  map((event) => event.data),
94
114
  filter((data): data is T => data !== undefined),
@@ -96,13 +116,68 @@ export class BridgeFacade {
96
116
  );
97
117
  }
98
118
 
119
+ /**
120
+ * Watches a "multi-entity" topic and emits only payloads whose discriminator matches `resourceTypes`.
121
+ *
122
+ * Important:
123
+ * - filtering is done on payload content (e.g. `@type`), not on network topic provenance.
124
+ * - in shared mode (`connectionMode: 'auto'` without `newConnection`), events from another shared topic
125
+ * can be emitted if they carry an allowed discriminator.
126
+ *
127
+ * For strict single-topic isolation, use a dedicated connection:
128
+ * `watchTypes$(..., ..., ..., { newConnection: true })`
129
+ * (or `connectionMode: 'single'` globally).
130
+ */
131
+ watchTypes$<R extends Record<string, any>>(
132
+ iri: Iri | Iri[],
133
+ resourceTypes: (keyof R & string)[],
134
+ cfg?: WatchTypesConfig,
135
+ options?: WatchSubscriptionOptions
136
+ ): Observable<WatchTypesResult<R>> {
137
+ const iris = (Array.isArray(iri) ? iri : [iri]).filter((v): v is string => typeof v === 'string' && v.length > 0);
138
+ const discriminator = cfg?.discriminator ?? '@type';
139
+ const allowedTypes = new Set(resourceTypes.filter((v): v is string => typeof v === 'string' && v.length > 0));
140
+
141
+ 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;
150
+
151
+ const key = resolvedType as keyof R & string;
152
+ return {resourceType: key, payload: raw as R[keyof R]} as WatchTypesResult<R>;
153
+ }),
154
+ filter((evt): evt is WatchTypesResult<R> => !!evt),
155
+ share()
156
+ );
157
+ }
158
+
99
159
  unwatch(iri: Iri | Iri[]): void {
100
160
  const iris = (Array.isArray(iri) ? iri : [iri]).filter((v): v is string => typeof v === 'string' && v.length > 0);
101
161
  this.realtime.unsubscribe(iris);
102
162
  }
103
163
 
164
+ realtimeDiagnostics$(): Observable<RealtimeDiagnostics> {
165
+ return this.realtime.diagnostics$();
166
+ }
167
+
104
168
  private resolveUrl(path?: Iri): string {
105
169
  if (!path) throw new Error('BridgeFacade: missing url');
106
170
  return resolveUrl(this.apiBase, path);
107
171
  }
108
172
  }
173
+
174
+ function readDiscriminator(raw: unknown, discriminator: string): string | undefined {
175
+ if (!raw || typeof raw !== 'object') return undefined;
176
+ const value = (raw as any)?.[discriminator];
177
+ if (typeof value === 'string') return value;
178
+ if (Array.isArray(value)) {
179
+ const first = value.find((v) => typeof v === 'string');
180
+ return typeof first === 'string' ? first : undefined;
181
+ }
182
+ return undefined;
183
+ }
@@ -8,6 +8,7 @@ import {
8
8
  AnyQuery,
9
9
  } from '../ports/resource-repository.port';
10
10
  import {Observable} from 'rxjs';
11
+ import {WatchConnectionOptions} from '../bridge.types';
11
12
 
12
13
  export interface Facade<T extends Item> {
13
14
  getCollection$(query?: AnyQuery, opts?: HttpCallOptions): Observable<Collection<T>>;
@@ -24,7 +25,7 @@ export interface Facade<T extends Item> {
24
25
 
25
26
  request$<R = unknown, B = unknown>(req: HttpRequestConfig<B>): Observable<R>;
26
27
 
27
- watch$(iri: Iri | Iri[]): Observable<T>;
28
+ watch$(iri: Iri | Iri[], options?: WatchConnectionOptions): Observable<T>;
28
29
 
29
30
  unwatch(iri: Iri | Iri[]): void;
30
31
  }
@@ -13,6 +13,7 @@ import {RealtimePort, RealtimeStatus} from '../ports/realtime.port';
13
13
  import {toSignal} from '@angular/core/rxjs-interop';
14
14
  import {filter, map, Observable, share} from 'rxjs';
15
15
  import {Facade} from './facade.interface';
16
+ import {WatchConnectionOptions} from '../bridge.types';
16
17
 
17
18
  export class ResourceFacade<T extends Item> implements Facade<T> {
18
19
 
@@ -60,9 +61,9 @@ export class ResourceFacade<T extends Item> implements Facade<T> {
60
61
  * Subscribes to real-time updates for one or many IRIs.
61
62
  * Undefined/empty values are ignored.
62
63
  */
63
- watch$(iri: Iri | Iri[]): Observable<T> {
64
+ watch$(iri: Iri | Iri[], options?: WatchConnectionOptions): Observable<T> {
64
65
  const iris = (Array.isArray(iri) ? iri : [iri]).filter((v): v is string => typeof v === 'string' && v.length > 0);
65
- return this.subscribeAndSync(iris);
66
+ return this.subscribeAndSync(iris, options);
66
67
  }
67
68
 
68
69
 
@@ -77,11 +78,12 @@ export class ResourceFacade<T extends Item> implements Facade<T> {
77
78
  */
78
79
  watchSubResource$<R>(
79
80
  iri: Iri | Iri[],
80
- field: string
81
+ field: string,
82
+ options?: WatchConnectionOptions
81
83
  ): Observable<R> {
82
84
  const iris = (Array.isArray(iri) ? iri : [iri]).filter((v): v is string => typeof v === 'string' && v.length > 0);
83
85
  return this.realtime
84
- .subscribe$<R>(iris, {field: field})
86
+ .subscribe$<R>(iris, {field: field}, options)
85
87
  .pipe(
86
88
  map(e => e.data),
87
89
  filter((d): d is R => d !== undefined),
@@ -89,9 +91,9 @@ export class ResourceFacade<T extends Item> implements Facade<T> {
89
91
  );
90
92
  }
91
93
 
92
- protected subscribeAndSync(iris: string[]): Observable<T> {
94
+ protected subscribeAndSync(iris: string[], options?: WatchConnectionOptions): Observable<T> {
93
95
  return this.realtime
94
- .subscribe$<T>(iris)
96
+ .subscribe$<T>(iris, undefined, options)
95
97
  .pipe(
96
98
  map(event => event.data),
97
99
  filter((data): data is T => data !== undefined),
@@ -1,5 +1,6 @@
1
1
  import {Observable} from 'rxjs';
2
2
  import {Iri} from './resource-repository.port';
3
+ import {WatchConnectionOptions} from '../bridge.types';
3
4
 
4
5
  export type RealtimeStatus = 'connecting' | 'connected' | 'closed';
5
6
 
@@ -15,11 +16,34 @@ export interface SseOptions {
15
16
 
16
17
  export interface RealtimeEvent<T> {
17
18
  iri: string;
19
+ /**
20
+ * Matched subscription topic (normalized).
21
+ *
22
+ * - When matching by `@id`, this is typically the same as `iri`.
23
+ * - When matching by a relation field (e.g. `conversation`), this is the parent topic IRI.
24
+ */
25
+ topic?: string;
18
26
  data?: T;
19
27
  }
20
28
 
21
29
  export type SubscribeFilter = {
22
- field: string;
30
+ /**
31
+ * Single relation path used to match events to subscription topics.
32
+ * Example: `conversation` or `embedded.author`.
33
+ */
34
+ field?: string;
35
+ /**
36
+ * Multiple relation paths used to match events to subscription topics.
37
+ */
38
+ fields?: string[];
39
+ /**
40
+ * When relation fields are provided, also include events whose `@id` matches the subscribed topics.
41
+ *
42
+ * Default:
43
+ * - `true` when no relation fields are provided (match by `@id`)
44
+ * - `false` when relation fields are provided (sub-resource mode)
45
+ */
46
+ includeSelf?: boolean;
23
47
  };
24
48
 
25
49
  export interface RealtimePort {
@@ -28,7 +52,14 @@ export interface RealtimePort {
28
52
  *
29
53
  * Note: topics must be stable strings. Undefined values are ignored by the adapter.
30
54
  */
31
- subscribe$<T>(iris: Iri[], filter?: SubscribeFilter): Observable<RealtimeEvent<T>>;
55
+ subscribe$<T>(iris: Iri[], filter?: SubscribeFilter, options?: WatchConnectionOptions): Observable<RealtimeEvent<T>>;
56
+
57
+ /**
58
+ * Subscribes to all JSON payloads received on the given Mercure topics.
59
+ *
60
+ * No payload-to-topic matching is applied.
61
+ */
62
+ subscribeAll$<T>(iris: Iri[], options?: WatchConnectionOptions): Observable<RealtimeEvent<T>>;
32
63
 
33
64
  unsubscribe(iris: Iri[]): void;
34
65
 
@@ -5,10 +5,12 @@ import {
5
5
  BRIDGE_DEFAULTS,
6
6
  BRIDGE_LOGGER,
7
7
  BRIDGE_WITH_CREDENTIALS,
8
+ MERCURE_CONNECTION_MODE,
8
9
  MERCURE_HUB_URL,
10
+ MERCURE_MAX_URL_LENGTH,
9
11
  MERCURE_TOPIC_MODE,
10
12
  } from './tokens';
11
- import {BridgeDefaults, BridgeLogger, MercureTopicMode} from './bridge.types';
13
+ import {BridgeDefaults, BridgeLogger, MercureConnectionMode, MercureTopicMode} from './bridge.types';
12
14
  import {contentTypeInterceptor} from './interceptors/content-type.interceptor';
13
15
  import {bridgeDefaultsInterceptor} from './interceptors/bridge-defaults.interceptor';
14
16
  import {bridgeDebugInterceptor} from './interceptors/bridge-debug.interceptor';
@@ -25,6 +27,17 @@ export interface BridgeMercureOptions {
25
27
  hubUrl?: string;
26
28
  init?: RequestInit;
27
29
  topicMode?: MercureTopicMode;
30
+ /**
31
+ * Connection strategy for SSE subscriptions.
32
+ * - `single`: one SSE connection per watch subscription
33
+ * - `auto` (default): shared connections with automatic URL-length sharding
34
+ */
35
+ connectionMode?: MercureConnectionMode;
36
+ /**
37
+ * Maximum Mercure URL length before auto-sharding topics into multiple connections.
38
+ * Used only when `connectionMode: 'auto'`. Default: `1900`.
39
+ */
40
+ maxUrlLength?: number;
28
41
  }
29
42
 
30
43
  export interface BridgeOptions {
@@ -70,6 +83,8 @@ export function provideBridge(opts: BridgeOptions): EnvironmentProviders {
70
83
  const resolvedMercureInit: RequestInit = mercure?.init ?? {credentials: 'include' as RequestCredentials};
71
84
  const resolvedMercureHubUrl = mercure?.hubUrl;
72
85
  const resolvedMercureTopicMode: MercureTopicMode = mercure?.topicMode ?? 'url';
86
+ const resolvedMercureConnectionMode: MercureConnectionMode = mercure?.connectionMode ?? 'auto';
87
+ const resolvedMercureMaxUrlLength = normalizeMercureMaxUrlLength(mercure?.maxUrlLength, 1900);
73
88
  const withCredentials = resolveWithCredentials(resolvedMercureInit);
74
89
 
75
90
  const loggerProvider: BridgeLogger = createBridgeLogger(debug);
@@ -92,6 +107,8 @@ export function provideBridge(opts: BridgeOptions): EnvironmentProviders {
92
107
  {provide: BRIDGE_WITH_CREDENTIALS, useValue: withCredentials},
93
108
  ...(resolvedMercureHubUrl ? [{provide: MERCURE_HUB_URL, useValue: resolvedMercureHubUrl}] : []),
94
109
  {provide: MERCURE_TOPIC_MODE, useValue: resolvedMercureTopicMode},
110
+ {provide: MERCURE_CONNECTION_MODE, useValue: resolvedMercureConnectionMode},
111
+ {provide: MERCURE_MAX_URL_LENGTH, useValue: resolvedMercureMaxUrlLength},
95
112
  {provide: BRIDGE_DEFAULTS, useValue: defaults ?? {}},
96
113
  {provide: BRIDGE_LOGGER, useValue: loggerProvider},
97
114
  ]);
@@ -113,6 +130,11 @@ function resolveWithCredentials(init: RequestInit | undefined): boolean {
113
130
  return anyInit.withCredentials === true || init.credentials === 'include';
114
131
  }
115
132
 
133
+ function normalizeMercureMaxUrlLength(input: unknown, fallback: number): number {
134
+ const value = typeof input === 'number' ? Math.floor(input) : fallback;
135
+ return Number.isFinite(value) && value >= 256 ? value : fallback;
136
+ }
137
+
116
138
  function createAuthInterceptors(auth?: BridgeAuth): HttpInterceptorFn[] {
117
139
  if (!auth) return [];
118
140
 
@@ -1,5 +1,5 @@
1
1
  import {InjectionToken} from '@angular/core';
2
- import {BridgeDefaults, BridgeLogger, MercureTopicMode} from './bridge.types';
2
+ import {BridgeDefaults, BridgeLogger, MercureConnectionMode, MercureTopicMode} from './bridge.types';
3
3
 
4
4
  /** Base URL of the API (e.g. `http://localhost:8000`). */
5
5
  export const API_BASE_URL = new InjectionToken<string>('API_BASE_URL');
@@ -15,6 +15,10 @@ export const BRIDGE_WITH_CREDENTIALS = new InjectionToken<boolean>('BRIDGE_WITH_
15
15
 
16
16
  export const MERCURE_TOPIC_MODE = new InjectionToken<MercureTopicMode>('MERCURE_TOPIC_MODE');
17
17
 
18
+ export const MERCURE_CONNECTION_MODE = new InjectionToken<MercureConnectionMode>('MERCURE_CONNECTION_MODE');
19
+
20
+ export const MERCURE_MAX_URL_LENGTH = new InjectionToken<number>('MERCURE_MAX_URL_LENGTH');
21
+
18
22
  export const BRIDGE_LOGGER = new InjectionToken<BridgeLogger>('BRIDGE_LOGGER');
19
23
 
20
24
  export const BRIDGE_DEFAULTS = new InjectionToken<BridgeDefaults>('BRIDGE_DEFAULTS');
@@ -1,5 +1,6 @@
1
1
  import process from 'node:process';
2
2
  import path from 'node:path';
3
+ import fs from 'node:fs/promises';
3
4
 
4
5
  import { readOpenApiSpec } from '../infra/spec-loader.js';
5
6
  import { generateBridgeWorkspace } from '../infra/generate/bridge.js';
@@ -69,6 +70,7 @@ export async function runBridgeWorkflow({
69
70
  const distDir = path.join(distRoot, libName);
70
71
  const packRel = packRelativeTo ? path.relative(packRelativeTo, distDir) : distDir;
71
72
  log?.step?.(`npm pack (${packRel})`);
73
+ await cleanupPackedTarballs(distDir);
72
74
  const packCode = await runCommand('npm', ['pack'], {
73
75
  cwd: distDir,
74
76
  env: {
@@ -83,3 +85,9 @@ export async function runBridgeWorkflow({
83
85
 
84
86
  return { workspaceRoot, distRoot, distDir, npmCacheDir };
85
87
  }
88
+
89
+ async function cleanupPackedTarballs(distDir) {
90
+ const entries = await fs.readdir(distDir, {withFileTypes: true});
91
+ const tarballs = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.tgz'));
92
+ await Promise.all(tarballs.map((entry) => fs.rm(path.join(distDir, entry.name), {force: true})));
93
+ }
@@ -108,5 +108,82 @@ export async function installPackageFromDist({ packageName, distDir, appRoot, lo
108
108
  filter: (src) => !src.endsWith('.tgz'),
109
109
  });
110
110
 
111
+ await invalidateAngularViteDependencyCache({ appRoot, packageName, log });
112
+
111
113
  return pkgDir;
112
114
  }
115
+
116
+ async function invalidateAngularViteDependencyCache({ appRoot, packageName, log }) {
117
+ const viteDepsRoots = await findAngularViteDepsRoots(appRoot);
118
+ if (viteDepsRoots.length === 0) return;
119
+
120
+ const cacheBase = packageNameToViteCacheBase(packageName);
121
+ let removed = 0;
122
+
123
+ for (const depsRoot of viteDepsRoots) {
124
+ const files = [
125
+ `${cacheBase}.js`,
126
+ `${cacheBase}.js.map`,
127
+ `${cacheBase}.mjs`,
128
+ `${cacheBase}.mjs.map`,
129
+ ];
130
+
131
+ for (const fileName of files) {
132
+ const p = path.join(depsRoot, fileName);
133
+ try {
134
+ await fs.rm(p, { force: true });
135
+ removed += 1;
136
+ } catch {
137
+ // ignore
138
+ }
139
+ }
140
+
141
+ // Metadata keeps dependency hashes/entries; remove it to force a clean re-optimization.
142
+ try {
143
+ await fs.rm(path.join(depsRoot, '_metadata.json'), { force: true });
144
+ removed += 1;
145
+ } catch {
146
+ // ignore
147
+ }
148
+ }
149
+
150
+ if (removed > 0) {
151
+ log?.info?.(`cache Angular/Vite invalidé (${removed} fichier(s)) pour ${packageName}`);
152
+ }
153
+ }
154
+
155
+ function packageNameToViteCacheBase(packageName) {
156
+ const normalized = String(packageName || '').trim();
157
+ // Example: "@obsidiane/bridge-sandbox" -> "@obsidiane_bridge-sandbox"
158
+ return normalized.replace('/', '_');
159
+ }
160
+
161
+ async function findAngularViteDepsRoots(appRoot) {
162
+ const angularCacheRoot = path.join(appRoot, '.angular', 'cache');
163
+ const roots = [];
164
+
165
+ async function walk(dir, depth) {
166
+ if (depth > 8) return;
167
+
168
+ let entries;
169
+ try {
170
+ entries = await fs.readdir(dir, { withFileTypes: true });
171
+ } catch {
172
+ return;
173
+ }
174
+
175
+ const hasDeps = entries.some((e) => e.isDirectory() && e.name === 'deps');
176
+ if (hasDeps && path.basename(dir) === 'vite') {
177
+ roots.push(path.join(dir, 'deps'));
178
+ }
179
+
180
+ for (const entry of entries) {
181
+ if (!entry.isDirectory()) continue;
182
+ if (entry.name === 'node_modules') continue;
183
+ await walk(path.join(dir, entry.name), depth + 1);
184
+ }
185
+ }
186
+
187
+ await walk(angularCacheRoot, 0);
188
+ return roots;
189
+ }
@@ -18,7 +18,7 @@ async function exists(p) {
18
18
  }
19
19
  }
20
20
 
21
- function toolchainPackageJson({ angularVersion }) {
21
+ function toolchainPackageJson({ angularVersion, ngPackagrVersion, typescriptVersion, rxjsVersion, tslibVersion }) {
22
22
  const v = angularVersion;
23
23
  return {
24
24
  name: 'meridiane-standalone-workspace',
@@ -29,10 +29,10 @@ function toolchainPackageJson({ angularVersion }) {
29
29
  '@angular/compiler': v,
30
30
  '@angular/compiler-cli': v,
31
31
  '@angular/core': v,
32
- 'ng-packagr': '20.1.0',
33
- rxjs: '7.8.2',
34
- tslib: '2.8.1',
35
- typescript: '5.8.3',
32
+ 'ng-packagr': ngPackagrVersion,
33
+ rxjs: rxjsVersion,
34
+ tslib: tslibVersion,
35
+ typescript: typescriptVersion,
36
36
  },
37
37
  };
38
38
  }
@@ -42,7 +42,9 @@ function rootTsconfigJson() {
42
42
  compilerOptions: {
43
43
  target: 'ES2022',
44
44
  module: 'ES2022',
45
- moduleResolution: 'Node',
45
+ // Angular >=21 relies on modern package exports resolution for some subpaths
46
+ // (e.g. @angular/common/http, @angular/core/rxjs-interop).
47
+ moduleResolution: 'bundler',
46
48
  lib: ['ES2022', 'DOM'],
47
49
  strict: true,
48
50
  skipLibCheck: true,
@@ -63,9 +65,18 @@ export async function ensureStandaloneWorkspace({ repoRoot, log }) {
63
65
  await ensureDir(distRoot);
64
66
  await ensureDir(workspaceRoot);
65
67
 
66
- const angularVersion = (await detectAngularVersion(repoRoot)) ?? '20.1.7';
68
+ const detected = await detectToolchainVersions(repoRoot);
69
+ const angularVersion = detected.angularVersion ?? '21.1.4';
70
+ const derived = deriveToolchainDefaultsFromAngular(angularVersion);
71
+ const ngPackagrVersion = detected.ngPackagrVersion ?? derived.ngPackagrVersion;
72
+ const typescriptVersion = detected.typescriptVersion ?? derived.typescriptVersion;
73
+ const rxjsVersion = detected.rxjsVersion ?? '7.8.2';
74
+ const tslibVersion = detected.tslibVersion ?? '2.8.1';
67
75
  log?.debug?.('[meridiane] angular version', { angularVersion });
68
- await writeJsonIfChanged(path.join(workspaceRoot, 'package.json'), toolchainPackageJson({ angularVersion }));
76
+ await writeJsonIfChanged(
77
+ path.join(workspaceRoot, 'package.json'),
78
+ toolchainPackageJson({ angularVersion, ngPackagrVersion, typescriptVersion, rxjsVersion, tslibVersion })
79
+ );
69
80
  await writeJsonIfChanged(path.join(workspaceRoot, 'tsconfig.json'), rootTsconfigJson());
70
81
 
71
82
  const npmCacheDir = path.join(workspaceRoot, '.npm-cache');
@@ -76,21 +87,44 @@ export async function ensureStandaloneWorkspace({ repoRoot, log }) {
76
87
  return { workspaceRoot, distRoot, npmCacheDir, requireFromWs };
77
88
  }
78
89
 
79
- async function detectAngularVersion(repoRoot) {
90
+ async function detectToolchainVersions(repoRoot) {
80
91
  const pkgPath = path.join(repoRoot, 'package.json');
81
92
  try {
82
93
  const raw = await fs.readFile(pkgPath, 'utf8');
83
94
  const parsed = JSON.parse(raw);
84
- const deps = parsed?.dependencies || {};
85
- const devDeps = parsed?.devDependencies || {};
86
- const peerDeps = parsed?.peerDependencies || {};
87
- const v = deps['@angular/core'] ?? devDeps['@angular/core'] ?? peerDeps['@angular/core'];
88
- return typeof v === 'string' && v.trim().length > 0 ? v.trim() : undefined;
95
+ const angularVersion = readDependencyVersion(parsed, '@angular/core');
96
+ const ngPackagrVersion = readDependencyVersion(parsed, 'ng-packagr');
97
+ const typescriptVersion = readDependencyVersion(parsed, 'typescript');
98
+ const rxjsVersion = readDependencyVersion(parsed, 'rxjs');
99
+ const tslibVersion = readDependencyVersion(parsed, 'tslib');
100
+ return { angularVersion, ngPackagrVersion, typescriptVersion, rxjsVersion, tslibVersion };
89
101
  } catch {
90
- return undefined;
102
+ return {};
91
103
  }
92
104
  }
93
105
 
106
+ function readDependencyVersion(pkg, depName) {
107
+ const deps = pkg?.dependencies || {};
108
+ const devDeps = pkg?.devDependencies || {};
109
+ const peerDeps = pkg?.peerDependencies || {};
110
+ const value = deps[depName] ?? devDeps[depName] ?? peerDeps[depName];
111
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
112
+ }
113
+
114
+ function deriveToolchainDefaultsFromAngular(angularVersion) {
115
+ const major = parseInt(String(angularVersion).match(/\d+/)?.[0] ?? '0', 10);
116
+ if (major >= 21) {
117
+ return {
118
+ ngPackagrVersion: '21.1.0',
119
+ typescriptVersion: '5.9.3',
120
+ };
121
+ }
122
+ return {
123
+ ngPackagrVersion: '20.1.0',
124
+ typescriptVersion: '5.8.3',
125
+ };
126
+ }
127
+
94
128
  export async function ensureToolchainInstalled({ workspaceRoot, requireFromWs, npmCacheDir, log }) {
95
129
  // Prefer using existing (hoisted) deps during local dev; in CI this will trigger installation.
96
130
  const canResolve = (id) => {