@powersync/service-module-mongodb-storage 0.0.0-dev-20250313091552 → 0.0.0-dev-20250317122913

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.
@@ -1,6 +1,14 @@
1
+ import { mongo } from '@powersync/lib-service-mongodb';
1
2
  import * as framework from '@powersync/lib-services-framework';
2
- import { storage } from '@powersync/service-core';
3
+ import {
4
+ Demultiplexer,
5
+ DemultiplexerValue,
6
+ storage,
7
+ WatchUserWriteCheckpointOptions,
8
+ WriteCheckpointResult
9
+ } from '@powersync/service-core';
3
10
  import { PowerSyncMongo } from './db.js';
11
+ import { CustomWriteCheckpointDocument, WriteCheckpointDocument } from './models.js';
4
12
 
5
13
  export type MongoCheckpointAPIOptions = {
6
14
  db: PowerSyncMongo;
@@ -93,6 +101,223 @@ export class MongoWriteCheckpointAPI implements storage.WriteCheckpointAPI {
93
101
  }
94
102
  }
95
103
 
104
+ watchUserWriteCheckpoint(options: WatchUserWriteCheckpointOptions): AsyncIterable<storage.WriteCheckpointResult> {
105
+ switch (this.writeCheckpointMode) {
106
+ case storage.WriteCheckpointMode.CUSTOM:
107
+ return this.watchCustomWriteCheckpoint(options);
108
+ case storage.WriteCheckpointMode.MANAGED:
109
+ return this.watchManagedWriteCheckpoint(options);
110
+ default:
111
+ throw new Error('Invalid write checkpoint mode');
112
+ }
113
+ }
114
+
115
+ private sharedIter = new Demultiplexer<WriteCheckpointResult>((signal) => {
116
+ const clusterTimePromise = (async () => {
117
+ const hello = await this.db.db.command({ hello: 1 });
118
+ // Note: This is not valid on sharded clusters.
119
+ const startClusterTime = hello.lastWrite?.majorityOpTime?.ts as mongo.Timestamp;
120
+ startClusterTime;
121
+ return startClusterTime;
122
+ })();
123
+
124
+ return {
125
+ iterator: this.watchAllManagedWriteCheckpoints(clusterTimePromise, signal),
126
+ getFirstValue: async (user_id: string) => {
127
+ // Potential race conditions we cater for:
128
+
129
+ // Case 1: changestream is behind.
130
+ // We get a doc now, then the same or older doc again later.
131
+ // No problem!
132
+
133
+ // Case 2: Query is behind. I.e. doc has been created, and emitted on the changestream, but the query doesn't see it yet.
134
+ // Not possible luckily, but can we make sure?
135
+
136
+ // Case 3: changestream delays openeing. A doc is created after our query here, but before the changestream is opened.
137
+ // Awaiting clusterTimePromise should be sufficient here, but as a sanity check we also confirm that our query
138
+ // timestamp is > the startClusterTime.
139
+
140
+ const changeStreamStart = await clusterTimePromise;
141
+
142
+ let doc = null as WriteCheckpointDocument | null;
143
+ let clusterTime = null as mongo.Timestamp | null;
144
+
145
+ await this.db.client.withSession(async (session) => {
146
+ doc = await this.db.write_checkpoints.findOne(
147
+ {
148
+ user_id: user_id
149
+ },
150
+ {
151
+ session
152
+ }
153
+ );
154
+ const time = session.clusterTime?.clusterTime ?? null;
155
+ clusterTime = time;
156
+ });
157
+ if (clusterTime == null) {
158
+ throw new framework.ServiceAssertionError('Could not get clusterTime for write checkpoint');
159
+ }
160
+
161
+ if (clusterTime.lessThan(changeStreamStart)) {
162
+ throw new framework.ServiceAssertionError(
163
+ 'clusterTime for write checkpoint is older than changestream start'
164
+ );
165
+ }
166
+
167
+ if (doc == null) {
168
+ return {
169
+ id: null,
170
+ lsn: null
171
+ };
172
+ }
173
+
174
+ return {
175
+ id: doc.client_id,
176
+ lsn: doc.lsns['1']
177
+ };
178
+ }
179
+ };
180
+ });
181
+
182
+ private async *watchAllManagedWriteCheckpoints(
183
+ clusterTimePromise: Promise<mongo.BSON.Timestamp>,
184
+ signal: AbortSignal
185
+ ): AsyncGenerator<DemultiplexerValue<WriteCheckpointResult>> {
186
+ const clusterTime = await clusterTimePromise;
187
+
188
+ const stream = this.db.write_checkpoints.watch(
189
+ [{ $match: { operationType: { $in: ['insert', 'update', 'replace'] } } }],
190
+ {
191
+ fullDocument: 'updateLookup',
192
+ startAtOperationTime: clusterTime
193
+ }
194
+ );
195
+
196
+ const hello = await this.db.db.command({ hello: 1 });
197
+ // Note: This is not valid on sharded clusters.
198
+ const startClusterTime = hello.lastWrite?.majorityOpTime?.ts as mongo.Timestamp;
199
+ if (startClusterTime == null) {
200
+ throw new framework.ServiceAssertionError('Could not get clusterTime');
201
+ }
202
+
203
+ signal.onabort = () => {
204
+ stream.close();
205
+ };
206
+
207
+ if (signal.aborted) {
208
+ stream.close();
209
+ return;
210
+ }
211
+
212
+ for await (let event of stream) {
213
+ if (!('fullDocument' in event) || event.fullDocument == null) {
214
+ continue;
215
+ }
216
+
217
+ const user_id = event.fullDocument.user_id;
218
+ yield {
219
+ key: user_id,
220
+ value: {
221
+ id: event.fullDocument.client_id,
222
+ lsn: event.fullDocument.lsns['1']
223
+ }
224
+ };
225
+ }
226
+ }
227
+
228
+ async *watchManagedWriteCheckpoint(
229
+ options: WatchUserWriteCheckpointOptions
230
+ ): AsyncIterable<storage.WriteCheckpointResult> {
231
+ const stream = this.sharedIter.subscribe(options.user_id, options.signal);
232
+
233
+ let lastId = -1n;
234
+
235
+ for await (let doc of stream) {
236
+ // Guard against out-of-order events
237
+ if (lastId == -1n || (doc.id != null && doc.id > lastId)) {
238
+ yield doc;
239
+ if (doc.id != null) {
240
+ lastId = doc.id;
241
+ }
242
+ }
243
+ }
244
+ }
245
+
246
+ async *watchCustomWriteCheckpoint(
247
+ options: WatchUserWriteCheckpointOptions
248
+ ): AsyncIterable<storage.WriteCheckpointResult> {
249
+ const { user_id, sync_rules_id, signal } = options;
250
+
251
+ let doc = null as CustomWriteCheckpointDocument | null;
252
+ let clusterTime = null as mongo.Timestamp | null;
253
+
254
+ await this.db.client.withSession(async (session) => {
255
+ doc = await this.db.custom_write_checkpoints.findOne(
256
+ {
257
+ user_id: user_id,
258
+ sync_rules_id: sync_rules_id
259
+ },
260
+ {
261
+ session
262
+ }
263
+ );
264
+ const time = session.clusterTime?.clusterTime ?? null;
265
+ clusterTime = time;
266
+ });
267
+ if (clusterTime == null) {
268
+ throw new framework.ServiceAssertionError('Could not get clusterTime');
269
+ }
270
+
271
+ const stream = this.db.custom_write_checkpoints.watch(
272
+ [
273
+ {
274
+ $match: {
275
+ 'fullDocument.user_id': user_id,
276
+ 'fullDocument.sync_rules_id': sync_rules_id,
277
+ operationType: { $in: ['insert', 'update', 'replace'] }
278
+ }
279
+ }
280
+ ],
281
+ {
282
+ fullDocument: 'updateLookup',
283
+ startAtOperationTime: clusterTime
284
+ }
285
+ );
286
+
287
+ signal.onabort = () => {
288
+ stream.close();
289
+ };
290
+
291
+ if (signal.aborted) {
292
+ stream.close();
293
+ return;
294
+ }
295
+
296
+ let lastId = -1n;
297
+
298
+ if (doc != null) {
299
+ yield {
300
+ id: doc.checkpoint,
301
+ lsn: null
302
+ };
303
+ lastId = doc.checkpoint;
304
+ }
305
+
306
+ for await (let event of stream) {
307
+ if (!('fullDocument' in event) || event.fullDocument == null) {
308
+ continue;
309
+ }
310
+ // Guard against out-of-order events
311
+ if (event.fullDocument.checkpoint > lastId) {
312
+ yield {
313
+ id: event.fullDocument.checkpoint,
314
+ lsn: null
315
+ };
316
+ lastId = event.fullDocument.checkpoint;
317
+ }
318
+ }
319
+ }
320
+
96
321
  protected async lastCustomWriteCheckpoint(filters: storage.CustomWriteCheckpointFilters) {
97
322
  const { user_id, sync_rules_id } = filters;
98
323
  const lastWriteCheckpoint = await this.db.custom_write_checkpoints.findOne({