@powersync/service-module-mongodb-storage 0.0.0-dev-20260803144812 → 0.0.0-dev-20260929095716

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 (213) hide show
  1. package/CHANGELOG.md +129 -7
  2. package/dist/storage/MongoBucketStorage.d.ts +42 -5
  3. package/dist/storage/MongoBucketStorage.js +404 -215
  4. package/dist/storage/MongoBucketStorage.js.map +1 -1
  5. package/dist/storage/implementation/MongoBucketBatch.d.ts +39 -14
  6. package/dist/storage/implementation/MongoBucketBatch.js +169 -98
  7. package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
  8. package/dist/storage/implementation/MongoCompactor.d.ts +21 -79
  9. package/dist/storage/implementation/MongoCompactor.js +27 -238
  10. package/dist/storage/implementation/MongoCompactor.js.map +1 -1
  11. package/dist/storage/implementation/MongoIdSequence.d.ts +10 -1
  12. package/dist/storage/implementation/MongoIdSequence.js +14 -1
  13. package/dist/storage/implementation/MongoIdSequence.js.map +1 -1
  14. package/dist/storage/implementation/MongoOpIdAllocator.d.ts +27 -0
  15. package/dist/storage/implementation/MongoOpIdAllocator.js +97 -0
  16. package/dist/storage/implementation/MongoOpIdAllocator.js.map +1 -0
  17. package/dist/storage/implementation/MongoParameterCompactor.d.ts +83 -10
  18. package/dist/storage/implementation/MongoParameterCompactor.js +309 -96
  19. package/dist/storage/implementation/MongoParameterCompactor.js.map +1 -1
  20. package/dist/storage/implementation/MongoParsedSyncConfigSet.d.ts +9 -1
  21. package/dist/storage/implementation/MongoParsedSyncConfigSet.js +35 -2
  22. package/dist/storage/implementation/MongoParsedSyncConfigSet.js.map +1 -1
  23. package/dist/storage/implementation/MongoPersistedReplicationStream.d.ts +2 -1
  24. package/dist/storage/implementation/MongoPersistedReplicationStream.js +7 -4
  25. package/dist/storage/implementation/MongoPersistedReplicationStream.js.map +1 -1
  26. package/dist/storage/implementation/MongoPersistedSyncConfigContent.d.ts +3 -3
  27. package/dist/storage/implementation/MongoPersistedSyncConfigContent.js +10 -8
  28. package/dist/storage/implementation/MongoPersistedSyncConfigContent.js.map +1 -1
  29. package/dist/storage/implementation/MongoStorageProvider.js +8 -4
  30. package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
  31. package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +32 -11
  32. package/dist/storage/implementation/MongoSyncBucketStorage.js +95 -31
  33. package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
  34. package/dist/storage/implementation/MongoSyncRulesLock.d.ts +26 -2
  35. package/dist/storage/implementation/MongoSyncRulesLock.js +59 -10
  36. package/dist/storage/implementation/MongoSyncRulesLock.js.map +1 -1
  37. package/dist/storage/implementation/MongoWriteBatch.d.ts +49 -0
  38. package/dist/storage/implementation/MongoWriteBatch.js +149 -0
  39. package/dist/storage/implementation/MongoWriteBatch.js.map +1 -0
  40. package/dist/storage/implementation/MongoWriteCheckpointAPI.d.ts +9 -7
  41. package/dist/storage/implementation/MongoWriteCheckpointAPI.js +9 -30
  42. package/dist/storage/implementation/MongoWriteCheckpointAPI.js.map +1 -1
  43. package/dist/storage/implementation/SyncRuleStateUpdate.d.ts +11 -5
  44. package/dist/storage/implementation/SyncRuleStateUpdate.js +14 -6
  45. package/dist/storage/implementation/SyncRuleStateUpdate.js.map +1 -1
  46. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.d.ts +22 -2
  47. package/dist/storage/implementation/common/PersistedBatch.d.ts +32 -4
  48. package/dist/storage/implementation/common/PersistedBatch.js +57 -14
  49. package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
  50. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.d.ts +3 -0
  51. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js +3 -0
  52. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js.map +1 -1
  53. package/dist/storage/implementation/db.d.ts +9 -1
  54. package/dist/storage/implementation/db.js +28 -1
  55. package/dist/storage/implementation/db.js.map +1 -1
  56. package/dist/storage/implementation/models.d.ts +35 -0
  57. package/dist/storage/implementation/models.js.map +1 -1
  58. package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +11 -0
  59. package/dist/storage/implementation/v1/MongoBucketBatchV1.js +227 -221
  60. package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
  61. package/dist/storage/implementation/v1/MongoCompactorV1.d.ts +38 -6
  62. package/dist/storage/implementation/v1/MongoCompactorV1.js +247 -20
  63. package/dist/storage/implementation/v1/MongoCompactorV1.js.map +1 -1
  64. package/dist/storage/implementation/v1/MongoParameterCompactorV1.d.ts +16 -2
  65. package/dist/storage/implementation/v1/MongoParameterCompactorV1.js +20 -9
  66. package/dist/storage/implementation/v1/MongoParameterCompactorV1.js.map +1 -1
  67. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.d.ts +18 -11
  68. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +66 -17
  69. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
  70. package/dist/storage/implementation/v1/PersistedBatchV1.d.ts +7 -5
  71. package/dist/storage/implementation/v1/PersistedBatchV1.js +22 -28
  72. package/dist/storage/implementation/v1/PersistedBatchV1.js.map +1 -1
  73. package/dist/storage/implementation/v1/models.js +1 -0
  74. package/dist/storage/implementation/v1/models.js.map +1 -1
  75. package/dist/storage/implementation/v3/CompactionLease.d.ts +50 -0
  76. package/dist/storage/implementation/v3/CompactionLease.js +131 -0
  77. package/dist/storage/implementation/v3/CompactionLease.js.map +1 -0
  78. package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +14 -2
  79. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +261 -194
  80. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  81. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +83 -25
  82. package/dist/storage/implementation/v3/MongoCompactorV3.js +702 -159
  83. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  84. package/dist/storage/implementation/v3/MongoParameterCompactorV3.d.ts +18 -0
  85. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js +23 -0
  86. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js.map +1 -0
  87. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.d.ts +2 -15
  88. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js +60 -92
  89. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js.map +1 -1
  90. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.d.ts +19 -9
  91. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +92 -32
  92. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
  93. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.d.ts +26 -0
  94. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.js +82 -0
  95. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.js.map +1 -0
  96. package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +14 -8
  97. package/dist/storage/implementation/v3/PersistedBatchV3.js +77 -45
  98. package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
  99. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.d.ts +12 -2
  100. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js +42 -3
  101. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js.map +1 -1
  102. package/dist/storage/implementation/v3/bucket-format.js +2 -0
  103. package/dist/storage/implementation/v3/bucket-format.js.map +1 -1
  104. package/dist/storage/implementation/v3/compact-utils.d.ts +108 -0
  105. package/dist/storage/implementation/v3/compact-utils.js +223 -0
  106. package/dist/storage/implementation/v3/compact-utils.js.map +1 -0
  107. package/dist/storage/implementation/v3/compaction-constants.d.ts +5 -0
  108. package/dist/storage/implementation/v3/compaction-constants.js +6 -0
  109. package/dist/storage/implementation/v3/compaction-constants.js.map +1 -0
  110. package/dist/storage/implementation/v3/models.d.ts +90 -6
  111. package/dist/storage/implementation/v3/models.js.map +1 -1
  112. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.d.ts +7 -8
  113. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +16 -5
  114. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -1
  115. package/dist/storage/implementation/v3/object-storage/ObjectStorage.d.ts +14 -11
  116. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +6 -8
  117. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +17 -11
  118. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -1
  119. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.d.ts +38 -0
  120. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js +164 -0
  121. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js.map +1 -0
  122. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.d.ts +37 -12
  123. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js +197 -25
  124. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js.map +1 -1
  125. package/dist/storage/implementation/v3/source-table-utils.d.ts +15 -7
  126. package/dist/storage/implementation/v3/source-table-utils.js +49 -57
  127. package/dist/storage/implementation/v3/source-table-utils.js.map +1 -1
  128. package/dist/storage/storage-index.d.ts +2 -1
  129. package/dist/storage/storage-index.js +1 -0
  130. package/dist/storage/storage-index.js.map +1 -1
  131. package/dist/types/types.d.ts +28 -1
  132. package/dist/types/types.js +28 -7
  133. package/dist/types/types.js.map +1 -1
  134. package/dist/utils/test-utils.d.ts +4 -2
  135. package/dist/utils/test-utils.js +7 -4
  136. package/dist/utils/test-utils.js.map +1 -1
  137. package/dist/utils/util.js +3 -1
  138. package/dist/utils/util.js.map +1 -1
  139. package/package.json +10 -9
  140. package/src/storage/MongoBucketStorage.ts +545 -277
  141. package/src/storage/implementation/MongoBucketBatch.ts +258 -124
  142. package/src/storage/implementation/MongoCompactor.ts +37 -346
  143. package/src/storage/implementation/MongoIdSequence.ts +21 -1
  144. package/src/storage/implementation/MongoOpIdAllocator.ts +105 -0
  145. package/src/storage/implementation/MongoParameterCompactor.ts +388 -102
  146. package/src/storage/implementation/MongoParsedSyncConfigSet.ts +46 -1
  147. package/src/storage/implementation/MongoPersistedReplicationStream.ts +12 -4
  148. package/src/storage/implementation/MongoPersistedSyncConfigContent.ts +48 -29
  149. package/src/storage/implementation/MongoStorageProvider.ts +16 -4
  150. package/src/storage/implementation/MongoSyncBucketStorage.ts +133 -40
  151. package/src/storage/implementation/MongoSyncRulesLock.ts +78 -12
  152. package/src/storage/implementation/MongoWriteBatch.ts +172 -0
  153. package/src/storage/implementation/MongoWriteCheckpointAPI.ts +16 -46
  154. package/src/storage/implementation/SyncRuleStateUpdate.ts +13 -6
  155. package/src/storage/implementation/common/MongoSyncBucketStorageCheckpoint.ts +24 -2
  156. package/src/storage/implementation/common/PersistedBatch.ts +76 -20
  157. package/src/storage/implementation/common/VersionedPowerSyncMongoBase.ts +4 -0
  158. package/src/storage/implementation/db.ts +36 -2
  159. package/src/storage/implementation/models.ts +39 -0
  160. package/src/storage/implementation/v1/MongoBucketBatchV1.ts +272 -254
  161. package/src/storage/implementation/v1/MongoCompactorV1.ts +327 -40
  162. package/src/storage/implementation/v1/MongoParameterCompactorV1.ts +25 -9
  163. package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +88 -28
  164. package/src/storage/implementation/v1/PersistedBatchV1.ts +25 -30
  165. package/src/storage/implementation/v1/models.ts +1 -0
  166. package/src/storage/implementation/v3/CompactionLease.ts +152 -0
  167. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +339 -247
  168. package/src/storage/implementation/v3/MongoCompactorV3.ts +955 -225
  169. package/src/storage/implementation/v3/MongoParameterCompactorV3.ts +34 -0
  170. package/src/storage/implementation/v3/MongoStoppedSyncConfigCleanup.ts +100 -109
  171. package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +112 -42
  172. package/src/storage/implementation/v3/MongoWriteCheckpointAPIV3.ts +111 -0
  173. package/src/storage/implementation/v3/PersistedBatchV3.ts +94 -51
  174. package/src/storage/implementation/v3/VersionedPowerSyncMongoV3.ts +55 -4
  175. package/src/storage/implementation/v3/bucket-format.ts +2 -0
  176. package/src/storage/implementation/v3/compact-utils.ts +329 -0
  177. package/src/storage/implementation/v3/compaction-constants.ts +5 -0
  178. package/src/storage/implementation/v3/models.ts +97 -4
  179. package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +24 -9
  180. package/src/storage/implementation/v3/object-storage/ObjectStorage.ts +20 -5
  181. package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +26 -14
  182. package/src/storage/implementation/v3/object-storage/ObjectStorageUsage.ts +229 -0
  183. package/src/storage/implementation/v3/object-storage/S3ObjectStorage.ts +279 -29
  184. package/src/storage/implementation/v3/source-table-utils.ts +82 -76
  185. package/src/storage/storage-index.ts +2 -0
  186. package/src/types/types.ts +42 -7
  187. package/src/utils/test-utils.ts +9 -5
  188. package/src/utils/util.ts +3 -1
  189. package/test/src/__snapshots__/storage.test.ts.snap +25 -0
  190. package/test/src/__snapshots__/storage_sync.test.ts.snap +794 -205
  191. package/test/src/chunk_compaction_config.test.ts +38 -0
  192. package/test/src/cleanup-stopped-sync-configs.test.ts +59 -26
  193. package/test/src/compact-utils.test.ts +251 -0
  194. package/test/src/concurrent_writers.test.ts +599 -0
  195. package/test/src/connection_config.test.ts +159 -0
  196. package/test/src/helpers/MemoryObjectStorage.ts +14 -6
  197. package/test/src/helpers/concurrentWriter.mjs +29 -0
  198. package/test/src/helpers/s3TestFactory.ts +12 -12
  199. package/test/src/object_storage_usage.test.ts +268 -0
  200. package/test/src/parameter_compacting_v1.test.ts +120 -0
  201. package/test/src/parameter_compacting_v3.test.ts +192 -0
  202. package/test/src/parameter_compaction_fence.test.ts +276 -0
  203. package/test/src/storage.test.ts +280 -24
  204. package/test/src/storage_compacting.test.ts +1097 -253
  205. package/test/src/storage_object_storage_inline_threshold.test.ts +3 -3
  206. package/test/src/storage_s3_checksums.test.ts +6 -7
  207. package/test/src/storage_s3_compaction_lifecycle.test.ts +143 -17
  208. package/test/src/storage_s3_reading.test.ts +294 -6
  209. package/test/src/storage_s3_writing.test.ts +4 -3
  210. package/test/src/storage_sync.test.ts +412 -80
  211. package/test/src/storeCurrentData.test.ts +5 -5
  212. package/test/src/util.ts +1 -1
  213. package/tsconfig.tsbuildinfo +1 -1
