@powersync/service-module-mongodb-storage 0.23.1 → 0.24.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 (99) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/LICENSE +201 -67
  3. package/dist/storage/MongoBucketStorage.d.ts +1 -0
  4. package/dist/storage/MongoBucketStorage.js +3 -0
  5. package/dist/storage/MongoBucketStorage.js.map +1 -1
  6. package/dist/storage/implementation/MongoBucketBatch.d.ts +60 -39
  7. package/dist/storage/implementation/MongoBucketBatch.js +123 -296
  8. package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
  9. package/dist/storage/implementation/MongoOpIdAllocator.d.ts +6 -1
  10. package/dist/storage/implementation/MongoOpIdAllocator.js +12 -5
  11. package/dist/storage/implementation/MongoOpIdAllocator.js.map +1 -1
  12. package/dist/storage/implementation/MongoParsedSyncConfigSet.d.ts +7 -0
  13. package/dist/storage/implementation/MongoParsedSyncConfigSet.js +28 -2
  14. package/dist/storage/implementation/MongoParsedSyncConfigSet.js.map +1 -1
  15. package/dist/storage/implementation/MongoReplicationOptions.d.ts +27 -0
  16. package/dist/storage/implementation/MongoReplicationOptions.js +26 -0
  17. package/dist/storage/implementation/MongoReplicationOptions.js.map +1 -0
  18. package/dist/storage/implementation/MongoSyncBucketStorage.js +2 -3
  19. package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
  20. package/dist/storage/implementation/MongoSyncRulesLock.d.ts +15 -2
  21. package/dist/storage/implementation/MongoSyncRulesLock.js +36 -14
  22. package/dist/storage/implementation/MongoSyncRulesLock.js.map +1 -1
  23. package/dist/storage/implementation/common/BatchOperationBuffer.d.ts +79 -0
  24. package/dist/storage/implementation/common/BatchOperationBuffer.js +286 -0
  25. package/dist/storage/implementation/common/BatchOperationBuffer.js.map +1 -0
  26. package/dist/storage/implementation/common/PersistedBatch.d.ts +10 -103
  27. package/dist/storage/implementation/common/PersistedBatch.js +1 -291
  28. package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
  29. package/dist/storage/implementation/common/PreparedPublication.d.ts +43 -0
  30. package/dist/storage/implementation/common/PreparedPublication.js +2 -0
  31. package/dist/storage/implementation/common/PreparedPublication.js.map +1 -0
  32. package/dist/storage/implementation/common/SourceRecordStore.d.ts +0 -2
  33. package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +17 -13
  34. package/dist/storage/implementation/v1/MongoBucketBatchV1.js +259 -18
  35. package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
  36. package/dist/storage/implementation/v1/PersistedBatchV1.d.ts +21 -15
  37. package/dist/storage/implementation/v1/PersistedBatchV1.js +58 -24
  38. package/dist/storage/implementation/v1/PersistedBatchV1.js.map +1 -1
  39. package/dist/storage/implementation/v1/SourceRecordStoreV1.d.ts +0 -2
  40. package/dist/storage/implementation/v1/SourceRecordStoreV1.js +0 -3
  41. package/dist/storage/implementation/v1/SourceRecordStoreV1.js.map +1 -1
  42. package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +25 -14
  43. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +350 -23
  44. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  45. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +1 -0
  46. package/dist/storage/implementation/v3/MongoCompactorV3.js +10 -4
  47. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  48. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +39 -28
  49. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
  50. package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +46 -24
  51. package/dist/storage/implementation/v3/PersistedBatchV3.js +159 -83
  52. package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
  53. package/dist/storage/implementation/v3/SourceRecordStoreV3.d.ts +12 -0
  54. package/dist/storage/implementation/v3/SourceRecordStoreV3.js +41 -13
  55. package/dist/storage/implementation/v3/SourceRecordStoreV3.js.map +1 -1
  56. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.d.ts +4 -0
  57. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +4 -0
  58. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -1
  59. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +27 -2
  60. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +61 -5
  61. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -1
  62. package/package.json +9 -10
  63. package/src/storage/MongoBucketStorage.ts +4 -0
  64. package/src/storage/implementation/MongoBucketBatch.ts +185 -392
  65. package/src/storage/implementation/MongoOpIdAllocator.ts +12 -5
  66. package/src/storage/implementation/MongoParsedSyncConfigSet.ts +40 -1
  67. package/src/storage/implementation/MongoReplicationOptions.ts +28 -0
  68. package/src/storage/implementation/MongoSyncBucketStorage.ts +2 -3
  69. package/src/storage/implementation/MongoSyncRulesLock.ts +39 -17
  70. package/src/storage/implementation/common/BatchOperationBuffer.ts +353 -0
  71. package/src/storage/implementation/common/PersistedBatch.ts +12 -390
  72. package/src/storage/implementation/common/PreparedPublication.ts +49 -0
  73. package/src/storage/implementation/common/SourceRecordStore.ts +0 -2
  74. package/src/storage/implementation/v1/MongoBucketBatchV1.ts +339 -27
  75. package/src/storage/implementation/v1/PersistedBatchV1.ts +74 -32
  76. package/src/storage/implementation/v1/SourceRecordStoreV1.ts +0 -5
  77. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +430 -32
  78. package/src/storage/implementation/v3/MongoCompactorV3.ts +13 -4
  79. package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +43 -29
  80. package/src/storage/implementation/v3/PersistedBatchV3.ts +209 -108
  81. package/src/storage/implementation/v3/SourceRecordStoreV3.ts +45 -15
  82. package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +9 -0
  83. package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +79 -5
  84. package/test/src/checkpoint_cleanup.test.ts +75 -0
  85. package/test/src/concurrent_writers.test.ts +127 -53
  86. package/test/src/helpers/PublicationCommandMonitor.ts +119 -0
  87. package/test/src/helpers/concurrentWriter.mjs +2 -2
  88. package/test/src/invalid_sync_config.test.ts +9 -0
  89. package/test/src/object_storage_lifecycle.test.ts +72 -2
  90. package/test/src/prepared_publication.test.ts +388 -0
  91. package/test/src/replication_preparation.test.ts +829 -0
  92. package/test/src/replication_publication_baseline.test.ts +203 -0
  93. package/test/src/replication_publication_batching.test.ts +212 -0
  94. package/test/src/replication_uploads.test.ts +393 -0
  95. package/test/src/source_table_config.test.ts +4 -4
  96. package/test/src/storage_object_storage_inline_threshold.test.ts +1 -1
  97. package/test/src/storage_s3_compaction_lifecycle.test.ts +93 -1
  98. package/test/src/storage_sync.test.ts +83 -2
  99. package/tsconfig.tsbuildinfo +1 -1
