@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,104 @@
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 { field } from '@cratis/fundamentals';
6
+ import { afterAll, beforeAll, chai, describe, it } from 'vitest';
7
+ import { ChronicleClient } from '../../../ChronicleClient.js';
8
+ import { ChronicleOptions } from '../../../ChronicleOptions.js';
9
+ import type { IClientArtifactsProvider } from '../../../artifacts/index.js';
10
+ import { eventType } from '../../../events/index.js';
11
+ import { fromEvent, removedWith } from '../../../projections/index.js';
12
+ import type { IReadModelWatcher } from '../../IReadModelWatcher.js';
13
+ import type { ReadModelChangeset } from '../../ReadModelChangeset.js';
14
+ import { ReadModelChangeType } from '../../ReadModelChangeType.js';
15
+
16
+ chai.should();
17
+ const connectionString = process.env.CHRONICLE_INTEGRATION_CONNECTION_STRING;
18
+
19
+ @eventType()
20
+ class NameChanged {
21
+ @field(String) name = '';
22
+ }
23
+
24
+ @eventType()
25
+ class EntryRemoved {
26
+ @field(String) reason = 'removed';
27
+ }
28
+
29
+ @fromEvent(NameChanged)
30
+ @removedWith(EntryRemoved)
31
+ class WatchedEntry {
32
+ @field(String) name = '';
33
+ }
34
+
35
+ const artifacts: IClientArtifactsProvider = {
36
+ eventTypes: [NameChanged, EntryRemoved], readModels: [WatchedEntry],
37
+ projections: [], reactors: [], reducers: [], seeders: [], constraints: [], webhooks: [],
38
+ eventTypeMigrations: [], globalForHandlers: []
39
+ };
40
+
41
+ describe.skipIf(!connectionString && !process.env.CI)('when awaiting readiness before appending against a kernel', () => {
42
+ const storeName = `Watcher${randomUUID().replaceAll('-', '').slice(0, 12)}`;
43
+ const source = randomUUID();
44
+ const correlationIds = [randomUUID(), randomUUID(), randomUUID()];
45
+ const occurred = new Date('2026-01-02T03:04:05.123Z');
46
+ const changes: ReadModelChangeset<WatchedEntry>[] = [];
47
+ const sequenceNumbers: bigint[] = [];
48
+ let client: ChronicleClient;
49
+ let watcher: IReadModelWatcher<WatchedEntry>;
50
+
51
+ beforeAll(async () => {
52
+ client = new ChronicleClient(ChronicleOptions.fromConnectionString(connectionString!, {
53
+ discoveryPatterns: [], clientArtifactsProvider: artifacts
54
+ }));
55
+ const store = await client.getEventStore(storeName);
56
+ watcher = store.readModels.createWatcher(WatchedEntry, { signal: AbortSignal.timeout(60_000) });
57
+ // No iteration, arbitrary sleep, or append before the server's readiness barrier.
58
+ await watcher.subscribed;
59
+ const iterator = watcher[Symbol.asyncIterator]();
60
+ const events = [Object.assign(new NameChanged(), { name: 'first' }),
61
+ Object.assign(new NameChanged(), { name: 'second' }), new EntryRemoved()];
62
+ for (const [index, event] of events.entries()) {
63
+ const result = await store.eventLog.append(source, event, { correlationId: correlationIds[index], occurred });
64
+ result.isSuccess.should.be.true;
65
+ sequenceNumbers.push(result.sequenceNumber.value);
66
+ const change = await iterator.next();
67
+ if (change.done) throw new Error('Watcher ended before the appended change arrived.');
68
+ changes.push(change.value);
69
+ }
70
+ });
71
+ afterAll(() => {
72
+ watcher?.dispose();
73
+ client?.dispose();
74
+ });
75
+
76
+ it('should observe creation, modification, and removal in order', () => {
77
+ changes.map(change => change.changeType).should.deep.equal([
78
+ ReadModelChangeType.Added, ReadModelChangeType.Modified, ReadModelChangeType.Removed
79
+ ]);
80
+ });
81
+ it('should preserve the existing model, key, namespace and removal fields', () => {
82
+ changes[0].readModel.should.be.instanceOf(WatchedEntry);
83
+ changes[0].readModel.name.should.equal('first');
84
+ changes[1].readModel.name.should.equal('second');
85
+ changes.map(change => change.key).should.deep.equal([source, source, source]);
86
+ changes.map(change => change.namespace).should.deep.equal(['Default', 'Default', 'Default']);
87
+ changes.map(change => change.removed).should.deep.equal([false, false, true]);
88
+ });
89
+ it('should preserve each triggering sequence number without numeric conversion', () => {
90
+ changes.map(change => change.changeContext!.sequenceNumber).should.deep.equal(sequenceNumbers);
91
+ });
92
+ it('should preserve each triggering correlation identifier', () => {
93
+ changes.map(change => change.changeContext!.correlationId).should.deep.equal(correlationIds);
94
+ });
95
+ it('should preserve each triggering timestamp', () => {
96
+ changes.map(change => change.changeContext!.occurred!.toISOString()).should.deep.equal([
97
+ occurred.toISOString(), occurred.toISOString(), occurred.toISOString()
98
+ ]);
99
+ });
100
+ it('should include the store and namespace in the triggering context', () => {
101
+ changes.every(change => change.changeContext!.eventStore === storeName &&
102
+ change.changeContext!.namespace === 'Default').should.be.true;
103
+ });
104
+ });
@@ -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 { ReadModelChangeset } from '@cratis/chronicle.contracts';
5
+
6
+ export class a_kernel_stream implements AsyncIterableIterator<ReadModelChangeset> {
7
+ private readonly _messages: IteratorResult<ReadModelChangeset>[] = [];
8
+ private _waiting?: {
9
+ resolve: (result: IteratorResult<ReadModelChangeset>) => void;
10
+ reject: (error: unknown) => void;
11
+ };
12
+ signal!: AbortSignal;
13
+ closed = false;
14
+ pulls = 0;
15
+
16
+ [Symbol.asyncIterator]() { return this; }
17
+
18
+ next(): Promise<IteratorResult<ReadModelChangeset>> {
19
+ this.pulls++;
20
+ const message = this._messages.shift();
21
+ if (message) return Promise.resolve(message);
22
+ if (this.closed) return Promise.resolve({ done: true, value: undefined });
23
+ return new Promise((resolve, reject) => { this._waiting = { resolve, reject }; });
24
+ }
25
+
26
+ async return(): Promise<IteratorResult<ReadModelChangeset>> {
27
+ this.end();
28
+ return { done: true, value: undefined };
29
+ }
30
+
31
+ send(change: Partial<ReadModelChangeset>): void {
32
+ const result = { done: false as const, value: ReadModelChangeset.fromPartial(change) };
33
+ if (this._waiting) {
34
+ this._waiting.resolve(result);
35
+ this._waiting = undefined;
36
+ } else this._messages.push(result);
37
+ }
38
+
39
+ end(): void {
40
+ this.closed = true;
41
+ this._waiting?.resolve({ done: true, value: undefined });
42
+ this._waiting = undefined;
43
+ }
44
+
45
+ fail(error: unknown): void {
46
+ if (!this._waiting) throw new Error('The stream must be reading before failing it.');
47
+ this._waiting.reject(error);
48
+ this._waiting = undefined;
49
+ }
50
+ }
@@ -0,0 +1,37 @@
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 { vi } from 'vitest';
5
+ import type { IClientArtifactsProvider } from '../../../artifacts/index.js';
6
+ import { ConnectionLifecycle } from '../../../connection/ConnectionLifecycle.js';
7
+ import type { ChronicleConnection } from '../../../connection/index.js';
8
+ import { fromEvent } from '../../../projections/index.js';
9
+ import { ReadModels } from '../../ReadModels.js';
10
+ import { a_kernel_stream } from './a_kernel_stream.js';
11
+
12
+ export class a_watched_read_model {
13
+ readonly model = class Model { id = ''; };
14
+ readonly lifecycle = new ConnectionLifecycle();
15
+ readonly streams: a_kernel_stream[] = [];
16
+ readonly watch = vi.fn((_request: unknown, options?: { signal: AbortSignal }) => {
17
+ const stream = new a_kernel_stream();
18
+ if (options) {
19
+ stream.signal = options.signal;
20
+ options.signal.addEventListener('abort', () => stream.end(), { once: true });
21
+ }
22
+ this.streams.push(stream);
23
+ return stream;
24
+ });
25
+ readonly readModels: ReadModels;
26
+
27
+ constructor() {
28
+ fromEvent(class Changed {})(this.model);
29
+ const artifacts = { readModels: [this.model], projections: [], reducers: [] } as unknown as IClientArtifactsProvider;
30
+ const connection = { readModels: { watch: this.watch } } as unknown as ChronicleConnection;
31
+ this.readModels = new ReadModels('store', 'tenant', connection, artifacts, 'sink', undefined, undefined, this.lifecycle);
32
+ }
33
+
34
+ async connect() { await this.lifecycle.connected(error => { throw error; }); }
35
+ async disconnect() { await this.lifecycle.disconnected(error => { throw error; }); }
36
+ async flush() { await new Promise<void>(resolve => setImmediate(resolve)); }
37
+ }
@@ -0,0 +1,29 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import type { Constructor } from '@cratis/fundamentals';
5
+ import { chai, describe, expectTypeOf, it } from 'vitest';
6
+ import type { IReadModels } from '../IReadModels.js';
7
+ import type { ReadModels } from '../ReadModels.js';
8
+ import type { ReadModelChangeset } from '../ReadModelChangeset.js';
9
+
10
+ chai.should();
11
+
12
+ describe('when implementing the original watch contract', () => {
13
+ it('should keep exactly the signature exported on main', () => {
14
+ expectTypeOf<IReadModels['watch']>().toEqualTypeOf<
15
+ <TReadModel>(readModelType: Constructor<TReadModel>) => AsyncIterable<ReadModelChangeset<TReadModel>>
16
+ >();
17
+ expectTypeOf<ReadModels['watch']>().toEqualTypeOf<IReadModels['watch']>();
18
+ });
19
+
20
+ it('should accept an existing async generator implementation without watcher members', async () => {
21
+ const mock: Pick<IReadModels, 'watch'> = {
22
+ async *watch<TReadModel>(readModelType: Constructor<TReadModel>) {
23
+ yield { namespace: 'tenant', key: 'one', readModel: new readModelType(), removed: false };
24
+ }
25
+ };
26
+ const iterator = mock.watch(class Model {})[Symbol.asyncIterator]();
27
+ (await iterator.next()).value.key.should.equal('one');
28
+ });
29
+ });
@@ -0,0 +1,86 @@
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 { ConnectionLifecycle } from '../../connection/ConnectionLifecycle.js';
6
+ import { ReadModelWatcher } from '../ReadModelWatcher.js';
7
+ import { a_kernel_stream } from './given/a_kernel_stream.js';
8
+ import { a_watched_read_model } from './given/a_watched_read_model.js';
9
+
10
+ chai.should();
11
+
12
+ describe('when a read model stream cannot recover', () => {
13
+ it.each([3, 7, 16])('should terminate on non-transport status %s', async code => {
14
+ const context = new a_watched_read_model();
15
+ await context.connect();
16
+ const watcher = context.readModels.createWatcher(context.model, { resume: true });
17
+ const failure = { code };
18
+ const readiness = watcher.subscribed.catch(error => error);
19
+ const next = watcher[Symbol.asyncIterator]().next().catch(error => error);
20
+ try {
21
+ context.streams[0].fail(failure);
22
+ (await readiness).should.equal(failure);
23
+ (await next).should.equal(failure);
24
+ await context.disconnect();
25
+ await context.connect();
26
+ context.streams.should.have.lengthOf(1);
27
+ } finally { watcher.dispose(); }
28
+ });
29
+
30
+ it('should not retry a transport-coded compliance or conversion failure', async () => {
31
+ const lifecycle = new ConnectionLifecycle();
32
+ await lifecycle.connected(error => { throw error; });
33
+ const stream = new a_kernel_stream();
34
+ const failure = { code: 14 };
35
+ let opened = 0;
36
+ const watcher = new ReadModelWatcher(() => { opened++; return stream; }, async () => { throw failure; }, new AbortController().signal, lifecycle, { resume: true });
37
+ const readiness = watcher.subscribed.catch(error => error);
38
+ const next = watcher.next().catch(error => error);
39
+ try {
40
+ stream.send({ ReadModel: '{}' });
41
+ (await readiness).should.equal(failure);
42
+ (await next).should.equal(failure);
43
+ await lifecycle.disconnected(error => { throw error; });
44
+ await lifecycle.connected(error => { throw error; });
45
+ opened.should.equal(1);
46
+ } finally { watcher.dispose(); }
47
+ });
48
+
49
+ it.each([false, true])('should retain completed standalone changes until consumed or disposed (disposed: %s)', async disposed => {
50
+ const stream = new a_kernel_stream();
51
+ const watcher = new ReadModelWatcher(() => stream, async change => ({
52
+ namespace: change.Namespace, key: change.ModelKey, readModel: {}, removed: false
53
+ }), new AbortController().signal);
54
+ try {
55
+ stream.send({ Subscribed: true });
56
+ stream.send({ ModelKey: 'first' });
57
+ stream.send({ ModelKey: 'second' });
58
+ stream.end();
59
+ await watcher.subscribed;
60
+ await new Promise<void>(resolve => setImmediate(resolve));
61
+ if (disposed) watcher.dispose();
62
+ else {
63
+ (await watcher.next()).value.key.should.equal('first');
64
+ (await watcher.next()).value.key.should.equal('second');
65
+ }
66
+ (await watcher.next()).done!.should.be.true;
67
+ } finally { watcher.dispose(); }
68
+ });
69
+
70
+ it.each([false, true])('should end a standalone stream cleanly (acknowledged: %s)', async acknowledged => {
71
+ const stream = new a_kernel_stream();
72
+ const watcher = new ReadModelWatcher(() => stream, async () => { throw new Error('Unexpected data'); }, new AbortController().signal);
73
+ try {
74
+ if (acknowledged) {
75
+ stream.send({ Subscribed: true });
76
+ await watcher.subscribed;
77
+ }
78
+ const readiness = watcher.subscribed.catch(error => error);
79
+ const next = watcher.next();
80
+ stream.end();
81
+ (await next).done!.should.be.true;
82
+ if (!acknowledged) (await readiness).message.should.contain('before subscription acknowledgment');
83
+ else await readiness;
84
+ } finally { watcher.dispose(); }
85
+ });
86
+ });
@@ -0,0 +1,80 @@
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 { a_watched_read_model } from './given/a_watched_read_model.js';
6
+
7
+ chai.should();
8
+
9
+ describe('when applying read model watch backpressure', () => {
10
+ let context: a_watched_read_model;
11
+ beforeEach(async () => {
12
+ context = new a_watched_read_model();
13
+ await context.connect();
14
+ });
15
+ afterEach(() => context.readModels.dispose());
16
+
17
+ it('should pause a created watcher for a slow consumer and deliver more than the buffer limit without failure', async () => {
18
+ const iterator = context.readModels.createWatcher(context.model)[Symbol.asyncIterator]();
19
+ const stream = context.streams[0];
20
+ stream.send({ Subscribed: true });
21
+ for (let index = 0; index < 2050; index++) stream.send({ ModelKey: `${index}`, ReadModel: '{}' });
22
+ await context.flush();
23
+ stream.pulls.should.equal(1025); // One acknowledgment and 1,024 buffered changes.
24
+ stream.signal.aborted.should.be.false;
25
+ (await iterator.next()).value.key.should.equal('0');
26
+ await context.flush();
27
+ stream.pulls.should.equal(1026);
28
+ for (let index = 1; index < 2050; index++) {
29
+ (await iterator.next()).value.key.should.equal(`${index}`);
30
+ }
31
+ stream.end();
32
+ (await iterator.next()).done!.should.be.true;
33
+ });
34
+
35
+ it('should reach acknowledgment with a full pre-acknowledgment buffer then pause until consumption', async () => {
36
+ const watcher = context.readModels.createWatcher(context.model);
37
+ const stream = context.streams[0];
38
+ for (let index = 0; index < 1024; index++) stream.send({ ModelKey: `${index}`, ReadModel: '{}' });
39
+ stream.send({ Subscribed: true });
40
+ stream.send({ ModelKey: 'after', ReadModel: '{}' });
41
+ await watcher.subscribed;
42
+ await context.flush();
43
+ stream.pulls.should.equal(1025);
44
+ const iterator = watcher[Symbol.asyncIterator]();
45
+ for (let index = 0; index < 1024; index++) (await iterator.next()).value.key.should.equal(`${index}`);
46
+ (await iterator.next()).value.key.should.equal('after');
47
+ });
48
+
49
+ it('should cancel a paused read on disposal', async () => {
50
+ const watcher = context.readModels.createWatcher(context.model);
51
+ const stream = context.streams[0];
52
+ stream.send({ Subscribed: true });
53
+ for (let index = 0; index < 1024; index++) stream.send({ ReadModel: '{}' });
54
+ await context.flush();
55
+ watcher.dispose();
56
+ await context.flush();
57
+ stream.closed.should.be.true;
58
+ stream.signal.aborted.should.be.true;
59
+ stream.pulls.should.equal(1025);
60
+ (await watcher[Symbol.asyncIterator]().next()).done!.should.be.true;
61
+ });
62
+
63
+ it('should read eagerly again until each resumed stream acknowledges even with a full buffer', async () => {
64
+ const watcher = context.readModels.createWatcher(context.model, { resume: true });
65
+ context.streams[0].send({ Subscribed: true });
66
+ for (let index = 0; index < 1024; index++) context.streams[0].send({ ModelKey: `${index}`, ReadModel: '{}' });
67
+ await context.flush();
68
+ await context.disconnect();
69
+ await context.connect();
70
+ context.streams[1].send({ Subscribed: true });
71
+ context.streams[1].send({ ModelKey: 'resumed', ReadModel: '{}' });
72
+ await watcher.subscribed;
73
+ await context.flush();
74
+ context.streams[0].closed.should.be.true;
75
+ context.streams[1].pulls.should.equal(1);
76
+ const iterator = watcher[Symbol.asyncIterator]();
77
+ for (let index = 0; index < 1024; index++) (await iterator.next()).value.key.should.equal(`${index}`);
78
+ (await iterator.next()).value.key.should.equal('resumed');
79
+ });
80
+ });
@@ -0,0 +1,39 @@
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 { a_watched_read_model } from './given/a_watched_read_model.js';
6
+
7
+ chai.should();
8
+
9
+ describe.each(['transport', 'stream', 'connection'] as const)('when a read model watcher has buffered changes before a terminal %s failure', kind => {
10
+ let context: a_watched_read_model;
11
+ beforeEach(async () => {
12
+ context = new a_watched_read_model();
13
+ await context.connect();
14
+ });
15
+ afterEach(() => context.readModels.dispose());
16
+
17
+ it.each([false, true])('should drain received changes before rejecting with the original error (acknowledged: %s)', async acknowledged => {
18
+ const watcher = context.readModels.createWatcher(context.model, { resume: kind !== 'transport' });
19
+ const readiness = watcher.subscribed.catch(error => error);
20
+ const stream = context.streams[0];
21
+ if (acknowledged) {
22
+ stream.send({ Subscribed: true });
23
+ await readiness;
24
+ }
25
+ stream.send({ ModelKey: 'first', ReadModel: '{"id":"first"}' });
26
+ stream.send({ ModelKey: 'second', ReadModel: '{"id":"second"}' });
27
+ await context.flush();
28
+ const failure = Object.assign(new Error('terminal failure'), { code: kind === 'transport' ? 14 : 7 });
29
+ if (kind === 'connection') await context.lifecycle.failed(failure, error => { throw error; });
30
+ else stream.fail(failure);
31
+ await context.flush();
32
+ if (!acknowledged) (await readiness).should.equal(failure);
33
+ stream.signal.aborted.should.be.true;
34
+ const iterator = watcher[Symbol.asyncIterator]();
35
+ (await iterator.next()).value.should.contain({ key: 'first' });
36
+ (await iterator.next()).value.should.contain({ key: 'second' });
37
+ (await iterator.next().catch(error => error)).should.equal(failure);
38
+ });
39
+ });
@@ -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, vi } from 'vitest';
5
+ import { a_watched_read_model } from './given/a_watched_read_model.js';
6
+
7
+ chai.should();
8
+
9
+ describe.each(['watch', 'createWatcher'] as const)('when %s has not opted into resumption', method => {
10
+ let context: a_watched_read_model;
11
+ beforeEach(async () => {
12
+ vi.useFakeTimers();
13
+ context = new a_watched_read_model();
14
+ await context.connect();
15
+ });
16
+ afterEach(() => {
17
+ context.readModels.dispose();
18
+ vi.useRealTimers();
19
+ });
20
+
21
+ it.each([1, 4, 13, 14])('should reject iteration with the original transport error for status %s', async code => {
22
+ const iterator = context.readModels[method](context.model)[Symbol.asyncIterator]();
23
+ const next = iterator.next().catch(error => error);
24
+ context.streams[0].send({ Subscribed: true });
25
+ await vi.advanceTimersByTimeAsync(0);
26
+ const failure = Object.assign(new Error('transport failed'), { code });
27
+ // A keep-alive disconnect arriving first must not replace the watch stream error.
28
+ await context.disconnect();
29
+ context.streams[0].closed.should.be.false;
30
+ context.streams[0].fail(failure);
31
+ (await next).should.equal(failure);
32
+ await context.connect();
33
+ await vi.advanceTimersByTimeAsync(1000);
34
+ context.streams.should.have.lengthOf(1);
35
+ });
36
+
37
+ it('should complete iteration without restarting after stream completion', async () => {
38
+ const iterator = context.readModels[method](context.model)[Symbol.asyncIterator]();
39
+ const next = iterator.next();
40
+ context.streams[0].send({ Subscribed: true });
41
+ context.streams[0].send({ ModelKey: 'received', ReadModel: '{}' });
42
+ context.streams[0].end();
43
+ await vi.advanceTimersByTimeAsync(1000);
44
+ (await next).value.key.should.equal('received');
45
+ (await iterator.next()).done!.should.be.true;
46
+ context.streams.should.have.lengthOf(1);
47
+ });
48
+
49
+ it('should stop opting into resumption after the last callback is removed', async () => {
50
+ const watcher = context.readModels.createWatcher(context.model);
51
+ const unsubscribe = watcher.onResubscribed(() => {});
52
+ context.streams[0].send({ Subscribed: true });
53
+ await watcher.subscribed;
54
+ unsubscribe();
55
+ const failure = { code: 14 };
56
+ const next = watcher[Symbol.asyncIterator]().next().catch(error => error);
57
+ context.streams[0].fail(failure);
58
+ (await next).should.equal(failure);
59
+ await vi.advanceTimersByTimeAsync(1000);
60
+ context.streams.should.have.lengthOf(1);
61
+ });
62
+ });
@@ -0,0 +1,128 @@
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
+ describe('when notifying read model resubscription', () => {
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(() => context.readModels.dispose());
19
+
20
+ it('should notify once per acknowledged resubscription and ignore duplicate markers', async () => {
21
+ const callback = vi.fn();
22
+ watcher.onResubscribed(callback);
23
+ context.streams[0].send({ Subscribed: true });
24
+ await watcher.subscribed;
25
+ for (let generation = 1; generation <= 2; generation++) {
26
+ await context.disconnect();
27
+ await context.connect();
28
+ callback.mock.calls.should.have.lengthOf(generation - 1);
29
+ context.streams[generation].send({ Subscribed: true });
30
+ context.streams[generation].send({ Subscribed: true });
31
+ await context.flush();
32
+ callback.mock.calls.should.have.lengthOf(generation);
33
+ }
34
+ });
35
+
36
+ it('should not notify for the first acknowledgment after an unacknowledged disconnect', async () => {
37
+ const callback = vi.fn();
38
+ watcher.onResubscribed(callback);
39
+ await context.disconnect();
40
+ await context.connect();
41
+ context.streams[1].send({ Subscribed: true });
42
+ await watcher.subscribed;
43
+ await context.flush();
44
+ callback.mock.calls.should.have.lengthOf(0);
45
+ });
46
+
47
+ it('should unregister callbacks idempotently without removing other callbacks', async () => {
48
+ const removed = vi.fn();
49
+ const remaining = vi.fn();
50
+ const unsubscribe = watcher.onResubscribed(removed);
51
+ watcher.onResubscribed(remaining);
52
+ context.streams[0].send({ Subscribed: true });
53
+ await watcher.subscribed;
54
+ unsubscribe();
55
+ unsubscribe();
56
+ await context.disconnect();
57
+ await context.connect();
58
+ context.streams[1].send({ Subscribed: true });
59
+ await context.flush();
60
+ removed.mock.calls.should.have.lengthOf(0);
61
+ remaining.mock.calls.should.have.lengthOf(1);
62
+ });
63
+
64
+ it('should await refresh callbacks in registration order before delivering resumed changes', async () => {
65
+ let finishRefresh!: () => void;
66
+ const refresh = new Promise<void>(resolve => { finishRefresh = resolve; });
67
+ const calls: string[] = [];
68
+ watcher.onResubscribed(async () => {
69
+ calls.push('refresh');
70
+ await refresh;
71
+ });
72
+ watcher.onResubscribed(() => { calls.push('refreshed'); });
73
+ context.streams[0].send({ Subscribed: true });
74
+ await watcher.subscribed;
75
+ const next = watcher[Symbol.asyncIterator]().next().then(change => {
76
+ calls.push('change');
77
+ return change;
78
+ });
79
+ await context.disconnect();
80
+ await context.connect();
81
+ context.streams[1].send({ Subscribed: true });
82
+ context.streams[1].send({ ModelKey: 'new', ReadModel: '{}' });
83
+ await context.flush();
84
+ calls.should.deep.equal(['refresh']);
85
+ finishRefresh();
86
+ (await next).value.key.should.equal('new');
87
+ calls.should.deep.equal(['refresh', 'refreshed', 'change']);
88
+ });
89
+
90
+ it.each([false, true])('should fail iteration on a callback failure even with a transport code (async: %s)', async asynchronous => {
91
+ const failure = { code: 14 };
92
+ watcher.onResubscribed(() => {
93
+ if (asynchronous) return Promise.reject(failure);
94
+ throw failure;
95
+ });
96
+ context.streams[0].send({ Subscribed: true });
97
+ await watcher.subscribed;
98
+ const next = watcher[Symbol.asyncIterator]().next().catch(error => error);
99
+ await context.disconnect();
100
+ await context.connect();
101
+ context.streams[1].send({ Subscribed: true });
102
+ (await next).should.equal(failure);
103
+ context.streams[1].signal.aborted.should.be.true;
104
+ await context.disconnect();
105
+ await context.connect();
106
+ context.streams.should.have.lengthOf(2);
107
+ });
108
+
109
+ it('should stop notifying callbacks when disposed during a refresh', async () => {
110
+ const remaining = vi.fn();
111
+ watcher.onResubscribed(() => watcher.dispose());
112
+ watcher.onResubscribed(remaining);
113
+ context.streams[0].send({ Subscribed: true });
114
+ await watcher.subscribed;
115
+ const next = watcher[Symbol.asyncIterator]().next();
116
+ await context.disconnect();
117
+ await context.connect();
118
+ context.streams[1].send({ Subscribed: true });
119
+ (await next).done!.should.be.true;
120
+ await context.flush();
121
+ remaining.mock.calls.should.have.lengthOf(0);
122
+ const unsubscribe = watcher.onResubscribed(remaining);
123
+ unsubscribe();
124
+ await context.disconnect();
125
+ await context.connect();
126
+ context.streams.should.have.lengthOf(2);
127
+ });
128
+ });
@@ -0,0 +1,39 @@
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 { a_watched_read_model } from './given/a_watched_read_model.js';
6
+
7
+ chai.should();
8
+
9
+ describe('when opting into watch overflow failure', () => {
10
+ let context: a_watched_read_model;
11
+ beforeEach(async () => {
12
+ context = new a_watched_read_model();
13
+ await context.connect();
14
+ });
15
+ afterEach(() => context.readModels.dispose());
16
+
17
+ it.each([false, true])('should fail instead of dropping changes above the explicit limit (acknowledged: %s)', async acknowledged => {
18
+ const watcher = context.readModels.createWatcher(context.model, { maxBuffered: 2, resume: true });
19
+ const readiness = watcher.subscribed.catch(error => error);
20
+ if (acknowledged) {
21
+ context.streams[0].send({ Subscribed: true });
22
+ await readiness;
23
+ }
24
+ for (let index = 0; index < 3; index++) context.streams[0].send({ ReadModel: '{}' });
25
+ await context.flush();
26
+ const failure = await watcher[Symbol.asyncIterator]().next().catch(error => error);
27
+ failure.message.should.contain('buffer exceeded 2 changes');
28
+ if (!acknowledged) (await readiness).should.equal(failure);
29
+ context.streams[0].signal.aborted.should.be.true;
30
+ await context.disconnect();
31
+ await context.connect();
32
+ context.streams.should.have.lengthOf(1);
33
+ });
34
+
35
+ it.each([0, -1, 1.5, Infinity, NaN, Number.MAX_SAFE_INTEGER + 1])('should reject an invalid limit %s before opening a stream', maxBuffered => {
36
+ (() => context.readModels.createWatcher(context.model, { maxBuffered })).should.throw(RangeError);
37
+ context.streams.should.have.lengthOf(0);
38
+ });
39
+ });