@powersync/service-module-mongodb-storage 0.15.4 → 0.17.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 (202) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/dist/migrations/db/migrations/1688556755264-initial-sync-rules.js +1 -1
  3. package/dist/migrations/db/migrations/1688556755264-initial-sync-rules.js.map +1 -1
  4. package/dist/migrations/db/migrations/1702295701188-sync-rule-state.js +2 -2
  5. package/dist/migrations/db/migrations/1702295701188-sync-rule-state.js.map +1 -1
  6. package/dist/storage/MongoBucketStorage.d.ts +8 -6
  7. package/dist/storage/MongoBucketStorage.js +153 -66
  8. package/dist/storage/MongoBucketStorage.js.map +1 -1
  9. package/dist/storage/implementation/BucketDefinitionMapping.d.ts +15 -0
  10. package/dist/storage/implementation/BucketDefinitionMapping.js +58 -0
  11. package/dist/storage/implementation/BucketDefinitionMapping.js.map +1 -0
  12. package/dist/storage/implementation/CheckpointState.d.ts +20 -0
  13. package/dist/storage/implementation/CheckpointState.js +31 -0
  14. package/dist/storage/implementation/CheckpointState.js.map +1 -0
  15. package/dist/storage/implementation/MongoBucketBatch.d.ts +48 -35
  16. package/dist/storage/implementation/MongoBucketBatch.js +118 -379
  17. package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
  18. package/dist/storage/implementation/MongoBucketBatchShared.d.ts +5 -0
  19. package/dist/storage/implementation/MongoBucketBatchShared.js +8 -0
  20. package/dist/storage/implementation/MongoBucketBatchShared.js.map +1 -0
  21. package/dist/storage/implementation/MongoChecksums.d.ts +29 -17
  22. package/dist/storage/implementation/MongoChecksums.js +13 -72
  23. package/dist/storage/implementation/MongoChecksums.js.map +1 -1
  24. package/dist/storage/implementation/MongoCompactor.d.ts +98 -58
  25. package/dist/storage/implementation/MongoCompactor.js +229 -296
  26. package/dist/storage/implementation/MongoCompactor.js.map +1 -1
  27. package/dist/storage/implementation/MongoParameterCompactor.d.ts +11 -6
  28. package/dist/storage/implementation/MongoParameterCompactor.js +11 -8
  29. package/dist/storage/implementation/MongoParameterCompactor.js.map +1 -1
  30. package/dist/storage/implementation/MongoPersistedSyncRules.d.ts +14 -0
  31. package/dist/storage/implementation/MongoPersistedSyncRules.js +67 -0
  32. package/dist/storage/implementation/MongoPersistedSyncRules.js.map +1 -0
  33. package/dist/storage/implementation/MongoPersistedSyncRulesContent.d.ts +22 -5
  34. package/dist/storage/implementation/MongoPersistedSyncRulesContent.js +56 -13
  35. package/dist/storage/implementation/MongoPersistedSyncRulesContent.js.map +1 -1
  36. package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +61 -32
  37. package/dist/storage/implementation/MongoSyncBucketStorage.js +85 -523
  38. package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
  39. package/dist/storage/implementation/MongoSyncRulesLock.d.ts +10 -4
  40. package/dist/storage/implementation/MongoSyncRulesLock.js +19 -13
  41. package/dist/storage/implementation/MongoSyncRulesLock.js.map +1 -1
  42. package/dist/storage/implementation/MongoWriteCheckpointAPI.js +1 -1
  43. package/dist/storage/implementation/MongoWriteCheckpointAPI.js.map +1 -1
  44. package/dist/storage/implementation/OperationBatch.js +1 -1
  45. package/dist/storage/implementation/SyncRuleStateUpdate.d.ts +14 -0
  46. package/dist/storage/implementation/SyncRuleStateUpdate.js +36 -0
  47. package/dist/storage/implementation/SyncRuleStateUpdate.js.map +1 -0
  48. package/dist/storage/implementation/common/BucketDataDoc.d.ts +35 -0
  49. package/dist/storage/implementation/common/BucketDataDoc.js +2 -0
  50. package/dist/storage/implementation/common/BucketDataDoc.js.map +1 -0
  51. package/dist/storage/implementation/common/MongoSyncBucketStorageContext.d.ts +13 -0
  52. package/dist/storage/implementation/common/MongoSyncBucketStorageContext.js +2 -0
  53. package/dist/storage/implementation/common/MongoSyncBucketStorageContext.js.map +1 -0
  54. package/dist/storage/implementation/common/PersistedBatch.d.ts +108 -0
  55. package/dist/storage/implementation/common/PersistedBatch.js +237 -0
  56. package/dist/storage/implementation/common/PersistedBatch.js.map +1 -0
  57. package/dist/storage/implementation/common/SingleBucketStore.d.ts +54 -0
  58. package/dist/storage/implementation/common/SingleBucketStore.js +3 -0
  59. package/dist/storage/implementation/common/SingleBucketStore.js.map +1 -0
  60. package/dist/storage/implementation/common/SourceRecordStore.d.ts +35 -0
  61. package/dist/storage/implementation/common/SourceRecordStore.js +2 -0
  62. package/dist/storage/implementation/common/SourceRecordStore.js.map +1 -0
  63. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.d.ts +27 -0
  64. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js +57 -0
  65. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js.map +1 -0
  66. package/dist/storage/implementation/createMongoSyncBucketStorage.d.ts +7 -0
  67. package/dist/storage/implementation/createMongoSyncBucketStorage.js +9 -0
  68. package/dist/storage/implementation/createMongoSyncBucketStorage.js.map +1 -0
  69. package/dist/storage/implementation/db.d.ts +41 -36
  70. package/dist/storage/implementation/db.js +77 -99
  71. package/dist/storage/implementation/db.js.map +1 -1
  72. package/dist/storage/implementation/models.d.ts +79 -66
  73. package/dist/storage/implementation/models.js +20 -1
  74. package/dist/storage/implementation/models.js.map +1 -1
  75. package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +27 -0
  76. package/dist/storage/implementation/v1/MongoBucketBatchV1.js +407 -0
  77. package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -0
  78. package/dist/storage/implementation/v1/MongoChecksumsV1.d.ts +12 -0
  79. package/dist/storage/implementation/v1/MongoChecksumsV1.js +56 -0
  80. package/dist/storage/implementation/v1/MongoChecksumsV1.js.map +1 -0
  81. package/dist/storage/implementation/v1/MongoCompactorV1.d.ts +23 -0
  82. package/dist/storage/implementation/v1/MongoCompactorV1.js +52 -0
  83. package/dist/storage/implementation/v1/MongoCompactorV1.js.map +1 -0
  84. package/dist/storage/implementation/v1/MongoParameterCompactorV1.d.ts +9 -0
  85. package/dist/storage/implementation/v1/MongoParameterCompactorV1.js +20 -0
  86. package/dist/storage/implementation/v1/MongoParameterCompactorV1.js.map +1 -0
  87. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.d.ts +50 -0
  88. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +354 -0
  89. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -0
  90. package/dist/storage/implementation/v1/PersistedBatchV1.d.ts +25 -0
  91. package/dist/storage/implementation/v1/PersistedBatchV1.js +183 -0
  92. package/dist/storage/implementation/v1/PersistedBatchV1.js.map +1 -0
  93. package/dist/storage/implementation/v1/SingleBucketStoreV1.d.ts +18 -0
  94. package/dist/storage/implementation/v1/SingleBucketStoreV1.js +57 -0
  95. package/dist/storage/implementation/v1/SingleBucketStoreV1.js.map +1 -0
  96. package/dist/storage/implementation/v1/SourceRecordStoreV1.d.ts +19 -0
  97. package/dist/storage/implementation/v1/SourceRecordStoreV1.js +105 -0
  98. package/dist/storage/implementation/v1/SourceRecordStoreV1.js.map +1 -0
  99. package/dist/storage/implementation/v1/VersionedPowerSyncMongoV1.d.ts +12 -0
  100. package/dist/storage/implementation/v1/VersionedPowerSyncMongoV1.js +20 -0
  101. package/dist/storage/implementation/v1/VersionedPowerSyncMongoV1.js.map +1 -0
  102. package/dist/storage/implementation/v1/models.d.ts +45 -0
  103. package/dist/storage/implementation/v1/models.js +37 -0
  104. package/dist/storage/implementation/v1/models.js.map +1 -0
  105. package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +30 -0
  106. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +463 -0
  107. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -0
  108. package/dist/storage/implementation/v3/MongoChecksumsV3.d.ts +15 -0
  109. package/dist/storage/implementation/v3/MongoChecksumsV3.js +84 -0
  110. package/dist/storage/implementation/v3/MongoChecksumsV3.js.map +1 -0
  111. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +23 -0
  112. package/dist/storage/implementation/v3/MongoCompactorV3.js +68 -0
  113. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -0
  114. package/dist/storage/implementation/v3/MongoParameterCompactorV3.d.ts +9 -0
  115. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js +18 -0
  116. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js.map +1 -0
  117. package/dist/storage/implementation/v3/MongoParameterLookupV3.d.ts +4 -0
  118. package/dist/storage/implementation/v3/MongoParameterLookupV3.js +9 -0
  119. package/dist/storage/implementation/v3/MongoParameterLookupV3.js.map +1 -0
  120. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.d.ts +63 -0
  121. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +508 -0
  122. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -0
  123. package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +28 -0
  124. package/dist/storage/implementation/v3/PersistedBatchV3.js +259 -0
  125. package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -0
  126. package/dist/storage/implementation/v3/SingleBucketStoreV3.d.ts +18 -0
  127. package/dist/storage/implementation/v3/SingleBucketStoreV3.js +48 -0
  128. package/dist/storage/implementation/v3/SingleBucketStoreV3.js.map +1 -0
  129. package/dist/storage/implementation/v3/SourceRecordStoreV3.d.ts +22 -0
  130. package/dist/storage/implementation/v3/SourceRecordStoreV3.js +164 -0
  131. package/dist/storage/implementation/v3/SourceRecordStoreV3.js.map +1 -0
  132. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.d.ts +22 -0
  133. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js +74 -0
  134. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js.map +1 -0
  135. package/dist/storage/implementation/v3/models.d.ts +101 -0
  136. package/dist/storage/implementation/v3/models.js +34 -0
  137. package/dist/storage/implementation/v3/models.js.map +1 -0
  138. package/dist/storage/storage-index.d.ts +6 -3
  139. package/dist/storage/storage-index.js +6 -3
  140. package/dist/storage/storage-index.js.map +1 -1
  141. package/dist/utils/util.d.ts +10 -3
  142. package/dist/utils/util.js +24 -3
  143. package/dist/utils/util.js.map +1 -1
  144. package/package.json +9 -9
  145. package/src/migrations/db/migrations/1688556755264-initial-sync-rules.ts +1 -1
  146. package/src/migrations/db/migrations/1702295701188-sync-rule-state.ts +7 -7
  147. package/src/storage/MongoBucketStorage.ts +254 -99
  148. package/src/storage/implementation/BucketDefinitionMapping.ts +75 -0
  149. package/src/storage/implementation/CheckpointState.ts +59 -0
  150. package/src/storage/implementation/MongoBucketBatch.ts +182 -490
  151. package/src/storage/implementation/MongoBucketBatchShared.ts +11 -0
  152. package/src/storage/implementation/MongoChecksums.ts +53 -75
  153. package/src/storage/implementation/MongoCompactor.ts +374 -404
  154. package/src/storage/implementation/MongoParameterCompactor.ts +37 -24
  155. package/src/storage/implementation/MongoPersistedSyncRules.ts +82 -0
  156. package/src/storage/implementation/MongoPersistedSyncRulesContent.ts +78 -16
  157. package/src/storage/implementation/MongoSyncBucketStorage.ts +179 -628
  158. package/src/storage/implementation/MongoSyncRulesLock.ts +20 -16
  159. package/src/storage/implementation/MongoWriteCheckpointAPI.ts +3 -1
  160. package/src/storage/implementation/OperationBatch.ts +1 -1
  161. package/src/storage/implementation/SyncRuleStateUpdate.ts +38 -0
  162. package/src/storage/implementation/common/BucketDataDoc.ts +37 -0
  163. package/src/storage/implementation/common/MongoSyncBucketStorageContext.ts +15 -0
  164. package/src/storage/implementation/common/PersistedBatch.ts +364 -0
  165. package/src/storage/implementation/common/SingleBucketStore.ts +63 -0
  166. package/src/storage/implementation/common/SourceRecordStore.ts +48 -0
  167. package/src/storage/implementation/common/VersionedPowerSyncMongoBase.ts +80 -0
  168. package/src/storage/implementation/createMongoSyncBucketStorage.ts +25 -0
  169. package/src/storage/implementation/db.ts +110 -131
  170. package/src/storage/implementation/models.ts +102 -79
  171. package/src/storage/implementation/v1/MongoBucketBatchV1.ts +509 -0
  172. package/src/storage/implementation/v1/MongoChecksumsV1.ts +75 -0
  173. package/src/storage/implementation/v1/MongoCompactorV1.ts +93 -0
  174. package/src/storage/implementation/v1/MongoParameterCompactorV1.ts +26 -0
  175. package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +543 -0
  176. package/src/storage/implementation/v1/PersistedBatchV1.ts +229 -0
  177. package/src/storage/implementation/v1/SingleBucketStoreV1.ts +74 -0
  178. package/src/storage/implementation/v1/SourceRecordStoreV1.ts +156 -0
  179. package/src/storage/implementation/v1/VersionedPowerSyncMongoV1.ts +28 -0
  180. package/src/storage/implementation/v1/models.ts +99 -0
  181. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +607 -0
  182. package/src/storage/implementation/v3/MongoChecksumsV3.ts +120 -0
  183. package/src/storage/implementation/v3/MongoCompactorV3.ts +107 -0
  184. package/src/storage/implementation/v3/MongoParameterCompactorV3.ts +24 -0
  185. package/src/storage/implementation/v3/MongoParameterLookupV3.ts +11 -0
  186. package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +678 -0
  187. package/src/storage/implementation/v3/PersistedBatchV3.ts +317 -0
  188. package/src/storage/implementation/v3/SingleBucketStoreV3.ts +68 -0
  189. package/src/storage/implementation/v3/SourceRecordStoreV3.ts +226 -0
  190. package/src/storage/implementation/v3/VersionedPowerSyncMongoV3.ts +117 -0
  191. package/src/storage/implementation/v3/models.ts +164 -0
  192. package/src/storage/storage-index.ts +6 -3
  193. package/src/utils/util.ts +34 -5
  194. package/test/src/storage_compacting.test.ts +57 -29
  195. package/test/src/storage_sync.test.ts +767 -5
  196. package/test/src/storeCurrentData.test.ts +211 -0
  197. package/test/tsconfig.json +0 -1
  198. package/tsconfig.tsbuildinfo +1 -1
  199. package/dist/storage/implementation/PersistedBatch.d.ts +0 -71
  200. package/dist/storage/implementation/PersistedBatch.js +0 -354
  201. package/dist/storage/implementation/PersistedBatch.js.map +0 -1
  202. package/src/storage/implementation/PersistedBatch.ts +0 -432
