@rebasepro/server-mongo 0.0.1-canary.4829d6e

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 (52) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +86 -0
  3. package/dist/MongoBootstrapper.d.ts +18 -0
  4. package/dist/MongoBootstrapper.d.ts.map +1 -0
  5. package/dist/auth/ensure-collections.d.ts +3 -0
  6. package/dist/auth/ensure-collections.d.ts.map +1 -0
  7. package/dist/auth/services.d.ts +156 -0
  8. package/dist/auth/services.d.ts.map +1 -0
  9. package/dist/connection.d.ts +35 -0
  10. package/dist/connection.d.ts.map +1 -0
  11. package/dist/db/MongoConditionBuilder.d.ts +64 -0
  12. package/dist/db/MongoConditionBuilder.d.ts.map +1 -0
  13. package/dist/db/MongoDataService.d.ts +101 -0
  14. package/dist/db/MongoDataService.d.ts.map +1 -0
  15. package/dist/ensure-collections-Bkx_O5CQ.js +94 -0
  16. package/dist/ensure-collections-Bkx_O5CQ.js.map +1 -0
  17. package/dist/ensure-history-collection-yajOt2dv.js +21 -0
  18. package/dist/ensure-history-collection-yajOt2dv.js.map +1 -0
  19. package/dist/factory.d.ts +151 -0
  20. package/dist/factory.d.ts.map +1 -0
  21. package/dist/history/ensure-history-collection.d.ts +3 -0
  22. package/dist/history/ensure-history-collection.d.ts.map +1 -0
  23. package/dist/index.d.ts +18 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.es.js +2508 -0
  26. package/dist/index.es.js.map +1 -0
  27. package/dist/index.umd.js +2925 -0
  28. package/dist/index.umd.js.map +1 -0
  29. package/dist/services/MongoDriver.d.ts +125 -0
  30. package/dist/services/MongoDriver.d.ts.map +1 -0
  31. package/dist/services/MongoHistoryService.d.ts +37 -0
  32. package/dist/services/MongoHistoryService.d.ts.map +1 -0
  33. package/dist/services/MongoRealtimeService.d.ts +103 -0
  34. package/dist/services/MongoRealtimeService.d.ts.map +1 -0
  35. package/dist/websocket-DQlwCHFq.js +281 -0
  36. package/dist/websocket-DQlwCHFq.js.map +1 -0
  37. package/dist/websocket.d.ts +7 -0
  38. package/dist/websocket.d.ts.map +1 -0
  39. package/package.json +81 -0
  40. package/src/MongoBootstrapper.ts +196 -0
  41. package/src/auth/ensure-collections.ts +105 -0
  42. package/src/auth/services.ts +732 -0
  43. package/src/connection.ts +60 -0
  44. package/src/db/MongoConditionBuilder.ts +224 -0
  45. package/src/db/MongoDataService.ts +368 -0
  46. package/src/factory.ts +305 -0
  47. package/src/history/ensure-history-collection.ts +22 -0
  48. package/src/index.ts +25 -0
  49. package/src/services/MongoDriver.ts +1120 -0
  50. package/src/services/MongoHistoryService.ts +181 -0
  51. package/src/services/MongoRealtimeService.ts +446 -0
  52. package/src/websocket.ts +297 -0
