@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
@@ -3,6 +3,7 @@
3
3
 
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import { afterAll, beforeAll, chai, describe, it } from 'vitest';
6
+ import { ObserverRunningState } from '@cratis/chronicle.contracts';
6
7
  import { field } from '@cratis/fundamentals';
7
8
  import { ChronicleClient } from '../../../ChronicleClient.js';
8
9
  import { ChronicleOptions } from '../../../ChronicleOptions.js';
@@ -65,12 +66,11 @@ const artifacts: IClientArtifactsProvider = {
65
66
  globalForHandlers: []
66
67
  };
67
68
 
68
- // 30s was occasionally too tight for this reactor's initial catch-up under concurrent
69
- // kernel load in CI (observed timing out at 31.2s on a run that passed cleanly on retry
70
- // with no code change); 45s keeps the polling assertion honest while tolerating that jitter.
71
- async function eventually(accept: () => boolean, timeoutMs = 45_000): Promise<void> {
69
+ // Keep the existing 45s replay polling budget for concurrent kernel load in CI.
70
+ // Initial observation is gated separately by the bounded readiness waits below.
71
+ async function eventually(accept: () => boolean | Promise<boolean>, timeoutMs = 45_000): Promise<void> {
72
72
  const deadline = Date.now() + timeoutMs;
73
- while (!accept()) {
73
+ while (!await accept()) {
74
74
  if (Date.now() > deadline) throw new Error('Timed out waiting for the kernel');
75
75
  await new Promise(resolve => setTimeout(resolve, 250));
76
76
  }
@@ -86,6 +86,17 @@ describe.skipIf(!connectionString && !process.env.CI)('when replaying a reactor
86
86
  let jobId: string | undefined;
87
87
  let invocationsBeforeReplay: string[];
88
88
 
89
+ const waitForActiveObserver = (lastHandledSequenceNumber?: bigint, timeoutMs = 15_000) => eventually(async () => {
90
+ const observer = await connection.observers.getObserverInformation({
91
+ EventStore: storeName,
92
+ Namespace: 'Default',
93
+ ObserverId: 'MixedOnceOnlyReactor',
94
+ EventSequenceId: 'event-log'
95
+ });
96
+ return observer.IsSubscribed && observer.RunningState === ObserverRunningState.Active &&
97
+ (lastHandledSequenceNumber === undefined || observer.LastHandledEventSequenceNumber === lastHandledSequenceNumber);
98
+ }, timeoutMs);
99
+
89
100
  beforeAll(async () => {
90
101
  client = new ChronicleClient(ChronicleOptions.fromConnectionString(connectionString!, {
91
102
  discoveryPatterns: [],
@@ -94,10 +105,18 @@ describe.skipIf(!connectionString && !process.env.CI)('when replaying a reactor
94
105
  store = await client.getEventStore(storeName);
95
106
  connection = (store as unknown as { _connection: ChronicleConnection })._connection;
96
107
 
108
+ // Registration starts observation in the background. Append only after the subscription
109
+ // is active, so initial catch-up cannot race the explicit replay's state transition.
110
+ // Initial registration and subscription took over 30s under concurrent CI kernel load before.
111
+ await waitForActiveObserver(undefined, 30_000);
97
112
  (await store.eventLog.append(randomUUID(), Object.assign(new MethodOnceOnlyHappened(), { name: 'a' }))).isSuccess.should.be.true;
98
113
  (await store.eventLog.append(randomUUID(), Object.assign(new MethodReplayableHappened(), { name: 'b' }))).isSuccess.should.be.true;
99
- (await store.eventLog.append(randomUUID(), Object.assign(new MethodReplayOnceOnlyHappened(), { name: 'c' }))).isSuccess.should.be.true;
100
- await eventually(() => invocations.length === 3);
114
+ const appended = await store.eventLog.append(randomUUID(), Object.assign(new MethodReplayOnceOnlyHappened(), { name: 'c' }));
115
+ appended.isSuccess.should.be.true;
116
+ // Worst case 30s + 30s + 15s + 45s + 3s stays inside the explicit 150s hook timeout below.
117
+ await eventually(() => invocations.length === 3, 30_000);
118
+ // The handlers update their sink before the kernel acknowledges delivery and records progress.
119
+ await waitForActiveObserver(appended.sequenceNumber.value);
101
120
  invocationsBeforeReplay = [...invocations];
102
121
 
103
122
  jobId = (await connection.observers.replay({
@@ -108,7 +127,7 @@ describe.skipIf(!connectionString && !process.env.CI)('when replaying a reactor
108
127
  })).JobId;
109
128
  await eventually(() => invocations.includes('replayable:b') && invocations.filter(_ => _ === 'replayable:b').length === 2);
110
129
  await new Promise(resolve => setTimeout(resolve, 3_000));
111
- });
130
+ }, 150_000);
112
131
 
113
132
  afterAll(() => client?.dispose());
114
133
 
@@ -0,0 +1,35 @@
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 { ReadModelChangeset } from './ReadModelChangeset.js';
5
+
6
+ /**
7
+ * A single-consumer stream of read model changes, started when createWatcher() is called.
8
+ * Terminal stream or connection failures retain buffered changes: iteration drains them
9
+ * before rejecting with the original error. Overflow and disposal discard buffered changes.
10
+ */
11
+ export interface IReadModelWatcher<TReadModel> extends AsyncIterable<ReadModelChangeset<TReadModel>> {
12
+ /**
13
+ * Resolves on the kernel's subscription acknowledgment, without requiring iteration.
14
+ * When resumption is opted into, read this property again after disconnect for the next subscription.
15
+ * An already pending promise carries across lifecycle reconnects; a previously resolved
16
+ * promise cannot be revoked. Rejects if the watcher stops before acknowledgment.
17
+ */
18
+ readonly subscribed: Promise<void>;
19
+
20
+ /**
21
+ * Registers a callback after each subscription acknowledgment following the first.
22
+ * Having at least one registered callback opts into resumption on transport failures and
23
+ * completion. Without callbacks or the resume option, transport errors reject iteration.
24
+ * Refresh your query here: changes during an outage are not replayed. Register before
25
+ * awaiting subscribed; callbacks are not replayed for late registrations.
26
+ * Callbacks run in registration order and are awaited before reading more changes on
27
+ * the resumed stream. Do not wait for iteration inside a callback. A thrown error or
28
+ * rejected promise fails iteration. Reconnects retain already received changes.
29
+ * @returns An idempotent function that unregisters the callback. Disposal also unregisters it.
30
+ */
31
+ onResubscribed(callback: () => void | Promise<void>): () => void;
32
+
33
+ /** Stops the stream and completes pending iteration. Safe to call more than once. */
34
+ dispose(): void;
35
+ }
@@ -3,7 +3,9 @@
3
3
 
4
4
  import type { Constructor } from '@cratis/fundamentals';
5
5
  import type { IMaterializedReadModels } from './IMaterializedReadModels.js';
6
+ import type { IReadModelWatcher } from './IReadModelWatcher.js';
6
7
  import type { ReadModelChangeset } from './ReadModelChangeset.js';
8
+ import type { ReadModelWatchOptions } from './ReadModelWatchOptions.js';
7
9
  import type { ReadModelSnapshot } from './ReadModelSnapshot.js';
8
10
 
9
11
  /**
@@ -65,12 +67,25 @@ export interface IReadModels {
65
67
  getSnapshotsById<TReadModel>(readModelType: Constructor<TReadModel>, key: string): Promise<ReadModelSnapshot<TReadModel>[]>;
66
68
 
67
69
  /**
68
- * Watches changes for a specific read model type.
70
+ * Watches changes for a specific read model type, opening the stream on the first next().
71
+ * Pulls changes only as the consumer requests them, without a client-side buffer or overflow limit.
69
72
  * @param readModelType - The read model type to observe.
70
- * @returns An async iterable of read model changes.
73
+ * @returns An async iterable of read model changes. Transport failures reject iteration.
74
+ * @remarks Keeps its original return type so existing watch implementations and mocks can
75
+ * return plain async iterables. Use createWatcher for readiness and lifetime controls.
71
76
  */
72
77
  watch<TReadModel>(readModelType: Constructor<TReadModel>): AsyncIterable<ReadModelChangeset<TReadModel>>;
73
78
 
79
+ /**
80
+ * Starts a single-consumer watcher immediately, without requiring iteration for readiness.
81
+ * @param readModelType - The read model type to observe.
82
+ * @param options - Cancellation, opt-in resumption, and an optional overflow-failure limit.
83
+ * @returns A watcher with subscription readiness, resubscription callbacks, and explicit disposal.
84
+ * Transport failures reject iteration unless resume is true or an onResubscribed callback
85
+ * is registered. Resumed watches retain received changes but do not replay outage gaps.
86
+ */
87
+ createWatcher<TReadModel>(readModelType: Constructor<TReadModel>, options?: ReadModelWatchOptions): IReadModelWatcher<TReadModel>;
88
+
74
89
  /**
75
90
  * Dehydrates a read model session.
76
91
  * @param sessionId - The session identifier to dehydrate.
@@ -0,0 +1,11 @@
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
+ /** The triggering event metadata available on the kernel's read model change stream. */
5
+ export interface ReadModelChangeContext {
6
+ readonly eventStore: string;
7
+ readonly namespace: string;
8
+ readonly sequenceNumber: bigint;
9
+ readonly correlationId?: string;
10
+ readonly occurred?: Date;
11
+ }
@@ -0,0 +1,9 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ /** The kind of change to a read model instance. */
5
+ export enum ReadModelChangeType {
6
+ Added = 0,
7
+ Modified = 1,
8
+ Removed = 2
9
+ }
@@ -1,6 +1,9 @@
1
1
  // Copyright (c) Cratis. All rights reserved.
2
2
  // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
3
 
4
+ import type { ReadModelChangeType } from './ReadModelChangeType.js';
5
+ import type { ReadModelChangeContext } from './ReadModelChangeContext.js';
6
+
4
7
  /**
5
8
  * Represents a change observed for a read model.
6
9
  */
@@ -16,4 +19,10 @@ export interface ReadModelChangeset<TReadModel> {
16
19
 
17
20
  /** Whether the read model was removed. */
18
21
  readonly removed: boolean;
22
+
23
+ /** The kind of change. Optional for compatibility with user-created changesets. */
24
+ readonly changeType?: ReadModelChangeType;
25
+
26
+ /** Triggering event metadata supplied by the kernel, not a complete event context. */
27
+ readonly changeContext?: ReadModelChangeContext;
19
28
  }
@@ -0,0 +1,23 @@
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
+ /** Options for creating a read model watcher. */
5
+ export interface ReadModelWatchOptions {
6
+ /** Aborting completes iteration and rejects pending readiness. */
7
+ readonly signal?: AbortSignal;
8
+
9
+ /**
10
+ * Resume after transport failure or completion. Defaults to false; registering an
11
+ * onResubscribed callback also opts in while at least one callback is registered.
12
+ * Changes missed during an outage are not replayed; refresh your query after resubscription.
13
+ */
14
+ readonly resume?: boolean;
15
+
16
+ /**
17
+ * Opt into failing on overflow instead of applying backpressure. Must be a positive safe integer.
18
+ * By default, pause reads at 1,024 buffered changes after subscription acknowledgment.
19
+ * Before each acknowledgment, keep reading to reach the marker; exceeding 1,024 changes
20
+ * (or this explicit limit) fails iteration and pending readiness rather than dropping changes.
21
+ */
22
+ readonly maxBuffered?: number;
23
+ }
@@ -0,0 +1,271 @@
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 { ReadModelChangeset as ContractChangeset } from '@cratis/chronicle.contracts';
5
+ import type { ConnectionLifecycle } from '../connection/ConnectionLifecycle.js';
6
+ import type { IReadModelWatcher } from './IReadModelWatcher.js';
7
+ import type { ReadModelChangeset } from './ReadModelChangeset.js';
8
+ import type { ReadModelWatchOptions } from './ReadModelWatchOptions.js';
9
+
10
+ /** @internal Owns one cancellable stream per connection generation. */
11
+ export class ReadModelWatcher<TReadModel> implements IReadModelWatcher<TReadModel>, AsyncIterableIterator<ReadModelChangeset<TReadModel>> {
12
+ private static readonly _restartDelayMs = 1000;
13
+ private readonly _bufferLimit: number;
14
+ private readonly _failOnOverflow: boolean;
15
+ private readonly _resume: boolean;
16
+ private _resumeReading?: () => void;
17
+
18
+ private _subscribed!: Promise<void>;
19
+ private _resolveSubscribed!: () => void;
20
+ private _rejectSubscribed!: (error: unknown) => void;
21
+ private _acknowledged = false;
22
+ private _hasSubscribed = false;
23
+ private _delivery = Promise.resolve();
24
+ private readonly _onResubscribed = new Set<() => void | Promise<void>>();
25
+ private _controller?: AbortController;
26
+ private _stopped = false;
27
+ private _failed = false;
28
+ private _failure?: unknown;
29
+ private readonly _buffered: ReadModelChangeset<TReadModel>[] = [];
30
+ private _restartTimer?: ReturnType<typeof setTimeout>;
31
+ private readonly _waiting: {
32
+ resolve: (result: IteratorResult<ReadModelChangeset<TReadModel>>) => void;
33
+ reject: (error: unknown) => void;
34
+ }[] = [];
35
+ private readonly _unsubscribe: (() => void)[] = [];
36
+
37
+ constructor(
38
+ private readonly _open: (signal: AbortSignal) => AsyncIterable<ContractChangeset>,
39
+ private readonly _convert: (change: ContractChangeset) => Promise<ReadModelChangeset<TReadModel>>,
40
+ signal: AbortSignal,
41
+ private readonly _lifecycle?: ConnectionLifecycle,
42
+ options: ReadModelWatchOptions = {}
43
+ ) {
44
+ if (options.maxBuffered !== undefined && (!Number.isSafeInteger(options.maxBuffered) || options.maxBuffered < 1)) {
45
+ throw new RangeError('maxBuffered must be a positive safe integer.');
46
+ }
47
+ this._bufferLimit = options.maxBuffered ?? 1024;
48
+ this._failOnOverflow = options.maxBuffered !== undefined;
49
+ this._resume = options.resume === true;
50
+ this.resetReadiness();
51
+ const abort = () => this.finish(signal.reason, false);
52
+ signal.addEventListener('abort', abort, { once: true });
53
+ this._unsubscribe.push(() => signal.removeEventListener('abort', abort));
54
+ if (_lifecycle) {
55
+ // Without opt-in, let the watch stream propagate its original transport error.
56
+ this._unsubscribe.push(_lifecycle.onDisconnected(async () => {
57
+ if (this.shouldResume()) this.disconnect();
58
+ }));
59
+ this._unsubscribe.push(_lifecycle.onConnected(async () => this.start()));
60
+ this._unsubscribe.push(_lifecycle.onFailed(async error => this.finish(error, true, true)));
61
+ }
62
+ if (signal.aborted) {
63
+ abort();
64
+ } else if (_lifecycle?.failure) {
65
+ this.finish(_lifecycle.failure, true, true);
66
+ } else if (!_lifecycle || _lifecycle.isConnected) {
67
+ this.start();
68
+ }
69
+ }
70
+
71
+ get subscribed(): Promise<void> {
72
+ return this._subscribed;
73
+ }
74
+
75
+ onResubscribed(callback: () => void | Promise<void>): () => void {
76
+ if (this._stopped) return () => {};
77
+ this._onResubscribed.add(callback);
78
+ return () => { this._onResubscribed.delete(callback); };
79
+ }
80
+
81
+ [Symbol.asyncIterator](): AsyncIterableIterator<ReadModelChangeset<TReadModel>> {
82
+ return this;
83
+ }
84
+
85
+ next(): Promise<IteratorResult<ReadModelChangeset<TReadModel>>> {
86
+ const value = this._buffered.shift();
87
+ if (value) {
88
+ this.resumeReading();
89
+ return Promise.resolve({ done: false, value });
90
+ }
91
+ if (this._failed) return Promise.reject(this._failure);
92
+ if (this._stopped) return Promise.resolve({ done: true, value: undefined });
93
+ return new Promise((resolve, reject) => this._waiting.push({ resolve, reject }));
94
+ }
95
+
96
+ async return(): Promise<IteratorResult<ReadModelChangeset<TReadModel>>> {
97
+ this.dispose();
98
+ return { done: true, value: undefined };
99
+ }
100
+
101
+ dispose(): void {
102
+ this._buffered.length = 0;
103
+ this.finish(new DOMException('Read model watcher disposed.', 'AbortError'), false);
104
+ }
105
+
106
+ private resetReadiness(): void {
107
+ this._acknowledged = false;
108
+ this._subscribed = new Promise<void>((resolve, reject) => {
109
+ this._resolveSubscribed = resolve;
110
+ this._rejectSubscribed = reject;
111
+ });
112
+ // Legacy consumers only iterate. They still receive stream failures from next();
113
+ // not observing the optional readiness promise must not cause an unhandled rejection.
114
+ this._subscribed.catch(() => {});
115
+ }
116
+
117
+ private start(): void {
118
+ if (this._stopped || this._controller) return;
119
+ this.clearRestartTimer();
120
+ const controller = new AbortController();
121
+ this._controller = controller;
122
+ void this.read(controller);
123
+ }
124
+
125
+ private disconnect(): void {
126
+ if (this._stopped) return;
127
+ this.clearRestartTimer();
128
+ this.cancelStream();
129
+ // Received changes are not replayed by the kernel. Keep them across reconnects.
130
+ if (this._acknowledged) this.resetReadiness();
131
+ }
132
+
133
+ private shouldResume(): boolean {
134
+ return this._resume || this._onResubscribed.size > 0;
135
+ }
136
+
137
+ private restart(): void {
138
+ this.disconnect();
139
+ if (this._stopped || (this._lifecycle && !this._lifecycle.isConnected)) return;
140
+ // The watch and keep-alive share a transport, but can fail in either order.
141
+ // Also recover a watch-only failure if no disconnected notification follows.
142
+ this._restartTimer = setTimeout(() => {
143
+ this._restartTimer = undefined;
144
+ if (!this._lifecycle || this._lifecycle.isConnected) this.start();
145
+ }, ReadModelWatcher._restartDelayMs);
146
+ this._restartTimer.unref?.();
147
+ }
148
+
149
+ private clearRestartTimer(): void {
150
+ clearTimeout(this._restartTimer);
151
+ this._restartTimer = undefined;
152
+ }
153
+
154
+ private cancelStream(): void {
155
+ const controller = this._controller;
156
+ this._controller = undefined;
157
+ controller?.abort();
158
+ this.resumeReading();
159
+ }
160
+
161
+ private resumeReading(): void {
162
+ this._resumeReading?.();
163
+ this._resumeReading = undefined;
164
+ }
165
+
166
+ private waitForCapacity(controller: AbortController): Promise<void> | undefined {
167
+ if (this._controller === controller && this._acknowledged && !this._failOnOverflow &&
168
+ this._buffered.length >= this._bufferLimit) {
169
+ return new Promise<void>(resolve => { this._resumeReading = resolve; })
170
+ .then(() => this.waitForCapacity(controller));
171
+ }
172
+ return undefined;
173
+ }
174
+
175
+ private async read(controller: AbortController): Promise<void> {
176
+ try {
177
+ for await (const change of this._open(controller.signal)) {
178
+ if (this._controller !== controller) return;
179
+ if (change.Subscribed) {
180
+ if (this._acknowledged) continue;
181
+ this._acknowledged = true;
182
+ this._resolveSubscribed();
183
+ const resubscribed = this._hasSubscribed;
184
+ this._hasSubscribed = true;
185
+ if (resubscribed) {
186
+ const callbacks = [...this._onResubscribed];
187
+ this._delivery = this._delivery.then(() => this.notifyResubscribed(callbacks));
188
+ await this._delivery;
189
+ }
190
+ const capacity = this.waitForCapacity(controller);
191
+ if (capacity) await capacity;
192
+ if (this._controller !== controller) return;
193
+ continue;
194
+ }
195
+ // Finish converting changes already received, even if their stream disconnects.
196
+ // A new stream queues behind them so conversion cannot reorder delivery.
197
+ this._delivery = this._delivery.then(() => this.deliver(change));
198
+ await this._delivery;
199
+ const capacity = this.waitForCapacity(controller);
200
+ if (capacity) await capacity;
201
+ if (this._controller !== controller) return;
202
+ }
203
+ if (this._controller === controller) {
204
+ if (this.shouldResume()) this.restart();
205
+ else this.finish(new Error('Read model watch ended before subscription acknowledgment.'), false, true);
206
+ }
207
+ } catch (error) {
208
+ if (this._controller !== controller) return;
209
+ const code = (error as { code?: number })?.code;
210
+ if (this.shouldResume() && code !== undefined && [1, 4, 13, 14].includes(code)) this.restart();
211
+ // Let the consumer drain received changes before observing the terminal stream error.
212
+ else this.finish(error, true, true);
213
+ }
214
+ }
215
+
216
+ private async deliver(change: ContractChangeset): Promise<void> {
217
+ if (this._stopped) return;
218
+ let converted: ReadModelChangeset<TReadModel>;
219
+ try {
220
+ converted = await this._convert(change);
221
+ } catch (error) {
222
+ // Conversion includes compliance RPCs: even transport-coded errors here
223
+ // are terminal, not permission to skip an unreleased change.
224
+ this.finish(error, true);
225
+ return;
226
+ }
227
+ if (this._stopped) return;
228
+ const waiting = this._waiting.shift();
229
+ if (waiting) {
230
+ waiting.resolve({ done: false, value: converted });
231
+ } else {
232
+ // Before Subscribed we must keep reading to reach the readiness marker.
233
+ // Afterward the default pauses reads; only an explicit limit opts into failure.
234
+ if (this._buffered.length >= this._bufferLimit) {
235
+ this.finish(new Error(`Read model watcher buffer exceeded ${this._bufferLimit} changes. Consume changes faster or refresh the read model and create a new watcher.`), true);
236
+ return;
237
+ }
238
+ this._buffered.push(converted);
239
+ }
240
+ }
241
+
242
+ private async notifyResubscribed(callbacks: (() => void | Promise<void>)[]): Promise<void> {
243
+ try {
244
+ for (const callback of callbacks) {
245
+ if (this._stopped) return;
246
+ if (this._onResubscribed.has(callback)) await callback();
247
+ }
248
+ } catch (error) {
249
+ // A failed refresh is not a recovered watch, even for transport-coded errors.
250
+ this.finish(error, true);
251
+ }
252
+ }
253
+
254
+ private finish(reason: unknown, failed: boolean, preserveBuffered = false): void {
255
+ if (this._stopped) return;
256
+ this._stopped = true;
257
+ this._failed = failed;
258
+ this._failure = reason;
259
+ this._rejectSubscribed(reason);
260
+ this.clearRestartTimer();
261
+ this.cancelStream();
262
+ if (!preserveBuffered) this._buffered.length = 0;
263
+ for (const unsubscribe of this._unsubscribe) unsubscribe();
264
+ this._unsubscribe.length = 0;
265
+ this._onResubscribed.clear();
266
+ for (const waiting of this._waiting.splice(0)) {
267
+ if (failed) waiting.reject(reason);
268
+ else waiting.resolve({ done: true, value: undefined });
269
+ }
270
+ }
271
+ }
@@ -3,15 +3,19 @@
3
3
 
4
4
  import type {
5
5
  AppendedEvent,
6
+ ReadModelChangeset as ContractReadModelChangeset,
6
7
  ReadModelObserverType
7
8
  } from '@cratis/chronicle.contracts';
8
9
  import {
9
- ReadModelObserverType as ContractReadModelObserverType
10
+ ReadModelObserverType as ContractReadModelObserverType,
11
+ ReadModelChangeType as ContractReadModelChangeType
10
12
  } from '@cratis/chronicle.contracts';
11
13
  import type { Constructor } from '@cratis/fundamentals';
12
14
  import { JsonSerializer } from '@cratis/fundamentals';
13
15
  import { IClientArtifactsProvider } from '../artifacts/index.js';
14
16
  import { ChronicleConnection } from '../connection/index.js';
17
+ import type { ConnectionLifecycle } from '../connection/ConnectionLifecycle.js';
18
+ import { fromContractsGuid } from '../connection/Guid.js';
15
19
  import { ensureQuerySuccess } from '../connection/callResults.js';
16
20
  import { EventSequenceId } from '../eventSequences/EventSequenceId.js';
17
21
  import { getProjectionMetadata } from '../projections/declarative/projection.js';
@@ -31,7 +35,11 @@ import { ReadModelSubjectResolver } from './ReadModelSubjectResolver.js';
31
35
  import { deserializeReadModel } from './deserializeReadModel.js';
32
36
  import type { ReadModelNamingPolicy } from './ReadModelNamingPolicy.js';
33
37
  import type { IReadModels } from './IReadModels.js';
38
+ import type { IReadModelWatcher } from './IReadModelWatcher.js';
39
+ import { ReadModelWatcher } from './ReadModelWatcher.js';
34
40
  import type { ReadModelChangeset } from './ReadModelChangeset.js';
41
+ import type { ReadModelWatchOptions } from './ReadModelWatchOptions.js';
42
+ import { ReadModelChangeType } from './ReadModelChangeType.js';
35
43
  import type { ReadModelSnapshot } from './ReadModelSnapshot.js';
36
44
 
37
45
  const unlimitedEventCount = BigInt('18446744073709551615');
@@ -52,6 +60,7 @@ interface ResolvedReadModel {
52
60
  */
53
61
  export class ReadModels implements IReadModels {
54
62
  readonly materialized: IMaterializedReadModels;
63
+ private readonly _watchCancellation = new AbortController();
55
64
 
56
65
  constructor(
57
66
  private readonly _eventStore: string,
@@ -60,7 +69,8 @@ export class ReadModels implements IReadModels {
60
69
  private readonly _clientArtifacts: IClientArtifactsProvider,
61
70
  private readonly _defaultSinkTypeId: string,
62
71
  private readonly _isModelBoundProjectionRegistered?: (readModelType: Constructor) => boolean,
63
- private readonly _readModelNamingPolicy?: ReadModelNamingPolicy
72
+ private readonly _readModelNamingPolicy?: ReadModelNamingPolicy,
73
+ private readonly _lifecycle?: ConnectionLifecycle
64
74
  ) {
65
75
  this.materialized = new MaterializedReadModels(_eventStore, _namespace, _connection);
66
76
  }
@@ -185,18 +195,35 @@ export class ReadModels implements IReadModels {
185
195
  continue;
186
196
  }
187
197
 
188
- const instance = this.deserializeReadModel(readModelType, changeset.ReadModel);
189
- const requiresRelease = !changeset.Removed && readModel.observerType === ContractReadModelObserverType.Reducer &&
190
- this.schemaHasComplianceMetadata(readModel.schema);
191
- yield {
192
- namespace: changeset.Namespace,
193
- key: changeset.ModelKey,
194
- readModel: requiresRelease ? await this.release(readModelType, instance) : instance,
195
- removed: changeset.Removed
196
- };
198
+ yield await this.toChangeset(readModelType, readModel, changeset);
197
199
  }
198
200
  }
199
201
 
202
+ /** @inheritdoc */
203
+ createWatcher<TReadModel>(readModelType: Constructor<TReadModel>, options?: ReadModelWatchOptions): IReadModelWatcher<TReadModel> {
204
+ const readModel = this.resolveReadModel(readModelType);
205
+ const signal = AbortSignal.any(options?.signal
206
+ ? [options.signal, this._watchCancellation.signal]
207
+ : [this._watchCancellation.signal]);
208
+ return new ReadModelWatcher(
209
+ streamSignal => this._connection.readModels.watch({
210
+ EventStore: this._eventStore,
211
+ Namespace: this._namespace,
212
+ ReadModelIdentifier: readModel.identifier,
213
+ EventSequenceId: readModel.eventSequenceId
214
+ }, { signal: streamSignal }),
215
+ changeset => this.toChangeset(readModelType, readModel, changeset),
216
+ signal,
217
+ this._lifecycle,
218
+ options
219
+ );
220
+ }
221
+
222
+ /** Stops all watchers created through createWatcher for this event store. */
223
+ dispose(): void {
224
+ this._watchCancellation.abort();
225
+ }
226
+
200
227
  /** @inheritdoc */
201
228
  async dehydrateSession<TReadModel>(sessionId: string, readModelType: Constructor<TReadModel>, key: string): Promise<void> {
202
229
  const readModel = this.resolveReadModel(readModelType);
@@ -355,6 +382,28 @@ export class ReadModels implements IReadModels {
355
382
  return typeof value === 'string' && value.length > 0;
356
383
  }
357
384
 
385
+ private async toChangeset<TReadModel>(readModelType: Constructor<TReadModel>, readModel: ResolvedReadModel, changeset: ContractReadModelChangeset): Promise<ReadModelChangeset<TReadModel>> {
386
+ const instance = this.deserializeReadModel(readModelType, changeset.ReadModel);
387
+ const occurred = changeset.Occurred?.Value ? new Date(changeset.Occurred.Value) : undefined;
388
+ const requiresRelease = !changeset.Removed && readModel.observerType === ContractReadModelObserverType.Reducer &&
389
+ this.schemaHasComplianceMetadata(readModel.schema);
390
+ return {
391
+ namespace: changeset.Namespace,
392
+ key: changeset.ModelKey,
393
+ readModel: requiresRelease ? await this.release(readModelType, instance) : instance,
394
+ removed: changeset.Removed,
395
+ changeType: changeset.ChangeType === ContractReadModelChangeType.Added ? ReadModelChangeType.Added :
396
+ changeset.ChangeType === ContractReadModelChangeType.Removed ? ReadModelChangeType.Removed : ReadModelChangeType.Modified,
397
+ changeContext: {
398
+ eventStore: this._eventStore,
399
+ namespace: changeset.Namespace,
400
+ sequenceNumber: changeset.EventSequenceNumber,
401
+ correlationId: changeset.CorrelationId ? fromContractsGuid(changeset.CorrelationId).toString() : undefined,
402
+ occurred: occurred && !Number.isNaN(occurred.getTime()) ? occurred : undefined
403
+ }
404
+ };
405
+ }
406
+
358
407
  private async releaseSnapshotInstances<TReadModel>(readModelType: Constructor<TReadModel>, snapshots: ReadModelSnapshot<TReadModel>[]): Promise<ReadModelSnapshot<TReadModel>[]> {
359
408
  return Promise.all(
360
409
  snapshots.map(async snapshot => ({