@rebasepro/server-mongo 0.17.3 → 0.18.0
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,412 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* MongoDB Row Service
|
|
3
|
-
*
|
|
4
|
-
* Implements DataRepository interface for MongoDB.
|
|
5
|
-
* Provides all CRUD operations for rows.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { Db, ObjectId, Collection, Document, FindOptions, Filter } from "mongodb";
|
|
9
|
-
import { FilterValues, DataRepository, CollectionConfig, EntityReference, LogicalCondition, OrderByTuple } from "@rebasepro/types";
|
|
10
|
-
import { MongoConditionBuilder } from "./MongoConditionBuilder";
|
|
11
|
-
import { ApiError } from "@rebasepro/server";
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* MongoDB Row Service
|
|
15
|
-
*
|
|
16
|
-
* Implements the DataRepository interface for MongoDB.
|
|
17
|
-
* Provides all CRUD operations for rows stored in MongoDB collections.
|
|
18
|
-
*/
|
|
19
|
-
export class MongoDataService implements DataRepository {
|
|
20
|
-
constructor(private db: Db) { }
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Get a MongoDB collection by its path
|
|
24
|
-
*/
|
|
25
|
-
private getCollection(collectionPath: string): Collection<Document> {
|
|
26
|
-
// Handle nested paths (e.g., "posts/123/comments" -> "posts_123_comments")
|
|
27
|
-
const collectionName = collectionPath.replace(/\//g, "_");
|
|
28
|
-
return this.db.collection(collectionName);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Convert a string ID to ObjectId if it's a valid ObjectId string
|
|
33
|
-
*/
|
|
34
|
-
private toObjectId(id: string | number): ObjectId | string | number {
|
|
35
|
-
if (typeof id === "string" && ObjectId.isValid(id) && id.length === 24) {
|
|
36
|
-
return new ObjectId(id);
|
|
37
|
-
}
|
|
38
|
-
return id;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Convert a MongoDB document to a flat row (`{ id, ...fields }`)
|
|
43
|
-
*/
|
|
44
|
-
private documentToRow(doc: Document): Record<string, unknown> {
|
|
45
|
-
const { _id, ...values } = doc;
|
|
46
|
-
return {
|
|
47
|
-
...this.convertFromMongoValues(values),
|
|
48
|
-
// Spread the canonical id last so it wins over a literal `id` field
|
|
49
|
-
id: _id.toString()
|
|
50
|
-
};
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Convert values from MongoDB format to Rebase format
|
|
55
|
-
*/
|
|
56
|
-
private convertFromMongoValues(values: Record<string, any>): Record<string, any> {
|
|
57
|
-
const result: Record<string, any> = {};
|
|
58
|
-
|
|
59
|
-
for (const [key, value] of Object.entries(values)) {
|
|
60
|
-
result[key] = this.convertFromMongoValue(value);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
return result;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Convert a single value from MongoDB format
|
|
68
|
-
*/
|
|
69
|
-
private convertFromMongoValue(value: any): any {
|
|
70
|
-
if (value === null || value === undefined) return value;
|
|
71
|
-
|
|
72
|
-
// Handle ObjectId
|
|
73
|
-
if (value instanceof ObjectId) {
|
|
74
|
-
return value.toString();
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Handle Date
|
|
78
|
-
if (value instanceof Date) {
|
|
79
|
-
return value;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// Handle arrays
|
|
83
|
-
if (Array.isArray(value)) {
|
|
84
|
-
return value.map(v => this.convertFromMongoValue(v));
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// Handle stored EntityReference. New writes are tagged with the
|
|
88
|
-
// `__type: "reference"` sentinel (see convertToMongoValue), which is
|
|
89
|
-
// unambiguous. We also accept the legacy shape — an object whose ONLY
|
|
90
|
-
// keys are `id` and `path` — so references written before the sentinel
|
|
91
|
-
// existed still decode. We deliberately do NOT treat any object that
|
|
92
|
-
// merely *contains* `id` and `path` as a reference, because that
|
|
93
|
-
// silently rewrites ordinary embedded sub-documents.
|
|
94
|
-
if (typeof value === "object") {
|
|
95
|
-
const keys = Object.keys(value);
|
|
96
|
-
const isTagged = value.__type === "reference" && "id" in value && "path" in value;
|
|
97
|
-
const isLegacy = keys.length === 2 && keys.includes("id") && keys.includes("path");
|
|
98
|
-
if (isTagged || isLegacy) {
|
|
99
|
-
return new EntityReference({
|
|
100
|
-
id: value.id instanceof ObjectId ? value.id.toString() : String(value.id),
|
|
101
|
-
path: value.path,
|
|
102
|
-
driver: value.driver,
|
|
103
|
-
databaseId: value.databaseId
|
|
104
|
-
});
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// Handle nested objects
|
|
109
|
-
if (typeof value === "object") {
|
|
110
|
-
return this.convertFromMongoValues(value);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
return value;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Convert values to MongoDB format for storage
|
|
118
|
-
*/
|
|
119
|
-
private convertToMongoValues(values: Record<string, any>): Record<string, any> {
|
|
120
|
-
const result: Record<string, any> = {};
|
|
121
|
-
|
|
122
|
-
for (const [key, value] of Object.entries(values)) {
|
|
123
|
-
result[key] = this.convertToMongoValue(value);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
return result;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Convert a single value to MongoDB format
|
|
131
|
-
*/
|
|
132
|
-
private convertToMongoValue(value: any): any {
|
|
133
|
-
if (value === null || value === undefined) return value;
|
|
134
|
-
|
|
135
|
-
// Handle EntityReference. Persist with a `__type: "reference"` sentinel
|
|
136
|
-
// so it round-trips unambiguously, and preserve `driver`/`databaseId`
|
|
137
|
-
// so cross-datasource pointers don't lose their target on write.
|
|
138
|
-
if (typeof value === "object" && value.isEntityReference?.()) {
|
|
139
|
-
const ref: Record<string, unknown> = {
|
|
140
|
-
__type: "reference",
|
|
141
|
-
id: ObjectId.isValid(value.id) ? new ObjectId(value.id) : value.id,
|
|
142
|
-
path: value.path
|
|
143
|
-
};
|
|
144
|
-
if (value.driver !== undefined) ref.driver = value.driver;
|
|
145
|
-
if (value.databaseId !== undefined) ref.databaseId = value.databaseId;
|
|
146
|
-
return ref;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// Handle Date
|
|
150
|
-
if (value instanceof Date) {
|
|
151
|
-
return value;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// Handle arrays
|
|
155
|
-
if (Array.isArray(value)) {
|
|
156
|
-
return value.map(v => this.convertToMongoValue(v));
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// Handle nested objects
|
|
160
|
-
if (typeof value === "object") {
|
|
161
|
-
return this.convertToMongoValues(value);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
return value;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// =============================================================
|
|
168
|
-
// DataRepository Implementation
|
|
169
|
-
// =============================================================
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Fetch a single row by ID
|
|
173
|
-
*/
|
|
174
|
-
async fetchOne<M extends Record<string, any>>(
|
|
175
|
-
collectionPath: string,
|
|
176
|
-
id: string | number,
|
|
177
|
-
_databaseId?: string
|
|
178
|
-
): Promise<Record<string, unknown> | undefined> {
|
|
179
|
-
const collection = this.getCollection(collectionPath);
|
|
180
|
-
const objectId = this.toObjectId(id);
|
|
181
|
-
|
|
182
|
-
const doc = await collection.findOne({ _id: objectId } as Filter<Document>);
|
|
183
|
-
|
|
184
|
-
if (!doc) return undefined;
|
|
185
|
-
|
|
186
|
-
return this.documentToRow(doc);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* Fetch a collection of rows with optional filtering, ordering, and pagination
|
|
191
|
-
*/
|
|
192
|
-
async fetchCollection<M extends Record<string, any>>(
|
|
193
|
-
collectionPath: string,
|
|
194
|
-
options: {
|
|
195
|
-
filter?: FilterValues<Extract<keyof M, string>>;
|
|
196
|
-
/** An `or(...)`/`and(...)` group, AND-ed with `filter`. */
|
|
197
|
-
logical?: LogicalCondition;
|
|
198
|
-
orderBy?: string | OrderByTuple[];
|
|
199
|
-
order?: "desc" | "asc";
|
|
200
|
-
limit?: number;
|
|
201
|
-
offset?: number;
|
|
202
|
-
startAfter?: any;
|
|
203
|
-
searchString?: string;
|
|
204
|
-
databaseId?: string;
|
|
205
|
-
collection?: CollectionConfig;
|
|
206
|
-
rawQuery?: Filter<Document>;
|
|
207
|
-
} = {}
|
|
208
|
-
): Promise<Record<string, unknown>[]> {
|
|
209
|
-
const collection = this.getCollection(collectionPath);
|
|
210
|
-
|
|
211
|
-
// Build query
|
|
212
|
-
const query = options.rawQuery ?? MongoConditionBuilder.buildQuery<M>({
|
|
213
|
-
filter: options.filter,
|
|
214
|
-
logical: options.logical,
|
|
215
|
-
searchString: options.searchString,
|
|
216
|
-
properties: options.collection?.properties ?? {}
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
// Build find options
|
|
220
|
-
const findOptions: FindOptions = {};
|
|
221
|
-
|
|
222
|
-
// Apply sorting
|
|
223
|
-
const sort = MongoConditionBuilder.buildSort(options.orderBy, options.order);
|
|
224
|
-
if (sort) {
|
|
225
|
-
findOptions.sort = sort;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// Apply limit
|
|
229
|
-
if (options.limit) {
|
|
230
|
-
findOptions.limit = options.limit;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// Apply pagination. `offset` is the parameter the REST layer and the
|
|
234
|
-
// SDK both speak; it was absent here, so every `?offset=` reaching this
|
|
235
|
-
// driver was discarded and page three served page one. `startAfter`
|
|
236
|
-
// stays as the older spelling and wins when both are given.
|
|
237
|
-
if (options.startAfter !== undefined) {
|
|
238
|
-
findOptions.skip = Number(options.startAfter);
|
|
239
|
-
} else if (options.offset) {
|
|
240
|
-
findOptions.skip = options.offset;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
const docs = await collection.find(query, findOptions).toArray();
|
|
244
|
-
|
|
245
|
-
return docs.map((doc: Document) => this.documentToRow(doc));
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
/**
|
|
249
|
-
* Search rows by text
|
|
250
|
-
*/
|
|
251
|
-
async searchRows<M extends Record<string, any>>(
|
|
252
|
-
collectionPath: string,
|
|
253
|
-
searchString: string,
|
|
254
|
-
options: {
|
|
255
|
-
filter?: FilterValues<Extract<keyof M, string>>;
|
|
256
|
-
orderBy?: string | OrderByTuple[];
|
|
257
|
-
order?: "desc" | "asc";
|
|
258
|
-
limit?: number;
|
|
259
|
-
databaseId?: string;
|
|
260
|
-
collection?: CollectionConfig;
|
|
261
|
-
rawQuery?: Filter<Document>;
|
|
262
|
-
} = {}
|
|
263
|
-
): Promise<Record<string, unknown>[]> {
|
|
264
|
-
return this.fetchCollection<M>(collectionPath, {
|
|
265
|
-
...options,
|
|
266
|
-
searchString
|
|
267
|
-
});
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
/**
|
|
271
|
-
* Count rows in a collection
|
|
272
|
-
*/
|
|
273
|
-
async count<M extends Record<string, any>>(
|
|
274
|
-
collectionPath: string,
|
|
275
|
-
options: {
|
|
276
|
-
filter?: FilterValues<Extract<keyof M, string>>;
|
|
277
|
-
/** An `or(...)`/`and(...)` group, AND-ed with `filter`. */
|
|
278
|
-
logical?: LogicalCondition;
|
|
279
|
-
searchString?: string;
|
|
280
|
-
collection?: CollectionConfig;
|
|
281
|
-
databaseId?: string;
|
|
282
|
-
rawQuery?: Filter<Document>;
|
|
283
|
-
} = {}
|
|
284
|
-
): Promise<number> {
|
|
285
|
-
const collection = this.getCollection(collectionPath);
|
|
286
|
-
|
|
287
|
-
// Every narrowing the listing applies has to apply here too, or the
|
|
288
|
-
// count describes a different query than the one it is reported
|
|
289
|
-
// against. `logical` and `searchString` were both missing.
|
|
290
|
-
const query = options.rawQuery ?? MongoConditionBuilder.buildQuery<M>({
|
|
291
|
-
filter: options.filter,
|
|
292
|
-
logical: options.logical,
|
|
293
|
-
searchString: options.searchString,
|
|
294
|
-
properties: options.collection?.properties ?? {}
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
return collection.countDocuments(query);
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
/**
|
|
301
|
-
* Save an row (create or update)
|
|
302
|
-
*
|
|
303
|
-
* Returns the **stored** document, not the values that were sent. A partial
|
|
304
|
-
* update sends only the fields that changed, and returning those was a
|
|
305
|
-
* partial row everywhere it went: the REST response, `afterSave`, the
|
|
306
|
-
* history entry a revert restores from, and the row pushed to realtime
|
|
307
|
-
* subscribers. Postgres returns the whole row here (`RETURNING *`).
|
|
308
|
-
*/
|
|
309
|
-
async save<M extends Record<string, any>>(
|
|
310
|
-
collectionPath: string,
|
|
311
|
-
values: Partial<M>,
|
|
312
|
-
id?: string | number,
|
|
313
|
-
_databaseId?: string
|
|
314
|
-
): Promise<Record<string, unknown>> {
|
|
315
|
-
const collection = this.getCollection(collectionPath);
|
|
316
|
-
const mongoValues = this.convertToMongoValues(values as Record<string, any>);
|
|
317
|
-
|
|
318
|
-
if (id) {
|
|
319
|
-
// Still an upsert: this is also the call that creates a row with a
|
|
320
|
-
// client-chosen id. Addressing an id that does not exist is caught
|
|
321
|
-
// above — the REST `PUT` 404s and the authenticated driver refuses
|
|
322
|
-
// — so the upsert only ever lands as the create it is meant to be.
|
|
323
|
-
const objectId = this.toObjectId(id);
|
|
324
|
-
await collection.updateOne(
|
|
325
|
-
{ _id: objectId } as Filter<Document>,
|
|
326
|
-
{ $set: mongoValues },
|
|
327
|
-
{ upsert: true }
|
|
328
|
-
);
|
|
329
|
-
|
|
330
|
-
return await this.readBack(collectionPath, objectId, { ...values,
|
|
331
|
-
id: id.toString() });
|
|
332
|
-
} else {
|
|
333
|
-
// Create new row
|
|
334
|
-
const newId = new ObjectId();
|
|
335
|
-
await collection.insertOne({
|
|
336
|
-
_id: newId,
|
|
337
|
-
...mongoValues
|
|
338
|
-
});
|
|
339
|
-
|
|
340
|
-
return await this.readBack(collectionPath, newId, { ...values,
|
|
341
|
-
id: newId.toString() });
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
/**
|
|
346
|
-
* Read the document back after a write, falling back to the caller's own
|
|
347
|
-
* values if it has already been removed by a concurrent delete.
|
|
348
|
-
*/
|
|
349
|
-
private async readBack(
|
|
350
|
-
collectionPath: string,
|
|
351
|
-
objectId: ObjectId | string | number,
|
|
352
|
-
fallback: Record<string, unknown>
|
|
353
|
-
): Promise<Record<string, unknown>> {
|
|
354
|
-
const doc = await this.getCollection(collectionPath).findOne({ _id: objectId } as Filter<Document>);
|
|
355
|
-
return doc ? this.documentToRow(doc) : fallback;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
/**
|
|
359
|
-
* Delete a row by ID.
|
|
360
|
-
*
|
|
361
|
-
* Rejects when nothing matched, which is the `DataDriver.delete` contract:
|
|
362
|
-
* resolving means the row is gone because this call removed it. This used
|
|
363
|
-
* to log a warning and resolve, so a caller could not tell a delete from a
|
|
364
|
-
* no-op — and the same call on the Postgres driver threw. The wording is
|
|
365
|
-
* the Postgres driver's, verbatim, because two spellings of one answer is
|
|
366
|
-
* how the two came apart in the first place.
|
|
367
|
-
*/
|
|
368
|
-
async delete(
|
|
369
|
-
collectionPath: string,
|
|
370
|
-
id: string | number,
|
|
371
|
-
_databaseId?: string
|
|
372
|
-
): Promise<void> {
|
|
373
|
-
const collection = this.getCollection(collectionPath);
|
|
374
|
-
const objectId = this.toObjectId(id);
|
|
375
|
-
|
|
376
|
-
const result = await collection.deleteOne({ _id: objectId } as Filter<Document>);
|
|
377
|
-
|
|
378
|
-
if (result.deletedCount === 0) {
|
|
379
|
-
throw ApiError.notFound(`No row "${id}" in "${collectionPath}" to delete.`);
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
/**
|
|
384
|
-
* Check if a field value is unique in a collection
|
|
385
|
-
*/
|
|
386
|
-
async checkUniqueField(
|
|
387
|
-
collectionPath: string,
|
|
388
|
-
fieldName: string,
|
|
389
|
-
value: any,
|
|
390
|
-
excludeEntityId?: string,
|
|
391
|
-
_databaseId?: string
|
|
392
|
-
): Promise<boolean> {
|
|
393
|
-
const collection = this.getCollection(collectionPath);
|
|
394
|
-
|
|
395
|
-
const query: Filter<Document> = { [fieldName]: value };
|
|
396
|
-
|
|
397
|
-
if (excludeEntityId) {
|
|
398
|
-
const objectId = this.toObjectId(excludeEntityId);
|
|
399
|
-
(query as Record<string, unknown>)._id = { $ne: objectId };
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
const count = await collection.countDocuments(query);
|
|
403
|
-
return count === 0;
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
/**
|
|
407
|
-
* Generate a new row ID
|
|
408
|
-
*/
|
|
409
|
-
generateId(): string {
|
|
410
|
-
return new ObjectId().toString();
|
|
411
|
-
}
|
|
412
|
-
}
|