@powersync/service-core 0.4.1 → 0.5.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 (59) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/entry/cli-entry.js +2 -1
  3. package/dist/entry/cli-entry.js.map +1 -1
  4. package/dist/entry/commands/compact-action.d.ts +2 -0
  5. package/dist/entry/commands/compact-action.js +49 -0
  6. package/dist/entry/commands/compact-action.js.map +1 -0
  7. package/dist/entry/entry-index.d.ts +1 -0
  8. package/dist/entry/entry-index.js +1 -0
  9. package/dist/entry/entry-index.js.map +1 -1
  10. package/dist/routes/endpoints/socket-route.js +9 -1
  11. package/dist/routes/endpoints/socket-route.js.map +1 -1
  12. package/dist/routes/endpoints/sync-stream.js +9 -1
  13. package/dist/routes/endpoints/sync-stream.js.map +1 -1
  14. package/dist/storage/BucketStorage.d.ts +25 -1
  15. package/dist/storage/BucketStorage.js.map +1 -1
  16. package/dist/storage/mongo/MongoCompactor.d.ts +38 -0
  17. package/dist/storage/mongo/MongoCompactor.js +278 -0
  18. package/dist/storage/mongo/MongoCompactor.js.map +1 -0
  19. package/dist/storage/mongo/MongoSyncBucketStorage.d.ts +3 -2
  20. package/dist/storage/mongo/MongoSyncBucketStorage.js +19 -13
  21. package/dist/storage/mongo/MongoSyncBucketStorage.js.map +1 -1
  22. package/dist/storage/mongo/models.d.ts +5 -4
  23. package/dist/storage/mongo/models.js.map +1 -1
  24. package/dist/storage/mongo/util.d.ts +3 -0
  25. package/dist/storage/mongo/util.js +22 -0
  26. package/dist/storage/mongo/util.js.map +1 -1
  27. package/dist/sync/RequestTracker.d.ts +9 -0
  28. package/dist/sync/RequestTracker.js +19 -0
  29. package/dist/sync/RequestTracker.js.map +1 -0
  30. package/dist/sync/sync.d.ts +2 -0
  31. package/dist/sync/sync.js +51 -18
  32. package/dist/sync/sync.js.map +1 -1
  33. package/dist/sync/util.d.ts +2 -1
  34. package/dist/sync/util.js +2 -3
  35. package/dist/sync/util.js.map +1 -1
  36. package/package.json +4 -4
  37. package/src/entry/cli-entry.ts +2 -1
  38. package/src/entry/commands/compact-action.ts +55 -0
  39. package/src/entry/entry-index.ts +1 -0
  40. package/src/routes/endpoints/socket-route.ts +9 -1
  41. package/src/routes/endpoints/sync-stream.ts +10 -1
  42. package/src/storage/BucketStorage.ts +29 -1
  43. package/src/storage/mongo/MongoCompactor.ts +356 -0
  44. package/src/storage/mongo/MongoSyncBucketStorage.ts +25 -14
  45. package/src/storage/mongo/models.ts +5 -4
  46. package/src/storage/mongo/util.ts +25 -0
  47. package/src/sync/RequestTracker.ts +21 -0
  48. package/src/sync/sync.ts +61 -17
  49. package/src/sync/util.ts +6 -2
  50. package/test/src/__snapshots__/sync.test.ts.snap +85 -0
  51. package/test/src/bucket_validation.test.ts +142 -0
  52. package/test/src/bucket_validation.ts +116 -0
  53. package/test/src/compacting.test.ts +207 -0
  54. package/test/src/data_storage.test.ts +19 -60
  55. package/test/src/slow_tests.test.ts +144 -102
  56. package/test/src/sync.test.ts +176 -28
  57. package/test/src/util.ts +65 -1
  58. package/test/src/wal_stream_utils.ts +13 -4
  59. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,207 @@