@@ -1,8 +1,64 @@
1
- import { storage, updateSyncRulesFromYaml } from '@powersync/service-core';
1
+ import { deserializeParameterLookup, JwtPayload, storage, updateSyncRulesFromYaml } from '@powersync/service-core';
2
2
  import { bucketRequest, register, test_utils } from '@powersync/service-core-tests';
3
+ import { DEFAULT_HYDRATION_STATE, nodeSqlite, RequestParameters, SqlSyncRules } from '@powersync/service-sync-rules';
4
+ import * as bson from 'bson';
5
+ import * as sqlite from 'node:sqlite';
3
6
  import { describe, expect, test } from 'vitest';
7
+ import { MongoBucketStorage } from '../../src/storage/MongoBucketStorage.js';
8
+ import { MongoSyncBucketStorage } from '../../src/storage/implementation/createMongoSyncBucketStorage.js';
9
+ import { SourceRecordStoreV3 } from '../../src/storage/implementation/v3/SourceRecordStoreV3.js';
10
+ import type { VersionedPowerSyncMongoV3 } from '../../src/storage/implementation/v3/VersionedPowerSyncMongoV3.js';
11
+ import {
12
+ CurrentBucketV3,
13
+ ReplicationStreamDocumentV3,
14
+ SyncConfigDefinition
15
+ } from '../../src/storage/implementation/v3/models.js';
4
16
  import { INITIALIZED_MONGO_STORAGE_FACTORY, TEST_STORAGE_VERSIONS } from './util.js';
