@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.
- package/LICENSE +22 -0
- package/README.md +86 -0
- package/dist/MongoBootstrapper.d.ts +18 -0
- package/dist/MongoBootstrapper.d.ts.map +1 -0
- package/dist/auth/ensure-collections.d.ts +3 -0
- package/dist/auth/ensure-collections.d.ts.map +1 -0
- package/dist/auth/services.d.ts +156 -0
- package/dist/auth/services.d.ts.map +1 -0
- package/dist/connection.d.ts +35 -0
- package/dist/connection.d.ts.map +1 -0
- package/dist/db/MongoConditionBuilder.d.ts +64 -0
- package/dist/db/MongoConditionBuilder.d.ts.map +1 -0
- package/dist/db/MongoDataService.d.ts +101 -0
- package/dist/db/MongoDataService.d.ts.map +1 -0
- package/dist/ensure-collections-Bkx_O5CQ.js +94 -0
- package/dist/ensure-collections-Bkx_O5CQ.js.map +1 -0
- package/dist/ensure-history-collection-yajOt2dv.js +21 -0
- package/dist/ensure-history-collection-yajOt2dv.js.map +1 -0
- package/dist/factory.d.ts +151 -0
- package/dist/factory.d.ts.map +1 -0
- package/dist/history/ensure-history-collection.d.ts +3 -0
- package/dist/history/ensure-history-collection.d.ts.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.es.js +2508 -0
- package/dist/index.es.js.map +1 -0
- package/dist/index.umd.js +2925 -0
- package/dist/index.umd.js.map +1 -0
- package/dist/services/MongoDriver.d.ts +125 -0
- package/dist/services/MongoDriver.d.ts.map +1 -0
- package/dist/services/MongoHistoryService.d.ts +37 -0
- package/dist/services/MongoHistoryService.d.ts.map +1 -0
- package/dist/services/MongoRealtimeService.d.ts +103 -0
- package/dist/services/MongoRealtimeService.d.ts.map +1 -0
- package/dist/websocket-DQlwCHFq.js +281 -0
- package/dist/websocket-DQlwCHFq.js.map +1 -0
- package/dist/websocket.d.ts +7 -0
- package/dist/websocket.d.ts.map +1 -0
- package/package.json +81 -0
- package/src/MongoBootstrapper.ts +196 -0
- package/src/auth/ensure-collections.ts +105 -0
- package/src/auth/services.ts +732 -0
- package/src/connection.ts +60 -0
- package/src/db/MongoConditionBuilder.ts +224 -0
- package/src/db/MongoDataService.ts +368 -0
- package/src/factory.ts +305 -0
- package/src/history/ensure-history-collection.ts +22 -0
- package/src/index.ts +25 -0
- package/src/services/MongoDriver.ts +1120 -0
- package/src/services/MongoHistoryService.ts +181 -0
- package/src/services/MongoRealtimeService.ts +446 -0
- 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
|
+
}
|