@powersync/service-module-mongodb-storage 0.20.0 → 0.22.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 (192) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/dist/storage/MongoBucketStorage.d.ts +36 -3
  3. package/dist/storage/MongoBucketStorage.js +372 -207
  4. package/dist/storage/MongoBucketStorage.js.map +1 -1
  5. package/dist/storage/implementation/MongoBucketBatch.d.ts +17 -5
  6. package/dist/storage/implementation/MongoBucketBatch.js +44 -17
  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 +7 -3
  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/MongoWriteBatch.d.ts +49 -0
  27. package/dist/storage/implementation/MongoWriteBatch.js +149 -0
  28. package/dist/storage/implementation/MongoWriteBatch.js.map +1 -0
  29. package/dist/storage/implementation/MongoWriteCheckpointAPI.d.ts +9 -7
  30. package/dist/storage/implementation/MongoWriteCheckpointAPI.js +9 -30
  31. package/dist/storage/implementation/MongoWriteCheckpointAPI.js.map +1 -1
  32. package/dist/storage/implementation/SyncRuleStateUpdate.d.ts +11 -5
  33. package/dist/storage/implementation/SyncRuleStateUpdate.js +14 -6
  34. package/dist/storage/implementation/SyncRuleStateUpdate.js.map +1 -1
  35. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.d.ts +22 -2
  36. package/dist/storage/implementation/common/PersistedBatch.d.ts +32 -4
  37. package/dist/storage/implementation/common/PersistedBatch.js +49 -6
  38. package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
  39. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.d.ts +3 -0
  40. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js +3 -0
  41. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js.map +1 -1
  42. package/dist/storage/implementation/db.d.ts +9 -1
  43. package/dist/storage/implementation/db.js +27 -1
  44. package/dist/storage/implementation/db.js.map +1 -1
  45. package/dist/storage/implementation/models.d.ts +31 -0
  46. package/dist/storage/implementation/models.js.map +1 -1
  47. package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +2 -0
  48. package/dist/storage/implementation/v1/MongoBucketBatchV1.js +33 -4
  49. package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
  50. package/dist/storage/implementation/v1/MongoCompactorV1.d.ts +38 -6
  51. package/dist/storage/implementation/v1/MongoCompactorV1.js +247 -20
  52. package/dist/storage/implementation/v1/MongoCompactorV1.js.map +1 -1
  53. package/dist/storage/implementation/v1/MongoParameterCompactorV1.d.ts +16 -2
  54. package/dist/storage/implementation/v1/MongoParameterCompactorV1.js +20 -9
  55. package/dist/storage/implementation/v1/MongoParameterCompactorV1.js.map +1 -1
  56. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.d.ts +18 -11
  57. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +65 -16
  58. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
  59. package/dist/storage/implementation/v1/PersistedBatchV1.d.ts +7 -5
  60. package/dist/storage/implementation/v1/PersistedBatchV1.js +22 -28
  61. package/dist/storage/implementation/v1/PersistedBatchV1.js.map +1 -1
  62. package/dist/storage/implementation/v1/models.js +1 -0
  63. package/dist/storage/implementation/v1/models.js.map +1 -1
  64. package/dist/storage/implementation/v3/CompactionLease.d.ts +50 -0
  65. package/dist/storage/implementation/v3/CompactionLease.js +131 -0
  66. package/dist/storage/implementation/v3/CompactionLease.js.map +1 -0
  67. package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +10 -2
  68. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +100 -26
  69. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  70. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +82 -25
  71. package/dist/storage/implementation/v3/MongoCompactorV3.js +698 -159
  72. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  73. package/dist/storage/implementation/v3/MongoParameterCompactorV3.d.ts +18 -0
  74. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js +23 -0
  75. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js.map +1 -0
  76. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.d.ts +2 -15
  77. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js +60 -92
  78. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js.map +1 -1
  79. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.d.ts +19 -9
  80. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +91 -31
  81. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
  82. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.d.ts +26 -0
  83. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.js +82 -0
  84. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.js.map +1 -0
  85. package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +14 -8
  86. package/dist/storage/implementation/v3/PersistedBatchV3.js +76 -42
  87. package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
  88. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.d.ts +12 -2
  89. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js +42 -3
  90. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js.map +1 -1
  91. package/dist/storage/implementation/v3/bucket-format.js +2 -0
  92. package/dist/storage/implementation/v3/bucket-format.js.map +1 -1
  93. package/dist/storage/implementation/v3/compact-utils.d.ts +107 -0
  94. package/dist/storage/implementation/v3/compact-utils.js +216 -0
  95. package/dist/storage/implementation/v3/compact-utils.js.map +1 -0
  96. package/dist/storage/implementation/v3/compaction-constants.d.ts +5 -0
  97. package/dist/storage/implementation/v3/compaction-constants.js +6 -0
  98. package/dist/storage/implementation/v3/compaction-constants.js.map +1 -0
  99. package/dist/storage/implementation/v3/models.d.ts +84 -4
  100. package/dist/storage/implementation/v3/models.js.map +1 -1
  101. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.d.ts +7 -8
  102. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +16 -5
  103. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -1
  104. package/dist/storage/implementation/v3/object-storage/ObjectStorage.d.ts +14 -11
  105. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +6 -8
  106. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +17 -11
  107. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -1
  108. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.d.ts +38 -0
  109. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js +164 -0
  110. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js.map +1 -0
  111. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.d.ts +37 -12
  112. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js +197 -25
  113. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js.map +1 -1
  114. package/dist/storage/implementation/v3/source-table-utils.d.ts +6 -6
  115. package/dist/storage/implementation/v3/source-table-utils.js +30 -37
  116. package/dist/storage/implementation/v3/source-table-utils.js.map +1 -1
  117. package/dist/storage/storage-index.d.ts +2 -1
  118. package/dist/storage/storage-index.js +1 -0
  119. package/dist/storage/storage-index.js.map +1 -1
  120. package/dist/types/types.d.ts +28 -1
  121. package/dist/types/types.js +28 -7
  122. package/dist/types/types.js.map +1 -1
  123. package/dist/utils/test-utils.js +1 -0
  124. package/dist/utils/test-utils.js.map +1 -1
  125. package/dist/utils/util.js +3 -1
  126. package/dist/utils/util.js.map +1 -1
  127. package/package.json +10 -9
  128. package/src/storage/MongoBucketStorage.ts +507 -269
  129. package/src/storage/implementation/MongoBucketBatch.ts +67 -24
  130. package/src/storage/implementation/MongoCompactor.ts +35 -346
  131. package/src/storage/implementation/MongoParameterCompactor.ts +388 -102
  132. package/src/storage/implementation/MongoParsedSyncConfigSet.ts +34 -0
  133. package/src/storage/implementation/MongoPersistedSyncConfigContent.ts +4 -2
  134. package/src/storage/implementation/MongoStorageProvider.ts +15 -3
  135. package/src/storage/implementation/MongoSyncBucketStorage.ts +114 -40
  136. package/src/storage/implementation/MongoSyncRulesLock.ts +22 -9
  137. package/src/storage/implementation/MongoWriteBatch.ts +172 -0
  138. package/src/storage/implementation/MongoWriteCheckpointAPI.ts +16 -46
  139. package/src/storage/implementation/SyncRuleStateUpdate.ts +13 -6
  140. package/src/storage/implementation/common/MongoSyncBucketStorageCheckpoint.ts +24 -2
  141. package/src/storage/implementation/common/PersistedBatch.ts +66 -10
  142. package/src/storage/implementation/common/VersionedPowerSyncMongoBase.ts +4 -0
  143. package/src/storage/implementation/db.ts +35 -2
  144. package/src/storage/implementation/models.ts +34 -0
  145. package/src/storage/implementation/v1/MongoBucketBatchV1.ts +46 -6
  146. package/src/storage/implementation/v1/MongoCompactorV1.ts +327 -40
  147. package/src/storage/implementation/v1/MongoParameterCompactorV1.ts +25 -9
  148. package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +87 -27
  149. package/src/storage/implementation/v1/PersistedBatchV1.ts +25 -30
  150. package/src/storage/implementation/v1/models.ts +1 -0
  151. package/src/storage/implementation/v3/CompactionLease.ts +152 -0
  152. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +138 -34
  153. package/src/storage/implementation/v3/MongoCompactorV3.ts +958 -225
  154. package/src/storage/implementation/v3/MongoParameterCompactorV3.ts +34 -0
  155. package/src/storage/implementation/v3/MongoStoppedSyncConfigCleanup.ts +100 -109
  156. package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +111 -41
  157. package/src/storage/implementation/v3/MongoWriteCheckpointAPIV3.ts +111 -0
  158. package/src/storage/implementation/v3/PersistedBatchV3.ts +93 -48
  159. package/src/storage/implementation/v3/VersionedPowerSyncMongoV3.ts +55 -4
  160. package/src/storage/implementation/v3/bucket-format.ts +2 -0
  161. package/src/storage/implementation/v3/compact-utils.ts +320 -0
  162. package/src/storage/implementation/v3/compaction-constants.ts +5 -0
  163. package/src/storage/implementation/v3/models.ts +91 -3
  164. package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +24 -9
  165. package/src/storage/implementation/v3/object-storage/ObjectStorage.ts +20 -5
  166. package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +26 -14
  167. package/src/storage/implementation/v3/object-storage/ObjectStorageUsage.ts +229 -0
  168. package/src/storage/implementation/v3/object-storage/S3ObjectStorage.ts +279 -29
  169. package/src/storage/implementation/v3/source-table-utils.ts +39 -41
  170. package/src/storage/storage-index.ts +2 -0
  171. package/src/types/types.ts +42 -7
  172. package/src/utils/test-utils.ts +1 -0
  173. package/src/utils/util.ts +3 -1
  174. package/test/src/__snapshots__/storage.test.ts.snap +25 -0
  175. package/test/src/__snapshots__/storage_sync.test.ts.snap +605 -16
  176. package/test/src/chunk_compaction_config.test.ts +38 -0
  177. package/test/src/cleanup-stopped-sync-configs.test.ts +40 -13
  178. package/test/src/compact-utils.test.ts +226 -0
  179. package/test/src/helpers/MemoryObjectStorage.ts +14 -6
  180. package/test/src/object_storage_usage.test.ts +267 -0
  181. package/test/src/parameter_compacting_v1.test.ts +120 -0
  182. package/test/src/parameter_compacting_v3.test.ts +192 -0
  183. package/test/src/parameter_compaction_fence.test.ts +276 -0
  184. package/test/src/storage.test.ts +273 -22
  185. package/test/src/storage_compacting.test.ts +968 -246
  186. package/test/src/storage_s3_checksums.test.ts +3 -4
  187. package/test/src/storage_s3_compaction_lifecycle.test.ts +138 -12
  188. package/test/src/storage_s3_reading.test.ts +290 -2
  189. package/test/src/storage_s3_writing.test.ts +1 -0
  190. package/test/src/storage_sync.test.ts +336 -41
  191. package/test/src/util.ts +1 -1
  192. package/tsconfig.tsbuildinfo +1 -1