1
+ import { SqlSyncRules } from '@powersync/service-sync-rules';
2
+ import { describe, expect, test } from 'vitest';
3
+ import { makeTestTable, MONGO_STORAGE_FACTORY } from './util.js';
4
+ import { oneFromAsync } from './wal_stream_utils.js';
5
+ import { MongoCompactOptions } from '@/storage/mongo/MongoCompactor.js';
6
+ import { reduceBucket, validateCompactedBucket, validateBucket } from './bucket_validation.js';
7
+
8
+ const TEST_TABLE = makeTestTable('test', ['id']);
9
+
10
+ // Test with the default options - large batch sizes
11
+ describe('compacting buckets - default options', () => compactTests({}));
12
+
13
+ // Also test with the miniumum batch sizes, forcing usage of multiple batches internally
14
+ describe('compacting buckets - batched', () =>
15
+ compactTests({ clearBatchLimit: 2, moveBatchLimit: 1, moveBatchQueryLimit: 1 }));
16
+
17
+ function compactTests(compactOptions: MongoCompactOptions) {
18
+ const factory = MONGO_STORAGE_FACTORY;
19
+
20
+ test('compacting (1)', async () => {
21
+ const sync_rules = SqlSyncRules.fromYaml(`
22
+ bucket_definitions:
23
+ global:
24
+ data: [select * from test]
25
+ `);
26
+
27
+ const storage = (await factory()).getInstance({ id: 1, sync_rules, slot_name: 'test' });
28
+
29
+ const result = await storage.startBatch({}, async (batch) => {
30
+ await batch.save({
31
+ sourceTable: TEST_TABLE,
32
+ tag: 'insert',
33
+ after: {
34
+ id: 't1'
35
+ }
36
+ });
37
+
38
+ await batch.save({
39
+ sourceTable: TEST_TABLE,
40
+ tag: 'insert',
41
+ after: {
42
+ id: 't2'
43
+ }
44
+ });
45
+
46
+ await batch.save({
47
+ sourceTable: TEST_TABLE,
48
+ tag: 'update',
49
+ after: {
50
+ id: 't2'
51
+ }
52
+ });
53
+ });
54
+
55
+ const checkpoint = result!.flushed_op;
56
+
57
+ const batchBefore = await oneFromAsync(storage.getBucketDataBatch(checkpoint, new Map([['global[]', '0']])));
58
+ const dataBefore = batchBefore.batch.data;
59
+
60
+ expect(dataBefore).toMatchObject([
61
+ {
62
+ checksum: 2634521662,
63
+ object_id: 't1',
64
+ op: 'PUT',
65
+ op_id: '1'
66
+ },
67
+ {
68
+ checksum: 4243212114,
69
+ object_id: 't2',
70
+ op: 'PUT',
71
+ op_id: '2'
72
+ },
73
+ {
74
+ checksum: 4243212114,
75
+ object_id: 't2',
76
+ op: 'PUT',
77
+ op_id: '3'
78
+ }
79
+ ]);
80
+
81
+ await storage.compact(compactOptions);
82
+
83
+ const batchAfter = await oneFromAsync(storage.getBucketDataBatch(checkpoint, new Map([['global[]', '0']])));
84
+ const dataAfter = batchAfter.batch.data;
85
+
86
+ expect(batchAfter.targetOp).toEqual(3n);
87
+ expect(dataAfter).toMatchObject([
88
+ {
89
+ checksum: 2634521662,
90
+ object_id: 't1',
91
+ op: 'PUT',
92
+ op_id: '1'
93
+ },
94
+ {
95
+ checksum: 4243212114,
96
+ op: 'MOVE',
97
+ op_id: '2'
98
+ },
99
+ {
100
+ checksum: 4243212114,
101
+ object_id: 't2',
102
+ op: 'PUT',
103
+ op_id: '3'
104
+ }
105
+ ]);
106
+
107
+ validateCompactedBucket(dataBefore, dataAfter);
108
+ });
109
+
110
+ test('compacting (2)', async () => {
111
+ const sync_rules = SqlSyncRules.fromYaml(`
112
+ bucket_definitions:
113
+ global:
114
+ data: [select * from test]
115
+ `);
116
+
117
+ const storage = (await factory()).getInstance({ id: 1, sync_rules, slot_name: 'test' });
118
+
119
+ const result = await storage.startBatch({}, async (batch) => {
120
+ await batch.save({
121
+ sourceTable: TEST_TABLE,
122
+ tag: 'insert',
123
+ after: {
124
+ id: 't1'
125
+ }
126
+ });
127
+
128
+ await batch.save({
129
+ sourceTable: TEST_TABLE,
130
+ tag: 'insert',
131
+ after: {
132
+ id: 't2'
133
+ }
134
+ });
135
+
136
+ await batch.save({
137
+ sourceTable: TEST_TABLE,
138
+ tag: 'delete',
139
+ before: {
140
+ id: 't1'
141
+ }
142
+ });
143
+
144
+ await batch.save({
145
+ sourceTable: TEST_TABLE,
146
+ tag: 'update',
147
+ after: {
148
+ id: 't2'
149
+ }
150
+ });
151
+ });
152
+
153
+ const checkpoint = result!.flushed_op;
154
+
155
+ const batchBefore = await oneFromAsync(storage.getBucketDataBatch(checkpoint, new Map([['global[]', '0']])));
156
+ const dataBefore = batchBefore.batch.data;
157
+
158
+ expect(dataBefore).toMatchObject([
159
+ {
160
+ checksum: 2634521662,
161
+ object_id: 't1',
162
+ op: 'PUT',
163
+ op_id: '1'
164
+ },
165
+ {
166
+ checksum: 4243212114,
167
+ object_id: 't2',
168
+ op: 'PUT',
169
+ op_id: '2'
170
+ },
171
+ {
172
+ checksum: 4228978084,
173
+ object_id: 't1',
174
+ op: 'REMOVE',
175
+ op_id: '3'
176
+ },
177
+ {
178
+ checksum: 4243212114,
179
+ object_id: 't2',
180
+ op: 'PUT',
181
+ op_id: '4'
182
+ }
183
+ ]);
184
+
185
+ await storage.compact(compactOptions);
186
+
187
+ const batchAfter = await oneFromAsync(storage.getBucketDataBatch(checkpoint, new Map([['global[]', '0']])));
188
+ const dataAfter = batchAfter.batch.data;
189
+
190
+ expect(batchAfter.targetOp).toEqual(4n);
191
+ expect(dataAfter).toMatchObject([
192
+ {
193
+ checksum: -1778190028,
194
+ op: 'CLEAR',
195
+ op_id: '3'
196
+ },
197
+ {
198
+ checksum: 4243212114,
199
+ object_id: 't2',
200
+ op: 'PUT',
201
+ op_id: '4'
202
+ }
203
+ ]);
204
+
205
+ validateCompactedBucket(dataBefore, dataAfter);
206
+ });
207
+ }
@@ -1,26 +1,8 @@
1
1
  import { RequestParameters, SqlSyncRules } from '@powersync/service-sync-rules';
