@cratis/chronicle 6.34.0 → 6.36.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 (119) hide show
  1. package/ChronicleClient.ts +8 -2
  2. package/EventStore.ts +8 -0
  3. package/dist/ChronicleClient.d.ts.map +1 -1
  4. package/dist/ChronicleClient.js +10 -2
  5. package/dist/ChronicleClient.js.map +1 -1
  6. package/dist/EventStore.d.ts.map +1 -1
  7. package/dist/EventStore.js +8 -0
  8. package/dist/EventStore.js.map +1 -1
  9. package/dist/events/handles.d.ts +12 -0
  10. package/dist/events/handles.d.ts.map +1 -0
  11. package/dist/events/handles.js +33 -0
  12. package/dist/events/handles.js.map +1 -0
  13. package/dist/events/index.d.ts +1 -0
  14. package/dist/events/index.d.ts.map +1 -1
  15. package/dist/events/index.js +1 -0
  16. package/dist/events/index.js.map +1 -1
  17. package/dist/observation/EventHandler.d.ts +7 -0
  18. package/dist/observation/EventHandler.d.ts.map +1 -0
  19. package/dist/observation/EventHandler.js +4 -0
  20. package/dist/observation/EventHandler.js.map +1 -0
  21. package/dist/observation/createHandlerInstanceValidator.d.ts +4 -0
  22. package/dist/observation/createHandlerInstanceValidator.d.ts.map +1 -0
  23. package/dist/observation/createHandlerInstanceValidator.js +40 -0
  24. package/dist/observation/createHandlerInstanceValidator.js.map +1 -0
  25. package/dist/observation/getEventHandlers.d.ts +5 -0
  26. package/dist/observation/getEventHandlers.d.ts.map +1 -0
  27. package/dist/observation/getEventHandlers.js +40 -0
  28. package/dist/observation/getEventHandlers.js.map +1 -0
  29. package/dist/observation/getInstanceMethods.d.ts +4 -0
  30. package/dist/observation/getInstanceMethods.d.ts.map +1 -0
  31. package/dist/observation/getInstanceMethods.js +29 -0
  32. package/dist/observation/getInstanceMethods.js.map +1 -0
  33. package/dist/reactors/ReactorDispatcher.d.ts.map +1 -1
  34. package/dist/reactors/ReactorDispatcher.js +27 -32
  35. package/dist/reactors/ReactorDispatcher.js.map +1 -1
  36. package/dist/reactors/Reactors.d.ts +1 -0
  37. package/dist/reactors/Reactors.d.ts.map +1 -1
  38. package/dist/reactors/Reactors.js +27 -14
  39. package/dist/reactors/Reactors.js.map +1 -1
  40. package/dist/reactors/index.d.ts +1 -0
  41. package/dist/reactors/index.d.ts.map +1 -1
  42. package/dist/reactors/index.js +1 -0
  43. package/dist/reactors/index.js.map +1 -1
  44. package/dist/reducers/ReducerEventDispatcher.d.ts +1 -0
  45. package/dist/reducers/ReducerEventDispatcher.d.ts.map +1 -1
  46. package/dist/reducers/ReducerEventDispatcher.js +5 -17
  47. package/dist/reducers/ReducerEventDispatcher.js.map +1 -1
  48. package/dist/reducers/ReducerEventHandler.d.ts +3 -6
  49. package/dist/reducers/ReducerEventHandler.d.ts.map +1 -1
  50. package/dist/reducers/Reducers.d.ts +1 -0
  51. package/dist/reducers/Reducers.d.ts.map +1 -1
  52. package/dist/reducers/Reducers.js +22 -10
  53. package/dist/reducers/Reducers.js.map +1 -1
  54. package/dist/reducers/index.d.ts +1 -0
  55. package/dist/reducers/index.d.ts.map +1 -1
  56. package/dist/reducers/index.js +1 -0
  57. package/dist/reducers/index.js.map +1 -1
  58. package/dist/testing/ReactorScenario.d.ts +1 -0
  59. package/dist/testing/ReactorScenario.d.ts.map +1 -1
  60. package/dist/testing/ReactorScenario.js +9 -1
  61. package/dist/testing/ReactorScenario.js.map +1 -1
  62. package/dist/testing/ReducerReadModelProcessor.d.ts.map +1 -1
  63. package/dist/testing/ReducerReadModelProcessor.js +1 -0
  64. package/dist/testing/ReducerReadModelProcessor.js.map +1 -1
  65. package/dist/testing/projections/ProjectionArithmetic.d.ts +11 -0
  66. package/dist/testing/projections/ProjectionArithmetic.d.ts.map +1 -0
  67. package/dist/testing/projections/ProjectionArithmetic.js +70 -0
  68. package/dist/testing/projections/ProjectionArithmetic.js.map +1 -0
  69. package/dist/testing/projections/ProjectionCapabilities.d.ts.map +1 -1
  70. package/dist/testing/projections/ProjectionCapabilities.js +26 -10
  71. package/dist/testing/projections/ProjectionCapabilities.js.map +1 -1
  72. package/dist/testing/projections/ProjectionReadModelProcessor.d.ts.map +1 -1
  73. package/dist/testing/projections/ProjectionReadModelProcessor.js +10 -3
  74. package/dist/testing/projections/ProjectionReadModelProcessor.js.map +1 -1
  75. package/dist/tsconfig.tsbuildinfo +1 -1
  76. package/events/for_handles/when_decorating_a_method.legacy.spec.ts +91 -0
  77. package/events/for_handles/when_decorating_a_method.standard.spec.ts +102 -0
  78. package/events/handles.ts +37 -0
  79. package/events/index.ts +1 -0
  80. package/for_ChronicleClient/when_registering_artifacts/with_invalid_handler_mappings.spec.ts +142 -0
  81. package/observation/EventHandler.ts +9 -0
  82. package/observation/createHandlerInstanceValidator.ts +42 -0
  83. package/observation/for_getEventHandlers/when_discovering_handlers.spec.ts +137 -0
  84. package/observation/for_getEventHandlers/when_reobserving_explicit_handlers.spec.ts +73 -0
  85. package/observation/getEventHandlers.ts +43 -0
  86. package/observation/getInstanceMethods.ts +29 -0
  87. package/package.json +2 -2
  88. package/reactors/ReactorDispatcher.ts +26 -30
  89. package/reactors/Reactors.ts +32 -16
  90. package/reactors/decorators.standard.spec.ts +8 -0
  91. package/reactors/for_ReactorDispatcher/when_selecting_explicit_handlers.spec.ts +64 -0
  92. package/reactors/for_Reactors/when_activating_a_field_override.spec.ts +62 -0
  93. package/reactors/for_Reactors/when_delivering_to_instance_handlers.spec.ts +108 -0
  94. package/reactors/for_Reactors/when_observing_explicit_handlers.legacy.spec.ts +77 -0
  95. package/reactors/for_Reactors/when_registering_explicit_handlers.spec.ts +53 -0
  96. package/reactors/index.ts +1 -0
  97. package/reducers/ReducerEventDispatcher.ts +5 -16
  98. package/reducers/ReducerEventHandler.ts +4 -6
  99. package/reducers/Reducers.ts +24 -12
  100. package/reducers/for_ReducerEventDispatcher/when_invoking_an_explicit_handler.spec.ts +51 -0
  101. package/reducers/for_ReducerEventDispatcher/when_invoking_instance_handlers.spec.ts +69 -0
  102. package/reducers/for_Reducers/when_activating_a_field_override.spec.ts +63 -0
  103. package/reducers/for_Reducers/when_observing_explicit_handlers.legacy.spec.ts +73 -0
  104. package/reducers/for_Reducers/when_registering_explicit_handlers.spec.ts +57 -0
  105. package/reducers/index.ts +1 -0
  106. package/testing/ReactorScenario.ts +9 -1
  107. package/testing/ReducerReadModelProcessor.ts +1 -0
  108. package/testing/for_ReactorScenario/when_delivering_to_an_explicit_handler.spec.ts +100 -0
  109. package/testing/for_ReadModelScenario/when_evaluating_arithmetic/with_a_fluent_projection.spec.ts +85 -0
  110. package/testing/for_ReadModelScenario/when_evaluating_arithmetic/with_legacy_decorators.legacy.spec.ts +91 -0
  111. package/testing/for_ReadModelScenario/when_evaluating_arithmetic/with_standard_decorators.standard.spec.ts +92 -0
  112. package/testing/for_ReducerReadModelProcessor/when_an_instance_field_hides_a_handler.spec.ts +29 -0
  113. package/testing/projections/ProjectionArithmetic.ts +71 -0
  114. package/testing/projections/ProjectionCapabilities.ts +27 -9
  115. package/testing/projections/ProjectionReadModelProcessor.fixtures.spec.ts +9 -6
  116. package/testing/projections/ProjectionReadModelProcessor.ts +9 -3
  117. package/testing/projections/for_ProjectionCapabilities/when_rejecting_unsupported_operations.spec.ts +3 -3
  118. package/testing/projections/for_ProjectionCapabilities/when_validating_arithmetic.spec.ts +127 -0
  119. package/testing/projections/for_ProjectionCapabilities/when_validating_numeric_schemas.spec.ts +4 -5
