@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,267 @@
1
+ import { MongoSyncBucketStorage } from '@module/storage/implementation/createMongoSyncBucketStorage.js';
2
+ import { BucketDataDocumentV3, ObjectStorageUsageDocument } from '@module/storage/implementation/v3/models.js';
3
+ import {
4
+ OBJECT_STORAGE_USAGE_BASE_WRITER_ID,
5
+ ObjectStorageUsage
6
+ } from '@module/storage/implementation/v3/object-storage/ObjectStorageUsage.js';
7
+ import { VersionedPowerSyncMongoV3 } from '@module/storage/implementation/v3/VersionedPowerSyncMongoV3.js';
8
+ import * as lib_mongo from '@powersync/lib-service-mongodb';
9
+ import { mongo } from '@powersync/lib-service-mongodb';
10
+ import { updateSyncRulesFromYaml } from '@powersync/service-core';
11
+ import { BucketDefinitionId } from '@powersync/service-sync-rules';
12
+ import { describe, expect, test } from 'vitest';
13
+ import { INITIALIZED_MONGO_STORAGE_FACTORY } from './util.js';
14
+
15
+ const SYNC_RULES_YAML = `
16
+ bucket_definitions:
17
+ global:
18
+ data:
19
+ - SELECT id FROM items
20
+ `;
21
+
22
+ type UsageContext = {
23
+ db: VersionedPowerSyncMongoV3;
24
+ bucketStorage: MongoSyncBucketStorage;
25
+ definitionId: BucketDefinitionId;
26
+ };
27
+
28
+ type UsageEntry = {
29
+ definition_id: BucketDefinitionId;
30
+ active_bytes: bigint;
31
+ };
32
+
33
+ async function withUsageContext<T>(callback: (context: UsageContext) => Promise<T>): Promise<T> {
34
+ await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
35
+ const syncRules = await factory.updateSyncRules(updateSyncRulesFromYaml(SYNC_RULES_YAML, { storageVersion: 3 }));
36
+ const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
37
+ const db = bucketStorage.db as VersionedPowerSyncMongoV3;
38
+ const definitionId = syncRules.syncConfigContent[0].mapping.allBucketDefinitionIds()[0];
39
+ return callback({ db, bucketStorage, definitionId });
40
+ }
41
+
42
+ async function withTransaction<T>(
43
+ db: VersionedPowerSyncMongoV3,
44
+ callback: (session: mongo.ClientSession) => Promise<T>
45
+ ): Promise<T> {
46
+ const session = db.client.startSession();
47
+ try {
48
+ return await session.withTransaction(() => callback(session));
49
+ } finally {
50
+ await session.endSession();
51
+ }
52
+ }
53
+
54
+ async function readUsageEntries(
55
+ db: VersionedPowerSyncMongoV3,
56
+ replicationStreamId: number,
57
+ session?: mongo.ClientSession
58
+ ): Promise<UsageEntry[]> {
59
+ const entries = await db.objectStorageUsage
60
+ .aggregate<{ _id: BucketDefinitionId; active_bytes: bigint }>(
61
+ [
62
+ { $match: { '_id.g': replicationStreamId } },
63
+ { $project: { definitions: { $objectToArray: '$definitions' } } },
64
+ { $unwind: '$definitions' },
65
+ { $group: { _id: '$definitions.k', active_bytes: { $sum: '$definitions.v' } } }
66
+ ],
67
+ { session, readConcern: 'snapshot' }
68
+ )
69
+ .toArray()
70
+ .catch((error) => {
71
+ if (lib_mongo.isMongoNamespaceNotFoundError(error)) {
72
+ return [];
73
+ }
74
+ throw error;
75
+ });
76
+
77
+ return entries.map((entry) => ({
78
+ definition_id: entry._id,
79
+ active_bytes: BigInt(entry.active_bytes ?? 0)
80
+ }));
81
+ }
82
+
83
+ function usageDocument(
84
+ replicationStreamId: number,
85
+ writerId: string,
86
+ definitions: Record<string, bigint>,
87
+ updatedAt = new Date(0)
88
+ ): ObjectStorageUsageDocument {
89
+ return {
90
+ _id: { g: replicationStreamId, w: writerId },
91
+ updated_at: updatedAt,
92
+ definitions
93
+ };
94
+ }
95
+
96
+ function bucketDataDocument(
97
+ replicationStreamId: number,
98
+ definitionId: BucketDefinitionId,
99
+ fileSize: number
100
+ ): BucketDataDocumentV3 {
101
+ return {
102
+ _id: { b: `${replicationStreamId}-${definitionId}`, o: 1n },
103
+ min_op: 1n,
104
+ checksum: 1n,
105
+ count: 1,
106
+ size: fileSize,
107
+ storage_ref: {
108
+ path: `bucket-data/${replicationStreamId}/${definitionId}/1.bson`,
109
+ file_size: fileSize
110
+ }
111
+ };
112
+ }
113
+
114
+ describe('ObjectStorageUsage', () => {
115
+ test('counts a committed reference exactly once after an aborted transaction is retried', async () => {
116
+ await withUsageContext(async ({ db, bucketStorage, definitionId }) => {
117
+ const replicationStreamId = bucketStorage.replicationStreamId;
118
+ const writerId = 'replication-writer';
119
+ const usage = new ObjectStorageUsage(db, replicationStreamId, writerId);
120
+ const bucketData = db.bucketData(replicationStreamId, definitionId);
121
+ const document = bucketDataDocument(replicationStreamId, definitionId, 123);
122
+
123
+ await expect(
124
+ withTransaction(db, async (session) => {
125
+ await bucketData.insertOne(document, { session });
126
+ const writes = db.createWriteBatch(session, { ordered: false });
127
+ usage.applyDelta(definitionId, 123n, writes);
128
+ await writes.execute();
129
+ throw new Error('abort this attempt');
130
+ })
131
+ ).rejects.toThrow('abort this attempt');
132
+
133
+ await withTransaction(db, async (session) => {
134
+ await bucketData.insertOne(document, { session });
135
+ const writes = db.createWriteBatch(session, { ordered: false });
136
+ usage.applyDelta(definitionId, 123n, writes);
137
+ await writes.execute();
138
+ });
139
+
140
+ expect(await bucketData.countDocuments({ storage_ref: { $exists: true } })).toBe(1);
141
+ await expect(readUsageEntries(db, replicationStreamId)).resolves.toEqual([
142
+ { definition_id: definitionId, active_bytes: 123n }
143
+ ]);
144
+ });
145
+ });
146
+
147
+ test('snapshot usage reads observe a complete pre-fold view', async () => {
148
+ await withUsageContext(async ({ db, bucketStorage, definitionId }) => {
149
+ const replicationStreamId = bucketStorage.replicationStreamId;
150
+ const usage = new ObjectStorageUsage(db, replicationStreamId);
151
+ await db.objectStorageUsage.insertMany([
152
+ usageDocument(replicationStreamId, OBJECT_STORAGE_USAGE_BASE_WRITER_ID, { [definitionId]: 100n }),
153
+ usageDocument(replicationStreamId, 'replication', { [definitionId]: 20n }),
154
+ usageDocument(replicationStreamId, 'compactor', { [definitionId]: -5n })
155
+ ]);
156
+
157
+ const readSession = db.client.startSession();
158
+ try {
159
+ readSession.startTransaction({ readConcern: { level: 'snapshot' } });
160
+ await db.objectStorageUsage.findOne(
161
+ { _id: { g: replicationStreamId, w: OBJECT_STORAGE_USAGE_BASE_WRITER_ID } },
162
+ { session: readSession }
163
+ );
164
+
165
+ await usage.foldStaleWriterDeltas({ staleWriterMs: 0 });
166
+ const entries = await readUsageEntries(db, replicationStreamId, readSession);
167
+
168
+ await readSession.commitTransaction();
169
+ expect(entries).toEqual([{ definition_id: definitionId, active_bytes: 115n }]);
170
+ } catch (error) {
171
+ await readSession.abortTransaction().catch(() => undefined);
172
+ throw error;
173
+ } finally {
174
+ await readSession.endSession();
175
+ }
176
+
177
+ await expect(readUsageEntries(db, replicationStreamId)).resolves.toEqual([
178
+ { definition_id: definitionId, active_bytes: 115n }
179
+ ]);
180
+ });
181
+ });
182
+
183
+ test('folding preserves totals across both writer/folder commit orders', async () => {
184
+ await withUsageContext(async ({ db, bucketStorage, definitionId }) => {
185
+ const replicationStreamId = bucketStorage.replicationStreamId;
186
+ const writerId = 'reused-writer';
187
+ const usage = new ObjectStorageUsage(db, replicationStreamId, writerId);
188
+
189
+ await withTransaction(db, async (session) => {
190
+ const writes = db.createWriteBatch(session, { ordered: false });
191
+ usage.applyDelta(definitionId, 10n, writes);
192
+ await writes.execute();
193
+ });
194
+ await db.objectStorageUsage.updateOne(
195
+ { _id: { g: replicationStreamId, w: writerId } },
196
+ { $set: { updated_at: new Date(0) } }
197
+ );
198
+ await usage.foldStaleWriterDeltas({ staleWriterMs: 0 });
199
+
200
+ // The folder committed first and deleted the writer shard. The writer's
201
+ // next transaction must recreate it through the upsert.
202
+ await withTransaction(db, async (session) => {
203
+ const writes = db.createWriteBatch(session, { ordered: false });
204
+ usage.applyDelta(definitionId, 4n, writes);
205
+ await writes.execute();
206
+ });
207
+ expect((await readUsageEntries(db, replicationStreamId))[0].active_bytes).toBe(14n);
208
+
209
+ await db.objectStorageUsage.updateOne(
210
+ { _id: { g: replicationStreamId, w: writerId } },
211
+ { $set: { updated_at: new Date(0) } }
212
+ );
213
+ await usage.foldStaleWriterDeltas({ staleWriterMs: 0 });
214
+
215
+ // The writer committed before the folder. Folding the recreated shard
216
+ // must preserve the same visible total.
217
+ expect((await readUsageEntries(db, replicationStreamId))[0].active_bytes).toBe(14n);
218
+ });
219
+ });
220
+
221
+ test('definition cleanup racing a fold removes only that definition, and stream clear is isolated', async () => {
222
+ await withUsageContext(async ({ db, bucketStorage, definitionId }) => {
223
+ const replicationStreamId = bucketStorage.replicationStreamId;
224
+ const otherStreamId = replicationStreamId + 1;
225
+ const otherDefinitionId = 'other_definition';
226
+ const usage = new ObjectStorageUsage(db, replicationStreamId);
227
+
228
+ await db.objectStorageUsage.insertMany([
229
+ usageDocument(replicationStreamId, OBJECT_STORAGE_USAGE_BASE_WRITER_ID, {
230
+ [definitionId]: 3n,
231
+ [otherDefinitionId]: 7n
232
+ }),
233
+ usageDocument(replicationStreamId, 'writer', { [definitionId]: 20n, [otherDefinitionId]: 11n }),
234
+ usageDocument(otherStreamId, 'other-writer', { [definitionId]: 50n })
235
+ ]);
236
+
237
+ await Promise.all([
238
+ usage.foldStaleWriterDeltas({ staleWriterMs: 0 }),
239
+ withTransaction(db, (session) => usage.removeDefinition(definitionId, session))
240
+ ]);
241
+
242
+ await expect(readUsageEntries(db, replicationStreamId)).resolves.toEqual([
243
+ { definition_id: otherDefinitionId, active_bytes: 18n }
244
+ ]);
245
+
246
+ await withTransaction(db, async (session) => {
247
+ await new ObjectStorageUsage(db, otherStreamId).removeStream(session);
248
+ });
249
+ await db.objectStorageUsage.insertOne(usageDocument(otherStreamId, 'other-writer', { [definitionId]: 50n }));
250
+
251
+ await withTransaction(db, async (session) => {
252
+ await usage.removeStream(session);
253
+ });
254
+
255
+ await expect(ObjectStorageUsage.readAllDefinitionUsage(db)).resolves.toEqual([
256
+ { replication_stream_id: otherStreamId, definition_id: definitionId, active_bytes: 50n }
257
+ ]);
258
+ });
259
+ });
260
+
261
+ test('returns no entries for an absent usage collection', async () => {
262
+ await withUsageContext(async ({ db, bucketStorage }) => {
263
+ await db.objectStorageUsage.drop().catch(() => undefined);
264
+ await expect(readUsageEntries(db, bucketStorage.replicationStreamId)).resolves.toEqual([]);
265
+ });
266
+ });
267
+ });
@@ -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
+ });