@powersync/service-core 1.27.2 → 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 +30 -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
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 {
|
|
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
|
|
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
|
|
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', () => {
|