@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,179 @@
1
+ // @zmdb/app — compile-time dependency injection (epic #262, spec ./SPEC.md).
2
+ // Container + @Inject field decorator. No emitDecoratorMetadata, no reflection,
3
+ // no `as` on the consumer surface. Resolution happens at build (class-init) time.
4
+
5
+ // Install Symbol.metadata (used by field decorators) before any decorated class
6
+ // in a consumer module is evaluated.
7
+ import '../polyfill.js';
8
+
9
+ // A typed injection token. The phantom `__type` carries the instance type at
10
+ // compile time without existing at runtime (it is `never`-valued and optional).
11
+ export interface Token<T> {
12
+ readonly description: string;
13
+ readonly __type?: T;
14
+ }
15
+
16
+ /** Create a unique injection token carrying its instance type. */
17
+ export function createToken<T>(description: string): Token<T> {
18
+ return { description };
19
+ }
20
+
21
+ /** Thrown by `Container.resolve` when a token was never registered. */
22
+ export class UnresolvedTokenError extends Error {
23
+ constructor(description: string) {
24
+ super(`@zmdb/app: no provider registered for token "${description}"`);
25
+ this.name = 'UnresolvedTokenError';
26
+ }
27
+ }
28
+
29
+ // A field-injection request recorded by @Inject. Nothing reads the slot yet — the
30
+ // HTTP-aware devtools inspector is its first reader
31
+ // (../../../web/src/devtools/SPEC.md §4).
32
+ interface InjectionRequest {
33
+ readonly field: string | symbol;
34
+ readonly token: Token<unknown>;
35
+ }
36
+
37
+ const INJECTIONS = Symbol('zmdb.web.di.injections');
38
+
39
+ interface DiMetadata {
40
+ [INJECTIONS]?: InjectionRequest[];
41
+ }
42
+
43
+ // boundary: our @Inject decorator is the only writer of the INJECTIONS slot, so
44
+ // viewing the metadata record through DiMetadata is sound (no call-site `as`).
45
+ function diView(metadata: DecoratorMetadata): DiMetadata {
46
+ return metadata;
47
+ }
48
+
49
+ /** Read the field-injection declarations recorded on a class. */
50
+ export function injectionsOf(
51
+ ctor: abstract new (...args: never[]) => unknown,
52
+ ): readonly { readonly field: string | symbol; readonly token: Token<unknown> }[] {
53
+ const metadata = ctor[Symbol.metadata];
54
+ if (metadata === undefined || metadata === null) {
55
+ return [];
56
+ }
57
+ return diView(metadata)[INJECTIONS] ?? [];
58
+ }
59
+
60
+ // The container whose `build` is currently running. Field initializers read it
61
+ // to resolve their token. Set for the duration of `build` and cleared in a
62
+ // `finally`, so there is no persistent global request-time state.
63
+ let currentContainer: Container | undefined;
64
+
65
+ // Run `fn` with `container` set as the active (building) container, restoring the
66
+ // previous one afterward. Keeps the swap out of `Container.build` so the method
67
+ // body never aliases `this` to a variable.
68
+ function withActiveContainer<T>(container: Container, fn: () => T): T {
69
+ const previous = currentContainer;
70
+ currentContainer = container;
71
+ try {
72
+ return fn();
73
+ } finally {
74
+ currentContainer = previous;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Field decorator: resolve `token` from the container that is building this
80
+ * instance. The field's declared type must be assignable from the token type,
81
+ * so a mismatch is a compile error and no `as` is needed.
82
+ */
83
+ export function Inject<T>(token: Token<T>) {
84
+ return function (_value: undefined, context: ClassFieldDecoratorContext<unknown, T>): (initial: T) => T {
85
+ const view = diView(context.metadata);
86
+ const request: InjectionRequest = { field: context.name, token };
87
+ // A subclass's metadata record is created with the base's as its prototype, so
88
+ // `view[INJECTIONS]` on a subclass reads the base's array and pushing into it
89
+ // files the subclass's field under the base class. Copy what is inherited on
90
+ // the first own write, then push, so a reader sees base fields then own.
91
+ const own = Object.hasOwn(context.metadata, INJECTIONS) ? view[INJECTIONS] : undefined;
92
+ if (own === undefined) {
93
+ view[INJECTIONS] = [...(view[INJECTIONS] ?? []), request];
94
+ } else {
95
+ own.push(request);
96
+ }
97
+ // The initializer runs during construction; resolve from the active build.
98
+ return function (): T {
99
+ if (currentContainer === undefined) {
100
+ throw new Error(
101
+ `@zmdb/app: @Inject field "${String(context.name)}" was initialized outside container.build(...)`,
102
+ );
103
+ }
104
+ return currentContainer.resolve(token);
105
+ };
106
+ };
107
+ }
108
+
109
+ /** A class constructor the container can build. */
110
+ export type Constructor<T> = new () => T;
111
+
112
+ /** Provider scope: a singleton is resolved once and cached; transient re-runs. */
113
+ export type Scope = 'singleton' | 'transient';
114
+
115
+ /** The explicit, opt-in DI registry. Resolution is O(1) by token identity. */
116
+ export class Container {
117
+ // Keyed by token identity. Values are the registered instances; each key's
118
+ // value type is guaranteed by `register`'s typed signature.
119
+ readonly #bindings = new Map<Token<unknown>, unknown>();
120
+ // Factory providers: token → { factory, scope }. Singleton factories cache
121
+ // their first result back into #bindings.
122
+ readonly #factories = new Map<Token<unknown>, { factory: (c: Container) => unknown; scope: Scope }>();
123
+
124
+ /** Bind a token to an instance. The instance type is constrained to T. */
125
+ register<T>(token: Token<T>, instance: T): void {
126
+ this.#bindings.set(token, instance);
127
+ }
128
+
129
+ /** Bind a token to a factory with a scope (default singleton). */
130
+ registerFactory<T>(token: Token<T>, factory: (c: Container) => T, scope: Scope = 'singleton'): void {
131
+ this.#factories.set(token, { factory, scope });
132
+ }
133
+
134
+ /** True if the token is registered (as a value or a factory). */
135
+ has<T>(token: Token<T>): boolean {
136
+ return this.#bindings.has(token) || this.#factories.has(token);
137
+ }
138
+
139
+ /** Resolve a token to its instance, or throw UnresolvedTokenError. */
140
+ resolve<T>(token: Token<T>): T {
141
+ if (this.#bindings.has(token)) {
142
+ return readBinding(this.#bindings, token);
143
+ }
144
+ const provider = this.#factories.get(token);
145
+ if (provider !== undefined) {
146
+ const value = provider.factory(this);
147
+ if (provider.scope === 'singleton') {
148
+ this.#bindings.set(token, value);
149
+ }
150
+ return narrowFactoryValue<T>(value);
151
+ }
152
+ throw new UnresolvedTokenError(token.description);
153
+ }
154
+
155
+ /**
156
+ * Construct `Ctor` with its `@Inject`ed fields satisfied from this container.
157
+ * Resolution happens here (once), then is cached on the instance.
158
+ */
159
+ build<T>(Ctor: Constructor<T>): T {
160
+ return withActiveContainer(this, () => new Ctor());
161
+ }
162
+ }
163
+
164
+ // boundary: `register<T>` is the only writer and stores exactly the token's T
165
+ // under that token key, so reading it back as T is sound. This is the single
166
+ // enumerated boundary cast in the DI module (ARCHITECTURE.md §2.1) — a
167
+ // heterogeneous token→instance Map cannot prove its value type structurally, so
168
+ // the assertion is isolated here with the soundness argument, and never appears
169
+ // at a call site or on the consumer surface.
170
+ function readBinding<T>(bindings: ReadonlyMap<Token<unknown>, unknown>, token: Token<T>): T {
171
+ return bindings.get(token) as T;
172
+ }
173
+
174
+ // boundary: a factory registered under Token<T> via registerFactory returns T by
175
+ // construction; widening its `unknown` result to T is sound. Same enumerated DI
176
+ // boundary as readBinding (ARCHITECTURE.md §2.1); never on the consumer surface.
177
+ function narrowFactoryValue<T>(value: unknown): T {
178
+ return value as T;
179
+ }
@@ -0,0 +1,248 @@
1
+ // @zmdb/app — typed, app-owned application events (epic #591, spec ./SPEC.md).
2
+ // Handlers are registered explicitly, run concurrently, and report failures
3
+ // without letting one handler stop another. Durable emission crosses through
4
+ // the caller's transaction and the repository outbox.
5
+
6
+ import '../polyfill.js';
7
+ import { type OutboxWriter } from '@zmdb/orm/outbox';
8
+ import { type TransactionContext } from '@zmdb/orm/transactions';
9
+
10
+ /** An application-owned map from event names to payload types. */
11
+ export interface EventMap {
12
+ readonly [event: string]: unknown;
13
+ }
14
+
15
+ /** One validator or handler failure observed while dispatching an event. */
16
+ export interface EventFailure {
17
+ readonly event: string;
18
+ readonly handler: string;
19
+ readonly error: unknown;
20
+ }
21
+
22
+ /** The settled result of an awaited in-process emission. */
23
+ export interface EmitReport {
24
+ readonly delivered: number;
25
+ readonly failures: readonly EventFailure[];
26
+ }
27
+
28
+ /** Construction options for one app-owned event registry. */
29
+ export interface EventsOptions<M extends EventMap> {
30
+ readonly onError: (failure: EventFailure) => void;
31
+ readonly validate?: { readonly [K in keyof M]?: (raw: unknown) => M[K] };
32
+ readonly outbox?: (tx: TransactionContext) => OutboxWriter;
33
+ }
34
+
35
+ /** Typed in-process dispatch plus an explicit transactional outbox crossing. */
36
+ export interface Events<M extends EventMap> {
37
+ emit<K extends keyof M & string>(event: K, payload: M[K]): void;
38
+ emitAndWait<K extends keyof M & string>(event: K, payload: M[K]): Promise<EmitReport>;
39
+ on<K extends keyof M & string>(event: K, handler: (payload: M[K]) => void | Promise<void>): () => void;
40
+ bind(instance: object): () => void;
41
+ emitInTransaction<K extends keyof M & string>(tx: TransactionContext, event: K, payload: M[K]): Promise<string>;
42
+ }
43
+
44
+ /** A handler declaration recorded by `@OnEvent`. */
45
+ export interface ResolvedEventHandler {
46
+ readonly event: string;
47
+ readonly handlerName: string;
48
+ }
49
+
50
+ const EVENT_HANDLERS = Symbol('zmdb.web.events.handlers');
51
+
52
+ interface EventMetadata {
53
+ [EVENT_HANDLERS]?: ResolvedEventHandler[];
54
+ }
55
+
56
+ interface StoredHandler {
57
+ readonly name: string;
58
+ invoke(payload: unknown): void | Promise<void>;
59
+ }
60
+
61
+ // boundary: @OnEvent is the only writer of the EVENT_HANDLERS slot, so this
62
+ // typed view of decorator metadata is sound (ARCHITECTURE.md §2.1).
63
+ function eventMetadata(metadata: DecoratorMetadata): EventMetadata {
64
+ return metadata;
65
+ }
66
+
67
+ /** Stage-3 method decorator recording an application-event binding. */
68
+ export function OnEvent(event: string) {
69
+ return function (_target: Function, context: ClassMethodDecoratorContext): void {
70
+ const handlerName = typeof context.name === 'string' ? context.name : context.name.toString();
71
+ const view = eventMetadata(context.metadata);
72
+ const own = Object.hasOwn(context.metadata, EVENT_HANDLERS) ? view[EVENT_HANDLERS] : undefined;
73
+ const binding = { event, handlerName };
74
+ if (own === undefined) {
75
+ view[EVENT_HANDLERS] = [...(view[EVENT_HANDLERS] ?? []), binding];
76
+ } else {
77
+ own.push(binding);
78
+ }
79
+ };
80
+ }
81
+
82
+ function handlersDeclaredBy(cls: Function): readonly ResolvedEventHandler[] {
83
+ const metadata = cls[Symbol.metadata];
84
+ if (metadata === undefined || metadata === null) {
85
+ return [];
86
+ }
87
+ return eventMetadata(metadata)[EVENT_HANDLERS] ?? [];
88
+ }
89
+
90
+ /** Read the bindings declared by a class. Nothing scans or constructs it. */
91
+ export function getEventHandlers(cls: abstract new (...args: never[]) => unknown): readonly ResolvedEventHandler[] {
92
+ return handlersDeclaredBy(cls);
93
+ }
94
+
95
+ function storedHandler<P>(
96
+ handler: (payload: P) => void | Promise<void>,
97
+ name = handler.name || '<anonymous>',
98
+ ): StoredHandler {
99
+ return {
100
+ name,
101
+ async invoke(payload: unknown): Promise<void> {
102
+ // The public `on(event, handler)` signature ties P to the same event key
103
+ // used by the registry; Reflect.apply is the erased runtime crossing.
104
+ await Reflect.apply(handler, undefined, [payload]);
105
+ },
106
+ };
107
+ }
108
+
109
+ function boundHandler(instance: object, declaration: ResolvedEventHandler): StoredHandler {
110
+ const value: unknown = Reflect.get(instance, declaration.handlerName);
111
+ if (typeof value !== 'function') {
112
+ throw new Error(`@zmdb/app: @OnEvent handler "${declaration.handlerName}" is not a callable instance method`);
113
+ }
114
+ return {
115
+ name: declaration.handlerName,
116
+ async invoke(payload: unknown): Promise<void> {
117
+ await Reflect.apply(value, instance, [payload]);
118
+ },
119
+ };
120
+ }
121
+
122
+ function invoke(handler: StoredHandler, payload: unknown): Promise<void> {
123
+ try {
124
+ return Promise.resolve(handler.invoke(payload));
125
+ } catch (error) {
126
+ return Promise.reject(error);
127
+ }
128
+ }
129
+
130
+ /** Build one isolated event registry for an application. */
131
+ export function createEvents<M extends EventMap>(options: EventsOptions<M>): Events<M> {
132
+ const handlers = new Map<string, StoredHandler[]>();
133
+
134
+ const report = (failure: EventFailure): void => {
135
+ try {
136
+ options.onError(failure);
137
+ } catch {
138
+ // The error sink is the terminal reporting boundary. Its own exception
139
+ // must not turn a fire-and-forget emission into an unhandled rejection.
140
+ }
141
+ };
142
+
143
+ const register = (event: string, handler: StoredHandler): (() => void) => {
144
+ const registrations = handlers.get(event);
145
+ if (registrations === undefined) {
146
+ handlers.set(event, [handler]);
147
+ } else {
148
+ registrations.push(handler);
149
+ }
150
+
151
+ let active = true;
152
+ return (): void => {
153
+ if (!active) {
154
+ return;
155
+ }
156
+ active = false;
157
+ const current = handlers.get(event);
158
+ if (current === undefined) {
159
+ return;
160
+ }
161
+ const index = current.indexOf(handler);
162
+ if (index >= 0) {
163
+ current.splice(index, 1);
164
+ }
165
+ if (current.length === 0) {
166
+ handlers.delete(event);
167
+ }
168
+ };
169
+ };
170
+
171
+ const dispatch = async <K extends keyof M & string>(event: K, payload: M[K]): Promise<EmitReport> => {
172
+ let checked = payload;
173
+ try {
174
+ const validator = options.validate?.[event];
175
+ if (validator !== undefined) {
176
+ checked = validator(payload);
177
+ }
178
+ } catch (error) {
179
+ const failure: EventFailure = { event, handler: 'validate', error };
180
+ report(failure);
181
+ return { delivered: 0, failures: [failure] };
182
+ }
183
+
184
+ const registrations = [...(handlers.get(event) ?? [])];
185
+ const settled = await Promise.allSettled(registrations.map(handler => invoke(handler, checked)));
186
+ const failures: EventFailure[] = [];
187
+ let delivered = 0;
188
+ for (let index = 0; index < settled.length; index += 1) {
189
+ const outcome = settled[index];
190
+ const registration = registrations[index];
191
+ if (outcome?.status === 'fulfilled') {
192
+ delivered += 1;
193
+ } else if (outcome !== undefined && registration !== undefined) {
194
+ const failure: EventFailure = {
195
+ event,
196
+ handler: registration.name,
197
+ error: outcome.reason,
198
+ };
199
+ failures.push(failure);
200
+ report(failure);
201
+ }
202
+ }
203
+ return { delivered, failures };
204
+ };
205
+
206
+ return {
207
+ emit(event, payload): void {
208
+ void dispatch(event, payload).catch(error => {
209
+ report({ event, handler: '<dispatch>', error });
210
+ });
211
+ },
212
+
213
+ emitAndWait: dispatch,
214
+
215
+ on(event, handler): () => void {
216
+ return register(event, storedHandler(handler));
217
+ },
218
+
219
+ bind(instance): () => void {
220
+ const ctor = instance.constructor;
221
+ const declarations = typeof ctor === 'function' ? handlersDeclaredBy(ctor) : [];
222
+ const disposers = declarations.map(declaration =>
223
+ register(declaration.event, boundHandler(instance, declaration)),
224
+ );
225
+ let active = true;
226
+ return (): void => {
227
+ if (!active) {
228
+ return;
229
+ }
230
+ active = false;
231
+ for (const dispose of disposers) {
232
+ dispose();
233
+ }
234
+ };
235
+ },
236
+
237
+ async emitInTransaction(tx, event, payload): Promise<string> {
238
+ if (options.outbox === undefined) {
239
+ throw new Error('@zmdb/app: emitInTransaction requires an outbox writer');
240
+ }
241
+ const encoded = JSON.stringify(payload);
242
+ if (encoded === undefined) {
243
+ throw new Error('@zmdb/app: event payload is not JSON-serializable');
244
+ }
245
+ return options.outbox(tx).write(event, encoded);
246
+ },
247
+ };
248
+ }
@@ -0,0 +1,54 @@
1
+ import { type Driver } from '@zmdb/orm';
2
+
3
+ /** The process is not wedged. Synchronous by construction. */
4
+ export interface LivenessCheck {
5
+ readonly name: string;
6
+ run(): boolean;
7
+ }
8
+
9
+ export interface CheckResult {
10
+ readonly ok: boolean;
11
+ readonly detail?: string;
12
+ }
13
+
14
+ /** The process can serve traffic. Dependencies receive an explicit deadline. */
15
+ export interface ReadinessCheck {
16
+ readonly name: string;
17
+ readonly timeoutMs: number;
18
+ readonly cacheMs?: number;
19
+ run(signal: AbortSignal): Promise<CheckResult>;
20
+ }
21
+
22
+ export interface HealthChecks {
23
+ readonly liveness?: readonly LivenessCheck[];
24
+ readonly readiness?: readonly ReadinessCheck[];
25
+ }
26
+
27
+ export interface DetailedCheck {
28
+ readonly name: string;
29
+ readonly ok: boolean;
30
+ readonly detail?: string;
31
+ readonly durationMs?: number;
32
+ }
33
+
34
+ export interface DatabaseReadinessOptions {
35
+ readonly name?: string;
36
+ readonly timeoutMs: number;
37
+ readonly cacheMs?: number;
38
+ }
39
+
40
+ /** Build the protocol-neutral readiness check for one required database. */
41
+ export function databaseReadinessCheck(
42
+ driver: Pick<Driver, 'execute'>,
43
+ options: DatabaseReadinessOptions,
44
+ ): ReadinessCheck {
45
+ return {
46
+ name: options.name ?? 'database',
47
+ timeoutMs: options.timeoutMs,
48
+ ...(options.cacheMs === undefined ? {} : { cacheMs: options.cacheMs }),
49
+ async run(signal) {
50
+ await driver.execute({ text: 'SELECT 1', parameters: [] }, { signal });
51
+ return { ok: true };
52
+ },
53
+ };
54
+ }
package/src/index.ts ADDED
@@ -0,0 +1,68 @@
1
+ // @zmdb/app — the protocol-neutral application kernel.
2
+ //
3
+ // This entry owns the one Stage-3 metadata installation and reader, the
4
+ // application graph, dependency injection and lifecycle. Concern-heavy
5
+ // capabilities such as command parsing, events and observability remain behind
6
+ // their explicit subpaths.
7
+
8
+ import './polyfill.js';
9
+
10
+ export type ApplicationMetadata = DecoratorMetadataObject;
11
+
12
+ interface HasMetadata {
13
+ readonly [Symbol.metadata]?: DecoratorMetadata | null;
14
+ }
15
+
16
+ const EMPTY: ApplicationMetadata = Object.freeze(Object.create(null));
17
+
18
+ function hasMetadata(value: object): value is { readonly [Symbol.metadata]: DecoratorMetadata } {
19
+ const carrier: HasMetadata = value;
20
+ const record = carrier[Symbol.metadata];
21
+ return record !== undefined && record !== null;
22
+ }
23
+
24
+ /** Read one Stage-3 metadata record, or the shared frozen empty record. */
25
+ export function metadataOf(target: object): ApplicationMetadata {
26
+ return hasMetadata(target) ? target[Symbol.metadata] : EMPTY;
27
+ }
28
+
29
+ export {
30
+ Container,
31
+ createToken,
32
+ Inject,
33
+ injectionsOf,
34
+ UnresolvedTokenError,
35
+ type Constructor,
36
+ type Scope,
37
+ type Token,
38
+ } from './di/index.js';
39
+ export {
40
+ compileModule,
41
+ lazy,
42
+ Module,
43
+ moduleDefOf,
44
+ type CompiledModule,
45
+ type LazyImport,
46
+ type LazyModuleHandle,
47
+ type LazyStatus,
48
+ type ModuleClass,
49
+ type ModuleDef,
50
+ type ProviderDef,
51
+ } from './modules/index.js';
52
+ export {
53
+ createApplication,
54
+ type Application,
55
+ type ApplicationExtension,
56
+ type ApplicationExtensionContext,
57
+ type ApplicationOptions,
58
+ } from './application.js';
59
+ export type { OnApplicationBootstrap, OnModuleInit, OnShutdown } from './lifecycle.js';
60
+
61
+ export { defineStateTransitions, createStateUpdatePayload, defineEntityStateMachine } from './state-transitions.js';
62
+ export type {
63
+ StateTransitions,
64
+ AllowedTargetStates,
65
+ StateUpdateDTO,
66
+ EntityStateMachineOptions,
67
+ EntityStateMachine,
68
+ } from './state-transitions.js';
@@ -0,0 +1,62 @@
1
+ // @zmdb/app — lifecycle hooks, shared by the app bootstrap and the test harness.
2
+ //
3
+ // Every protocol adapter and test harness delegates to this one ledger. Hook
4
+ // detection and ordering therefore cannot drift between HTTP, commands, jobs
5
+ // or a standalone application.
6
+ //
7
+ // Detection is structural `in`-narrowing on the instance: no reflection, no cast.
8
+
9
+ /** Called after the eager application instances are constructed. */
10
+ export interface OnModuleInit {
11
+ onModuleInit(): void | Promise<void>;
12
+ }
13
+ /** Called after all modules are initialized. */
14
+ export interface OnApplicationBootstrap {
15
+ onApplicationBootstrap(): void | Promise<void>;
16
+ }
17
+ /** Called on graceful shutdown (via `await using` / dispose). */
18
+ export interface OnShutdown {
19
+ onShutdown(): void | Promise<void>;
20
+ }
21
+
22
+ function hasModuleInit(x: object): x is OnModuleInit {
23
+ return 'onModuleInit' in x && typeof x.onModuleInit === 'function';
24
+ }
25
+ function hasBootstrap(x: object): x is OnApplicationBootstrap {
26
+ return 'onApplicationBootstrap' in x && typeof x.onApplicationBootstrap === 'function';
27
+ }
28
+ function hasShutdown(x: object): x is OnShutdown {
29
+ return 'onShutdown' in x && typeof x.onShutdown === 'function';
30
+ }
31
+
32
+ /**
33
+ * `onModuleInit` on every implementer, then `onApplicationBootstrap` on every
34
+ * implementer — two full passes, so a bootstrap hook can rely on every module
35
+ * having been initialized.
36
+ */
37
+ export async function runInit(instances: readonly object[]): Promise<void> {
38
+ for (const instance of instances) {
39
+ if (hasModuleInit(instance)) await instance.onModuleInit();
40
+ }
41
+ for (const instance of instances) {
42
+ if (hasBootstrap(instance)) await instance.onApplicationBootstrap();
43
+ }
44
+ }
45
+
46
+ /** `onShutdown` in reverse construction order, so a dependent tears down before what it depends on. */
47
+ export async function runShutdown(instances: readonly (object | undefined)[]): Promise<void> {
48
+ const errors: unknown[] = [];
49
+ for (let i = instances.length - 1; i >= 0; i -= 1) {
50
+ const instance = instances[i];
51
+ if (instance === undefined || !hasShutdown(instance)) continue;
52
+ try {
53
+ await instance.onShutdown();
54
+ } catch (error) {
55
+ errors.push(error);
56
+ }
57
+ }
58
+ if (errors.length === 1) throw errors[0];
59
+ if (errors.length > 1) {
60
+ throw new AggregateError(errors, '@zmdb/app: application shutdown hooks failed');
61
+ }
62
+ }