@@ -0,0 +1,42 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import type { Constructor } from '@cratis/fundamentals';
5
+ import { getHandledEventType } from '../events/handles.js';
6
+ import { getInstanceMethods } from './getInstanceMethods.js';
7
+
8
+ /** Captures @handles prototype methods during preflight and rejects instance properties hiding them, except bound methods. */
9
+ export function createHandlerInstanceValidator(type: Constructor, names: Iterable<string>, methods = getInstanceMethods(type)):
10
+ (instance: object) => void {
11
+ const handlers = [...new Set(names)].filter(name => getHandledEventType(methods.get(name)!) !== undefined).map(name => {
12
+ const method = methods.get(name)!;
13
+ let declaring = type.prototype;
14
+ while (declaring && Object.getOwnPropertyDescriptor(declaring, name)?.value !== method) {
15
+ declaring = Object.getPrototypeOf(declaring);
16
+ }
17
+ const eventType = getHandledEventType(method)!;
18
+ const declaration = `@handles(${(eventType as Function).name})`;
19
+ return { name, method, declaration, declaringType: declaring?.constructor.name ?? type.name };
20
+ });
21
+ return instance => {
22
+ for (const { name, method, declaration, declaringType } of handlers) {
23
+ let current: object | null = instance;
24
+ while (current && !Object.hasOwn(current, name)) current = Object.getPrototypeOf(current) as object | null;
25
+ const value: unknown = current ? Object.getOwnPropertyDescriptor(current, name)?.value : undefined;
26
+ if (current === instance && typeof value === 'function' && isBoundCopyOf(value, method)) continue;
27
+ if (current === instance || !current || value !== method) {
28
+ throw new Error(`Override '${name}' on '${type.name}' hides ${declaration} declared on '${declaringType}'; use a method with @handles instead of an instance field.`);
29
+ }
30
+ }
31
+ };
32
+ }
33
+
34
+ /**
35
+ * Checks whether a function is a (possibly repeatedly) Function.prototype.bind copy of a method.
36
+ * @param value - The function found on the instance.
37
+ * @param method - The declared method.
38
+ * @returns True when the function name is one or more `bound ` prefixes followed by the method name.
39
+ */
40
+ function isBoundCopyOf(value: Function, method: Function): boolean {
41
+ return value.name.startsWith('bound ') && value.name.replace(/^(bound )+/, '') === method.name;
42
+ }
@@ -0,0 +1,137 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { beforeEach, chai, describe, it } from 'vitest';
5
+ import { eventType } from '../../events/eventTypeDecorator.js';
6
+ import { handles } from '../../events/handles.js';
7
+ import { getReactorEventTypes } from '../../reactors/ReactorDispatcher.js';
8
+ import { ReducerEventDispatcher } from '../../reducers/ReducerEventDispatcher.js';
9
+
10
+ chai.should();
11
+
12
+ @eventType('explicit-author', 2)
13
+ class AuthorRegistered {}
14
+ @eventType('conventional-book')
15
+ class BookBorrowed {}
16
+
17
+ const registeredTypes = [AuthorRegistered, BookBorrowed];
18
+ const discoverers = [
19
+ { name: 'reactor', discover: getReactorEventTypes },
20
+ { name: 'reducer', discover: (type: new () => object, types: readonly (new () => object)[]) => new ReducerEventDispatcher(type, types).handlers }
21
+ ];
22
+
23
+ for (const { name, discover } of discoverers) {
24
+ describe(`when discovering explicit and conventional ${name} handlers`, () => {
25
+ class Observer {
26
+ @handles(AuthorRegistered)
27
+ notify() {}
28
+ bookBorrowed() {}
29
+ helper() {}
30
+ get unrelated() { throw new Error('A getter must not run during discovery'); }
31
+ }
32
+ let entries: ReturnType<typeof getReactorEventTypes>;
33
+ beforeEach(() => { entries = discover(Observer, registeredTypes); });
34
+ it('should include both event types without treating helpers as handlers', () => {
35
+ entries.should.deep.equal([
36
+ { id: 'explicit-author', generation: 2, methodName: 'notify' },
37
+ { id: 'conventional-book', generation: 1, methodName: 'bookBorrowed' }
38
+ ]);
39
+ });
40
+ });
41
+
42
+ describe(`when an explicit ${name} method has another event's conventional name`, () => {
43
+ class Observer { @handles(AuthorRegistered) bookBorrowed() {} }
44
+ it('should use only the explicit event type', () => {
45
+ discover(Observer, registeredTypes).should.deep.equal([{ id: 'explicit-author', generation: 2, methodName: 'bookBorrowed' }]);
46
+ });
47
+ });
48
+
49
+ describe(`when an explicit ${name} method has its own conventional name`, () => {
50
+ class Observer { @handles(AuthorRegistered) authorRegistered() {} }
51
+ it('should register it only once', () => {
52
+ discover(Observer, registeredTypes).should.have.lengthOf(1);
53
+ });
54
+ });
55
+
56
+ describe(`when inheriting an explicit ${name} handler`, () => {
57
+ class Base { @handles(AuthorRegistered) notify() {} }
58
+ class Inherited extends Base {}
59
+ class Overridden extends Base { notify() {} }
60
+ class Remapped extends Base { @handles(BookBorrowed) notify() {} }
61
+ it('should discover the inherited handler', () => {
62
+ discover(Inherited, registeredTypes)[0].methodName!.should.equal('notify');
63
+ });
64
+ it('should reject an override that hides the base declaration', () => {
65
+ (() => discover(Overridden, registeredTypes)).should.throw(
66
+ "Override 'notify' on 'Overridden' hides @handles(AuthorRegistered) declared on 'Base'; redecorate the override.");
67
+ });
68
+ it('should reject an inherited undecorated override across multiple levels', () => {
69
+ class Derived extends Overridden {}
70
+ (() => discover(Derived, registeredTypes)).should.throw(
71
+ "Override 'notify' on 'Overridden' hides @handles(AuthorRegistered) declared on 'Base'; redecorate the override.");
72
+ });
73
+ it('should allow an override explicitly keeping the same event type', () => {
74
+ class Reaffirmed extends Base { @handles(AuthorRegistered) notify() {} }
75
+ discover(Reaffirmed, registeredTypes).should.deep.equal([{ id: 'explicit-author', generation: 2, methodName: 'notify' }]);
76
+ });
77
+ it('should use the override event type instead of the base declaration', () => {
78
+ discover(Remapped, registeredTypes).should.deep.equal([{ id: 'conventional-book', generation: 1, methodName: 'notify' }]);
79
+ });
80
+ });
81
+
82
+ describe(`when explicit ${name} event classes share a name`, () => {
83
+ const OtherAuthorRegistered = class AuthorRegistered {};
84
+ eventType('another-author')(OtherAuthorRegistered);
85
+ class Observer {
86
+ @handles(AuthorRegistered) notify() {}
87
+ @handles(OtherAuthorRegistered) archive() {}
88
+ }
89
+ it('should resolve event constructors rather than class names', () => {
90
+ discover(Observer, [AuthorRegistered, OtherAuthorRegistered]).map(entry => entry.id)
91
+ .should.deep.equal(['explicit-author', 'another-author']);
92
+ });
93
+ });
94
+
95
+ describe(`when an explicit ${name} event type is not registered`, () => {
96
+ class Observer { @handles(AuthorRegistered) notify() {} }
97
+ it('should name the observer method and missing event constructor', () => {
98
+ (() => discover(Observer, [BookBorrowed])).should.throw(/notify.*Observer.*AuthorRegistered.*no registered event type/);
99
+ });
100
+ it('should not accept a different constructor with the same event id', () => {
101
+ @eventType('explicit-author', 2)
102
+ class OtherAuthor {}
103
+ (() => discover(Observer, [OtherAuthor])).should.throw(/notify.*no registered event type/);
104
+ });
105
+ it('should not accept a listed class without event metadata', () => {
106
+ class Unknown {}
107
+ class Invalid { @handles(Unknown) notify() {} }
108
+ (() => discover(Invalid, [Unknown])).should.throw(/notify.*Unknown.*no registered event type/);
109
+ });
110
+ });
111
+
112
+ describe(`when two ${name} methods handle one event type`, () => {
113
+ class Explicit {
114
+ @handles(AuthorRegistered) notify() {}
115
+ @handles(AuthorRegistered) archive() {}
116
+ }
117
+ class Mixed {
118
+ @handles(AuthorRegistered) notify() {}
119
+ authorRegistered() {}
120
+ }
121
+ class Base { @handles(AuthorRegistered) notify() {} }
122
+ class Inherited extends Base { @handles(AuthorRegistered) archive() {} }
123
+ class Conventional extends Base { authorRegistered() {} }
124
+ it('should reject two explicit handlers', () => {
125
+ (() => discover(Explicit, registeredTypes)).should.throw(/multiple handlers.*explicit-author.*notify.*archive/);
126
+ });
127
+ it('should reject an explicit handler and a conventional handler', () => {
128
+ (() => discover(Mixed, registeredTypes)).should.throw(/multiple handlers.*explicit-author.*notify.*authorRegistered/);
129
+ });
130
+ it('should include inherited methods in duplicate detection', () => {
131
+ (() => discover(Inherited, registeredTypes)).should.throw(/multiple handlers.*explicit-author.*archive.*notify/);
132
+ });
133
+ it('should reject a conventional handler duplicating an inherited explicit handler', () => {
134
+ (() => discover(Conventional, registeredTypes)).should.throw(/multiple handlers.*explicit-author.*notify.*authorRegistered/);
135
+ });
136
+ });
137
+ }
@@ -0,0 +1,73 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import type { ReactorMessage, ReducerMessage } from '@cratis/chronicle.contracts';
5
+ import { afterEach, beforeEach, chai, describe, it, vi } from 'vitest';
6
+ import type { IClientArtifactsProvider } from '../../artifacts/index.js';
7
+ import type { ChronicleConnection } from '../../connection/index.js';
8
+ import { ConnectionLifecycle } from '../../connection/ConnectionLifecycle.js';
9
+ import type { IEventLog } from '../../eventSequences/IEventLog.js';
10
+ import { eventType } from '../../events/eventTypeDecorator.js';
11
+ import { handles } from '../../events/handles.js';
12
+ import { onceOnly, reactor, Reactors } from '../../reactors/index.js';
13
+ import { reducer, Reducers } from '../../reducers/index.js';
14
+
15
+ chai.should();
16
+
17
+ @eventType('reobserved-event')
18
+ class AuthorRegistered {}
19
+
20
+ @onceOnly()
21
+ @reactor('reobserved-reactor')
22
+ class AuthorReactor { @handles(AuthorRegistered) notify() {} }
23
+
24
+ @reducer('reobserved-reducer')
25
+ class AuthorReducer { @handles(AuthorRegistered) update() {} }
26
+
27
+ for (const kind of ['reactor', 'reducer'] as const) {
28
+ describe(`when reobserving an explicit ${kind} handler`, () => {
29
+ const registrations: (ReactorMessage | ReducerMessage)[] = [];
30
+ let observations: Reactors | Reducers;
31
+ beforeEach(async () => {
32
+ vi.useFakeTimers();
33
+ registrations.length = 0;
34
+ const lifecycle = new ConnectionLifecycle();
35
+ async function* stream(queue: AsyncIterable<ReactorMessage | ReducerMessage>) {
36
+ registrations.push((await queue[Symbol.asyncIterator]().next()).value!);
37
+ yield { Events: [], Partition: 'author', ReplayState: 0, InitialState: '' };
38
+ }
39
+ const connection = {
40
+ reactors: { observe: stream }, reducers: { observe: stream },
41
+ readModels: { registerMany: async () => ({}) }
42
+ } as unknown as ChronicleConnection;
43
+ const artifacts = {
44
+ reactors: [AuthorReactor], reducers: [AuthorReducer], eventTypes: [AuthorRegistered], readModels: []
45
+ } as unknown as IClientArtifactsProvider;
46
+ observations = kind === 'reactor'
47
+ ? new Reactors(artifacts, connection, 'store', 'tenant', lifecycle, {} as IEventLog)
48
+ : new Reducers(artifacts, connection, 'store', 'tenant', lifecycle, 'sink');
49
+ await observations.register();
50
+ await vi.advanceTimersByTimeAsync(0);
51
+ // Changing the catalog must not invalidate an already validated observation on its timer callback.
52
+ artifacts.eventTypes.splice(0);
53
+ await vi.advanceTimersByTimeAsync(2000);
54
+ });
55
+ afterEach(() => {
56
+ observations.dispose();
57
+ vi.clearAllTimers();
58
+ vi.useRealTimers();
59
+ });
60
+ it('should reuse the registered handler mapping when the stream ends', () => {
61
+ registrations.should.have.lengthOf(2);
62
+ registrations[1].should.deep.equal(registrations[0]);
63
+ const registration = registrations[1].Content!.Value0!;
64
+ const observer = 'Reactor' in registration ? registration.Reactor : registration.Reducer;
65
+ observer!.EventTypes.map(entry => entry.EventType!.Id).should.deep.equal(['reobserved-event']);
66
+ });
67
+ if (kind === 'reactor') {
68
+ it('should register a class-level onceOnly reactor with an explicit handler as non-replayable', () => {
69
+ (registrations[0] as ReactorMessage).Content!.Value0!.Reactor!.IsReplayable.should.be.false;
70
+ });
71
+ }
72
+ });
73
+ }
@@ -0,0 +1,43 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import type { Constructor } from '@cratis/fundamentals';
5
+ import { getEventTypeMetadata } from '../events/eventTypeDecorator.js';
6
+ import { getHandledEventType } from '../events/handles.js';
7
+ import type { EventHandler } from './EventHandler.js';
8
+ import { getInstanceMethods } from './getInstanceMethods.js';
9
+
10
+ /** Resolves explicit event constructors first, with camelCase discovery for undecorated methods. */
11
+ export function getEventHandlers(type: Constructor, registeredTypes: readonly Constructor[], methods = getInstanceMethods(type)): EventHandler[] {
12
+ const eventTypes = new Map(registeredTypes.flatMap(eventType => {
13
+ const metadata = getEventTypeMetadata(eventType);
14
+ return metadata ? [[eventType, metadata.eventType] as const] : [];
15
+ }));
16
+ const handlers = new Map<string, EventHandler>();
17
+ const addHandler = (eventType: Constructor, methodName: string) => {
18
+ const metadata = eventTypes.get(eventType)!;
19
+ const id = metadata.id.value;
20
+ const previous = handlers.get(id);
21
+ if (previous && previous.methodName !== methodName) {
22
+ throw new Error(`'${(type as Function).name}' has multiple handlers for event type '${id}': '${previous.methodName}' and '${methodName}'.`);
23
+ }
24
+ handlers.set(id, { id, generation: metadata.generation.value, methodName });
25
+ };
26
+
27
+ for (const [name, method] of methods) {
28
+ const eventType = getHandledEventType(method);
29
+ if (eventType === undefined) continue;
30
+ if (!eventTypes.has(eventType)) {
31
+ throw new Error(`Handler '${name}' on '${(type as Function).name}' names event type '${(eventType as Function).name}', which has no registered event type. Register that event constructor with the event store.`);
32
+ }
33
+ addHandler(eventType, name);
34
+ }
35
+
36
+ for (const eventType of eventTypes.keys()) {
37
+ const className = (eventType as Function).name;
38
+ const name = className.charAt(0).toLowerCase() + className.slice(1);
39
+ const method = methods.get(name);
40
+ if (method && getHandledEventType(method) === undefined) addHandler(eventType, name);
41
+ }
42
+ return [...handlers.values()];
43
+ }
@@ -0,0 +1,29 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import type { Constructor } from '@cratis/fundamentals';
5
+ import { getHandledEventType } from '../events/handles.js';
6
+
7
+ /** Finds prototype methods without invoking getters; explicit handlers require explicitly decorated overrides. */
8
+ export function getInstanceMethods(type: Constructor): Map<string, Function> {
9
+ const methods = new Map<string, Function>();
10
+ const declaringTypes = new Map<string, string>();
11
+ for (let current = type.prototype; current && current !== Object.prototype; current = Object.getPrototypeOf(current)) {
12
+ for (const name of Object.getOwnPropertyNames(current)) {
13
+ if (name === 'constructor') continue;
14
+ const method = Object.getOwnPropertyDescriptor(current, name)?.value;
15
+ const declaringType = declaringTypes.get(name);
16
+ if (declaringType !== undefined) {
17
+ const eventType = typeof method === 'function' ? getHandledEventType(method) : undefined;
18
+ const override = methods.get(name);
19
+ if (eventType && (!override || getHandledEventType(override) === undefined)) {
20
+ throw new Error(`Override '${name}' on '${declaringType}' hides @handles(${(eventType as Function).name}) declared on '${current.constructor.name}'; redecorate the override.`);
21
+ }
22
+ continue;
23
+ }
24
+ declaringTypes.set(name, current.constructor.name);
25
+ if (typeof method === 'function') methods.set(name, method);
26
+ }
27
+ }
28
+ return methods;
29
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cratis/chronicle",
3
- "version": "6.34.0",
3
+ "version": "6.36.0",
4
4
  "description": "Event sourcing for TypeScript and Node.js — the idiomatic client for Cratis Chronicle, the open-source event-sourcing database",
5
5
  "author": "Cratis",
6
6
  "license": "MIT",
@@ -168,7 +168,7 @@
168
168
  "clean": "rm -rf dist",
169
169
  "test": "vitest run",
170
170
  "test:integration": "vitest run --config vitest.integration.config.ts",
171
- "test:standard-types": "tsc --ignoreConfig --noEmit --target es2022 --module es2022 --moduleResolution bundler --lib es2022,esnext.decorators --types node --skipLibCheck --strict schemas/decorators.standard.spec.ts projections/declarative/addChildCompatibility.standard.spec.ts projections/modelBound/decorators.standard.spec.ts events/classDecorators.standard.spec.ts reactors/decorators.standard.spec.ts readModels/standardFallbacks.standard.spec.ts readModels/registration.standard.spec.ts for_ChronicleClient/when_registering_artifacts/with_standard_property_mappings.standard.spec.ts artifacts/validateArtifactSchemas.spec.ts testing/for_EventScenario/when_appending_concept_fields/with_standard_decorators.standard.spec.ts testing/for_EventScenario/when_appending_scoped_constraints/with_standard_decorators.standard.spec.ts schemas/for_JsonSchemaGenerator/when_generating_children/with_standard_decorators.standard.spec.ts",
171
+ "test:standard-types": "tsc --ignoreConfig --noEmit --target es2022 --module es2022 --moduleResolution bundler --lib es2022,esnext.decorators --types node --skipLibCheck --strict schemas/decorators.standard.spec.ts projections/declarative/addChildCompatibility.standard.spec.ts projections/modelBound/decorators.standard.spec.ts events/classDecorators.standard.spec.ts reactors/decorators.standard.spec.ts readModels/standardFallbacks.standard.spec.ts readModels/registration.standard.spec.ts for_ChronicleClient/when_registering_artifacts/with_standard_property_mappings.standard.spec.ts artifacts/validateArtifactSchemas.spec.ts testing/for_EventScenario/when_appending_concept_fields/with_standard_decorators.standard.spec.ts testing/for_EventScenario/when_appending_scoped_constraints/with_standard_decorators.standard.spec.ts schemas/for_JsonSchemaGenerator/when_generating_children/with_standard_decorators.standard.spec.ts events/for_handles/when_decorating_a_method.standard.spec.ts testing/for_ReadModelScenario/when_evaluating_arithmetic/with_standard_decorators.standard.spec.ts",
172
172
  "test:native": "node ./scripts/check-native-esm.mjs"
173
173
  },
