@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
@@ -20,9 +20,9 @@ import {
20
20
  ParameterLookupDefinitionId,
21
21
  ParameterLookupRows,
22
22
  ParameterLookupScope,
23
+ parseSyncConfig,
23
24
  ScopedParameterLookup,
24
25
  SourceTableRef,
25
- SqlSyncRules,
26
26
  TablePattern,
27
27
  versionedHydrationState
28
28
  } from '@powersync/service-sync-rules';
@@ -58,7 +58,7 @@ describe('BucketChecksumState', () => {
58
58
 
59
59
  // Single global[] bucket.
60
60
  // We don't care about data in these tests
61
- const SYNC_RULES_GLOBAL = SqlSyncRules.fromYaml(
61
+ const SYNC_RULES_GLOBAL = parseSyncConfig(
62
62
  `
63
63
  bucket_definitions:
64
64
  global:
@@ -68,7 +68,7 @@ bucket_definitions:
68
68
  ).config.hydrate({ hydrationState: versionedHydrationState(1), sqlite: nodeSqlite(sqlite) });
69
69
 
70
70
  // global[1] and global[2]
71
- const SYNC_RULES_GLOBAL_TWO = SqlSyncRules.fromYaml(
71
+ const SYNC_RULES_GLOBAL_TWO = parseSyncConfig(
72
72
  `
73
73
  bucket_definitions:
74
74
  global:
@@ -81,7 +81,7 @@ bucket_definitions:
81
81
  ).config.hydrate({ hydrationState: versionedHydrationState(2), sqlite: nodeSqlite(sqlite) });
82
82
 
83
83
  // by_project[n]
84
- const SYNC_RULES_DYNAMIC = SqlSyncRules.fromYaml(
84
+ const SYNC_RULES_DYNAMIC = parseSyncConfig(
85
85
  `
86
86
  bucket_definitions:
87
87
  by_project:
@@ -698,7 +698,7 @@ config:
698
698
  `;
699
699
  }
700
700
 
701
- const rules = SqlSyncRules.fromYaml(source, {
701
+ const rules = parseSyncConfig(source, {
702
702
  defaultSchema: 'public'
703
703
  }).config.hydrate({ hydrationState: versionedHydrationState(1), sqlite: nodeSqlite(sqlite) });
704
704
 
@@ -1137,7 +1137,7 @@ config:
1137
1137
  });
1138
1138
 
1139
1139
  test('throws error with breakdown when too many parameters were fetched', async () => {
1140
- const syncRules = SqlSyncRules.fromYaml(
1140
+ const syncRules = parseSyncConfig(
1141
1141
  `
1142
1142
  config:
1143
1143
  edition: 3
@@ -1241,7 +1241,7 @@ streams:
1241
1241
  });
1242
1242
 
1243
1243
  test('throws error when too many lookups are requested at once', async () => {
1244
- const syncRules = SqlSyncRules.fromYaml(
1244
+ const syncRules = parseSyncConfig(
1245
1245
  `
1246
1246
  config:
1247
1247
  edition: 3
@@ -1309,7 +1309,7 @@ streams:
1309
1309
  test('throws error with breakdown when bucket limit is exceeded', async () => {
1310
1310
  // These streams are designed to return buckets without consuming too many parameters (as exceeding that limit is
1311
1311
  // a different error).
1312
- const SYNC_RULES_MULTI = SqlSyncRules.fromYaml(
1312
+ const SYNC_RULES_MULTI = parseSyncConfig(
1313
1313
  `
1314
1314
  config:
1315
1315
  edition: 3
@@ -1387,7 +1387,7 @@ streams:
1387
1387
  });
1388
1388
 
1389
1389
  test('labels the bucket breakdown as bucket definitions for legacy sync rules', async () => {
1390
- const legacySyncRules = SqlSyncRules.fromYaml(
1390
+ const legacySyncRules = parseSyncConfig(
1391
1391
  `
1392
1392
  bucket_definitions:
1393
1393
  projects:
@@ -1445,7 +1445,7 @@ streams:
1445
1445
  yamlDefinitions += ` query: SELECT * FROM tbl WHERE b = auth.parameter('${i}')\n`;
1446
1446
  }
1447
1447
 
1448
- const SYNC_RULES_MANY = SqlSyncRules.fromYaml(yamlDefinitions, { defaultSchema: 'public' }).config.hydrate({
1448
+ const SYNC_RULES_MANY = parseSyncConfig(yamlDefinitions, { defaultSchema: 'public' }).config.hydrate({
1449
1449
  hydrationState: versionedHydrationState(5),
1450
1450
  sqlite: nodeSqlite(sqlite)
1451
1451
  });
@@ -0,0 +1,389 @@
1
+ import {
2
+ BucketChecksumState,
3
+ CHECKPOINT_INVALIDATE_ALL,
4
+ ChecksumCache,
5
+ ChecksumMap,
6
+ FetchPartialBucketChecksum,
7
+ JwtPayload,
8
+ RequestTracker,
9
+ streamResponse,
10
+ SyncRulesBucketStorage
11
+ } from '@/index.js';
12
+ import { DEFAULT_HYDRATION_STATE, HydratedSyncConfig, nodeSqlite, SqlSyncRules } from '@powersync/service-sync-rules';
13
+ import * as sqlite from 'node:sqlite';
14
+ import { describe, expect, test, vi } from 'vitest';
15
+ import { mockServiceContext } from '../routes/mocks.js';
16
+
17
+ const syncRules = SqlSyncRules.fromYaml(
18
+ `
19
+ config:
20
+ edition: 3
21
+ streams:
22
+ test:
23
+ query: SELECT * FROM test
24
+ `,
25
+ { defaultSchema: 'public' }
26
+ ).config.hydrate({ hydrationState: DEFAULT_HYDRATION_STATE, sqlite: nodeSqlite(sqlite) });
27
+
28
+ function response(
29
+ storage: Partial<SyncRulesBucketStorage>,
30
+ signal: AbortSignal,
31
+ config: HydratedSyncConfig = syncRules
32
+ ) {
33
+ const context = mockServiceContext(storage);
34
+ return streamResponse({
35
+ syncContext: context.syncContext,
36
+ bucketStorage: storage as SyncRulesBucketStorage,
37
+ syncRules: config,
38
+ params: { streams: { subscriptions: [{ stream: 'test', parameters: null, override_priority: null }] } },
39
+ token: new JwtPayload({ sub: 'test-user', exp: Date.now() / 1000 + 3600 }),
40
+ tracker: new RequestTracker(context.metricsEngine),
41
+ isEncodingAsBson: false,
42
+ signal
43
+ })[Symbol.asyncIterator]();
44
+ }
45
+
46
+ describe('streamResponse cancellation', () => {
47
+ test('preserves a checksum failure before cancellation', async () => {
48
+ const failure = new Error('checksum query failed');
49
+ const iterator = response(
50
+ {
51
+ watchCheckpointChanges: async function* () {
52
+ yield {
53
+ base: { checkpoint: 1n, lsn: null, getParameterSets: vi.fn() },
54
+ writeCheckpoint: null,
55
+ update: CHECKPOINT_INVALIDATE_ALL
56
+ };
57
+ },
58
+ getChecksums: async () => {
59
+ throw failure;
60
+ }
61
+ },
62
+ new AbortController().signal
63
+ );
64
+ await expect(iterator.next()).rejects.toBe(failure);
65
+ });
66
+
67
+ test('a data fetch failure cancels an overlapping checkpoint calculation', async () => {
68
+ const controller = new AbortController();
69
+ const started = Promise.withResolvers<void>();
70
+ const fetch = Promise.withResolvers<ChecksumMap>();
71
+ const failure = new Error('data fetch failed');
72
+ let closed = false;
73
+ const iterator = response(
74
+ {
75
+ watchCheckpointChanges: async function* () {
76
+ try {
77
+ for (const checkpoint of [1n, 2n]) {
78
+ yield {
79
+ base: { checkpoint, lsn: null, getParameterSets: vi.fn() },
80
+ writeCheckpoint: null,
81
+ update: CHECKPOINT_INVALIDATE_ALL
82
+ };
83
+ }
84
+ } finally {
85
+ closed = true;
86
+ }
87
+ },
88
+ getChecksums: async (checkpoint, buckets) => {
89
+ if (checkpoint.checkpoint === 2n) {
90
+ started.resolve();
91
+ return fetch.promise;
92
+ }
93
+ return new Map(buckets.map(({ bucket }) => [bucket, { bucket, checksum: 1, count: 1000 }]));
94
+ },
95
+ getBucketDataBatch: async function* (_checkpoint, buckets) {
96
+ yield {
97
+ chunkData: {
98
+ bucket: buckets[0].bucket,
99
+ data: Array.from({ length: 1000 }, (_, i) => ({ op: 'PUT' as const, op_id: String(i + 1), checksum: 1 })),
100
+ has_more: true,
101
+ after: '0',
102
+ next_after: '1000'
103
+ },
104
+ targetOp: null
105
+ };
106
+ await started.promise;
107
+ throw failure;
108
+ }
109
+ },
110
+ controller.signal
111
+ );
112
+ const consume = async () => {
113
+ while (!(await iterator.next()).done) {}
114
+ };
115
+ let finished = false;
116
+ const completion = expect(
117
+ consume().finally(() => {
118
+ finished = true;
119
+ })
120
+ ).rejects.toBe(failure);
121
+ await started.promise;
122
+ await new Promise<void>((resolve) => setImmediate(resolve));
123
+ expect(finished).toBe(false);
124
+ fetch.resolve(new Map());
125
+ await completion;
126
+ expect(controller.signal.aborted).toBe(false);
127
+ expect(closed).toBe(true);
128
+ });
129
+
130
+ test('checks cancellation after an incremental checksum read finishes', async () => {
131
+ const controller = new AbortController();
132
+ const started = Promise.withResolvers<void>();
133
+ const fetch = Promise.withResolvers<ChecksumMap>();
134
+ const getChecksums = vi
135
+ .fn<SyncRulesBucketStorage['getChecksums']>()
136
+ .mockImplementation(
137
+ async (_cp, buckets) => new Map(buckets.map(({ bucket }) => [bucket, { bucket, checksum: 1, count: 1 }]))
138
+ );
139
+ const context = mockServiceContext({});
140
+ const state = new BucketChecksumState({
141
+ syncContext: context.syncContext,
142
+ bucketStorage: { getChecksums },
143
+ syncRules,
144
+ syncRequest: { streams: { subscriptions: [{ stream: 'test', parameters: null, override_priority: null }] } },
145
+ tokenPayload: new JwtPayload({ sub: 'test-user' })
146
+ });
147
+ const base = { checkpoint: 1n, lsn: null, getParameterSets: vi.fn() };
148
+ const first = (await state.buildNextCheckpointLine({
149
+ base,
150
+ writeCheckpoint: null,
151
+ update: CHECKPOINT_INVALIDATE_ALL
152
+ }))!;
153
+ first.advance();
154
+ getChecksums.mockImplementation(() => {
155
+ started.resolve();
156
+ return fetch.promise;
157
+ });
158
+
159
+ const pending = state.buildNextCheckpointLine(
160
+ {
161
+ base: { ...base, checkpoint: 2n },
162
+ writeCheckpoint: null,
163
+ update: {
164
+ ...CHECKPOINT_INVALIDATE_ALL,
165
+ invalidateDataBuckets: false,
166
+ updatedDataBuckets: new Set(first.bucketsToFetch.map((b) => b.bucket))
167
+ }
168
+ },
169
+ undefined,
170
+ controller.signal
171
+ );
172
+ const reason = new Error('client disconnected during checkpoint diff');
173
+ const completion = expect(pending).rejects.toBe(reason);
174
+ await started.promise;
175
+ controller.abort(reason);
176
+ fetch.resolve(new Map());
177
+ await completion;
178
+ });
179
+
180
+ test('finishes a pending parameter read before cancellation, without starting checksum work', async () => {
181
+ const config = SqlSyncRules.fromYaml(
182
+ `
183
+ config:
184
+ edition: 3
185
+ streams:
186
+ test:
187
+ query: SELECT * FROM test WHERE id IN (SELECT id FROM memberships WHERE user_id = auth.user_id())
188
+ `,
189
+ { defaultSchema: 'public' }
190
+ ).config.hydrate({ hydrationState: DEFAULT_HYDRATION_STATE, sqlite: nodeSqlite(sqlite) });
191
+ const controller = new AbortController();
192
+ const started = Promise.withResolvers<void>();
193
+ const read = Promise.withResolvers<never[]>();
194
+ const getChecksums = vi.fn();
195
+ let closed = false;
196
+ const iterator = response(
197
+ {
198
+ watchCheckpointChanges: async function* () {
199
+ try {
200
+ yield {
201
+ base: {
202
+ checkpoint: 1n,
203
+ lsn: null,
204
+ getParameterSets: () => {
205
+ started.resolve();
206
+ return read.promise;
207
+ }
208
+ },
209
+ writeCheckpoint: null,
210
+ update: CHECKPOINT_INVALIDATE_ALL
211
+ };
212
+ } finally {
213
+ closed = true;
214
+ }
215
+ },
216
+ getChecksums
217
+ },
218
+ controller.signal,
219
+ config
220
+ );
221
+ const next = iterator.next();
222
+ await started.promise;
223
+ controller.abort();
224
+ await new Promise<void>((resolve) => setImmediate(resolve));
225
+ expect(closed).toBe(false);
226
+ read.resolve([]);
227
+ await expect(next).resolves.toEqual({ done: true, value: undefined });
228
+ expect(closed).toBe(true);
229
+ expect(getChecksums).not.toHaveBeenCalled();
230
+ });
231
+
232
+ test.each(['success', 'failure'] as const)(
233
+ 'waits for a shared checksum fetch to finish with %s before closing',
234
+ async (outcome) => {
235
+ const started = Promise.withResolvers<void>();
236
+ const fetch = Promise.withResolvers<ChecksumMap>();
237
+ const fetchChecksums = vi.fn((_batch: FetchPartialBucketChecksum[]) => fetch.promise);
238
+ const cache = new ChecksumCache({ fetchChecksums });
239
+ const controller = new AbortController();
240
+ const checkpoint = { checkpoint: 1n, lsn: null, getParameterSets: vi.fn() };
241
+ let closed = false;
242
+ let shared: Promise<PromiseSettledResult<ChecksumMap>>;
243
+ const iterator = response(
244
+ {
245
+ watchCheckpointChanges: async function* () {
246
+ try {
247
+ yield { base: checkpoint, writeCheckpoint: null, update: CHECKPOINT_INVALIDATE_ALL };
248
+ } finally {
249
+ closed = true;
250
+ }
251
+ },
252
+ getChecksums: (_checkpoint, buckets) => {
253
+ const first = cache.getChecksumMap(1n, buckets);
254
+ // A second connection shares this fetch and must survive the first disconnecting.
255
+ shared = Promise.allSettled([cache.getChecksumMap(1n, buckets)]).then(([result]) => result);
256
+ started.resolve();
257
+ return first;
258
+ }
259
+ },
260
+ controller.signal
261
+ );
262
+
263
+ const next = Promise.allSettled([iterator.next()]).then(([result]) => result);
264
+ await started.promise;
265
+ controller.abort(new Error('client disconnected'));
266
+ // In-flight storage work finishes normally before cancellation is observed.
267
+ await new Promise<void>((resolve) => setImmediate(resolve));
268
+ expect(closed).toBe(false);
269
+ expect(fetchChecksums).toHaveBeenCalledTimes(1);
270
+
271
+ if (outcome === 'success') {
272
+ const bucket = fetchChecksums.mock.calls[0][0][0].bucket;
273
+ const checksums = new Map([[bucket, { bucket, checksum: 42, count: 1 }]]);
274
+ fetch.resolve(checksums);
275
+ await expect(shared!).resolves.toEqual({ status: 'fulfilled', value: checksums });
276
+ await expect(next).resolves.toEqual({ status: 'fulfilled', value: { done: true, value: undefined } });
277
+ } else {
278
+ const error = new Error('checksum query failed after disconnect');
279
+ fetch.reject(error);
280
+ await expect(shared!).resolves.toEqual({ status: 'rejected', reason: error });
281
+ const result = await next;
282
+ expect(result.status).toBe('rejected');
283
+ if (result.status === 'rejected') {
284
+ const errors = result.reason instanceof AggregateError ? result.reason.errors : [result.reason];
285
+ expect(errors).toContain(error);
286
+ }
287
+ }
288
+ expect(closed).toBe(true);
289
+ }
290
+ );
291
+
292
+ test.each([
293
+ { reason: new Error('client disconnected') },
294
+ { reason: 'client disconnected' },
295
+ { reason: 0 },
296
+ { reason: false },
297
+ { reason: '' },
298
+ { reason: null },
299
+ { reason: NaN },
300
+ { reason: undefined }
301
+ ])('ends without error for abort reason $reason, before and during a read', async ({ reason }) => {
302
+ const watch = vi.fn(async function* () {});
303
+ const preAborted = response({ watchCheckpointChanges: watch }, AbortSignal.abort(reason));
304
+ await expect(preAborted.next()).resolves.toEqual({ done: true, value: undefined });
305
+ expect(watch).not.toHaveBeenCalled();
306
+
307
+ const started = Promise.withResolvers<void>();
308
+ const controller = new AbortController();
309
+ let closed = false;
310
+ const iterator = response(
311
+ {
312
+ watchCheckpointChanges: async function* ({ signal }) {
313
+ try {
314
+ started.resolve();
315
+ await new Promise<void>((_resolve, reject) => {
316
+ signal.addEventListener('abort', () => reject(signal.reason), { once: true });
317
+ });
318
+ } finally {
319
+ closed = true;
320
+ }
321
+ }
322
+ },
323
+ controller.signal
324
+ );
325
+ const next = iterator.next();
326
+ const completion = expect(next).resolves.toEqual({ done: true, value: undefined });
327
+ await started.promise;
328
+ controller.abort(reason);
329
+ await completion;
330
+ expect(closed).toBe(true);
331
+ });
332
+
333
+ test('does not suppress cleanup failures alongside cancellation', async () => {
334
+ const started = Promise.withResolvers<void>();
335
+ const controller = new AbortController();
336
+ const reason = new Error('client disconnected');
337
+ const cleanupError = new Error('watcher cleanup failed');
338
+ const iterator = response(
339
+ {
340
+ watchCheckpointChanges: async function* ({ signal }) {
341
+ try {
342
+ started.resolve();
343
+ await new Promise<void>((_resolve, reject) => {
344
+ signal.addEventListener('abort', () => reject(signal.reason), { once: true });
345
+ });
346
+ } finally {
347
+ throw cleanupError;
348
+ }
349
+ }
350
+ },
351
+ controller.signal
352
+ );
353
+ const outcome = iterator.next().catch((error) => error);
354
+ await started.promise;
355
+ controller.abort(reason);
356
+ const error = await outcome;
357
+ expect(error).toBeInstanceOf(AggregateError);
358
+ const failures = error.errors.flatMap((failure: unknown) =>
359
+ failure instanceof AggregateError ? failure.errors : [failure]
360
+ );
361
+ expect(failures).toContain(cleanupError);
362
+ });
363
+
364
+ test('does not suppress an unrelated failure when the signal is also aborted', async () => {
365
+ const controller = new AbortController();
366
+ const failure = new Error('storage failed');
367
+ const iterator = response(
368
+ {
369
+ watchCheckpointChanges: async function* () {
370
+ try {
371
+ throw failure;
372
+ } finally {
373
+ // The source error starts shutdown before the caller cancels in the same turn.
374
+ queueMicrotask(() => controller.abort(new Error('client disconnected')));
375
+ }
376
+ }
377
+ },
378
+ controller.signal
379
+ );
380
+ const outcome = await iterator.next().catch((error) => error);
381
+ expect(controller.signal.aborted).toBe(true);
382
+ expect(outcome).toBeInstanceOf(Error);
383
+ if (outcome instanceof AggregateError) {
384
+ expect(outcome.errors).toContain(failure);
385
+ } else {
386
+ expect(outcome).toBe(failure);
387
+ }
388
+ });
389
+ });
@@ -1,17 +1,28 @@
1
1
  import { acquireSemaphoreAbortable, isAbortError } from '@/index.js';
2
2
  import { Semaphore, SemaphoreInterface } from 'async-mutex';
3
- import { AbortError } from 'ix/aborterror.js';
3
+ import { setTimeout } from 'node:timers/promises';
4
4
  import { describe, expect, test, vi } from 'vitest';
5
5
 
6
6
  describe('isAbortError', () => {
7
- test('recognizes ix and native abort errors', () => {
7
+ test('recognizes DOM and Node abort errors', () => {
8
8
  const controller = new AbortController();
9
9
  controller.abort();
10
10
 
11
- expect(isAbortError(new AbortError())).toBe(true);
11
+ expect(isAbortError(new DOMException('Cancelled', 'AbortError'))).toBe(true);
12
+ expect(isAbortError(Object.assign(new Error('Cancelled'), { name: 'AbortError' }))).toBe(true);
12
13
  expect(isAbortError(controller.signal.reason)).toBe(true);
13
14
  expect(isAbortError(new Error('not aborted'))).toBe(false);
14
15
  });
16
+
17
+ test('recognizes timer cancellation with the original reason as its cause', async () => {
18
+ const controller = new AbortController();
19
+ const reason = new Error('cancelled');
20
+ const timer = setTimeout(60_000, undefined, { signal: controller.signal });
21
+ controller.abort(reason);
22
+ const error = await timer.catch((error) => error);
23
+ expect(isAbortError(error)).toBe(true);
24
+ expect(error.cause).toBe(reason);
25
+ });
15
26
  });
16
27
 
17
28
  describe('acquireSemaphoreAbortable', () => {