@cratis/chronicle 6.45.0 → 6.47.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 (78) hide show
  1. package/dist/events/eventSourceTypeDecorator.d.ts +18 -0
  2. package/dist/events/eventSourceTypeDecorator.d.ts.map +1 -0
  3. package/dist/events/eventSourceTypeDecorator.js +25 -0
  4. package/dist/events/eventSourceTypeDecorator.js.map +1 -0
  5. package/dist/events/eventStreamTypeDecorator.d.ts +20 -0
  6. package/dist/events/eventStreamTypeDecorator.d.ts.map +1 -0
  7. package/dist/events/eventStreamTypeDecorator.js +29 -0
  8. package/dist/events/eventStreamTypeDecorator.js.map +1 -0
  9. package/dist/events/index.d.ts +2 -0
  10. package/dist/events/index.d.ts.map +1 -1
  11. package/dist/events/index.js +2 -0
  12. package/dist/events/index.js.map +1 -1
  13. package/dist/projections/declarative/CompositeKeyBuilder.d.ts +4 -1
  14. package/dist/projections/declarative/CompositeKeyBuilder.d.ts.map +1 -1
  15. package/dist/projections/declarative/CompositeKeyBuilder.js +15 -8
  16. package/dist/projections/declarative/CompositeKeyBuilder.js.map +1 -1
  17. package/dist/projections/declarative/ICompositeKeyBuilder.d.ts +8 -1
  18. package/dist/projections/declarative/ICompositeKeyBuilder.d.ts.map +1 -1
  19. package/dist/projections/declarative/ICompositeKeySetBuilder.d.ts +17 -0
  20. package/dist/projections/declarative/ICompositeKeySetBuilder.d.ts.map +1 -0
  21. package/dist/projections/declarative/ICompositeKeySetBuilder.js +4 -0
  22. package/dist/projections/declarative/ICompositeKeySetBuilder.js.map +1 -0
  23. package/dist/projections/declarative/index.d.ts +1 -0
  24. package/dist/projections/declarative/index.d.ts.map +1 -1
  25. package/dist/reactors/ReactorSideEffects.d.ts +6 -3
  26. package/dist/reactors/ReactorSideEffects.d.ts.map +1 -1
  27. package/dist/reactors/ReactorSideEffects.js +12 -6
  28. package/dist/reactors/ReactorSideEffects.js.map +1 -1
  29. package/dist/reactors/Reactors.d.ts.map +1 -1
  30. package/dist/reactors/Reactors.js +4 -2
  31. package/dist/reactors/Reactors.js.map +1 -1
  32. package/dist/reducers/Reducers.d.ts.map +1 -1
  33. package/dist/reducers/Reducers.js +4 -2
  34. package/dist/reducers/Reducers.js.map +1 -1
  35. package/dist/testing/ReactorScenario.d.ts.map +1 -1
  36. package/dist/testing/ReactorScenario.js +9 -1
  37. package/dist/testing/ReactorScenario.js.map +1 -1
  38. package/dist/testing/ReducerReadModelProcessor.d.ts.map +1 -1
  39. package/dist/testing/ReducerReadModelProcessor.js +13 -0
  40. package/dist/testing/ReducerReadModelProcessor.js.map +1 -1
  41. package/dist/testing/UnsupportedReducerOperation.d.ts +7 -0
  42. package/dist/testing/UnsupportedReducerOperation.d.ts.map +1 -0
  43. package/dist/testing/UnsupportedReducerOperation.js +14 -0
  44. package/dist/testing/UnsupportedReducerOperation.js.map +1 -0
  45. package/dist/testing/index.d.ts +1 -0
  46. package/dist/testing/index.d.ts.map +1 -1
  47. package/dist/testing/index.js +1 -0
  48. package/dist/testing/index.js.map +1 -1
  49. package/dist/tsconfig.tsbuildinfo +1 -1
  50. package/events/eventSourceTypeDecorator.ts +28 -0
  51. package/events/eventStreamTypeDecorator.ts +33 -0
  52. package/events/for_observerFilters/when_registering/filter_registration_behaviors.fixture.ts +55 -0
  53. package/events/for_observerFilters/when_registering/with_legacy_decorators.legacy.spec.ts +30 -0
  54. package/events/for_observerFilters/when_registering/with_standard_decorators.standard.spec.ts +30 -0
  55. package/events/index.ts +2 -0
  56. package/package.json +2 -2
  57. package/projections/declarative/CompositeKeyBuilder.ts +26 -9
  58. package/projections/declarative/ICompositeKeyBuilder.ts +9 -1
  59. package/projections/declarative/ICompositeKeySetBuilder.ts +21 -0
  60. package/projections/declarative/for_CompositeKeyBuilder/when_matching_kernel_fixtures.spec.ts +108 -0
  61. package/projections/declarative/for_CompositeKeyBuilder/when_setting_a_duplicate_part.spec.ts +43 -0
  62. package/projections/declarative/for_CompositeKeyBuilder/when_setting_expression_parts.spec.ts +84 -0
  63. package/projections/declarative/for_CompositeKeyBuilder/when_typing_constant_parts.standard.spec.ts +43 -0
  64. package/projections/declarative/index.ts +1 -0
  65. package/reactors/ReactorSideEffects.ts +16 -6
  66. package/reactors/Reactors.ts +4 -2
  67. package/reactors/for_ReactorSideEffects/when_dispatching/with_reactor_source_and_stream_types.spec.ts +75 -0
  68. package/reactors/for_Reactors/against_a_kernel/when_filtering_source_and_stream_types.integration.spec.ts +147 -0
  69. package/reducers/Reducers.ts +4 -2
  70. package/testing/ReactorScenario.ts +10 -1
  71. package/testing/ReducerReadModelProcessor.ts +13 -0
  72. package/testing/UnsupportedReducerOperation.ts +10 -0
  73. package/testing/for_ReactorScenario/when_recording_side_effects/with_declared_stream_type.spec.ts +50 -0
  74. package/testing/for_observerScenarios/when_selecting_filters/with_reducer_tag_filters.spec.ts +50 -0
  75. package/testing/for_observerScenarios/when_selecting_filters/with_source_and_stream_types.spec.ts +48 -0
  76. package/testing/index.ts +1 -0
  77. package/testing/projections/ProjectionReadModelProcessor.fixtures.spec.ts +5 -0
  78. package/testing/projections/for_ProjectionCapabilities/when_rejecting_composite_expression_parts.spec.ts +40 -0