@@ -5,7 +5,13 @@ import {
5
5
  storage
6
6
  } from '@powersync/service-core';
7
7
 
8
- import { DO_NOT_LOG, ErrorCode, logger, ServiceError } from '@powersync/lib-services-framework';
8
+ import {
9
+ DO_NOT_LOG,
10
+ ErrorCode,
11
+ logger,
12
+ ReplicationAssertionError,
13
+ ServiceError
14
+ } from '@powersync/lib-services-framework';
9
15
  import { v4 as uuid } from 'uuid';
10
16
 
11
17
  import * as lib_mongo from '@powersync/lib-service-mongodb';
@@ -16,18 +22,22 @@ import {
16
22
  formatIncrementalSyncConfigUpdateLog,
17
23
  isCompatible
18
24
  } from '@powersync/service-core';
25
+ import { Semaphore } from 'async-mutex';
19
26
  import { ObjectId } from 'bson';
20
- import { DEFAULT_CLEAR_BATCH_THROTTLE_RATE } from '../types/types.js';
27
+ import { DEFAULT_CLEAR_BATCH_THROTTLE_RATE, normalizeChunkCompactionConcurrency } from '../types/types.js';
21
28
  import { generateReplicationStreamName } from '../utils/util.js';
22
29
  import type { MongoSyncBucketStorage } from './implementation/createMongoSyncBucketStorage.js';
23
30
  import { createMongoSyncBucketStorage } from './implementation/createMongoSyncBucketStorage.js';
24
31
  import { PowerSyncMongo } from './implementation/db.js';
25
32
  import { getMongoStorageConfig, StorageConfig, SyncRuleDocumentBase } from './implementation/models.js';
