@powersync/service-module-mongodb-storage 0.0.0-dev-20251030082344 → 0.0.0-dev-20251110132117

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 (62) hide show
  1. package/CHANGELOG.md +18 -7
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.js +1 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/migrations/db/migrations/1752661449910-connection-reporting.d.ts +3 -0
  6. package/dist/migrations/db/migrations/1752661449910-connection-reporting.js +36 -0
  7. package/dist/migrations/db/migrations/1752661449910-connection-reporting.js.map +1 -0
  8. package/dist/storage/MongoBucketStorage.js +1 -1
  9. package/dist/storage/MongoBucketStorage.js.map +1 -1
  10. package/dist/storage/MongoReportStorage.d.ts +18 -0
  11. package/dist/storage/MongoReportStorage.js +165 -0
  12. package/dist/storage/MongoReportStorage.js.map +1 -0
  13. package/dist/storage/implementation/MongoBucketBatch.js +1 -1
  14. package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
  15. package/dist/storage/implementation/MongoStorageProvider.d.ts +1 -1
  16. package/dist/storage/implementation/MongoStorageProvider.js +7 -3
  17. package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
  18. package/dist/storage/implementation/MongoSyncBucketStorage.js +1 -1
  19. package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
  20. package/dist/storage/implementation/PersistedBatch.js +1 -1
  21. package/dist/storage/implementation/PersistedBatch.js.map +1 -1
  22. package/dist/storage/implementation/db.d.ts +6 -1
  23. package/dist/storage/implementation/db.js +15 -0
  24. package/dist/storage/implementation/db.js.map +1 -1
  25. package/dist/storage/implementation/models.d.ts +3 -0
  26. package/dist/storage/storage-index.d.ts +3 -2
  27. package/dist/storage/storage-index.js +3 -2
  28. package/dist/storage/storage-index.js.map +1 -1
  29. package/dist/utils/test-utils.d.ts +13 -0
  30. package/dist/utils/test-utils.js +40 -0
  31. package/dist/utils/test-utils.js.map +1 -0
  32. package/dist/{storage/implementation → utils}/util.d.ts +9 -6
  33. package/dist/{storage/implementation → utils}/util.js +38 -15
  34. package/dist/utils/util.js.map +1 -0
  35. package/dist/utils/utils-index.d.ts +2 -0
  36. package/dist/utils/utils-index.js +3 -0
  37. package/dist/utils/utils-index.js.map +1 -0
  38. package/package.json +8 -8
  39. package/src/index.ts +1 -0
  40. package/src/migrations/db/migrations/1752661449910-connection-reporting.ts +58 -0
  41. package/src/storage/MongoBucketStorage.ts +1 -1
  42. package/src/storage/MongoReportStorage.ts +196 -0
  43. package/src/storage/implementation/MongoBucketBatch.ts +1 -1
  44. package/src/storage/implementation/MongoStorageProvider.ts +9 -4
  45. package/src/storage/implementation/MongoSyncBucketStorage.ts +2 -1
  46. package/src/storage/implementation/PersistedBatch.ts +1 -1
  47. package/src/storage/implementation/db.ts +17 -0
  48. package/src/storage/implementation/models.ts +3 -0
  49. package/src/storage/storage-index.ts +3 -2
  50. package/src/utils/test-utils.ts +57 -0
  51. package/src/{storage/implementation → utils}/util.ts +49 -18
  52. package/src/utils/utils-index.ts +2 -0
  53. package/test/src/__snapshots__/connection-report-storage.test.ts.snap +372 -0
  54. package/test/src/connection-report-storage.test.ts +133 -0
  55. package/test/src/storage.test.ts +3 -51
  56. package/test/src/util.ts +6 -2
  57. package/tsconfig.tsbuildinfo +1 -1
  58. package/dist/storage/implementation/MongoTestStorageFactoryGenerator.d.ts +0 -9
  59. package/dist/storage/implementation/MongoTestStorageFactoryGenerator.js +0 -20
  60. package/dist/storage/implementation/MongoTestStorageFactoryGenerator.js.map +0 -1
  61. package/dist/storage/implementation/util.js.map +0 -1
  62. package/src/storage/implementation/MongoTestStorageFactoryGenerator.ts +0 -32
