@powersync/service-module-postgres 0.18.0 → 0.19.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.
@@ -1,11 +1,10 @@
1
+ import { METRICS_HELPER } from '@powersync/service-core-tests';
2
+ import { ReplicationMetric } from '@powersync/service-types';
3
+ import * as timers from 'node:timers/promises';
1
4
  import { describe, expect, test } from 'vitest';
2
5
  import { env } from './env.js';
3
6
  import { describeWithStorage, StorageVersionTestContext } from './util.js';
4
7
  import { WalStreamTestContext } from './wal_stream_utils.js';
5
- import { METRICS_HELPER } from '@powersync/service-core-tests';
6
- import { ReplicationMetric } from '@powersync/service-types';
7
- import * as timers from 'node:timers/promises';
8
- import { ReplicationAbortedError } from '@powersync/lib-services-framework';
9
8
 
10
9
  describe.skipIf(!(env.CI || env.SLOW_TESTS))('batch replication', function () {
11
10
  describeWithStorage({ timeout: 240_000 }, function ({ factory, storageVersion }) {
@@ -80,8 +79,7 @@ async function testResumingReplication(
80
79
  await context.dispose();
81
80
  })();
82
81
  // This confirms that initial replication was interrupted
83
- const error = await p.catch((e) => e);
84
- expect(error).toBeInstanceOf(ReplicationAbortedError);
82
+ await expect(p).rejects.toThrowError();
85
83
  done = true;
86
84
  } finally {
87
85
  done = true;
@@ -111,7 +109,6 @@ async function testResumingReplication(
111
109
  await context2.loadNextSyncRules();
112
110
  await context2.replicateSnapshot();
113
111
 
114
- context2.startStreaming();
115
112
  const data = await context2.getBucketData('global[]', undefined, {});
116
113
 
117
114
  const deletedRowOps = data.filter(
@@ -134,14 +131,14 @@ async function testResumingReplication(
134
131
  // so it's not in the resulting ops at all.
135
132
  }
136
133
 
137
- expect(updatedRowOps.length).toEqual(2);
134
+ expect(updatedRowOps.length).toBeGreaterThanOrEqual(2);
138
135
  // description for the first op could be 'foo' or 'update1'.
139
136
  // We only test the final version.
140
- expect(JSON.parse(updatedRowOps[1].data as string).description).toEqual('update1');
137
+ expect(JSON.parse(updatedRowOps[updatedRowOps.length - 1].data as string).description).toEqual('update1');
141
138
 
142
- expect(insertedRowOps.length).toEqual(2);
139
+ expect(insertedRowOps.length).toBeGreaterThanOrEqual(1);
143
140
  expect(JSON.parse(insertedRowOps[0].data as string).description).toEqual('insert1');
144
- expect(JSON.parse(insertedRowOps[1].data as string).description).toEqual('insert1');
141
+ expect(JSON.parse(insertedRowOps[insertedRowOps.length - 1].data as string).description).toEqual('insert1');
145
142
 
146
143
  // 1000 of test_data1 during first replication attempt.
147
144
  // N >= 1000 of test_data2 during first replication attempt.
@@ -152,12 +149,12 @@ async function testResumingReplication(
152
149
  // This adds 2 ops.
153
150
  // We expect this to be 11002 for stopAfter: 2000, and 11004 for stopAfter: 8000.
154
151
  // However, this is not deterministic.
155
- const expectedCount = 11002 + deletedRowOps.length;
152
+ const expectedCount = 11000 - 2 + insertedRowOps.length + updatedRowOps.length + deletedRowOps.length;
156
153
  expect(data.length).toEqual(expectedCount);
157
154
 
158
155
  const replicatedCount =
159
156
  ((await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.ROWS_REPLICATED)) ?? 0) - startRowCount;
160
157
 
161
158
  // With resumable replication, there should be no need to re-replicate anything.
162
- expect(replicatedCount).toEqual(expectedCount);
159
+ expect(replicatedCount).toBeGreaterThanOrEqual(expectedCount);
163
160
  }
@@ -1,7 +1,7 @@
1
- import { describe, expect, test } from 'vitest';
2
- import { clearTestDb, connectPgPool } from './util.js';
3
1
  import { PostgresRouteAPIAdapter } from '@module/api/PostgresRouteAPIAdapter.js';
4
2
  import { TYPE_INTEGER, TYPE_REAL, TYPE_TEXT } from '@powersync/service-sync-rules';
3
+ import { describe, expect, test } from 'vitest';
4
+ import { clearTestDb, connectPgPool } from './util.js';
5
5
 
6
6
  describe('PostgresRouteAPIAdapter tests', () => {
7
7
  test('infers connection schema', async () => {
@@ -39,7 +39,6 @@ function defineTests({ factory, storageVersion }: StorageVersionTestContext) {
39
39
  await pool.query(`INSERT INTO test_data(id, description) VALUES('t1', 'test1')`);
40
40
 
41
41
  await context.replicateSnapshot();
42
- context.startStreaming();
43
42
 
44
43
  await pool.query(`INSERT INTO test_data(id, description) VALUES('t2', 'test2')`);
45
44
 
@@ -62,13 +61,17 @@ function defineTests({ factory, storageVersion }: StorageVersionTestContext) {
62
61
  // Truncate - order doesn't matter
63
62
  expect(data.slice(2, 4).sort(compareIds)).toMatchObject([REMOVE_T1, REMOVE_T2]);
64
63
 
65
- expect(data.slice(4)).toMatchObject([
66
- // Snapshot insert
67
- PUT_T3,
68
- // Replicated insert
69
- // We may eventually be able to de-duplicate this
64
+ expect(data.slice(4, 5)).toMatchObject([
65
+ // Snapshot and/or replication insert
70
66
  PUT_T3
71
67
  ]);
68
+
69
+ if (data.length > 5) {
70
+ expect(data.slice(5)).toMatchObject([
71
+ // Replicated insert (optional duplication)
72
+ PUT_T3
73
+ ]);
74
+ }
72
75
  });
73
76
 
74
77
  test('add table', async () => {
@@ -78,7 +81,6 @@ function defineTests({ factory, storageVersion }: StorageVersionTestContext) {
78
81
  const { pool } = context;
79
82
 
80
83
  await context.replicateSnapshot();
81
- context.startStreaming();
82
84
 
83
85
  await pool.query(`CREATE TABLE test_data(id text primary key, description text)`);
84
86
  await pool.query(`INSERT INTO test_data(id, description) VALUES('t1', 'test1')`);
@@ -86,17 +88,10 @@ function defineTests({ factory, storageVersion }: StorageVersionTestContext) {
86
88
  const data = await context.getBucketData('global[]');
87
89
 
88
90
  // "Reduce" the bucket to get a stable output to test.
91
+ // The specific operation sequence may vary depending on storage implementation, so just check the end result.
89
92
  // slice(1) to skip the CLEAR op.
90
93
  const reduced = reduceBucket(data).slice(1);
91
94
  expect(reduced.sort(compareIds)).toMatchObject([PUT_T1]);
92
-
93
- expect(data).toMatchObject([
94
- // Snapshot insert
95
- PUT_T1,
96
- // Replicated insert
97
- // We may eventually be able to de-duplicate this
98
- PUT_T1
99
- ]);
100
95
  });
101
96
 
102
97
  test('rename table (1)', async () => {
@@ -110,7 +105,6 @@ function defineTests({ factory, storageVersion }: StorageVersionTestContext) {
110
105
  await pool.query(`INSERT INTO test_data_old(id, description) VALUES('t1', 'test1')`);
111
106
 
112
107
  await context.replicateSnapshot();
113
- context.startStreaming();
114
108
 
115
109
  await pool.query(
116
110
  { statement: `ALTER TABLE test_data_old RENAME TO test_data` },
@@ -130,11 +124,13 @@ function defineTests({ factory, storageVersion }: StorageVersionTestContext) {
130
124
  PUT_T1,
131
125
  PUT_T2
132
126
  ]);
133
- expect(data.slice(2)).toMatchObject([
134
- // Replicated insert
135
- // We may eventually be able to de-duplicate this
136
- PUT_T2
137
- ]);
127
+ if (data.length > 2) {
128
+ expect(data.slice(2)).toMatchObject([
129
+ // Replicated insert
130
+ // May be de-duplicated
131
+ PUT_T2
132
+ ]);
133
+ }
138
134
  });
139
135
 
140
136
  test('rename table (2)', async () => {
@@ -153,7 +149,6 @@ function defineTests({ factory, storageVersion }: StorageVersionTestContext) {
153
149
  await pool.query(`INSERT INTO test_data1(id, description) VALUES('t1', 'test1')`);
154
150
 
155
151
  await context.replicateSnapshot();
156
- context.startStreaming();
157
152
 
158
153
  await pool.query(
159
154
  { statement: `ALTER TABLE test_data1 RENAME TO test_data2` },
@@ -183,11 +178,13 @@ function defineTests({ factory, storageVersion }: StorageVersionTestContext) {
183
178
  putOp('test_data2', { id: 't1', description: 'test1' }),
184
179
  putOp('test_data2', { id: 't2', description: 'test2' })
185
180
  ]);
186
- expect(data.slice(4)).toMatchObject([
187
- // Replicated insert
188
- // We may eventually be able to de-duplicate this
189
- putOp('test_data2', { id: 't2', description: 'test2' })
190
- ]);
181
+ if (data.length > 4) {
182
+ expect(data.slice(4)).toMatchObject([
183
+ // Replicated insert
184
+ // This may be de-duplicated
185
+ putOp('test_data2', { id: 't2', description: 'test2' })
186
+ ]);
187
+ }
191
188
  });
192
189
 
193
190
  test('rename table (3)', async () => {
@@ -202,7 +199,6 @@ function defineTests({ factory, storageVersion }: StorageVersionTestContext) {
202
199
  await pool.query(`INSERT INTO test_data(id, description) VALUES('t1', 'test1')`);
203
200
 
204
201
  await context.replicateSnapshot();
205
- context.startStreaming();
206
202
 
207
203
  await pool.query(
208
204
  { statement: `ALTER TABLE test_data RENAME TO test_data_na` },
@@ -237,7 +233,6 @@ function defineTests({ factory, storageVersion }: StorageVersionTestContext) {
237
233
  await pool.query(`INSERT INTO test_data(id, description) VALUES('t1', 'test1')`);
238
234
 
239
235
  await context.replicateSnapshot();
240
- context.startStreaming();
241
236
 
242
237
  await pool.query(
243
238
  { statement: `ALTER TABLE test_data REPLICA IDENTITY FULL` },
@@ -262,11 +257,13 @@ function defineTests({ factory, storageVersion }: StorageVersionTestContext) {
262
257
  // Snapshot - order doesn't matter
263
258
  expect(data.slice(2, 4).sort(compareIds)).toMatchObject([PUT_T1, PUT_T2]);
264
259
 
265
- expect(data.slice(4).sort(compareIds)).toMatchObject([
266
- // Replicated insert
267
- // We may eventually be able to de-duplicate this
268
- PUT_T2
269
- ]);
260
+ if (data.length > 4) {
261
+ expect(data.slice(4).sort(compareIds)).toMatchObject([
262
+ // Replicated insert
263
+ // This may be de-duplicated
264
+ PUT_T2
265
+ ]);
266
+ }
270
267
  });
271
268
 
272
269
  test('change full replica id by adding column', async () => {
@@ -283,7 +280,6 @@ function defineTests({ factory, storageVersion }: StorageVersionTestContext) {
283
280
  await pool.query(`INSERT INTO test_data(id, description) VALUES('t1', 'test1')`);
284
281
 
285
282
  await context.replicateSnapshot();
286
- context.startStreaming();
287
283
 
288
284
  await pool.query(
289
285
  { statement: `ALTER TABLE test_data ADD COLUMN other TEXT` },
@@ -305,11 +301,13 @@ function defineTests({ factory, storageVersion }: StorageVersionTestContext) {
305
301
  putOp('test_data', { id: 't2', description: 'test2', other: null })
306
302
  ]);
307
303
 
308
- expect(data.slice(4).sort(compareIds)).toMatchObject([
309
- // Replicated insert
310
- // We may eventually be able to de-duplicate this
311
- putOp('test_data', { id: 't2', description: 'test2', other: null })
312
- ]);
304
+ if (data.length > 4) {
305
+ expect(data.slice(4).sort(compareIds)).toMatchObject([
306
+ // Replicated insert
307
+ // This may be de-duplicated
308
+ putOp('test_data', { id: 't2', description: 'test2', other: null })
309
+ ]);
310
+ }
313
311
  });
314
312
 
315
313
  test('change default replica id by changing column type', async () => {
@@ -323,7 +321,6 @@ function defineTests({ factory, storageVersion }: StorageVersionTestContext) {
323
321
  await pool.query(`INSERT INTO test_data(id, description) VALUES('t1', 'test1')`);
324
322
 
325
323
  await context.replicateSnapshot();
326
- context.startStreaming();
327
324
 
328
325
  await pool.query(
329
326
  { statement: `ALTER TABLE test_data ALTER COLUMN id TYPE varchar` },
@@ -342,11 +339,13 @@ function defineTests({ factory, storageVersion }: StorageVersionTestContext) {
342
339
  // Snapshot - order doesn't matter
343
340
  expect(data.slice(2, 4).sort(compareIds)).toMatchObject([PUT_T1, PUT_T2]);
344
341
 
345
- expect(data.slice(4).sort(compareIds)).toMatchObject([
346
- // Replicated insert
347
- // We may eventually be able to de-duplicate this
348
- PUT_T2
349
- ]);
342
+ if (data.length > 4) {
343
+ expect(data.slice(4).sort(compareIds)).toMatchObject([
344
+ // Replicated insert
345
+ // May be de-duplicated
346
+ PUT_T2
347
+ ]);
348
+ }
350
349
  });
351
350
 
352
351
  test('change index id by changing column type', async () => {
@@ -365,7 +364,6 @@ function defineTests({ factory, storageVersion }: StorageVersionTestContext) {
365
364
  await pool.query(`INSERT INTO test_data(id, description) VALUES('t1', 'test1')`);
366
365
 
367
366
  await context.replicateSnapshot();
368
- context.startStreaming();
369
367
 
370
368
  await pool.query(`INSERT INTO test_data(id, description) VALUES('t2', 'test2')`);
371
369
 
@@ -388,21 +386,7 @@ function defineTests({ factory, storageVersion }: StorageVersionTestContext) {
388
386
  const reduced = reduceBucket(data).slice(1);
389
387
  expect(reduced.sort(compareIds)).toMatchObject([PUT_T1, PUT_T2, PUT_T3]);
390
388
 
391
- // Previously had more specific tests, but this varies too much based on timing:
392
- // expect(data.slice(2, 4).sort(compareIds)).toMatchObject([
393
- // // Truncate - any order
394
- // REMOVE_T1,
395
- // REMOVE_T2
396
- // ]);
397
-
398
- // // Snapshot - order doesn't matter
399
- // expect(data.slice(4, 7).sort(compareIds)).toMatchObject([PUT_T1, PUT_T2, PUT_T3]);
400
-
401
- // expect(data.slice(7).sort(compareIds)).toMatchObject([
402
- // // Replicated insert
403
- // // We may eventually be able to de-duplicate this
404
- // PUT_T3
405
- // ]);
389
+ // Previously had more specific tests, but this varies too much based on timing.
406
390
  });
407
391
 
408
392
  test('add to publication', async () => {
@@ -420,7 +404,6 @@ function defineTests({ factory, storageVersion }: StorageVersionTestContext) {
420
404
  await pool.query(`INSERT INTO test_data(id, description) VALUES('t1', 'test1')`);
421
405
 
422
406
  await context.replicateSnapshot();
423
- context.startStreaming();
424
407
 
425
408
  await pool.query(`INSERT INTO test_data(id, description) VALUES('t2', 'test2')`);
426
409
 
@@ -436,11 +419,13 @@ function defineTests({ factory, storageVersion }: StorageVersionTestContext) {
436
419
  PUT_T3
437
420
  ]);
438
421
 
439
- expect(data.slice(3)).toMatchObject([
440
- // Replicated insert
441
- // We may eventually be able to de-duplicate this
442
- PUT_T3
443
- ]);
422
+ if (data.length > 3) {
423
+ expect(data.slice(3)).toMatchObject([
424
+ // Replicated insert
425
+ // May be de-duplicated
426
+ PUT_T3
427
+ ]);
428
+ }
444
429
 
445
430
  // "Reduce" the bucket to get a stable output to test.
446
431
  // slice(1) to skip the CLEAR op.
@@ -464,7 +449,6 @@ function defineTests({ factory, storageVersion }: StorageVersionTestContext) {
464
449
  await pool.query(`INSERT INTO test_other(id, description) VALUES('t1', 'test1')`);
465
450
 
466
451
  await context.replicateSnapshot();
467
- context.startStreaming();
468
452
 
469
453
  await pool.query(`INSERT INTO test_other(id, description) VALUES('t2', 'test2')`);
470
454
 
@@ -489,7 +473,6 @@ function defineTests({ factory, storageVersion }: StorageVersionTestContext) {
489
473
  await pool.query(`INSERT INTO test_data(id, description) VALUES('t1', 'test1')`);
490
474
 
491
475
  await context.replicateSnapshot();
492
- context.startStreaming();
493
476
 
494
477
  await pool.query(`INSERT INTO test_data(id, description) VALUES('t2', 'test2')`);
495
478
 
@@ -532,7 +515,6 @@ function defineTests({ factory, storageVersion }: StorageVersionTestContext) {
532
515
  await pool.query(`INSERT INTO test_data(id, description) VALUES('t1', 'test1')`);
533
516
 
534
517
  await context.replicateSnapshot();
535
- context.startStreaming();
536
518
 
537
519
  await pool.query(`INSERT INTO test_data(id, description) VALUES('t2', 'test2')`);
538
520
 
@@ -586,7 +568,6 @@ function defineTests({ factory, storageVersion }: StorageVersionTestContext) {
586
568
  await pool.query(`INSERT INTO test_data_old(id, num) VALUES('t2', 0)`);
587
569
 
588
570
  await context.replicateSnapshot();
589
- context.startStreaming();
590
571
 
591
572
  await pool.query(
592
573
  { statement: `ALTER TABLE test_data_old RENAME TO test_data` },
@@ -658,7 +639,6 @@ config:
658
639
  await pool.query(`INSERT INTO test_data(id) VALUES ('t1')`);
659
640
 
660
641
  await context.replicateSnapshot();
661
- context.startStreaming();
662
642
 
663
643
  await pool.query(
664
644
  { statement: `CREATE TYPE composite AS (foo bool, bar int4);` },
@@ -666,7 +646,7 @@ config:
666
646
  { statement: `UPDATE test_data SET other = ROW(TRUE, 2)::composite;` }
667
647
  );
668
648
 
669
- const data = await context.getBucketData('1#stream|0[]');
649
+ const data = await context.getBucketData('stream|0[]');
670
650
  expect(data).toMatchObject([
671
651
  putOp('test_data', { id: 't1' }),
672
652
  putOp('test_data', { id: 't1', other: '{"foo":1,"bar":2}' })
@@ -15,16 +15,18 @@ import * as pgwire from '@powersync/service-jpgwire';
15
15
  import { SqliteRow } from '@powersync/service-sync-rules';
16
16
 
17
17
  import { PgManager } from '@module/replication/PgManager.js';
18
+ import { ReplicationAbortedError } from '@powersync/lib-services-framework';
18
19
  import {
19
20
  createCoreReplicationMetrics,
20
21
  initializeCoreReplicationMetrics,
22
+ reduceBucket,
21
23
  updateSyncRulesFromYaml
22
24
  } from '@powersync/service-core';
23
25
  import { METRICS_HELPER, test_utils } from '@powersync/service-core-tests';
24
26
  import * as mongo_storage from '@powersync/service-module-mongodb-storage';
25
27
  import * as postgres_storage from '@powersync/service-module-postgres-storage';
26
28
  import * as timers from 'node:timers/promises';
27
- import { CustomTypeRegistry } from '@module/types/registry.js';
29
+ import { WalStreamTestContext } from './wal_stream_utils.js';
28
30
 
29
31
  describe.skipIf(!(env.CI || env.SLOW_TESTS))('slow tests', function () {
30
32
  describeWithStorage({ timeout: 120_000 }, function ({ factory, storageVersion }) {
@@ -47,7 +49,7 @@ function defineSlowTests({ factory, storageVersion }: StorageVersionTestContext)
47
49
  // This cleans up, similar to WalStreamTestContext.dispose().
48
50
  // These tests are a little more complex than what is supported by WalStreamTestContext.
49
51
  abortController?.abort();
50
- await streamPromise;
52
+ await streamPromise?.catch((_) => {});
51
53
  streamPromise = undefined;
52
54
  connections?.destroy();
53
55
 
@@ -75,7 +77,6 @@ function defineSlowTests({ factory, storageVersion }: StorageVersionTestContext)
75
77
 
76
78
  async function testRepeatedReplication(testOptions: { compact: boolean; maxBatchSize: number; numBatches: number }) {
77
79
  const connections = new PgManager(TEST_CONNECTION_OPTIONS, {});
78
- const replicationConnection = await connections.replicationConnection();
79
80
  const pool = connections.pool;
80
81
  await clearTestDb(pool);
81
82
  await using f = await factory();
@@ -102,11 +103,11 @@ bucket_definitions:
102
103
  );
103
104
  await pool.query(`ALTER TABLE test_data REPLICA IDENTITY FULL`);
104
105
 
105
- await walStream.initReplication(replicationConnection);
106
106
  let abort = false;
107
- streamPromise = walStream.streamChanges(replicationConnection).finally(() => {
107
+ streamPromise = walStream.replicate().finally(() => {
108
108
  abort = true;
109
109
  });
110
+ await walStream.waitForInitialSnapshot();
110
111
  const start = Date.now();
111
112
 
112
113
  while (!abort && Date.now() - start < TEST_DURATION_MS) {
@@ -228,11 +229,12 @@ bucket_definitions:
228
229
  await compactPromise;
229
230
 
230
231
  // Wait for replication to finish
231
- let checkpoint = await getClientCheckpoint(pool, storage.factory, { timeout: TIMEOUT_MARGIN_MS });
232
+ await getClientCheckpoint(pool, storage.factory, { timeout: TIMEOUT_MARGIN_MS });
232
233
 
233
234
  if (f instanceof mongo_storage.storage.MongoBucketStorage) {
234
235
  // Check that all inserts have been deleted again
235
- const docs = await f.db.current_data.find().toArray();
236
+ // Note: at this point, the pending_delete cleanup may not have run yet.
237
+ const docs = await f.db.current_data.find({ pending_delete: { $exists: false } }).toArray();
236
238
  const transformed = docs.map((doc) => {
237
239
  return bson.deserialize(doc.data.buffer) as SqliteRow;
238
240
  });
@@ -254,13 +256,14 @@ bucket_definitions:
254
256
  } else if (f instanceof postgres_storage.storage.PostgresBucketStorageFactory) {
255
257
  const { db } = f;
256
258
  // Check that all inserts have been deleted again
259
+ // FIXME: handle different storage versions
257
260
  const docs = await db.sql`
258
261
  SELECT
259
262
  *
260
263
  FROM
261
264
  current_data
262
265
  `
263
- .decoded(postgres_storage.models.CurrentData)
266
+ .decoded(postgres_storage.models.V1CurrentData)
264
267
  .rows();
265
268
  const transformed = docs.map((doc) => {
266
269
  return bson.deserialize(doc.data) as SqliteRow;
@@ -293,14 +296,20 @@ bucket_definitions:
293
296
  }
294
297
 
295
298
  abortController.abort();
296
- await streamPromise;
299
+ await streamPromise.catch((e) => {
300
+ if (e instanceof ReplicationAbortedError) {
301
+ // Ignore
302
+ } else {
303
+ throw e;
304
+ }
305
+ });
297
306
  }
298
307
 
299
308
  // Test repeatedly performing initial replication.
300
309
  //
301
310
  // If the first LSN does not correctly match with the first replication transaction,
302
311
  // we may miss some updates.
303
- test('repeated initial replication', { timeout: TEST_DURATION_MS + TIMEOUT_MARGIN_MS }, async () => {
312
+ test('repeated initial replication (1)', { timeout: TEST_DURATION_MS + TIMEOUT_MARGIN_MS }, async () => {
304
313
  const pool = await connectPgPool();
305
314
  await clearTestDb(pool);
306
315
  await using f = await factory();
@@ -337,7 +346,6 @@ bucket_definitions:
337
346
  i += 1;
338
347
 
339
348
  const connections = new PgManager(TEST_CONNECTION_OPTIONS, {});
340
- const replicationConnection = await connections.replicationConnection();
341
349
 
342
350
  abortController = new AbortController();
343
351
  const options: WalStreamOptions = {
@@ -350,19 +358,14 @@ bucket_definitions:
350
358
 
351
359
  await storage.clear();
352
360
 
353
- // 3. Start initial replication, then streaming, but don't wait for any of this
361
+ // 3. Start replication, but don't wait for it
354
362
  let initialReplicationDone = false;
355
- streamPromise = (async () => {
356
- await walStream.initReplication(replicationConnection);
357
- initialReplicationDone = true;
358
- await walStream.streamChanges(replicationConnection);
359
- })()
360
- .catch((e) => {
363
+ streamPromise = walStream.replicate();
364
+ walStream
365
+ .waitForInitialSnapshot()
366
+ .catch((_) => {})
367
+ .finally(() => {
361
368
  initialReplicationDone = true;
362
- throw e;
363
- })
364
- .then((v) => {
365
- return v;
366
369
  });
367
370
 
368
371
  // 4. While initial replication is still running, write more changes
@@ -405,8 +408,104 @@ bucket_definitions:
405
408
  }
406
409
 
407
410
  abortController.abort();
408
- await streamPromise;
411
+ await streamPromise.catch((e) => {
412
+ if (e instanceof ReplicationAbortedError) {
413
+ // Ignore
414
+ } else {
415
+ throw e;
416
+ }
417
+ });
409
418
  await connections.end();
410
419
  }
411
420
  });
421
+
422
+ // Test repeatedly performing initial replication while deleting data.
423
+ //
424
+ // This specifically checks for data in the initial snapshot being deleted while snapshotting.
425
+ test('repeated initial replication with deletes', { timeout: TEST_DURATION_MS + TIMEOUT_MARGIN_MS }, async () => {
426
+ const syncRuleContent = `
427
+ bucket_definitions:
428
+ global:
429
+ data:
430
+ - SELECT id, description FROM "test_data"
431
+ `;
432
+
433
+ const start = Date.now();
434
+ let i = 0;
435
+
436
+ while (Date.now() - start < TEST_DURATION_MS) {
437
+ i += 1;
438
+
439
+ // 1. Each iteration starts with a clean slate
440
+ await using context = await WalStreamTestContext.open(factory, {
441
+ walStreamOptions: { snapshotChunkLength: 100 }
442
+ });
443
+ const pool = context.pool;
444
+
445
+ // Introduce an artificial delay in snapshot queries, to make it more likely to reproduce an
446
+ // issue.
447
+ const originalSnapshotConnectionFn = context.connectionManager.snapshotConnection;
448
+ context.connectionManager.snapshotConnection = async () => {
449
+ const conn = await originalSnapshotConnectionFn.call(context.connectionManager);
450
+ // Wrap streaming query to add delays to snapshots
451
+ const originalStream = conn.stream;
452
+ conn.stream = async function* (...args: any[]) {
453
+ const delay = Math.random() * 20;
454
+ yield* originalStream.call(this, ...args);
455
+ await new Promise((resolve) => setTimeout(resolve, delay));
456
+ };
457
+ return conn;
458
+ };
459
+
460
+ await pool.query(`CREATE TABLE test_data(id uuid primary key default uuid_generate_v4(), description text)`);
461
+ await context.updateSyncRules(syncRuleContent);
462
+
463
+ let statements: pgwire.Statement[] = [];
464
+
465
+ const n = Math.floor(Math.random() * 200);
466
+ for (let i = 0; i < n; i++) {
467
+ statements.push({
468
+ statement: `INSERT INTO test_data(description) VALUES('test_init') RETURNING id`
469
+ });
470
+ }
471
+ const results = await pool.query(...statements);
472
+ const ids = new Set(
473
+ results.results.map((sub) => {
474
+ return sub.rows[0].decodeWithoutCustomTypes(0) as string;
475
+ })
476
+ );
477
+
478
+ // 3. Start replication, but don't wait for it
479
+ let initialReplicationDone = false;
480
+
481
+ streamPromise = context.replicateSnapshot().finally(() => {
482
+ initialReplicationDone = true;
483
+ });
484
+
485
+ // 4. While initial replication is still running, delete random rows
486
+ while (!initialReplicationDone && ids.size > 0) {
487
+ let statements: pgwire.Statement[] = [];
488
+
489
+ const m = Math.floor(Math.random() * 10) + 1;
490
+ const idArray = Array.from(ids);
491
+ for (let i = 0; i < m; i++) {
492
+ const id = idArray[Math.floor(Math.random() * idArray.length)];
493
+ statements.push({
494
+ statement: `DELETE FROM test_data WHERE id = $1`,
495
+ params: [{ type: 'uuid', value: id }]
496
+ });
497
+ ids.delete(id);
498
+ }
499
+ await pool.query(...statements);
500
+ await new Promise((resolve) => setTimeout(resolve, Math.random() * 10));
501
+ }
502
+
503
+ await streamPromise;
504
+
505
+ // 5. Once initial replication is done, wait for the streaming changes to complete syncing.
506
+ const data = await context.getBucketData('global[]', 0n);
507
+ const normalized = reduceBucket(data).filter((op) => op.op !== 'CLEAR');
508
+ expect(normalized.length).toEqual(ids.size);
509
+ }
510
+ });
412
511
  }
@@ -7,9 +7,9 @@ describe.skipIf(!env.TEST_POSTGRES_STORAGE)('replication storage combination - p
7
7
  test('should allow the same Postgres cluster to be used for data and storage', async () => {
8
8
  // Use the same cluster for the storage as the data source
9
9
  await using context = await WalStreamTestContext.open(
10
- postgres_storage.test_utils.postgresTestStorageFactoryGenerator({
10
+ postgres_storage.test_utils.postgresTestSetup({
11
11
  url: env.PG_TEST_URL
12
- }),
12
+ }).factory,
13
13
  { doNotClear: false }
14
14
  );
15
15
 
package/test/src/util.ts CHANGED
@@ -7,6 +7,8 @@ import {
7
7
  CURRENT_STORAGE_VERSION,
8
8
  InternalOpId,
9
9
  LEGACY_STORAGE_VERSION,
10
+ SUPPORTED_STORAGE_VERSIONS,
11
+ TestStorageConfig,
10
12
  TestStorageFactory
11
13
  } from '@powersync/service-core';
12
14
  import * as pgwire from '@powersync/service-jpgwire';
@@ -22,11 +24,11 @@ export const INITIALIZED_MONGO_STORAGE_FACTORY = mongo_storage.test_utils.mongoT
22
24
  isCI: env.CI
23
25
  });
24
26
 
25
- export const INITIALIZED_POSTGRES_STORAGE_FACTORY = postgres_storage.test_utils.postgresTestStorageFactoryGenerator({
27
+ export const INITIALIZED_POSTGRES_STORAGE_FACTORY = postgres_storage.test_utils.postgresTestSetup({
26
28
  url: env.PG_STORAGE_TEST_URL
27
29
  });
28
30
 
29
- const TEST_STORAGE_VERSIONS = [LEGACY_STORAGE_VERSION, CURRENT_STORAGE_VERSION];
31
+ const TEST_STORAGE_VERSIONS = SUPPORTED_STORAGE_VERSIONS;
30
32
 
31
33
  export interface StorageVersionTestContext {
32
34
  factory: TestStorageFactory;
@@ -34,12 +36,12 @@ export interface StorageVersionTestContext {
34
36
  }
35
37
 
36
38
  export function describeWithStorage(options: TestOptions, fn: (context: StorageVersionTestContext) => void) {
37
- const describeFactory = (storageName: string, factory: TestStorageFactory) => {
39
+ const describeFactory = (storageName: string, config: TestStorageConfig) => {
38
40
  describe(`${storageName} storage`, options, function () {
39
41
  for (const storageVersion of TEST_STORAGE_VERSIONS) {
40
42
  describe(`storage v${storageVersion}`, function () {
41
43
  fn({
42
- factory,
44
+ factory: config.factory,
43
45
  storageVersion
44
46
  });
45
47
  });
@@ -6,7 +6,7 @@ import { WalStreamTestContext } from './wal_stream_utils.js';
6
6
  import { updateSyncRulesFromYaml } from '@powersync/service-core';
7
7
 
8
8
  test('validate tables', async () => {
9
- await using context = await WalStreamTestContext.open(INITIALIZED_MONGO_STORAGE_FACTORY);
9
+ await using context = await WalStreamTestContext.open(INITIALIZED_MONGO_STORAGE_FACTORY.factory);
10
10
  const { pool } = context;
11
11
 
12
12
  await pool.query(`CREATE TABLE test_data(id uuid primary key default uuid_generate_v4(), description text)`);