@powersync/service-module-mongodb-storage 0.9.4 → 0.10.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 (43) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/migrations/db/migrations/1749720702136-checkpoint-events.d.ts +3 -0
  3. package/dist/migrations/db/migrations/1749720702136-checkpoint-events.js +34 -0
  4. package/dist/migrations/db/migrations/1749720702136-checkpoint-events.js.map +1 -0
  5. package/dist/storage/MongoBucketStorage.js +5 -0
  6. package/dist/storage/MongoBucketStorage.js.map +1 -1
  7. package/dist/storage/implementation/MongoBucketBatch.d.ts +9 -3
  8. package/dist/storage/implementation/MongoBucketBatch.js +117 -37
  9. package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
  10. package/dist/storage/implementation/MongoPersistedSyncRulesContent.d.ts +1 -0
  11. package/dist/storage/implementation/MongoPersistedSyncRulesContent.js +2 -0
  12. package/dist/storage/implementation/MongoPersistedSyncRulesContent.js.map +1 -1
  13. package/dist/storage/implementation/MongoStorageProvider.js +23 -1
  14. package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
  15. package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +14 -5
  16. package/dist/storage/implementation/MongoSyncBucketStorage.js +161 -159
  17. package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
  18. package/dist/storage/implementation/MongoTestStorageFactoryGenerator.js +2 -0
  19. package/dist/storage/implementation/MongoTestStorageFactoryGenerator.js.map +1 -1
  20. package/dist/storage/implementation/MongoWriteCheckpointAPI.d.ts +9 -15
  21. package/dist/storage/implementation/MongoWriteCheckpointAPI.js +55 -191
  22. package/dist/storage/implementation/MongoWriteCheckpointAPI.js.map +1 -1
  23. package/dist/storage/implementation/PersistedBatch.d.ts +6 -2
  24. package/dist/storage/implementation/PersistedBatch.js +39 -7
  25. package/dist/storage/implementation/PersistedBatch.js.map +1 -1
  26. package/dist/storage/implementation/db.d.ts +12 -1
  27. package/dist/storage/implementation/db.js +39 -0
  28. package/dist/storage/implementation/db.js.map +1 -1
  29. package/dist/storage/implementation/models.d.ts +29 -1
  30. package/package.json +7 -7
  31. package/src/migrations/db/migrations/1749720702136-checkpoint-events.ts +50 -0
  32. package/src/storage/MongoBucketStorage.ts +5 -0
  33. package/src/storage/implementation/MongoBucketBatch.ts +160 -49
  34. package/src/storage/implementation/MongoPersistedSyncRulesContent.ts +2 -0
  35. package/src/storage/implementation/MongoStorageProvider.ts +27 -1
  36. package/src/storage/implementation/MongoSyncBucketStorage.ts +187 -200
  37. package/src/storage/implementation/MongoTestStorageFactoryGenerator.ts +3 -0
  38. package/src/storage/implementation/MongoWriteCheckpointAPI.ts +66 -255
  39. package/src/storage/implementation/PersistedBatch.ts +50 -11
  40. package/src/storage/implementation/db.ts +42 -0
  41. package/src/storage/implementation/models.ts +32 -1
  42. package/test/src/__snapshots__/storage_sync.test.ts.snap +147 -0
  43. package/tsconfig.tsbuildinfo +1 -1
@@ -1,14 +1,7 @@
1
1
  import { mongo } from '@powersync/lib-service-mongodb';
2
2
  import * as framework from '@powersync/lib-services-framework';
3
- import {
4
- Demultiplexer,
5
- DemultiplexerValue,
6
- storage,
7
- WatchUserWriteCheckpointOptions,
8
- WriteCheckpointResult
9
- } from '@powersync/service-core';
3
+ import { GetCheckpointChangesOptions, InternalOpId, storage } from '@powersync/service-core';
10
4
  import { PowerSyncMongo } from './db.js';
11
- import { CustomWriteCheckpointDocument, WriteCheckpointDocument } from './models.js';
12
5
 