@@ -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
+ });
@@ -1,8 +1,10 @@
1
1
  import { mongoTestStorageFactoryGenerator } from '@module/utils/test-utils.js';
2
2
  import { mongo } from '@powersync/lib-service-mongodb';
3
3
  import { storage, updateSyncRulesFromYaml } from '@powersync/service-core';
4
- import { register, test_utils } from '@powersync/service-core-tests';
4
+ import { compactActive, register, test_utils } from '@powersync/service-core-tests';
5
5
  import { describe, expect, test } from 'vitest';
6
+ import { MongoSyncBucketStorage } from '../../src/storage/implementation/createMongoSyncBucketStorage.js';
7
+ import { VersionedPowerSyncMongoV3 } from '../../src/storage/implementation/v3/VersionedPowerSyncMongoV3.js';
6
8
  import { env } from './env.js';
7
9
  import { INITIALIZED_MONGO_STORAGE_FACTORY, TEST_STORAGE_VERSIONS } from './util.js';
8
10
 
@@ -115,36 +117,285 @@ bucket_definitions:
115
117
  await managedWriter.markAllSnapshotDone('1/1');
116
118
  await managedWriter.keepalive('8/0');
117
119
 
118
- await bucketStorage.compact({
120
+ await compactActive(factory, {
119
121
  compactBuckets: [],
120
122
  deleteCheckpointRequestsBefore: new Date('2024-02-01T00:00:00.000Z')
121
123
  });
122
124
  // The request is now both expired and processed, so it can be removed.
123
125
  await expect(factory.db.write_checkpoints.findOne({ user_id: 'user2' })).resolves.toBeNull();
124
126
 
125
- bucketStorage.setWriteCheckpointMode(storage.WriteCheckpointMode.CUSTOM);
126
- await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
127
- await writer.markAllSnapshotDone('1/1');
128
- const customCheckpointRequestedAt = new Date('2024-01-01T00:00:00.000Z');
129
- writer.addCustomWriteCheckpoint({
130
- user_id: 'custom1',
131
- checkpoint: 51n,
132
- checkpoint_requested_at: customCheckpointRequestedAt
133
- });
134
- await writer.flush();
135
- const customRequested = await factory.db.custom_write_checkpoints.findOne({ user_id: 'custom1' });
136
- expect(customRequested?.checkpoint_requested_at).toEqual(customCheckpointRequestedAt);
127
+ if (storageVersion < 3) {
128
+ bucketStorage.setWriteCheckpointMode({
129
+ mode: storage.WriteCheckpointMode.CUSTOM
130
+ });
131
+ await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
132
+ await writer.markAllSnapshotDone('1/1');
133
+ const customCheckpointRequestedAt = new Date('2024-01-01T00:00:00.000Z');
134
+ writer.addCustomWriteCheckpoint({
135
+ user_id: 'custom1',
136
+ checkpoint: 51n,
137
+ checkpoint_requested_at: customCheckpointRequestedAt
138
+ });
139
+ await writer.flush();
140
+ const customRequested = await factory.db.custom_write_checkpoints.findOne({ user_id: 'custom1' });
141
+ expect(customRequested?.checkpoint_requested_at).toEqual(customCheckpointRequestedAt);
137
142
 
138
- writer.addCustomWriteCheckpoint({
139
- user_id: 'custom1',
140
- checkpoint: 52n
141
- });
142
- await writer.flush();
143
- const customGenerated = await factory.db.custom_write_checkpoints.findOne({ user_id: 'custom1' });
144
- expect(customGenerated).not.toBeNull();
145
- expect(customGenerated?.checkpoint_requested_at).toBeUndefined();
143
+ writer.addCustomWriteCheckpoint({
144
+ user_id: 'custom1',
145
+ checkpoint: 52n
146
+ });
147
+ await writer.flush();
148
+ const customGenerated = await factory.db.custom_write_checkpoints.findOne({ user_id: 'custom1' });
149
+ expect(customGenerated).not.toBeNull();
150
+ expect(customGenerated?.checkpoint_requested_at).toBeUndefined();
151
+ }
146
152
  });
