@cratis/chronicle 6.46.0 → 6.48.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 (139) hide show
  1. package/ChronicleClient.ts +3 -0
  2. package/EventStore.ts +2 -1
  3. package/connection/ConnectionLifecycle.ts +22 -0
  4. package/dist/ChronicleClient.d.ts.map +1 -1
  5. package/dist/ChronicleClient.js +3 -0
  6. package/dist/ChronicleClient.js.map +1 -1
  7. package/dist/EventStore.d.ts.map +1 -1
  8. package/dist/EventStore.js +2 -1
  9. package/dist/EventStore.js.map +1 -1
  10. package/dist/connection/ConnectionLifecycle.d.ts +8 -0
  11. package/dist/connection/ConnectionLifecycle.d.ts.map +1 -1
  12. package/dist/connection/ConnectionLifecycle.js +21 -0
  13. package/dist/connection/ConnectionLifecycle.js.map +1 -1
  14. package/dist/events/eventSourceTypeDecorator.d.ts +18 -0
  15. package/dist/events/eventSourceTypeDecorator.d.ts.map +1 -0
  16. package/dist/events/eventSourceTypeDecorator.js +25 -0
  17. package/dist/events/eventSourceTypeDecorator.js.map +1 -0
  18. package/dist/events/eventStreamTypeDecorator.d.ts +20 -0
  19. package/dist/events/eventStreamTypeDecorator.d.ts.map +1 -0
  20. package/dist/events/eventStreamTypeDecorator.js +29 -0
  21. package/dist/events/eventStreamTypeDecorator.js.map +1 -0
  22. package/dist/events/index.d.ts +2 -0
  23. package/dist/events/index.d.ts.map +1 -1
  24. package/dist/events/index.js +2 -0
  25. package/dist/events/index.js.map +1 -1
  26. package/dist/reactors/ReactorSideEffects.d.ts +6 -3
  27. package/dist/reactors/ReactorSideEffects.d.ts.map +1 -1
  28. package/dist/reactors/ReactorSideEffects.js +12 -6
  29. package/dist/reactors/ReactorSideEffects.js.map +1 -1
  30. package/dist/reactors/Reactors.d.ts.map +1 -1
  31. package/dist/reactors/Reactors.js +4 -2
  32. package/dist/reactors/Reactors.js.map +1 -1
  33. package/dist/readModels/IReadModelWatcher.d.ts +30 -0
  34. package/dist/readModels/IReadModelWatcher.d.ts.map +1 -0
  35. package/dist/readModels/IReadModelWatcher.js +4 -0
  36. package/dist/readModels/IReadModelWatcher.js.map +1 -0
  37. package/dist/readModels/IReadModels.d.ts +16 -2
  38. package/dist/readModels/IReadModels.d.ts.map +1 -1
  39. package/dist/readModels/ReadModelChangeContext.d.ts +9 -0
  40. package/dist/readModels/ReadModelChangeContext.d.ts.map +1 -0
  41. package/dist/readModels/ReadModelChangeContext.js +4 -0
  42. package/dist/readModels/ReadModelChangeContext.js.map +1 -0
  43. package/dist/readModels/ReadModelChangeType.d.ts +7 -0
  44. package/dist/readModels/ReadModelChangeType.d.ts.map +1 -0
  45. package/dist/readModels/ReadModelChangeType.js +10 -0
  46. package/dist/readModels/ReadModelChangeType.js.map +1 -0
  47. package/dist/readModels/ReadModelChangeset.d.ts +6 -0
  48. package/dist/readModels/ReadModelChangeset.d.ts.map +1 -1
  49. package/dist/readModels/ReadModelWatchOptions.d.ts +19 -0
  50. package/dist/readModels/ReadModelWatchOptions.d.ts.map +1 -0
  51. package/dist/readModels/ReadModelWatchOptions.js +4 -0
  52. package/dist/readModels/ReadModelWatchOptions.js.map +1 -0
  53. package/dist/readModels/ReadModelWatcher.d.ts +52 -0
  54. package/dist/readModels/ReadModelWatcher.d.ts.map +1 -0
  55. package/dist/readModels/ReadModelWatcher.js +276 -0
  56. package/dist/readModels/ReadModelWatcher.js.map +1 -0
  57. package/dist/readModels/ReadModels.d.ts +11 -1
  58. package/dist/readModels/ReadModels.d.ts.map +1 -1
  59. package/dist/readModels/ReadModels.js +47 -11
  60. package/dist/readModels/ReadModels.js.map +1 -1
  61. package/dist/readModels/for_ReadModelWatcher/given/a_kernel_stream.d.ts +15 -0
  62. package/dist/readModels/for_ReadModelWatcher/given/a_kernel_stream.d.ts.map +1 -0
  63. package/dist/readModels/for_ReadModelWatcher/given/a_kernel_stream.js +45 -0
  64. package/dist/readModels/for_ReadModelWatcher/given/a_kernel_stream.js.map +1 -0
  65. package/dist/readModels/for_ReadModelWatcher/given/a_watched_read_model.d.ts +21 -0
  66. package/dist/readModels/for_ReadModelWatcher/given/a_watched_read_model.d.ts.map +1 -0
  67. package/dist/readModels/for_ReadModelWatcher/given/a_watched_read_model.js +35 -0
  68. package/dist/readModels/for_ReadModelWatcher/given/a_watched_read_model.js.map +1 -0
  69. package/dist/readModels/index.d.ts +4 -0
  70. package/dist/readModels/index.d.ts.map +1 -1
  71. package/dist/readModels/index.js +1 -0
  72. package/dist/readModels/index.js.map +1 -1
  73. package/dist/reducers/Reducers.d.ts.map +1 -1
  74. package/dist/reducers/Reducers.js +4 -2
  75. package/dist/reducers/Reducers.js.map +1 -1
  76. package/dist/testing/ReactorScenario.d.ts.map +1 -1
  77. package/dist/testing/ReactorScenario.js +11 -1
  78. package/dist/testing/ReactorScenario.js.map +1 -1
  79. package/dist/testing/ReducerReadModelProcessor.d.ts.map +1 -1
  80. package/dist/testing/ReducerReadModelProcessor.js +13 -0
  81. package/dist/testing/ReducerReadModelProcessor.js.map +1 -1
  82. package/dist/testing/UnsupportedReducerOperation.d.ts +7 -0
  83. package/dist/testing/UnsupportedReducerOperation.d.ts.map +1 -0
  84. package/dist/testing/UnsupportedReducerOperation.js +14 -0
  85. package/dist/testing/UnsupportedReducerOperation.js.map +1 -0
  86. package/dist/testing/index.d.ts +1 -0
  87. package/dist/testing/index.d.ts.map +1 -1
  88. package/dist/testing/index.js +1 -0
  89. package/dist/testing/index.js.map +1 -1
  90. package/dist/tsconfig.tsbuildinfo +1 -1
  91. package/events/eventSourceTypeDecorator.ts +28 -0
  92. package/events/eventStreamTypeDecorator.ts +33 -0
  93. package/events/for_observerFilters/when_registering/filter_registration_behaviors.fixture.ts +55 -0
  94. package/events/for_observerFilters/when_registering/with_legacy_decorators.legacy.spec.ts +30 -0
  95. package/events/for_observerFilters/when_registering/with_standard_decorators.standard.spec.ts +30 -0
  96. package/events/index.ts +2 -0
  97. package/for_ChronicleClient/when_reconnecting/with_waiting_read_model_watchers.spec.ts +117 -0
  98. package/jobs/for_Jobs/against_a_kernel/when_managing_a_replay_job.integration.spec.ts +24 -4
  99. package/package.json +2 -2
  100. package/reactors/ReactorSideEffects.ts +16 -6
  101. package/reactors/Reactors.ts +4 -2
  102. package/reactors/for_ReactorSideEffects/when_dispatching/with_reactor_source_and_stream_types.spec.ts +75 -0
  103. package/reactors/for_Reactors/against_a_kernel/when_filtering_source_and_stream_types.integration.spec.ts +147 -0
  104. package/reactors/for_Reactors/against_a_kernel/when_replaying_reactors_with_once_only_handlers.integration.spec.ts +27 -8
  105. package/readModels/IReadModelWatcher.ts +35 -0
  106. package/readModels/IReadModels.ts +17 -2
  107. package/readModels/ReadModelChangeContext.ts +11 -0
  108. package/readModels/ReadModelChangeType.ts +9 -0
  109. package/readModels/ReadModelChangeset.ts +9 -0
  110. package/readModels/ReadModelWatchOptions.ts +23 -0
  111. package/readModels/ReadModelWatcher.ts +271 -0
  112. package/readModels/ReadModels.ts +60 -11
  113. package/readModels/for_ReadModelWatcher/against_a_kernel/when_watching_changes.integration.spec.ts +104 -0
  114. package/readModels/for_ReadModelWatcher/given/a_kernel_stream.ts +50 -0
  115. package/readModels/for_ReadModelWatcher/given/a_watched_read_model.ts +37 -0
  116. package/readModels/for_ReadModelWatcher/watchCompatibility.standard.spec.ts +29 -0
  117. package/readModels/for_ReadModelWatcher/when_a_stream_cannot_recover.spec.ts +86 -0
  118. package/readModels/for_ReadModelWatcher/when_applying_backpressure.spec.ts +80 -0
  119. package/readModels/for_ReadModelWatcher/when_failing_with_buffered_changes.spec.ts +39 -0
  120. package/readModels/for_ReadModelWatcher/when_not_opting_into_resume.spec.ts +62 -0
  121. package/readModels/for_ReadModelWatcher/when_notifying_resubscription.spec.ts +128 -0
  122. package/readModels/for_ReadModelWatcher/when_opting_into_overflow.spec.ts +39 -0
  123. package/readModels/for_ReadModelWatcher/when_receiving_changes.spec.ts +69 -0
  124. package/readModels/for_ReadModelWatcher/when_reconnecting.spec.ts +99 -0
  125. package/readModels/for_ReadModelWatcher/when_reconnecting_during_conversion.spec.ts +84 -0
  126. package/readModels/for_ReadModelWatcher/when_recovering_a_stream.spec.ts +106 -0
  127. package/readModels/for_ReadModelWatcher/when_stopping.spec.ts +85 -0
  128. package/readModels/for_ReadModelWatcher/when_subscribing.spec.ts +62 -0
  129. package/readModels/for_ReadModels/when_watching.spec.ts +85 -0
  130. package/readModels/index.ts +4 -0
  131. package/reducers/Reducers.ts +4 -2
  132. package/testing/ReactorScenario.spec.ts +18 -1
  133. package/testing/ReactorScenario.ts +11 -1
  134. package/testing/ReducerReadModelProcessor.ts +13 -0
  135. package/testing/UnsupportedReducerOperation.ts +10 -0
  136. package/testing/for_ReactorScenario/when_recording_side_effects/with_declared_stream_type.spec.ts +50 -0
  137. package/testing/for_observerScenarios/when_selecting_filters/with_reducer_tag_filters.spec.ts +50 -0
  138. package/testing/for_observerScenarios/when_selecting_filters/with_source_and_stream_types.spec.ts +48 -0
  139. package/testing/index.ts +1 -0
