@powersync/service-module-mongodb-storage 0.17.0 → 0.18.1

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 (185) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/storage/MongoBucketStorage.d.ts +34 -16
  3. package/dist/storage/MongoBucketStorage.js +219 -70
  4. package/dist/storage/MongoBucketStorage.js.map +1 -1
  5. package/dist/storage/implementation/MongoBucketBatch.d.ts +42 -23
  6. package/dist/storage/implementation/MongoBucketBatch.js +61 -44
  7. package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
  8. package/dist/storage/implementation/MongoChecksums.d.ts +10 -9
  9. package/dist/storage/implementation/MongoChecksums.js +3 -111
  10. package/dist/storage/implementation/MongoChecksums.js.map +1 -1
  11. package/dist/storage/implementation/MongoCompactor.d.ts +2 -0
  12. package/dist/storage/implementation/MongoCompactor.js +18 -9
  13. package/dist/storage/implementation/MongoCompactor.js.map +1 -1
  14. package/dist/storage/implementation/MongoHydrationState.d.ts +11 -0
  15. package/dist/storage/implementation/MongoHydrationState.js +49 -0
  16. package/dist/storage/implementation/MongoHydrationState.js.map +1 -0
  17. package/dist/storage/implementation/MongoParameterCompactor.d.ts +6 -5
  18. package/dist/storage/implementation/MongoParameterCompactor.js +24 -1
  19. package/dist/storage/implementation/MongoParameterCompactor.js.map +1 -1
  20. package/dist/storage/implementation/MongoParsedSyncConfigSet.d.ts +13 -0
  21. package/dist/storage/implementation/MongoParsedSyncConfigSet.js +64 -0
  22. package/dist/storage/implementation/MongoParsedSyncConfigSet.js.map +1 -0
  23. package/dist/storage/implementation/MongoPersistedReplicationStream.d.ts +36 -0
  24. package/dist/storage/implementation/MongoPersistedReplicationStream.js +90 -0
  25. package/dist/storage/implementation/MongoPersistedReplicationStream.js.map +1 -0
  26. package/dist/storage/implementation/MongoPersistedSyncConfigContent.d.ts +25 -0
  27. package/dist/storage/implementation/MongoPersistedSyncConfigContent.js +123 -0
  28. package/dist/storage/implementation/MongoPersistedSyncConfigContent.js.map +1 -0
  29. package/dist/storage/implementation/MongoStorageProvider.js +5 -2
  30. package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
  31. package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +41 -23
  32. package/dist/storage/implementation/MongoSyncBucketStorage.js +51 -43
  33. package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
  34. package/dist/storage/implementation/MongoSyncRulesLock.d.ts +1 -1
  35. package/dist/storage/implementation/MongoSyncRulesLock.js +3 -3
  36. package/dist/storage/implementation/MongoSyncRulesLock.js.map +1 -1
  37. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.d.ts +6 -0
  38. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.js +2 -0
  39. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.js.map +1 -0
  40. package/dist/storage/implementation/common/PersistedBatch.d.ts +1 -2
  41. package/dist/storage/implementation/common/PersistedBatch.js +5 -0
  42. package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
  43. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js +1 -1
  44. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js.map +1 -1
  45. package/dist/storage/implementation/createMongoSyncBucketStorage.d.ts +2 -2
  46. package/dist/storage/implementation/createMongoSyncBucketStorage.js +4 -4
  47. package/dist/storage/implementation/createMongoSyncBucketStorage.js.map +1 -1
  48. package/dist/storage/implementation/db.js +9 -14
  49. package/dist/storage/implementation/db.js.map +1 -1
  50. package/dist/storage/implementation/models.d.ts +4 -13
  51. package/dist/storage/implementation/models.js.map +1 -1
  52. package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +9 -1
  53. package/dist/storage/implementation/v1/MongoBucketBatchV1.js +42 -26
  54. package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
  55. package/dist/storage/implementation/v1/MongoChecksumsV1.js +114 -3
  56. package/dist/storage/implementation/v1/MongoChecksumsV1.js.map +1 -1
  57. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.d.ts +15 -17
  58. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +47 -45
  59. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
  60. package/dist/storage/implementation/v1/VersionedPowerSyncMongoV1.d.ts +2 -1
  61. package/dist/storage/implementation/v1/VersionedPowerSyncMongoV1.js +3 -0
  62. package/dist/storage/implementation/v1/VersionedPowerSyncMongoV1.js.map +1 -1
  63. package/dist/storage/implementation/v1/models.d.ts +10 -1
  64. package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +8 -4
  65. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +286 -259
  66. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  67. package/dist/storage/implementation/v3/MongoChecksumsV3.d.ts +27 -7
  68. package/dist/storage/implementation/v3/MongoChecksumsV3.js +202 -18
  69. package/dist/storage/implementation/v3/MongoChecksumsV3.js.map +1 -1
  70. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +19 -2
  71. package/dist/storage/implementation/v3/MongoCompactorV3.js +511 -22
  72. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  73. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.d.ts +62 -0
  74. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js +350 -0
  75. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js.map +1 -0
  76. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.d.ts +40 -28
  77. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +232 -116
  78. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
  79. package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +1 -1
  80. package/dist/storage/implementation/v3/PersistedBatchV3.js +43 -20
  81. package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
  82. package/dist/storage/implementation/v3/SingleBucketStoreV3.js +52 -16
  83. package/dist/storage/implementation/v3/SingleBucketStoreV3.js.map +1 -1
  84. package/dist/storage/implementation/v3/SourceRecordStoreV3.d.ts +3 -3
  85. package/dist/storage/implementation/v3/SourceRecordStoreV3.js +13 -11
  86. package/dist/storage/implementation/v3/SourceRecordStoreV3.js.map +1 -1
  87. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.d.ts +13 -12
  88. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js +29 -27
  89. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js.map +1 -1
  90. package/dist/storage/implementation/v3/bucket-format.d.ts +4 -0
  91. package/dist/storage/implementation/v3/bucket-format.js +58 -0
  92. package/dist/storage/implementation/v3/bucket-format.js.map +1 -0
  93. package/dist/storage/implementation/v3/chunking.d.ts +10 -0
  94. package/dist/storage/implementation/v3/chunking.js +28 -0
  95. package/dist/storage/implementation/v3/chunking.js.map +1 -0
  96. package/dist/storage/implementation/v3/models.d.ts +64 -21
  97. package/dist/storage/implementation/v3/models.js +11 -28
  98. package/dist/storage/implementation/v3/models.js.map +1 -1
  99. package/dist/storage/implementation/v3/source-table-utils.d.ts +55 -0
  100. package/dist/storage/implementation/v3/source-table-utils.js +214 -0
  101. package/dist/storage/implementation/v3/source-table-utils.js.map +1 -0
  102. package/dist/storage/storage-index.d.ts +4 -4
  103. package/dist/storage/storage-index.js +4 -4
  104. package/dist/storage/storage-index.js.map +1 -1
  105. package/dist/types/types.d.ts +2 -2
  106. package/dist/utils/test-utils.d.ts +1 -2
  107. package/dist/utils/test-utils.js +5 -1
  108. package/dist/utils/test-utils.js.map +1 -1
  109. package/dist/utils/util.d.ts +1 -1
  110. package/dist/utils/util.js +3 -2
  111. package/dist/utils/util.js.map +1 -1
  112. package/package.json +9 -19
  113. package/src/storage/MongoBucketStorage.ts +334 -87
  114. package/src/storage/implementation/MongoBucketBatch.ts +86 -58
  115. package/src/storage/implementation/MongoChecksums.ts +6 -138
  116. package/src/storage/implementation/MongoCompactor.ts +19 -9
  117. package/src/storage/implementation/MongoHydrationState.ts +61 -0
  118. package/src/storage/implementation/MongoParameterCompactor.ts +24 -5
  119. package/src/storage/implementation/MongoParsedSyncConfigSet.ts +88 -0
  120. package/src/storage/implementation/MongoPersistedReplicationStream.ts +118 -0
  121. package/src/storage/implementation/MongoPersistedSyncConfigContent.ts +154 -0
  122. package/src/storage/implementation/MongoStorageProvider.ts +5 -2
  123. package/src/storage/implementation/MongoSyncBucketStorage.ts +70 -56
  124. package/src/storage/implementation/MongoSyncRulesLock.ts +7 -4
  125. package/src/storage/implementation/common/MongoSyncBucketStorageCheckpoint.ts +7 -0
  126. package/src/storage/implementation/common/PersistedBatch.ts +7 -3
  127. package/src/storage/implementation/common/VersionedPowerSyncMongoBase.ts +1 -1
  128. package/src/storage/implementation/createMongoSyncBucketStorage.ts +21 -7
  129. package/src/storage/implementation/db.ts +11 -19
  130. package/src/storage/implementation/models.ts +4 -14
  131. package/src/storage/implementation/v1/MongoBucketBatchV1.ts +55 -34
  132. package/src/storage/implementation/v1/MongoChecksumsV1.ts +137 -4
  133. package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +70 -64
  134. package/src/storage/implementation/v1/VersionedPowerSyncMongoV1.ts +11 -1
  135. package/src/storage/implementation/v1/models.ts +11 -1
  136. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +358 -314
  137. package/src/storage/implementation/v3/MongoChecksumsV3.ts +262 -26
  138. package/src/storage/implementation/v3/MongoCompactorV3.ts +646 -29
  139. package/src/storage/implementation/v3/MongoStoppedSyncConfigCleanup.ts +480 -0
  140. package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +315 -157
  141. package/src/storage/implementation/v3/PersistedBatchV3.ts +62 -36
  142. package/src/storage/implementation/v3/SingleBucketStoreV3.ts +59 -19
  143. package/src/storage/implementation/v3/SourceRecordStoreV3.ts +12 -12
  144. package/src/storage/implementation/v3/VersionedPowerSyncMongoV3.ts +41 -36
  145. package/src/storage/implementation/v3/bucket-format.ts +70 -0
  146. package/src/storage/implementation/v3/chunking.ts +38 -0
  147. package/src/storage/implementation/v3/models.ts +93 -55
  148. package/src/storage/implementation/v3/source-table-utils.ts +392 -0
  149. package/src/storage/storage-index.ts +12 -4
  150. package/src/utils/test-utils.ts +6 -3
  151. package/src/utils/util.ts +3 -2
  152. package/test/src/__snapshots__/storage.test.ts.snap +0 -75
  153. package/test/src/__snapshots__/storage_sync.test.ts.snap +48 -48
  154. package/test/src/cleanup-stopped-sync-configs.test.ts +648 -0
  155. package/test/src/storage.test.ts +11 -11
  156. package/test/src/storage_compacting.test.ts +1956 -6
  157. package/test/src/storage_sync.test.ts +1077 -68
  158. package/test/src/storeCurrentData.test.ts +17 -7
  159. package/test/src/util.ts +2 -1
  160. package/tsconfig.json +1 -4
  161. package/tsconfig.tsbuildinfo +1 -1
  162. package/dist/storage/implementation/BucketDefinitionMapping.d.ts +0 -15
  163. package/dist/storage/implementation/BucketDefinitionMapping.js +0 -58
  164. package/dist/storage/implementation/BucketDefinitionMapping.js.map +0 -1
  165. package/dist/storage/implementation/MongoPersistedSyncRules.d.ts +0 -14
  166. package/dist/storage/implementation/MongoPersistedSyncRules.js +0 -67
  167. package/dist/storage/implementation/MongoPersistedSyncRules.js.map +0 -1
  168. package/dist/storage/implementation/MongoPersistedSyncRulesContent.d.ts +0 -29
  169. package/dist/storage/implementation/MongoPersistedSyncRulesContent.js +0 -76
  170. package/dist/storage/implementation/MongoPersistedSyncRulesContent.js.map +0 -1
  171. package/dist/storage/implementation/common/MongoSyncBucketStorageContext.d.ts +0 -13
  172. package/dist/storage/implementation/common/MongoSyncBucketStorageContext.js +0 -2
  173. package/dist/storage/implementation/common/MongoSyncBucketStorageContext.js.map +0 -1
  174. package/dist/storage/implementation/v3/MongoParameterCompactorV3.d.ts +0 -9
  175. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js +0 -18
  176. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js.map +0 -1
  177. package/dist/storage/implementation/v3/MongoParameterLookupV3.d.ts +0 -4
  178. package/dist/storage/implementation/v3/MongoParameterLookupV3.js +0 -9
  179. package/dist/storage/implementation/v3/MongoParameterLookupV3.js.map +0 -1
  180. package/src/storage/implementation/BucketDefinitionMapping.ts +0 -75
  181. package/src/storage/implementation/MongoPersistedSyncRules.ts +0 -82
  182. package/src/storage/implementation/MongoPersistedSyncRulesContent.ts +0 -101
  183. package/src/storage/implementation/common/MongoSyncBucketStorageContext.ts +0 -15
  184. package/src/storage/implementation/v3/MongoParameterCompactorV3.ts +0 -24
  185. package/src/storage/implementation/v3/MongoParameterLookupV3.ts +0 -11
