@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,69 @@
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 { Guid } from '@cratis/fundamentals';
5
+ import { ReadModelChangeType as ContractChangeType } from '@cratis/chronicle.contracts';
6
+ import { afterEach, beforeEach, chai, describe, it } from 'vitest';
7
+ import { toContractsGuid } from '../../connection/Guid.js';
8
+ import type { IReadModelWatcher } from '../IReadModelWatcher.js';
9
+ import { ReadModelChangeType } from '../ReadModelChangeType.js';
10
+ import { a_watched_read_model } from './given/a_watched_read_model.js';
11
+
12
+ chai.should();
13
+
14
+ describe('when receiving read model changes', () => {
15
+ let context: a_watched_read_model;
16
+ let watcher: IReadModelWatcher<{ id: string }>;
17
+ beforeEach(async () => {
18
+ context = new a_watched_read_model();
19
+ await context.connect();
20
+ watcher = context.readModels.createWatcher(context.model);
21
+ context.streams[0].send({ Subscribed: true });
22
+ await watcher.subscribed;
23
+ });
24
+ afterEach(() => watcher.dispose());
25
+
26
+ for (const [contractKind, kind] of [
27
+ [ContractChangeType.Added, ReadModelChangeType.Added],
28
+ [ContractChangeType.Modified, ReadModelChangeType.Modified],
29
+ [ContractChangeType.Removed, ReadModelChangeType.Removed],
30
+ [ContractChangeType.UNRECOGNIZED, ReadModelChangeType.Modified]
31
+ ]) {
32
+ it(`should preserve ${ContractChangeType[contractKind]} changes and their triggering context`, async () => {
33
+ const removed = contractKind === ContractChangeType.Removed;
34
+ const occurred = '2026-06-07T08:09:10.123Z';
35
+ const correlationId = '12345678-1234-5678-9abc-123456789abc';
36
+ context.streams[0].send({ Namespace: 'tenant', ModelKey: 'one', ReadModel: removed ? '' : '{"id":"one"}',
37
+ Removed: removed, ChangeType: contractKind, EventSequenceNumber: 9007199254740997n,
38
+ CorrelationId: toContractsGuid(Guid.as(correlationId)), Occurred: { Value: occurred } });
39
+ const change = (await watcher[Symbol.asyncIterator]().next()).value;
40
+ change.namespace.should.equal('tenant');
41
+ change.key.should.equal('one');
42
+ change.removed.should.equal(removed);
43
+ change.readModel.should.be.instanceOf(context.model);
44
+ change.changeType.should.equal(kind);
45
+ change.changeContext.should.deep.equal({ eventStore: 'store', namespace: 'tenant',
46
+ sequenceNumber: 9007199254740997n, correlationId, occurred: new Date(occurred) });
47
+ });
48
+ }
49
+
50
+ it('should leave absent timestamp and correlation metadata absent', async () => {
51
+ context.streams[0].send({ ReadModel: '{}' });
52
+ const change = (await watcher[Symbol.asyncIterator]().next()).value;
53
+ (change.changeContext.occurred === undefined).should.be.true;
54
+ (change.changeContext.correlationId === undefined).should.be.true;
55
+ });
56
+
57
+ it.each(['', 'not a timestamp'])('should leave an empty or invalid timestamp (%s) absent', async value => {
58
+ context.streams[0].send({ ReadModel: '{}', Occurred: { Value: value } });
59
+ const change = (await watcher[Symbol.asyncIterator]().next()).value;
60
+ (change.changeContext.occurred === undefined).should.be.true;
61
+ });
62
+
63
+ it('should surface a deserialization failure and stop the stream', async () => {
64
+ const next = watcher[Symbol.asyncIterator]().next().catch(error => error);
65
+ context.streams[0].send({ ReadModel: 'not json' });
66
+ (await next).should.be.instanceOf(SyntaxError);
67
+ context.streams[0].signal.aborted.should.be.true;
68
+ });
69
+ });
@@ -0,0 +1,99 @@
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 } from 'vitest';
5
+ import type { IReadModelWatcher } from '../IReadModelWatcher.js';
6
+ import { a_watched_read_model } from './given/a_watched_read_model.js';
7
+
8
+ chai.should();
9
+
10
+ describe('when reconnecting a read model watcher', () => {
11
+ let context: a_watched_read_model;
12
+ let watcher: IReadModelWatcher<{ id: string }>;
13
+ beforeEach(async () => {
14
+ context = new a_watched_read_model();
15
+ await context.connect();
16
+ watcher = context.readModels.createWatcher(context.model, { resume: true });
17
+ });
18
+ afterEach(() => context.readModels.dispose());
19
+
20
+ it('should replace resolved readiness at disconnect and continue the same iterator after a fresh acknowledgment', async () => {
21
+ context.streams[0].send({ Subscribed: true });
22
+ const firstReadiness = watcher.subscribed;
23
+ await firstReadiness;
24
+ const iterator = watcher[Symbol.asyncIterator]();
25
+ const next = iterator.next();
26
+ await context.disconnect();
27
+ context.streams[0].signal.aborted.should.be.true;
28
+ watcher.subscribed.should.not.equal(firstReadiness);
29
+ let ready = false;
30
+ void watcher.subscribed.then(() => { ready = true; });
31
+ await context.connect();
32
+ await context.flush();
33
+ ready.should.be.false;
34
+ context.streams[1].send({ Subscribed: true });
35
+ await watcher.subscribed;
36
+ ready.should.be.true;
37
+ context.streams[1].send({ ModelKey: 'new', ReadModel: '{"id":"new"}' });
38
+ (await next).value.key.should.equal('new');
39
+ });
40
+
41
+ it('should preserve still pending readiness across a disconnect before acknowledgment', async () => {
42
+ const pending = watcher.subscribed;
43
+ await context.disconnect();
44
+ watcher.subscribed.should.equal(pending);
45
+ await context.connect();
46
+ context.streams[1].send({ Subscribed: true });
47
+ await pending;
48
+ });
49
+
50
+ it('should deliver buffered changes from the previous connection before new changes', async () => {
51
+ context.streams[0].send({ Subscribed: true });
52
+ context.streams[0].send({ ModelKey: 'old', ReadModel: '{"id":"old"}' });
53
+ await watcher.subscribed;
54
+ await context.flush();
55
+ await context.disconnect();
56
+ await context.connect();
57
+ context.streams[1].send({ Subscribed: true });
58
+ context.streams[1].send({ ModelKey: 'new', ReadModel: '{"id":"new"}' });
59
+ const iterator = watcher[Symbol.asyncIterator]();
60
+ (await iterator.next()).value.key.should.equal('old');
61
+ (await iterator.next()).value.key.should.equal('new');
62
+ });
63
+
64
+ it('should acknowledge a new subscription before consuming its buffered changes', async () => {
65
+ context.streams[0].send({ Subscribed: true });
66
+ await watcher.subscribed;
67
+ const iterator = watcher[Symbol.asyncIterator]();
68
+ await context.disconnect();
69
+ const readiness = watcher.subscribed;
70
+ await context.connect();
71
+ context.streams[1].send({ ModelKey: 'new', ReadModel: '{"id":"new"}' });
72
+ context.streams[1].send({ Subscribed: true });
73
+ await readiness;
74
+ (await iterator.next()).value.key.should.equal('new');
75
+ });
76
+
77
+ it('should not create duplicate streams for repeated connected notifications', async () => {
78
+ await context.connect();
79
+ context.streams.should.have.lengthOf(1);
80
+ });
81
+
82
+ it('should not restart a disposed watcher', async () => {
83
+ watcher.dispose();
84
+ await context.disconnect();
85
+ await context.connect();
86
+ context.streams.should.have.lengthOf(1);
87
+ });
88
+
89
+ it('should defer opening a watcher created while disconnected', async () => {
90
+ watcher.dispose();
91
+ await context.disconnect();
92
+ watcher = context.readModels.createWatcher(context.model);
93
+ context.streams.should.have.lengthOf(1);
94
+ await context.connect();
95
+ context.streams.should.have.lengthOf(2);
96
+ context.streams[1].send({ Subscribed: true });
97
+ await watcher.subscribed;
98
+ });
99
+ });
@@ -0,0 +1,84 @@
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 } from 'vitest';
5
+ import { ReadModelWatcher } from '../ReadModelWatcher.js';
6
+ import { a_watched_read_model } from './given/a_watched_read_model.js';
7
+
8
+ chai.should();
9
+
10
+ describe('when reconnecting during read model conversion', () => {
11
+ let context: a_watched_read_model;
12
+ let watcher: ReadModelWatcher<object>;
13
+ let finishConversion: () => void;
14
+ let failConversion: (error: unknown) => void;
15
+ const converted: string[] = [];
16
+ beforeEach(async () => {
17
+ converted.length = 0;
18
+ context = new a_watched_read_model();
19
+ await context.connect();
20
+ const conversion = new Promise<void>((resolve, reject) => {
21
+ finishConversion = resolve;
22
+ failConversion = reject;
23
+ });
24
+ watcher = new ReadModelWatcher(signal => context.watch(undefined, { signal }), async change => {
25
+ converted.push(change.ModelKey);
26
+ if (change.ModelKey === 'received') await conversion;
27
+ return { namespace: change.Namespace, key: change.ModelKey, readModel: {}, removed: false };
28
+ }, new AbortController().signal, context.lifecycle, { resume: true });
29
+ context.streams[0].send({ Subscribed: true });
30
+ await watcher.subscribed;
31
+ context.streams[0].send({ ModelKey: 'received' });
32
+ await context.flush();
33
+ await context.disconnect();
34
+ await context.connect();
35
+ context.streams[1].send({ Subscribed: true });
36
+ context.streams[1].send({ ModelKey: 'resumed' });
37
+ await watcher.subscribed;
38
+ });
39
+ afterEach(() => {
40
+ finishConversion();
41
+ watcher.dispose();
42
+ context.readModels.dispose();
43
+ });
44
+
45
+ it('should finish received conversion and preserve order across connections', async () => {
46
+ await context.flush();
47
+ converted.should.deep.equal(['received']);
48
+ finishConversion();
49
+ (await watcher.next()).value.key.should.equal('received');
50
+ (await watcher.next()).value.key.should.equal('resumed');
51
+ converted.should.deep.equal(['received', 'resumed']);
52
+ });
53
+
54
+ it('should not replay an acknowledgment to a callback registered while earlier conversion finishes', async () => {
55
+ let notifications = 0;
56
+ watcher.onResubscribed(() => { notifications++; });
57
+ finishConversion();
58
+ await context.flush();
59
+ notifications.should.equal(0);
60
+ await context.disconnect();
61
+ await context.connect();
62
+ context.streams[2].send({ Subscribed: true });
63
+ await context.flush();
64
+ notifications.should.equal(1);
65
+ });
66
+
67
+ it('should still fail on a received change that cannot be released after reconnect', async () => {
68
+ const failure = { code: 14 };
69
+ const next = watcher.next().catch(error => error);
70
+ failConversion(failure);
71
+ (await next).should.equal(failure);
72
+ context.streams[1].signal.aborted.should.be.true;
73
+ converted.should.deep.equal(['received']);
74
+ });
75
+
76
+ it('should not deliver an in-flight conversion after disposal', async () => {
77
+ const next = watcher.next();
78
+ watcher.dispose();
79
+ finishConversion();
80
+ (await next).done!.should.be.true;
81
+ await context.flush();
82
+ (await watcher.next()).done!.should.be.true;
83
+ });
84
+ });
@@ -0,0 +1,106 @@
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 type { IReadModelWatcher } from '../IReadModelWatcher.js';
6
+ import { a_watched_read_model } from './given/a_watched_read_model.js';
7
+
8
+ chai.should();
9
+
10
+ const recoveries = [1, 4, 13, 14, undefined].flatMap(code => ['option', 'callback'].map(optIn => ({ code, optIn })));
11
+
12
+ describe.each(recoveries)('when resumption uses $optIn after stream status $code', ({ code, optIn }) => {
13
+ let context: a_watched_read_model;
14
+ let watcher: IReadModelWatcher<{ id: string }>;
15
+ const endStream = () => {
16
+ if (code === undefined) context.streams[0].end();
17
+ else context.streams[0].fail({ code });
18
+ };
19
+ beforeEach(async () => {
20
+ vi.useFakeTimers();
21
+ context = new a_watched_read_model();
22
+ await context.connect();
23
+ watcher = context.readModels.createWatcher(context.model, { resume: optIn === 'option' });
24
+ if (optIn === 'callback') watcher.onResubscribed(() => {});
25
+ });
26
+ afterEach(() => {
27
+ context.readModels.dispose();
28
+ vi.useRealTimers();
29
+ });
30
+
31
+ it('should renew readiness and deliver the next connection change to the same pending iterator', async () => {
32
+ context.streams[0].send({ Subscribed: true });
33
+ const acknowledged = watcher.subscribed;
34
+ await acknowledged;
35
+ const iterator = watcher[Symbol.asyncIterator]();
36
+ const next = iterator.next();
37
+ endStream();
38
+ await vi.advanceTimersByTimeAsync(0);
39
+ watcher.subscribed.should.not.equal(acknowledged);
40
+ const readiness = watcher.subscribed;
41
+ await context.disconnect();
42
+ watcher.subscribed.should.equal(readiness);
43
+ await vi.advanceTimersByTimeAsync(1000);
44
+ context.streams.should.have.lengthOf(1);
45
+ await context.connect();
46
+ context.streams[1].send({ Subscribed: true });
47
+ await readiness;
48
+ context.streams[1].send({ ModelKey: 'next', ReadModel: '{}' });
49
+ (await next).value.key.should.equal('next');
50
+ await vi.advanceTimersByTimeAsync(1000);
51
+ context.streams.should.have.lengthOf(2);
52
+ });
53
+
54
+ it('should preserve pending readiness and retry if the lifecycle never disconnects', async () => {
55
+ const readiness = watcher.subscribed;
56
+ endStream();
57
+ await vi.advanceTimersByTimeAsync(0);
58
+ watcher.subscribed.should.equal(readiness);
59
+ context.streams.should.have.lengthOf(1);
60
+ await vi.advanceTimersByTimeAsync(1000);
61
+ context.streams.should.have.lengthOf(2);
62
+ context.streams[1].send({ Subscribed: true });
63
+ await readiness;
64
+ });
65
+
66
+ it('should notify resubscription only after recovery acknowledgment and continue iteration', async () => {
67
+ const resubscribed = vi.fn();
68
+ watcher.onResubscribed(resubscribed);
69
+ context.streams[0].send({ Subscribed: true });
70
+ await watcher.subscribed;
71
+ resubscribed.mock.calls.should.have.lengthOf(0);
72
+ const next = watcher[Symbol.asyncIterator]().next();
73
+ endStream();
74
+ await vi.advanceTimersByTimeAsync(1000);
75
+ resubscribed.mock.calls.should.have.lengthOf(0);
76
+ context.streams[1].send({ Subscribed: true });
77
+ context.streams[1].send({ ModelKey: 'resumed', ReadModel: '{}' });
78
+ (await next).value.key.should.equal('resumed');
79
+ resubscribed.mock.calls.should.have.lengthOf(1);
80
+ });
81
+
82
+ it('should keep received buffered changes after a transport failure or completion', async () => {
83
+ context.streams[0].send({ Subscribed: true });
84
+ await watcher.subscribed;
85
+ context.streams[0].send({ ModelKey: 'received', ReadModel: '{}' });
86
+ await vi.advanceTimersByTimeAsync(0);
87
+ endStream();
88
+ await vi.advanceTimersByTimeAsync(1000);
89
+ context.streams[1].send({ Subscribed: true });
90
+ context.streams[1].send({ ModelKey: 'resumed', ReadModel: '{}' });
91
+ await watcher.subscribed;
92
+ const iterator = watcher[Symbol.asyncIterator]();
93
+ (await iterator.next()).value.key.should.equal('received');
94
+ (await iterator.next()).value.key.should.equal('resumed');
95
+ });
96
+
97
+ it('should cancel a scheduled restart when disposed', async () => {
98
+ const readiness = watcher.subscribed.catch(error => error);
99
+ endStream();
100
+ await vi.advanceTimersByTimeAsync(0);
101
+ watcher.dispose();
102
+ await vi.advanceTimersByTimeAsync(1000);
103
+ (await readiness).name.should.equal('AbortError');
104
+ context.streams.should.have.lengthOf(1);
105
+ });
106
+ });
@@ -0,0 +1,85 @@
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 } from 'vitest';
5
+ import type { IReadModelWatcher } from '../IReadModelWatcher.js';
6
+ import { a_watched_read_model } from './given/a_watched_read_model.js';
7
+
8
+ chai.should();
9
+
10
+ describe('when stopping a read model watcher', () => {
11
+ let context: a_watched_read_model;
12
+ let watcher: IReadModelWatcher<{ id: string }>;
13
+ let cancellation: AbortController;
14
+ beforeEach(async () => {
15
+ context = new a_watched_read_model();
16
+ await context.connect();
17
+ cancellation = new AbortController();
18
+ watcher = context.readModels.createWatcher(context.model, { signal: cancellation.signal });
19
+ });
20
+ afterEach(() => context.readModels.dispose());
21
+
22
+ it('should cancel the transport and settle pending iteration and readiness on disposal', async () => {
23
+ const readiness = watcher.subscribed.catch(error => error);
24
+ const next = watcher[Symbol.asyncIterator]().next();
25
+ watcher.dispose();
26
+ watcher.dispose();
27
+ context.streams[0].signal.aborted.should.be.true;
28
+ (await readiness).name.should.equal('AbortError');
29
+ (await next).done!.should.be.true;
30
+ });
31
+
32
+ it('should complete iteration when the caller aborts', async () => {
33
+ const readiness = watcher.subscribed.catch(error => error);
34
+ const next = watcher[Symbol.asyncIterator]().next();
35
+ const reason = new Error('caller cancelled');
36
+ cancellation.abort(reason);
37
+ (await readiness).should.equal(reason);
38
+ (await next).done!.should.be.true;
39
+ context.streams[0].signal.aborted.should.be.true;
40
+ });
41
+
42
+ it('should not open a stream with an already aborted signal', async () => {
43
+ watcher.dispose();
44
+ cancellation.abort();
45
+ watcher = context.readModels.createWatcher(context.model, { signal: cancellation.signal });
46
+ const failure = await watcher.subscribed.catch(error => error);
47
+ failure.name.should.equal('AbortError');
48
+ context.streams.should.have.lengthOf(1);
49
+ });
50
+
51
+ it('should cancel on an early for await break', async () => {
52
+ context.streams[0].send({ Subscribed: true });
53
+ context.streams[0].send({ ReadModel: '{"id":"one"}' });
54
+ for await (const change of watcher) {
55
+ change.readModel.id.should.equal('one');
56
+ break;
57
+ }
58
+ context.streams[0].signal.aborted.should.be.true;
59
+ });
60
+
61
+ it('should cancel watchers when their owner is disposed', async () => {
62
+ const readiness = watcher.subscribed.catch(error => error);
63
+ context.readModels.dispose();
64
+ (await readiness).name.should.equal('AbortError');
65
+ context.streams[0].signal.aborted.should.be.true;
66
+ });
67
+
68
+ it('should surface a stream failure to readiness and iteration', async () => {
69
+ const failure = new Error('transport failed');
70
+ const readiness = watcher.subscribed.catch(error => error);
71
+ const next = watcher[Symbol.asyncIterator]().next().catch(error => error);
72
+ context.streams[0].fail(failure);
73
+ (await readiness).should.equal(failure);
74
+ (await next).should.equal(failure);
75
+ });
76
+
77
+ it('should surface a later stream failure without changing an already resolved promise', async () => {
78
+ context.streams[0].send({ Subscribed: true });
79
+ await watcher.subscribed;
80
+ const next = watcher[Symbol.asyncIterator]().next().catch(error => error);
81
+ context.streams[0].fail(new Error('later failure'));
82
+ (await next).message.should.equal('later failure');
83
+ await watcher.subscribed;
84
+ });
85
+ });
@@ -0,0 +1,62 @@
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 } from 'vitest';
5
+ import type { IReadModelWatcher } from '../IReadModelWatcher.js';
6
+ import { a_watched_read_model } from './given/a_watched_read_model.js';
7
+
8
+ chai.should();
9
+
10
+ describe('when subscribing to read model changes', () => {
11
+ let context: a_watched_read_model;
12
+ let watcher: IReadModelWatcher<{ id: string }>;
13
+ beforeEach(async () => {
14
+ context = new a_watched_read_model();
15
+ await context.connect();
16
+ watcher = context.readModels.createWatcher(context.model);
17
+ });
18
+ afterEach(() => watcher.dispose());
19
+
20
+ it('should start the stream without iteration and await the actual acknowledgment', async () => {
21
+ let ready = false;
22
+ void watcher.subscribed.then(() => { ready = true; });
23
+ await context.flush();
24
+ ready.should.be.false;
25
+ context.watch.mock.calls[0][0].should.deep.equal({
26
+ EventStore: 'store', Namespace: 'tenant', ReadModelIdentifier: 'Model', EventSequenceId: 'event-log'
27
+ });
28
+ context.streams[0].send({ Subscribed: true });
29
+ await watcher.subscribed;
30
+ ready.should.be.true;
31
+ });
32
+
33
+ it('should not yield subscription markers or lose a change before iteration begins', async () => {
34
+ context.streams[0].send({ Subscribed: true });
35
+ context.streams[0].send({ Namespace: 'tenant', ModelKey: 'one', ReadModel: '{"id":"one"}' });
36
+ await watcher.subscribed;
37
+ await context.flush();
38
+ const result = await watcher[Symbol.asyncIterator]().next();
39
+ result.done!.should.be.false;
40
+ result.value.key.should.equal('one');
41
+ });
42
+
43
+ it('should acknowledge without iteration even when changes arrive before the marker', async () => {
44
+ context.streams[0].send({ ModelKey: 'one', ReadModel: '{"id":"one"}' });
45
+ context.streams[0].send({ ModelKey: 'two', ReadModel: '{"id":"two"}' });
46
+ context.streams[0].send({ Subscribed: true });
47
+ await watcher.subscribed;
48
+ const iterator = watcher[Symbol.asyncIterator]();
49
+ (await iterator.next()).value.key.should.equal('one');
50
+ (await iterator.next()).value.key.should.equal('two');
51
+ });
52
+
53
+ it('should fail explicitly on pre-acknowledgment overflow instead of hanging or silently dropping changes', async () => {
54
+ const failure = watcher.subscribed.catch(error => error);
55
+ for (let index = 0; index <= 1024; index++) context.streams[0].send({ ReadModel: '{}' });
56
+ context.streams[0].send({ Subscribed: true });
57
+ (await failure).message.should.contain('buffer exceeded 1024 changes');
58
+ const iterationFailure = await watcher[Symbol.asyncIterator]().next().catch(error => error);
59
+ iterationFailure.should.equal(await failure);
60
+ context.streams[0].signal.aborted.should.be.true;
61
+ });
62
+ });
@@ -0,0 +1,85 @@
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 { ReadModelChangeType as ContractChangeType } from '@cratis/chronicle.contracts';
5
+ import { Guid } from '@cratis/fundamentals';
6
+ import { afterEach, beforeEach, chai, describe, it } from 'vitest';
7
+ import { toContractsGuid } from '../../connection/Guid.js';
8
+ import { ReadModelChangeType } from '../ReadModelChangeType.js';
9
+ import { a_watched_read_model } from '../for_ReadModelWatcher/given/a_watched_read_model.js';
10
+
11
+ chai.should();
12
+
13
+ describe('when watching with the original pull-based API', () => {
14
+ let context: a_watched_read_model;
15
+ beforeEach(async () => {
16
+ context = new a_watched_read_model();
17
+ await context.connect();
18
+ });
19
+ afterEach(() => context.readModels.dispose());
20
+
21
+ it('should not open or read the stream until the first next call', async () => {
22
+ const changes = context.readModels.watch(context.model);
23
+ await context.flush();
24
+ context.watch.mock.calls.should.have.lengthOf(0);
25
+ const iterator = changes[Symbol.asyncIterator]();
26
+ await context.flush();
27
+ context.watch.mock.calls.should.have.lengthOf(0);
28
+ const next = iterator.next();
29
+ context.watch.mock.calls.should.have.lengthOf(1);
30
+ context.streams[0].pulls.should.equal(1);
31
+ context.streams[0].send({ ModelKey: 'first', ReadModel: '{}' });
32
+ (await next).value.key.should.equal('first');
33
+ await iterator.return!();
34
+ context.streams[0].closed.should.be.true;
35
+ });
36
+
37
+ it('should defer model resolution failures until iteration starts', async () => {
38
+ const changes = context.readModels.watch(class Unknown {});
39
+ context.watch.mock.calls.should.have.lengthOf(0);
40
+ const failure = await changes[Symbol.asyncIterator]().next().catch(error => error);
41
+ failure.message.should.contain("Unknown read model 'Unknown'");
42
+ });
43
+
44
+ it.each([0, 2050, undefined])('should pull more than the watcher buffer limit without read-ahead or overflow (acknowledgment after %s changes)', async acknowledgeAfter => {
45
+ const iterator = context.readModels.watch(context.model)[Symbol.asyncIterator]();
46
+ const first = iterator.next();
47
+ const stream = context.streams[0];
48
+ if (acknowledgeAfter === 0) stream.send({ Subscribed: true });
49
+ for (let index = 0; index < 2050; index++) {
50
+ stream.send({ ModelKey: `${index}`, ReadModel: '{}' });
51
+ }
52
+ if (acknowledgeAfter === 2050) stream.send({ Subscribed: true });
53
+ stream.end();
54
+ try {
55
+ for (let index = 0; index < 2050; index++) {
56
+ const result = await (index === 0 ? first : iterator.next());
57
+ result.done!.should.be.false;
58
+ result.value.key.should.equal(`${index}`);
59
+ if (index === 0 || index === 1024 || index === 2049) {
60
+ // Simulate a slow consumer: no next() means no further transport pulls.
61
+ await context.flush();
62
+ stream.pulls.should.equal(index + 1 + (acknowledgeAfter === 0 ? 1 : 0));
63
+ }
64
+ }
65
+ (await iterator.next()).done!.should.be.true;
66
+ } finally {
67
+ await iterator.return!();
68
+ }
69
+ });
70
+
71
+ it('should retain optional change kind and context on pull-based changes', async () => {
72
+ const iterator = context.readModels.watch(context.model)[Symbol.asyncIterator]();
73
+ const next = iterator.next();
74
+ const correlationId = '12345678-1234-5678-9abc-123456789abc';
75
+ const occurred = '2026-06-07T08:09:10.123Z';
76
+ context.streams[0].send({ Namespace: 'tenant', ModelKey: 'one', ReadModel: '{"id":"one"}',
77
+ ChangeType: ContractChangeType.Added, EventSequenceNumber: 9007199254740997n,
78
+ CorrelationId: toContractsGuid(Guid.as(correlationId)), Occurred: { Value: occurred } });
79
+ const change = (await next).value;
80
+ change.changeType.should.equal(ReadModelChangeType.Added);
81
+ change.changeContext.should.deep.equal({ eventStore: 'store', namespace: 'tenant',
82
+ sequenceNumber: 9007199254740997n, correlationId, occurred: new Date(occurred) });
83
+ await iterator.return!();
84
+ });
85
+ });
@@ -9,6 +9,10 @@ export { ReadModels } from './ReadModels.js';
9
9
  export { MaterializedReadModels } from './MaterializedReadModels.js';