@@ -0,0 +1,30 @@
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 { eventSourceType, eventStreamType, filterEventsByTag, tag } from '../../../index.js';
5
+ import { filterRegistrationBehaviors } from './filter_registration_behaviors.fixture.js';
6
+
7
+ class Unfiltered {}
8
+ @eventSourceType('Customers')
9
+ class SourceFiltered {}
10
+ @eventStreamType('Orders')
11
+ class StreamFiltered {}
12
+ @eventSourceType('Customers')
13
+ @eventStreamType('Orders')
14
+ @filterEventsByTag('vip')
15
+ @filterEventsByTag('priority')
16
+ @tag('Analytics')
17
+ class Combined {}
18
+ class Inherited extends Combined {}
19
+ @eventSourceType('')
20
+ @eventStreamType('All')
21
+ class Reset extends Combined {}
22
+
23
+ filterRegistrationBehaviors([
24
+ { type: Unfiltered, source: '', stream: 'All', tags: [] },
25
+ { type: SourceFiltered, source: 'Customers', stream: 'All', tags: [] },
26
+ { type: StreamFiltered, source: '', stream: 'Orders', tags: [] },
27
+ { type: Combined, source: 'Customers', stream: 'Orders', tags: ['priority', 'vip'] },
28
+ { type: Inherited, source: 'Customers', stream: 'Orders', tags: ['priority', 'vip'] },
29
+ { type: Reset, source: '', stream: 'All', tags: ['priority', 'vip'] }
30
+ ]);
@@ -0,0 +1,30 @@
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 { eventSourceType, eventStreamType, filterEventsByTag, tag } from '../../../index.js';
5
+ import { filterRegistrationBehaviors } from './filter_registration_behaviors.fixture.js';
6
+
7
+ class Unfiltered {}
8
+ @eventSourceType('Customers')
9
+ class SourceFiltered {}
10
+ @eventStreamType('Orders')
11
+ class StreamFiltered {}
12
+ @eventSourceType('Customers')
13
+ @eventStreamType('Orders')
14
+ @filterEventsByTag('vip')
15
+ @filterEventsByTag('priority')
16
+ @tag('Analytics')
17
+ class Combined {}
18
+ class Inherited extends Combined {}
19
+ @eventSourceType('')
20
+ @eventStreamType('All')
21
+ class Reset extends Combined {}
22
+
23
+ filterRegistrationBehaviors([
24
+ { type: Unfiltered, source: '', stream: 'All', tags: [] },
25
+ { type: SourceFiltered, source: 'Customers', stream: 'All', tags: [] },
26
+ { type: StreamFiltered, source: '', stream: 'Orders', tags: [] },
27
+ { type: Combined, source: 'Customers', stream: 'Orders', tags: ['priority', 'vip'] },
28
+ { type: Inherited, source: 'Customers', stream: 'Orders', tags: ['priority', 'vip'] },
29
+ { type: Reset, source: '', stream: 'All', tags: ['priority', 'vip'] }
30
+ ]);
package/events/index.ts CHANGED
@@ -18,6 +18,8 @@ export { isRegisteredEvent } from './isRegisteredEvent.js';
18
18
  export { Tag } from './Tag.js';