2
- import * as bson from 'bson';
3
2
  import { describe, expect, test } from 'vitest';
4
- import { SourceTable } from '../../src/storage/SourceTable.js';
5
- import { hashData } from '../../src/util/utils.js';
6
- import { MONGO_STORAGE_FACTORY, StorageFactory } from './util.js';
7
- import { SyncBucketData } from '../../src/util/protocol-types.js';
8
3
  import { BucketDataBatchOptions } from '../../src/storage/BucketStorage.js';
9
- import { fromAsync } from './wal_stream_utils.js';
10
-
11
- function makeTestTable(name: string, columns?: string[] | undefined) {
12
- const relId = hashData('table', name, (columns ?? ['id']).join(','));
13
- const id = new bson.ObjectId('6544e3899293153fa7b38331');
14
- return new SourceTable(
15
- id,
16
- SourceTable.DEFAULT_TAG,
17
- relId,
18
- SourceTable.DEFAULT_SCHEMA,
19
- name,
20
- (columns ?? ['id']).map((column) => ({ name: column, typeOid: 25 })),
21
- true
22
- );
23
- }
4
+ import { getBatchData, getBatchMeta, makeTestTable, MONGO_STORAGE_FACTORY, StorageFactory } from './util.js';
5
+ import { fromAsync, oneFromAsync } from './wal_stream_utils.js';
24
6
 
25
7
  const TEST_TABLE = makeTestTable('test', ['id']);
26
8
 
@@ -236,7 +218,7 @@ bucket_definitions:
236
218
  const checkpoint = result!.flushed_op;
237
219
 
238
220
  const batch = await fromAsync(storage.getBucketDataBatch(checkpoint, new Map([['global[]', '0']])));
