@powersync/service-module-mongodb-storage 0.0.0-dev-20250312112247 → 0.0.0-dev-20250317113118

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