@powersync/service-module-mongodb-storage 0.18.3 → 0.20.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 (145) hide show
  1. package/CHANGELOG.md +53 -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/MongoCompactor.d.ts +15 -19
  11. package/dist/storage/implementation/MongoCompactor.js +68 -260
  12. package/dist/storage/implementation/MongoCompactor.js.map +1 -1
  13. package/dist/storage/implementation/MongoStorageProvider.js +26 -3
  14. package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
  15. package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +10 -4
  16. package/dist/storage/implementation/MongoSyncBucketStorage.js +13 -12
  17. package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
  18. package/dist/storage/implementation/MongoWriteCheckpointAPI.d.ts +2 -1
  19. package/dist/storage/implementation/MongoWriteCheckpointAPI.js +146 -34
  20. package/dist/storage/implementation/MongoWriteCheckpointAPI.js.map +1 -1
  21. package/dist/storage/implementation/common/PersistedBatch.d.ts +6 -0
  22. package/dist/storage/implementation/common/PersistedBatch.js +15 -8
  23. package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
  24. package/dist/storage/implementation/models.d.ts +20 -0
  25. package/dist/storage/implementation/models.js.map +1 -1
  26. package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +4 -0
  27. package/dist/storage/implementation/v1/MongoBucketBatchV1.js +58 -84
  28. package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
  29. package/dist/storage/implementation/v1/MongoCompactorV1.d.ts +11 -2
  30. package/dist/storage/implementation/v1/MongoCompactorV1.js +250 -1
  31. package/dist/storage/implementation/v1/MongoCompactorV1.js.map +1 -1
  32. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +19 -11
  33. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
  34. package/dist/storage/implementation/v1/SingleBucketStoreV1.d.ts +9 -10
  35. package/dist/storage/implementation/v1/SingleBucketStoreV1.js +2 -8
  36. package/dist/storage/implementation/v1/SingleBucketStoreV1.js.map +1 -1
  37. package/dist/storage/implementation/v3/BucketDataContextV3.d.ts +13 -0
  38. package/dist/storage/implementation/v3/BucketDataContextV3.js +23 -0
  39. package/dist/storage/implementation/v3/BucketDataContextV3.js.map +1 -0
  40. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +35 -23
  41. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  42. package/dist/storage/implementation/v3/MongoChecksumsV3.d.ts +1 -0
  43. package/dist/storage/implementation/v3/MongoChecksumsV3.js +59 -103
  44. package/dist/storage/implementation/v3/MongoChecksumsV3.js.map +1 -1
  45. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +23 -2
  46. package/dist/storage/implementation/v3/MongoCompactorV3.js +354 -219
  47. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  48. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.d.ts +5 -0
  49. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js +12 -11
  50. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js.map +1 -1
  51. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.d.ts +5 -3
  52. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +108 -106
  53. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
  54. package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +5 -2
  55. package/dist/storage/implementation/v3/PersistedBatchV3.js +53 -15
  56. package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
  57. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.d.ts +2 -1
  58. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js +3 -0
  59. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js.map +1 -1
  60. package/dist/storage/implementation/v3/bucket-format.d.ts +10 -1
  61. package/dist/storage/implementation/v3/bucket-format.js +28 -9
  62. package/dist/storage/implementation/v3/bucket-format.js.map +1 -1
  63. package/dist/storage/implementation/v3/chunking.js +3 -1
  64. package/dist/storage/implementation/v3/chunking.js.map +1 -1
  65. package/dist/storage/implementation/v3/models.d.ts +43 -2
  66. package/dist/storage/implementation/v3/models.js.map +1 -1
  67. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.d.ts +21 -0
  68. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +44 -0
  69. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -0
  70. package/dist/storage/implementation/v3/object-storage/ObjectStorage.d.ts +30 -0
  71. package/dist/storage/implementation/v3/object-storage/ObjectStorage.js +12 -0
  72. package/dist/storage/implementation/v3/object-storage/ObjectStorage.js.map +1 -0
  73. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +70 -0
  74. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +146 -0
  75. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -0
  76. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.d.ts +42 -0
  77. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js +285 -0
  78. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js.map +1 -0
  79. package/dist/storage/implementation/v3/source-table-utils.d.ts +9 -1
  80. package/dist/storage/implementation/v3/source-table-utils.js +20 -21
  81. package/dist/storage/implementation/v3/source-table-utils.js.map +1 -1
  82. package/dist/types/types.d.ts +35 -0
  83. package/dist/types/types.js +35 -1
  84. package/dist/types/types.js.map +1 -1
  85. package/dist/utils/test-utils.js +3 -1
  86. package/dist/utils/test-utils.js.map +1 -1
  87. package/dist/utils/util.d.ts +8 -1
  88. package/dist/utils/util.js +125 -2
  89. package/dist/utils/util.js.map +1 -1
  90. package/package.json +10 -7
  91. package/src/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.ts +55 -0
  92. package/src/storage/MongoBucketStorage.ts +10 -2
  93. package/src/storage/implementation/MongoBucketBatch.ts +4 -0
  94. package/src/storage/implementation/MongoCompactor.ts +90 -314
  95. package/src/storage/implementation/MongoStorageProvider.ts +32 -3
  96. package/src/storage/implementation/MongoSyncBucketStorage.ts +23 -22
  97. package/src/storage/implementation/MongoWriteCheckpointAPI.ts +164 -37
  98. package/src/storage/implementation/common/PersistedBatch.ts +22 -10
  99. package/src/storage/implementation/models.ts +20 -0
  100. package/src/storage/implementation/v1/MongoBucketBatchV1.ts +76 -96
  101. package/src/storage/implementation/v1/MongoCompactorV1.ts +292 -8
  102. package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +53 -40
  103. package/src/storage/implementation/v1/SingleBucketStoreV1.ts +18 -27
  104. package/src/storage/implementation/v3/BucketDataContextV3.ts +32 -0
  105. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +40 -23
  106. package/src/storage/implementation/v3/MongoChecksumsV3.ts +75 -105
  107. package/src/storage/implementation/v3/MongoCompactorV3.ts +456 -258
  108. package/src/storage/implementation/v3/MongoStoppedSyncConfigCleanup.ts +21 -19
  109. package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +122 -126
  110. package/src/storage/implementation/v3/PersistedBatchV3.ts +69 -16
  111. package/src/storage/implementation/v3/VersionedPowerSyncMongoV3.ts +5 -0
  112. package/src/storage/implementation/v3/bucket-format.ts +36 -9
  113. package/src/storage/implementation/v3/chunking.ts +3 -1
  114. package/src/storage/implementation/v3/models.ts +45 -1
  115. package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +54 -0
  116. package/src/storage/implementation/v3/object-storage/ObjectStorage.ts +29 -0
  117. package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +171 -0
  118. package/src/storage/implementation/v3/object-storage/S3ObjectStorage.ts +343 -0
  119. package/src/storage/implementation/v3/source-table-utils.ts +43 -35
  120. package/src/types/types.ts +42 -1
  121. package/src/utils/test-utils.ts +3 -1
  122. package/src/utils/util.ts +182 -4
  123. package/test/src/__snapshots__/storage_sync.test.ts.snap +1 -583
  124. package/test/src/cleanup-stopped-sync-configs.test.ts +24 -1
  125. package/test/src/helpers/MemoryObjectStorage.ts +53 -0
  126. package/test/src/helpers/s3TestFactory.ts +69 -0
  127. package/test/src/object_storage_lifecycle.test.ts +59 -0
  128. package/test/src/setup.ts +6 -1
  129. package/test/src/storage.test.ts +216 -2
  130. package/test/src/storage_compacting.test.ts +176 -187
  131. package/test/src/storage_object_storage_inline_threshold.test.ts +116 -0
  132. package/test/src/storage_s3_checksums.test.ts +191 -0
  133. package/test/src/storage_s3_compaction_lifecycle.test.ts +417 -0
  134. package/test/src/storage_s3_reading.test.ts +526 -0
  135. package/test/src/storage_s3_writing.test.ts +135 -0
  136. package/test/src/storage_sync.test.ts +124 -5
  137. package/tsconfig.tsbuildinfo +1 -1
  138. package/dist/storage/implementation/common/SingleBucketStore.d.ts +0 -54
  139. package/dist/storage/implementation/common/SingleBucketStore.js +0 -3
  140. package/dist/storage/implementation/common/SingleBucketStore.js.map +0 -1
  141. package/dist/storage/implementation/v3/SingleBucketStoreV3.d.ts +0 -18
  142. package/dist/storage/implementation/v3/SingleBucketStoreV3.js +0 -84
  143. package/dist/storage/implementation/v3/SingleBucketStoreV3.js.map +0 -1
  144. package/src/storage/implementation/common/SingleBucketStore.ts +0 -63
  145. 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
 