239
- const data = batch[0].data.map((d) => {
221
+ const data = batch[0].batch.data.map((d) => {
240
222
  return {
241
223
  op: d.op,
242
224
  object_id: d.object_id,
@@ -504,7 +486,7 @@ bucket_definitions:
504
486
  });
505
487
  const checkpoint = result!.flushed_op;
506
488
  const batch = await fromAsync(storage.getBucketDataBatch(checkpoint, new Map([['global[]', '0']])));
507
- const data = batch[0].data.map((d) => {
489
+ const data = batch[0].batch.data.map((d) => {
508
490
  return {
509
491
  op: d.op,
510
492
  object_id: d.object_id
@@ -568,7 +550,7 @@ bucket_definitions:
568
550
  const checkpoint = result!.flushed_op;
569
551
 
570
552
  const batch = await fromAsync(storage.getBucketDataBatch(checkpoint, new Map([['global[]', '0']])));
571
- const data = batch[0].data.map((d) => {
553
+ const data = batch[0].batch.data.map((d) => {
572
554
  return {
573
555
  op: d.op,
574
556
  object_id: d.object_id,
@@ -680,7 +662,7 @@ bucket_definitions:
680
662
 
681
663
  const batch = await fromAsync(storage.getBucketDataBatch(checkpoint, new Map([['global[]', '0']])));
682
664
 
683
- const data = batch[0].data.map((d) => {
665
+ const data = batch[0].batch.data.map((d) => {
684
666
  return {
685
667
  op: d.op,
686
668
  object_id: d.object_id,
@@ -855,7 +837,7 @@ bucket_definitions:
855
837
  const checkpoint2 = result2!.flushed_op;
856
838
 
857
839
  const batch = await fromAsync(storage.getBucketDataBatch(checkpoint2, new Map([['global[]', checkpoint1]])));
858
- const data = batch[0].data.map((d) => {
840
+ const data = batch[0].batch.data.map((d) => {
859
841
  return {
860
842
  op: d.op,
861
843
  object_id: d.object_id,
@@ -939,7 +921,7 @@ bucket_definitions:
939
921
  const checkpoint3 = result3!.flushed_op;
940
922
 
941
923
  const batch = await fromAsync(storage.getBucketDataBatch(checkpoint3, new Map([['global[]', checkpoint1]])));
942
- const data = batch[0].data.map((d) => {
924
+ const data = batch[0].batch.data.map((d) => {
943
925
  return {
944
926
  op: d.op,
945
927
  object_id: d.object_id,
@@ -1031,7 +1013,7 @@ bucket_definitions:
1031
1013
  const checkpoint3 = result3!.flushed_op;
1032
1014
 
1033
1015
  const batch = await fromAsync(storage.getBucketDataBatch(checkpoint3, new Map([['global[]', checkpoint1]])));
1034
- const data = batch[0].data.map((d) => {
1016
+ const data = batch[0].batch.data.map((d) => {
1035
1017
  return {
1036
1018
  op: d.op,
1037
1019
  object_id: d.object_id,
@@ -1133,7 +1115,7 @@ bucket_definitions:
1133
1115
  });
1134
1116
 
1135
1117
  const batch2 = await fromAsync(
1136
- storage.getBucketDataBatch(checkpoint, new Map([['global[]', batch1[0].next_after]]), options)
1118
+ storage.getBucketDataBatch(checkpoint, new Map([['global[]', batch1[0].batch.next_after]]), options)
1137
1119
  );
1138
1120
  expect(getBatchData(batch2)).toEqual([
1139
1121
  { op_id: '3', op: 'PUT', object_id: 'large2', checksum: 1795508474 },
@@ -1146,7 +1128,7 @@ bucket_definitions:
1146
1128
  });
1147
1129
 
1148
1130
  const batch3 = await fromAsync(
1149
- storage.getBucketDataBatch(checkpoint, new Map([['global[]', batch2[0].next_after]]), options)
1131
+ storage.getBucketDataBatch(checkpoint, new Map([['global[]', batch2[0].batch.next_after]]), options)
1150
1132
  );
1151
1133
  expect(getBatchData(batch3)).toEqual([]);
1152
1134
  expect(getBatchMeta(batch3)).toEqual(null);
@@ -1223,7 +1205,7 @@ bucket_definitions:
1223
1205
  });
1224
1206
 
1225
1207
  const batch2 = await fromAsync(
1226
- storage.getBucketDataBatch(checkpoint, new Map([['global[]', batch1[0].next_after]]), options)
1208
+ storage.getBucketDataBatch(checkpoint, new Map([['global[]', batch1[0].batch.next_after]]), options)
1227
1209
  );
1228
1210
  expect(getBatchData(batch2)).toEqual([{ op_id: '3', op: 'PUT', object_id: 'large2', checksum: 1607205872 }]);
1229
1211
  expect(getBatchMeta(batch2)).toEqual({
@@ -1233,7 +1215,7 @@ bucket_definitions:
1233
1215
  });
1234
1216
 
1235
1217
  const batch3 = await fromAsync(
1236
- storage.getBucketDataBatch(checkpoint, new Map([['global[]', batch2[0].next_after]]), options)
1218
+ storage.getBucketDataBatch(checkpoint, new Map([['global[]', batch2[0].batch.next_after]]), options)
1237
1219
  );
1238
1220
  expect(getBatchData(batch3)).toEqual([{ op_id: '4', op: 'PUT', object_id: 'test3', checksum: 1359888332 }]);
1239
1221
  expect(getBatchMeta(batch3)).toEqual({
@@ -1270,7 +1252,9 @@ bucket_definitions:
1270
1252
 
1271
1253
  const checkpoint = result!.flushed_op;
1272
1254
 
1273
- const batch1 = await fromAsync(storage.getBucketDataBatch(checkpoint, new Map([['global[]', '0']]), { limit: 4 }));
1255
+ const batch1 = await oneFromAsync(
1256
+ storage.getBucketDataBatch(checkpoint, new Map([['global[]', '0']]), { limit: 4 })
1257
+ );
1274
1258
 
1275
1259
  expect(getBatchData(batch1)).toEqual([
1276
1260
  { op_id: '1', op: 'PUT', object_id: 'test1', checksum: 2871785649 },
@@ -1285,8 +1269,8 @@ bucket_definitions:
1285
1269
  next_after: '4'
1286
1270
  });
1287
1271
 
1288
- const batch2 = await fromAsync(
1289
- storage.getBucketDataBatch(checkpoint, new Map([['global[]', batch1[0].next_after]]), {
1272
+ const batch2 = await oneFromAsync(
1273
+ storage.getBucketDataBatch(checkpoint, new Map([['global[]', batch1.batch.next_after]]), {
1290
1274
  limit: 4
1291
1275
  })
1292
1276
  );
@@ -1302,7 +1286,7 @@ bucket_definitions:
1302
1286
  });
1303
1287
 
1304
1288
  const batch3 = await fromAsync(
1305
- storage.getBucketDataBatch(checkpoint, new Map([['global[]', batch2[0].next_after]]), {
1289
+ storage.getBucketDataBatch(checkpoint, new Map([['global[]', batch2.batch.next_after]]), {
1306
1290
  limit: 4
1307
1291
  })
1308
1292
  );
@@ -1311,28 +1295,3 @@ bucket_definitions:
1311
1295
  expect(getBatchMeta(batch3)).toEqual(null);
1312
1296
  });
1313
1297
  }
1314
-
1315
- function getBatchData(batch: SyncBucketData[]) {
1316
- if (batch.length == 0) {
1317
- return [];
1318
- }
1319
- return batch[0].data.map((d) => {
1320
- return {
1321
- op_id: d.op_id,
1322
- op: d.op,
1323
- object_id: d.object_id,
1324
- checksum: d.checksum
1325
- };
1326
- });
1327
- }
1328
-
1329
- function getBatchMeta(batch: SyncBucketData[]) {
1330
- if (batch.length == 0) {
1331
- return null;
1332
- }
1333
- return {
1334
- has_more: batch[0].has_more,
1335
- after: batch[0].after,
1336
- next_after: batch[0].next_after
1337
- };
1338
- }
@@ -4,12 +4,15 @@ import { afterEach, describe, expect, test } from 'vitest';
4
4
  import { WalStream, WalStreamOptions } from '../../src/replication/WalStream.js';
5
5
  import { getClientCheckpoint } from '../../src/util/utils.js';
6
6
  import { env } from './env.js';
7
- import { MONGO_STORAGE_FACTORY, StorageFactory, TEST_CONNECTION_OPTIONS, connectPgPool } from './util.js';
7
+ import { MONGO_STORAGE_FACTORY, StorageFactory, TEST_CONNECTION_OPTIONS, clearTestDb, connectPgPool } from './util.js';
8
8
 
9
9
  import * as pgwire from '@powersync/service-jpgwire';
10
10
  import { SqliteRow } from '@powersync/service-sync-rules';
11
11
  import { MongoBucketStorage } from '../../src/storage/MongoBucketStorage.js';
12
12
  import { PgManager } from '../../src/util/PgManager.js';
13
+ import { mapOpEntry } from '@/storage/storage-index.js';
14
+ import { reduceBucket, validateCompactedBucket, validateBucket } from './bucket_validation.js';
15
+ import * as timers from 'node:timers/promises';
13
16
 
14
17
  describe('slow tests - mongodb', function () {
15
18
  // These are slow, inconsistent tests.
@@ -51,130 +54,169 @@ function defineSlowTests(factory: StorageFactory) {
51
54
  // * Skipping LSNs after a keepalive message
52
55
  // * Skipping LSNs when source transactions overlap
53
56
  test(
54
- 'repeated replication',
57
+ 'repeated replication - basic',
55
58
  async () => {
56
- const connections = new PgManager(TEST_CONNECTION_OPTIONS, {});
57
- const replicationConnection = await connections.replicationConnection();
58
- const pool = connections.pool;
59
- const f = (await factory()) as MongoBucketStorage;
59
+ await testRepeatedReplication({ compact: false, maxBatchSize: 50, numBatches: 5 });
60
+ },
61
+ { timeout: TEST_DURATION_MS + TIMEOUT_MARGIN_MS }
62
+ );
60
63
 
61
- const syncRuleContent = `
64
+ test(
65
+ 'repeated replication - compacted',
66
+ async () => {
67
+ await testRepeatedReplication({ compact: true, maxBatchSize: 100, numBatches: 2 });
68
+ },
69
+ { timeout: TEST_DURATION_MS + TIMEOUT_MARGIN_MS }
70
+ );
71
+
72
+ async function testRepeatedReplication(testOptions: { compact: boolean; maxBatchSize: number; numBatches: number }) {
73
+ const connections = new PgManager(TEST_CONNECTION_OPTIONS, {});
74
+ const replicationConnection = await connections.replicationConnection();
75
+ const pool = connections.pool;
76
+ await clearTestDb(pool);
77
+ const f = (await factory()) as MongoBucketStorage;
78
+
79
+ const syncRuleContent = `
62
80
  bucket_definitions:
63
81
  global:
64
82
  data:
65
83
  - SELECT * FROM "test_data"
66
84
  `;
67
- const syncRules = await f.updateSyncRules({ content: syncRuleContent });
68
- const storage = f.getInstance(syncRules.parsed());
69
- abortController = new AbortController();
70
- const options: WalStreamOptions = {
71
- abort_signal: abortController.signal,
72
- connections,
73
- storage: storage,
74
- factory: f
75
- };
76
- walStream = new WalStream(options);
77
-
78
- await pool.query(`DROP TABLE IF EXISTS test_data`);
79
- await pool.query(
80
- `CREATE TABLE test_data(id uuid primary key default uuid_generate_v4(), description text, num decimal)`
81
- );
82
- await pool.query(`ALTER TABLE test_data REPLICA IDENTITY FULL`);
83
-
84
- await walStream.initReplication(replicationConnection);
85
- await storage.autoActivate();
86
- let abort = false;
87
- streamPromise = walStream.streamChanges(replicationConnection).finally(() => {
88
- abort = true;
89
- });
90
- const start = Date.now();
85
+ const syncRules = await f.updateSyncRules({ content: syncRuleContent });
86
+ const storage = f.getInstance(syncRules.parsed());
87
+ abortController = new AbortController();
88
+ const options: WalStreamOptions = {
89
+ abort_signal: abortController.signal,
90
+ connections,
91
+ storage: storage,
92
+ factory: f
93
+ };
94
+ walStream = new WalStream(options);
95
+
96
+ await pool.query(
97
+ `CREATE TABLE test_data(id uuid primary key default uuid_generate_v4(), description text, num decimal)`
98
+ );
99
+ await pool.query(`ALTER TABLE test_data REPLICA IDENTITY FULL`);
100
+
101
+ await walStream.initReplication(replicationConnection);
102
+ await storage.autoActivate();
103
+ let abort = false;
104
+ streamPromise = walStream.streamChanges(replicationConnection).finally(() => {
105
+ abort = true;
106
+ });
107
+ const start = Date.now();
108
+
109
+ while (!abort && Date.now() - start < TEST_DURATION_MS) {
110
+ const bg = async () => {
111
+ for (let j = 0; j < testOptions.numBatches && !abort; j++) {
112
+ const n = Math.max(1, Math.floor(Math.random() * testOptions.maxBatchSize));
113
+ let statements: pgwire.Statement[] = [];
114
+ for (let i = 0; i < n; i++) {
115
+ const description = `test${i}`;
116
+ statements.push({
117
+ statement: `INSERT INTO test_data(description, num) VALUES($1, $2) returning id as test_id`,
118
+ params: [
119
+ { type: 'varchar', value: description },
120
+ { type: 'float8', value: Math.random() }
121
+ ]
122
+ });
123
+ }
124
+ const results = await pool.query(...statements);
125
+ const ids = results.results.map((sub) => {
126
+ return sub.rows[0][0] as string;
127
+ });
128
+ await new Promise((resolve) => setTimeout(resolve, Math.random() * 30));
91
129
 
92
- while (!abort && Date.now() - start < TEST_DURATION_MS) {
93
- const bg = async () => {
94
- for (let j = 0; j < 1 && !abort; j++) {
95
- const n = 1;
96
- let statements: pgwire.Statement[] = [];
97
- for (let i = 0; i < n; i++) {
98
- const description = `test${i}`;
99
- statements.push({
100
- statement: `INSERT INTO test_data(description, num) VALUES($1, $2) returning id as test_id`,
130
+ if (Math.random() > 0.5) {
131
+ const updateStatements: pgwire.Statement[] = ids.map((id) => {
132
+ return {
133
+ statement: `UPDATE test_data SET num = $2 WHERE id = $1`,
101
134
  params: [
102
- { type: 'varchar', value: description },
135
+ { type: 'uuid', value: id },
103
136
  { type: 'float8', value: Math.random() }
104
137
  ]
105
- });
106
- }
107
- const results = await pool.query(...statements);
108
- const ids = results.results.map((sub) => {
109
- return sub.rows[0][0] as string;
138
+ };
110
139
  });
111
- await new Promise((resolve) => setTimeout(resolve, Math.random() * 30));
112
140
 
141
+ await pool.query(...updateStatements);
113
142
  if (Math.random() > 0.5) {
114
- const updateStatements: pgwire.Statement[] = ids.map((id) => {
115
- return {
116
- statement: `UPDATE test_data SET num = $2 WHERE id = $1`,
117
- params: [
118
- { type: 'uuid', value: id },
119
- { type: 'float8', value: Math.random() }
120
- ]
121
- };
122
- });
123
-
143
+ // Special case - an update that doesn't change data
124
144
  await pool.query(...updateStatements);
125
- if (Math.random() > 0.5) {
126
- // Special case - an update that doesn't change data
127
- await pool.query(...updateStatements);
128
- }
129
145
  }
130
-
131
- const deleteStatements: pgwire.Statement[] = ids.map((id) => {
132
- return {
133
- statement: `DELETE FROM test_data WHERE id = $1`,
134
- params: [{ type: 'uuid', value: id }]
135
- };
136
- });
137
- await pool.query(...deleteStatements);
138
-
139
- await new Promise((resolve) => setTimeout(resolve, Math.random() * 10));
140
146
  }
141
- };
142
147
 
143
- // Call the above loop multiple times concurrently
144
- let promises = [1, 2, 3].map((i) => bg());
145
- await Promise.all(promises);
148
+ const deleteStatements: pgwire.Statement[] = ids.map((id) => {
149
+ return {
150
+ statement: `DELETE FROM test_data WHERE id = $1`,
151
+ params: [{ type: 'uuid', value: id }]
152
+ };
153
+ });
154
+ await pool.query(...deleteStatements);
146
155
 
147
- // Wait for replication to finish
148
- let checkpoint = await getClientCheckpoint(pool, storage.factory, { timeout: TIMEOUT_MARGIN_MS });
156
+ await new Promise((resolve) => setTimeout(resolve, Math.random() * 10));
157
+ }
158
+ };
149
159
 
150
- // Check that all inserts have been deleted again
151
- const docs = await f.db.current_data.find().toArray();
152
- const transformed = docs.map((doc) => {
153
- return bson.deserialize((doc.data as mongo.Binary).buffer) as SqliteRow;
154
- });
155
- expect(transformed).toEqual([]);
156
-
157
- // Check that each PUT has a REMOVE
158
- const ops = await f.db.bucket_data.find().sort({ _id: 1 }).toArray();
159
- let active = new Set<string>();
160
- for (let op of ops) {
161
- const key = op.source_key.toHexString();
162
- if (op.op == 'PUT') {
163
- active.add(key);
164
- } else if (op.op == 'REMOVE') {
165
- active.delete(key);
160
+ let compactController = new AbortController();
161
+
162
+ const bgCompact = async () => {
163
+ // Repeatedly compact, and check that the compact conditions hold
164
+ while (!compactController.signal.aborted) {
165
+ const delay = Math.random() * 50;
166
+ try {
167
+ await timers.setTimeout(delay, undefined, { signal: compactController.signal });
168
+ } catch (e) {
169
+ break;
166
170
  }
171
+
172
+ const checkpoint = BigInt((await storage.getCheckpoint()).checkpoint);
173
+ const opsBefore = (await f.db.bucket_data.find().sort({ _id: 1 }).toArray())
174
+ .filter((row) => row._id.o <= checkpoint)
175
+ .map(mapOpEntry);
176
+ await storage.compact({ maxOpId: checkpoint });
177
+ const opsAfter = (await f.db.bucket_data.find().sort({ _id: 1 }).toArray())
178
+ .filter((row) => row._id.o <= checkpoint)
179
+ .map(mapOpEntry);
180
+
181
+ validateCompactedBucket(opsBefore, opsAfter);
167
182
  }
168
- if (active.size > 0) {
169
- throw new Error(`${active.size} rows not removed`);
183
+ };
184
+
185
+ // Call the above loop multiple times concurrently
186
+ const promises = [1, 2, 3].map((i) => bg());
187
+ const compactPromise = testOptions.compact ? bgCompact() : null;
188
+ await Promise.all(promises);
189
+ compactController.abort();
190
+ await compactPromise;
191
+
192
+ // Wait for replication to finish
193
+ let checkpoint = await getClientCheckpoint(pool, storage.factory, { timeout: TIMEOUT_MARGIN_MS });
194
+
195
+ // Check that all inserts have been deleted again
196
+ const docs = await f.db.current_data.find().toArray();
197
+ const transformed = docs.map((doc) => {
198
+ return bson.deserialize((doc.data as mongo.Binary).buffer) as SqliteRow;
199
+ });
200
+ expect(transformed).toEqual([]);
201
+
202
+ // Check that each PUT has a REMOVE
203
+ const ops = await f.db.bucket_data.find().sort({ _id: 1 }).toArray();
204
+
205
+ // All a single bucket in this test
206
+ const bucket = ops.map((op) => mapOpEntry(op));
207
+ const reduced = reduceBucket(bucket);
208
+ expect(reduced).toMatchObject([
209
+ {
210
+ op_id: '0',
211
+ op: 'CLEAR'
170
212
  }
171
- }
213
+ // Should contain no additional data
214
+ ]);
215
+ }
172
216
 
173
- abortController.abort();
174
- await streamPromise;
175
- },
176
- { timeout: TEST_DURATION_MS + TIMEOUT_MARGIN_MS }
177
- );
217
+ abortController.abort();
218
+ await streamPromise;
219
+ }
178
220
 
179
221
  // Test repeatedly performing initial replication.
180
222
  //
@@ -184,6 +226,7 @@ bucket_definitions:
184
226
  'repeated initial replication',
185
227
  async () => {
186
228
  const pool = await connectPgPool();
229
+ await clearTestDb(pool);
187
230
  const f = await factory();
188
231
 
189
232
  const syncRuleContent = `
@@ -196,7 +239,6 @@ bucket_definitions:
196
239
  const storage = f.getInstance(syncRules.parsed());
197
240
 
198
241
  // 1. Setup some base data that will be replicated in initial replication
199
- await pool.query(`DROP TABLE IF EXISTS test_data`);
200
242
  await pool.query(`CREATE TABLE test_data(id uuid primary key default uuid_generate_v4(), description text)`);
201
243
 
202
244
  let statements: pgwire.Statement[] = [];