@powersync/service-module-mongodb-storage 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/dist/storage/MongoBucketStorage.d.ts +36 -3
  3. package/dist/storage/MongoBucketStorage.js +372 -207
  4. package/dist/storage/MongoBucketStorage.js.map +1 -1
  5. package/dist/storage/implementation/MongoBucketBatch.d.ts +17 -5
  6. package/dist/storage/implementation/MongoBucketBatch.js +44 -17
  7. package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
  8. package/dist/storage/implementation/MongoCompactor.d.ts +19 -79
  9. package/dist/storage/implementation/MongoCompactor.js +27 -238
  10. package/dist/storage/implementation/MongoCompactor.js.map +1 -1
  11. package/dist/storage/implementation/MongoParameterCompactor.d.ts +83 -10
  12. package/dist/storage/implementation/MongoParameterCompactor.js +309 -96
  13. package/dist/storage/implementation/MongoParameterCompactor.js.map +1 -1
  14. package/dist/storage/implementation/MongoParsedSyncConfigSet.d.ts +9 -1
  15. package/dist/storage/implementation/MongoParsedSyncConfigSet.js +29 -0
  16. package/dist/storage/implementation/MongoParsedSyncConfigSet.js.map +1 -1
  17. package/dist/storage/implementation/MongoPersistedSyncConfigContent.js +4 -2
  18. package/dist/storage/implementation/MongoPersistedSyncConfigContent.js.map +1 -1
  19. package/dist/storage/implementation/MongoStorageProvider.js +7 -3
  20. package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
  21. package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +27 -11
  22. package/dist/storage/implementation/MongoSyncBucketStorage.js +78 -31
  23. package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
  24. package/dist/storage/implementation/MongoSyncRulesLock.js +21 -9
  25. package/dist/storage/implementation/MongoSyncRulesLock.js.map +1 -1
  26. package/dist/storage/implementation/MongoWriteBatch.d.ts +49 -0
  27. package/dist/storage/implementation/MongoWriteBatch.js +149 -0
  28. package/dist/storage/implementation/MongoWriteBatch.js.map +1 -0
  29. package/dist/storage/implementation/MongoWriteCheckpointAPI.d.ts +9 -7
  30. package/dist/storage/implementation/MongoWriteCheckpointAPI.js +9 -30
  31. package/dist/storage/implementation/MongoWriteCheckpointAPI.js.map +1 -1
  32. package/dist/storage/implementation/SyncRuleStateUpdate.d.ts +11 -5
  33. package/dist/storage/implementation/SyncRuleStateUpdate.js +14 -6
  34. package/dist/storage/implementation/SyncRuleStateUpdate.js.map +1 -1
  35. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.d.ts +22 -2
  36. package/dist/storage/implementation/common/PersistedBatch.d.ts +32 -4
  37. package/dist/storage/implementation/common/PersistedBatch.js +49 -6
  38. package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
  39. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.d.ts +3 -0
  40. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js +3 -0
  41. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js.map +1 -1
  42. package/dist/storage/implementation/db.d.ts +9 -1
  43. package/dist/storage/implementation/db.js +27 -1
  44. package/dist/storage/implementation/db.js.map +1 -1
  45. package/dist/storage/implementation/models.d.ts +31 -0
  46. package/dist/storage/implementation/models.js.map +1 -1
  47. package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +2 -0
  48. package/dist/storage/implementation/v1/MongoBucketBatchV1.js +33 -4
  49. package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
  50. package/dist/storage/implementation/v1/MongoCompactorV1.d.ts +38 -6
  51. package/dist/storage/implementation/v1/MongoCompactorV1.js +247 -20
  52. package/dist/storage/implementation/v1/MongoCompactorV1.js.map +1 -1
  53. package/dist/storage/implementation/v1/MongoParameterCompactorV1.d.ts +16 -2
  54. package/dist/storage/implementation/v1/MongoParameterCompactorV1.js +20 -9
  55. package/dist/storage/implementation/v1/MongoParameterCompactorV1.js.map +1 -1
  56. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.d.ts +18 -11
  57. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +65 -16
  58. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
  59. package/dist/storage/implementation/v1/PersistedBatchV1.d.ts +7 -5
  60. package/dist/storage/implementation/v1/PersistedBatchV1.js +22 -28
  61. package/dist/storage/implementation/v1/PersistedBatchV1.js.map +1 -1
  62. package/dist/storage/implementation/v1/models.js +1 -0
  63. package/dist/storage/implementation/v1/models.js.map +1 -1
  64. package/dist/storage/implementation/v3/CompactionLease.d.ts +50 -0
  65. package/dist/storage/implementation/v3/CompactionLease.js +131 -0
  66. package/dist/storage/implementation/v3/CompactionLease.js.map +1 -0
  67. package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +10 -2
  68. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +100 -26
  69. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  70. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +82 -25
  71. package/dist/storage/implementation/v3/MongoCompactorV3.js +698 -159
  72. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  73. package/dist/storage/implementation/v3/MongoParameterCompactorV3.d.ts +18 -0
  74. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js +23 -0
  75. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js.map +1 -0
  76. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.d.ts +2 -15
  77. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js +60 -92
  78. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js.map +1 -1
  79. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.d.ts +19 -9
  80. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +91 -31
  81. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
  82. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.d.ts +26 -0
  83. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.js +82 -0
  84. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.js.map +1 -0
  85. package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +14 -8
  86. package/dist/storage/implementation/v3/PersistedBatchV3.js +76 -42
  87. package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
  88. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.d.ts +12 -2
  89. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js +42 -3
  90. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js.map +1 -1
  91. package/dist/storage/implementation/v3/bucket-format.js +2 -0
  92. package/dist/storage/implementation/v3/bucket-format.js.map +1 -1
  93. package/dist/storage/implementation/v3/compact-utils.d.ts +107 -0
  94. package/dist/storage/implementation/v3/compact-utils.js +216 -0
  95. package/dist/storage/implementation/v3/compact-utils.js.map +1 -0
  96. package/dist/storage/implementation/v3/compaction-constants.d.ts +5 -0
  97. package/dist/storage/implementation/v3/compaction-constants.js +6 -0
  98. package/dist/storage/implementation/v3/compaction-constants.js.map +1 -0
  99. package/dist/storage/implementation/v3/models.d.ts +84 -4
  100. package/dist/storage/implementation/v3/models.js.map +1 -1
  101. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.d.ts +7 -8
  102. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +16 -5
  103. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -1
  104. package/dist/storage/implementation/v3/object-storage/ObjectStorage.d.ts +14 -11
  105. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +6 -8
  106. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +17 -11
  107. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -1
  108. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.d.ts +38 -0
  109. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js +164 -0
  110. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js.map +1 -0
  111. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.d.ts +37 -12
  112. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js +197 -25
  113. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js.map +1 -1
  114. package/dist/storage/implementation/v3/source-table-utils.d.ts +6 -6
  115. package/dist/storage/implementation/v3/source-table-utils.js +30 -37
  116. package/dist/storage/implementation/v3/source-table-utils.js.map +1 -1
  117. package/dist/storage/storage-index.d.ts +2 -1
  118. package/dist/storage/storage-index.js +1 -0
  119. package/dist/storage/storage-index.js.map +1 -1
  120. package/dist/types/types.d.ts +28 -1
  121. package/dist/types/types.js +28 -7
  122. package/dist/types/types.js.map +1 -1
  123. package/dist/utils/test-utils.js +1 -0
  124. package/dist/utils/test-utils.js.map +1 -1
  125. package/dist/utils/util.js +3 -1
  126. package/dist/utils/util.js.map +1 -1
  127. package/package.json +10 -9
  128. package/src/storage/MongoBucketStorage.ts +507 -269
  129. package/src/storage/implementation/MongoBucketBatch.ts +67 -24
  130. package/src/storage/implementation/MongoCompactor.ts +35 -346
  131. package/src/storage/implementation/MongoParameterCompactor.ts +388 -102
  132. package/src/storage/implementation/MongoParsedSyncConfigSet.ts +34 -0
  133. package/src/storage/implementation/MongoPersistedSyncConfigContent.ts +4 -2
  134. package/src/storage/implementation/MongoStorageProvider.ts +15 -3
  135. package/src/storage/implementation/MongoSyncBucketStorage.ts +114 -40
  136. package/src/storage/implementation/MongoSyncRulesLock.ts +22 -9
  137. package/src/storage/implementation/MongoWriteBatch.ts +172 -0
  138. package/src/storage/implementation/MongoWriteCheckpointAPI.ts +16 -46
  139. package/src/storage/implementation/SyncRuleStateUpdate.ts +13 -6
  140. package/src/storage/implementation/common/MongoSyncBucketStorageCheckpoint.ts +24 -2
  141. package/src/storage/implementation/common/PersistedBatch.ts +66 -10
  142. package/src/storage/implementation/common/VersionedPowerSyncMongoBase.ts +4 -0
  143. package/src/storage/implementation/db.ts +35 -2
  144. package/src/storage/implementation/models.ts +34 -0
  145. package/src/storage/implementation/v1/MongoBucketBatchV1.ts +46 -6
  146. package/src/storage/implementation/v1/MongoCompactorV1.ts +327 -40
  147. package/src/storage/implementation/v1/MongoParameterCompactorV1.ts +25 -9
  148. package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +87 -27
  149. package/src/storage/implementation/v1/PersistedBatchV1.ts +25 -30
  150. package/src/storage/implementation/v1/models.ts +1 -0
  151. package/src/storage/implementation/v3/CompactionLease.ts +152 -0
  152. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +138 -34
  153. package/src/storage/implementation/v3/MongoCompactorV3.ts +958 -225
  154. package/src/storage/implementation/v3/MongoParameterCompactorV3.ts +34 -0
  155. package/src/storage/implementation/v3/MongoStoppedSyncConfigCleanup.ts +100 -109
  156. package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +111 -41
  157. package/src/storage/implementation/v3/MongoWriteCheckpointAPIV3.ts +111 -0
  158. package/src/storage/implementation/v3/PersistedBatchV3.ts +93 -48
  159. package/src/storage/implementation/v3/VersionedPowerSyncMongoV3.ts +55 -4
  160. package/src/storage/implementation/v3/bucket-format.ts +2 -0
  161. package/src/storage/implementation/v3/compact-utils.ts +320 -0
  162. package/src/storage/implementation/v3/compaction-constants.ts +5 -0
  163. package/src/storage/implementation/v3/models.ts +91 -3
  164. package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +24 -9
  165. package/src/storage/implementation/v3/object-storage/ObjectStorage.ts +20 -5
  166. package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +26 -14
  167. package/src/storage/implementation/v3/object-storage/ObjectStorageUsage.ts +229 -0
  168. package/src/storage/implementation/v3/object-storage/S3ObjectStorage.ts +279 -29
  169. package/src/storage/implementation/v3/source-table-utils.ts +39 -41
  170. package/src/storage/storage-index.ts +2 -0
  171. package/src/types/types.ts +42 -7
  172. package/src/utils/test-utils.ts +1 -0
  173. package/src/utils/util.ts +3 -1
  174. package/test/src/__snapshots__/storage.test.ts.snap +25 -0
  175. package/test/src/__snapshots__/storage_sync.test.ts.snap +605 -16
  176. package/test/src/chunk_compaction_config.test.ts +38 -0
  177. package/test/src/cleanup-stopped-sync-configs.test.ts +40 -13
  178. package/test/src/compact-utils.test.ts +226 -0
  179. package/test/src/helpers/MemoryObjectStorage.ts +14 -6
  180. package/test/src/object_storage_usage.test.ts +267 -0
  181. package/test/src/parameter_compacting_v1.test.ts +120 -0
  182. package/test/src/parameter_compacting_v3.test.ts +192 -0
  183. package/test/src/parameter_compaction_fence.test.ts +276 -0
  184. package/test/src/storage.test.ts +273 -22
  185. package/test/src/storage_compacting.test.ts +968 -246
  186. package/test/src/storage_s3_checksums.test.ts +3 -4
  187. package/test/src/storage_s3_compaction_lifecycle.test.ts +138 -12
  188. package/test/src/storage_s3_reading.test.ts +290 -2
  189. package/test/src/storage_s3_writing.test.ts +1 -0
  190. package/test/src/storage_sync.test.ts +336 -41
  191. package/test/src/util.ts +1 -1
  192. package/tsconfig.tsbuildinfo +1 -1