@@ -2,6 +2,8 @@
2
2
  // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
3
 
4
4
  import { hasEventType } from '../events/eventTypeDecorator.js';
5
+ import { getEventSourceTypeFor } from '../events/eventSourceTypeDecorator.js';
6
+ import { getDeclaredEventStreamTypeFor } from '../events/eventStreamTypeDecorator.js';
5
7
  import type { EventForEventSourceId } from '../eventSequences/EventForEventSourceId.js';
6
8
  import type { IEventLog } from '../eventSequences/IEventLog.js';
7
9
  import type { EventContext } from '../events/EventContext.js';
@@ -25,7 +27,8 @@ export async function dispatchReactorSideEffects(eventLog: IEventLog, result: un
25
27
  reactorType: Function, eventStore: string, namespace: string, handler?: ReactorResultHandler): Promise<void> {
26
28
  if (await handler?.(result, context, reactorType, eventStore, namespace)) return;
27
29
  const appended = await appendReactorSideEffects(eventLog, result, context.eventSourceId,
28
- context.eventStreamType ?? 'Default', context.eventStreamId ?? context.eventSourceId);
30
+ context.eventStreamType ?? 'Default', context.eventStreamId ?? context.eventSourceId,
31
+ getEventSourceTypeFor(reactorType), getDeclaredEventStreamTypeFor(reactorType));
29
32
  if (!appended.isSuccess) throw new Error(`Reactor side effect failed to append: ${appended.errors.join('; ')}`);
30
33
  }
31
34
 
@@ -58,22 +61,27 @@ function isEventForEventSourceId(value: unknown): value is EventForEventSourceId
58
61
  * event returns.
59
62
  * @param triggeringEventStreamId - The event stream identifier of the triggering event, used for
60
63
  * bare event returns.
64
+ * @param reactorEventSourceType - Non-empty reactor source type metadata for bare event returns.
65
+ * @param reactorEventStreamType - Non-empty declared reactor stream type metadata for bare event returns,
66
+ * overriding the triggering stream type. Omitted metadata preserves the triggering stream type.
61
67
  * @returns The outcome of the append. Each {@link EventForEventSourceId} entry keeps its own
62
- * target (event source id, stream type/id, subject); bare events use the triggering event's.
68
+ * target metadata; bare events use the triggering target with the reactor's declared types.
63
69
  */
64
70
  export async function appendReactorSideEffects(
65
71
  eventLog: IEventLog,
66
72
  handlerResult: unknown,
67
73
  triggeringEventSourceId: string,
68
74
  triggeringEventStreamType: string,
69
- triggeringEventStreamId: string
75
+ triggeringEventStreamId: string,
76
+ reactorEventSourceType?: string,
77
+ reactorEventStreamType?: string
70
78
  ): Promise<ReactorSideEffectResult> {
71
79
  if (handlerResult === undefined || handlerResult === null) {
72
80
  return noSideEffects;
73
81
  }
74
82
 
75
83
  const events = normalizeReactorSideEffects(handlerResult, triggeringEventSourceId,
76
- triggeringEventStreamType, triggeringEventStreamId);
84
+ triggeringEventStreamType, triggeringEventStreamId, reactorEventSourceType, reactorEventStreamType);
77
85
  if (events.length === 0) {
78
86
  return noSideEffects;
79
87
  }
