@powersync/service-module-mongodb-storage 0.23.2 → 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 +36 -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,4 +1,5 @@
1
1
  import { mongo } from '@powersync/lib-service-mongodb';
2
+ import { ReplicationAbortedError } from '@powersync/lib-services-framework';
2
3
  import { storage, updateSyncRulesFromYaml } from '@powersync/service-core';
3
4
  import { test_utils } from '@powersync/service-core-tests';
4
5
  import { execFile } from 'node:child_process';
@@ -8,6 +9,9 @@ import { describe, expect, test, vi } from 'vitest';
8
9
  import { PersistedBatch } from '../../src/storage/implementation/common/PersistedBatch.js';
9
10
  import { MongoOpIdAllocator } from '../../src/storage/implementation/MongoOpIdAllocator.js';
10
11
  import { MongoPersistedReplicationStream } from '../../src/storage/implementation/MongoPersistedReplicationStream.js';
12
+ import { PersistedBatchV1 } from '../../src/storage/implementation/v1/PersistedBatchV1.js';
13
+ import { VersionedPowerSyncMongoV1 } from '../../src/storage/implementation/v1/VersionedPowerSyncMongoV1.js';
14
+ import { PersistedBatchV3 } from '../../src/storage/implementation/v3/PersistedBatchV3.js';
11
15
  import { mongoTestStorageFactoryGenerator } from '../../src/utils/test-utils.js';
12
16
  import { env } from './env.js';
13
17
 
@@ -37,6 +41,10 @@ async function insert(writer: storage.BucketStorageBatch, table: storage.SourceT
37
41
  }
38
42
 
