@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.
- package/CHANGELOG.md +21 -0
- package/dist/replication/WalStream.d.ts +9 -2
- package/dist/replication/WalStream.js +29 -10
- package/dist/replication/WalStream.js.map +1 -1
- package/dist/replication/replication-utils.js +1 -1
- package/dist/replication/replication-utils.js.map +1 -1
- package/package.json +10 -10
- package/src/replication/WalStream.ts +36 -17
- package/src/replication/replication-utils.ts +1 -1
- package/test/src/checkpoints.test.ts +2 -4
- package/test/src/chunked_snapshots.test.ts +2 -1
- package/test/src/large_batch.test.ts +1 -7
- package/test/src/pg_test.test.ts +5 -5
- package/test/src/resuming_snapshots.test.ts +10 -13
- package/test/src/route_api_adapter.test.ts +2 -2
- package/test/src/schema_changes.test.ts +54 -74
- package/test/src/slow_tests.test.ts +122 -23
- package/test/src/storage_combination.test.ts +2 -2
- package/test/src/util.ts +6 -4
- package/test/src/validation.test.ts +1 -1
- package/test/src/wal_stream.test.ts +7 -18
- package/test/src/wal_stream_utils.ts +46 -54
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -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
|
-
|
|
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).
|
|
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).
|
|
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 =
|
|
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).
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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('
|
|
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 {
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
361
|
+
// 3. Start replication, but don't wait for it
|
|
354
362
|
let initialReplicationDone = false;
|
|
355
|
-
streamPromise = (
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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.
|
|
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.
|
|
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 =
|
|
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,
|
|
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)`);
|