@@ -91,7 +99,8 @@ export async function appendReactorSideEffects(
91
99
 
92
100
  /** Production return-shape normalization; unknown values are ignored by production dispatch. */
93
101
  export function normalizeReactorSideEffects(handlerResult: unknown, triggeringEventSourceId: string,
94
- triggeringEventStreamType: string, triggeringEventStreamId: string): EventForEventSourceId[] {
102
+ triggeringEventStreamType: string, triggeringEventStreamId: string,
103
+ reactorEventSourceType?: string, reactorEventStreamType?: string): EventForEventSourceId[] {
95
104
  const items = Array.isArray(handlerResult) ? handlerResult : [handlerResult];
96
105
  const events: EventForEventSourceId[] = [];
97
106
 
@@ -102,7 +111,8 @@ export function normalizeReactorSideEffects(handlerResult: unknown, triggeringEv
102
111
  events.push({
103
112
  eventSourceId: triggeringEventSourceId,
104
113
  event: item,
105
- eventStreamType: triggeringEventStreamType,
114
+ eventStreamType: reactorEventStreamType || triggeringEventStreamType,
115
+ ...(reactorEventSourceType ? { eventSourceType: reactorEventSourceType } : {}),
106
116
  eventStreamId: triggeringEventStreamId,
107
117
  subject: triggeringEventSourceId
108
118
  });
@@ -12,6 +12,8 @@ import { ConnectionLifecycle } from '../connection/ConnectionLifecycle.js';
12
12
  import { toClientEventContext } from '../events/toClientEventContext.js';
13
13
  import { getTagsFor } from '../events/tagDecorator.js';
14
14
  import { getFilterTagsFor } from '../events/filterEventsByTagDecorator.js';
15
+ import { getEventSourceTypeFor } from '../events/eventSourceTypeDecorator.js';
16
+ import { getEventStreamTypeFor } from '../events/eventStreamTypeDecorator.js';
15
17
  import { EventSequenceId } from '../eventSequences/EventSequenceId.js';
16
18
  import type { IEventLog } from '../eventSequences/IEventLog.js';
17
19
  import { createHandlerInstanceValidator } from '../observation/createHandlerInstanceValidator.js';
@@ -277,8 +279,8 @@ export class Reactors implements IReactors {
277
279
  Tags: getTagsFor(reactorType).map(t => t.value),
278
280
  Filters: {
279
281
  FilterTags: getFilterTagsFor(reactorType).map(t => t.value),
280
- EventSourceType: '',
281
- EventStreamType: 'All'
282
+ EventSourceType: getEventSourceTypeFor(reactorType),
283
+ EventStreamType: getEventStreamTypeFor(reactorType)
282
284
  }
283
285
  }
284
286
  },
@@ -0,0 +1,75 @@
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, vi } from 'vitest';
5
+ import { eventSourceType } from '../../../events/eventSourceTypeDecorator.js';
6
+ import { eventStreamType } from '../../../events/eventStreamTypeDecorator.js';
7
+ import { eventType } from '../../../events/eventTypeDecorator.js';
8
+ import type { EventContext } from '../../../events/EventContext.js';
9
+ import type { EventForEventSourceId } from '../../../eventSequences/EventForEventSourceId.js';
10
+ import type { IEventLog } from '../../../eventSequences/IEventLog.js';
11
+ import { dispatchReactorSideEffects } from '../../ReactorSideEffects.js';
12
+
13
+ chai.should();
14
+ @eventType('side-effect-with-reactor-types')
15
+ class Outbound {}
16
+ @eventSourceType('my-source')
17
+ class SourceReactor {}
18
+ @eventStreamType('my-stream')
19
+ class StreamReactor extends SourceReactor {}
20
+ class InheritedReactor extends StreamReactor {}
21
+ @eventSourceType('')
22
+ @eventStreamType('')
23
+ class EmptyReactor extends StreamReactor {}
24
+ @eventStreamType('All')
25
+ class AllStreamsReactor {}
26
+ const context = { eventSourceId: 'trigger', eventStreamType: 'trigger-stream', eventStreamId: 'trigger-stream-id' } as EventContext;
27
+
28
+ for (const [reactorType, sourceType, streamType] of [
29
+ [SourceReactor, 'my-source', 'trigger-stream'],
30
+ [StreamReactor, 'my-source', 'my-stream'],
31
+ [InheritedReactor, 'my-source', 'my-stream'],
32
+ [EmptyReactor, undefined, 'trigger-stream'],
33
+ [AllStreamsReactor, undefined, 'All']
34
+ ] as const) {
35
+ describe(`when dispatching a bare returned event from ${reactorType.name}`, () => {
36
+ let appended: EventForEventSourceId[];
37
+ const outbound = new Outbound();
38
+ beforeEach(async () => {
39
+ const eventLog = { appendMany: vi.fn(async (events: EventForEventSourceId[]) => {
40
+ appended = events;
41
+ return [{ isSuccess: true, errors: [], constraintViolations: [] }];
42
+ }) } as unknown as IEventLog;
43
+ await dispatchReactorSideEffects(eventLog, outbound, context, reactorType, 'store', 'namespace');
44
+ });
45
+ it('should append the returned event with the reactor types and triggering target', () => {
46
+ appended.should.deep.equal([{ eventSourceId: 'trigger', event: outbound, eventStreamType: streamType,
47
+ ...(sourceType ? { eventSourceType: sourceType } : {}), eventStreamId: 'trigger-stream-id', subject: 'trigger' }]);
48
+ });
49
+ });
50
+ }
51
+
52
+ describe('when dispatching mixed bare and explicitly targeted returned events', () => {
53
+ let appended: EventForEventSourceId[];
54
+ const explicit = { eventSourceId: 'other', event: new Outbound(), eventSourceType: 'explicit-source',
55
+ eventStreamType: 'explicit-stream', eventStreamId: 'other-stream', subject: 'other-subject' };
56
+ const omitted = { eventSourceId: 'omitted', event: new Outbound() };
57
+ const empty = { eventSourceId: 'empty', event: new Outbound(), eventSourceType: '', eventStreamType: '' };
58
+ beforeEach(async () => {
59
+ const eventLog = { appendMany: vi.fn(async (events: EventForEventSourceId[]) => {
60
+ appended = events;
61
+ return [{ isSuccess: true, errors: [], constraintViolations: [] }];
62
+ }) } as unknown as IEventLog;
63
+ await dispatchReactorSideEffects(eventLog, [new Outbound(), explicit, omitted, empty], context, StreamReactor, 'store', 'namespace');
64
+ });
65
+ it('should apply reactor metadata to only the bare event', () => {
66
+ appended[0].eventSourceType!.should.equal('my-source');
67
+ appended[0].eventStreamType!.should.equal('my-stream');
68
+ });
69
+ it('should preserve explicit targets including omitted and empty type values', () => {
70
+ appended.slice(1).should.deep.equal([explicit, omitted, empty]);
71
+ appended[1].should.equal(explicit);
72
+ appended[2].should.equal(omitted);
73
+ appended[3].should.equal(empty);
74
+ });
75
+ });
@@ -0,0 +1,147 @@
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 { randomUUID } from 'node:crypto';
5
+ import { afterAll, beforeAll, chai, describe, it } from 'vitest';
6
+ import { field } from '@cratis/fundamentals';
7
+ import { ObserverRunningState } from '@cratis/chronicle.contracts';
8
+ import { ChronicleClient, ChronicleOptions, eventType, eventSourceType, eventStreamType, filterEventsByTag,
9
+ reactor, reducer, tag, type IEventStore, type IClientArtifactsProvider } from '../../../index.js';
10
+ import type { ChronicleConnection } from '../../../connection/index.js';
11
+
12
+ chai.should();
13
+ function should(value: unknown): ReturnType<typeof chai.expect> {
14
+ return (value as { should: ReturnType<typeof chai.expect> }).should;
15
+ }
16
+ const connectionString = process.env.CHRONICLE_INTEGRATION_CONNECTION_STRING;
17
+
18
+ @eventType()
19
+ class FilteredOrderPlaced {
20
+ @field(String) name = '';
21
+ }
22
+
23
+ const reactorCalls = new Map<string, string[]>();
24
+ const reducerCalls = new Map<string, string[]>();
25
+ class Totals { count = 0; lastName = ''; }
26
+ class SourceTotals extends Totals {}
27
+ class StreamTotals extends Totals {}
28
+ class CombinedTotals extends Totals {}
29
+
30
+ @reactor()
31
+ class AllOrders {
32
+ filteredOrderPlaced(event: FilteredOrderPlaced) {
33
+ const name = this.constructor.name;
34
+ reactorCalls.set(name, [...reactorCalls.get(name) ?? [], event.name]);
35
+ }
36
+ }
37
+ @reactor()
38
+ @eventSourceType('Customers')
39
+ class CustomerOrders extends AllOrders {}
40
+ @reactor()
41
+ @eventStreamType('Orders')
42
+ class OrdersStream extends AllOrders {}
43
+ @reactor()
44
+ @eventSourceType('Customers')
45
+ @eventStreamType('Orders')
46
+ @filterEventsByTag('vip')
47
+ @filterEventsByTag('priority')
48
+ @tag('Analytics')
49
+ class PriorityCustomerOrders extends AllOrders {}
50
+
51
+ @reducer('AllOrderTotals', undefined, Totals)
52
+ class AllOrderTotals {
53
+ filteredOrderPlaced(event: FilteredOrderPlaced, current: Totals | undefined): Totals {
54
+ const name = this.constructor.name;
55
+ reducerCalls.set(name, [...reducerCalls.get(name) ?? [], event.name]);
56
+ return { count: (current?.count ?? 0) + 1, lastName: event.name };
57
+ }
58
+ }
59
+ @reducer('CustomerOrderTotals', undefined, SourceTotals)
60
+ @eventSourceType('Customers')
61
+ class CustomerOrderTotals extends AllOrderTotals {}
62
+ @reducer('OrdersStreamTotals', undefined, StreamTotals)
63
+ @eventStreamType('Orders')
64
+ class OrdersStreamTotals extends AllOrderTotals {}
65
+ @reducer('PriorityCustomerOrderTotals', undefined, CombinedTotals)
66
+ @eventSourceType('Customers')
67
+ @eventStreamType('Orders')
68
+ @filterEventsByTag('vip')
69
+ @filterEventsByTag('priority')
70
+ @tag('Analytics')
71
+ class PriorityCustomerOrderTotals extends AllOrderTotals {}
72
+
73
+ const artifacts: IClientArtifactsProvider = {
74
+ eventTypes: [FilteredOrderPlaced], reactors: [AllOrders, CustomerOrders, OrdersStream, PriorityCustomerOrders],
75
+ reducers: [AllOrderTotals, CustomerOrderTotals, OrdersStreamTotals, PriorityCustomerOrderTotals],
76
+ readModels: [Totals, SourceTotals, StreamTotals, CombinedTotals], seeders: [], constraints: [], projections: [],
77
+ webhooks: [], eventTypeMigrations: [], globalForHandlers: []
78
+ };
79
+
80
+ async function eventually(accept: () => boolean | Promise<boolean>, timeoutMs = 30_000): Promise<void> {
81
+ const deadline = Date.now() + timeoutMs;
82
+ while (!await accept()) {
83
+ if (Date.now() > deadline) throw new Error('Timed out waiting for filtered observers');
84
+ await new Promise(resolve => setTimeout(resolve, 250));
85
+ }
86
+ }
87
+
88
+ // A final matching event is an ordered barrier in every observer's shared source partition.
89
+ // The unrestricted observers also prove all excluded input was actually committed and delivered.
90
+ const expected = [
91
+ ['wrong-source', 'wrong-stream', 'wrong-tag', 'no-tag', 'priority', 'barrier'],
92
+ ['wrong-stream', 'wrong-tag', 'no-tag', 'priority', 'barrier'],
93
+ ['wrong-source', 'wrong-tag', 'no-tag', 'priority', 'barrier'],
94
+ ['priority', 'barrier']
95
+ ];
96
+
97
+ describe.skipIf(!connectionString && !process.env.CI)('when filtering reactor and reducer delivery against a kernel', () => {
98
+ const storeName = `Filters${randomUUID().replaceAll('-', '').slice(0, 12)}`;
99
+ const sourceId = randomUUID();
100
+ let client: ChronicleClient;
101
+ let store: IEventStore;
102
+
103
+ beforeAll(async () => {
104
+ client = new ChronicleClient(ChronicleOptions.fromConnectionString(connectionString!, {
105
+ discoveryPatterns: [], clientArtifactsProvider: artifacts
106
+ }));
107
+ store = await client.getEventStore(storeName);
108
+ const connection = (store as unknown as { _connection: ChronicleConnection })._connection;
109
+ await eventually(async () => {
110
+ const observers = await Promise.all([...artifacts.reactors, ...artifacts.reducers].map(type =>
111
+ connection.observers.getObserverInformation({ EventStore: storeName, Namespace: 'Default',
112
+ ObserverId: type.name, EventSequenceId: 'event-log' })));
113
+ return observers.every(observer => observer.IsSubscribed && observer.RunningState === ObserverRunningState.Active);
114
+ });
115
+ const input = [
116
+ { name: 'wrong-source', source: 'Other', stream: 'Orders', tags: ['vip'] },
117
+ { name: 'wrong-stream', source: 'Customers', stream: 'Other', tags: ['vip'] },
118
+ { name: 'wrong-tag', source: 'Customers', stream: 'Orders', tags: ['regular'] },
119
+ { name: 'no-tag', source: 'Customers', stream: 'Orders', tags: [] },
120
+ { name: 'priority', source: 'Customers', stream: 'Orders', tags: ['priority'] },
121
+ { name: 'barrier', source: 'Customers', stream: 'Orders', tags: ['vip'] }
122
+ ];
123
+ const results = await store.eventLog.appendMany(input.map(item => ({ eventSourceId: sourceId,
124
+ event: Object.assign(new FilteredOrderPlaced(), { name: item.name }), eventSourceType: item.source,
125
+ eventStreamType: item.stream, eventStreamId: 'Default', tags: item.tags })));
126
+ should(results).have.lengthOf(input.length);
127
+ should(results.every(result => result.isSuccess)).be.true;
128
+ await eventually(() => [...artifacts.reactors].every(type => reactorCalls.get(type.name)?.at(-1) === 'barrier') &&
129
+ [...artifacts.reducers].every(type => reducerCalls.get(type.name)?.at(-1) === 'barrier'));
130
+ await eventually(async () => {
131
+ const states = await Promise.all(artifacts.readModels.map(type => store.readModels.findInstanceById(type, sourceId)));
132
+ return states.every(state => (state as Totals | null)?.lastName === 'barrier');
133
+ });
134
+ });
135
+ afterAll(() => client?.dispose());
136
+
137
+ for (let index = 0; index < expected.length; index++) {
138
+ it(`should deliver only the selected events to reactor ${artifacts.reactors[index].name}`, () => {
139
+ should(reactorCalls.get(artifacts.reactors[index].name)).deep.equal(expected[index]);
140
+ });
141
+ it(`should deliver only the selected events to reducer ${artifacts.reducers[index].name}`, async () => {
142
+ should(reducerCalls.get(artifacts.reducers[index].name)).deep.equal(expected[index]);
143
+ const state = await store.readModels.getInstanceById(artifacts.readModels[index], sourceId) as Totals;
144
+ should(state.count).equal(expected[index].length);
145
+ });
146
+ }
147
+ });
@@ -12,6 +12,8 @@ import { ConnectionLifecycle } from '../connection/ConnectionLifecycle.js';
12
12
  import { toClientEventContext } from '../events/toClientEventContext.js';
13
13
  import { getTagsFor } from '../events/tagDecorator.js';
14
14
  import { getFilterTagsFor } from '../events/filterEventsByTagDecorator.js';
15
+ import { getEventSourceTypeFor } from '../events/eventSourceTypeDecorator.js';
16
+ import { getEventStreamTypeFor } from '../events/eventStreamTypeDecorator.js';
15
17
  import { EventSequenceId } from '../eventSequences/EventSequenceId.js';
16
18
  import { notifyReplayLifecycle } from '../observation/notifyReplayLifecycle.js';
17
19
  import { IReducers } from './IReducers.js';
@@ -347,8 +349,8 @@ export class Reducers implements IReducers {
347
349
  Tags: getTagsFor(reducerType).map(t => t.value),
348
350
  Filters: {
349
351
  FilterTags: getFilterTagsFor(reducerType).map(t => t.value),
350
- EventSourceType: '',
351
- EventStreamType: 'All'
352
+ EventSourceType: getEventSourceTypeFor(reducerType),
353
+ EventStreamType: getEventStreamTypeFor(reducerType)
352
354
  }
353
355
  }
354
356
  },