174
174
  "dependencies": {
@@ -8,6 +8,9 @@ import { ArtifactDelivery } from '../artifacts/ArtifactDelivery.js';
8
8
  import { runActivated } from '../artifacts/withActivatedArtifact.js';
9
9
  import type { EventContext } from '../events/EventContext.js';
10
10
  import { getEventTypeMetadata } from '../events/eventTypeDecorator.js';
11
+ import { getHandledEventType } from '../events/handles.js';
12
+ import { getEventHandlers } from '../observation/getEventHandlers.js';
13
+ import { getInstanceMethods } from '../observation/getInstanceMethods.js';
11
14
  import { isOnceOnly } from './onceOnly.js';
12
15
  import { getReplayEventType } from './replay.js';
13
16
  import type { ReactorServices } from './ReactorServices.js';
@@ -21,8 +24,7 @@ export interface ReactorEventTypeEntry {
21
24
  }
22
25
 
23
26
  export function getReactorEventTypes(reactorType: Constructor, registeredTypes: readonly Constructor[]): ReactorEventTypeEntry[] {
24
- const proto = reactorType.prototype as Record<string, unknown>;
25
- const entries: ReactorEventTypeEntry[] = [];
27
+ const methods = getInstanceMethods(reactorType);
26
28
  const replayHandlers = new Map<string, string>();
27
29
  const eventTypes = new Map<string, { eventTypeClass: Function; id: string; generation: number }>();
28
30
 
@@ -37,40 +39,34 @@ export function getReactorEventTypes(reactorType: Constructor, registeredTypes:
37
39
  }
38
40
  }
39
41
 
40
- // A derived method shadows a base method of the same name, even if it is not marked for replay.
41
- const seenMethods = new Set<string>();
42
- for (let current = proto; current && current !== Object.prototype; current = Object.getPrototypeOf(current) as Record<string, unknown>) {
43
- for (const name of Object.getOwnPropertyNames(current)) {
44
- if (seenMethods.has(name)) continue;
45
- seenMethods.add(name);
46
- const method = current[name];
47
- if (typeof method !== 'function') continue;
48
- const replayEventType = getReplayEventType(method);
49
- if (replayEventType === undefined) continue;
50
-
51
- const eventType = replayEventType === true
52
- ? eventTypes.get(name.startsWith('replay') ? name.slice('replay'.length) : '')
53
- : [...eventTypes.values()].find(candidate => candidate.eventTypeClass === replayEventType);
54
- if (!eventType) {
55
- throw new Error(`Replay handler '${name}' on reactor '${(reactorType as Function).name}' has no registered event type.`);
56
- }
57
- if (replayHandlers.has(eventType.id)) {
58
- throw new Error(`Reactor '${(reactorType as Function).name}' has multiple replay handlers for event type '${eventType.id}': '${replayHandlers.get(eventType.id)}' and '${name}'.`);
59
- }
60
- replayHandlers.set(eventType.id, name);
42
+ for (const [name, method] of methods) {
43
+ const replayEventType = getReplayEventType(method);
44
+ if (replayEventType === undefined) continue;
45
+ if (getHandledEventType(method) !== undefined) {
46
+ throw new Error(`Handler '${name}' on reactor '${(reactorType as Function).name}' cannot combine @handles and @replay. Use separate live and replay methods.`);
47
+ }
48
+ const eventType = replayEventType === true
49
+ ? eventTypes.get(name.startsWith('replay') ? name.slice('replay'.length) : '')
50
+ : [...eventTypes.values()].find(candidate => candidate.eventTypeClass === replayEventType);
51
+ if (!eventType) {
52
+ throw new Error(`Replay handler '${name}' on reactor '${(reactorType as Function).name}' has no registered event type.`);
53
+ }
54
+ if (replayHandlers.has(eventType.id)) {
55
+ throw new Error(`Reactor '${(reactorType as Function).name}' has multiple replay handlers for event type '${eventType.id}': '${replayHandlers.get(eventType.id)}' and '${name}'.`);
61
56
  }
57
+ replayHandlers.set(eventType.id, name);
58
+ methods.delete(name);
62
59
  }
63
60
 
64
- for (const [className, eventType] of eventTypes) {
65
- const methodName = className.charAt(0).toLowerCase() + className.slice(1);
66
- const liveMethod = proto[methodName];
67
- const liveMethodName = typeof liveMethod === 'function' && getReplayEventType(liveMethod) === undefined ? methodName : undefined;
61
+ const entries = new Map<string, ReactorEventTypeEntry>(getEventHandlers(reactorType, registeredTypes, methods)
62
+ .map(handler => [handler.id, handler]));
63
+ for (const eventType of eventTypes.values()) {
68
64
  const replayMethodName = replayHandlers.get(eventType.id);
69
- if (liveMethodName || replayMethodName) {
70
- entries.push({ id: eventType.id, generation: eventType.generation, methodName: liveMethodName, replayMethodName });
65
+ if (replayMethodName) {
66
+ entries.set(eventType.id, { id: eventType.id, generation: eventType.generation, ...entries.get(eventType.id), replayMethodName });
71
67
  }
72
68
  }
73
- return entries;
69
+ return [...entries.values()];
74
70
  }
75
71
 
76
72
  export function selectReactorHandler(entries: readonly ReactorEventTypeEntry[], reactorType: Constructor,
@@ -14,6 +14,7 @@ import { getTagsFor } from '../events/tagDecorator.js';
14
14
  import { getFilterTagsFor } from '../events/filterEventsByTagDecorator.js';
15
15
  import { EventSequenceId } from '../eventSequences/EventSequenceId.js';
16
16
  import type { IEventLog } from '../eventSequences/IEventLog.js';
17
+ import { createHandlerInstanceValidator } from '../observation/createHandlerInstanceValidator.js';
17
18
  import { notifyReplayLifecycle } from '../observation/notifyReplayLifecycle.js';
18
19
  import { IReactors } from './IReactors.js';
19
20
  import { dispatchReactorSideEffects } from './ReactorSideEffects.js';
@@ -105,6 +106,7 @@ export class Reactors implements IReactors {
105
106
 
106
107
  private readonly _logger: ReturnType<typeof createLogger>;
107
108
  private readonly _lifecycle: ConnectionLifecycle;
109
+ private readonly _unsubscribeDisconnected: () => void;
108
110
  private readonly _reactors = new Map<string, Constructor>();
109
111
  private readonly _queues = new Map<string, AsyncQueue<ReactorMessage>>();
110
112
  private readonly _observations = new Map<string, AbortController>();
@@ -133,7 +135,7 @@ export class Reactors implements IReactors {
133
135
  ) {
134
136
  this._logger = createLogger('@cratis/chronicle/reactors', _connection.logger);
135
137
  this._lifecycle = lifecycle;
136
- lifecycle.onDisconnected(async () => {
138
+ this._unsubscribeDisconnected = lifecycle.onDisconnected(async () => {
137
139
  this._logger.info('Disconnected — stopping all reactor observations');
138
140
  this._registered = false;
139
141
  this.disconnectAll();
@@ -142,6 +144,7 @@ export class Reactors implements IReactors {
142
144
 
143
145
  /** Stops observations permanently when the owning client is disposed. */
144
146
  dispose(): void {
147
+ this._unsubscribeDisconnected();
145
148
  this._disposed = true;
146
149
  this._registered = false;
147
150
  this.disconnectAll();
@@ -170,20 +173,24 @@ export class Reactors implements IReactors {
170
173
  }
171
174
 
172
175
  if (this._disposed) return;
176
+ const registrations = [...this._reactors].map(([id, type]) => ({
177
+ id, type, eventTypes: getReactorEventTypes(type, this._clientArtifacts.eventTypes)
178
+ })).map(registration => ({ ...registration, validateInstance: createHandlerInstanceValidator(registration.type,
179
+ registration.eventTypes.flatMap(entry => [entry.methodName, entry.replayMethodName].filter((name): name is string => !!name))) }));
173
180
  this._logger.info('Registering reactors', { count: this._reactors.size });
174
- for (const [id, reactorType] of this._reactors) {
175
- this.startObservation(id, reactorType);
181
+ for (const { id, type, eventTypes, validateInstance } of registrations) {
182
+ this.startObservation(id, type, eventTypes, validateInstance);
176
183
  }
177
184
 
178
185
  this._registered = true;
179
186
  }
180
187
 
181
- private startObservation(id: string, reactorType: Constructor): void {
188
+ private startObservation(id: string, reactorType: Constructor,
189
+ eventTypes: ReactorEventTypeEntry[], validateInstance: (instance: object) => void): void {
182
190
  runInBackgroundContext(() => {
183
191
  if (this._disposed) return;
184
192
  const metadata = getReactorMetadata(reactorType)!;
185
193
  const eventSequenceId = metadata.eventSequenceId ?? EventSequenceId.eventLog.value;
186
- const eventTypes = getReactorEventTypes(reactorType, this._clientArtifacts.eventTypes);
187
194
 
188
195
  this._logger.info('Starting reactor observation', {
189
196
  reactorId: id,
@@ -192,7 +199,7 @@ export class Reactors implements IReactors {
192
199
  handlers: eventTypes.map(e => e.methodName)
193
200
  });
194
201
 
195
- void this.runObservation(id, reactorType, eventSequenceId, eventTypes);
202
+ void this.runObservation(id, reactorType, eventSequenceId, eventTypes, validateInstance);
196
203
  });
197
204
  }
198
205
 
@@ -200,15 +207,16 @@ export class Reactors implements IReactors {
200
207
  id: string,
201
208
  reactorType: Constructor,
202
209
  eventSequenceId: string,
203
- eventTypes: ReactorEventTypeEntry[]
210
+ eventTypes: ReactorEventTypeEntry[],
211
+ validateInstance: (instance: object) => void
204
212
  ): Promise<void> {
205
213
  try {
206
- await this.observeReactor(id, reactorType, eventSequenceId, eventTypes);
214
+ await this.observeReactor(id, reactorType, eventSequenceId, eventTypes, validateInstance);
207
215
  } catch (error) {
208
216
  this._logger.error('Reactor observation loop exited with error', { reactorId: id, error });
209
217
  }
210
218
 
211
- this.scheduleReobserve(id, reactorType);
219
+ this.scheduleReobserve(id, reactorType, eventTypes, validateInstance);
212
220
  }
213
221
 
214
222
  /**
@@ -220,7 +228,7 @@ export class Reactors implements IReactors {
220
228
  * the whole client reconnects. Both endings are therefore retried, and the delay
221
229
  * keeps a stream that keeps ending from becoming a hot loop.
222
230
  */
223
- private scheduleReobserve(id: string, reactorType: Constructor): void {
231
+ private scheduleReobserve(id: string, reactorType: Constructor, eventTypes: ReactorEventTypeEntry[], validateInstance: (instance: object) => void): void {
224
232
  // A disconnect clears the registration; the reconnect re-registers every
225
233
  // reactor from scratch, so retrying here as well would double up.
226
234
  if (!this._registered || this._disposed) {
@@ -233,7 +241,7 @@ export class Reactors implements IReactors {
233
241
  }
234
242
 
235
243
  this._logger.info('Re-establishing reactor observation', { reactorId: id });
236
- this.startObservation(id, reactorType);
244
+ this.startObservation(id, reactorType, eventTypes, validateInstance);
237
245
  }, Reactors._reobserveDelayMs));
238
246
 
239
247
  handle.unref?.();
@@ -243,7 +251,8 @@ export class Reactors implements IReactors {
243
251
  id: string,
244
252
  reactorType: Constructor,
245
253
  eventSequenceId: string,
246
- eventTypes: ReactorEventTypeEntry[]
254
+ eventTypes: ReactorEventTypeEntry[],
255
+ validateInstance: (instance: object) => void
247
256
  ): Promise<void> {
248
257
  const queue = new AsyncQueue<ReactorMessage>();
249
258
  const controller = new AbortController();
@@ -278,6 +287,7 @@ export class Reactors implements IReactors {
278
287
 
279
288
  try {
280
289
  const reactorInstance = this._artifactActivator ? undefined : new (reactorType as new () => Record<string, Function>)();
290
+ if (reactorInstance) validateInstance(reactorInstance);
281
291
  const services: ReactorServices | undefined = this._eventStore && {
282
292
  eventStore: this._eventStore, readModels: this._eventStore.readModels, signal: controller.signal
283
293
  };
@@ -302,9 +312,12 @@ export class Reactors implements IReactors {
302
312
  readModels: services.readModels, eventSequenceId, partition: eventsToObserve.Partition,
303
313
  signal: controller.signal, delivery: ArtifactDelivery.ReplayNotification,
304
314
  replayState: eventsToObserve.ReplayState
305
- }, this._artifactActivator, artifact => runActivated(artifact, () =>
306
- notifyReplayLifecycle(artifact.instance, eventsToObserve.ReplayState, eventsToObserve.Partition),
307
- { delivery: ArtifactDelivery.ReplayNotification, replayState: eventsToObserve.ReplayState }), this._connection.logger);
315
+ }, this._artifactActivator, artifact => {
316
+ validateInstance(artifact.instance);
317
+ return runActivated(artifact, () =>
318
+ notifyReplayLifecycle(artifact.instance, eventsToObserve.ReplayState, eventsToObserve.Partition),
319
+ { delivery: ArtifactDelivery.ReplayNotification, replayState: eventsToObserve.ReplayState });
320
+ }, this._connection.logger);
308
321
  } else if (reactorInstance) {
309
322
  await notifyReplayLifecycle(reactorInstance, eventsToObserve.ReplayState, eventsToObserve.Partition);
310
323
  }
@@ -388,7 +401,10 @@ export class Reactors implements IReactors {
388
401
  readModels: services.readModels, eventSequenceId, partition: eventsToObserve.Partition,
389
402
  signal: controller.signal, delivery: ArtifactDelivery.Events,
390
403
  eventContext: toClientEventContext(firstInvocableEvent.Context!)
391
- }, this._artifactActivator, processEvents, this._connection.logger);
404
+ }, this._artifactActivator, artifact => {
405
+ validateInstance(artifact.instance);
406
+ return processEvents(artifact);
407
+ }, this._connection.logger);
392
408
  } catch (err) {
393
409
  this._logger.error('Error activating reactor', { reactorId: id, error: err });
394
410
  exceptionMessages.push(String(err));
@@ -4,11 +4,17 @@
4
4
  import { describe, expect, it } from 'vitest';
5
5
  import { onceOnly, isOnceOnly } from './onceOnly.js';
6
6
  import { replay, getReplayEventType } from './replay.js';
7
+ import { handles } from '../index.js';
8
+ import { getHandledEventType } from '../events/handles.js';
7
9
 
8
10
  class EventForReplay {}
9
11
 
10
12
  @onceOnly()
11
13
  class StandardReactor {
14
+ @handles(EventForReplay)
15
+ @onceOnly()
16
+ notify() {}
17
+
12
18
  @onceOnly()
13
19
  eventForReplay() {}
14
20
 
@@ -31,6 +37,8 @@ describe('standard reactor policy decorators', () => {
31
37
  });
32
38
 
33
39
  it('marks methods without modifying the class or other methods', () => {
40
+ expect(getHandledEventType(StandardReactor.prototype.notify)).toBe(EventForReplay);
41
+ expect(isOnceOnly(StandardReactor.prototype.notify)).toBe(true);
34
42
  expect(isOnceOnly(StandardReactor.prototype.eventForReplay)).toBe(true);
35
43
  expect(isOnceOnly(StandardReactor.prototype.replayEventForReplay)).toBe(false);
36
44
  expect(getReplayEventType(StandardReactor.prototype.replayEventForReplay)).toBe(true);