@outputai/core 0.7.1-next.de30052.0 → 0.8.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 (77) hide show
  1. package/bin/worker.sh +6 -0
  2. package/package.json +1 -1
  3. package/src/consts.js +0 -4
  4. package/src/errors.js +6 -2
  5. package/src/interface/evaluator.js +7 -20
  6. package/src/interface/evaluator.spec.js +117 -1
  7. package/src/interface/step.js +8 -9
  8. package/src/interface/step.spec.js +124 -0
  9. package/src/interface/validations/index.js +108 -0
  10. package/src/interface/validations/index.spec.js +182 -0
  11. package/src/interface/validations/schemas.js +113 -0
  12. package/src/interface/validations/schemas.spec.js +209 -0
  13. package/src/interface/webhook.js +1 -1
  14. package/src/interface/webhook.spec.js +1 -1
  15. package/src/interface/workflow.d.ts +10 -9
  16. package/src/interface/workflow.js +76 -164
  17. package/src/interface/workflow.spec.js +637 -521
  18. package/src/interface/workflow_activity_options.js +16 -0
  19. package/src/interface/workflow_utils.js +1 -1
  20. package/src/interface/zod_integration.spec.js +2 -2
  21. package/src/internal_utils/aggregations.js +0 -10
  22. package/src/internal_utils/aggregations.spec.js +1 -48
  23. package/src/internal_utils/errors.js +14 -8
  24. package/src/internal_utils/errors.spec.js +73 -27
  25. package/src/utils/index.d.ts +19 -0
  26. package/src/utils/utils.js +53 -0
  27. package/src/utils/utils.spec.js +105 -1
  28. package/src/worker/bundle.js +26 -0
  29. package/src/worker/bundle.spec.js +53 -0
  30. package/src/worker/bundler_options.js +1 -1
  31. package/src/worker/bundler_options.spec.js +1 -1
  32. package/src/worker/catalog_workflow/catalog_job.js +148 -0
  33. package/src/worker/catalog_workflow/catalog_job.spec.js +232 -0
  34. package/src/worker/check.js +24 -0
  35. package/src/worker/connection_monitor.js +112 -0
  36. package/src/worker/connection_monitor.spec.js +199 -0
  37. package/src/worker/index.js +146 -41
  38. package/src/worker/index.spec.js +281 -109
  39. package/src/worker/interceptors/activity.js +7 -24
  40. package/src/worker/interceptors/activity.spec.js +97 -66
  41. package/src/worker/interceptors/index.js +4 -7
  42. package/src/worker/interceptors/modules.js +15 -0
  43. package/src/worker/interceptors/workflow.js +6 -8
  44. package/src/worker/interceptors/workflow.spec.js +49 -42
  45. package/src/worker/interruption.js +33 -0
  46. package/src/worker/interruption.spec.js +98 -0
  47. package/src/worker/loader/activities.js +75 -0
  48. package/src/worker/loader/activities.spec.js +213 -0
  49. package/src/worker/loader/hooks.js +28 -0
  50. package/src/worker/loader/hooks.spec.js +64 -0
  51. package/src/worker/loader/matchers.js +46 -0
  52. package/src/worker/loader/matchers.spec.js +140 -0
  53. package/src/worker/{loader_tools.js → loader/tools.js} +19 -67
  54. package/src/worker/{loader_tools.spec.js → loader/tools.spec.js} +53 -85
  55. package/src/worker/loader/workflows.js +82 -0
  56. package/src/worker/loader/workflows.spec.js +256 -0
  57. package/src/worker/{setup_telemetry.js → telemetry.js} +9 -4
  58. package/src/worker/{setup_telemetry.spec.js → telemetry.spec.js} +3 -3
  59. package/src/worker/webpack_loaders/workflow_rewriter/collect_target_imports.js +5 -109
  60. package/src/worker/webpack_loaders/workflow_rewriter/collect_target_imports.spec.js +31 -103
  61. package/src/worker/webpack_loaders/workflow_rewriter/index.mjs +5 -6
  62. package/src/worker/webpack_loaders/workflow_rewriter/index.spec.js +11 -83
  63. package/src/worker/webpack_loaders/workflow_rewriter/rewrite_fn_bodies.js +8 -11
  64. package/src/worker/webpack_loaders/workflow_rewriter/rewrite_fn_bodies.spec.js +9 -9
  65. package/src/interface/validations/runtime.js +0 -20
  66. package/src/interface/validations/runtime.spec.js +0 -29
  67. package/src/interface/validations/schema_utils.js +0 -8
  68. package/src/interface/validations/schema_utils.spec.js +0 -67
  69. package/src/interface/validations/static.js +0 -137
  70. package/src/interface/validations/static.spec.js +0 -397
  71. package/src/interface/workflow.replay_compatibility.spec.js +0 -254
  72. package/src/worker/loader.js +0 -202
  73. package/src/worker/loader.spec.js +0 -498
  74. package/src/worker/shutdown.js +0 -26
  75. package/src/worker/shutdown.spec.js +0 -82
  76. package/src/worker/start_catalog.js +0 -96
  77. package/src/worker/start_catalog.spec.js +0 -179
