@objy/server 0.0.0-stage → 0.1.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.
Files changed (55) hide show
  1. package/API.md +1469 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE.md +9 -0
  4. package/README.md +21 -2
  5. package/objydb.config.json +48 -0
  6. package/package.json +42 -4
  7. package/public/index.html +4134 -0
  8. package/public/objy-db-logo.png +0 -0
  9. package/src/aggregate.js +438 -0
  10. package/src/api.js +15 -0
  11. package/src/auth.js +472 -0
  12. package/src/clock.js +18 -0
  13. package/src/cluster-secret.js +25 -0
  14. package/src/cluster-status.js +562 -0
  15. package/src/compression-stats.js +32 -0
  16. package/src/config.js +360 -0
  17. package/src/crdt.js +1076 -0
  18. package/src/derived-state-scheduler.js +128 -0
  19. package/src/files.js +324 -0
  20. package/src/hnsw.js +269 -0
  21. package/src/http-cors.js +31 -0
  22. package/src/http-router.js +162 -0
  23. package/src/index.js +137 -0
  24. package/src/interfaces/auth.js +123 -0
  25. package/src/interfaces/cluster.js +296 -0
  26. package/src/interfaces/public-object.js +2452 -0
  27. package/src/interfaces/ui.js +38 -0
  28. package/src/lib/crypto.cjs +54 -0
  29. package/src/lib/jwt.cjs +231 -0
  30. package/src/local-wal.js +122 -0
  31. package/src/memory-guard.js +152 -0
  32. package/src/ndjson.js +117 -0
  33. package/src/node-runtime.js +29 -0
  34. package/src/object-hooks.js +282 -0
  35. package/src/object-id.js +7 -0
  36. package/src/object-storage.js +263 -0
  37. package/src/object-store.js +288 -0
  38. package/src/patch.js +87 -0
  39. package/src/permissions.js +178 -0
  40. package/src/persistence.js +1918 -0
  41. package/src/plugin-loader.js +21 -0
  42. package/src/query-planner.js +266 -0
  43. package/src/query.js +416 -0
  44. package/src/references.js +349 -0
  45. package/src/replication-scheduler.js +303 -0
  46. package/src/restore-status.js +77 -0
  47. package/src/runtime.js +47 -0
  48. package/src/server.js +242 -0
  49. package/src/shape.js +286 -0
  50. package/src/shard-map-store.js +85 -0
  51. package/src/shard-map.js +333 -0
  52. package/src/shard-router.js +737 -0
  53. package/src/store.js +3410 -0
  54. package/src/system-secrets.js +107 -0
  55. package/src/vector.js +83 -0