@@ -1,13 +1,24 @@
1
- import { deserializeParameterLookup, JwtPayload, storage, updateSyncRulesFromYaml } from '@powersync/service-core';
1
+ import { MongoSyncBucketStorageV3 } from '@module/storage/implementation/v3/MongoSyncBucketStorageV3.js';
2
+ import {
3
+ deserializeParameterLookup,
4
+ JwtPayload,
5
+ SingleSyncConfigBucketDefinitionMapping,
6
+ storage,
7
+ updateSyncRulesFromYaml
8
+ } from '@powersync/service-core';
2
9
  import { bucketRequest, register, test_utils } from '@powersync/service-core-tests';
3
- import { DEFAULT_HYDRATION_STATE, nodeSqlite, RequestParameters, SqlSyncRules } from '@powersync/service-sync-rules';
10
+ import { RequestParameters, ScopedParameterLookup, SqlSyncRules } from '@powersync/service-sync-rules';
4
11
  import * as bson from 'bson';
5
- import * as sqlite from 'node:sqlite';
6
12
  import { describe, expect, test } from 'vitest';
7
13
  import { MongoBucketStorage } from '../../src/storage/MongoBucketStorage.js';
14
+ import { MongoParsedSyncConfigSet } from '../../src/storage/implementation/MongoParsedSyncConfigSet.js';
15
+ import { MongoPersistedSyncConfigContentV3 } from '../../src/storage/implementation/MongoPersistedSyncConfigContent.js';
16
+ import { BucketDataDoc, BucketKey } from '../../src/storage/implementation/common/BucketDataDoc.js';
8
17
  import { MongoSyncBucketStorage } from '../../src/storage/implementation/createMongoSyncBucketStorage.js';
18
+ import { getMongoStorageConfig } from '../../src/storage/implementation/models.js';
9
19
  import { SourceRecordStoreV3 } from '../../src/storage/implementation/v3/SourceRecordStoreV3.js';
10
20
  import type { VersionedPowerSyncMongoV3 } from '../../src/storage/implementation/v3/VersionedPowerSyncMongoV3.js';
21
+ import { serializeBucketData } from '../../src/storage/implementation/v3/bucket-format.js';
11
22
  import {
12
23
  CurrentBucketV3,
13
24
  ReplicationStreamDocumentV3,
@@ -53,10 +64,54 @@ function objectIdGenerator(id: string) {
53
64
  };
54
65
  }
55
66
 