@@ -6,6 +6,7 @@ import * as bson from 'bson';
6
6
  import { BucketDataSource, BucketDefinitionId } from '@powersync/service-sync-rules';
7
7
  import { mongoTableId } from '../../../utils/util.js';
8
8
  import { EMPTY_DATA } from '../MongoBucketBatchShared.js';
9
+ import { MongoWriteBatch } from '../MongoWriteBatch.js';
9
10
  import {
10
11
  BucketStateUpdate,
11
12
  PersistedBatch,
@@ -25,7 +26,9 @@ import {
25
26
  export class PersistedBatchV1 extends PersistedBatch {
26
27
  declare protected readonly db: VersionedPowerSyncMongoV1;
27
28
 
28
- currentData: mongo.AnyBulkWriteOperation<CurrentDataDocument>[] = [];
29
+ // Each upsert supplies the complete source-record state. Only its final
30
+ // state matters within a flush; bucket and parameter history is kept separately.
31
+ currentData = new Map<string, mongo.AnyBulkWriteOperation<CurrentDataDocument>>();
29
32
 
30
33
  protected checkDefinitionId(_definitionId: BucketDefinitionId | null): BucketDefinitionId {
31
34
  // V1 storage doesn't persist the id, and we don't use it.
@@ -94,7 +97,7 @@ export class PersistedBatchV1 extends PersistedBatch {
94
97
  }
95
98
 
96
99
  hardDeleteCurrentData(sourceTableId: bson.ObjectId, replicaId: storage.ReplicaId) {
97
- this.currentData.push({
100
+ this.currentData.set(this.currentDataKey(sourceTableId, replicaId), {
98
101
  deleteOne: {
99
102
  filter: { _id: this.currentDataId(sourceTableId, replicaId) }
100
103
  }
@@ -124,7 +127,7 @@ export class PersistedBatchV1 extends PersistedBatch {
124
127
  return lookup.lookup;
125
128
  });
126
129
 
127
- this.currentData.push({
130
+ this.currentData.set(this.currentDataKey(values.sourceTableId, values.replicaId), {
128
131
  updateOne: {
129
132
  filter: { _id: this.currentDataId(values.sourceTableId, values.replicaId) },
130
133
  update: {
@@ -141,57 +144,45 @@ export class PersistedBatchV1 extends PersistedBatch {
141
144
  }
142
145
 
143
146
  protected get currentDataCount() {
144
- return this.currentData.length;
147
+ return this.currentData.size;
145
148
  }
146
149
 
147
- protected async flushBucketData(session: mongo.ClientSession) {
148
- await this.db.bucketDataV1.bulkWrite(
150
+ protected async queueBucketData(writes: MongoWriteBatch) {
151
+ writes.bulkWriteUnordered(
152
+ this.db.bucketDataV1,
149
153
  this.bucketData.map((document) => ({
150
154
  insertOne: {
151
155
  document: serializeBucketDataV1(document)
152
156
  }
153
- })),
154
- {
155
- session,
156
- ordered: false
157
- }
157
+ }))
158
158
  );
159
159
  }
160
160
 
161
- protected async flushBucketParameters(session: mongo.ClientSession) {
162
- await this.db.parameterIndexV1.bulkWrite(
161
+ protected queueBucketParameters(writes: MongoWriteBatch): void {
162
+ writes.bulkWriteUnordered(
163
+ this.db.parameterIndexV1,
163
164
  this.bucketParameters.map((document) => ({
164
165
  insertOne: {
165
166
  document: taggedBucketParameterDocumentToV1(document)
166
167
  }
167
- })),
168
- {
169
- session,
170
- ordered: false
171
- }
168
+ }))
172
169
  );
173
170
  }
174
171
 
175
- protected async flushCurrentData(session: mongo.ClientSession) {
176
- if (this.currentData.length == 0) {
172
+ protected queueCurrentData(writes: MongoWriteBatch): void {
173
+ if (this.currentData.size == 0) {
177
174
  return;
178
175
  }
179
176
 
180
- await this.db.sourceRecordsV1.bulkWrite(this.currentData, {
181
- session,
182
- ordered: true
183
- });
177
+ writes.bulkWriteUnordered(this.db.sourceRecordsV1, [...this.currentData.values()]);
184
178
  }
185
179
 
186
- protected async flushBucketStates(session: mongo.ClientSession) {
187
- await this.db.bucketStateV1.bulkWrite(this.getBucketStateUpdates(), {
188
- session,
189
- ordered: false
190
- });
180
+ protected queueBucketStates(writes: MongoWriteBatch): void {
181
+ writes.bulkWriteUnordered(this.db.bucketStateV1, this.getBucketStateUpdates());
191
182
  }
192
183
 
193
184
  protected resetCurrentData() {
194
- this.currentData = [];
185
+ this.currentData.clear();
195
186
  }
196
187
 
197
188
  private getBucketStateUpdates(): mongo.AnyBulkWriteOperation<BucketStateDocumentV1>[] {
@@ -219,6 +210,10 @@ export class PersistedBatchV1 extends PersistedBatch {
219
210
  });
220
211
  }
221
212
 
213
+ private currentDataKey(sourceTableId: bson.ObjectId, replicaId: storage.ReplicaId): string {
214
+ return Buffer.from(bson.serialize({ t: sourceTableId, k: replicaId })).toString('base64');
215
+ }
216
+
222
217
  private currentDataId(sourceTableId: bson.ObjectId, replicaId: storage.ReplicaId): SourceKey {
223
218
  return {
224
219
  g: this.group_id,
@@ -48,6 +48,7 @@ export function serializeBucketDataV1(document: BucketDataDoc): BucketDataDocume
48
48
  op: document.op,
49
49
  source_table: document.source_table,
50
50
  source_key: document.source_key,
51
+ subkey: document.subkey,
51
52
  table: document.table,
52
53
  row_id: document.row_id,
53
54
  checksum: document.checksum,
@@ -0,0 +1,152 @@
1
+ import { mongo } from '@powersync/lib-service-mongodb';
2
+ import { InternalOpId } from '@powersync/service-core';
3
+ import { CompactionLeaseLostError } from '../MongoCompactor.js';
4
+ import { BucketStateDocumentV3 } from './models.js';
5
+
6
+ const LEASE_RENEW_INTERVAL_MS = 60 * 1000;
7
+
8
+ /**
9
+ * Filters for buckets that have no lease or an expired lease.
10
+ */
11
+ export const AVAILABLE_LEASE_EXPR = {
12
+ // $$NOW is evaluated by MongoDB, avoiding lease expiry races due
13
+ // to clocks on separate compact workers.
14
+ $expr: {
15
+ $or: [{ $eq: [{ $type: '$compact_lease' }, 'missing'] }, { $lte: ['$compact_lease.expires_at', '$$NOW'] }]
16
+ }
17
+ };
18
+
19
+ /**
20
+ * Owns one V3 bucket-compaction lease, including its server-time renewal and
21
+ * the owner-fenced operations which release it.
22
+ *
23
+ * This is intended as a way to reduce redundant work if multiple jobs attempt to
24
+ * compact the same bucket concurrently, but it's not an absolute safety guarantee.
25
+ * The individual operations must still be designed to be safe with concurrent compacting.
26
+ */
27
+ export class CompactionLease implements AsyncDisposable {
28
+ readonly startedAt: Date;
29
+ readonly lastOp: InternalOpId;
30
+
31
+ private timer: NodeJS.Timeout | undefined;
32
+ private renewalInFlight = false;
33
+ private renewalError: unknown;
34
+ private finalizing = false;
35
+ private finished = false;
36
+
37
+ private constructor(
38
+ private readonly collection: mongo.Collection<BucketStateDocumentV3>,
39
+ readonly state: BucketStateDocumentV3,
40
+ readonly id: mongo.ObjectId,
41
+ private readonly durationMs: number
42
+ ) {
43
+ this.startedAt = new Date(state.compact_lease!.expires_at.getTime() - durationMs);
44
+ this.lastOp = state.last_op;
45
+ }
46
+
47
+ static async claim(
48
+ collection: mongo.Collection<BucketStateDocumentV3>,
49
+ filter: mongo.Filter<BucketStateDocumentV3>,
50
+ sort: mongo.Sort | undefined,
51
+ durationMs: number
52
+ ): Promise<CompactionLease | null> {
53
+ const id = new mongo.ObjectId();
54
+ const state = await collection.findOneAndUpdate(
55
+ {
56
+ $and: [filter, AVAILABLE_LEASE_EXPR]
57
+ },
58
+ [
59
+ {
60
+ $set: {
61
+ compact_lease: {
62
+ id,
63
+ expires_at: { $dateAdd: { startDate: '$$NOW', unit: 'millisecond', amount: durationMs } }
64
+ }
65
+ }
66
+ }
67
+ ],
68
+ { sort, returnDocument: 'after' }
69
+ );
70
+ return state == null ? null : new CompactionLease(collection, state, id, durationMs);
71
+ }
72
+
73
+ startRenewal() {
74
+ const interval = Math.max(1, Math.min(LEASE_RENEW_INTERVAL_MS, Math.floor(this.durationMs / 2)));
75
+ this.timer = setInterval(() => {
76
+ if (this.renewalInFlight) {
77
+ return;
78
+ }
79
+ this.renewalInFlight = true;
80
+ void this.renew()
81
+ .catch((error) => {
82
+ this.renewalError = error;
83
+ })
84
+ .finally(() => {
85
+ this.renewalInFlight = false;
86
+ });
87
+ }, interval);
88
+ this.timer.unref();
89
+ }
90
+
91
+ async throwIfLost() {
92
+ if (this.renewalError != null) {
93
+ throw this.renewalError;
94
+ }
95
+ }
96
+
97
+ async reschedule(nextCompactCheck: mongo.Document) {
98
+ await this.finish([{ $set: { next_compact_check: nextCompactCheck } }, { $unset: 'compact_lease' }]);
99
+ }
100
+
101
+ async finalize(update: mongo.Document) {
102
+ await this.finish([{ $set: update }, { $unset: 'compact_lease' }]);
103
+ }
104
+
105
+ async [Symbol.asyncDispose]() {
106
+ if (this.timer != null) {
107
+ clearInterval(this.timer);
108
+ this.timer = undefined;
109
+ }
110
+ if (this.finished) {
111
+ return;
112
+ }
113
+ this.finalizing = true;
114
+ await this.collection.updateOne(this.filter, { $unset: { compact_lease: '' } });
115
+ this.finished = true;
116
+ }
117
+
118
+ private get filter(): mongo.Filter<BucketStateDocumentV3> {
119
+ return { _id: this.state._id, 'compact_lease.id': this.id };
120
+ }
121
+
122
+ private async renew() {
123
+ const result = await this.collection.updateOne(this.filter, [
124
+ {
125
+ $set: {
126
+ 'compact_lease.expires_at': {
127
+ $dateAdd: { startDate: '$$NOW', unit: 'millisecond', amount: this.durationMs }
128
+ }
129
+ }
130
+ }
131
+ ]);
132
+ // A finalization in progress owns the authoritative fenced result.
133
+ if (result.matchedCount != 1 && !this.finalizing) {
134
+ throw new CompactionLeaseLostError(`Lost compaction lease for bucket ${this.state._id.b}`);
135
+ }
136
+ // A successful renewal confirms that a transient failure has passed. A
137
+ // lease-loss error remains sticky: it means another worker may own it.
138
+ if (!(this.renewalError instanceof CompactionLeaseLostError)) {
139
+ this.renewalError = undefined;
140
+ }
141
+ }
142
+
143
+ private async finish(update: mongo.Document[]) {
144
+ await this.throwIfLost();
145
+ this.finalizing = true;
146
+ const result = await this.collection.updateOne(this.filter, update);
147
+ if (result.matchedCount != 1) {
148
+ throw new CompactionLeaseLostError(`Lost compaction lease for bucket ${this.state._id.b}`);
149
+ }
150
+ this.finished = true;
151
+ }
152
+ }
@@ -1,21 +1,22 @@
1
1
  import * as lib_mongo from '@powersync/lib-service-mongodb';
2
- import { ReplicationAssertionError } from '@powersync/lib-services-framework';
3
- import { storage } from '@powersync/service-core';
2
+ import { ReplicationAssertionError, ServiceAssertionError } from '@powersync/lib-services-framework';
3
+ import { InternalOpId, storage } from '@powersync/service-core';
4
+ import { EventDefinitionId } from '@powersync/service-sync-rules';
4
5
  import * as bson from 'bson';
5
6
  import { mongoTableId } from '../../../utils/util.js';
6
7
  import { canCheckpointState } from '../CheckpointState.js';
7
8
  import { MongoBucketBatch, MongoBucketBatchOptions } from '../MongoBucketBatch.js';
8
9
  import { MongoParsedSyncConfigSet } from '../MongoParsedSyncConfigSet.js';
9
- import { syncRuleStateUpdatePipeline } from '../SyncRuleStateUpdate.js';
10
+ import { MongoWriteBatch } from '../MongoWriteBatch.js';
11
+ import { stopReplicationStreamPipeline } from '../SyncRuleStateUpdate.js';
10
12
  import { PersistedBatch } from '../common/PersistedBatch.js';
11
13
  import { SourceRecordStore } from '../common/SourceRecordStore.js';
12
14
  import { PersistedBatchV3 } from './PersistedBatchV3.js';
13
15
  import { SourceRecordStoreV3 } from './SourceRecordStoreV3.js';
14
16
  import { VersionedPowerSyncMongoV3 } from './VersionedPowerSyncMongoV3.js';
15
- import { ReplicationStreamDocumentV3, SourceTableDocumentV3 } from './models.js';
17
+ import { CustomCheckpointRequestDocumentV3, ReplicationStreamDocumentV3, SourceTableDocumentV3 } from './models.js';
16
18
  import {
17
19
  createNewSourceTable,
18
- designateEventCarrier,
19
20
  overlappingSourceTableFilter,
20
21
  planSourceTableReconciliation,
21
22
  sourceTableDesiredResolution,
@@ -27,6 +28,10 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
27
28
  declare readonly db: VersionedPowerSyncMongoV3;
28
29
 
29
30
  private readonly store: SourceRecordStore;
31
+ /** Avoid repeating idempotent createIndexes calls for every checkpoint flush. */
32
+ private readonly initializedCustomCheckpointEventIds = new Set<EventDefinitionId>();
33
+ // Used only to validate supplied checkpoint ids; it does not initialize collections.
34
+ private readonly knownEventIds: ReadonlySet<EventDefinitionId>;
30
35
  private readonly syncConfigIds: bson.ObjectId[];
31
36
  private needsActivationV3 = true;
32
37
  private lastWaitingLogThrottledV3 = 0;
@@ -38,6 +43,7 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
38
43
  throw new ReplicationAssertionError('Missing sync config id for v3 batch');
39
44
  }
40
45
  this.syncConfigIds = syncConfigIds;
46
+ this.knownEventIds = new Set(this.mapping.allEventDefinitionIds());
41
47
  this.store = new SourceRecordStoreV3(this.db, this.replicationStreamId, this.mapping);
42
48
  }
43
49
 
@@ -45,7 +51,9 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
45
51
  return new PersistedBatchV3(this.db, this.replicationStreamId, this.mapping, writtenSize, {
46
52
  logger: this.logger,
47
53
  objectStorage: this.options.objectStorage,
48
- inlineThresholdBytes: this.options.inlineThresholdBytes
54
+ inlineThresholdBytes: this.options.inlineThresholdBytes,
55
+ signal: this.options.signal,
56
+ objectStorageUsageWriterId: this.objectStorageUsageWriterId
49
57
  });
50
58
  }
51
59
 
@@ -53,14 +61,12 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
53
61
  return this.store;
54
62
  }
55
63
 
56
- protected override async onReplicationTransactionFlush(
57
- session: lib_mongo.mongo.ClientSession,
58
- lastOp: bigint
59
- ): Promise<void> {
64
+ protected override onReplicationTransactionFlush(writes: MongoWriteBatch, lastOp: bigint): void {
60
65
  // Durably advance the stream-level head of persisted ops within the flush transaction.
61
66
  // This ensures a checkpoint created later (even by an empty commit, or by a freshly-appended
62
67
  // config that replicates nothing) covers all ops persisted before a potential crash.
63
- await this.db.sync_rules.updateOne(
68
+ writes.updateOne(
69
+ this.db.sync_rules,
64
70
  {
65
71
  _id: this.replicationStreamId
66
72
  },
@@ -68,8 +74,7 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
68
74
  $max: {
69
75
  last_persisted_op: lastOp
70
76
  }
71
- },
72
- { session }
77
+ }
73
78
  );
74
79
  }
75
80
 
@@ -90,7 +95,8 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
90
95
  this.syncConfigIds.map((id) => id.toHexString()),
91
96
  table.ref,
92
97
  bucketDataSourceIds,
93
- parameterLookupSourceIds
98
+ parameterLookupSourceIds,
99
+ [...table.eventDefinitionIds!]
94
100
  )
95
101
  );
96
102
  }
@@ -132,7 +138,7 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
132
138
  .sourceRecords(this.replicationStreamId, mongoTableId(table.id))
133
139
  .drop()
134
140
  .catch((error) => {
135
- if (lib_mongo.isMongoServerError(error) && error.codeName === 'NamespaceNotFound') {
141
+ if (lib_mongo.isMongoNamespaceNotFoundError(error)) {
136
142
  return;
137
143
  }
138
144
  throw error;
@@ -146,6 +152,7 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
146
152
  const parsedOverride = options.parsedSyncConfig as MongoParsedSyncConfigSet | undefined;
147
153
  const syncConfig = parsedOverride?.hydratedSyncConfig ?? this.sync_rules;
148
154
  const mapping = parsedOverride?.mapping ?? this.mapping;
155
+ const eventById = (parsedOverride ?? this.options.parsedSyncConfig).eventById;
149
156
 
150
157
  const { connection_id, source } = options;
151
158
  const reconcile = options.reconcileSourceTables ?? storage.defaultSourceTableReconciler;
@@ -173,7 +180,7 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
173
180
  .toArray();
174
181
 
175
182
  const candidateTables = candidateDocs.map((doc) =>
176
- sourceTableFromDocument(doc, source.connectionTag, syncConfig, mapping)
183
+ sourceTableFromDocument(doc, source.connectionTag, syncConfig, mapping, eventById)
177
184
  );
178
185
  const candidates = candidateTables.map((table) => table.clone());
179
186
  const resolution = await reconcile({ source, candidates });
@@ -191,7 +198,7 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
191
198
  storeCurrentData: source.sendsCompleteRows !== true,
192
199
  syncConfig,
193
200
  mapping,
194
- desired: sourceTableDesiredResolution(syncConfig, source, mapping),
201
+ desired: sourceTableDesiredResolution(syncConfig, source, mapping, eventById),
195
202
  sourceCompatibleTables: resolution.compatibleTables,
196
203
  newTableSourceMetadata: resolution.newTableValues.sourceMetadata
197
204
  };
@@ -209,7 +216,8 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
209
216
  {
210
217
  $set: {
211
218
  bucket_data_source_ids: update.memberships.bucketDataSourceIds,
212
- parameter_lookup_source_ids: update.memberships.parameterLookupSourceIds
219
+ parameter_lookup_source_ids: update.memberships.parameterLookupSourceIds,
220
+ event_definition_ids: update.memberships.eventDefinitionIds
213
221
  }
214
222
  },
215
223
  { session }
@@ -227,12 +235,11 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
227
235
  plan.tables.push(table);
228
236
  }
229
237
 
230
- // If memberships are split across multiple source tables, only one may fire events.
231
- designateEventCarrier(plan.tables, context.desired.triggersEvent);
232
-
233
238
  result = {
234
239
  tables: plan.tables,
235
- dropTables: plan.dropDocs.map((doc) => sourceTableFromDocument(doc, context.connectionTag, syncConfig, mapping))
240
+ dropTables: plan.dropDocs.map((doc) =>
241
+ sourceTableFromDocument(doc, context.connectionTag, syncConfig, mapping, eventById)
242
+ )
236
243
  };
237
244
  });
238
245
 
@@ -247,12 +254,13 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
247
254
  return null;
248
255
  }
249
256
 
250
- const refreshed = sourceTableFromDocument(doc, table.ref.connectionTag, this.sync_rules, this.mapping);
251
- // The event-carrier designation is decided per resolveTables result and not persisted -
252
- // preserve the caller's designation instead of recomputing it from the ref, so that
253
- // refreshing a non-carrier table does not make it fire events.
254
- refreshed.syncEvent = table.syncEvent;
255
- return refreshed;
257
+ return sourceTableFromDocument(
258
+ doc,
259
+ table.ref.connectionTag,
260
+ this.sync_rules,
261
+ this.mapping,
262
+ this.options.parsedSyncConfig.eventById
263
+ );
256
264
  }
257
265
 
258
266
  async commit(lsn: string, options?: storage.BucketBatchCommitOptions): Promise<storage.CheckpointResult> {
@@ -519,7 +527,7 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
519
527
  _id: { $ne: this.replicationStreamId },
520
528
  state: { $in: [storage.SyncRuleState.ACTIVE, storage.SyncRuleState.ERRORED] }
521
529
  },
522
- syncRuleStateUpdatePipeline(storage.SyncRuleState.STOP),
530
+ stopReplicationStreamPipeline(),
523
531
  { session }
524
532
  );
525
533
  activated = true;
@@ -642,7 +650,9 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
642
650
  const syncConfigIds = this.relevantSyncConfigIdsForTables(tables);
643
651
 
644
652
  await this.withTransaction(async () => {
645
- await this.db.sourceTables(this.replicationStreamId).updateMany(
653
+ const writes = this.db.createWriteBatch(session, { ordered: false });
654
+ writes.updateMany(
655
+ this.db.sourceTables(this.replicationStreamId),
646
656
  { _id: { $in: ids } },
647
657
  {
648
658
  $set: {
@@ -651,12 +661,12 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
651
661
  $unset: {
652
662
  snapshot_status: 1
653
663
  }
654
- },
655
- { session }
664
+ }
656
665
  );
657
666
 
658
667
  if (no_checkpoint_before_lsn != null && syncConfigIds.length > 0) {
659
- await this.db.sync_rules.updateOne(
668
+ writes.updateOne(
669
+ this.db.sync_rules,
660
670
  {
661
671
  _id: this.replicationStreamId,
662
672
  'sync_configs._id': { $in: syncConfigIds }
@@ -670,12 +680,12 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
670
680
  }
671
681
  },
672
682
  {
673
- session: this.session,
674
683
  // Only set for sync configs that use this table
675
684
  arrayFilters: [{ 'config._id': { $in: syncConfigIds } }]
676
685
  }
677
686
  );
678
687
  }
688
+ await writes.execute();
679
689
  });
680
690
  return tables.map((table) => {
681
691
  const copy = table.clone();
@@ -683,4 +693,98 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
683
693
  return copy;
684
694
  });
685
695
  }
696
+
697
+ protected override async batchCreateCustomWriteCheckpoints(
698
+ session: lib_mongo.mongo.ClientSession,
699
+ opId: InternalOpId
700
+ ): Promise<void> {
701
+ if (this.write_checkpoint_batch.length == 0) {
702
+ return;
703
+ }
704
+
705
+ const checkpointsByEvent = Map.groupBy(this.write_checkpoint_batch, (checkpoint) =>
706
+ this.validateCustomCheckpointEventId(checkpoint.event_id)
707
+ );
708
+
709
+ const writes = this.db.createWriteBatch(session, { ordered: false });
710
+ for (const [eventId, checkpoints] of checkpointsByEvent) {
711
+ this.batchCreateEventCustomWriteCheckpoints(writes, opId, eventId, checkpoints);
712
+ }
713
+ await writes.execute();
714
+ }
715
+
716
+ protected override async prepareCustomWriteCheckpoints(): Promise<void> {
717
+ // Most events never produce custom checkpoints, so create collections lazily
718
+ // only for the event ids present in this batch. This hook runs before the
719
+ // replication transaction because MongoDB cannot create indexes within it.
720
+ const eventIds = new Set(
721
+ this.write_checkpoint_batch.map((checkpoint) => this.validateCustomCheckpointEventId(checkpoint.event_id))
722
+ );
723
+ for (const eventId of eventIds) {
724
+ if (this.initializedCustomCheckpointEventIds.has(eventId)) {
725
+ continue;
726
+ }
727
+ await this.db.initializeCustomCheckpointRequestsCollection({
728
+ replicationStreamId: this.replicationStreamId,
729
+ eventId
730
+ });
731
+ this.initializedCustomCheckpointEventIds.add(eventId);
732
+ }
733
+ }
734
+
735
+ private validateCustomCheckpointEventId(eventId: EventDefinitionId | undefined): EventDefinitionId {
736
+ if (eventId == null) {
737
+ throw new ServiceAssertionError('V3 custom checkpoints require an event definition id');
738
+ }
739
+
740
+ if (!this.knownEventIds.has(eventId)) {
741
+ throw new ServiceAssertionError(`Unknown custom checkpoint event definition ${eventId}`);
742
+ }
743
+ return eventId;
744
+ }
745
+
746
+ private batchCreateEventCustomWriteCheckpoints(
747
+ writes: MongoWriteBatch,
748
+ opId: InternalOpId,
749
+ eventId: EventDefinitionId,
750
+ checkpoints: storage.CustomWriteCheckpointOptions[]
751
+ ): void {
752
+ // A repeated user within an event replaces the complete checkpoint state.
753
+ const uniqueCheckpoints = new Map(checkpoints.map((checkpoint) => [checkpoint.user_id, checkpoint]));
754
+ writes.bulkWriteUnordered(
755
+ this.db.customCheckpointRequests({
756
+ eventId,
757
+ replicationStreamId: this.replicationStreamId
758
+ }),
759
+ [...uniqueCheckpoints.values()].map((checkpointOptions) => {
760
+ const set: Partial<CustomCheckpointRequestDocumentV3> = {
761
+ user_id: checkpointOptions.user_id,
762
+ checkpoint: checkpointOptions.checkpoint,
763
+ op_id: opId
764
+ };
765
+ if (checkpointOptions.checkpoint_requested_at != null) {
766
+ set.checkpoint_requested_at = checkpointOptions.checkpoint_requested_at;
767
+ }
768
+
769
+ return {
770
+ updateOne: {
771
+ filter: {
772
+ user_id: checkpointOptions.user_id
773
+ },
774
+ update: {
775
+ $set: set,
776
+ ...(checkpointOptions.checkpoint_requested_at == null
777
+ ? {
778
+ $unset: {
779
+ checkpoint_requested_at: 1
780
+ }
781
+ }
782
+ : {})
783
+ },
784
+ upsert: true
785
+ }
786
+ };
787
+ })
788
+ );
789
+ }
686
790
  }