@@ -0,0 +1,148 @@
1
+ import { Client, WorkflowNotFoundError } from '@temporalio/client';
2
+ import { WorkflowExecutionAlreadyStartedError, WorkflowIdConflictPolicy } from '@temporalio/common';
3
+ import { WORKFLOW_CATALOG } from '#consts';
4
+ import { catalogId, taskQueue } from '../configs.js';
5
+ import { createChildLogger } from '#logger';
6
+ import { CancellablePromise } from '#utils';
7
+
8
+ const log = createChildLogger( 'Catalog' );
9
+
10
+ class CancellationError extends Error {};
11
+
12
+ // Note, functions don't log on "WorkflowNotFound" errors,
13
+ // because they happen when the catalog is not running at all.
14
+
15
+ /** Make sure the latest version of the catalog workflow is running. Stateful. */
16
+ export class CatalogJob {
17
+ #cancellation = new CancellablePromise();
18
+ #connection = null;
19
+ #namespace = null;
20
+ #catalog = null;
21
+ #catalogHash = null;
22
+
23
+ #running = false;
24
+ #executePromise = null;
25
+ #onErrorCb = null;
26
+ #error = null;
27
+
28
+ #runCancellable = promise => Promise
29
+ .race( [ promise, this.#cancellation.promise.then( () => {
30
+ throw new CancellationError();
31
+ } ) ] );
32
+
33
+ /** Check if the currently running catalog has the same hash as instance. */
34
+ async #checkCatalogIsTheSame( handle ) {
35
+ log.info( 'Checking running catalog hash against worker hash...' );
36
+ return this.#runCancellable( handle.query( 'get_hash' ) ).then( h => h === this.#catalogHash ).catch( e => {
37
+ if ( e instanceof CancellationError ) {
38
+ throw e;
39
+ }
40
+ if ( !( e instanceof WorkflowNotFoundError ) ) {
41
+ log.warn( 'Error retrieving catalog hash', { error: e } );
42
+ }
43
+ return false;
44
+ } );
45
+ };
46
+
47
+ /** Check if the catalog workflow is running. */
48
+ async #checkCatalogRunning( handle ) {
49
+ log.info( 'Checking if the catalog workflow is running...' );
50
+ return this.#runCancellable( handle.describe() ).then( d => !d.closeTime ).catch( e => {
51
+ if ( e instanceof CancellationError ) {
52
+ throw e;
53
+ }
54
+ if ( !( e instanceof WorkflowNotFoundError ) ) {
55
+ log.warn( 'Error describing catalog workflow', { error: e } );
56
+ }
57
+ return false;
58
+ } );
59
+ };
60
+
61
+ /** Complete previous running catalog workflow. */
62
+ async #completePreviousCatalog( handle ) {
63
+ log.info( 'Completing previous catalog workflow...' );
64
+ return this.#runCancellable( handle.executeUpdate( 'complete', { args: [] } ) ).catch( e => {
65
+ if ( e instanceof CancellationError ) {
66
+ throw e;
67
+ }
68
+ if ( !( e instanceof WorkflowNotFoundError ) ) {
69
+ log.warn( 'Error completing previous catalog workflow', { error: e } );
70
+ }
71
+ } );
72
+ };
73
+
74
+ /** Run the sequence to start the catalog */
75
+ async #execute() {
76
+ const client = new Client( { connection: this.#connection, namespace: this.#namespace } );
77
+ const handle = client.workflow.getHandle( catalogId );
78
+
79
+ if ( await this.#checkCatalogRunning( handle ) ) {
80
+ if ( await this.#checkCatalogIsTheSame( handle ) ) {
81
+ log.info( 'Current catalog workflow hash matches worker, restart skipped' );
82
+ return;
83
+ }
84
+ await this.#completePreviousCatalog( handle );
85
+ }
86
+
87
+ const startArguments = {
88
+ taskQueue,
89
+ workflowId: catalogId,
90
+ workflowIdConflictPolicy: WorkflowIdConflictPolicy.FAIL,
91
+ args: [ this.#catalog, this.#catalogHash ]
92
+ };
93
+
94
+ log.info( 'Starting catalog workflow...' );
95
+ try {
96
+ await this.#runCancellable( client.workflow.start( WORKFLOW_CATALOG, startArguments ) );
97
+ } catch ( error ) {
98
+ // if the error was caused by the catalog existing and its hash is the same as the one from the worker, just ignore the error
99
+ if ( error instanceof WorkflowExecutionAlreadyStartedError && await this.#checkCatalogIsTheSame( handle ) ) {
100
+ log.info( 'Ignoring start error: it failed because execution already started but catalog hash matches worker' );
101
+ } else {
102
+ throw error;
103
+ }
104
+ }
105
+ log.info( 'Startup completed' );
106
+ }
107
+
108
+ constructor( { connection, namespace, catalog, catalogHash } ) {
109
+ this.#connection = connection;
110
+ this.#namespace = namespace;
111
+ this.#catalog = catalog;
112
+ this.#catalogHash = catalogHash;
113
+ }
114
+
115
+ onError( cb ) {
116
+ this.#onErrorCb = cb;
117
+ }
118
+
119
+ get running() {
120
+ return this.#running;
121
+ }
122
+
123
+ interrupt() {
124
+ if ( this.#running ) {
125
+ this.#cancellation.complete();
126
+ }
127
+ return this.#executePromise ?? Promise.resolve();
128
+ }
129
+
130
+ get error() {
131
+ return this.#error;
132
+ }
133
+
134
+ async run() {
135
+ this.#running = true;
136
+ this.#executePromise = this.#execute()
137
+ .catch( error => {
138
+ if ( !( error instanceof CancellationError ) ) {
139
+ this.#error = error;
140
+ this.#onErrorCb?.( error );
141
+ }
142
+ } )
143
+ .finally( () => {
144
+ this.#running = false;
145
+ } );
146
+ return this.#executePromise;
147
+ }
148
+ };
@@ -0,0 +1,232 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { WorkflowNotFoundError } from '@temporalio/client';
3
+ import { WorkflowExecutionAlreadyStartedError, WorkflowIdConflictPolicy } from '@temporalio/common';
4
+ import { CatalogJob } from './catalog_job.js';
5
+
6
+ const {
7
+ catalogId,
8
+ describeMock,
9
+ executeUpdateMock,
10
+ mockLog,
11
+ queryMock,
12
+ taskQueue,
13
+ workflowStartMock
14
+ } = vi.hoisted( () => ( {
15
+ catalogId: 'test-catalog',
16
+ describeMock: vi.fn(),
17
+ executeUpdateMock: vi.fn(),
18
+ mockLog: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
19
+ queryMock: vi.fn(),
20
+ taskQueue: 'test-queue',
21
+ workflowStartMock: vi.fn()
22
+ } ) );
23
+
24
+ vi.mock( '#logger', () => ( { createChildLogger: () => mockLog } ) );
25
+ vi.mock( '#consts', () => ( { WORKFLOW_CATALOG: 'catalog' } ) );
26
+ vi.mock( '../configs.js', () => ( { catalogId, taskQueue } ) );
27
+ vi.mock( '@temporalio/client', async importOriginal => {
28
+ const actual = await importOriginal();
29
+ return {
30
+ ...actual,
31
+ Client: vi.fn().mockImplementation( function () {
32
+ return {
33
+ workflow: {
34
+ start: workflowStartMock,
35
+ getHandle: () => ( { describe: describeMock, query: queryMock, executeUpdate: executeUpdateMock } )
36
+ }
37
+ };
38
+ } )
39
+ };
40
+ } );
41
+
42
+ const mockConnection = {};
43
+ const namespace = 'default';
44
+ const catalog = { workflows: [], activities: {} };
45
+ const catalogHash = 'catalog-hash';
46
+
47
+ const createJob = () => new CatalogJob( { connection: mockConnection, namespace, catalog, catalogHash } );
48
+
49
+ const flushPromises = async () => Array
50
+ .from( { length: 10 } )
51
+ .reduce( promise => promise.then( () => Promise.resolve() ), Promise.resolve() );
52
+
53
+ describe( 'CatalogJob', () => {
54
+ beforeEach( () => {
55
+ vi.clearAllMocks();
56
+ describeMock.mockResolvedValue( { closeTime: '2024-01-01T00:00:00Z' } );
57
+ executeUpdateMock.mockResolvedValue( undefined );
58
+ queryMock.mockResolvedValue( catalogHash );
59
+ workflowStartMock.mockResolvedValue( undefined );
60
+ } );
61
+
62
+ it( 'completes a previous running stale catalog before starting the new workflow', async () => {
63
+ describeMock.mockResolvedValue( { closeTime: undefined } );
64
+ queryMock.mockResolvedValue( 'old-catalog-hash' );
65
+ const job = createJob();
66
+
67
+ await job.run();
68
+
69
+ expect( describeMock ).toHaveBeenCalled();
70
+ expect( queryMock ).toHaveBeenCalledWith( 'get_hash' );
71
+ expect( executeUpdateMock ).toHaveBeenCalledWith( 'complete', { args: [] } );
72
+ expect( workflowStartMock ).toHaveBeenCalledWith( 'catalog', {
73
+ taskQueue,
74
+ workflowId: catalogId,
75
+ workflowIdConflictPolicy: WorkflowIdConflictPolicy.FAIL,
76
+ args: [ catalog, catalogHash ]
77
+ } );
78
+ expect( job.error ).toBeNull();
79
+ expect( job.running ).toBe( false );
80
+ } );
81
+
82
+ it( 'keeps the existing catalog workflow when the running hash matches', async () => {
83
+ describeMock.mockResolvedValue( { closeTime: undefined } );
84
+ queryMock.mockResolvedValue( catalogHash );
85
+ const job = createJob();
86
+
87
+ await job.run();
88
+
89
+ expect( queryMock ).toHaveBeenCalledWith( 'get_hash' );
90
+ expect( executeUpdateMock ).not.toHaveBeenCalled();
91
+ expect( workflowStartMock ).not.toHaveBeenCalled();
92
+ expect( mockLog.info ).toHaveBeenCalledWith( 'Current catalog workflow hash matches worker, restart skipped' );
93
+ expect( job.error ).toBeNull();
94
+ } );
95
+
96
+ it( 'starts the catalog workflow when no previous catalog exists', async () => {
97
+ describeMock.mockRejectedValue( new WorkflowNotFoundError( 'not found' ) );
98
+ const job = createJob();
99
+
100
+ await job.run();
101
+
102
+ expect( describeMock ).toHaveBeenCalled();
103
+ expect( queryMock ).not.toHaveBeenCalled();
104
+ expect( executeUpdateMock ).not.toHaveBeenCalled();
105
+ expect( workflowStartMock ).toHaveBeenCalledWith( 'catalog', {
106
+ taskQueue,
107
+ workflowId: catalogId,
108
+ workflowIdConflictPolicy: WorkflowIdConflictPolicy.FAIL,
109
+ args: [ catalog, catalogHash ]
110
+ } );
111
+ expect( mockLog.warn ).not.toHaveBeenCalled();
112
+ } );
113
+
114
+ it( 'starts the catalog workflow when the previous catalog is closed', async () => {
115
+ const job = createJob();
116
+
117
+ await job.run();
118
+
119
+ expect( describeMock ).toHaveBeenCalled();
120
+ expect( queryMock ).not.toHaveBeenCalled();
121
+ expect( executeUpdateMock ).not.toHaveBeenCalled();
122
+ expect( workflowStartMock ).toHaveBeenCalledWith( 'catalog', {
123
+ taskQueue,
124
+ workflowId: catalogId,
125
+ workflowIdConflictPolicy: WorkflowIdConflictPolicy.FAIL,
126
+ args: [ catalog, catalogHash ]
127
+ } );
128
+ } );
129
+
130
+ it( 'warns and continues when describing or completing the previous catalog fails', async () => {
131
+ const completeError = new Error( 'complete failed' );
132
+ describeMock.mockResolvedValue( { closeTime: undefined } );
133
+ queryMock.mockResolvedValue( 'old-catalog-hash' );
134
+ executeUpdateMock.mockRejectedValue( completeError );
135
+ const job = createJob();
136
+
137
+ await job.run();
138
+
139
+ expect( mockLog.warn ).toHaveBeenCalledWith( 'Error completing previous catalog workflow', { error: completeError } );
140
+ expect( workflowStartMock ).toHaveBeenCalledWith( 'catalog', expect.any( Object ) );
141
+ expect( job.error ).toBeNull();
142
+ } );
143
+
144
+ it( 'ignores an already-started error when the running catalog hash matches', async () => {
145
+ const alreadyStartedError = new WorkflowExecutionAlreadyStartedError( 'already started', catalogId, 'catalog' );
146
+ describeMock.mockRejectedValue( new WorkflowNotFoundError( 'not found' ) );
147
+ workflowStartMock.mockRejectedValue( alreadyStartedError );
148
+ queryMock.mockResolvedValue( catalogHash );
149
+ const job = createJob();
150
+
151
+ await job.run();
152
+
153
+ expect( workflowStartMock ).toHaveBeenCalledWith( 'catalog', expect.any( Object ) );
154
+ expect( queryMock ).toHaveBeenCalledWith( 'get_hash' );
155
+ expect( mockLog.info ).toHaveBeenCalledWith(
156
+ 'Ignoring start error: it failed because execution already started but catalog hash matches worker'
157
+ );
158
+ expect( job.error ).toBeNull();
159
+ } );
160
+
161
+ it( 'stores start errors and calls the error callback', async () => {
162
+ const error = new Error( 'start failed' );
163
+ const onError = vi.fn();
164
+ workflowStartMock.mockRejectedValue( error );
165
+ const job = createJob();
166
+
167
+ job.onError( onError );
168
+ await job.run();
169
+
170
+ expect( job.error ).toBe( error );
171
+ expect( onError ).toHaveBeenCalledWith( error );
172
+ expect( job.running ).toBe( false );
173
+ } );
174
+
175
+ it( 'stores stale already-started errors and calls the error callback', async () => {
176
+ const alreadyStartedError = new WorkflowExecutionAlreadyStartedError( 'already started', catalogId, 'catalog' );
177
+ const onError = vi.fn();
178
+ describeMock.mockRejectedValue( new WorkflowNotFoundError( 'not found' ) );
179
+ workflowStartMock.mockRejectedValue( alreadyStartedError );
180
+ queryMock.mockResolvedValue( 'old-catalog-hash' );
181
+ const job = createJob();
182
+
183
+ job.onError( onError );
184
+ await job.run();
185
+
186
+ expect( job.error ).toBe( alreadyStartedError );
187
+ expect( onError ).toHaveBeenCalledWith( alreadyStartedError );
188
+ expect( job.running ).toBe( false );
189
+ } );
190
+
191
+ it( 'reports running while a catalog call is pending', async () => {
192
+ describeMock.mockReturnValue( new Promise( () => {} ) );
193
+ const job = createJob();
194
+
195
+ const run = job.run();
196
+ await flushPromises();
197
+
198
+ expect( job.running ).toBe( true );
199
+
200
+ await job.interrupt();
201
+ await run;
202
+
203
+ expect( job.running ).toBe( false );
204
+ expect( job.error ).toBeNull();
205
+ } );
206
+
207
+ it( 'interrupts pending catalog calls without storing an error or calling the callback', async () => {
208
+ const onError = vi.fn();
209
+ describeMock.mockReturnValue( new Promise( () => {} ) );
210
+ const job = createJob();
211
+
212
+ job.onError( onError );
213
+ const run = job.run();
214
+ await flushPromises();
215
+
216
+ const interrupt = job.interrupt();
217
+
218
+ await expect( interrupt ).resolves.toBeUndefined();
219
+ await expect( run ).resolves.toBeUndefined();
220
+ expect( job.error ).toBeNull();
221
+ expect( onError ).not.toHaveBeenCalled();
222
+ expect( job.running ).toBe( false );
223
+ } );
224
+
225
+ it( 'returns a resolved promise when interrupted before running', async () => {
226
+ const job = createJob();
227
+
228
+ await expect( job.interrupt() ).resolves.toBeUndefined();
229
+ expect( job.running ).toBe( false );
230
+ expect( job.error ).toBeNull();
231
+ } );
232
+ } );
@@ -0,0 +1,24 @@
1
+ import { bundleWorkflows } from './bundle.js';
2
+
3
+ // `output-worker --check` entry: bundle the workflows exactly as the worker would, then
4
+ // exit 0 (ok) / 1 (fail). The exit code is the signal. Workflow-discovery logs write to
5
+ // stdout via the worker logger, so mute stdout to keep output clean for CI/tooling; only
6
+ // real errors surface, on stderr. Catches bad workflow imports (e.g. `node:` built-ins)
7
+ // at build/CI time instead of crash-looping the worker at startup.
8
+ const callerDir = process.argv[2] ?? process.cwd();
9
+
10
+ process.stdout.write = ( ...args ) => {
11
+ const callback = args.find( arg => typeof arg === 'function' );
12
+ if ( callback ) {
13
+ callback();
14
+ }
15
+ return true;
16
+ };
17
+
18
+ ( async () => {
19
+ await bundleWorkflows( callerDir );
20
+ process.exit( 0 );
21
+ } )().catch( error => {
22
+ console.error( error );
23
+ process.exit( 1 );
24
+ } );
@@ -0,0 +1,112 @@
1
+ import { createChildLogger } from '#logger';
2
+ import { setTimeout as delay } from 'node:timers/promises';
3
+ import { CancellablePromise } from '#utils';
4
+
5
+ const ServingStatus = {
6
+ UNKNOWN: 0,
7
+ SERVING: 1,
8
+ NOT_SERVING: 2,
9
+ SERVICE_UNKNOWN: 3
10
+ };
11
+
12
+ const log = createChildLogger( 'Connection' );
13
+
14
+ export class TemporalConnectionMonitor {
15
+ #MAX_FAILURES = 3;
16
+ #CHECK_INTERVAL_MS = 60_000;
17
+ #CHECK_TIMEOUT_MS = 5_000;
18
+
19
+ #cancellation = new CancellablePromise();
20
+ #failures = 0;
21
+ #error = null;
22
+ #running = false;
23
+ #watchPromise = null;
24
+ #connection = null;
25
+ #connectionLostCb = null;
26
+
27
+ #getTimeout = async () => delay( this.#CHECK_TIMEOUT_MS, 0, { ref: false } ).then( () => {
28
+ throw new Error( 'Connection health check timed out' );
29
+ } );
30
+
31
+ #healthcheck = async () => this.#connection.healthService.check( {} );
32
+
33
+ #sleep = async () => delay( this.#CHECK_INTERVAL_MS, 0, { ref: false } );
34
+
35
+ #watch = async () => {
36
+ while ( !this.#cancellation.completed ) {
37
+ try {
38
+ const health = await Promise.race( [ this.#healthcheck(), this.#getTimeout(), this.#cancellation.promise ] );
39
+
40
+ // cancellation won the race
41
+ if ( this.#cancellation.completed ) {
42
+ break;
43
+ }
44
+
45
+ if ( health?.status !== ServingStatus.SERVING ) {
46
+ throw new Error( `Connection not serving (status ${health?.status})` );
47
+ }
48
+
49
+ log.info( this.#failures === 0 ? 'Healthy' : 'Recovered' );
50
+ this.#failures = 0;
51
+ } catch ( error ) {
52
+ // cancellation will ignore warnings and not throw errors;
53
+ if ( this.#cancellation.completed ) {
54
+ break;
55
+ }
56
+
57
+ if ( ++this.#failures >= this.#MAX_FAILURES ) {
58
+ log.warn( 'Connection lost', { error: error.message, failures: this.#failures } );
59
+ this.#error = error;
60
+ this.#connectionLostCb?.( error );
61
+ this.#cancellation.complete();
62
+ break;
63
+ } else {
64
+ log.warn( 'Connection unhealthy', { error: error.message, failures: this.#failures } );
65
+ }
66
+ }
67
+
68
+ await Promise.race( [ this.#sleep(), this.#cancellation.promise ] );
69
+ }
70
+ };
71
+
72
+ constructor( connection, overrides = {} ) {
73
+ this.#connection = connection;
74
+ if ( Number.isFinite( overrides?.maxFailures ) ) {
75
+ this.#MAX_FAILURES = overrides.maxFailures;
76
+ }
77
+ if ( Number.isFinite( overrides?.checkIntervalMs ) ) {
78
+ this.#CHECK_INTERVAL_MS = overrides.checkIntervalMs;
79
+ }
80
+ if ( Number.isFinite( overrides?.checkTimeoutMs ) ) {
81
+ this.#CHECK_TIMEOUT_MS = overrides.checkTimeoutMs;
82
+ }
83
+ }
84
+
85
+ onConnectionLost( cb ) {
86
+ this.#connectionLostCb = cb;
87
+ }
88
+
89
+ get running() {
90
+ return this.#running;
91
+ }
92
+
93
+ start() {
94
+ if ( this.#watchPromise ) {
95
+ return this.#watchPromise;
96
+ }
97
+ this.#running = true;
98
+ this.#watchPromise = this.#watch().finally( () => {
99
+ this.#running = false;
100
+ } );
101
+ return this.#watchPromise;
102
+ }
103
+
104
+ stop() {
105
+ this.#cancellation.complete();
106
+ return this.#watchPromise ?? Promise.resolve();
107
+ }
108
+
109
+ get connectionLossError() {
110
+ return this.#error;
111
+ }
112
+ };