@powersync/service-module-postgres-storage 0.16.3 → 0.18.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 (52) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/migrations/scripts/1782950400000-checkpoint-requested-at.d.ts +3 -0
  3. package/dist/migrations/scripts/1782950400000-checkpoint-requested-at.js +42 -0
  4. package/dist/migrations/scripts/1782950400000-checkpoint-requested-at.js.map +1 -0
  5. package/dist/migrations/scripts/1784900000000-source-metadata.d.ts +3 -0
  6. package/dist/migrations/scripts/1784900000000-source-metadata.js +22 -0
  7. package/dist/migrations/scripts/1784900000000-source-metadata.js.map +1 -0
  8. package/dist/storage/PostgresBucketStorageFactory.js +0 -2
  9. package/dist/storage/PostgresBucketStorageFactory.js.map +1 -1
  10. package/dist/storage/PostgresCompactor.d.ts +2 -0
  11. package/dist/storage/PostgresCompactor.js +20 -0
  12. package/dist/storage/PostgresCompactor.js.map +1 -1
  13. package/dist/storage/PostgresSyncRulesStorage.d.ts +16 -1
  14. package/dist/storage/PostgresSyncRulesStorage.js +200 -131
  15. package/dist/storage/PostgresSyncRulesStorage.js.map +1 -1
  16. package/dist/storage/batch/PostgresBucketBatch.d.ts +2 -20
  17. package/dist/storage/batch/PostgresBucketBatch.js +217 -240
  18. package/dist/storage/batch/PostgresBucketBatch.js.map +1 -1
  19. package/dist/storage/checkpoints/PostgresWriteCheckpointAPI.d.ts +1 -1
  20. package/dist/storage/checkpoints/PostgresWriteCheckpointAPI.js +156 -36
  21. package/dist/storage/checkpoints/PostgresWriteCheckpointAPI.js.map +1 -1
  22. package/dist/storage/current-data-store.d.ts +8 -2
  23. package/dist/storage/current-data-store.js +66 -11
  24. package/dist/storage/current-data-store.js.map +1 -1
  25. package/dist/types/models/SourceTable.d.ts +7 -2
  26. package/dist/types/models/SourceTable.js +6 -2
  27. package/dist/types/models/SourceTable.js.map +1 -1
  28. package/dist/types/models/WriteCheckpoint.d.ts +2 -0
  29. package/dist/types/models/WriteCheckpoint.js +5 -2
  30. package/dist/types/models/WriteCheckpoint.js.map +1 -1
  31. package/dist/utils/checkpoints.d.ts +9 -0
  32. package/dist/utils/checkpoints.js +26 -0
  33. package/dist/utils/checkpoints.js.map +1 -0
  34. package/package.json +8 -8
  35. package/src/migrations/scripts/1782950400000-checkpoint-requested-at.ts +51 -0
  36. package/src/migrations/scripts/1784900000000-source-metadata.ts +31 -0
  37. package/src/storage/PostgresBucketStorageFactory.ts +0 -3
  38. package/src/storage/PostgresCompactor.ts +24 -0
  39. package/src/storage/PostgresSyncRulesStorage.ts +219 -137
  40. package/src/storage/batch/PostgresBucketBatch.ts +227 -246
  41. package/src/storage/checkpoints/PostgresWriteCheckpointAPI.ts +165 -37
  42. package/src/storage/current-data-store.ts +66 -11
  43. package/src/types/models/SourceTable.ts +7 -2
  44. package/src/types/models/WriteCheckpoint.ts +5 -2
  45. package/src/utils/checkpoints.ts +31 -0
  46. package/test/src/__snapshots__/storage_sync.test.ts.snap +0 -582
  47. package/test/src/checkpoint_notifications.test.ts +522 -0
  48. package/test/src/storage.test.ts +214 -5
  49. package/test/src/storage_compacting.test.ts +3 -3
  50. package/test/src/storage_sync.test.ts +48 -4
  51. package/test/tsconfig.json +1 -1
  52. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,522 @@
