@powersync/service-module-mongodb-storage 0.21.0 → 0.23.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 (130) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/storage/MongoBucketStorage.d.ts +10 -1
  3. package/dist/storage/MongoBucketStorage.js +37 -7
  4. package/dist/storage/MongoBucketStorage.js.map +1 -1
  5. package/dist/storage/implementation/MongoBucketBatch.d.ts +24 -10
  6. package/dist/storage/implementation/MongoBucketBatch.js +134 -89
  7. package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
  8. package/dist/storage/implementation/MongoCompactor.d.ts +2 -0
  9. package/dist/storage/implementation/MongoCompactor.js.map +1 -1
  10. package/dist/storage/implementation/MongoIdSequence.d.ts +10 -1
  11. package/dist/storage/implementation/MongoIdSequence.js +14 -1
  12. package/dist/storage/implementation/MongoIdSequence.js.map +1 -1
  13. package/dist/storage/implementation/MongoOpIdAllocator.d.ts +27 -0
  14. package/dist/storage/implementation/MongoOpIdAllocator.js +97 -0
  15. package/dist/storage/implementation/MongoOpIdAllocator.js.map +1 -0
  16. package/dist/storage/implementation/MongoStorageProvider.js +4 -2
  17. package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
  18. package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +5 -0
  19. package/dist/storage/implementation/MongoSyncBucketStorage.js +17 -0
  20. package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
  21. package/dist/storage/implementation/MongoSyncRulesLock.d.ts +26 -2
  22. package/dist/storage/implementation/MongoSyncRulesLock.js +38 -1
  23. package/dist/storage/implementation/MongoSyncRulesLock.js.map +1 -1
  24. package/dist/storage/implementation/MongoWriteBatch.d.ts +49 -0
  25. package/dist/storage/implementation/MongoWriteBatch.js +149 -0
  26. package/dist/storage/implementation/MongoWriteBatch.js.map +1 -0
  27. package/dist/storage/implementation/MongoWriteCheckpointAPI.js +2 -2
  28. package/dist/storage/implementation/MongoWriteCheckpointAPI.js.map +1 -1
  29. package/dist/storage/implementation/common/PersistedBatch.d.ts +5 -4
  30. package/dist/storage/implementation/common/PersistedBatch.js +6 -4
  31. package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
  32. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.d.ts +3 -0
  33. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js +3 -0
  34. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js.map +1 -1
  35. package/dist/storage/implementation/db.d.ts +5 -0
  36. package/dist/storage/implementation/db.js +14 -0
  37. package/dist/storage/implementation/db.js.map +1 -1
  38. package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +5 -0
  39. package/dist/storage/implementation/v1/MongoBucketBatchV1.js +149 -139
  40. package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
  41. package/dist/storage/implementation/v1/MongoCompactorV1.d.ts +2 -1
  42. package/dist/storage/implementation/v1/MongoCompactorV1.js +23 -17
  43. package/dist/storage/implementation/v1/MongoCompactorV1.js.map +1 -1
  44. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +1 -1
  45. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
  46. package/dist/storage/implementation/v1/PersistedBatchV1.d.ts +7 -5
  47. package/dist/storage/implementation/v1/PersistedBatchV1.js +22 -28
  48. package/dist/storage/implementation/v1/PersistedBatchV1.js.map +1 -1
  49. package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +6 -1
  50. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +148 -162
  51. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  52. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +10 -4
  53. package/dist/storage/implementation/v3/MongoCompactorV3.js +175 -76
  54. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  55. package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +12 -8
  56. package/dist/storage/implementation/v3/PersistedBatchV3.js +25 -32
  57. package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
  58. package/dist/storage/implementation/v3/compact-utils.d.ts +3 -2
  59. package/dist/storage/implementation/v3/compact-utils.js +12 -5
  60. package/dist/storage/implementation/v3/compact-utils.js.map +1 -1
  61. package/dist/storage/implementation/v3/models.d.ts +1 -1
  62. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +8 -1
  63. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -1
  64. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +3 -3
  65. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +7 -9
  66. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -1
  67. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.d.ts +3 -2
  68. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js +4 -7
  69. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js.map +1 -1
  70. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js +21 -3
  71. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js.map +1 -1
  72. package/dist/types/types.d.ts +5 -0
  73. package/dist/types/types.js +12 -0
  74. package/dist/types/types.js.map +1 -1
  75. package/dist/utils/test-utils.d.ts +2 -1
  76. package/dist/utils/test-utils.js +5 -3
  77. package/dist/utils/test-utils.js.map +1 -1
  78. package/package.json +7 -7
  79. package/src/storage/MongoBucketStorage.ts +47 -8
  80. package/src/storage/implementation/MongoBucketBatch.ts +205 -111
  81. package/src/storage/implementation/MongoCompactor.ts +2 -0
  82. package/src/storage/implementation/MongoIdSequence.ts +21 -1
  83. package/src/storage/implementation/MongoOpIdAllocator.ts +105 -0
  84. package/src/storage/implementation/MongoStorageProvider.ts +12 -2
  85. package/src/storage/implementation/MongoSyncBucketStorage.ts +19 -0
  86. package/src/storage/implementation/MongoSyncRulesLock.ts +56 -3
  87. package/src/storage/implementation/MongoWriteBatch.ts +172 -0
  88. package/src/storage/implementation/MongoWriteCheckpointAPI.ts +4 -2
  89. package/src/storage/implementation/common/PersistedBatch.ts +12 -8
  90. package/src/storage/implementation/common/VersionedPowerSyncMongoBase.ts +4 -0
  91. package/src/storage/implementation/db.ts +15 -0
  92. package/src/storage/implementation/models.ts +1 -0
  93. package/src/storage/implementation/v1/MongoBucketBatchV1.ts +167 -160
  94. package/src/storage/implementation/v1/MongoCompactorV1.ts +29 -26
  95. package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +1 -1
  96. package/src/storage/implementation/v1/PersistedBatchV1.ts +25 -30
  97. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +217 -242
  98. package/src/storage/implementation/v3/MongoCompactorV3.ts +284 -108
  99. package/src/storage/implementation/v3/PersistedBatchV3.ts +36 -36
  100. package/src/storage/implementation/v3/compact-utils.ts +16 -7
  101. package/src/storage/implementation/v3/models.ts +1 -1
  102. package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +8 -1
  103. package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +11 -10
  104. package/src/storage/implementation/v3/object-storage/ObjectStorageUsage.ts +8 -9
  105. package/src/storage/implementation/v3/object-storage/S3ObjectStorage.ts +23 -3
  106. package/src/types/types.ts +18 -0
  107. package/src/utils/test-utils.ts +6 -3
  108. package/test/src/__snapshots__/storage.test.ts.snap +102 -2
  109. package/test/src/__snapshots__/storage_compacting.test.ts.snap +2 -2
  110. package/test/src/__snapshots__/storage_sync.test.ts.snap +12 -12
  111. package/test/src/chunk_compaction_config.test.ts +38 -0
  112. package/test/src/cleanup-stopped-sync-configs.test.ts +19 -13
  113. package/test/src/compact-utils.test.ts +33 -8
  114. package/test/src/concurrent_writers.test.ts +599 -0
  115. package/test/src/helpers/concurrentWriter.mjs +29 -0
  116. package/test/src/helpers/s3TestFactory.ts +12 -12
  117. package/test/src/object_storage_usage.test.ts +18 -5
  118. package/test/src/parameter_compacting_v1.test.ts +2 -2
  119. package/test/src/parameter_compacting_v3.test.ts +2 -2
  120. package/test/src/parameter_compaction_fence.test.ts +3 -3
  121. package/test/src/storage.test.ts +32 -33
  122. package/test/src/storage_compacting.test.ts +402 -24
  123. package/test/src/storage_object_storage_inline_threshold.test.ts +3 -3
  124. package/test/src/storage_s3_checksums.test.ts +4 -4
  125. package/test/src/storage_s3_compaction_lifecycle.test.ts +8 -8
  126. package/test/src/storage_s3_reading.test.ts +51 -5
  127. package/test/src/storage_s3_writing.test.ts +3 -3
  128. package/test/src/storage_sync.test.ts +52 -46
  129. package/test/src/storeCurrentData.test.ts +5 -5
  130. package/tsconfig.tsbuildinfo +1 -1