147
153
 
154
+ test.runIf(storageVersion >= 3)(
155
+ 'stores and streams checkpoints from different event definitions independently',
156
+ async (context) => {
157
+ await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
158
+ const syncRules = await factory.updateSyncRules(
159
+ updateSyncRulesFromYaml(
160
+ `
161
+ config:
162
+ edition: 3
163
+
164
+ streams:
165
+ global:
166
+ query: SELECT * FROM checkpoints
167
+
168
+ event_definitions:
169
+ checkpoint_a:
170
+ payloads:
171
+ - SELECT id FROM checkpoints WHERE kind = 'a'
172
+ checkpoint_b:
173
+ payloads:
174
+ - SELECT id FROM checkpoints WHERE kind = 'b'
175
+ unrelated_event:
176
+ payloads:
177
+ - SELECT id FROM checkpoints WHERE kind = 'unrelated'
178
+ `,
179
+ { storageVersion }
180
+ )
181
+ );
182
+ const bucketStorage = factory.getInstance(syncRules);
183
+ const db = bucketStorage.db as VersionedPowerSyncMongoV3;
184
+ const activeSyncConfig = bucketStorage.getParsedSyncRules({ defaultSchema: 'public' });
185
+ const eventA = activeSyncConfig.eventDescriptors.find((event) => event.name == 'checkpoint_a')!;
186
+ const eventB = activeSyncConfig.eventDescriptors.find((event) => event.name == 'checkpoint_b')!;
187
+ const unrelatedEvent = activeSyncConfig.eventDescriptors.find((event) => event.name == 'unrelated_event')!;
188
+ const mapping = syncRules.syncConfigContent[0].mapping;
189
+ const eventAId = mapping.eventId(eventA);
190
+ const eventBId = mapping.eventId(eventB);
191
+ const unrelatedEventId = mapping.eventId(unrelatedEvent);
192
+ expect(() =>
193
+ bucketStorage.setWriteCheckpointMode({
194
+ mode: storage.WriteCheckpointMode.CUSTOM
195
+ })
196
+ ).toThrow('eventName');
197
+ bucketStorage.setWriteCheckpointMode({
198
+ mode: storage.WriteCheckpointMode.CUSTOM,
199
+ eventName: 'unknown-event'
200
+ });
201
+ await expect(bucketStorage.lastWriteCheckpoint({ user_id: 'user1' })).rejects.toThrow(
202
+ 'No mapping found for event definition unknown-event'
203
+ );
204
+
205
+ bucketStorage.setWriteCheckpointMode({
206
+ mode: storage.WriteCheckpointMode.CUSTOM,
207
+ eventName: eventA.name
208
+ });
209
+ // Reads before the first checkpoint must behave like an empty result;
210
+ // they must not require the lazily-created collection to exist.
211
+ await expect(bucketStorage.lastWriteCheckpoint({ user_id: 'user1' })).resolves.toBeNull();
212
+
213
+ await using invalidWriter = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
214
+ invalidWriter.addCustomWriteCheckpoint({ user_id: 'invalid', checkpoint: 1n });
215
+ await expect(invalidWriter.flush()).rejects.toThrow('require an event definition id');
216
+ await expect(db.listCustomCheckpointRequestCollections(syncRules.replicationStreamId)).resolves.toEqual([]);
217
+
218
+ await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
219
+ await writer.markAllSnapshotDone('1/1');
220
+ const abortController = new AbortController();
221
+ context.onTestFinished(() => abortController.abort());
222
+ const iter = bucketStorage
223
+ .watchCheckpointChanges({
224
+ user_id: 'user1',
225
+ signal: abortController.signal
226
+ })
227
+ [Symbol.asyncIterator]();
228
+
229
+ writer.addCustomWriteCheckpoint({ user_id: 'user1', checkpoint: 5n, event_id: eventAId });
230
+ writer.addCustomWriteCheckpoint({ user_id: 'user1', checkpoint: 8n, event_id: eventBId });
231
+ await writer.flush();
232
+ await writer.keepalive('5/0');
233
+
234
+ const eventACollection = db.customCheckpointRequests({
235
+ replicationStreamId: syncRules.replicationStreamId,
236
+ eventId: eventAId
237
+ });
238
+ const eventBCollection = db.customCheckpointRequests({
239
+ replicationStreamId: syncRules.replicationStreamId,
240
+ eventId: eventBId
241
+ });
242
+ await expect(eventACollection.findOne({ user_id: 'user1' })).resolves.toMatchObject({ checkpoint: 5n });
243
+ await expect(eventBCollection.findOne({ user_id: 'user1' })).resolves.toMatchObject({ checkpoint: 8n });
244
+ const checkpointCollectionNames = (
245
+ await db.listCustomCheckpointRequestCollections(syncRules.replicationStreamId)
246
+ )
247
+ .map((collection) => collection.collectionName)
248
+ .sort();
249
+ expect(checkpointCollectionNames).toEqual(
250
+ [eventACollection.collectionName, eventBCollection.collectionName].sort()
251
+ );
252
+ expect(checkpointCollectionNames).not.toContain(
253
+ db.customCheckpointRequests({
254
+ replicationStreamId: syncRules.replicationStreamId,
255
+ eventId: unrelatedEventId
256
+ }).collectionName
257
+ );
258
+ await expect(eventACollection.indexExists(['user_unique', 'op_id', 'checkpoint_requested_at'])).resolves.toBe(
259
+ true
260
+ );
261
+ await expect(bucketStorage.lastWriteCheckpoint({ user_id: 'user1' })).resolves.toEqual(5n);
262
+ await expect(iter.next()).resolves.toMatchObject({
263
+ done: false,
264
+ value: { writeCheckpoint: 5n }
265
+ });
266
+
267
+ writer.addCustomWriteCheckpoint({ user_id: 'user1', checkpoint: 9n, event_id: eventBId });
268
+ await writer.flush();
269
+ await writer.keepalive('6/0');
270
+ // The processing event's record does not advance clients still served by
271
+ // the active event definition.
272
+ await expect(iter.next()).resolves.toMatchObject({
273
+ done: false,
274
+ value: { writeCheckpoint: 5n }
275
+ });
276
+
277
+ bucketStorage.setWriteCheckpointMode({
278
+ mode: storage.WriteCheckpointMode.CUSTOM,
279
+ eventName: eventB.name
280
+ });
281
+ await expect(bucketStorage.lastWriteCheckpoint({ user_id: 'user1' })).resolves.toEqual(9n);
282
+
283
+ writer.addCustomWriteCheckpoint({
284
+ user_id: 'temporary',
285
+ checkpoint: 10n,
286
+ event_id: eventBId,
287
+ checkpoint_requested_at: new Date('2024-01-01T00:00:00.000Z')
288
+ });
289
+ await writer.flush();
290
+ await compactActive(factory, {
291
+ compactBuckets: [],
292
+ deleteCheckpointRequestsBefore: new Date('2024-01-02T00:00:00.000Z')
293
+ });
294
+ await expect(eventBCollection.findOne({ user_id: 'temporary' })).resolves.toBeNull();
295
+
296
+ // A fresh storage instance has an empty initialization cache, just like
297
+ // one created after a service restart. Recreating the existing indexes
298
+ // must be idempotent before another checkpoint is written.
299
+ const freshBucketStorage = factory.getInstance(syncRules);
300
+ await using freshWriter = await freshBucketStorage.createWriter(test_utils.BATCH_OPTIONS);
301
+ freshWriter.addCustomWriteCheckpoint({
302
+ user_id: 'after-restart',
303
+ checkpoint: 11n,
304
+ event_id: eventAId
305
+ });
306
+ await freshWriter.flush();
307
+ await expect(eventACollection.findOne({ user_id: 'after-restart' })).resolves.toMatchObject({
308
+ checkpoint: 11n
309
+ });
310
+
311
+ await bucketStorage.clear();
312
+ await expect(db.listCustomCheckpointRequestCollections(syncRules.replicationStreamId)).resolves.toEqual([]);
313
+ }
314
+ );
315
+
316
+ test.runIf(storageVersion >= 3)(
317
+ 'resolves custom checkpoints through the active event mapping across deployments',
318
+ async () => {
319
+ await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
320
+ const syncConfigYaml = (predicate: string, includeExtraStream: boolean) => `
321
+ config:
322
+ edition: 3
323
+
324
+ streams:
325
+ global:
326
+ query: SELECT * FROM checkpoints
327
+ ${includeExtraStream ? ' todos:\n query: SELECT * FROM todos' : ''}
328
+
329
+ event_definitions:
330
+ write_checkpoints:
331
+ payloads:
332
+ - SELECT user_id, checkpoint FROM checkpoints WHERE ${predicate}
333
+ `;
334
+
335
+ const first = await factory.updateSyncRules(
336
+ updateSyncRulesFromYaml(syncConfigYaml("kind = 'write'", false), { storageVersion })
337
+ );
338
+ const firstEventId = first.syncConfigContent[0].mapping.eventDefinitionIdByName('write_checkpoints');
339
+ const firstStorage = factory.getInstance(first);
340
+ await using firstWriter = await firstStorage.createWriter(test_utils.BATCH_OPTIONS);
341
+ firstWriter.addCustomWriteCheckpoint({ user_id: 'user1', checkpoint: 5n, event_id: firstEventId });
342
+ await firstWriter.markAllSnapshotDone('1/1');
343
+ await firstWriter.commit('1/1');
344
+
345
+ const unchanged = await factory.updateSyncRules(
346
+ updateSyncRulesFromYaml(syncConfigYaml("kind = 'write'", true), { storageVersion })
347
+ );
348
+ const unchangedEventId = unchanged.syncConfigContent[1].mapping.eventDefinitionIdByName('write_checkpoints');
349
+ expect(unchangedEventId).toBe(firstEventId);
350
+
351
+ await using unchangedWriter = await factory.getInstance(unchanged).createWriter(test_utils.BATCH_OPTIONS);
352
+ await unchangedWriter.markAllSnapshotDone('2/1');
353
+ await unchangedWriter.commit('2/1');
354
+
355
+ const unchangedActiveStorage = (await factory.getActiveSyncConfig())!.storage as MongoSyncBucketStorage;
356
+ unchangedActiveStorage.setWriteCheckpointMode({
357
+ mode: storage.WriteCheckpointMode.CUSTOM,
358
+ eventName: 'write_checkpoints'
359
+ });
360
+ await expect(
361
+ unchangedActiveStorage.lastWriteCheckpoint({
362
+ user_id: 'user1'
363
+ })
364
+ ).resolves.toBe(5n);
365
+
366
+ const changed = await factory.updateSyncRules(
367
+ updateSyncRulesFromYaml(syncConfigYaml("kind = 'processed'", true), { storageVersion })
368
+ );
369
+ const changedEventId = changed.syncConfigContent[1].mapping.eventDefinitionIdByName('write_checkpoints');
370
+ expect(changedEventId).not.toBe(unchangedEventId);
371
+
372
+ await using changedWriter = await factory.getInstance(changed).createWriter(test_utils.BATCH_OPTIONS);
373
+ changedWriter.addCustomWriteCheckpoint({ user_id: 'user1', checkpoint: 9n, event_id: changedEventId });
374
+ await changedWriter.flush();
375
+
376
+ // Until activation, reads still use the previous config's assigned id and collection.
377
+ await expect(
378
+ unchangedActiveStorage.lastWriteCheckpoint({
379
+ user_id: 'user1'
380
+ })
381
+ ).resolves.toBe(5n);
382
+
383
+ await changedWriter.markAllSnapshotDone('3/1');
384
+ await changedWriter.commit('3/1');
385
+
386
+ const changedActiveStorage = (await factory.getActiveSyncConfig())!.storage as MongoSyncBucketStorage;
387
+ changedActiveStorage.setWriteCheckpointMode({
388
+ mode: storage.WriteCheckpointMode.CUSTOM,
389
+ eventName: 'write_checkpoints'
390
+ });
391
+ await expect(
392
+ changedActiveStorage.lastWriteCheckpoint({
393
+ user_id: 'user1'
394
+ })
395
+ ).resolves.toBe(9n);
396
+ }
397
+ );
398
+
148
399
  /**
149
400
  * It's extremely rare (but technically possible) to have duplicate write checkpoint records
150
401
  * for a full user_id. This is normally not an issue for checkpoints requested from `write-checkpoint2.json`,