@powersync/service-module-mongodb-storage 0.20.0 → 0.21.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 (176) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/storage/MongoBucketStorage.d.ts +31 -3
  3. package/dist/storage/MongoBucketStorage.js +365 -206
  4. package/dist/storage/MongoBucketStorage.js.map +1 -1
  5. package/dist/storage/implementation/MongoBucketBatch.d.ts +15 -4
  6. package/dist/storage/implementation/MongoBucketBatch.js +35 -9
  7. package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
  8. package/dist/storage/implementation/MongoCompactor.d.ts +19 -79
  9. package/dist/storage/implementation/MongoCompactor.js +27 -238
  10. package/dist/storage/implementation/MongoCompactor.js.map +1 -1
  11. package/dist/storage/implementation/MongoParameterCompactor.d.ts +83 -10
  12. package/dist/storage/implementation/MongoParameterCompactor.js +309 -96
  13. package/dist/storage/implementation/MongoParameterCompactor.js.map +1 -1
  14. package/dist/storage/implementation/MongoParsedSyncConfigSet.d.ts +9 -1
  15. package/dist/storage/implementation/MongoParsedSyncConfigSet.js +29 -0
  16. package/dist/storage/implementation/MongoParsedSyncConfigSet.js.map +1 -1
  17. package/dist/storage/implementation/MongoPersistedSyncConfigContent.js +4 -2
  18. package/dist/storage/implementation/MongoPersistedSyncConfigContent.js.map +1 -1
  19. package/dist/storage/implementation/MongoStorageProvider.js +3 -1
  20. package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
  21. package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +27 -11
  22. package/dist/storage/implementation/MongoSyncBucketStorage.js +78 -31
  23. package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
  24. package/dist/storage/implementation/MongoSyncRulesLock.js +21 -9
  25. package/dist/storage/implementation/MongoSyncRulesLock.js.map +1 -1
  26. package/dist/storage/implementation/MongoWriteCheckpointAPI.d.ts +9 -7
  27. package/dist/storage/implementation/MongoWriteCheckpointAPI.js +7 -28
  28. package/dist/storage/implementation/MongoWriteCheckpointAPI.js.map +1 -1
  29. package/dist/storage/implementation/SyncRuleStateUpdate.d.ts +11 -5
  30. package/dist/storage/implementation/SyncRuleStateUpdate.js +14 -6
  31. package/dist/storage/implementation/SyncRuleStateUpdate.js.map +1 -1
  32. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.d.ts +22 -2
  33. package/dist/storage/implementation/common/PersistedBatch.d.ts +27 -0
  34. package/dist/storage/implementation/common/PersistedBatch.js +43 -2
  35. package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
  36. package/dist/storage/implementation/db.d.ts +4 -1
  37. package/dist/storage/implementation/db.js +14 -1
  38. package/dist/storage/implementation/db.js.map +1 -1
  39. package/dist/storage/implementation/models.d.ts +31 -0
  40. package/dist/storage/implementation/models.js.map +1 -1
  41. package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +2 -0
  42. package/dist/storage/implementation/v1/MongoBucketBatchV1.js +22 -0
  43. package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
  44. package/dist/storage/implementation/v1/MongoCompactorV1.d.ts +36 -5
  45. package/dist/storage/implementation/v1/MongoCompactorV1.js +229 -8
  46. package/dist/storage/implementation/v1/MongoCompactorV1.js.map +1 -1
  47. package/dist/storage/implementation/v1/MongoParameterCompactorV1.d.ts +16 -2
  48. package/dist/storage/implementation/v1/MongoParameterCompactorV1.js +20 -9
  49. package/dist/storage/implementation/v1/MongoParameterCompactorV1.js.map +1 -1
  50. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.d.ts +18 -11
  51. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +65 -16
  52. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
  53. package/dist/storage/implementation/v1/models.js +1 -0
  54. package/dist/storage/implementation/v1/models.js.map +1 -1
  55. package/dist/storage/implementation/v3/CompactionLease.d.ts +50 -0
  56. package/dist/storage/implementation/v3/CompactionLease.js +131 -0
  57. package/dist/storage/implementation/v3/CompactionLease.js.map +1 -0
  58. package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +8 -1
  59. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +90 -19
  60. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  61. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +77 -25
  62. package/dist/storage/implementation/v3/MongoCompactorV3.js +585 -141
  63. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  64. package/dist/storage/implementation/v3/MongoParameterCompactorV3.d.ts +18 -0
  65. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js +23 -0
  66. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js.map +1 -0
  67. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.d.ts +2 -15
  68. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js +60 -92
  69. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js.map +1 -1
  70. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.d.ts +19 -9
  71. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +91 -31
  72. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
  73. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.d.ts +26 -0
  74. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.js +82 -0
  75. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.js.map +1 -0
  76. package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +2 -0
  77. package/dist/storage/implementation/v3/PersistedBatchV3.js +52 -11
  78. package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
  79. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.d.ts +12 -2
  80. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js +42 -3
  81. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js.map +1 -1
  82. package/dist/storage/implementation/v3/bucket-format.js +2 -0
  83. package/dist/storage/implementation/v3/bucket-format.js.map +1 -1
  84. package/dist/storage/implementation/v3/compact-utils.d.ts +107 -0
  85. package/dist/storage/implementation/v3/compact-utils.js +216 -0
  86. package/dist/storage/implementation/v3/compact-utils.js.map +1 -0
  87. package/dist/storage/implementation/v3/compaction-constants.d.ts +5 -0
  88. package/dist/storage/implementation/v3/compaction-constants.js +6 -0
  89. package/dist/storage/implementation/v3/compaction-constants.js.map +1 -0
  90. package/dist/storage/implementation/v3/models.d.ts +84 -4
  91. package/dist/storage/implementation/v3/models.js.map +1 -1
  92. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.d.ts +7 -8
  93. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +8 -4
  94. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -1
  95. package/dist/storage/implementation/v3/object-storage/ObjectStorage.d.ts +14 -11
  96. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +3 -5
  97. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +10 -2
  98. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -1
  99. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.d.ts +37 -0
  100. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js +167 -0
  101. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js.map +1 -0
  102. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.d.ts +37 -12
  103. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js +177 -23
  104. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js.map +1 -1
  105. package/dist/storage/implementation/v3/source-table-utils.d.ts +6 -6
  106. package/dist/storage/implementation/v3/source-table-utils.js +30 -37
  107. package/dist/storage/implementation/v3/source-table-utils.js.map +1 -1
  108. package/dist/storage/storage-index.d.ts +2 -1
  109. package/dist/storage/storage-index.js +1 -0
  110. package/dist/storage/storage-index.js.map +1 -1
  111. package/dist/types/types.d.ts +23 -1
  112. package/dist/types/types.js +16 -7
  113. package/dist/types/types.js.map +1 -1
  114. package/dist/utils/util.js +3 -1
  115. package/dist/utils/util.js.map +1 -1
  116. package/package.json +10 -9
  117. package/src/storage/MongoBucketStorage.ts +495 -268
  118. package/src/storage/implementation/MongoBucketBatch.ts +54 -14
  119. package/src/storage/implementation/MongoCompactor.ts +35 -346
  120. package/src/storage/implementation/MongoParameterCompactor.ts +388 -102
  121. package/src/storage/implementation/MongoParsedSyncConfigSet.ts +34 -0
  122. package/src/storage/implementation/MongoPersistedSyncConfigContent.ts +4 -2
  123. package/src/storage/implementation/MongoStorageProvider.ts +3 -1
  124. package/src/storage/implementation/MongoSyncBucketStorage.ts +114 -40
  125. package/src/storage/implementation/MongoSyncRulesLock.ts +22 -9
  126. package/src/storage/implementation/MongoWriteCheckpointAPI.ts +12 -44
  127. package/src/storage/implementation/SyncRuleStateUpdate.ts +13 -6
  128. package/src/storage/implementation/common/MongoSyncBucketStorageCheckpoint.ts +24 -2
  129. package/src/storage/implementation/common/PersistedBatch.ts +54 -2
  130. package/src/storage/implementation/db.ts +21 -2
  131. package/src/storage/implementation/models.ts +34 -0
  132. package/src/storage/implementation/v1/MongoBucketBatchV1.ts +31 -0
  133. package/src/storage/implementation/v1/MongoCompactorV1.ts +303 -19
  134. package/src/storage/implementation/v1/MongoParameterCompactorV1.ts +25 -9
  135. package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +87 -27
  136. package/src/storage/implementation/v1/models.ts +1 -0
  137. package/src/storage/implementation/v3/CompactionLease.ts +152 -0
  138. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +124 -22
  139. package/src/storage/implementation/v3/MongoCompactorV3.ts +744 -190
  140. package/src/storage/implementation/v3/MongoParameterCompactorV3.ts +34 -0
  141. package/src/storage/implementation/v3/MongoStoppedSyncConfigCleanup.ts +100 -109
  142. package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +111 -41
  143. package/src/storage/implementation/v3/MongoWriteCheckpointAPIV3.ts +111 -0
  144. package/src/storage/implementation/v3/PersistedBatchV3.ts +57 -12
  145. package/src/storage/implementation/v3/VersionedPowerSyncMongoV3.ts +55 -4
  146. package/src/storage/implementation/v3/bucket-format.ts +2 -0
  147. package/src/storage/implementation/v3/compact-utils.ts +320 -0
  148. package/src/storage/implementation/v3/compaction-constants.ts +5 -0
  149. package/src/storage/implementation/v3/models.ts +91 -3
  150. package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +16 -8
  151. package/src/storage/implementation/v3/object-storage/ObjectStorage.ts +20 -5
  152. package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +15 -4
  153. package/src/storage/implementation/v3/object-storage/ObjectStorageUsage.ts +230 -0
  154. package/src/storage/implementation/v3/object-storage/S3ObjectStorage.ts +257 -27
  155. package/src/storage/implementation/v3/source-table-utils.ts +39 -41
  156. package/src/storage/storage-index.ts +2 -0
  157. package/src/types/types.ts +24 -7
  158. package/src/utils/util.ts +3 -1
  159. package/test/src/__snapshots__/storage.test.ts.snap +25 -0
  160. package/test/src/__snapshots__/storage_sync.test.ts.snap +605 -16
  161. package/test/src/cleanup-stopped-sync-configs.test.ts +40 -13
  162. package/test/src/compact-utils.test.ts +226 -0
  163. package/test/src/helpers/MemoryObjectStorage.ts +14 -6
  164. package/test/src/object_storage_usage.test.ts +255 -0
  165. package/test/src/parameter_compacting_v1.test.ts +120 -0
  166. package/test/src/parameter_compacting_v3.test.ts +192 -0
  167. package/test/src/parameter_compaction_fence.test.ts +276 -0
  168. package/test/src/storage.test.ts +273 -22
  169. package/test/src/storage_compacting.test.ts +714 -244
  170. package/test/src/storage_s3_checksums.test.ts +3 -4
  171. package/test/src/storage_s3_compaction_lifecycle.test.ts +138 -12
  172. package/test/src/storage_s3_reading.test.ts +243 -1
  173. package/test/src/storage_s3_writing.test.ts +1 -0
  174. package/test/src/storage_sync.test.ts +336 -41
  175. package/test/src/util.ts +1 -1
  176. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,120 @@