@@ -0,0 +1,181 @@
1
+ import { Db, ObjectId } from "mongodb";
2
+ import { logger } from "@rebasepro/server";
3
+
4
+ /**
5
+ * Deep equality without JSON.stringify.
6
+ * Handles primitives, arrays, Dates, and plain objects recursively.
7
+ */
8
+ function deepEqual(a: unknown, b: unknown): boolean {
9
+ if (a === b) return true;
10
+ if (a == null || b == null) return false;
11
+ if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
12
+ if (Array.isArray(a) && Array.isArray(b)) {
13
+ if (a.length !== b.length) return false;
14
+ return a.every((v, i) => deepEqual(v, b[i]));
15
+ }
16
+ if (typeof a === "object" && typeof b === "object") {
17
+ const aObj = a as Record<string, unknown>;
18
+ const bObj = b as Record<string, unknown>;
19
+ const aKeys = Object.keys(aObj);
20
+ const bKeys = Object.keys(bObj);
21
+ if (aKeys.length !== bKeys.length) return false;
22
+ return aKeys.every(k => deepEqual(aObj[k], bObj[k]));
23
+ }
24
+ return false;
25
+ }
26
+
27
+ /**
28
+ * Shallow comparison to find top-level keys that changed between two objects.
29
+ */
30
+ export function findChangedFields(
31
+ oldValues: Record<string, unknown>,
32
+ newValues: Record<string, unknown>
33
+ ): string[] | null {
34
+ const changed: string[] = [];
35
+ const allKeys = new Set([
36
+ ...Object.keys(oldValues),
37
+ ...Object.keys(newValues)
38
+ ]);
39
+
40
+ for (const key of allKeys) {
41
+ const oldVal = oldValues[key];
42
+ const newVal = newValues[key];
43
+
44
+ // Skip internal metadata
45
+ if (key.startsWith("__")) continue;
46
+
47
+ if (oldVal !== newVal) {
48
+ // For objects/arrays, use structural comparison
49
+ if (
50
+ typeof oldVal === "object" && oldVal !== null &&
51
+ typeof newVal === "object" && newVal !== null
52
+ ) {
53
+ if (!deepEqual(oldVal, newVal)) {
54
+ changed.push(key);
55
+ }
56
+ } else {
57
+ changed.push(key);
58
+ }
59
+ }
60
+ }
61
+
62
+ return changed.length > 0 ? changed : null;
63
+ }
64
+
65
+ export interface HistoryEntry {
66
+ _id?: ObjectId;
67
+ id: string;
68
+ table_name: string;
69
+ entity_id: string;
70
+ action: "create" | "update" | "delete";
71
+ changed_fields: string[] | null;
72
+ values: Record<string, unknown> | null;
73
+ previous_values: Record<string, unknown> | null;
74
+ updated_by: string | null;
75
+ updated_at: Date;
76
+ }
77
+
78
+ export interface RecordHistoryParams {
79
+ tableName: string;
80
+ id: string;
81
+ action: "create" | "update" | "delete";
82
+ values?: Record<string, unknown> | null;
83
+ previousValues?: Record<string, unknown> | null;
84
+ updatedBy?: string | null;
85
+ }
86
+
87
+ export interface HistoryRetentionConfig {
88
+ maxEntries: number;
89
+ ttlDays: number;
90
+ }
91
+
92
+ const DEFAULT_RETENTION: HistoryRetentionConfig = {
93
+ maxEntries: 200,
94
+ ttlDays: 90
95
+ };
96
+
97
+ export class MongoHistoryService {
98
+ public retention: HistoryRetentionConfig;
99
+
100
+ constructor(
101
+ private db: Db,
102
+ retention?: Partial<HistoryRetentionConfig>
103
+ ) {
104
+ this.retention = { ...DEFAULT_RETENTION,
105
+ ...retention };
106
+ }
107
+
108
+ async recordHistory(params: RecordHistoryParams): Promise<void> {
109
+ const {
110
+ tableName,
111
+ id,
112
+ action,
113
+ values,
114
+ previousValues,
115
+ updatedBy
116
+ } = params;
117
+
118
+ const changedFields = previousValues && values
119
+ ? findChangedFields(previousValues, values)
120
+ : null;
121
+
122
+ if (action === "update" && (!changedFields || changedFields.length === 0)) {
123
+ return;
124
+ }
125
+
126
+ try {
127
+ const entry: HistoryEntry = {
128
+ id: new ObjectId().toString(),
129
+ table_name: tableName,
130
+ entity_id: String(id),
131
+ action,
132
+ changed_fields: changedFields,
133
+ values: values || null,
134
+ previous_values: previousValues || null,
135
+ updated_by: updatedBy || null,
136
+ updated_at: new Date()
137
+ };
138
+
139
+ await this.db.collection("__rebase_history").insertOne(entry);
140
+
141
+ // Non-blocking prune for this specific row
142
+ this.pruneHistory(String(id), tableName).catch(e => {
143
+ logger.error(`[HistoryService] Failed to prune history for ${tableName}/${id}`, { error: e });
144
+ });
145
+ } catch (error) {
146
+ logger.error(`[HistoryService] Failed to record history for ${tableName}/${id}`, { error: error });
147
+ }
148
+ }
149
+
150
+ private async pruneHistory(id: string, tableName: string): Promise<void> {
151
+ const collection = this.db.collection("__rebase_history");
152
+
153
+ // 1. Enforce maxEntries
154
+ const count = await collection.countDocuments({ entity_id: id,
155
+ table_name: tableName });
156
+ if (count > this.retention.maxEntries) {
157
+ const toDelete = count - this.retention.maxEntries;
158
+ const oldestEntries = await collection
159
+ .find({ entity_id: id,
160
+ table_name: tableName })
161
+ .sort({ updated_at: 1 })
162
+ .limit(toDelete)
163
+ .toArray();
164
+
165
+ if (oldestEntries.length > 0) {
166
+ const idsToDelete = oldestEntries.map(entry => entry._id);
167
+ await collection.deleteMany({ _id: { $in: idsToDelete } });
168
+ }
169
+ }
170
+
171
+ // 2. Enforce ttlDays
172
+ const cutoffDate = new Date();
173
+ cutoffDate.setDate(cutoffDate.getDate() - this.retention.ttlDays);
174
+
175
+ await collection.deleteMany({
176
+ entity_id: id,
177
+ table_name: tableName,
178
+ updated_at: { $lt: cutoffDate }
179
+ });
180
+ }
181
+ }
@@ -0,0 +1,446 @@
1
+ /**
2
+ * MongoDB Realtime Service
3
+ *
4
+ * Implements RealtimeProvider interface using MongoDB Change Streams.
5
+ * Provides real-time subscriptions to collection and row changes.
6
+ */
7
+
8
+ import { Db, ChangeStream, ChangeStreamDocument, Document, ObjectId } from "mongodb";
9
+ import {
10
+ FilterValues,
11
+ RealtimeProvider,
12
+ CollectionSubscriptionConfig,
13
+ SingleSubscriptionConfig,
14
+ WebSocketMessage,
15
+ User
16
+ } from "@rebasepro/types";
17
+ import { WebSocket } from "ws";
18
+ import { MongoDataService } from "../db/MongoDataService";
19
+
20
+ import { MongoDriver } from "./MongoDriver";
21
+ import { logger } from "@rebasepro/server";
22
+
23
+ interface Subscription {
24
+ type: "collection" | "single";
25
+ config: CollectionSubscriptionConfig | SingleSubscriptionConfig;
26
+ changeStream?: ChangeStream;
27
+ callback?: (data: any) => void;
28
+ authContext?: { userId: string; roles: string[] };
29
+ }
30
+
31
+ /**
32
+ * MongoDB Realtime Service
33
+ *
34
+ * Implements real-time subscriptions using MongoDB Change Streams.
35
+ * Requires MongoDB replica set for change streams to work.
36
+ */
37
+ export class MongoRealtimeService implements RealtimeProvider {
38
+ private subscriptions = new Map<string, Subscription>();
39
+ private clients = new Map<string, WebSocket>();
40
+ private dataService: MongoDataService;
41
+ private driver?: MongoDriver;
42
+
43
+ constructor(private db: Db) {
44
+ this.dataService = new MongoDataService(db);
45
+ }
46
+
47
+ setDataDriver(driver: MongoDriver) {
48
+ this.driver = driver;
49
+ }
50
+
51
+ /**
52
+ * Get the collection name from a path
53
+ */
54
+ private getCollectionName(path: string): string {
55
+ return path.replace(/\//g, "_");
56
+ }
57
+
58
+ /**
59
+ * Subscribe to collection changes
60
+ */
61
+ subscribeToCollection(
62
+ subscriptionId: string,
63
+ config: CollectionSubscriptionConfig & { authContext?: { userId: string; roles: string[] } },
64
+ callback?: (rows: Record<string, unknown>[]) => void
65
+ ): void {
66
+ // Clean up existing subscription if any
67
+ this.unsubscribe(subscriptionId);
68
+
69
+ const collectionName = this.getCollectionName(config.path);
70
+ const collection = this.db.collection(collectionName);
71
+
72
+ // Build pipeline for change stream filtering
73
+ const pipeline: Document[] = [];
74
+
75
+ // Filter by operation types we care about
76
+ pipeline.push({
77
+ $match: {
78
+ operationType: { $in: ["insert", "update", "replace", "delete"] }
79
+ }
80
+ });
81
+
82
+ try {
83
+ // Create change stream
84
+ const changeStream = collection.watch(pipeline, {
85
+ fullDocument: "updateLookup"
86
+ });
87
+
88
+ const subscription: Subscription = {
89
+ type: "collection",
90
+ config,
91
+ changeStream,
92
+ callback,
93
+ authContext: config.authContext
94
+ };
95
+
96
+ this.subscriptions.set(subscriptionId, subscription);
97
+
98
+ // Fetch initial data
99
+ this.fetchAndNotifyCollection(subscriptionId, config, callback);
100
+
101
+ // Listen for changes
102
+ changeStream.on("change", async (change: ChangeStreamDocument) => {
103
+ // Re-fetch the entire collection when any change happens
104
+ // This is simpler and ensures consistent sorting/filtering
105
+ await this.fetchAndNotifyCollection(subscriptionId, config, callback);
106
+ });
107
+
108
+ changeStream.on("error", (error: Error) => {
109
+ logger.error(`Change stream error for subscription ${subscriptionId}`, { error: error });
110
+ });
111
+
112
+ } catch (error) {
113
+ // Change streams might not be available (e.g., standalone MongoDB)
114
+ logger.warn("Change streams not available, falling back to polling", { error: error });
115
+
116
+ // Store subscription without change stream for manual notifications
117
+ const subscription: Subscription = {
118
+ type: "collection",
119
+ config,
120
+ callback,
121
+ authContext: config.authContext
122
+ };
123
+
124
+ this.subscriptions.set(subscriptionId, subscription);
125
+
126
+ // Fetch initial data
127
+ this.fetchAndNotifyCollection(subscriptionId, config, callback);
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Fetch collection and notify callback
133
+ */
134
+ private async fetchAndNotifyCollection(
135
+ subscriptionId: string,
136
+ config: CollectionSubscriptionConfig & { authContext?: { userId: string; roles: string[] } },
137
+ callback?: (rows: Record<string, unknown>[]) => void
138
+ ): Promise<void> {
139
+ try {
140
+ let rows;
141
+ const registryCollection = this.driver?.registry?.getCollectionByPath(config.path);
142
+
143
+ if (config.authContext && this.driver) {
144
+ const mockUser = { uid: config.authContext.userId,
145
+ roles: config.authContext.roles } as User;
146
+ const authenticatedDriver = await this.driver.withAuth(mockUser);
147
+ rows = await authenticatedDriver.fetchCollection({
148
+ path: config.path,
149
+ collection: registryCollection,
150
+ filter: config.filter as FilterValues<string> | undefined,
151
+ orderBy: config.orderBy,
152
+ order: config.order,
153
+ limit: config.limit,
154
+ startAfter: config.startAfter,
155
+ searchString: config.searchString
156
+ });
157
+ } else {
158
+ rows = await this.dataService.fetchCollection(config.path, {
159
+ filter: config.filter as FilterValues<string> | undefined,
160
+ orderBy: config.orderBy,
161
+ order: config.order,
162
+ limit: config.limit,
163
+ startAfter: config.startAfter,
164
+ searchString: config.searchString,
165
+ collection: registryCollection
166
+ });
167
+ }
168
+
169
+ if (callback) {
170
+ callback(rows);
171
+ }
172
+ } catch (error) {
173
+ logger.error(`Error fetching collection for subscription ${subscriptionId}`, { error: error });
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Subscribe to single row changes
179
+ */
180
+ subscribeToOne(
181
+ subscriptionId: string,
182
+ config: SingleSubscriptionConfig & { authContext?: { userId: string; roles: string[] } },
183
+ callback?: (row: Record<string, unknown> | null) => void
184
+ ): void {
185
+ // Clean up existing subscription if any
186
+ this.unsubscribe(subscriptionId);
187
+
188
+ const collectionName = this.getCollectionName(config.path);
189
+ const collection = this.db.collection(collectionName);
190
+
191
+ // Build pipeline to watch specific document
192
+ const id = typeof config.id === "string" && ObjectId.isValid(config.id)
193
+ ? new ObjectId(config.id)
194
+ : config.id;
195
+
196
+ const pipeline: Document[] = [
197
+ {
198
+ $match: {
199
+ "documentKey._id": id,
200
+ operationType: { $in: ["insert", "update", "replace", "delete"] }
201
+ }
202
+ }
203
+ ];
204
+
205
+ try {
206
+ const changeStream = collection.watch(pipeline, {
207
+ fullDocument: "updateLookup"
208
+ });
209
+
210
+ const subscription: Subscription = {
211
+ type: "single",
212
+ config,
213
+ changeStream,
214
+ callback,
215
+ authContext: config.authContext
216
+ };
217
+
218
+ this.subscriptions.set(subscriptionId, subscription);
219
+
220
+ // Fetch initial data
221
+ this.fetchAndNotifyOne(subscriptionId, config, callback);
222
+
223
+ // Listen for changes
224
+ changeStream.on("change", async (change: ChangeStreamDocument) => {
225
+ if (change.operationType === "delete") {
226
+ if (callback) {
227
+ callback(null);
228
+ }
229
+ } else {
230
+ await this.fetchAndNotifyOne(subscriptionId, config, callback);
231
+ }
232
+ });
233
+
234
+ changeStream.on("error", (error: Error) => {
235
+ logger.error(`Change stream error for subscription ${subscriptionId}`, { error: error });
236
+ });
237
+
238
+ } catch (error) {
239
+ logger.warn("Change streams not available, falling back to polling", { error: error });
240
+
241
+ const subscription: Subscription = {
242
+ type: "single",
243
+ config,
244
+ callback,
245
+ authContext: config.authContext
246
+ };
247
+
248
+ this.subscriptions.set(subscriptionId, subscription);
249
+
250
+ // Fetch initial data
251
+ this.fetchAndNotifyOne(subscriptionId, config, callback);
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Fetch row and notify callback
257
+ */
258
+ private async fetchAndNotifyOne(
259
+ subscriptionId: string,
260
+ config: SingleSubscriptionConfig & { authContext?: { userId: string; roles: string[] } },
261
+ callback?: (row: Record<string, unknown> | null) => void
262
+ ): Promise<void> {
263
+ try {
264
+ let row;
265
+ const registryCollection = this.driver?.registry?.getCollectionByPath(config.path);
266
+
267
+ if (config.authContext && this.driver) {
268
+ const mockUser = { uid: config.authContext.userId,
269
+ roles: config.authContext.roles } as User;
270
+ const authenticatedDriver = await this.driver.withAuth(mockUser);
271
+ row = await authenticatedDriver.fetchOne({
272
+ path: config.path,
273
+ id: config.id,
274
+ collection: registryCollection
275
+ });
276
+ } else {
277
+ row = await this.dataService.fetchOne(config.path, config.id);
278
+ }
279
+
280
+ if (callback) {
281
+ callback(row || null);
282
+ }
283
+ } catch (error) {
284
+ logger.error(`Error fetching row for subscription ${subscriptionId}`, { error: error });
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Unsubscribe from a subscription
290
+ */
291
+ unsubscribe(subscriptionId: string): void {
292
+ const subscription = this.subscriptions.get(subscriptionId);
293
+ if (subscription) {
294
+ if (subscription.changeStream) {
295
+ subscription.changeStream.close().catch((err) => logger.error("Operation failed", { error: err }));
296
+ }
297
+ this.subscriptions.delete(subscriptionId);
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Notify all relevant subscribers of an row update
303
+ * This is called after save/delete operations to push updates
304
+ */
305
+ async notifyUpdate(
306
+ path: string,
307
+ id: string,
308
+ row: Record<string, unknown> | null,
309
+ _databaseId?: string
310
+ ): Promise<void> {
311
+ // Find all subscriptions that might be affected by this update
312
+ for (const [subscriptionId, subscription] of this.subscriptions) {
313
+ if (subscription.type === "single") {
314
+ const config = subscription.config as SingleSubscriptionConfig;
315
+ if (config.path === path && config.id.toString() === id) {
316
+ if (subscription.callback) {
317
+ subscription.callback(row);
318
+ }
319
+ }
320
+ } else if (subscription.type === "collection") {
321
+ const config = subscription.config as CollectionSubscriptionConfig;
322
+ if (config.path === path) {
323
+ // Re-fetch the collection to get updated data
324
+ await this.fetchAndNotifyCollection(subscriptionId, config, subscription.callback);
325
+ }
326
+ }
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Get all active subscriptions (for debugging)
332
+ */
333
+ getSubscriptions(): Map<string, Subscription> {
334
+ return this.subscriptions;
335
+ }
336
+
337
+ /**
338
+ * Close all subscriptions
339
+ */
340
+ async closeAll(): Promise<void> {
341
+ for (const [subscriptionId] of this.subscriptions) {
342
+ this.unsubscribe(subscriptionId);
343
+ }
344
+ }
345
+
346
+ // =============================================================================
347
+ // WebSocket Client Management (parity with PostgreSQL RealtimeService)
348
+ // =============================================================================
349
+
350
+ /**
351
+ * Register a WebSocket client for real-time communication
352
+ */
353
+ addClient(clientId: string, ws: WebSocket) {
354
+ this.clients.set(clientId, ws);
355
+
356
+ ws.on("close", () => {
357
+ this.removeClient(clientId);
358
+ });
359
+
360
+ ws.on("error", (error) => {
361
+ logger.error("WebSocket error for client", { detail: clientId, error });
362
+ this.removeClient(clientId);
363
+ });
364
+ }
365
+
366
+ /**
367
+ * Remove a WebSocket client and clean up its subscriptions
368
+ */
369
+ private removeClient(clientId: string) {
370
+ this.clients.delete(clientId);
371
+ }
372
+
373
+ /**
374
+ * Handle an incoming WebSocket message for subscription management
375
+ */
376
+ async handleClientMessage(
377
+ clientId: string,
378
+ message: { type: string; payload?: any; subscriptionId?: string },
379
+ _authContext?: { userId: string; roles: unknown[] }
380
+ ): Promise<void> {
381
+ const ws = this.clients.get(clientId);
382
+ if (!ws) return;
383
+
384
+ const authContext = _authContext ? { userId: _authContext.userId,
385
+ roles: (_authContext.roles ?? []).map(String) } : undefined;
386
+
387
+ switch (message.type) {
388
+ case "subscribe_collection": {
389
+ const subscriptionId = message.payload?.subscriptionId ?? message.subscriptionId;
390
+ if (!subscriptionId) return;
391
+
392
+ this.subscribeToCollection(
393
+ subscriptionId,
394
+ {
395
+ clientId,
396
+ path: message.payload?.path,
397
+ filter: message.payload?.filter,
398
+ orderBy: message.payload?.orderBy,
399
+ order: message.payload?.order,
400
+ limit: message.payload?.limit,
401
+ startAfter: message.payload?.startAfter,
402
+ searchString: message.payload?.searchString,
403
+ authContext
404
+ },
405
+ (rows) => {
406
+ ws.send(JSON.stringify({
407
+ type: "collection_update",
408
+ subscriptionId,
409
+ rows
410
+ }));
411
+ }
412
+ );
413
+ break;
414
+ }
415
+ case "subscribe_one": {
416
+ const subscriptionId = message.payload?.subscriptionId ?? message.subscriptionId;
417
+ if (!subscriptionId) return;
418
+
419
+ this.subscribeToOne(
420
+ subscriptionId,
421
+ {
422
+ clientId,
423
+ path: message.payload?.path,
424
+ id: message.payload?.id,
425
+ authContext
426
+ },
427
+ (row) => {
428
+ ws.send(JSON.stringify({
429
+ type: "single_update",
430
+ subscriptionId,
431
+ row
432
+ }));
433
+ }
434
+ );
435
+ break;
436
+ }
437
+ case "unsubscribe": {
438
+ const subscriptionId = message.payload?.subscriptionId ?? message.subscriptionId;
439
+ if (subscriptionId) {
440
+ this.unsubscribe(subscriptionId);
441
+ }
442
+ break;
443
+ }
444
+ }
445
+ }
446
+ }