@powersync/service-module-mongodb-storage 0.18.2 → 0.19.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 (143) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.d.ts +3 -0
  3. package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.js +41 -0
  4. package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.js.map +1 -0
  5. package/dist/storage/MongoBucketStorage.d.ts +5 -1
  6. package/dist/storage/MongoBucketStorage.js +5 -1
  7. package/dist/storage/MongoBucketStorage.js.map +1 -1
  8. package/dist/storage/implementation/MongoBucketBatch.d.ts +3 -0
  9. package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
  10. package/dist/storage/implementation/MongoChecksums.d.ts +2 -0
  11. package/dist/storage/implementation/MongoChecksums.js +3 -2
  12. package/dist/storage/implementation/MongoChecksums.js.map +1 -1
  13. package/dist/storage/implementation/MongoCompactor.d.ts +15 -19
  14. package/dist/storage/implementation/MongoCompactor.js +68 -260
  15. package/dist/storage/implementation/MongoCompactor.js.map +1 -1
  16. package/dist/storage/implementation/MongoStorageProvider.js +26 -3
  17. package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
  18. package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +12 -5
  19. package/dist/storage/implementation/MongoSyncBucketStorage.js +25 -14
  20. package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
  21. package/dist/storage/implementation/MongoWriteCheckpointAPI.d.ts +2 -1
  22. package/dist/storage/implementation/MongoWriteCheckpointAPI.js +146 -34
  23. package/dist/storage/implementation/MongoWriteCheckpointAPI.js.map +1 -1
  24. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.d.ts +2 -0
  25. package/dist/storage/implementation/common/PersistedBatch.d.ts +6 -0
  26. package/dist/storage/implementation/common/PersistedBatch.js +15 -8
  27. package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
  28. package/dist/storage/implementation/models.d.ts +16 -0
  29. package/dist/storage/implementation/models.js.map +1 -1
  30. package/dist/storage/implementation/v1/MongoCompactorV1.d.ts +11 -2
  31. package/dist/storage/implementation/v1/MongoCompactorV1.js +250 -1
  32. package/dist/storage/implementation/v1/MongoCompactorV1.js.map +1 -1
  33. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +20 -11
  34. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
  35. package/dist/storage/implementation/v1/SingleBucketStoreV1.d.ts +9 -10
  36. package/dist/storage/implementation/v1/SingleBucketStoreV1.js +2 -8
  37. package/dist/storage/implementation/v1/SingleBucketStoreV1.js.map +1 -1
  38. package/dist/storage/implementation/v3/BucketDataContextV3.d.ts +13 -0
  39. package/dist/storage/implementation/v3/BucketDataContextV3.js +23 -0
  40. package/dist/storage/implementation/v3/BucketDataContextV3.js.map +1 -0
  41. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +3 -1
  42. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  43. package/dist/storage/implementation/v3/MongoChecksumsV3.d.ts +1 -0
  44. package/dist/storage/implementation/v3/MongoChecksumsV3.js +59 -103
  45. package/dist/storage/implementation/v3/MongoChecksumsV3.js.map +1 -1
  46. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +23 -2
  47. package/dist/storage/implementation/v3/MongoCompactorV3.js +354 -219
  48. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  49. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.d.ts +5 -0
  50. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js +12 -11
  51. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js.map +1 -1
  52. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.d.ts +5 -3
  53. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +109 -106
  54. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
  55. package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +5 -2
  56. package/dist/storage/implementation/v3/PersistedBatchV3.js +53 -15
  57. package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
  58. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.d.ts +2 -1
  59. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js +3 -0
  60. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js.map +1 -1
  61. package/dist/storage/implementation/v3/bucket-format.d.ts +10 -1
  62. package/dist/storage/implementation/v3/bucket-format.js +28 -9
  63. package/dist/storage/implementation/v3/bucket-format.js.map +1 -1
  64. package/dist/storage/implementation/v3/chunking.js +3 -1
  65. package/dist/storage/implementation/v3/chunking.js.map +1 -1
  66. package/dist/storage/implementation/v3/models.d.ts +38 -1
  67. package/dist/storage/implementation/v3/models.js.map +1 -1
  68. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.d.ts +21 -0
  69. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +44 -0
  70. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -0
  71. package/dist/storage/implementation/v3/object-storage/ObjectStorage.d.ts +30 -0
  72. package/dist/storage/implementation/v3/object-storage/ObjectStorage.js +12 -0
  73. package/dist/storage/implementation/v3/object-storage/ObjectStorage.js.map +1 -0
  74. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +70 -0
  75. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +146 -0
  76. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -0
  77. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.d.ts +42 -0
  78. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js +285 -0
  79. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js.map +1 -0
  80. package/dist/types/types.d.ts +35 -0
  81. package/dist/types/types.js +35 -1
  82. package/dist/types/types.js.map +1 -1
  83. package/dist/utils/test-utils.js +3 -1
  84. package/dist/utils/test-utils.js.map +1 -1
  85. package/dist/utils/util.d.ts +8 -1
  86. package/dist/utils/util.js +125 -2
  87. package/dist/utils/util.js.map +1 -1
  88. package/package.json +10 -7
  89. package/src/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.ts +55 -0
  90. package/src/storage/MongoBucketStorage.ts +10 -2
  91. package/src/storage/implementation/MongoBucketBatch.ts +4 -0
  92. package/src/storage/implementation/MongoChecksums.ts +5 -2
  93. package/src/storage/implementation/MongoCompactor.ts +90 -314
  94. package/src/storage/implementation/MongoStorageProvider.ts +32 -3
  95. package/src/storage/implementation/MongoSyncBucketStorage.ts +34 -24
  96. package/src/storage/implementation/MongoWriteCheckpointAPI.ts +164 -37
  97. package/src/storage/implementation/common/MongoSyncBucketStorageCheckpoint.ts +2 -0
  98. package/src/storage/implementation/common/PersistedBatch.ts +22 -10
  99. package/src/storage/implementation/models.ts +16 -0
  100. package/src/storage/implementation/v1/MongoCompactorV1.ts +292 -8
  101. package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +54 -40
  102. package/src/storage/implementation/v1/SingleBucketStoreV1.ts +18 -27
  103. package/src/storage/implementation/v3/BucketDataContextV3.ts +32 -0
  104. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +3 -1
  105. package/src/storage/implementation/v3/MongoChecksumsV3.ts +75 -105
  106. package/src/storage/implementation/v3/MongoCompactorV3.ts +456 -258
  107. package/src/storage/implementation/v3/MongoStoppedSyncConfigCleanup.ts +21 -19
  108. package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +123 -126
  109. package/src/storage/implementation/v3/PersistedBatchV3.ts +69 -16
  110. package/src/storage/implementation/v3/VersionedPowerSyncMongoV3.ts +5 -0
  111. package/src/storage/implementation/v3/bucket-format.ts +36 -9
  112. package/src/storage/implementation/v3/chunking.ts +3 -1
  113. package/src/storage/implementation/v3/models.ts +40 -1
  114. package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +54 -0
  115. package/src/storage/implementation/v3/object-storage/ObjectStorage.ts +29 -0
  116. package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +171 -0
  117. package/src/storage/implementation/v3/object-storage/S3ObjectStorage.ts +343 -0
  118. package/src/types/types.ts +42 -1
  119. package/src/utils/test-utils.ts +3 -1
  120. package/src/utils/util.ts +182 -4
  121. package/test/src/__snapshots__/storage_sync.test.ts.snap +1 -583
  122. package/test/src/cleanup-stopped-sync-configs.test.ts +24 -1
  123. package/test/src/helpers/MemoryObjectStorage.ts +53 -0
  124. package/test/src/helpers/s3TestFactory.ts +69 -0
  125. package/test/src/object_storage_lifecycle.test.ts +59 -0
  126. package/test/src/setup.ts +6 -1
  127. package/test/src/storage.test.ts +216 -2
  128. package/test/src/storage_compacting.test.ts +176 -187
  129. package/test/src/storage_object_storage_inline_threshold.test.ts +116 -0
  130. package/test/src/storage_s3_checksums.test.ts +191 -0
  131. package/test/src/storage_s3_compaction_lifecycle.test.ts +417 -0
  132. package/test/src/storage_s3_reading.test.ts +526 -0
  133. package/test/src/storage_s3_writing.test.ts +135 -0
  134. package/test/src/storage_sync.test.ts +95 -5
  135. package/tsconfig.tsbuildinfo +1 -1
  136. package/dist/storage/implementation/common/SingleBucketStore.d.ts +0 -54
  137. package/dist/storage/implementation/common/SingleBucketStore.js +0 -3
  138. package/dist/storage/implementation/common/SingleBucketStore.js.map +0 -1
  139. package/dist/storage/implementation/v3/SingleBucketStoreV3.d.ts +0 -18
  140. package/dist/storage/implementation/v3/SingleBucketStoreV3.js +0 -84
  141. package/dist/storage/implementation/v3/SingleBucketStoreV3.js.map +0 -1
  142. package/src/storage/implementation/common/SingleBucketStore.ts +0 -63
  143. package/src/storage/implementation/v3/SingleBucketStoreV3.ts +0 -108