1
+ import { storage, updateSyncRulesFromYaml } from '@powersync/service-core';
2
+ import { test_utils } from '@powersync/service-core-tests';
3
+ import * as bson from 'bson';
4
+ import { describe, expect, test } from 'vitest';
5
+ import type { SyncRuleDocumentV1 } from '../../src/storage/implementation/v1/models.js';
6
+ import { MongoParameterCompactorV1 } from '../../src/storage/implementation/v1/MongoParameterCompactorV1.js';
7
+ import { MongoSyncBucketStorageV1 } from '../../src/storage/implementation/v1/MongoSyncBucketStorageV1.js';
8
+ import type { VersionedPowerSyncMongoV1 } from '../../src/storage/implementation/v1/VersionedPowerSyncMongoV1.js';
9
+ import { INITIALIZED_MONGO_STORAGE_FACTORY } from './util.js';
10
+
11
+ const PARAMETER_RULES = `
12
+ bucket_definitions:
13
+ test:
14
+ parameters: select id from test where id = request.user_id()
15
+ data: []
16
+ `;
17
+
18
+ async function createActiveStorage() {
19
+ const factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
20
+ const syncRules = await factory.updateSyncRules(
21
+ updateSyncRulesFromYaml(PARAMETER_RULES, { storageVersion: storage.STORAGE_VERSION_2 })
22
+ );
23
+ const processingStorage = factory.getInstance(syncRules);
24
+ await using writer = await processingStorage.createWriter(test_utils.BATCH_OPTIONS);
25
+ await writer.markAllSnapshotDone('1/1');
26
+ await writer.commit('1/1');
27
+
28
+ const active = await factory.getActiveSyncConfig();
29
+ if (active == null) {
30
+ throw new Error('Expected an active sync config');
31
+ }
32
+ return { factory, storage: active.storage as MongoSyncBucketStorageV1, streamId: syncRules.replicationStreamId };
33
+ }
34
+
35
+ function parameterDocument(
36
+ id: bigint,
37
+ groupId: number,
38
+ key: { t: bson.ObjectId; k: string },
39
+ lookup: bson.Binary,
40
+ bucket_parameters: Record<string, string>[]
41
+ ): Record<string, unknown> {
42
+ return { _id: id, key: { ...key, g: groupId }, lookup, bucket_parameters };
43
+ }
44
+
45
+ describe('Mongo parameter compaction V1', () => {
46
+ test('compacts incrementally with an _id-only scan and persists the cursor', async () => {
47
+ const { factory, storage: bucketStorage, streamId } = await createActiveStorage();
48
+ await using _factory = factory;
49
+
50
+ const db = bucketStorage.db as VersionedPowerSyncMongoV1;
51
+ const collection = db.parameterIndexV1 as any;
52
+ const key = { t: new bson.ObjectId(), k: 'row' };
53
+ const lookup = new bson.Binary(Buffer.from('lookup'));
54
+
55
+ await collection.insertMany([
56
+ parameterDocument(90n, streamId + 1, key, lookup, [{ id: 'other-stream' }]),
57
+ parameterDocument(100n, streamId, key, lookup, [{ id: 'old' }]),
58
+ parameterDocument(110n, streamId, key, lookup, [{ id: 'new' }]),
59
+ parameterDocument(120n, streamId, { ...key, k: 'deleted' }, lookup, [{ id: 'delete-me' }]),
60
+ parameterDocument(130n, streamId, { ...key, k: 'deleted' }, lookup, []),
61
+ parameterDocument(200n, streamId, key, lookup, [{ id: 'at-target' }])
62
+ ]);
63
+
64
+ await bucketStorage.compact({
65
+ compactBuckets: [],
66
+ compactParameterData: true,
67
+ incrementalOnly: true,
68
+ maxOpId: 200n
69
+ });
70
+
71
+ const firstPass = await collection.find({}, { sort: { _id: 1 } }).toArray();
72
+ expect(firstPass.map((document: any) => BigInt(document._id))).toEqual([90n, 110n, 200n]);
73
+ const firstStreamDoc = (await db.sync_rules.findOne({ _id: streamId })) as SyncRuleDocumentV1;
74
+ expect(BigInt(firstStreamDoc.parameter_compaction!.compacted_before)).toBe(200n);
75
+
76
+ await collection.insertMany([
77
+ parameterDocument(210n, streamId, key, lookup, [{ id: 'later' }]),
78
+ parameterDocument(220n, streamId, { ...key, k: 'row' }, lookup, [])
79
+ ]);
80
+ const incremental = new MongoParameterCompactorV1(db, streamId, 300n, {});
81
+ await incremental.compact();
82
+
83
+ const secondPass = await collection.find({}, { sort: { _id: 1 } }).toArray();
84
+ expect(secondPass.map((document: any) => BigInt(document._id))).toEqual([90n]);
85
+ const secondStreamDoc = (await db.sync_rules.findOne({ _id: streamId })) as SyncRuleDocumentV1;
86
+ expect(BigInt(secondStreamDoc.parameter_compaction!.compacted_before)).toBe(300n);
87
+ });
88
+
89
+ test('seeds the compaction cursor when a V1 stream is created', async () => {
90
+ await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
91
+ // Stand in for parameter history written by earlier deployments: all V1 streams share the
92
+ // `main` op id sequence and the `bucket_parameters` collection.
93
+ await factory.db.op_id_sequence.updateOne({ _id: 'main' }, { $set: { op_id: 500n } }, { upsert: true });
94
+
95
+ const syncRules = await factory.updateSyncRules(
96
+ updateSyncRulesFromYaml(PARAMETER_RULES, { storageVersion: storage.STORAGE_VERSION_2 })
97
+ );
98
+
99
+ // Entries for this stream can only be written above the sequence head, so its first compaction
100
+ // does not have to scan the older entries of other streams.
101
+ const streamDoc = (await factory.db.sync_rules.findOne({
102
+ _id: syncRules.replicationStreamId
103
+ })) as SyncRuleDocumentV1;
104
+ expect(BigInt(streamDoc.parameter_compaction!.compacted_before)).toBe(500n);
105
+ });
106
+
107
+ test('clearing a V1 stream clears the parameter compaction cursor', async () => {
108
+ const { factory, storage: bucketStorage, streamId } = await createActiveStorage();
109
+ await using _factory = factory;
110
+
111
+ const db = bucketStorage.db as VersionedPowerSyncMongoV1;
112
+ await db.sync_rules.updateOne({ _id: streamId }, {
113
+ $set: { 'parameter_compaction.compacted_before': 123n }
114
+ } as any);
115
+ await bucketStorage.clear();
116
+
117
+ const streamDoc = (await db.sync_rules.findOne({ _id: streamId })) as SyncRuleDocumentV1;
118
+ expect(streamDoc.parameter_compaction).toBeUndefined();
119
+ });
120
+ });
@@ -0,0 +1,192 @@
1
+ import { storage, updateSyncRulesFromYaml } from '@powersync/service-core';
2
+ import { test_utils } from '@powersync/service-core-tests';
3
+ import * as bson from 'bson';
4
+ import { describe, expect, test } from 'vitest';
5
+ import { MongoParameterCompactorV3 } from '../../src/storage/implementation/v3/MongoParameterCompactorV3.js';
6
+ import { MongoSyncBucketStorageV3 } from '../../src/storage/implementation/v3/MongoSyncBucketStorageV3.js';
7
+ import type { VersionedPowerSyncMongoV3 } from '../../src/storage/implementation/v3/VersionedPowerSyncMongoV3.js';
8
+ import type { ReplicationStreamDocumentV3 } from '../../src/storage/implementation/v3/models.js';
9
+ import { INITIALIZED_MONGO_STORAGE_FACTORY } from './util.js';
10
+
11
+ const PARAMETER_RULES = `
12
+ bucket_definitions:
13
+ test:
14
+ parameters: select id from test where id = request.user_id()
15
+ data: []
16
+ `;
17
+
18
+ /** Two parameter indexes over the same lookup values, so both store identical (key, lookup) pairs. */
19
+ const TWO_INDEX_PARAMETER_RULES = `
20
+ bucket_definitions:
21
+ test1:
22
+ parameters: select id from test where id = request.user_id()
23
+ data: []
24
+ test2:
25
+ parameters: select id from test where id = request.user_id()
26
+ data: []
27
+ `;
28
+
29
+ async function createActiveStorage(rules = PARAMETER_RULES) {
30
+ const factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
31
+ const syncRules = await factory.updateSyncRules(
32
+ updateSyncRulesFromYaml(rules, { storageVersion: storage.STORAGE_VERSION_3 })
33
+ );
34
+ const processingStorage = factory.getInstance(syncRules);
35
+ await using writer = await processingStorage.createWriter(test_utils.BATCH_OPTIONS);
36
+ await writer.markAllSnapshotDone('1/1');
37
+ await writer.commit('1/1');
38
+
39
+ const active = await factory.getActiveSyncConfig();
40
+ if (active == null) {
41
+ throw new Error('Expected an active sync config');
42
+ }
43
+
44
+ return { factory, storage: active.storage as MongoSyncBucketStorageV3, streamId: syncRules.replicationStreamId };
45
+ }
46
+
47
+ function parameterDocument(
48
+ id: bigint,
49
+ key: { t: bson.ObjectId; k: string },
50
+ lookup: bson.Binary,
51
+ bucket_parameters: Record<string, string>[]
52
+ ): Record<string, unknown> {
53
+ return { _id: id, key, lookup, bucket_parameters };
54
+ }
55
+
56
+ describe('Mongo parameter compaction V3', () => {
57
+ test('compacts incrementally, honors the target boundary, and persists the cursor', async () => {
58
+ const { factory, storage: bucketStorage, streamId } = await createActiveStorage();
59
+ await using _factory = factory;
60
+
61
+ const db = bucketStorage.db as VersionedPowerSyncMongoV3;
62
+ const parameterCollections = await db.listParameterIndexCollections(streamId);
63
+ expect(parameterCollections).toHaveLength(1);
64
+ const collection = parameterCollections[0].collection as any;
65
+ const key = { t: new bson.ObjectId(), k: 'row' };
66
+ const lookup = new bson.Binary(Buffer.from('lookup'));
67
+
68
+ await collection.insertMany([
69
+ parameterDocument(100n, key, lookup, [{ id: 'old' }]),
70
+ parameterDocument(110n, key, lookup, [{ id: 'new' }]),
71
+ parameterDocument(120n, { ...key, k: 'deleted' }, lookup, [{ id: 'delete-me' }]),
72
+ parameterDocument(130n, { ...key, k: 'deleted' }, lookup, []),
73
+ parameterDocument(200n, key, lookup, [{ id: 'at-target' }])
74
+ ]);
75
+
76
+ await bucketStorage.compact({
77
+ compactBuckets: [],
78
+ compactParameterData: true,
79
+ maxOpId: 200n
80
+ });
81
+
82
+ const firstPass = await collection.find({}, { sort: { _id: 1 } }).toArray();
83
+ expect(firstPass.map((document: any) => BigInt(document._id))).toEqual([110n, 200n]);
84
+ const firstStreamDoc = (await db.sync_rules.findOne({ _id: streamId })) as ReplicationStreamDocumentV3;
85
+ expect(BigInt(firstStreamDoc.parameter_compaction!.compacted_before)).toBe(200n);
86
+
87
+ // A repeated pass at the same target has no eligible range to scan.
88
+ await bucketStorage.compact({
89
+ compactBuckets: [],
90
+ compactParameterData: true,
91
+ maxOpId: 200n
92
+ });
93
+ await expect(collection.countDocuments({})).resolves.toBe(2);
94
+
95
+ await collection.insertMany([
96
+ parameterDocument(210n, key, lookup, [{ id: 'later' }]),
97
+ parameterDocument(220n, { ...key, k: 'row' }, lookup, [])
98
+ ]);
99
+
100
+ // Use a small batch to exercise identities that span multiple reads. The exact-target entry
101
+ // becomes eligible only after the target advances.
102
+ const incremental = new MongoParameterCompactorV3(db, streamId, 300n, {}, 2);
103
+ await incremental.compact();
104
+
105
+ const secondPass = await collection.find({}, { sort: { _id: 1 } }).toArray();
106
+ expect(secondPass.map((document: any) => BigInt(document._id))).toEqual([]);
107
+ const secondStreamDoc = (await db.sync_rules.findOne({ _id: streamId })) as ReplicationStreamDocumentV3;
108
+ expect(BigInt(secondStreamDoc.parameter_compaction!.compacted_before)).toBe(300n);
109
+ });
110
+
111
+ test('keeps identities scoped per parameter index while compacting them in lock-step', async () => {
112
+ const { factory, storage: bucketStorage, streamId } = await createActiveStorage(TWO_INDEX_PARAMETER_RULES);
113
+ await using _factory = factory;
114
+
115
+ const db = bucketStorage.db as VersionedPowerSyncMongoV3;
116
+ const parameterCollections = await db.listParameterIndexCollections(streamId);
117
+ expect(parameterCollections).toHaveLength(2);
118
+ const [first, second] = parameterCollections.map(({ collection }) => collection as any);
119
+
120
+ // The same source row and lookup values in both indexes. V3 keeps the index id in the
121
+ // collection name rather than in the lookup, so these documents are byte-identical apart from
122
+ // their op ids - the compactor must not carry what it deleted in one index over to the other.
123
+ const key = { t: new bson.ObjectId(), k: 'row' };
124
+ const lookup = new bson.Binary(Buffer.from('lookup'));
125
+ await first.insertMany([
126
+ parameterDocument(10n, key, lookup, [{ id: 'first' }]),
127
+ parameterDocument(40n, key, lookup, [])
128
+ ]);
129
+ await second.insertMany([
130
+ parameterDocument(20n, key, lookup, [{ id: 'second' }]),
131
+ parameterDocument(30n, key, lookup, [])
132
+ ]);
133
+
134
+ // One document per batch, so the two indexes are processed in interleaved turns.
135
+ await new MongoParameterCompactorV3(db, streamId, 100n, {}, 1).compact();
136
+
137
+ // Each tombstone removed its own index's history, and only that.
138
+ await expect(first.countDocuments({})).resolves.toBe(0);
139
+ await expect(second.countDocuments({})).resolves.toBe(0);
140
+ });
141
+
142
+ test('persists progress during a pass', async () => {
143
+ const { factory, storage: bucketStorage, streamId } = await createActiveStorage(TWO_INDEX_PARAMETER_RULES);
144
+ await using _factory = factory;
145
+
146
+ const db = bucketStorage.db as VersionedPowerSyncMongoV3;
147
+ const parameterCollections = await db.listParameterIndexCollections(streamId);
148
+ const [first, second] = parameterCollections.map(({ collection }) => collection as any);
149
+ const lookup = new bson.Binary(Buffer.from('lookup'));
150
+ const key = (k: string) => ({ t: new bson.ObjectId(), k });
151
+ await first.insertMany([
152
+ parameterDocument(10n, key('a'), lookup, [{ id: 'a' }]),
153
+ parameterDocument(30n, key('b'), lookup, [{ id: 'b' }])
154
+ ]);
155
+ await second.insertMany([
156
+ parameterDocument(20n, key('c'), lookup, [{ id: 'c' }]),
157
+ parameterDocument(40n, key('d'), lookup, [{ id: 'd' }])
158
+ ]);
159
+
160
+ const persisted: bigint[] = [];
161
+ class RecordingCompactor extends MongoParameterCompactorV3 {
162
+ protected override async persistCompactedBefore(compactedBefore: bigint): Promise<void> {
163
+ persisted.push(compactedBefore);
164
+ return super.persistCompactedBefore(compactedBefore);
165
+ }
166
+ }
167
+
168
+ // One document per batch, and no throttling of progress writes.
169
+ await new RecordingCompactor(db, streamId, 100n, {}, 1, 0).compact();
170
+
171
+ // The cursor only ever covers what both indexes have passed, and it moves before the pass
172
+ // completes, so an interruption does not lose all progress.
173
+ expect(persisted).toEqual([...persisted].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)));
174
+ expect(persisted.some((value) => value > 0n && value < 100n)).toBe(true);
175
+ expect(persisted.at(-1)).toBe(100n);
176
+ });
177
+
178
+ test('clearing a V3 stream clears the parameter compaction cursor', async () => {
179
+ const { factory, storage: bucketStorage, streamId } = await createActiveStorage();
180
+ await using _factory = factory;
181
+
182
+ const db = bucketStorage.db as VersionedPowerSyncMongoV3;
183
+ await db.sync_rules.updateOne({ _id: streamId }, {
184
+ $set: { 'parameter_compaction.compacted_before': 123n }
185
+ } as any);
186
+
187
+ await bucketStorage.clear();
188
+
189
+ const streamDoc = (await db.sync_rules.findOne({ _id: streamId })) as ReplicationStreamDocumentV3;
190
+ expect(streamDoc.parameter_compaction).toBeUndefined();
191
+ });
192
+ });
@@ -0,0 +1,276 @@
1
+ import { mongo } from '@powersync/lib-service-mongodb';
2
+ import { InternalOpId, storage, updateSyncRulesFromYaml } from '@powersync/service-core';
3
+ import { test_utils } from '@powersync/service-core-tests';
4
+ import { describe, expect, test } from 'vitest';
5
+ import type { VersionedPowerSyncMongo } from '../../src/storage/implementation/db.js';
6
+ import type { SyncRuleDocumentBase } from '../../src/storage/implementation/models.js';
7
+ import { MongoParameterCompactor } from '../../src/storage/implementation/MongoParameterCompactor.js';
8
+ import { MongoSyncBucketStorage } from '../../src/storage/implementation/MongoSyncBucketStorage.js';
9
+ import { MongoParameterCompactorV1 } from '../../src/storage/implementation/v1/MongoParameterCompactorV1.js';
10
+ import { MongoParameterCompactorV3 } from '../../src/storage/implementation/v3/MongoParameterCompactorV3.js';
11
+ import { MongoBucketStorage } from '../../src/storage/MongoBucketStorage.js';
12
+ import { INITIALIZED_MONGO_STORAGE_FACTORY, TEST_STORAGE_VERSIONS } from './util.js';
13
+
14
+ const PARAMETER_RULES = `
15
+ bucket_definitions:
16
+ test:
17
+ parameters: select id from test where id = request.user_id()
18
+ data: []
19
+ `;
20
+
21
+ /**
22
+ * Wraps a collection so that any delete fails. Used to check that an interrupted compaction pass
23
+ * leaves the invalidation fence advanced, but not the compaction cursor.
24
+ */
25
+ function withFailingDeletes<T extends object>(collection: T): T {
26
+ return new Proxy(collection, {
27
+ get(target, property) {
28
+ if (property == 'deleteMany' || property == 'bulkWrite') {
29
+ return () => Promise.reject(new Error('simulated delete failure'));
30
+ }
31
+ const value = Reflect.get(target, property, target);
32
+ // Bind to the target: the driver's collection uses private fields internally.
33
+ return typeof value == 'function' ? value.bind(target) : value;
34
+ }
35
+ }) as T;
36
+ }
37
+
38
+ class FailingParameterCompactorV1 extends MongoParameterCompactorV1 {
39
+ protected override async getCollections(): Promise<mongo.Collection<mongo.Document>[]> {
40
+ return (await super.getCollections()).map(withFailingDeletes);
41
+ }
42
+ }
43
+
44
+ class FailingParameterCompactorV3 extends MongoParameterCompactorV3 {
45
+ protected override async getCollections(): Promise<mongo.Collection<mongo.Document>[]> {
46
+ return (await super.getCollections()).map(withFailingDeletes);
47
+ }
48
+ }
49
+
50
+ describe('parameter compaction invalidation fence', () => {
51
+ for (const storageVersion of TEST_STORAGE_VERSIONS) {
52
+ describe(`storage v${storageVersion}`, () => {
53
+ function createCompactor(
54
+ db: VersionedPowerSyncMongo,
55
+ streamId: number,
56
+ checkpoint: InternalOpId,
57
+ options: storage.CompactOptions & { failDeletes?: boolean } = {}
58
+ ): MongoParameterCompactor {
59
+ const { failDeletes, ...compactOptions } = options;
60
+ if (storageVersion >= 3) {
61
+ const Compactor = failDeletes ? FailingParameterCompactorV3 : MongoParameterCompactorV3;
62
+ return new Compactor(db, streamId, checkpoint, compactOptions);
63
+ }
64
+ const Compactor = failDeletes ? FailingParameterCompactorV1 : MongoParameterCompactorV1;
65
+ return new Compactor(db, streamId, checkpoint, compactOptions);
66
+ }
67
+
68
+ async function readCompactionState(db: VersionedPowerSyncMongo, streamId: number) {
69
+ const doc = (await db.sync_rules.findOne({ _id: streamId })) as SyncRuleDocumentBase;
70
+ return {
71
+ compactedBefore: doc.parameter_compaction?.compacted_before ?? null,
72
+ invalidBefore: doc.parameter_compaction?.checkpoint_changes_invalid_before ?? null
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Replicates three checkpoints:
78
+ *
79
+ * 1. `checkpoint1`: t1 and t2 inserted.
80
+ * 2. t2 deleted - this writes a parameter tombstone, the only remaining record of t2's lookup.
81
+ * 3. `checkpoint3`: t3 inserted, which puts the tombstone strictly below the checkpoint,
82
+ * making it eligible for compaction.
83
+ */
84
+ async function replicateParameterHistory(factory: storage.BucketStorageFactory) {
85
+ const syncRules = await factory.updateSyncRules(updateSyncRulesFromYaml(PARAMETER_RULES, { storageVersion }));
86
+ const processingStorage = factory.getInstance(syncRules);
87
+ const writer = await processingStorage.createWriter(test_utils.BATCH_OPTIONS);
88
+ const testTable = await test_utils.resolveTestTable(writer, 'test', ['id'], INITIALIZED_MONGO_STORAGE_FACTORY);
89
+
90
+ await writer.markAllSnapshotDone('1/1');
91
+ for (const id of ['t1', 't2']) {
92
+ await writer.save({
93
+ sourceTable: testTable,
94
+ tag: storage.SaveOperationTag.INSERT,
95
+ after: { id },
96
+ afterReplicaId: test_utils.rid(id)
97
+ });
98
+ }
99
+ await writer.commit('1/1');
100
+
101
+ const active = await factory.getActiveSyncConfig();
102
+ if (active == null) {
103
+ throw new Error('Expected an active sync config');
104
+ }
105
+ const bucketStorage = active.storage as MongoSyncBucketStorage;
106
+ const checkpoint1 = await bucketStorage.getCheckpoint();
107
+
108
+ await writer.save({
109
+ sourceTable: testTable,
110
+ tag: storage.SaveOperationTag.DELETE,
111
+ before: { id: 't2' },
112
+ beforeReplicaId: test_utils.rid('t2')
113
+ });
114
+ await writer.commit('1/2');
115
+
116
+ await writer.save({
117
+ sourceTable: testTable,
118
+ tag: storage.SaveOperationTag.INSERT,
119
+ after: { id: 't3' },
120
+ afterReplicaId: test_utils.rid('t3')
121
+ });
122
+ await writer.commit('1/3');
123
+ await writer.dispose();
124
+
125
+ const checkpoint3 = await bucketStorage.getCheckpoint();
126
+ expect(checkpoint3.checkpoint).toBeGreaterThan(checkpoint1.checkpoint);
127
+
128
+ return {
129
+ bucketStorage,
130
+ replicationStream: active.replicationStream,
131
+ streamId: syncRules.replicationStreamId,
132
+ checkpoint1,
133
+ checkpoint3
134
+ };
135
+ }
136
+
137
+ test('invalidates parameter buckets for checkpoints below the fence', async () => {
138
+ await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
139
+ const { bucketStorage, streamId, checkpoint1, checkpoint3 } = await replicateParameterHistory(factory);
140
+ const db = bucketStorage.db;
141
+
142
+ // The tombstone for t2 and the new entry for t3 are both in this range.
143
+ const baseline = await bucketStorage.getCheckpointChanges({
144
+ lastCheckpoint: checkpoint1,
145
+ nextCheckpoint: checkpoint3
146
+ });
147
+ expect(baseline.invalidateParameterBuckets).toBe(false);
148
+ expect(baseline.updatedParameterLookups.size).toBe(2);
149
+
150
+ await createCompactor(db, streamId, checkpoint3.checkpoint).compact();
151
+
152
+ // The fence is advanced because the pass deleted parameter entries.
153
+ expect(await readCompactionState(db, streamId)).toEqual({
154
+ compactedBefore: checkpoint3.checkpoint,
155
+ invalidBefore: checkpoint3.checkpoint
156
+ });
157
+
158
+ // A checkpoint read after compaction captures the fence. The transition from checkpoint1
159
+ // can no longer be resolved to individual lookups, so all parameter buckets are
160
+ // invalidated. This is a different cache entry from the baseline above, even though the
161
+ // checkpoint and LSN are unchanged.
162
+ const checkpoint3After = await bucketStorage.getCheckpoint();
163
+ expect(checkpoint3After.checkpoint).toBe(checkpoint3.checkpoint);
164
+ const afterCompaction = await bucketStorage.getCheckpointChanges({
165
+ lastCheckpoint: checkpoint1,
166
+ nextCheckpoint: checkpoint3After
167
+ });
168
+ expect(afterCompaction.invalidateParameterBuckets).toBe(true);
169
+ expect(afterCompaction.updatedParameterLookups.size).toBe(0);
170
+
171
+ // A transition starting at the fence is not affected by it.
172
+ const atFence = await bucketStorage.getCheckpointChanges({
173
+ lastCheckpoint: checkpoint3After,
174
+ nextCheckpoint: checkpoint3After
175
+ });
176
+ expect(atFence.invalidateParameterBuckets).toBe(false);
177
+ expect(atFence.updatedParameterLookups.size).toBe(0);
178
+ });
179
+
180
+ test('reads changes at the checkpoint snapshot, including compacted entries', async () => {
181
+ await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
182
+ const { bucketStorage, replicationStream, streamId, checkpoint1, checkpoint3 } =
183
+ await replicateParameterHistory(factory);
184
+ const db = bucketStorage.db;
185
+
186
+ const baseline = await bucketStorage.getCheckpointChanges({
187
+ lastCheckpoint: checkpoint1,
188
+ nextCheckpoint: checkpoint3
189
+ });
190
+ expect(baseline.updatedParameterLookups.size).toBe(2);
191
+
192
+ await createCompactor(db, streamId, checkpoint3.checkpoint).compact();
193
+
194
+ // checkpoint3 was captured before compaction, so it does not have the fence and the
195
+ // change query runs at its snapshot. That snapshot still contains the deleted tombstone,
196
+ // which is the only record of t2's lookup.
197
+ //
198
+ // A separate storage instance is used to get a cold checkpoint-changes cache.
199
+ const coldStorage = (factory as MongoBucketStorage).getInstance(replicationStream);
200
+ const atSnapshot = await coldStorage.getCheckpointChanges({
201
+ lastCheckpoint: checkpoint1,
202
+ nextCheckpoint: checkpoint3
203
+ });
204
+ expect(atSnapshot.invalidateParameterBuckets).toBe(false);
205
+ expect(atSnapshot.updatedParameterLookups).toEqual(baseline.updatedParameterLookups);
206
+ });
207
+
208
+ test('advances the fence before the first delete, and the cursor only after the last', async () => {
209
+ await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
210
+ const { bucketStorage, streamId, checkpoint3 } = await replicateParameterHistory(factory);
211
+ const db = bucketStorage.db;
212
+
213
+ // V1 seeds the cursor when the stream is created, V3 leaves it unset.
214
+ const initialState = await readCompactionState(db, streamId);
215
+ expect(initialState.invalidBefore).toBeNull();
216
+
217
+ await expect(
218
+ createCompactor(db, streamId, checkpoint3.checkpoint, { failDeletes: true }).compact()
219
+ ).rejects.toThrow('simulated delete failure');
220
+
221
+ // The fence was committed before the first delete was attempted, but the interrupted pass
222
+ // may not skip any deletion work on a retry, so the cursor stays where it was.
223
+ expect(await readCompactionState(db, streamId)).toEqual({
224
+ compactedBefore: initialState.compactedBefore,
225
+ invalidBefore: checkpoint3.checkpoint
226
+ });
227
+
228
+ // Retrying completes the pass.
229
+ await createCompactor(db, streamId, checkpoint3.checkpoint).compact();
230
+ expect(await readCompactionState(db, streamId)).toEqual({
231
+ compactedBefore: checkpoint3.checkpoint,
232
+ invalidBefore: checkpoint3.checkpoint
233
+ });
234
+ });
235
+
236
+ test('an aborted pass does not advance the cursor', async () => {
237
+ await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
238
+ const { bucketStorage, streamId, checkpoint3 } = await replicateParameterHistory(factory);
239
+ const db = bucketStorage.db;
240
+
241
+ const initialState = await readCompactionState(db, streamId);
242
+ const controller = new AbortController();
243
+ controller.abort();
244
+ await expect(
245
+ createCompactor(db, streamId, checkpoint3.checkpoint, { signal: controller.signal }).compact()
246
+ ).rejects.toThrow();
247
+
248
+ expect(await readCompactionState(db, streamId)).toEqual(initialState);
249
+ expect(initialState.invalidBefore).toBeNull();
250
+ });
251
+
252
+ test('does not advance the fence for a pass without deletes', async () => {
253
+ await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
254
+ const { bucketStorage, streamId, checkpoint3 } = await replicateParameterHistory(factory);
255
+ const db = bucketStorage.db;
256
+
257
+ // Compact past every persisted entry, so that the next pass has an empty range.
258
+ const firstTarget = checkpoint3.checkpoint + 1000n;
259
+ await createCompactor(db, streamId, firstTarget).compact();
260
+ expect(await readCompactionState(db, streamId)).toEqual({
261
+ compactedBefore: firstTarget,
262
+ invalidBefore: firstTarget
263
+ });
264
+
265
+ const secondTarget = firstTarget + 1000n;
266
+ await createCompactor(db, streamId, secondTarget).compact();
267
+ // The cursor advances, but nothing was deleted, so checkpoint change detection stays
268
+ // available for everything above the previous fence.
269
+ expect(await readCompactionState(db, streamId)).toEqual({
270
+ compactedBefore: secondTarget,
271
+ invalidBefore: firstTarget
272
+ });
273
+ });
274
+ });
275
+ }
276
+ });