10
10
  export { ReadModelSubjectResolver } from './ReadModelSubjectResolver.js';
11
11
  export type { ReadModelChangeset } from './ReadModelChangeset.js';
12
+ export type { IReadModelWatcher } from './IReadModelWatcher.js';
13
+ export type { ReadModelWatchOptions } from './ReadModelWatchOptions.js';
14
+ export type { ReadModelChangeContext } from './ReadModelChangeContext.js';
15
+ export { ReadModelChangeType } from './ReadModelChangeType.js';
12
16
  export type { ReadModelSnapshot } from './ReadModelSnapshot.js';
13
17
  export type { ReadModelNamingPolicy } from './ReadModelNamingPolicy.js';
14
18
  export { readModel, getReadModelMetadata, isReadModel } from './readModel.js';
@@ -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
  },
@@ -175,18 +175,35 @@ describe('ReactorScenario live delivery', () => {
175
175
  try {
176
176
  for await (const _change of services.readModels.watch(Registered)) { /* Unsupported. */ }
177
177
  } catch (error) { errors.push(error); }
178
+ try {
179
+ await services.readModels.createWatcher(Registered).subscribed;
180
+ } catch (error) { errors.push(error); }
178
181
  }
179
182
  }
180
183
  const scenario = new ReactorScenario(ServiceRejection, options);
