@arki/event-sourcing 0.1.5 → 0.2.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.
@@ -0,0 +1,96 @@
1
+ import { z } from '@arki/contracts';
2
+
3
+ export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
4
+ export type JsonObject = { [key: string]: JsonValue };
5
+
6
+ export const EVENT_SOURCING_ACTION_META_SCHEMA = '@arki/event-sourcing/action-meta@1';
7
+ export const EVENT_SOURCING_SCHEMA_REJECTED_CODE = 'EVENT_SOURCING_PLUGIN_E007';
8
+
9
+ export type EventSourcingActionMeta =
10
+ | {
11
+ readonly kind: 'command';
12
+ readonly input: JsonObject;
13
+ }
14
+ | {
15
+ readonly kind: 'event';
16
+ readonly data: JsonObject;
17
+ };
18
+
19
+ export type EventSourcingActionDeclaration = {
20
+ readonly id: string;
21
+ readonly binding: 'es';
22
+ readonly direction: 'in' | 'out';
23
+ readonly address: string;
24
+ readonly metaSchema: typeof EVENT_SOURCING_ACTION_META_SCHEMA;
25
+ readonly meta: EventSourcingActionMeta;
26
+ };
27
+
28
+ export class EventSourcingActionError extends Error {
29
+ readonly code = EVENT_SOURCING_SCHEMA_REJECTED_CODE;
30
+
31
+ constructor(message: string, cause: unknown) {
32
+ super(message, { cause });
33
+ this.name = 'EventSourcingActionError';
34
+ }
35
+ }
36
+
37
+ function isJsonPrimitive(value: unknown): value is string | number | boolean | null {
38
+ return value === null || typeof value === 'string' || typeof value === 'boolean' || typeof value === 'number';
39
+ }
40
+
41
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
42
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
43
+ const prototype = Object.getPrototypeOf(value) as unknown;
44
+ return prototype === Object.prototype || prototype === null;
45
+ }
46
+
47
+ function isJsonValue(value: unknown): value is JsonValue {
48
+ if (isJsonPrimitive(value)) return typeof value !== 'number' || Number.isFinite(value);
49
+ if (Array.isArray(value)) return value.every(isJsonValue);
50
+ if (!isPlainObject(value)) return false;
51
+ return Object.values(value).every(isJsonValue);
52
+ }
53
+
54
+ export function isJsonObject(value: unknown): value is JsonObject {
55
+ return isPlainObject(value) && Object.values(value).every(isJsonValue);
56
+ }
57
+
58
+ function jsonDeepEqual(left: unknown, right: unknown): boolean {
59
+ if (isJsonPrimitive(left) || isJsonPrimitive(right)) return Object.is(left, right);
60
+ if (Array.isArray(left) || Array.isArray(right)) {
61
+ if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
62
+ return left.every((value, index) => jsonDeepEqual(value, right[index]));
63
+ }
64
+ if (!isPlainObject(left) || !isPlainObject(right)) return false;
65
+ const leftEntries = Object.entries(left);
66
+ const rightEntries = Object.entries(right);
67
+ if (leftEntries.length !== rightEntries.length) return false;
68
+ for (const [key, value] of leftEntries) {
69
+ if (!Object.hasOwn(right, key) || !jsonDeepEqual(value, right[key])) return false;
70
+ }
71
+ return true;
72
+ }
73
+
74
+ function toJsonObject(value: unknown): JsonObject {
75
+ const serialized = JSON.stringify(value);
76
+ if (serialized === undefined) throw new TypeError('Value must be a JSON-serializable object.');
77
+ const parsed = JSON.parse(serialized) as unknown;
78
+ if (!isJsonObject(parsed) || !jsonDeepEqual(value, parsed)) {
79
+ throw new TypeError('Value must be a JSON-serializable object without lossy coercions.');
80
+ }
81
+ return parsed;
82
+ }
83
+
84
+ export function schemaToJsonObject(schema: z.ZodType, label: string): JsonObject {
85
+ try {
86
+ const json = z.toJSONSchema(schema) as Record<string, unknown>;
87
+ const rest = { ...json };
88
+ delete rest['$schema'];
89
+ return toJsonObject(rest);
90
+ } catch (error) {
91
+ throw new EventSourcingActionError(
92
+ `[event-sourcing] ${label} schema could not be converted to JSON Schema.`,
93
+ error,
94
+ );
95
+ }
96
+ }
package/src/dot.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * DOT adapter for `@arki/event-sourcing`.
3
3
  *