@@ -13,6 +13,8 @@ import type { AppendedEvent } from '../events/AppendedEvent.js';
13
13
  import type { EventContext } from '../events/EventContext.js';
14
14
  import { getEventTypeMetadata } from '../events/eventTypeDecorator.js';
15
15
  import { getFilterTagsFor } from '../events/filterEventsByTagDecorator.js';
16
+ import { getEventSourceTypeFor } from '../events/eventSourceTypeDecorator.js';
17
+ import { getDeclaredEventStreamTypeFor, getEventStreamTypeFor } from '../events/eventStreamTypeDecorator.js';
16
18
  import type { IEventLog } from '../eventSequences/IEventLog.js';
17
19
  import type { EventForEventSourceId } from '../eventSequences/EventForEventSourceId.js';
18
20
  import type { AppendOptions } from '../eventSequences/AppendOptions.js';
@@ -63,6 +65,12 @@ export class ReactorScenario {
63
65
  if (getFilterTagsFor(_reactor).length) {
64
66
  throw new UnsupportedReactorOperation('reactor.filterEventsByTag', _reactor.name, 'Tag-filtered delivery is not fixture-backed.');
65
67
  }
68
+ if (getEventSourceTypeFor(_reactor) !== '') {
69
+ throw new UnsupportedReactorOperation('reactor.eventSourceType', _reactor.name, 'Source-type-filtered delivery is not fixture-backed.');
70
+ }
71
+ if (getEventStreamTypeFor(_reactor) !== 'All') {
72
+ throw new UnsupportedReactorOperation('reactor.eventStreamType', _reactor.name, 'Stream-type-filtered delivery is not fixture-backed.');
73
+ }
66
74
  if (_options.commandTypes?.length) {
67
75
  throw new UnsupportedReactorOperation('options.commandTypes', _reactor.name, 'Command classification belongs to the explicit-composition increment.');
68
76
  }
@@ -282,7 +290,8 @@ export class ReactorScenario {
282
290
  if (result == null) return;
283
291
  const items = Array.isArray(result) ? result : [result];
284
292
  const events = normalizeReactorSideEffects(result, context.eventSourceId,
285
- context.eventStreamType ?? 'Default', context.eventStreamId ?? context.eventSourceId);
293
+ context.eventStreamType ?? 'Default', context.eventStreamId ?? context.eventSourceId,
294
+ getEventSourceTypeFor(this._reactor), getDeclaredEventStreamTypeFor(this._reactor));
286
295
  if (items.length !== events.length || events.some(entry => !this._eventTypes.includes(entry.event.constructor as Constructor))) {
287
296
  throw new UnsupportedReactorOperation('handler.return', this._reactor.name,
288
297
  'Only registered events and production EventForEventSourceId wrappers may be recorded; commands and unknown values are unsupported.');
@@ -3,6 +3,10 @@
3
3
 
4
4
  import type { Constructor } from '@cratis/fundamentals';
5
5
  import { ReducerEventDispatcher } from '../reducers/ReducerEventDispatcher.js';
6
+ import { getEventSourceTypeFor } from '../events/eventSourceTypeDecorator.js';
7
+ import { getEventStreamTypeFor } from '../events/eventStreamTypeDecorator.js';
8
+ import { getFilterTagsFor } from '../events/filterEventsByTagDecorator.js';
9
+ import { UnsupportedReducerOperation } from './UnsupportedReducerOperation.js';
6
10
  import type { IReadModelProcessor } from './IReadModelProcessor.js';
7
11
  import type { ReadModelState } from './ReadModelState.js';
8
12
  import type { ScenarioEvent } from './ScenarioEvent.js';
@@ -12,6 +16,15 @@ export class ReducerReadModelProcessor<TReadModel extends object> implements IRe
12
16
  private readonly _dispatcher: ReducerEventDispatcher;
13
17
 
14
18
  constructor(private readonly _readModelType: Constructor<TReadModel>, private readonly _reducerType: Constructor, eventTypes: Constructor[]) {
19
+ if (getFilterTagsFor(_reducerType).length) {
20
+ throw new UnsupportedReducerOperation('reducer.filterEventsByTag', _reducerType.name, 'Tag-filtered delivery is not fixture-backed.');
21
+ }
22
+ if (getEventSourceTypeFor(_reducerType) !== '') {
23
+ throw new UnsupportedReducerOperation('reducer.eventSourceType', _reducerType.name, 'Source-type-filtered delivery is not fixture-backed.');
24
+ }
25
+ if (getEventStreamTypeFor(_reducerType) !== 'All') {
26
+ throw new UnsupportedReducerOperation('reducer.eventStreamType', _reducerType.name, 'Stream-type-filtered delivery is not fixture-backed.');
27
+ }
15
28
  this._dispatcher = new ReducerEventDispatcher(_reducerType, eventTypes);
16
29
  }
17
30
 
@@ -0,0 +1,10 @@
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
+ /** An operation whose kernel/observer semantics have not been established for this scenario. */
5
+ export class UnsupportedReducerOperation extends Error {
6
+ constructor(readonly operation: string, readonly artifact: string, reason: string) {
7
+ super(`Unsupported reducer operation '${operation}' for '${artifact}': ${reason} Use a kernel-backed test.`);
8
+ this.name = 'UnsupportedReducerOperation';
9
+ }
10
+ }
@@ -0,0 +1,50 @@
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 { field } from '@cratis/fundamentals';
5
+ import { beforeEach, chai, describe, it, vi } from 'vitest';
6
+ import { eventType } from '../../../events/eventTypeDecorator.js';
7
+ import { eventStreamType } from '../../../events/eventStreamTypeDecorator.js';
8
+ import type { EventForEventSourceId } from '../../../eventSequences/EventForEventSourceId.js';
9
+ import type { IEventLog } from '../../../eventSequences/IEventLog.js';
10
+ import { reactor } from '../../../reactors/reactor.js';
11
+ import { dispatchReactorSideEffects } from '../../../reactors/ReactorSideEffects.js';
12
+ import { ReactorScenario } from '../../ReactorScenario.js';
13
+
14
+ chai.should();
15
+ @eventType('scenario-type-side-effect-input')
16
+ class Input { @field(String) name = 'input'; }
17
+ @eventType('scenario-type-side-effect-output')
18
+ class Outbound { @field(String) name = 'output'; }
19
+ const bare = new Outbound();
20
+ const explicit = { eventSourceId: 'other', event: new Outbound(), eventSourceType: 'explicit-source', eventStreamType: 'explicit-stream' };
21
+ const omitted = { eventSourceId: 'omitted', event: new Outbound() };
22
+
23
+ @reactor('scenario-type-side-effects')
24
+ @eventStreamType('All')
25
+ class AllStreamsReactor { input() { return [bare, explicit, omitted]; } }
26
+
27
+ // Non-default filters still require kernel-backed delivery. An explicit unrestricted stream
28
+ // decorator exercises declared side-effect metadata without pretending to implement filtering.
29
+ describe('when recording side effects with an explicit unrestricted stream decorator', () => {
30
+ let scenario: ReactorScenario;
31
+ let appended: EventForEventSourceId[];
32
+ beforeEach(async () => {
33
+ scenario = new ReactorScenario(AllStreamsReactor, { artifacts: { eventTypes: [Input, Outbound] }, constraints: 'disabled' });
34
+ await scenario.when.forEventSource('trigger').events(new Input());
35
+ const eventLog = { appendMany: vi.fn(async (events: EventForEventSourceId[]) => {
36
+ appended = events;
37
+ return [{ isSuccess: true, errors: [], constraintViolations: [] }];
38
+ }) } as unknown as IEventLog;
39
+ await dispatchReactorSideEffects(eventLog, [bare, explicit, omitted], scenario.sideEffects[0].triggeringContext,
40
+ AllStreamsReactor, 'store', 'namespace');
41
+ });
42
+ it('should record the same target metadata as production dispatch', () => {
43
+ scenario.sideEffects.map(effect => effect.target).should.deep.equal(appended);
44
+ scenario.sideEffects[0].target.eventStreamType!.should.equal('All');
45
+ });
46
+ it('should preserve explicitly targeted events instead of inheriting reactor metadata', () => {
47
+ scenario.sideEffects[1].target.should.equal(explicit);
48
+ scenario.sideEffects[2].target.should.equal(omitted);
49
+ });
50
+ });
@@ -0,0 +1,50 @@
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 { chai, describe, it } from 'vitest';
5
+ import { filterEventsByTag, reducer, tag } from '../../../index.js';
6
+ import { ReadModelScenario, UnsupportedReducerOperation } from '../../index.js';
7
+
8
+ chai.should();
9
+ class State { count = 0; }
10
+ let created = false;
11
+ @reducer('tag-filtered-scenario', undefined, State)
12
+ @filterEventsByTag('vip')
13
+ class TagFiltered {
14
+ constructor() { created = true; }
15
+ }
16
+ class InheritedTagFiltered extends TagFiltered {}
17
+ @reducer('label-only-scenario', undefined, State)
18
+ @tag('vip')
19
+ class Labeled {}
20
+ @reducer('empty-tag-filter-scenario', undefined, State)
21
+ @filterEventsByTag('')
22
+ class EmptyFilter {}
23
+
24
+ for (const type of [TagFiltered, InheritedTagFiltered]) {
25
+ describe(`when creating a reducer scenario for ${type.name}`, () => {
26
+ it('should reject tag-filtered delivery before activating the reducer', () => {
27
+ created = false;
28
+ try {
29
+ new ReadModelScenario(State, { reducers: [type], eventTypes: [], projections: [] });
30
+ throw new Error('Expected unsupported reducer operation');
31
+ } catch (error) {
32
+ (error instanceof UnsupportedReducerOperation).should.be.true;
33
+ const unsupported = error as UnsupportedReducerOperation;
34
+ unsupported.operation.should.equal('reducer.filterEventsByTag');
35
+ unsupported.artifact.should.equal(type.name);
36
+ unsupported.message.should.contain('Tag-filtered delivery is not fixture-backed.');
37
+ unsupported.message.should.contain('Use a kernel-backed test.');
38
+ }
39
+ created.should.be.false;
40
+ });
41
+ });
42
+ }
43
+
44
+ for (const type of [Labeled, EmptyFilter]) {
45
+ describe(`when creating a reducer scenario for ${type.name}`, () => {
46
+ it('should allow metadata that does not restrict delivery', () => {
47
+ (() => new ReadModelScenario(State, { reducers: [type], eventTypes: [], projections: [] })).should.not.throw();
48
+ });
49
+ });
50
+ }
@@ -0,0 +1,48 @@
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 { chai, describe, it } from 'vitest';
5
+ import { eventSourceType, eventStreamType, reactor, reducer } from '../../../index.js';
6
+ import { ReactorScenario, ReadModelScenario, UnsupportedReactorOperation, UnsupportedReducerOperation } from '../../index.js';
7
+
8
+ chai.should();
9
+ function should(value: unknown): ReturnType<typeof chai.expect> {
10
+ return (value as { should: ReturnType<typeof chai.expect> }).should;
11
+ }
12
+
13
+ class State { count = 0; }
14
+ @reactor('source-filtered-scenario')
15
+ @reducer('source-filtered-scenario', undefined, State)
16
+ @eventSourceType('Customers')
17
+ class SourceFiltered {}
18
+ @reactor('stream-filtered-scenario')
19
+ @reducer('stream-filtered-scenario', undefined, State)
20
+ @eventStreamType('Orders')
21
+ class StreamFiltered {}
22
+ @reactor('default-filtered-scenario')
23
+ @reducer('default-filtered-scenario', undefined, State)
24
+ @eventSourceType('')
25
+ @eventStreamType('All')
26
+ class DefaultFiltered {}
27
+ class InheritedSourceFiltered extends SourceFiltered {}
28
+
29
+ for (const type of [SourceFiltered, StreamFiltered, InheritedSourceFiltered]) {
30
+ describe(`when creating observer scenarios for ${type.name}`, () => {
31
+ it('should reject unproven reactor filter semantics before delivering events', () => {
32
+ should(() => new ReactorScenario(type)).throw(UnsupportedReactorOperation, 'Use a kernel-backed test.');
33
+ });
34
+ it('should reject unproven reducer filter semantics before folding events', () => {
35
+ should(() => new ReadModelScenario(State, { reducers: [type], eventTypes: [], projections: [] }))
36
+ .throw(UnsupportedReducerOperation, 'Use a kernel-backed test.');
37
+ });
38
+ });
39
+ }
40
+
41
+ describe('when creating observer scenarios with explicit default filters', () => {
42
+ it('should allow the existing unrestricted reactor scenario', () => {
43
+ should(() => new ReactorScenario(DefaultFiltered, { artifacts: { eventTypes: [] } })).not.throw();
44
+ });
45
+ it('should allow the existing unrestricted reducer scenario', () => {
46
+ should(() => new ReadModelScenario(State, { reducers: [DefaultFiltered], eventTypes: [], projections: [] })).not.throw();
47
+ });
48
+ });
package/testing/index.ts CHANGED
@@ -7,6 +7,7 @@ export type { ReactorScenarioOptions } from './ReactorScenarioOptions.js';
7
7
  export type { ReactorDeliveryResult } from './ReactorDeliveryResult.js';
8
8
  export type { RecordedReactorSideEffect } from './RecordedReactorSideEffect.js';
9
9
  export { UnsupportedReactorOperation } from './UnsupportedReactorOperation.js';
10
+ export { UnsupportedReducerOperation } from './UnsupportedReducerOperation.js';
10
11
  export type { EventScenarioOptions } from './EventScenarioOptions.js';
11
12
  export { EventScenarioGivenBuilder } from './EventScenarioGivenBuilder.js';
12
13
  export { EventScenarioGiven } from './EventScenarioGiven.js';
@@ -38,6 +38,11 @@ const allFixtures = readdirSync(directory).filter(name => name.endsWith('.json')
38
38
  .map(name => ({ name, fixture: JSON.parse(readFileSync(new URL(name, directory), 'utf8')) as Fixture }));
39
39
  const fixtures = allFixtures.filter(({ fixture }) => fixture.kind === 'kernelSemantics');
40
40
  const guardErrors: Record<string, { type: string; message: string }> = {
41
+ 'composite-parent-key-without-constant.json': { type: 'UnsupportedProjectionOperation', message: 'only a lowercase id with a fixture-backed string, GUID, or number/double schema' },
42
+ 'composite-child-join-key-expression-parts.json': { type: 'UnsupportedProjectionOperation', message: 'only a lowercase id with a fixture-backed string, GUID, or number/double schema' },
43
+ 'composite-key-expression-parts.json': { type: 'UnsupportedProjectionOperation', message: 'only a lowercase id with a fixture-backed string, GUID, or number/double schema' },
44
+ 'composite-parent-key-expression-parts.json': { type: 'UnsupportedProjectionOperation', message: 'only a lowercase id with a fixture-backed string, GUID, or number/double schema' },
45
+ 'composite-join-key-expression-parts.json': { type: 'UnsupportedProjectionOperation', message: 'join keys other than $eventSourceId' },
41
46
  'from-all-only.json': { type: 'UnsupportedProjectionOperation', message: 'subscribe-to-all projections require a kernel-backed test' },
42
47
  'from-all-mixed.json': { type: 'UnsupportedProjectionOperation', message: 'subscribe-to-all projections require a kernel-backed test' },
43
48
  'from-all-exclude-children.json': { type: 'UnsupportedProjectionOperation', message: 'subscribe-to-all projections require a kernel-backed test' },
@@ -0,0 +1,40 @@
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 'reflect-metadata';
5
+ import { chai, describe, it } from 'vitest';
6
+ import type { ICompositeKeyBuilder } from '../../../projections/declarative/ICompositeKeyBuilder.js';
7
+ import { ProjectionCapabilities } from '../ProjectionCapabilities.js';
8
+ import { UnsupportedProjectionOperation } from '../UnsupportedProjectionOperation.js';
9
+ import { Changed } from './given/Changed.js';
10
+ import { Removed } from './given/Removed.js';
11
+ import { compileDeclarative } from './given/compile.js';
12
+
13
+ chai.should();
14
+
15
+ for (const source of ['event property', 'context', 'source id', 'constant'] as const) {
16
+ for (const kind of ['root', 'parent', 'join'] as const) {
17
+ describe(`when validating a ${kind} composite key with a ${source} part before replay`, () => {
18
+ it('should reject the composite key rather than evaluate unproven semantics', () => {
19
+ const configure = <TEvent>(key: ICompositeKeyBuilder<{ part: string }, TEvent>) => {
20
+ const part = key.set(target => target.part);
21
+ if (source === 'context') return part.toEventContextProperty('subject');
22
+ if (source === 'source id') return part.toEventSourceId();
23
+ if (source === 'constant') return part.toValue('fixed');
24
+ return part.to(event => (event as { name: string }).name);
25
+ };
26
+ const { compiled, definition } = compileDeclarative(builder => {
27
+ if (kind === 'root') builder.from(Changed, from => from.usingCompositeKey(configure));
28
+ if (kind === 'parent') builder.from(Changed, from => from.usingParentCompositeKey(configure));
29
+ if (kind === 'join') builder
30
+ .from(Changed, from => from.set(model => model.state).to(event => event.name))
31
+ .join(Removed, join => join.on(model => model.state).usingCompositeKey(configure));
32
+ });
33
+ const section = kind === 'join' ? 'Join[capability-removed:1].Key'
34
+ : kind === 'parent' ? 'From[capability-changed:1].ParentKey' : 'From[capability-changed:1].Key';
35
+ (() => ProjectionCapabilities.validate(compiled, definition)).should.throw(UnsupportedProjectionOperation)
36
+ .with.property('message').that.includes(section);
37
+ });
38
+ });
39
+ }
40
+ }