@zmdb/app 1.0.0-beta.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 (108) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +27 -0
  3. package/dist/application.d.ts +45 -0
  4. package/dist/application.d.ts.map +1 -0
  5. package/dist/application.js +186 -0
  6. package/dist/application.js.map +1 -0
  7. package/dist/commands/index.d.ts +31 -0
  8. package/dist/commands/index.d.ts.map +1 -0
  9. package/dist/commands/index.js +338 -0
  10. package/dist/commands/index.js.map +1 -0
  11. package/dist/cqrs/index.d.ts +47 -0
  12. package/dist/cqrs/index.d.ts.map +1 -0
  13. package/dist/cqrs/index.js +76 -0
  14. package/dist/cqrs/index.js.map +1 -0
  15. package/dist/data/index.d.ts +12 -0
  16. package/dist/data/index.d.ts.map +1 -0
  17. package/dist/data/index.js +25 -0
  18. package/dist/data/index.js.map +1 -0
  19. package/dist/di/index.d.ts +44 -0
  20. package/dist/di/index.d.ts.map +1 -0
  21. package/dist/di/index.js +136 -0
  22. package/dist/di/index.js.map +1 -0
  23. package/dist/events/index.d.ts +46 -0
  24. package/dist/events/index.d.ts.map +1 -0
  25. package/dist/events/index.js +181 -0
  26. package/dist/events/index.js.map +1 -0
  27. package/dist/health/index.d.ts +35 -0
  28. package/dist/health/index.d.ts.map +1 -0
  29. package/dist/health/index.js +14 -0
  30. package/dist/health/index.js.map +1 -0
  31. package/dist/index.d.ts +11 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +22 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/lifecycle.d.ts +21 -0
  36. package/dist/lifecycle.d.ts.map +1 -0
  37. package/dist/lifecycle.js +52 -0
  38. package/dist/lifecycle.js.map +1 -0
  39. package/dist/messaging/index.d.ts +176 -0
  40. package/dist/messaging/index.d.ts.map +1 -0
  41. package/dist/messaging/index.js +442 -0
  42. package/dist/messaging/index.js.map +1 -0
  43. package/dist/messaging/transport-kit.d.ts +22 -0
  44. package/dist/messaging/transport-kit.d.ts.map +1 -0
  45. package/dist/messaging/transport-kit.js +174 -0
  46. package/dist/messaging/transport-kit.js.map +1 -0
  47. package/dist/modules/index.d.ts +52 -0
  48. package/dist/modules/index.d.ts.map +1 -0
  49. package/dist/modules/index.js +351 -0
  50. package/dist/modules/index.js.map +1 -0
  51. package/dist/modules/lifecycle-instances.d.ts +4 -0
  52. package/dist/modules/lifecycle-instances.d.ts.map +1 -0
  53. package/dist/modules/lifecycle-instances.js +19 -0
  54. package/dist/modules/lifecycle-instances.js.map +1 -0
  55. package/dist/modules/runtime.d.ts +18 -0
  56. package/dist/modules/runtime.d.ts.map +1 -0
  57. package/dist/modules/runtime.js +8 -0
  58. package/dist/modules/runtime.js.map +1 -0
  59. package/dist/observability/index.d.ts +27 -0
  60. package/dist/observability/index.d.ts.map +1 -0
  61. package/dist/observability/index.js +145 -0
  62. package/dist/observability/index.js.map +1 -0
  63. package/dist/observability/propagation.d.ts +10 -0
  64. package/dist/observability/propagation.d.ts.map +1 -0
  65. package/dist/observability/propagation.js +96 -0
  66. package/dist/observability/propagation.js.map +1 -0
  67. package/dist/observability/types.d.ts +66 -0
  68. package/dist/observability/types.d.ts.map +1 -0
  69. package/dist/observability/types.js +10 -0
  70. package/dist/observability/types.js.map +1 -0
  71. package/dist/polyfill.d.ts +2 -0
  72. package/dist/polyfill.d.ts.map +1 -0
  73. package/dist/polyfill.js +21 -0
  74. package/dist/polyfill.js.map +1 -0
  75. package/dist/state/fixtures.d.ts +10 -0
  76. package/dist/state/fixtures.d.ts.map +1 -0
  77. package/dist/state/fixtures.js +14 -0
  78. package/dist/state/fixtures.js.map +1 -0
  79. package/dist/state/index.d.ts +26 -0
  80. package/dist/state/index.d.ts.map +1 -0
  81. package/dist/state/index.js +37 -0
  82. package/dist/state/index.js.map +1 -0
  83. package/dist/state-transitions.d.ts +57 -0
  84. package/dist/state-transitions.d.ts.map +1 -0
  85. package/dist/state-transitions.js +31 -0
  86. package/dist/state-transitions.js.map +1 -0
  87. package/package.json +99 -0
  88. package/src/application.ts +243 -0
  89. package/src/commands/index.ts +447 -0
  90. package/src/cqrs/index.ts +124 -0
  91. package/src/data/index.ts +33 -0
  92. package/src/di/index.ts +179 -0
  93. package/src/events/index.ts +248 -0
  94. package/src/health/index.ts +54 -0
  95. package/src/index.ts +68 -0
  96. package/src/lifecycle.ts +62 -0
  97. package/src/messaging/index.ts +668 -0
  98. package/src/messaging/transport-kit.ts +234 -0
  99. package/src/modules/index.ts +476 -0
  100. package/src/modules/lifecycle-instances.ts +23 -0
  101. package/src/modules/runtime.ts +26 -0
  102. package/src/observability/index.ts +204 -0
  103. package/src/observability/propagation.ts +107 -0
  104. package/src/observability/types.ts +68 -0
  105. package/src/polyfill.ts +24 -0
  106. package/src/state/fixtures.ts +20 -0
  107. package/src/state/index.ts +57 -0
  108. package/src/state-transitions.ts +150 -0