26
33
  import { MongoChecksumOptions } from './implementation/MongoChecksums.js';
34
+ import { MongoOpIdAllocator } from './implementation/MongoOpIdAllocator.js';
27
35
  import { MongoPersistedReplicationStream } from './implementation/MongoPersistedReplicationStream.js';
28
- import { syncRuleStateUpdatePipeline } from './implementation/SyncRuleStateUpdate.js';
36
+ import { MongoSyncRulesLock } from './implementation/MongoSyncRulesLock.js';
37
+ import { stopReplicationStreamPipeline } from './implementation/SyncRuleStateUpdate.js';
29
38
  import { SyncRuleDocumentV1 } from './implementation/v1/models.js';
30
39
  import { ObjectStorage } from './implementation/v3/object-storage/ObjectStorage.js';
40
+ import { ObjectStorageUsage } from './implementation/v3/object-storage/ObjectStorageUsage.js';
31
41
  import { VersionedPowerSyncMongoV3 } from './implementation/v3/VersionedPowerSyncMongoV3.js';
32
42
  import { ReplicationStreamDocumentV3, SyncConfigDefinition, SyncRuleConfigStateV3 } from './storage-index.js';
33
43
 
@@ -35,6 +45,8 @@ export interface MongoBucketStorageOptions {
35
45
  checksumOptions?: Omit<MongoChecksumOptions, 'storageConfig'>;
36
46
  objectStorage?: ObjectStorage;
37
47
  inlineThresholdBytes?: number;
48
+ /** Shared across chunk-compaction jobs. Default: 4 with object storage, otherwise 2. */
49
+ chunkCompactionConcurrency?: number;
38
50
  /**
39
51
  * Prefix for replication stream name and Postgres logical replication slot name.
40
52
  */
@@ -42,6 +54,7 @@ export interface MongoBucketStorageOptions {
42
54
  readPreference?: mongo.ReadPreference;
43
55
  checksumCacheTtlMs?: number;
44
56
  clearBatchThrottleRate?: number;
57
+ defaultStorageVersion?: number;
45
58
  /**
46
59
  * Reuse a compatible active replication stream by appending a new sync config.
47
60
  *
@@ -56,28 +69,50 @@ export class MongoBucketStorage extends storage.BucketStorageFactory {
56
69
  [DO_NOT_LOG] = true;
57
70
 
58
71
  private readonly client: mongo.MongoClient;
59
- private readonly session: mongo.ClientSession;
60
72
  public readonly replicationStreamNamePrefix: string;
61
73
 
74
+ private readonly opIdAllocators = new Map<number, { lock: MongoSyncRulesLock; allocator: MongoOpIdAllocator }>();
75
+
76
+ discardOpIdAllocator(streamId: number) {
77
+ this.opIdAllocators.get(streamId)?.allocator.discard();
78
+ this.opIdAllocators.delete(streamId);
79
+ }
80
+
81
+ getOpIdAllocator(stream: MongoPersistedReplicationStream, lock: MongoSyncRulesLock): MongoOpIdAllocator {
82
+ lock.throwIfAborted();
83
+ const previous = this.opIdAllocators.get(stream.replicationStreamId);
84
+ if (previous?.lock === lock) {
85
+ return previous.allocator;
86
+ }
87
+ previous?.allocator.discard();
88
+ const allocator = new MongoOpIdAllocator(this.db.versioned(stream.getStorageConfig()));
89
+ this.opIdAllocators.set(stream.replicationStreamId, { lock, allocator });
90
+ return allocator;
91
+ }
92
+
62
93
  private activeStorageCache: MongoSyncBucketStorage | undefined;
63
94
 
64
95
  public readonly db: PowerSyncMongo;
96
+ public readonly chunkCompactionConcurrency: number;
97
+ public readonly chunkCompactionSlots: Semaphore;
65
98
 
66
99
  constructor(
67
100
  db: PowerSyncMongo,
101
+ syncConfigParser: storage.SyncConfigParser,
68
102
  private options: MongoBucketStorageOptions
69
103
  ) {
70
- super();
104
+ super({ syncConfigParser });
105
+ this.chunkCompactionConcurrency = normalizeChunkCompactionConcurrency(
106
+ options.chunkCompactionConcurrency,
107
+ options.objectStorage != null
108
+ );
109
+ // All replication streams created by this factory share the configured limit.
110
+ this.chunkCompactionSlots = new Semaphore(this.chunkCompactionConcurrency);
71
111
  this.client = db.client;
72
112
  this.db = db;
73
- this.session = this.client.startSession();
74
113
  this.replicationStreamNamePrefix = options.replicationStreamNamePrefix;
75
114
  }
76
115
 
77
- async [Symbol.asyncDispose]() {
78
- // No-op
79
- }
80
-
81
116
  getInstance(
82
117
  replicationStream: storage.PersistedReplicationStream,
83
118
  options?: GetIntanceOptions
@@ -90,6 +125,15 @@ export class MongoBucketStorage extends storage.BucketStorageFactory {
90
125
  replicationStreamId = Number(replicationStreamId);
91
126
  }
92
127
  const storageConfig = replicationStream.getStorageConfig();
128
+ if (options?.replicationLock != null) {
129
+ if (
130
+ !(options.replicationLock instanceof MongoSyncRulesLock) ||
131
+ options.replicationLock.sync_rules_id !== replicationStream.replicationStreamId
132
+ ) {
133
+ throw new ReplicationAssertionError('Replication lock does not belong to this MongoDB stream');
134
+ }
135
+ replicationStream.current_lock = options.replicationLock;
136
+ }
93
137
  const syncRuleStorage = createMongoSyncBucketStorage(
94
138
  this,
95
139
  replicationStreamId,
@@ -138,187 +182,256 @@ export class MongoBucketStorage extends storage.BucketStorageFactory {
138
182
  }
139
183
 
140
184
  async restartReplication(replicationStreamId: number) {
141
- const next = await this.getDeployingSyncConfig();
142
- const active = await this.getActiveSyncConfig();
143
-
144
- if (next != null && next.content.replicationStreamId == replicationStreamId) {
145
- // We need to redo the "next" replication stream
146
- await this.updateSyncRules(next.content.asUpdateOptions());
147
- // Pro-actively stop replicating
148
- await this.db.sync_rules.updateOne(
149
- {
150
- _id: next.content.replicationStreamId,
151
- state: storage.SyncRuleState.PROCESSING
152
- },
153
- syncRuleStateUpdatePipeline(storage.SyncRuleState.STOP)
154
- );
155
- await this.db.notifyCheckpoint();
156
- } else if (next == null && active?.content.replicationStreamId == replicationStreamId) {
157
- // Slot removed for "active" replication stream, while there is no "next" one.
158
- await this.updateSyncRules(active.content.asUpdateOptions());
159
-
160
- // In this case we keep the old one as active for clients, so that that existing clients
161
- // can still get the latest data while we replicate the new ones.
162
- // It will however not replicate anymore.
185
+ await this.withTransaction(async (session) => {
186
+ const next = await this.getDeployingSyncConfigInternal(session);
187
+ const active = await this.getActiveSyncConfigInternal(session);
188
+
189
+ if (next != null && next.content.replicationStreamId == replicationStreamId) {
190
+ // We need to redo the "next" replication stream.
191
+ // This creates a new stream, and stops the existing PROCESSING one.
192
+ await this.updateSyncRulesInTransaction(
193
+ next.content.asUpdateOptions({ forceNewReplicationStream: true }),
194
+ session
195
+ );
196
+ const sharedActiveStream = active?.content.replicationStreamId == replicationStreamId;
197
+ if (sharedActiveStream) {
198
+ // The same stream was also used for the ACTIVE config. Transition that to ERRORED
199
+ await this.errorActiveStreamForReplacement(active, session);
200
+ }
201
+ } else if (next == null && active?.content.replicationStreamId == replicationStreamId) {
202
+ // Slot removed for "active" replication stream, while there is no "next" one.
203
+ await this.updateSyncRulesInTransaction(
204
+ active.content.asUpdateOptions({ forceNewReplicationStream: true }),
205
+ session
206
+ );
207
+
208
+ // In this case we keep the old one as active for clients, so that that existing clients
209
+ // can still get the latest data while we replicate the new ones.
210
+ // It will however not replicate anymore.
211
+ await this.errorActiveStreamForReplacement(active, session);
212
+ } else if (next != null && active?.content.replicationStreamId == replicationStreamId) {
213
+ // Already have next replication stream, but need to stop replicating the active one.
214
+ await this.errorActiveStreamForReplacement(active, session);
215
+ } else {
216
+ // replicationStreamId does not match the ACTIVE or PROCESSING streams - no-op.
217
+ }
218
+ });
219
+ await this.db.notifyCheckpoint();
220
+ }
163
221
 
164
- await this.db.sync_rules.updateOne(
222
+ /**
223
+ * Keep an active config readable after its replication stream is invalidated.
224
+ *
225
+ * Incremental V3 streams can retain stopped historical configs and embed a deploying config.
226
+ * Read-side storage requires exactly one ACTIVE or ERRORED config, so only the active config
227
+ * transitions to ERRORED; every other embedded config remains or becomes STOP.
228
+ */
229
+ private async errorActiveStreamForReplacement(active: storage.ResolvedSyncConfig, session: mongo.ClientSession) {
230
+ const activeSyncConfigId = active.content.syncConfigId;
231
+ if (activeSyncConfigId == null) {
232
+ // V1 documents
233
+ const result = await this.db.sync_rules.updateOne(
165
234
  {
166
235
  _id: active.content.replicationStreamId,
167
- state: storage.SyncRuleState.ACTIVE
236
+ state: { $in: [storage.SyncRuleState.ACTIVE, storage.SyncRuleState.ERRORED] }
168
237
  },
169
- syncRuleStateUpdatePipeline(storage.SyncRuleState.ERRORED)
170
- );
171
- await this.db.notifyCheckpoint();
172
- } else if (next != null && active?.content.replicationStreamId == replicationStreamId) {
173
- // Already have next replication stream, but need to stop replicating the active one.
174
-
175
- await this.db.sync_rules.updateOne(
176
238
  {
177
- _id: active.content.replicationStreamId,
178
- state: storage.SyncRuleState.ACTIVE
239
+ $set: {
240
+ state: storage.SyncRuleState.ERRORED
241
+ }
179
242
  },
180
- syncRuleStateUpdatePipeline(storage.SyncRuleState.ERRORED)
243
+ { session }
244
+ );
245
+ if (result.matchedCount != 1) {
246
+ // This should generally not happen, since the entire process runs in a transaction.
247
+ // The filters just double-check that the state is what we expect, and fails hard otherwise.
248
+ throw new ReplicationAssertionError(
249
+ `Active replication stream ${active.content.replicationStreamId} changed during restart`
250
+ );
251
+ }
252
+ return;
253
+ }
254
+
255
+ const activeConfigObjectId = new ObjectId(activeSyncConfigId);
256
+ const result = await this.db.sync_rules.updateOne(
257
+ {
258
+ _id: active.content.replicationStreamId,
259
+ state: { $in: [storage.SyncRuleState.ACTIVE, storage.SyncRuleState.ERRORED] },
260
+ sync_configs: {
261
+ // Confirm that this is still the ACTIVE/ERRORED one.
262
+ $elemMatch: {
263
+ _id: activeConfigObjectId,
264
+ state: { $in: [storage.SyncRuleState.ACTIVE, storage.SyncRuleState.ERRORED] }
265
+ }
266
+ }
267
+ },
268
+ {
269
+ $set: {
270
+ state: storage.SyncRuleState.ERRORED,
271
+ // There must be exactly one config with an ERRORED state: This will be used to serve
272
+ // clients until there is a new ACTIVE one.
273
+ 'sync_configs.$[activeConfig].state': storage.SyncRuleState.ERRORED,
274
+ // PROCESSING configs must stop. Historical STOP configs remain unchanged.
275
+ 'sync_configs.$[processingConfig].state': storage.SyncRuleState.STOP
276
+ }
277
+ },
278
+ {
279
+ session,
280
+ arrayFilters: [
281
+ {
282
+ 'activeConfig._id': activeConfigObjectId,
283
+ 'activeConfig.state': { $in: [storage.SyncRuleState.ACTIVE, storage.SyncRuleState.ERRORED] }
284
+ },
285
+ {
286
+ 'processingConfig._id': { $ne: activeConfigObjectId },
287
+ 'processingConfig.state': storage.SyncRuleState.PROCESSING
288
+ }
289
+ ]
290
+ }
291
+ );
292
+ if (result.matchedCount != 1) {
293
+ // This should generally not happen, since the entire process runs in a transaction.
294
+ // The filters just double-check that the state is what we expect, and fails hard otherwise.
295
+ throw new ReplicationAssertionError(
296
+ `Active replication stream ${active.content.replicationStreamId} changed during restart`
181
297
  );
182
- await this.db.notifyCheckpoint();
183
298
  }
184
299
  }
185
300
 
186
- private async updateSyncRulesV3(
301
+ private async updateSyncRulesInTransaction(options: storage.UpdateSyncRulesOptions, session: mongo.ClientSession) {
302
+ const storageVersion =
303
+ options.storageVersion ??
304
+ options.config.parsed.config.storageVersion ??
305
+ this.options.defaultStorageVersion ??
306
+ storage.CURRENT_STORAGE_VERSION;
307
+ const storageConfig = getMongoStorageConfig(storageVersion);
308
+ if (storageConfig.incrementalReprocessing) {
309
+ return this.updateSyncConfigV3InTransaction(options, storageVersion, storageConfig, session);
310
+ }
311
+ return this.updateSyncConfigV1InTransaction(options, storageVersion, session);
312
+ }
313
+
314
+ /**
315
+ * Update to a V3 sync config.
316
+ *
317
+ * Does support cases where existing replication streams are on V1.
318
+ */
319
+ private async updateSyncConfigV3InTransaction(
187
320
  options: storage.UpdateSyncRulesOptions,
188
321
  storageVersion: number,
189
- storageConfig: StorageConfig
322
+ storageConfig: StorageConfig,
323
+ session: mongo.ClientSession
190
324
  ): Promise<MongoPersistedReplicationStream> {
191
- let rules: MongoPersistedReplicationStream | undefined = undefined;
192
325
  const versioned = this.db.versioned(storageConfig) as VersionedPowerSyncMongoV3;
193
326
 
194
- const session = this.session;
327
+ const active = await this.db.sync_rules.findOne<ReplicationStreamDocumentV3>(
328
+ {
329
+ state: storage.SyncRuleState.ACTIVE,
330
+ storage_version: storageVersion
331
+ },
332
+ { session, sort: { _id: -1 }, limit: 1 }
333
+ );
334
+ if (active != null) {
335
+ const existingConfigDocs = await this.loadSyncConfigDefinitions(versioned, active, session);
336
+
337
+ if (
338
+ !options.forceNewReplicationStream &&
339
+ this.options.supportsMultipleSyncConfigs &&
340
+ isCompatible(
341
+ existingConfigDocs.map((d) => d.serialized_plan ?? null),
342
+ options.config,
343
+ logger
344
+ )
345
+ ) {
346
+ logger.info(`Using incremental reprocessing`);
347
+ await this.stopExistingProcessingWork(session);
348
+ return await this.appendSyncConfigToStream({
349
+ versioned,
350
+ existing: active,
351
+ existingConfigDocs,
352
+ options,
353
+ storageVersion,
354
+ session
355
+ });
356
+ }
357
+ }
195
358
 
196
- await session.withTransaction(async () => {
197
- const active = await this.db.sync_rules.findOne<ReplicationStreamDocumentV3>(
198
- {
199
- state: storage.SyncRuleState.ACTIVE,
200
- storage_version: storageVersion
201
- },
202
- { session, sort: { _id: -1 }, limit: 1 }
203
- );
204
- if (active != null) {
205
- const existingConfigDocs = await this.loadSyncConfigDefinitions(versioned, active, session);
359
+ await this.stopExistingProcessingWork(session);
206
360
 
207
- if (
208
- this.options.supportsMultipleSyncConfigs &&
209
- isCompatible(
210
- existingConfigDocs.map((d) => d.serialized_plan ?? null),
211
- options.config,
212
- logger
213
- )
214
- ) {
215
- logger.info(`Using incremental reprocessing`);
216
- await this.db.sync_rules.updateMany(
217
- {
218
- state: storage.SyncRuleState.PROCESSING
219
- },
220
- syncRuleStateUpdatePipeline(storage.SyncRuleState.STOP),
221
- { session }
222
- );
223
- await this.stopEmbeddedDeployingConfigs(active, session);
224
- rules = await this.appendSyncConfigToStream({
225
- versioned,
226
- existing: active,
227
- existingConfigDocs,
228
- options,
229
- storageVersion,
230
- session
231
- });
232
- return;
361
+ const id_doc = await this.db.op_id_sequence.findOneAndUpdate(
362
+ {
363
+ // Uses the legacy id "sync_rules" for backwards-compatibility. See AGENTS.md.
364
+ _id: 'sync_rules'
365
+ },
366
+ {
367
+ $inc: {
368
+ op_id: 1n
233
369
  }
234
-
235
- await this.stopEmbeddedDeployingConfigs(active, session);
370
+ },
371
+ {
372
+ upsert: true,
373
+ returnDocument: 'after',
374
+ session
236
375
  }
376
+ );
237
377
 
238
- // Only have a single replication stream with PROCESSING.
239
- await this.db.sync_rules.updateMany(
240
- {
241
- state: storage.SyncRuleState.PROCESSING
242
- },
243
- syncRuleStateUpdatePipeline(storage.SyncRuleState.STOP),
244
- { session }
245
- );
378
+ const id = Number(id_doc!.op_id);
379
+ const replicationStreamName = generateReplicationStreamName(this.replicationStreamNamePrefix, id);
380
+
381
+ const mapping =
382
+ options.config.plan == null
383
+ ? // For legacy Sync Rules and Streams, use the parsed config directly to create a mapping
384
+ SingleSyncConfigBucketDefinitionMapping.fromParsedSyncConfig(options.config.parsed)
385
+ : // For new Sync Streams, always use the serialized version
386
+ SingleSyncConfigBucketDefinitionMapping.constructIncrementalMappingFromSerializedPlans(
387
+ [],
388
+ options.config.plan.plan,
389
+ []
390
+ );
246
391
 
247
- const id_doc = await this.db.op_id_sequence.findOneAndUpdate(
248
- {
249
- _id: 'sync_rules'
250
- },
251
- {
252
- $inc: {
253
- op_id: 1n
254
- }
255
- },
392
+ const syncConfigDoc: SyncConfigDefinition = {
393
+ _id: new ObjectId(),
394
+ replication_stream_id: id,
395
+ created_at: new Date(),
396
+ storage_version: storageVersion,
397
+ content: options.config.yaml,
398
+ version_label: options.version_label,
399
+ serialized_plan: options.config.plan,
400
+ rule_mapping: mapping.serialize()
401
+ };
402
+ await versioned.syncConfigDefinitions.insertOne(syncConfigDoc, { session });
403
+
404
+ const doc: ReplicationStreamDocumentV3 = {
405
+ _id: id,
406
+ storage_version: storageVersion,
407
+ sync_configs: [
256
408
  {
257
- upsert: true,
258
- returnDocument: 'after',
259
- session
409
+ _id: syncConfigDoc._id,
410
+ state: storage.SyncRuleState.PROCESSING,
411
+ last_checkpoint: null,
412
+ last_checkpoint_lsn: null,
413
+ no_checkpoint_before: null,
414
+ snapshot_done: false
260
415
  }
261
- );
262
-
263
- const id = Number(id_doc!.op_id);
264
- const replicationStreamName = generateReplicationStreamName(this.replicationStreamNamePrefix, id);
265
-
266
- const mapping =
267
- options.config.plan == null
268
- ? // For legacy sync rules and streams, use the parsed config directly to create a mapping
269
- SingleSyncConfigBucketDefinitionMapping.fromParsedSyncConfig(options.config.parsed)
270
- : // For new sync streams, always use the serialized version
271
- SingleSyncConfigBucketDefinitionMapping.constructIncrementalMappingFromSerializedPlans(
272
- [],
273
- options.config.plan.plan,
274
- []
275
- );
276
-
277
- const syncConfigDoc: SyncConfigDefinition = {
278
- _id: new ObjectId(),
279
- replication_stream_id: id,
280
- created_at: new Date(),
281
- storage_version: storageVersion,
282
- content: options.config.yaml,
283
- serialized_plan: options.config.plan,
284
- rule_mapping: mapping.serialize()
285
- };
286
- await versioned.syncConfigDefinitions.insertOne(syncConfigDoc, { session });
287
-
288
- const doc: ReplicationStreamDocumentV3 = {
289
- _id: id,
290
- storage_version: storageVersion,
291
- sync_configs: [
292
- {
293
- _id: syncConfigDoc._id,
294
- state: storage.SyncRuleState.PROCESSING,
295
- last_checkpoint: null,
296
- last_checkpoint_lsn: null,
297
- no_checkpoint_before: null,
298
- snapshot_done: false
299
- }
300
- ],
301
- snapshot_lsn: undefined,
302
- state: storage.SyncRuleState.PROCESSING,
303
- slot_name: replicationStreamName,
304
- last_checkpoint_ts: null,
305
- last_fatal_error: null,
306
- last_fatal_error_ts: null,
307
- last_keepalive_ts: null
308
- };
309
-
310
- await this.db.sync_rules.insertOne(doc, { session });
311
- rules = new MongoPersistedReplicationStream(this.db, doc, [syncConfigDoc]);
312
- if (options.lock) {
313
- // The lock is persisted on rules.current_lock
314
- await rules.lock(session);
315
- }
316
- });
317
-
318
- // Notify only after the transaction has committed, so listeners cannot observe pre-commit state.
319
- await this.db.notifyCheckpoint();
416
+ ],
417
+ snapshot_lsn: undefined,
418
+ state: storage.SyncRuleState.PROCESSING,
419
+ slot_name: replicationStreamName,
420
+ last_checkpoint_ts: null,
421
+ last_fatal_error: null,
422
+ last_fatal_error_ts: null,
423
+ last_keepalive_ts: null
424
+ };
320
425
 
321
- return rules!;
426
+ await this.db.sync_rules.insertOne(doc, { session });
427
+ const rules = new MongoPersistedReplicationStream(this.db, doc, [syncConfigDoc], this.syncConfigParser);
428
+ if (options.lock) {
429
+ // We only lock when creating a new stream - otherwise we'll likely get lock contention
430
+ // from an existing job on the stream, which would fail the entire transaction.
431
+ // The lock is persisted on rules.current_lock
432
+ await rules.lock(session);
433
+ }
434
+ return rules;
322
435
  }
323
436
 
324
437
  private async loadSyncConfigDefinitions(
@@ -363,31 +476,6 @@ export class MongoBucketStorage extends storage.BucketStorageFactory {
363
476
  .toArray();
364
477
  }
365
478
 
366
- private async stopEmbeddedDeployingConfigs(existing: ReplicationStreamDocumentV3, session: mongo.ClientSession) {
367
- const deployingConfigs = existing.sync_configs
368
- .filter((config) => config.state == storage.SyncRuleState.PROCESSING)
369
- .map((config) => config._id);
370
- if (deployingConfigs.length == 0) {
371
- return;
372
- }
373
-
374
- await this.db.sync_rules.updateOne(
375
- {
376
- _id: existing._id,
377
- 'sync_configs._id': { $in: deployingConfigs }
378
- },
379
- {
380
- $set: {
381
- 'sync_configs.$[config].state': storage.SyncRuleState.STOP
382
- }
383
- },
384
- {
385
- session,
386
- arrayFilters: [{ 'config._id': { $in: deployingConfigs } }]
387
- }
388
- );
389
- }
390
-
391
479
  private logIncrementalDefinitionChanges(changes: ReturnType<typeof describeIncrementalSyncConfigUpdate>) {
392
480
  logger.info(`Incremental reprocessing sync config update:\n${formatIncrementalSyncConfigUpdateLog(changes)}`);
393
481
  }
@@ -432,6 +520,7 @@ export class MongoBucketStorage extends storage.BucketStorageFactory {
432
520
  created_at: new Date(),
433
521
  storage_version: storageVersion,
434
522
  content: updateOptions.config.yaml,
523
+ version_label: updateOptions.version_label,
435
524
  serialized_plan: updateOptions.config.plan,
436
525
  rule_mapping: mapping.serialize()
437
526
  };
@@ -468,7 +557,8 @@ export class MongoBucketStorage extends storage.BucketStorageFactory {
468
557
  ...existing,
469
558
  sync_configs: syncConfigStates
470
559
  },
471
- [...existingConfigDocs, syncConfigDoc]
560
+ [...existingConfigDocs, syncConfigDoc],
561
+ this.syncConfigParser
472
562
  );
473
563
  // The stream already exists, so an active replication job may already hold the stream lock.
474
564
  // Deployment only persists the appended sync config; replication job locking is handled by
@@ -477,92 +567,144 @@ export class MongoBucketStorage extends storage.BucketStorageFactory {
477
567
  }
478
568
 
479
569
  async updateSyncRules(options: storage.UpdateSyncRulesOptions): Promise<MongoPersistedReplicationStream> {
480
- const storageVersion =
481
- options.storageVersion ?? options.config.parsed.config.storageVersion ?? storage.CURRENT_STORAGE_VERSION;
570
+ let rules: MongoPersistedReplicationStream | undefined;
571
+ await this.withTransaction(async (session) => {
572
+ rules = await this.updateSyncRulesInTransaction(options, session);
573
+ });
574
+ await this.db.notifyCheckpoint();
575
+ return rules!;
576
+ }
482
577
 
483
- const storageConfig = getMongoStorageConfig(storageVersion);
484
- if (storageConfig.incrementalReprocessing) {
485
- return this.updateSyncRulesV3(options, storageVersion, storageConfig);
486
- }
578
+ private async withTransaction<T>(callback: (session: mongo.ClientSession) => Promise<T>): Promise<T> {
579
+ return this.client.withSession((session) => session.withTransaction(callback));
580
+ }
581
+
582
+ /**
583
+ * Update to a V1 sync config.
584
+ *
585
+ * Does support cases where existing replication streams are on V3.
586
+ */
587
+ private async updateSyncConfigV1InTransaction(
588
+ options: storage.UpdateSyncRulesOptions,
589
+ storageVersion: number,
590
+ session: mongo.ClientSession
591
+ ): Promise<MongoPersistedReplicationStream> {
592
+ await this.stopExistingProcessingWork(session);
487
593
 
488
- let rules: MongoPersistedReplicationStream | undefined = undefined;
594
+ const id_doc = await this.db.op_id_sequence.findOneAndUpdate(
595
+ {
596
+ // Uses the legacy id "sync_rules" for backwards-compatibility. See AGENTS.md.
597
+ _id: 'sync_rules'
598
+ },
599
+ {
600
+ $inc: {
601
+ op_id: 1n
602
+ }
603
+ },
604
+ {
605
+ upsert: true,
606
+ returnDocument: 'after',
607
+ session
608
+ }
609
+ );
489
610
 
490
- const session = this.session;
611
+ const id = Number(id_doc!.op_id);
612
+ const slot_name = generateReplicationStreamName(this.replicationStreamNamePrefix, id);
491
613
 
492
- await session.withTransaction(async () => {
493
- // Only have a single replication stream with PROCESSING.
494
- await this.db.sync_rules.updateMany(
495
- {
496
- state: storage.SyncRuleState.PROCESSING
497
- },
498
- syncRuleStateUpdatePipeline(storage.SyncRuleState.STOP),
499
- { session }
500
- );
614
+ // All V1 replication streams share both the `main` op id sequence and the `bucket_parameters`
615
+ // collection, so every parameter entry this stream writes gets an op id above the current
616
+ // head. Seeding the parameter compaction cursor with that head keeps the stream's first
617
+ // compaction from scanning other streams' history, which would otherwise be repeated for
618
+ // every new deployment. A concurrent replication flush can only advance the head after this
619
+ // read, which makes the seed conservative, never too high.
620
+ const opSequence = await this.db.op_id_sequence.findOne({ _id: 'main' }, { session });
501
621
 
502
- const id_doc = await this.db.op_id_sequence.findOneAndUpdate(
503
- {
504
- _id: 'sync_rules'
505
- },
506
- {
507
- $inc: {
508
- op_id: 1n
509
- }
510
- },
511
- {
512
- upsert: true,
513
- returnDocument: 'after',
514
- session
515
- }
516
- );
622
+ const doc: SyncRuleDocumentV1 = {
623
+ _id: id,
624
+ storage_version: storageVersion,
625
+ content: options.config.yaml,
626
+ version_label: options.version_label,
627
+ serialized_plan: options.config.plan,
628
+ last_checkpoint: null,
629
+ last_checkpoint_lsn: null,
630
+ no_checkpoint_before: null,
631
+ keepalive_op: null,
632
+ snapshot_done: false,
633
+ snapshot_lsn: undefined,
634
+ state: storage.SyncRuleState.PROCESSING,
635
+ slot_name: slot_name,
636
+ last_checkpoint_ts: null,
637
+ last_fatal_error: null,
638
+ last_fatal_error_ts: null,
639
+ last_keepalive_ts: null,
640
+ parameter_compaction: { compacted_before: opSequence?.op_id ?? 0n }
641
+ };
642
+
643
+ await this.db.sync_rules.insertOne(doc, { session });
644
+ const rules = new MongoPersistedReplicationStream(this.db, doc, [], this.syncConfigParser);
645
+ if (options.lock) {
646
+ // The lock is persisted on rules.current_lock
647
+ await rules.lock(session);
648
+ }
649
+ return rules;
650
+ }
517
651
 
518
- const id = Number(id_doc!.op_id);
519
- const slot_name = generateReplicationStreamName(this.replicationStreamNamePrefix, id);
520
-
521
- const doc: SyncRuleDocumentV1 = {
522
- _id: id,
523
- storage_version: storageVersion,
524
- content: options.config.yaml,
525
- serialized_plan: options.config.plan,
526
- last_checkpoint: null,
527
- last_checkpoint_lsn: null,
528
- no_checkpoint_before: null,
529
- keepalive_op: null,
530
- snapshot_done: false,
531
- snapshot_lsn: undefined,
532
- state: storage.SyncRuleState.PROCESSING,
533
- slot_name: slot_name,
534
- last_checkpoint_ts: null,
535
- last_fatal_error: null,
536
- last_fatal_error_ts: null,
537
- last_keepalive_ts: null
538
- };
539
-
540
- await this.db.sync_rules.insertOne(doc, { session });
541
- await this.db.notifyCheckpoint();
542
- rules = new MongoPersistedReplicationStream(this.db, doc);
543
- if (options.lock) {
544
- // The lock is persisted on rules.current_lock
545
- await rules.lock(session);
652
+ /**
653
+ * Stop top-level and embedded processing work before creating a replacement stream.
654
+ *
655
+ * Use in the same transaction that the replacement stream is created.
656
+ */
657
+ private async stopExistingProcessingWork(session: mongo.ClientSession) {
658
+ await this.db.sync_rules.updateMany(
659
+ {
660
+ state: { $in: [storage.SyncRuleState.ACTIVE, storage.SyncRuleState.ERRORED] },
661
+ 'sync_configs.state': storage.SyncRuleState.PROCESSING
662
+ },
663
+ {
664
+ $set: {
665
+ 'sync_configs.$[config].state': storage.SyncRuleState.STOP
666
+ }
667
+ },
668
+ {
669
+ session,
670
+ arrayFilters: [{ 'config.state': storage.SyncRuleState.PROCESSING }]
546
671
  }
547
- });
672
+ );
548
673
 
549
- return rules!;
674
+ await this.db.sync_rules.updateMany(
675
+ {
676
+ state: storage.SyncRuleState.PROCESSING
677
+ },
678
+ stopReplicationStreamPipeline(),
679
+ { session }
680
+ );
550
681
  }
551
682
 
552
683
  async getActiveSyncConfig(): Promise<storage.ResolvedSyncConfig | null> {
684
+ return this.getActiveSyncConfigInternal();
685
+ }
686
+
687
+ private async getActiveSyncConfigInternal(session?: mongo.ClientSession): Promise<storage.ResolvedSyncConfig | null> {
553
688
  const doc = await this.db.sync_rules.findOne(
554
689
  {
555
690
  state: { $in: [storage.SyncRuleState.ACTIVE, storage.SyncRuleState.ERRORED] }
556
691
  },
557
- { sort: { _id: -1 }, limit: 1 }
692
+ { session, sort: { _id: -1 }, limit: 1 }
558
693
  );
559
694
 
560
- return this.resolvedSyncConfigFromDoc(doc, [storage.SyncRuleState.ACTIVE, storage.SyncRuleState.ERRORED], {
561
- cacheActiveStorage: true
562
- });
695
+ return this.resolvedSyncConfigFromDoc(
696
+ doc,
697
+ [storage.SyncRuleState.ACTIVE, storage.SyncRuleState.ERRORED],
698
+ { cacheActiveStorage: session == null },
699
+ session
700
+ );
563
701
  }
564
702
 
565
- private async replicationStreamFromDoc(doc: SyncRuleDocumentBase | null, stateFilter: storage.SyncRuleState[]) {
703
+ private async replicationStreamFromDoc(
704
+ doc: SyncRuleDocumentBase | null,
705
+ stateFilter: storage.SyncRuleState[],
706
+ session?: mongo.ClientSession
707
+ ) {
566
708
  if (doc == null) {
567
709
  return null;
568
710
  }
@@ -580,29 +722,38 @@ export class MongoBucketStorage extends storage.BucketStorageFactory {
580
722
  // It is not a major bottleneck though, since it only runs once every couple of seconds at most.
581
723
  const db = this.db.versioned(storageConfig) as VersionedPowerSyncMongoV3;
582
724
  const syncConfigDocs = await db.syncConfigDefinitions
583
- .find({
584
- _id: { $in: matching.map((config) => config._id) }
585
- })
725
+ .find(
726
+ {
727
+ _id: { $in: matching.map((config) => config._id) }
728
+ },
729
+ { session }
730
+ )
586
731
  .toArray();
587
732
 
588
733
  if (syncConfigDocs.length == 0) {
589
734
  return null;
590
735
  }
591
- return new MongoPersistedReplicationStream(this.db, v3, syncConfigDocs);
736
+ return new MongoPersistedReplicationStream(this.db, v3, syncConfigDocs, this.syncConfigParser);
592
737
  }
593
738
 
594
- return new MongoPersistedReplicationStream(this.db, doc as SyncRuleDocumentV1);
739
+ return new MongoPersistedReplicationStream(this.db, doc as SyncRuleDocumentV1, [], this.syncConfigParser);
595
740
  }
596
741
 
597
742
  async getDeployingSyncConfig(): Promise<storage.ResolvedSyncConfig | null> {
743
+ return this.getDeployingSyncConfigInternal();
744
+ }
745
+
746
+ private async getDeployingSyncConfigInternal(
747
+ session?: mongo.ClientSession
748
+ ): Promise<storage.ResolvedSyncConfig | null> {
598
749
  const doc = await this.db.sync_rules.findOne(
599
750
  {
600
751
  $or: [{ state: storage.SyncRuleState.PROCESSING }, { 'sync_configs.state': storage.SyncRuleState.PROCESSING }]
601
752
  },
602
- { sort: { _id: -1 }, limit: 1 }
753
+ { session, sort: { _id: -1 }, limit: 1 }
603
754
  );
604
755
 
605
- return this.resolvedSyncConfigFromDoc(doc, [storage.SyncRuleState.PROCESSING]);
756
+ return this.resolvedSyncConfigFromDoc(doc, [storage.SyncRuleState.PROCESSING], {}, session);
606
757
  }
607
758
 
608
759
  async getReplicatingReplicationStreams(): Promise<storage.PersistedReplicationStream[]> {
@@ -640,9 +791,10 @@ export class MongoBucketStorage extends storage.BucketStorageFactory {
640
791
  private async resolvedSyncConfigFromDoc(
641
792
  doc: SyncRuleDocumentBase | null,
642
793
  stateFilter: storage.SyncRuleState[],
643
- options: { cacheActiveStorage?: boolean } = {}
794
+ options: { cacheActiveStorage?: boolean } = {},
795
+ session?: mongo.ClientSession
644
796
  ): Promise<storage.ResolvedSyncConfig | null> {
645
- const stream = await this.replicationStreamFromDoc(doc, stateFilter);
797
+ const stream = await this.replicationStreamFromDoc(doc, stateFilter, session);
646
798
  if (stream == null) {
647
799
  return null;
648
800
  }
@@ -675,7 +827,7 @@ export class MongoBucketStorage extends storage.BucketStorageFactory {
675
827
 
676
828
  async getStorageMetrics(): Promise<storage.StorageMetrics> {
677
829
  const ignoreNotExisting = (e: unknown) => {
678
- if (lib_mongo.isMongoServerError(e) && e.codeName == 'NamespaceNotFound') {
830
+ if (lib_mongo.isMongoNamespaceNotFoundError(e)) {
679
831
  // Collection doesn't exist - return 0
680
832
  return [{ storageStats: { size: 0 } }];
681
833
  } else {
@@ -683,9 +835,6 @@ export class MongoBucketStorage extends storage.BucketStorageFactory {
683
835
  }
684
836
  };
685
837
 
686
- // For now, we get storage metrics over all v1 and v3 collections.
687
- // In the future, we may split these metrics to report separately for active replication streams versus processing streams.
688
-
689
838
  const aggregateStaticCollection = async <T extends mongo.Document>(collection: mongo.Collection<T>) => {
690
839
  // We check whether the collection exists before getting the statistics. This avoids repeated
691
840
  // errors in the MongoDB logs if the collection hasn't been created yet.
@@ -709,8 +858,9 @@ export class MongoBucketStorage extends storage.BucketStorageFactory {
709
858
  };
710
859
 
711
860
  const operations_aggregate = await aggregateStaticCollection(this.db.bucket_data);
861
+ const v3OperationCollections = await this.db.listBucketDataCollectionsV3();
712
862
  const v3_operation_aggregates = await Promise.all(
713
- (await this.db.listBucketDataCollectionsV3()).map((collection) =>
863
+ v3OperationCollections.map((collection) =>
714
864
  collection
715
865
  .aggregate([
716
866
  {
@@ -726,8 +876,9 @@ export class MongoBucketStorage extends storage.BucketStorageFactory {
726
876
 
727
877
  const parameters_aggregate = await aggregateStaticCollection(this.db.bucket_parameters);
728
878
 
879
+ const v3ParameterCollections = await this.db.listAllParameterIndexCollectionsV3();
729
880
  const v3_parameter_aggregates = await Promise.all(
730
- (await this.db.listAllParameterIndexCollectionsV3()).map((collection) =>
881
+ v3ParameterCollections.map((collection) =>
731
882
  collection
732
883
  .aggregate([
733
884
  {
@@ -743,8 +894,9 @@ export class MongoBucketStorage extends storage.BucketStorageFactory {
743
894
 
744
895
  const v1_source_record_aggregate = await aggregateStaticCollection(this.db.current_data);
745
896
 
897
+ const v3SourceRecordCollections = await this.db.listAllSourceRecordCollectionsV3();
746
898
  const source_record_aggregates = await Promise.all(
747
- (await this.db.listAllSourceRecordCollectionsV3()).map((collection) =>
899
+ v3SourceRecordCollections.map((collection) =>
748
900
  collection
749
901
  .aggregate([
750
902
  {
@@ -757,6 +909,120 @@ export class MongoBucketStorage extends storage.BucketStorageFactory {
757
909
  .catch(ignoreNotExisting)
758
910
  )
759
911
  );
912
+
913
+ const v3StorageConfig = getMongoStorageConfig(storage.STORAGE_VERSION_3) as StorageConfig & {
914
+ incrementalReprocessing: true;
915
+ };
916
+ const v3Db = this.db.versioned(v3StorageConfig) as VersionedPowerSyncMongoV3;
917
+ const objectStorageDefinitionUsage = await ObjectStorageUsage.readAllDefinitionUsage(v3Db);
918
+
919
+ const v3StreamDocs = (await this.db.sync_rules
920
+ .find({ storage_version: { $gte: storage.STORAGE_VERSION_3 } }, { projection: { _id: 1, sync_configs: 1 } })
921
+ .toArray()) as unknown as Pick<ReplicationStreamDocumentV3, '_id' | 'sync_configs'>[];
922
+
923
+ const collectionSizes = new Map<string, number>();
924
+ const addCollectionSize = (
925
+ collection: { collectionName: string },
926
+ aggregate: { storageStats?: { size?: number | bigint } }[],
927
+ prefix: string,
928
+ sizes: Map<string, number>
929
+ ) => {
930
+ const match = collection.collectionName.match(new RegExp(`^${prefix}(\\d+)_(.+)$`));
931
+ if (match == null) {
932
+ return;
933
+ }
934
+ sizes.set(`${prefix}${match[1]}:${match[2]}`, Number(aggregate[0]?.storageStats?.size ?? 0));
935
+ };
936
+
937
+ v3OperationCollections.forEach((collection, index) =>
938
+ addCollectionSize(collection, v3_operation_aggregates[index], 'bucket_data_', collectionSizes)
939
+ );
940
+ v3ParameterCollections.forEach((collection, index) =>
941
+ addCollectionSize(collection, v3_parameter_aggregates[index], 'parameter_index_', collectionSizes)
942
+ );
943
+ v3SourceRecordCollections.forEach((collection, index) =>
944
+ addCollectionSize(collection, source_record_aggregates[index], 'source_records_', collectionSizes)
945
+ );
946
+
947
+ const syncConfigIds = v3StreamDocs.flatMap((stream) => (stream.sync_configs ?? []).map((config) => config._id));
948
+ const syncConfigDefinitions =
949
+ syncConfigIds.length == 0
950
+ ? []
951
+ : await v3Db.syncConfigDefinitions
952
+ .find({ _id: { $in: syncConfigIds } }, { projection: { _id: 1, rule_mapping: 1, version_label: 1 } })
953
+ .toArray();
954
+ const syncConfigDefinitionsById = new Map(
955
+ syncConfigDefinitions.map((definition) => [definition._id.toHexString(), definition])
956
+ );
957
+
958
+ const sourceTablesByStream = new Map<
959
+ number,
960
+ { _id: ObjectId; bucket_data_source_ids: string[]; parameter_lookup_source_ids: string[] }[]
961
+ >();
962
+ await Promise.all(
963
+ v3StreamDocs.map(async (stream) => {
964
+ const sourceTables = await v3Db
965
+ .sourceTables(stream._id)
966
+ .find({}, { projection: { _id: 1, bucket_data_source_ids: 1, parameter_lookup_source_ids: 1 } })
967
+ .toArray();
968
+ sourceTablesByStream.set(stream._id, sourceTables);
969
+ })
970
+ );
971
+
972
+ const objectStorageSizeByDefinition = new Map<string, number>();
973
+ for (const usage of objectStorageDefinitionUsage) {
974
+ objectStorageSizeByDefinition.set(
975
+ `${usage.replication_stream_id}:${usage.definition_id}`,
976
+ Number(usage.active_bytes)
977
+ );
978
+ }
979
+
980
+ const sumCollectionSizes = (prefix: string, streamId: number, ids: ReadonlySet<string>) =>
981
+ [...ids].reduce((total, id) => total + (collectionSizes.get(`${prefix}${streamId}:${id}`) ?? 0), 0);
982
+
983
+ const syncConfigMetrics: storage.StorageSyncConfigMetrics[] = [];
984
+ for (const stream of v3StreamDocs) {
985
+ const sourceTables = sourceTablesByStream.get(stream._id) ?? [];
986
+ for (const syncConfig of stream.sync_configs ?? []) {
987
+ const definition = syncConfigDefinitionsById.get(syncConfig._id.toHexString());
988
+ if (definition == null) {
989
+ continue;
990
+ }
991
+
992
+ const mapping = SingleSyncConfigBucketDefinitionMapping.fromPersistedMapping(definition.rule_mapping);
993
+ const bucketDefinitionIds = mapping.allBucketDefinitionIds();
994
+ const parameterIndexIds = mapping.allParameterIndexIds();
995
+ const bucketDefinitionIdSet = new Set(bucketDefinitionIds);
996
+ const parameterIndexIdSet = new Set(parameterIndexIds);
997
+ const replicationSize = sourceTables.reduce((total, sourceTable) => {
998
+ if (
999
+ !(sourceTable.bucket_data_source_ids ?? []).some((id) => bucketDefinitionIdSet.has(id)) &&
1000
+ !(sourceTable.parameter_lookup_source_ids ?? []).some((id) => parameterIndexIdSet.has(id))
1001
+ ) {
1002
+ return total;
1003
+ }
1004
+ return total + (collectionSizes.get(`source_records_${stream._id}:${sourceTable._id.toHexString()}`) ?? 0);
1005
+ }, 0);
1006
+
1007
+ syncConfigMetrics.push({
1008
+ sync_config_id: syncConfig._id.toHexString(),
1009
+ sync_config_state: String(syncConfig.state),
1010
+ version_label: definition.version_label,
1011
+ attributed_bucket_data_bytes: sumCollectionSizes('bucket_data_', stream._id, bucketDefinitionIdSet),
1012
+ attributed_parameter_indexes_bytes: sumCollectionSizes('parameter_index_', stream._id, parameterIndexIdSet),
1013
+ attributed_source_records_bytes: replicationSize,
1014
+ attributed_object_storage_bytes: [...bucketDefinitionIdSet].reduce(
1015
+ (total, definitionId) => total + (objectStorageSizeByDefinition.get(`${stream._id}:${definitionId}`) ?? 0),
1016
+ 0
1017
+ )
1018
+ });
1019
+ }
1020
+ }
1021
+
1022
+ const totalObjectStorageSize = objectStorageDefinitionUsage.reduce(
1023
+ (total, usage) => total + usage.active_bytes,
1024
+ 0n
1025
+ );
760
1026
  return {
761
1027
  operations_size_bytes:
762
1028
  Number(operations_aggregate[0].storageStats.size) +
@@ -766,7 +1032,9 @@ export class MongoBucketStorage extends storage.BucketStorageFactory {
766
1032
  v3_parameter_aggregates.reduce((total, aggregate) => total + Number(aggregate[0].storageStats.size), 0),
767
1033
  replication_size_bytes:
768
1034
  Number(v1_source_record_aggregate[0]?.storageStats?.size ?? 0) +
769
- source_record_aggregates.reduce((total, aggregate) => total + Number(aggregate[0]?.storageStats?.size ?? 0), 0)
1035
+ source_record_aggregates.reduce((total, aggregate) => total + Number(aggregate[0]?.storageStats?.size ?? 0), 0),
1036
+ object_storage_size_bytes: Number(totalObjectStorageSize),
1037
+ sync_config_metrics: syncConfigMetrics
770
1038
  };
771
1039
  }
772
1040