@cratis/chronicle 6.34.0 → 6.35.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.
- package/ChronicleClient.ts +8 -2
- package/EventStore.ts +8 -0
- package/dist/ChronicleClient.d.ts.map +1 -1
- package/dist/ChronicleClient.js +10 -2
- package/dist/ChronicleClient.js.map +1 -1
- package/dist/EventStore.d.ts.map +1 -1
- package/dist/EventStore.js +8 -0
- package/dist/EventStore.js.map +1 -1
- package/dist/events/handles.d.ts +12 -0
- package/dist/events/handles.d.ts.map +1 -0
- package/dist/events/handles.js +33 -0
- package/dist/events/handles.js.map +1 -0
- package/dist/events/index.d.ts +1 -0
- package/dist/events/index.d.ts.map +1 -1
- package/dist/events/index.js +1 -0
- package/dist/events/index.js.map +1 -1
- package/dist/observation/EventHandler.d.ts +7 -0
- package/dist/observation/EventHandler.d.ts.map +1 -0
- package/dist/observation/EventHandler.js +4 -0
- package/dist/observation/EventHandler.js.map +1 -0
- package/dist/observation/createHandlerInstanceValidator.d.ts +4 -0
- package/dist/observation/createHandlerInstanceValidator.d.ts.map +1 -0
- package/dist/observation/createHandlerInstanceValidator.js +40 -0
- package/dist/observation/createHandlerInstanceValidator.js.map +1 -0
- package/dist/observation/getEventHandlers.d.ts +5 -0
- package/dist/observation/getEventHandlers.d.ts.map +1 -0
- package/dist/observation/getEventHandlers.js +40 -0
- package/dist/observation/getEventHandlers.js.map +1 -0
- package/dist/observation/getInstanceMethods.d.ts +4 -0
- package/dist/observation/getInstanceMethods.d.ts.map +1 -0
- package/dist/observation/getInstanceMethods.js +29 -0
- package/dist/observation/getInstanceMethods.js.map +1 -0
- package/dist/reactors/ReactorDispatcher.d.ts.map +1 -1
- package/dist/reactors/ReactorDispatcher.js +27 -32
- package/dist/reactors/ReactorDispatcher.js.map +1 -1
- package/dist/reactors/Reactors.d.ts +1 -0
- package/dist/reactors/Reactors.d.ts.map +1 -1
- package/dist/reactors/Reactors.js +27 -14
- package/dist/reactors/Reactors.js.map +1 -1
- package/dist/reactors/index.d.ts +1 -0
- package/dist/reactors/index.d.ts.map +1 -1
- package/dist/reactors/index.js +1 -0
- package/dist/reactors/index.js.map +1 -1
- package/dist/reducers/ReducerEventDispatcher.d.ts +1 -0
- package/dist/reducers/ReducerEventDispatcher.d.ts.map +1 -1
- package/dist/reducers/ReducerEventDispatcher.js +5 -17
- package/dist/reducers/ReducerEventDispatcher.js.map +1 -1
- package/dist/reducers/ReducerEventHandler.d.ts +3 -6
- package/dist/reducers/ReducerEventHandler.d.ts.map +1 -1
- package/dist/reducers/Reducers.d.ts +1 -0
- package/dist/reducers/Reducers.d.ts.map +1 -1
- package/dist/reducers/Reducers.js +22 -10
- package/dist/reducers/Reducers.js.map +1 -1
- package/dist/reducers/index.d.ts +1 -0
- package/dist/reducers/index.d.ts.map +1 -1
- package/dist/reducers/index.js +1 -0
- package/dist/reducers/index.js.map +1 -1
- package/dist/testing/ReactorScenario.d.ts +1 -0
- package/dist/testing/ReactorScenario.d.ts.map +1 -1
- package/dist/testing/ReactorScenario.js +9 -1
- package/dist/testing/ReactorScenario.js.map +1 -1
- package/dist/testing/ReducerReadModelProcessor.d.ts.map +1 -1
- package/dist/testing/ReducerReadModelProcessor.js +1 -0
- package/dist/testing/ReducerReadModelProcessor.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/events/for_handles/when_decorating_a_method.legacy.spec.ts +91 -0
- package/events/for_handles/when_decorating_a_method.standard.spec.ts +102 -0
- package/events/handles.ts +37 -0
- package/events/index.ts +1 -0
- package/for_ChronicleClient/when_registering_artifacts/with_invalid_handler_mappings.spec.ts +142 -0
- package/observation/EventHandler.ts +9 -0
- package/observation/createHandlerInstanceValidator.ts +42 -0
- package/observation/for_getEventHandlers/when_discovering_handlers.spec.ts +137 -0
- package/observation/for_getEventHandlers/when_reobserving_explicit_handlers.spec.ts +73 -0
- package/observation/getEventHandlers.ts +43 -0
- package/observation/getInstanceMethods.ts +29 -0
- package/package.json +2 -2
- package/reactors/ReactorDispatcher.ts +26 -30
- package/reactors/Reactors.ts +32 -16
- package/reactors/decorators.standard.spec.ts +8 -0
- package/reactors/for_ReactorDispatcher/when_selecting_explicit_handlers.spec.ts +64 -0
- package/reactors/for_Reactors/when_activating_a_field_override.spec.ts +62 -0
- package/reactors/for_Reactors/when_delivering_to_instance_handlers.spec.ts +108 -0
- package/reactors/for_Reactors/when_observing_explicit_handlers.legacy.spec.ts +77 -0
- package/reactors/for_Reactors/when_registering_explicit_handlers.spec.ts +53 -0
- package/reactors/index.ts +1 -0
- package/reducers/ReducerEventDispatcher.ts +5 -16
- package/reducers/ReducerEventHandler.ts +4 -6
- package/reducers/Reducers.ts +24 -12
- package/reducers/for_ReducerEventDispatcher/when_invoking_an_explicit_handler.spec.ts +51 -0
- package/reducers/for_ReducerEventDispatcher/when_invoking_instance_handlers.spec.ts +69 -0
- package/reducers/for_Reducers/when_activating_a_field_override.spec.ts +63 -0
- package/reducers/for_Reducers/when_observing_explicit_handlers.legacy.spec.ts +73 -0
- package/reducers/for_Reducers/when_registering_explicit_handlers.spec.ts +57 -0
- package/reducers/index.ts +1 -0
- package/testing/ReactorScenario.ts +9 -1
- package/testing/ReducerReadModelProcessor.ts +1 -0
- package/testing/for_ReactorScenario/when_delivering_to_an_explicit_handler.spec.ts +100 -0
- package/testing/for_ReducerReadModelProcessor/when_an_instance_field_hides_a_handler.spec.ts +29 -0
|
@@ -0,0 +1,142 @@
|
|
|
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 { afterEach, beforeEach, chai, describe, it, vi } from 'vitest';
|
|
5
|
+
import { ChronicleClient } from '../../ChronicleClient.js';
|
|
6
|
+
import { ConnectionLifecycle } from '../../connection/ConnectionLifecycle.js';
|
|
7
|
+
import { ChronicleOptions } from '../../ChronicleOptions.js';
|
|
8
|
+
import type { IClientArtifactsProvider } from '../../artifacts/IClientArtifactsProvider.js';
|
|
9
|
+
import { eventType } from '../../events/eventTypeDecorator.js';
|
|
10
|
+
import { handles } from '../../events/handles.js';
|
|
11
|
+
import { reactor, replay } from '../../reactors/index.js';
|
|
12
|
+
import { reducer } from '../../reducers/index.js';
|
|
13
|
+
|
|
14
|
+
chai.should();
|
|
15
|
+
|
|
16
|
+
const transport = vi.hoisted(() => ({
|
|
17
|
+
ensureEventStore: vi.fn(),
|
|
18
|
+
register: vi.fn(),
|
|
19
|
+
observe: vi.fn()
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
vi.mock('../../connection/ChronicleConnection', () => ({
|
|
23
|
+
ChronicleConnection: class {
|
|
24
|
+
async resetChannel() {}
|
|
25
|
+
async connect() {}
|
|
26
|
+
disconnect() {}
|
|
27
|
+
server = { getVersionInfo: async () => ({}) };
|
|
28
|
+
eventStores = { ensureEventStore: transport.ensureEventStore };
|
|
29
|
+
eventTypes = new Proxy({}, { get: () => transport.register });
|
|
30
|
+
constraints = new Proxy({}, { get: () => transport.register });
|
|
31
|
+
projections = new Proxy({}, { get: () => transport.register });
|
|
32
|
+
readModels = new Proxy({}, { get: () => transport.register });
|
|
33
|
+
webhooks = new Proxy({}, { get: () => transport.register });
|
|
34
|
+
eventSeeding = new Proxy({}, { get: () => transport.register });
|
|
35
|
+
reactors = { observe: transport.observe };
|
|
36
|
+
reducers = { observe: transport.observe };
|
|
37
|
+
}
|
|
38
|
+
}));
|
|
39
|
+
vi.mock('../../connection/KernelKeepAlive', () => ({
|
|
40
|
+
KernelKeepAlive: class { async start() {} }
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
@eventType('preflight-author')
|
|
44
|
+
class AuthorRegistered {}
|
|
45
|
+
@eventType('preflight-missing')
|
|
46
|
+
class Unregistered {}
|
|
47
|
+
|
|
48
|
+
@reactor('valid-reactor')
|
|
49
|
+
class ValidReactor { @handles(AuthorRegistered) notify() {} }
|
|
50
|
+
@reducer('valid-reducer')
|
|
51
|
+
class ValidReducer { @handles(AuthorRegistered) update() {} }
|
|
52
|
+
|
|
53
|
+
@reactor('missing-event')
|
|
54
|
+
class MissingReactor { @handles(Unregistered) notify() {} }
|
|
55
|
+
@reactor('duplicate-event')
|
|
56
|
+
class DuplicateReactor {
|
|
57
|
+
@handles(AuthorRegistered) notify() {}
|
|
58
|
+
authorRegistered() {}
|
|
59
|
+
}
|
|
60
|
+
@reactor('ambiguous-replay')
|
|
61
|
+
class AmbiguousReactor { @replay(AuthorRegistered) @handles(AuthorRegistered) notify() {} }
|
|
62
|
+
|
|
63
|
+
@reducer('missing-event')
|
|
64
|
+
class MissingReducer { @handles(Unregistered) update() {} }
|
|
65
|
+
@reducer('duplicate-event')
|
|
66
|
+
class DuplicateReducer {
|
|
67
|
+
@handles(AuthorRegistered) update() {}
|
|
68
|
+
authorRegistered() {}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for (const [kind, type, message] of [
|
|
72
|
+
['reactor', MissingReactor, /notify.*MissingReactor.*Unregistered.*no registered event type/],
|
|
73
|
+
['reactor', DuplicateReactor, /multiple handlers.*preflight-author.*notify.*authorRegistered/],
|
|
74
|
+
['reactor', AmbiguousReactor, /notify.*AmbiguousReactor.*cannot combine @handles and @replay/],
|
|
75
|
+
['reducer', MissingReducer, /update.*MissingReducer.*Unregistered.*no registered event type/],
|
|
76
|
+
['reducer', DuplicateReducer, /multiple handlers.*preflight-author.*update.*authorRegistered/]
|
|
77
|
+
] as const) {
|
|
78
|
+
describe(`when getting an event store with invalid mappings on ${type.name}`, () => {
|
|
79
|
+
let client: ChronicleClient;
|
|
80
|
+
let artifacts: IClientArtifactsProvider;
|
|
81
|
+
let result: PromiseSettledResult<unknown>;
|
|
82
|
+
let disconnectedSubscriptions: Set<unknown>;
|
|
83
|
+
let clientSubscriptionCount: number;
|
|
84
|
+
beforeEach(async () => {
|
|
85
|
+
vi.clearAllMocks();
|
|
86
|
+
disconnectedSubscriptions = new Set();
|
|
87
|
+
const onDisconnected = ConnectionLifecycle.prototype.onDisconnected;
|
|
88
|
+
vi.spyOn(ConnectionLifecycle.prototype, 'onDisconnected').mockImplementation(function (this: ConnectionLifecycle, handler) {
|
|
89
|
+
disconnectedSubscriptions.add(handler);
|
|
90
|
+
const unsubscribe = onDisconnected.call(this, handler);
|
|
91
|
+
return () => { unsubscribe(); disconnectedSubscriptions.delete(handler); };
|
|
92
|
+
});
|
|
93
|
+
transport.ensureEventStore.mockResolvedValue({ IsAuthorized: true });
|
|
94
|
+
transport.register.mockResolvedValue({});
|
|
95
|
+
transport.observe.mockImplementation(async function* () {
|
|
96
|
+
yield { Events: [], Partition: '', ReplayState: 0, InitialState: '' };
|
|
97
|
+
});
|
|
98
|
+
artifacts = {
|
|
99
|
+
reactors: kind === 'reactor' ? [ValidReactor, type] : [ValidReactor],
|
|
100
|
+
reducers: kind === 'reducer' ? [ValidReducer, type] : [ValidReducer],
|
|
101
|
+
eventTypes: [AuthorRegistered], readModels: [], projections: [], constraints: [],
|
|
102
|
+
webhooks: [], seeders: [], eventTypeMigrations: [], globalForHandlers: []
|
|
103
|
+
};
|
|
104
|
+
client = new ChronicleClient(ChronicleOptions.fromConnectionString('chronicle://localhost:35000', {
|
|
105
|
+
discoveryPatterns: [], clientArtifactsProvider: artifacts
|
|
106
|
+
}));
|
|
107
|
+
clientSubscriptionCount = disconnectedSubscriptions.size;
|
|
108
|
+
[result] = await Promise.allSettled([client.getEventStore('store')]);
|
|
109
|
+
});
|
|
110
|
+
afterEach(() => { client.dispose(); vi.restoreAllMocks(); });
|
|
111
|
+
|
|
112
|
+
it('should reject getEventStore with the handler validation error', () => {
|
|
113
|
+
result.status.should.equal('rejected');
|
|
114
|
+
if (result.status === 'rejected') (result.reason as Error).message.should.match(message);
|
|
115
|
+
});
|
|
116
|
+
it('should leave no discarded-store lifecycle subscriptions after either failed retrieval', async () => {
|
|
117
|
+
disconnectedSubscriptions.size.should.equal(clientSubscriptionCount);
|
|
118
|
+
await Promise.allSettled([client.getEventStore('store')]);
|
|
119
|
+
disconnectedSubscriptions.size.should.equal(clientSubscriptionCount);
|
|
120
|
+
});
|
|
121
|
+
it('should not register any artifacts or begin observations', () => {
|
|
122
|
+
transport.register.mock.calls.should.have.lengthOf(0);
|
|
123
|
+
transport.observe.mock.calls.should.have.lengthOf(0);
|
|
124
|
+
});
|
|
125
|
+
it('should reject a second retrieval instead of returning the failed store', async () => {
|
|
126
|
+
const [retry] = await Promise.allSettled([client.getEventStore('store')]);
|
|
127
|
+
retry.status.should.equal('rejected');
|
|
128
|
+
if (retry.status === 'rejected') (retry.reason as Error).message.should.match(message);
|
|
129
|
+
transport.register.mock.calls.should.have.lengthOf(0);
|
|
130
|
+
transport.observe.mock.calls.should.have.lengthOf(0);
|
|
131
|
+
});
|
|
132
|
+
it('should register a fresh store after the invalid mapping is removed and cache only that store', async () => {
|
|
133
|
+
artifacts.reactors.splice(1);
|
|
134
|
+
artifacts.reducers.splice(1);
|
|
135
|
+
const store = await client.getEventStore('store');
|
|
136
|
+
transport.register.mock.calls.length.should.be.greaterThan(0);
|
|
137
|
+
transport.observe.mock.calls.should.have.lengthOf(2);
|
|
138
|
+
(await client.getEventStore('store')).should.equal(store);
|
|
139
|
+
transport.ensureEventStore.mock.calls.should.have.lengthOf(2);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
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
|
+
/** A registered event type and its selected instance method. */
|
|
5
|
+
export interface EventHandler {
|
|
6
|
+
readonly id: string;
|
|
7
|
+
readonly generation: number;
|
|
8
|
+
readonly methodName: string;
|
|
9
|
+
}
|
|
@@ -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.
|
|
3
|
+
"version": "6.35.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",
|
|
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
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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 (
|
|
70
|
-
entries.
|
|
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,
|