@xemahq/api-client-generator 0.1.5 → 0.3.0

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 (55) hide show
  1. package/dist/bin/xema-openapi.js +2 -2
  2. package/dist/bin/xema-openapi.js.map +1 -1
  3. package/dist/index.d.ts +0 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +0 -1
  6. package/dist/index.js.map +1 -1
  7. package/dist/lib/client-package.d.ts.map +1 -1
  8. package/dist/lib/client-package.js +9 -4
  9. package/dist/lib/client-package.js.map +1 -1
  10. package/dist/lib/default-stubs.d.ts.map +1 -1
  11. package/dist/lib/default-stubs.js +35 -10
  12. package/dist/lib/default-stubs.js.map +1 -1
  13. package/dist/lib/extract-openapi.d.ts +2 -2
  14. package/dist/lib/extract-openapi.d.ts.map +1 -1
  15. package/dist/lib/extract-openapi.js +35 -39
  16. package/dist/lib/extract-openapi.js.map +1 -1
  17. package/dist/lib/generate-clients.d.ts +1 -1
  18. package/dist/lib/generate-clients.d.ts.map +1 -1
  19. package/dist/lib/generate-clients.js +33 -6
  20. package/dist/lib/generate-clients.js.map +1 -1
  21. package/dist/lib/overrides.d.ts.map +1 -1
  22. package/dist/lib/overrides.js +10 -2
  23. package/dist/lib/overrides.js.map +1 -1
  24. package/dist/lib/repo-post-process.d.ts.map +1 -1
  25. package/dist/lib/repo-post-process.js +2 -1
  26. package/dist/lib/repo-post-process.js.map +1 -1
  27. package/dist/lib/seed-openapi-env.d.ts +2 -1
  28. package/dist/lib/seed-openapi-env.d.ts.map +1 -1
  29. package/dist/lib/seed-openapi-env.js +14 -24
  30. package/dist/lib/seed-openapi-env.js.map +1 -1
  31. package/dist/lib/service-config.d.ts +1 -1
  32. package/dist/lib/service-config.d.ts.map +1 -1
  33. package/dist/lib/service-config.js +2 -2
  34. package/dist/lib/service-config.js.map +1 -1
  35. package/dist/lib/service-nest.d.ts.map +1 -1
  36. package/dist/lib/service-nest.js.map +1 -1
  37. package/package.json +6 -4
  38. package/src/bin/xema-client-generate.ts +11 -0
  39. package/src/bin/xema-openapi.ts +51 -0
  40. package/src/index.ts +9 -0
  41. package/src/lib/client-package.ts +215 -0
  42. package/src/lib/client-readme.ts +124 -0
  43. package/src/lib/custom-fetch-template.ts +200 -0
  44. package/src/lib/default-stubs.ts +422 -0
  45. package/src/lib/extract-openapi.ts +240 -0
  46. package/src/lib/generate-clients.ts +240 -0
  47. package/src/lib/overrides.ts +81 -0
  48. package/src/lib/repo-post-process.ts +106 -0
  49. package/src/lib/seed-openapi-env.ts +137 -0
  50. package/src/lib/service-config.ts +170 -0
  51. package/src/lib/service-nest.ts +58 -0
  52. package/dist/lib/prune-spec.d.ts +0 -16
  53. package/dist/lib/prune-spec.d.ts.map +0 -1
  54. package/dist/lib/prune-spec.js +0 -100
  55. package/dist/lib/prune-spec.js.map +0 -1
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Canonical `custom-fetch.ts` body, identical to the hand-maintained mutator
3
+ * shipped in every existing client package. Used ONLY as the fallback when a
4
+ * freshly scaffolded surface client has no public-surface sibling to copy from
5
+ * (a pure-internal/operator service). Kept byte-identical to the committed
6
+ * version so all clients share one fetch implementation.
7
+ */
8
+ export const CANONICAL_CUSTOM_FETCH = `/**
9
+ * Configurable fetch wrapper for Orval-generated clients.
10
+ *
11
+ * Consumers must call \`configureClient()\` before using any endpoint function.
12
+ * The baseUrl is prepended to the relative paths generated by Orval.
13
+ */
14
+
15
+ export interface ClientConfig {
16
+ /** Static base URL (e.g. 'http://localhost:3140') — no trailing slash. */
17
+ baseUrl?: string;
18
+ /** Per-request base-URL resolver (service-registry-safe). */
19
+ baseUrlResolver?: () => string | Promise<string>;
20
+ /** Optional async callback to get an auth token. */
21
+ getAuthToken?: () => Promise<string>;
22
+ /** Optional callback returning headers to inject on every request. */
23
+ getHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
24
+ /** Optional callback invoked on 401 before a single retry. */
25
+ onUnauthorized?: () => Promise<void>;
26
+ /** Maximum retry attempts for transient failures (default: 3). */
27
+ maxRetries?: number;
28
+ }
29
+
30
+ export class ClientError extends Error {
31
+ constructor(
32
+ public readonly status: number,
33
+ public readonly url: string,
34
+ public readonly body: unknown,
35
+ ) {
36
+ super(\`HTTP \${status} from \${url}\`);
37
+ this.name = 'ClientError';
38
+ }
39
+ }
40
+
41
+ let clientConfig: ClientConfig | null = null;
42
+
43
+ export function configureClient(config: ClientConfig): void {
44
+ clientConfig = config;
45
+ }
46
+
47
+ export function getClientConfig(): ClientConfig {
48
+ if (!clientConfig) {
49
+ throw new Error(
50
+ 'Client not configured. Call configureClient({ baseUrl }) before using endpoint functions.',
51
+ );
52
+ }
53
+ return clientConfig;
54
+ }
55
+
56
+ const RETRYABLE_STATUSES = [429, 502, 503, 504];
57
+
58
+ async function buildHeaders(
59
+ config: ClientConfig,
60
+ callerHeaders: RequestInit['headers'],
61
+ ): Promise<Headers> {
62
+ const headers = new Headers(callerHeaders);
63
+ if (config.getHeaders) {
64
+ const globalHeaders = await Promise.resolve(config.getHeaders());
65
+ for (const [key, value] of Object.entries(globalHeaders)) {
66
+ if (!headers.has(key)) {
67
+ headers.set(key, value);
68
+ }
69
+ }
70
+ }
71
+ if (config.getAuthToken && !headers.has('Authorization')) {
72
+ const token = await config.getAuthToken();
73
+ headers.set('Authorization', \`Bearer \${token}\`);
74
+ }
75
+ return headers;
76
+ }
77
+
78
+ export const customFetch = async <T>(
79
+ url: string,
80
+ options: RequestInit,
81
+ ): Promise<T> => {
82
+ const config = getClientConfig();
83
+ const base = config.baseUrlResolver
84
+ ? await config.baseUrlResolver()
85
+ : config.baseUrl;
86
+ if (base === undefined) {
87
+ throw new Error(
88
+ 'Client not configured: set baseUrl or baseUrlResolver via configureClient().',
89
+ );
90
+ }
91
+ const fullUrl = \`\${base}\${url}\`;
92
+ const maxRetries = config.maxRetries ?? 3;
93
+
94
+ const headers = await buildHeaders(config, options.headers);
95
+ const requestInit: RequestInit = { ...options, headers };
96
+
97
+ let delay = 1000;
98
+ let lastError: Error | undefined;
99
+
100
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
101
+ try {
102
+ const response = await fetch(fullUrl, requestInit);
103
+
104
+ if (response.status === 401 && config.onUnauthorized && attempt === 0) {
105
+ await config.onUnauthorized();
106
+ const refreshedHeaders = await buildHeaders(config, options.headers);
107
+ const retryResponse = await fetch(fullUrl, { ...options, headers: refreshedHeaders });
108
+ const retryBody = await parseBody(retryResponse);
109
+ if (retryResponse.status >= 400) {
110
+ throw new ClientError(retryResponse.status, fullUrl, retryBody);
111
+ }
112
+ return unwrapEnvelope(retryBody) as T;
113
+ }
114
+
115
+ if (!RETRYABLE_STATUSES.includes(response.status) || attempt >= maxRetries) {
116
+ const body = await parseBody(response);
117
+ if (response.status >= 400) {
118
+ throw new ClientError(response.status, fullUrl, body);
119
+ }
120
+ return unwrapEnvelope(body) as T;
121
+ }
122
+
123
+ const retryAfter = parseRetryAfter(response.headers.get('Retry-After'));
124
+ const waitMs = retryAfter ?? addJitter(delay);
125
+ await sleep(waitMs);
126
+ delay = Math.min(delay * 2, 30_000);
127
+ } catch (error) {
128
+ if (error instanceof ClientError) throw error;
129
+ lastError = error instanceof Error ? error : new Error(String(error));
130
+ if (attempt >= maxRetries) throw lastError;
131
+ const waitMs = addJitter(delay);
132
+ await sleep(waitMs);
133
+ delay = Math.min(delay * 2, 30_000);
134
+ }
135
+ }
136
+
137
+ throw lastError ?? new Error(\`All retries exhausted for \${fullUrl}\`);
138
+ };
139
+
140
+ /**
141
+ * Xema platform services wrap every 2xx payload in a { data: T } envelope via
142
+ * the global ResponseEnvelopeInterceptor (platform-common). Generated types
143
+ * describe the INNER T (controllers declare their return type as T, not
144
+ * { data: T }), so the client must peel data to keep the static types and the
145
+ * wire shape consistent — otherwise consumers read fields off the envelope and
146
+ * get undefined.
147
+ *
148
+ * Only 2xx bodies reach this helper: non-2xx responses are surfaced as a thrown
149
+ * ClientError before unwrap, and error envelopes are emitted by the
150
+ * GlobalExceptionFilter, never wrapped in { data }. Paginated lists arrive as
151
+ * { data: T[], pagination: {...} }; consumers read both fields, so that envelope
152
+ * is kept intact (detected by the presence of pagination). Any body without a
153
+ * top-level data field is returned as-is.
154
+ */
155
+ function unwrapEnvelope(body: unknown): unknown {
156
+ if (
157
+ body === null ||
158
+ typeof body !== 'object' ||
159
+ Array.isArray(body) ||
160
+ !('data' in body)
161
+ ) {
162
+ return body;
163
+ }
164
+ const envelope = body as { data: unknown; pagination?: unknown };
165
+ if ('pagination' in envelope) {
166
+ return envelope;
167
+ }
168
+ return envelope.data;
169
+ }
170
+
171
+ async function parseBody(response: Response): Promise<unknown> {
172
+ const contentType = response.headers.get('content-type');
173
+ if (contentType?.includes('application/json')) {
174
+ return response.json();
175
+ }
176
+ if (response.status === 204) {
177
+ return undefined;
178
+ }
179
+ return response.text();
180
+ }
181
+
182
+ function parseRetryAfter(value: string | null): number | undefined {
183
+ if (!value) return undefined;
184
+ const seconds = Number(value);
185
+ if (!isNaN(seconds) && seconds >= 0) return seconds * 1000;
186
+ const date = new Date(value);
187
+ if (!isNaN(date.getTime())) return Math.max(0, date.getTime() - Date.now());
188
+ return undefined;
189
+ }
190
+
191
+ function addJitter(delay: number): number {
192
+ return delay + (Math.random() * 2 - 1) * delay * 0.25;
193
+ }
194
+
195
+ function sleep(ms: number): Promise<void> {
196
+ return new Promise(resolve => setTimeout(resolve, ms));
197
+ }
198
+
199
+ export default customFetch;
200
+ `;
@@ -0,0 +1,422 @@
1
+ import { dirname, join } from 'node:path';
2
+
3
+ import type { TestingModuleBuilder } from '@nestjs/testing';
4
+
5
+ /**
6
+ * Layer 2 of the DI-stub strategy: convention stubs applied to every service.
7
+ *
8
+ * Each token is resolved DYNAMICALLY from the service's own `node_modules`
9
+ * (or, for `PrismaService`, the conventional `src/prisma/prisma.service`
10
+ * path) so the generator stays decoupled — it declares no dependency on
11
+ * identity-client / event-hub-client / platform-common / the service's prisma
12
+ * module. A token that does not resolve is simply skipped (the service does
13
+ * not use it). A resolved token that is not actually in the DI graph yields a
14
+ * harmless unused override.
15
+ *
16
+ * This is NOT a heuristic: the conventional prisma path either resolves to a
17
+ * real module or it does not. When a service's wiring needs something beyond
18
+ * this baseline, it supplies an `openapi.overrides.ts` (Layer 3).
19
+ */
20
+
21
+ /** Resolve a module export from the service's resolution root; undefined if absent. */
22
+ function tryResolveExport(
23
+ fromDir: string,
24
+ request: string,
25
+ exportName: string,
26
+ ): unknown {
27
+ try {
28
+ const resolved = require.resolve(request, { paths: [fromDir] });
29
+
30
+ const mod = require(resolved) as Record<string, unknown>;
31
+ return mod[exportName];
32
+ } catch {
33
+ return undefined;
34
+ }
35
+ }
36
+
37
+ /** The service's package.json `name` (used only for dummy identity registration
38
+ * fields that extraction never dials); falls back to the directory basename. */
39
+ function readServiceName(serviceDir: string): string {
40
+ try {
41
+ const pkg = require(join(serviceDir, 'package.json')) as { name?: string };
42
+ if (typeof pkg.name === 'string' && pkg.name.length > 0) {
43
+ return pkg.name;
44
+ }
45
+ } catch {
46
+ // no readable package.json — fall through to the basename.
47
+ }
48
+ return (
49
+ dirname(join(serviceDir, 'x')).split(/[\\/]/).pop() ?? 'openapi-service'
50
+ );
51
+ }
52
+
53
+ /**
54
+ * The two accessors `@xemahq/biome-database-nest` declares as returning a
55
+ * CLIENT rather than a query result (`prisma-service-factory.ts`:
56
+ * `asSystem(): TClient`, `forOrg(orgId): TClient`).
57
+ *
58
+ * They must return the stub itself. The proxy's `apply` trap answers every
59
+ * call with `Promise.resolve([])`, which is right for a query but wrong for
60
+ * an accessor — `prisma.asSystem()` yielded a Promise, so every consumer that
61
+ * reads a property off the returned client saw `undefined`. That is not a
62
+ * cosmetic mismatch: `OrgErasureModule`'s boot gate reads
63
+ * `client._runtimeDataModel.models` off it and refuses to start, which broke
64
+ * OpenAPI extraction for EVERY service using org-erasure.
65
+ *
66
+ * A closed set from a typed interface, not a growing list of guessed names.
67
+ */
68
+ const CLIENT_ACCESSORS: ReadonlySet<string> = new Set(['asSystem', 'forOrg']);
69
+
70
+ /**
71
+ * The real model list, read from the service's GENERATED Prisma client
72
+ * without instantiating it or touching a database.
73
+ *
74
+ * `OrgErasureModule` asserts at boot that its `orgField` matches at least one
75
+ * model — a participant matching none would answer every erasure
76
+ * `{totalDeleted: 0}` and have it recorded as success. Handing the extraction
77
+ * an empty or fabricated model list would either defeat that gate or trip it
78
+ * for the wrong reason, so the stub carries the schema the service actually
79
+ * generated.
80
+ */
81
+ function readGeneratedModels(
82
+ serviceDir: string,
83
+ ): Record<string, { readonly fields: readonly { readonly name: string }[] }> {
84
+ const client = tryResolveExport(serviceDir, '@prisma/client', 'Prisma') as
85
+ | { dmmf?: { datamodel?: { models?: readonly PrismaDmmfModel[] } } }
86
+ | undefined;
87
+ const models = client?.dmmf?.datamodel?.models;
88
+ if (models === undefined) {
89
+ throw new Error(
90
+ `[openapi-stubs] ${serviceDir} has a PrismaService but its generated ` +
91
+ `Prisma client exposes no dmmf datamodel. Run \`pnpm prisma:generate\` ` +
92
+ `in that service before extracting — the extraction cannot fabricate a ` +
93
+ `schema, and a service using OrgErasureModule asserts against it at boot.`,
94
+ );
95
+ }
96
+ return Object.fromEntries(models.map((model) => [model.name, model]));
97
+ }
98
+
99
+ interface PrismaDmmfModel {
100
+ readonly name: string;
101
+ readonly fields: readonly { readonly name: string }[];
102
+ }
103
+
104
+ /** The defensive Prisma stub the hand-written scripts used: every access is a no-op thenable-free proxy. */
105
+ function createPrismaStub(serviceDir: string): unknown {
106
+ const runtimeDataModel = { models: readGeneratedModels(serviceDir) };
107
+ const handler: ProxyHandler<(...args: unknown[]) => unknown> = {
108
+ get: (_t, prop) => {
109
+ if (prop === 'then') {
110
+ return undefined;
111
+ }
112
+ if (prop === '_runtimeDataModel') {
113
+ return runtimeDataModel;
114
+ }
115
+ if (prop === '$connect' || prop === '$disconnect' || prop === '$on') {
116
+ return () => Promise.resolve();
117
+ }
118
+ if (typeof prop === 'string' && CLIENT_ACCESSORS.has(prop)) {
119
+ return () => stub;
120
+ }
121
+ return new Proxy(() => Promise.resolve([]), handler);
122
+ },
123
+ apply: () => Promise.resolve([]),
124
+ };
125
+ const stub: unknown = new Proxy({} as never, handler);
126
+ return stub;
127
+ }
128
+
129
+ const IDENTITY_STUB = {
130
+ waitForReady: () => Promise.resolve(true),
131
+ getAccessToken: () => Promise.resolve('dummy-token'),
132
+ getKeycloakBaseUrl: () => 'http://localhost:8180',
133
+ getAllowedRealms: () => ['master'],
134
+ getClientId: () => 'openapi-extraction',
135
+ };
136
+
137
+ const EVENT_HUB_BOOTSTRAP_STUB = {
138
+ onModuleInit: async () => {},
139
+ onModuleDestroy: async () => {},
140
+ isEventHubRegistered: () => true,
141
+ waitForRegistration: () => Promise.resolve(true),
142
+ };
143
+
144
+ const EVENT_HUB_CLIENT_STUB = {
145
+ getAuthInfo: () => ({
146
+ jwksUrl: 'http://localhost:8180/realms/dummy/protocol/openid-connect/certs',
147
+ issuer: 'http://localhost:8180/realms/dummy',
148
+ }),
149
+ };
150
+
151
+ /**
152
+ * `@nestjs/core` provider CLASSES that a foreign-core dependency injects and that
153
+ * are only STORED (or used at a bootstrap phase extraction SKIPS), never during
154
+ * the metadata-only DI compile — so an inert cross-store stand-in is safe:
155
+ *
156
+ * - `ModuleRef` — `@xemahq/xema-service-nest` injects it into the
157
+ * `ServiceRegistryModule` options factory to LAZILY resolve
158
+ * `IdentityBootstrapService` for request-time service-token minting (a genuine
159
+ * module-init value cycle forbids injecting the service directly). The closure
160
+ * is never invoked during extraction.
161
+ * - `Reflector` — `@nestjs/schedule`'s `SchedulerMetadataAccessor` injects it;
162
+ * it is read only by the scheduler's `onApplicationBootstrap` scan, which
163
+ * extraction never runs. The api-surface scan reads decorator metadata through
164
+ * the global `reflect-metadata` polyfill, NOT a Nest `Reflector`, so stubbing
165
+ * this token does not affect classification.
166
+ */
167
+ const BRIDGED_CORE_PROVIDERS = ['ModuleRef', 'Reflector'] as const;
168
+
169
+ /**
170
+ * Dependencies that inject an `@nestjs/core` provider resolved from their OWN
171
+ * peer copy of `@nestjs/core`. In a multi-`@nestjs/core` checkout each resolves a
172
+ * copy DISTINCT from the service's, so the provider CLASS they inject mismatches
173
+ * the one the app's module registered under.
174
+ */
175
+ const FOREIGN_CORE_INJECTORS = [
176
+ '@xemahq/xema-service-nest',
177
+ '@xemahq/service-registry-nest',
178
+ '@nestjs/schedule',
179
+ ];
180
+
181
+ /**
182
+ * Apply the convention stubs to a `Test.createTestingModule(...)` builder.
183
+ * Returns the same builder for chaining.
184
+ */
185
+ export function applyDefaultOpenApiStubs(
186
+ builder: TestingModuleBuilder,
187
+ serviceDir: string,
188
+ ): TestingModuleBuilder {
189
+ // PrismaService — conventional service-local path.
190
+ const PrismaService = tryResolveExport(
191
+ serviceDir,
192
+ join(serviceDir, 'src', 'prisma', 'prisma.service'),
193
+ 'PrismaService',
194
+ );
195
+ if (PrismaService) {
196
+ builder
197
+ .overrideProvider(PrismaService as never)
198
+ .useValue(createPrismaStub(serviceDir));
199
+ }
200
+
201
+ // Identity bootstrap.
202
+ const IdentityBootstrapService = tryResolveExport(
203
+ serviceDir,
204
+ '@xemahq/identity-client',
205
+ 'IdentityBootstrapService',
206
+ );
207
+ if (IdentityBootstrapService) {
208
+ builder
209
+ .overrideProvider(IdentityBootstrapService as never)
210
+ .useValue(IDENTITY_STUB);
211
+ }
212
+
213
+ // Identity bootstrap CONFIG. `IdentityBootstrapModule.forRootAsync`'s options
214
+ // factory eagerly resolves `identity-api` from the service registry
215
+ // (`resolveHttpUrlEagerly`) at DI-compile time — which cannot succeed in an
216
+ // extraction sandbox (no live registry). Overriding the resolved config token
217
+ // skips that factory entirely; the `IdentityBootstrapService` that consumes it
218
+ // is already stubbed above, so these values are never read.
219
+ const IDENTITY_BOOTSTRAP_CONFIG = tryResolveExport(
220
+ serviceDir,
221
+ '@xemahq/identity-client',
222
+ 'IDENTITY_BOOTSTRAP_CONFIG',
223
+ );
224
+ if (IDENTITY_BOOTSTRAP_CONFIG) {
225
+ builder.overrideProvider(IDENTITY_BOOTSTRAP_CONFIG as never).useValue({
226
+ identityApiUrl: 'http://localhost:3053',
227
+ identityApiInternalToken: 'dummy-token',
228
+ serviceName: readServiceName(serviceDir),
229
+ serviceDisplayName: readServiceName(serviceDir),
230
+ });
231
+ }
232
+
233
+ // Event hub.
234
+ const EventHubBootstrapService = tryResolveExport(
235
+ serviceDir,
236
+ '@xemahq/event-hub-client',
237
+ 'EventHubBootstrapService',
238
+ );
239
+ if (EventHubBootstrapService) {
240
+ builder
241
+ .overrideProvider(EventHubBootstrapService as never)
242
+ .useValue(EVENT_HUB_BOOTSTRAP_STUB);
243
+ }
244
+ const EventHubClient = tryResolveExport(
245
+ serviceDir,
246
+ '@xemahq/event-hub-client',
247
+ 'EventHubClient',
248
+ );
249
+ if (EventHubClient) {
250
+ builder
251
+ .overrideProvider(EventHubClient as never)
252
+ .useValue(EVENT_HUB_CLIENT_STUB);
253
+ }
254
+
255
+ // Service-token guard (let every route through during extraction).
256
+ const ServiceTokenGuard = tryResolveExport(
257
+ serviceDir,
258
+ '@xemahq/platform-common',
259
+ 'ServiceTokenGuard',
260
+ );
261
+ if (ServiceTokenGuard) {
262
+ builder
263
+ .overrideGuard(ServiceTokenGuard as never)
264
+ .useValue({ canActivate: () => true });
265
+ }
266
+
267
+ // XemaRuntimeScanner — stub so it never runs during extraction (manifest
268
+ // emission / capability auto-register are irrelevant to spec extraction).
269
+ const XemaRuntimeScanner = tryResolveExport(
270
+ serviceDir,
271
+ '@xemahq/xema-decorators',
272
+ 'XemaRuntimeScanner',
273
+ );
274
+ if (XemaRuntimeScanner) {
275
+ builder.overrideProvider(XemaRuntimeScanner as never).useValue({
276
+ onApplicationBootstrap: async () => {},
277
+ });
278
+ }
279
+
280
+ // DiscoveryModule instantiates `DiscoveryService` EAGERLY (it is a provider,
281
+ // not lazy). Under local cross-repo dev the symlinked `@xemahq/xema-decorators`
282
+ // resolves a DIFFERENT `@nestjs/core` store than the service, so the
283
+ // DiscoveryService token built into the app may carry a `ModulesContainer`
284
+ // the service-store testing container can't resolve. Override it (from both
285
+ // the service's and the decorators' core stores) with an inert stub: the
286
+ // token actually in the graph is replaced; the other is a harmless no-op
287
+ // (CI's single-core per-repo install has only one). Spec extraction never
288
+ // needs it — the surface scan builds a fresh DiscoveryService from the app's
289
+ // real ModulesContainer afterwards.
290
+ //
291
+ // NOTE (local-dev only): other infra modules that eagerly inject core
292
+ // internals under a FOREIGN `@nestjs/core` hit the same multi-store mismatch
293
+ // locally — e.g. `@nestjs/schedule`'s `DiscoveryModule` provides its own
294
+ // `DiscoveryService` (→ `ModulesContainer`). We therefore override
295
+ // `DiscoveryService` in EVERY reachable core store (the service's, the
296
+ // decorators', and each foreign-core injector's). The exported core PROVIDER
297
+ // tokens those foreign modules inject (`Reflector`, `ModuleRef`) are bridged
298
+ // separately by {@link applyCrossStoreCoreProviderBridge}. The definitive fix
299
+ // remains a single `@nestjs/core` (which CI's standalone per-repo install
300
+ // gives); this is the aggregator/local-dev compensation.
301
+ const discoveryStub = { getProviders: () => [], getControllers: () => [] };
302
+ const coreRoots = new Set<string>([serviceDir]);
303
+ for (const pkg of ['@xemahq/xema-decorators', ...FOREIGN_CORE_INJECTORS]) {
304
+ try {
305
+ coreRoots.add(dirname(require.resolve(pkg, { paths: [serviceDir] })));
306
+ } catch {
307
+ // package not resolvable from this service — nothing to add for it.
308
+ }
309
+ }
310
+ const seenDiscovery = new Set<unknown>();
311
+ for (const root of coreRoots) {
312
+ const DiscoveryService = tryResolveExport(
313
+ root,
314
+ '@nestjs/core',
315
+ 'DiscoveryService',
316
+ );
317
+ if (DiscoveryService && !seenDiscovery.has(DiscoveryService)) {
318
+ seenDiscovery.add(DiscoveryService);
319
+ builder
320
+ .overrideProvider(DiscoveryService as never)
321
+ .useValue(discoveryStub);
322
+ }
323
+ }
324
+
325
+ // Service registry — the permissive build-time stub.
326
+ const SERVICE_REGISTRY_TOKEN = tryResolveExport(
327
+ serviceDir,
328
+ '@xemahq/service-registry-nest',
329
+ 'SERVICE_REGISTRY_TOKEN',
330
+ );
331
+ const createOpenApiStubRegistry = tryResolveExport(
332
+ serviceDir,
333
+ '@xemahq/service-registry-nest',
334
+ 'createOpenApiStubRegistry',
335
+ ) as (() => unknown) | undefined;
336
+ if (SERVICE_REGISTRY_TOKEN && createOpenApiStubRegistry) {
337
+ builder
338
+ .overrideProvider(SERVICE_REGISTRY_TOKEN as never)
339
+ .useValue(createOpenApiStubRegistry());
340
+ }
341
+
342
+ applyCrossStoreCoreProviderBridge(builder, serviceDir);
343
+
344
+ return builder;
345
+ }
346
+
347
+ /**
348
+ * Bridge cross-store `@nestjs/core` provider tokens across a dual-`@nestjs/core`
349
+ * checkout.
350
+ *
351
+ * A core-provider inject (`ModuleRef`, `Reflector`, …) is a lookup keyed by the
352
+ * provider CLASS OBJECT. NestJS registers one instance per module under the class
353
+ * from the `@nestjs/core` that INSTANTIATED the module — the SERVICE's copy, since
354
+ * `Test`/`NestFactory` are resolved from `serviceDir`. In a cross-repo / workspace
355
+ * checkout a symlinked dependency (e.g. `@xemahq/xema-service-nest`,
356
+ * `@nestjs/schedule`) resolves its OWN `@nestjs/core` via realpath, so the class it
357
+ * bakes into an `inject` array is a DIFFERENT object than the app's →
358
+ * `providers.has(Token)` is false → construction fails under BOTH `Test.compile()`
359
+ * AND `NestFactory` (this is a token-identity fault, not a boot-method one).
360
+ * Production and standalone-CI are single-`@nestjs/core`, so the class objects
361
+ * coincide and this never bites there — hence the bridge is a no-op when the
362
+ * foreign copy IS the service's copy.
363
+ *
364
+ * The cross-store token is resolved to an inert stand-in that FAIL-FASTS if any of
365
+ * its methods are actually called (they never are during a metadata-only compile).
366
+ * Every OTHER unresolved token returns `undefined`, so the `TestingInjector`
367
+ * rethrows as normal — no broad silent mocking, no swallowed dependency error.
368
+ * This extends `service-nest.ts`'s dual-store strategy to the core providers that
369
+ * cannot be redirected by resolving Nest from the service dir (their tokens are
370
+ * baked into module metadata at `AppModule` import time, before the generator gets
371
+ * control).
372
+ */
373
+ function applyCrossStoreCoreProviderBridge(
374
+ builder: TestingModuleBuilder,
375
+ serviceDir: string,
376
+ ): void {
377
+ const serviceProviders = BRIDGED_CORE_PROVIDERS.map((name) =>
378
+ tryResolveExport(serviceDir, '@nestjs/core', name),
379
+ );
380
+ const crossStore = new Set<unknown>();
381
+ for (const pkg of FOREIGN_CORE_INJECTORS) {
382
+ let pkgDir: string;
383
+ try {
384
+ pkgDir = dirname(require.resolve(pkg, { paths: [serviceDir] }));
385
+ } catch {
386
+ continue; // service does not depend on this package.
387
+ }
388
+ BRIDGED_CORE_PROVIDERS.forEach((name, i) => {
389
+ const foreign = tryResolveExport(pkgDir, '@nestjs/core', name);
390
+ // Only a copy DISTINCT from the service's own is a hazard; a single-core
391
+ // install resolves to the same class object → nothing to bridge.
392
+ if (foreign && foreign !== serviceProviders[i]) {
393
+ crossStore.add(foreign);
394
+ }
395
+ });
396
+ }
397
+ if (crossStore.size === 0) {
398
+ return; // single `@nestjs/core` end-to-end — every inject resolves natively.
399
+ }
400
+ // An inert stand-in. Property READS return a fail-fast function (so NestJS's
401
+ // benign resolution-time introspection — e.g. the `.then` thenable probe, which
402
+ // returns `undefined` — does not throw), and only CALLING a method fail-fasts.
403
+ const failFast = (): never => {
404
+ throw new Error(
405
+ 'api-client-generator: a cross-store @nestjs/core provider stub was invoked ' +
406
+ 'during OpenAPI extraction. It exists only so a provider can be CONSTRUCTED; ' +
407
+ 'it must never be called (extraction skips app.init() and serves no request).',
408
+ );
409
+ };
410
+ const inert = new Proxy(
411
+ {},
412
+ {
413
+ get(_target, prop): unknown {
414
+ if (prop === 'then' || typeof prop === 'symbol') {
415
+ return undefined; // thenable / well-known-symbol probes stay benign.
416
+ }
417
+ return failFast;
418
+ },
419
+ },
420
+ );
421
+ builder.useMocker((token) => (crossStore.has(token) ? inert : undefined));
422
+ }