@@ -0,0 +1,2452 @@
1
+ import { GLOBAL_DATABASE, USERS_COLLECTION } from "../auth.js";
2
+ import { aggregateRows } from "../aggregate.js";
3
+ import { collectClusterStatus } from "../cluster-status.js";
4
+ import { isNdjsonRequest, json, readJson, readNdjson, remoteHttpError } from "../http-router.js";
5
+ import {
6
+ buildMultipartObjectFile,
7
+ isMultipartRequest,
8
+ readMultipartObjectFile,
9
+ writeBlobResponse,
10
+ } from "../files.js";
11
+ import { createObjectId } from "../object-id.js";
12
+ import { applyPatchToObject, patchUnsetsPath } from "../patch.js";
13
+ import {
14
+ canCreate,
15
+ canDelete,
16
+ canRead,
17
+ canUpdate,
18
+ isPermissionBypass,
19
+ mergeReadPermissionQuery,
20
+ permissionError,
21
+ } from "../permissions.js";
22
+ import { DEFAULT_IMPORT_CONFIG } from "../config.js";
23
+ import {
24
+ firstQueryInteger,
25
+ matchesQuery,
26
+ parseQuerySort,
27
+ queryPageOffset,
28
+ splitQueryControls,
29
+ } from "../query.js";
30
+ import { QUERY_PATH_HEADER } from "../query-planner.js";
31
+ import {
32
+ baseCriteriaForReferenceQuery,
33
+ expandReferences,
34
+ normalizeRefDepth,
35
+ paginateReferencePairs,
36
+ pathHasReferenceObject,
37
+ prepareReferenceValue,
38
+ referenceDepthForAggregateSpec,
39
+ referenceDepthForQuery,
40
+ referenceReadPlan,
41
+ sortReferencePairs,
42
+ stripReferenceObjects,
43
+ } from "../references.js";
44
+ import { objectPath, ok } from "../shard-router.js";
45
+ import { isValidVector } from "../vector.js";
46
+
47
+ const DEFAULT_PUBLIC_QUERY_LIMIT = 250;
48
+ const MAX_PUBLIC_QUERY_LIMIT = 250;
49
+ const READ_ONLY_DATABASES = new Set([GLOBAL_DATABASE]);
50
+ const NDJSON_MINIMAL_REQUIRED = "NDJSON write streams require Prefer: return=minimal";
51
+
52
+ export function createPublicObjectInterface() {
53
+ return {
54
+ name: "public-object",
55
+ register(router, { node, auth, routing, durability, memoryGuard, restoreStatus, replicationScheduler, files, imports, objectHooks }) {
56
+ const secure = secureRoute(auth);
57
+ const importLimits = normalizeImportLimits(imports);
58
+
59
+ router.get("/api/databases", secure((req, res) => {
60
+ const databases = node.listDatabases().sort();
61
+ if (auth.enabled && !isGlobalAdminRequest(req, auth)) {
62
+ return json(res, 200, { databases: databases.filter((database) => database === req.auth?.claims?.database) });
63
+ }
64
+ json(res, 200, { databases });
65
+ }));
66
+
67
+ router.get("/api/config", secure(async (req, res) => {
68
+ if (rejectUnlessGlobalAdmin(req, res, auth)) return;
69
+ json(res, 200, await collectClusterStatus(node, {
70
+ memoryGuard,
71
+ replicationScheduler,
72
+ restoreStatus,
73
+ details: req.query.get("details") === "1",
74
+ }));
75
+ }));
76
+
77
+ router.post("/api/databases", secure(async (req, res) => {
78
+ if (rejectUnlessGlobalAdmin(req, res, auth)) return;
79
+ const body = await readJson(req);
80
+ const name = body?.name;
81
+ if (typeof name !== "string" || name.length === 0) {
82
+ return json(res, 400, { error: "body.name must be a non-empty string" });
83
+ }
84
+ if (rejectIfReadOnlyDatabaseWrite(res, name)) return;
85
+ if (rejectIfMemoryGuardBlocked(res, memoryGuard)) return;
86
+ if (body.meta !== undefined && !isPlainObject(body.meta)) {
87
+ return json(res, 400, { error: "body.meta must be a plain object" });
88
+ }
89
+ let adminUser;
90
+ try {
91
+ adminUser = routing.isForwarded(req) ? null : parseDatabaseAdminUser(body);
92
+ } catch (err) {
93
+ return json(res, err.status || 400, err.body || errorBody(err), err.headers || {});
94
+ }
95
+ const created = node.getDatabaseMeta(name) === null;
96
+ try {
97
+ const meta = node.createDatabase(name, body.meta || {});
98
+ let adminResult = null;
99
+ let adminCollectionMeta = null;
100
+ if (adminUser) {
101
+ adminCollectionMeta = ensureAdminUserCollection(node, name, adminUser.collection);
102
+ }
103
+ await afterLocalWrite(durability);
104
+ if (!routing.isForwarded(req)) {
105
+ await forwardSchemaChange(routing, req, "/api/databases", {
106
+ method: "POST",
107
+ body: databaseCreateFanoutBody(name, body),
108
+ }, "database metadata fan-out failed");
109
+ if (adminUser) {
110
+ await forwardSchemaChange(
111
+ routing,
112
+ req,
113
+ `/api/${encodeURIComponent(name)}/collections`,
114
+ {
115
+ method: "POST",
116
+ body: { name: adminUser.collection, meta: adminCollectionMeta },
117
+ },
118
+ "admin users collection metadata fan-out failed",
119
+ );
120
+ }
121
+ }
122
+ if (adminUser) {
123
+ await assertCanCreateDatabaseAdmin({ req, routing, database: name, adminUser });
124
+ const written = await routedWriteMany({
125
+ req,
126
+ routing,
127
+ auth,
128
+ node,
129
+ database: name,
130
+ collection: adminUser.collection,
131
+ objects: [adminUser.user],
132
+ method: "POST",
133
+ resultKey: "inserted",
134
+ partial: false,
135
+ localWrite: (items) => {
136
+ const rows = node.insertMany(name, adminUser.collection, items);
137
+ return { inserted: rows.length, items: rows };
138
+ },
139
+ durability,
140
+ memoryGuard,
141
+ imports: importLimits,
142
+ });
143
+ adminResult = {
144
+ collection: adminUser.collection,
145
+ user: auth.redactObject(name, adminUser.collection, written.items[0]),
146
+ };
147
+ }
148
+ json(res, created ? 201 : 200, {
149
+ name,
150
+ created,
151
+ meta,
152
+ ...(adminResult ? { adminUser: adminResult } : {}),
153
+ });
154
+ } catch (err) {
155
+ json(res, err.status || 400, err.body || errorBody(err), err.headers || {});
156
+ }
157
+ }));
158
+
159
+ router.delete("/api/databases/:database", secure(async (req, res) => {
160
+ if (rejectUnlessGlobalAdmin(req, res, auth)) return;
161
+ if (rejectIfReadOnlyDatabaseWrite(res, req.params.database)) return;
162
+ try {
163
+ const existed = node.getDatabaseMeta(req.params.database) !== null;
164
+ const alreadyDeleted = node.db.isDatabaseDeleted(req.params.database);
165
+ if (!existed && !alreadyDeleted && !routing.isForwarded(req)) {
166
+ return json(res, 404, { error: "database not found" });
167
+ }
168
+ const removed = node.removeDatabase(req.params.database);
169
+ if (removed || !alreadyDeleted) await afterLocalWrite(durability);
170
+ if (removed) await deleteDatabaseFilesBestEffort(files, req.params.database);
171
+ if (!routing.isForwarded(req)) {
172
+ await forwardSchemaChange(
173
+ routing,
174
+ req,
175
+ `/api/databases/${encodeURIComponent(req.params.database)}`,
176
+ { method: "DELETE", allowUnavailable: false },
177
+ "database delete fan-out failed",
178
+ );
179
+ }
180
+ json(res, 204);
181
+ } catch (err) {
182
+ json(res, err.status || 400, err.body || errorBody(err), err.headers || {});
183
+ }
184
+ }));
185
+
186
+ router.get("/api/:database/collections", secure((req, res) => {
187
+ if (rejectUnlessDatabaseAdmin(req, res, auth)) return;
188
+ json(res, 200, {
189
+ database: req.params.database,
190
+ collections: node.listCollections(req.params.database).sort(),
191
+ });
192
+ }));
193
+
194
+ router.post("/api/:database/collections", secure(async (req, res) => {
195
+ if (rejectIfReadOnlyDatabaseWrite(res, req.params.database)) return;
196
+ if (rejectUnlessDatabaseAdmin(req, res, auth)) return;
197
+ if (rejectIfMemoryGuardBlocked(res, memoryGuard)) return;
198
+ const body = await readJson(req);
199
+ const name = body?.name;
200
+ if (typeof name !== "string" || name.length === 0) {
201
+ return json(res, 400, { error: "body.name must be a non-empty string" });
202
+ }
203
+ const created = node.getCollectionMeta(req.params.database, name) === null;
204
+ try {
205
+ const collectionMeta = collectionMetaFromBody(body);
206
+ objectHooks.validateMeta(collectionMeta);
207
+ const meta = node.createCollection(req.params.database, name, collectionMeta);
208
+ await afterLocalWrite(durability);
209
+ if (!routing.isForwarded(req)) {
210
+ await forwardSchemaChange(
211
+ routing,
212
+ req,
213
+ `/api/${encodeURIComponent(req.params.database)}/collections`,
214
+ { method: "POST", body },
215
+ "collection metadata fan-out failed",
216
+ );
217
+ }
218
+ json(res, created ? 201 : 200, { name, created, meta });
219
+ } catch (err) {
220
+ json(res, err.status || 400, err.body || errorBody(err), err.headers || {});
221
+ }
222
+ }));
223
+
224
+ router.delete("/api/:database/collections/:collection", secure(async (req, res) => {
225
+ if (rejectIfReadOnlyDatabaseWrite(res, req.params.database)) return;
226
+ if (rejectUnlessDatabaseAdmin(req, res, auth)) return;
227
+ try {
228
+ const existed = node.getCollectionMeta(req.params.database, req.params.collection) !== null;
229
+ const alreadyDeleted = node.db.isCollectionDeleted(req.params.database, req.params.collection);
230
+ if (!existed && !alreadyDeleted && !routing.isForwarded(req)) {
231
+ return json(res, 404, { error: "collection not found" });
232
+ }
233
+ const removed = node.removeCollection(req.params.database, req.params.collection);
234
+ if (removed || !alreadyDeleted) await afterLocalWrite(durability);
235
+ if (removed) await deleteCollectionFilesBestEffort(files, req.params.database, req.params.collection);
236
+ if (!routing.isForwarded(req)) {
237
+ await forwardSchemaChange(
238
+ routing,
239
+ req,
240
+ `/api/${encodeURIComponent(req.params.database)}/collections/${encodeURIComponent(req.params.collection)}`,
241
+ { method: "DELETE", allowUnavailable: false },
242
+ "collection delete fan-out failed",
243
+ );
244
+ }
245
+ json(res, 204);
246
+ } catch (err) {
247
+ json(res, err.status || 400, err.body || errorBody(err), err.headers || {});
248
+ }
249
+ }));
250
+
251
+ router.get("/api/:database/:collection/meta", secure((req, res) => {
252
+ if (rejectUnlessDatabaseAdmin(req, res, auth)) return;
253
+ const meta = node.getCollectionMeta(req.params.database, req.params.collection);
254
+ if (!meta) {
255
+ json(res, 404, { error: "collection not found" });
256
+ return;
257
+ }
258
+ json(res, 200, meta);
259
+ }));
260
+
261
+ router.put("/api/:database/:collection/meta", secure(async (req, res) => {
262
+ if (rejectIfReadOnlyDatabaseWrite(res, req.params.database)) return;
263
+ if (rejectUnlessDatabaseAdmin(req, res, auth)) return;
264
+ if (rejectIfMemoryGuardBlocked(res, memoryGuard)) return;
265
+ const body = await readJson(req);
266
+ if (!isPlainObject(body)) return json(res, 400, { error: "collection meta must be a plain object" });
267
+ try {
268
+ objectHooks.validateMeta(body);
269
+ const meta = node.setCollectionMeta(req.params.database, req.params.collection, body);
270
+ await afterLocalWrite(durability);
271
+ if (!routing.isForwarded(req)) {
272
+ await forwardSchemaChange(
273
+ routing,
274
+ req,
275
+ `/api/${encodeURIComponent(req.params.database)}/${encodeURIComponent(req.params.collection)}/meta`,
276
+ { method: "PUT", body },
277
+ "collection metadata fan-out failed",
278
+ );
279
+ }
280
+ json(res, 200, meta);
281
+ } catch (err) {
282
+ json(res, err.status || 400, err.body || errorBody(err), err.headers || {});
283
+ }
284
+ }));
285
+
286
+ router.post("/api/:database/:collection/search", secure(async (req, res) => {
287
+ const query = await readJson(req);
288
+ if (!isPlainObject(query)) return json(res, 400, { error: "search body must be a JSON object" });
289
+ try {
290
+ const result = await runObjectOperation({
291
+ req, node, routing, auth, objectHooks, operation: "read", variant: "query", input: { query },
292
+ }, ({ query: hookedQuery }) => {
293
+ if (!isPlainObject(hookedQuery)) throw httpError(400, "search body must be a JSON object");
294
+ return executeCollectionQuery(req, { node, routing, query: hookedQuery });
295
+ });
296
+ jsonWithQueryPath(
297
+ res,
298
+ 200,
299
+ publicQueryResult(req, auth, req.params.database, req.params.collection, result),
300
+ result.path,
301
+ );
302
+ } catch (err) {
303
+ json(res, err.status || 400, err.body || errorBody(err), err.headers || {});
304
+ }
305
+ }));
306
+
307
+ router.get("/api/:database/:collection/object", secure(async (req, res) => {
308
+ let query;
309
+ try {
310
+ query = parseQueryParam(req);
311
+ } catch (err) {
312
+ json(res, 400, { error: err.message });
313
+ return;
314
+ }
315
+ try {
316
+ const result = await runObjectOperation({
317
+ req, node, routing, auth, objectHooks, operation: "read", variant: "query", input: { query },
318
+ }, ({ query: hookedQuery }) => {
319
+ if (!isPlainObject(hookedQuery)) throw httpError(400, "q must be a JSON object");
320
+ return executeCollectionQuery(req, { node, routing, query: hookedQuery });
321
+ });
322
+ jsonWithQueryPath(
323
+ res,
324
+ 200,
325
+ publicQueryResult(req, auth, req.params.database, req.params.collection, result),
326
+ result.path,
327
+ );
328
+ } catch (err) {
329
+ json(res, err.status || 400, err.body || errorBody(err), err.headers || {});
330
+ }
331
+ }));
332
+
333
+ router.get("/api/:database/:collection/object/:id", secure(async (req, res) => {
334
+ const refDepth = refDepthParam(req);
335
+ try {
336
+ const result = await runObjectOperation({
337
+ req,
338
+ node,
339
+ routing,
340
+ objectHooks,
341
+ operation: "read",
342
+ variant: "single",
343
+ auth,
344
+ input: { ids: [req.params.id], options: { refDepth } },
345
+ }, async () => {
346
+ if (!routing.shouldHandleObjectLocally(req, req.params.database, req.params.collection, req.params.id)) {
347
+ const remote = await routing.forwardToObject(
348
+ req,
349
+ req.params.database,
350
+ req.params.collection,
351
+ req.params.id,
352
+ objectPath(req.params.database, req.params.collection, req.params.id),
353
+ { method: "GET", query: refDepthQuery(0), retryStatuses: [404, 409, 503] },
354
+ );
355
+ if (!ok(remote.status)) return { status: remote.status, body: remote.body, headers: remote.headers };
356
+ const object = await expandObjectForRead({
357
+ req,
358
+ node,
359
+ routing,
360
+ database: req.params.database,
361
+ object: stripReferenceObjects(remote.body),
362
+ refDepth,
363
+ });
364
+ return { status: 200, body: object };
365
+ }
366
+
367
+ const object = node.get(req.params.database, req.params.collection, req.params.id);
368
+ if (!object) return { status: 404, body: { error: "not found" } };
369
+ assertReadAllowed(req, object);
370
+ return {
371
+ status: 200,
372
+ body: await expandObjectForRead({
373
+ req,
374
+ node,
375
+ routing,
376
+ database: req.params.database,
377
+ object,
378
+ refDepth,
379
+ }),
380
+ };
381
+ });
382
+ if (result.status !== 200) return json(res, result.status, result.body, result.headers || {});
383
+ assertReadAllowed(req, result.body);
384
+ json(res, 200, auth.redactObject(req.params.database, req.params.collection, result.body));
385
+ } catch (err) {
386
+ return json(res, err.status || 400, err.body || errorBody(err), err.headers || {});
387
+ }
388
+ }));
389
+
390
+ router.get("/api/:database/:collection/object/:id/blob", secure(async (req, res) => {
391
+ if (!files) return json(res, 404, { error: "file storage is not configured" });
392
+ try {
393
+ const result = await runObjectOperation({
394
+ req,
395
+ node,
396
+ routing,
397
+ objectHooks,
398
+ operation: "read",
399
+ variant: "blob",
400
+ auth,
401
+ input: { ids: [req.params.id] },
402
+ }, async () => {
403
+ const object = await readableObjectForFile({ req, node, routing });
404
+ if (!object) return { status: 404, body: { error: "not found" } };
405
+ if (!object._file) return { status: 404, body: { error: "object has no file" } };
406
+ const blob = await files.get(req.params.database, req.params.collection, req.params.id, object._file);
407
+ if (!blob) return { status: 404, body: { error: "file not found" } };
408
+ return { status: 200, blob, mimeType: object._file.mimeType };
409
+ });
410
+ if (result.status !== 200) return json(res, result.status, result.body);
411
+ await writeBlobResponse(res, result.blob, result.mimeType);
412
+ } catch (err) {
413
+ json(res, err.status || 400, err.body || errorBody(err), err.headers || {});
414
+ }
415
+ }));
416
+
417
+ router.post("/api/:database/:collection/object", secure(async (req, res) => {
418
+ if (rejectIfReadOnlyDatabaseWrite(res, req.params.database)) return;
419
+ if (isMultipartRequest(req)) {
420
+ return multipartCreateObject({ req, res, node, auth, routing, durability, memoryGuard, files, imports: importLimits, objectHooks });
421
+ }
422
+ if (isNdjsonRequest(req)) {
423
+ return streamCreateObjects({ req, res, node, auth, routing, durability, memoryGuard, files, imports: importLimits, objectHooks });
424
+ }
425
+ const body = await readJson(req);
426
+ const isBulk = Array.isArray(body);
427
+ const rawItems = isBulk ? body : [body || {}];
428
+ try {
429
+ const items = prepareCreateObjects(files, req.params.database, req.params.collection, rawItems);
430
+ const minimalBulkReturn = isBulk && prefersMinimalReturn(req);
431
+ const result = await runObjectOperation({
432
+ req, node, routing, auth, objectHooks, operation: "create", variant: isBulk ? "bulk" : "single", input: { objects: items },
433
+ }, ({ objects }, context) => routedCreateMany({
434
+ req,
435
+ routing,
436
+ auth,
437
+ node,
438
+ database: req.params.database,
439
+ collection: req.params.collection,
440
+ objects: context ? prepareCreateObjects(files, req.params.database, req.params.collection, objects) : objects,
441
+ durability,
442
+ memoryGuard,
443
+ imports: importLimits,
444
+ minimalReturn: minimalBulkReturn,
445
+ }));
446
+ if (minimalBulkReturn && !Object.hasOwn(result, "responseOverride")) {
447
+ return json(res, 201, { inserted: result.inserted });
448
+ }
449
+ const safeRows = auth.redactObjects(req.params.database, req.params.collection, result.items);
450
+ json(res, 201, writeResponse(req, auth, result,
451
+ isBulk ? { inserted: safeRows.length, items: safeRows } : safeRows[0]));
452
+ } catch (err) {
453
+ return json(res, err.status || 400, err.body || errorBody(err), err.headers || {});
454
+ }
455
+ }));
456
+
457
+ router.put("/api/:database/:collection/object", secure(async (req, res) => {
458
+ if (rejectIfReadOnlyDatabaseWrite(res, req.params.database)) return;
459
+ if (isNdjsonRequest(req)) {
460
+ return streamReplaceObjects({ req, res, node, auth, routing, durability, memoryGuard, files, imports: importLimits, objectHooks });
461
+ }
462
+ const body = await readJson(req);
463
+ if (!Array.isArray(body)) return json(res, 400, { error: "body must be an array of objects with _id" });
464
+ const error = validatePlainObjects(body, { requireId: true });
465
+ if (error) return json(res, 400, error);
466
+ try {
467
+ const objects = normalizeFileObjects(files, req.params.database, req.params.collection, body);
468
+ const result = await runObjectOperation({
469
+ req, node, routing, auth, objectHooks, operation: "update", variant: "bulk-replace", input: { objects },
470
+ }, ({ objects: hookedObjects }, context) => routedWriteMany({
471
+ req,
472
+ routing,
473
+ auth,
474
+ node,
475
+ database: req.params.database,
476
+ collection: req.params.collection,
477
+ objects: context
478
+ ? validateAndNormalizeObjects(files, req.params.database, req.params.collection, hookedObjects)
479
+ : hookedObjects,
480
+ method: "PUT",
481
+ resultKey: "replaced",
482
+ partial: false,
483
+ localWrite: (prepared) => node.replaceMany(req.params.database, req.params.collection, prepared),
484
+ durability,
485
+ memoryGuard,
486
+ files,
487
+ imports: importLimits,
488
+ localWriteCount: (prepared) => node.replaceManyCount(req.params.database, req.params.collection, prepared),
489
+ minimalReturn: prefersMinimalReturn(req),
490
+ }));
491
+ json(res, 200, writeResponse(req, auth, result, result.items
492
+ ? auth.redactResult(req.params.database, req.params.collection, result)
493
+ : result));
494
+ } catch (err) {
495
+ json(res, err.status || 400, err.body || errorBody(err), err.headers || {});
496
+ }
497
+ }));
498
+
499
+ router.patch("/api/:database/:collection/object", secure(async (req, res) => {
500
+ if (rejectIfReadOnlyDatabaseWrite(res, req.params.database)) return;
501
+ if (isNdjsonRequest(req)) {
502
+ return streamPatchObjects({ req, res, node, auth, routing, durability, memoryGuard, files, imports: importLimits, objectHooks });
503
+ }
504
+ const body = await readJson(req);
505
+ if (!Array.isArray(body)) return json(res, 400, { error: "body must be an array of objects with _id" });
506
+ const error = validatePlainObjects(body, { requireId: true });
507
+ if (error) return json(res, 400, error);
508
+ try {
509
+ const result = await runObjectOperation({
510
+ req, node, routing, auth, objectHooks, operation: "update", variant: "bulk-patch", input: { patches: body },
511
+ }, ({ patches }, context) => routedPatchMany({
512
+ req,
513
+ routing,
514
+ auth,
515
+ node,
516
+ database: req.params.database,
517
+ collection: req.params.collection,
518
+ patches: context ? validatePatches(patches) : patches,
519
+ durability,
520
+ memoryGuard,
521
+ files,
522
+ imports: importLimits,
523
+ minimalReturn: prefersMinimalReturn(req),
524
+ }));
525
+ json(res, 200, writeResponse(req, auth, result, result.items
526
+ ? auth.redactResult(req.params.database, req.params.collection, result)
527
+ : result));
528
+ } catch (err) {
529
+ json(res, err.status || 400, err.body || errorBody(err), err.headers || {});
530
+ }
531
+ }));
532
+
533
+ router.delete("/api/:database/:collection/object", secure(async (req, res) => {
534
+ if (rejectIfReadOnlyDatabaseWrite(res, req.params.database)) return;
535
+ if (isNdjsonRequest(req)) {
536
+ return streamRemoveObjects({
537
+ req,
538
+ res,
539
+ node,
540
+ routing,
541
+ durability,
542
+ files,
543
+ imports: importLimits,
544
+ database: req.params.database,
545
+ collection: req.params.collection,
546
+ objectHooks,
547
+ auth,
548
+ });
549
+ }
550
+ let ids;
551
+ try {
552
+ ids = parseBulkIds(await readJson(req, null));
553
+ } catch (err) {
554
+ return json(res, 400, errorBody(err));
555
+ }
556
+ try {
557
+ const result = await runObjectOperation({
558
+ req, node, routing, auth, objectHooks, operation: "delete", variant: "bulk", input: { ids },
559
+ }, ({ ids: hookedIds }) => routedRemoveMany(
560
+ req,
561
+ routing,
562
+ node,
563
+ durability,
564
+ req.params.database,
565
+ req.params.collection,
566
+ normalizeHookIds(hookedIds),
567
+ files,
568
+ { minimalReturn: prefersMinimalReturn(req) },
569
+ ));
570
+ json(res, 200, writeResponse(req, auth, result, result));
571
+ } catch (err) {
572
+ json(res, err.status || 400, err.body || errorBody(err), err.headers || {});
573
+ }
574
+ }));
575
+
576
+ router.put("/api/:database/:collection/object/:id", secure(async (req, res) => {
577
+ if (rejectIfReadOnlyDatabaseWrite(res, req.params.database)) return;
578
+ if (isMultipartRequest(req)) {
579
+ return multipartReplaceObject({ req, res, node, auth, routing, durability, memoryGuard, files, imports: importLimits, objectHooks });
580
+ }
581
+ const body = await readJson(req);
582
+ const error = validatePlainObjects([body || {}]);
583
+ if (error) return json(res, 400, error);
584
+ const refDepth = refDepthParam(req);
585
+ try {
586
+ const result = await runObjectOperation({
587
+ req,
588
+ node,
589
+ routing,
590
+ objectHooks,
591
+ auth,
592
+ operation: "update",
593
+ variant: "single-replace",
594
+ input: { objects: [{ ...(body || {}), _id: req.params.id }] },
595
+ }, async ({ objects }) => {
596
+ const object = singlePathObject(objects, req.params.id);
597
+ if (!routing.shouldHandleObjectLocally(req, req.params.database, req.params.collection, req.params.id)) {
598
+ const remote = await routing.forwardToObject(
599
+ req,
600
+ req.params.database,
601
+ req.params.collection,
602
+ req.params.id,
603
+ objectPath(req.params.database, req.params.collection, req.params.id),
604
+ { method: "PUT", body: object, query: refDepthQuery(0) },
605
+ );
606
+ if (!ok(remote.status)) return { status: remote.status, body: remote.body, headers: remote.headers };
607
+ return {
608
+ status: remote.status,
609
+ body: await expandObjectForRead({
610
+ req,
611
+ node,
612
+ routing,
613
+ database: req.params.database,
614
+ object: stripReferenceObjects(remote.body),
615
+ refDepth,
616
+ }),
617
+ };
618
+ }
619
+ const [prepared] = auth.prepareWriteObjects(
620
+ req.params.database,
621
+ req.params.collection,
622
+ [object],
623
+ { partial: false },
624
+ );
625
+ const preparedObject = normalizeFileObject(files, req.params.database, req.params.collection, prepared);
626
+ assertImportWriteAccepts({ req, imports: importLimits, durability, objects: [preparedObject] });
627
+ assertUpsertAllowed(req, node, req.params.database, req.params.collection, [preparedObject], { partial: false });
628
+ assertMemoryGuardAcceptsWrites(memoryGuard);
629
+ const before = node.get(req.params.database, req.params.collection, req.params.id);
630
+ node.replace(req.params.database, req.params.collection, req.params.id, preparedObject);
631
+ await afterLocalWrite(durability);
632
+ const written = node.get(req.params.database, req.params.collection, req.params.id) || { _id: req.params.id };
633
+ await deleteRemovedFiles(files, req.params.database, req.params.collection, [before], [written]);
634
+ return {
635
+ status: 200,
636
+ body: await expandObjectForRead({
637
+ req,
638
+ node,
639
+ routing,
640
+ database: req.params.database,
641
+ object: written,
642
+ refDepth,
643
+ }),
644
+ };
645
+ });
646
+ if (!ok(result.status)) return json(res, result.status, result.body, result.headers || {});
647
+ json(res, result.status, writeResponse(req, auth, result,
648
+ auth.redactObject(req.params.database, req.params.collection, result.body)));
649
+ } catch (err) {
650
+ json(res, err.status || 400, err.body || errorBody(err), err.headers || {});
651
+ }
652
+ }));
653
+
654
+ router.patch("/api/:database/:collection/object/:id", secure(async (req, res) => {
655
+ if (rejectIfReadOnlyDatabaseWrite(res, req.params.database)) return;
656
+ const body = await readJson(req);
657
+ const error = validatePlainObjects([body || {}]);
658
+ if (error) return json(res, 400, error);
659
+ const refDepth = refDepthParam(req);
660
+ try {
661
+ const result = await runObjectOperation({
662
+ req,
663
+ node,
664
+ routing,
665
+ objectHooks,
666
+ operation: "update",
667
+ variant: "single-patch",
668
+ auth,
669
+ input: { patches: [{ ...(body || {}), _id: req.params.id }] },
670
+ }, async ({ patches }) => {
671
+ const patch = singlePathObject(patches, req.params.id);
672
+ if (!routing.shouldHandleObjectLocally(req, req.params.database, req.params.collection, req.params.id)) {
673
+ const remote = await routing.forwardToObject(
674
+ req,
675
+ req.params.database,
676
+ req.params.collection,
677
+ req.params.id,
678
+ objectPath(req.params.database, req.params.collection, req.params.id),
679
+ { method: "PATCH", body: withoutObjectId(patch), query: refDepthQuery(0) },
680
+ );
681
+ if (!ok(remote.status)) return { status: remote.status, body: remote.body, headers: remote.headers };
682
+ return {
683
+ status: remote.status,
684
+ body: await expandObjectForRead({
685
+ req,
686
+ node,
687
+ routing,
688
+ database: req.params.database,
689
+ object: stripReferenceObjects(remote.body),
690
+ refDepth,
691
+ }),
692
+ };
693
+ }
694
+ const patchedObject = compilePatchedObject({
695
+ node,
696
+ database: req.params.database,
697
+ collection: req.params.collection,
698
+ id: req.params.id,
699
+ patch: withoutObjectId(patch),
700
+ files,
701
+ });
702
+ if (!patchedObject) return { status: 404, body: { error: "not found" } };
703
+ const [prepared] = auth.prepareWriteObjects(
704
+ req.params.database,
705
+ req.params.collection,
706
+ [patchedObject],
707
+ { partial: true },
708
+ );
709
+ const preparedObject = normalizeFileObject(files, req.params.database, req.params.collection, prepared);
710
+ assertImportWriteAccepts({ req, imports: importLimits, durability, objects: [preparedObject] });
711
+ assertUpdateAllowed(req, node, req.params.database, req.params.collection, [preparedObject]);
712
+ assertMemoryGuardAcceptsWrites(memoryGuard);
713
+ const before = node.get(req.params.database, req.params.collection, req.params.id);
714
+ node.replace(req.params.database, req.params.collection, req.params.id, preparedObject);
715
+ await afterLocalWrite(durability);
716
+ const written = node.get(req.params.database, req.params.collection, req.params.id) || { _id: req.params.id };
717
+ await deleteRemovedFiles(files, req.params.database, req.params.collection, [before], [written]);
718
+ return {
719
+ status: 200,
720
+ body: await expandObjectForRead({
721
+ req,
722
+ node,
723
+ routing,
724
+ database: req.params.database,
725
+ object: written,
726
+ refDepth,
727
+ }),
728
+ };
729
+ });
730
+ if (!ok(result.status)) return json(res, result.status, result.body, result.headers || {});
731
+ json(res, result.status, writeResponse(req, auth, result,
732
+ auth.redactObject(req.params.database, req.params.collection, result.body)));
733
+ } catch (err) {
734
+ json(res, err.status || 400, err.body || errorBody(err), err.headers || {});
735
+ }
736
+ }));
737
+
738
+ router.delete("/api/:database/:collection/object/:id", secure(async (req, res) => {
739
+ if (rejectIfReadOnlyDatabaseWrite(res, req.params.database)) return;
740
+ try {
741
+ const result = await runObjectOperation({
742
+ req,
743
+ node,
744
+ routing,
745
+ objectHooks,
746
+ operation: "delete",
747
+ variant: "single",
748
+ auth,
749
+ input: { ids: [req.params.id] },
750
+ }, async () => {
751
+ if (!routing.shouldHandleObjectLocally(req, req.params.database, req.params.collection, req.params.id)) {
752
+ return routing.forwardToObject(
753
+ req,
754
+ req.params.database,
755
+ req.params.collection,
756
+ req.params.id,
757
+ objectPath(req.params.database, req.params.collection, req.params.id),
758
+ { method: "DELETE" },
759
+ );
760
+ }
761
+ const existing = node.get(req.params.database, req.params.collection, req.params.id);
762
+ if (!existing) return { status: 404, body: { error: "not found" } };
763
+ assertDeleteAllowed(req, [existing], req.params.database);
764
+ const removed = node.remove(req.params.database, req.params.collection, req.params.id);
765
+ if (!removed) return { status: 404, body: { error: "not found" } };
766
+ await afterLocalWrite(durability);
767
+ await deleteObjectFiles(files, req.params.database, req.params.collection, [existing]);
768
+ return { status: 204, body: null };
769
+ });
770
+ json(res, Object.hasOwn(result, "responseOverride") ? 200 : result.status,
771
+ writeResponse(req, auth, result, result.body), result.headers || {});
772
+ } catch (err) {
773
+ return json(res, err.status || 400, err.body || errorBody(err), err.headers || {});
774
+ }
775
+ }));
776
+ },
777
+ };
778
+ }
779
+
780
+ function runObjectOperation({
781
+ req,
782
+ node,
783
+ routing,
784
+ auth,
785
+ objectHooks,
786
+ operation,
787
+ variant,
788
+ input,
789
+ batch = null,
790
+ }, execute) {
791
+ if (!objectHooks || routing.isForwarded(req)) return execute(input, null);
792
+ return objectHooks.run({
793
+ operation,
794
+ variant,
795
+ database: req.params.database,
796
+ collection: req.params.collection,
797
+ input,
798
+ principal: req.auth?.user || null,
799
+ batch,
800
+ lookup: createHookLookup(req, node, routing, auth),
801
+ }, execute);
802
+ }
803
+
804
+ function writeResponse(req, auth, result, ordinaryBody) {
805
+ return Object.hasOwn(result, "responseOverride")
806
+ ? auth.redactObject(req.params.database, req.params.collection, result.responseOverride)
807
+ : ordinaryBody;
808
+ }
809
+
810
+ function createHookLookup(req, node, routing, auth) {
811
+ const database = req.params.database;
812
+ const find = async (collection, criteria = {}, options = {}) => {
813
+ if (typeof collection !== "string" || !collection) throw httpError(400, "lookup collection is required");
814
+ const access = readCriteriaForRequest(req, criteria, collection);
815
+ if (access.denied) return [];
816
+ const result = await routing.find(req, database, collection, access.query, {
817
+ limit: options.limit ?? 250,
818
+ offset: options.offset ?? 0,
819
+ refDepth: 0,
820
+ });
821
+ const readable = (result.objects || []).filter((object) => canRead(permissionUser(req), object, collection));
822
+ return auth.redactObjects(database, collection, readable);
823
+ };
824
+ return Object.freeze({
825
+ find,
826
+ async get(collection, id) {
827
+ if (typeof collection !== "string" || !collection) throw httpError(400, "lookup collection is required");
828
+ const object = await resolveReferencedObject({
829
+ req, node, routing, database, collection, id: String(id),
830
+ });
831
+ return object ? auth.redactObject(database, collection, object) : null;
832
+ },
833
+ });
834
+ }
835
+
836
+ function publicQueryResult(req, auth, database, collection, result) {
837
+ if (!result.objectRows) return result.body;
838
+ const readable = (result.body || []).filter((object) => canRead(permissionUser(req), object, collection));
839
+ return auth.redactObjects(database, collection, readable);
840
+ }
841
+
842
+ function validateAndNormalizeObjects(files, database, collection, objects) {
843
+ const error = validatePlainObjects(objects || [], { requireId: true });
844
+ if (error) throw validationError(error);
845
+ return normalizeFileObjects(files, database, collection, objects);
846
+ }
847
+
848
+ function validatePatches(patches) {
849
+ const error = validatePlainObjects(patches || [], { requireId: true });
850
+ if (error) throw validationError(error);
851
+ return patches;
852
+ }
853
+
854
+ function normalizeHookIds(ids) {
855
+ if (!Array.isArray(ids)) throw httpError(400, "hook input.ids must be an array");
856
+ return parseNdjsonIds(ids);
857
+ }
858
+
859
+ function singleObject(objects) {
860
+ if (!Array.isArray(objects) || objects.length !== 1) {
861
+ throw httpError(400, "single-object hook input must contain exactly one object");
862
+ }
863
+ const error = validatePlainObjects(objects, { requireId: true });
864
+ if (error) throw validationError(error);
865
+ return objects[0];
866
+ }
867
+
868
+ function singlePathObject(objects, id) {
869
+ if (!Array.isArray(objects) || objects.length !== 1) {
870
+ throw httpError(400, "single-object hook input must contain exactly one object");
871
+ }
872
+ const error = validatePlainObjects(objects);
873
+ if (error) throw validationError(error);
874
+ return { ...objects[0], _id: String(id) };
875
+ }
876
+
877
+ function withoutObjectId(object = {}) {
878
+ const { _id, ...rest } = object;
879
+ return rest;
880
+ }
881
+
882
+ async function multipartCreateObject({ req, res, node, auth, routing, durability, memoryGuard, files, imports, objectHooks }) {
883
+ if (!files) return json(res, 404, { error: "file storage is not configured" });
884
+ try {
885
+ assertMemoryGuardAcceptsWrites(memoryGuard);
886
+ const { object, file } = await readMultipartObjectFile(req, {
887
+ maxBytes: files.maxUploadBytes,
888
+ assertCanBuffer: () => assertMemoryGuardAcceptsWrites(memoryGuard),
889
+ });
890
+ const error = validatePlainObjects([object]);
891
+ if (error) return json(res, 400, error);
892
+ const item = withObjectIds([object])[0];
893
+ const result = await runObjectOperation({
894
+ req,
895
+ node,
896
+ routing,
897
+ objectHooks,
898
+ operation: "create",
899
+ variant: "file",
900
+ auth,
901
+ input: { objects: [item], file },
902
+ }, async ({ objects, file: hookedFile }) => {
903
+ const hookedItem = singleObject(objects);
904
+ if (!routing.shouldHandleObjectLocally(req, req.params.database, req.params.collection, hookedItem._id)) {
905
+ const forwarded = buildMultipartObjectFile({ object: hookedItem, file: hookedFile });
906
+ const remote = await routing.forwardToObjectRaw(
907
+ req,
908
+ req.params.database,
909
+ req.params.collection,
910
+ hookedItem._id,
911
+ objectPath(req.params.database, req.params.collection),
912
+ {
913
+ method: "POST",
914
+ body: forwarded.body,
915
+ contentType: forwarded.contentType,
916
+ query: refDepthQuery(0),
917
+ },
918
+ );
919
+ if (!ok(remote.status)) return { status: remote.status, body: remote.body, headers: remote.headers };
920
+ return {
921
+ status: remote.status,
922
+ body: await expandObjectForRead({
923
+ req,
924
+ node,
925
+ routing,
926
+ database: req.params.database,
927
+ object: stripReferenceObjects(remote.body),
928
+ refDepth: refDepthParam(req),
929
+ }),
930
+ };
931
+ }
932
+
933
+ if (node.get(req.params.database, req.params.collection, hookedItem._id)) {
934
+ throw httpError(409, `object already exists: ${hookedItem._id}`);
935
+ }
936
+ const objectWithFile = files.attach(req.params.database, req.params.collection, hookedItem, hookedFile);
937
+ const [prepared] = auth.prepareWriteObjects(req.params.database, req.params.collection, [objectWithFile], { partial: false });
938
+ assertImportWriteAccepts({ req, imports, durability, objects: [prepared], ignoreBodyBytes: true });
939
+ assertCreateAllowed(req, [prepared], req.params.database);
940
+ await files.put(req.params.database, req.params.collection, hookedItem._id, objectWithFile._file, hookedFile);
941
+ const [row] = node.insertMany(req.params.database, req.params.collection, [prepared]);
942
+ await afterLocalWrite(durability);
943
+ return {
944
+ status: 201,
945
+ body: await expandObjectForRead({
946
+ req,
947
+ node,
948
+ routing,
949
+ database: req.params.database,
950
+ object: stripReferenceObjects(row),
951
+ refDepth: refDepthParam(req),
952
+ }),
953
+ };
954
+ });
955
+ if (!ok(result.status)) return json(res, result.status, result.body, result.headers || {});
956
+ return json(res, result.status, writeResponse(req, auth, result,
957
+ auth.redactObject(req.params.database, req.params.collection, result.body)));
958
+ } catch (err) {
959
+ return json(res, err.status || 400, err.body || errorBody(err), err.headers || {});
960
+ }
961
+ }
962
+
963
+ async function multipartReplaceObject({ req, res, node, auth, routing, durability, memoryGuard, files, imports, objectHooks }) {
964
+ if (!files) return json(res, 404, { error: "file storage is not configured" });
965
+ try {
966
+ assertMemoryGuardAcceptsWrites(memoryGuard);
967
+ const { object, file } = await readMultipartObjectFile(req, {
968
+ maxBytes: files.maxUploadBytes,
969
+ assertCanBuffer: () => assertMemoryGuardAcceptsWrites(memoryGuard),
970
+ });
971
+ const error = validatePlainObjects([object]);
972
+ if (error) return json(res, 400, error);
973
+ const item = { ...object, _id: req.params.id };
974
+ const result = await runObjectOperation({
975
+ req,
976
+ node,
977
+ routing,
978
+ objectHooks,
979
+ operation: "update",
980
+ variant: "file",
981
+ auth,
982
+ input: { objects: [item], file },
983
+ }, async ({ objects, file: hookedFile }) => {
984
+ const hookedItem = singlePathObject(objects, req.params.id);
985
+ if (!routing.shouldHandleObjectLocally(req, req.params.database, req.params.collection, req.params.id)) {
986
+ const forwarded = buildMultipartObjectFile({ object: hookedItem, file: hookedFile });
987
+ const remote = await routing.forwardToObjectRaw(
988
+ req,
989
+ req.params.database,
990
+ req.params.collection,
991
+ req.params.id,
992
+ objectPath(req.params.database, req.params.collection, req.params.id),
993
+ {
994
+ method: "PUT",
995
+ body: forwarded.body,
996
+ contentType: forwarded.contentType,
997
+ query: refDepthQuery(0),
998
+ },
999
+ );
1000
+ if (!ok(remote.status)) return { status: remote.status, body: remote.body, headers: remote.headers };
1001
+ return {
1002
+ status: remote.status,
1003
+ body: await expandObjectForRead({
1004
+ req,
1005
+ node,
1006
+ routing,
1007
+ database: req.params.database,
1008
+ object: stripReferenceObjects(remote.body),
1009
+ refDepth: refDepthParam(req),
1010
+ }),
1011
+ };
1012
+ }
1013
+
1014
+ const objectWithFile = files.attach(req.params.database, req.params.collection, hookedItem, hookedFile);
1015
+ const [prepared] = auth.prepareWriteObjects(req.params.database, req.params.collection, [objectWithFile], { partial: false });
1016
+ assertImportWriteAccepts({ req, imports, durability, objects: [prepared], ignoreBodyBytes: true });
1017
+ assertUpsertAllowed(req, node, req.params.database, req.params.collection, [prepared], { partial: false });
1018
+ const before = node.get(req.params.database, req.params.collection, req.params.id);
1019
+ await files.put(req.params.database, req.params.collection, req.params.id, objectWithFile._file, hookedFile);
1020
+ node.replace(req.params.database, req.params.collection, req.params.id, prepared);
1021
+ await afterLocalWrite(durability);
1022
+ const row = node.get(req.params.database, req.params.collection, req.params.id) || { _id: req.params.id };
1023
+ await deleteRemovedFiles(files, req.params.database, req.params.collection, [before], [row]);
1024
+ return {
1025
+ status: 200,
1026
+ body: await expandObjectForRead({
1027
+ req,
1028
+ node,
1029
+ routing,
1030
+ database: req.params.database,
1031
+ object: stripReferenceObjects(row),
1032
+ refDepth: refDepthParam(req),
1033
+ }),
1034
+ };
1035
+ });
1036
+ if (!ok(result.status)) return json(res, result.status, result.body, result.headers || {});
1037
+ return json(res, result.status, writeResponse(req, auth, result,
1038
+ auth.redactObject(req.params.database, req.params.collection, result.body)));
1039
+ } catch (err) {
1040
+ return json(res, err.status || 400, err.body || errorBody(err), err.headers || {});
1041
+ }
1042
+ }
1043
+
1044
+ async function readableObjectForFile({ req, node, routing }) {
1045
+ if (!routing.shouldHandleObjectLocally(req, req.params.database, req.params.collection, req.params.id)) {
1046
+ const remote = await routing.forwardToObject(
1047
+ req,
1048
+ req.params.database,
1049
+ req.params.collection,
1050
+ req.params.id,
1051
+ objectPath(req.params.database, req.params.collection, req.params.id),
1052
+ { method: "GET", query: refDepthQuery(0), retryStatuses: [404, 409, 503] },
1053
+ );
1054
+ if (remote.status === 404) return null;
1055
+ if (!ok(remote.status)) throw remoteHttpError(remote, "file object lookup failed");
1056
+ return stripReferenceObjects(remote.body);
1057
+ }
1058
+
1059
+ const object = node.get(req.params.database, req.params.collection, req.params.id);
1060
+ if (object) assertReadAllowed(req, object);
1061
+ return object || null;
1062
+ }
1063
+
1064
+ async function executeCollectionQuery(req, { node, routing, query }) {
1065
+ const { criteria, controls } = splitQueryControls(query);
1066
+ const database = req.params.database;
1067
+ const collection = req.params.collection;
1068
+
1069
+ const access = readCriteriaForRequest(req, criteria);
1070
+ if (access.denied) return { body: [], path: "permission-denied" };
1071
+ const securedCriteria = access.query;
1072
+ const options = queryOptions(req, controls);
1073
+ if (controls.$aggregate !== undefined) {
1074
+ if (controls.$vector !== undefined) {
1075
+ throw httpError(400, "$aggregate and $vector are mutually exclusive");
1076
+ }
1077
+ const queryDepth = referenceDepthForQuery(securedCriteria);
1078
+ const aggregateDepth = referenceDepthForAggregateSpec(controls.$aggregate);
1079
+ if (queryDepth > 0 || aggregateDepth > 0) {
1080
+ const rows = await findReferenceAwareRows({
1081
+ req,
1082
+ node,
1083
+ routing,
1084
+ database,
1085
+ collection,
1086
+ criteria: securedCriteria,
1087
+ options: { refDepth: Math.max(queryDepth, aggregateDepth) },
1088
+ });
1089
+ return { body: aggregateRows(rows.rows, controls.$aggregate), path: aggregateReferencePath(rows.path) };
1090
+ }
1091
+ const result = await routing.aggregate(req, database, collection, securedCriteria, controls.$aggregate);
1092
+ return { body: result.rows, path: result.path };
1093
+ }
1094
+
1095
+ if (controls.$vector !== undefined) {
1096
+ if (referenceDepthForQuery(securedCriteria) > 0) {
1097
+ throw httpError(400, "$vector cannot be combined with reference object paths yet");
1098
+ }
1099
+ const vector = parseVectorSearch(controls.$vector);
1100
+ const rows = await routing.vectorSearch(req, database, collection, securedCriteria, vector, options);
1101
+ const expanded = await expandObjectsForRead({
1102
+ req,
1103
+ node,
1104
+ routing,
1105
+ database,
1106
+ collection,
1107
+ objects: rows,
1108
+ refDepth: options.refDepth,
1109
+ });
1110
+ return {
1111
+ body: expanded,
1112
+ path: Object.keys(securedCriteria || {}).length > 0 ? "vector-filter" : "vector-index",
1113
+ objectRows: true,
1114
+ };
1115
+ }
1116
+
1117
+ const result = await findPublicObjects({
1118
+ req,
1119
+ node,
1120
+ routing,
1121
+ database,
1122
+ collection,
1123
+ criteria: securedCriteria,
1124
+ options,
1125
+ });
1126
+ return { body: result.objects, path: result.path, objectRows: true };
1127
+ }
1128
+
1129
+ async function streamCreateObjects({ req, res, node, auth, routing, durability, memoryGuard, files, imports, objectHooks }) {
1130
+ if (rejectNdjsonWithoutMinimal(req, res)) return;
1131
+ const result = { inserted: 0 };
1132
+ try {
1133
+ await consumeNdjsonChunks({
1134
+ req,
1135
+ imports,
1136
+ prepare: (objects) => prepareCreateObjects(
1137
+ files,
1138
+ req.params.database,
1139
+ req.params.collection,
1140
+ objects,
1141
+ { mutateIds: true },
1142
+ ),
1143
+ write: async (objects, batchIndex) => {
1144
+ const written = await runObjectOperation({
1145
+ req,
1146
+ node,
1147
+ routing,
1148
+ objectHooks,
1149
+ operation: "create",
1150
+ variant: "stream",
1151
+ auth,
1152
+ input: { objects },
1153
+ batch: { index: batchIndex, streaming: true },
1154
+ }, ({ objects: hookedObjects }, context) => routedCreateMany({
1155
+ req,
1156
+ routing,
1157
+ auth,
1158
+ node,
1159
+ database: req.params.database,
1160
+ collection: req.params.collection,
1161
+ objects: context
1162
+ ? prepareCreateObjects(files, req.params.database, req.params.collection, hookedObjects)
1163
+ : hookedObjects,
1164
+ durability,
1165
+ memoryGuard,
1166
+ imports,
1167
+ minimalReturn: true,
1168
+ ignoreBodyBytes: true,
1169
+ validatedObjectBytes: !context,
1170
+ }));
1171
+ result.inserted += written.inserted;
1172
+ },
1173
+ });
1174
+ json(res, 201, result);
1175
+ } catch (err) {
1176
+ json(res, err.status || 400, streamErrorBody(err, result), err.headers || {});
1177
+ }
1178
+ }
1179
+
1180
+ async function streamReplaceObjects({ req, res, node, auth, routing, durability, memoryGuard, files, imports, objectHooks }) {
1181
+ if (rejectNdjsonWithoutMinimal(req, res)) return;
1182
+ const result = { replaced: 0 };
1183
+ try {
1184
+ await consumeNdjsonChunks({
1185
+ req,
1186
+ imports,
1187
+ prepare: (objects) => {
1188
+ const error = validatePlainObjects(objects, { requireId: true });
1189
+ if (error) throw validationError(error);
1190
+ return normalizeFileObjects(files, req.params.database, req.params.collection, objects);
1191
+ },
1192
+ write: async (objects, batchIndex) => {
1193
+ const written = await runObjectOperation({
1194
+ req,
1195
+ node,
1196
+ routing,
1197
+ objectHooks,
1198
+ operation: "update",
1199
+ variant: "stream-replace",
1200
+ auth,
1201
+ input: { objects },
1202
+ batch: { index: batchIndex, streaming: true },
1203
+ }, ({ objects: hookedObjects }, context) => routedWriteMany({
1204
+ req,
1205
+ routing,
1206
+ auth,
1207
+ node,
1208
+ database: req.params.database,
1209
+ collection: req.params.collection,
1210
+ objects: context
1211
+ ? validateAndNormalizeObjects(files, req.params.database, req.params.collection, hookedObjects)
1212
+ : hookedObjects,
1213
+ method: "PUT",
1214
+ resultKey: "replaced",
1215
+ partial: false,
1216
+ localWrite: (prepared) => node.replaceMany(req.params.database, req.params.collection, prepared),
1217
+ localWriteCount: (prepared) => node.replaceManyCount(req.params.database, req.params.collection, prepared),
1218
+ durability,
1219
+ memoryGuard,
1220
+ files,
1221
+ imports,
1222
+ minimalReturn: true,
1223
+ ignoreBodyBytes: true,
1224
+ validatedObjectBytes: !context,
1225
+ }));
1226
+ result.replaced += written.replaced;
1227
+ },
1228
+ });
1229
+ json(res, 200, result);
1230
+ } catch (err) {
1231
+ json(res, err.status || 400, streamErrorBody(err, result), err.headers || {});
1232
+ }
1233
+ }
1234
+
1235
+ async function streamPatchObjects({ req, res, node, auth, routing, durability, memoryGuard, files, imports, objectHooks }) {
1236
+ if (rejectNdjsonWithoutMinimal(req, res)) return;
1237
+ const result = { updated: 0, skipped: 0 };
1238
+ try {
1239
+ await consumeNdjsonChunks({
1240
+ req,
1241
+ imports,
1242
+ prepare: (objects) => {
1243
+ const error = validatePlainObjects(objects, { requireId: true });
1244
+ if (error) throw validationError(error);
1245
+ return objects;
1246
+ },
1247
+ write: async (patches, batchIndex) => {
1248
+ const written = await runObjectOperation({
1249
+ req,
1250
+ node,
1251
+ routing,
1252
+ objectHooks,
1253
+ operation: "update",
1254
+ variant: "stream-patch",
1255
+ auth,
1256
+ input: { patches },
1257
+ batch: { index: batchIndex, streaming: true },
1258
+ }, ({ patches: hookedPatches }, context) => routedPatchMany({
1259
+ req,
1260
+ routing,
1261
+ auth,
1262
+ node,
1263
+ database: req.params.database,
1264
+ collection: req.params.collection,
1265
+ patches: context ? validatePatches(hookedPatches) : hookedPatches,
1266
+ durability,
1267
+ memoryGuard,
1268
+ files,
1269
+ imports,
1270
+ minimalReturn: true,
1271
+ ignoreBodyBytes: true,
1272
+ validatedObjectBytes: !context,
1273
+ }));
1274
+ result.updated += written.updated;
1275
+ result.skipped += written.skipped;
1276
+ },
1277
+ });
1278
+ json(res, 200, result);
1279
+ } catch (err) {
1280
+ json(res, err.status || 400, streamErrorBody(err, result), err.headers || {});
1281
+ }
1282
+ }
1283
+
1284
+ async function streamRemoveObjects({ req, res, node, routing, auth, durability, files, imports, database, collection, objectHooks }) {
1285
+ if (rejectNdjsonWithoutMinimal(req, res)) return;
1286
+ const result = { deleted: 0, skipped: 0 };
1287
+ try {
1288
+ await consumeNdjsonChunks({
1289
+ req,
1290
+ imports,
1291
+ prepare: parseNdjsonIds,
1292
+ write: async (ids, batchIndex) => {
1293
+ const removed = await runObjectOperation({
1294
+ req,
1295
+ node,
1296
+ routing,
1297
+ objectHooks,
1298
+ operation: "delete",
1299
+ variant: "stream",
1300
+ auth,
1301
+ input: { ids },
1302
+ batch: { index: batchIndex, streaming: true },
1303
+ }, ({ ids: hookedIds }, context) => {
1304
+ const normalizedIds = normalizeHookIds(hookedIds);
1305
+ assertImportWriteAccepts({
1306
+ req,
1307
+ imports,
1308
+ durability,
1309
+ objects: normalizedIds.map((id) => ({ _id: id })),
1310
+ ignoreBodyBytes: true,
1311
+ validatedObjectBytes: !context,
1312
+ });
1313
+ return routedRemoveMany(
1314
+ req,
1315
+ routing,
1316
+ node,
1317
+ durability,
1318
+ database,
1319
+ collection,
1320
+ normalizedIds,
1321
+ files,
1322
+ { minimalReturn: true },
1323
+ );
1324
+ });
1325
+ result.deleted += removed.deleted;
1326
+ result.skipped += removed.skipped;
1327
+ },
1328
+ });
1329
+ json(res, 200, result);
1330
+ } catch (err) {
1331
+ json(res, err.status || 400, streamErrorBody(err, result), err.headers || {});
1332
+ }
1333
+ }
1334
+
1335
+ async function routedCreateMany({
1336
+ req,
1337
+ routing,
1338
+ auth,
1339
+ node,
1340
+ database,
1341
+ collection,
1342
+ objects,
1343
+ durability,
1344
+ memoryGuard,
1345
+ imports,
1346
+ minimalReturn = false,
1347
+ ignoreBodyBytes = false,
1348
+ validatedObjectBytes = false,
1349
+ }) {
1350
+ assertImportWriteAccepts({ req, imports, durability, objects, ignoreBodyBytes, validatedObjectBytes });
1351
+ const rows = [];
1352
+ let inserted = 0;
1353
+ let wroteLocally = false;
1354
+ const groups = routing.groupByShard(req, database, collection, objects, (item) => item._id);
1355
+ const preparedByGroup = new Map();
1356
+
1357
+ for (const group of groups) {
1358
+ if (!group.local) continue;
1359
+ const prepared = auth.prepareWriteObjects(database, collection, group.items, { partial: false });
1360
+ assertCreateAllowed(req, prepared, database);
1361
+ preparedByGroup.set(group, prepared);
1362
+ }
1363
+ if (preparedByGroup.size) assertMemoryGuardAcceptsWrites(memoryGuard);
1364
+
1365
+ try {
1366
+ for (const group of groups) {
1367
+ if (group.local) {
1368
+ const prepared = preparedByGroup.get(group);
1369
+ if (minimalReturn) {
1370
+ inserted += node.insertManyCount(database, collection, prepared);
1371
+ } else {
1372
+ const localRows = node.insertMany(database, collection, prepared);
1373
+ rows.push(...localRows);
1374
+ inserted += localRows.length;
1375
+ }
1376
+ wroteLocally = true;
1377
+ continue;
1378
+ }
1379
+
1380
+ const remote = await forwardShardBulkWrite(routing, req, group.shard, objectPath(database, collection), {
1381
+ method: "POST",
1382
+ items: group.items,
1383
+ query: refDepthQuery(0),
1384
+ minimalReturn,
1385
+ });
1386
+ if (!ok(remote.status)) throw remoteHttpError(remote, `POST failed on shard ${group.shard}`);
1387
+ const remoteRows = remoteInsertedItems(remote.body);
1388
+ if (!minimalReturn) rows.push(...remoteRows);
1389
+ inserted += remoteInsertedCount(remote.body, remoteRows.length);
1390
+ }
1391
+ if (wroteLocally) await afterLocalWrite(durability);
1392
+ } catch (err) {
1393
+ if (wroteLocally) await afterLocalWrite(durability).catch((flushErr) => {
1394
+ err = flushErr;
1395
+ });
1396
+ throw err;
1397
+ }
1398
+
1399
+ if (minimalReturn) return { inserted };
1400
+ return {
1401
+ inserted,
1402
+ items: await expandObjectsForRead({
1403
+ req,
1404
+ node,
1405
+ routing,
1406
+ database,
1407
+ collection,
1408
+ objects: orderLikeInput(objects, rows).map(stripReferenceObjects),
1409
+ refDepth: refDepthParam(req),
1410
+ }),
1411
+ };
1412
+ }
1413
+
1414
+ async function routedWriteMany({
1415
+ req,
1416
+ routing,
1417
+ auth,
1418
+ node,
1419
+ database,
1420
+ collection,
1421
+ objects,
1422
+ method,
1423
+ resultKey,
1424
+ partial,
1425
+ localWrite,
1426
+ localWriteCount,
1427
+ durability,
1428
+ memoryGuard,
1429
+ files,
1430
+ imports,
1431
+ minimalReturn = false,
1432
+ ignoreBodyBytes = false,
1433
+ validatedObjectBytes = false,
1434
+ }) {
1435
+ assertImportWriteAccepts({ req, imports, durability, objects, ignoreBodyBytes, validatedObjectBytes });
1436
+ const rows = [];
1437
+ const removedFileObjects = [];
1438
+ let total = 0;
1439
+ let wroteLocally = false;
1440
+ const groups = routing.groupByShard(req, database, collection, objects, (item) => item._id);
1441
+ const preparedByGroup = new Map();
1442
+ for (const group of groups) {
1443
+ if (!group.local) continue;
1444
+ const prepared = auth.prepareWriteObjects(database, collection, group.items, { partial });
1445
+ assertUpsertAllowed(req, node, database, collection, prepared, { partial });
1446
+ preparedByGroup.set(group, prepared);
1447
+ }
1448
+ if (preparedByGroup.size) assertMemoryGuardAcceptsWrites(memoryGuard);
1449
+
1450
+ try {
1451
+ for (const group of groups) {
1452
+ if (group.local) {
1453
+ const prepared = preparedByGroup.get(group);
1454
+ const before = files ? existingObjectMap(node, database, collection, prepared.map((object) => object._id)) : null;
1455
+ if (minimalReturn && !files && localWriteCount) {
1456
+ total += Number(localWriteCount(prepared)) || 0;
1457
+ } else {
1458
+ const localResult = localWrite(prepared);
1459
+ total += Number(localResult[resultKey]) || 0;
1460
+ if (!minimalReturn) rows.push(...(localResult.items || []));
1461
+ removedFileObjects.push(...removedFiles(before, localResult.items));
1462
+ }
1463
+ wroteLocally = true;
1464
+ continue;
1465
+ }
1466
+
1467
+ const remote = await forwardShardBulkWrite(routing, req, group.shard, objectPath(database, collection), {
1468
+ method,
1469
+ items: group.items,
1470
+ query: refDepthQuery(0),
1471
+ minimalReturn,
1472
+ });
1473
+ if (!ok(remote.status)) throw remoteHttpError(remote, `${method} failed on shard ${group.shard}`);
1474
+ total += Number(remote.body?.[resultKey]) || 0;
1475
+ if (!minimalReturn) rows.push(...(Array.isArray(remote.body?.items) ? remote.body.items : []));
1476
+ }
1477
+ if (wroteLocally) {
1478
+ await afterLocalWrite(durability);
1479
+ await deleteObjectFiles(files, database, collection, removedFileObjects);
1480
+ }
1481
+ } catch (err) {
1482
+ if (wroteLocally) await afterLocalWrite(durability).catch((flushErr) => {
1483
+ err = flushErr;
1484
+ });
1485
+ throw err;
1486
+ }
1487
+
1488
+ if (minimalReturn) return { [resultKey]: total };
1489
+ return {
1490
+ [resultKey]: total,
1491
+ items: await expandObjectsForRead({
1492
+ req,
1493
+ node,
1494
+ routing,
1495
+ database,
1496
+ collection,
1497
+ objects: orderLikeInput(objects, rows).map(stripReferenceObjects),
1498
+ refDepth: refDepthParam(req),
1499
+ }),
1500
+ };
1501
+ }
1502
+
1503
+ async function routedPatchMany({
1504
+ req,
1505
+ routing,
1506
+ auth,
1507
+ node,
1508
+ database,
1509
+ collection,
1510
+ patches,
1511
+ durability,
1512
+ memoryGuard,
1513
+ files,
1514
+ imports,
1515
+ minimalReturn = false,
1516
+ ignoreBodyBytes = false,
1517
+ validatedObjectBytes = false,
1518
+ }) {
1519
+ assertImportWriteAccepts({ req, imports, durability, objects: patches, ignoreBodyBytes, validatedObjectBytes });
1520
+ const rows = [];
1521
+ const skipped = [];
1522
+ let skippedCount = 0;
1523
+ const removedFileObjects = [];
1524
+ let updated = 0;
1525
+ let wroteLocally = false;
1526
+ const groups = routing.groupByShard(req, database, collection, patches, (item) => item._id);
1527
+ const preparedByGroup = new Map();
1528
+
1529
+ for (const group of groups) {
1530
+ if (!group.local) continue;
1531
+ const compiled = [];
1532
+ for (const item of group.items) {
1533
+ const { _id, ...patch } = item;
1534
+ const patched = compilePatchedObject({ node, database, collection, id: _id, patch, files });
1535
+ if (!patched) {
1536
+ if (minimalReturn) skippedCount += 1;
1537
+ else skipped.push(String(_id));
1538
+ continue;
1539
+ }
1540
+ compiled.push(patched);
1541
+ }
1542
+ if (!compiled.length) continue;
1543
+ const prepared = normalizeFileObjects(
1544
+ files,
1545
+ database,
1546
+ collection,
1547
+ auth.prepareWriteObjects(database, collection, compiled, { partial: true }),
1548
+ );
1549
+ assertUpdateAllowed(req, node, database, collection, prepared);
1550
+ preparedByGroup.set(group, prepared);
1551
+ }
1552
+ if (preparedByGroup.size) assertMemoryGuardAcceptsWrites(memoryGuard);
1553
+
1554
+ try {
1555
+ for (const group of groups) {
1556
+ if (group.local) {
1557
+ const prepared = preparedByGroup.get(group) || [];
1558
+ if (!prepared.length) continue;
1559
+ const before = files ? existingObjectMap(node, database, collection, prepared.map((object) => object._id)) : null;
1560
+ if (minimalReturn && !files) {
1561
+ updated += node.replaceManyCount(database, collection, prepared);
1562
+ } else {
1563
+ const localResult = node.replaceMany(database, collection, prepared);
1564
+ updated += Number(localResult.replaced) || 0;
1565
+ if (!minimalReturn) rows.push(...(localResult.items || []));
1566
+ removedFileObjects.push(...removedFiles(before, localResult.items));
1567
+ }
1568
+ wroteLocally = true;
1569
+ continue;
1570
+ }
1571
+
1572
+ const remote = await forwardShardBulkWrite(routing, req, group.shard, objectPath(database, collection), {
1573
+ method: "PATCH",
1574
+ items: group.items,
1575
+ query: refDepthQuery(0),
1576
+ minimalReturn,
1577
+ });
1578
+ if (!ok(remote.status)) throw remoteHttpError(remote, `PATCH failed on shard ${group.shard}`);
1579
+ updated += Number(remote.body?.updated) || 0;
1580
+ if (!minimalReturn) {
1581
+ rows.push(...(Array.isArray(remote.body?.items) ? remote.body.items : []));
1582
+ skipped.push(...(Array.isArray(remote.body?.skipped) ? remote.body.skipped.map(String) : []));
1583
+ } else {
1584
+ skippedCount += responseSkippedCount(remote.body);
1585
+ }
1586
+ }
1587
+ if (wroteLocally) {
1588
+ await afterLocalWrite(durability);
1589
+ await deleteObjectFiles(files, database, collection, removedFileObjects);
1590
+ }
1591
+ } catch (err) {
1592
+ if (wroteLocally) await afterLocalWrite(durability).catch((flushErr) => {
1593
+ err = flushErr;
1594
+ });
1595
+ throw err;
1596
+ }
1597
+
1598
+ if (minimalReturn) return { updated, skipped: skippedCount };
1599
+ return {
1600
+ updated,
1601
+ skipped,
1602
+ items: await expandObjectsForRead({
1603
+ req,
1604
+ node,
1605
+ routing,
1606
+ database,
1607
+ collection,
1608
+ objects: orderLikeInput(patches, rows).map(stripReferenceObjects),
1609
+ refDepth: refDepthParam(req),
1610
+ }),
1611
+ };
1612
+ }
1613
+
1614
+ function compilePatchedObject({ node, database, collection, id, patch }) {
1615
+ const objectId = String(id);
1616
+ const current = node.get(database, collection, objectId);
1617
+ if (!current) return null;
1618
+ const next = applyPatchToObject(current, patch);
1619
+ const vectorField = collectionVectorField(node, database, collection);
1620
+ if (vectorField && patchUnsetsPath(patch, vectorField)) next[vectorField] = null;
1621
+ return { ...next, _id: objectId };
1622
+ }
1623
+
1624
+ function collectionVectorField(node, database, collection) {
1625
+ try {
1626
+ return node.db.collection(database, collection).vectorField || null;
1627
+ } catch {
1628
+ return null;
1629
+ }
1630
+ }
1631
+
1632
+ async function routedRemoveMany(req, routing, node, durability, database, collection, ids, files = null, {
1633
+ minimalReturn = false,
1634
+ } = {}) {
1635
+ const result = {
1636
+ deleted: 0,
1637
+ ...(minimalReturn ? { skipped: 0 } : { ids: [], skipped: [] }),
1638
+ };
1639
+ let wroteLocally = false;
1640
+ const existingByGroup = new Map();
1641
+ const removedFileObjects = [];
1642
+ const groups = routing.groupByShard(req, database, collection, ids, (id) => id);
1643
+ for (const group of groups) {
1644
+ if (!group.local) continue;
1645
+ const existing = existingObjectsForIds(node, database, collection, group.items);
1646
+ assertDeleteAllowed(req, existing, database);
1647
+ existingByGroup.set(group, new Map(existing.map((object) => [String(object._id), object])));
1648
+ }
1649
+ try {
1650
+ for (const group of groups) {
1651
+ if (group.local) {
1652
+ const local = node.removeMany(database, collection, group.items);
1653
+ result.deleted += local.deleted;
1654
+ if (minimalReturn) result.skipped += local.skipped.length;
1655
+ else {
1656
+ result.ids.push(...local.ids);
1657
+ result.skipped.push(...local.skipped);
1658
+ }
1659
+ const existing = existingByGroup.get(group) || new Map();
1660
+ removedFileObjects.push(...local.ids.map((id) => existing.get(String(id))).filter(Boolean));
1661
+ wroteLocally = wroteLocally || local.deleted > 0;
1662
+ continue;
1663
+ }
1664
+
1665
+ const remote = await forwardShardBulkWrite(routing, req, group.shard, objectPath(database, collection), {
1666
+ method: "DELETE",
1667
+ items: group.items,
1668
+ body: { ids: group.items },
1669
+ minimalReturn,
1670
+ });
1671
+ if (!ok(remote.status)) throw remoteHttpError(remote, `DELETE failed on shard ${group.shard}`);
1672
+ result.deleted += Number(remote.body?.deleted) || 0;
1673
+ if (minimalReturn) {
1674
+ result.skipped += responseSkippedCount(remote.body);
1675
+ } else {
1676
+ result.ids.push(...(Array.isArray(remote.body?.ids) ? remote.body.ids : []));
1677
+ result.skipped.push(...(Array.isArray(remote.body?.skipped) ? remote.body.skipped : []));
1678
+ }
1679
+ }
1680
+ if (wroteLocally) {
1681
+ await afterLocalWrite(durability);
1682
+ await deleteObjectFiles(files, database, collection, removedFileObjects);
1683
+ }
1684
+ } catch (err) {
1685
+ if (wroteLocally) await afterLocalWrite(durability).catch((flushErr) => {
1686
+ err = flushErr;
1687
+ });
1688
+ throw err;
1689
+ }
1690
+
1691
+ return result;
1692
+ }
1693
+
1694
+ async function findPublicObjects({ req, node, routing, database, collection, criteria, options }) {
1695
+ if (referenceDepthForQuery(criteria) > 0 || pathHasReferenceObject(options.sortBy)) {
1696
+ const result = await findReferenceAwarePairs({ req, node, routing, database, collection, criteria, options });
1697
+ const objects = await expandObjectsForRead({
1698
+ req,
1699
+ node,
1700
+ routing,
1701
+ database,
1702
+ collection,
1703
+ objects: result.pairs.map(({ raw }) => raw),
1704
+ refDepth: options.refDepth,
1705
+ });
1706
+ return {
1707
+ objects,
1708
+ path: referencePath(result.path),
1709
+ };
1710
+ }
1711
+
1712
+ const result = await routing.find(req, database, collection, criteria, { ...options, refDepth: 0 });
1713
+ const objects = await expandObjectsForRead({
1714
+ req,
1715
+ node,
1716
+ routing,
1717
+ database,
1718
+ collection,
1719
+ objects: result.objects,
1720
+ refDepth: options.refDepth,
1721
+ });
1722
+ return {
1723
+ objects,
1724
+ path: result.path,
1725
+ };
1726
+ }
1727
+
1728
+ async function findReferenceAwareRows({ req, node, routing, database, collection, criteria, options = {} }) {
1729
+ const result = await findReferenceAwarePairs({ req, node, routing, database, collection, criteria, options });
1730
+ return {
1731
+ rows: result.pairs.map(({ matchObject }) => matchObject),
1732
+ path: result.path,
1733
+ };
1734
+ }
1735
+
1736
+ async function findReferenceAwarePairs({ req, node, routing, database, collection, criteria, options = {} }) {
1737
+ const { expansionDepth } = referenceReadPlan(criteria, options);
1738
+ const fetchOptions = {
1739
+ refDepth: 0,
1740
+ ...(typeof options.includeId === "function" ? { includeId: options.includeId } : {}),
1741
+ };
1742
+ const result = await routing.find(req, database, collection, baseCriteriaForReferenceQuery(criteria), fetchOptions);
1743
+ const resolver = createReferenceResolver({ req, node, routing });
1744
+ const pairs = [];
1745
+
1746
+ for (const raw of result.objects || []) {
1747
+ const compact = stripReferenceObjects(raw);
1748
+ const matchObject = expansionDepth > 0
1749
+ ? await expandReferences(compact, { database, depth: expansionDepth, getObject: resolver })
1750
+ : compact;
1751
+ if (matchesQuery(matchObject, criteria)) pairs.push({ raw: compact, matchObject });
1752
+ }
1753
+
1754
+ return {
1755
+ pairs: paginateReferencePairs(sortReferencePairs(pairs, options), options),
1756
+ path: result.path,
1757
+ };
1758
+ }
1759
+
1760
+ async function expandObjectForRead({ req, node, routing, database, object, refDepth }) {
1761
+ if (!object) return object;
1762
+ const depth = normalizeRefDepth(refDepth, 1);
1763
+ const prepared = prepareReferenceValue(object);
1764
+ if (depth <= 0 || !prepared.hasReference) return prepared.value;
1765
+ return expandReferences(prepared.value, {
1766
+ database,
1767
+ depth,
1768
+ getObject: createReferenceResolver({ req, node, routing }),
1769
+ });
1770
+ }
1771
+
1772
+ async function expandObjectsForRead({ req, node, routing, database, collection, objects, refDepth }) {
1773
+ const out = [];
1774
+ for (const object of objects || []) {
1775
+ let row = object;
1776
+ if (collection && object?._id !== undefined) {
1777
+ const shard = routing.shardForObject(database, collection, object._id);
1778
+ if (routing.ownsShard(shard)) {
1779
+ row = mergeResponseOnlyFields(node.get(database, collection, object._id, { refDepth: 0 }) || object, object);
1780
+ }
1781
+ }
1782
+ out.push(await expandObjectForRead({ req, node, routing, database, object: row, refDepth }));
1783
+ }
1784
+ return out;
1785
+ }
1786
+
1787
+ function mergeResponseOnlyFields(base = {}, source = {}) {
1788
+ const out = { ...(base || {}) };
1789
+ for (const [key, value] of Object.entries(source || {})) {
1790
+ if (key === "_id" || key === "_vec") continue;
1791
+ if (key.startsWith("_") && out[key] === undefined) out[key] = value;
1792
+ }
1793
+ return out;
1794
+ }
1795
+
1796
+ function createReferenceResolver({ req, node, routing }) {
1797
+ const cache = new Map();
1798
+ return async (database, collection, id) => {
1799
+ const key = `${database}\0${collection}\0${id}`;
1800
+ if (!cache.has(key)) {
1801
+ cache.set(key, resolveReferencedObject({ req, node, routing, database, collection, id }));
1802
+ }
1803
+ return cache.get(key);
1804
+ };
1805
+ }
1806
+
1807
+ async function resolveReferencedObject({ req, node, routing, database, collection, id }) {
1808
+ const shard = routing.shardForObject(database, collection, id);
1809
+ if (routing.ownsShard(shard)) {
1810
+ const object = node.get(database, collection, id, { refDepth: 0 }) || null;
1811
+ if (object) assertReadAllowed(req, object, collection);
1812
+ return object;
1813
+ }
1814
+
1815
+ const remote = await routing.forwardToObject(req, database, collection, id, objectPath(database, collection, id), {
1816
+ method: "GET",
1817
+ query: refDepthQuery(0),
1818
+ retryStatuses: [404, 409, 503],
1819
+ });
1820
+ if (remote.status === 404) return null;
1821
+ if (!ok(remote.status)) throw remoteHttpError(remote, `reference lookup failed for ${database}/${collection}/${id}`);
1822
+ return remote.body ? stripReferenceObjects(remote.body) : null;
1823
+ }
1824
+
1825
+ function permissionUser(req) {
1826
+ return req.auth?.user || null;
1827
+ }
1828
+
1829
+ function rejectUnlessGlobalAdmin(req, res, auth) {
1830
+ if (isGlobalAdminRequest(req, auth)) return false;
1831
+ json(res, 403, { error: "global admin token required" });
1832
+ return true;
1833
+ }
1834
+
1835
+ function rejectUnlessDatabaseAdmin(req, res, auth) {
1836
+ if (isAdminRequest(req, auth)) return false;
1837
+ json(res, 403, { error: "database admin token required" });
1838
+ return true;
1839
+ }
1840
+
1841
+ function rejectIfReadOnlyDatabaseWrite(res, database) {
1842
+ if (!isReadOnlyDatabase(database)) return false;
1843
+ json(res, 403, { error: `database is read-only: ${database}` });
1844
+ return true;
1845
+ }
1846
+
1847
+ function isReadOnlyDatabase(database) {
1848
+ return READ_ONLY_DATABASES.has(database);
1849
+ }
1850
+
1851
+ function isGlobalAdminRequest(req, auth) {
1852
+ return auth.enabled === false || (req.auth?.claims?.globalUser === true && isAdminRequest(req, auth));
1853
+ }
1854
+
1855
+ function isAdminRequest(req, auth) {
1856
+ return auth.enabled === false || isPermissionBypass(permissionUser(req));
1857
+ }
1858
+
1859
+ function readCriteriaForRequest(req, criteria = {}, collection = req.params?.collection) {
1860
+ return mergeReadPermissionQuery(criteria, permissionUser(req), collection);
1861
+ }
1862
+
1863
+ function assertReadAllowed(req, object, collection = req.params?.collection) {
1864
+ if (!canRead(permissionUser(req), object, collection)) throw permissionError("read", object?._id);
1865
+ }
1866
+
1867
+ function assertCreateAllowed(req, objects = [], database = null) {
1868
+ assertDatabaseWritable(database);
1869
+ const user = permissionUser(req);
1870
+ for (const object of objects || []) {
1871
+ if (!canCreate(user, object, req.params?.collection)) throw permissionError("create", object?._id);
1872
+ }
1873
+ }
1874
+
1875
+ function assertUpsertAllowed(req, node, database, collection, objects = [], { partial = false } = {}) {
1876
+ assertDatabaseWritable(database);
1877
+ const user = permissionUser(req);
1878
+ const existing = existingObjectMap(node, database, collection, objects.map((object) => object._id));
1879
+ for (const object of objects || []) {
1880
+ const before = existing.get(String(object._id));
1881
+ const after = partial && before ? { ...before, ...object, _id: object._id } : object;
1882
+ const allowed = before ? canUpdate(user, before, after, collection) : canCreate(user, after, collection);
1883
+ if (!allowed) throw permissionError(before ? "update" : "create", object?._id);
1884
+ }
1885
+ }
1886
+
1887
+ function assertUpdateAllowed(req, node, database, collection, objects = []) {
1888
+ assertDatabaseWritable(database);
1889
+ const user = permissionUser(req);
1890
+ const existing = existingObjectMap(node, database, collection, objects.map((object) => object._id));
1891
+ for (const object of objects || []) {
1892
+ const before = existing.get(String(object._id));
1893
+ if (!before || !canUpdate(user, before, object, collection)) throw permissionError("update", object?._id);
1894
+ }
1895
+ }
1896
+
1897
+ function assertDeleteAllowed(req, objects = [], database = null) {
1898
+ assertDatabaseWritable(database);
1899
+ const user = permissionUser(req);
1900
+ for (const object of objects || []) {
1901
+ if (!canDelete(user, object, req.params?.collection)) throw permissionError("delete", object?._id);
1902
+ }
1903
+ }
1904
+
1905
+ function assertDatabaseWritable(database) {
1906
+ if (isReadOnlyDatabase(database)) throw permissionError("write", database);
1907
+ }
1908
+
1909
+ function existingObjectsForIds(node, database, collection, ids = []) {
1910
+ return node.getMany(database, collection, ids);
1911
+ }
1912
+
1913
+ function existingObjectMap(node, database, collection, ids = []) {
1914
+ return new Map(existingObjectsForIds(node, database, collection, ids).map((object) => [String(object._id), object]));
1915
+ }
1916
+
1917
+ function parseDatabaseAdminUser(body = {}) {
1918
+ if (body?.adminUser === undefined) return null;
1919
+ if (!isPlainObject(body.adminUser)) throw httpError(400, "body.adminUser must be a plain object");
1920
+
1921
+ const rawCollection = body.adminUser.collection;
1922
+ const collection = rawCollection === undefined || rawCollection === null || rawCollection === ""
1923
+ ? USERS_COLLECTION
1924
+ : rawCollection;
1925
+ if (typeof collection !== "string" || collection.length === 0) {
1926
+ throw httpError(400, "body.adminUser.collection must be a non-empty string when provided");
1927
+ }
1928
+
1929
+ if (typeof body.adminUser.username !== "string" || body.adminUser.username.length === 0) {
1930
+ throw httpError(400, "body.adminUser.username must be a non-empty string");
1931
+ }
1932
+ if (typeof body.adminUser.password !== "string" || body.adminUser.password.length === 0) {
1933
+ throw httpError(400, "body.adminUser.password must be a non-empty string");
1934
+ }
1935
+ if (
1936
+ body.adminUser._id !== undefined &&
1937
+ body.adminUser._id !== null &&
1938
+ String(body.adminUser._id).length === 0
1939
+ ) {
1940
+ throw httpError(400, "body.adminUser._id must be a non-empty string when provided");
1941
+ }
1942
+
1943
+ const { collection: _collection, ...user } = body.adminUser;
1944
+ return {
1945
+ collection,
1946
+ user: {
1947
+ ...user,
1948
+ ...(user._id !== undefined && user._id !== null ? { _id: String(user._id) } : { _id: createObjectId() }),
1949
+ admin: true,
1950
+ },
1951
+ };
1952
+ }
1953
+
1954
+ function ensureAdminUserCollection(node, database, collection) {
1955
+ const existing = node.getCollectionMeta(database, collection);
1956
+ const meta = {
1957
+ ...(existing || {}),
1958
+ indexes: uniqueStrings([...(existing?.indexes || []), "username"]),
1959
+ };
1960
+ return existing === null
1961
+ ? node.createCollection(database, collection, meta)
1962
+ : node.setCollectionMeta(database, collection, meta);
1963
+ }
1964
+
1965
+ async function assertCanCreateDatabaseAdmin({ req, routing, database, adminUser }) {
1966
+ const existingAdmins = await routing.find(req, database, adminUser.collection, { admin: true }, { limit: 1 });
1967
+ if ((existingAdmins.objects || []).length > 0) {
1968
+ throw httpError(409, `database already has an admin user in ${adminUser.collection}`);
1969
+ }
1970
+
1971
+ const existingUsername = await routing.find(req, database, adminUser.collection, { username: adminUser.user.username }, { limit: 1 });
1972
+ if ((existingUsername.objects || []).length > 0) {
1973
+ throw httpError(409, `username already exists in ${adminUser.collection}`);
1974
+ }
1975
+ }
1976
+
1977
+ function databaseCreateFanoutBody(name, body = {}) {
1978
+ return {
1979
+ name,
1980
+ ...(body.meta !== undefined ? { meta: body.meta } : {}),
1981
+ };
1982
+ }
1983
+
1984
+ function uniqueStrings(values) {
1985
+ return [...new Set(values.filter((value) => typeof value === "string" && value.length > 0))];
1986
+ }
1987
+
1988
+ async function afterLocalWrite(durability) {
1989
+ try {
1990
+ await durability?.afterWrite?.();
1991
+ } catch (err) {
1992
+ err.status = err.status || 500;
1993
+ throw err;
1994
+ }
1995
+ }
1996
+
1997
+ async function forwardShardBulkWrite(routing, req, shard, path, {
1998
+ method,
1999
+ items,
2000
+ body = items,
2001
+ query,
2002
+ minimalReturn = false,
2003
+ }) {
2004
+ if (minimalReturn && typeof routing.forwardToShardNdjson === "function") {
2005
+ return routing.forwardToShardNdjson(req, shard, path, { method, items, query });
2006
+ }
2007
+ return routing.forwardToShard(req, shard, path, { method, body, query });
2008
+ }
2009
+
2010
+ async function forwardSchemaChange(routing, req, path, options, message) {
2011
+ const { allowUnavailable = true, ...forwardOptions } = options;
2012
+ const remote = await routing.forwardToAllPeers(req, path, {
2013
+ ...forwardOptions,
2014
+ allowUnavailable,
2015
+ });
2016
+ if (!ok(remote.status)) throw remoteHttpError(remote, message);
2017
+ return remote;
2018
+ }
2019
+
2020
+ async function consumeNdjsonChunks({ req, imports, prepare, write }) {
2021
+ let entries = [];
2022
+ let bytes = 0;
2023
+ let batchIndex = 0;
2024
+ const maxObjects = Math.max(1, Number(imports.maxBulkObjects) || DEFAULT_IMPORT_CONFIG.maxBulkObjects);
2025
+ const maxBytes = Math.max(1, Number(imports.maxBulkBytes) || DEFAULT_IMPORT_CONFIG.maxBulkBytes);
2026
+
2027
+ for await (const entry of readNdjson(req, { maxObjectBytes: imports.maxObjectBytes })) {
2028
+ if (entry.bytes > maxBytes) {
2029
+ throw httpError(413, "bulk write is too large", {
2030
+ error: "bulk write is too large",
2031
+ line: entry.line,
2032
+ bytes: entry.bytes,
2033
+ maxBulkBytes: maxBytes,
2034
+ });
2035
+ }
2036
+ if (entries.length && bytes + entry.bytes > maxBytes) {
2037
+ await flushNdjsonChunk(entries, prepare, write, batchIndex++);
2038
+ entries = [];
2039
+ bytes = 0;
2040
+ }
2041
+ entries.push(entry);
2042
+ bytes += entry.bytes;
2043
+ if (entries.length >= maxObjects || bytes >= maxBytes) {
2044
+ await flushNdjsonChunk(entries, prepare, write, batchIndex++);
2045
+ entries = [];
2046
+ bytes = 0;
2047
+ }
2048
+ }
2049
+
2050
+ if (entries.length) await flushNdjsonChunk(entries, prepare, write, batchIndex);
2051
+ }
2052
+
2053
+ async function flushNdjsonChunk(entries, prepare, write, batchIndex) {
2054
+ const lines = entries.map((entry) => entry.line);
2055
+ try {
2056
+ const values = entries.map((entry) => entry.value);
2057
+ await write(prepare(values), batchIndex);
2058
+ } catch (err) {
2059
+ throw annotateStreamLine(err, lines);
2060
+ }
2061
+ }
2062
+
2063
+ function annotateStreamLine(err, lines) {
2064
+ if (err.line === undefined && err.index !== undefined && lines[err.index] !== undefined) {
2065
+ err.line = lines[err.index];
2066
+ }
2067
+ if (err.line !== undefined) {
2068
+ err.body = { ...(err.body || errorBody(err)), line: err.line };
2069
+ }
2070
+ return err;
2071
+ }
2072
+
2073
+ function prepareCreateObjects(files, database, collection, rawItems, { mutateIds = false } = {}) {
2074
+ const error = validatePlainObjects(rawItems);
2075
+ if (error) throw validationError(error);
2076
+ return normalizeFileObjects(files, database, collection, withObjectIds(rawItems, { mutate: mutateIds }));
2077
+ }
2078
+
2079
+ function validationError(error) {
2080
+ const err = httpError(400, error.error, error);
2081
+ err.index = error.index;
2082
+ return err;
2083
+ }
2084
+
2085
+ function parseNdjsonIds(items) {
2086
+ return items.map((item, index) => {
2087
+ if (typeof item === "string" && item.length > 0) return item;
2088
+ if (item && typeof item === "object" && !Array.isArray(item) && item._id !== undefined && item._id !== null) {
2089
+ const id = String(item._id);
2090
+ if (id.length > 0) return id;
2091
+ }
2092
+ const err = new TypeError(`id at index ${index} must be a non-empty string or object with _id`);
2093
+ err.index = index;
2094
+ throw err;
2095
+ });
2096
+ }
2097
+
2098
+ function rejectNdjsonWithoutMinimal(req, res) {
2099
+ if (prefersMinimalReturn(req)) return false;
2100
+ json(res, 400, { error: NDJSON_MINIMAL_REQUIRED });
2101
+ return true;
2102
+ }
2103
+
2104
+ function streamErrorBody(err, progress = {}) {
2105
+ return {
2106
+ ...(err.body || errorBody(err)),
2107
+ ...progress,
2108
+ };
2109
+ }
2110
+
2111
+ function withObjectIds(items, { mutate = false } = {}) {
2112
+ return items.map((item) => {
2113
+ const id = item._id !== undefined && item._id !== null ? String(item._id) : createObjectId();
2114
+ if (mutate) {
2115
+ item._id = id;
2116
+ return item;
2117
+ }
2118
+ return { ...item, _id: id };
2119
+ });
2120
+ }
2121
+
2122
+ function normalizeFileObjects(files, database, collection, objects = []) {
2123
+ return files ? objects.map((object) => normalizeFileObject(files, database, collection, object)) : objects;
2124
+ }
2125
+
2126
+ function normalizeFileObject(files, database, collection, object) {
2127
+ return files ? files.normalizeObject(database, collection, object) : object;
2128
+ }
2129
+
2130
+ function removedFiles(beforeMap, afterObjects = []) {
2131
+ if (!beforeMap) return [];
2132
+ const removed = [];
2133
+ for (const after of afterObjects || []) {
2134
+ const before = beforeMap.get(String(after?._id));
2135
+ if (before?._file && before._file.key !== after?._file?.key) removed.push(before);
2136
+ }
2137
+ return removed;
2138
+ }
2139
+
2140
+ async function deleteRemovedFiles(files, database, collection, beforeObjects = [], afterObjects = []) {
2141
+ if (!files) return;
2142
+ const beforeMap = new Map((beforeObjects || []).filter(Boolean).map((object) => [String(object._id), object]));
2143
+ await deleteObjectFiles(files, database, collection, removedFiles(beforeMap, afterObjects));
2144
+ }
2145
+
2146
+ async function deleteCollectionFilesBestEffort(files, database, collection) {
2147
+ if (!files) return;
2148
+ try {
2149
+ await files.deleteCollectionFiles(database, collection);
2150
+ } catch {
2151
+ // File cleanup is best-effort; the collection delete is already durable.
2152
+ }
2153
+ }
2154
+
2155
+ async function deleteDatabaseFilesBestEffort(files, database) {
2156
+ if (!files) return;
2157
+ try {
2158
+ await files.deleteDatabaseFiles(database);
2159
+ } catch {
2160
+ // File cleanup is best-effort; the database delete is already durable.
2161
+ }
2162
+ }
2163
+
2164
+ async function deleteObjectFiles(files, database, collection, objects = []) {
2165
+ if (!files) return;
2166
+ for (const object of objects || []) {
2167
+ try {
2168
+ await files.deleteObjectFile(database, collection, object);
2169
+ } catch {
2170
+ // File cleanup is best-effort; the database delete/replace is already durable.
2171
+ }
2172
+ }
2173
+ }
2174
+
2175
+ function remoteInsertedItems(body) {
2176
+ if (Array.isArray(body?.items)) return body.items;
2177
+ if (body && typeof body === "object" && body._id !== undefined) return [body];
2178
+ return [];
2179
+ }
2180
+
2181
+ function remoteInsertedCount(body, fallback) {
2182
+ if (Number.isFinite(body?.inserted)) return body.inserted;
2183
+ return fallback;
2184
+ }
2185
+
2186
+ function responseSkippedCount(body) {
2187
+ if (Number.isFinite(body?.skipped)) return body.skipped;
2188
+ if (Number.isFinite(body?.skippedCount)) return body.skippedCount;
2189
+ if (Array.isArray(body?.skipped)) return body.skipped.length;
2190
+ return 0;
2191
+ }
2192
+
2193
+ function orderLikeInput(input, rows) {
2194
+ const byId = new Map();
2195
+ for (const row of rows) {
2196
+ if (row?._id !== undefined && !byId.has(String(row._id))) byId.set(String(row._id), row);
2197
+ }
2198
+ return input.map((item) => byId.get(String(item._id))).filter(Boolean);
2199
+ }
2200
+
2201
+ function httpError(status, message, body, headers) {
2202
+ const err = new Error(message);
2203
+ err.status = status;
2204
+ if (body) err.body = body;
2205
+ if (headers) err.headers = headers;
2206
+ return err;
2207
+ }
2208
+
2209
+ function normalizeImportLimits(config = {}) {
2210
+ return {
2211
+ maxBulkObjects: positiveInt(config.maxBulkObjects, DEFAULT_IMPORT_CONFIG.maxBulkObjects),
2212
+ maxBulkBytes: positiveInt(config.maxBulkBytes, DEFAULT_IMPORT_CONFIG.maxBulkBytes),
2213
+ maxObjectBytes: positiveInt(config.maxObjectBytes, DEFAULT_IMPORT_CONFIG.maxObjectBytes),
2214
+ maxPendingPersistenceBytes: positiveInt(config.maxPendingPersistenceBytes, DEFAULT_IMPORT_CONFIG.maxPendingPersistenceBytes),
2215
+ retryAfterMs: positiveInt(config.retryAfterMs, DEFAULT_IMPORT_CONFIG.retryAfterMs),
2216
+ };
2217
+ }
2218
+
2219
+ function assertImportWriteAccepts({ req, imports, durability, objects = [], ignoreBodyBytes = false, validatedObjectBytes = false }) {
2220
+ if (objects.length > imports.maxBulkObjects) {
2221
+ throw httpError(413, "bulk write contains too many objects", {
2222
+ error: "bulk write contains too many objects",
2223
+ count: objects.length,
2224
+ maxBulkObjects: imports.maxBulkObjects,
2225
+ });
2226
+ }
2227
+
2228
+ const bodyBytes = ignoreBodyBytes ? 0 : jsonRequestBytes(req);
2229
+ if (bodyBytes > imports.maxBulkBytes) {
2230
+ throw httpError(413, "bulk write is too large", {
2231
+ error: "bulk write is too large",
2232
+ bytes: bodyBytes,
2233
+ maxBulkBytes: imports.maxBulkBytes,
2234
+ });
2235
+ }
2236
+
2237
+ if (!validatedObjectBytes) assertObjectByteLimit(objects, imports.maxObjectBytes);
2238
+ const pendingBytes = pendingPersistenceBytes(durability);
2239
+ if (pendingBytes > imports.maxPendingPersistenceBytes) {
2240
+ const retryAfterSeconds = Math.max(1, Math.ceil(imports.retryAfterMs / 1000));
2241
+ throw httpError(503, "persistence backlog is full", {
2242
+ error: "persistence backlog is full",
2243
+ pendingBytes,
2244
+ maxPendingPersistenceBytes: imports.maxPendingPersistenceBytes,
2245
+ }, {
2246
+ "retry-after": String(retryAfterSeconds),
2247
+ "x-objydb-pressure": "persistence",
2248
+ });
2249
+ }
2250
+ }
2251
+
2252
+ function assertObjectByteLimit(objects, maxObjectBytes) {
2253
+ if (!maxObjectBytes) return;
2254
+ for (let i = 0; i < objects.length; i++) {
2255
+ const bytes = jsonByteLength(objects[i], `write object ${i}`);
2256
+ if (bytes <= maxObjectBytes) continue;
2257
+ const err = httpError(413, "object is too large", {
2258
+ error: "object is too large",
2259
+ index: i,
2260
+ bytes,
2261
+ maxObjectBytes,
2262
+ });
2263
+ err.index = i;
2264
+ throw err;
2265
+ }
2266
+ }
2267
+
2268
+ function jsonRequestBytes(req) {
2269
+ const type = String(req.headers?.["content-type"] || "");
2270
+ return type.includes("application/json") ? Number(req._bodyBytes) || 0 : 0;
2271
+ }
2272
+
2273
+ function pendingPersistenceBytes(durability) {
2274
+ const stats = durability?.getStats?.() || {};
2275
+ return (Number(stats.pendingBytes) || 0) + (Number(stats.localWal?.pendingBytes) || 0);
2276
+ }
2277
+
2278
+ function jsonByteLength(value, label) {
2279
+ try {
2280
+ return Buffer.byteLength(JSON.stringify(value));
2281
+ } catch (err) {
2282
+ if (err instanceof RangeError) {
2283
+ throw httpError(413, "object is too large", { error: "object is too large", label });
2284
+ }
2285
+ throw err;
2286
+ }
2287
+ }
2288
+
2289
+ function rejectIfMemoryGuardBlocked(res, memoryGuard) {
2290
+ const rejection = memoryGuard?.writeRejection?.();
2291
+ if (!rejection) return false;
2292
+ json(res, rejection.status, rejection.body, rejection.headers || {});
2293
+ return true;
2294
+ }
2295
+
2296
+ function assertMemoryGuardAcceptsWrites(memoryGuard) {
2297
+ memoryGuard?.assertCanWrite?.();
2298
+ }
2299
+
2300
+ function jsonWithQueryPath(res, status, body, path) {
2301
+ const value = normalizeQueryPath(path);
2302
+ return json(res, status, body, value ? { [QUERY_PATH_HEADER]: value } : {});
2303
+ }
2304
+
2305
+ function referencePath(path) {
2306
+ const value = normalizeQueryPath(path);
2307
+ return value ? `reference-${value}` : "reference-scan";
2308
+ }
2309
+
2310
+ function aggregateReferencePath(path) {
2311
+ const value = normalizeQueryPath(path);
2312
+ return value ? `aggregate-reference-${value}` : "aggregate-reference-scan";
2313
+ }
2314
+
2315
+ function normalizeQueryPath(path) {
2316
+ const value = String(path || "").trim();
2317
+ return value.length ? value : null;
2318
+ }
2319
+
2320
+ function secureRoute(auth) {
2321
+ return (handler) => async (req, res) => {
2322
+ const result = auth.authenticateRequest(req);
2323
+ if (!result.ok) return json(res, result.status, { error: result.error, code: result.code, operationStarted: false });
2324
+ res.operationStarted = true;
2325
+ return handler(req, res);
2326
+ };
2327
+ }
2328
+
2329
+ function parseQueryParam(req) {
2330
+ const raw = req.query.get("q");
2331
+ if (!raw) return {};
2332
+ const parsed = JSON.parse(raw);
2333
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
2334
+ throw new TypeError("q must be a JSON object");
2335
+ }
2336
+ return parsed;
2337
+ }
2338
+
2339
+ function queryOptions(req, controls = {}) {
2340
+ const parsedSort = parseQuerySort(controls.$sort);
2341
+ const limit = publicQueryLimit(firstQueryInteger(
2342
+ req.query.get("limit"),
2343
+ req.query.get("$limit"),
2344
+ controls.$limit,
2345
+ controls.$pageSize,
2346
+ ));
2347
+ const offset = firstQueryInteger(
2348
+ req.query.get("offset"),
2349
+ req.query.get("skip"),
2350
+ req.query.get("$offset"),
2351
+ req.query.get("$skip"),
2352
+ controls.$offset,
2353
+ controls.$skip,
2354
+ queryPageOffset(controls.$page, limit),
2355
+ );
2356
+ return {
2357
+ sortBy: req.query.get("sortBy") || parsedSort.sortBy || undefined,
2358
+ sort: req.query.get("sort") || parsedSort.sort || undefined,
2359
+ limit,
2360
+ offset,
2361
+ refDepth: refDepthParam(req, controls),
2362
+ };
2363
+ }
2364
+
2365
+ function parseVectorSearch(value) {
2366
+ if (!isPlainObject(value)) {
2367
+ throw httpError(400, "$vector must be an object with .vector");
2368
+ }
2369
+ if (!isValidVector(value.vector)) {
2370
+ throw httpError(400, "$vector.vector must be a non-empty array of finite numbers");
2371
+ }
2372
+ return {
2373
+ queryVec: value.vector,
2374
+ limit: Number.isFinite(value.limit) && value.limit > 0 ? Math.floor(value.limit) : 20,
2375
+ ef: Number.isFinite(value.ef) && value.ef > 0 ? Math.floor(value.ef) : undefined,
2376
+ };
2377
+ }
2378
+
2379
+ function refDepthParam(req, controls = {}) {
2380
+ if (req?.query?.has("$refDepth")) return normalizeRefDepth(req.query.get("$refDepth"), 1);
2381
+ if (req?.query?.has("refDepth")) return normalizeRefDepth(req.query.get("refDepth"), 1);
2382
+ if (Object.prototype.hasOwnProperty.call(controls, "$refDepth")) return normalizeRefDepth(controls.$refDepth, 1);
2383
+ return 1;
2384
+ }
2385
+
2386
+ function refDepthQuery(depth) {
2387
+ const params = new URLSearchParams();
2388
+ params.set("$refDepth", String(normalizeRefDepth(depth, 0)));
2389
+ return params.toString();
2390
+ }
2391
+
2392
+ function positiveInt(value, fallback) {
2393
+ const n = Number(value);
2394
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : fallback;
2395
+ }
2396
+
2397
+ function publicQueryLimit(value) {
2398
+ const limit = value === undefined ? DEFAULT_PUBLIC_QUERY_LIMIT : value;
2399
+ return Math.min(limit, MAX_PUBLIC_QUERY_LIMIT);
2400
+ }
2401
+
2402
+ function validatePlainObjects(items, { requireId = false } = {}) {
2403
+ for (let i = 0; i < items.length; i++) {
2404
+ const item = items[i];
2405
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
2406
+ return { error: `item at index ${i} must be a plain object`, index: i };
2407
+ }
2408
+ if (!requireId && item._id !== undefined && item._id !== null && String(item._id).length === 0) {
2409
+ return { error: `item at index ${i} must include a non-empty _id when _id is provided`, index: i };
2410
+ }
2411
+ if (requireId && (item._id === undefined || item._id === null || String(item._id).length === 0)) {
2412
+ return { error: `item at index ${i} must include _id`, index: i };
2413
+ }
2414
+ }
2415
+ return null;
2416
+ }
2417
+
2418
+ function isPlainObject(value) {
2419
+ return value !== null && typeof value === "object" && !Array.isArray(value);
2420
+ }
2421
+
2422
+ function collectionMetaFromBody(body) {
2423
+ if (body.meta !== undefined) {
2424
+ if (!isPlainObject(body.meta)) throw new TypeError("body.meta must be a plain object");
2425
+ return body.meta;
2426
+ }
2427
+ const { name, ...meta } = body || {};
2428
+ return meta;
2429
+ }
2430
+
2431
+ function parseBulkIds(body) {
2432
+ const source = Array.isArray(body) ? body : body?.ids;
2433
+ if (!Array.isArray(source)) throw new TypeError("body must be an array of ids or { ids: [...] }");
2434
+ return source.map((item, index) => {
2435
+ if (typeof item === "string" && item.length > 0) return item;
2436
+ if (item && typeof item === "object" && item._id !== undefined && item._id !== null) return String(item._id);
2437
+ const err = new TypeError(`id at index ${index} must be a non-empty string or object with _id`);
2438
+ err.index = index;
2439
+ throw err;
2440
+ });
2441
+ }
2442
+
2443
+ function errorBody(err) {
2444
+ return {
2445
+ error: err.message,
2446
+ ...(err.index !== undefined ? { index: err.index } : {}),
2447
+ };
2448
+ }
2449
+
2450
+ function prefersMinimalReturn(req) {
2451
+ return /\breturn=minimal\b/i.test(req.headers.prefer || "");
2452
+ }