@@ -26,8 +26,9 @@ import { HydratedSyncConfig, ParameterLookupRows, ScopedParameterLookup } from '
26
26
  import * as bson from 'bson';
27
27
  import { LRUCache } from 'lru-cache';
28
28
  import * as timers from 'timers/promises';
29
- import { retryOnMongoMaxTimeMSExpired } from '../../utils/util.js';
29
+ import { DEFAULT_CLEAR_BATCH_THROTTLE_RATE } from '../../types/types.js';
30
30
  import { MongoBucketStorage } from '../MongoBucketStorage.js';
31
+ import { DEFAULT_INLINE_THRESHOLD_BYTES } from './common/PersistedBatch.js';
31
32
  import type { VersionedPowerSyncMongo } from './db.js';
32
33
  import { StorageConfig } from './models.js';
33
34
  import { MongoBucketBatchOptions } from './MongoBucketBatch.js';
@@ -37,12 +38,16 @@ import { MongoParameterCompactor } from './MongoParameterCompactor.js';
37
38
  import { MongoParsedSyncConfigSet } from './MongoParsedSyncConfigSet.js';
38
39
  import { MongoPersistedReplicationStream } from './MongoPersistedReplicationStream.js';
39
40
  import { MongoWriteCheckpointAPI } from './MongoWriteCheckpointAPI.js';
