@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.
Files changed (131) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/LICENSE +201 -67
  3. package/dist/api/RouteAPI.d.ts +6 -0
  4. package/dist/api/api-metrics.js +4 -0
  5. package/dist/api/api-metrics.js.map +1 -1
  6. package/dist/api/diagnostics.d.ts +5 -0
  7. package/dist/api/diagnostics.js +17 -2
  8. package/dist/api/diagnostics.js.map +1 -1
  9. package/dist/metrics/connection-metrics.d.ts +28 -0
  10. package/dist/metrics/connection-metrics.js +55 -0
  11. package/dist/metrics/connection-metrics.js.map +1 -0
  12. package/dist/metrics/metrics-index.d.ts +1 -0
  13. package/dist/metrics/metrics-index.js +1 -0
  14. package/dist/metrics/metrics-index.js.map +1 -1
  15. package/dist/metrics/metrics-interfaces.d.ts +7 -1
  16. package/dist/metrics/metrics-interfaces.js.map +1 -1
  17. package/dist/modules/loader.js +8 -2
  18. package/dist/modules/loader.js.map +1 -1
  19. package/dist/replication/AbstractReplicationJob.d.ts +2 -0
  20. package/dist/replication/AbstractReplicationJob.js +17 -1
  21. package/dist/replication/AbstractReplicationJob.js.map +1 -1
  22. package/dist/replication/AbstractReplicator.d.ts +8 -18
  23. package/dist/replication/AbstractReplicator.js +63 -64
  24. package/dist/replication/AbstractReplicator.js.map +1 -1
  25. package/dist/replication/ReplicationModule.js +0 -6
  26. package/dist/replication/ReplicationModule.js.map +1 -1
  27. package/dist/routes/configure-fastify.js +5 -1
  28. package/dist/routes/configure-fastify.js.map +1 -1
  29. package/dist/routes/endpoints/admin.js +24 -22
  30. package/dist/routes/endpoints/admin.js.map +1 -1
  31. package/dist/routes/endpoints/socket-route.js +19 -27
  32. package/dist/routes/endpoints/socket-route.js.map +1 -1
  33. package/dist/routes/endpoints/sync-rules.js +17 -40
  34. package/dist/routes/endpoints/sync-rules.js.map +1 -1
  35. package/dist/routes/endpoints/sync-stream.js +25 -23
  36. package/dist/routes/endpoints/sync-stream.js.map +1 -1
  37. package/dist/routes/hooks.d.ts +1 -1
  38. package/dist/routes/hooks.js +2 -1
  39. package/dist/routes/hooks.js.map +1 -1
  40. package/dist/routes/sync-connection.d.ts +7 -0
  41. package/dist/routes/sync-connection.js +34 -0
  42. package/dist/routes/sync-connection.js.map +1 -0
  43. package/dist/storage/BucketStorageFactory.d.ts +30 -22
  44. package/dist/storage/BucketStorageFactory.js +14 -14
  45. package/dist/storage/BucketStorageFactory.js.map +1 -1
  46. package/dist/storage/PersistedSyncConfigContent.js +2 -2
  47. package/dist/storage/PersistedSyncConfigContent.js.map +1 -1
  48. package/dist/storage/SyncRulesBucketStorage.d.ts +23 -2
  49. package/dist/storage/SyncRulesBucketStorage.js.map +1 -1
  50. package/dist/streams/BroadcastIterable.js +8 -7
  51. package/dist/streams/BroadcastIterable.js.map +1 -1
  52. package/dist/streams/LastValueSink.d.ts +2 -2
  53. package/dist/streams/LastValueSink.js +8 -13
  54. package/dist/streams/LastValueSink.js.map +1 -1
  55. package/dist/streams/merge.d.ts +15 -31
  56. package/dist/streams/merge.js +136 -152
  57. package/dist/streams/merge.js.map +1 -1
  58. package/dist/sync/BucketChecksumState.d.ts +3 -2
  59. package/dist/sync/BucketChecksumState.js +12 -5
  60. package/dist/sync/BucketChecksumState.js.map +1 -1
  61. package/dist/sync/sync.js +48 -38
  62. package/dist/sync/sync.js.map +1 -1
  63. package/dist/sync/util.d.ts +1 -2
  64. package/dist/sync/util.js +2 -5
  65. package/dist/sync/util.js.map +1 -1
  66. package/dist/system/ServiceContext.d.ts +7 -0
  67. package/dist/system/ServiceContext.js +3 -3
  68. package/dist/system/ServiceContext.js.map +1 -1
  69. package/dist/system/SyncConfigEngine.d.ts +135 -0
  70. package/dist/system/SyncConfigEngine.js +186 -0
  71. package/dist/system/SyncConfigEngine.js.map +1 -0
  72. package/dist/system/system-index.d.ts +1 -0
  73. package/dist/system/system-index.js +1 -0
  74. package/dist/system/system-index.js.map +1 -1
  75. package/dist/util/config/types.d.ts +4 -0
  76. package/package.json +8 -9
  77. package/src/api/RouteAPI.ts +6 -0
  78. package/src/api/api-metrics.ts +5 -0
  79. package/src/api/diagnostics.ts +22 -2
  80. package/src/metrics/connection-metrics.ts +75 -0
  81. package/src/metrics/metrics-index.ts +1 -0
  82. package/src/metrics/metrics-interfaces.ts +8 -1
  83. package/src/modules/loader.ts +16 -2
  84. package/src/replication/AbstractReplicationJob.ts +17 -1
  85. package/src/replication/AbstractReplicator.ts +83 -81
  86. package/src/replication/ReplicationModule.ts +0 -5
  87. package/src/routes/configure-fastify.ts +7 -1
  88. package/src/routes/endpoints/admin.ts +27 -20
  89. package/src/routes/endpoints/socket-route.ts +26 -38
  90. package/src/routes/endpoints/sync-rules.ts +22 -46
  91. package/src/routes/endpoints/sync-stream.ts +31 -27
  92. package/src/routes/hooks.ts +5 -1
  93. package/src/routes/sync-connection.ts +39 -0
  94. package/src/storage/BucketStorageFactory.ts +45 -29
  95. package/src/storage/PersistedSyncConfigContent.ts +2 -2
  96. package/src/storage/SyncRulesBucketStorage.ts +23 -2
  97. package/src/streams/BroadcastIterable.ts +8 -7
  98. package/src/streams/LastValueSink.ts +10 -16
  99. package/src/streams/merge.ts +149 -169
  100. package/src/sync/BucketChecksumState.ts +17 -5
  101. package/src/sync/sync.ts +71 -57
  102. package/src/sync/util.ts +2 -7
  103. package/src/system/ServiceContext.ts +8 -3
  104. package/src/system/SyncConfigEngine.ts +247 -0
  105. package/src/system/system-index.ts +1 -0
  106. package/src/util/config/types.ts +4 -0
  107. package/test/src/AbstractReplicator.test.ts +375 -45
  108. package/test/src/PersistedSyncConfigContent.test.ts +5 -5
  109. package/test/src/SyncConfigEngine.test.ts +208 -0
  110. package/test/src/broadcast_iterable.test.ts +59 -31
  111. package/test/src/config.test.ts +65 -3
  112. package/test/src/connection-metrics-export.test.ts +76 -0
  113. package/test/src/connection-metrics.test.ts +184 -0
  114. package/test/src/diagnostics.test.ts +3 -3
  115. package/test/src/last_value_sink.test.ts +34 -0
  116. package/test/src/merge_iterable.test.ts +304 -55
  117. package/test/src/module-loader.test.ts +14 -1
  118. package/test/src/recording-metrics.ts +36 -0
  119. package/test/src/routes/admin.test.ts +2 -2
  120. package/test/src/routes/concurrency-limit.test.ts +108 -0
  121. package/test/src/routes/connection-metrics.test.ts +269 -0
  122. package/test/src/routes/invalid-config.test.ts +184 -0
  123. package/test/src/routes/mocks.ts +26 -13
  124. package/test/src/routes/stream-disconnect.integration.test.ts +129 -0
  125. package/test/src/routes/stream.test.ts +200 -107
  126. package/test/src/routes/sync-rules.test.ts +19 -1
  127. package/test/src/source_table_config.test.ts +7 -7
  128. package/test/src/sync/BucketChecksumState.test.ts +10 -10
  129. package/test/src/sync/stream_response.test.ts +389 -0
  130. package/test/src/sync/util.test.ts +14 -3
  131. 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 { SqlSyncRules } from '@powersync/service-sync-rules';
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 = SqlSyncRules.fromYaml(MINIMAL_SYNC_RULES, {
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
- SqlSyncRules.fromYaml(yaml, { ...options, defaultSchema: 'public', throwOnError: true });
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 { mergeAsyncIterablesNew, mergeAsyncIterablesOld } from '@/streams/merge.js';
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
- type MergeIteratorFunction = <T>(source: AsyncIterable<T>[]) => AsyncIterable<T>;
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 = mergeIterables([a()]);
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 = mergeIterables([a()]);
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 = mergeIterables([a()]);
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 = mergeIterables([a(), b()]);
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 = mergeIterables([a(), b()]);
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
- await iter.return!();
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 = mergeIterables([a(), b()]);
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 = mergeIterables([a(), b()]);
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 = mergeIterables([a()]);
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
- test('do not block return', async () => {
302
- let aDone = false;
303
- let bDone = false;
304
- let b2Reached = false;
305
- let b2Timedout = false;
306
- let b3Reached = false;
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
- const a = async function* (): AsyncGenerator<string, void> {
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
- yield 'a';
343
+ await timers.setTimeout(60_000, undefined, { signal });
344
+ yield 2;
311
345
  } finally {
312
- aDone = true;
346
+ closed = true;
313
347
  }
314
348
  };
315
- const b = async function* (): AsyncGenerator<string, void> {
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
- yield 'b1';
318
- // This point should be reached, even if the value is not used
319
- b2Reached = true;
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
- bDone = true;
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
- const merged = mergeIterables([a(), b()]);
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
- const iter = merged[Symbol.asyncIterator]();
334
- expect(await iter.next()).toEqual({ value: 'a', done: false });
335
- expect(await iter.next()).toEqual({ value: 'b1', done: false });
336
- expect(await iter.next()).toEqual({ value: undefined, done: true });
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
- expect(aDone).toEqual(true);
339
- expect(b2Reached).toEqual(true);
340
- expect(b2Reached).toEqual(true);
341
- expect(b2Timedout).toEqual(false);
342
- expect(bDone).toEqual(false);
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
- await timers.setTimeout(60);
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
- expect(b2Timedout).toEqual(true);
347
- expect(bDone).toEqual(true);
348
- expect(b3Reached).toEqual(false);
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
+ });