eventsourcingdb 1.8.10 → 1.9.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.
package/README.md CHANGED
@@ -260,6 +260,29 @@ for await (const row of client.runEventQlQuery(`
260
260
  controller.abort();
261
261
  ```
262
262
 
263
+ #### Detecting a Stalled Connection
264
+
265
+ While a query is running, EventSourcingDB sends a heartbeat every second whenever there is no row to send. If neither a row nor a heartbeat arrives for 30 seconds, for example because a proxy keeps the connection open but no longer passes anything through, the client SDK closes the connection and the iterator throws a `HeartbeatTimeoutError`:
266
+
267
+ ```typescript
268
+ import { HeartbeatTimeoutError } from 'eventsourcingdb';
269
+
270
+ try {
271
+ for await (const row of client.runEventQlQuery(`
272
+ FROM e IN events
273
+ PROJECT INTO e
274
+ `)) {
275
+ // ...
276
+ }
277
+ } catch (error) {
278
+ if (error instanceof HeartbeatTimeoutError) {
279
+ // ...
280
+ }
281
+ }
282
+ ```
283
+
284
+ *Note that the time you spend handling a row does not count towards the 30 seconds, and that aborting the query does not cause a `HeartbeatTimeoutError`.*
285
+
263
286
  ### Observing Events
264
287
 
265
288
  To observe all events of a subject, call the `observeEvents` function with the subject as the first argument and an options object as the second argument. Set the `recursive` option to `false`. This ensures that only events of the given subject are returned, not events of nested subjects.
@@ -344,6 +367,28 @@ for await (const event of client.observeEvents('/books/42', {
344
367
  controller.abort();
345
368
  ```
346
369
 
370
+ #### Detecting a Stalled Connection
371
+
372
+ While observing, EventSourcingDB sends a heartbeat every second whenever there is no event to send. If neither an event nor a heartbeat arrives for 30 seconds, for example because a proxy keeps the connection open but no longer passes anything through, the client SDK closes the connection and the iterator throws a `HeartbeatTimeoutError`:
373
+
374
+ ```typescript
375
+ import { HeartbeatTimeoutError } from 'eventsourcingdb';
376
+
377
+ try {
378
+ for await (const event of client.observeEvents('/books/42', {
379
+ recursive: false
380
+ })) {
381
+ // ...
382
+ }
383
+ } catch (error) {
384
+ if (error instanceof HeartbeatTimeoutError) {
385
+ // ...
386
+ }
387
+ }
388
+ ```
389
+
390
+ *Note that the time you spend handling an event does not count towards the 30 seconds, and that aborting observing does not cause a `HeartbeatTimeoutError`.*
391
+
347
392
  ### Registering an Event Schema
348
393
 
349
394
  To register an event schema, call the `registerEventSchema` function and hand over an event type and the desired schema:
package/dist/Client.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { convertCloudEventToEvent } from './convertCloudEventToEvent.js';
2
+ import { heartbeatTimeout } from './heartbeatTimeout.js';
2
3
  import { isCloudEvent } from './isCloudEvent.js';
3
4
  import { isValidServerHeader } from './isValidServerHeader.js';
4
5
  import { readNdJsonStream } from './ndjson/readNdJsonStream.js';
@@ -203,7 +204,7 @@ class Client {
203
204
  if (!response.body) {
204
205
  throw new Error('Failed to run EventQL query.');
205
206
  }
206
- for await (const line of readNdJsonStream(response.body, combinedSignal)) {
207
+ for await (const line of readNdJsonStream(response.body, combinedSignal, heartbeatTimeout.milliseconds)) {
207
208
  if (isStreamHeartbeat(line)) {
208
209
  continue;
209
210
  }
@@ -273,7 +274,7 @@ class Client {
273
274
  if (!response.body) {
274
275
  throw new Error('Failed to observe events.');
275
276
  }
276
- for await (const line of readNdJsonStream(response.body, combinedSignal)) {
277
+ for await (const line of readNdJsonStream(response.body, combinedSignal, heartbeatTimeout.milliseconds)) {
277
278
  if (isStreamHeartbeat(line)) {
278
279
  continue;
279
280
  }
@@ -0,0 +1,4 @@
1
+ declare class HeartbeatTimeoutError extends Error {
2
+ constructor();
3
+ }
4
+ export { HeartbeatTimeoutError };
@@ -0,0 +1,7 @@
1
+ class HeartbeatTimeoutError extends Error {
2
+ constructor() {
3
+ super('No event and no heartbeat arrived for 30 seconds.');
4
+ this.name = 'HeartbeatTimeoutError';
5
+ }
6
+ }
7
+ export { HeartbeatTimeoutError };
@@ -0,0 +1,4 @@
1
+ declare const heartbeatTimeout: {
2
+ milliseconds: number;
3
+ };
4
+ export { heartbeatTimeout };
@@ -0,0 +1,6 @@
1
+ // A stream that carries heartbeats ends with a HeartbeatTimeoutError if no line
2
+ // arrives for this long. The package does not export this, only tests shorten it.
3
+ const heartbeatTimeout = {
4
+ milliseconds: 30_000,
5
+ };
6
+ export { heartbeatTimeout };
package/dist/index.d.ts CHANGED
@@ -3,6 +3,7 @@ import { Container } from './Container.js';
3
3
  import type { Event } from './Event.js';
4
4
  import type { EventCandidate } from './EventCandidate.js';
5
5
  import type { EventType } from './EventType.js';
6
+ import { HeartbeatTimeoutError } from './HeartbeatTimeoutError.js';
6
7
  import { isEventQlQueryTrue } from './isEventQlQueryTrue.js';
7
8
  import { isSubjectOnEventId } from './isSubjectOnEventId.js';
8
9
  import { isSubjectPopulated } from './isSubjectPopulated.js';
@@ -11,4 +12,4 @@ import type { ObserveEventsOptions } from './ObserveEventsOptions.js';
11
12
  import type { Precondition } from './Precondition.js';
12
13
  import type { ReadEventsOptions } from './ReadEventsOptions.js';
13
14
  export type { Event, EventCandidate, EventType, ObserveEventsOptions, Precondition, ReadEventsOptions, };
14
- export { Client, Container, isEventQlQueryTrue, isSubjectOnEventId, isSubjectPopulated, isSubjectPristine, };
15
+ export { Client, Container, HeartbeatTimeoutError, isEventQlQueryTrue, isSubjectOnEventId, isSubjectPopulated, isSubjectPristine, };
package/dist/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { Client } from './Client.js';
2
2
  import { Container } from './Container.js';
3
+ import { HeartbeatTimeoutError } from './HeartbeatTimeoutError.js';
3
4
  import { isEventQlQueryTrue } from './isEventQlQueryTrue.js';
4
5
  import { isSubjectOnEventId } from './isSubjectOnEventId.js';
5
6
  import { isSubjectPopulated } from './isSubjectPopulated.js';
6
7
  import { isSubjectPristine } from './isSubjectPristine.js';
7
- export { Client, Container, isEventQlQueryTrue, isSubjectOnEventId, isSubjectPopulated, isSubjectPristine, };
8
+ export { Client, Container, HeartbeatTimeoutError, isEventQlQueryTrue, isSubjectOnEventId, isSubjectPopulated, isSubjectPristine, };
@@ -1,2 +1,2 @@
1
- declare const readNdJsonStream: (stream: ReadableStream<Uint8Array>, signal: AbortSignal) => AsyncGenerator<Record<string, unknown>, void, void>;
1
+ declare const readNdJsonStream: (stream: ReadableStream<Uint8Array>, signal: AbortSignal, heartbeatTimeoutInMilliseconds?: number) => AsyncGenerator<Record<string, unknown>, void, void>;
2
2
  export { readNdJsonStream };
@@ -1,7 +1,27 @@
1
- const readNdJsonStream = async function* (stream, signal) {
1
+ import { HeartbeatTimeoutError } from '../HeartbeatTimeoutError.js';
2
+ const readNdJsonStream = async function* (stream, signal, heartbeatTimeoutInMilliseconds) {
2
3
  const reader = stream.getReader();
3
4
  const decoder = new TextDecoder('utf-8');
4
5
  let buffer = '';
6
+ let heartbeatTimer;
7
+ let hasHeartbeatTimedOut = false;
8
+ // The timer only runs while waiting for the next line, not while the caller
9
+ // handles a line, so a slow caller does not cause a heartbeat timeout.
10
+ const startHeartbeatTimer = () => {
11
+ if (heartbeatTimeoutInMilliseconds === undefined || heartbeatTimer !== undefined) {
12
+ return;
13
+ }
14
+ heartbeatTimer = setTimeout(() => {
15
+ hasHeartbeatTimedOut = true;
16
+ reader.cancel().catch(() => {
17
+ // Intentionally left blank.
18
+ });
19
+ }, heartbeatTimeoutInMilliseconds);
20
+ };
21
+ const stopHeartbeatTimer = () => {
22
+ clearTimeout(heartbeatTimer);
23
+ heartbeatTimer = undefined;
24
+ };
5
25
  const onAbort = () => {
6
26
  reader.cancel().catch(() => {
7
27
  // Intentionally left blank.
@@ -16,6 +36,7 @@ const readNdJsonStream = async function* (stream, signal) {
16
36
  signal.addEventListener('abort', onAbort);
17
37
  try {
18
38
  while (!signal.aborted) {
39
+ startHeartbeatTimer();
19
40
  // biome-ignore lint/performance/noAwaitInLoops: Awaiting the result is fine here, although we are in a loop.
20
41
  const { done, value } = await reader.read();
21
42
  if (done) {
@@ -27,13 +48,18 @@ const readNdJsonStream = async function* (stream, signal) {
27
48
  const line = buffer.slice(0, index).trim();
28
49
  buffer = buffer.slice(index + 1);
29
50
  if (line) {
51
+ stopHeartbeatTimer();
30
52
  yield JSON.parse(line);
31
53
  }
32
54
  index = buffer.indexOf('\n');
33
55
  }
34
56
  }
57
+ if (hasHeartbeatTimedOut) {
58
+ throw new HeartbeatTimeoutError();
59
+ }
35
60
  }
36
61
  finally {
62
+ stopHeartbeatTimer();
37
63
  signal.removeEventListener('abort', onAbort);
38
64
  await reader.cancel().catch(() => {
39
65
  // Intentionally left blank.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eventsourcingdb",
3
- "version": "1.8.10",
3
+ "version": "1.9.0",
4
4
  "description": "The official JavaScript client SDK for EventSourcingDB.",
5
5
  "author": {
6
6
  "name": "the native web GmbH",