41
+ import { ObjectStorage } from './v3/object-storage/ObjectStorage.js';
40
42
 
41
43
  export interface MongoSyncBucketStorageOptions {
42
44
  checksumOptions?: Omit<MongoChecksumOptions, 'storageConfig'>;
43
45
  readPreference?: mongo.ReadPreference;
44
46
  checksumCacheTtlMs?: number;
47
+ clearBatchThrottleRate?: number;
45
48
  storageConfig: StorageConfig;
49
+ objectStorage?: ObjectStorage;
50
+ inlineThresholdBytes?: number;
46
51
  }
47
52
 
48
53
  interface InternalCheckpointChanges extends CheckpointChanges {
@@ -71,6 +76,9 @@ export abstract class MongoSyncBucketStorage
71
76
 
72
77
  readonly checksums: MongoChecksums;
73
78
 
79
+ readonly objectStorage?: ObjectStorage;
80
+ readonly inlineThresholdBytes: number;
81
+
74
82
  /**
75
83
  * Canonical parsed sync config sets, keyed by defaultSchema.
76
84
  *
@@ -83,6 +91,7 @@ export abstract class MongoSyncBucketStorage
83
91
  public readonly logger: Logger;
84
92
  public readonly storageConfig: StorageConfig;
85
93
  public readonly readPreference: mongo.ReadPreference | undefined;
94
+ public readonly clearBatchThrottleRate: number;
86
95
  #storageInitialized = false;
87
96
 
88
97
  constructor(
@@ -95,7 +104,12 @@ export abstract class MongoSyncBucketStorage
95
104
  ) {
96
105
  super();
97
106
  this.storageConfig = options.storageConfig;
107
+ this.objectStorage = options.objectStorage;
108
+ // Keep small chunks inline in MongoDB rather than offloading them to S3.
109
+ // Configurable via object_storage.inline_threshold_bytes.
110
+ this.inlineThresholdBytes = options.inlineThresholdBytes ?? DEFAULT_INLINE_THRESHOLD_BYTES;
98
111
  this.readPreference = options.readPreference;
112
+ this.clearBatchThrottleRate = options.clearBatchThrottleRate ?? DEFAULT_CLEAR_BATCH_THROTTLE_RATE;
99
113
  this.db = factory.db.versioned(this.storageConfig);
100
114
  this.checksums = this.createMongoChecksums(options);
101
115
  this.writeCheckpointAPI = new MongoWriteCheckpointAPI({
@@ -134,7 +148,9 @@ export abstract class MongoSyncBucketStorage
134
148
  this.writeCheckpointAPI.setWriteCheckpointMode(mode);
135
149
  }
136
150
 
137
- createManagedWriteCheckpoints(checkpoints: storage.ManagedWriteCheckpointOptions[]): Promise<Map<string, bigint>> {
151
+ createManagedWriteCheckpoints(
152
+ checkpoints: storage.ManagedWriteCheckpointOptions[]
153
+ ): Promise<storage.CreateManagedWriteCheckpointsResult> {
138
154
  return this.writeCheckpointAPI.createManagedWriteCheckpoints(checkpoints);
139
155
  }
140
156
 
@@ -174,10 +190,14 @@ export abstract class MongoSyncBucketStorage
174
190
  }
175
191
 
176
192
  const snapshotTime = (session as any).snapshotTime as bson.Timestamp | undefined;
193
+ const clusterTime = session.clusterTime;
177
194
  if (snapshotTime == null) {
178
195
  throw new ServiceAssertionError('Missing snapshotTime in getCheckpoint()');
179
196
  }
180
- return new MongoReplicationCheckpoint(this, state.checkpoint, state.lsn, snapshotTime);
197
+ if (clusterTime == null) {
198
+ throw new ServiceAssertionError('Missing clusterTime in getCheckpoint()');
199
+ }
200
+ return new MongoReplicationCheckpoint(this, state.checkpoint, state.lsn, snapshotTime, clusterTime);
181
201
  });
182
202
  }
183
203
 
@@ -214,7 +234,9 @@ export abstract class MongoSyncBucketStorage
214
234
  skipExistingRows: options.skipExistingRows ?? false,
215
235
  markRecordUnavailable: options.markRecordUnavailable,
216
236
  hooks: options.hooks,
217
- tracer: options.tracer
237
+ tracer: options.tracer,
238
+ objectStorage: this.objectStorage,
239
+ inlineThresholdBytes: this.inlineThresholdBytes
218
240
  };
219
241
  }
220
242
 
@@ -254,13 +276,13 @@ export abstract class MongoSyncBucketStorage
254
276
  checkpoint: MongoReplicationCheckpoint,
255
277
  dataBuckets: storage.BucketDataRequest[],
256
278
  options?: storage.BucketDataBatchOptions
257
- ): AsyncIterable<storage.SyncBucketDataChunk>;
279
+ ): AsyncIterable<storage.SyncBucketDataChunk | storage.SyncBucketDataBatchEnd>;
258
280
 
259
281
  async *getBucketDataBatch(
260
282
  checkpoint: storage.ReplicationCheckpoint,
261
283
  dataBuckets: storage.BucketDataRequest[],
262
284
  options?: storage.BucketDataBatchOptions
263
- ): AsyncIterable<storage.SyncBucketDataChunk> {
285
+ ): AsyncIterable<storage.SyncBucketDataChunk | storage.SyncBucketDataBatchEnd> {
264
286
  yield* this.getBucketDataBatchImpl(checkpoint as MongoReplicationCheckpoint, dataBuckets, options);
265
287
  }
266
288
 
@@ -273,6 +295,7 @@ export abstract class MongoSyncBucketStorage
273
295
  const snapshotTime = mongoCheckpoint.snapshotTime; // May be undefined in tests
274
296
  return this.checksums.getChecksums(checkpoint.checkpoint, buckets, {
275
297
  snapshotTime,
298
+ clusterTime: mongoCheckpoint.clusterTime,
276
299
  readPreference: options?.requestHint == 'bulk' ? this.readPreference : undefined
277
300
  });
278
301
  }
@@ -326,23 +349,6 @@ export abstract class MongoSyncBucketStorage
326
349
  this.#storageInitialized = false;
327
350
  }
328
351
 
329
- protected async clearDeleteMany(
330
- label: string,
331
- operation: () => Promise<mongo.DeleteResult>,
332
- signal?: AbortSignal
333
- ): Promise<void> {
334
- await retryOnMongoMaxTimeMSExpired(operation, {
335
- signal,
336
- abortMessage: 'Aborted clearing data',
337
- retryDelayMs: lib_mongo.db.MONGO_CLEAR_OPERATION_TIMEOUT_MS / 5,
338
- onRetry: () => {
339
- this.logger.info(
340
- `Cleared batch of ${label} in ${lib_mongo.db.MONGO_CLEAR_OPERATION_TIMEOUT_MS}ms, continuing...`
341
- );
342
- }
343
- });
344
- }
345
-
346
352
  async reportError(e: any): Promise<void> {
347
353
  const message = String(e.message ?? 'Replication failure');
348
354
  await this.db.sync_rules.updateOne(
@@ -590,6 +596,9 @@ export abstract class MongoSyncBucketStorage
590
596
  >({
591
597
  max: 50,
592
598
  maxSize: 12 * 1024 * 1024,
599
+ // When we have more fetches than the cache size, complete the fetches instead
600
+ // of failing with Error('evicted').
601
+ ignoreFetchAbort: true,
593
602
  sizeCalculation: (value: InternalCheckpointChanges) => {
594
603
  const paramSize = [...value.updatedParameterLookups].reduce<number>((a, b) => a + b.length, 0);
595
604
  const bucketSize = [...value.updatedDataBuckets].reduce<number>((a, b) => a + b.length, 0);
@@ -627,7 +636,8 @@ class MongoReplicationCheckpoint implements ReplicationCheckpoint {
627
636
  storage: MongoSyncBucketStorage,
628
637
  public readonly checkpoint: InternalOpId,
629
638
  public readonly lsn: string | null,
630
- public snapshotTime: mongo.Timestamp
639
+ public snapshotTime: mongo.Timestamp,
640
+ public clusterTime: mongo.ClusterTime
631
641
  ) {
632
642
  this.#storage = storage;
633
643
  }
@@ -28,21 +28,26 @@ export class MongoWriteCheckpointAPI implements storage.WriteCheckpointAPI {
28
28
 
29
29
  async createManagedWriteCheckpoints(
30
30
  checkpoints: storage.ManagedWriteCheckpointOptions[]
31
- ): Promise<Map<string, bigint>> {
31
+ ): Promise<storage.CreateManagedWriteCheckpointsResult> {
32
32
  if (this.writeCheckpointMode !== storage.WriteCheckpointMode.MANAGED) {
33
33
  throw new framework.ServiceAssertionError(
34
34
  `Attempting to create a managed Write Checkpoint when the current Write Checkpoint mode is set to "${this.writeCheckpointMode}"`
35
35
  );
36
36
  }
37
37
 
38
- const uniqueCheckpoints = [...new Map(checkpoints.map((checkpoint) => [checkpoint.user_id, checkpoint])).values()];
38
+ const uniqueCheckpoints = storage.uniqueManagedWriteCheckpoints(checkpoints);
39
39
  if (uniqueCheckpoints.length == 0) {
40
- return new Map();
40
+ return { writeCheckpoints: new Map(), shouldAdvance: false };
41
41
  }
42
42
 
43
- if (uniqueCheckpoints.length == 1) {
43
+ let shouldAdvance = false;
44
+ const writeCheckpoints = new Map<string, bigint>();
45
+ const generatedCheckpoints = uniqueCheckpoints.filter((checkpoint) => checkpoint.checkpoint_request_id == null);
46
+ const suppliedCheckpoints = uniqueCheckpoints.filter((checkpoint) => checkpoint.checkpoint_request_id != null);
47
+
48
+ if (generatedCheckpoints.length == 1) {
44
49
  // For the common case of a single checkpoint, we can do this in a single request.
45
- const { user_id, heads: lsns } = uniqueCheckpoints[0];
50
+ const { user_id, heads: lsns } = generatedCheckpoints[0];
46
51
  const doc = await this.db.write_checkpoints.findOneAndUpdate(
47
52
  {
48
53
  user_id
@@ -52,6 +57,11 @@ export class MongoWriteCheckpointAPI implements storage.WriteCheckpointAPI {
52
57
  lsns,
53
58
  processed_at_lsn: null
54
59
  },
60
+ // Unset rather than set to null, to keep the document out of the
61
+ // partial checkpoint_requested_at index used for retention cleanup.
62
+ $unset: {
63
+ checkpoint_requested_at: 1
64
+ },
55
65
  $inc: {
56
66
  client_id: 1n
57
67
  }
@@ -59,29 +69,123 @@ export class MongoWriteCheckpointAPI implements storage.WriteCheckpointAPI {
59
69
  { upsert: true, returnDocument: 'after' }
60
70
  );
61
71
 
62
- return new Map([[doc!.user_id, doc!.client_id]]);
72
+ // Generated checkpoints always reset processed_at_lsn, so they are pending
73
+ // and the source marker must be forced.
74
+ shouldAdvance = true;
75
+ writeCheckpoints.set(doc!.user_id, doc!.client_id);
76
+ } else if (generatedCheckpoints.length > 1) {
77
+ // For more than one generated checkpoint, this gives a constant 2 requests.
78
+ await this.db.write_checkpoints.bulkWrite(
79
+ generatedCheckpoints.map(({ user_id, heads: lsns }) => ({
80
+ updateOne: {
81
+ filter: { user_id },
82
+ update: {
83
+ $set: {
84
+ lsns,
85
+ processed_at_lsn: null
86
+ },
87
+ $unset: {
88
+ checkpoint_requested_at: 1
89
+ },
90
+ $inc: {
91
+ client_id: 1n
92
+ }
93
+ },
94
+ upsert: true
95
+ }
96
+ }))
97
+ );
98
+
99
+ const userIds = generatedCheckpoints.map((checkpoint) => checkpoint.user_id);
100
+ const docs = await this.db.write_checkpoints
101
+ .find(
102
+ {
103
+ user_id: { $in: userIds }
104
+ },
105
+ {
106
+ projection: {
107
+ user_id: 1,
108
+ client_id: 1
109
+ }
110
+ }
111
+ )
112
+ .toArray();
113
+
114
+ shouldAdvance = true;
115
+ for (const doc of docs) {
116
+ writeCheckpoints.set(doc.user_id, doc.client_id);
117
+ }
63
118
  }
64
119
 
65
- // For more than one checkpoint, this gives a constant 2 requests
120
+ if (suppliedCheckpoints.length > 0) {
121
+ const suppliedResult = await this.createSuppliedManagedWriteCheckpoints(suppliedCheckpoints);
122
+ shouldAdvance ||= suppliedResult.shouldAdvance;
123
+ for (const [userId, writeCheckpoint] of suppliedResult.writeCheckpoints) {
124
+ writeCheckpoints.set(userId, writeCheckpoint);
125
+ }
126
+ }
127
+
128
+ return { writeCheckpoints, shouldAdvance };
129
+ }
130
+
131
+ private async createSuppliedManagedWriteCheckpoints(checkpoints: storage.ManagedWriteCheckpointOptions[]) {
132
+ const checkpointRequestedAt = new Date();
133
+
66
134
  await this.db.write_checkpoints.bulkWrite(
67
- uniqueCheckpoints.map(({ user_id, heads: lsns }) => ({
68
- updateOne: {
69
- filter: { user_id },
70
- update: {
71
- $set: {
72
- lsns,
73
- processed_at_lsn: null
74
- },
75
- $inc: {
76
- client_id: 1n
77
- }
78
- },
79
- upsert: true
80
- }
81
- }))
135
+ checkpoints.map((checkpoint) => {
136
+ const { user_id, heads: lsns } = checkpoint;
137
+ const checkpointRequestId = checkpoint.checkpoint_request_id!;
138
+
139
+ // Supplied request ids are monotonic. updateMany preserves the existing
140
+ // upsert behavior for first checkpoint requests, while also advancing
141
+ // every duplicate row for the user_id that is lower than this request id.
142
+ const shouldApplyRequestId = {
143
+ $or: [
144
+ { $eq: [{ $ifNull: ['$client_id', null] }, null] },
145
+ { $lt: ['$client_id', { $literal: checkpointRequestId }] }
146
+ ]
147
+ };
148
+ // Equal retries keep their original source head but refresh retention.
149
+ const shouldRefreshRequest = {
150
+ $or: [
151
+ { $eq: [{ $ifNull: ['$client_id', null] }, null] },
152
+ { $lte: ['$client_id', { $literal: checkpointRequestId }] }
153
+ ]
154
+ };
155
+
156
+ return {
157
+ updateMany: {
158
+ filter: { user_id },
159
+ update: [
160
+ {
161
+ $set: {
162
+ user_id,
163
+ client_id: {
164
+ $cond: [shouldApplyRequestId, { $literal: checkpointRequestId }, '$client_id']
165
+ },
166
+ lsns: {
167
+ $cond: [shouldApplyRequestId, { $literal: lsns }, '$lsns']
168
+ },
169
+ processed_at_lsn: {
170
+ $cond: [shouldApplyRequestId, null, '$processed_at_lsn']
171
+ },
172
+ checkpoint_requested_at: {
173
+ $cond: [shouldRefreshRequest, { $literal: checkpointRequestedAt }, '$checkpoint_requested_at']
174
+ }
175
+ }
176
+ }
177
+ ],
178
+ upsert: true
179
+ }
180
+ };
181
+ })
82
182
  );
83
183
 
84
- const userIds = uniqueCheckpoints.map((checkpoint) => checkpoint.user_id);
184
+ // Fetch the final ids separately so stale requests can still return the
185
+ // checkpoint currently stored. If duplicate rows already exist for a
186
+ // user_id, use the highest stored id instead of whichever document MongoDB
187
+ // happens to return first.
188
+ const userIds = checkpoints.map((checkpoint) => checkpoint.user_id);
85
189
  const docs = await this.db.write_checkpoints
86
190
  .find(
87
191
  {
@@ -90,13 +194,28 @@ export class MongoWriteCheckpointAPI implements storage.WriteCheckpointAPI {
90
194
  {
91
195
  projection: {
92
196
  user_id: 1,
93
- client_id: 1
197
+ client_id: 1,
198
+ processed_at_lsn: 1
94
199
  }
95
200
  }
96
201
  )
97
202
  .toArray();
203
+ const maxDocsByUser = new Map<string, (typeof docs)[number]>();
204
+ for (const doc of docs) {
205
+ const existing = maxDocsByUser.get(doc.user_id);
206
+ if (
207
+ existing == null ||
208
+ doc.client_id > existing.client_id ||
209
+ (doc.client_id == existing.client_id && existing.processed_at_lsn != null && doc.processed_at_lsn == null)
210
+ ) {
211
+ maxDocsByUser.set(doc.user_id, doc);
212
+ }
213
+ }
98
214
 
99
- return new Map(docs.map((doc) => [doc.user_id, doc.client_id]));
215
+ return {
216
+ writeCheckpoints: new Map([...maxDocsByUser].map(([userId, doc]) => [userId, doc.client_id])),
217
+ shouldAdvance: [...maxDocsByUser.values()].some((doc) => doc.processed_at_lsn == null)
218
+ };
100
219
  }
101
220
 
102
221
  async lastWriteCheckpoint(filters: storage.LastWriteCheckpointFilters): Promise<bigint | null> {
@@ -225,19 +344,27 @@ export async function batchCreateCustomWriteCheckpoints(
225
344
  }
226
345
 
227
346
  await db.custom_write_checkpoints.bulkWrite(
228
- checkpoints.map((checkpointOptions) => ({
229
- updateOne: {
230
- filter: { user_id: checkpointOptions.user_id, sync_rules_id: checkpointOptions.sync_rules_id },
231
- update: {
232
- $set: {
233
- checkpoint: checkpointOptions.checkpoint,
234
- sync_rules_id: checkpointOptions.sync_rules_id,
235
- op_id: opId
236
- }
237
- },
238
- upsert: true
347
+ checkpoints.map((checkpointOptions) => {
348
+ const set: Record<string, unknown> = {
349
+ checkpoint: checkpointOptions.checkpoint,
350
+ sync_rules_id: checkpointOptions.sync_rules_id,
351
+ op_id: opId
352
+ };
353
+ if (checkpointOptions.checkpoint_requested_at != null) {
354
+ set.checkpoint_requested_at = checkpointOptions.checkpoint_requested_at;
239
355
  }
240
- })),
356
+
357
+ return {
358
+ updateOne: {
359
+ filter: { user_id: checkpointOptions.user_id, sync_rules_id: checkpointOptions.sync_rules_id },
360
+ update: {
361
+ $set: set,
362
+ ...(checkpointOptions.checkpoint_requested_at == null ? { $unset: { checkpoint_requested_at: 1 } } : {})
363
+ },
364
+ upsert: true
365
+ }
366
+ };
367
+ }),
241
368
  { session }
242
369
  );
243
370
  }
@@ -1,7 +1,9 @@
1
+ import { mongo } from '@powersync/lib-service-mongodb';
1
2
  import { InternalOpId } from '@powersync/service-core';
2
3
  import * as bson from 'bson';
3
4
 
4
5
  export interface MongoSyncBucketStorageCheckpoint {
5
6
  checkpoint: InternalOpId;
6
7
  snapshotTime: bson.Timestamp;
8
+ clusterTime: mongo.ClusterTime;
7
9
  }
@@ -10,6 +10,7 @@ import { currentBucketKey, MAX_ROW_SIZE } from '../MongoBucketBatchShared.js';
10
10
  import { MongoIdSequence } from '../MongoIdSequence.js';
11
11
  import type { VersionedPowerSyncMongo } from '../db.js';
12
12
  import { TaggedBucketParameterDocument } from '../models.js';
13
+ import { ObjectStorage } from '../v3/object-storage/ObjectStorage.js';
13
14
  import { BucketDataDoc, BucketKey } from './BucketDataDoc.js';
14
15
  import { SourceRecordBucketState, SourceRecordLookupState } from './SourceRecordStore.js';
15
16
 
@@ -34,6 +35,8 @@ const MAX_TRANSACTION_BATCH_SIZE = 30_000_000;
34
35
  */
35
36
  const MAX_TRANSACTION_DOC_COUNT = 2_000;
36
37
 
38
+ export const DEFAULT_INLINE_THRESHOLD_BYTES = 16 * 1024;
39
+
37
40
  export interface SaveBucketDataOptions {
38
41
  op_seq: MongoIdSequence;
39
42
  sourceKey: storage.ReplicaId;
@@ -60,6 +63,8 @@ export interface UpsertCurrentDataOptions {
60
63
 
61
64
  export interface PersistedBatchOptions {
62
65
  logger?: Logger;
66
+ objectStorage?: ObjectStorage;
67
+ inlineThresholdBytes?: number;
63
68
  }
64
69
 
65
70
  /**
@@ -74,6 +79,9 @@ export abstract class PersistedBatch {
74
79
  bucketParameters: TaggedBucketParameterDocument[] = [];
75
80
  bucketStates: Map<string, BucketStateUpdate> = new Map();
76
81
 
82
+ protected readonly objectStorage?: ObjectStorage;
83
+ protected readonly inlineThresholdBytes: number = DEFAULT_INLINE_THRESHOLD_BYTES;
84
+
77
85
  /**
78
86
  * For debug logging only.
79
87
  */
@@ -93,6 +101,10 @@ export abstract class PersistedBatch {
93
101
  ) {
94
102
  this.currentSize = writtenSize;
95
103
  this.logger = options?.logger ?? defaultLogger;
104
+ this.objectStorage = options?.objectStorage;
105
+ if (options?.inlineThresholdBytes != null) {
106
+ this.inlineThresholdBytes = options.inlineThresholdBytes;
107
+ }
96
108
  }
97
109
 
98
110
  saveBucketData(options: SaveBucketDataOptions) {
@@ -309,32 +321,32 @@ export abstract class PersistedBatch {
309
321
  const replicationLag = Math.round((Date.now() - options.oldestUncommittedChange.getTime()) / 1000);
310
322
 
311
323
  this.logger.info(
312
- `Flushed ${this.bucketDataCount} + ${this.bucketParameters.length} + ${
324
+ `Flushed: ${this.bucketDataCount} ops, ${this.bucketParameters.length} index entries, ${
313
325
  this.currentDataCount
314
- } updates, ${Math.round(this.currentSize / 1024)}kb in ${duration}ms. Last op_id: ${this.debugLastOpId}. Replication lag: ${replicationLag}s`,
326
+ } records. ${Math.round(this.currentSize / 1024)}kb in ${duration}ms. Last op_id: ${this.debugLastOpId}. Replication lag: ${replicationLag}s`,
315
327
  {
316
328
  flushed: {
317
329
  duration: duration,
318
330
  size: this.currentSize,
319
- bucket_data_count: this.bucketDataCount,
320
- parameter_data_count: this.bucketParameters.length,
321
- current_data_count: this.currentDataCount,
331
+ bucket_ops_count: this.bucketDataCount,
332
+ parameter_indexes_count: this.bucketParameters.length,
333
+ source_records_count: this.currentDataCount,
322
334
  replication_lag_seconds: replicationLag
323
335
  }
324
336
  }
325
337
  );
326
338
  } else {
327
339
  this.logger.info(
328
- `Flushed ${this.bucketDataCount} + ${this.bucketParameters.length} + ${
340
+ `Flushed: ${this.bucketDataCount} ops, ${this.bucketParameters.length} index entries, ${
329
341
  this.currentDataCount
330
- } updates, ${Math.round(this.currentSize / 1024)}kb in ${duration}ms. Last op_id: ${this.debugLastOpId}`,
342
+ } records. ${Math.round(this.currentSize / 1024)}kb in ${duration}ms. Last op_id: ${this.debugLastOpId}`,
331
343
  {
332
344
  flushed: {
333
345
  duration: duration,
334
346
  size: this.currentSize,
335
- bucket_data_count: this.bucketDataCount,
336
- parameter_data_count: this.bucketParameters.length,
337
- current_data_count: this.currentDataCount
347
+ bucket_ops_count: this.bucketDataCount,
348
+ parameter_indexes_count: this.bucketParameters.length,
349
+ source_records_count: this.currentDataCount
338
350
  }
339
351
  }
340
352
  );
@@ -78,6 +78,11 @@ export interface BucketDataProperties {
78
78
  row_id?: string;
79
79
  checksum: bigint;
80
80
  data: string | null;
81
+ /**
82
+ * V1-only.
83
+ *
84
+ * V3 stores this on the BucketDataDocumentV3 instead of the individual ops.
85
+ */
81
86
  target_op?: bigint | null;
82
87
  }
83
88
 
@@ -273,6 +278,11 @@ export interface CustomWriteCheckpointDocument {
273
278
  * This is not unique - multiple write checkpoints can have the same op_id.
274
279
  */
275
280
  op_id?: InternalOpId;
281
+ /**
282
+ * Set when this checkpoint was created from a client-supplied checkpoint
283
+ * request rather than a persistent custom write checkpoint.
284
+ */
285
+ checkpoint_requested_at?: Date | null;
276
286
  }
277
287
 
278
288
  export interface WriteCheckpointDocument {
@@ -286,6 +296,12 @@ export interface WriteCheckpointDocument {
286
296
  * between two checkpoints.
287
297
  */
288
298
  processed_at_lsn: string | null;
299
+ /**
300
+ * Set when this checkpoint was created from a client-supplied checkpoint request
301
+ * rather than a server-generated id. A missing/null value means this is not a
302
+ * checkpoint request.
303
+ */
304
+ checkpoint_requested_at?: Date | null;
289
305
  }
290
306
 
291
307
  export interface InstanceDocument {