@@ -0,0 +1,196 @@
1
+ import { storage } from '@powersync/service-core';
2
+ import { event_types } from '@powersync/service-types';
3
+ import { PowerSyncMongo } from './implementation/db.js';
4
+ import { logger } from '@powersync/lib-services-framework';
5
+ import { createPaginatedConnectionQuery } from '../utils/util.js';
6
+
7
+ export class MongoReportStorage implements storage.ReportStorage {
8
+ public readonly db: PowerSyncMongo;
9
+
10
+ constructor(db: PowerSyncMongo) {
11
+ this.db = db;
12
+ }
13
+ async deleteOldConnectionData(data: event_types.DeleteOldConnectionData): Promise<void> {
14
+ const { date } = data;
15
+ const result = await this.db.connection_report_events.deleteMany({
16
+ connected_at: { $lt: date },
17
+ $or: [
18
+ { disconnected_at: { $exists: true } },
19
+ { jwt_exp: { $lt: new Date() }, disconnected_at: { $exists: false } }
20
+ ]
21
+ });
22
+ if (result.deletedCount > 0) {
23
+ logger.info(
24
+ `TTL from ${date.toISOString()}: ${result.deletedCount} MongoDB documents have been removed from connection_report_events.`
25
+ );
26
+ }
27
+ }
28
+
29
+ async getClientConnectionReports(
30
+ data: event_types.ClientConnectionReportRequest
31
+ ): Promise<event_types.ClientConnectionReportResponse> {
32
+ const { start, end } = data;
33
+ const result = await this.db.connection_report_events
34
+ .aggregate<event_types.ClientConnectionReportResponse>([
35
+ {
36
+ $match: {
37
+ connected_at: { $lte: end, $gte: start }
38
+ }
39
+ },
40
+ this.connectionsFacetPipeline(),
41
+ this.connectionsProjectPipeline()
42
+ ])
43
+ .toArray();
44
+ return result[0];
45
+ }
46
+
47
+ async getGeneralClientConnectionAnalytics(
48
+ data: event_types.ClientConnectionAnalyticsRequest
49
+ ): Promise<event_types.PaginatedResponse<event_types.ClientConnection>> {
50
+ const { cursor, date_range } = data;
51
+ const limit = data?.limit || 100;
52
+
53
+ const connected_at = date_range ? { connected_at: { $lte: date_range.end, $gte: date_range.start } } : undefined;
54
+ const user_id = data.user_id ? { user_id: data.user_id } : undefined;
55
+ const client_id = data.client_id ? { client_id: data.client_id } : undefined;
56
+ return (await createPaginatedConnectionQuery(
57
+ {
58
+ ...client_id,
59
+ ...user_id,
60
+ ...connected_at
61
+ },
62
+ this.db.connection_report_events,
63
+ limit,
64
+ cursor
65
+ )) as event_types.PaginatedResponse<event_types.ClientConnection>;
66
+ }
67
+
68
+ async reportClientConnection(data: event_types.ClientConnectionBucketData): Promise<void> {
69
+ const updateFilter = this.updateDocFilter(data.user_id, data.client_id!);
70
+ await this.db.connection_report_events.findOneAndUpdate(
71
+ updateFilter,
72
+ {
73
+ $set: data,
74
+ $unset: {
75
+ disconnected_at: ''
76
+ }
77
+ },
78
+ {
79
+ upsert: true
80
+ }
81
+ );
82
+ }
83
+ async reportClientDisconnection(data: event_types.ClientDisconnectionEventData): Promise<void> {
84
+ const { connected_at, user_id, client_id } = data;
85
+ await this.db.connection_report_events.findOneAndUpdate(
86
+ {
87
+ client_id,
88
+ user_id,
89
+ connected_at
90
+ },
91
+ {
92
+ $set: {
93
+ disconnected_at: data.disconnected_at
94
+ },
95
+ $unset: {
96
+ jwt_exp: ''
97
+ }
98
+ }
99
+ );
100
+ }
101
+ async getConnectedClients(): Promise<event_types.ClientConnectionReportResponse> {
102
+ const result = await this.db.connection_report_events
103
+ .aggregate<event_types.ClientConnectionReportResponse>([
104
+ {
105
+ $match: {
106
+ disconnected_at: { $exists: false },
107
+ jwt_exp: { $gt: new Date() }
108
+ }
109
+ },
110
+ this.connectionsFacetPipeline(),
111
+ this.connectionsProjectPipeline()
112
+ ])
113
+ .toArray();
114
+ return result[0];
115
+ }
116
+
117
+ async [Symbol.asyncDispose]() {
118
+ // No-op
119
+ }
120
+
121
+ private parseJsDate(date: Date) {
122
+ const year = date.getUTCFullYear();
123
+ const month = date.getUTCMonth();
124
+ const today = date.getUTCDate();
125
+ const day = date.getUTCDay();
126
+ return {
127
+ year,
128
+ month,
129
+ today,
130
+ day,
131
+ parsedDate: date
132
+ };
133
+ }
134
+
135
+ private connectionsFacetPipeline() {
136
+ return {
137
+ $facet: {
138
+ unique_users: [
139
+ {
140
+ $group: {
141
+ _id: '$user_id'
142
+ }
143
+ },
144
+ {
145
+ $count: 'count'
146
+ }
147
+ ],
148
+ sdk_versions_array: [
149
+ {
150
+ $group: {
151
+ _id: '$sdk',
152
+ total: { $sum: 1 },
153
+ client_ids: { $addToSet: '$client_id' },
154
+ user_ids: { $addToSet: '$user_id' }
155
+ }
156
+ },
157
+ {
158
+ $project: {
159
+ _id: 0,
160
+ sdk: '$_id',
161
+ users: { $size: '$user_ids' },
162
+ clients: { $size: '$client_ids' }
163
+ }
164
+ },
165
+ {
166
+ $sort: {
167
+ sdk: 1
168
+ }
169
+ }
170
+ ]
171
+ }
172
+ };
173
+ }
174
+
175
+ private connectionsProjectPipeline() {
176
+ return {
177
+ $project: {
178
+ users: { $ifNull: [{ $arrayElemAt: ['$unique_users.count', 0] }, 0] },
179
+ sdks: '$sdk_versions_array'
180
+ }
181
+ };
182
+ }
183
+
184
+ private updateDocFilter(userId: string, clientId: string) {
185
+ const { year, month, today } = this.parseJsDate(new Date());
186
+ const nextDay = today + 1;
187
+ return {
188
+ user_id: userId,
189
+ client_id: clientId,
190
+ connected_at: {
191
+ $gte: new Date(Date.UTC(year, month, today)),
192
+ $lt: new Date(Date.UTC(year, month, nextDay))
193
+ }
194
+ };
195
+ }
196
+ }
@@ -28,7 +28,7 @@ import { MongoIdSequence } from './MongoIdSequence.js';
28
28
  import { batchCreateCustomWriteCheckpoints } from './MongoWriteCheckpointAPI.js';