19
19
  export { tag, tags, getTagsFor } from './tagDecorator.js';
20
20
  export { filterEventsByTag, getFilterTagsFor } from './filterEventsByTagDecorator.js';
21
+ export { eventSourceType, getEventSourceTypeFor } from './eventSourceTypeDecorator.js';
22
+ export { eventStreamType, getEventStreamTypeFor } from './eventStreamTypeDecorator.js';
21
23
  export { mergeTags } from './mergeTags.js';
22
24
  export * from './constraints/index.js';
23
25
  export * from './migrations/index.js';
@@ -0,0 +1,117 @@
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 { ChronicleOptions } from '../../ChronicleOptions.js';
7
+ import { EventStore } from '../../EventStore.js';
8
+ import type { IEventStore } from '../../IEventStore.js';
9
+ import { IncompatibleChronicleServer } from '../../connection/IncompatibleChronicleServer.js';
10
+ import { RejectedChronicleCredentials } from '../../connection/RejectedChronicleCredentials.js';
11
+ import { projection } from '../../projections/declarative/projection.js';
12
+ import type { IReadModelWatcher } from '../../readModels/IReadModelWatcher.js';
13
+ import { a_kernel_stream } from '../../readModels/for_ReadModelWatcher/given/a_kernel_stream.js';
14
+
15
+ chai.should();
16
+
17
+ const transport = vi.hoisted(() => ({
18
+ resetChannel: vi.fn(), connect: vi.fn(), disconnect: vi.fn(), getVersionInfo: vi.fn(),
19
+ ensureEventStore: vi.fn(), watch: vi.fn(),
20
+ lost: undefined as ((reason: string, error: unknown) => void) | undefined
21
+ }));
22
+ vi.mock('../../connection/ChronicleConnection', () => ({
23
+ ChronicleConnection: class {
24
+ resetChannel = transport.resetChannel;
25
+ connect = transport.connect;
26
+ disconnect = transport.disconnect;
27
+ server = { getVersionInfo: transport.getVersionInfo };
28
+ eventStores = { ensureEventStore: transport.ensureEventStore };
29
+ readModels = { watch: transport.watch };
30
+ }
31
+ }));
32
+ vi.mock('../../connection/KernelKeepAlive', () => ({
33
+ KernelKeepAlive: class {
34
+ constructor(_connections: unknown, onLost: (reason: string, error: unknown) => void) { transport.lost = onLost; }
35
+ async start() {}
36
+ }
37
+ }));
38
+
39
+ class Model { id = ''; }
40
+ class ModelProjection { define() {} }
41
+ projection('model', Model)(ModelProjection);
42
+
43
+ describe('when reconnecting with waiting read model watchers', () => {
44
+ let client: ChronicleClient;
45
+ let store: IEventStore;
46
+ let watcher: IReadModelWatcher<Model>;
47
+ let stream: a_kernel_stream;
48
+ let rejectReconnect: (error: Error) => void;
49
+
50
+ beforeEach(async () => {
51
+ vi.useFakeTimers();
52
+ vi.clearAllMocks();
53
+ // Registration is outside this connection-lifetime specification; retain the real store and watchers.
54
+ vi.spyOn(EventStore.prototype, 'registerArtifacts').mockResolvedValue(undefined);
55
+ transport.resetChannel.mockResolvedValue(undefined);
56
+ transport.connect.mockResolvedValue(undefined);
57
+ transport.getVersionInfo.mockResolvedValue({});
58
+ transport.ensureEventStore.mockResolvedValue({ IsSuccess: true, IsAuthorized: true });
59
+ stream = new a_kernel_stream();
60
+ transport.watch.mockImplementation((_request, options: { signal: AbortSignal }) => {
61
+ stream.signal = options.signal;
62
+ options.signal.addEventListener('abort', () => stream.end(), { once: true });
63
+ return stream;
64
+ });
65
+ client = new ChronicleClient(ChronicleOptions.fromConnectionString('chronicle://localhost:35000', {
66
+ discoveryPatterns: [],
67
+ clientArtifactsProvider: {
68
+ readModels: [Model], projections: [ModelProjection], eventTypes: [], reactors: [], reducers: [],
69
+ seeders: [], constraints: [], webhooks: [], eventTypeMigrations: [], globalForHandlers: []
70
+ }
71
+ }));
72
+ store = await client.getEventStore('store');
73
+ watcher = store.readModels.createWatcher(Model, { resume: true });
74
+ stream.send({ Subscribed: true });
75
+ await watcher.subscribed;
76
+ transport.connect.mockImplementation(() => new Promise<void>((_resolve, reject) => { rejectReconnect = reject; }));
77
+ transport.lost!('keep-alive-ended', { code: 14 });
78
+ await vi.advanceTimersByTimeAsync(0);
79
+ stream.signal.aborted.should.be.true;
80
+ });
81
+ afterEach(() => {
82
+ client.dispose();
83
+ vi.restoreAllMocks();
84
+ vi.useRealTimers();
85
+ });
86
+
87
+ it.each([
88
+ ['incompatible server', IncompatibleChronicleServer],
89
+ ['rejected credentials', RejectedChronicleCredentials]
90
+ ] as const)('should reject readiness and iteration on terminal %s', async (_reason, Failure) => {
91
+ const failure = new Failure('Replacement connection rejected');
92
+ const readiness = watcher.subscribed.catch(error => error);
93
+ const next = watcher[Symbol.asyncIterator]().next().catch(error => error);
94
+ rejectReconnect(failure);
95
+ (await readiness).should.equal(failure);
96
+ (await next).should.equal(failure);
97
+ await vi.advanceTimersByTimeAsync(15000);
98
+ transport.watch.mock.calls.should.have.lengthOf(1);
99
+ transport.connect.mock.calls.should.have.lengthOf(2);
100
+ // A cached store cannot create a watcher that waits forever after the terminal verdict.
101
+ const later = store.readModels.createWatcher(Model);
102
+ (await later.subscribed.catch(error => error)).should.equal(failure);
103
+ (await later[Symbol.asyncIterator]().next().catch(error => error)).should.equal(failure);
104
+ });
105
+
106
+ it('should reject pending readiness but complete iteration cleanly if disposed during recovery', async () => {
107
+ const readiness = watcher.subscribed.catch(error => error);
108
+ const next = watcher[Symbol.asyncIterator]().next();
109
+ client.dispose();
110
+ (await readiness).name.should.equal('AbortError');
111
+ (await next).done!.should.be.true;
112
+ rejectReconnect(new IncompatibleChronicleServer('Late reconnect failure'));
113
+ await vi.advanceTimersByTimeAsync(15000);
114
+ (await watcher[Symbol.asyncIterator]().next()).done!.should.be.true;
115
+ transport.watch.mock.calls.should.have.lengthOf(1);
116
+ });
117
+ });
@@ -3,12 +3,13 @@
3
3
 
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import { afterAll, beforeAll, chai, describe, it } from 'vitest';
6
- import { JobStatus, JobStepStatus, type JobStepSummaryResponse, type JobSummaryResponse } from '@cratis/chronicle.contracts';
6
+ import { JobStatus, JobStepStatus, ObserverRunningState, type JobStepSummaryResponse, type JobSummaryResponse } from '@cratis/chronicle.contracts';
7
7
  import { field, Guid } from '@cratis/fundamentals';