13
6
  export type MongoCheckpointAPIOptions = {
14
7
  db: PowerSyncMongo;
@@ -35,13 +28,9 @@ export class MongoWriteCheckpointAPI implements storage.WriteCheckpointAPI {
35
28
  this._mode = mode;
36
29
  }
37
30
 
38
- async batchCreateCustomWriteCheckpoints(checkpoints: storage.CustomWriteCheckpointOptions[]): Promise<void> {
39
- return batchCreateCustomWriteCheckpoints(this.db, checkpoints);
40
- }
41
-
42
31
  async createManagedWriteCheckpoint(checkpoint: storage.ManagedWriteCheckpointOptions): Promise<bigint> {
43
32
  if (this.writeCheckpointMode !== storage.WriteCheckpointMode.MANAGED) {
44
- throw new framework.errors.ValidationError(
33
+ throw new framework.ServiceAssertionError(
45
34
  `Attempting to create a managed Write Checkpoint when the current Write Checkpoint mode is set to "${this.writeCheckpointMode}"`
46
35
  );
47
36
  }
@@ -53,7 +42,8 @@ export class MongoWriteCheckpointAPI implements storage.WriteCheckpointAPI {
53
42
  },
54
43
  {
55
44
  $set: {
56
- lsns
45
+ lsns,
46
+ processed_at_lsn: null
57
47
  },
58
48
  $inc: {
59
49
  client_id: 1n
@@ -68,12 +58,12 @@ export class MongoWriteCheckpointAPI implements storage.WriteCheckpointAPI {
68
58
  switch (this.writeCheckpointMode) {
69
59
  case storage.WriteCheckpointMode.CUSTOM:
70
60
  if (false == 'sync_rules_id' in filters) {
71
- throw new framework.errors.ValidationError(`Sync rules ID is required for custom Write Checkpoint filtering`);
61
+ throw new framework.ServiceAssertionError(`Sync rules ID is required for custom Write Checkpoint filtering`);
72
62
  }
73
63
  return this.lastCustomWriteCheckpoint(filters);
74
64
  case storage.WriteCheckpointMode.MANAGED:
75
65
  if (false == 'heads' in filters) {
76
- throw new framework.errors.ValidationError(
66
+ throw new framework.ServiceAssertionError(
77
67
  `Replication HEAD is required for managed Write Checkpoint filtering`
78
68
  );
79
69
  }
@@ -81,231 +71,15 @@ export class MongoWriteCheckpointAPI implements storage.WriteCheckpointAPI {
81
71
  }
82
72
  }
83
73
 
84
- watchUserWriteCheckpoint(options: WatchUserWriteCheckpointOptions): AsyncIterable<storage.WriteCheckpointResult> {
74
+ async getWriteCheckpointChanges(options: GetCheckpointChangesOptions) {
85
75
  switch (this.writeCheckpointMode) {
86
76
  case storage.WriteCheckpointMode.CUSTOM:
87
- return this.watchCustomWriteCheckpoint(options);
77
+ return this.getCustomWriteCheckpointChanges(options);
88
78
  case storage.WriteCheckpointMode.MANAGED:
89
- return this.watchManagedWriteCheckpoint(options);
90
- default:
91
- throw new Error('Invalid write checkpoint mode');
92
- }
93
- }
94
-
95
- private sharedManagedIter = new Demultiplexer<WriteCheckpointResult>((signal) => {
96
- const clusterTimePromise = this.getClusterTime();
97
-
98
- return {
99
- iterator: this.watchAllManagedWriteCheckpoints(clusterTimePromise, signal),
100
- getFirstValue: async (user_id: string) => {
101
- // Potential race conditions we cater for:
102
-
103
- // Case 1: changestream is behind.
104
- // We get a doc now, then the same or older doc again later.
105
- // No problem!
106
-
107
- // Case 2: Query is behind. I.e. doc has been created, and emitted on the changestream, but the query doesn't see it yet.
108
- // Not possible luckily, but can we make sure?
109
-
110
- // Case 3: changestream delays openeing. A doc is created after our query here, but before the changestream is opened.
111
- // Awaiting clusterTimePromise should be sufficient here, but as a sanity check we also confirm that our query
112
- // timestamp is > the startClusterTime.
113
-
114
- const changeStreamStart = await clusterTimePromise;
115
-
116
- let doc = null as WriteCheckpointDocument | null;
117
- let clusterTime = null as mongo.Timestamp | null;
118
-
119
- await this.db.client.withSession(async (session) => {
120
- doc = await this.db.write_checkpoints.findOne(
121
- {
122
- user_id: user_id
123
- },
124
- {
125
- session
126
- }
127
- );
128
- const time = session.clusterTime?.clusterTime ?? null;
129
- clusterTime = time;
130
- });
131
- if (clusterTime == null) {
132
- throw new framework.ServiceAssertionError('Could not get clusterTime for write checkpoint');
133
- }
134
-
135
- if (clusterTime.lessThan(changeStreamStart)) {
136
- throw new framework.ServiceAssertionError(
137
- 'clusterTime for write checkpoint is older than changestream start'
138
- );
139
- }
140
-
141
- if (doc == null) {
142
- return {
143
- id: null,
144
- lsn: null
145
- };
146
- }
147
-
148
- return {
149
- id: doc.client_id,
150
- lsn: doc.lsns['1']
151
- };
152
- }
153
- };
154
- });
155
-
156
- private async *watchAllManagedWriteCheckpoints(
157
- clusterTimePromise: Promise<mongo.BSON.Timestamp>,
158
- signal: AbortSignal
159
- ): AsyncGenerator<DemultiplexerValue<WriteCheckpointResult>> {
160
- const clusterTime = await clusterTimePromise;
161
-
162
- const stream = this.db.write_checkpoints.watch(
163
- [{ $match: { operationType: { $in: ['insert', 'update', 'replace'] } } }],
164
- {
165
- fullDocument: 'updateLookup',
166
- startAtOperationTime: clusterTime
167
- }
168
- );
169
-
170
- signal.onabort = () => {
171
- stream.close();
172
- };
173
-
174
- if (signal.aborted) {
175
- stream.close();
176
- return;
177
- }
178
-
179
- for await (let event of stream) {
180
- if (!('fullDocument' in event) || event.fullDocument == null) {
181
- continue;
182
- }
183
-
184
- const user_id = event.fullDocument.user_id;
185
- yield {
186
- key: user_id,
187
- value: {
188
- id: event.fullDocument.client_id,
189
- lsn: event.fullDocument.lsns['1']
190
- }
191
- };
79
+ return this.getManagedWriteCheckpointChanges(options);
192
80
  }
193
81
  }
194
82
 
195
- watchManagedWriteCheckpoint(options: WatchUserWriteCheckpointOptions): AsyncIterable<storage.WriteCheckpointResult> {
196
- const stream = this.sharedManagedIter.subscribe(options.user_id, options.signal);
197
- return this.orderedStream(stream);
198
- }
199
-
200
- private sharedCustomIter = new Demultiplexer<WriteCheckpointResult>((signal) => {
201
- const clusterTimePromise = this.getClusterTime();
202
-
203
- return {
204
- iterator: this.watchAllCustomWriteCheckpoints(clusterTimePromise, signal),
205
- getFirstValue: async (user_id: string) => {
206
- // We cater for the same potential race conditions as for managed write checkpoints.
207
-
208
- const changeStreamStart = await clusterTimePromise;
209
-
210
- let doc = null as CustomWriteCheckpointDocument | null;
211
- let clusterTime = null as mongo.Timestamp | null;
212
-
213
- await this.db.client.withSession(async (session) => {
214
- doc = await this.db.custom_write_checkpoints.findOne(
215
- {
216
- user_id: user_id,
217
- sync_rules_id: this.sync_rules_id
218
- },
219
- {
220
- session
221
- }
222
- );
223
- const time = session.clusterTime?.clusterTime ?? null;
224
- clusterTime = time;
225
- });
226
- if (clusterTime == null) {
227
- throw new framework.ServiceAssertionError('Could not get clusterTime for write checkpoint');
228
- }
229
-
230
- if (clusterTime.lessThan(changeStreamStart)) {
231
- throw new framework.ServiceAssertionError(
232
- 'clusterTime for write checkpoint is older than changestream start'
233
- );
234
- }
235
-
236
- if (doc == null) {
237
- // No write checkpoint, but we still need to return a result
238
- return {
239
- id: null,
240
- lsn: null
241
- };
242
- }
243
-
244
- return {
245
- id: doc.checkpoint,
246
- // custom write checkpoints are not tied to a LSN
247
- lsn: null
248
- };
249
- }
250
- };
251
- });
252
-
253
- private async *watchAllCustomWriteCheckpoints(
254
- clusterTimePromise: Promise<mongo.BSON.Timestamp>,
255
- signal: AbortSignal
256
- ): AsyncGenerator<DemultiplexerValue<WriteCheckpointResult>> {
257
- const clusterTime = await clusterTimePromise;
258
-
259
- const stream = this.db.custom_write_checkpoints.watch(
260
- [
261
- {
262
- $match: {
263
- 'fullDocument.sync_rules_id': this.sync_rules_id,
264
- operationType: { $in: ['insert', 'update', 'replace'] }
265
- }
266
- }
267
- ],
268
- {
269
- fullDocument: 'updateLookup',
270
- startAtOperationTime: clusterTime
271
- }
272
- );
273
-
274
- signal.onabort = () => {
275
- stream.close();
276
- };
277
-
278
- if (signal.aborted) {
279
- stream.close();
280
- return;
281
- }
282
-
283
- for await (let event of stream) {
284
- if (!('fullDocument' in event) || event.fullDocument == null) {
285
- continue;
286
- }
287
-
288
- const user_id = event.fullDocument.user_id;
289
- yield {
290
- key: user_id,
291
- value: {
292
- id: event.fullDocument.checkpoint,
293
- // Custom write checkpoints are not tied to a specific LSN
294
- lsn: null
295
- }
296
- };
297
- }
298
- }
299
-
300
- watchCustomWriteCheckpoint(options: WatchUserWriteCheckpointOptions): AsyncIterable<storage.WriteCheckpointResult> {
301
- if (options.sync_rules_id != this.sync_rules_id) {
302
- throw new framework.ServiceAssertionError('sync_rules_id does not match');
303
- }
304
-
305
- const stream = this.sharedCustomIter.subscribe(options.user_id, options.signal);
306
- return this.orderedStream(stream);
307
- }
308
-
309
83
  protected async lastCustomWriteCheckpoint(filters: storage.CustomWriteCheckpointFilters) {
310
84
  const { user_id, sync_rules_id } = filters;
311
85
  const lastWriteCheckpoint = await this.db.custom_write_checkpoints.findOne({
@@ -330,34 +104,70 @@ export class MongoWriteCheckpointAPI implements storage.WriteCheckpointAPI {
330
104
  return lastWriteCheckpoint?.client_id ?? null;
331
105
  }
332
106
 
333
- private async getClusterTime(): Promise<mongo.Timestamp> {
334
- const hello = await this.db.db.command({ hello: 1 });
335
- // Note: This is not valid on sharded clusters.
336
- const startClusterTime = hello.lastWrite?.majorityOpTime?.ts as mongo.Timestamp;
337
- return startClusterTime;
338
- }
107
+ private async getManagedWriteCheckpointChanges(options: GetCheckpointChangesOptions) {
108
+ const limit = 1000;
109
+ const changes = await this.db.write_checkpoints
110
+ .find(
111
+ {
112
+ processed_at_lsn: { $gt: options.lastCheckpoint.lsn, $lte: options.nextCheckpoint.lsn }
113
+ },
114
+ {
115
+ limit: limit + 1,
116
+ batchSize: limit + 1,
117
+ singleBatch: true
118
+ }
119
+ )
120
+ .toArray();
121
+ const invalidate = changes.length > limit;
122
+
123
+ const updatedWriteCheckpoints = new Map<string, bigint>();
124
+ if (!invalidate) {
125
+ for (let c of changes) {
126
+ updatedWriteCheckpoints.set(c.user_id, c.client_id);
127
+ }
128
+ }
339
129
 
340
- /**
341
- * Makes a write checkpoint stream an orderered one - any out-of-order events are discarded.
342
- */
343
- private async *orderedStream(stream: AsyncIterable<storage.WriteCheckpointResult>) {
344
- let lastId = -1n;
130
+ return {
131
+ invalidateWriteCheckpoints: invalidate,
132
+ updatedWriteCheckpoints
133
+ };
134
+ }
345
135
 
346
- for await (let event of stream) {
347
- // Guard against out-of-order events
348
- if (lastId == -1n || (event.id != null && event.id > lastId)) {
349
- yield event;
350
- if (event.id != null) {
351
- lastId = event.id;
136
+ private async getCustomWriteCheckpointChanges(options: GetCheckpointChangesOptions) {
137
+ const limit = 1000;
138
+ const changes = await this.db.custom_write_checkpoints
139
+ .find(
140
+ {
141
+ op_id: { $gt: options.lastCheckpoint.checkpoint, $lte: options.nextCheckpoint.checkpoint }
142
+ },
143
+ {
144
+ limit: limit + 1,
145
+ batchSize: limit + 1,
146
+ singleBatch: true
352
147
  }
148
+ )
149
+ .toArray();
150
+ const invalidate = changes.length > limit;
151
+
152
+ const updatedWriteCheckpoints = new Map<string, bigint>();
153
+ if (!invalidate) {
154
+ for (let c of changes) {
155
+ updatedWriteCheckpoints.set(c.user_id, c.checkpoint);
353
156
  }
354
157
  }
158
+
159
+ return {
160
+ invalidateWriteCheckpoints: invalidate,
161
+ updatedWriteCheckpoints
162
+ };
355
163
  }
356
164
  }
357
165
 
358
166
  export async function batchCreateCustomWriteCheckpoints(
359
167
  db: PowerSyncMongo,
360
- checkpoints: storage.CustomWriteCheckpointOptions[]
168
+ session: mongo.ClientSession,
169
+ checkpoints: storage.CustomWriteCheckpointOptions[],
170
+ opId: InternalOpId
361
171
  ): Promise<void> {
362
172
  if (checkpoints.length == 0) {
363
173
  return;
@@ -370,12 +180,13 @@ export async function batchCreateCustomWriteCheckpoints(
370
180
  update: {
371
181
  $set: {
372
182
  checkpoint: checkpointOptions.checkpoint,
373
- sync_rules_id: checkpointOptions.sync_rules_id
183
+ sync_rules_id: checkpointOptions.sync_rules_id,
184
+ op_id: opId
374
185
  }
375
186
  },
376
187
  upsert: true
377
188
  }
378
189
  })),
379
- {}
190
+ { session }
380
191
  );
381
192
  }
@@ -3,7 +3,7 @@ import { JSONBig } from '@powersync/service-jsonbig';
3
3
  import { EvaluatedParameters, EvaluatedRow } from '@powersync/service-sync-rules';
4
4
  import * as bson from 'bson';
5
5
 
6
- import { logger } from '@powersync/lib-services-framework';
6
+ import { Logger, logger as defaultLogger } from '@powersync/lib-services-framework';
7
7
  import { InternalOpId, storage, utils } from '@powersync/service-core';
8
8
  import { currentBucketKey, MAX_ROW_SIZE } from './MongoBucketBatch.js';
9
9
  import { MongoIdSequence } from './MongoIdSequence.js';
@@ -46,6 +46,7 @@ const MAX_TRANSACTION_DOC_COUNT = 2_000;
46
46
  * multiple transactions.
47
47
  */
48
48
  export class PersistedBatch {
49
+ logger: Logger;
49
50
  bucketData: mongo.AnyBulkWriteOperation<BucketDataDocument>[] = [];
50
51
  bucketParameters: mongo.AnyBulkWriteOperation<BucketParameterDocument>[] = [];
51
52
  currentData: mongo.AnyBulkWriteOperation<CurrentDataDocument>[] = [];
@@ -63,9 +64,11 @@ export class PersistedBatch {
63
64
 
64
65
  constructor(
65
66
  private group_id: number,
66
- writtenSize: number
67
+ writtenSize: number,
68
+ options?: { logger?: Logger }
67
69
  ) {
68
70
  this.currentSize = writtenSize;
71
+ this.logger = options?.logger ?? defaultLogger;
69
72
  }
70
73
 
71
74
  private incrementBucket(bucket: string, op_id: InternalOpId) {
@@ -107,7 +110,7 @@ export class PersistedBatch {
107
110
  // the BSON size is small enough, but the JSON size is too large.
108
111
  // In these cases, we can't store the data, so we skip it, or generate a REMOVE operation if the row
109
112
  // was synced previously.
110
- logger.error(`powersync_${this.group_id} Row ${key} too large: ${recordData.length} bytes. Removing.`);
113
+ this.logger.error(`Row ${key} too large: ${recordData.length} bytes. Removing.`);
111
114
  continue;
112
115
  }
113
116
 
@@ -206,7 +209,7 @@ export class PersistedBatch {
206
209
  k: sourceKey
207
210
  },
208
211
  lookup: binLookup,
209
- bucket_parameters: result.bucket_parameters
212
+ bucket_parameters: result.bucketParameters
210
213
  }
211
214
  }
212
215
  });
@@ -270,9 +273,11 @@ export class PersistedBatch {
270
273
  );
271
274
  }
272
275
 
273
- async flush(db: PowerSyncMongo, session: mongo.ClientSession) {
276
+ async flush(db: PowerSyncMongo, session: mongo.ClientSession, options?: storage.BucketBatchCommitOptions) {
274
277
  const startAt = performance.now();
278
+ let flushedSomething = false;
275
279
  if (this.bucketData.length > 0) {
280
+ flushedSomething = true;
276
281
  await db.bucket_data.bulkWrite(this.bucketData, {
277
282
  session,
278
283
  // inserts only - order doesn't matter
@@ -280,6 +285,7 @@ export class PersistedBatch {
280
285
  });
281
286
  }
282
287
  if (this.bucketParameters.length > 0) {
288
+ flushedSomething = true;
283
289
  await db.bucket_parameters.bulkWrite(this.bucketParameters, {
284
290
  session,
285
291
  // inserts only - order doesn't matter
@@ -287,6 +293,7 @@ export class PersistedBatch {
287
293
  });
288
294
  }
289
295
  if (this.currentData.length > 0) {
296
+ flushedSomething = true;
290
297
  await db.current_data.bulkWrite(this.currentData, {
291
298
  session,
292
299
  // may update and delete data within the same batch - order matters
@@ -295,6 +302,7 @@ export class PersistedBatch {
295
302
  }
296
303
 
297
304
  if (this.bucketStates.size > 0) {
305
+ flushedSomething = true;
298
306
  await db.bucket_state.bulkWrite(this.getBucketStateUpdates(), {
299
307
  session,
300
308
  // Per-bucket operation - order doesn't matter
@@ -302,12 +310,43 @@ export class PersistedBatch {
302
310
  });
303
311
  }
304
312
 
305
- const duration = performance.now() - startAt;
306
- logger.info(
307
- `powersync_${this.group_id} Flushed ${this.bucketData.length} + ${this.bucketParameters.length} + ${
308
- this.currentData.length
309
- } updates, ${Math.round(this.currentSize / 1024)}kb in ${duration.toFixed(0)}ms. Last op_id: ${this.debugLastOpId}`
310
- );
313
+ if (flushedSomething) {
314
+ const duration = Math.round(performance.now() - startAt);
315
+ if (options?.oldestUncommittedChange != null) {
316
+ const replicationLag = Math.round((Date.now() - options.oldestUncommittedChange.getTime()) / 1000);
317
+
318
+ this.logger.info(
319
+ `Flushed ${this.bucketData.length} + ${this.bucketParameters.length} + ${
320
+ this.currentData.length
321
+ } updates, ${Math.round(this.currentSize / 1024)}kb in ${duration}ms. Last op_id: ${this.debugLastOpId}. Replication lag: ${replicationLag}s`,
322
+ {
323
+ flushed: {
324
+ duration: duration,
325
+ size: this.currentSize,
326
+ bucket_data_count: this.bucketData.length,
327
+ parameter_data_count: this.bucketParameters.length,
328
+ current_data_count: this.currentData.length,
329
+ replication_lag_seconds: replicationLag
330
+ }
331
+ }
332
+ );
333
+ } else {
334
+ this.logger.info(
335
+ `Flushed ${this.bucketData.length} + ${this.bucketParameters.length} + ${
336
+ this.currentData.length
337
+ } updates, ${Math.round(this.currentSize / 1024)}kb in ${duration}ms. Last op_id: ${this.debugLastOpId}`,
338
+ {
339
+ flushed: {
340
+ duration: duration,
341
+ size: this.currentSize,
342
+ bucket_data_count: this.bucketData.length,
343
+ parameter_data_count: this.bucketParameters.length,
344
+ current_data_count: this.currentData.length
345
+ }
346
+ }
347
+ );
348
+ }
349
+ }
311
350
 
312
351
  this.bucketData = [];
313
352
  this.bucketParameters = [];
@@ -7,6 +7,7 @@ import {
7
7
  BucketDataDocument,
8
8
  BucketParameterDocument,
9
9
  BucketStateDocument,
10
+ CheckpointEventDocument,
10
11
  CurrentDataDocument,
11
12
  CustomWriteCheckpointDocument,
12
13
  IdSequenceDocument,
@@ -35,6 +36,7 @@ export class PowerSyncMongo {
35
36
  readonly instance: mongo.Collection<InstanceDocument>;
36
37
  readonly locks: mongo.Collection<lib_mongo.locks.Lock>;
37
38
  readonly bucket_state: mongo.Collection<BucketStateDocument>;
39
+ readonly checkpoint_events: mongo.Collection<CheckpointEventDocument>;
38
40
 
39
41
  readonly client: mongo.MongoClient;
40
42
  readonly db: mongo.Db;
@@ -58,6 +60,7 @@ export class PowerSyncMongo {
58
60
  this.instance = db.collection('instance');
59
61
  this.locks = this.db.collection('locks');
60
62
  this.bucket_state = this.db.collection('bucket_state');
63
+ this.checkpoint_events = this.db.collection('checkpoint_events');
61
64
  }
62
65
 
63
66
  /**
@@ -85,6 +88,45 @@ export class PowerSyncMongo {
85
88
  async drop() {
86
89
  await this.db.dropDatabase();
87
90
  }
91
+
92
+ /**
93
+ * Call this after every checkpoint or sync rules status update. Rather call too often than too rarely.
94
+ *
95
+ * This is used in a similar way to the Postgres NOTIFY functionality.
96
+ */
97
+ async notifyCheckpoint() {
98
+ await this.checkpoint_events.insertOne({} as any, { forceServerObjectId: true });
99
+ }
100
+
101
+ /**
102
+ * Only use in migrations and tests.
103
+ */
104
+ async createCheckpointEventsCollection() {
105
+ // We cover the case where the replication process was started before running this migration.
106
+ const existingCollections = await this.db
107
+ .listCollections({ name: 'checkpoint_events' }, { nameOnly: false })
108
+ .toArray();
109
+ const collection = existingCollections[0];
110
+ if (collection != null) {
111
+ if (!collection.options?.capped) {
112
+ // Collection was auto-created but not capped, so we need to drop it
113
+ await this.db.dropCollection('checkpoint_events');
114
+ } else {
115
+ // Collection previously created somehow - ignore
116
+ return;
117
+ }
118
+ }
119
+
120
+ await this.db.createCollection('checkpoint_events', {
121
+ capped: true,
122
+ // We want a small size, since opening a tailable cursor scans this entire collection.
123
+ // On the other hand, if we fill this up faster than a process can read it, it will
124
+ // invalidate the cursor. We do handle cursor invalidation events, but don't want
125
+ // that to happen too often.
126
+ size: 50 * 1024, // size in bytes
127
+ max: 50 // max number of documents
128
+ });
129
+ }
88
130
  }
89
131
 
90
132
  export function createPowerSyncMongo(config: MongoStorageConfig, options?: lib_mongo.MongoConnectionOptions) {
@@ -1,4 +1,4 @@
1
- import { storage } from '@powersync/service-core';
1
+ import { InternalOpId, storage } from '@powersync/service-core';
2
2
  import { SqliteJsonValue } from '@powersync/service-sync-rules';
3
3
  import * as bson from 'bson';
4
4
 
@@ -73,6 +73,13 @@ export interface SourceTableDocument {
73
73
  replica_id_columns: string[] | null;
74
74
  replica_id_columns2: { name: string; type_oid?: number; type?: string }[] | undefined;
75
75
  snapshot_done: boolean | undefined;
76
+ snapshot_status: SourceTableDocumentSnapshotStatus | undefined;
77
+ }
78
+
79
+ export interface SourceTableDocumentSnapshotStatus {
80
+ total_estimated_count: number;
81
+ replicated_count: number;
82
+ last_key: bson.Binary | null;
76
83
  }
77
84
 
78
85
  /**
@@ -110,6 +117,13 @@ export interface SyncRuleDocument {
110
117
  */
111
118
  snapshot_done: boolean;
112
119
 
120
+ /**
121
+ * If snapshot_done = false, this may be the lsn at which we started the snapshot.
122
+ *
123
+ * This can be used for resuming the snapshot after a restart.
124
+ */
125
+ snapshot_lsn: string | undefined;
126
+
113
127
  /**
114
128
  * The last consistent checkpoint.
115
129
  *
@@ -159,6 +173,10 @@ export interface SyncRuleDocument {
159
173
  content: string;
160
174
  }
161
175
 
176
+ export interface CheckpointEventDocument {
177
+ _id: bson.ObjectId;
178
+ }
179
+
162
180
  export type SyncRuleCheckpointState = Pick<
163
181
  SyncRuleDocument,
164
182
  'last_checkpoint' | 'last_checkpoint_lsn' | '_id' | 'state'
@@ -169,6 +187,13 @@ export interface CustomWriteCheckpointDocument {
169
187
  user_id: string;
170
188
  checkpoint: bigint;
171
189
  sync_rules_id: number;
190
+ /**
191
+ * Unlike managed write checkpoints, custom write checkpoints are flushed together with
192
+ * normal ops. This means we can assign an op_id for ordering / correlating with read checkpoints.
193
+ *
194
+ * This is not unique - multiple write checkpoints can have the same op_id.
195
+ */
196
+ op_id?: InternalOpId;
172
197
  }
173
198
 
174
199
  export interface WriteCheckpointDocument {
@@ -176,6 +201,12 @@ export interface WriteCheckpointDocument {
176
201
  user_id: string;
177
202
  lsns: Record<string, string>;
178
203
  client_id: bigint;
204
+ /**
205
+ * This is set to the checkpoint lsn when the checkpoint lsn >= this lsn.
206
+ * This is used to make it easier to determine what write checkpoints have been processed
207
+ * between two checkpoints.
208
+ */
209
+ processed_at_lsn: string | null;
179
210
  }
180
211
 
181
212
  export interface InstanceDocument {