29
29
  import { cacheKey, OperationBatch, RecordOperation } from './OperationBatch.js';
30
30
  import { PersistedBatch } from './PersistedBatch.js';
31
- import { idPrefixFilter } from './util.js';
31
+ import { idPrefixFilter } from '../../utils/util.js';
32
32
 
33
33
  /**
34
34
  * 15MB
@@ -4,8 +4,9 @@ import { POWERSYNC_VERSION, storage } from '@powersync/service-core';
4
4
  import { MongoStorageConfig } from '../../types/types.js';
5
5
  import { MongoBucketStorage } from '../MongoBucketStorage.js';
6
6
  import { PowerSyncMongo } from './db.js';
7
+ import { MongoReportStorage } from '../MongoReportStorage.js';
7
8
 
8
- export class MongoStorageProvider implements storage.BucketStorageProvider {
9
+ export class MongoStorageProvider implements storage.StorageProvider {
9
10
  get type() {
10
11
  return lib_mongo.MONGO_CONNECTION_TYPE;
11
12
  }
@@ -37,15 +38,19 @@ export class MongoStorageProvider implements storage.BucketStorageProvider {
37
38
  await client.connect();
38
39
 
39
40
  const database = new PowerSyncMongo(client, { database: resolvedConfig.storage.database });
40
- const factory = new MongoBucketStorage(database, {
41
+ const syncStorageFactory = new MongoBucketStorage(database, {
41
42
  // TODO currently need the entire resolved config due to this
42
43
  slot_name_prefix: resolvedConfig.slot_name_prefix
43
44
  });
45
+
46
+ // Storage factory for reports
47
+ const reportStorageFactory = new MongoReportStorage(database);
44
48
  return {
45
- storage: factory,
49
+ storage: syncStorageFactory,
50
+ reportStorage: reportStorageFactory,
46
51
  shutDown: async () => {
47
52
  shuttingDown = true;
48
- await factory[Symbol.asyncDispose]();
53
+ await syncStorageFactory[Symbol.asyncDispose]();
49
54
  await client.close();
50
55
  },
51
56
  tearDown: () => {
@@ -37,7 +37,8 @@ import { MongoChecksumOptions, MongoChecksums } from './MongoChecksums.js';
37
37
  import { MongoCompactor } from './MongoCompactor.js';
38
38
  import { MongoParameterCompactor } from './MongoParameterCompactor.js';
39
39
  import { MongoWriteCheckpointAPI } from './MongoWriteCheckpointAPI.js';
40
- import { idPrefixFilter, mapOpEntry, readSingleBatch, setSessionSnapshotTime } from './util.js';
40
+ import { idPrefixFilter, mapOpEntry, readSingleBatch, setSessionSnapshotTime } from '../../utils/util.js';
41
+
41
42
 
42
43
  export interface MongoSyncBucketStorageOptions {
43
44
  checksumOptions?: MongoChecksumOptions;
@@ -16,7 +16,7 @@ import {
16
16
  CurrentDataDocument,
17
17
  SourceKey
18
18
  } from './models.js';
19
- import { replicaIdToSubkey } from './util.js';
19
+ import { replicaIdToSubkey } from '../../utils/util.js';
20
20
 
21
21
  /**
22
22
  * Maximum size of operations we write in a single transaction.
@@ -8,6 +8,7 @@ import {
8
8
  BucketParameterDocument,
9
9
  BucketStateDocument,
10
10
  CheckpointEventDocument,
11
+ ClientConnectionDocument,
11
12
  CurrentDataDocument,
12
13
  CustomWriteCheckpointDocument,
13
14
  IdSequenceDocument,
@@ -37,6 +38,7 @@ export class PowerSyncMongo {
37
38
  readonly locks: mongo.Collection<lib_mongo.locks.Lock>;
38
39
  readonly bucket_state: mongo.Collection<BucketStateDocument>;
39
40
  readonly checkpoint_events: mongo.Collection<CheckpointEventDocument>;
41
+ readonly connection_report_events: mongo.Collection<ClientConnectionDocument>;
40
42
 
41
43
  readonly client: mongo.MongoClient;
42
44
  readonly db: mongo.Db;
@@ -61,6 +63,7 @@ export class PowerSyncMongo {
61
63
  this.locks = this.db.collection('locks');
62
64
  this.bucket_state = this.db.collection('bucket_state');
63
65
  this.checkpoint_events = this.db.collection('checkpoint_events');
66
+ this.connection_report_events = this.db.collection('connection_report_events');
64
67
  }
65
68
 
66
69
  /**
@@ -128,6 +131,20 @@ export class PowerSyncMongo {
128
131
  });
129
132
  }
130
133
 
134
+ /**
135
+ * Only use in migrations and tests.
136
+ */
137
+ async createConnectionReportingCollection() {
138
+ const existingCollections = await this.db
139
+ .listCollections({ name: 'connection_report_events' }, { nameOnly: false })
140
+ .toArray();
141
+ const collection = existingCollections[0];
142
+ if (collection != null) {
143
+ return;
144
+ }
145
+ await this.db.createCollection('connection_report_events');
146
+ }
147
+
131
148
  /**
132
149
  * Only use in migrations and tests.
133
150
  */