@@ -1,14 +1,15 @@
1
1
  import { mongo } from '@powersync/lib-service-mongodb';
2
- import { ReplicationAssertionError } from '@powersync/lib-services-framework';
2
+ import { ErrorCode, ReplicationAssertionError, ServiceError } from '@powersync/lib-services-framework';
3
3
  import { ColumnDescriptor, InternalOpId, SourceTable, storage } from '@powersync/service-core';
4
4
  import { HydratedSyncConfig } from '@powersync/service-sync-rules';
5
5
  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 { MongoIdSequence, OpIdRangeExhausted } from '../MongoIdSequence.js';
9
10
  import { MongoSyncRulesLock } from '../MongoSyncRulesLock.js';
10
11
  import { MongoWriteBatch } from '../MongoWriteBatch.js';
11
- import { PersistedBatch } from '../common/PersistedBatch.js';
12
+ import { OperationBatch } from '../OperationBatch.js';
12
13
  import { SourceRecordStore } from '../common/SourceRecordStore.js';
13
14
  import { SyncRuleDocumentBase } from '../models.js';
14
15
  import { PersistedBatchV1 } from './PersistedBatchV1.js';
@@ -37,6 +38,306 @@ export class MongoBucketBatchV1 extends MongoBucketBatch {
37
38
  this.store = new SourceRecordStoreV1(this.db, this.replicationStreamId);
38
39
  }
39
40
 