@@ -218,7 +234,9 @@ export abstract class MongoSyncBucketStorage
218
234
  skipExistingRows: options.skipExistingRows ?? false,
219
235
  markRecordUnavailable: options.markRecordUnavailable,
220
236
  hooks: options.hooks,
221
- tracer: options.tracer
237
+ tracer: options.tracer,
238
+ objectStorage: this.objectStorage,
239
+ inlineThresholdBytes: this.inlineThresholdBytes
222
240
  };
223
241
  }
224
242
 
@@ -258,13 +276,13 @@ export abstract class MongoSyncBucketStorage
258
276
  checkpoint: MongoReplicationCheckpoint,
259
277
  dataBuckets: storage.BucketDataRequest[],
260
278
  options?: storage.BucketDataBatchOptions
261
- ): AsyncIterable<storage.SyncBucketDataChunk>;
279
+ ): AsyncIterable<storage.SyncBucketDataChunk | storage.SyncBucketDataBatchEnd>;
262
280
 
263
281
  async *getBucketDataBatch(
264
282
  checkpoint: storage.ReplicationCheckpoint,
265
283
  dataBuckets: storage.BucketDataRequest[],
266
284
  options?: storage.BucketDataBatchOptions
267
- ): AsyncIterable<storage.SyncBucketDataChunk> {
285
+ ): AsyncIterable<storage.SyncBucketDataChunk | storage.SyncBucketDataBatchEnd> {
268
286
  yield* this.getBucketDataBatchImpl(checkpoint as MongoReplicationCheckpoint, dataBuckets, options);
269
287
  }