4
4
  * Wraps `eventSourcingFeatures.initEventSourcing` + `initMessageBus` as a
5
- * single `DotPip`. The pip opens the PostgreSQL event store in
5
+ * single `DotPlugin`. The plugin opens the PostgreSQL event store in
6
6
  * `boot`, attaches command handlers to an in-memory message bus, publishes
7
7
  * both as `services.eventStore` and `services.messageBus`, and closes the
8
8
  * event store pool in `dispose` (reverse declaration order).
@@ -37,37 +37,133 @@
37
37
  * load — that is intentional: the adapter only makes sense in a DOT app.
38
38
  */
39
39
 
40
- import type { EventStore, MessageBus } from '@event-driven-io/emmett';
40
+ import type { Command, Event, EventStore, MessageBus } from '@event-driven-io/emmett';
41
41
 
42
- import type { EmptyShape, Pip } from '@arki/dot/pip';
43
- import { pip, DotPipError } from '@arki/dot/pip';
42
+ import type { EmptyShape, Plugin, Token } from '@arki/dot/plugin';
43
+ import { plugin, DotPluginError } from '@arki/dot/plugin';
44
44
 
45
45
  import type { CommandHandlerRegistration } from './command.js';
46
+ import { debugBuilder } from './debug.js';
46
47
  import type { PostgreSQLProjectionInput } from './event-sourcing-features.js';
47
48
  import { EVENT_STORE_URL_VARIANTS, eventSourcingFeatures } from './event-sourcing-features.js';
49
+ import type { CommandHandler } from './builders/command-handler.js';
48
50
 
49
51
  /**
50
- * Stable error codes thrown by the event-sourcing pip. Exported so consumers
52
+ * Stable error codes thrown by the event-sourcing plugin. Exported so consumers
51
53
  * and coding agents can match against them — never parse the message.
52
54
  *
53
55
  * @see packages/dot/docs/principles.md — principle 1.3 ("errors are part
54
56
  * of the API") and principle 4 ("agent-discoverable everywhere").
55
57
  */