56
- function hydratedRulesFor(yaml: string) {
67
+ /**
68
+ * Build a standalone parsed sync config set for the given yaml, with its own mapping.
69
+ *
70
+ * This keeps the sync rules and the bucket definition mapping paired, as required by
71
+ * `ResolveTablesOptions.parsedSyncConfig`.
72
+ */
73
+ function parsedSyncConfigSetFor(yaml: string, storageVersion: number) {
57
74
  const parsed = SqlSyncRules.fromYaml(yaml, test_utils.PARSE_OPTIONS);
58
75
  expect(parsed.errors).toEqual([]);
59
- return parsed.config.hydrate({ hydrationState: DEFAULT_HYDRATION_STATE, sqlite: nodeSqlite(sqlite) });
76
+ return new MongoParsedSyncConfigSet(1, getMongoStorageConfig(storageVersion), 'test_slot', [
77
+ {
78
+ syncConfigId: new bson.ObjectId().toHexString(),
79
+ syncConfig: parsed,
80
+ mapping: SingleSyncConfigBucketDefinitionMapping.fromParsedSyncConfig(parsed)
81
+ }
82
+ ]);
83
+ }
84
+
85
+ async function getMongoSyncConfigContents(factory: storage.BucketStorageFactory, replicationStreamId: number) {
86
+ const mongoFactory = factory as MongoBucketStorage;
87
+ const doc = (await mongoFactory.db.sync_rules.findOne({
88
+ _id: replicationStreamId
89
+ })) as ReplicationStreamDocumentV3 | null;
90
+ if (doc == null) {
91
+ return [];
92
+ }
93
+
94
+ const db = mongoFactory.db.versioned(getMongoStorageConfig(doc.storage_version)) as VersionedPowerSyncMongoV3;
95
+ const syncConfigDocs = await db.syncConfigDefinitions
96
+ .find({
97
+ _id: { $in: doc.sync_configs.map((config) => config._id) }
98
+ })
99
+ .toArray();
100
+
101
+ return syncConfigDocs.map((config) => new MongoPersistedSyncConfigContentV3(mongoFactory.db, doc, config));
102
+ }
103
+
104
+ /**
105
+ * Get a MongoDB V3 storage bucket definition id, from a bucket name.
106
+ *
107
+ * This relies on internals of bucket naming, but helps for simplifying tests.
108
+ */
109
+ function bucketDefinitionId(bucket: string) {
110
+ const match = bucket.match(/^\w+\|\w+\.\w+\.(\w+)\[/);
111
+ if (match == null) {
112
+ throw new Error(`Expected versioned bucket name, got ${bucket}`);
113
+ }
114
+ return parseInt(match[1], 16);
60
115
  }
61
116
 
62
117
  function registerSyncStorageTests(storageConfig: storage.TestStorageConfig, storageVersion: number) {
@@ -82,6 +137,7 @@ function registerSyncStorageTests(storageConfig: storage.TestStorageConfig, stor
82
137
  )
83
138
  );
84
139
  const bucketStorage = factory.getInstance(syncRules);
140
+ const syncRulesContent = syncRules.syncConfigContent[0];
85
141
 
86
142
  await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
87
143
 
@@ -136,7 +192,7 @@ function registerSyncStorageTests(storageConfig: storage.TestStorageConfig, stor
136
192
 
137
193
  const options: storage.BucketDataBatchOptions = {};
138
194
  const batch1 = await test_utils.fromAsync(
139
- bucketStorage.getBucketDataBatch(checkpoint, [bucketRequest(syncRules, 'global[]', 0n)], options)
195
+ bucketStorage.getBucketDataBatch(checkpoint, [bucketRequest(syncRulesContent, 'global[]', 0n)], options)
140
196
  );
141
197
  expect(test_utils.getBatchData(batch1)).toEqual([
142
198
  { op_id: '1', op: 'PUT', object_id: 'test1', checksum: 2871785649 },
@@ -151,7 +207,7 @@ function registerSyncStorageTests(storageConfig: storage.TestStorageConfig, stor
151
207
  const batch2 = await test_utils.fromAsync(
152
208
  bucketStorage.getBucketDataBatch(
153
209
  checkpoint,
154
- [bucketRequest(syncRules, 'global[]', batch1[0].chunkData.next_after)],
210
+ [bucketRequest(syncRulesContent, 'global[]', batch1[0].chunkData.next_after)],
155
211
  options
156
212
  )
157
213
  );
@@ -167,7 +223,7 @@ function registerSyncStorageTests(storageConfig: storage.TestStorageConfig, stor
167
223
  const batch3 = await test_utils.fromAsync(
168
224
  bucketStorage.getBucketDataBatch(
169
225
  checkpoint,
170
- [bucketRequest(syncRules, 'global[]', batch2[0].chunkData.next_after)],
226
+ [bucketRequest(syncRulesContent, 'global[]', batch2[0].chunkData.next_after)],
171
227
  options
172
228
  )
173
229
  );
@@ -189,7 +245,10 @@ function registerSyncStorageTests(storageConfig: storage.TestStorageConfig, stor
189
245
  await Promise.all(
190
246
  (
191
247
  await mongoFactory.db.db
192
- .listCollections({ name: new RegExp(`^bucket_data_${syncRules.id}_`) }, { nameOnly: true })
248
+ .listCollections(
249
+ { name: new RegExp(`^bucket_data_${syncRules.replicationStreamId}_`) },
250
+ { nameOnly: true }
251
+ )
193
252
  .toArray()
194
253
  ).map((collection: { name: string }) =>
195
254
  mongoFactory.db.db
@@ -388,8 +447,8 @@ function registerSyncStorageTests(storageConfig: storage.TestStorageConfig, stor
388
447
  }
389
448
  );
390
449
 
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.
450
+ test.runIf(storageVersion >= 3)('resolveTables handles v3 source membership additions', async () => {
451
+ // Tests the behavior of resolveTables when bucket data sources and parameter index creators are added.
393
452
  // These are not end-to-end tests yet, since we don't have a full incremental reprocessing implementation.
394
453
  // This just tests the specific resolveTables behavior.
395
454
 
@@ -434,20 +493,19 @@ function registerSyncStorageTests(storageConfig: storage.TestStorageConfig, stor
434
493
  const syncRules = await factory.updateSyncRules(updateSyncRulesFromYaml(fullRulesYaml, { storageVersion }));
435
494
  const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
436
495
  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);
496
+ const fullRules = parsedSyncConfigSetFor(fullRulesYaml, storageVersion);
497
+ const dataOnlyRules = parsedSyncConfigSetFor(dataOnlyRulesYaml, storageVersion);
498
+ const parameterOnlyRules = parsedSyncConfigSetFor(parameterOnlyRulesYaml, storageVersion);
499
+ const eventOnlyRules = parsedSyncConfigSetFor(eventOnlyRulesYaml, storageVersion);
441
500
  const source = sourceDescriptor('memberships', { objectId: 'memberships-relation' });
442
501
  const dataOnlyTableId = new bson.ObjectId('6544e3899293153fa7b38348');
443
502
  const addedParameterTableId = new bson.ObjectId('6544e3899293153fa7b38349');
444
- const removedDataTableId = new bson.ObjectId('6544e3899293153fa7b3834a');
445
503
 
446
504
  const dataOnly = await writer.resolveTables({
447
505
  connection_id: 1,
448
506
  source,
449
507
  idGenerator: () => dataOnlyTableId,
450
- syncRules: dataOnlyRules
508
+ parsedSyncConfig: dataOnlyRules
451
509
  });
452
510
  expect(dataOnly.tables.map((table) => table.id)).toEqual([dataOnlyTableId]);
453
511
  expect(dataOnly.dropTables.map((table) => table.id)).toEqual([]);
@@ -458,7 +516,7 @@ function registerSyncStorageTests(storageConfig: storage.TestStorageConfig, stor
458
516
  connection_id: 1,
459
517
  source,
460
518
  idGenerator: () => addedParameterTableId,
461
- syncRules: fullRules
519
+ parsedSyncConfig: fullRules
462
520
  });
463
521
  // Adding a definition always creates a new SourceTable
464
522
  expect(addedParameter.tables.map((table) => table.id)).toEqual([dataOnlyTableId, addedParameterTableId]);
@@ -472,29 +530,29 @@ function registerSyncStorageTests(storageConfig: storage.TestStorageConfig, stor
472
530
  idGenerator: () => {
473
531
  throw new Error('data-only resolve should reuse existing v3 source table');
474
532
  },
475
- syncRules: dataOnlyRules
533
+ parsedSyncConfig: dataOnlyRules
476
534
  });
477
535
  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]);
536
+ // Same-identity tables that no longer cover desired memberships are left in place.
537
+ expect(removedParameter.dropTables.map((table) => table.id)).toEqual([]);
480
538
  expect(removedParameter.tables[0].bucketDataSources).toHaveLength(1);
481
539
  expect(removedParameter.tables[0].parameterLookupSources).toHaveLength(0);
482
- await writer.drop(removedParameter.dropTables);
483
540
 
484
541
  const removedData = await writer.resolveTables({
485
542
  connection_id: 1,
486
543
  source,
487
- idGenerator: () => removedDataTableId,
488
- syncRules: parameterOnlyRules
544
+ idGenerator: () => {
545
+ throw new Error('parameter-only resolve should reuse existing v3 source table');
546
+ },
547
+ parsedSyncConfig: parameterOnlyRules
489
548
  });
490
549
 
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]);
550
+ // This goes from dataOnlyRules -> parameterOnlyRules, which removes one definition but
551
+ // reuses the existing parameter-only source table.
552
+ expect(removedData.tables.map((table) => table.id)).toEqual([addedParameterTableId]);
553
+ expect(removedData.dropTables.map((table) => table.id)).toEqual([]);
495
554
  expect(removedData.tables[0].bucketDataSources).toHaveLength(0);
496
555
  expect(removedData.tables[0].parameterLookupSources).toHaveLength(1);
497
- await writer.drop(removedData.dropTables);
498
556
 
499
557
  const eventOnly = await writer.resolveTables({
500
558
  connection_id: 1,
@@ -502,11 +560,12 @@ function registerSyncStorageTests(storageConfig: storage.TestStorageConfig, stor
502
560
  idGenerator: () => {
503
561
  throw new Error('resolve should reuse existing v3 source table');
504
562
  },
505
- syncRules: eventOnlyRules
563
+ parsedSyncConfig: eventOnlyRules
506
564
  });
507
565
 
508
566
  // Event-only table can re-use any existing table.
509
- expect(eventOnly.tables.map((table) => table.id)).toEqual([removedDataTableId]);
567
+ expect(eventOnly.tables).toHaveLength(1);
568
+ expect([dataOnlyTableId.toString(), addedParameterTableId.toString()]).toContain(eventOnly.tables[0].id.toString());
510
569
  expect(eventOnly.dropTables.map((table) => table.id)).toEqual([]);
511
570
  expect(eventOnly.tables[0].bucketDataSources).toHaveLength(0);
512
571
  expect(eventOnly.tables[0].parameterLookupSources).toHaveLength(0);
@@ -515,6 +574,76 @@ function registerSyncStorageTests(storageConfig: storage.TestStorageConfig, stor
515
574
  expect(eventOnly.tables[0].syncEvent).toBe(true);
516
575
  });
517
576
 
577
+ test.runIf(storageVersion >= 3)(
578
+ 'resolveTables designates a single event carrier across split source tables',
579
+ async () => {
580
+ // When memberships are split over multiple SourceTables for the same ref, a row change
581
+ // is saved once per table. Only one table may fire events, otherwise the same event
582
+ // would fire once per table for every row change.
583
+ const dataOnlyEventYaml = `
584
+ bucket_definitions:
585
+ by_owner:
586
+ parameters:
587
+ - SELECT token_parameters.owner_id as owner_id
588
+ data:
589
+ - SELECT id, owner_id FROM memberships WHERE owner_id = bucket.owner_id
590
+
591
+ event_definitions:
592
+ write_checkpoints:
593
+ payloads:
594
+ - SELECT id, owner_id FROM memberships
595
+ `;
596
+ const fullEventYaml = `
597
+ bucket_definitions:
598
+ by_owner:
599
+ parameters:
600
+ - SELECT owner_id FROM memberships WHERE id = token_parameters.test_id
601
+ data:
602
+ - SELECT id, owner_id FROM memberships WHERE owner_id = bucket.owner_id
603
+
604
+ event_definitions:
605
+ write_checkpoints:
606
+ payloads:
607
+ - SELECT id, owner_id FROM memberships
608
+ `;
609
+
610
+ await using factory = await storageConfig.factory();
611
+ const syncRules = await factory.updateSyncRules(updateSyncRulesFromYaml(dataOnlyEventYaml, { storageVersion }));
612
+ const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
613
+ await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
614
+ const dataOnlyRules = parsedSyncConfigSetFor(dataOnlyEventYaml, storageVersion);
615
+ const fullRules = parsedSyncConfigSetFor(fullEventYaml, storageVersion);
616
+ const source = sourceDescriptor('memberships', { objectId: 'memberships-relation' });
617
+
618
+ const initial = await writer.resolveTables({
619
+ connection_id: 1,
620
+ source,
621
+ idGenerator: objectIdGenerator('6544e3899293153fa7b3834b'),
622
+ parsedSyncConfig: dataOnlyRules
623
+ });
624
+ expect(initial.tables).toHaveLength(1);
625
+ expect(initial.tables[0].syncEvent).toBe(true);
626
+
627
+ // Adding the table-based parameter lookup creates a second SourceTable for the same ref.
628
+ const split = await writer.resolveTables({
629
+ connection_id: 1,
630
+ source,
631
+ idGenerator: objectIdGenerator('6544e3899293153fa7b3834c'),
632
+ parsedSyncConfig: fullRules
633
+ });
634
+ expect(split.tables).toHaveLength(2);
635
+ // Both tables match the event by ref, but only one may carry it.
636
+ const carriers = split.tables.filter((table) => table.syncEvent);
637
+ expect(carriers).toHaveLength(1);
638
+
639
+ // getSourceTableStatus preserves the carrier designation rather than recomputing it
640
+ // from the ref, so refreshing a non-carrier table does not make it fire events.
641
+ const nonCarrier = split.tables.find((table) => !table.syncEvent)!;
642
+ const refreshed = await writer.getSourceTableStatus(nonCarrier);
643
+ expect(refreshed!.syncEvent).toBe(false);
644
+ }
645
+ );
646
+
518
647
  test.runIf(storageVersion >= 3)('uses v3 mongodb model shapes', async () => {
519
648
  await using factory = await storageConfig.factory();
520
649
  const syncRules = await factory.updateSyncRules(
@@ -531,7 +660,8 @@ function registerSyncStorageTests(storageConfig: storage.TestStorageConfig, stor
531
660
  )
532
661
  );
533
662
  const bucketStorage = factory.getInstance(syncRules);
534
- const sync_rules = syncRules.parsed(test_utils.PARSE_OPTIONS).hydratedSyncConfig();
663
+ const syncRulesContent = syncRules.syncConfigContent[0];
664
+ const sync_rules = syncRulesContent.parsed(test_utils.PARSE_OPTIONS).hydratedSyncConfig;
535
665
  await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
536
666
  const sourceTable = await test_utils.resolveTestTable(writer, 'test', ['id'], INITIALIZED_MONGO_STORAGE_FACTORY);
537
667
 
@@ -552,7 +682,7 @@ function registerSyncStorageTests(storageConfig: storage.TestStorageConfig, stor
552
682
  const parameters = new RequestParameters(new JwtPayload({ sub: 'u1', parameters: { test: 'shape-check' } }), {});
553
683
  const querier = sync_rules.getBucketParameterQuerier(test_utils.querierOptions(parameters)).querier;
554
684
  const buckets = await querier.queryDynamicBucketDescriptions({
555
- async getParameterSets(lookups) {
685
+ async getParameterSets(lookups: ScopedParameterLookup[]) {
556
686
  expect(lookups.map((l) => l.indexKey)).toEqual([['shape-check']]);
557
687
  expect(lookups[0].indexId).toEqual('1');
558
688
 
@@ -561,33 +691,635 @@ function registerSyncStorageTests(storageConfig: storage.TestStorageConfig, stor
561
691
  return parameter_sets;
562
692
  }
563
693
  });
564
- expect(buckets.map((b) => b.bucket)).toEqual([bucketRequest(syncRules, 'global["user-1"]').bucket]);
694
+ expect(buckets.map((b) => b.bucket)).toEqual([bucketRequest(syncRulesContent, 'global["user-1"]').bucket]);
565
695
 
566
696
  const mongoFactory = factory as MongoBucketStorage;
567
697
  const db = (bucketStorage as MongoSyncBucketStorage).db as VersionedPowerSyncMongoV3;
568
- const currentDataCollections = await db.listSourceRecordCollectionsV3(syncRules.id);
698
+ const currentDataCollections = await db.listSourceRecordCollections(syncRules.replicationStreamId);
569
699
  const currentData = await currentDataCollections[0]?.findOne({});
570
700
  const firstBucket: CurrentBucketV3 | undefined = currentData?.buckets[0] as CurrentBucketV3 | undefined;
571
701
  expect(firstBucket?.def).toMatch(/^[0-9a-f]+$/);
572
702
 
573
703
  const bucketCollections = await mongoFactory.db.db
574
- .listCollections({ name: new RegExp(`^bucket_data_${syncRules.id}_`) }, { nameOnly: true })
704
+ .listCollections({ name: new RegExp(`^bucket_data_${syncRules.replicationStreamId}_`) }, { nameOnly: true })
575
705
  .toArray();
576
706
  expect(
577
- bucketCollections.some((collection) => collection.name === `bucket_data_${syncRules.id}_${firstBucket?.def}`)
707
+ bucketCollections.some(
708
+ (collection) => collection.name === `bucket_data_${syncRules.replicationStreamId}_${firstBucket?.def}`
709
+ )
578
710
  ).toBe(true);
579
711
 
580
- const syncRule = (await mongoFactory.db.sync_rules.findOne({ _id: syncRules.id })) as ReplicationStreamDocumentV3;
712
+ const syncRule = (await mongoFactory.db.sync_rules.findOne({
713
+ _id: syncRules.replicationStreamId
714
+ })) as ReplicationStreamDocumentV3;
581
715
  const syncConfig = await db.syncConfigDefinitions.findOne({ _id: syncRule.sync_configs[0]._id });
582
716
  const ruleMapping: SyncConfigDefinition['rule_mapping'] | undefined = syncConfig?.rule_mapping;
583
717
  expect(Object.keys(ruleMapping?.definitions ?? {})).not.toHaveLength(0);
584
718
 
585
719
  const parameterIndexId = Object.values(ruleMapping?.parameter_indexes ?? {})[0] as string | undefined;
586
720
  expect(parameterIndexId).toBeDefined();
587
- const parameterEntry = await db.parameterIndexV3(syncRules.id, parameterIndexId!).findOne({});
721
+ const parameterEntry = await db.parameterIndex(syncRules.replicationStreamId, parameterIndexId!).findOne({});
588
722
  expect(deserializeParameterLookup(parameterEntry!.lookup)).toEqual(['shape-check']);
589
723
  });
590
724
 
725
+ test.runIf(storageVersion >= 3)('replaces an existing deploying sync config', async () => {
726
+ await using factory = await storageConfig.factory();
727
+
728
+ const first = await factory.updateSyncRules(
729
+ updateSyncRulesFromYaml(
730
+ `
731
+ config:
732
+ edition: 2
733
+
734
+ streams:
735
+ by_owner:
736
+ query: SELECT * FROM todos WHERE owner_id = subscription.parameter('owner_id')
737
+ `,
738
+ { storageVersion }
739
+ )
740
+ );
741
+ const second = await factory.updateSyncRules(
742
+ updateSyncRulesFromYaml(
743
+ `
744
+ config:
745
+ edition: 2
746
+
747
+ streams:
748
+ by_project:
749
+ query: SELECT * FROM todos WHERE project_id = subscription.parameter('project_id')
750
+ `,
751
+ { storageVersion }
752
+ )
753
+ );
754
+
755
+ expect(second.replicationStreamId).not.toEqual(first.replicationStreamId);
756
+ expect(
757
+ (await factory.getStoppedReplicationStreams()).find(
758
+ (stream) => stream.replicationStreamId == first.replicationStreamId
759
+ )?.state
760
+ ).toBe(storage.SyncRuleState.STOP);
761
+
762
+ const replicatingStreams = await factory.getReplicatingReplicationStreams();
763
+ expect(replicatingStreams).toHaveLength(1);
764
+ expect(replicatingStreams[0].replicationStreamId).toEqual(second.replicationStreamId);
765
+
766
+ const configs = second.syncConfigContent;
767
+ expect(configs).toHaveLength(1);
768
+ const statuses = await Promise.all(configs.map((config) => config.getSyncConfigStatus()));
769
+ expect(statuses.map((status) => status?.state)).toEqual([storage.SyncRuleState.PROCESSING]);
770
+ expect(statuses.map((status) => status?.id)).toEqual(configs.map((config) => config.syncConfigId));
771
+ const parsed = replicatingStreams[0].parsed(test_utils.PARSE_OPTIONS);
772
+ expect(parsed.syncConfigs).toHaveLength(1);
773
+ expect(parsed.hydratedSyncConfig.bucketDataSources).toHaveLength(1);
774
+
775
+ const bucketStorage = factory.getInstance(replicatingStreams[0]);
776
+ await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
777
+ const resolved = await writer.resolveTables({
778
+ connection_id: 1,
779
+ source: sourceDescriptor('todos', { objectId: 'todos-relation' }),
780
+ idGenerator: objectIdGenerator('6544e3899293153fa7b3834b')
781
+ });
782
+ expect(resolved.tables).toHaveLength(1);
783
+ expect(resolved.tables[0].bucketDataSources).toHaveLength(1);
784
+ });
785
+
786
+ test.runIf(storageVersion >= 3)('removing one config keeps shared source-table membership', async () => {
787
+ await using factory = await storageConfig.factory();
788
+
789
+ const first = await factory.updateSyncRules(
790
+ updateSyncRulesFromYaml(
791
+ `
792
+ config:
793
+ edition: 3
794
+
795
+ streams:
796
+ by_owner:
797
+ query: SELECT * FROM todos WHERE owner_id = subscription.parameter('owner_id')
798
+ `,
799
+ { storageVersion }
800
+ )
801
+ );
802
+ const firstStorage = factory.getInstance(first) as MongoSyncBucketStorage;
803
+ await using firstWriter = await firstStorage.createWriter(test_utils.BATCH_OPTIONS);
804
+ await firstWriter.markAllSnapshotDone('1/1');
805
+ await firstWriter.commit('1/1');
806
+
807
+ const second = await factory.updateSyncRules(
808
+ updateSyncRulesFromYaml(
809
+ `
810
+ config:
811
+ edition: 3
812
+
813
+ streams:
814
+ by_project:
815
+ query: SELECT * FROM todos WHERE project_id = subscription.parameter('project_id')
816
+ `,
817
+ { storageVersion }
818
+ )
819
+ );
820
+ expect(second.replicationStreamId).toEqual(first.replicationStreamId);
821
+
822
+ const bucketStorage = factory.getInstance(second);
823
+ await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
824
+ const source = sourceDescriptor('todos', { objectId: 'todos-relation' });
825
+
826
+ const resolved = await writer.resolveTables({
827
+ connection_id: 1,
828
+ source,
829
+ idGenerator: objectIdGenerator('6544e3899293153fa7b3834c')
830
+ });
831
+ expect(resolved.tables).toHaveLength(1);
832
+ expect(resolved.tables[0].bucketDataSources).toHaveLength(2);
833
+
834
+ await writer.markAllSnapshotDone('2/1');
835
+ await writer.commit('2/1');
836
+
837
+ const activeStorage = (await factory.getActiveSyncConfig())?.storage as MongoSyncBucketStorage;
838
+ await using activeWriter = await activeStorage.createWriter(test_utils.BATCH_OPTIONS);
839
+ const activeStatus = await activeWriter.getSourceTableStatus(resolved.tables[0]);
840
+ expect(activeStatus?.bucketDataSources).toHaveLength(1);
841
+ });
842
+
843
+ test.runIf(storageVersion >= 3)(
844
+ 'editing a stream query on the same table does not drop the active source table',
845
+ async () => {
846
+ await using factory = await storageConfig.factory();
847
+
848
+ const first = await factory.updateSyncRules(
849
+ updateSyncRulesFromYaml(
850
+ `
851
+ config:
852
+ edition: 3
853
+
854
+ streams:
855
+ todos:
856
+ query: SELECT * FROM todos WHERE owner_id = subscription.parameter('owner_id')
857
+ `,
858
+ { storageVersion }
859
+ )
860
+ );
861
+ const firstStorage = factory.getInstance(first) as MongoSyncBucketStorage;
862
+ await using firstWriter = await firstStorage.createWriter(test_utils.BATCH_OPTIONS);
863
+ const source = sourceDescriptor('todos', { objectId: 'todos-relation' });
864
+ const firstResolved = await firstWriter.resolveTables({
865
+ connection_id: 1,
866
+ source,
867
+ idGenerator: objectIdGenerator('6544e3899293153fa7b3834d')
868
+ });
869
+ expect(firstResolved.dropTables).toHaveLength(0);
870
+ await firstWriter.markAllSnapshotDone('1/1');
871
+ await firstWriter.commit('1/1');
872
+
873
+ const second = await factory.updateSyncRules(
874
+ updateSyncRulesFromYaml(
875
+ `
876
+ config:
877
+ edition: 3
878
+
879
+ streams:
880
+ todos:
881
+ query: SELECT * FROM todos WHERE project_id = subscription.parameter('project_id')
882
+ `,
883
+ { storageVersion }
884
+ )
885
+ );
886
+ expect(second.replicationStreamId).toBe(first.replicationStreamId);
887
+
888
+ const deploying = await factory.getDeployingSyncConfig();
889
+ expect(deploying?.replicationStream.replicationStreamId).toBe(first.replicationStreamId);
890
+ const bucketStorage = deploying!.storage;
891
+ await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
892
+ const resolved = await writer.resolveTables({
893
+ connection_id: 1,
894
+ source,
895
+ idGenerator: objectIdGenerator('6544e3899293153fa7b3834e')
896
+ });
897
+
898
+ expect(resolved.dropTables).toHaveLength(0);
899
+ expect(resolved.tables.map((table) => table.id.toString())).toEqual(['6544e3899293153fa7b3834e']);
900
+ expect(resolved.tables[0].bucketDataSources).toHaveLength(1);
901
+
902
+ const activeStatus = await firstWriter.getSourceTableStatus(firstResolved.tables[0]);
903
+ expect(activeStatus?.bucketDataSources).toHaveLength(1);
904
+ }
905
+ );
906
+
907
+ test.runIf(storageVersion >= 3)('reserves historical mapping ids without reusing stopped configs', async () => {
908
+ await using factory = await storageConfig.factory();
909
+
910
+ const ownerRules = `
911
+ config:
912
+ edition: 3
913
+
914
+ streams:
915
+ by_owner:
916
+ query: SELECT * FROM todos WHERE owner_id = subscription.parameter('owner_id')
917
+ `;
918
+ const projectRules = `
919
+ config:
920
+ edition: 3
921
+
922
+ streams:
923
+ by_project:
924
+ query: SELECT * FROM todos WHERE project_id = subscription.parameter('project_id')
925
+ `;
926
+ const statusRules = `
927
+ config:
928
+ edition: 3
929
+
930
+ streams:
931
+ by_status:
932
+ query: SELECT * FROM todos WHERE status = subscription.parameter('status')
933
+ `;
934
+
935
+ const first = await factory.updateSyncRules(updateSyncRulesFromYaml(ownerRules, { storageVersion }));
936
+ const firstStorage = factory.getInstance(first);
937
+ await using firstWriter = await firstStorage.createWriter(test_utils.BATCH_OPTIONS);
938
+ await firstWriter.markAllSnapshotDone('1/1');
939
+ await firstWriter.commit('1/1');
940
+
941
+ const second = await factory.updateSyncRules(updateSyncRulesFromYaml(projectRules, { storageVersion }));
942
+ expect(second.replicationStreamId).toBe(first.replicationStreamId);
943
+
944
+ const third = await factory.updateSyncRules(updateSyncRulesFromYaml(statusRules, { storageVersion }));
945
+ expect(third.replicationStreamId).toBe(first.replicationStreamId);
946
+
947
+ const configs = await getMongoSyncConfigContents(factory, first.replicationStreamId);
948
+ const statuses = await Promise.all(
949
+ configs.map(async (config) => [config, await config.getSyncConfigStatus()] as const)
950
+ );
951
+ // ownerRules
952
+ const activeConfig = statuses.find(([, status]) => status?.state == storage.SyncRuleState.ACTIVE)?.[0];
953
+ // projectRules
954
+ const stoppedConfig = statuses.find(([, status]) => status?.state == storage.SyncRuleState.STOP)?.[0];
955
+ // statusRules
956
+ const processingConfig = statuses.find(([, status]) => status?.state == storage.SyncRuleState.PROCESSING)?.[0];
957
+ expect(stoppedConfig).toBeDefined();
958
+ expect(activeConfig).toBeDefined();
959
+ expect(processingConfig).toBeDefined();
960
+
961
+ const activePrefix = bucketRequest(activeConfig!, 'by_owner|0["owner"]').bucket;
962
+ const stoppedPrefix = bucketRequest(stoppedConfig!, 'by_project|0["project"]').bucket;
963
+ const processingPrefix = bucketRequest(processingConfig!, 'by_status|0["status"]').bucket;
964
+
965
+ expect(processingPrefix).not.toBe(stoppedPrefix);
966
+ expect(processingPrefix).not.toBe(activePrefix);
967
+ expect(bucketDefinitionId(processingPrefix)).toBeGreaterThan(
968
+ Math.max(bucketDefinitionId(stoppedPrefix), bucketDefinitionId(activePrefix))
969
+ );
970
+ });
971
+
972
+ test.runIf(storageVersion >= 3)('table snapshot status only affects sync configs using that table', async () => {
973
+ await using factory = await storageConfig.factory();
974
+
975
+ const first = await factory.updateSyncRules(
976
+ updateSyncRulesFromYaml(
977
+ `
978
+ config:
979
+ edition: 3
980
+
981
+ streams:
982
+ by_owner:
983
+ query: SELECT * FROM todos WHERE owner_id = subscription.parameter('owner_id')
984
+ `,
985
+ { storageVersion }
986
+ )
987
+ );
988
+ const firstStorage = factory.getInstance(first) as MongoSyncBucketStorage;
989
+ await using firstWriter = await firstStorage.createWriter(test_utils.BATCH_OPTIONS);
990
+ await firstWriter.markAllSnapshotDone('1/1');
991
+ await firstWriter.commit('1/1');
992
+
993
+ const second = await factory.updateSyncRules(
994
+ updateSyncRulesFromYaml(
995
+ `
996
+ config:
997
+ edition: 3
998
+
999
+ streams:
1000
+ by_owner:
1001
+ query: SELECT * FROM todos WHERE owner_id = subscription.parameter('owner_id')
1002
+ by_project:
1003
+ query: SELECT * FROM projects WHERE id = subscription.parameter('project_id')
1004
+ `,
1005
+ { storageVersion }
1006
+ )
1007
+ );
1008
+ expect(second.replicationStreamId).toBe(first.replicationStreamId);
1009
+
1010
+ const bucketStorage = factory.getInstance(second);
1011
+ await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
1012
+ const resolved = await writer.resolveTables({
1013
+ connection_id: 1,
1014
+ source: sourceDescriptor('projects', { objectId: 'projects-relation' }),
1015
+ idGenerator: objectIdGenerator('6544e3899293153fa7b3834d')
1016
+ });
1017
+ expect(resolved.tables).toHaveLength(1);
1018
+
1019
+ await writer.markAllSnapshotDone('2/1');
1020
+ await writer.markTableSnapshotRequired(resolved.tables[0]);
1021
+
1022
+ const configsAfterRequired = await getMongoSyncConfigContents(factory, first.replicationStreamId);
1023
+ const statusesAfterRequired = await Promise.all(configsAfterRequired.map((config) => config.getSyncConfigStatus()));
1024
+ const ownerConfigAfterRequired = statusesAfterRequired.find(
1025
+ (status) => status?.state == storage.SyncRuleState.ACTIVE
1026
+ );
1027
+ const projectConfigAfterRequired = statusesAfterRequired.find(
1028
+ (status) => status?.state == storage.SyncRuleState.PROCESSING
1029
+ );
1030
+ expect(ownerConfigAfterRequired?.snapshot_done).toBe(true);
1031
+ expect(projectConfigAfterRequired?.snapshot_done).toBe(false);
1032
+
1033
+ await writer.markTableSnapshotDone(resolved.tables, '3/1');
1034
+
1035
+ const configsAfterDone = await getMongoSyncConfigContents(factory, first.replicationStreamId);
1036
+ const statusesAfterDone = await Promise.all(configsAfterDone.map((config) => config.getSyncConfigStatus()));
1037
+ const ownerConfigAfterDone = statusesAfterDone.find((status) => status?.id == ownerConfigAfterRequired!.id);
1038
+ const projectConfigAfterDone = statusesAfterDone.find((status) => status?.id == projectConfigAfterRequired!.id);
1039
+ expect(ownerConfigAfterDone?.snapshot_done).toBe(true);
1040
+ expect(projectConfigAfterDone?.snapshot_done).toBe(false);
1041
+ });
1042
+
1043
+ test.runIf(storageVersion >= 3)(
1044
+ 'keeps compatible active and deploying sync configs in one replication stream',
1045
+ async () => {
1046
+ await using factory = await storageConfig.factory();
1047
+
1048
+ const first = await factory.updateSyncRules(
1049
+ updateSyncRulesFromYaml(
1050
+ `
1051
+ config:
1052
+ edition: 3
1053
+
1054
+ streams:
1055
+ by_owner:
1056
+ query: SELECT * FROM todos WHERE owner_id = subscription.parameter('owner_id')
1057
+ `,
1058
+ { storageVersion }
1059
+ )
1060
+ );
1061
+ const firstStorage = factory.getInstance(first) as MongoSyncBucketStorage;
1062
+ await using firstWriter = await firstStorage.createWriter(test_utils.BATCH_OPTIONS);
1063
+ await firstWriter.markAllSnapshotDone('1/1');
1064
+ await firstWriter.commit('1/1');
1065
+ const initialActiveStorage = (await factory.getActiveSyncConfig())?.storage as MongoSyncBucketStorage;
1066
+
1067
+ let configs = first.syncConfigContent;
1068
+ const firstConfigId = configs[0].syncConfigId;
1069
+ expect((await factory.getActiveSyncConfig())?.content.syncConfigId).toBe(firstConfigId);
1070
+
1071
+ const second = await factory.updateSyncRules(
1072
+ updateSyncRulesFromYaml(
1073
+ `
1074
+ config:
1075
+ edition: 3
1076
+
1077
+ streams:
1078
+ by_project:
1079
+ query: SELECT * FROM todos WHERE project_id = subscription.parameter('project_id')
1080
+ `,
1081
+ { storageVersion }
1082
+ )
1083
+ );
1084
+ expect(second.replicationStreamId).toEqual(first.replicationStreamId);
1085
+
1086
+ configs = await getMongoSyncConfigContents(factory, first.replicationStreamId);
1087
+ const deploying = await factory.getDeployingSyncConfig();
1088
+ expect(configs).toHaveLength(2);
1089
+ expect(deploying).not.toBeNull();
1090
+ expect((await factory.getActiveSyncConfig())?.content.syncConfigId).toBe(firstConfigId);
1091
+
1092
+ const stream = (await factory.getActiveSyncConfig())?.replicationStream;
1093
+ expect(stream?.state).toBe(storage.SyncRuleState.ACTIVE);
1094
+ expect(
1095
+ (await Promise.all(configs.map((config) => config.getSyncConfigStatus()))).map((status) => status?.state).sort()
1096
+ ).toEqual([storage.SyncRuleState.ACTIVE, storage.SyncRuleState.PROCESSING]);
1097
+
1098
+ const replicatingStreams = await factory.getReplicatingReplicationStreams();
1099
+ expect(replicatingStreams).toHaveLength(1);
1100
+ for (const config of configs) {
1101
+ expect(replicatingStreams[0].replicationJobId).toContain(config.syncConfigId);
1102
+ }
1103
+
1104
+ const secondStorage = factory.getInstance(replicatingStreams[0]) as MongoSyncBucketStorage;
1105
+ await using secondWriter = await secondStorage.createWriter(test_utils.BATCH_OPTIONS);
1106
+ await secondWriter.markAllSnapshotDone('2/1');
1107
+ await secondWriter.commit('2/1');
1108
+
1109
+ const updatedStream = (await factory.getActiveSyncConfig())?.replicationStream;
1110
+ expect(updatedStream?.state).toBe(storage.SyncRuleState.ACTIVE);
1111
+ const updatedConfigs = await getMongoSyncConfigContents(factory, first.replicationStreamId);
1112
+ expect(
1113
+ (await Promise.all(updatedConfigs.map((config) => config.getSyncConfigStatus())))
1114
+ .map((status) => status?.state)
1115
+ .sort()
1116
+ ).toEqual([storage.SyncRuleState.ACTIVE, storage.SyncRuleState.STOP]);
1117
+ expect(await factory.getDeployingSyncConfig()).toBeNull();
1118
+ expect((await factory.getActiveSyncConfig())?.content.syncConfigId).not.toBe(firstConfigId);
1119
+ const updatedActiveStorage = (await factory.getActiveSyncConfig())?.storage as MongoSyncBucketStorage;
1120
+ expect(updatedActiveStorage.replicationStream.replicationJobId).not.toBe(
1121
+ initialActiveStorage.replicationStream.replicationJobId
1122
+ );
1123
+ expect(updatedActiveStorage.replicationStream.replicationJobId).toContain(
1124
+ (await factory.getActiveSyncConfig())!.content.syncConfigId
1125
+ );
1126
+ }
1127
+ );
1128
+
1129
+ test.runIf(storageVersion >= 3)(
1130
+ 'appended compatible config adopts the stream checkpoint instead of regressing to 0',
1131
+ async () => {
1132
+ await using factory = await storageConfig.factory();
1133
+
1134
+ // First config replicates some data, advancing the stream-level op head and its checkpoint.
1135
+ const first = await factory.updateSyncRules(
1136
+ updateSyncRulesFromYaml(
1137
+ `
1138
+ config:
1139
+ edition: 3
1140
+
1141
+ streams:
1142
+ by_owner:
1143
+ query: SELECT * FROM todos WHERE owner_id = subscription.parameter('owner_id')
1144
+ `,
1145
+ { storageVersion }
1146
+ )
1147
+ );
1148
+ const firstStorage = factory.getInstance(first) as MongoSyncBucketStorage;
1149
+ await using firstWriter = await firstStorage.createWriter(test_utils.BATCH_OPTIONS);
1150
+ const sourceTable = await test_utils.resolveTestTable(
1151
+ firstWriter,
1152
+ 'todos',
1153
+ ['id'],
1154
+ INITIALIZED_MONGO_STORAGE_FACTORY
1155
+ );
1156
+ await firstWriter.save({
1157
+ sourceTable,
1158
+ tag: storage.SaveOperationTag.INSERT,
1159
+ after: {
1160
+ id: 'todo-1',
1161
+ owner_id: 'user-1',
1162
+ project_id: 'project-1'
1163
+ },
1164
+ afterReplicaId: test_utils.rid('todo-1')
1165
+ });
1166
+ await firstWriter.markAllSnapshotDone('1/1');
1167
+ await firstWriter.commit('1/1');
1168
+
1169
+ const firstCheckpoint = (await firstStorage.getCheckpoint()).checkpoint;
1170
+ expect(firstCheckpoint).toBeGreaterThan(0n);
1171
+
1172
+ const mongoFactory = factory as MongoBucketStorage;
1173
+ const streamDocBefore = (await mongoFactory.db.sync_rules.findOne({
1174
+ _id: first.replicationStreamId
1175
+ })) as ReplicationStreamDocumentV3;
1176
+ // The stream-level head was advanced durably to (at least) the first config's checkpoint.
1177
+ expect(streamDocBefore.last_persisted_op).not.toBeNull();
1178
+ expect(BigInt(streamDocBefore.last_persisted_op!)).toBeGreaterThanOrEqual(firstCheckpoint);
1179
+
1180
+ // Append a compatible second config that replicates nothing new.
1181
+ const second = await factory.updateSyncRules(
1182
+ updateSyncRulesFromYaml(
1183
+ `
1184
+ config:
1185
+ edition: 3
1186
+
1187
+ streams:
1188
+ by_project:
1189
+ query: SELECT * FROM todos WHERE project_id = subscription.parameter('project_id')
1190
+ `,
1191
+ { storageVersion }
1192
+ )
1193
+ );
1194
+ expect(second.replicationStreamId).toEqual(first.replicationStreamId);
1195
+
1196
+ const replicatingStreams = await factory.getReplicatingReplicationStreams();
1197
+ expect(replicatingStreams).toHaveLength(1);
1198
+ const secondStorage = factory.getInstance(replicatingStreams[0]) as MongoSyncBucketStorage;
1199
+ await using secondWriter = await secondStorage.createWriter(test_utils.BATCH_OPTIONS);
1200
+ // No new data replicated - just complete the snapshot and commit.
1201
+ await secondWriter.markAllSnapshotDone('2/1');
1202
+ await secondWriter.commit('2/1');
1203
+
1204
+ // The appended config's checkpoint must adopt the stream head, not regress to 0.
1205
+ const streamDocAfter = (await mongoFactory.db.sync_rules.findOne({
1206
+ _id: first.replicationStreamId
1207
+ })) as ReplicationStreamDocumentV3;
1208
+ const head = BigInt(streamDocAfter.last_persisted_op!);
1209
+ for (const config of streamDocAfter.sync_configs) {
1210
+ expect(config.last_checkpoint).not.toBeNull();
1211
+ expect(BigInt(config.last_checkpoint!)).toEqual(head);
1212
+ }
1213
+ // All configs share the same checkpoint, equal to the stream head.
1214
+ expect(head).toBeGreaterThanOrEqual(firstCheckpoint);
1215
+
1216
+ // After activation, the active config's checkpoint does not regress.
1217
+ const activeStorage = (await factory.getActiveSyncConfig())?.storage as MongoSyncBucketStorage;
1218
+ const activeCheckpoint = (await activeStorage.getCheckpoint()).checkpoint;
1219
+ expect(activeCheckpoint).toBeGreaterThanOrEqual(firstCheckpoint);
1220
+ }
1221
+ );
1222
+
1223
+ test.runIf(storageVersion >= 3)('creates a new replication stream when compatibility options differ', async () => {
1224
+ await using factory = await storageConfig.factory();
1225
+
1226
+ const first = await factory.updateSyncRules(
1227
+ updateSyncRulesFromYaml(
1228
+ `
1229
+ config:
1230
+ edition: 3
1231
+
1232
+ streams:
1233
+ by_owner:
1234
+ query: SELECT * FROM todos WHERE owner_id = subscription.parameter('owner_id')
1235
+ `,
1236
+ { storageVersion }
1237
+ )
1238
+ );
1239
+ const firstStorage = factory.getInstance(first) as MongoSyncBucketStorage;
1240
+ await using firstWriter = await firstStorage.createWriter(test_utils.BATCH_OPTIONS);
1241
+ await firstWriter.markAllSnapshotDone('1/1');
1242
+ await firstWriter.commit('1/1');
1243
+
1244
+ // Same streams, but a different compatibility edition - must not append to the active stream.
1245
+ const second = await factory.updateSyncRules(
1246
+ updateSyncRulesFromYaml(
1247
+ `
1248
+ config:
1249
+ edition: 2
1250
+
1251
+ streams:
1252
+ by_owner:
1253
+ query: SELECT * FROM todos WHERE owner_id = subscription.parameter('owner_id')
1254
+ `,
1255
+ { storageVersion }
1256
+ )
1257
+ );
1258
+ expect(second.replicationStreamId).not.toEqual(first.replicationStreamId);
1259
+
1260
+ // The first stream stays active until the replacement has replicated.
1261
+ const active = await factory.getActiveSyncConfig();
1262
+ expect(active?.replicationStream.replicationStreamId).toEqual(first.replicationStreamId);
1263
+ expect((await factory.getDeployingSyncConfig())?.replicationStream.replicationStreamId).toEqual(
1264
+ second.replicationStreamId
1265
+ );
1266
+ });
1267
+
1268
+ test.runIf(storageVersion >= 3)(
1269
+ 'creates a new replication stream for legacy sync rules without a serialized plan',
1270
+ async () => {
1271
+ await using factory = await storageConfig.factory();
1272
+
1273
+ const first = await factory.updateSyncRules(
1274
+ updateSyncRulesFromYaml(
1275
+ `
1276
+ config:
1277
+ edition: 3
1278
+
1279
+ streams:
1280
+ by_owner:
1281
+ query: SELECT * FROM todos WHERE owner_id = subscription.parameter('owner_id')
1282
+ `,
1283
+ { storageVersion }
1284
+ )
1285
+ );
1286
+ const firstStorage = factory.getInstance(first) as MongoSyncBucketStorage;
1287
+ await using firstWriter = await firstStorage.createWriter(test_utils.BATCH_OPTIONS);
1288
+ await firstWriter.markAllSnapshotDone('1/1');
1289
+ await firstWriter.commit('1/1');
1290
+
1291
+ // Legacy sync rules have no serialized plan - must not append to the active stream.
1292
+ const second = await factory.updateSyncRules(updateSyncRulesFromYaml(MINIMAL_SYNC_RULES, { storageVersion }));
1293
+ expect(second.replicationStreamId).not.toEqual(first.replicationStreamId);
1294
+ }
1295
+ );
1296
+
1297
+ test.runIf(storageVersion >= 3)('does not append to an active legacy sync config', async () => {
1298
+ await using factory = await storageConfig.factory();
1299
+
1300
+ const first = await factory.updateSyncRules(updateSyncRulesFromYaml(MINIMAL_SYNC_RULES, { storageVersion }));
1301
+ const firstStorage = factory.getInstance(first) as MongoSyncBucketStorage;
1302
+ await using firstWriter = await firstStorage.createWriter(test_utils.BATCH_OPTIONS);
1303
+ await firstWriter.markAllSnapshotDone('1/1');
1304
+ await firstWriter.commit('1/1');
1305
+
1306
+ // The active config has no serialized plan, so a new streams config must not be appended to it.
1307
+ const second = await factory.updateSyncRules(
1308
+ updateSyncRulesFromYaml(
1309
+ `
1310
+ config:
1311
+ edition: 3
1312
+
1313
+ streams:
1314
+ by_owner:
1315
+ query: SELECT * FROM todos WHERE owner_id = subscription.parameter('owner_id')
1316
+ `,
1317
+ { storageVersion }
1318
+ )
1319
+ );
1320
+ expect(second.replicationStreamId).not.toEqual(first.replicationStreamId);
1321
+ });
1322
+
591
1323
  test.runIf(storageVersion < 3)('can replace processing legacy sync rules', async () => {
592
1324
  await using factory = await storageConfig.factory();
593
1325
 
@@ -600,7 +1332,7 @@ function registerSyncStorageTests(storageConfig: storage.TestStorageConfig, stor
600
1332
  ).resolves.toBeDefined();
601
1333
 
602
1334
  const mongoFactory = factory as MongoBucketStorage;
603
- expect((await mongoFactory.db.sync_rules.findOne({ _id: firstSyncRules.id }))?.state).toBe(
1335
+ expect((await mongoFactory.db.sync_rules.findOne({ _id: firstSyncRules.replicationStreamId }))?.state).toBe(
604
1336
  storage.SyncRuleState.STOP
605
1337
  );
606
1338
  });
@@ -612,10 +1344,48 @@ function registerSyncStorageTests(storageConfig: storage.TestStorageConfig, stor
612
1344
  updateSyncRulesFromYaml(MINIMAL_SYNC_RULES, { storageVersion, lock: true })
613
1345
  );
614
1346
 
615
- expect(syncRules.current_lock?.sync_rules_id).toBe(syncRules.id);
1347
+ expect(syncRules.current_lock?.sync_rules_id).toBe(syncRules.replicationStreamId);
616
1348
  await syncRules.current_lock?.release();
617
1349
  });
618
1350
 
1351
+ test.runIf(storageVersion >= 3)('does not lock when appending a sync config to an existing stream', async () => {
1352
+ await using factory = await storageConfig.factory();
1353
+
1354
+ const firstRules = `
1355
+ config:
1356
+ edition: 3
1357
+
1358
+ streams:
1359
+ by_owner:
1360
+ query: SELECT * FROM todos WHERE owner_id = subscription.parameter('owner_id')
1361
+ `;
1362
+ const secondRules = `
1363
+ config:
1364
+ edition: 3
1365
+
1366
+ streams:
1367
+ by_project:
1368
+ query: SELECT * FROM todos WHERE project_id = subscription.parameter('project_id')
1369
+ `;
1370
+
1371
+ const first = await factory.updateSyncRules(updateSyncRulesFromYaml(firstRules, { storageVersion, lock: true }));
1372
+ expect(first.current_lock?.sync_rules_id).toBe(first.replicationStreamId);
1373
+ try {
1374
+ const firstStorage = factory.getInstance(first);
1375
+ await using firstWriter = await firstStorage.createWriter(test_utils.BATCH_OPTIONS);
1376
+ await firstWriter.markAllSnapshotDone('1/1');
1377
+ await firstWriter.commit('1/1');
1378
+
1379
+ const second = await factory.updateSyncRules(
1380
+ updateSyncRulesFromYaml(secondRules, { storageVersion, lock: true })
1381
+ );
1382
+ expect(second.replicationStreamId).toBe(first.replicationStreamId);
1383
+ expect(second.current_lock).toBeNull();
1384
+ } finally {
1385
+ await first.current_lock?.release();
1386
+ }
1387
+ });
1388
+
619
1389
  test.runIf(storageVersion < 3)('uses a single current_data collection for v1 source records', async () => {
620
1390
  await using factory = await storageConfig.factory();
621
1391
  const syncRules = await factory.updateSyncRules(
@@ -647,10 +1417,10 @@ function registerSyncStorageTests(storageConfig: storage.TestStorageConfig, stor
647
1417
  await writer.commit('1/1');
648
1418
 
649
1419
  const mongoFactory = factory as MongoBucketStorage;
650
- expect(await mongoFactory.db.current_data.countDocuments({ '_id.g': syncRules.id })).toBe(1);
1420
+ expect(await mongoFactory.db.current_data.countDocuments({ '_id.g': syncRules.replicationStreamId })).toBe(1);
651
1421
 
652
1422
  const sourceRecordCollections = await mongoFactory.db.db
653
- .listCollections({ name: new RegExp(`^source_records_${syncRules.id}_`) }, { nameOnly: true })
1423
+ .listCollections({ name: new RegExp(`^source_records_${syncRules.replicationStreamId}_`) }, { nameOnly: true })
654
1424
  .toArray();
655
1425
  expect(sourceRecordCollections).toEqual([]);
656
1426
  });
@@ -686,11 +1456,11 @@ function registerSyncStorageTests(storageConfig: storage.TestStorageConfig, stor
686
1456
  await writer.commit('1/1');
687
1457
 
688
1458
  const mongoFactory = factory as MongoBucketStorage;
689
- expect(await mongoFactory.db.current_data.countDocuments({ '_id.g': syncRules.id })).toBe(1);
1459
+ expect(await mongoFactory.db.current_data.countDocuments({ '_id.g': syncRules.replicationStreamId })).toBe(1);
690
1460
 
691
1461
  await bucketStorage.clear();
692
1462
 
693
- expect(await mongoFactory.db.current_data.countDocuments({ '_id.g': syncRules.id })).toBe(0);
1463
+ expect(await mongoFactory.db.current_data.countDocuments({ '_id.g': syncRules.replicationStreamId })).toBe(0);
694
1464
  });
695
1465
 
696
1466
  test.runIf(storageVersion < 3)('storage metrics include v1 current_data', async () => {
@@ -725,7 +1495,7 @@ function registerSyncStorageTests(storageConfig: storage.TestStorageConfig, stor
725
1495
  await writer.commit('1/1');
726
1496
 
727
1497
  const mongoFactory = factory as MongoBucketStorage;
728
- expect(await mongoFactory.db.current_data.countDocuments({ '_id.g': syncRules.id })).toBe(1);
1498
+ expect(await mongoFactory.db.current_data.countDocuments({ '_id.g': syncRules.replicationStreamId })).toBe(1);
729
1499
 
730
1500
  const metricsAfter = await factory.getStorageMetrics();
731
1501
  expect(metricsAfter.replication_size_bytes).toBeGreaterThan(metricsBefore.replication_size_bytes);
@@ -783,7 +1553,7 @@ function registerSyncStorageTests(storageConfig: storage.TestStorageConfig, stor
783
1553
  }
784
1554
  );
785
1555
 
786
- test.runIf(storageVersion >= 3)('cleans pending deletes only for tracked v3 source tables', async () => {
1556
+ test.runIf(storageVersion == 3)('cleans pending deletes only for tracked v3 source tables', async () => {
787
1557
  await using factory = await storageConfig.factory();
788
1558
  const syncRules = await factory.updateSyncRules(
789
1559
  updateSyncRulesFromYaml(
@@ -798,21 +1568,20 @@ function registerSyncStorageTests(storageConfig: storage.TestStorageConfig, stor
798
1568
  );
799
1569
 
800
1570
  const mongoFactory = factory as MongoBucketStorage;
801
- const bucketStorage = mongoFactory.getInstance(syncRules) as any;
802
- const db = bucketStorage.db;
803
- await db.initializeStreamStorage(syncRules.id);
1571
+ const bucketStorage = mongoFactory.getInstance(syncRules);
1572
+ const db = bucketStorage.db as VersionedPowerSyncMongoV3;
1573
+ await db.initializeStreamStorage(syncRules.replicationStreamId);
804
1574
 
805
1575
  const sourceTableA = new bson.ObjectId();
806
1576
  const sourceTableB = new bson.ObjectId();
807
- await db.sourceTablesV3(syncRules.id).insertMany([
1577
+ await db.sourceTables(syncRules.replicationStreamId).insertMany([
808
1578
  {
809
1579
  _id: sourceTableA,
810
1580
  connection_id: 1,
811
1581
  relation_id: 'a',
812
1582
  schema_name: 'public',
813
1583
  table_name: 'table_a',
814
- replica_id_columns: null,
815
- replica_id_columns2: [],
1584
+ replica_id_columns: [],
816
1585
  snapshot_done: true,
817
1586
  snapshot_status: undefined,
818
1587
  bucket_data_source_ids: [],
@@ -825,8 +1594,7 @@ function registerSyncStorageTests(storageConfig: storage.TestStorageConfig, stor
825
1594
  relation_id: 'b',
826
1595
  schema_name: 'public',
827
1596
  table_name: 'table_b',
828
- replica_id_columns: null,
829
- replica_id_columns2: [],
1597
+ replica_id_columns: [],
830
1598
  snapshot_done: true,
831
1599
  snapshot_status: undefined,
832
1600
  bucket_data_source_ids: [],
@@ -835,35 +1603,53 @@ function registerSyncStorageTests(storageConfig: storage.TestStorageConfig, stor
835
1603
  }
836
1604
  ]);
837
1605
 
838
- await db.sourceRecordsV3(syncRules.id, sourceTableA).insertMany([
1606
+ await db.sourceRecords(syncRules.replicationStreamId, sourceTableA).insertMany([
839
1607
  { _id: 'deleted-1', data: null, buckets: [], lookups: [], pending_delete: 5n },
840
1608
  { _id: 'deleted-2', data: null, buckets: [], lookups: [], pending_delete: 9n },
841
1609
  { _id: 'active', data: null, buckets: [], lookups: [] }
842
1610
  ]);
843
1611
  await db
844
- .sourceRecordsV3(syncRules.id, sourceTableB)
1612
+ .sourceRecords(syncRules.replicationStreamId, sourceTableB)
845
1613
  .insertMany([{ _id: 'later-delete', data: null, buckets: [], lookups: [], pending_delete: 12n }]);
846
1614
 
847
- const store = new SourceRecordStoreV3(db, syncRules.id, bucketStorage.sync_rules.mapping);
1615
+ const store = new SourceRecordStoreV3(
1616
+ db,
1617
+ syncRules.replicationStreamId,
1618
+ bucketStorage.replicationStream.syncConfigContent[0].mapping
1619
+ );
848
1620
  const logger = { info() {} } as any;
849
1621
 
850
1622
  await store.postCommitCleanup(6n, logger);
851
1623
 
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);
1624
+ expect(
1625
+ await db.sourceRecords(syncRules.replicationStreamId, sourceTableA).countDocuments({ pending_delete: 5n })
1626
+ ).toBe(0);
1627
+ expect(
1628
+ await db.sourceRecords(syncRules.replicationStreamId, sourceTableA).countDocuments({ pending_delete: 9n })
1629
+ ).toBe(1);
1630
+ expect(
1631
+ await db.sourceRecords(syncRules.replicationStreamId, sourceTableB).countDocuments({ pending_delete: 12n })
1632
+ ).toBe(1);
1633
+ expect(
1634
+ (await db.sourceTables(syncRules.replicationStreamId).findOne({ _id: sourceTableA }))?.latest_pending_delete
1635
+ ).toBe(9n);
1636
+ expect(
1637
+ (await db.sourceTables(syncRules.replicationStreamId).findOne({ _id: sourceTableB }))?.latest_pending_delete
1638
+ ).toBe(12n);
857
1639
 
858
1640
  await store.postCommitCleanup(10n, logger);
859
1641
 
860
1642
  expect(
861
- await db.sourceRecordsV3(syncRules.id, sourceTableA).countDocuments({ pending_delete: { $exists: true } })
1643
+ await db
1644
+ .sourceRecords(syncRules.replicationStreamId, sourceTableA)
1645
+ .countDocuments({ pending_delete: { $exists: true } })
862
1646
  ).toBe(0);
863
1647
  expect(
864
- (await db.sourceTablesV3(syncRules.id).findOne({ _id: sourceTableA }))?.latest_pending_delete
1648
+ (await db.sourceTables(syncRules.replicationStreamId).findOne({ _id: sourceTableA }))?.latest_pending_delete
865
1649
  ).toBeUndefined();
866
- expect((await db.sourceTablesV3(syncRules.id).findOne({ _id: sourceTableB }))?.latest_pending_delete).toBe(12n);
1650
+ expect(
1651
+ (await db.sourceTables(syncRules.replicationStreamId).findOne({ _id: sourceTableB }))?.latest_pending_delete
1652
+ ).toBe(12n);
867
1653
  });
868
1654
  }
869
1655
 
@@ -880,7 +1666,7 @@ describe('sync - mongodb', () => {
880
1666
  await legacyWriter.markAllSnapshotDone('1/1');
881
1667
  await legacyWriter.commit('1/1');
882
1668
 
883
- expect((await mongoFactory.db.sync_rules.findOne({ _id: legacySyncRules.id }))?.state).toBe(
1669
+ expect((await mongoFactory.db.sync_rules.findOne({ _id: legacySyncRules.replicationStreamId }))?.state).toBe(
884
1670
  storage.SyncRuleState.ACTIVE
885
1671
  );
886
1672
 
@@ -892,7 +1678,7 @@ describe('sync - mongodb', () => {
892
1678
  await v3Writer.markAllSnapshotDone('2/1');
893
1679
  await v3Writer.commit('2/1');
894
1680
 
895
- expect((await mongoFactory.db.sync_rules.findOne({ _id: legacySyncRules.id }))?.state).toBe(
1681
+ expect((await mongoFactory.db.sync_rules.findOne({ _id: legacySyncRules.replicationStreamId }))?.state).toBe(
896
1682
  storage.SyncRuleState.STOP
897
1683
  );
898
1684
  });
@@ -900,6 +1686,229 @@ describe('sync - mongodb', () => {
900
1686
  for (const storageVersion of TEST_STORAGE_VERSIONS) {
901
1687
  describe(`storage v${storageVersion}`, () => {
902
1688
  registerSyncStorageTests(INITIALIZED_MONGO_STORAGE_FACTORY, storageVersion);
1689
+
1690
+ describe.runIf(storageVersion == 3)('V3 read filtering boundaries', () => {
1691
+ async function setupFilteringTest() {
1692
+ await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
1693
+ const syncRules = await factory.updateSyncRules(
1694
+ updateSyncRulesFromYaml(
1695
+ `
1696
+ bucket_definitions:
1697
+ global:
1698
+ data:
1699
+ - SELECT id, description FROM test
1700
+ `,
1701
+ { storageVersion }
1702
+ )
1703
+ );
1704
+ const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
1705
+ const db = bucketStorage.db as VersionedPowerSyncMongoV3;
1706
+ const mapping = syncRules.syncConfigContent[0].mapping;
1707
+
1708
+ const request = bucketRequest(syncRules.syncConfigContent[0], 'global[]', 0n);
1709
+ const definitionId = mapping.bucketSourceId(request.source);
1710
+ const collection = db.bucketData(syncRules.replicationStreamId, definitionId);
1711
+
1712
+ const bucketName = request.bucket;
1713
+ const sourceTable = new bson.ObjectId();
1714
+ const bucketKey: BucketKey = {
1715
+ replicationStreamId: syncRules.replicationStreamId,
1716
+ definitionId,
1717
+ bucket: bucketName
1718
+ };
1719
+
1720
+ function makeOps(opIds: bigint[]): BucketDataDoc[] {
1721
+ return opIds.map((opId) => ({
1722
+ bucketKey,
1723
+ o: opId,
1724
+ op: 'PUT' as const,
1725
+ source_table: sourceTable,
1726
+ source_key: test_utils.rid(`row-${opId}`),
1727
+ table: 'items',
1728
+ row_id: `row-${opId}`,
1729
+ checksum: BigInt(opId) * 10n,
1730
+ data: `{"id":"row-${opId}"}`
1731
+ }));
1732
+ }
1733
+
1734
+ const docA = serializeBucketData(bucketName, makeOps([10n, 20n, 30n]));
1735
+ const docB = serializeBucketData(bucketName, makeOps([40n, 50n, 60n]));
1736
+ const docC = serializeBucketData(bucketName, makeOps([70n, 80n, 90n]));
1737
+
1738
+ await collection.insertMany([docA, docB, docC]);
1739
+
1740
+ return { factory, syncRules, bucketStorage, bucketName };
1741
+ }
1742
+
1743
+ async function getFilteredOps(start: number, checkpoint: number): Promise<bigint[]> {
1744
+ const { syncRules, bucketStorage } = await setupFilteringTest();
1745
+ const request = bucketRequest(syncRules.syncConfigContent[0], 'global[]', BigInt(start));
1746
+ const batch = await test_utils.fromAsync(bucketStorage.getBucketDataBatch(BigInt(checkpoint), [request]));
1747
+ const ops = batch.flatMap((b) => b.chunkData.data.map((d) => BigInt(d.op_id)));
1748
+ return ops;
1749
+ }
1750
+
1751
+ test('case 1: start=5, checkpoint=95 → all ops', async () => {
1752
+ const ops = await getFilteredOps(5, 95);
1753
+ expect(ops).toEqual([10n, 20n, 30n, 40n, 50n, 60n, 70n, 80n, 90n]);
1754
+ });
1755
+
1756
+ test('case 2: start=10, checkpoint=90 → ops in (10,90]', async () => {
1757
+ const ops = await getFilteredOps(10, 90);
1758
+ expect(ops).toEqual([20n, 30n, 40n, 50n, 60n, 70n, 80n, 90n]);
1759
+ });
1760
+
1761
+ test('case 3: start=15, checkpoint=85 → partial doc boundaries', async () => {
1762
+ const ops = await getFilteredOps(15, 85);
1763
+ expect(ops).toEqual([20n, 30n, 40n, 50n, 60n, 70n, 80n]);
1764
+ });
1765
+
1766
+ test('case 4: start=25, checkpoint=55 → spans two docs', async () => {
1767
+ const ops = await getFilteredOps(25, 55);
1768
+ expect(ops).toEqual([30n, 40n, 50n]);
1769
+ });
1770
+
1771
+ test('case 5: start=35, checkpoint=45 → single op within doc', async () => {
1772
+ const ops = await getFilteredOps(35, 45);
1773
+ expect(ops).toEqual([40n]);
1774
+ });
1775
+
1776
+ test('case 6: start=35, checkpoint=65 → full doc B', async () => {
1777
+ const ops = await getFilteredOps(35, 65);
1778
+ expect(ops).toEqual([40n, 50n, 60n]);
1779
+ });
1780
+
1781
+ test('case 7: start=25, checkpoint=35 → single op from doc A', async () => {
1782
+ const ops = await getFilteredOps(25, 35);
1783
+ expect(ops).toEqual([30n]);
1784
+ });
1785
+
1786
+ test('case 8: start=30, checkpoint=40 → op at checkpoint from next doc', async () => {
1787
+ const ops = await getFilteredOps(30, 40);
1788
+ expect(ops).toEqual([40n]);
1789
+ });
1790
+
1791
+ test('case 9: start=100, checkpoint=200 → beyond all docs', async () => {
1792
+ const ops = await getFilteredOps(100, 200);
1793
+ expect(ops).toEqual([]);
1794
+ });
1795
+
1796
+ test('case 10: start=0, checkpoint=5 → before all docs', async () => {
1797
+ const ops = await getFilteredOps(0, 5);
1798
+ expect(ops).toEqual([]);
1799
+ });
1800
+
1801
+ test('case 11: start=50, checkpoint=50 → zero-width range', async () => {
1802
+ const ops = await getFilteredOps(50, 50);
1803
+ expect(ops).toEqual([]);
1804
+ });
1805
+
1806
+ test('case 12: start=45, checkpoint=50 → op at checkpoint boundary', async () => {
1807
+ const ops = await getFilteredOps(45, 50);
1808
+ expect(ops).toEqual([50n]);
1809
+ });
1810
+
1811
+ test('case 13: start=50, checkpoint=55 → no ops strictly after start', async () => {
1812
+ const ops = await getFilteredOps(50, 55);
1813
+ expect(ops).toEqual([]);
1814
+ });
1815
+
1816
+ test('all-filtered first batch still returns data behind the batch boundary', async () => {
1817
+ // Documents straddling the requested (start, end] window are matched by the
1818
+ // query, but contribute no rows after filtering. If an entire server batch
1819
+ // (~101 documents) consists of such straddlers, the remaining documents in
1820
+ // the cursor must still be reachable. Storage reports the straddler buckets
1821
+ // as complete via empty chunks, and the caller re-requests the rest.
1822
+ await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
1823
+ const syncRules = await factory.updateSyncRules(
1824
+ updateSyncRulesFromYaml(
1825
+ `
1826
+ bucket_definitions:
1827
+ by_user:
1828
+ parameters: select request.user_id() as user_id
1829
+ data: [select * from test where owner_id = bucket.user_id]
1830
+ `,
1831
+ { storageVersion }
1832
+ )
1833
+ );
1834
+ const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorageV3;
1835
+ const db = bucketStorage.db as VersionedPowerSyncMongoV3;
1836
+
1837
+ const start = 5n;
1838
+ const end = 50n;
1839
+
1840
+ // 150 buckets sorted before the data bucket, each with a single document
1841
+ // containing ops at 1 and 100: matched (_id.o=100 > start, min_op=1 <= end),
1842
+ // but no op in (5, 50].
1843
+ const straddlerNames = Array.from({ length: 150 }, (_, i) => `b${`${i}`.padStart(3, '0')}`);
1844
+ const requests = [...straddlerNames, 'zzz'].map((id) =>
1845
+ bucketRequest(syncRules.syncConfigContent[0], `by_user["${id}"]`, start)
1846
+ );
1847
+ const mapping = syncRules.syncConfigContent[0].mapping;
1848
+ const definitionId = mapping.bucketSourceId(requests[0].source);
1849
+ const collection = db.bucketData(syncRules.replicationStreamId, definitionId);
1850
+ const sourceTable = new bson.ObjectId();
1851
+
1852
+ function makeOps(bucket: string, opIds: bigint[]): BucketDataDoc[] {
1853
+ const bucketKey: BucketKey = {
1854
+ replicationStreamId: syncRules.replicationStreamId,
1855
+ definitionId,
1856
+ bucket
1857
+ };
1858
+ return opIds.map((opId) => ({
1859
+ bucketKey,
1860
+ o: opId,
1861
+ op: 'PUT' as const,
1862
+ source_table: sourceTable,
1863
+ source_key: test_utils.rid(`row-${opId}`),
1864
+ table: 'test',
1865
+ row_id: `row-${opId}`,
1866
+ checksum: BigInt(opId) * 10n,
1867
+ data: `{"id":"row-${opId}"}`
1868
+ }));
1869
+ }
1870
+
1871
+ const straddlerDocs = requests
1872
+ .slice(0, -1)
1873
+ .map((request) => serializeBucketData(request.bucket, makeOps(request.bucket, [1n, 100n])));
1874
+ const dataBucket = requests[requests.length - 1].bucket;
1875
+ const dataDoc = serializeBucketData(dataBucket, makeOps(dataBucket, [10n]));
1876
+ await collection.insertMany([...straddlerDocs, dataDoc]);
1877
+
1878
+ // Emulate the caller loop in sync.ts / BucketChecksumState: advance bucket
1879
+ // positions from each chunk, drop completed buckets, and re-request while
1880
+ // any chunk reported has_more.
1881
+ const positions = new Map(requests.map((request) => [request.bucket, request.start]));
1882
+ const pending = new Set(positions.keys());
1883
+ const receivedOps: bigint[] = [];
1884
+ let rounds = 0;
1885
+
1886
+ while (rounds < 10) {
1887
+ rounds++;
1888
+ const roundRequests = requests
1889
+ .filter((request) => pending.has(request.bucket))
1890
+ .map((request) => ({ ...request, start: positions.get(request.bucket)! }));
1891
+ const batch = await test_utils.fromAsync(bucketStorage.getBucketDataBatch(end, roundRequests));
1892
+ let anyHasMore = false;
1893
+ for (const { chunkData } of batch) {
1894
+ positions.set(chunkData.bucket, BigInt(chunkData.next_after));
1895
+ if (chunkData.has_more) {
1896
+ anyHasMore = true;
1897
+ } else {
1898
+ pending.delete(chunkData.bucket);
1899
+ }
1900
+ receivedOps.push(...chunkData.data.map((entry) => BigInt(entry.op_id)));
1901
+ }
1902
+ if (!anyHasMore) {
1903
+ break;
1904
+ }
1905
+ }
1906
+
1907
+ // The op behind the all-straddler first batch must be returned.
1908
+ expect(receivedOps).toEqual([10n]);
1909
+ expect(rounds).toBeLessThan(10);
1910
+ });
1911
+ });
903
1912
  });
904
1913
  }
905
1914
  });