1
+ import { storage, updateSyncRulesFromYaml } from '@powersync/service-core';
2
+ import { test_utils } from '@powersync/service-core-tests';
3
+ import * as pgwire from '@powersync/service-jpgwire';
4
+ import { expect, test, TestContext, vi } from 'vitest';
5
+ import * as checkpointUtils from '../../src/utils/checkpoints.js';
6
+ import { POSTGRES_STORAGE_FACTORY } from './util.js';
7
+
8
+ /**
9
+ * Reproduces checkpoints being committed while the Postgres notification connection is unavailable:
10
+ *
11
+ * 1. Start the checkpoint stream at 1/0 and configure a short idle session timeout on the connection
12
+ * that executed LISTEN.
13
+ * 2. Wait for Postgres to destroy that connection and verify its `whenDestroyed` callback fires.
14
+ * 3. Pause the replacement connection immediately before it executes LISTEN, then commit 2/0 and 3/0.
15
+ * Those notifications cannot be received because no notification channel is registered at that point.
16
+ * 4. Allow LISTEN to complete. The `channels-registered` event writes `null` to the watcher, which
17
+ * must re-query storage and recover the latest missed checkpoint, 3/0.
18
+ * 5. Commit 4/0 after the channel is restored and verify it is delivered as a normal live notification.
19
+ *
20
+ * The stream must therefore emit 1/0, 3/0, and 4/0 in order, proving that reconnect catches up to the
21
+ * latest persisted state without emitting a stale missed checkpoint or losing later live notifications.
22
+ */
23
+ test('checkpoint stream catches up in order after the notification connection is recreated', async (context) => {
24
+ await using factory = await POSTGRES_STORAGE_FACTORY.factory();
25
+ await requireIdleSessionTimeout(factory, context);
26
+ const reconnect = controlNotificationReconnect(factory, context);
27
+
28
+ const syncRules = await factory.configureSyncRules(
29
+ updateSyncRulesFromYaml(
30
+ `
31
+ bucket_definitions:
32
+ global:
33
+ data: []
34
+ `,
35
+ { validate: false }
36
+ )
37
+ );
38
+ const bucketStorage = factory.getInstance(syncRules.persisted_sync_rules!);
39
+
40
+ await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
41
+ await writer.markAllSnapshotDone('1/0');
42
+ await writer.keepalive('1/0');
43
+
44
+ const abortController = new AbortController();
45
+ context.onTestFinished(() => abortController.abort());
46
+ const iterator = bucketStorage
47
+ .watchCheckpointChanges({ user_id: 'user', signal: abortController.signal })
48
+ [Symbol.asyncIterator]();
49
+
50
+ await expect(
51
+ resolvesWithin({ promise: iterator.next(), description: 'Initial checkpoint should be returned' })
52
+ ).resolves.toMatchObject({
53
+ done: false,
54
+ value: { base: { checkpoint: 0n, lsn: '1/0' } }
55
+ });
56
+
57
+ const notificationConnection = await resolvesWithin({
58
+ promise: reconnect.initialConnection,
59
+ description: 'Notification connection should register LISTEN'
60
+ });
61
+ await notificationConnection.query({ statement: `SET idle_session_timeout = '250ms'` });
62
+ await resolvesWithin({
63
+ promise: notificationConnection.whenDestroyed,
64
+ description: 'Notification connection should be destroyed after the idle timeout'
65
+ });
66
+ expect(reconnect.connectionDestroyed).toHaveBeenCalledOnce();
67
+
68
+ try {
69
+ // Hold the replacement connection immediately before LISTEN so these updates
70
+ // cannot produce notifications for this process.
71
+ await resolvesWithin({ promise: reconnect.listenStarted, description: 'Connection pool should try to listen' });
72
+ const recoveredWriteCheckpoint = await createManagedWriteCheckpoint(bucketStorage, '3/0');
73
+ await writer.keepalive('2/0');
74
+ await writer.keepalive('3/0');
75
+ await expect(bucketStorage.getCheckpoint()).resolves.toMatchObject({ checkpoint: 0n, lsn: '3/0' });
76
+
77
+ const recoveredCheckpoint = iterator.next();
78
+ reconnect.allowListen();
79
+ await resolvesWithin({ promise: reconnect.listenCompleted, description: 'Replacement LISTEN should complete' });
80
+
81
+ await expect(
82
+ resolvesWithin({ promise: recoveredCheckpoint, description: 'The checkpoint should be emitted after recovery' })
83
+ ).resolves.toMatchObject({
84
+ done: false,
85
+ value: {
86
+ base: { checkpoint: 0n, lsn: '3/0' },
87
+ writeCheckpoint: recoveredWriteCheckpoint
88
+ }
89
+ });
90
+
91
+ const liveWriteCheckpoint = await createManagedWriteCheckpoint(bucketStorage, '4/0');
92
+ await writer.keepalive('4/0');
93
+ await expect(
94
+ resolvesWithin({
95
+ promise: iterator.next(),
96
+ description: 'Live checkpoint should be emitted after LISTEN is restored'
97
+ })
98
+ ).resolves.toMatchObject({
99
+ done: false,
100
+ value: {
101
+ base: { checkpoint: 0n, lsn: '4/0' },
102
+ writeCheckpoint: liveWriteCheckpoint
103
+ }
104
+ });
105
+ } finally {
106
+ reconnect.allowListen();
107
+ }
108
+ }, 15_000);
109
+
110
+ /**
111
+ * Reproduces the checkpoint advancing while the reconnect-triggered storage query is already in flight:
112
+ *
113
+ * 1. Recreate the notification connection as above, commit 2/0 while LISTEN is unavailable, then allow
114
+ * LISTEN to complete so its `null` event starts the recovery query.
115
+ * 2. Let that query read 2/0 from Postgres, but pause it before returning the result to the watcher.
116
+ * 3. While the query is paused and LISTEN is active, commit 3/0. Its notification is buffered behind the
117
+ * in-flight 2/0 query result.
118
+ * 4. Release the stale 2/0 query result. The last-value-buffered public stream must emit the newer 3/0,
119
+ * rather than exposing 3/0 followed later by the stale 2/0.
120
+ * 5. Commit 4/0 and verify it is emitted next, proving no delayed result can regress the stream afterward.
121
+ *
122
+ * This covers the boundary between reconnect recovery and normal notifications: concurrent progress may
123
+ * supersede an in-flight query result, but observable checkpoints must remain monotonic and converge on the
124
+ * latest persisted value. The immediate supersession of 2/0 by 3/0 is primarily provided by the
125
+ * last-value-buffered sink; the monotonic watcher comparison is the additional guard that prevents an older
126
+ * result from being emitted after a newer checkpoint.
127
+ */
128
+ test('checkpoint stream emits the latest value when the checkpoint advances during the reconnect query', async (context) => {
129
+ await using factory = await POSTGRES_STORAGE_FACTORY.factory();
130
+ await requireIdleSessionTimeout(factory, context);
131
+ const reconnect = controlNotificationReconnect(factory, context);
132
+
133
+ const syncRules = await factory.configureSyncRules(
134
+ updateSyncRulesFromYaml(
135
+ `
136
+ bucket_definitions:
137
+ global:
138
+ data: []
139
+ `,
140
+ { validate: false }
141
+ )
142
+ );
143
+ const bucketStorage = factory.getInstance(syncRules.persisted_sync_rules!);
144
+
145
+ await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
146
+ await writer.markAllSnapshotDone('1/0');
147
+ await writer.keepalive('1/0');
148
+
149
+ const abortController = new AbortController();
150
+ context.onTestFinished(() => abortController.abort());
151
+ const iterator = bucketStorage
152
+ .watchCheckpointChanges({ user_id: 'user', signal: abortController.signal })
153
+ [Symbol.asyncIterator]();
154
+
155
+ await expect(
156
+ resolvesWithin({ promise: iterator.next(), description: 'Initial checkpoint should be returned' })
157
+ ).resolves.toMatchObject({
158
+ done: false,
159
+ value: { base: { checkpoint: 0n, lsn: '1/0' } }
160
+ });
161
+
162
+ const notificationConnection = await resolvesWithin({
163
+ promise: reconnect.initialConnection,
164
+ description: 'Notification connection should register LISTEN'
165
+ });
166
+ await notificationConnection.query({ statement: `SET idle_session_timeout = '250ms'` });
167
+ await resolvesWithin({
168
+ promise: notificationConnection.whenDestroyed,
169
+ description: 'Notification connection should be destroyed after the idle timeout'
170
+ });
171
+ expect(reconnect.connectionDestroyed).toHaveBeenCalledOnce();
172
+
173
+ const queryResultCaptured = Promise.withResolvers<void>();
174
+ const allowQueryResult = Promise.withResolvers<void>();
175
+ const originalQuery = checkpointUtils.getActiveCheckpointDocument;
176
+ let delayActiveCheckpointQuery = true;
177
+ const activeCheckpointQuerySpy = vi
178
+ .spyOn(checkpointUtils, 'getActiveCheckpointDocument')
179
+ .mockImplementation(async (options) => {
180
+ const result = await originalQuery(options);
181
+ if (delayActiveCheckpointQuery) {
182
+ delayActiveCheckpointQuery = false;
183
+ queryResultCaptured.resolve();
184
+ await allowQueryResult.promise;
185
+ }
186
+ return result;
187
+ });
188
+
189
+ try {
190
+ await resolvesWithin({
191
+ promise: reconnect.listenStarted,
192
+ description: 'Replacement connection should attempt LISTEN'
193
+ });
194
+
195
+ await createManagedWriteCheckpoint(bucketStorage, '2/0');
196
+ await writer.keepalive('2/0');
197
+
198
+ const firstRecoveredCheckpoint = iterator.next();
199
+ reconnect.allowListen();
200
+ await resolvesWithin({ promise: reconnect.listenCompleted, description: 'Replacement LISTEN should complete' });
201
+ await resolvesWithin({
202
+ promise: queryResultCaptured.promise,
203
+ description: 'Recovery query should read the active checkpoint'
204
+ });
205
+ await expect(bucketStorage.getCheckpoint()).resolves.toMatchObject({ checkpoint: 0n, lsn: '2/0' });
206
+
207
+ // The query has read 2/0 but has not returned it to the watcher yet. Advance
208
+ // to 3/0 so its notification is buffered behind the in-flight query result.
209
+ const secondWriteCheckpoint = await createManagedWriteCheckpoint(bucketStorage, '3/0');
210
+ await writer.keepalive('3/0');
211
+ allowQueryResult.resolve();
212
+
213
+ // The public stream is last-value buffered, so 3/0 supersedes the stale 2/0
214
+ // query result before it is emitted to this consumer.
215
+ await expect(
216
+ resolvesWithin({
217
+ promise: firstRecoveredCheckpoint,
218
+ description: 'Latest checkpoint should supersede the delayed query result'
219
+ })
220
+ ).resolves.toMatchObject({
221
+ done: false,
222
+ value: {
223
+ base: { checkpoint: 0n, lsn: '3/0' },
224
+ writeCheckpoint: secondWriteCheckpoint
225
+ }
226
+ });
227
+
228
+ const liveWriteCheckpoint = await createManagedWriteCheckpoint(bucketStorage, '4/0');
229
+ await writer.keepalive('4/0');
230
+ await expect(
231
+ resolvesWithin({ promise: iterator.next(), description: 'Subsequent live checkpoint should be emitted' })
232
+ ).resolves.toMatchObject({
233
+ done: false,
234
+ value: {
235
+ base: { checkpoint: 0n, lsn: '4/0' },
236
+ writeCheckpoint: liveWriteCheckpoint
237
+ }
238
+ });
239
+ } finally {
240
+ reconnect.allowListen();
241
+ allowQueryResult.resolve();
242
+ activeCheckpointQuerySpy.mockRestore();
243
+ }
244
+ }, 15_000);
245
+
246
+ /**
247
+ * Reproduces reconnecting when no checkpoint was committed during the notification outage:
248
+ *
249
+ * 1. Start the active-checkpoint watcher at 1/0, destroy its LISTEN connection, and pause the replacement
250
+ * connection before it restores LISTEN.
251
+ * 2. Restore LISTEN without committing anything. The registration callback writes `null`, causing the
252
+ * watcher to query storage and read the same 1/0 checkpoint it emitted before the disconnect.
253
+ * 3. Verify the next iterator read remains pending after that query completes. Re-registering the channel
254
+ * must not turn an unchanged persisted checkpoint into a duplicate stream event.
255
+ * 4. Commit 2/0 and verify the already-pending read resolves with that actual advancement.
256
+ *
257
+ * This test observes the internal active-checkpoint stream directly so the public write-checkpoint layer's
258
+ * separate deduplication cannot hide a duplicate produced by the reconnect watcher itself.
259
+ */
260
+ test('active checkpoint stream does not duplicate an unchanged checkpoint after reconnect', async (context) => {
261
+ await using factory = await POSTGRES_STORAGE_FACTORY.factory();
262
+ await requireIdleSessionTimeout(factory, context);
263
+ const reconnect = controlNotificationReconnect(factory, context);
264
+
265
+ const syncRules = await factory.configureSyncRules(
266
+ updateSyncRulesFromYaml(
267
+ `
268
+ bucket_definitions:
269
+ global:
270
+ data: []
271
+ `,
272
+ { validate: false }
273
+ )
274
+ );
275
+ const bucketStorage = factory.getInstance(syncRules.persisted_sync_rules!);
276
+
277
+ await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
278
+ await writer.markAllSnapshotDone('1/0');
279
+ await writer.keepalive('1/0');
280
+
281
+ const abortController = new AbortController();
282
+ context.onTestFinished(() => abortController.abort());
283
+
284
+ // Observe the protected watcher directly for this assertion. watchCheckpointChanges()
285
+ // independently suppresses unchanged operation and write checkpoints, which would hide
286
+ // a duplicate emitted by watchActiveCheckpoint() and let this regression test pass incorrectly.
287
+ const iterator = (
288
+ bucketStorage as unknown as {
289
+ watchActiveCheckpoint(signal: AbortSignal): AsyncIterable<storage.ReplicationCheckpoint>;
290
+ }
291
+ )
292
+ .watchActiveCheckpoint(abortController.signal)
293
+ [Symbol.asyncIterator]();
294
+
295
+ await expect(
296
+ resolvesWithin({ promise: iterator.next(), description: 'Initial active checkpoint should be returned' })
297
+ ).resolves.toMatchObject({
298
+ done: false,
299
+ value: { checkpoint: 0n, lsn: '1/0' }
300
+ });
301
+
302
+ const notificationConnection = await resolvesWithin({
303
+ promise: reconnect.initialConnection,
304
+ description: 'Notification connection should register LISTEN'
305
+ });
306
+ // Simulate a small idle timeout, which will cause the notification conection to close
307
+ await notificationConnection.query({ statement: `SET idle_session_timeout = '250ms'` });
308
+ await resolvesWithin({
309
+ promise: notificationConnection.whenDestroyed,
310
+ description: 'Notification connection should be destroyed after the idle timeout'
311
+ });
312
+ expect(reconnect.connectionDestroyed).toHaveBeenCalledOnce();
313
+
314
+ const unchangedCheckpointQueried = Promise.withResolvers<void>();
315
+ const originalQuery = checkpointUtils.getActiveCheckpointDocument;
316
+ let observeActiveCheckpointQuery = true;
317
+ const activeCheckpointQuerySpy = vi
318
+ .spyOn(checkpointUtils, 'getActiveCheckpointDocument')
319
+ .mockImplementation(async (options) => {
320
+ const result = await originalQuery(options);
321
+ if (observeActiveCheckpointQuery) {
322
+ observeActiveCheckpointQuery = false;
323
+ unchangedCheckpointQueried.resolve();
324
+ }
325
+ return result;
326
+ });
327
+
328
+ try {
329
+ await resolvesWithin({
330
+ promise: reconnect.listenStarted,
331
+ description: 'Replacement connection should attempt LISTEN'
332
+ });
333
+ const nextCheckpoint = iterator.next();
334
+ reconnect.allowListen();
335
+ await resolvesWithin({ promise: reconnect.listenCompleted, description: 'Replacement LISTEN should complete' });
336
+ await resolvesWithin({
337
+ promise: unchangedCheckpointQueried.promise,
338
+ description: 'Reconnect query should return the unchanged active checkpoint'
339
+ });
340
+
341
+ const duplicateEmitted = await Promise.race([
342
+ nextCheckpoint.then(() => true),
343
+ new Promise<false>((resolve) => setTimeout(() => resolve(false), 100))
344
+ ]);
345
+ expect(duplicateEmitted).toBe(false);
346
+
347
+ await writer.keepalive('2/0');
348
+ await expect(
349
+ resolvesWithin({
350
+ promise: nextCheckpoint,
351
+ description: 'Pending iterator should resolve after the checkpoint advances'
352
+ })
353
+ ).resolves.toMatchObject({
354
+ done: false,
355
+ value: { checkpoint: 0n, lsn: '2/0' }
356
+ });
357
+ } finally {
358
+ reconnect.allowListen();
359
+ activeCheckpointQuerySpy.mockRestore();
360
+ }
361
+ }, 15_000);
362
+
363
+ test('active checkpoint stream closes when a new replication stream is activated', async (context) => {
364
+ await using factory = await POSTGRES_STORAGE_FACTORY.factory();
365
+
366
+ const initialSyncRules = await factory.configureSyncRules(
367
+ updateSyncRulesFromYaml(
368
+ `
369
+ bucket_definitions:
370
+ initial:
371
+ data: []
372
+ `,
373
+ { validate: false }
374
+ )
375
+ );
376
+ const initialStorage = factory.getInstance(initialSyncRules.persisted_sync_rules!);
377
+
378
+ await using initialWriter = await initialStorage.createWriter(test_utils.BATCH_OPTIONS);
379
+ await initialWriter.markAllSnapshotDone('1/0');
380
+ await initialWriter.keepalive('1/0');
381
+
382
+ const abortController = new AbortController();
383
+ context.onTestFinished(() => abortController.abort());
384
+ const iterator = (
385
+ initialStorage as unknown as {
386
+ watchActiveCheckpoint(signal: AbortSignal): AsyncIterable<storage.ReplicationCheckpoint>;
387
+ }
388
+ )
389
+ .watchActiveCheckpoint(abortController.signal)
390
+ [Symbol.asyncIterator]();
391
+
392
+ await expect(
393
+ resolvesWithin({ promise: iterator.next(), description: 'Initial active checkpoint should be returned' })
394
+ ).resolves.toMatchObject({
395
+ done: false,
396
+ value: { checkpoint: 0n, lsn: '1/0' }
397
+ });
398
+
399
+ const streamClosed = iterator.next();
400
+ const nextSyncRules = await factory.configureSyncRules(
401
+ updateSyncRulesFromYaml(
402
+ `
403
+ bucket_definitions:
404
+ replacement:
405
+ data: []
406
+ `,
407
+ { validate: false }
408
+ )
409
+ );
410
+ const nextStorage = factory.getInstance(nextSyncRules.persisted_sync_rules!);
411
+
412
+ await using nextWriter = await nextStorage.createWriter(test_utils.BATCH_OPTIONS);
413
+ await nextWriter.markAllSnapshotDone('2/0');
414
+ await nextWriter.keepalive('2/0');
415
+
416
+ await expect(
417
+ resolvesWithin({ promise: streamClosed, description: 'Old active checkpoint stream should close on activation' })
418
+ ).resolves.toEqual({ done: true, value: undefined });
419
+ }, 15_000);
420
+
421
+ type PostgresTestFactory = Awaited<ReturnType<typeof POSTGRES_STORAGE_FACTORY.factory>>;
422
+
423
+ async function requireIdleSessionTimeout(factory: PostgresTestFactory, context: TestContext) {
424
+ const result = await factory.db.query('SHOW server_version_num');
425
+ const serverVersionNumber = Number(result.rows[0].decodeWithoutCustomTypes(0));
426
+ if (serverVersionNumber < 140_000) {
427
+ context.skip('idle_session_timeout requires PostgreSQL 14 or newer');
428
+ }
429
+ }
430
+
431
+ function controlNotificationReconnect(factory: PostgresTestFactory, context: TestContext) {
432
+ const firstListen = Promise.withResolvers<pgwire.PgConnection>();
433
+ const reconnectListenStarted = Promise.withResolvers<void>();
434
+ const allowReconnectListen = Promise.withResolvers<void>();
435
+ const reconnectListenCompleted = Promise.withResolvers<void>();
436
+ const notificationConnectionDestroyed = vi.fn();
437
+ let listenCount = 0;
438
+ let notificationRegistrationCount = 0;
439
+
440
+ context.onTestFinished(() => allowReconnectListen.resolve());
441
+ const disposeConnectionListener = factory.db.registerListener({
442
+ notificationEvent: (event) => {
443
+ if (event.type != 'channels-registered') {
444
+ return;
445
+ }
446
+ notificationRegistrationCount++;
447
+ if (notificationRegistrationCount === 2) {
448
+ reconnectListenCompleted.resolve();
449
+ }
450
+ },
451
+ connectionCreated: async (connection) => {
452
+ const originalQuery = connection.query.bind(connection) as (...args: any[]) => Promise<any>;
453
+
454
+ vi.spyOn(connection, 'query').mockImplementation(async (...args: any[]) => {
455
+ const statement = args[0]?.statement;
456
+ if (typeof statement === 'string' && statement.startsWith('LISTEN ')) {
457
+ listenCount++;
458
+
459
+ if (listenCount === 1) {
460
+ const result = await originalQuery(...args);
461
+ connection.whenDestroyed.then(notificationConnectionDestroyed);
462
+ firstListen.resolve(connection);
463
+ return result;
464
+ }
465
+
466
+ if (listenCount === 2) {
467
+ reconnectListenStarted.resolve();
468
+ await allowReconnectListen.promise;
469
+ }
470
+ }
471
+
472
+ return originalQuery(...args);
473
+ });
474
+ }
475
+ });
476
+ context.onTestFinished(disposeConnectionListener);
477
+
478
+ return {
479
+ initialConnection: firstListen.promise,
480
+ listenStarted: reconnectListenStarted.promise,
481
+ listenCompleted: reconnectListenCompleted.promise,
482
+ allowListen: () => allowReconnectListen.resolve(),
483
+ connectionDestroyed: notificationConnectionDestroyed
484
+ };
485
+ }
486
+
487
+ async function createManagedWriteCheckpoint(
488
+ bucketStorage: storage.SyncRulesBucketStorage,
489
+ lsn: string
490
+ ): Promise<bigint> {
491
+ const checkpoints = await bucketStorage.createManagedWriteCheckpoints([
492
+ {
493
+ heads: { '1': lsn },
494
+ user_id: 'user'
495
+ }
496
+ ]);
497
+ const checkpoint = checkpoints.writeCheckpoints.get('user');
498
+ expect(checkpoint).toBeDefined();
499
+ return checkpoint!;
500
+ }
501
+
502
+ async function resolvesWithin<T>({
503
+ promise,
504
+ description,
505
+ timeoutMs = 5_000
506
+ }: {
507
+ promise: Promise<T>;
508
+ description: string;
509
+ timeoutMs?: number;
510
+ }): Promise<T> {
511
+ let timeout: NodeJS.Timeout | undefined;
512
+ try {
513
+ return await Promise.race([
514
+ promise,
515
+ new Promise<T>((_, reject) => {
516
+ timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${description}`)), timeoutMs);
517
+ })
518
+ ]);
519
+ } finally {
520
+ clearTimeout(timeout);
521
+ }
522
+ }