56
- export const EVENT_SOURCING_PIP_ERROR_CODES = {
58
+ export const EVENT_SOURCING_PLUGIN_ERROR_CODES = {
57
59
  /** boot was called without a configured event-store URL. */
58
- dbUrlNotConfigured: 'EVENT_SOURCING_PIP_E001',
60
+ dbUrlNotConfigured: 'EVENT_SOURCING_PLUGIN_E001',
61
+ /** two collected handlers claim the same command type. */
62
+ duplicateCommandHandler: 'EVENT_SOURCING_PLUGIN_E002',
63
+ /** a bundle token listed in options.bundles was not provided. */
64
+ bundleMissing: 'EVENT_SOURCING_PLUGIN_E003',
65
+ /** a command was dispatched with no registered handler. */
66
+ commandHandlerMissing: 'EVENT_SOURCING_PLUGIN_E004',
67
+ /** a command was dispatched after the dispatcher closed. */
68
+ dispatcherClosed: 'EVENT_SOURCING_PLUGIN_E005',
69
+ /** reserved for a future declared-action/handler parity diagnostic. */
70
+ declaredActionUnbound: 'EVENT_SOURCING_PLUGIN_E006',
71
+ /** command/event schema could not be converted to JSON Schema. */
72
+ schemaRejected: 'EVENT_SOURCING_PLUGIN_E007',
59
73
  } as const;
60
74
 
75
+ const EVENT_SOURCING_PLUGIN_VERSION = '0.2.0';
76
+
77
+ /** A token publishing an {@link EsBundle} — what `options.bundles` lists. */
78
+ export type BundleToken = Token<EsBundle, string>;
79
+
80
+ /** Wire-needs record derived from bundle tokens. */
81
+ export type BundleNeeds<TBundles extends readonly BundleToken[]> = {
82
+ readonly [Tok in TBundles[number] as Tok extends Token<EsBundle, infer K> ? K : never]: EsBundle;
83
+ };
84
+
85
+ /**
86
+ * A feature token whose slice MAY carry an ES bundle under the `es` key —
87
+ * what `options.features` lists (the feature-plugins slice protocol).
88
+ * Features without an `es` slice contribute nothing, by design.
89
+ */
90
+ export type EsFeatureToken = Token<{ readonly es?: EsBundle }, string>;
91
+
92
+ /** Wire-needs record derived from feature tokens. */
93
+ export type EsFeatureNeeds<TFeatures extends readonly EsFeatureToken[]> = {
94
+ readonly [Tok in TFeatures[number] as Tok extends Token<unknown, infer K> ? K : never]: {
95
+ readonly es?: EsBundle;
96
+ };
97
+ };
98
+
99
+ type CommandFactory<C extends Command = Command> = {
100
+ readonly type: C['type'];
101
+ } & ((input: never, metadata?: never) => C);
102
+
103
+ /** Feature-local event-sourcing registrations collected by `eventSourcing()`. */
104
+ export type EsBundle = {
105
+ /** Command handlers attached to the in-process message bus at boot. */
106
+ readonly handlers: readonly CommandHandlerRegistration[];
107
+ /** Inline PostgreSQL read-model projections registered on the event store. */
108
+ readonly readModels: readonly PostgreSQLProjectionInput[];
109
+ };
110
+
111
+ export type EsBundleInput = {
112
+ readonly handlers?: readonly CommandHandlerRegistration[];
113
+ readonly readModels?: readonly PostgreSQLProjectionInput[];
114
+ };
115
+
116
+ function bundle(input: EsBundleInput = {}): EsBundle {
117
+ return {
118
+ handlers: input.handlers ?? [],
119
+ readModels: input.readModels ?? [],
120
+ };
121
+ }
122
+
123
+ function handle<C extends Command, State, StreamEvent extends Event>(
124
+ command: CommandFactory<C>,
125
+ handler: CommandHandler<State, C, StreamEvent>,
126
+ getStreamName: (command: C) => string,
127
+ ): CommandHandlerRegistration<C, State, StreamEvent> {
128
+ return {
129
+ commandType: command.type,
130
+ handler,
131
+ getStreamName,
132
+ };
133
+ }
134
+
135
+ /** Namespace for feature-local ES bundle helpers. */
136
+ export const es = {
137
+ bundle,
138
+ handle,
139
+ };
140
+
61
141
  /**
62
142
  * Options for the event-sourcing DOT adapter.
63
143
  */
64
- export type EventSourcingDotOptions = {
144
+ export type EventSourcingDotOptions<
145
+ TBundles extends readonly BundleToken[] = readonly BundleToken[],
146
+ TFeatures extends readonly EsFeatureToken[] = readonly EsFeatureToken[],
147
+ > = {
65
148
  /**
66
149
  * PostgreSQL projection definitions registered inline on the event store.
67
150
  * Accepts the three projection builder patterns documented on
68
151
  * {@link PostgreSQLProjectionInput}.
69
152
  */
70
- readonly projections: readonly PostgreSQLProjectionInput[];
153
+ readonly projections?: readonly PostgreSQLProjectionInput[];
154
+ /**
155
+ * Feature-local bundles to collect. Each token becomes a typed DOT need,
156
+ * so the app builder rejects an `eventSourcing({ bundles })` collector
157
+ * unless an earlier plugin publishes every listed bundle.
158
+ */
159
+ readonly bundles?: TBundles;
160
+ /**
161
+ * Feature-plugin slice tokens to collect (`{ es?: EsBundle }`). Each token
162
+ * becomes a typed DOT need like `bundles`; a feature without an `es`
163
+ * slice contributes nothing (partial features are zero-config — a
164
+ * debug line records the empty contribution).
165
+ */
166
+ readonly features?: TFeatures;
71
167
  /**
72
168
  * Command handlers wired into the in-memory message bus. Each handler's
73
169
  * `getStreamName` callback maps an incoming command to its target stream.
@@ -77,7 +173,7 @@ export type EventSourcingDotOptions = {
77
173
  */
78
174
  readonly commandHandlers?: readonly CommandHandlerRegistration[];
79
175
  /**
80
- * Connection URL for the event store. When omitted, the pip reads
176
+ * Connection URL for the event store. When omitted, the plugin reads
81
177
  * `EVENT_STORE_URL`, `EVENTSTORE_URL`, or `EVENT_DB_URL` from
82
178
  * `process.env`. If none are set, `boot` throws.
83
179
  */
@@ -95,6 +191,10 @@ export type EventSourcingServices = {
95
191
  readonly messageBus: MessageBus;
96
192
  };
97
193
 
194
+ type ControlledMessageBus = MessageBus & {
195
+ close(): Promise<void>;
196
+ };
197
+
98
198
  /**
99
199
  * Resolve the event-store connection URL from explicit options first, then
100
200
  * from the recognised env vars in priority order. Returns `undefined` when
@@ -109,53 +209,228 @@ function resolveDbUrl(explicit: string | undefined): string | undefined {
109
209
  return undefined;
110
210
  }
111
211
 
212
+ function dotPluginError(args: {
213
+ code: (typeof EVENT_SOURCING_PLUGIN_ERROR_CODES)[keyof typeof EVENT_SOURCING_PLUGIN_ERROR_CODES];
214
+ message: string;
215
+ remediation: string;
216
+ docsSlug: string;
217
+ }): DotPluginError {
218
+ return new DotPluginError({
219
+ code: args.code,
220
+ message: args.message,
221
+ remediation: args.remediation,
222
+ docsUrl: `https://arki.dev/event-sourcing/errors/${args.docsSlug}`,
223
+ });
224
+ }
225
+
226
+ function assertUniqueCommandHandlers(commandHandlers: readonly CommandHandlerRegistration[]): void {
227
+ const seen = new Map<string, number>();
228
+ for (const registration of commandHandlers) {
229
+ const count = seen.get(registration.commandType) ?? 0;
230
+ seen.set(registration.commandType, count + 1);
231
+ }
232
+ const duplicate = [...seen.entries()].find(([, count]) => count > 1)?.[0];
233
+ if (duplicate === undefined) return;
234
+
235
+ throw dotPluginError({
236
+ code: EVENT_SOURCING_PLUGIN_ERROR_CODES.duplicateCommandHandler,
237
+ message: `[event-sourcing] command handler "${duplicate}" is registered more than once.`,
238
+ remediation:
239
+ 'Each command type may have exactly one handler across options.bundles and dynamic commandHandlers. Remove one registration or rename the command type.',
240
+ docsSlug: 'event-sourcing-plugin-e002',
241
+ });
242
+ }
243
+
244
+ function controlledMessageBus(inner: MessageBus, commandTypes: ReadonlySet<string>): ControlledMessageBus {
245
+ let closed = false;
246
+ let inflight = 0;
247
+ const waiters: (() => void)[] = [];
248
+
249
+ const finishOne = (): void => {
250
+ inflight -= 1;
251
+ if (inflight !== 0) return;
252
+ const drained = waiters.splice(0);
253
+ for (const waiter of drained) waiter();
254
+ };
255
+
256
+ return {
257
+ async send(command) {
258
+ if (closed) {
259
+ throw dotPluginError({
260
+ code: EVENT_SOURCING_PLUGIN_ERROR_CODES.dispatcherClosed,
261
+ message: `[event-sourcing] command "${command.type}" was dispatched after the message bus closed.`,
262
+ remediation:
263
+ 'Do not send commands after app.stop() begins. Stop ingress before closing event-sourcing consumers.',
264
+ docsSlug: 'event-sourcing-plugin-e005',
265
+ });
266
+ }
267
+ if (!commandTypes.has(command.type)) {
268
+ throw dotPluginError({
269
+ code: EVENT_SOURCING_PLUGIN_ERROR_CODES.commandHandlerMissing,
270
+ message: `[event-sourcing] no command handler is registered for "${command.type}".`,
271
+ remediation:
272
+ 'Declare a feature ES bundle with es.handle(...), publish it before eventSourcing(...), and list its token in options.bundles.',
273
+ docsSlug: 'event-sourcing-plugin-e004',
274
+ });
275
+ }
276
+
277
+ inflight += 1;
278
+ try {
279
+ await inner.send(command);
280
+ } finally {
281
+ finishOne();
282
+ }
283
+ },
284
+ publish: event => inner.publish(event),
285
+ schedule: (message, when) => {
286
+ inner.schedule(message, when);
287
+ },
288
+ close() {
289
+ closed = true;
290
+ if (inflight === 0) return Promise.resolve();
291
+ return new Promise(resolve => {
292
+ waiters.push(resolve);
293
+ });
294
+ },
295
+ };
296
+ }
297
+
112
298
  /**
113
- * Build a DOT pip that opens the event store, wires command handlers
299
+ * Build a DOT plugin that opens the event store, wires command handlers
114
300
  * into an in-memory message bus, and publishes both as services. The
115
301
  * kernel calls `dispose` in reverse declaration order to release the
116
302
  * underlying PG pool.
117
303
  */
118
- export function eventSourcing(options: EventSourcingDotOptions): Pip<EmptyShape, EventSourcingServices> {
119
- const commandHandlers = options.commandHandlers ?? [];
304
+ export function eventSourcing<
305
+ const TBundles extends readonly BundleToken[] = readonly [],
306
+ const TFeatures extends readonly EsFeatureToken[] = readonly [],
307
+ >(
308
+ options: EventSourcingDotOptions<TBundles, TFeatures> = {},
309
+ ): Plugin<BundleNeeds<TBundles> & EsFeatureNeeds<TFeatures>, EventSourcingServices> {
310
+ const bundleTokens = options.bundles ?? [];
311
+ const featureTokens = options.features ?? [];
312
+ const needs: Record<string, BundleToken | EsFeatureToken> = {};
313
+ for (const tok of [...bundleTokens, ...featureTokens]) {
314
+ if (needs[tok.key] !== undefined) {
315
+ throw dotPluginError({
316
+ code: EVENT_SOURCING_PLUGIN_ERROR_CODES.duplicateCommandHandler,
317
+ message: `[event-sourcing] token "${tok.key}" is listed twice across options.bundles/options.features.`,
318
+ remediation: 'List each ES bundle or feature token once.',
319
+ docsSlug: 'event-sourcing-plugin-e002',
320
+ });
321
+ }
322
+ needs[tok.key] = tok;
323
+ }
120
324
 
121
325
  // Captured at boot so dispose can call it without re-reading services
122
326
  // (dispose is allowed to run even when services failed to publish).
123
327
  let closeStore: (() => Promise<void>) | undefined;
328
+ let closeBus: (() => Promise<void>) | undefined;
124
329
 
125
- return pip({
330
+ const inner = plugin<EmptyShape, EventSourcingServices>({
126
331
  name: 'event-sourcing',
127
- version: '0.1.0',
332
+ version: EVENT_SOURCING_PLUGIN_VERSION,
333
+ needs,
128
334
  configure(ctx) {
129
335
  ctx.registerService('eventStore', 'event-store');
130
336
  ctx.registerService('messageBus', 'message-bus');
337
+ ctx.registerProjection({
338
+ format: 'event-catalog',
339
+ binding: 'es',
340
+ module: '@arki/event-sourcing/projection',
341
+ });
131
342
  ctx.declareProvides('event-store', 'message-bus');
132
343
  },
133
- boot(): EventSourcingServices {
134
- // Validate at the pip boundary so the DOT lifecycle gets a coded
344
+ boot(ctx): EventSourcingServices {
345
+ // Validate at the plugin boundary so the DOT lifecycle gets a coded
135
346
  // error. `eventSourcingFeatures.initEventSourcing` still throws raw
136
347
  // `Error` for non-DOT consumers (its public contract is unchanged);
137
348
  // the check here makes sure we never reach it without a URL.
138
349
  const dbUrl = resolveDbUrl(options.dbUrl);
139
350
  if (dbUrl === undefined) {
140
- throw new DotPipError({
141
- code: EVENT_SOURCING_PIP_ERROR_CODES.dbUrlNotConfigured,
351
+ throw new DotPluginError({
352
+ code: EVENT_SOURCING_PLUGIN_ERROR_CODES.dbUrlNotConfigured,
142
353
  message: '[event-sourcing] Event Store database URL is not configured.',
143
354
  remediation: `Pass options.dbUrl to eventSourcing(...) or set one of ${EVENT_STORE_URL_VARIANTS.join(
144
355
  ', ',
145
356
  )} in the environment before booting the app.`,
146
- docsUrl: 'https://arki.dev/dot/errors/event-sourcing-pip-e001',
357
+ docsUrl: 'https://arki.dev/dot/errors/event-sourcing-plugin-e001',
147
358
  });
148
359
  }
149
- const { eventStore, close } = eventSourcingFeatures.initEventSourcing(options.projections, dbUrl);
360
+
361
+ const record = ctx as unknown as Readonly<Record<string, EsBundle | undefined>>;
362
+ const bundles: EsBundle[] = [];
363
+ for (const tok of bundleTokens) {
364
+ const collected = record[tok.key];
365
+ if (collected === undefined) {
366
+ throw dotPluginError({
367
+ code: EVENT_SOURCING_PLUGIN_ERROR_CODES.bundleMissing,
368
+ message: `[event-sourcing] bundle "${tok.key}" was not provided by any earlier plugin.`,
369
+ remediation:
370
+ 'Mount the plugin that publishes this ES bundle before eventSourcing(...). The typed builder enforces this; erased composition reaches this check.',
371
+ docsSlug: 'event-sourcing-plugin-e003',
372
+ });
373
+ }
374
+ bundles.push(collected);
375
+ }
376
+
377
+ const featureRecord = ctx as unknown as Readonly<Record<string, { readonly es?: EsBundle } | undefined>>;
378
+ for (const tok of featureTokens) {
379
+ const slice = featureRecord[tok.key];
380
+ if (slice === undefined) {
381
+ throw dotPluginError({
382
+ code: EVENT_SOURCING_PLUGIN_ERROR_CODES.bundleMissing,
383
+ message: `[event-sourcing] feature "${tok.key}" was not provided by any earlier plugin.`,
384
+ remediation:
385
+ 'Mount the feature plugin that provides this token before eventSourcing(...). The typed builder enforces this; erased composition reaches this check.',
386
+ docsSlug: 'event-sourcing-plugin-e003',
387
+ });
388
+ }
389
+ if (slice.es === undefined) {
390
+ debugBuilder('[event-sourcing] feature "%s" did not contribute an es slice.', tok.key);
391
+ continue;
392
+ }
393
+ bundles.push(slice.es);
394
+ }
395
+
396
+ const readModels = [
397
+ ...(options.projections ?? []),
398
+ ...bundles.flatMap(collected => collected.readModels),
399
+ ];
400
+ const commandHandlers = [
401
+ ...bundles.flatMap(collected => collected.handlers),
402
+ ...(options.commandHandlers ?? []),
403
+ ];
404
+ assertUniqueCommandHandlers(commandHandlers);
405
+
406
+ const { eventStore, close } = eventSourcingFeatures.initEventSourcing(readModels, dbUrl);
150
407
  closeStore = close;
151
- const messageBus = eventSourcingFeatures.initMessageBus(eventStore, [...commandHandlers]);
408
+ const messageBus = controlledMessageBus(
409
+ eventSourcingFeatures.initMessageBus(eventStore, [...commandHandlers]),
410
+ new Set(commandHandlers.map(registration => registration.commandType)),
411
+ );
412
+ closeBus = () => messageBus.close();
152
413
  return { eventStore, messageBus };
153
414
  },
415
+ start() {
416
+ // Marks the plugin active so DOT will run `stop`, where the dispatcher
417
+ // closes before dispose releases the store pool.
418
+ },
419
+ async stop() {
420
+ await closeBus?.();
421
+ },
154
422
  async dispose() {
423
+ await closeBus?.();
424
+ closeBus = undefined;
155
425
  if (closeStore !== undefined) {
156
426
  await closeStore();
157
427
  closeStore = undefined;
158
428
  }
159
429
  },
160
430
  });
431
+
432
+ // Erasure seam: `needs` is assembled from runtime tokens, while the
433
+ // return type re-attaches the token-derived wire shape for the app
434
+ // builder guard. Same pattern as @arki/http's bundle collector.
435
+ return inner as unknown as Plugin<BundleNeeds<TBundles> & EsFeatureNeeds<TFeatures>, EventSourcingServices>;
161
436
  }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * DOT projection entry point for `dot explain --as event-catalog`.
3
+ *
4
+ * Pure manifest in, JSON document out. It intentionally renders only
5
+ * configure-time ES actions: command handlers collected dynamically through
6
+ * deprecated 0.1.x options are executable but manifest-invisible.
7
+ */
8
+
9
+ import type { JsonObject } from './dot-action.js';
10
+ import {
11
+ EVENT_SOURCING_ACTION_META_SCHEMA,
12
+ EventSourcingActionError,
13
+ isJsonObject,
14
+ } from './dot-action.js';
15
+
16
+ type DotActionLike = {
17
+ readonly id: string;
18
+ readonly plugin: string;
19
+ readonly binding: string;
20
+ readonly direction: 'in' | 'out';
21
+ readonly address?: string;
22
+ readonly summary?: string;
23
+ readonly meta?: JsonObject;
24
+ readonly metaSchema?: string;
25
+ };
26
+
27
+ type DotManifestLike = {
28
+ readonly app: {
29
+ readonly name: string;
30
+ readonly version?: string;
31
+ };
32
+ readonly actions: readonly DotActionLike[];
33
+ };
34
+
35
+ type CatalogCommand = JsonObject & {
36
+ readonly type: string;
37
+ readonly address: string;
38
+ readonly summary?: string;
39
+ readonly input: JsonObject;
40
+ };
41
+
42
+ type CatalogEvent = JsonObject & {
43
+ readonly type: string;
44
+ readonly address: string;
45
+ readonly summary?: string;
46
+ readonly data: JsonObject;
47
+ };
48
+
49
+ type CatalogPlugin = JsonObject & {
50
+ readonly name: string;
51
+ readonly commands: CatalogCommand[];
52
+ readonly events: CatalogEvent[];
53
+ };
54
+
55
+ type EventCatalog = JsonObject & {
56
+ readonly format: 'event-catalog';
57
+ readonly app: {
58
+ readonly name: string;
59
+ readonly version?: string;
60
+ };
61
+ readonly plugins: CatalogPlugin[];
62
+ };
63
+
64
+ function projectionError(message: string): EventSourcingActionError {
65
+ return new EventSourcingActionError(message, new TypeError(message));
66
+ }
67
+
68
+ function getGroup(groups: CatalogPlugin[], plugin: string): CatalogPlugin {
69
+ const existing = groups.find(group => group.name === plugin);
70
+ if (existing !== undefined) return existing;
71
+ const created: CatalogPlugin = { name: plugin, commands: [], events: [] };
72
+ groups.push(created);
73
+ return created;
74
+ }
75
+
76
+ function assertMeta(action: DotActionLike): JsonObject {
77
+ if (action.metaSchema !== EVENT_SOURCING_ACTION_META_SCHEMA) {
78
+ throw projectionError(
79
+ `[event-sourcing] action "${action.id}" uses unsupported meta schema "${action.metaSchema ?? 'missing'}".`,
80
+ );
81
+ }
82
+ if (action.meta === undefined) {
83
+ throw projectionError(`[event-sourcing] action "${action.id}" has no event-sourcing metadata.`);
84
+ }
85
+ return action.meta;
86
+ }
87
+
88
+ function commandFromAction(action: DotActionLike, meta: JsonObject): CatalogCommand {
89
+ const input = meta['input'];
90
+ if (meta['kind'] !== 'command' || !isJsonObject(input)) {
91
+ throw projectionError(`[event-sourcing] action "${action.id}" has invalid command metadata.`);
92
+ }
93
+ return {
94
+ type: action.id,
95
+ address: action.address ?? action.id,
96
+ ...(action.summary === undefined ? {} : { summary: action.summary }),
97
+ input,
98
+ };
99
+ }
100
+
101
+ function eventFromAction(action: DotActionLike, meta: JsonObject): CatalogEvent {
102
+ const data = meta['data'];
103
+ if (meta['kind'] !== 'event' || !isJsonObject(data)) {
104
+ throw projectionError(`[event-sourcing] action "${action.id}" has invalid event metadata.`);
105
+ }
106
+ return {
107
+ type: action.id,
108
+ address: action.address ?? action.id,
109
+ ...(action.summary === undefined ? {} : { summary: action.summary }),
110
+ data,
111
+ };
112
+ }
113
+
114
+ export function project(manifest: DotManifestLike): EventCatalog {
115
+ const plugins: CatalogPlugin[] = [];
116
+ for (const action of manifest.actions) {
117
+ if (action.binding !== 'es') continue;
118
+ const meta = assertMeta(action);
119
+ const group = getGroup(plugins, action.plugin);
120
+ if (action.direction === 'in') {
121
+ group.commands.push(commandFromAction(action, meta));
122
+ } else {
123
+ group.events.push(eventFromAction(action, meta));
124
+ }
125
+ }
126
+
127
+ return {
128
+ format: 'event-catalog',
129
+ app: {
130
+ name: manifest.app.name,
131
+ ...(manifest.app.version === undefined ? {} : { version: manifest.app.version }),
132
+ },
133
+ plugins,
134
+ };
135
+ }