39
43
  describe.each([1, 2, 4])('concurrent writers v%s', (version) => {
44
+ const batchPrototype = version < 3 ? PersistedBatchV1.prototype : PersistedBatchV3.prototype;
45
+ const originalWrite = () => (version < 3 ? PersistedBatchV1.prototype.flush : PersistedBatchV3.prototype.publish);
46
+ const spyWrite = () =>
47
+ version < 3 ? vi.spyOn(PersistedBatchV1.prototype, 'flush') : vi.spyOn(PersistedBatchV3.prototype, 'publish');
40
48
  test('requires a lease to create a writer, including after another lease is released', async () => {
41
49
  await using factory = await factoryGen.factory();
42
50
  const stream = await factory.updateSyncRules(updateSyncRulesFromYaml(rules, { storageVersion: version }));
@@ -98,13 +106,10 @@ describe.each([1, 2, 4])('concurrent writers v%s', (version) => {
98
106
  await insert(writer, a.table, 'warmup');
99
107
  await writer.commit('1/2');
100
108
  // Force a split after each row without needing a transaction-sized fixture.
101
- const split = vi.spyOn(PersistedBatch.prototype, 'shouldFlushTransaction').mockReturnValue(true);
102
- const flush = PersistedBatch.prototype.flush;
109
+ const split = vi.spyOn(batchPrototype, 'shouldFlushTransaction').mockReturnValue(true);
110
+ const flush = originalWrite();
103
111
  const observed: bigint[] = [];
104
- const inspect = vi.spyOn(PersistedBatch.prototype, 'flush').mockImplementation(async function (
105
- this: PersistedBatch,
106
- ...args
107
- ) {
112
+ const inspect = spyWrite().mockImplementation(async function (this: PersistedBatch, ...args) {
108
113
  observed.push((await a.bucketStorage.getCheckpoint()).checkpoint);
109
114
  return flush.apply(this, args);
110
115
  });
@@ -206,15 +211,14 @@ describe.each([1, 2, 4])('concurrent writers v%s', (version) => {
206
211
  let admissions = 0;
207
212
  const admission = vi.spyOn(mutex, 'exclusiveLock').mockImplementation(function (callback) {
208
213
  const result = exclusiveLock.call(mutex, callback);
209
- if (++admissions === 3) queued.resolve();
214
+ if (++admissions === 3) {
215
+ queued.resolve();
216
+ }
210
217
  return result;
211
218
  });
212
219
  const transactions = vi.spyOn(mongo.ClientSession.prototype, 'withTransaction');
213
- const flush = PersistedBatch.prototype.flush;
214
- const stalled = vi.spyOn(PersistedBatch.prototype, 'flush').mockImplementationOnce(async function (
215
- this: PersistedBatch,
216
- ...args
217
- ) {
220
+ const flush = originalWrite();
221
+ const stalled = spyWrite().mockImplementationOnce(async function (this: PersistedBatch, ...args) {
218
222
  entered.resolve();
219
223
  await release.promise;
220
224
  return flush.apply(this, args);
@@ -246,17 +250,22 @@ describe.each([1, 2, 4])('concurrent writers v%s', (version) => {
246
250
  await using factory = await factoryGen.factory();
247
251
  const a = await openStream(factory, version);
248
252
  await using writerA = a.writer;
253
+ // Keep A active and B processing, so independent publication is tested
254
+ // without replacing/stopping A's config or activating B under A's fence.
255
+ if (version >= 3) {
256
+ await writerA.commit('1/1');
257
+ }
249
258
  // An independent factory/client has no shared allocator or in-process coordination.
250
259
  await using other = await factoryGen.factory({ doNotClear: true });
251
260
  const b = await openStream(other, version);
252
261
  await using writerB = b.writer;
262
+ if (version >= 3) {
263
+ await writerB.markTableSnapshotRequired(b.table);
264
+ }
253
265
  const entered = Promise.withResolvers<void>();
254
266
  const release = Promise.withResolvers<void>();
255
- const flush = PersistedBatch.prototype.flush;
256
- const stalled = vi.spyOn(PersistedBatch.prototype, 'flush').mockImplementationOnce(async function (
257
- this: PersistedBatch,
258
- ...args
259
- ) {
267
+ const flush = originalWrite();
268
+ const stalled = spyWrite().mockImplementationOnce(async function (this: PersistedBatch, ...args) {
260
269
  entered.resolve();
261
270
  await release.promise;
262
271
  return flush.apply(this, args);
@@ -267,7 +276,8 @@ describe.each([1, 2, 4])('concurrent writers v%s', (version) => {
267
276
  await entered.promise;
268
277
  await insert(writerB, b.table, 'b');
269
278
  await writerB.commit('1/2');
270
- expect((await b.bucketStorage.getCheckpoint()).checkpoint).toBeGreaterThan(1n);
279
+ expect(writerB.last_flushed_op).toBeGreaterThan(1n);
280
+ expect((await b.bucketStorage.getCheckpoint()).checkpoint).toBe(version >= 3 ? 0n : writerB.last_flushed_op);
271
281
  expect((await a.bucketStorage.getCheckpoint()).checkpoint).toBe(0n);
272
282
  } finally {
273
283
  release.resolve();
@@ -281,20 +291,23 @@ describe.each([1, 2, 4])('concurrent writers v%s', (version) => {
281
291
  expect(writerA.last_flushed_op).toBe(2n);
282
292
  });
283
293
 
284
- test('another process can reserve and checkpoint while this process holds a stream transaction', async () => {
294
+ test('another process can reserve and commit progress while this process holds a stream transaction', async () => {
285
295
  await using factory = await factoryGen.factory();
286
296
  const a = await openStream(factory, version);
287
297
  await using writerA = a.writer;
298
+ if (version >= 3) {
299
+ await writerA.commit('1/1');
300
+ }
288
301
  const b = await openStream(factory, version);
302
+ if (version >= 3) {
303
+ await b.writer.markTableSnapshotRequired(b.table);
304
+ }
289
305
  await b.writer.dispose();
290
306
  await test_utils.releaseTestStorageLease(factory, b.stream.replicationStreamId);
291
307
  const entered = Promise.withResolvers<void>();
292
308
  const release = Promise.withResolvers<void>();
293
- const flush = PersistedBatch.prototype.flush;
294
- const stalled = vi.spyOn(PersistedBatch.prototype, 'flush').mockImplementationOnce(async function (
295
- this: PersistedBatch,
296
- ...args
297
- ) {
309
+ const flush = originalWrite();
310
+ const stalled = spyWrite().mockImplementationOnce(async function (this: PersistedBatch, ...args) {
298
311
  entered.resolve();
299
312
  await release.promise;
300
313
  return flush.apply(this, args);
@@ -314,7 +327,7 @@ describe.each([1, 2, 4])('concurrent writers v%s', (version) => {
314
327
  ).catch((error) => {
315
328
  throw new Error(`${error.message}\n${error.stdout}\n${error.stderr}`);
316
329
  });
317
- expect(child.stdout).toMatch(/checkpoint=[1-9][0-9]+/);
330
+ expect(child.stdout).toMatch(version >= 3 ? /persisted=[1-9][0-9]+/ : /checkpoint=[1-9][0-9]+/);
318
331
  expect((await a.bucketStorage.getCheckpoint()).checkpoint).toBe(0n);
319
332
  } finally {
320
333
  release.resolve();
@@ -328,11 +341,8 @@ describe.each([1, 2, 4])('concurrent writers v%s', (version) => {
328
341
  const { writer: batch, table, bucketStorage } = await openStream(factory, version);
329
342
  await using writer = batch;
330
343
  const reservations = vi.spyOn(MongoOpIdAllocator.prototype, 'reserve');
331
- const flush = PersistedBatch.prototype.flush;
332
- const retry = vi.spyOn(PersistedBatch.prototype, 'flush').mockImplementationOnce(async function (
333
- this: PersistedBatch,
334
- ...args
335
- ) {
344
+ const flush = originalWrite();
345
+ const retry = spyWrite().mockImplementationOnce(async function (this: PersistedBatch, ...args) {
336
346
  await flush.apply(this, args);
337
347
  throw new mongo.MongoServerError({ message: 'retry', code: 112, errorLabels: ['TransientTransactionError'] });
338
348
  });
@@ -364,11 +374,8 @@ describe.each([1, 2, 4])('concurrent writers v%s', (version) => {
364
374
  const abort = new AbortController();
365
375
  await using writer = await a.bucketStorage.createWriter({ ...test_utils.BATCH_OPTIONS, signal: abort.signal });
366
376
  const table = await test_utils.resolveTestTable(writer, 'items', ['id'], factoryGen, a.stream.replicationStreamId);
367
- const flush = PersistedBatch.prototype.flush;
368
- const interrupted = vi.spyOn(PersistedBatch.prototype, 'flush').mockImplementationOnce(async function (
369
- this: PersistedBatch,
370
- ...args
371
- ) {
377
+ const flush = originalWrite();
378
+ const interrupted = spyWrite().mockImplementationOnce(async function (this: PersistedBatch, ...args) {
372
379
  const result = await flush.apply(this, args);
373
380
  if (when === 'during flush') {
374
381
  abort.abort();
@@ -455,10 +462,18 @@ describe.each([1, 2, 4])('concurrent writers v%s', (version) => {
455
462
  await writerB.flush();
456
463
  const higher = writerB.last_flushed_op!;
457
464
  await insert(writerA, a.table, 'c');
458
- await writerA.flush();
459
- expect(writerA.last_flushed_op).toBeGreaterThan(higher);
460
- await writerA.commit('1/2');
461
- expect((await a.bucketStorage.getCheckpoint()).checkpoint).toBe(writerA.last_flushed_op);
465
+ if (version >= 3) {
466
+ // Independent allocators on the same lease can reserve disjoint ranges in
467
+ // the opposite order from publication. The stale writer must stop.
468
+ await expect(writerA.flush()).rejects.toThrow('does not advance persisted stream head');
469
+ expect(writerA.last_flushed_op).toBeLessThan(higher);
470
+ await expect(writerA.commit('1/2')).rejects.toThrow('restart replication from persisted progress');
471
+ } else {
472
+ await writerA.flush();
473
+ expect(writerA.last_flushed_op).toBeGreaterThan(higher);
474
+ await writerA.commit('1/2');
475
+ expect((await a.bucketStorage.getCheckpoint()).checkpoint).toBe(writerA.last_flushed_op);
476
+ }
462
477
  });
463
478
 
464
479
  test('lease takeover fences stale row, metadata, and checkpoint writes', async () => {
@@ -468,7 +483,7 @@ describe.each([1, 2, 4])('concurrent writers v%s', (version) => {
468
483
  const stream = initial.stream as MongoPersistedReplicationStream;
469
484
  await test_utils.releaseTestStorageLease(factory, stream.replicationStreamId);
470
485
  const first = await stream.lock();
471
- await using firstLifetime = { [Symbol.asyncDispose]: () => first.release() };
486
+ await using _firstLifetime = { [Symbol.asyncDispose]: () => first.release() };
472
487
  await using oldWriter = await factory.getInstance(stream).createWriter(test_utils.BATCH_OPTIONS);
473
488
  await using oldCheckpointWriter = await factory.getInstance(stream).createWriter(test_utils.BATCH_OPTIONS);
474
489
  const oldTable = await test_utils.resolveTestTable(oldWriter, 'items', ['id'], factoryGen, 1);
@@ -482,7 +497,7 @@ describe.each([1, 2, 4])('concurrent writers v%s', (version) => {
482
497
  await using other = await factoryGen.factory({ doNotClear: true });
483
498
  const nextStream = (await other.getReplicatingReplicationStreams())[0];
484
499
  const next = await nextStream.lock();
485
- await using nextLifetime = { [Symbol.asyncDispose]: () => next.release() };
500
+ await using _nextLifetime = { [Symbol.asyncDispose]: () => next.release() };
486
501
  await using nextWriter = await other.getInstance(nextStream).createWriter(test_utils.BATCH_OPTIONS);
487
502
  const nextTable = await test_utils.resolveTestTable(nextWriter, 'items', ['id'], factoryGen, 1);
488
503
  await insert(nextWriter, nextTable, 'new');
@@ -490,12 +505,12 @@ describe.each([1, 2, 4])('concurrent writers v%s', (version) => {
490
505
  expect(nextWriter.last_flushed_op).toBeGreaterThan(abandonedEnd);
491
506
  await insert(oldWriter, oldTable, 'stale');
492
507
  await expect(oldWriter.flush()).rejects.toThrow('no longer owns');
493
- await expect(oldWriter.setResumeLsn('9/9')).rejects.toThrow('no longer owns');
494
- await expect(oldWriter.markAllSnapshotDone('9/9')).rejects.toThrow('no longer owns');
495
- await expect(oldWriter.markTableSnapshotRequired(oldTable)).rejects.toThrow('no longer owns');
496
- await expect(oldWriter.commit('9/9')).rejects.toThrow('no longer owns');
508
+ await expect(oldWriter.setResumeLsn('9/9')).rejects.toBeInstanceOf(ReplicationAbortedError);
509
+ await expect(oldWriter.markAllSnapshotDone('9/9')).rejects.toBeInstanceOf(ReplicationAbortedError);
510
+ await expect(oldWriter.markTableSnapshotRequired(oldTable)).rejects.toBeInstanceOf(ReplicationAbortedError);
511
+ await expect(oldWriter.commit('9/9')).rejects.toBeInstanceOf(ReplicationAbortedError);
497
512
  // No pending rows: this exercises the checkpoint fence, not the flush fence.
498
- await expect(oldCheckpointWriter.commit('9/9')).rejects.toThrow('no longer owns');
513
+ await expect(oldCheckpointWriter.commit('9/9')).rejects.toBeInstanceOf(ReplicationAbortedError);
499
514
  await first.release();
500
515
  expect((await factory.db.sync_rules.findOne({ _id: stream.replicationStreamId }))!.lock?.id).toBe(
501
516
  (next as typeof first).lock_id
@@ -511,7 +526,7 @@ describe.each([1, 2, 4])('concurrent writers v%s', (version) => {
511
526
  bucketStorage.replicationStream.current_lock!
512
527
  );
513
528
  await allocator.reserve();
514
- allocator.committed(65_535n);
529
+ allocator.consume(65_535n);
515
530
  // Simulate capacity becoming insufficient after the pre-transaction check.
516
531
  // The fallback must still abort and retry safely in that case.
517
532
  const capacity = vi.spyOn(allocator, 'ensureCapacity');
@@ -525,7 +540,7 @@ describe.each([1, 2, 4])('concurrent writers v%s', (version) => {
525
540
  await writer.commit('1/2');
526
541
  expect(writer.last_flushed_op).toBe(65_537n);
527
542
  expect((await bucketStorage.getCheckpoint()).checkpoint).toBe(65_537n);
528
- expect(sequences).toHaveBeenCalledTimes(fallback ? 2 : 1);
543
+ expect(sequences).toHaveBeenCalledTimes(version < 3 ? (fallback ? 2 : 1) : fallback ? 3 : 2);
529
544
  } finally {
530
545
  capacity.mockRestore();
531
546
  sequences.mockRestore();
@@ -533,11 +548,70 @@ describe.each([1, 2, 4])('concurrent writers v%s', (version) => {
533
548
  });
534
549
  });
535
550
 
551
+ describe.each([1, 2])('legacy batch lifecycle v%s', (version) => {
552
+ test('clears a successful flush and can accumulate the next writes independently', async () => {
553
+ await using factory = await factoryGen.factory();
554
+ const context = await openStream(factory, version);
555
+ await using _writer = context.writer;
556
+ const db = factory.db.versioned(
557
+ context.bucketStorage.replicationStream.getStorageConfig()
558
+ ) as VersionedPowerSyncMongoV1;
559
+ const batch = new PersistedBatchV1(db, context.stream.replicationStreamId, 0);
560
+ await db.client.withSession(async (session) => {
561
+ for (const id of ['first', 'second']) {
562
+ batch.upsertCurrentData({
563
+ sourceTableId: context.table.id as mongo.ObjectId,
564
+ replicaId: test_utils.rid(id),
565
+ data: null,
566
+ buckets: [],
567
+ lookups: []
568
+ });
569
+ expect(batch.currentSize).toBeGreaterThan(0);
570
+ await session.withTransaction(async () => {
571
+ expect(await batch.flush(session)).toEqual({
572
+ bucketDataCount: 0,
573
+ parameterDataCount: 0,
574
+ currentDataCount: 1,
575
+ flushedAny: true
576
+ });
577
+ });
578
+ expect(batch.currentSize).toBe(0);
579
+ }
580
+ });
581
+ const records = await db.sourceRecordsV1.find({ '_id.g': context.stream.replicationStreamId }).toArray();
582
+ expect(records).toHaveLength(2);
583
+ expect(records.map((record) => record._id.k)).toEqual(
584
+ expect.arrayContaining([test_utils.rid('first'), test_utils.rid('second')])
585
+ );
586
+ });
587
+ });
588
+
536
589
  describe('operation ID reservations', () => {
590
+ test('consumes abandoned IDs without advancing the durable head or discarding the unused tail', async () => {
591
+ await using factory = await factoryGen.factory();
592
+ const a = await openStream(factory, 4);
593
+ await using _writer = a.writer;
594
+ const allocator = factory.getOpIdAllocator(
595
+ a.bucketStorage.replicationStream,
596
+ a.bucketStorage.replicationStream.current_lock!
597
+ );
598
+ await allocator.reserve();
599
+ const sequence = allocator.sequence(0n);
600
+ expect(sequence.next()).toBe(1n);
601
+ expect(sequence.next()).toBe(2n);
602
+ allocator.consume(sequence.last());
603
+ // The sealed work is abandoned: a new sequence sees the same durable head.
604
+ expect(allocator.sequence(0n).next()).toBe(3n);
605
+ expect((await a.bucketStorage.getCheckpoint()).checkpoint).toBe(0n);
606
+ const watermark = (await factory.db.op_id_sequence.findOne({ _id: 'main' }))!.op_id;
607
+ await allocator.ensureCapacity();
608
+ expect((await factory.db.op_id_sequence.findOne({ _id: 'main' }))!.op_id).toBe(watermark);
609
+ });
610
+
537
611
  test('initializes and reserves disjoint ranges concurrently in one command each', async () => {
538
612
  await using factory = await factoryGen.factory();
539
613
  const a = await openStream(factory, 4);
540
- await using writer = a.writer;
614
+ await using _writer = a.writer;
541
615
  const db = factory.db.versioned(a.bucketStorage.replicationStream.getStorageConfig());
542
616
  const allocators = Array.from({ length: 4 }, () => new MongoOpIdAllocator(db));
543
617
  const initialize = vi.spyOn(db.op_id_sequence, 'updateOne');
@@ -557,20 +631,20 @@ describe('operation ID reservations', () => {
557
631
  test('refills below 16k remaining IDs, preserving the tail across disjoint ranges', async () => {
558
632
  await using factory = await factoryGen.factory();
559
633
  const a = await openStream(factory, 4);
560
- await using writer = a.writer;
634
+ await using _writer = a.writer;
561
635
  const db = factory.db.versioned(a.bucketStorage.replicationStream.getStorageConfig());
562
636
  const allocator = factory.getOpIdAllocator(
563
637
  a.bucketStorage.replicationStream,
564
638
  a.bucketStorage.replicationStream.current_lock!
565
639
  );
566
640
  await allocator.ensureCapacity();
567
- allocator.committed(49_152n);
641
+ allocator.consume(49_152n);
568
642
  await allocator.ensureCapacity();
569
643
  expect((await db.op_id_sequence.findOne({ _id: 'main' }))!.op_id).toBe(65_536n);
570
644
 
571
645
  // Another stream's reservation creates a gap that must not count as capacity.
572
646
  await new MongoOpIdAllocator(db).reserve();
573
- allocator.committed(49_153n);
647
+ allocator.consume(49_153n);
574
648
  await Promise.all([allocator.ensureCapacity(), allocator.ensureCapacity()]);
575
649
  expect((await db.op_id_sequence.findOne({ _id: 'main' }))!.op_id).toBe(196_608n);
576
650
  expect(allocator.sequence(49_153n).next()).toBe(49_154n);
@@ -584,7 +658,7 @@ describe('operation ID reservations', () => {
584
658
  test('refuses a reservation that would overflow without changing the watermark', async () => {
585
659
  await using factory = await factoryGen.factory();
586
660
  const a = await openStream(factory, 4);
587
- await using writer = a.writer;
661
+ await using _writer = a.writer;
588
662
  const max = (1n << 63n) - 1n;
589
663
  await factory.db.op_id_sequence.insertOne({ _id: 'main', op_id: max - 65_536n });
590
664
  const allocator = factory.getOpIdAllocator(
@@ -0,0 +1,119 @@
1
+ import { mongo } from '@powersync/lib-service-mongodb';
2
+
3
+ interface CommandSample {
4
+ name: string;
5
+ startedAt: number;
6
+ endedAt?: number;
7
+ failed?: boolean;
8
+ transaction?: TransactionSample;
9
+ }
10
+
11
+ interface TransactionSample {
12
+ key: string;
13
+ startedAt: number;
14
+ endedAt?: number;
15
+ outcome?: 'committed' | 'aborted';
16
+ replication: boolean;
17
+ commands: CommandSample[];
18
+ }
19
+
20
+ /**
21
+ * Counts actual wire commands, including getMore, aborted attempts and commit retries.
22
+ * Attach after setup/warmup to keep index creation and allocator refills separate.
23
+ * Store timing and classification only, never source data or command payloads.
24
+ */
25
+ export class PublicationCommandMonitor implements Disposable {
26
+ readonly commands: CommandSample[] = [];
27
+ readonly transactions = new Map<string, TransactionSample>();
28
+ private readonly requests = new Map<number, CommandSample>();
29
+
30
+ constructor(private readonly client: mongo.MongoClient) {
31
+ client.on('commandStarted', this.started);
32
+ client.on('commandSucceeded', this.succeeded);
33
+ client.on('commandFailed', this.failed);
34
+ }
35
+
36
+ get replicationAttempts() {
37
+ return [...this.transactions.values()].filter((transaction) => transaction.replication);
38
+ }
39
+
40
+ get activeReplicationAttempts() {
41
+ return this.replicationAttempts.filter((transaction) => transaction.endedAt == null);
42
+ }
43
+
44
+ summary() {
45
+ const counts = (commands: CommandSample[]) => {
46
+ const result: Record<string, number> = {};
47
+ for (const command of commands) {
48
+ result[command.name] = (result[command.name] ?? 0) + 1;
49
+ }
50
+ return result;
51
+ };
52
+ return {
53
+ commands: counts(this.commands),
54
+ outsideTransaction: counts(this.commands.filter((command) => command.transaction == null)),
55
+ replicationAttempts: this.replicationAttempts.map((transaction) => ({
56
+ outcome: transaction.outcome,
57
+ activeMs: transaction.endedAt == null ? null : transaction.endedAt - transaction.startedAt,
58
+ commands: counts(transaction.commands),
59
+ failedCommands: transaction.commands.filter((command) => command.failed).length
60
+ })),
61
+ otherTransactions: [...this.transactions.values()].filter((transaction) => !transaction.replication).length
62
+ };
63
+ }
64
+
65
+ private started = (event: mongo.CommandStartedEvent) => {
66
+ const command = event.command;
67
+ const startedAt = performance.now();
68
+ let transaction: TransactionSample | undefined;
69
+ // txnNumber alone also appears on retryable writes outside transactions.
70
+ if (command.autocommit === false) {
71
+ const key = `${command.lsid.id.toString('hex')}:${command.txnNumber.toString()}`;
72
+ transaction = this.transactions.get(key);
73
+ if (transaction == null) {
74
+ transaction = { key, startedAt, replication: false, commands: [] };
75
+ this.transactions.set(key, transaction);
76
+ }
77
+ // The writer's first command is the conditional stream fence. Compaction
78
+ // shares the client but does not fence the replication stream document.
79
+ transaction.replication ||= event.commandName === 'findAndModify' && command.findAndModify === 'sync_rules';
80
+ }
81
+ const sample: CommandSample = { name: event.commandName, startedAt, transaction };
82
+ this.commands.push(sample);
83
+ transaction?.commands.push(sample);
84
+ this.requests.set(event.requestId, sample);
85
+ };
86
+
87
+ private finish(requestId: number, failed: boolean, errorCode?: number) {
88
+ const sample = this.requests.get(requestId);
89
+ if (sample == null) {
90
+ return;
91
+ }
92
+ this.requests.delete(requestId);
93
+ sample.endedAt = performance.now();
94
+ sample.failed = failed;
95
+ // A failed commit can have an unknown outcome and may be retried. Only a
96
+ // successful commit or a confirmed abort closes the observed attempt.
97
+ // NoSuchTransaction also confirms that the server already aborted it.
98
+ if (
99
+ sample.transaction != null &&
100
+ ((sample.name === 'abortTransaction' && (!failed || errorCode === 251)) ||
101
+ (sample.name === 'commitTransaction' && !failed))
102
+ ) {
103
+ sample.transaction.endedAt = sample.endedAt;
104
+ sample.transaction.outcome = sample.name === 'commitTransaction' ? 'committed' : 'aborted';
105
+ }
106
+ }
107
+
108
+ private succeeded = (event: mongo.CommandSucceededEvent) => this.finish(event.requestId, false);
109
+ private failed = (event: mongo.CommandFailedEvent) => {
110
+ const errorCode = event.failure instanceof mongo.MongoServerError ? event.failure.code : undefined;
111
+ this.finish(event.requestId, true, typeof errorCode === 'number' ? errorCode : undefined);
112
+ };
113
+
114
+ [Symbol.dispose]() {
115
+ this.client.off('commandStarted', this.started);
116
+ this.client.off('commandSucceeded', this.succeeded);
117
+ this.client.off('commandFailed', this.failed);
118
+ }
119
+ }
@@ -10,7 +10,7 @@ async function main() {
10
10
  const streamId = Number(process.argv[3]);
11
11
  const stream = (await factory.getReplicatingReplicationStreams()).find((s) => s.replicationStreamId === streamId);
12
12
  const lock = await stream.lock();
13
- await using lockLifetime = { [Symbol.asyncDispose]: () => lock.release() };
13
+ await using _lockLifetime = { [Symbol.asyncDispose]: () => lock.release() };
14
14
  const bucketStorage = factory.getInstance(stream, { replicationLock: lock });
15
15
  await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
16
16
  const table = await test_utils.resolveTestTable(writer, 'items', ['id'], factoryGen, streamId);
@@ -21,7 +21,7 @@ async function main() {
21
21
  afterReplicaId: test_utils.rid('child')
22
22
  });
23
23
  await writer.commit('1/2');
24
- console.log(`checkpoint=${(await bucketStorage.getCheckpoint()).checkpoint}`);
24
+ console.log(`checkpoint=${(await bucketStorage.getCheckpoint()).checkpoint}; persisted=${writer.last_flushed_op}`);
25
25
  }
26
26
 
27
27
  await main();
@@ -0,0 +1,9 @@
1
+ import { register } from '@powersync/service-core-tests';
2
+ import { describe } from 'vitest';
3
+ import { INITIALIZED_MONGO_STORAGE_FACTORY, TEST_STORAGE_VERSIONS } from './util.js';
4
+
5
+ for (const storageVersion of TEST_STORAGE_VERSIONS) {
6
+ describe(`storage v${storageVersion}`, () => {
7
+ register.registerInvalidSyncConfigTests(INITIALIZED_MONGO_STORAGE_FACTORY, storageVersion);
8
+ });
9
+ }
@@ -1,9 +1,79 @@
1
+ import { ObjectId } from 'bson';
1
2
  import { createHash } from 'node:crypto';
2
- import { describe, expect, test } from 'vitest';
3
- import { ObjectStorageLifecycle } from '../../src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js';
3
+ import { describe, expect, test, vi } from 'vitest';
4
+ import {
5
+ OBJECT_STORAGE_PUBLICATION_SAFETY_MARGIN_MS,
6
+ ObjectStorageLifecycle
7
+ } from '../../src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js';
4
8
  import { MemoryObjectStorage } from './helpers/MemoryObjectStorage.js';
5
9
 
6
10
  describe('ObjectStorageLifecycle', () => {
11
+ test('does not start PUTs when their protection has expired', async () => {
12
+ const objectStorage = new MemoryObjectStorage();
13
+ const lifecycle = new ObjectStorageLifecycle(null as any, 12, objectStorage);
14
+ using put = vi.spyOn(objectStorage, 'put');
15
+ await expect(
16
+ lifecycle.storeUploads([
17
+ {
18
+ upload: {
19
+ markerId: new ObjectId(),
20
+ path: 'expired',
21
+ deleteAfter: new Date(Date.now() + OBJECT_STORAGE_PUBLICATION_SAFETY_MARGIN_MS)
22
+ },
23
+ payload: new Uint8Array()
24
+ }
25
+ ])
26
+ ).rejects.toThrow('lease expired before PUT');
27
+ expect(put).not.toHaveBeenCalled();
28
+ });
29
+
30
+ test('expiry cancels queued requests and joins an active request before rejecting', async () => {
31
+ vi.useFakeTimers();
32
+ using _clock = { [Symbol.dispose]: () => vi.useRealTimers() };
33
+ const objectStorage = new MemoryObjectStorage();
34
+ const lifecycle = new ObjectStorageLifecycle(null as any, 12, objectStorage);
35
+ const release = Promise.withResolvers<void>();
36
+ let activeSignal: AbortSignal | undefined;
37
+ let queuedStarted = false;
38
+ using _put = vi
39
+ .spyOn(objectStorage, 'put')
40
+ .mockImplementationOnce(async (_path, _data, _metadata, options) => {
41
+ activeSignal = options!.signal;
42
+ await release.promise;
43
+ activeSignal!.throwIfAborted();
44
+ })
45
+ .mockImplementationOnce(async (_path, _data, _metadata, options) => {
46
+ // Model waiting for the shared adapter's semaphore: observe abort before
47
+ // starting the remote request, even when the active request is draining.
48
+ await new Promise<void>((resolve) =>
49
+ options!.signal!.addEventListener('abort', () => resolve(), { once: true })
50
+ );
51
+ options!.signal!.throwIfAborted();
52
+ queuedStarted = true;
53
+ });
54
+ const uploads = ['active', 'queued'].map((path) => ({
55
+ upload: {
56
+ markerId: new ObjectId(),
57
+ path,
58
+ deleteAfter: new Date(Date.now() + OBJECT_STORAGE_PUBLICATION_SAFETY_MARGIN_MS + 1000)
59
+ },
60
+ payload: new Uint8Array()
61
+ }));
62
+ const pending = lifecycle.storeUploads(uploads);
63
+ let settled = false;
64
+ const failed = expect(
65
+ pending.finally(() => {
66
+ settled = true;
67
+ })
68
+ ).rejects.toThrow('lease expired during PUT');
69
+ await vi.advanceTimersByTimeAsync(1000);
70
+ expect(activeSignal!.aborted).toBe(true);
71
+ expect(queuedStarted).toBe(false);
72
+ expect(settled).toBe(false);
73
+ release.resolve();
74
+ await failed;
75
+ });
76
+
7
77
  test('escapes bucket names as path segments', () => {
8
78
  const lifecycle = new ObjectStorageLifecycle(null as any, 12, null as any);
9
79