@powersync/service-core 1.27.1 → 1.28.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/CHANGELOG.md +32 -0
- package/LICENSE +201 -67
- package/dist/api/RouteAPI.d.ts +6 -0
- package/dist/api/api-metrics.js +4 -0
- package/dist/api/api-metrics.js.map +1 -1
- package/dist/api/diagnostics.d.ts +5 -0
- package/dist/api/diagnostics.js +17 -2
- package/dist/api/diagnostics.js.map +1 -1
- package/dist/metrics/connection-metrics.d.ts +28 -0
- package/dist/metrics/connection-metrics.js +55 -0
- package/dist/metrics/connection-metrics.js.map +1 -0
- package/dist/metrics/metrics-index.d.ts +1 -0
- package/dist/metrics/metrics-index.js +1 -0
- package/dist/metrics/metrics-index.js.map +1 -1
- package/dist/metrics/metrics-interfaces.d.ts +7 -1
- package/dist/metrics/metrics-interfaces.js.map +1 -1
- package/dist/modules/loader.js +8 -2
- package/dist/modules/loader.js.map +1 -1
- package/dist/replication/AbstractReplicationJob.d.ts +2 -0
- package/dist/replication/AbstractReplicationJob.js +17 -1
- package/dist/replication/AbstractReplicationJob.js.map +1 -1
- package/dist/replication/AbstractReplicator.d.ts +8 -18
- package/dist/replication/AbstractReplicator.js +63 -64
- package/dist/replication/AbstractReplicator.js.map +1 -1
- package/dist/replication/ReplicationModule.js +0 -6
- package/dist/replication/ReplicationModule.js.map +1 -1
- package/dist/routes/configure-fastify.js +5 -1
- package/dist/routes/configure-fastify.js.map +1 -1
- package/dist/routes/endpoints/admin.js +24 -22
- package/dist/routes/endpoints/admin.js.map +1 -1
- package/dist/routes/endpoints/socket-route.js +19 -27
- package/dist/routes/endpoints/socket-route.js.map +1 -1
- package/dist/routes/endpoints/sync-rules.js +17 -40
- package/dist/routes/endpoints/sync-rules.js.map +1 -1
- package/dist/routes/endpoints/sync-stream.js +25 -23
- package/dist/routes/endpoints/sync-stream.js.map +1 -1
- package/dist/routes/hooks.d.ts +1 -1
- package/dist/routes/hooks.js +2 -1
- package/dist/routes/hooks.js.map +1 -1
- package/dist/routes/sync-connection.d.ts +7 -0
- package/dist/routes/sync-connection.js +34 -0
- package/dist/routes/sync-connection.js.map +1 -0
- package/dist/storage/BucketStorageFactory.d.ts +30 -22
- package/dist/storage/BucketStorageFactory.js +14 -14
- package/dist/storage/BucketStorageFactory.js.map +1 -1
- package/dist/storage/PersistedSyncConfigContent.js +2 -2
- package/dist/storage/PersistedSyncConfigContent.js.map +1 -1
- package/dist/storage/SyncRulesBucketStorage.d.ts +23 -2
- package/dist/storage/SyncRulesBucketStorage.js.map +1 -1
- package/dist/streams/BroadcastIterable.js +8 -7
- package/dist/streams/BroadcastIterable.js.map +1 -1
- package/dist/streams/LastValueSink.d.ts +2 -2
- package/dist/streams/LastValueSink.js +8 -13
- package/dist/streams/LastValueSink.js.map +1 -1
- package/dist/streams/merge.d.ts +15 -31
- package/dist/streams/merge.js +136 -152
- package/dist/streams/merge.js.map +1 -1
- package/dist/sync/BucketChecksumState.d.ts +3 -2
- package/dist/sync/BucketChecksumState.js +12 -5
- package/dist/sync/BucketChecksumState.js.map +1 -1
- package/dist/sync/sync.js +48 -38
- package/dist/sync/sync.js.map +1 -1
- package/dist/sync/util.d.ts +1 -2
- package/dist/sync/util.js +2 -5
- package/dist/sync/util.js.map +1 -1
- package/dist/system/ServiceContext.d.ts +7 -0
- package/dist/system/ServiceContext.js +3 -3
- package/dist/system/ServiceContext.js.map +1 -1
- package/dist/system/SyncConfigEngine.d.ts +135 -0
- package/dist/system/SyncConfigEngine.js +186 -0
- package/dist/system/SyncConfigEngine.js.map +1 -0
- package/dist/system/system-index.d.ts +1 -0
- package/dist/system/system-index.js +1 -0
- package/dist/system/system-index.js.map +1 -1
- package/dist/util/config/types.d.ts +4 -0
- package/package.json +8 -9
- package/src/api/RouteAPI.ts +6 -0
- package/src/api/api-metrics.ts +5 -0
- package/src/api/diagnostics.ts +22 -2
- package/src/metrics/connection-metrics.ts +75 -0
- package/src/metrics/metrics-index.ts +1 -0
- package/src/metrics/metrics-interfaces.ts +8 -1
- package/src/modules/loader.ts +16 -2
- package/src/replication/AbstractReplicationJob.ts +17 -1
- package/src/replication/AbstractReplicator.ts +83 -81
- package/src/replication/ReplicationModule.ts +0 -5
- package/src/routes/configure-fastify.ts +7 -1
- package/src/routes/endpoints/admin.ts +27 -20
- package/src/routes/endpoints/socket-route.ts +26 -38
- package/src/routes/endpoints/sync-rules.ts +22 -46
- package/src/routes/endpoints/sync-stream.ts +31 -27
- package/src/routes/hooks.ts +5 -1
- package/src/routes/sync-connection.ts +39 -0
- package/src/storage/BucketStorageFactory.ts +45 -29
- package/src/storage/PersistedSyncConfigContent.ts +2 -2
- package/src/storage/SyncRulesBucketStorage.ts +23 -2
- package/src/streams/BroadcastIterable.ts +8 -7
- package/src/streams/LastValueSink.ts +10 -16
- package/src/streams/merge.ts +149 -169
- package/src/sync/BucketChecksumState.ts +17 -5
- package/src/sync/sync.ts +71 -57
- package/src/sync/util.ts +2 -7
- package/src/system/ServiceContext.ts +8 -3
- package/src/system/SyncConfigEngine.ts +247 -0
- package/src/system/system-index.ts +1 -0
- package/src/util/config/types.ts +4 -0
- package/test/src/AbstractReplicator.test.ts +375 -45
- package/test/src/PersistedSyncConfigContent.test.ts +5 -5
- package/test/src/SyncConfigEngine.test.ts +208 -0
- package/test/src/broadcast_iterable.test.ts +59 -31
- package/test/src/config.test.ts +65 -3
- package/test/src/connection-metrics-export.test.ts +76 -0
- package/test/src/connection-metrics.test.ts +184 -0
- package/test/src/diagnostics.test.ts +3 -3
- package/test/src/last_value_sink.test.ts +34 -0
- package/test/src/merge_iterable.test.ts +304 -55
- package/test/src/module-loader.test.ts +14 -1
- package/test/src/recording-metrics.ts +36 -0
- package/test/src/routes/admin.test.ts +2 -2
- package/test/src/routes/concurrency-limit.test.ts +108 -0
- package/test/src/routes/connection-metrics.test.ts +269 -0
- package/test/src/routes/invalid-config.test.ts +184 -0
- package/test/src/routes/mocks.ts +26 -13
- package/test/src/routes/stream-disconnect.integration.test.ts +129 -0
- package/test/src/routes/stream.test.ts +200 -107
- package/test/src/routes/sync-rules.test.ts +19 -1
- package/test/src/source_table_config.test.ts +7 -7
- package/test/src/sync/BucketChecksumState.test.ts +10 -10
- package/test/src/sync/stream_response.test.ts +389 -0
- package/test/src/sync/util.test.ts +14 -3
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { ErrorCode, InternalServerError, ServiceError } from '@powersync/lib-services-framework';
|
|
2
|
+
import { APIMetric } from '@powersync/service-types';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
initializeCoreAPIMetrics,
|
|
7
|
+
recordSyncConnection,
|
|
8
|
+
SyncCloseReason,
|
|
9
|
+
syncConnectionCloseReasonLogText,
|
|
10
|
+
SyncTransport
|
|
11
|
+
} from '@/index.js';
|
|
12
|
+
import { recordingMetricsEngine } from './recording-metrics.js';
|
|
13
|
+
|
|
14
|
+
describe('recordSyncConnection', () => {
|
|
15
|
+
let recorder: ReturnType<typeof recordingMetricsEngine>;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
recorder = recordingMetricsEngine('connection-metrics-test');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(async () => {
|
|
22
|
+
await recorder.shutdown();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function seriesValue(attributes: Record<string, string>): Promise<number | undefined> {
|
|
26
|
+
return recorder.seriesValue(APIMetric.SYNC_CONNECTIONS, attributes);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
it.each([
|
|
30
|
+
[SyncCloseReason.ClientClosed, 'ok', SyncTransport.HttpStream],
|
|
31
|
+
[SyncCloseReason.ServiceClosed, 'ok', SyncTransport.HttpStream],
|
|
32
|
+
[SyncCloseReason.ProcessShutdown, 'ok', SyncTransport.HttpStream],
|
|
33
|
+
[SyncCloseReason.Unknown, 'ok', SyncTransport.HttpStream],
|
|
34
|
+
[SyncCloseReason.StreamError, 'error', SyncTransport.HttpStream],
|
|
35
|
+
[SyncCloseReason.ServiceUnavailable, 'rejected', SyncTransport.HttpStream],
|
|
36
|
+
[SyncCloseReason.NoSyncConfig, 'rejected', SyncTransport.HttpStream],
|
|
37
|
+
[SyncCloseReason.StorageError, 'rejected', SyncTransport.HttpStream],
|
|
38
|
+
[SyncCloseReason.SyncConfigError, 'rejected', SyncTransport.HttpStream],
|
|
39
|
+
[SyncCloseReason.ConcurrencyLimit, 'rejected', SyncTransport.RSocket]
|
|
40
|
+
] as const)('classifies %s as %s', async (closeReason, outcome, transport) => {
|
|
41
|
+
recordSyncConnection(recorder.engine, { transport, closeReason });
|
|
42
|
+
|
|
43
|
+
expect(await seriesValue({ close_reason: closeReason, outcome })).toBe(1);
|
|
44
|
+
expect(await seriesValue({})).toBe(1);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('creates an error-code series on its first failure without resetting it on initialization', async () => {
|
|
48
|
+
const labels = {
|
|
49
|
+
outcome: 'error',
|
|
50
|
+
close_reason: 'stream_error',
|
|
51
|
+
error_code: ErrorCode.PSYNC_S2403,
|
|
52
|
+
transport: 'http_stream'
|
|
53
|
+
};
|
|
54
|
+
expect(await seriesValue(labels)).toBeUndefined();
|
|
55
|
+
|
|
56
|
+
recordSyncConnection(recorder.engine, {
|
|
57
|
+
transport: SyncTransport.HttpStream,
|
|
58
|
+
closeReason: SyncCloseReason.StreamError,
|
|
59
|
+
error: new ServiceError(ErrorCode.PSYNC_S2403, 'Query timed out')
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
expect(await seriesValue(labels)).toBe(1);
|
|
63
|
+
initializeCoreAPIMetrics(recorder.engine);
|
|
64
|
+
expect(await seriesValue(labels)).toBe(1);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('does not seed connection series but still initializes the concurrent connection gauge', async () => {
|
|
68
|
+
expect(await seriesValue({})).toBeUndefined();
|
|
69
|
+
expect(await recorder.seriesValue(APIMetric.CONCURRENT_CONNECTIONS, {})).toBe(0);
|
|
70
|
+
|
|
71
|
+
initializeCoreAPIMetrics(recorder.engine);
|
|
72
|
+
|
|
73
|
+
expect(await seriesValue({})).toBeUndefined();
|
|
74
|
+
expect(await recorder.seriesValue(APIMetric.CONCURRENT_CONNECTIONS, {})).toBe(0);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('error_code', () => {
|
|
78
|
+
it('reports other for errors without a recognized PowerSync error code', async () => {
|
|
79
|
+
for (const error of [
|
|
80
|
+
new Error('boom'),
|
|
81
|
+
'not even an error',
|
|
82
|
+
{ is_service_error: true, errorData: { code: 'customer-specific-code' } }
|
|
83
|
+
]) {
|
|
84
|
+
recordSyncConnection(recorder.engine, {
|
|
85
|
+
transport: SyncTransport.RSocket,
|
|
86
|
+
closeReason: SyncCloseReason.StreamError,
|
|
87
|
+
error
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
expect(await seriesValue({ close_reason: 'stream_error', error_code: 'other' })).toBe(3);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('reports the PowerSync code from a service error', async () => {
|
|
95
|
+
recordSyncConnection(recorder.engine, {
|
|
96
|
+
transport: SyncTransport.RSocket,
|
|
97
|
+
closeReason: SyncCloseReason.StreamError,
|
|
98
|
+
error: new ServiceError({ status: 500, code: ErrorCode.PSYNC_S2305, description: 'x' })
|
|
99
|
+
});
|
|
100
|
+
recordSyncConnection(recorder.engine, {
|
|
101
|
+
transport: SyncTransport.RSocket,
|
|
102
|
+
closeReason: SyncCloseReason.StreamError,
|
|
103
|
+
error: new InternalServerError(new Error('boom'))
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
expect(await seriesValue({ close_reason: 'stream_error', error_code: ErrorCode.PSYNC_S2305 })).toBe(1);
|
|
107
|
+
expect(await seriesValue({ close_reason: 'stream_error', error_code: ErrorCode.PSYNC_S2001 })).toBe(1);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('reports other when a failure has no error code', async () => {
|
|
111
|
+
recordSyncConnection(recorder.engine, {
|
|
112
|
+
transport: SyncTransport.RSocket,
|
|
113
|
+
closeReason: SyncCloseReason.StreamError
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
expect(await seriesValue({ close_reason: 'stream_error', error_code: 'other' })).toBe(1);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('passes a PowerSync error code through', async () => {
|
|
120
|
+
recordSyncConnection(recorder.engine, {
|
|
121
|
+
transport: SyncTransport.RSocket,
|
|
122
|
+
closeReason: SyncCloseReason.NoSyncConfig,
|
|
123
|
+
error: new ServiceError({ status: 500, code: ErrorCode.PSYNC_S2302, description: 'x' })
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
expect(
|
|
127
|
+
await seriesValue({
|
|
128
|
+
outcome: 'rejected',
|
|
129
|
+
close_reason: 'no_sync_config',
|
|
130
|
+
error_code: ErrorCode.PSYNC_S2302,
|
|
131
|
+
transport: 'rsocket'
|
|
132
|
+
})
|
|
133
|
+
).toBe(1);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it.each([
|
|
137
|
+
SyncCloseReason.ClientClosed,
|
|
138
|
+
SyncCloseReason.ServiceClosed,
|
|
139
|
+
SyncCloseReason.ProcessShutdown,
|
|
140
|
+
SyncCloseReason.Unknown
|
|
141
|
+
])('omits error_code and counts %s once even when the handler collected an error', async (closeReason) => {
|
|
142
|
+
const add = vi.spyOn(recorder.engine.getCounter(APIMetric.SYNC_CONNECTIONS), 'add');
|
|
143
|
+
recordSyncConnection(recorder.engine, {
|
|
144
|
+
transport: SyncTransport.RSocket,
|
|
145
|
+
closeReason,
|
|
146
|
+
error: new InternalServerError(new Error('ignored'))
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
expect(add).toHaveBeenCalledExactlyOnceWith(1, {
|
|
150
|
+
outcome: 'ok',
|
|
151
|
+
close_reason: closeReason,
|
|
152
|
+
transport: 'rsocket'
|
|
153
|
+
});
|
|
154
|
+
expect(await seriesValue({})).toBe(1);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('accumulates independently for each transport', async () => {
|
|
159
|
+
for (let i = 0; i < 2; i++) {
|
|
160
|
+
recordSyncConnection(recorder.engine, {
|
|
161
|
+
transport: SyncTransport.HttpStream,
|
|
162
|
+
closeReason: SyncCloseReason.ClientClosed
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
recordSyncConnection(recorder.engine, {
|
|
166
|
+
transport: SyncTransport.RSocket,
|
|
167
|
+
closeReason: SyncCloseReason.ClientClosed
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const labels = { outcome: 'ok', close_reason: 'client_closed' };
|
|
171
|
+
expect(await seriesValue({ ...labels, transport: 'http_stream' })).toBe(2);
|
|
172
|
+
expect(await seriesValue({ ...labels, transport: 'rsocket' })).toBe(1);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('syncConnectionCloseReasonLogText', () => {
|
|
177
|
+
it('keeps the pre-existing log wording for handler close reasons', () => {
|
|
178
|
+
expect(syncConnectionCloseReasonLogText(SyncCloseReason.ClientClosed)).toBe('client closing stream');
|
|
179
|
+
expect(syncConnectionCloseReasonLogText(SyncCloseReason.ServiceClosed)).toBe('service closing stream');
|
|
180
|
+
expect(syncConnectionCloseReasonLogText(SyncCloseReason.ProcessShutdown)).toBe('process shutdown');
|
|
181
|
+
expect(syncConnectionCloseReasonLogText(SyncCloseReason.StreamError)).toBe('stream error');
|
|
182
|
+
expect(syncConnectionCloseReasonLogText(undefined)).toBe('unknown');
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { DiagnosticsOptions, getSyncRulesStatus } from '@/api/diagnostics.js';
|
|
2
2
|
import { RouteAPI, SlotWalBudgetInfo } from '@/api/RouteAPI.js';
|
|
3
3
|
import { ParsedSyncConfigSet, storage } from '@/index.js';
|
|
4
|
-
import {
|
|
4
|
+
import { parseSyncConfig } from '@powersync/service-sync-rules';
|
|
5
5
|
import { describe, expect, test } from 'vitest';
|
|
6
6
|
|
|
7
7
|
const GB = 1024 * 1024 * 1024;
|
|
@@ -40,7 +40,7 @@ function makeSyncRulesContent(overrides?: {
|
|
|
40
40
|
syncConfigState: status.state,
|
|
41
41
|
version_label: overrides?.version_label,
|
|
42
42
|
parsed(options?: any) {
|
|
43
|
-
const syncRules =
|
|
43
|
+
const syncRules = parseSyncConfig(MINIMAL_SYNC_RULES, {
|
|
44
44
|
...options,
|
|
45
45
|
defaultSchema: 'public'
|
|
46
46
|
});
|
|
@@ -217,7 +217,7 @@ streams:
|
|
|
217
217
|
`;
|
|
218
218
|
const content = makeSyncRulesContent();
|
|
219
219
|
content.parsed = (options?: any) => {
|
|
220
|
-
|
|
220
|
+
parseSyncConfig(yaml, { ...options, defaultSchema: 'public', throwOnError: true });
|
|
221
221
|
throw new Error('Expected parsing to fail');
|
|
222
222
|
};
|
|
223
223
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { LastValueSink } from '@/streams/LastValueSink.js';
|
|
2
|
+
import { describe, expect, test } from 'vitest';
|
|
3
|
+
|
|
4
|
+
describe('LastValueSink cancellation', () => {
|
|
5
|
+
test.each([
|
|
6
|
+
{ reason: undefined },
|
|
7
|
+
{ reason: new DOMException('Cancelled', 'AbortError') },
|
|
8
|
+
{ reason: new Error('Cancelled') },
|
|
9
|
+
{ reason: 'cancelled' },
|
|
10
|
+
{ reason: 0 },
|
|
11
|
+
{ reason: false },
|
|
12
|
+
{ reason: '' },
|
|
13
|
+
{ reason: null }
|
|
14
|
+
])('preserves abort reason $reason before and during a read', async ({ reason }) => {
|
|
15
|
+
const controller = new AbortController();
|
|
16
|
+
const sink = new LastValueSink<number>(undefined);
|
|
17
|
+
const iterator = sink.withSignal(controller.signal)[Symbol.asyncIterator]();
|
|
18
|
+
const pending = iterator.next();
|
|
19
|
+
controller.abort(reason);
|
|
20
|
+
await expect(pending).rejects.toBe(controller.signal.reason);
|
|
21
|
+
|
|
22
|
+
const alreadyAborted = new LastValueSink<number>(1).withSignal(controller.signal)[Symbol.asyncIterator]();
|
|
23
|
+
await expect(alreadyAborted.next()).rejects.toBe(controller.signal.reason);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('uses a DOM AbortError for default cancellation', async () => {
|
|
27
|
+
const signal = AbortSignal.abort();
|
|
28
|
+
const iterator = new LastValueSink<number>(undefined).withSignal(signal)[Symbol.asyncIterator]();
|
|
29
|
+
await expect(iterator.next()).rejects.toBeInstanceOf(DOMException);
|
|
30
|
+
await expect(new LastValueSink<number>(1).withSignal(signal)[Symbol.asyncIterator]().next()).rejects.toMatchObject({
|
|
31
|
+
name: 'AbortError'
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -1,18 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { IterableSource } from '@/streams/BroadcastIterable.js';
|
|
2
|
+
import { mergeAsyncIterables } from '@/streams/merge.js';
|
|
2
3
|
import * as timers from 'timers/promises';
|
|
3
|
-
import { describe, expect, test } from 'vitest';
|
|
4
|
+
import { describe, expect, test, vi } from 'vitest';
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
// These finite legacy sources do not wait indefinitely. Cancellation-aware sources below test pending reads.
|
|
7
|
+
function mergeSources<T>(sources: AsyncIterable<T>[]): AsyncIterable<T> {
|
|
8
|
+
return mergeAsyncIterables(sources.map((source) => () => source));
|
|
9
|
+
}
|
|
6
10
|
|
|
7
11
|
describe('merge iterables', () => {
|
|
8
|
-
makeTests((source) => mergeAsyncIterablesNew(source));
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
describe('merge iterables - legacy implementation', () => {
|
|
12
|
-
makeTests((source) => mergeAsyncIterablesOld(source));
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
function makeTests(mergeIterables: MergeIteratorFunction) {
|
|
16
12
|
test('iterable should not pre-emptively continue (1)', async () => {
|
|
17
13
|
// Test that the generator stops after the first yield
|
|
18
14
|
let afterYield = false;
|
|
@@ -27,7 +23,7 @@ function makeTests(mergeIterables: MergeIteratorFunction) {
|
|
|
27
23
|
}
|
|
28
24
|
};
|
|
29
25
|
|
|
30
|
-
const merged =
|
|
26
|
+
const merged = mergeSources([a()]);
|
|
31
27
|
const iter = merged[Symbol.asyncIterator]();
|
|
32
28
|
const r = await iter.next();
|
|
33
29
|
expect(r).toEqual({ value: 1, done: false });
|
|
@@ -51,7 +47,7 @@ function makeTests(mergeIterables: MergeIteratorFunction) {
|
|
|
51
47
|
}
|
|
52
48
|
};
|
|
53
49
|
|
|
54
|
-
const merged =
|
|
50
|
+
const merged = mergeSources([a()]);
|
|
55
51
|
|
|
56
52
|
for await (let r of merged) {
|
|
57
53
|
expect(r).toEqual(1);
|
|
@@ -75,7 +71,7 @@ function makeTests(mergeIterables: MergeIteratorFunction) {
|
|
|
75
71
|
}
|
|
76
72
|
};
|
|
77
73
|
|
|
78
|
-
const merged =
|
|
74
|
+
const merged = mergeSources([a()]);
|
|
79
75
|
|
|
80
76
|
for await (let r of merged) {
|
|
81
77
|
expect(r).toEqual(1);
|
|
@@ -107,7 +103,7 @@ function makeTests(mergeIterables: MergeIteratorFunction) {
|
|
|
107
103
|
}
|
|
108
104
|
};
|
|
109
105
|
|
|
110
|
-
const merged =
|
|
106
|
+
const merged = mergeSources([a(), b()]);
|
|
111
107
|
|
|
112
108
|
const iter = merged[Symbol.asyncIterator]();
|
|
113
109
|
expect(await iter.next()).toEqual({ value: 'a', done: false });
|
|
@@ -151,12 +147,13 @@ function makeTests(mergeIterables: MergeIteratorFunction) {
|
|
|
151
147
|
}
|
|
152
148
|
};
|
|
153
149
|
|
|
154
|
-
const merged =
|
|
150
|
+
const merged = mergeSources([a(), b()]);
|
|
155
151
|
|
|
156
152
|
const iter = merged[Symbol.asyncIterator]();
|
|
157
153
|
expect(await iter.next()).toEqual({ value: 'a', done: false });
|
|
158
154
|
expect(await iter.next()).toEqual({ value: 'b', done: false });
|
|
159
|
-
|
|
155
|
+
// A previously pending read fails during cleanup. Blocking cleanup must surface that error.
|
|
156
|
+
await expect(iter.return!()).rejects.toThrow('a');
|
|
160
157
|
|
|
161
158
|
await timers.setTimeout(2);
|
|
162
159
|
|
|
@@ -193,7 +190,7 @@ function makeTests(mergeIterables: MergeIteratorFunction) {
|
|
|
193
190
|
}
|
|
194
191
|
};
|
|
195
192
|
|
|
196
|
-
const merged =
|
|
193
|
+
const merged = mergeSources([a(), b()]);
|
|
197
194
|
|
|
198
195
|
const iter = merged[Symbol.asyncIterator]();
|
|
199
196
|
expect(await iter.next()).toEqual({ value: 'a', done: false });
|
|
@@ -241,7 +238,7 @@ function makeTests(mergeIterables: MergeIteratorFunction) {
|
|
|
241
238
|
}
|
|
242
239
|
};
|
|
243
240
|
|
|
244
|
-
const merged =
|
|
241
|
+
const merged = mergeSources([a(), b()]);
|
|
245
242
|
|
|
246
243
|
const iter = merged[Symbol.asyncIterator]();
|
|
247
244
|
expect(await iter.next()).toEqual({ value: 'a', done: false });
|
|
@@ -281,7 +278,7 @@ function makeTests(mergeIterables: MergeIteratorFunction) {
|
|
|
281
278
|
}
|
|
282
279
|
};
|
|
283
280
|
|
|
284
|
-
const merged =
|
|
281
|
+
const merged = mergeSources([a()]);
|
|
285
282
|
const iter = merged[Symbol.asyncIterator]();
|
|
286
283
|
const r = await iter.next();
|
|
287
284
|
expect(r).toEqual({ value: 1, done: false });
|
|
@@ -297,54 +294,306 @@ function makeTests(mergeIterables: MergeIteratorFunction) {
|
|
|
297
294
|
expect(aFinally).toEqual(true);
|
|
298
295
|
expect(aError).toEqual(new Error('loop error'));
|
|
299
296
|
});
|
|
297
|
+
});
|
|
300
298
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
299
|
+
describe('blocking merge cleanup', () => {
|
|
300
|
+
test.each([
|
|
301
|
+
{ reason: new DOMException('Cancelled', 'AbortError') },
|
|
302
|
+
{ reason: new Error('Cancelled') },
|
|
303
|
+
{ reason: 'cancelled' },
|
|
304
|
+
{ reason: 0 },
|
|
305
|
+
{ reason: false },
|
|
306
|
+
{ reason: null }
|
|
307
|
+
])('preserves abort reason $reason before and during iteration', async ({ reason }) => {
|
|
308
|
+
const preAborted = mergeAsyncIterables([], AbortSignal.abort(reason))[Symbol.asyncIterator]();
|
|
309
|
+
await expect(preAborted.next()).rejects.toBe(reason);
|
|
310
|
+
|
|
311
|
+
const started = Promise.withResolvers<void>();
|
|
312
|
+
const controller = new AbortController();
|
|
313
|
+
const source: IterableSource<number> = async function* (signal) {
|
|
314
|
+
started.resolve();
|
|
315
|
+
await timers.setTimeout(60_000, undefined, { signal });
|
|
316
|
+
yield 1;
|
|
317
|
+
};
|
|
318
|
+
const iterator = mergeAsyncIterables([source], controller.signal)[Symbol.asyncIterator]();
|
|
319
|
+
const outcome = expect(iterator.next()).rejects.toBe(reason);
|
|
320
|
+
await started.promise;
|
|
321
|
+
controller.abort(reason);
|
|
322
|
+
await outcome;
|
|
323
|
+
});
|
|
307
324
|
|
|
308
|
-
|
|
325
|
+
test('passes the iteration failure to source cancellation', async () => {
|
|
326
|
+
const error = new Error('source failed');
|
|
327
|
+
let sourceSignal: AbortSignal | undefined;
|
|
328
|
+
const failing: IterableSource<number> = async function* () {
|
|
329
|
+
throw error;
|
|
330
|
+
};
|
|
331
|
+
const other: IterableSource<number> = async function* (signal) {
|
|
332
|
+
sourceSignal = signal;
|
|
333
|
+
yield 1;
|
|
334
|
+
};
|
|
335
|
+
await expect(mergeAsyncIterables([failing, other])[Symbol.asyncIterator]().next()).rejects.toBe(error);
|
|
336
|
+
expect(sourceSignal!.reason).toBe(error);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test('aborts a pending source read when another source completes', async () => {
|
|
340
|
+
let closed = false;
|
|
341
|
+
const waiting: IterableSource<number> = async function* (signal) {
|
|
309
342
|
try {
|
|
310
|
-
|
|
343
|
+
await timers.setTimeout(60_000, undefined, { signal });
|
|
344
|
+
yield 2;
|
|
311
345
|
} finally {
|
|
312
|
-
|
|
346
|
+
closed = true;
|
|
313
347
|
}
|
|
314
348
|
};
|
|
315
|
-
const
|
|
349
|
+
const iter = mergeAsyncIterables([
|
|
350
|
+
async function* () {
|
|
351
|
+
yield 1;
|
|
352
|
+
},
|
|
353
|
+
waiting
|
|
354
|
+
])[Symbol.asyncIterator]();
|
|
355
|
+
|
|
356
|
+
expect(await iter.next()).toEqual({ done: false, value: 1 });
|
|
357
|
+
expect(await iter.next()).toEqual({ done: true, value: undefined });
|
|
358
|
+
expect(closed).toBe(true);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test.each(['return', 'throw', 'abort'] as const)('unblocks an in-flight next() on %s', async (method) => {
|
|
362
|
+
const started = Promise.withResolvers<void>();
|
|
363
|
+
const controller = new AbortController();
|
|
364
|
+
const original = new Error('consumer failed');
|
|
365
|
+
let closed = false;
|
|
366
|
+
const source: IterableSource<number> = async function* (signal) {
|
|
316
367
|
try {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
// The final iter.next() should not wait for this to complete
|
|
321
|
-
await timers.setTimeout(50);
|
|
322
|
-
b2Timedout = true;
|
|
323
|
-
yield 'b2';
|
|
324
|
-
// This point should not be reached
|
|
325
|
-
b3Reached = true;
|
|
368
|
+
started.resolve();
|
|
369
|
+
await timers.setTimeout(60_000, undefined, { signal });
|
|
370
|
+
yield 1;
|
|
326
371
|
} finally {
|
|
327
|
-
|
|
372
|
+
closed = true;
|
|
328
373
|
}
|
|
329
374
|
};
|
|
375
|
+
const iter = mergeAsyncIterables([source], controller.signal)[Symbol.asyncIterator]();
|
|
376
|
+
const next = iter.next();
|
|
377
|
+
const outcome = next.then(
|
|
378
|
+
(value) => value,
|
|
379
|
+
(error) => error
|
|
380
|
+
);
|
|
381
|
+
await started.promise;
|
|
382
|
+
|
|
383
|
+
if (method === 'throw') {
|
|
384
|
+
await expect(iter.throw!(original)).rejects.toBe(original);
|
|
385
|
+
expect(await outcome).toBe(original);
|
|
386
|
+
} else if (method === 'abort') {
|
|
387
|
+
controller.abort();
|
|
388
|
+
expect(await outcome).toMatchObject({ name: 'AbortError' });
|
|
389
|
+
} else {
|
|
390
|
+
await expect(iter.return!()).resolves.toEqual({ done: true, value: undefined });
|
|
391
|
+
expect(await outcome).toEqual({ done: true, value: undefined });
|
|
392
|
+
}
|
|
393
|
+
expect(closed).toBe(true);
|
|
394
|
+
});
|
|
330
395
|
|
|
331
|
-
|
|
396
|
+
test('waits for every cleanup and starts cleanups concurrently', async () => {
|
|
397
|
+
const gates = [Promise.withResolvers<void>(), Promise.withResolvers<void>()];
|
|
398
|
+
const started = gates.map(() => Promise.withResolvers<void>());
|
|
399
|
+
const closed: number[] = [];
|
|
400
|
+
const sources = gates.map(
|
|
401
|
+
(gate, index): IterableSource<number> =>
|
|
402
|
+
async function* () {
|
|
403
|
+
try {
|
|
404
|
+
yield index;
|
|
405
|
+
} finally {
|
|
406
|
+
started[index].resolve();
|
|
407
|
+
await gate.promise;
|
|
408
|
+
closed.push(index);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
);
|
|
412
|
+
const iter = mergeAsyncIterables(sources)[Symbol.asyncIterator]();
|
|
413
|
+
await iter.next();
|
|
414
|
+
let finished = false;
|
|
415
|
+
const cleanup = iter.return!().then(() => {
|
|
416
|
+
finished = true;
|
|
417
|
+
});
|
|
418
|
+
await Promise.all(started.map((item) => item.promise));
|
|
419
|
+
expect(finished).toBe(false);
|
|
420
|
+
gates[0].resolve();
|
|
421
|
+
await timers.setImmediate();
|
|
422
|
+
expect(closed).toEqual([0]);
|
|
423
|
+
expect(finished).toBe(false);
|
|
424
|
+
gates[1].resolve();
|
|
425
|
+
await cleanup;
|
|
426
|
+
expect(closed).toEqual([0, 1]);
|
|
427
|
+
});
|
|
332
428
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
429
|
+
test.each(['return', 'throw'] as const)(
|
|
430
|
+
'surfaces cleanup failures on %s without hiding the consumer error',
|
|
431
|
+
async (method) => {
|
|
432
|
+
const original = new Error('consumer failed');
|
|
433
|
+
const cleanupError = new Error('cleanup failed');
|
|
434
|
+
let otherClosed = false;
|
|
435
|
+
const failing: IterableSource<number> = async function* () {
|
|
436
|
+
try {
|
|
437
|
+
yield 1;
|
|
438
|
+
} finally {
|
|
439
|
+
throw cleanupError;
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
const other: IterableSource<number> = async function* () {
|
|
443
|
+
try {
|
|
444
|
+
yield 2;
|
|
445
|
+
} finally {
|
|
446
|
+
otherClosed = true;
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
const iter = mergeAsyncIterables([failing, other])[Symbol.asyncIterator]();
|
|
450
|
+
await iter.next();
|
|
337
451
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
452
|
+
if (method === 'return') {
|
|
453
|
+
await expect(iter.return!()).rejects.toBe(cleanupError);
|
|
454
|
+
} else {
|
|
455
|
+
await expect(iter.throw!(original)).rejects.toMatchObject({
|
|
456
|
+
errors: [original, cleanupError],
|
|
457
|
+
cause: original
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
expect(otherClosed).toBe(true);
|
|
461
|
+
}
|
|
462
|
+
);
|
|
343
463
|
|
|
344
|
-
|
|
464
|
+
test('preserves the iteration error when another source fails cleanup', async () => {
|
|
465
|
+
const original = new Error('source failed');
|
|
466
|
+
const cleanupError = new Error('cleanup failed');
|
|
467
|
+
const failing: IterableSource<number> = async function* () {
|
|
468
|
+
throw original;
|
|
469
|
+
};
|
|
470
|
+
const other: IterableSource<number> = async function* () {
|
|
471
|
+
try {
|
|
472
|
+
yield 1;
|
|
473
|
+
} finally {
|
|
474
|
+
throw cleanupError;
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
const iter = mergeAsyncIterables([failing, other])[Symbol.asyncIterator]();
|
|
478
|
+
await expect(iter.next()).rejects.toMatchObject({ errors: [original, cleanupError], cause: original });
|
|
479
|
+
});
|
|
345
480
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
481
|
+
test('handles a losing read that rejects while the consumer is paused', async () => {
|
|
482
|
+
const error = new Error('background read failed');
|
|
483
|
+
const failure = Promise.withResolvers<void>();
|
|
484
|
+
const source: IterableSource<number> = async function* () {
|
|
485
|
+
yield 1;
|
|
486
|
+
};
|
|
487
|
+
const failing: IterableSource<number> = async function* () {
|
|
488
|
+
await failure.promise;
|
|
489
|
+
throw error;
|
|
490
|
+
};
|
|
491
|
+
const iter = mergeAsyncIterables([source, failing])[Symbol.asyncIterator]();
|
|
492
|
+
expect(await iter.next()).toEqual({ done: false, value: 1 });
|
|
493
|
+
failure.resolve();
|
|
494
|
+
// No next() consumer is attached during this turn. Vitest detects any unhandled rejection.
|
|
495
|
+
await timers.setImmediate();
|
|
496
|
+
await expect(iter.return!()).rejects.toBe(error);
|
|
349
497
|
});
|
|
350
|
-
|
|
498
|
+
|
|
499
|
+
test('closes a source even if it catches an injected error and yields again', async () => {
|
|
500
|
+
const original = new Error('consumer failed');
|
|
501
|
+
let closed = false;
|
|
502
|
+
const source: IterableSource<number> = async function* () {
|
|
503
|
+
try {
|
|
504
|
+
try {
|
|
505
|
+
yield 1;
|
|
506
|
+
} catch {
|
|
507
|
+
yield 2;
|
|
508
|
+
}
|
|
509
|
+
} finally {
|
|
510
|
+
closed = true;
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
const iter = mergeAsyncIterables([source])[Symbol.asyncIterator]();
|
|
514
|
+
await iter.next();
|
|
515
|
+
await expect(iter.throw!(original)).rejects.toBe(original);
|
|
516
|
+
expect(closed).toBe(true);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test('cleans up initialized sources if a later factory throws', async () => {
|
|
520
|
+
const error = new Error('factory failed');
|
|
521
|
+
const close = vi.fn(async () => ({ done: true as const, value: undefined }));
|
|
522
|
+
const source: IterableSource<number> = () => ({
|
|
523
|
+
[Symbol.asyncIterator]: () => ({ next: async () => ({ done: false, value: 1 }), return: close })
|
|
524
|
+
});
|
|
525
|
+
const iter = mergeAsyncIterables([
|
|
526
|
+
source,
|
|
527
|
+
() => {
|
|
528
|
+
throw error;
|
|
529
|
+
}
|
|
530
|
+
])[Symbol.asyncIterator]();
|
|
531
|
+
await expect(iter.next()).rejects.toBe(error);
|
|
532
|
+
expect(close).toHaveBeenCalledOnce();
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
test('does not create sources after cancellation or return before the first read', async () => {
|
|
536
|
+
const source = vi.fn(async function* () {
|
|
537
|
+
yield 1;
|
|
538
|
+
});
|
|
539
|
+
const controller = new AbortController();
|
|
540
|
+
controller.abort();
|
|
541
|
+
await expect(mergeAsyncIterables([source], controller.signal)[Symbol.asyncIterator]().next()).rejects.toMatchObject(
|
|
542
|
+
{ name: 'AbortError' }
|
|
543
|
+
);
|
|
544
|
+
await mergeAsyncIterables([source])[Symbol.asyncIterator]().return!();
|
|
545
|
+
expect(source).not.toHaveBeenCalled();
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
test('completes an empty merge', async () => {
|
|
549
|
+
await expect(mergeAsyncIterables([])[Symbol.asyncIterator]().next()).resolves.toEqual({
|
|
550
|
+
done: true,
|
|
551
|
+
value: undefined
|
|
552
|
+
});
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
test('collects multiple cleanup errors, including synchronous failures', async () => {
|
|
556
|
+
const errors = [new Error('first cleanup failed'), new Error('second cleanup failed')];
|
|
557
|
+
const closed: number[] = [];
|
|
558
|
+
const sources = errors.map(
|
|
559
|
+
(error, index): IterableSource<number> =>
|
|
560
|
+
() => ({
|
|
561
|
+
[Symbol.asyncIterator]: () => ({
|
|
562
|
+
next: async () => ({ done: false, value: index }),
|
|
563
|
+
return: () => {
|
|
564
|
+
closed.push(index);
|
|
565
|
+
if (index === 0) throw error;
|
|
566
|
+
return Promise.reject(error);
|
|
567
|
+
}
|
|
568
|
+
})
|
|
569
|
+
})
|
|
570
|
+
);
|
|
571
|
+
const iter = mergeAsyncIterables(sources)[Symbol.asyncIterator]();
|
|
572
|
+
await iter.next();
|
|
573
|
+
await expect(iter.return!()).rejects.toMatchObject({ errors, cause: errors[0] });
|
|
574
|
+
expect(closed).toEqual([0, 1]);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
test('cleans up on external abort while the consumer is paused and retains cleanup failures', async () => {
|
|
578
|
+
const controller = new AbortController();
|
|
579
|
+
const closed = Promise.withResolvers<void>();
|
|
580
|
+
const error = new Error('cleanup failed');
|
|
581
|
+
const source: IterableSource<number> = async function* () {
|
|
582
|
+
try {
|
|
583
|
+
yield 1;
|
|
584
|
+
} finally {
|
|
585
|
+
closed.resolve();
|
|
586
|
+
throw error;
|
|
587
|
+
}
|
|
588
|
+
};
|
|
589
|
+
const iter = mergeAsyncIterables([source], controller.signal)[Symbol.asyncIterator]();
|
|
590
|
+
await iter.next();
|
|
591
|
+
controller.abort();
|
|
592
|
+
await closed.promise;
|
|
593
|
+
// No consumer is awaiting cleanup yet. The stored cleanup result must not reject in the background.
|
|
594
|
+
await timers.setImmediate();
|
|
595
|
+
await expect(iter.return!()).rejects.toMatchObject({
|
|
596
|
+
errors: [expect.objectContaining({ name: 'AbortError' }), error]
|
|
597
|
+
});
|
|
598
|
+
});
|
|
599
|
+
});
|