270
288
 
@@ -331,23 +349,6 @@ export abstract class MongoSyncBucketStorage
331
349
  this.#storageInitialized = false;
332
350
  }
333
351
 
334
- protected async clearDeleteMany(
335
- label: string,
336
- operation: () => Promise<mongo.DeleteResult>,
337
- signal?: AbortSignal
338
- ): Promise<void> {
339
- await retryOnMongoMaxTimeMSExpired(operation, {
340
- signal,
341
- abortMessage: 'Aborted clearing data',
342
- retryDelayMs: lib_mongo.db.MONGO_CLEAR_OPERATION_TIMEOUT_MS / 5,
343
- onRetry: () => {
344
- this.logger.info(
345
- `Cleared batch of ${label} in ${lib_mongo.db.MONGO_CLEAR_OPERATION_TIMEOUT_MS}ms, continuing...`
346
- );
347
- }
348
- });
349
- }
350
-
351
352
  async reportError(e: any): Promise<void> {
352
353
  const message = String(e.message ?? 'Replication failure');
353
354
  await this.db.sync_rules.updateOne(
@@ -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
  }
@@ -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
 
@@ -105,6 +110,10 @@ export interface SourceTableDocument {
105
110
  replica_id_columns2: { name: string; type_oid?: number; type?: string }[] | undefined;
106
111
  snapshot_done: boolean | undefined;
107
112
  snapshot_status: SourceTableDocumentSnapshotStatus | undefined;
113
+ /**
114
+ * Source-specific metadata. Absent for legacy records.
115
+ */
116
+ source_metadata?: storage.JsonValue;
108
117
  }
109
118
 
110
119
  export interface SourceTableDocumentSnapshotStatus {
@@ -273,6 +282,11 @@ export interface CustomWriteCheckpointDocument {
273
282
  * This is not unique - multiple write checkpoints can have the same op_id.
274
283
  */
275
284
  op_id?: InternalOpId;
285
+ /**
286
+ * Set when this checkpoint was created from a client-supplied checkpoint
287
+ * request rather than a persistent custom write checkpoint.
288
+ */
289
+ checkpoint_requested_at?: Date | null;
276
290
  }
277
291
 
278
292
  export interface WriteCheckpointDocument {
@@ -286,6 +300,12 @@ export interface WriteCheckpointDocument {
286
300
  * between two checkpoints.
287
301
  */
288
302
  processed_at_lsn: string | null;
303
+ /**
304
+ * Set when this checkpoint was created from a client-supplied checkpoint request
305
+ * rather than a server-generated id. A missing/null value means this is not a
306
+ * checkpoint request.
307
+ */
308
+ checkpoint_requested_at?: Date | null;
289
309
  }
290
310
 
291
311
  export interface InstanceDocument {