5
17
 
18
+ const MINIMAL_SYNC_RULES = `
19
+ bucket_definitions:
20
+ global:
21
+ data:
22
+ - SELECT id FROM test
23
+ `;
24
+
25
+ function sourceDescriptor(
26
+ name: string,
27
+ options: {
28
+ objectId?: string;
29
+ replicaIdColumns?: string[];
30
+ } = {}
31
+ ): storage.SourceEntityDescriptor {
32
+ return {
33
+ connectionTag: storage.SourceTable.DEFAULT_TAG,
34
+ objectId: options.objectId ?? name,
35
+ schema: 'public',
36
+ name,
37
+ replicaIdColumns: (options.replicaIdColumns ?? ['id']).map((column) => ({
38
+ name: column,
39
+ type: 'VARCHAR',
40
+ typeId: 25
41
+ }))
42
+ };
43
+ }
44
+
45
+ function objectIdGenerator(id: string) {
46
+ let used = false;
47
+ return () => {
48
+ if (used) {
49
+ throw new Error(`Can only generate a single id using ${id}`);
50
+ }
51
+ used = true;
52
+ return new bson.ObjectId(id);
53
+ };
54
+ }
55
+
56
+ function hydratedRulesFor(yaml: string) {
57
+ const parsed = SqlSyncRules.fromYaml(yaml, test_utils.PARSE_OPTIONS);
58
+ expect(parsed.errors).toEqual([]);
59
+ return parsed.config.hydrate({ hydrationState: DEFAULT_HYDRATION_STATE, sqlite: nodeSqlite(sqlite) });
60
+ }
61
+
6
62
  function registerSyncStorageTests(storageConfig: storage.TestStorageConfig, storageVersion: number) {
7
63
  register.registerSyncTests(storageConfig.factory, {
8
64
  storageVersion,
@@ -126,15 +182,721 @@ function registerSyncStorageTests(storageConfig: storage.TestStorageConfig, stor
126
182
 
127
183
  // Test that the checksum type is correct.
128
184
  // Specifically, test that it never persisted as double.
129
- const mongoFactory = factory as any;
130
- const checksumTypes = await mongoFactory.db.bucket_data
131
- .aggregate([{ $group: { _id: { $type: '$checksum' }, count: { $sum: 1 } } }])
132
- .toArray();
185
+ const mongoFactory = factory as MongoBucketStorage;
186
+ const checksumTypes =
187
+ storageVersion >= 3
188
+ ? (
189
+ await Promise.all(
190
+ (
191
+ await mongoFactory.db.db
192
+ .listCollections({ name: new RegExp(`^bucket_data_${syncRules.id}_`) }, { nameOnly: true })
193
+ .toArray()
194
+ ).map((collection: { name: string }) =>
195
+ mongoFactory.db.db
196
+ .collection(collection.name)
197
+ .aggregate([{ $group: { _id: { $type: '$checksum' }, count: { $sum: 1 } } }])
198
+ .toArray()
199
+ )
200
+ )
201
+ ).flat()
202
+ : await mongoFactory.db.bucket_data
203
+ .aggregate([{ $group: { _id: { $type: '$checksum' }, count: { $sum: 1 } } }])
204
+ .toArray();
133
205
  expect(checksumTypes).toEqual([{ _id: 'long', count: 4 }]);
134
206
  });
207
+
208
+ test('resolveTables populates matching data and parameter sources', async () => {
209
+ await using factory = await storageConfig.factory();
210
+ const syncRules = await factory.updateSyncRules(
211
+ updateSyncRulesFromYaml(
212
+ `
213
+ bucket_definitions:
214
+ by_owner:
215
+ parameters:
216
+ - SELECT owner_id FROM test WHERE id = token_parameters.test_id
217
+ data:
218
+ - SELECT id, owner_id FROM test WHERE owner_id = bucket.owner_id
219
+ `,
220
+ { storageVersion }
221
+ )
222
+ );
223
+ const bucketStorage = factory.getInstance(syncRules);
224
+
225
+ await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
226
+ const sourceTable = await test_utils.resolveTestTable(writer, 'test', ['id'], INITIALIZED_MONGO_STORAGE_FACTORY);
227
+
228
+ expect(sourceTable.bucketDataSources).toHaveLength(1);
229
+ expect(sourceTable.parameterLookupSources).toHaveLength(1);
230
+ expect(sourceTable.syncData).toBe(true);
231
+ expect(sourceTable.syncParameters).toBe(true);
232
+ expect(sourceTable.syncEvent).toBe(false);
233
+ });
234
+
235
+ test('resolveTables drops old table when table name changes for the same objectId', async () => {
236
+ await using factory = await storageConfig.factory();
237
+ const syncRules = await factory.updateSyncRules(
238
+ updateSyncRulesFromYaml(
239
+ `
240
+ bucket_definitions:
241
+ global:
242
+ data:
243
+ - SELECT id FROM "%"
244
+ `,
245
+ { storageVersion }
246
+ )
247
+ );
248
+ const bucketStorage = factory.getInstance(syncRules);
249
+
250
+ await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
251
+ const before = await writer.resolveTables({
252
+ connection_id: 1,
253
+ source: sourceDescriptor('orders', { objectId: 'orders-relation' }),
254
+ idGenerator: objectIdGenerator('6544e3899293153fa7b38342')
255
+ });
256
+ const after = await writer.resolveTables({
257
+ connection_id: 1,
258
+ source: sourceDescriptor('renamed_orders', { objectId: 'orders-relation' }),
259
+ idGenerator: objectIdGenerator('6544e3899293153fa7b38343')
260
+ });
261
+
262
+ expect(after.tables).toHaveLength(1);
263
+ expect(after.tables[0].id).not.toEqual(before.tables[0].id);
264
+ expect(after.tables[0].bucketDataSources).toHaveLength(1);
265
+ expect(after.tables[0].parameterLookupSources).toHaveLength(0);
266
+ expect(after.dropTables.map((table) => ({ id: table.id, name: table.name }))).toEqual([
267
+ { id: before.tables[0].id, name: 'orders' }
268
+ ]);
269
+ expect(after.dropTables[0].bucketDataSources).toHaveLength(1);
270
+ expect(after.dropTables[0].parameterLookupSources).toHaveLength(0);
271
+ });
272
+
273
+ test('resolveTables drops old table when objectId changes for the same table name', async () => {
274
+ await using factory = await storageConfig.factory();
275
+ const syncRules = await factory.updateSyncRules(
276
+ updateSyncRulesFromYaml(
277
+ `
278
+ bucket_definitions:
279
+ global:
280
+ data:
281
+ - SELECT id FROM "%"
282
+ `,
283
+ { storageVersion }
284
+ )
285
+ );
286
+ const bucketStorage = factory.getInstance(syncRules);
287
+
288
+ await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
289
+ const before = await writer.resolveTables({
290
+ connection_id: 1,
291
+ source: sourceDescriptor('accounts', { objectId: 'accounts-relation-old' }),
292
+ idGenerator: objectIdGenerator('6544e3899293153fa7b38344')
293
+ });
294
+ const after = await writer.resolveTables({
295
+ connection_id: 1,
296
+ source: sourceDescriptor('accounts', { objectId: 'accounts-relation-new' }),
297
+ idGenerator: objectIdGenerator('6544e3899293153fa7b38345')
298
+ });
299
+
300
+ expect(after.tables).toHaveLength(1);
301
+ expect(after.tables[0].id).not.toEqual(before.tables[0].id);
302
+ expect(after.tables[0].bucketDataSources).toHaveLength(1);
303
+ expect(after.dropTables.map((table) => ({ id: table.id, objectId: table.objectId }))).toEqual([
304
+ { id: before.tables[0].id, objectId: 'accounts-relation-old' }
305
+ ]);
306
+ expect(after.dropTables[0].bucketDataSources).toHaveLength(1);
307
+ });
308
+
309
+ test('resolveTables drops old table when replica id columns change', async () => {
310
+ await using factory = await storageConfig.factory();
311
+ const syncRules = await factory.updateSyncRules(
312
+ updateSyncRulesFromYaml(
313
+ `
314
+ bucket_definitions:
315
+ global:
316
+ data:
317
+ - SELECT id FROM "%"
318
+ `,
319
+ { storageVersion }
320
+ )
321
+ );
322
+ const bucketStorage = factory.getInstance(syncRules);
323
+
324
+ await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
325
+ const before = await writer.resolveTables({
326
+ connection_id: 1,
327
+ source: sourceDescriptor('items', { objectId: 'items-relation', replicaIdColumns: ['id'] }),
328
+ idGenerator: objectIdGenerator('6544e3899293153fa7b38346')
329
+ });
330
+ const after = await writer.resolveTables({
331
+ connection_id: 1,
332
+ source: sourceDescriptor('items', { objectId: 'items-relation', replicaIdColumns: ['tenant_id', 'id'] }),
333
+ idGenerator: objectIdGenerator('6544e3899293153fa7b38347')
334
+ });
335
+
336
+ expect(after.tables).toHaveLength(1);
337
+ expect(after.tables[0].id).not.toEqual(before.tables[0].id);
338
+ expect(after.tables[0].replicaIdColumns.map((column) => column.name)).toEqual(['tenant_id', 'id']);
339
+ expect(after.tables[0].bucketDataSources).toHaveLength(1);
340
+ expect(
341
+ after.dropTables.map((table) => ({ id: table.id, columns: table.replicaIdColumns.map((c) => c.name) }))
342
+ ).toEqual([{ id: before.tables[0].id, columns: ['id'] }]);
343
+ expect(after.dropTables[0].bucketDataSources).toHaveLength(1);
344
+ });
345
+
346
+ test.runIf(storageVersion >= 3)(
347
+ 'resolveTables resolves v3 event-only tables without source memberships',
348
+ async () => {
349
+ await using factory = await storageConfig.factory();
350
+ const syncRules = await factory.updateSyncRules(
351
+ updateSyncRulesFromYaml(
352
+ `
353
+ bucket_definitions:
354
+ by_owner:
355
+ data:
356
+ - SELECT id FROM users
357
+
358
+ event_definitions:
359
+ write_checkpoints:
360
+ payloads:
361
+ - SELECT user_id, checkpoint FROM checkpoints
362
+ `,
363
+ { storageVersion }
364
+ )
365
+ );
366
+ const bucketStorage = factory.getInstance(syncRules);
367
+
368
+ await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
369
+ const resolved = await writer.resolveTables({
370
+ connection_id: 1,
371
+ source: {
372
+ connectionTag: storage.SourceTable.DEFAULT_TAG,
373
+ objectId: 'checkpoints',
374
+ schema: 'public',
375
+ name: 'checkpoints',
376
+ replicaIdColumns: [{ name: 'id', type: 'VARCHAR', typeId: 25 }]
377
+ },
378
+ idGenerator: () => new bson.ObjectId('6544e3899293153fa7b38341')
379
+ });
380
+
381
+ expect(resolved.tables).toHaveLength(1);
382
+ expect(resolved.dropTables).toHaveLength(0);
383
+ expect(resolved.tables[0].bucketDataSources).toEqual([]);
384
+ expect(resolved.tables[0].parameterLookupSources).toEqual([]);
385
+ expect(resolved.tables[0].syncData).toBe(false);
386
+ expect(resolved.tables[0].syncParameters).toBe(false);
387
+ expect(resolved.tables[0].syncEvent).toBe(true);
388
+ }
389
+ );
390
+
391
+ test.runIf(storageVersion >= 3)('resolveTables handles v3 source membership additions and removals', async () => {
392
+ // Tests the behavior of resolveTables when bucket data sources and parameter index creators are added or removed.
393
+ // These are not end-to-end tests yet, since we don't have a full incremental reprocessing implementation.
394
+ // This just tests the specific resolveTables behavior.
395
+
396
+ // The same tests should work with sync streams, but legacy bucket_definitions make it easy
397
+ // to see the distinction between the parameter index queries and the data sources.
398
+ const fullRulesYaml = `
399
+ bucket_definitions:
400
+ by_owner:
401
+ parameters:
402
+ - SELECT owner_id FROM memberships WHERE id = token_parameters.test_id
403
+ data:
404
+ - SELECT id, owner_id FROM memberships WHERE owner_id = bucket.owner_id
405
+ `;
406
+ const dataOnlyRulesYaml = `
407
+ bucket_definitions:
408
+ by_owner:
409
+ parameters:
410
+ - SELECT token_parameters.owner_id as owner_id
411
+ data:
412
+ - SELECT id, owner_id FROM memberships WHERE owner_id = bucket.owner_id
413
+ `;
414
+ const parameterOnlyRulesYaml = `
415
+ bucket_definitions:
416
+ by_owner:
417
+ parameters:
418
+ - SELECT owner_id FROM memberships WHERE id = token_parameters.test_id
419
+ data: []
420
+ `;
421
+ const eventOnlyRulesYaml = `
422
+ bucket_definitions: {}
423
+
424
+ event_definitions:
425
+ write_checkpoints:
426
+ payloads:
427
+ - SELECT id, owner_id FROM memberships
428
+ `;
429
+
430
+ await using factory = await storageConfig.factory();
431
+ // This does not quite match what actual API usage would look like.
432
+ // Here we're persisting one sync config, then resolving tables with others.
433
+ // We're also using the default hydration state for them all.
434
+ const syncRules = await factory.updateSyncRules(updateSyncRulesFromYaml(fullRulesYaml, { storageVersion }));
435
+ const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
436
+ await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
437
+ const fullRules = hydratedRulesFor(fullRulesYaml);
438
+ const dataOnlyRules = hydratedRulesFor(dataOnlyRulesYaml);
439
+ const parameterOnlyRules = hydratedRulesFor(parameterOnlyRulesYaml);
440
+ const eventOnlyRules = hydratedRulesFor(eventOnlyRulesYaml);
441
+ const source = sourceDescriptor('memberships', { objectId: 'memberships-relation' });
442
+ const dataOnlyTableId = new bson.ObjectId('6544e3899293153fa7b38348');
443
+ const addedParameterTableId = new bson.ObjectId('6544e3899293153fa7b38349');
444
+ const removedDataTableId = new bson.ObjectId('6544e3899293153fa7b3834a');
445
+
446
+ const dataOnly = await writer.resolveTables({
447
+ connection_id: 1,
448
+ source,
449
+ idGenerator: () => dataOnlyTableId,
450
+ syncRules: dataOnlyRules
451
+ });
452
+ expect(dataOnly.tables.map((table) => table.id)).toEqual([dataOnlyTableId]);
453
+ expect(dataOnly.dropTables.map((table) => table.id)).toEqual([]);
454
+ expect(dataOnly.tables[0].bucketDataSources).toHaveLength(1);
455
+ expect(dataOnly.tables[0].parameterLookupSources).toHaveLength(0);
456
+
457
+ const addedParameter = await writer.resolveTables({
458
+ connection_id: 1,
459
+ source,
460
+ idGenerator: () => addedParameterTableId,
461
+ syncRules: fullRules
462
+ });
463
+ // Adding a definition always creates a new SourceTable
464
+ expect(addedParameter.tables.map((table) => table.id)).toEqual([dataOnlyTableId, addedParameterTableId]);
465
+ expect(addedParameter.tables.map((table) => table.bucketDataSources.length).sort()).toEqual([0, 1]);
466
+ expect(addedParameter.tables.map((table) => table.parameterLookupSources.length).sort()).toEqual([0, 1]);
467
+ expect(addedParameter.dropTables.map((table) => table.id)).toEqual([]);
468
+
469
+ const removedParameter = await writer.resolveTables({
470
+ connection_id: 1,
471
+ source,
472
+ idGenerator: () => {
473
+ throw new Error('data-only resolve should reuse existing v3 source table');
474
+ },
475
+ syncRules: dataOnlyRules
476
+ });
477
+ expect(removedParameter.tables.map((table) => table.id)).toEqual([dataOnlyTableId]);
478
+ // Now this sourceTable is unused & dropped
479
+ expect(removedParameter.dropTables.map((table) => table.id)).toEqual([addedParameterTableId]);
480
+ expect(removedParameter.tables[0].bucketDataSources).toHaveLength(1);
481
+ expect(removedParameter.tables[0].parameterLookupSources).toHaveLength(0);
482
+ await writer.drop(removedParameter.dropTables);
483
+
484
+ const removedData = await writer.resolveTables({
485
+ connection_id: 1,
486
+ source,
487
+ idGenerator: () => removedDataTableId,
488
+ syncRules: parameterOnlyRules
489
+ });
490
+
491
+ // This goes from dataOnlyRules -> parameterOnlyRules, which adds one definition and removes another.
492
+ // This generates a new SourceTable again, and removes all others.
493
+ expect(removedData.tables.map((table) => table.id)).toEqual([removedDataTableId]);
494
+ expect(removedData.dropTables.map((table) => table.id)).toEqual([dataOnlyTableId]);
495
+ expect(removedData.tables[0].bucketDataSources).toHaveLength(0);
496
+ expect(removedData.tables[0].parameterLookupSources).toHaveLength(1);
497
+ await writer.drop(removedData.dropTables);
498
+
499
+ const eventOnly = await writer.resolveTables({
500
+ connection_id: 1,
501
+ source,
502
+ idGenerator: () => {
503
+ throw new Error('resolve should reuse existing v3 source table');
504
+ },
505
+ syncRules: eventOnlyRules
506
+ });
507
+
508
+ // Event-only table can re-use any existing table.
509
+ expect(eventOnly.tables.map((table) => table.id)).toEqual([removedDataTableId]);
510
+ expect(eventOnly.dropTables.map((table) => table.id)).toEqual([]);
511
+ expect(eventOnly.tables[0].bucketDataSources).toHaveLength(0);
512
+ expect(eventOnly.tables[0].parameterLookupSources).toHaveLength(0);
513
+ expect(eventOnly.tables[0].syncData).toBe(false);
514
+ expect(eventOnly.tables[0].syncParameters).toBe(false);
515
+ expect(eventOnly.tables[0].syncEvent).toBe(true);
516
+ });
517
+
518
+ test.runIf(storageVersion >= 3)('uses v3 mongodb model shapes', async () => {
519
+ await using factory = await storageConfig.factory();
520
+ const syncRules = await factory.updateSyncRules(
521
+ updateSyncRulesFromYaml(
522
+ `
523
+ bucket_definitions:
524
+ global:
525
+ parameters:
526
+ - SELECT owner_id FROM test WHERE id = token_parameters.test
527
+ data:
528
+ - SELECT id, description, owner_id FROM test WHERE id = bucket.owner_id
529
+ `,
530
+ { storageVersion }
531
+ )
532
+ );
533
+ const bucketStorage = factory.getInstance(syncRules);
534
+ const sync_rules = syncRules.parsed(test_utils.PARSE_OPTIONS).hydratedSyncConfig();
535
+ await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
536
+ const sourceTable = await test_utils.resolveTestTable(writer, 'test', ['id'], INITIALIZED_MONGO_STORAGE_FACTORY);
537
+
538
+ await writer.save({
539
+ sourceTable,
540
+ tag: storage.SaveOperationTag.INSERT,
541
+ after: {
542
+ id: 'shape-check',
543
+ description: 'shape',
544
+ owner_id: 'user-1'
545
+ },
546
+ afterReplicaId: test_utils.rid('shape-check')
547
+ });
548
+ await writer.markAllSnapshotDone('1/1');
549
+ await writer.commit('1/1');
550
+
551
+ const checkpoint = await bucketStorage.getCheckpoint();
552
+ const parameters = new RequestParameters(new JwtPayload({ sub: 'u1', parameters: { test: 'shape-check' } }), {});
553
+ const querier = sync_rules.getBucketParameterQuerier(test_utils.querierOptions(parameters)).querier;
554
+ const buckets = await querier.queryDynamicBucketDescriptions({
555
+ async getParameterSets(lookups) {
556
+ expect(lookups.map((l) => l.indexKey)).toEqual([['shape-check']]);
557
+ expect(lookups[0].indexId).toEqual('1');
558
+
559
+ const parameter_sets = await checkpoint.getParameterSets(lookups, 1000);
560
+ expect(parameter_sets).toEqual([{ lookup: lookups[0], rows: [{ owner_id: 'user-1' }] }]);
561
+ return parameter_sets;
562
+ }
563
+ });
564
+ expect(buckets.map((b) => b.bucket)).toEqual([bucketRequest(syncRules, 'global["user-1"]').bucket]);
565
+
566
+ const mongoFactory = factory as MongoBucketStorage;
567
+ const db = (bucketStorage as MongoSyncBucketStorage).db as VersionedPowerSyncMongoV3;
568
+ const currentDataCollections = await db.listSourceRecordCollectionsV3(syncRules.id);
569
+ const currentData = await currentDataCollections[0]?.findOne({});
570
+ const firstBucket: CurrentBucketV3 | undefined = currentData?.buckets[0] as CurrentBucketV3 | undefined;
571
+ expect(firstBucket?.def).toMatch(/^[0-9a-f]+$/);
572
+
573
+ const bucketCollections = await mongoFactory.db.db
574
+ .listCollections({ name: new RegExp(`^bucket_data_${syncRules.id}_`) }, { nameOnly: true })
575
+ .toArray();
576
+ expect(
577
+ bucketCollections.some((collection) => collection.name === `bucket_data_${syncRules.id}_${firstBucket?.def}`)
578
+ ).toBe(true);
579
+
580
+ const syncRule = (await mongoFactory.db.sync_rules.findOne({ _id: syncRules.id })) as ReplicationStreamDocumentV3;
581
+ const syncConfig = await db.syncConfigDefinitions.findOne({ _id: syncRule.sync_configs[0]._id });
582
+ const ruleMapping: SyncConfigDefinition['rule_mapping'] | undefined = syncConfig?.rule_mapping;
583
+ expect(Object.keys(ruleMapping?.definitions ?? {})).not.toHaveLength(0);
584
+
585
+ const parameterIndexId = Object.values(ruleMapping?.parameter_indexes ?? {})[0] as string | undefined;
586
+ expect(parameterIndexId).toBeDefined();
587
+ const parameterEntry = await db.parameterIndexV3(syncRules.id, parameterIndexId!).findOne({});
588
+ expect(deserializeParameterLookup(parameterEntry!.lookup)).toEqual(['shape-check']);
589
+ });
590
+
591
+ test.runIf(storageVersion < 3)('can replace processing legacy sync rules', async () => {
592
+ await using factory = await storageConfig.factory();
593
+
594
+ const firstSyncRules = await factory.updateSyncRules(
595
+ updateSyncRulesFromYaml(MINIMAL_SYNC_RULES, { storageVersion })
596
+ );
597
+
598
+ await expect(
599
+ factory.updateSyncRules(updateSyncRulesFromYaml(MINIMAL_SYNC_RULES, { storageVersion }))
600
+ ).resolves.toBeDefined();
601
+
602
+ const mongoFactory = factory as MongoBucketStorage;
603
+ expect((await mongoFactory.db.sync_rules.findOne({ _id: firstSyncRules.id }))?.state).toBe(
604
+ storage.SyncRuleState.STOP
605
+ );
606
+ });
607
+
608
+ test('can lock newly-created sync rules', async () => {
609
+ await using factory = await storageConfig.factory();
610
+
611
+ const syncRules = await factory.updateSyncRules(
612
+ updateSyncRulesFromYaml(MINIMAL_SYNC_RULES, { storageVersion, lock: true })
613
+ );
614
+
615
+ expect(syncRules.current_lock?.sync_rules_id).toBe(syncRules.id);
616
+ await syncRules.current_lock?.release();
617
+ });
618
+
619
+ test.runIf(storageVersion < 3)('uses a single current_data collection for v1 source records', async () => {
620
+ await using factory = await storageConfig.factory();
621
+ const syncRules = await factory.updateSyncRules(
622
+ updateSyncRulesFromYaml(
623
+ `
624
+ bucket_definitions:
625
+ global:
626
+ data:
627
+ - SELECT id, description FROM test
628
+ `,
629
+ { storageVersion }
630
+ )
631
+ );
632
+ const bucketStorage = factory.getInstance(syncRules);
633
+
634
+ await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
635
+ const sourceTable = await test_utils.resolveTestTable(writer, 'test', ['id'], INITIALIZED_MONGO_STORAGE_FACTORY);
636
+
637
+ await writer.save({
638
+ sourceTable,
639
+ tag: storage.SaveOperationTag.INSERT,
640
+ after: {
641
+ id: 'shape-check',
642
+ description: 'shape'
643
+ },
644
+ afterReplicaId: test_utils.rid('shape-check')
645
+ });
646
+ await writer.markAllSnapshotDone('1/1');
647
+ await writer.commit('1/1');
648
+
649
+ const mongoFactory = factory as MongoBucketStorage;
650
+ expect(await mongoFactory.db.current_data.countDocuments({ '_id.g': syncRules.id })).toBe(1);
651
+
652
+ const sourceRecordCollections = await mongoFactory.db.db
653
+ .listCollections({ name: new RegExp(`^source_records_${syncRules.id}_`) }, { nameOnly: true })
654
+ .toArray();
655
+ expect(sourceRecordCollections).toEqual([]);
656
+ });
657
+
658
+ test.runIf(storageVersion < 3)('clear removes v1 current_data rows', async () => {
659
+ await using factory = await storageConfig.factory();
660
+ const syncRules = await factory.updateSyncRules(
661
+ updateSyncRulesFromYaml(
662
+ `
663
+ bucket_definitions:
664
+ global:
665
+ data:
666
+ - SELECT id, description FROM test
667
+ `,
668
+ { storageVersion }
669
+ )
670
+ );
671
+ const bucketStorage = factory.getInstance(syncRules);
672
+
673
+ await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
674
+ const sourceTable = await test_utils.resolveTestTable(writer, 'test', ['id'], INITIALIZED_MONGO_STORAGE_FACTORY);
675
+
676
+ await writer.save({
677
+ sourceTable,
678
+ tag: storage.SaveOperationTag.INSERT,
679
+ after: {
680
+ id: 'clear-check',
681
+ description: 'shape'
682
+ },
683
+ afterReplicaId: test_utils.rid('clear-check')
684
+ });
685
+ await writer.markAllSnapshotDone('1/1');
686
+ await writer.commit('1/1');
687
+
688
+ const mongoFactory = factory as MongoBucketStorage;
689
+ expect(await mongoFactory.db.current_data.countDocuments({ '_id.g': syncRules.id })).toBe(1);
690
+
691
+ await bucketStorage.clear();
692
+
693
+ expect(await mongoFactory.db.current_data.countDocuments({ '_id.g': syncRules.id })).toBe(0);
694
+ });
695
+
696
+ test.runIf(storageVersion < 3)('storage metrics include v1 current_data', async () => {
697
+ await using factory = await storageConfig.factory();
698
+ const syncRules = await factory.updateSyncRules(
699
+ updateSyncRulesFromYaml(
700
+ `
701
+ bucket_definitions:
702
+ global:
703
+ data:
704
+ - SELECT id, description FROM test
705
+ `,
706
+ { storageVersion }
707
+ )
708
+ );
709
+ const bucketStorage = factory.getInstance(syncRules);
710
+ const metricsBefore = await factory.getStorageMetrics();
711
+
712
+ await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
713
+ const sourceTable = await test_utils.resolveTestTable(writer, 'test', ['id'], INITIALIZED_MONGO_STORAGE_FACTORY);
714
+
715
+ await writer.save({
716
+ sourceTable,
717
+ tag: storage.SaveOperationTag.INSERT,
718
+ after: {
719
+ id: 'metric-check',
720
+ description: 'shape'
721
+ },
722
+ afterReplicaId: test_utils.rid('metric-check')
723
+ });
724
+ await writer.markAllSnapshotDone('1/1');
725
+ await writer.commit('1/1');
726
+
727
+ const mongoFactory = factory as MongoBucketStorage;
728
+ expect(await mongoFactory.db.current_data.countDocuments({ '_id.g': syncRules.id })).toBe(1);
729
+
730
+ const metricsAfter = await factory.getStorageMetrics();
731
+ expect(metricsAfter.replication_size_bytes).toBeGreaterThan(metricsBefore.replication_size_bytes);
732
+ });
733
+
734
+ test.runIf(storageVersion >= 3)(
735
+ 'loads parameter checkpoint changes across all v3 parameter index collections',
736
+ async () => {
737
+ await using factory = await storageConfig.factory();
738
+ const syncRules = await factory.updateSyncRules(
739
+ updateSyncRulesFromYaml(
740
+ `
741
+ bucket_definitions:
742
+ by_owner:
743
+ parameters:
744
+ - SELECT owner_id FROM test WHERE id = token_parameters.owner_lookup
745
+ data:
746
+ - SELECT id, owner_id FROM test WHERE owner_id = bucket.owner_id
747
+ by_category:
748
+ parameters:
749
+ - SELECT category_id FROM test WHERE id = token_parameters.category_lookup
750
+ data:
751
+ - SELECT id, category_id FROM test WHERE category_id = bucket.category_id
752
+ `,
753
+ { storageVersion }
754
+ )
755
+ );
756
+ const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
757
+ const previousCheckpoint = await bucketStorage.getCheckpoint();
758
+
759
+ await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
760
+ const sourceTable = await test_utils.resolveTestTable(writer, 'test', ['id'], INITIALIZED_MONGO_STORAGE_FACTORY);
761
+
762
+ await writer.save({
763
+ sourceTable,
764
+ tag: storage.SaveOperationTag.INSERT,
765
+ after: {
766
+ id: 'shape-check',
767
+ owner_id: 'user-1',
768
+ category_id: 'cat-1'
769
+ },
770
+ afterReplicaId: test_utils.rid('shape-check')
771
+ });
772
+ await writer.markAllSnapshotDone('1/1');
773
+ await writer.commit('1/1');
774
+
775
+ const nextCheckpoint = await bucketStorage.getCheckpoint();
776
+ const changes = await bucketStorage.getCheckpointChanges({
777
+ lastCheckpoint: previousCheckpoint,
778
+ nextCheckpoint
779
+ });
780
+
781
+ expect(changes.invalidateParameterBuckets).toBe(false);
782
+ expect(changes.updatedParameterLookups).toEqual(new Set(['["1","","shape-check"]', '["2","","shape-check"]']));
783
+ }
784
+ );
785
+
786
+ test.runIf(storageVersion >= 3)('cleans pending deletes only for tracked v3 source tables', async () => {
787
+ await using factory = await storageConfig.factory();
788
+ const syncRules = await factory.updateSyncRules(
789
+ updateSyncRulesFromYaml(
790
+ `
791
+ bucket_definitions:
792
+ global:
793
+ data:
794
+ - SELECT id, description FROM test
795
+ `,
796
+ { storageVersion }
797
+ )
798
+ );
799
+
800
+ const mongoFactory = factory as MongoBucketStorage;
801
+ const bucketStorage = mongoFactory.getInstance(syncRules) as any;
802
+ const db = bucketStorage.db;
803
+ await db.initializeStreamStorage(syncRules.id);
804
+
805
+ const sourceTableA = new bson.ObjectId();
806
+ const sourceTableB = new bson.ObjectId();
807
+ await db.sourceTablesV3(syncRules.id).insertMany([
808
+ {
809
+ _id: sourceTableA,
810
+ connection_id: 1,
811
+ relation_id: 'a',
812
+ schema_name: 'public',
813
+ table_name: 'table_a',
814
+ replica_id_columns: null,
815
+ replica_id_columns2: [],
816
+ snapshot_done: true,
817
+ snapshot_status: undefined,
818
+ bucket_data_source_ids: [],
819
+ parameter_lookup_source_ids: [],
820
+ latest_pending_delete: 9n
821
+ },
822
+ {
823
+ _id: sourceTableB,
824
+ connection_id: 1,
825
+ relation_id: 'b',
826
+ schema_name: 'public',
827
+ table_name: 'table_b',
828
+ replica_id_columns: null,
829
+ replica_id_columns2: [],
830
+ snapshot_done: true,
831
+ snapshot_status: undefined,
832
+ bucket_data_source_ids: [],
833
+ parameter_lookup_source_ids: [],
834
+ latest_pending_delete: 12n
835
+ }
836
+ ]);
837
+
838
+ await db.sourceRecordsV3(syncRules.id, sourceTableA).insertMany([
839
+ { _id: 'deleted-1', data: null, buckets: [], lookups: [], pending_delete: 5n },
840
+ { _id: 'deleted-2', data: null, buckets: [], lookups: [], pending_delete: 9n },
841
+ { _id: 'active', data: null, buckets: [], lookups: [] }
842
+ ]);
843
+ await db
844
+ .sourceRecordsV3(syncRules.id, sourceTableB)
845
+ .insertMany([{ _id: 'later-delete', data: null, buckets: [], lookups: [], pending_delete: 12n }]);
846
+
847
+ const store = new SourceRecordStoreV3(db, syncRules.id, bucketStorage.sync_rules.mapping);
848
+ const logger = { info() {} } as any;
849
+
850
+ await store.postCommitCleanup(6n, logger);
851
+
852
+ expect(await db.sourceRecordsV3(syncRules.id, sourceTableA).countDocuments({ pending_delete: 5n })).toBe(0);
853
+ expect(await db.sourceRecordsV3(syncRules.id, sourceTableA).countDocuments({ pending_delete: 9n })).toBe(1);
854
+ expect(await db.sourceRecordsV3(syncRules.id, sourceTableB).countDocuments({ pending_delete: 12n })).toBe(1);
855
+ expect((await db.sourceTablesV3(syncRules.id).findOne({ _id: sourceTableA }))?.latest_pending_delete).toBe(9n);
856
+ expect((await db.sourceTablesV3(syncRules.id).findOne({ _id: sourceTableB }))?.latest_pending_delete).toBe(12n);
857
+
858
+ await store.postCommitCleanup(10n, logger);
859
+
860
+ expect(
861
+ await db.sourceRecordsV3(syncRules.id, sourceTableA).countDocuments({ pending_delete: { $exists: true } })
862
+ ).toBe(0);
863
+ expect(
864
+ (await db.sourceTablesV3(syncRules.id).findOne({ _id: sourceTableA }))?.latest_pending_delete
865
+ ).toBeUndefined();
866
+ expect((await db.sourceTablesV3(syncRules.id).findOne({ _id: sourceTableB }))?.latest_pending_delete).toBe(12n);
867
+ });
135
868
  }
136
869
 
137
870
  describe('sync - mongodb', () => {
871
+ test('v3 activation stops legacy active sync rules', async () => {
872
+ await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
873
+ const mongoFactory = factory as MongoBucketStorage;
874
+
875
+ const legacySyncRules = await factory.updateSyncRules(
876
+ updateSyncRulesFromYaml(MINIMAL_SYNC_RULES, { storageVersion: storage.LEGACY_STORAGE_VERSION })
877
+ );
878
+ const legacyStorage = factory.getInstance(legacySyncRules);
879
+ await using legacyWriter = await legacyStorage.createWriter(test_utils.BATCH_OPTIONS);
880
+ await legacyWriter.markAllSnapshotDone('1/1');
881
+ await legacyWriter.commit('1/1');
882
+
883
+ expect((await mongoFactory.db.sync_rules.findOne({ _id: legacySyncRules.id }))?.state).toBe(
884
+ storage.SyncRuleState.ACTIVE
885
+ );
886
+
887
+ const v3SyncRules = await factory.updateSyncRules(
888
+ updateSyncRulesFromYaml(MINIMAL_SYNC_RULES, { storageVersion: storage.STORAGE_VERSION_3 })
889
+ );
890
+ const v3Storage = factory.getInstance(v3SyncRules);
891
+ await using v3Writer = await v3Storage.createWriter(test_utils.BATCH_OPTIONS);
892
+ await v3Writer.markAllSnapshotDone('2/1');
893
+ await v3Writer.commit('2/1');
894
+
895
+ expect((await mongoFactory.db.sync_rules.findOne({ _id: legacySyncRules.id }))?.state).toBe(
896
+ storage.SyncRuleState.STOP
897
+ );
898
+ });
899
+
138
900
  for (const storageVersion of TEST_STORAGE_VERSIONS) {
139
901
  describe(`storage v${storageVersion}`, () => {
140
902
  registerSyncStorageTests(INITIALIZED_MONGO_STORAGE_FACTORY, storageVersion);