41
+ private async withReplicationTransaction(
42
+ description: string,
43
+ callback: (session: mongo.ClientSession, opSeq: MongoIdSequence) => Promise<void>,
44
+ finish?: (stream: SyncRuleDocumentBase, lastOp: InternalOpId) => Promise<boolean>,
45
+ projection?: mongo.Document
46
+ ): Promise<void> {
47
+ let flushTry = 0;
48
+ const lastTry = Date.now() + 90000;
49
+ const allocator = this.options.opIdAllocator;
50
+ let lastOp = 0n;
51
+ // Refill outside the publication transaction, before evaluating any rows.
52
+ // Exhaustion remains a fallback for large batches or a newer stream head.
53
+ this.options.replicationLock.throwIfAborted();
54
+ await allocator.ensureCapacity();
55
+ while (true) {
56
+ try {
57
+ await this.withFencedTransaction(
58
+ () => this.fence(this.session, projection),
59
+ async (stream) => {
60
+ flushTry += 1;
61
+ if (flushTry > 20 && Date.now() > lastTry) {
62
+ throw new ServiceError(ErrorCode.PSYNC_S1402, 'Max transaction tries exceeded');
63
+ }
64
+ // The fence already holds this stream document for the transaction.
65
+ // A different writer may have used higher IDs since our last flush.
66
+ const opSeq = allocator.sequence(this.persistedOpHead(stream));
67
+ await callback(this.session, opSeq);
68
+ lastOp = opSeq.last();
69
+
70
+ if (!(await finish?.(stream, lastOp))) {
71
+ const writes = this.db.createWriteBatch(this.session, { ordered: false });
72
+ this.onReplicationTransactionFlush(writes, lastOp);
73
+ await writes.execute();
74
+ }
75
+ },
76
+ this.session,
77
+ description
78
+ );
79
+ allocator.consume(lastOp);
80
+ return;
81
+ } catch (error) {
82
+ if (!(error instanceof OpIdRangeExhausted)) {
83
+ throw error;
84
+ }
85
+ // The transaction aborted every write. Reserve outside it and replay
86
+ // evaluation from the same committed stream head.
87
+ this.options.replicationLock.throwIfAborted();
88
+ await allocator.reserve();
89
+ }
90
+ }
91
+ }
92
+
93
+ private async clearError(session: mongo.ClientSession): Promise<void> {
94
+ await this.db.sync_rules.updateOne(
95
+ { _id: this.replicationStreamId },
96
+ { $set: { last_fatal_error: null, last_fatal_error_ts: null } },
97
+ { session }
98
+ );
99
+ }
100
+
101
+ protected override async flushInner(
102
+ options?: storage.BatchBucketFlushOptions,
103
+ checkpoint?: (stream: SyncRuleDocumentBase, lastOp: InternalOpId, writes?: MongoWriteBatch) => Promise<void>,
104
+ projection?: mongo.Document
105
+ ): Promise<storage.FlushedResult | null> {
106
+ const batch = this.batch;
107
+ let last_op: InternalOpId | null = null;
108
+ let resumeBatch: OperationBatch | null = null;
109
+ let clearedError = false;
110
+
111
+ using _ = this.tracer.span('storage', 'flush');
112
+
113
+ await this.hooks?.beforeBatchFlush?.(this);
114
+ if (this.write_checkpoint_batch.length > 0) {
115
+ // Collection/index creation cannot run inside the replication transaction.
116
+ await this.prepareCustomWriteCheckpoints();
117
+ }
118
+
119
+ await this.withReplicationTransaction(
120
+ `Flushing ${batch?.length ?? 0} ops`,
121
+ async (session, opSeq) => {
122
+ clearedError = false;
123
+ if (batch != null) {
124
+ const result = await this.replicateBatch(session, batch, opSeq, options);
125
+ resumeBatch = result.resumeBatch;
126
+ clearedError ||= result.clearedError;
127
+ }
128
+
129
+ if (this.write_checkpoint_batch.length > 0) {
130
+ this.logger.info(`Writing ${this.write_checkpoint_batch.length} custom write checkpoints`);
131
+ await this.batchCreateCustomWriteCheckpoints(session, opSeq.next());
132
+ }
133
+
134
+ last_op = opSeq.last();
135
+ },
136
+ async (stream, lastOp) => {
137
+ if (checkpoint != null && resumeBatch == null) {
138
+ // The checkpoint must also persist the head, including when visibility is blocked.
139
+ await checkpoint(stream, lastOp);
140
+ return true;
141
+ }
142
+ return false;
143
+ },
144
+ projection
145
+ );
146
+
147
+ // Keep checkpoints available if the transaction retries after writing them.
148
+ this.write_checkpoint_batch = [];
149
+
150
+ if (clearedError) {
151
+ this.clearedError = true;
152
+ }
153
+
154
+ // null if done, set if we need another flush
155
+ this.batch = resumeBatch;
156
+
157
+ if (last_op == null) {
158
+ throw new ReplicationAssertionError('Unexpected last_op == null');
159
+ }
160
+
161
+ this.recordPersistedOp(last_op);
162
+ this.last_flushed_op = last_op;
163
+ await this.hooks?.afterBatchFlush?.(this);
164
+ return { flushed_op: last_op };
165
+ }
166
+
167
+ private async replicateBatch(
168
+ session: mongo.ClientSession,
169
+ batch: OperationBatch,
170
+ op_seq: MongoIdSequence,
171
+ options?: storage.BucketBatchCommitOptions
172
+ ): Promise<{ resumeBatch: OperationBatch | null; clearedError: boolean }> {
173
+ let sizes: Map<string, number> | undefined = undefined;
174
+ using _ = this.tracer.span('storage', 'replicate_batch');
175
+ // Only look up current_data sizes if the batch stores current_data and at least one
176
+ // table in it does too (per-table can disable it, e.g. Postgres REPLICA IDENTITY FULL).
177
+ const anyTableStoresCurrentData =
178
+ this.storeCurrentData && batch.batch.some((r) => r.record.sourceTable.storeCurrentData);
179
+ if (anyTableStoresCurrentData && !this.skipExistingRows) {
180
+ // We skip this step if no tables store current_data, since the sizes will
181
+ // always be small in that case.
182
+
183
+ // With skipExistingRows, we don't load the full documents into memory,
184
+ // so we can also skip the size lookup step.
185
+
186
+ // Find sizes of current_data documents, to assist in intelligent batching without
187
+ // exceeding memory limits.
188
+ //
189
+ // A previous attempt tried to do batching by the results of the current_data query
190
+ // (automatically limited to 48MB(?) per batch by MongoDB). The issue is that it changes
191
+ // the order of processing, which then becomes really tricky to manage.
192
+ // This now takes 2+ queries, but doesn't have any issues with order of operations.
193
+ // Within this branch this.storeCurrentData is true, so the per-table flag is the
194
+ // effective value - only look up sizes for tables that actually store current_data.
195
+ const sizeLookups = batch.batch
196
+ .filter((r) => r.record.sourceTable.storeCurrentData)
197
+ .map((r) => ({
198
+ sourceTableId: mongoTableId(r.record.sourceTable.id),
199
+ replicaId: r.beforeId
200
+ }));
201
+
202
+ sizes = await this.sourceRecordStore.loadSizes(session, sizeLookups);
203
+ }
204
+
205
+ // If set, we need to start a new transaction with this batch.
206
+ let resumeBatch: OperationBatch | null = null;
207
+ let transactionSize = 0;
208
+
209
+ let didFlush = false;
210
+
211
+ // Now batch according to the sizes
212
+ // This is a single batch if storeCurrentData == false
213
+ for await (let b of batch.batched(sizes)) {
214
+ if (resumeBatch) {
215
+ for (let op of b) {
216
+ resumeBatch.push(op);
217
+ }
218
+ continue;
219
+ }
220
+ using lookupSpan = this.tracer.span('storage', 'lookup');
221
+ const lookups = b.map((r) => ({
222
+ sourceTableId: mongoTableId(r.record.sourceTable.id),
223
+ replicaId: r.beforeId
224
+ }));
225
+ let sourceRecordLookup = await this.sourceRecordStore.loadDocuments(session, lookups, this.skipExistingRows);
226
+ lookupSpan.end();
227
+
228
+ let persistedBatch: PersistedBatchV1 | null = this.createPersistedBatch(transactionSize);
229
+
230
+ // The current code structure makes it tricky to cleanly split this span from the one
231
+ // where fluhsing. So we manually end and re-create this span whenever we flush.
232
+ let evalSpan = this.tracer.span('evaluate');
233
+ for (let op of b) {
234
+ if (resumeBatch) {
235
+ resumeBatch.push(op);
236
+ continue;
237
+ }
238
+ const sourceRecord = sourceRecordLookup.get(op.internalBeforeKey) ?? null;
239
+ if (sourceRecord != null) {
240
+ // If it will be used again later, it will be set again using nextData below
241
+ sourceRecordLookup.delete(op.internalBeforeKey);
242
+ }
243
+ const nextData = this.saveOperation(persistedBatch!, op, sourceRecord, op_seq, this.markRecordUnavailable);
244
+ if (nextData != null) {
245
+ // Update our current_data and size cache
246
+ sourceRecordLookup.set(op.internalAfterKey!, nextData);
247
+ sizes?.set(op.internalAfterKey!, nextData.data?.length() ?? 0);
248
+ }
249
+
250
+ if (persistedBatch!.shouldFlushTransaction()) {
251
+ evalSpan.end();
252
+ // Transaction is getting big.
253
+ // Flush, and resume in a new transaction.
254
+ using persistSpan = this.tracer.span('storage', 'persist_flush');
255
+ const { flushedAny } = await persistedBatch!.flush(this.session, options);
256
+
257
+ didFlush ||= flushedAny;
258
+ persistedBatch = null;
259
+ // Computing our current progress is a little tricky here, since
260
+ // we're stopping in the middle of a batch.
261
+ // We create a new batch, and push any remaining operations to it.
262
+ resumeBatch = new OperationBatch();
263
+ persistSpan.end();
264
+ evalSpan = this.tracer.span('evaluate');
265
+ }
266
+ }
267
+ evalSpan.end();
268
+
269
+ if (persistedBatch) {
270
+ transactionSize = persistedBatch.currentSize;
271
+ using _ = this.tracer.span('storage', 'persist_flush');
272
+ const { flushedAny } = await persistedBatch.flush(this.session, options);
273
+ didFlush ||= flushedAny;
274
+ }
275
+ }
276
+
277
+ const clearedError = didFlush && !this.clearedError;
278
+ if (clearedError) {
279
+ // No need to clear an error more than once per batch, since an error would always result in restarting the batch.
280
+ using _ = this.tracer.span('storage', 'clear_error');
281
+ await this.clearError(this.session);
282
+ }
283
+
284
+ return {
285
+ resumeBatch: resumeBatch?.hasData() ? resumeBatch : null,
286
+ clearedError
287
+ };
288
+ }
289
+
290
+ protected override async truncateSingleInternal(sourceTable: storage.SourceTable): Promise<InternalOpId> {
291
+ let last_op: InternalOpId | null = null;
292
+
293
+ // To avoid too large transactions, we limit the amount of data we delete per transaction.
294
+ // Since we don't use the record data here, we don't have explicit size limits per batch.
295
+ const BATCH_LIMIT = 2000;
296
+
297
+ let lastBatchCount = BATCH_LIMIT;
298
+ while (lastBatchCount == BATCH_LIMIT) {
299
+ await this.withReplicationTransaction(`Truncate ${sourceTable.qualifiedName}`, async (session, opSeq) => {
300
+ using evalSpan = this.tracer.span('evaluate');
301
+ const sourceTableId = mongoTableId(sourceTable.id);
302
+ const batch = await this.sourceRecordStore.loadTruncateBatch(session, sourceTableId, BATCH_LIMIT);
303
+ const persistedBatch = this.createPersistedBatch(0);
304
+
305
+ for (let value of batch) {
306
+ persistedBatch.saveBucketData({
307
+ op_seq: opSeq,
308
+ before_buckets: value.buckets,
309
+ evaluated: [],
310
+ table: sourceTable,
311
+ sourceKey: value.replicaId
312
+ });
313
+ persistedBatch.saveParameterData({
314
+ op_seq: opSeq,
315
+ existing_lookups: value.lookups,
316
+ evaluated: [],
317
+ sourceTable: sourceTable,
318
+ sourceKey: value.replicaId
319
+ });
320
+
321
+ // Since this is not from streaming replication, we can do a hard delete
322
+ persistedBatch.hardDeleteCurrentData(sourceTableId, value.replicaId);
323
+ }
324
+ evalSpan.end();
325
+
326
+ using _ = this.tracer.span('storage', 'persist_flush');
327
+ await persistedBatch.flush(session);
328
+ lastBatchCount = batch.length;
329
+
330
+ last_op = opSeq.last();
331
+ });
332
+ }
333
+
334
+ if (last_op) {
335
+ this.recordPersistedOp(last_op);
336
+ }
337
+
338
+ return last_op!;
339
+ }
340
+
40
341
  protected override persistedOpHead(stream: SyncRuleDocumentBase): InternalOpId {
41
342
  const legacy = stream as SyncRuleDocumentV1;
42
343
  const checkpoint = legacy.last_checkpoint ?? 0n;
@@ -58,12 +359,12 @@ export class MongoBucketBatchV1 extends MongoBucketBatch {
58
359
  ]);