@@ -1,6 +1,7 @@
1
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
+ import { event_types } from '@powersync/service-types';
4
5
 
5
6
  /**
6
7
  * Replica id uniquely identifying a row on the source database.
@@ -238,3 +239,5 @@ export interface InstanceDocument {
238
239
  // The instance UUID
239
240
  _id: string;
240
241
  }
242
+
243
+ export interface ClientConnectionDocument extends event_types.ClientConnection {}
@@ -7,8 +7,9 @@ export * from './implementation/MongoPersistedSyncRulesContent.js';
7
7
  export * from './implementation/MongoStorageProvider.js';
8
8
  export * from './implementation/MongoSyncBucketStorage.js';
9
9
  export * from './implementation/MongoSyncRulesLock.js';
10
- export * from './implementation/MongoTestStorageFactoryGenerator.js';
11
10
  export * from './implementation/OperationBatch.js';
12
11
  export * from './implementation/PersistedBatch.js';
13
- export * from './implementation/util.js';
12
+ export * from '../utils/util.js';
14
13
  export * from './MongoBucketStorage.js';
14
+ export * from './MongoReportStorage.js';
15
+ export * as test_utils from '../utils/test-utils.js';
@@ -0,0 +1,57 @@
1
+ import { mongo } from '@powersync/lib-service-mongodb';
2
+ import { PowerSyncMongo } from '../storage/implementation/db.js';
3
+ import { TestStorageOptions } from '@powersync/service-core';
4
+ import { MongoReportStorage } from '../storage/MongoReportStorage.js';
5
+ import { MongoBucketStorage } from '../storage/MongoBucketStorage.js';
6
+ import { MongoSyncBucketStorageOptions } from '../storage/implementation/MongoSyncBucketStorage.js';
7
+
8
+ export type MongoTestStorageOptions = {
9
+ url: string;
10
+ isCI: boolean;
11
+ internalOptions?: MongoSyncBucketStorageOptions;
12
+ };
13
+
14
+ export function mongoTestStorageFactoryGenerator(factoryOptions: MongoTestStorageOptions) {
15
+ return async (options?: TestStorageOptions) => {
16
+ const db = connectMongoForTests(factoryOptions.url, factoryOptions.isCI);
17
+
18
+ // None of the tests insert data into this collection, so it was never created
19
+ if (!(await db.db.listCollections({ name: db.bucket_parameters.collectionName }).hasNext())) {
20
+ await db.db.createCollection('bucket_parameters');
21
+ }
22
+
23
+ // Full migrations are not currently run for tests, so we manually create this
24
+ await db.createCheckpointEventsCollection();
25
+
26
+ if (!options?.doNotClear) {
27
+ await db.clear();
28
+ }
29
+
30
+ return new MongoBucketStorage(db, { slot_name_prefix: 'test_' }, factoryOptions.internalOptions);
31
+ };
32
+ }
33
+
34
+ export function mongoTestReportStorageFactoryGenerator(factoryOptions: MongoTestStorageOptions) {
35
+ return async (options?: TestStorageOptions) => {
36
+ const db = connectMongoForTests(factoryOptions.url, factoryOptions.isCI);
37
+
38
+ await db.createConnectionReportingCollection();
39
+
40
+ if (!options?.doNotClear) {
41
+ await db.clear();
42
+ }
43
+
44
+ return new MongoReportStorage(db);
45
+ };
46
+ }
47
+
48
+ export const connectMongoForTests = (url: string, isCI: boolean) => {
49
+ // Short timeout for tests, to fail fast when the server is not available.
50
+ // Slightly longer timeouts for CI, to avoid arbitrary test failures
51
+ const client = new mongo.MongoClient(url, {
52
+ connectTimeoutMS: isCI ? 15_000 : 5_000,
53
+ socketTimeoutMS: isCI ? 15_000 : 5_000,
54
+ serverSelectionTimeoutMS: isCI ? 15_000 : 2_500
55
+ });
56
+ return new PowerSyncMongo(client);
57
+ };
@@ -3,11 +3,9 @@ import * as crypto from 'crypto';
3
3
  import * as uuid from 'uuid';
4
4
 
5
5
  import { mongo } from '@powersync/lib-service-mongodb';
6
- import { BucketChecksum, PartialChecksum, PartialOrFullChecksum, storage, utils } from '@powersync/service-core';
7
-
8
- import { PowerSyncMongo } from './db.js';
9
- import { BucketDataDocument } from './models.js';
6
+ import { storage, utils } from '@powersync/service-core';
10
7
  import { ServiceAssertionError } from '@powersync/lib-services-framework';
8
+ import { BucketDataDocument } from '../storage/implementation/models.js';
11
9
 
12
10
  export function idPrefixFilter<T>(prefix: Partial<T>, rest: (keyof T)[]): mongo.Condition<T> {
13
11
  let filter = {
@@ -105,20 +103,6 @@ export function replicaIdToSubkey(table: bson.ObjectId, id: storage.ReplicaId):
105
103
  }
106
104
  }
107
105
 
108
- /**
109
- * Helper for unit tests
110
- */
111
- export const connectMongoForTests = (url: string, isCI: boolean) => {
112
- // Short timeout for tests, to fail fast when the server is not available.
113
- // Slightly longer timeouts for CI, to avoid arbitrary test failures
114
- const client = new mongo.MongoClient(url, {
115
- connectTimeoutMS: isCI ? 15_000 : 5_000,
116
- socketTimeoutMS: isCI ? 15_000 : 5_000,
117
- serverSelectionTimeoutMS: isCI ? 15_000 : 2_500
118
- });
119
- return new PowerSyncMongo(client);
120
- };
121
-
122
106
  export function setSessionSnapshotTime(session: mongo.ClientSession, time: bson.Timestamp) {
123
107
  // This is a workaround for the lack of direct support for snapshot reads in the MongoDB driver.
124
108
  if (!session.snapshotEnabled) {
@@ -130,3 +114,50 @@ export function setSessionSnapshotTime(session: mongo.ClientSession, time: bson.
130
114
  throw new ServiceAssertionError(`Session snapshotTime is already set`);
131
115
  }
132
116
  }
117
+
118
+ export const createPaginatedConnectionQuery = async <T extends mongo.Document>(
119
+ query: mongo.Filter<T>,
120
+ collection: mongo.Collection<T>,
121
+ limit: number,
122
+ cursor?: string
123
+ ) => {
124
+ const createQuery = (cursor?: string) => {
125
+ if (!cursor) {
126
+ return query;
127
+ }
128
+ return {
129
+ $and: [
130
+ query,
131
+ {
132
+ /** We are using the connected at date as the cursor so that the functionality works the same on Postgres implementation
133
+ * The id field in postgres is an uuid, this will work similarly to the ObjectId in Mongodb
134
+ * */
135
+ connected_at: {
136
+ $lt: new Date(cursor)
137
+ }
138
+ }
139
+ ]
140
+ } as mongo.Filter<T>;
141
+ };
142
+
143
+ /** cursor.count() deprecated */
144
+ const total = await collection.countDocuments(query);
145
+
146
+ const findCursor = collection.find(createQuery(cursor), {
147
+ sort: {
148
+ /** We are sorting by connected at date descending to match cursor Postgres implementation */
149
+ connected_at: -1
150
+ }
151
+ });
152
+
153
+ const items = await findCursor.limit(limit).toArray();
154
+ const count = items.length;
155
+ return {
156
+ items,
157
+ total,
158
+ count,
159
+ /** Setting the cursor to the connected at date of the last item in the list */
160
+ cursor: count === limit ? items[items.length - 1].connected_at.toISOString() : undefined,
161
+ more: count < total
162
+ };
163
+ };
@@ -0,0 +1,2 @@
1
+ export * as test_utils from './test-utils.js';
2
+ export * from './util.js';