@@ -6,8 +6,11 @@ import * as bson from 'bson';
6
6
  import { mongoTableId } from '../../../utils/util.js';
7
7
  import { calculateCheckpointState } from '../CheckpointState.js';
8
8
  import { MongoBucketBatch, MongoBucketBatchOptions } from '../MongoBucketBatch.js';
9
+ import { MongoSyncRulesLock } from '../MongoSyncRulesLock.js';
10
+ import { MongoWriteBatch } from '../MongoWriteBatch.js';
9
11
  import { PersistedBatch } from '../common/PersistedBatch.js';
10
12
  import { SourceRecordStore } from '../common/SourceRecordStore.js';
13
+ import { SyncRuleDocumentBase } from '../models.js';
11
14
  import { PersistedBatchV1 } from './PersistedBatchV1.js';
12
15
  import { SourceRecordStoreV1 } from './SourceRecordStoreV1.js';
13
16
  import { VersionedPowerSyncMongoV1 } from './VersionedPowerSyncMongoV1.js';
@@ -34,6 +37,27 @@ export class MongoBucketBatchV1 extends MongoBucketBatch {
34
37
  this.store = new SourceRecordStoreV1(this.db, this.replicationStreamId);
35
38
  }
36
39
 
40
+ protected override persistedOpHead(stream: SyncRuleDocumentBase): InternalOpId {
41
+ const legacy = stream as SyncRuleDocumentV1;
42
+ const checkpoint = legacy.last_checkpoint ?? 0n;
43
+ const keepalive = legacy.keepalive_op == null ? 0n : BigInt(legacy.keepalive_op);
44
+ return checkpoint > keepalive ? checkpoint : keepalive;
45
+ }
46
+
47
+ protected override onReplicationTransactionFlush(writes: MongoWriteBatch, lastOp: InternalOpId): void {
48
+ // Keep flushed operations recoverable before a checkpoint can be published.
49
+ // keepalive_op remains a decimal string for compatibility with legacy readers.
50
+ writes.updateOne(this.db.sync_rules, { _id: this.replicationStreamId }, [
51
+ {
52
+ $set: {
53
+ keepalive_op: {
54
+ $toString: { $max: [{ $toLong: '$keepalive_op' }, '$last_checkpoint', lastOp] }
55
+ }
56
+ }
57
+ }
58
+ ]);
59
+ }
60
+
37
61
  protected override recordPersistedOp(lastOp: InternalOpId): void {
38
62
  this.persisted_op = lastOp;
39
63
  }