59
360
  }
60
361
 
61
- protected override recordPersistedOp(lastOp: InternalOpId): void {
362
+ private recordPersistedOp(lastOp: InternalOpId): void {
62
363
  this.persisted_op = lastOp;
63
364
  }
64
365
 
65
- protected createPersistedBatch(writtenSize: number): PersistedBatch {
66
- return new PersistedBatchV1(this.db, this.replicationStreamId, this.mapping, writtenSize, {
366
+ protected createPersistedBatch(writtenSize: number): PersistedBatchV1 {
367
+ return new PersistedBatchV1(this.db, this.replicationStreamId, writtenSize, {
67
368
  logger: this.logger
68
369
  });
69
370
  }
@@ -75,10 +376,7 @@ export class MongoBucketBatchV1 extends MongoBucketBatch {
75
376
  // No-op for V1: source records live in a shared collection.
76
377
  }
77
378
 
78
- protected override async batchCreateCustomWriteCheckpoints(
79
- session: mongo.ClientSession,
80
- opId: InternalOpId
81
- ): Promise<void> {
379
+ private async batchCreateCustomWriteCheckpoints(session: mongo.ClientSession, opId: InternalOpId): Promise<void> {
82
380
  // Only the final checkpoint per user/stream is visible after this transaction.
83
381
  const checkpoints = new Map(
84
382
  this.write_checkpoint_batch.map((checkpoint) => [
@@ -112,7 +410,9 @@ export class MongoBucketBatchV1 extends MongoBucketBatch {
112
410
  );
113
411
  }
114
412
 
115
- async resolveTables(options: storage.ResolveTablesOptions): Promise<storage.ResolveTablesResult> {
413
+ protected override async resolveTablesInternal(
414
+ options: storage.ResolveTablesOptions
415
+ ): Promise<storage.ResolveTablesResult> {
116
416
  const syncRules = options.parsedSyncConfig?.hydratedSyncConfig ?? this.sync_rules;
117
417
  const reconcile = options.reconcileSourceTables ?? storage.defaultSourceTableReconciler;
118
418
  const { connection_id, source } = options;
@@ -145,13 +445,24 @@ export class MongoBucketBatchV1 extends MongoBucketBatch {
145
445
  const resolution = await reconcile({ source, candidates });
146
446
  storage.validateSourceTableCandidateResolution(candidates, resolution);
147
447
 
448
+ const materializedResolution = storage.materializeSourceTableResolution(candidateTables, resolution);
449
+ const compatibleTables = materializedResolution.compatibleTables;
450
+
451
+ if (!syncRules.tableSyncsAny(source)) {
452
+ // Still reconcile old mappings so renames out of scope remove previously replicated data.
453
+ // Skip updating source_metadata below: these records are dropped by the caller in this
454
+ // same transaction, so persisting new metadata into them first is wasted work.
455
+ result = {
456
+ tables: [],
457
+ dropTables: [...materializedResolution.incompatibleTables, ...compatibleTables]
458
+ };
459
+ return;
460
+ }
461
+
148
462
  for (const { id, sourceMetadata } of storage.diffSourceTableUpdates(candidateTables, resolution)) {
149
463
  await col.updateOne({ _id: mongoTableId(id) }, { $set: { source_metadata: sourceMetadata } }, { session });
150
464
  }
151
465
 
152
- const materializedResolution = storage.materializeSourceTableResolution(candidateTables, resolution);
153
- const compatibleTables = materializedResolution.compatibleTables;
154
-
155
466
  // V1 keeps one record, preferring one that has already been snapshotted.
156
467
  const reuse = compatibleTables.find((table) => table.snapshotComplete) ?? compatibleTables[0] ?? null;
157
468
 
@@ -225,7 +536,9 @@ export class MongoBucketBatchV1 extends MongoBucketBatch {
225
536
  return table;
226
537
  }
227
538
 
228
- async getSourceTableStatus(table: storage.SourceTable): Promise<storage.SourceTable | null> {
539
+ protected override async getSourceTableStatusInternal(
540
+ table: storage.SourceTable
541
+ ): Promise<storage.SourceTable | null> {
229
542
  const doc = await this.db.sourceTablesV1(this.replicationStreamId).findOne(
230
543
  {
231
544
  group_id: this.replicationStreamId,
@@ -241,7 +554,10 @@ export class MongoBucketBatchV1 extends MongoBucketBatch {
241
554
  return this.hydrateSourceTable(doc, table.ref.connectionTag, this.sync_rules, undefined);
242
555
  }
243
556
 
244
- async commit(lsn: string, options?: storage.BucketBatchCommitOptions): Promise<storage.CheckpointResult> {
557
+ protected override async commitInternal(
558
+ lsn: string,
559
+ options?: storage.BucketBatchCommitOptions
560
+ ): Promise<storage.CheckpointResult> {
245
561
  const { createEmptyCheckpoints } = { ...storage.DEFAULT_BUCKET_BATCH_COMMIT_OPTIONS, ...options };
246
562
 
247
563
  using _ = this.tracer.span('storage', 'commit');
@@ -394,9 +710,6 @@ export class MongoBucketBatchV1 extends MongoBucketBatch {
394
710
  await this.autoActivate(lsn);
395
711
  await this.db.notifyCheckpoint();
396
712
  this.persisted_op = null;
397
- if (checkpointState.newLastCheckpoint != null) {
398
- await this.sourceRecordStore.postCommitCleanup(checkpointState.newLastCheckpoint, this.logger);
399
- }
400
713
  }
401
714
 
402
715
  return {
@@ -405,18 +718,14 @@ export class MongoBucketBatchV1 extends MongoBucketBatch {
405
718
  };
406
719
  }
407
720
 
408
- async keepalive(lsn: string): Promise<storage.CheckpointResult> {
409
- return await this.commit(lsn, { createEmptyCheckpoints: true });
410
- }
411
-
412
- async setResumeLsn(lsn: string): Promise<void> {
721
+ protected override async setResumeLsnInternal(lsn: string): Promise<void> {
413
722
  using _ = this.tracer.span('storage', 'set_resume_lsn');
414
723
  // Losing occasional resume LSN would only reprocess source changes.
415
724
  // Keep the lease check atomic, but do not wait for majority replication of this resume hint.
416
725
  await this.updateStreamMetadata({ snapshot_lsn: { $literal: lsn } }, {}, { w: 1 });
417
726
  }
418
727
 
419
- async markAllSnapshotDone(no_checkpoint_before_lsn: string): Promise<void> {
728
+ protected override async markAllSnapshotDoneInternal(no_checkpoint_before_lsn: string): Promise<void> {
420
729
  await this.updateStreamMetadata(this.snapshotDoneUpdate(no_checkpoint_before_lsn));
421
730
  }
422
731
 
@@ -427,7 +736,10 @@ export class MongoBucketBatchV1 extends MongoBucketBatch {
427
736
  };
428
737
  }
429
738
 
430
- async markSnapshotDone(no_checkpoint_before_lsn: string, options?: { throwOnConflict?: boolean }): Promise<void> {
739
+ protected override async markSnapshotDoneInternal(
740
+ no_checkpoint_before_lsn: string,
741
+ options?: { throwOnConflict?: boolean }
742
+ ): Promise<void> {
431
743
  await this.withTransaction(async () => {
432
744
  // Protect against race conditions
433
745
  const blockingTables = await this.db
@@ -458,11 +770,11 @@ export class MongoBucketBatchV1 extends MongoBucketBatch {
458
770
  });
459
771
  }
460
772
 
461
- async markTableSnapshotRequired(_table: storage.SourceTable): Promise<void> {
773
+ protected override async markTableSnapshotRequiredInternal(_table: storage.SourceTable): Promise<void> {
462
774
  await this.updateStreamMetadata({ snapshot_done: false });
463
775
  }
464
776
 
465
- async markTableSnapshotDone(
777
+ protected override async markTableSnapshotDoneInternal(
466
778
  tables: storage.SourceTable[],
467
779
  no_checkpoint_before_lsn?: string
468
780
  ): Promise<storage.SourceTable[]> {
@@ -2,14 +2,16 @@ import { mongo } from '@powersync/lib-service-mongodb';
2
2
  import { ReplicationAssertionError } from '@powersync/lib-services-framework';
3
3
  import { storage } from '@powersync/service-core';
4
4
  import * as bson from 'bson';
5
+ import { BatchOperationBuffer } from '../common/BatchOperationBuffer.js';
5
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
9
  import { MongoWriteBatch } from '../MongoWriteBatch.js';
10
10
  import {
11
11
  BucketStateUpdate,
12
12
  PersistedBatch,
13
+ PersistedBatchOptions,
14
+ SaveBucketDataOptions,
13
15
  SaveParameterDataOptions,
14
16
  UpsertCurrentDataOptions
15
17
  } from '../common/PersistedBatch.js';
@@ -23,20 +25,68 @@ import {
23
25
  taggedBucketParameterDocumentToV1
24
26
  } from './models.js';
25
27
 
26
- export class PersistedBatchV1 extends PersistedBatch {
27
- declare protected readonly db: VersionedPowerSyncMongoV1;
28
+ export class PersistedBatchV1 implements PersistedBatch {
29
+ private readonly operations: BatchOperationBuffer;
28
30
 
29
31
  // Each upsert supplies the complete source-record state. Only its final
30
32
  // state matters within a flush; bucket and parameter history is kept separately.
31
- currentData = new Map<string, mongo.AnyBulkWriteOperation<CurrentDataDocument>>();
33
+ private readonly currentData = new Map<string, mongo.AnyBulkWriteOperation<CurrentDataDocument>>();
32
34
 
33
- protected checkDefinitionId(_definitionId: BucketDefinitionId | null): BucketDefinitionId {
34
- // V1 storage doesn't persist the id, and we don't use it.
35
- return LEGACY_BUCKET_DATA_DEFINITION_ID;
35
+ constructor(
36
+ private readonly db: VersionedPowerSyncMongoV1,
37
+ private readonly group_id: number,
38
+ writtenSize: number,
39
+ options?: PersistedBatchOptions
40
+ ) {
41
+ this.operations = new BatchOperationBuffer(
42
+ group_id,
43
+ writtenSize,
44
+ {
45
+ checkDefinitionId: () => LEGACY_BUCKET_DATA_DEFINITION_ID,
46
+ getBucketDefinitionId: () => LEGACY_BUCKET_DATA_DEFINITION_ID
47
+ },
48
+ options?.logger
49
+ );
50
+ }
51
+
52
+ get currentSize(): number {
53
+ return this.operations.currentSize;
54
+ }
55
+
56
+ saveBucketData(options: SaveBucketDataOptions): void {
57
+ this.operations.saveBucketData(options);
36
58
  }
37
59
 
38
- protected getBucketDefinitionId(_bucketSource: BucketDataSource): BucketDefinitionId {
39
- return LEGACY_BUCKET_DATA_DEFINITION_ID;
60
+ shouldFlushTransaction(): boolean {
61
+ return this.operations.shouldFlushTransaction(this.currentData.size);
62
+ }
63
+
64
+ /**
65
+ * Keeps track of bulkwrite operations within a transaction.
66
+ *
67
+ * There may be multiple of these batches per transaction, but it may not span
68
+ * multiple transactions.
69
+ */
70
+ async flush(session: mongo.ClientSession, options?: storage.BucketBatchCommitOptions) {
71
+ const startAt = performance.now();
72
+ const writes = this.db.createWriteBatch(session, { ordered: false });
73
+ if (this.operations.bucketDataCount > 0) {
74
+ await this.queueBucketData(writes);
75
+ }
76
+ if (this.operations.bucketParameters.length > 0) {
77
+ this.queueBucketParameters(writes);
78
+ }
79
+ if (this.currentData.size > 0) {
80
+ this.queueCurrentData(writes);
81
+ }
82
+ if (this.operations.bucketStates.size > 0) {
83
+ this.queueBucketStates(writes);
84
+ }
85
+ await writes.execute();
86
+ const stats = this.operations.logFlush(startAt, this.currentData.size, options);
87
+ this.currentData.clear();
88
+ this.operations.clear();
89
+ return stats;
40
90
  }
41
91
 
42
92
  saveParameterData(data: SaveParameterDataOptions) {
@@ -55,7 +105,7 @@ export class PersistedBatchV1 extends PersistedBatch {
55
105
  remaining_lookups.delete(binLookup.toString('base64'));
56
106
 
57
107
  const op_id = data.op_seq.next();
58
- this.debugLastOpId = op_id;
108
+ this.operations.debugLastOpId = op_id;
59
109
  const values: BucketParameterDocument = {
60
110
  _id: op_id,
61
111
  key: {
@@ -66,17 +116,17 @@ export class PersistedBatchV1 extends PersistedBatch {
66
116
  lookup: binLookup,
67
117
  bucket_parameters: result.bucketParameters
68
118
  };
69
- this.bucketParameters.push({
119
+ this.operations.bucketParameters.push({
70
120
  ...values,
71
121
  index: LEGACY_BUCKET_PARAMETER_INDEX_ID
72
122
  });
73
123
 
74
- this.currentSize += 200;
124
+ this.operations.currentSize += 200;
75
125
  }
76
126
 
77
127
  for (let lookup of remaining_lookups.values()) {
78
128
  const op_id = data.op_seq.next();
79
- this.debugLastOpId = op_id;
129
+ this.operations.debugLastOpId = op_id;
80
130
  const values: BucketParameterDocument = {
81
131
  _id: op_id,
82
132
  key: {
@@ -87,12 +137,12 @@ export class PersistedBatchV1 extends PersistedBatch {
87
137
  lookup,
88
138
  bucket_parameters: []
89
139
  };
90
- this.bucketParameters.push({
140
+ this.operations.bucketParameters.push({
91
141
  ...values,
92
142
  index: LEGACY_BUCKET_PARAMETER_INDEX_ID
93
143
  });
94
144
 
95
- this.currentSize += 200;
145
+ this.operations.currentSize += 200;
96
146
  }
97
147
  }
98
148
 
@@ -102,7 +152,7 @@ export class PersistedBatchV1 extends PersistedBatch {
102
152
  filter: { _id: this.currentDataId(sourceTableId, replicaId) }
103
153
  }
104
154
  });
105
- this.currentSize += 50;
155
+ this.operations.currentSize += 50;
106
156
  }
107
157
 
108
158
  softDeleteCurrentData(sourceTableId: bson.ObjectId, replicaId: storage.ReplicaId, _checkpointGreaterThan: bigint) {
@@ -140,17 +190,13 @@ export class PersistedBatchV1 extends PersistedBatch {
140
190
  upsert: true
141
191
  }
142
192
  });
143
- this.currentSize += (values.data?.length() ?? 0) + 100;
144
- }
145
-
146
- protected get currentDataCount() {
147
- return this.currentData.size;
193
+ this.operations.currentSize += (values.data?.length() ?? 0) + 100;
148
194
  }
149
195
 
150
- protected async queueBucketData(writes: MongoWriteBatch) {
196
+ private async queueBucketData(writes: MongoWriteBatch) {
151
197
  writes.bulkWriteUnordered(
152
198
  this.db.bucketDataV1,
153
- this.bucketData.map((document) => ({
199
+ this.operations.bucketData.map((document) => ({
154
200
  insertOne: {
155
201
  document: serializeBucketDataV1(document)
156
202
  }
@@ -158,10 +204,10 @@ export class PersistedBatchV1 extends PersistedBatch {
158
204
  );
159
205
  }
160
206
 
161
- protected queueBucketParameters(writes: MongoWriteBatch): void {
207
+ private queueBucketParameters(writes: MongoWriteBatch): void {
162
208
  writes.bulkWriteUnordered(
163
209
  this.db.parameterIndexV1,
164
- this.bucketParameters.map((document) => ({
210
+ this.operations.bucketParameters.map((document) => ({
165
211
  insertOne: {
166
212
  document: taggedBucketParameterDocumentToV1(document)
167
213
  }
@@ -169,7 +215,7 @@ export class PersistedBatchV1 extends PersistedBatch {
169
215
  );
170
216
  }
171
217
 
172
- protected queueCurrentData(writes: MongoWriteBatch): void {
218
+ private queueCurrentData(writes: MongoWriteBatch): void {
173
219
  if (this.currentData.size == 0) {
174
220
  return;
175
221
  }
@@ -177,16 +223,12 @@ export class PersistedBatchV1 extends PersistedBatch {
177
223
  writes.bulkWriteUnordered(this.db.sourceRecordsV1, [...this.currentData.values()]);
178
224
  }
179
225
 
180
- protected queueBucketStates(writes: MongoWriteBatch): void {
226
+ private queueBucketStates(writes: MongoWriteBatch): void {
181
227
  writes.bulkWriteUnordered(this.db.bucketStateV1, this.getBucketStateUpdates());
182
228
  }
183
229
 
184
- protected resetCurrentData() {
185
- this.currentData.clear();
186
- }
187
-
188
230
  private getBucketStateUpdates(): mongo.AnyBulkWriteOperation<BucketStateDocumentV1>[] {
189
- return Array.from(this.bucketStates.values()).map((state: BucketStateUpdate) => {
231
+ return Array.from(this.operations.bucketStates.values()).map((state: BucketStateUpdate) => {
190
232
  return {
191
233
  updateOne: {
192
234
  filter: {
@@ -1,5 +1,4 @@
1
1
  import { mongo } from '@powersync/lib-service-mongodb';
2
- import { Logger } from '@powersync/lib-services-framework';
3
2
  import { storage } from '@powersync/service-core';
4
3
  import { EvaluatedParameters, EvaluatedRow } from '@powersync/service-sync-rules';
5
4
  import * as bson from 'bson';
@@ -149,8 +148,4 @@ export class SourceRecordStoreV1 implements SourceRecordStore {
149
148
  this.createLoadedDocument(sourceTableId, doc._id, null, doc.buckets, doc.lookups)
150
149
  );
151
150
  }
152
-
153
- async postCommitCleanup(_lastCheckpoint: bigint, _logger: Logger): Promise<void> {
154
- // No-op for V1.
155
- }
156
151
  }