8
8
  import { ChronicleClient } from '../../../ChronicleClient.js';
9
9
  import { ChronicleOptions } from '../../../ChronicleOptions.js';
10
10
  import { fromContractsGuid } from '../../../connection/Guid.js';
11
11
  import type { IClientArtifactsProvider } from '../../../artifacts/index.js';
12
+ import type { ChronicleConnection } from '../../../connection/index.js';
12
13
  import { eventType } from '../../../events/index.js';
13
14
  import type { IEventStore } from '../../../IEventStore.js';
14
15
  import { fromEvent } from '../../../projections/index.js';
@@ -55,8 +56,10 @@ async function eventually<T>(read: () => Promise<T>, accept: (value: T) => boole
55
56
  }
56
57
 
57
58
  describe.skipIf(!connectionString && !process.env.CI)('when managing a replay job against a kernel', () => {
59
+ const storeName = `Jobs${randomUUID().replaceAll('-', '').slice(0, 12)}`;
58
60
  let client: ChronicleClient;
59
61
  let store: IEventStore;
62
+ let connection: ChronicleConnection;
60
63
  let jobId: JobId;
61
64
  let listed: JobSummaryResponse[];
62
65
  let stopped: JobSummaryResponse | undefined;
@@ -65,19 +68,36 @@ describe.skipIf(!connectionString && !process.env.CI)('when managing a replay jo
65
68
  let stopUnknownJob: unknown;
66
69
  let deleteUnknownJob: unknown;
67
70
 
71
+ const waitForActiveObserver = (lastHandledSequenceNumber?: bigint) => eventually(
72
+ () => connection.observers.getObserverInformation({
73
+ EventStore: storeName,
74
+ Namespace: 'Default',
75
+ ObserverId: 'Item',
76
+ EventSequenceId: 'event-log'
77
+ }),
78
+ observer => observer.IsSubscribed && observer.RunningState === ObserverRunningState.Active &&
79
+ (lastHandledSequenceNumber === undefined || observer.LastHandledEventSequenceNumber === lastHandledSequenceNumber),
80
+ 15_000);
81
+
82
+ // Readiness (30s) and the existing job-state waits (90s) leave 30s for RPC overhead.
68
83
  beforeAll(async () => {
69
84
  client = new ChronicleClient(ChronicleOptions.fromConnectionString(connectionString!, {
70
85
  discoveryPatterns: [],
71
86
  clientArtifactsProvider: artifacts
72
87
  }));
73
- store = await client.getEventStore(`Jobs${randomUUID().replaceAll('-', '').slice(0, 12)}`);
88
+ store = await client.getEventStore(storeName);
89
+ connection = (store as unknown as { _connection: ChronicleConnection })._connection;
74
90
 
91
+ // Append only after initial catch-up completes, so it cannot race the explicit replay.
92
+ await waitForActiveObserver();
75
93
  // Enough events that the replay is still running when it is stopped; a stopped job stays until deleted,
76
94
  // where a completed one is removed and could not be listed reliably.
77
- await store.eventLog.appendMany(Array.from({ length: 500 }, (_, index) => ({
95
+ const appended = await store.eventLog.appendMany(Array.from({ length: 500 }, (_, index) => ({
78
96
  eventSourceId: randomUUID(),
79
97
  event: Object.assign(new ItemAdded(), { name: `Item ${index}` })
80
98
  })));
99
+ // ReplayForModel uses Observer.Replay too; wait for acknowledged progress before requesting it.
100
+ await waitForActiveObserver(appended.at(-1)!.sequenceNumber.value);
81
101
 
82
102
  jobId = await store.projections.replayForModel(Item);
83
103
 
@@ -98,7 +118,7 @@ describe.skipIf(!connectionString && !process.env.CI)('when managing a replay jo
98
118
 
99
119
  stopUnknownJob = await store.jobs.stop(randomUUID()).catch((error: unknown) => error);
100
120
  deleteUnknownJob = await store.jobs.delete(randomUUID()).catch((error: unknown) => error);
101
- });
121
+ }, 150_000);
102
122
 
103
123
  afterAll(() => client?.dispose());
104
124
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cratis/chronicle",
3
- "version": "6.46.0",
3
+ "version": "6.48.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",
@@ -173,7 +173,7 @@
173
173
  "clean": "rm -rf dist",
174
174
  "test": "vitest run",
175
175
  "test:integration": "vitest run --config vitest.integration.config.ts",
176
- "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/declarative/for_CompositeKeyBuilder/when_typing_constant_parts.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 testing/for_ReadModelScenario/when_evaluating_joins/with_standard_decorators.standard.spec.ts testing/for_EventScenario/when_appending_field_types/with_standard_decorators.standard.spec.ts eventSequences/for_EventSequence/when_appending/with_subject_annotations.standard.spec.ts projections/for_ProjectionDefinitionCompiler/when_compiling_from_all.standard.spec.ts projections/for_ProjectionDefinitionCompiler/when_compiling_tags/with_standard_decorators.standard.spec.ts telemetry/for_ChronicleTelemetry/when_exposing_names/with_convention_names.spec.ts projections/for_ProjectionDefinitionCompiler/when_compiling_from_all_events.standard.spec.ts",
176
+ "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/declarative/for_CompositeKeyBuilder/when_typing_constant_parts.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 testing/for_ReadModelScenario/when_evaluating_joins/with_standard_decorators.standard.spec.ts testing/for_EventScenario/when_appending_field_types/with_standard_decorators.standard.spec.ts eventSequences/for_EventSequence/when_appending/with_subject_annotations.standard.spec.ts projections/for_ProjectionDefinitionCompiler/when_compiling_from_all.standard.spec.ts projections/for_ProjectionDefinitionCompiler/when_compiling_tags/with_standard_decorators.standard.spec.ts telemetry/for_ChronicleTelemetry/when_exposing_names/with_convention_names.spec.ts projections/for_ProjectionDefinitionCompiler/when_compiling_from_all_events.standard.spec.ts events/for_observerFilters/when_registering/with_standard_decorators.standard.spec.ts reactors/for_Reactors/against_a_kernel/when_filtering_source_and_stream_types.integration.spec.ts testing/for_observerScenarios/when_selecting_filters/with_source_and_stream_types.spec.ts readModels/for_ReadModelWatcher/watchCompatibility.standard.spec.ts",
177
177
  "test:native": "node ./scripts/check-native-esm.mjs"
178
178
  },
179
179
  "dependencies": {
@@ -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
+ });