@@ -55,8 +79,15 @@ export class MongoBucketBatchV1 extends MongoBucketBatch {
55
79
  session: mongo.ClientSession,
56
80
  opId: InternalOpId
57
81
  ): Promise<void> {
82
+ // Only the final checkpoint per user/stream is visible after this transaction.
83
+ const checkpoints = new Map(
84
+ this.write_checkpoint_batch.map((checkpoint) => [
85
+ JSON.stringify([checkpoint.sync_rules_id, checkpoint.user_id]),
86
+ checkpoint
87
+ ])
88
+ );
58
89
  await this.db.custom_write_checkpoints.bulkWrite(
59
- this.write_checkpoint_batch.map((checkpointOptions) => {
90
+ [...checkpoints.values()].map((checkpointOptions) => {
60
91
  const set: Record<string, unknown> = {
61
92
  checkpoint: checkpointOptions.checkpoint,
62
93
  sync_rules_id: checkpointOptions.sync_rules_id,
@@ -77,7 +108,7 @@ export class MongoBucketBatchV1 extends MongoBucketBatch {
77
108
  }
78
109
  };
79
110
  }),
80
- { session }
111
+ { session, ordered: false }
81
112
  );
82
113
  }
83
114
 
@@ -94,7 +125,8 @@ export class MongoBucketBatchV1 extends MongoBucketBatch {
94
125
  }));
95
126
 
96
127
  let result: storage.ResolveTablesResult | null = null;