181
184
  let failure: unknown;
182
185
  await scenario.when.forEventSource('A').events(new Registered('first')).catch(error => { failure = error; });
183
- errors.length.should.equal(10);
186
+ errors.length.should.equal(11);
184
187
  errors.every(error => error instanceof UnsupportedReactorOperation && String(error).includes('Use a kernel-backed test.')).should.be.true;
185
188
  // Swallowed rejections still fail the delivery with the first unsupported operation.
186
189
  (failure === errors[0]).should.be.true;
187
190
  scenario.results[0].completed.should.be.false;
188
191
  });
189
192
 
193
+ it('should complete delivery if a reactor creates a watch without iterating it', async () => {
194
+ @reactor('uniterated-watch-reactor')
195
+ class UniteratedWatch {
196
+ registered(_event: Registered, _context: EventContext, services: ReactorServices) {
197
+ services.readModels.watch(Registered);
198
+ return new Skipped('produced');
199
+ }
200
+ }
201
+ const scenario = new ReactorScenario(UniteratedWatch, options);
202
+ await scenario.when.forEventSource('A').events(new Registered('first'));
203
+ scenario.results[0].completed.should.be.true;
204
+ scenario.shouldHaveProduced(Skipped, event => event.label === 'produced');
205
+ });
206
+
190
207
  it('rejects behavior pattern queries through the default event store', async () => {
191
208
  let error: unknown;
192
209
  @reactor('pattern-rejection-reactor')