@@ -0,0 +1,234 @@
1
+ import type { TraceCarrier } from '../observability/index.js';
2
+ import type { MessageReply, RawMessage } from './index.js';
3
+
4
+ interface WireEnvelope extends TraceCarrier {
5
+ readonly version: 1;
6
+ readonly payload: unknown;
7
+ readonly headers: Readonly<Record<string, string>>;
8
+ readonly correlationId?: string;
9
+ readonly replyTo?: string;
10
+ }
11
+
12
+ export interface DeliveryMetadata {
13
+ readonly correlationId?: string;
14
+ readonly replyTo?: string;
15
+ }
16
+
17
+ function isRecord(value: unknown): value is Record<string, unknown> {
18
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
19
+ }
20
+
21
+ function stringRecord(value: unknown): Readonly<Record<string, string>> | undefined {
22
+ if (!isRecord(value)) {
23
+ return undefined;
24
+ }
25
+ const result: Record<string, string> = {};
26
+ for (const [key, entry] of Object.entries(value)) {
27
+ if (typeof entry !== 'string') {
28
+ return undefined;
29
+ }
30
+ result[key] = entry;
31
+ }
32
+ return result;
33
+ }
34
+
35
+ function optionalString(value: unknown): string | undefined | false {
36
+ return value === undefined || typeof value === 'string' ? value : false;
37
+ }
38
+
39
+ function encode(value: unknown, description: string): string {
40
+ let encoded: string | undefined;
41
+ try {
42
+ encoded = JSON.stringify(value);
43
+ } catch (error) {
44
+ throw new TypeError(`@zmdb/app: ${description} is not JSON-serializable`, { cause: error });
45
+ }
46
+ if (encoded === undefined) {
47
+ throw new TypeError(`@zmdb/app: ${description} is not JSON-serializable`);
48
+ }
49
+ return encoded;
50
+ }
51
+
52
+ export function encodeDelivery(
53
+ payload: unknown,
54
+ carrier: TraceCarrier | undefined,
55
+ metadata: DeliveryMetadata = {},
56
+ ): string {
57
+ if (payload === undefined) {
58
+ throw new TypeError('@zmdb/app: broker payloads cannot be undefined');
59
+ }
60
+ const envelope: WireEnvelope = {
61
+ version: 1,
62
+ payload,
63
+ headers: {},
64
+ ...(metadata.correlationId === undefined ? {} : { correlationId: metadata.correlationId }),
65
+ ...(metadata.replyTo === undefined ? {} : { replyTo: metadata.replyTo }),
66
+ ...(carrier?.traceparent === undefined ? {} : { traceparent: carrier.traceparent }),
67
+ ...(carrier?.tracestate === undefined ? {} : { tracestate: carrier.tracestate }),
68
+ };
69
+ return encode(envelope, 'broker payload');
70
+ }
71
+
72
+ function invalidDelivery(
73
+ pattern: string,
74
+ payload: unknown,
75
+ deliveryAttempt: number,
76
+ error: unknown,
77
+ metadata: DeliveryMetadata,
78
+ ): RawMessage {
79
+ return {
80
+ pattern,
81
+ payload,
82
+ headers: {},
83
+ correlationId: metadata.correlationId,
84
+ replyTo: metadata.replyTo,
85
+ deliveryAttempt,
86
+ parseError: error,
87
+ };
88
+ }
89
+
90
+ export function decodeDelivery(
91
+ pattern: string,
92
+ text: string,
93
+ deliveryAttempt: number,
94
+ metadata: DeliveryMetadata = {},
95
+ ): RawMessage {
96
+ let parsed: unknown;
97
+ try {
98
+ parsed = JSON.parse(text);
99
+ } catch (error) {
100
+ return invalidDelivery(pattern, text, deliveryAttempt, error, metadata);
101
+ }
102
+
103
+ if (!isRecord(parsed)) {
104
+ return invalidDelivery(
105
+ pattern,
106
+ parsed,
107
+ deliveryAttempt,
108
+ new TypeError('@zmdb/app: broker envelope must be an object'),
109
+ metadata,
110
+ );
111
+ }
112
+
113
+ const headers = stringRecord(parsed.headers);
114
+ const correlationId = optionalString(parsed.correlationId);
115
+ const replyTo = optionalString(parsed.replyTo);
116
+ const traceparent = optionalString(parsed.traceparent);
117
+ const tracestate = optionalString(parsed.tracestate);
118
+ if (
119
+ parsed.version !== 1 ||
120
+ !Object.hasOwn(parsed, 'payload') ||
121
+ headers === undefined ||
122
+ correlationId === false ||
123
+ replyTo === false ||
124
+ traceparent === false ||
125
+ tracestate === false
126
+ ) {
127
+ return invalidDelivery(
128
+ pattern,
129
+ Object.hasOwn(parsed, 'payload') ? parsed.payload : parsed,
130
+ deliveryAttempt,
131
+ new TypeError('@zmdb/app: invalid broker envelope'),
132
+ metadata,
133
+ );
134
+ }
135
+
136
+ return {
137
+ pattern,
138
+ payload: parsed.payload,
139
+ headers,
140
+ correlationId: metadata.correlationId ?? correlationId,
141
+ replyTo: metadata.replyTo ?? replyTo,
142
+ deliveryAttempt,
143
+ ...(traceparent === undefined ? {} : { traceparent }),
144
+ ...(tracestate === undefined ? {} : { tracestate }),
145
+ };
146
+ }
147
+
148
+ export function encodeReply(reply: MessageReply): string {
149
+ return encode(reply, 'broker reply');
150
+ }
151
+
152
+ export function decodeReply(text: string): MessageReply {
153
+ let parsed: unknown;
154
+ try {
155
+ parsed = JSON.parse(text);
156
+ } catch (error) {
157
+ throw new TypeError('@zmdb/app: broker reply is not valid JSON', { cause: error });
158
+ }
159
+ if (!isRecord(parsed) || typeof parsed.correlationId !== 'string') {
160
+ throw new TypeError('@zmdb/app: invalid broker reply envelope');
161
+ }
162
+ if (parsed.kind === 'result' && Object.hasOwn(parsed, 'payload')) {
163
+ return { kind: 'result', correlationId: parsed.correlationId, payload: parsed.payload };
164
+ }
165
+ if (parsed.kind === 'error' && typeof parsed.message === 'string') {
166
+ return { kind: 'error', correlationId: parsed.correlationId, message: parsed.message };
167
+ }
168
+ throw new TypeError('@zmdb/app: invalid broker reply envelope');
169
+ }
170
+
171
+ export type TransportErrorSink = (error: unknown) => void;
172
+
173
+ export function reportTransportError(sink: TransportErrorSink, error: unknown): void {
174
+ try {
175
+ sink(error);
176
+ } catch {
177
+ // A diagnostic sink cannot replace transport settlement or shutdown.
178
+ }
179
+ }
180
+
181
+ export class InFlight {
182
+ readonly #tasks = new Set<Promise<void>>();
183
+ readonly #onError: TransportErrorSink;
184
+ #accepting = true;
185
+
186
+ constructor(onError: TransportErrorSink) {
187
+ this.#onError = onError;
188
+ }
189
+
190
+ run(action: () => Promise<void>): Promise<void> {
191
+ if (!this.#accepting) {
192
+ return Promise.resolve();
193
+ }
194
+ let task: Promise<void>;
195
+ task = Promise.resolve()
196
+ .then(action)
197
+ .catch(error => {
198
+ reportTransportError(this.#onError, error);
199
+ })
200
+ .finally(() => {
201
+ this.#tasks.delete(task);
202
+ });
203
+ this.#tasks.add(task);
204
+ return task;
205
+ }
206
+
207
+ stop(): void {
208
+ this.#accepting = false;
209
+ }
210
+
211
+ async settled(): Promise<void> {
212
+ await Promise.all(this.#tasks);
213
+ }
214
+ }
215
+
216
+ export async function withinGrace(action: Promise<void>, graceMs: number): Promise<boolean> {
217
+ let timer: ReturnType<typeof setTimeout> | undefined;
218
+ try {
219
+ return await Promise.race([
220
+ action.then(() => true),
221
+ new Promise<false>(resolve => {
222
+ timer = setTimeout(() => resolve(false), graceMs);
223
+ }),
224
+ ]);
225
+ } finally {
226
+ if (timer !== undefined) {
227
+ clearTimeout(timer);
228
+ }
229
+ }
230
+ }
231
+
232
+ export function abortError(signal: AbortSignal): unknown {
233
+ return signal.reason ?? new Error('@zmdb/app: broker request aborted');
234
+ }
@@ -0,0 +1,476 @@
1
+ // @zmdb/app — modules & providers (epic #282, spec ./SPEC.md). @Module records a
2
+ // module graph in context.metadata; compileModule validates its declarations,
3
+ // constructs eager modules, and retains per-app handles for lazy subtrees.
4
+
5
+ import '../polyfill.js';
6
+ import {
7
+ Container,
8
+ injectionsOf,
9
+ UnresolvedTokenError,
10
+ type Constructor,
11
+ type Scope,
12
+ type Token,
13
+ } from '../di/index.js';
14
+ import { runInit } from '../lifecycle.js';
15
+ import { createLifecycleRecorder, lifecycleInstances } from './lifecycle-instances.js';
16
+ import { rememberRuntime, type CompiledController } from './runtime.js';
17
+
18
+ /** A provider binds a token to a value or a factory, with an optional scope. */
19
+ export type ProviderDef<T = unknown> =
20
+ | { readonly token: Token<T>; readonly useValue: T }
21
+ | { readonly token: Token<T>; readonly useFactory: (container: Container) => T; readonly scope?: Scope };
22
+
23
+ /** A module class (nominal — carries its definition in Symbol.metadata). */
24
+ export type ModuleClass = abstract new (...args: never[]) => unknown;
25
+
26
+ /** An inert declaration that defers one imported module's instantiation. */
27
+ export interface LazyImport {
28
+ readonly kind: 'lazy';
29
+ readonly module: ModuleClass;
30
+ }
31
+
32
+ /** The `@Module` definition. */
33
+ export interface ModuleDef {
34
+ readonly controllers?: readonly Constructor<object>[];
35
+ readonly commands?: readonly Constructor<object>[];
36
+ readonly providers?: readonly ProviderDef[];
37
+ readonly imports?: readonly (ModuleClass | LazyImport)[];
38
+ readonly exports?: readonly Token<unknown>[];
39
+ }
40
+
41
+ export type LazyStatus = 'unloaded' | 'loading' | 'loaded' | 'failed';
42
+
43
+ export interface LazyModuleHandle {
44
+ readonly name: string;
45
+ readonly status: LazyStatus;
46
+ load(): Promise<void>;
47
+ }
48
+
49
+ /** The result of compiling a module graph. */
50
+ export interface CompiledModule {
51
+ readonly container: Container;
52
+ readonly controllers: readonly object[];
53
+ readonly commands: readonly object[];
54
+ readonly lazy: readonly LazyModuleHandle[];
55
+ }
56
+
57
+ const MODULE = Symbol('zmdb.app.module');
58
+
59
+ interface ModuleMetadata {
60
+ [MODULE]?: ModuleDef;
61
+ }
62
+
63
+ interface CompilePlan {
64
+ readonly definitions: ReadonlyMap<ModuleClass, ModuleDef>;
65
+ readonly moduleOrder: readonly ModuleClass[];
66
+ readonly eagerModules: ReadonlySet<ModuleClass>;
67
+ readonly providerModules: ReadonlyMap<Token<unknown>, ModuleClass>;
68
+ readonly lazyRoots: readonly ModuleClass[];
69
+ readonly lazyOrders: ReadonlyMap<ModuleClass, readonly ModuleClass[]>;
70
+ }
71
+
72
+ const compilePlans = new WeakMap<ModuleClass, CompilePlan>();
73
+
74
+ // boundary: our @Module decorator is the only writer of the MODULE slot, so
75
+ // viewing the record through ModuleMetadata is sound (ARCHITECTURE.md §2.1).
76
+ function moduleView(metadata: DecoratorMetadata): ModuleMetadata {
77
+ return metadata;
78
+ }
79
+
80
+ /** Stage-3 class decorator: record a module definition. */
81
+ export function Module(def: ModuleDef) {
82
+ return function <T extends ModuleClass>(_target: T, context: ClassDecoratorContext<T>): void {
83
+ moduleView(context.metadata)[MODULE] = def;
84
+ };
85
+ }
86
+
87
+ /** Declare an import whose instances are constructed on first use. */
88
+ export function lazy(module: ModuleClass): LazyImport {
89
+ return { kind: 'lazy', module };
90
+ }
91
+
92
+ /** Read the module declaration written by `@Module`. */
93
+ export function moduleDefOf(moduleClass: ModuleClass): ModuleDef | undefined {
94
+ const metadata = moduleClass[Symbol.metadata];
95
+ if (metadata === undefined || metadata === null) {
96
+ return undefined;
97
+ }
98
+ return moduleView(metadata)[MODULE];
99
+ }
100
+
101
+ /**
102
+ * Validate a module graph, construct its eager modules, and retain per-app
103
+ * handles for lazy subtrees. Overrides are registered before construction and
104
+ * remain authoritative when a lazy subtree is loaded later.
105
+ */
106
+ export function compileModule(rootModule: ModuleClass, overrides: readonly ProviderDef[] = []): CompiledModule {
107
+ const plan = compilePlans.get(rootModule) ?? buildCompilePlan(rootModule);
108
+ validateInjections(plan, overrides);
109
+
110
+ const container = new Container();
111
+ const controllers: object[] = [];
112
+ const commands: object[] = [];
113
+ const recordInstance = createLifecycleRecorder(container);
114
+ for (const override of overrides) {
115
+ registerProvider(container, override, recordInstance);
116
+ }
117
+
118
+ // Preserve the original eager path: imports are in post-order, and each
119
+ // module registers its providers immediately before building its controllers.
120
+ if (plan.lazyRoots.length === 0) {
121
+ for (const moduleClass of plan.moduleOrder) {
122
+ instantiateEagerModule(
123
+ moduleClass,
124
+ plan.definitions,
125
+ container,
126
+ controllers,
127
+ commands,
128
+ overrides,
129
+ recordInstance,
130
+ );
131
+ }
132
+ return { container, controllers, commands, lazy: [] };
133
+ }
134
+
135
+ const controllerInstances = new Map<ModuleClass, Map<Constructor<object>, object>>();
136
+ const instantiated = new Set<ModuleClass>();
137
+ const pendingLoads = new Set<Promise<void>>();
138
+ let shuttingDown = false;
139
+
140
+ const instantiateModules = (order: readonly ModuleClass[]): object[] => {
141
+ const firstInstance = lifecycleInstances(container).length;
142
+ for (const moduleClass of order) {
143
+ if (instantiated.has(moduleClass)) {
144
+ continue;
145
+ }
146
+ instantiated.add(moduleClass);
147
+ const def = plan.definitions.get(moduleClass);
148
+ for (const provider of def?.providers ?? []) {
149
+ if (!isOverridden(overrides, provider)) {
150
+ registerProvider(container, provider, recordInstance);
151
+ }
152
+ }
153
+ const instances = new Map<Constructor<object>, object>();
154
+ controllerInstances.set(moduleClass, instances);
155
+ for (const Controller of def?.controllers ?? []) {
156
+ const instance = container.build(Controller);
157
+ recordInstance(instance);
158
+ instances.set(Controller, instance);
159
+ controllers.push(instance);
160
+ }
161
+ for (const Command of def?.commands ?? []) {
162
+ const instance = container.build(Command);
163
+ recordInstance(instance);
164
+ commands.push(instance);
165
+ }
166
+ }
167
+ return lifecycleInstances(container).slice(firstInstance);
168
+ };
169
+
170
+ const eagerOrder = plan.moduleOrder.filter(module => plan.eagerModules.has(module));
171
+ instantiateModules(eagerOrder);
172
+
173
+ const lazyHandles: LazyModuleHandle[] = [];
174
+ const handleByModule = new Map<ModuleClass, LazyModuleHandle>();
175
+
176
+ const createLazyHandle = (name: string, wire: () => object[]): LazyModuleHandle => {
177
+ let status: LazyStatus = 'unloaded';
178
+ let inFlight: Promise<void> | undefined;
179
+ let failure: unknown;
180
+
181
+ return {
182
+ name,
183
+ get status(): LazyStatus {
184
+ return status;
185
+ },
186
+ load(): Promise<void> {
187
+ if (status === 'loaded') {
188
+ return Promise.resolve();
189
+ }
190
+ if (status === 'failed') {
191
+ return Promise.reject(failure);
192
+ }
193
+ if (shuttingDown) {
194
+ return Promise.reject(new Error('@zmdb/app: application is shutting down'));
195
+ }
196
+ if (inFlight !== undefined) {
197
+ return inFlight;
198
+ }
199
+
200
+ status = 'loading';
201
+ let instances: object[];
202
+ try {
203
+ instances = wire();
204
+ } catch (error) {
205
+ failure = error;
206
+ status = 'failed';
207
+ return Promise.reject(error);
208
+ }
209
+
210
+ const load = runInit(instances).then(
211
+ () => {
212
+ status = 'loaded';
213
+ },
214
+ error => {
215
+ failure = error;
216
+ status = 'failed';
217
+ throw error;
218
+ },
219
+ );
220
+ inFlight = load;
221
+ pendingLoads.add(load);
222
+ void load.then(
223
+ () => {
224
+ inFlight = undefined;
225
+ pendingLoads.delete(load);
226
+ },
227
+ () => {
228
+ inFlight = undefined;
229
+ pendingLoads.delete(load);
230
+ },
231
+ );
232
+ return load;
233
+ },
234
+ };
235
+ };
236
+
237
+ for (const root of plan.lazyRoots) {
238
+ const loadOrder = plan.lazyOrders.get(root) ?? [];
239
+ const handle = createLazyHandle(root.name, () => instantiateModules(loadOrder));
240
+ lazyHandles.push(handle);
241
+ for (const moduleClass of loadOrder) {
242
+ if (!handleByModule.has(moduleClass)) {
243
+ handleByModule.set(moduleClass, handle);
244
+ }
245
+ }
246
+ }
247
+
248
+ const controllerBindings: CompiledController[] = [];
249
+ for (const moduleClass of plan.moduleOrder) {
250
+ const def = plan.definitions.get(moduleClass);
251
+ if (def === undefined) {
252
+ continue;
253
+ }
254
+ if (plan.eagerModules.has(moduleClass)) {
255
+ const instances = controllerInstances.get(moduleClass);
256
+ for (const Controller of def.controllers ?? []) {
257
+ const instance = instances?.get(Controller);
258
+ if (instance !== undefined) {
259
+ controllerBindings.push({ kind: 'eager', controller: instance });
260
+ }
261
+ }
262
+ continue;
263
+ }
264
+ const handle = handleByModule.get(moduleClass);
265
+ if (handle === undefined) {
266
+ continue;
267
+ }
268
+ for (const Controller of def.controllers ?? []) {
269
+ controllerBindings.push({
270
+ kind: 'deferred',
271
+ controller: Controller,
272
+ instance: async (): Promise<object> => {
273
+ await handle.load();
274
+ const instance = controllerInstances.get(moduleClass)?.get(Controller);
275
+ if (instance === undefined) {
276
+ throw new Error(`@zmdb/app: lazy controller ${Controller.name} was not constructed`);
277
+ }
278
+ return instance;
279
+ },
280
+ });
281
+ }
282
+ }
283
+
284
+ const compiled: CompiledModule = { container, controllers, commands, lazy: lazyHandles };
285
+ rememberRuntime(compiled, {
286
+ controllers: controllerBindings,
287
+ beginShutdown: () => {
288
+ shuttingDown = true;
289
+ },
290
+ waitForLoads: async () => {
291
+ await Promise.allSettled(pendingLoads);
292
+ },
293
+ });
294
+ return compiled;
295
+ }
296
+
297
+ function buildCompilePlan(rootModule: ModuleClass): CompilePlan {
298
+ const definitions = new Map<ModuleClass, ModuleDef>();
299
+ const moduleOrder: ModuleClass[] = [];
300
+ const visiting = new Set<ModuleClass>();
301
+ const visited = new Set<ModuleClass>();
302
+ const eagerModules = new Set<ModuleClass>();
303
+ const providerModules = new Map<Token<unknown>, ModuleClass>();
304
+
305
+ const collect = (moduleClass: ModuleClass): void => {
306
+ if (visited.has(moduleClass)) {
307
+ return;
308
+ }
309
+ if (visiting.has(moduleClass)) {
310
+ const path = [...visiting];
311
+ const repeatedAt = path.indexOf(moduleClass);
312
+ const cycle = [...path.slice(repeatedAt), moduleClass].map(module => module.name).join(' -> ');
313
+ throw new Error(`@zmdb/app: import cycle in the module graph: ${cycle}`);
314
+ }
315
+ visiting.add(moduleClass);
316
+ const def = moduleDefOf(moduleClass);
317
+ if (def !== undefined) {
318
+ definitions.set(moduleClass, def);
319
+ for (const imported of def.imports ?? []) {
320
+ collect(typeof imported === 'function' ? imported : imported.module);
321
+ }
322
+ }
323
+ visiting.delete(moduleClass);
324
+ visited.add(moduleClass);
325
+ moduleOrder.push(moduleClass);
326
+ };
327
+
328
+ const markEager = (moduleClass: ModuleClass): void => {
329
+ if (eagerModules.has(moduleClass)) {
330
+ return;
331
+ }
332
+ eagerModules.add(moduleClass);
333
+ for (const imported of definitions.get(moduleClass)?.imports ?? []) {
334
+ if (typeof imported === 'function') {
335
+ markEager(imported);
336
+ }
337
+ }
338
+ };
339
+
340
+ collect(rootModule);
341
+ markEager(rootModule);
342
+
343
+ for (const moduleClass of moduleOrder) {
344
+ for (const provider of definitions.get(moduleClass)?.providers ?? []) {
345
+ const previous = providerModules.get(provider.token);
346
+ if (previous !== undefined && previous !== moduleClass) {
347
+ throw new Error(
348
+ `@zmdb/app: duplicate provider token "${provider.token.description}" in ${previous.name} and ${moduleClass.name}`,
349
+ );
350
+ }
351
+ providerModules.set(provider.token, moduleClass);
352
+ }
353
+ }
354
+
355
+ const lazyRoots: ModuleClass[] = [];
356
+ const seenLazyRoots = new Set<ModuleClass>();
357
+ for (const moduleClass of moduleOrder) {
358
+ if (!eagerModules.has(moduleClass)) {
359
+ continue;
360
+ }
361
+ for (const imported of definitions.get(moduleClass)?.imports ?? []) {
362
+ if (typeof imported !== 'function' && !eagerModules.has(imported.module) && !seenLazyRoots.has(imported.module)) {
363
+ seenLazyRoots.add(imported.module);
364
+ lazyRoots.push(imported.module);
365
+ }
366
+ }
367
+ }
368
+
369
+ const lazyOrders = new Map<ModuleClass, readonly ModuleClass[]>();
370
+ for (const root of lazyRoots) {
371
+ lazyOrders.set(root, lazySubtreeOrder(root, definitions, moduleOrder, eagerModules));
372
+ }
373
+
374
+ const plan: CompilePlan = {
375
+ definitions,
376
+ moduleOrder,
377
+ eagerModules,
378
+ providerModules,
379
+ lazyRoots,
380
+ lazyOrders,
381
+ };
382
+ compilePlans.set(rootModule, plan);
383
+ return plan;
384
+ }
385
+
386
+ function validateInjections(plan: CompilePlan, overrides: readonly ProviderDef[]): void {
387
+ for (const moduleClass of plan.moduleOrder) {
388
+ const def = plan.definitions.get(moduleClass);
389
+ const injectables = [...(def?.controllers ?? []), ...(def?.commands ?? [])];
390
+ for (const Injectable of injectables) {
391
+ for (const injection of injectionsOf(Injectable)) {
392
+ if (overrides.some(override => override.token === injection.token)) {
393
+ continue;
394
+ }
395
+ const providerModule = plan.providerModules.get(injection.token);
396
+ if (providerModule === undefined) {
397
+ throw new UnresolvedTokenError(injection.token.description);
398
+ }
399
+ if (plan.eagerModules.has(moduleClass) && !plan.eagerModules.has(providerModule)) {
400
+ throw new Error(
401
+ `@zmdb/app: eager class ${Injectable.name} injects lazy-only token ` +
402
+ `"${injection.token.description}" from ${providerModule.name}; import ${providerModule.name} eagerly`,
403
+ );
404
+ }
405
+ }
406
+ }
407
+ }
408
+ }
409
+
410
+ function lazySubtreeOrder(
411
+ root: ModuleClass,
412
+ definitions: ReadonlyMap<ModuleClass, ModuleDef>,
413
+ moduleOrder: readonly ModuleClass[],
414
+ eagerModules: ReadonlySet<ModuleClass>,
415
+ ): readonly ModuleClass[] {
416
+ const reachable = new Set<ModuleClass>();
417
+ const visit = (moduleClass: ModuleClass): void => {
418
+ if (reachable.has(moduleClass) || eagerModules.has(moduleClass)) {
419
+ return;
420
+ }
421
+ reachable.add(moduleClass);
422
+ for (const imported of definitions.get(moduleClass)?.imports ?? []) {
423
+ visit(typeof imported === 'function' ? imported : imported.module);
424
+ }
425
+ };
426
+ visit(root);
427
+ return moduleOrder.filter(module => reachable.has(module));
428
+ }
429
+
430
+ function instantiateEagerModule(
431
+ moduleClass: ModuleClass,
432
+ definitions: ReadonlyMap<ModuleClass, ModuleDef>,
433
+ container: Container,
434
+ controllers: object[],
435
+ commands: object[],
436
+ overrides: readonly ProviderDef[],
437
+ recordInstance: (value: unknown) => void,
438
+ ): void {
439
+ const def = definitions.get(moduleClass);
440
+ for (const provider of def?.providers ?? []) {
441
+ if (!isOverridden(overrides, provider)) {
442
+ registerProvider(container, provider, recordInstance);
443
+ }
444
+ }
445
+ for (const Controller of def?.controllers ?? []) {
446
+ const controller = container.build(Controller);
447
+ recordInstance(controller);
448
+ controllers.push(controller);
449
+ }
450
+ for (const Command of def?.commands ?? []) {
451
+ const command = container.build(Command);
452
+ recordInstance(command);
453
+ commands.push(command);
454
+ }
455
+ }
456
+
457
+ function isOverridden(overrides: readonly ProviderDef[], provider: ProviderDef): boolean {
458
+ return overrides.some(override => override.token === provider.token);
459
+ }
460
+
461
+ function registerProvider(container: Container, provider: ProviderDef, recordInstance: (value: unknown) => void): void {
462
+ if ('useValue' in provider) {
463
+ container.register(provider.token, provider.useValue);
464
+ recordInstance(provider.useValue);
465
+ } else {
466
+ container.registerFactory(
467
+ provider.token,
468
+ c => {
469
+ const value = provider.useFactory(c);
470
+ recordInstance(value);
471
+ return value;
472
+ },
473
+ provider.scope ?? 'singleton',
474
+ );
475
+ }
476
+ }