97
- await this.db.client.withSession(async (session) => {
128
+ const session = this.session;
129
+ await this.withTransaction(async () => {
98
130
  const col = this.db.sourceTablesV1(this.replicationStreamId);
99
131
 
100
132
  // Find records that overlap by name or relation id.
@@ -212,129 +244,136 @@ export class MongoBucketBatchV1 extends MongoBucketBatch {
212
244
  async commit(lsn: string, options?: storage.BucketBatchCommitOptions): Promise<storage.CheckpointResult> {
213
245
  const { createEmptyCheckpoints } = { ...storage.DEFAULT_BUCKET_BATCH_COMMIT_OPTIONS, ...options };
214
246
 
215
- await this.flush(options);
216
-
217
247
  using _ = this.tracer.span('storage', 'commit');
218
248
 
219
- const now = new Date();
249
+ const updateCheckpoint = async (persistedOp = this.persisted_op) => {
250
+ const now = new Date();
220
251
 
221
- await this.db.write_checkpoints.updateMany(
222
- {
223
- processed_at_lsn: null,
224
- 'lsns.1': { $lte: lsn }
225
- },
226
- {
227
- $set: {
228
- processed_at_lsn: lsn
229
- }
230
- },
231
- {
232
- session: this.session
233
- }
234
- );
252
+ const can_checkpoint = {
253
+ $and: [
254
+ { $eq: ['$snapshot_done', true] },
255
+ {
256
+ $or: [{ $eq: ['$last_checkpoint_lsn', null] }, { $lte: ['$last_checkpoint_lsn', { $literal: lsn }] }]
257
+ },
258
+ {
259
+ $or: [{ $eq: ['$no_checkpoint_before', null] }, { $lte: ['$no_checkpoint_before', { $literal: lsn }] }]
260
+ }
261
+ ]
262
+ };
235
263
 
236
- const can_checkpoint = {
237
- $and: [
238
- { $eq: ['$snapshot_done', true] },
239
- {
240
- $or: [{ $eq: ['$last_checkpoint_lsn', null] }, { $lte: ['$last_checkpoint_lsn', { $literal: lsn }] }]
241
- },
242
- {
243
- $or: [{ $eq: ['$no_checkpoint_before', null] }, { $lte: ['$no_checkpoint_before', { $literal: lsn }] }]
244
- }
245
- ]
246
- };
264
+ const new_keepalive_op = {
265
+ $cond: [
266
+ can_checkpoint,
267
+ { $literal: null },
268
+ {
269
+ $toString: {
270
+ $max: [{ $toLong: '$keepalive_op' }, { $literal: persistedOp }, 0n]
271
+ }
272
+ }
273
+ ]
274
+ };
247
275
 
248
- const new_keepalive_op = {
249
- $cond: [
250
- can_checkpoint,
251
- { $literal: null },
276
+ const new_last_checkpoint = {
277
+ $cond: [
278
+ can_checkpoint,
279
+ {
280
+ $max: ['$last_checkpoint', { $literal: persistedOp }, { $toLong: '$keepalive_op' }, 0n]
281
+ },
282
+ '$last_checkpoint'
283
+ ]
284
+ };
285
+
286
+ const preUpdateDocument = (await this.db.sync_rules.findOneAndUpdate(
287
+ MongoSyncRulesLock.ownerFilter(this.replicationStreamId, this.options.replicationLock),
288
+ [
289
+ {
290
+ $set: {
291
+ _can_checkpoint: can_checkpoint,
292
+ _not_empty: createEmptyCheckpoints
293
+ ? true
294
+ : {
295
+ $or: [
296
+ { $literal: createEmptyCheckpoints },
297
+ { $ne: ['$keepalive_op', new_keepalive_op] },
298
+ { $ne: ['$last_checkpoint', new_last_checkpoint] }
299
+ ]
300
+ }
301
+ }
302
+ },
303
+ {
304
+ $set: {
305
+ ...MongoSyncRulesLock.heartbeatUpdate(),
306
+ last_checkpoint_lsn: {
307
+ $cond: [{ $and: ['$_can_checkpoint', '$_not_empty'] }, { $literal: lsn }, '$last_checkpoint_lsn']
308
+ },
309
+ last_checkpoint_ts: {
310
+ $cond: [{ $and: ['$_can_checkpoint', '$_not_empty'] }, { $literal: now }, '$last_checkpoint_ts']
311
+ },
312
+
313
+ last_fatal_error: { $literal: null },
314
+ last_fatal_error_ts: { $literal: null },
315
+ keepalive_op: new_keepalive_op,
316
+ last_checkpoint: new_last_checkpoint,
317
+ snapshot_lsn: {
318
+ $cond: [{ $and: ['$_can_checkpoint', '$_not_empty'] }, { $literal: null }, '$snapshot_lsn']
319
+ }
320
+ }
321
+ },
322
+ {
323
+ $unset: ['_can_checkpoint', '_not_empty']
324
+ }
325
+ ],
252
326
  {
253
- $toString: {
254
- $max: [{ $toLong: '$keepalive_op' }, { $literal: this.persisted_op }, 0n]
327
+ session: this.session,
328
+ returnDocument: 'before',
329
+ projection: {
330
+ snapshot_done: 1,
331
+ last_checkpoint_lsn: 1,
332
+ no_checkpoint_before: 1,
333
+ keepalive_op: 1,
334
+ last_checkpoint: 1
255
335
  }
256
336
  }
257
- ]
258
- };
337
+ )) as SyncRuleDocumentV1;
259
338
 
260
- const new_last_checkpoint = {
261
- $cond: [
262
- can_checkpoint,
263
- {
264
- $max: ['$last_checkpoint', { $literal: this.persisted_op }, { $toLong: '$keepalive_op' }, 0n]
265
- },
266
- '$last_checkpoint'
267
- ]
339
+ return MongoSyncRulesLock.assertOwned(preUpdateDocument);
268
340
  };
269
341
 
270
- const preUpdateDocument = (await this.db.sync_rules.findOneAndUpdate(
271
- { _id: this.replicationStreamId },
272
- [
342
+ const finishCheckpoint = async (preUpdateDocument: SyncRuleDocumentV1, lastOp?: InternalOpId) => {
343
+ await this.db.write_checkpoints.updateMany(
273
344
  {
274
- $set: {
275
- _can_checkpoint: can_checkpoint,
276
- _not_empty: createEmptyCheckpoints
277
- ? true
278
- : {
279
- $or: [
280
- { $literal: createEmptyCheckpoints },
281
- { $ne: ['$keepalive_op', new_keepalive_op] },
282
- { $ne: ['$last_checkpoint', new_last_checkpoint] }
283
- ]
284
- }
285
- }
345
+ processed_at_lsn: null,
346
+ 'lsns.1': { $lte: lsn }
286
347
  },
287
348
  {
288
349
  $set: {
289
- last_checkpoint_lsn: {
290
- $cond: [{ $and: ['$_can_checkpoint', '$_not_empty'] }, { $literal: lsn }, '$last_checkpoint_lsn']
291
- },
292
- last_checkpoint_ts: {
293
- $cond: [{ $and: ['$_can_checkpoint', '$_not_empty'] }, { $literal: now }, '$last_checkpoint_ts']
294
- },
295
- last_keepalive_ts: { $literal: now },
296
- last_fatal_error: { $literal: null },
297
- last_fatal_error_ts: { $literal: null },
298
- keepalive_op: new_keepalive_op,
299
- last_checkpoint: new_last_checkpoint,
300
- snapshot_lsn: {
301
- $cond: [{ $and: ['$_can_checkpoint', '$_not_empty'] }, { $literal: null }, '$snapshot_lsn']
302
- }
350
+ processed_at_lsn: lsn
303
351
  }
304
352
  },
305
353
  {
306
- $unset: ['_can_checkpoint', '_not_empty']
354
+ session: this.session
307
355
  }
308
- ],
309
- {
310
- session: this.session,
311
- returnDocument: 'before',
312
- projection: {
313
- snapshot_done: 1,
314
- last_checkpoint_lsn: 1,
315
- no_checkpoint_before: 1,
316
- keepalive_op: 1,
317
- last_checkpoint: 1
318
- }
319
- }
320
- )) as SyncRuleDocumentV1;
321
-
322
- if (preUpdateDocument == null) {
323
- throw new ReplicationAssertionError(
324
- 'Failed to update checkpoint - no matching sync_rules document for _id: ' + this.replicationStreamId
325
356
  );
326
- }
327
357
 
328
- const checkpointState = calculateCheckpointState({
329
- lsn,
330
- snapshotDone: preUpdateDocument.snapshot_done === true,
331
- lastCheckpointLsn: preUpdateDocument.last_checkpoint_lsn,
332
- noCheckpointBefore: preUpdateDocument.no_checkpoint_before,
333
- keepaliveOp: preUpdateDocument.keepalive_op == null ? null : BigInt(preUpdateDocument.keepalive_op),
334
- lastCheckpoint: preUpdateDocument.last_checkpoint,
335
- persistedOp: this.persisted_op,
336
- createEmptyCheckpoints
337
- });
358
+ const checkpointState = calculateCheckpointState({
359
+ lsn,
360
+ snapshotDone: preUpdateDocument.snapshot_done === true,
361
+ lastCheckpointLsn: preUpdateDocument.last_checkpoint_lsn,
362
+ noCheckpointBefore: preUpdateDocument.no_checkpoint_before,
363
+ keepaliveOp: preUpdateDocument.keepalive_op == null ? null : BigInt(preUpdateDocument.keepalive_op),
364
+ lastCheckpoint: preUpdateDocument.last_checkpoint,
365
+ persistedOp: lastOp ?? this.persistedOpHead(preUpdateDocument),
366
+ createEmptyCheckpoints
367
+ });
368
+ return { checkpointState, preUpdateDocument };
369
+ };
370
+
371
+ const { checkpointState, preUpdateDocument } = await this.flushAndCommit(
372
+ async (_stream, lastOp) => finishCheckpoint(await updateCheckpoint(lastOp), lastOp),
373
+ // Without a flush, the checkpoint update itself fences the transaction.
374
+ () => this.withFencedTransaction(updateCheckpoint, finishCheckpoint),
375
+ options
376
+ );
338
377
  if (checkpointState.checkpointBlocked) {
339
378
  if (Date.now() - this.lastWaitingLogThrottled > 5_000) {
340
379
  this.logger.info(
@@ -372,41 +411,20 @@ export class MongoBucketBatchV1 extends MongoBucketBatch {
372
411
 
373
412
  async setResumeLsn(lsn: string): Promise<void> {
374
413
  using _ = this.tracer.span('storage', 'set_resume_lsn');
375
- await this.db.sync_rules.updateOne(
376
- {
377
- _id: this.replicationStreamId
378
- },
379
- {
380
- $set: {
381
- snapshot_lsn: lsn
382
- }
383
- },
384
- {
385
- session: this.session,
386
- // Losing occasional resume LSN is fine. That may mean reprocessing
387
- // some source changes in some edge cases, which is not an issue since
388
- // changes are processed in an idempotent way.
389
- writeConcern: { w: 1 }
390
- }
391
- );
414
+ // Losing occasional resume LSN would only reprocess source changes.
415
+ // Keep the lease check atomic, but do not wait for majority replication of this resume hint.
416
+ await this.updateStreamMetadata({ snapshot_lsn: { $literal: lsn } }, {}, { w: 1 });
392
417
  }
393
418
 
394
419
  async markAllSnapshotDone(no_checkpoint_before_lsn: string): Promise<void> {
395
- await this.db.sync_rules.updateOne(
396
- {
397
- _id: this.replicationStreamId
398
- },
399
- {
400
- $set: {
401
- snapshot_done: true,
402
- last_keepalive_ts: new Date()
403
- },
404
- $max: {
405
- no_checkpoint_before: no_checkpoint_before_lsn
406
- }
407
- },
408
- { session: this.session }
409
- );
420
+ await this.updateStreamMetadata(this.snapshotDoneUpdate(no_checkpoint_before_lsn));
421
+ }
422
+
423
+ private snapshotDoneUpdate(no_checkpoint_before_lsn: string) {
424
+ return {
425
+ snapshot_done: true,
426
+ no_checkpoint_before: { $max: ['$no_checkpoint_before', { $literal: no_checkpoint_before_lsn }] }
427
+ };
410
428
  }
411
429
 
412
430
  async markSnapshotDone(no_checkpoint_before_lsn: string, options?: { throwOnConflict?: boolean }): Promise<void> {
@@ -436,22 +454,12 @@ export class MongoBucketBatchV1 extends MongoBucketBatch {
436
454
  }
437
455
  }
438
456
 
439
- await this.markAllSnapshotDone(no_checkpoint_before_lsn);
457
+ await this.updateStreamMetadataInTransaction(this.snapshotDoneUpdate(no_checkpoint_before_lsn));
440
458
  });
441
459
  }
442
460
 
443
461
  async markTableSnapshotRequired(_table: storage.SourceTable): Promise<void> {
444
- await this.db.sync_rules.updateOne(
445
- {
446
- _id: this.replicationStreamId
447
- },
448
- {
449
- $set: {
450
- snapshot_done: false
451
- }
452
- },
453
- { session: this.session }
454
- );
462
+ await this.updateStreamMetadata({ snapshot_done: false });
455
463
  }
456
464
 
457
465
  async markTableSnapshotDone(
@@ -462,7 +470,9 @@ export class MongoBucketBatchV1 extends MongoBucketBatch {
462
470
  const ids = tables.map((table) => mongoTableId(table.id));
463
471
 
464
472
  await this.withTransaction(async () => {
465
- await this.db.sourceTablesV1(this.replicationStreamId).updateMany(
473
+ const writes = this.db.createWriteBatch(session, { ordered: false });
474
+ writes.updateMany(
475
+ this.db.sourceTablesV1(this.replicationStreamId),
466
476
  { _id: { $in: ids } },
467
477
  {
468
478
  $set: {
@@ -471,26 +481,23 @@ export class MongoBucketBatchV1 extends MongoBucketBatch {
471
481
  $unset: {
472
482
  snapshot_status: 1
473
483
  }
474
- },
475
- { session }
484
+ }
476
485
  );
477
486
 
478
487
  if (no_checkpoint_before_lsn != null) {
479
- await this.db.sync_rules.updateOne(
488
+ writes.updateOne(
489
+ this.db.sync_rules,
480
490
  {
481
491
  _id: this.replicationStreamId
482
492
  },
483
493
  {
484
- $set: {
485
- last_keepalive_ts: new Date()
486
- },
487
494
  $max: {
488
495
  no_checkpoint_before: no_checkpoint_before_lsn
489
496
  }
490
- },
491
- { session: this.session }
497
+ }
492
498
  );
493
499
  }
500
+ await writes.execute();
494
501
  });
495
502
  return tables.map((table) => {
496
503
  const copy = table.clone();
@@ -507,7 +514,7 @@ export class MongoBucketBatchV1 extends MongoBucketBatch {
507
514
  const session = this.session;
508
515
  let activated = false;
509
516
  let needsFutureActivationCheck = true;
510
- await session.withTransaction(async () => {
517
+ await this.withTransaction(async () => {
511
518
  // Reset on transaction retries.
512
519
  activated = false;
513
520
  needsFutureActivationCheck = true;
@@ -545,7 +552,7 @@ export class MongoBucketBatchV1 extends MongoBucketBatch {
545
552
  } else if (doc?.state != storage.SyncRuleState.PROCESSING) {
546
553
  needsFutureActivationCheck = false;
547
554
  }
548
- });
555
+ }, session);
549
556
  if (activated) {
550
557
  this.logger.info(`Activated new replication stream at ${lsn}`);
551
558
  await this.db.notifyCheckpoint();
@@ -13,6 +13,7 @@ import { BucketDefinitionId } from '@powersync/service-sync-rules';
13
13
  import { BucketDataDoc } from '../common/BucketDataDoc.js';
14
14
  import { BucketStateDocumentBase, LEGACY_BUCKET_DATA_DEFINITION_ID } from '../models.js';
15
15
  import { MongoCompactOptions, MongoCompactor } from '../MongoCompactor.js';
16
+ import { MongoWriteBatch } from '../MongoWriteBatch.js';
16
17
  import { cacheKey } from '../OperationBatch.js';
17
18
  import { BucketDataDocumentV1, BucketStateDocumentV1 } from './models.js';
18
19
  import type { MongoSyncBucketStorageV1 } from './MongoSyncBucketStorageV1.js';
@@ -296,11 +297,10 @@ export class MongoCompactorV1 extends MongoCompactor {
296
297
  }
297
298
 
298
299
  private async flushBucketStateUpdates() {
299
- if (this.bucketStateUpdates.length > 0) {
300
- this.logger.info(`Updating ${this.bucketStateUpdates.length} bucket states`);
301
- await this.writeBucketStateUpdates();
302
- this.bucketStateUpdates = [];
303
- }
300
+ const writes = this.db.createWriteBatch(undefined, { ordered: false });
301
+ this.writeBucketStateUpdates(writes);
302
+ await writes.execute();
303
+ this.bucketStateUpdates = [];
304
304
  }
305
305
 
306
306
  private async updateChecksumsBatch(buckets: Pick<DirtyBucket, 'bucket' | 'definitionId'>[]) {
@@ -340,10 +340,12 @@ export class MongoCompactorV1 extends MongoCompactor {
340
340
  await this.flushBucketStateUpdates();
341
341
  }
342
342
 
343
- protected async writeBucketStateUpdates(): Promise<void> {
344
- await this.db.bucketStateV1.bulkWrite(
345
- this.bucketStateUpdates as mongo.AnyBulkWriteOperation<BucketStateDocumentV1>[],
346
- { ordered: false }
343
+ protected writeBucketStateUpdates(writes: MongoWriteBatch): void {
344
+ if (this.bucketStateUpdates.length === 0) return;
345
+ this.logger.info(`Updating ${this.bucketStateUpdates.length} bucket states`);
346
+ writes.bulkWriteUnordered(
347
+ this.db.bucketStateV1,
348
+ this.bucketStateUpdates as mongo.AnyBulkWriteOperation<BucketStateDocumentV1>[]
347
349
  );
348
350
  }
349
351
 
@@ -541,17 +543,19 @@ export class MongoCompactorV1 extends MongoCompactor {
541
543
  }
542
544
 
543
545
  private async flush(bucketContext: SingleBucketStoreV1) {
546
+ // Persist data changes before advertising the resulting bucket state.
547
+ const writes = this.db.createWriteBatch(undefined, { ordered: true });
544
548
  if (this.updates.length > 0) {
545
549
  this.logger.info(`Compacting ${this.updates.length} ops`);
546
- await bucketContext.collection.bulkWrite(this.updates, {
547
- // Order is not important. Since checksums are not affected, these operations can happen in any order,
548
- // and it's fine if the operations are partially applied. Each individual operation is atomic.
549
- ordered: false
550
- });
551
- this.updates = [];
550
+ // Order is not important. Since checksums are not affected, these operations can happen in any order,
551
+ // and it's fine if the operations are partially applied. Each individual operation is atomic.
552
+ writes.bulkWriteUnordered(bucketContext.collection, this.updates);
552
553
  }
553
554
 
554
- await this.flushBucketStateUpdates();
555
+ this.writeBucketStateUpdates(writes);
556
+ await writes.execute();
557
+ this.updates = [];
558
+ this.bucketStateUpdates = [];
555
559
  }
556
560
 
557
561
  /**
@@ -622,15 +626,13 @@ export class MongoCompactorV1 extends MongoCompactor {
622
626
  }
623
627
 
624
628
  this.logger.info(`Flushing CLEAR for ${numberOfOpsToClear} ops at ${lastOp?.o}`);
625
- await bucketContext.collection.deleteMany(
626
- {
627
- _id: {
628
- $gte: bucketContext.minId,
629
- $lte: bucketContext.docId(lastOp!.o)
630
- }
631
- },
632
- { session }
633
- );
629
+ const writes = this.db.createWriteBatch(session, { ordered: true });
630
+ writes.deleteMany(bucketContext.collection, {
631
+ _id: {
632
+ $gte: bucketContext.minId,
633
+ $lte: bucketContext.docId(lastOp!.o)
634
+ }
635
+ });
634
636
 
635
637
  const op = bucketContext.toPersistedDocument({
636
638
  o: lastOp!.o,
@@ -639,7 +641,8 @@ export class MongoCompactorV1 extends MongoCompactor {
639
641
  data: null,
640
642
  target_op: targetOp
641
643
  });
642
- await bucketContext.collection.insertOne(op, { session });
644
+ writes.insertOne(bucketContext.collection, op);
645
+ await writes.execute();
643
646
 
644
647
  opCountDiff = -numberOfOpsToClear + 1;
645
648
  },
@@ -90,7 +90,7 @@ export class MongoSyncBucketStorageV1 extends MongoSyncBucketStorage {
90
90
  if (keepaliveOp == null && lastCheckpoint == null) {
91
91
  return null;
92
92
  }
93
- return (keepaliveOp ?? 0n) > (lastCheckpoint ?? 0n) ? keepaliveOp : lastCheckpoint;
93
+ return [keepaliveOp ?? 0n, lastCheckpoint ?? 0n].reduce((a, b) => (a > b ? a : b));
94
94
  }
95
95
 
96
96
  protected async createWriterImpl(options: storage.CreateWriterOptions): Promise<storage.BucketStorageBatch> {
@@ -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,