@rebasepro/server-mongo 0.17.3 → 0.18.1
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.
- package/LICENSE +0 -1
- package/README.md +4 -0
- package/dist/MongoBootstrapper.d.ts +0 -1
- package/dist/auth/ensure-collections.d.ts +0 -1
- package/dist/auth/services.d.ts +0 -1
- package/dist/connection.d.ts +0 -1
- package/dist/db/MongoConditionBuilder.d.ts +0 -1
- package/dist/db/MongoDataService.d.ts +0 -1
- package/dist/db/securityRuleFilter.d.ts +0 -1
- package/dist/factory.d.ts +0 -1
- package/dist/history/ensure-history-collection.d.ts +0 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.es.js +83 -79
- package/dist/index.es.js.map +1 -1
- package/dist/schema/plan-schema-change.d.ts +0 -1
- package/dist/services/MongoDriver.d.ts +0 -1
- package/dist/services/MongoHistoryService.d.ts +0 -1
- package/dist/services/MongoRealtimeService.d.ts +0 -1
- package/dist/websocket.d.ts +0 -1
- package/package.json +28 -24
- package/dist/MongoBootstrapper.d.ts.map +0 -1
- package/dist/auth/ensure-collections.d.ts.map +0 -1
- package/dist/auth/services.d.ts.map +0 -1
- package/dist/connection.d.ts.map +0 -1
- package/dist/db/MongoConditionBuilder.d.ts.map +0 -1
- package/dist/db/MongoDataService.d.ts.map +0 -1
- package/dist/db/securityRuleFilter.d.ts.map +0 -1
- package/dist/factory.d.ts.map +0 -1
- package/dist/history/ensure-history-collection.d.ts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/schema/plan-schema-change.d.ts.map +0 -1
- package/dist/services/MongoDriver.d.ts.map +0 -1
- package/dist/services/MongoHistoryService.d.ts.map +0 -1
- package/dist/services/MongoRealtimeService.d.ts.map +0 -1
- package/dist/websocket.d.ts.map +0 -1
- package/src/MongoBootstrapper.ts +0 -204
- package/src/auth/ensure-collections.ts +0 -153
- package/src/auth/services.ts +0 -866
- package/src/connection.ts +0 -60
- package/src/db/MongoConditionBuilder.ts +0 -348
- package/src/db/MongoDataService.ts +0 -412
- package/src/db/securityRuleFilter.ts +0 -398
- package/src/factory.ts +0 -331
- package/src/history/ensure-history-collection.ts +0 -22
- package/src/index.ts +0 -25
- package/src/schema/plan-schema-change.ts +0 -159
- package/src/services/MongoDriver.ts +0 -950
- package/src/services/MongoHistoryService.ts +0 -186
- package/src/services/MongoRealtimeService.ts +0 -592
- package/src/websocket.ts +0 -387
|
@@ -1,592 +0,0 @@
|
|
|
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
|
-
ANONYMOUS_USER_ID,
|
|
11
|
-
DataDriver,
|
|
12
|
-
FilterValues,
|
|
13
|
-
RealtimeProvider,
|
|
14
|
-
CollectionSubscriptionConfig,
|
|
15
|
-
SingleSubscriptionConfig,
|
|
16
|
-
WebSocketMessage,
|
|
17
|
-
User,
|
|
18
|
-
ListLimitError,
|
|
19
|
-
resolveClientListLimit
|
|
20
|
-
} from "@rebasepro/types";
|
|
21
|
-
import { WebSocket } from "ws";
|
|
22
|
-
|
|
23
|
-
import type { MongoDriver } from "./MongoDriver";
|
|
24
|
-
import { logger } from "@rebasepro/server";
|
|
25
|
-
|
|
26
|
-
/** The acting user for a subscription, as the driver and the socket carry it. */
|
|
27
|
-
export interface SubscriptionAuthContext {
|
|
28
|
-
uid: string;
|
|
29
|
-
roles: string[];
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* The query half of a subscription config — everything except who is watching.
|
|
34
|
-
*
|
|
35
|
-
* Spread into the re-fetch rather than re-listed field by field. Re-listing is
|
|
36
|
-
* how `logical` and `offset` went missing twice on this path: the type declares
|
|
37
|
-
* them, every boundary accepted them, and each hand-written list quietly named
|
|
38
|
-
* a subset. A function that removes the two non-query fields cannot fall behind
|
|
39
|
-
* the type the way a list of the other nine can.
|
|
40
|
-
*/
|
|
41
|
-
const queryOf = <T extends { clientId: string; authContext?: SubscriptionAuthContext }>(
|
|
42
|
-
config: T
|
|
43
|
-
): Omit<T, "clientId" | "authContext"> => {
|
|
44
|
-
const { clientId: _clientId, authContext: _authContext, ...query } = config;
|
|
45
|
-
return query;
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
interface Subscription {
|
|
49
|
-
type: "collection" | "single";
|
|
50
|
-
/**
|
|
51
|
-
* Carries `authContext`. There is deliberately no second copy on this
|
|
52
|
-
* object: every fetch reads `config.authContext`, and the one that used to
|
|
53
|
-
* live here was written from three places and read from none — so a
|
|
54
|
-
* subscription that looked authorized was re-fetched as nobody.
|
|
55
|
-
*/
|
|
56
|
-
config: (CollectionSubscriptionConfig | SingleSubscriptionConfig) & { authContext?: SubscriptionAuthContext };
|
|
57
|
-
changeStream?: ChangeStream;
|
|
58
|
-
callback?: (data: any) => void;
|
|
59
|
-
/**
|
|
60
|
-
* How many deliveries have been started for this subscription, and the
|
|
61
|
-
* highest that has already reached the callback.
|
|
62
|
-
*
|
|
63
|
-
* Every delivery here is a re-fetch, and three independent things start one
|
|
64
|
-
* for the same subscription: the initial fetch, the change stream, and
|
|
65
|
-
* `notifyUpdate` after a save. They overlap, and a fetch that started
|
|
66
|
-
* earlier can finish later — at which point the callback replaces the
|
|
67
|
-
* client's whole list with the state before the change. Nothing corrects it
|
|
68
|
-
* until something else happens to that collection.
|
|
69
|
-
*
|
|
70
|
-
* A counter taken before the await and checked after it is what makes the
|
|
71
|
-
* last *started* delivery the last *delivered* one.
|
|
72
|
-
*/
|
|
73
|
-
started: number;
|
|
74
|
-
delivered: number;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* MongoDB Realtime Service
|
|
79
|
-
*
|
|
80
|
-
* Implements real-time subscriptions using MongoDB Change Streams.
|
|
81
|
-
* Requires MongoDB replica set for change streams to work.
|
|
82
|
-
*/
|
|
83
|
-
export class MongoRealtimeService implements RealtimeProvider {
|
|
84
|
-
private subscriptions = new Map<string, Subscription>();
|
|
85
|
-
private clients = new Map<string, WebSocket>();
|
|
86
|
-
private driver?: MongoDriver;
|
|
87
|
-
|
|
88
|
-
constructor(private db: Db) {}
|
|
89
|
-
|
|
90
|
-
setDataDriver(driver: MongoDriver) {
|
|
91
|
-
this.driver = driver;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Get the collection name from a path
|
|
96
|
-
*/
|
|
97
|
-
private getCollectionName(path: string): string {
|
|
98
|
-
return path.replace(/\//g, "_");
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Claim a delivery slot for a subscription, before doing the work.
|
|
103
|
-
*
|
|
104
|
-
* Returns the check to run immediately before calling the callback. It
|
|
105
|
-
* refuses in three cases, all of which used to deliver:
|
|
106
|
-
*
|
|
107
|
-
* - **Out of order.** A newer fetch has already delivered, so this one is
|
|
108
|
-
* stale — the client would go back to the state before the change.
|
|
109
|
-
* - **Unsubscribed.** The subscription was cancelled while the fetch was in
|
|
110
|
-
* flight, and its callback belongs to a client that stopped listening.
|
|
111
|
-
* - **Re-subscribed.** `subscribeToCollection` unsubscribes first, so the
|
|
112
|
-
* same id can name a *different* subscription by the time a fetch lands —
|
|
113
|
-
* with a different filter, and a different caller's rows.
|
|
114
|
-
*
|
|
115
|
-
* Synchronous deliveries claim a slot too. A `delete` notification with no
|
|
116
|
-
* fetch behind it is the newest thing known about the row, so it must also
|
|
117
|
-
* be the thing that closes the door on an older fetch still in flight —
|
|
118
|
-
* otherwise the deleted row reappears a moment after it vanished.
|
|
119
|
-
*/
|
|
120
|
-
private beginDelivery(subscriptionId: string, subscription: Subscription): () => boolean {
|
|
121
|
-
const seq = ++subscription.started;
|
|
122
|
-
return () => {
|
|
123
|
-
if (this.subscriptions.get(subscriptionId) !== subscription) return false;
|
|
124
|
-
if (seq <= subscription.delivered) return false;
|
|
125
|
-
subscription.delivered = seq;
|
|
126
|
-
return true;
|
|
127
|
-
};
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Subscribe to collection changes
|
|
132
|
-
*/
|
|
133
|
-
subscribeToCollection(
|
|
134
|
-
subscriptionId: string,
|
|
135
|
-
config: CollectionSubscriptionConfig & { authContext?: SubscriptionAuthContext },
|
|
136
|
-
callback?: (rows: Record<string, unknown>[]) => void
|
|
137
|
-
): void {
|
|
138
|
-
// Clean up existing subscription if any
|
|
139
|
-
this.unsubscribe(subscriptionId);
|
|
140
|
-
|
|
141
|
-
const collectionName = this.getCollectionName(config.path);
|
|
142
|
-
const collection = this.db.collection(collectionName);
|
|
143
|
-
|
|
144
|
-
// Build pipeline for change stream filtering
|
|
145
|
-
const pipeline: Document[] = [];
|
|
146
|
-
|
|
147
|
-
// Filter by operation types we care about
|
|
148
|
-
pipeline.push({
|
|
149
|
-
$match: {
|
|
150
|
-
operationType: { $in: ["insert", "update", "replace", "delete"] }
|
|
151
|
-
}
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
try {
|
|
155
|
-
// Create change stream
|
|
156
|
-
const changeStream = collection.watch(pipeline, {
|
|
157
|
-
fullDocument: "updateLookup"
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
const subscription: Subscription = {
|
|
161
|
-
type: "collection",
|
|
162
|
-
config,
|
|
163
|
-
changeStream,
|
|
164
|
-
callback,
|
|
165
|
-
started: 0,
|
|
166
|
-
delivered: 0
|
|
167
|
-
};
|
|
168
|
-
|
|
169
|
-
this.subscriptions.set(subscriptionId, subscription);
|
|
170
|
-
|
|
171
|
-
// Fetch initial data
|
|
172
|
-
this.fetchAndNotifyCollection(subscriptionId, subscription);
|
|
173
|
-
|
|
174
|
-
// Listen for changes
|
|
175
|
-
changeStream.on("change", async (change: ChangeStreamDocument) => {
|
|
176
|
-
// Re-fetch the entire collection when any change happens
|
|
177
|
-
// This is simpler and ensures consistent sorting/filtering
|
|
178
|
-
await this.fetchAndNotifyCollection(subscriptionId, subscription);
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
changeStream.on("error", (error: Error) => {
|
|
182
|
-
logger.error(`Change stream error for subscription ${subscriptionId}`, { error: error });
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
} catch (error) {
|
|
186
|
-
// Change streams might not be available (e.g., standalone MongoDB)
|
|
187
|
-
logger.warn("Change streams not available, falling back to polling", { error: error });
|
|
188
|
-
|
|
189
|
-
// Store subscription without change stream for manual notifications
|
|
190
|
-
const subscription: Subscription = {
|
|
191
|
-
type: "collection",
|
|
192
|
-
config,
|
|
193
|
-
callback,
|
|
194
|
-
started: 0,
|
|
195
|
-
delivered: 0
|
|
196
|
-
};
|
|
197
|
-
|
|
198
|
-
this.subscriptions.set(subscriptionId, subscription);
|
|
199
|
-
|
|
200
|
-
// Fetch initial data
|
|
201
|
-
this.fetchAndNotifyCollection(subscriptionId, subscription);
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* Fetch collection and notify callback
|
|
207
|
-
*/
|
|
208
|
-
private async fetchAndNotifyCollection(
|
|
209
|
-
subscriptionId: string,
|
|
210
|
-
subscription: Subscription
|
|
211
|
-
): Promise<void> {
|
|
212
|
-
const config = subscription.config as CollectionSubscriptionConfig & { authContext?: SubscriptionAuthContext };
|
|
213
|
-
const callback = subscription.callback;
|
|
214
|
-
const canDeliver = this.beginDelivery(subscriptionId, subscription);
|
|
215
|
-
try {
|
|
216
|
-
const registryCollection = this.driver?.registry?.getCollectionByPath(config.path);
|
|
217
|
-
// One path, authenticated or not. The `else` branch used to reach
|
|
218
|
-
// past the driver into the repository, which applies no security
|
|
219
|
-
// rules at all — the fallback stubbing out the contract the primary
|
|
220
|
-
// branch honours, and granting more while doing it. An anonymous
|
|
221
|
-
// subscriber is now a user like any other: rules are evaluated
|
|
222
|
-
// against the anonymous uid, and a rule that needs a real one
|
|
223
|
-
// matches nothing.
|
|
224
|
-
const driver = await this.scopedDriver(config.authContext);
|
|
225
|
-
// The stored config forwarded whole. Re-listing its fields here is
|
|
226
|
-
// how `logical` and `offset` went missing a second time, one layer
|
|
227
|
-
// below where they went missing the first time: the subscription
|
|
228
|
-
// carried them and the re-fetch did not ask for them.
|
|
229
|
-
const rows = await driver.fetchCollection({
|
|
230
|
-
...queryOf(config),
|
|
231
|
-
filter: config.filter as FilterValues<string> | undefined,
|
|
232
|
-
collection: registryCollection
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
if (callback && canDeliver()) {
|
|
236
|
-
callback(rows);
|
|
237
|
-
}
|
|
238
|
-
} catch (error) {
|
|
239
|
-
logger.error(`Error fetching collection for subscription ${subscriptionId}`, { error: error });
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/**
|
|
244
|
-
* The driver scoped to a subscriber.
|
|
245
|
-
*
|
|
246
|
-
* Never the bare repository: everything a subscription delivers has to pass
|
|
247
|
-
* the same row authorization an HTTP read does.
|
|
248
|
-
*/
|
|
249
|
-
private async scopedDriver(authContext?: SubscriptionAuthContext): Promise<DataDriver> {
|
|
250
|
-
if (!this.driver) {
|
|
251
|
-
throw new Error("MongoRealtimeService has no data driver — subscriptions cannot be authorized");
|
|
252
|
-
}
|
|
253
|
-
const user = { uid: authContext?.uid ?? ANONYMOUS_USER_ID,
|
|
254
|
-
roles: authContext?.roles ?? [] } as User;
|
|
255
|
-
return this.driver.withAuth(user);
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/**
|
|
259
|
-
* Subscribe to single row changes
|
|
260
|
-
*/
|
|
261
|
-
subscribeToOne(
|
|
262
|
-
subscriptionId: string,
|
|
263
|
-
config: SingleSubscriptionConfig & { authContext?: SubscriptionAuthContext },
|
|
264
|
-
callback?: (row: Record<string, unknown> | null) => void
|
|
265
|
-
): void {
|
|
266
|
-
// Clean up existing subscription if any
|
|
267
|
-
this.unsubscribe(subscriptionId);
|
|
268
|
-
|
|
269
|
-
const collectionName = this.getCollectionName(config.path);
|
|
270
|
-
const collection = this.db.collection(collectionName);
|
|
271
|
-
|
|
272
|
-
// Build pipeline to watch specific document
|
|
273
|
-
const id = typeof config.id === "string" && ObjectId.isValid(config.id)
|
|
274
|
-
? new ObjectId(config.id)
|
|
275
|
-
: config.id;
|
|
276
|
-
|
|
277
|
-
const pipeline: Document[] = [
|
|
278
|
-
{
|
|
279
|
-
$match: {
|
|
280
|
-
"documentKey._id": id,
|
|
281
|
-
operationType: { $in: ["insert", "update", "replace", "delete"] }
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
];
|
|
285
|
-
|
|
286
|
-
try {
|
|
287
|
-
const changeStream = collection.watch(pipeline, {
|
|
288
|
-
fullDocument: "updateLookup"
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
const subscription: Subscription = {
|
|
292
|
-
type: "single",
|
|
293
|
-
config,
|
|
294
|
-
changeStream,
|
|
295
|
-
callback,
|
|
296
|
-
started: 0,
|
|
297
|
-
delivered: 0
|
|
298
|
-
};
|
|
299
|
-
|
|
300
|
-
this.subscriptions.set(subscriptionId, subscription);
|
|
301
|
-
|
|
302
|
-
// Fetch initial data
|
|
303
|
-
this.fetchAndNotifyOne(subscriptionId, subscription);
|
|
304
|
-
|
|
305
|
-
// Listen for changes
|
|
306
|
-
changeStream.on("change", async (change: ChangeStreamDocument) => {
|
|
307
|
-
if (change.operationType === "delete") {
|
|
308
|
-
// Claims a slot like any other delivery: the deletion is the
|
|
309
|
-
// newest fact about this row, so an older fetch still in
|
|
310
|
-
// flight must not put it back.
|
|
311
|
-
const canDeliver = this.beginDelivery(subscriptionId, subscription);
|
|
312
|
-
if (callback && canDeliver()) {
|
|
313
|
-
callback(null);
|
|
314
|
-
}
|
|
315
|
-
} else {
|
|
316
|
-
await this.fetchAndNotifyOne(subscriptionId, subscription);
|
|
317
|
-
}
|
|
318
|
-
});
|
|
319
|
-
|
|
320
|
-
changeStream.on("error", (error: Error) => {
|
|
321
|
-
logger.error(`Change stream error for subscription ${subscriptionId}`, { error: error });
|
|
322
|
-
});
|
|
323
|
-
|
|
324
|
-
} catch (error) {
|
|
325
|
-
logger.warn("Change streams not available, falling back to polling", { error: error });
|
|
326
|
-
|
|
327
|
-
const subscription: Subscription = {
|
|
328
|
-
type: "single",
|
|
329
|
-
config,
|
|
330
|
-
callback,
|
|
331
|
-
started: 0,
|
|
332
|
-
delivered: 0
|
|
333
|
-
};
|
|
334
|
-
|
|
335
|
-
this.subscriptions.set(subscriptionId, subscription);
|
|
336
|
-
|
|
337
|
-
// Fetch initial data
|
|
338
|
-
this.fetchAndNotifyOne(subscriptionId, subscription);
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
/**
|
|
343
|
-
* Fetch row and notify callback
|
|
344
|
-
*/
|
|
345
|
-
private async fetchAndNotifyOne(
|
|
346
|
-
subscriptionId: string,
|
|
347
|
-
subscription: Subscription
|
|
348
|
-
): Promise<void> {
|
|
349
|
-
const config = subscription.config as SingleSubscriptionConfig & { authContext?: SubscriptionAuthContext };
|
|
350
|
-
const callback = subscription.callback;
|
|
351
|
-
const canDeliver = this.beginDelivery(subscriptionId, subscription);
|
|
352
|
-
try {
|
|
353
|
-
const registryCollection = this.driver?.registry?.getCollectionByPath(config.path);
|
|
354
|
-
const driver = await this.scopedDriver(config.authContext);
|
|
355
|
-
const row = await driver.fetchOne({
|
|
356
|
-
path: config.path,
|
|
357
|
-
id: config.id,
|
|
358
|
-
collection: registryCollection
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
if (callback && canDeliver()) {
|
|
362
|
-
callback(row || null);
|
|
363
|
-
}
|
|
364
|
-
} catch (error) {
|
|
365
|
-
logger.error(`Error fetching row for subscription ${subscriptionId}`, { error: error });
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
/**
|
|
370
|
-
* Unsubscribe from a subscription
|
|
371
|
-
*/
|
|
372
|
-
unsubscribe(subscriptionId: string): void {
|
|
373
|
-
const subscription = this.subscriptions.get(subscriptionId);
|
|
374
|
-
if (subscription) {
|
|
375
|
-
if (subscription.changeStream) {
|
|
376
|
-
subscription.changeStream.close().catch((err) => logger.error("Operation failed", { error: err }));
|
|
377
|
-
}
|
|
378
|
-
this.subscriptions.delete(subscriptionId);
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
/**
|
|
383
|
-
* Notify all relevant subscribers of an row update
|
|
384
|
-
* This is called after save/delete operations to push updates
|
|
385
|
-
*/
|
|
386
|
-
async notifyUpdate(
|
|
387
|
-
path: string,
|
|
388
|
-
id: string,
|
|
389
|
-
row: Record<string, unknown> | null,
|
|
390
|
-
_databaseId?: string
|
|
391
|
-
): Promise<void> {
|
|
392
|
-
// Find all subscriptions that might be affected by this update
|
|
393
|
-
for (const [subscriptionId, subscription] of this.subscriptions) {
|
|
394
|
-
if (subscription.type === "single") {
|
|
395
|
-
const config = subscription.config as SingleSubscriptionConfig & { authContext?: SubscriptionAuthContext };
|
|
396
|
-
if (config.path === path && config.id.toString() === id) {
|
|
397
|
-
if (row === null) {
|
|
398
|
-
// A deletion carries no row to authorize — but it still
|
|
399
|
-
// claims a delivery slot, so a re-fetch already in
|
|
400
|
-
// flight cannot land after it and resurrect the row.
|
|
401
|
-
const canDeliver = this.beginDelivery(subscriptionId, subscription);
|
|
402
|
-
if (canDeliver()) subscription.callback?.(null);
|
|
403
|
-
} else {
|
|
404
|
-
// Re-fetched through the subscriber's own driver rather
|
|
405
|
-
// than pushed verbatim: `notifyUpdate` runs after every
|
|
406
|
-
// save, and handing it the row as written broadcast any
|
|
407
|
-
// document to whoever happened to be watching its id.
|
|
408
|
-
await this.fetchAndNotifyOne(subscriptionId, subscription);
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
} else if (subscription.type === "collection") {
|
|
412
|
-
const config = subscription.config as CollectionSubscriptionConfig & { authContext?: SubscriptionAuthContext };
|
|
413
|
-
if (config.path === path) {
|
|
414
|
-
// Re-fetch the collection to get updated data
|
|
415
|
-
await this.fetchAndNotifyCollection(subscriptionId, subscription);
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
/**
|
|
422
|
-
* Get all active subscriptions (for debugging)
|
|
423
|
-
*/
|
|
424
|
-
getSubscriptions(): Map<string, Subscription> {
|
|
425
|
-
return this.subscriptions;
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
/**
|
|
429
|
-
* Close all subscriptions
|
|
430
|
-
*/
|
|
431
|
-
async closeAll(): Promise<void> {
|
|
432
|
-
for (const [subscriptionId] of this.subscriptions) {
|
|
433
|
-
this.unsubscribe(subscriptionId);
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
// =============================================================================
|
|
438
|
-
// WebSocket Client Management (parity with PostgreSQL RealtimeService)
|
|
439
|
-
// =============================================================================
|
|
440
|
-
|
|
441
|
-
/**
|
|
442
|
-
* Register a WebSocket client for real-time communication
|
|
443
|
-
*/
|
|
444
|
-
addClient(clientId: string, ws: WebSocket) {
|
|
445
|
-
this.clients.set(clientId, ws);
|
|
446
|
-
|
|
447
|
-
ws.on("close", () => {
|
|
448
|
-
this.removeClient(clientId);
|
|
449
|
-
});
|
|
450
|
-
|
|
451
|
-
ws.on("error", (error) => {
|
|
452
|
-
logger.error("WebSocket error for client", { detail: clientId, error });
|
|
453
|
-
this.removeClient(clientId);
|
|
454
|
-
});
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
/**
|
|
458
|
-
* Remove a WebSocket client and clean up its subscriptions
|
|
459
|
-
*/
|
|
460
|
-
private removeClient(clientId: string) {
|
|
461
|
-
this.clients.delete(clientId);
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
/**
|
|
465
|
-
* Handle an incoming WebSocket message for subscription management
|
|
466
|
-
*/
|
|
467
|
-
async handleClientMessage(
|
|
468
|
-
clientId: string,
|
|
469
|
-
message: { type: string; payload?: any; subscriptionId?: string },
|
|
470
|
-
_authContext?: { uid: string; roles: unknown[] }
|
|
471
|
-
): Promise<void> {
|
|
472
|
-
const ws = this.clients.get(clientId);
|
|
473
|
-
if (!ws) return;
|
|
474
|
-
|
|
475
|
-
const authContext = _authContext ? { uid: _authContext.uid,
|
|
476
|
-
roles: (_authContext.roles ?? []).map(String) } : undefined;
|
|
477
|
-
|
|
478
|
-
switch (message.type) {
|
|
479
|
-
case "subscribe_collection": {
|
|
480
|
-
const subscriptionId = message.payload?.subscriptionId ?? message.subscriptionId;
|
|
481
|
-
if (!subscriptionId) return;
|
|
482
|
-
|
|
483
|
-
// The same list bound the Postgres socket and every REST route
|
|
484
|
-
// apply. This ingress applied none: an absent limit reached the
|
|
485
|
-
// driver as `undefined` and emitted no `.limit()` at all, so one
|
|
486
|
-
// subscribe frame streamed the whole collection — and re-streamed
|
|
487
|
-
// it on every matching write. An over-large limit is refused
|
|
488
|
-
// rather than shrunk, because a `collection_update` frame carries
|
|
489
|
-
// no `total` or `hasMore` for the client to notice with.
|
|
490
|
-
let boundedLimit: number;
|
|
491
|
-
try {
|
|
492
|
-
boundedLimit = resolveClientListLimit(message.payload?.limit);
|
|
493
|
-
} catch (e) {
|
|
494
|
-
if (!(e instanceof ListLimitError)) throw e;
|
|
495
|
-
logger.warn(`⚠️ [MongoRealtime] Refused subscription to '${message.payload?.path}': ${e.message}`);
|
|
496
|
-
ws.send(JSON.stringify({
|
|
497
|
-
type: "ERROR",
|
|
498
|
-
subscriptionId,
|
|
499
|
-
payload: { error: { message: e.message, code: "INVALID_LIMIT" } },
|
|
500
|
-
error: e.message
|
|
501
|
-
}));
|
|
502
|
-
return;
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
this.subscribeToCollection(
|
|
506
|
-
subscriptionId,
|
|
507
|
-
{
|
|
508
|
-
clientId,
|
|
509
|
-
path: message.payload?.path,
|
|
510
|
-
filter: message.payload?.filter,
|
|
511
|
-
// `logical` and `offset` were absent from this list, so
|
|
512
|
-
// an `or(...)` subscription was pushed every row the
|
|
513
|
-
// caller's policies allowed and a subscription to page
|
|
514
|
-
// two was pushed page one. The client has been sending
|
|
515
|
-
// both since it stopped stringifying `offset` into
|
|
516
|
-
// `startAfter`; nothing here read them.
|
|
517
|
-
logical: message.payload?.logical,
|
|
518
|
-
offset: message.payload?.offset,
|
|
519
|
-
orderBy: message.payload?.orderBy,
|
|
520
|
-
order: message.payload?.order,
|
|
521
|
-
limit: boundedLimit,
|
|
522
|
-
startAfter: message.payload?.startAfter,
|
|
523
|
-
searchString: message.payload?.searchString,
|
|
524
|
-
searchExplain: message.payload?.searchExplain,
|
|
525
|
-
authContext
|
|
526
|
-
},
|
|
527
|
-
(rows) => {
|
|
528
|
-
ws.send(JSON.stringify({
|
|
529
|
-
type: "collection_update",
|
|
530
|
-
subscriptionId,
|
|
531
|
-
rows
|
|
532
|
-
}));
|
|
533
|
-
}
|
|
534
|
-
);
|
|
535
|
-
break;
|
|
536
|
-
}
|
|
537
|
-
case "subscribe_one": {
|
|
538
|
-
const subscriptionId = message.payload?.subscriptionId ?? message.subscriptionId;
|
|
539
|
-
if (!subscriptionId) return;
|
|
540
|
-
|
|
541
|
-
this.subscribeToOne(
|
|
542
|
-
subscriptionId,
|
|
543
|
-
{
|
|
544
|
-
clientId,
|
|
545
|
-
path: message.payload?.path,
|
|
546
|
-
id: message.payload?.id,
|
|
547
|
-
authContext
|
|
548
|
-
},
|
|
549
|
-
(row) => {
|
|
550
|
-
ws.send(JSON.stringify({
|
|
551
|
-
type: "single_update",
|
|
552
|
-
subscriptionId,
|
|
553
|
-
row
|
|
554
|
-
}));
|
|
555
|
-
}
|
|
556
|
-
);
|
|
557
|
-
break;
|
|
558
|
-
}
|
|
559
|
-
case "unsubscribe": {
|
|
560
|
-
const subscriptionId = message.payload?.subscriptionId ?? message.subscriptionId;
|
|
561
|
-
if (subscriptionId) {
|
|
562
|
-
this.unsubscribe(subscriptionId);
|
|
563
|
-
}
|
|
564
|
-
break;
|
|
565
|
-
}
|
|
566
|
-
default: {
|
|
567
|
-
// A silent `switch` over a wire protocol is how channel,
|
|
568
|
-
// presence and broadcast frames came to be accepted here and
|
|
569
|
-
// dropped: the client's `broadcast()` resolved, `onPresence`
|
|
570
|
-
// never fired, and a `channel_history` request buffered live
|
|
571
|
-
// messages until the catch-up timeout on every join. Say so,
|
|
572
|
-
// and tell the sender rather than leaving it waiting.
|
|
573
|
-
logger.warn(
|
|
574
|
-
`⚠️ [MongoRealtime] Unhandled realtime message type "${message.type}" — ` +
|
|
575
|
-
"channels, presence and broadcast are not implemented by the Mongo driver."
|
|
576
|
-
);
|
|
577
|
-
ws.send(JSON.stringify({
|
|
578
|
-
type: "ERROR",
|
|
579
|
-
subscriptionId: message.subscriptionId,
|
|
580
|
-
payload: {
|
|
581
|
-
error: {
|
|
582
|
-
message: `Realtime message type "${message.type}" is not supported by the Mongo driver`,
|
|
583
|
-
code: "REALTIME_UNSUPPORTED"
|
|
584
|
-
}
|
|
585
|
-
},
|
|
586
|
-
error: `Realtime message type "${message.type}" is not supported by the Mongo driver`
|
|
587
|
-
}));
|
|
588
|
-
break;
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
}
|