@ignex/ninox 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +1000 -0
- package/dist/index.d.ts +2570 -0
- package/dist/index.js +4370 -0
- package/dist/index.js.map +1 -0
- package/dist/timeout-BgrqtMnd.d.ts +95 -0
- package/dist/utils.d.ts +13 -0
- package/dist/utils.js +223 -0
- package/dist/utils.js.map +1 -0
- package/package.json +83 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,4370 @@
|
|
|
1
|
+
import { ObjectId, MongoClient, Decimal128 } from 'mongodb';
|
|
2
|
+
import { writeFile, mkdir, readdir } from 'fs/promises';
|
|
3
|
+
import { basename, join } from 'path';
|
|
4
|
+
import { pathToFileURL } from 'url';
|
|
5
|
+
|
|
6
|
+
// src/utils/timeout.ts
|
|
7
|
+
var withTimeout = (promise, timeoutMs, label) => {
|
|
8
|
+
if (timeoutMs <= 0) return promise;
|
|
9
|
+
let timer;
|
|
10
|
+
const timeout = new Promise((_, reject) => {
|
|
11
|
+
timer = setTimeout(() => {
|
|
12
|
+
reject(new Error(`Operation timed out after ${timeoutMs}ms: ${label}`));
|
|
13
|
+
}, timeoutMs);
|
|
14
|
+
});
|
|
15
|
+
return Promise.race([promise, timeout]).finally(() => {
|
|
16
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
17
|
+
});
|
|
18
|
+
};
|
|
19
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
20
|
+
var sleepJittered = (ms, jitterMs = 1e3) => sleep(ms + Math.floor(Math.random() * Math.min(ms, jitterMs)));
|
|
21
|
+
|
|
22
|
+
// src/capabilities.ts
|
|
23
|
+
var createMongoCapabilitiesStore = () => {
|
|
24
|
+
let state = { probed: false, transactionsSupported: false };
|
|
25
|
+
return {
|
|
26
|
+
get: () => state,
|
|
27
|
+
set: (capabilities) => {
|
|
28
|
+
state = capabilities;
|
|
29
|
+
},
|
|
30
|
+
reset: () => {
|
|
31
|
+
state = { probed: false, transactionsSupported: false };
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
var readMongoTransactionsEnvOverride = () => {
|
|
36
|
+
const raw = process.env.MONGO_USE_TRANSACTIONS;
|
|
37
|
+
if (raw === void 0) return void 0;
|
|
38
|
+
return ["1", "true", "yes"].includes(raw.toLowerCase());
|
|
39
|
+
};
|
|
40
|
+
var probeMongoCapabilities = async (client) => {
|
|
41
|
+
try {
|
|
42
|
+
const db = client && "startSession" in client ? client.db() : client;
|
|
43
|
+
if (!db) return { probed: false, transactionsSupported: false };
|
|
44
|
+
const hello = await withTimeout(
|
|
45
|
+
db.admin().command({ hello: 1 }),
|
|
46
|
+
5e3,
|
|
47
|
+
"probeMongoCapabilities: hello"
|
|
48
|
+
);
|
|
49
|
+
const isReplicaSet = typeof hello.setName === "string" && hello.setName.length > 0;
|
|
50
|
+
const isMongos = hello.msg === "isdbgrid";
|
|
51
|
+
return { probed: true, transactionsSupported: isReplicaSet || isMongos };
|
|
52
|
+
} catch {
|
|
53
|
+
return { probed: false, transactionsSupported: false };
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
var mongoTransactionsEnabled = (store, options = {}) => {
|
|
57
|
+
const env = options.envOverride ?? readMongoTransactionsEnvOverride();
|
|
58
|
+
if (env !== void 0) return env;
|
|
59
|
+
const state = store.get();
|
|
60
|
+
if (state.probed) return state.transactionsSupported;
|
|
61
|
+
if (options.urlHint) return /replicaSet=/.test(options.urlHint);
|
|
62
|
+
return false;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// src/errors/classes.ts
|
|
66
|
+
var ERROR_HTTP_STATUS = {
|
|
67
|
+
BAD_REQUEST: 400,
|
|
68
|
+
NOT_FOUND: 404,
|
|
69
|
+
DUPLICATE_KEY: 409,
|
|
70
|
+
VERSION_CONFLICT: 409,
|
|
71
|
+
COLLECTION_EXISTS: 409,
|
|
72
|
+
SCHEMA_DRIFT: 409,
|
|
73
|
+
VALIDATION_FAILED: 422,
|
|
74
|
+
MONGO_TIMEOUT: 504
|
|
75
|
+
};
|
|
76
|
+
var AppError = class extends Error {
|
|
77
|
+
code;
|
|
78
|
+
statusCode;
|
|
79
|
+
extra;
|
|
80
|
+
constructor(code, message, statusCode = 500, extra) {
|
|
81
|
+
super(message);
|
|
82
|
+
this.name = "AppError";
|
|
83
|
+
this.code = code;
|
|
84
|
+
this.statusCode = statusCode;
|
|
85
|
+
this.extra = extra;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Stable, client-safe serialization for framework error middleware: a
|
|
89
|
+
* framework can forward `JSON.stringify(error)` (or `error.toJSON()`) straight
|
|
90
|
+
* to the client — no parsing of server internals. `stack` is dropped; the
|
|
91
|
+
* shape is always `{ name, code, message, statusCode, ...(extra) }`.
|
|
92
|
+
*/
|
|
93
|
+
toJSON() {
|
|
94
|
+
return {
|
|
95
|
+
name: this.name,
|
|
96
|
+
code: this.code,
|
|
97
|
+
message: this.message,
|
|
98
|
+
statusCode: this.statusCode,
|
|
99
|
+
...this.extra !== void 0 ? { extra: this.extra } : {}
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
var DomainError = class extends AppError {
|
|
104
|
+
constructor(code, message, extra) {
|
|
105
|
+
super(code, message, ERROR_HTTP_STATUS[code] ?? 400, extra);
|
|
106
|
+
this.name = "DomainError";
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
var InfraError = class extends AppError {
|
|
110
|
+
constructor(code, message, extra) {
|
|
111
|
+
super(code, message, ERROR_HTTP_STATUS[code] ?? 500, extra);
|
|
112
|
+
this.name = "InfraError";
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
var BadRequest = class extends DomainError {
|
|
116
|
+
constructor(message, extra) {
|
|
117
|
+
super("BAD_REQUEST", message, extra);
|
|
118
|
+
this.name = "BadRequest";
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
var isAppError = (err) => err instanceof AppError;
|
|
122
|
+
var isDomainError = (err) => err instanceof DomainError;
|
|
123
|
+
var isInfraError = (err) => err instanceof InfraError;
|
|
124
|
+
|
|
125
|
+
// src/errors/transient.ts
|
|
126
|
+
var TRANSIENT_MONGO_ERROR_CODES = Object.freeze(
|
|
127
|
+
/* @__PURE__ */ new Set([
|
|
128
|
+
6,
|
|
129
|
+
// HostUnreachable
|
|
130
|
+
7,
|
|
131
|
+
// HostNotFound
|
|
132
|
+
89,
|
|
133
|
+
// NetworkTimeout
|
|
134
|
+
91,
|
|
135
|
+
// ShutdownInProgress
|
|
136
|
+
189,
|
|
137
|
+
// PrimarySteppedDown
|
|
138
|
+
9001,
|
|
139
|
+
// SocketException
|
|
140
|
+
10107,
|
|
141
|
+
// NotWritablePrimary
|
|
142
|
+
11600,
|
|
143
|
+
// InterruptedAtShutdown
|
|
144
|
+
11602,
|
|
145
|
+
// InterruptedDueToReplStateChange
|
|
146
|
+
13435,
|
|
147
|
+
// NotPrimaryNoSecondaryOk
|
|
148
|
+
13436
|
|
149
|
+
// NotPrimaryOrSecondary
|
|
150
|
+
])
|
|
151
|
+
);
|
|
152
|
+
var TRANSIENT_NAME = /MongoNetworkError|PoolClearedError|MongoWriteConcernError|MongoNotPrimaryError|MongoNodeIsRecoveringError|MongoNetworkTimeoutError/;
|
|
153
|
+
var isMongoTransientError = (error) => {
|
|
154
|
+
if (!error || typeof error !== "object") return false;
|
|
155
|
+
const e = error;
|
|
156
|
+
if (typeof e.name === "string" && TRANSIENT_NAME.test(e.name)) return true;
|
|
157
|
+
if (typeof e.code === "number" && TRANSIENT_MONGO_ERROR_CODES.has(e.code)) return true;
|
|
158
|
+
return false;
|
|
159
|
+
};
|
|
160
|
+
var isMongoDuplicateKeyError = (error) => {
|
|
161
|
+
if (!error || typeof error !== "object") return false;
|
|
162
|
+
const e = error;
|
|
163
|
+
if (e.code === 11e3) return true;
|
|
164
|
+
if (typeof e.message === "string" && /E11000|duplicate key/i.test(e.message)) return true;
|
|
165
|
+
return false;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// src/errors/driver-map.ts
|
|
169
|
+
var extractValidationPaths = (errInfo) => {
|
|
170
|
+
if (!errInfo || typeof errInfo !== "object") return [];
|
|
171
|
+
const info = errInfo;
|
|
172
|
+
const out = [];
|
|
173
|
+
for (const rule of info.details?.schemaRulesNotSatisfied ?? []) {
|
|
174
|
+
if (typeof rule.propertyName === "string") out.push(rule.propertyName);
|
|
175
|
+
for (const prop of rule.propertiesNotSatisfied ?? []) {
|
|
176
|
+
if (typeof prop.propertyName === "string") out.push(prop.propertyName);
|
|
177
|
+
}
|
|
178
|
+
for (const prop of rule.additionalProperties ?? []) {
|
|
179
|
+
if (typeof prop === "string") out.push(prop);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return [...new Set(out)];
|
|
183
|
+
};
|
|
184
|
+
var addValidationContext = (context, errInfo) => {
|
|
185
|
+
if (!errInfo || typeof errInfo !== "object") return;
|
|
186
|
+
const info = errInfo;
|
|
187
|
+
if (info.failingDocumentId !== void 0) context.documentId = info.failingDocumentId;
|
|
188
|
+
const fields = extractValidationPaths(errInfo);
|
|
189
|
+
if (fields.length > 0) context.fields = fields;
|
|
190
|
+
if (info.details !== void 0) context.details = info.details;
|
|
191
|
+
};
|
|
192
|
+
var isBulkWriteError = (error) => typeof error === "object" && error !== null && "writeErrors" in error && Array.isArray(error.writeErrors);
|
|
193
|
+
var mapMongoDriverError = (error, ctx = {}) => {
|
|
194
|
+
if (error instanceof AppError) return error;
|
|
195
|
+
const context = { ...ctx };
|
|
196
|
+
if (isMongoDuplicateKeyError(error)) {
|
|
197
|
+
const e = error;
|
|
198
|
+
if (e.keyPattern) context.keyPattern = e.keyPattern;
|
|
199
|
+
if (e.keyValue) context.keyValue = e.keyValue;
|
|
200
|
+
return new DomainError("DUPLICATE_KEY", "Duplicate key error", context);
|
|
201
|
+
}
|
|
202
|
+
if (isBulkWriteError(error)) {
|
|
203
|
+
const first = error.writeErrors[0];
|
|
204
|
+
if (first?.code === 11e3) {
|
|
205
|
+
if (first.keyPattern) context.keyPattern = first.keyPattern;
|
|
206
|
+
if (first.keyValue) context.keyValue = first.keyValue;
|
|
207
|
+
return new DomainError("DUPLICATE_KEY", first.errmsg ?? "Duplicate key error", context);
|
|
208
|
+
}
|
|
209
|
+
if (first?.code === 121) {
|
|
210
|
+
addValidationContext(context, first.errInfo);
|
|
211
|
+
return new DomainError("VALIDATION_FAILED", "Document failed schema validation", context);
|
|
212
|
+
}
|
|
213
|
+
return new InfraError("MONGO_QUERY_ERROR", "Bulk write failed", context);
|
|
214
|
+
}
|
|
215
|
+
const code = error.code;
|
|
216
|
+
switch (code) {
|
|
217
|
+
case 112:
|
|
218
|
+
return new DomainError("VERSION_CONFLICT", "Document version conflict", context);
|
|
219
|
+
case 50:
|
|
220
|
+
return new InfraError("MONGO_TIMEOUT", "MongoDB operation timed out", context);
|
|
221
|
+
case 121: {
|
|
222
|
+
addValidationContext(context, error.errInfo);
|
|
223
|
+
return new DomainError("VALIDATION_FAILED", "Document failed schema validation", context);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (isMongoTransientError(error)) {
|
|
227
|
+
const e = error;
|
|
228
|
+
return new InfraError("MONGO_QUERY_ERROR", e.message ?? "Transient MongoDB error", context);
|
|
229
|
+
}
|
|
230
|
+
const msg = error.message ?? "MongoDB query error";
|
|
231
|
+
return new InfraError("MONGO_QUERY_ERROR", msg, context);
|
|
232
|
+
};
|
|
233
|
+
var isMappedMongoError = (error) => error instanceof AppError;
|
|
234
|
+
|
|
235
|
+
// src/errors/http-status.ts
|
|
236
|
+
var httpStatusForError = (err) => {
|
|
237
|
+
if (err instanceof AppError) return err.statusCode;
|
|
238
|
+
if (isMongoTransientError(err)) return 503;
|
|
239
|
+
return 500;
|
|
240
|
+
};
|
|
241
|
+
var serializeError = (err, ctx = {}) => {
|
|
242
|
+
const mapped = mapMongoDriverError(err, ctx);
|
|
243
|
+
if (mapped instanceof AppError) return mapped.toJSON();
|
|
244
|
+
if (mapped instanceof Error) {
|
|
245
|
+
return { name: mapped.name, code: "UNKNOWN", message: mapped.message, statusCode: 500 };
|
|
246
|
+
}
|
|
247
|
+
return { name: "UnknownError", code: "UNKNOWN", message: String(mapped), statusCode: 500 };
|
|
248
|
+
};
|
|
249
|
+
var cloneDeep = (value) => {
|
|
250
|
+
if (value instanceof Date) return new Date(value.getTime());
|
|
251
|
+
if (value instanceof ObjectId) return new ObjectId(value.toHexString());
|
|
252
|
+
if (value instanceof RegExp) return new RegExp(value.source, value.flags);
|
|
253
|
+
if (Array.isArray(value)) return value.map((item) => cloneDeep(item));
|
|
254
|
+
if (value !== null && typeof value === "object") {
|
|
255
|
+
const out = {};
|
|
256
|
+
for (const [k, v] of Object.entries(value)) {
|
|
257
|
+
out[k] = cloneDeep(v);
|
|
258
|
+
}
|
|
259
|
+
return out;
|
|
260
|
+
}
|
|
261
|
+
return value;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
// src/utils/hash.ts
|
|
265
|
+
var isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
266
|
+
var toPrimitive = (v) => {
|
|
267
|
+
if (v === void 0 || v === null) return null;
|
|
268
|
+
if (typeof v === "bigint") return v.toString();
|
|
269
|
+
if (v instanceof Date) return `date:${v.getTime()}`;
|
|
270
|
+
if (typeof v === "object") {
|
|
271
|
+
const anyV = v;
|
|
272
|
+
if (typeof anyV.toHexString === "function") return `oid:${anyV.toHexString()}`;
|
|
273
|
+
if (anyV instanceof RegExp) return `re:${anyV.source}/${anyV.flags}`;
|
|
274
|
+
}
|
|
275
|
+
return v;
|
|
276
|
+
};
|
|
277
|
+
var stableStringify = (value) => {
|
|
278
|
+
const visit = (v) => {
|
|
279
|
+
if (Array.isArray(v)) return v.map(visit);
|
|
280
|
+
if (isPlainObject(v)) {
|
|
281
|
+
const out = {};
|
|
282
|
+
for (const key of Object.keys(v).sort()) out[key] = visit(v[key]);
|
|
283
|
+
return out;
|
|
284
|
+
}
|
|
285
|
+
return toPrimitive(v);
|
|
286
|
+
};
|
|
287
|
+
return JSON.stringify(visit(value));
|
|
288
|
+
};
|
|
289
|
+
var hashString = (input) => {
|
|
290
|
+
let hash = 5381;
|
|
291
|
+
for (let i = 0; i < input.length; i++) {
|
|
292
|
+
hash = (hash << 5) + hash + input.charCodeAt(i) | 0;
|
|
293
|
+
}
|
|
294
|
+
return (hash >>> 0).toString(36);
|
|
295
|
+
};
|
|
296
|
+
var stableHash = (value) => hashString(stableStringify(value));
|
|
297
|
+
|
|
298
|
+
// src/utils/lru.ts
|
|
299
|
+
var LRU = class {
|
|
300
|
+
max;
|
|
301
|
+
map = /* @__PURE__ */ new Map();
|
|
302
|
+
/** Number of entries evicted to make room for new ones (capacity evictions only). */
|
|
303
|
+
evictions = 0;
|
|
304
|
+
constructor(options) {
|
|
305
|
+
if (options.max <= 0) throw new Error("LRU max must be > 0");
|
|
306
|
+
this.max = options.max;
|
|
307
|
+
}
|
|
308
|
+
get size() {
|
|
309
|
+
return this.map.size;
|
|
310
|
+
}
|
|
311
|
+
get(key) {
|
|
312
|
+
const value = this.map.get(key);
|
|
313
|
+
if (value === void 0) return void 0;
|
|
314
|
+
this.map.delete(key);
|
|
315
|
+
this.map.set(key, value);
|
|
316
|
+
return value;
|
|
317
|
+
}
|
|
318
|
+
peek(key) {
|
|
319
|
+
return this.map.get(key);
|
|
320
|
+
}
|
|
321
|
+
has(key) {
|
|
322
|
+
return this.map.has(key);
|
|
323
|
+
}
|
|
324
|
+
set(key, value) {
|
|
325
|
+
if (this.map.has(key)) this.map.delete(key);
|
|
326
|
+
this.map.set(key, value);
|
|
327
|
+
if (this.map.size > this.max) {
|
|
328
|
+
const oldest = this.map.keys().next().value;
|
|
329
|
+
if (oldest !== void 0) {
|
|
330
|
+
this.map.delete(oldest);
|
|
331
|
+
this.evictions++;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return this;
|
|
335
|
+
}
|
|
336
|
+
delete(key) {
|
|
337
|
+
return this.map.delete(key);
|
|
338
|
+
}
|
|
339
|
+
clear() {
|
|
340
|
+
this.map.clear();
|
|
341
|
+
}
|
|
342
|
+
keys() {
|
|
343
|
+
return this.map.keys();
|
|
344
|
+
}
|
|
345
|
+
entries() {
|
|
346
|
+
return this.map.entries();
|
|
347
|
+
}
|
|
348
|
+
[Symbol.iterator]() {
|
|
349
|
+
return this.map.entries();
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
// src/cache/in-flight.ts
|
|
354
|
+
var InFlight = class {
|
|
355
|
+
pending = /* @__PURE__ */ new Map();
|
|
356
|
+
get size() {
|
|
357
|
+
return this.pending.size;
|
|
358
|
+
}
|
|
359
|
+
run(key, fn) {
|
|
360
|
+
const existing = this.pending.get(key);
|
|
361
|
+
if (existing) return existing;
|
|
362
|
+
const promise = fn().finally(() => {
|
|
363
|
+
this.pending.delete(key);
|
|
364
|
+
});
|
|
365
|
+
this.pending.set(key, promise);
|
|
366
|
+
return promise;
|
|
367
|
+
}
|
|
368
|
+
clear() {
|
|
369
|
+
this.pending.clear();
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
var estimateSize = (value) => {
|
|
373
|
+
if (value === null || value === void 0) return 0;
|
|
374
|
+
if (typeof value === "string") return value.length * 2;
|
|
375
|
+
if (typeof value === "number" || typeof value === "boolean") return 8;
|
|
376
|
+
if (value instanceof ObjectId) return 16;
|
|
377
|
+
if (value instanceof Date) return 8;
|
|
378
|
+
if (Array.isArray(value)) return value.reduce((sum, v) => sum + estimateSize(v), 0);
|
|
379
|
+
if (typeof value === "object") {
|
|
380
|
+
let sum = 0;
|
|
381
|
+
for (const [k, v] of Object.entries(value)) {
|
|
382
|
+
sum += k.length * 2 + estimateSize(v);
|
|
383
|
+
}
|
|
384
|
+
return sum;
|
|
385
|
+
}
|
|
386
|
+
return 0;
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
// src/cache/hot-cache/ticker.ts
|
|
390
|
+
var RefreshTicker = class {
|
|
391
|
+
constructor(host, tickIntervalMs) {
|
|
392
|
+
this.host = host;
|
|
393
|
+
this.intervalMs = tickIntervalMs;
|
|
394
|
+
}
|
|
395
|
+
timer;
|
|
396
|
+
intervalMs;
|
|
397
|
+
/** Whether the timer is currently running. */
|
|
398
|
+
get running() {
|
|
399
|
+
return this.timer !== void 0;
|
|
400
|
+
}
|
|
401
|
+
/** Start the interval (idempotent). The timer is `.unref()`'d so it never holds the process open. */
|
|
402
|
+
start() {
|
|
403
|
+
if (this.timer) return;
|
|
404
|
+
this.timer = setInterval(() => {
|
|
405
|
+
try {
|
|
406
|
+
this.tick();
|
|
407
|
+
} catch (err) {
|
|
408
|
+
this.host.logger.warn?.(
|
|
409
|
+
{ error: err instanceof Error ? err.message : String(err) },
|
|
410
|
+
"hot cache tick failed"
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
}, this.intervalMs);
|
|
414
|
+
const t = this.timer;
|
|
415
|
+
t?.unref?.();
|
|
416
|
+
}
|
|
417
|
+
/** Stop the interval (idempotent). */
|
|
418
|
+
stop() {
|
|
419
|
+
if (this.timer) {
|
|
420
|
+
clearInterval(this.timer);
|
|
421
|
+
this.timer = void 0;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
/** Background refresh pass: re-run loaders for due entries, swap fresh values. */
|
|
425
|
+
tick() {
|
|
426
|
+
const now = Date.now();
|
|
427
|
+
for (const q of this.host.queries().values()) {
|
|
428
|
+
if (q.refreshIntervalMs <= 0) continue;
|
|
429
|
+
for (const [key, entry] of [...q.lru.entries()]) {
|
|
430
|
+
if (entry.nextRefreshAt <= now) {
|
|
431
|
+
q.refreshes++;
|
|
432
|
+
void this.host.fetch(q, key, entry.args, q.gen).catch((err) => {
|
|
433
|
+
const current = q.lru.peek(key);
|
|
434
|
+
if (current) current.nextRefreshAt = now + q.refreshIntervalMs;
|
|
435
|
+
this.host.logger.warn?.(
|
|
436
|
+
{
|
|
437
|
+
error: err instanceof Error ? err.message : String(err),
|
|
438
|
+
query: q.name
|
|
439
|
+
},
|
|
440
|
+
"hot cache background refresh failed (stale value retained)"
|
|
441
|
+
);
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* In standalone mode the ticker only refreshes queries with an explicit
|
|
449
|
+
* `refreshIntervalMs`. A query with neither `refreshIntervalMs` nor `ttlMs`
|
|
450
|
+
* set is served until it is manually invalidated — an easy way to serve
|
|
451
|
+
* unbounded-stale data. Surface those queries once so the operator opts in
|
|
452
|
+
* deliberately.
|
|
453
|
+
*/
|
|
454
|
+
warnUnboundedStaleness() {
|
|
455
|
+
const unbounded = [...this.host.queries().values()].filter(
|
|
456
|
+
(q) => q.refreshIntervalMs <= 0 && q.ttlMs <= 0
|
|
457
|
+
);
|
|
458
|
+
if (unbounded.length === 0) return;
|
|
459
|
+
this.host.logger.warn?.(
|
|
460
|
+
{ queries: unbounded.map((q) => q.name) },
|
|
461
|
+
"hot cache: standalone mode \u2014 these queries have no refreshIntervalMs/ttlMs, so values are served until manually invalidated (unbounded staleness); set refreshIntervalMs or ttlMs to bound it"
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
// src/cache/hot-cache/types.ts
|
|
467
|
+
var WATCH_SEP = "\0";
|
|
468
|
+
|
|
469
|
+
// src/cache/hot-cache/watcher.ts
|
|
470
|
+
var WatchCoordinator = class {
|
|
471
|
+
constructor(host) {
|
|
472
|
+
this.host = host;
|
|
473
|
+
}
|
|
474
|
+
streams = /* @__PURE__ */ new Map();
|
|
475
|
+
failedStreams = /* @__PURE__ */ new Set();
|
|
476
|
+
stopped = true;
|
|
477
|
+
/** Number of currently open change streams. */
|
|
478
|
+
get size() {
|
|
479
|
+
return this.streams.size;
|
|
480
|
+
}
|
|
481
|
+
/** Mark the coordinator as running and open watchers for all watch refs (idempotent). */
|
|
482
|
+
start() {
|
|
483
|
+
this.stopped = false;
|
|
484
|
+
this.openWatchStreams();
|
|
485
|
+
}
|
|
486
|
+
/** Close every watcher and clear failure bookkeeping (idempotent). */
|
|
487
|
+
async stop() {
|
|
488
|
+
this.stopped = true;
|
|
489
|
+
const closes = [...this.streams.values()].map((stream) => {
|
|
490
|
+
try {
|
|
491
|
+
return Promise.resolve(stream.close()).catch(() => {
|
|
492
|
+
});
|
|
493
|
+
} catch {
|
|
494
|
+
return Promise.resolve();
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
this.streams.clear();
|
|
498
|
+
this.failedStreams.clear();
|
|
499
|
+
await Promise.allSettled(closes);
|
|
500
|
+
}
|
|
501
|
+
openWatchStreams() {
|
|
502
|
+
const seen = /* @__PURE__ */ new Set();
|
|
503
|
+
for (const q of this.host.queries().values()) {
|
|
504
|
+
for (const ref of q.config.watch ?? []) {
|
|
505
|
+
const key = `${ref.db.databaseName}${WATCH_SEP}${ref.collection}`;
|
|
506
|
+
if (seen.has(key) || this.streams.has(key)) continue;
|
|
507
|
+
seen.add(key);
|
|
508
|
+
void this.watchLoop(key, ref);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Long-lived change-stream consumer for one collection. Each data change
|
|
514
|
+
* invalidates the queries bound to it. On permanent "not supported" errors the
|
|
515
|
+
* watcher is dropped and, if every planned stream failed, the cache falls back
|
|
516
|
+
* to the standalone ticker. Transient errors retry with backoff.
|
|
517
|
+
*/
|
|
518
|
+
async watchLoop(key, ref) {
|
|
519
|
+
let backoff = 0;
|
|
520
|
+
while (!this.stopped) {
|
|
521
|
+
try {
|
|
522
|
+
const stream = ref.db.collection(ref.collection).watch([]);
|
|
523
|
+
stream.on("error", () => {
|
|
524
|
+
});
|
|
525
|
+
this.streams.set(key, stream);
|
|
526
|
+
if (backoff > 0) {
|
|
527
|
+
this.host.logger.warn?.(
|
|
528
|
+
{ collection: ref.collection },
|
|
529
|
+
"hot cache: change stream reconnected \u2014 invalidating collection to drop entries missed during the outage"
|
|
530
|
+
);
|
|
531
|
+
this.host.invalidateCollection(ref.collection);
|
|
532
|
+
}
|
|
533
|
+
const iterator = stream[Symbol.asyncIterator]();
|
|
534
|
+
for await (const _change of iterator) {
|
|
535
|
+
this.host.invalidateCollection(ref.collection);
|
|
536
|
+
}
|
|
537
|
+
} catch (err) {
|
|
538
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
539
|
+
this.host.logger.warn?.(
|
|
540
|
+
{ error: message, collection: ref.collection },
|
|
541
|
+
"hot cache change stream error"
|
|
542
|
+
);
|
|
543
|
+
const abandoned = this.streams.get(key);
|
|
544
|
+
this.streams.delete(key);
|
|
545
|
+
if (abandoned) {
|
|
546
|
+
try {
|
|
547
|
+
await abandoned.close();
|
|
548
|
+
} catch {
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
if (/only supported on replica sets|not supported|ChangeStream/i.test(message)) {
|
|
552
|
+
this.markStreamFailed(key);
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
if (this.stopped) return;
|
|
557
|
+
backoff = Math.min(backoff + 1e3, 5e3);
|
|
558
|
+
await sleepJittered(backoff);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
markStreamFailed(key) {
|
|
562
|
+
this.failedStreams.add(key);
|
|
563
|
+
if (!this.host.isReplica()) return;
|
|
564
|
+
const planned = this.plannedStreamKeys();
|
|
565
|
+
if (planned.size > 0 && [...planned].every((k) => this.failedStreams.has(k))) {
|
|
566
|
+
this.host.logger.warn?.({}, "hot cache: change streams unsupported, falling back to ticker");
|
|
567
|
+
this.host.fallbackToStandalone();
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
plannedStreamKeys() {
|
|
571
|
+
const set = /* @__PURE__ */ new Set();
|
|
572
|
+
for (const q of this.host.queries().values()) {
|
|
573
|
+
for (const ref of q.config.watch ?? []) {
|
|
574
|
+
set.add(`${ref.db.databaseName}${WATCH_SEP}${ref.collection}`);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
return set;
|
|
578
|
+
}
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
// src/cache/hot-cache/index.ts
|
|
582
|
+
var HotCache = class {
|
|
583
|
+
options;
|
|
584
|
+
logger;
|
|
585
|
+
refreshEnabled;
|
|
586
|
+
queries = /* @__PURE__ */ new Map();
|
|
587
|
+
inflight = new InFlight();
|
|
588
|
+
watcher;
|
|
589
|
+
ticker;
|
|
590
|
+
_mode = "unknown";
|
|
591
|
+
started = false;
|
|
592
|
+
startAttempted = false;
|
|
593
|
+
stopped = false;
|
|
594
|
+
disposed = false;
|
|
595
|
+
startPromise;
|
|
596
|
+
constructor(options = {}) {
|
|
597
|
+
this.options = options;
|
|
598
|
+
this.refreshEnabled = options.autoRefresh !== false;
|
|
599
|
+
this.logger = options.logger ?? { debug() {
|
|
600
|
+
}, info() {
|
|
601
|
+
}, warn() {
|
|
602
|
+
}, error() {
|
|
603
|
+
} };
|
|
604
|
+
this.ticker = new RefreshTicker(
|
|
605
|
+
{
|
|
606
|
+
queries: () => this.queries,
|
|
607
|
+
fetch: (q, key, args, gen) => this.fetch(q, key, args, gen),
|
|
608
|
+
logger: this.logger
|
|
609
|
+
},
|
|
610
|
+
options.tickIntervalMs ?? 1e3
|
|
611
|
+
);
|
|
612
|
+
this.watcher = new WatchCoordinator({
|
|
613
|
+
queries: () => this.queries,
|
|
614
|
+
invalidateCollection: (collection) => this.invalidateCollection(collection),
|
|
615
|
+
isReplica: () => this._mode === "replica",
|
|
616
|
+
fallbackToStandalone: () => {
|
|
617
|
+
this._mode = "standalone";
|
|
618
|
+
if (this.refreshEnabled) this.ticker.start();
|
|
619
|
+
},
|
|
620
|
+
logger: this.logger
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
/** Number of cached entries across all registered queries. */
|
|
624
|
+
get size() {
|
|
625
|
+
let total = 0;
|
|
626
|
+
for (const q of this.queries.values()) total += q.lru.size;
|
|
627
|
+
return total;
|
|
628
|
+
}
|
|
629
|
+
/** Resolved mode: `unknown` until `start()` (or the first `get`) probes. */
|
|
630
|
+
get mode() {
|
|
631
|
+
return this._mode;
|
|
632
|
+
}
|
|
633
|
+
/** Whether the standalone background-refresh ticker is enabled (`autoRefresh`). */
|
|
634
|
+
get autoRefresh() {
|
|
635
|
+
return this.refreshEnabled;
|
|
636
|
+
}
|
|
637
|
+
/** Whether a query with this name has been registered. */
|
|
638
|
+
has(name) {
|
|
639
|
+
return this.queries.has(name);
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* Opt a query into the hot cache. Throws `BadRequest` on a duplicate name.
|
|
643
|
+
* Returns a fully typed accessor — `.get(...args)` (intellisense on the loader
|
|
644
|
+
* parameters) and `.invalidate()` / `.invalidate(...args)` for manual
|
|
645
|
+
* invalidation (all entries or a single parameter set).
|
|
646
|
+
*/
|
|
647
|
+
register(name, config) {
|
|
648
|
+
if (this.queries.has(name)) {
|
|
649
|
+
throw new BadRequest(`hot cache: query '${name}' is already registered`);
|
|
650
|
+
}
|
|
651
|
+
const query = {
|
|
652
|
+
name,
|
|
653
|
+
config,
|
|
654
|
+
lru: new LRU({
|
|
655
|
+
max: config.maxSize ?? this.options.defaultMaxSize ?? 500
|
|
656
|
+
}),
|
|
657
|
+
ttlMs: config.ttlMs ?? this.options.defaultTtlMs ?? 0,
|
|
658
|
+
refreshIntervalMs: config.refreshIntervalMs ?? 0,
|
|
659
|
+
clone: config.clone ?? false,
|
|
660
|
+
maxValueBytes: config.maxValueBytes,
|
|
661
|
+
gen: 0,
|
|
662
|
+
hits: 0,
|
|
663
|
+
misses: 0,
|
|
664
|
+
refreshes: 0,
|
|
665
|
+
loadErrors: 0,
|
|
666
|
+
sizeSkips: 0,
|
|
667
|
+
warnedSize: false
|
|
668
|
+
};
|
|
669
|
+
this.queries.set(name, query);
|
|
670
|
+
if (this._mode === "replica" && !this.stopped) {
|
|
671
|
+
this.watcher.start();
|
|
672
|
+
}
|
|
673
|
+
return {
|
|
674
|
+
get: (...args) => this.get(name, ...args),
|
|
675
|
+
invalidate: (...args) => {
|
|
676
|
+
if (args.length === 0) this.invalidate(name);
|
|
677
|
+
else this.invalidateParams(name, ...args);
|
|
678
|
+
}
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* Read-through read by dynamic name lookup. Call parameters are hashed into
|
|
683
|
+
* the cache key (`keyOf` override wins). Throws `BadRequest` for an unknown
|
|
684
|
+
* query name. For fully typed parameters/results prefer the accessor returned
|
|
685
|
+
* by `register()` — this dynamic form returns `Promise<unknown>`.
|
|
686
|
+
*/
|
|
687
|
+
async get(name, ...args) {
|
|
688
|
+
this.ensureStarted();
|
|
689
|
+
const q = this.queries.get(name);
|
|
690
|
+
if (!q) throw new BadRequest(`hot cache: unknown query '${name}'`);
|
|
691
|
+
const key = q.config.keyOf ? q.config.keyOf(...args) : stableHash(args);
|
|
692
|
+
const entry = q.lru.get(key);
|
|
693
|
+
if (entry && !(q.ttlMs > 0 && entry.expiresAt <= Date.now())) {
|
|
694
|
+
q.hits++;
|
|
695
|
+
return q.clone ? cloneDeep(entry.value) : entry.value;
|
|
696
|
+
}
|
|
697
|
+
if (entry && q.ttlMs > 0 && entry.expiresAt <= Date.now()) {
|
|
698
|
+
q.lru.delete(key);
|
|
699
|
+
}
|
|
700
|
+
q.misses++;
|
|
701
|
+
return this.fetch(q, key, args, q.gen);
|
|
702
|
+
}
|
|
703
|
+
/** Drop every cached entry for a registered query. */
|
|
704
|
+
invalidate(name) {
|
|
705
|
+
const q = this.queries.get(name);
|
|
706
|
+
if (!q) return;
|
|
707
|
+
q.lru.clear();
|
|
708
|
+
q.gen++;
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Drop only the cached entry for a specific parameter set. The key is computed
|
|
712
|
+
* exactly like `get` (`keyOf` override ?? `stableHash` of args), so the same
|
|
713
|
+
* parameters invalidate the same entry.
|
|
714
|
+
*/
|
|
715
|
+
invalidateParams(name, ...args) {
|
|
716
|
+
const q = this.queries.get(name);
|
|
717
|
+
if (!q) return;
|
|
718
|
+
const key = q.config.keyOf ? q.config.keyOf(...args) : stableHash(args);
|
|
719
|
+
q.lru.delete(key);
|
|
720
|
+
q.gen++;
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Invalidate every query bound to a physical collection (via `watch`). Can be
|
|
724
|
+
* called manually to wire ORM write hooks in standalone mode.
|
|
725
|
+
*/
|
|
726
|
+
invalidateCollection(collection) {
|
|
727
|
+
for (const q of this.queries.values()) {
|
|
728
|
+
if (q.config.watch?.some((ref) => ref.collection === collection)) {
|
|
729
|
+
q.lru.clear();
|
|
730
|
+
q.gen++;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
/** Drop every cached entry for every query. */
|
|
735
|
+
clear() {
|
|
736
|
+
for (const q of this.queries.values()) {
|
|
737
|
+
q.lru.clear();
|
|
738
|
+
q.gen++;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* Runtime observability snapshot: total queries/entries plus per-query
|
|
743
|
+
* counters (`hits`, `misses`, background `refreshes`, `loadErrors`, and
|
|
744
|
+
* `sizeSkips` for entries dropped by `maxValueBytes`). Useful for dashboards
|
|
745
|
+
* and cache-effectiveness checks.
|
|
746
|
+
*/
|
|
747
|
+
stats() {
|
|
748
|
+
const perQuery = {};
|
|
749
|
+
for (const [name, q] of this.queries) {
|
|
750
|
+
perQuery[name] = {
|
|
751
|
+
size: q.lru.size,
|
|
752
|
+
hits: q.hits,
|
|
753
|
+
misses: q.misses,
|
|
754
|
+
refreshes: q.refreshes,
|
|
755
|
+
loadErrors: q.loadErrors,
|
|
756
|
+
sizeSkips: q.sizeSkips,
|
|
757
|
+
evictions: q.lru.evictions
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
return { mode: this._mode, queries: this.queries.size, entries: this.size, perQuery };
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* Probe replica support once, then start change-stream watchers (replica) or
|
|
764
|
+
* the global refresh ticker (standalone). Idempotent — resolves the mode.
|
|
765
|
+
*/
|
|
766
|
+
async start() {
|
|
767
|
+
if (this.disposed) return this._mode;
|
|
768
|
+
if (this.started) return this._mode;
|
|
769
|
+
if (!this.startPromise) {
|
|
770
|
+
this.startPromise = this.runStart();
|
|
771
|
+
}
|
|
772
|
+
return this.startPromise;
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* Stop the ticker, close every change stream, and **dispose** the instance.
|
|
776
|
+
* Terminal: a later `get()` serves cached values read-through but never
|
|
777
|
+
* re-probes or re-opens background resources.
|
|
778
|
+
*/
|
|
779
|
+
async stop() {
|
|
780
|
+
this.disposed = true;
|
|
781
|
+
this.stopped = true;
|
|
782
|
+
this.ticker.stop();
|
|
783
|
+
await this.watcher.stop();
|
|
784
|
+
this.inflight.clear();
|
|
785
|
+
for (const q of this.queries.values()) q.gen++;
|
|
786
|
+
this.started = false;
|
|
787
|
+
this.startAttempted = false;
|
|
788
|
+
this.startPromise = void 0;
|
|
789
|
+
this._mode = "unknown";
|
|
790
|
+
}
|
|
791
|
+
/* ----------------------------- internals ----------------------------- */
|
|
792
|
+
async runStart() {
|
|
793
|
+
this.stopped = false;
|
|
794
|
+
const supported = this.options.mode === "replica" ? true : this.options.mode === "standalone" ? false : await (this.options.probe ?? this.defaultProbe)();
|
|
795
|
+
if (this.disposed) {
|
|
796
|
+
this.started = false;
|
|
797
|
+
return this._mode;
|
|
798
|
+
}
|
|
799
|
+
if (supported) {
|
|
800
|
+
this._mode = "replica";
|
|
801
|
+
this.watcher.start();
|
|
802
|
+
} else {
|
|
803
|
+
this._mode = "standalone";
|
|
804
|
+
if (this.refreshEnabled) {
|
|
805
|
+
this.ticker.start();
|
|
806
|
+
this.ticker.warnUnboundedStaleness();
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
this.started = true;
|
|
810
|
+
return this._mode;
|
|
811
|
+
}
|
|
812
|
+
ensureStarted() {
|
|
813
|
+
if (this.disposed || this.started || this.startAttempted) return;
|
|
814
|
+
this.startAttempted = true;
|
|
815
|
+
void this.start().catch((err) => {
|
|
816
|
+
this.logger.warn?.(
|
|
817
|
+
{ error: err instanceof Error ? err.message : String(err) },
|
|
818
|
+
"hot cache start failed; reads continue without auto-refresh"
|
|
819
|
+
);
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
defaultProbe = async () => {
|
|
823
|
+
for (const q of this.queries.values()) {
|
|
824
|
+
for (const ref of q.config.watch ?? []) {
|
|
825
|
+
const caps = await probeMongoCapabilities(ref.db);
|
|
826
|
+
if (!caps.probed) {
|
|
827
|
+
this.logger.warn?.(
|
|
828
|
+
{ db: ref.db.databaseName },
|
|
829
|
+
"hot cache: replica probe returned no result (timeout?) \u2014 falling back to the standalone ticker; pass `mode` or a custom `probe` to pin the behavior"
|
|
830
|
+
);
|
|
831
|
+
return false;
|
|
832
|
+
}
|
|
833
|
+
return caps.transactionsSupported;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
return false;
|
|
837
|
+
};
|
|
838
|
+
fetch(q, key, args, gen) {
|
|
839
|
+
const dedupeKey = `${q.name}${WATCH_SEP}${key}`;
|
|
840
|
+
return this.inflight.run(dedupeKey, async () => {
|
|
841
|
+
try {
|
|
842
|
+
const value = await q.config.loader(...args);
|
|
843
|
+
if (q.gen === gen) {
|
|
844
|
+
if (q.maxValueBytes !== void 0 && estimateSize(value) > q.maxValueBytes) {
|
|
845
|
+
q.sizeSkips++;
|
|
846
|
+
if (!q.warnedSize) {
|
|
847
|
+
q.warnedSize = true;
|
|
848
|
+
this.logger.warn?.(
|
|
849
|
+
{ query: q.name, bytes: estimateSize(value), max: q.maxValueBytes },
|
|
850
|
+
"hot cache: value exceeds maxValueBytes \u2014 returned but not cached"
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
} else {
|
|
854
|
+
this.setEntry(q, key, args, value);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
return value;
|
|
858
|
+
} catch (err) {
|
|
859
|
+
q.loadErrors++;
|
|
860
|
+
throw err;
|
|
861
|
+
}
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
setEntry(q, key, args, value) {
|
|
865
|
+
const now = Date.now();
|
|
866
|
+
q.lru.set(key, {
|
|
867
|
+
value,
|
|
868
|
+
args,
|
|
869
|
+
expiresAt: q.ttlMs > 0 ? now + q.ttlMs : Infinity,
|
|
870
|
+
nextRefreshAt: q.refreshIntervalMs > 0 ? now + q.refreshIntervalMs : Infinity
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
};
|
|
874
|
+
var createHotCache = (options) => new HotCache(options);
|
|
875
|
+
|
|
876
|
+
// src/cache/query-cache.ts
|
|
877
|
+
var SEP = "\0";
|
|
878
|
+
var NS_SEP = "";
|
|
879
|
+
var cacheCollectionKey = (dbName, physical) => `${dbName}${NS_SEP}${physical}`;
|
|
880
|
+
var QueryCache = class {
|
|
881
|
+
lru;
|
|
882
|
+
index = /* @__PURE__ */ new Map();
|
|
883
|
+
ttlMs;
|
|
884
|
+
clone;
|
|
885
|
+
hits = 0;
|
|
886
|
+
misses = 0;
|
|
887
|
+
sets = 0;
|
|
888
|
+
deletes = 0;
|
|
889
|
+
invalidateEvents = 0;
|
|
890
|
+
clearEvents = 0;
|
|
891
|
+
constructor(options = {}) {
|
|
892
|
+
this.lru = new LRU({ max: options.maxSize ?? 500 });
|
|
893
|
+
this.ttlMs = options.ttlMs ?? 0;
|
|
894
|
+
this.clone = options.clone ?? false;
|
|
895
|
+
}
|
|
896
|
+
get size() {
|
|
897
|
+
return this.lru.size;
|
|
898
|
+
}
|
|
899
|
+
/** Cache-health snapshot: sizes plus lifetime hit/miss/eviction counters. */
|
|
900
|
+
stats() {
|
|
901
|
+
return {
|
|
902
|
+
size: this.lru.size,
|
|
903
|
+
maxSize: this.lru.max,
|
|
904
|
+
hits: this.hits,
|
|
905
|
+
misses: this.misses,
|
|
906
|
+
sets: this.sets,
|
|
907
|
+
deletes: this.deletes,
|
|
908
|
+
invalidateEvents: this.invalidateEvents,
|
|
909
|
+
clearEvents: this.clearEvents,
|
|
910
|
+
evictions: this.lru.evictions
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
/** Compose a cache key for a collection + filter/options hash. */
|
|
914
|
+
key(collection, filterHash) {
|
|
915
|
+
return `${collection}${SEP}${filterHash}`;
|
|
916
|
+
}
|
|
917
|
+
get(key) {
|
|
918
|
+
const entry = this.lru.get(key);
|
|
919
|
+
if (!entry) {
|
|
920
|
+
this.misses++;
|
|
921
|
+
return void 0;
|
|
922
|
+
}
|
|
923
|
+
if (this.ttlMs > 0 && Date.now() > entry.expiresAt) {
|
|
924
|
+
this.lru.delete(key);
|
|
925
|
+
this.misses++;
|
|
926
|
+
return void 0;
|
|
927
|
+
}
|
|
928
|
+
this.hits++;
|
|
929
|
+
return this.clone ? cloneDeep(entry.value) : entry.value;
|
|
930
|
+
}
|
|
931
|
+
set(key, value, ttlMs) {
|
|
932
|
+
this.sets++;
|
|
933
|
+
const ttl = ttlMs ?? this.ttlMs;
|
|
934
|
+
const expiresAt = ttl > 0 ? Date.now() + ttl : Infinity;
|
|
935
|
+
this.lru.set(key, { value, expiresAt });
|
|
936
|
+
const sep = key.indexOf(SEP);
|
|
937
|
+
const collection = sep === -1 ? key : key.slice(0, sep);
|
|
938
|
+
let keys = this.index.get(collection);
|
|
939
|
+
if (!keys) {
|
|
940
|
+
keys = /* @__PURE__ */ new Set();
|
|
941
|
+
this.index.set(collection, keys);
|
|
942
|
+
}
|
|
943
|
+
keys.add(key);
|
|
944
|
+
}
|
|
945
|
+
delete(key) {
|
|
946
|
+
this.deletes++;
|
|
947
|
+
this.lru.delete(key);
|
|
948
|
+
}
|
|
949
|
+
/** Drop every cached entry belonging to a physical collection. */
|
|
950
|
+
invalidateByCollection(collection) {
|
|
951
|
+
const keys = this.index.get(collection);
|
|
952
|
+
if (!keys) return;
|
|
953
|
+
this.invalidateEvents++;
|
|
954
|
+
for (const key of keys) this.lru.delete(key);
|
|
955
|
+
this.index.delete(collection);
|
|
956
|
+
}
|
|
957
|
+
clear() {
|
|
958
|
+
this.clearEvents++;
|
|
959
|
+
this.lru.clear();
|
|
960
|
+
this.index.clear();
|
|
961
|
+
}
|
|
962
|
+
};
|
|
963
|
+
|
|
964
|
+
// src/graceful-transaction.ts
|
|
965
|
+
var withGracefulMongoTransaction = async (runner, fn, options = {}) => {
|
|
966
|
+
const enabled = options.capabilities === void 0 || mongoTransactionsEnabled(options.capabilities, { urlHint: options.urlHint });
|
|
967
|
+
if (!enabled) return fn(null);
|
|
968
|
+
try {
|
|
969
|
+
return await runner.transaction(fn);
|
|
970
|
+
} catch (err) {
|
|
971
|
+
const message = err instanceof Error ? err.message : "";
|
|
972
|
+
if (/Transaction numbers are only allowed|transactions are not supported/i.test(message)) {
|
|
973
|
+
options.logger?.warn?.({ error: message }, "transactions unsupported, falling back");
|
|
974
|
+
return fn(null);
|
|
975
|
+
}
|
|
976
|
+
if (options.wrapMongoErrors !== false) {
|
|
977
|
+
throw mapMongoDriverError(err, { op: "transaction" });
|
|
978
|
+
}
|
|
979
|
+
throw err;
|
|
980
|
+
}
|
|
981
|
+
};
|
|
982
|
+
|
|
983
|
+
// src/hooks/hooks.ts
|
|
984
|
+
var HOOK_NAMES = [
|
|
985
|
+
"beforeCreate",
|
|
986
|
+
"afterCreate",
|
|
987
|
+
"beforeUpdate",
|
|
988
|
+
"afterUpdate",
|
|
989
|
+
"beforeDelete",
|
|
990
|
+
"afterDelete",
|
|
991
|
+
"afterRead"
|
|
992
|
+
];
|
|
993
|
+
var hasHook = (registry, collection, name) => registry?.[collection]?.[name] !== void 0;
|
|
994
|
+
var runHooks = async (registry, collection, name, ctx) => {
|
|
995
|
+
const hooks = registry?.[collection];
|
|
996
|
+
const hook = hooks?.[name];
|
|
997
|
+
if (!hook) return;
|
|
998
|
+
await hook(ctx);
|
|
999
|
+
};
|
|
1000
|
+
|
|
1001
|
+
// src/loader/dataloader.ts
|
|
1002
|
+
var DataLoader = class {
|
|
1003
|
+
batchFn;
|
|
1004
|
+
maxBatchSize;
|
|
1005
|
+
maxBatchDelayMs;
|
|
1006
|
+
cacheEnabled;
|
|
1007
|
+
keyOf;
|
|
1008
|
+
cache;
|
|
1009
|
+
queue = [];
|
|
1010
|
+
scheduled = false;
|
|
1011
|
+
flushing = false;
|
|
1012
|
+
constructor(options) {
|
|
1013
|
+
this.batchFn = options.batch;
|
|
1014
|
+
this.maxBatchSize = options.maxBatchSize ?? 1e3;
|
|
1015
|
+
this.maxBatchDelayMs = options.maxBatchDelayMs ?? 0;
|
|
1016
|
+
this.cacheEnabled = options.cache ?? true;
|
|
1017
|
+
this.keyOf = options.keyOf ?? ((key) => `${typeof key}:${String(key)}`);
|
|
1018
|
+
this.cache = options.cacheMap ?? /* @__PURE__ */ new Map();
|
|
1019
|
+
}
|
|
1020
|
+
/** Load one key. Concurrent loads are batched; cached keys resolve instantly. */
|
|
1021
|
+
load(key) {
|
|
1022
|
+
const canonicalKey2 = this.keyOf(key);
|
|
1023
|
+
if (this.cacheEnabled) {
|
|
1024
|
+
const cached = this.cache.get(canonicalKey2);
|
|
1025
|
+
if (cached) return cached;
|
|
1026
|
+
}
|
|
1027
|
+
let resolve;
|
|
1028
|
+
let reject;
|
|
1029
|
+
const promise = new Promise((res, rej) => {
|
|
1030
|
+
resolve = res;
|
|
1031
|
+
reject = rej;
|
|
1032
|
+
});
|
|
1033
|
+
this.queue.push({ key, canonicalKey: canonicalKey2, promise, resolve, reject });
|
|
1034
|
+
if (this.cacheEnabled) this.cache.set(canonicalKey2, promise);
|
|
1035
|
+
this.scheduleFlush();
|
|
1036
|
+
return promise;
|
|
1037
|
+
}
|
|
1038
|
+
/** Load many keys (each resolves independently). */
|
|
1039
|
+
loadMany(keys) {
|
|
1040
|
+
return Promise.all(keys.map((key) => this.load(key)));
|
|
1041
|
+
}
|
|
1042
|
+
clear(key) {
|
|
1043
|
+
this.cache.delete(this.keyOf(key));
|
|
1044
|
+
return this;
|
|
1045
|
+
}
|
|
1046
|
+
clearAll() {
|
|
1047
|
+
this.cache.clear();
|
|
1048
|
+
return this;
|
|
1049
|
+
}
|
|
1050
|
+
/** Pre-populate the cache without hitting the batch function. */
|
|
1051
|
+
prime(key, value) {
|
|
1052
|
+
if (this.cacheEnabled) this.cache.set(this.keyOf(key), Promise.resolve(value));
|
|
1053
|
+
return this;
|
|
1054
|
+
}
|
|
1055
|
+
get pendingCount() {
|
|
1056
|
+
return this.queue.length;
|
|
1057
|
+
}
|
|
1058
|
+
scheduleFlush() {
|
|
1059
|
+
if (this.scheduled) return;
|
|
1060
|
+
this.scheduled = true;
|
|
1061
|
+
if (this.maxBatchDelayMs > 0) {
|
|
1062
|
+
setTimeout(() => {
|
|
1063
|
+
void this.flush();
|
|
1064
|
+
}, this.maxBatchDelayMs);
|
|
1065
|
+
} else {
|
|
1066
|
+
queueMicrotask(() => {
|
|
1067
|
+
void this.flush();
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
async flush() {
|
|
1072
|
+
if (this.flushing) return;
|
|
1073
|
+
if (this.queue.length === 0) {
|
|
1074
|
+
this.scheduled = false;
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
const items = this.queue.splice(0, this.maxBatchSize);
|
|
1078
|
+
this.flushing = true;
|
|
1079
|
+
try {
|
|
1080
|
+
const resultMap = await this.batchFn(items.map((item) => item.key));
|
|
1081
|
+
for (const item of items) {
|
|
1082
|
+
item.resolve(resultMap.get(item.key));
|
|
1083
|
+
}
|
|
1084
|
+
} catch (error) {
|
|
1085
|
+
for (const item of items) {
|
|
1086
|
+
if (this.cacheEnabled && this.cache.get(item.canonicalKey) === item.promise) {
|
|
1087
|
+
this.cache.delete(item.canonicalKey);
|
|
1088
|
+
}
|
|
1089
|
+
item.reject(error);
|
|
1090
|
+
}
|
|
1091
|
+
} finally {
|
|
1092
|
+
this.flushing = false;
|
|
1093
|
+
this.scheduled = false;
|
|
1094
|
+
if (this.queue.length > 0) {
|
|
1095
|
+
this.scheduled = true;
|
|
1096
|
+
queueMicrotask(() => {
|
|
1097
|
+
void this.flush();
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
};
|
|
1103
|
+
var canonicalKey = (value) => {
|
|
1104
|
+
if (value === void 0 || value === null) return "null";
|
|
1105
|
+
if (typeof value === "object") {
|
|
1106
|
+
const anyValue = value;
|
|
1107
|
+
if (typeof anyValue.toHexString === "function") return `oid:${anyValue.toHexString()}`;
|
|
1108
|
+
if (value instanceof Date) return `date:${value.getTime()}`;
|
|
1109
|
+
}
|
|
1110
|
+
return `${typeof value}:${String(value)}`;
|
|
1111
|
+
};
|
|
1112
|
+
|
|
1113
|
+
// src/migrations/types.ts
|
|
1114
|
+
var MIGRATION_FILE = /^(\d+)_.+\.(ts|js|mjs)$/;
|
|
1115
|
+
var JOURNAL_COLLECTION = "_migrations";
|
|
1116
|
+
|
|
1117
|
+
// src/migrations/files.ts
|
|
1118
|
+
var migrationNameOf = (file) => basename(file).replace(/\.(ts|js|mjs)$/, "");
|
|
1119
|
+
var listMigrationFiles = async (dir) => {
|
|
1120
|
+
const files = await readdir(dir);
|
|
1121
|
+
return files.filter((file) => MIGRATION_FILE.test(file)).sort();
|
|
1122
|
+
};
|
|
1123
|
+
var loadMigration = async (dir, file) => {
|
|
1124
|
+
const mod = await import(pathToFileURL(join(dir, file)).href);
|
|
1125
|
+
return { name: migrationNameOf(file), ...mod };
|
|
1126
|
+
};
|
|
1127
|
+
var ensureMigrationDir = async (dir) => {
|
|
1128
|
+
await mkdir(dir, { recursive: true });
|
|
1129
|
+
};
|
|
1130
|
+
var nextMigrationNumber = async (dir) => {
|
|
1131
|
+
const files = await readdir(dir);
|
|
1132
|
+
return files.reduce((max, file) => {
|
|
1133
|
+
const match = MIGRATION_FILE.exec(file);
|
|
1134
|
+
return match ? Math.max(max, Number(match[1])) : max;
|
|
1135
|
+
}, 0) + 1;
|
|
1136
|
+
};
|
|
1137
|
+
|
|
1138
|
+
// src/migrations/journal.ts
|
|
1139
|
+
var createMigrationJournal = (manager) => {
|
|
1140
|
+
const appliedNames = async () => {
|
|
1141
|
+
const rows = await manager.findMany(JOURNAL_COLLECTION);
|
|
1142
|
+
return new Set(
|
|
1143
|
+
rows.filter((row) => row.status === void 0 || row.status === "applied").map((row) => row.name)
|
|
1144
|
+
);
|
|
1145
|
+
};
|
|
1146
|
+
const cleanupStale = async () => {
|
|
1147
|
+
await manager.deleteMany(JOURNAL_COLLECTION, {
|
|
1148
|
+
status: "running",
|
|
1149
|
+
appliedAt: { $lt: new Date(Date.now() - 6e4) }
|
|
1150
|
+
}).catch(() => {
|
|
1151
|
+
});
|
|
1152
|
+
};
|
|
1153
|
+
const claim = async (name, file) => {
|
|
1154
|
+
const claimed = await manager.findOneAndUpdate(
|
|
1155
|
+
JOURNAL_COLLECTION,
|
|
1156
|
+
{ name },
|
|
1157
|
+
{
|
|
1158
|
+
$setOnInsert: {
|
|
1159
|
+
name,
|
|
1160
|
+
appliedAt: /* @__PURE__ */ new Date(),
|
|
1161
|
+
status: "running",
|
|
1162
|
+
file
|
|
1163
|
+
}
|
|
1164
|
+
},
|
|
1165
|
+
{ upsert: true, returnDocument: "before" }
|
|
1166
|
+
);
|
|
1167
|
+
return claimed === null;
|
|
1168
|
+
};
|
|
1169
|
+
const markApplied = async (name) => {
|
|
1170
|
+
await manager.updateOne(JOURNAL_COLLECTION, { name, status: "running" }, { $set: { status: "applied" } }).catch(async () => {
|
|
1171
|
+
await manager.insertOne(JOURNAL_COLLECTION, {
|
|
1172
|
+
name,
|
|
1173
|
+
appliedAt: /* @__PURE__ */ new Date(),
|
|
1174
|
+
status: "applied"
|
|
1175
|
+
}).catch(() => {
|
|
1176
|
+
});
|
|
1177
|
+
});
|
|
1178
|
+
};
|
|
1179
|
+
const removeRunning = async (name) => {
|
|
1180
|
+
await manager.deleteOne(JOURNAL_COLLECTION, { name, status: "running" }).catch(() => {
|
|
1181
|
+
});
|
|
1182
|
+
};
|
|
1183
|
+
const unrecord = async (name) => {
|
|
1184
|
+
await manager.deleteOne(JOURNAL_COLLECTION, { name });
|
|
1185
|
+
};
|
|
1186
|
+
return { appliedNames, cleanupStale, claim, markApplied, removeRunning, unrecord };
|
|
1187
|
+
};
|
|
1188
|
+
|
|
1189
|
+
// src/migrations/index.ts
|
|
1190
|
+
var createMongoMigrationRunner = (service, options = {}) => {
|
|
1191
|
+
const migrationDir = options.migrationDir ?? service.config?.migrationDir ?? "./migrations";
|
|
1192
|
+
const logger = options.logger ?? service.logger;
|
|
1193
|
+
const manager = () => {
|
|
1194
|
+
const key = Object.keys(service.db)[0];
|
|
1195
|
+
const db = key ? service.db[key] : void 0;
|
|
1196
|
+
if (!db) throw new Error("No database client connected \u2014 call service.makeConnections() first");
|
|
1197
|
+
return db;
|
|
1198
|
+
};
|
|
1199
|
+
const up = async () => {
|
|
1200
|
+
await ensureMigrationDir(migrationDir);
|
|
1201
|
+
const journal = createMigrationJournal(manager());
|
|
1202
|
+
await journal.cleanupStale();
|
|
1203
|
+
const files = await listMigrationFiles(migrationDir);
|
|
1204
|
+
const applied = await journal.appliedNames();
|
|
1205
|
+
const pending = files.filter((file) => !applied.has(migrationNameOf(file)));
|
|
1206
|
+
for (const file of pending) {
|
|
1207
|
+
const migration = await loadMigration(migrationDir, file);
|
|
1208
|
+
logger.info({ name: migration.name }, "running migration up");
|
|
1209
|
+
if (!await journal.claim(migration.name, basename(file))) {
|
|
1210
|
+
logger.info({ name: migration.name }, "migration already applied, skipping");
|
|
1211
|
+
continue;
|
|
1212
|
+
}
|
|
1213
|
+
try {
|
|
1214
|
+
await migration.up({ service, logger, name: migration.name });
|
|
1215
|
+
await journal.markApplied(migration.name);
|
|
1216
|
+
} catch (err) {
|
|
1217
|
+
await journal.removeRunning(migration.name);
|
|
1218
|
+
throw err;
|
|
1219
|
+
}
|
|
1220
|
+
logger.info({ name: migration.name }, "migration applied");
|
|
1221
|
+
}
|
|
1222
|
+
};
|
|
1223
|
+
const down = async (targetName) => {
|
|
1224
|
+
const files = await listMigrationFiles(migrationDir);
|
|
1225
|
+
const journal = createMigrationJournal(manager());
|
|
1226
|
+
const applied = await journal.appliedNames();
|
|
1227
|
+
const appliedFiles = files.filter((file) => applied.has(migrationNameOf(file))).reverse();
|
|
1228
|
+
for (const file of appliedFiles) {
|
|
1229
|
+
const migration = await loadMigration(migrationDir, file);
|
|
1230
|
+
logger.info({ name: migration.name }, "running migration down");
|
|
1231
|
+
await migration.down({ service, logger, name: migration.name });
|
|
1232
|
+
await journal.unrecord(migration.name);
|
|
1233
|
+
logger.info({ name: migration.name }, "migration rolled back");
|
|
1234
|
+
if (targetName && migration.name === targetName) break;
|
|
1235
|
+
}
|
|
1236
|
+
};
|
|
1237
|
+
const status = async () => {
|
|
1238
|
+
const files = await listMigrationFiles(migrationDir);
|
|
1239
|
+
const applied = await createMigrationJournal(manager()).appliedNames();
|
|
1240
|
+
const all = files.map(migrationNameOf);
|
|
1241
|
+
return {
|
|
1242
|
+
applied: all.filter((name) => applied.has(name)),
|
|
1243
|
+
pending: all.filter((name) => !applied.has(name))
|
|
1244
|
+
};
|
|
1245
|
+
};
|
|
1246
|
+
const create = async (name) => {
|
|
1247
|
+
await ensureMigrationDir(migrationDir);
|
|
1248
|
+
const next = await nextMigrationNumber(migrationDir);
|
|
1249
|
+
const padded = String(next).padStart(3, "0");
|
|
1250
|
+
const fileName = `${padded}_${name}.ts`;
|
|
1251
|
+
const filePath = join(migrationDir, fileName);
|
|
1252
|
+
const template = `import type { MigrationContext } from '@ignex/ninox';
|
|
1253
|
+
|
|
1254
|
+
export const up = async (ctx: MigrationContext): Promise<void> => {
|
|
1255
|
+
// const users = ctx.service.db.primaryClient;
|
|
1256
|
+
// await users.createSchema('users');
|
|
1257
|
+
};
|
|
1258
|
+
|
|
1259
|
+
export const down = async (ctx: MigrationContext): Promise<void> => {
|
|
1260
|
+
// const users = ctx.service.db.primaryClient;
|
|
1261
|
+
// await users.client.dropCollection('users');
|
|
1262
|
+
};
|
|
1263
|
+
`;
|
|
1264
|
+
await writeFile(filePath, template, "utf8");
|
|
1265
|
+
logger.info({ name, file: fileName }, "migration scaffolded");
|
|
1266
|
+
return filePath;
|
|
1267
|
+
};
|
|
1268
|
+
return { up, down, status, create };
|
|
1269
|
+
};
|
|
1270
|
+
|
|
1271
|
+
// src/mongo-helpers.ts
|
|
1272
|
+
var withRetry = async (fn, options = {}, _ctx) => {
|
|
1273
|
+
const maxAttempts = Math.max(1, options.maxAttempts ?? 3);
|
|
1274
|
+
const delayMs = options.delayMs ?? 150;
|
|
1275
|
+
const logger = options.logger;
|
|
1276
|
+
let attempt = 0;
|
|
1277
|
+
let lastError;
|
|
1278
|
+
while (attempt < maxAttempts) {
|
|
1279
|
+
try {
|
|
1280
|
+
return await fn();
|
|
1281
|
+
} catch (err) {
|
|
1282
|
+
lastError = err;
|
|
1283
|
+
attempt += 1;
|
|
1284
|
+
if (attempt >= maxAttempts || !isMongoTransientError(err)) throw err;
|
|
1285
|
+
const backoff = delayMs * 2 ** (attempt - 1);
|
|
1286
|
+
logger?.debug?.({ attempt, backoffMs: backoff, error: err?.message }, "retrying");
|
|
1287
|
+
await sleep(backoff);
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
throw lastError ?? new Error("withRetry: no attempt was made (maxAttempts must be >= 1)");
|
|
1291
|
+
};
|
|
1292
|
+
var withTransaction = async (client, fn) => {
|
|
1293
|
+
const session = client.startSession();
|
|
1294
|
+
try {
|
|
1295
|
+
session.startTransaction();
|
|
1296
|
+
const result = await fn(session);
|
|
1297
|
+
await session.commitTransaction();
|
|
1298
|
+
return result;
|
|
1299
|
+
} catch (err) {
|
|
1300
|
+
await session.abortTransaction().catch(() => {
|
|
1301
|
+
});
|
|
1302
|
+
throw err;
|
|
1303
|
+
} finally {
|
|
1304
|
+
await session.endSession().catch(() => {
|
|
1305
|
+
});
|
|
1306
|
+
}
|
|
1307
|
+
};
|
|
1308
|
+
|
|
1309
|
+
// src/shared/constants.ts
|
|
1310
|
+
var DEFAULT_MAX_LIMIT = 1e3;
|
|
1311
|
+
var DEFAULT_FIND_LIMIT = 100;
|
|
1312
|
+
var MAX_BATCH_OPS = 1e5;
|
|
1313
|
+
|
|
1314
|
+
// src/query-builder/query-builder.ts
|
|
1315
|
+
var QueryBuilder = class {
|
|
1316
|
+
constructor(ctx) {
|
|
1317
|
+
this.ctx = ctx;
|
|
1318
|
+
}
|
|
1319
|
+
_filter = {};
|
|
1320
|
+
_sort;
|
|
1321
|
+
_skip;
|
|
1322
|
+
_limit;
|
|
1323
|
+
_projection;
|
|
1324
|
+
_options = {};
|
|
1325
|
+
get filter() {
|
|
1326
|
+
return this._filter;
|
|
1327
|
+
}
|
|
1328
|
+
/** Shallow-merge a filter into the current one (later keys win). */
|
|
1329
|
+
where(filter) {
|
|
1330
|
+
this._filter = { ...this._filter, ...filter };
|
|
1331
|
+
return this;
|
|
1332
|
+
}
|
|
1333
|
+
/** AND-combine the current filter with a new one. */
|
|
1334
|
+
and(filter) {
|
|
1335
|
+
this._filter = { $and: [this._filter, filter] };
|
|
1336
|
+
return this;
|
|
1337
|
+
}
|
|
1338
|
+
/** OR-combine: current filter AND (filter1 OR filter2 ...). */
|
|
1339
|
+
or(...filters) {
|
|
1340
|
+
this._filter = { $and: [this._filter, { $or: filters }] };
|
|
1341
|
+
return this;
|
|
1342
|
+
}
|
|
1343
|
+
/** Set the sort order (`{ field: 1 | -1 }`). */
|
|
1344
|
+
sort(sort) {
|
|
1345
|
+
this._sort = sort;
|
|
1346
|
+
return this;
|
|
1347
|
+
}
|
|
1348
|
+
/** Skip the first `n` matching documents. */
|
|
1349
|
+
skip(n) {
|
|
1350
|
+
this._skip = n;
|
|
1351
|
+
return this;
|
|
1352
|
+
}
|
|
1353
|
+
/** Limit the result set to `n` documents. */
|
|
1354
|
+
limit(n) {
|
|
1355
|
+
this._limit = n;
|
|
1356
|
+
return this;
|
|
1357
|
+
}
|
|
1358
|
+
/** Project only the given fields — the projection is pushed to the driver. */
|
|
1359
|
+
select(fields) {
|
|
1360
|
+
const projection = {};
|
|
1361
|
+
for (const field of fields) projection[field] = 1;
|
|
1362
|
+
this._projection = projection;
|
|
1363
|
+
return this;
|
|
1364
|
+
}
|
|
1365
|
+
/** Raw projection document (`{ field: 1 }` / `{ field: 0 }`). */
|
|
1366
|
+
project(projection) {
|
|
1367
|
+
this._projection = projection;
|
|
1368
|
+
return this;
|
|
1369
|
+
}
|
|
1370
|
+
/** Set the index hint. */
|
|
1371
|
+
hint(hint) {
|
|
1372
|
+
this._options.hint = hint;
|
|
1373
|
+
return this;
|
|
1374
|
+
}
|
|
1375
|
+
/** Set the driver cursor batch size. */
|
|
1376
|
+
batchSize(n) {
|
|
1377
|
+
this._options.batchSize = n;
|
|
1378
|
+
return this;
|
|
1379
|
+
}
|
|
1380
|
+
/** Run the query inside a transaction session. */
|
|
1381
|
+
session(session) {
|
|
1382
|
+
this._options.session = session;
|
|
1383
|
+
return this;
|
|
1384
|
+
}
|
|
1385
|
+
/** Cap server-side execution time. */
|
|
1386
|
+
maxTimeMS(ms) {
|
|
1387
|
+
this._options.maxTimeMS = ms;
|
|
1388
|
+
return this;
|
|
1389
|
+
}
|
|
1390
|
+
/** Bypass the read cache for this query when `false`. */
|
|
1391
|
+
cache(on) {
|
|
1392
|
+
this._options.cache = on;
|
|
1393
|
+
return this;
|
|
1394
|
+
}
|
|
1395
|
+
/** Override in-flight dedup for this query when set. */
|
|
1396
|
+
dedupe(on) {
|
|
1397
|
+
this._options.dedupe = on;
|
|
1398
|
+
return this;
|
|
1399
|
+
}
|
|
1400
|
+
/** Compile the builder into driver options (does not execute). */
|
|
1401
|
+
compile() {
|
|
1402
|
+
const out = { ...this._options };
|
|
1403
|
+
if (this._sort) out.sort = this._sort;
|
|
1404
|
+
if (this._skip !== void 0) out.skip = this._skip;
|
|
1405
|
+
if (this._limit !== void 0) out.limit = this._limit;
|
|
1406
|
+
if (this._projection) out.projection = this._projection;
|
|
1407
|
+
return out;
|
|
1408
|
+
}
|
|
1409
|
+
countOptions() {
|
|
1410
|
+
const out = { ...this._options };
|
|
1411
|
+
if (this._skip !== void 0) out.skip = this._skip;
|
|
1412
|
+
if (this._limit !== void 0) out.limit = this._limit;
|
|
1413
|
+
return out;
|
|
1414
|
+
}
|
|
1415
|
+
/** Fetch one document (limit forced to 1). */
|
|
1416
|
+
async one() {
|
|
1417
|
+
const options = this.compile();
|
|
1418
|
+
if (options.limit === void 0) options.limit = 1;
|
|
1419
|
+
return this.ctx.run(
|
|
1420
|
+
"mongo.getOne",
|
|
1421
|
+
this._filter,
|
|
1422
|
+
(opts) => this.ctx.collection.findOne(
|
|
1423
|
+
this._filter,
|
|
1424
|
+
opts
|
|
1425
|
+
),
|
|
1426
|
+
options
|
|
1427
|
+
);
|
|
1428
|
+
}
|
|
1429
|
+
/** Fetch many documents (default limit 100 when unset). */
|
|
1430
|
+
async many() {
|
|
1431
|
+
const options = this.compile();
|
|
1432
|
+
if (options.limit === void 0) options.limit = DEFAULT_FIND_LIMIT;
|
|
1433
|
+
return this.ctx.run(
|
|
1434
|
+
"mongo.findMany",
|
|
1435
|
+
this._filter,
|
|
1436
|
+
(opts) => this.ctx.collection.find(this._filter, opts).toArray(),
|
|
1437
|
+
options
|
|
1438
|
+
);
|
|
1439
|
+
}
|
|
1440
|
+
/** Streaming cursor (raw driver cursor; no retry, applies batchSize). */
|
|
1441
|
+
cursor() {
|
|
1442
|
+
return this.ctx.collection.find(
|
|
1443
|
+
this._filter,
|
|
1444
|
+
this.compile()
|
|
1445
|
+
);
|
|
1446
|
+
}
|
|
1447
|
+
/** Count matching documents. */
|
|
1448
|
+
async count() {
|
|
1449
|
+
return this.ctx.run(
|
|
1450
|
+
"mongo.countDocuments",
|
|
1451
|
+
this._filter,
|
|
1452
|
+
(opts) => this.ctx.collection.countDocuments(this._filter, opts),
|
|
1453
|
+
this.countOptions()
|
|
1454
|
+
);
|
|
1455
|
+
}
|
|
1456
|
+
/** True when at least one document matches. */
|
|
1457
|
+
async exists() {
|
|
1458
|
+
return await this.one() !== null;
|
|
1459
|
+
}
|
|
1460
|
+
};
|
|
1461
|
+
|
|
1462
|
+
// src/relation/relation.ts
|
|
1463
|
+
var belongsTo = (config) => ({
|
|
1464
|
+
type: "belongsTo",
|
|
1465
|
+
foreignField: "_id",
|
|
1466
|
+
...config
|
|
1467
|
+
});
|
|
1468
|
+
var hasMany = (config) => ({
|
|
1469
|
+
type: "hasMany",
|
|
1470
|
+
foreignField: "_id",
|
|
1471
|
+
...config
|
|
1472
|
+
});
|
|
1473
|
+
var manyToMany = (config) => ({
|
|
1474
|
+
type: "manyToMany",
|
|
1475
|
+
foreignField: "_id",
|
|
1476
|
+
...config
|
|
1477
|
+
});
|
|
1478
|
+
|
|
1479
|
+
// src/repository/repository.ts
|
|
1480
|
+
var createRepository = (manager, collection, options = {}) => {
|
|
1481
|
+
const repo = {
|
|
1482
|
+
collection,
|
|
1483
|
+
manager,
|
|
1484
|
+
getById: (id, o) => manager.getOne(collection, { _id: id }, o),
|
|
1485
|
+
getByIds: (ids, o) => manager.findMany(collection, { _id: { $in: ids } }, o),
|
|
1486
|
+
findOne: (filter, o) => manager.getOne(collection, filter, o),
|
|
1487
|
+
findOneOrFail: (filter, o) => manager.getOneOrFail(collection, filter, o),
|
|
1488
|
+
findMany: (filter, o) => manager.findMany(collection, filter, {
|
|
1489
|
+
...o ?? {},
|
|
1490
|
+
...options.limit !== void 0 ? { limit: options.limit } : {}
|
|
1491
|
+
}),
|
|
1492
|
+
findActive: (filter, o) => manager.findActive(collection, filter, o),
|
|
1493
|
+
count: (filter, o) => manager.countDocuments(collection, filter, o),
|
|
1494
|
+
exists: async (filter, o) => await manager.getOne(collection, filter, { ...o ?? {}, projection: { _id: 1 } }) !== null,
|
|
1495
|
+
distinct: (field, filter, o) => manager.distinct(collection, field, filter, o),
|
|
1496
|
+
create: (doc, o) => manager.insertOne(collection, doc, o),
|
|
1497
|
+
createMany: (docs, o) => manager.insertMany(collection, docs, o),
|
|
1498
|
+
update: (id, patch, o) => manager.updateOne(collection, { _id: id }, patch, o),
|
|
1499
|
+
updateVersioned: (id, patch, o) => manager.updateWithVersion(collection, { _id: id }, patch, o),
|
|
1500
|
+
deleteById: (id, o) => manager.deleteOne(collection, { _id: id }, o),
|
|
1501
|
+
softDelete: (id, o) => manager.softDeleteOne(collection, { _id: id }, o),
|
|
1502
|
+
page: (filter, config) => manager.paginateFlexible(collection, filter, config),
|
|
1503
|
+
pageCursor: (filter, config) => manager.paginateCursor(collection, filter, config),
|
|
1504
|
+
pipeline: (o) => manager.pipeline(collection, o),
|
|
1505
|
+
aggregate: (stages, o) => manager.aggregate(collection, stages, o),
|
|
1506
|
+
// The repository's public signature already validates `relations` at the
|
|
1507
|
+
// call site; the manager's mapped-type param can't be re-inferred here
|
|
1508
|
+
// (TS mapped-type inference friction), so delegate through the validated
|
|
1509
|
+
// shape without weakening the public type.
|
|
1510
|
+
populate: (docs, relations, o) => manager.populate(docs, relations, o),
|
|
1511
|
+
watch: (pipeline, options2) => manager.watchCollection(collection, pipeline, options2)
|
|
1512
|
+
};
|
|
1513
|
+
return repo;
|
|
1514
|
+
};
|
|
1515
|
+
|
|
1516
|
+
// src/schema/collections.ts
|
|
1517
|
+
var resolveItemName = (item) => "schema" in item ? item.schema.name : item.name;
|
|
1518
|
+
var resolveItemValue = (item) => {
|
|
1519
|
+
if ("schema" in item) {
|
|
1520
|
+
const def = item;
|
|
1521
|
+
return {
|
|
1522
|
+
schema: def.schema,
|
|
1523
|
+
...def.indexes ? { indexes: def.indexes } : {},
|
|
1524
|
+
...def.hooks ? { hooks: def.hooks } : {},
|
|
1525
|
+
...def.timestamps !== void 0 ? { timestamps: def.timestamps } : {}
|
|
1526
|
+
};
|
|
1527
|
+
}
|
|
1528
|
+
return item;
|
|
1529
|
+
};
|
|
1530
|
+
var defineCollection = (name, schema, extras = {}) => {
|
|
1531
|
+
const namedSchema = { ...schema, name };
|
|
1532
|
+
return {
|
|
1533
|
+
name,
|
|
1534
|
+
schema: namedSchema,
|
|
1535
|
+
...extras.indexes ? { indexes: extras.indexes } : {},
|
|
1536
|
+
...extras.hooks ? { hooks: extras.hooks } : {},
|
|
1537
|
+
...extras.timestamps !== void 0 ? { timestamps: extras.timestamps } : {}
|
|
1538
|
+
};
|
|
1539
|
+
};
|
|
1540
|
+
var defineCollections = (...items) => {
|
|
1541
|
+
const out = {};
|
|
1542
|
+
for (const item of items) {
|
|
1543
|
+
const name = resolveItemName(item);
|
|
1544
|
+
if (name in out) {
|
|
1545
|
+
throw new Error(`defineCollections: duplicate collection name "${name}"`);
|
|
1546
|
+
}
|
|
1547
|
+
out[name] = resolveItemValue(item);
|
|
1548
|
+
}
|
|
1549
|
+
return out;
|
|
1550
|
+
};
|
|
1551
|
+
|
|
1552
|
+
// src/schema/to-mongo-schema.ts
|
|
1553
|
+
var addNumericBounds = (out, field) => {
|
|
1554
|
+
if (field.minimum !== void 0) out.minimum = field.minimum;
|
|
1555
|
+
if (field.maximum !== void 0) out.maximum = field.maximum;
|
|
1556
|
+
if (field.multipleOf !== void 0) out.multipleOf = field.multipleOf;
|
|
1557
|
+
if (field.exclusiveMinimum) out.exclusiveMinimum = true;
|
|
1558
|
+
if (field.exclusiveMaximum) out.exclusiveMaximum = true;
|
|
1559
|
+
return out;
|
|
1560
|
+
};
|
|
1561
|
+
var convertField = (field) => {
|
|
1562
|
+
if (field.kind === "raw") {
|
|
1563
|
+
const out2 = { ...field.fragment };
|
|
1564
|
+
if (field.description && !out2.description) out2.description = field.description;
|
|
1565
|
+
return out2;
|
|
1566
|
+
}
|
|
1567
|
+
const out = {};
|
|
1568
|
+
if (field.description) out.description = field.description;
|
|
1569
|
+
switch (field.kind) {
|
|
1570
|
+
case "string":
|
|
1571
|
+
out.bsonType = "string";
|
|
1572
|
+
if (field.minLength !== void 0) out.minLength = field.minLength;
|
|
1573
|
+
if (field.maxLength !== void 0) out.maxLength = field.maxLength;
|
|
1574
|
+
if (field.pattern) out.pattern = field.pattern;
|
|
1575
|
+
return out;
|
|
1576
|
+
case "number":
|
|
1577
|
+
out.bsonType = field.integer ? "int" : "number";
|
|
1578
|
+
return addNumericBounds(out, field);
|
|
1579
|
+
case "double":
|
|
1580
|
+
out.bsonType = "double";
|
|
1581
|
+
return addNumericBounds(out, field);
|
|
1582
|
+
case "long":
|
|
1583
|
+
out.bsonType = "long";
|
|
1584
|
+
return addNumericBounds(out, field);
|
|
1585
|
+
case "decimal":
|
|
1586
|
+
out.bsonType = "decimal";
|
|
1587
|
+
return addNumericBounds(out, field);
|
|
1588
|
+
case "boolean":
|
|
1589
|
+
out.bsonType = "bool";
|
|
1590
|
+
return out;
|
|
1591
|
+
case "date":
|
|
1592
|
+
out.bsonType = "date";
|
|
1593
|
+
return out;
|
|
1594
|
+
case "objectId":
|
|
1595
|
+
out.bsonType = "objectId";
|
|
1596
|
+
return out;
|
|
1597
|
+
case "geoPoint":
|
|
1598
|
+
return {
|
|
1599
|
+
bsonType: "object",
|
|
1600
|
+
required: ["type", "coordinates"],
|
|
1601
|
+
properties: {
|
|
1602
|
+
type: { enum: ["Point"] },
|
|
1603
|
+
coordinates: {
|
|
1604
|
+
bsonType: "array",
|
|
1605
|
+
items: { bsonType: "number" },
|
|
1606
|
+
minItems: 2,
|
|
1607
|
+
maxItems: 2
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
};
|
|
1611
|
+
case "null":
|
|
1612
|
+
out.bsonType = "null";
|
|
1613
|
+
return out;
|
|
1614
|
+
case "any":
|
|
1615
|
+
return out;
|
|
1616
|
+
case "enum": {
|
|
1617
|
+
out.enum = [...field.values];
|
|
1618
|
+
const types = /* @__PURE__ */ new Set();
|
|
1619
|
+
for (const v of field.values) {
|
|
1620
|
+
if (v === null) types.add("null");
|
|
1621
|
+
else if (typeof v === "string") types.add("string");
|
|
1622
|
+
else if (typeof v === "number") {
|
|
1623
|
+
const allInt = field.values.every((x) => typeof x !== "number" || Number.isInteger(x));
|
|
1624
|
+
types.add(allInt ? "int" : "number");
|
|
1625
|
+
break;
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
if (types.size === 1) {
|
|
1629
|
+
out.bsonType = [...types][0];
|
|
1630
|
+
} else if (types.size > 1) {
|
|
1631
|
+
out.bsonType = [...types];
|
|
1632
|
+
}
|
|
1633
|
+
return out;
|
|
1634
|
+
}
|
|
1635
|
+
case "array":
|
|
1636
|
+
out.bsonType = "array";
|
|
1637
|
+
out.items = convertField(field.items);
|
|
1638
|
+
if (field.minItems !== void 0) out.minItems = field.minItems;
|
|
1639
|
+
if (field.maxItems !== void 0) out.maxItems = field.maxItems;
|
|
1640
|
+
if (field.uniqueItems) out.uniqueItems = true;
|
|
1641
|
+
return out;
|
|
1642
|
+
case "object":
|
|
1643
|
+
return convertObject(field);
|
|
1644
|
+
}
|
|
1645
|
+
};
|
|
1646
|
+
var convertObject = (field) => {
|
|
1647
|
+
const out = { bsonType: "object" };
|
|
1648
|
+
const required = [];
|
|
1649
|
+
const properties = {};
|
|
1650
|
+
for (const [key, prop] of Object.entries(field.properties)) {
|
|
1651
|
+
properties[key] = convertField(prop);
|
|
1652
|
+
if (!prop.flags.optional && !prop.flags.hasDefault) required.push(key);
|
|
1653
|
+
}
|
|
1654
|
+
if (required.length > 0) out.required = required;
|
|
1655
|
+
out.properties = properties;
|
|
1656
|
+
out.additionalProperties = field.additionalProperties ?? false;
|
|
1657
|
+
if (field.minProperties !== void 0) out.minProperties = field.minProperties;
|
|
1658
|
+
if (field.maxProperties !== void 0) out.maxProperties = field.maxProperties;
|
|
1659
|
+
return out;
|
|
1660
|
+
};
|
|
1661
|
+
var ORM_RESERVED_FIELDS = {
|
|
1662
|
+
__v: { bsonType: "int" },
|
|
1663
|
+
deletedAt: { bsonType: ["date", "null"] }
|
|
1664
|
+
};
|
|
1665
|
+
var toMongoSchema = (schema) => {
|
|
1666
|
+
const result = convertObject(schema);
|
|
1667
|
+
result.properties ??= {};
|
|
1668
|
+
if (!result.properties._id) {
|
|
1669
|
+
result.properties._id = { bsonType: "objectId" };
|
|
1670
|
+
}
|
|
1671
|
+
for (const [key, fragment] of Object.entries(ORM_RESERVED_FIELDS)) {
|
|
1672
|
+
if (!result.properties[key]) result.properties[key] = fragment;
|
|
1673
|
+
}
|
|
1674
|
+
return result;
|
|
1675
|
+
};
|
|
1676
|
+
var toMongoValidator = (schema) => ({
|
|
1677
|
+
$jsonSchema: toMongoSchema(schema)
|
|
1678
|
+
});
|
|
1679
|
+
|
|
1680
|
+
// src/schema/types.ts
|
|
1681
|
+
var chain = (field) => {
|
|
1682
|
+
const obj = field;
|
|
1683
|
+
obj.optional = () => chain({ ...field, flags: { ...field.flags, optional: true } });
|
|
1684
|
+
obj.default = (value) => chain({ ...field, flags: { ...field.flags, hasDefault: true, defaultValue: value } });
|
|
1685
|
+
return obj;
|
|
1686
|
+
};
|
|
1687
|
+
var BASE = { flags: { optional: false, hasDefault: false } };
|
|
1688
|
+
function objectField(properties, opts = {}) {
|
|
1689
|
+
return chain({ kind: "object", properties, ...BASE, ...opts });
|
|
1690
|
+
}
|
|
1691
|
+
var s = {
|
|
1692
|
+
/** UTF-8 string field (optional `format` / length / regex constraints). */
|
|
1693
|
+
string(opts = {}) {
|
|
1694
|
+
return chain({ kind: "string", ...BASE, ...opts });
|
|
1695
|
+
},
|
|
1696
|
+
/** 32-bit integer field with optional bounds (min/max, exclusive, multipleOf). */
|
|
1697
|
+
integer(opts = {}) {
|
|
1698
|
+
return chain({ kind: "number", integer: true, ...BASE, ...opts });
|
|
1699
|
+
},
|
|
1700
|
+
/** Double/float field with optional bounds (min/max, exclusive, multipleOf). */
|
|
1701
|
+
number(opts = {}) {
|
|
1702
|
+
return chain({ kind: "number", integer: false, ...BASE, ...opts });
|
|
1703
|
+
},
|
|
1704
|
+
/** Strict BSON `double` (IEEE 754 float64) field. */
|
|
1705
|
+
double(opts = {}) {
|
|
1706
|
+
return chain({ kind: "double", ...BASE, ...opts });
|
|
1707
|
+
},
|
|
1708
|
+
/** Strict BSON `long` (int64) field — inferred as `number`. */
|
|
1709
|
+
long(opts = {}) {
|
|
1710
|
+
return chain({ kind: "long", ...BASE, ...opts });
|
|
1711
|
+
},
|
|
1712
|
+
/** Strict BSON `decimal` (Decimal128) field — inferred as `Decimal128`. */
|
|
1713
|
+
decimal(opts = {}) {
|
|
1714
|
+
return chain({ kind: "decimal", ...BASE, ...opts });
|
|
1715
|
+
},
|
|
1716
|
+
/** Boolean field. */
|
|
1717
|
+
boolean() {
|
|
1718
|
+
return chain({ kind: "boolean", ...BASE });
|
|
1719
|
+
},
|
|
1720
|
+
/** UTC datetime field (BSON date). */
|
|
1721
|
+
date() {
|
|
1722
|
+
return chain({ kind: "date", ...BASE });
|
|
1723
|
+
},
|
|
1724
|
+
/** MongoDB ObjectId field (`_id` and foreign keys). */
|
|
1725
|
+
objectId() {
|
|
1726
|
+
return chain({ kind: "objectId", ...BASE });
|
|
1727
|
+
},
|
|
1728
|
+
/** Array of `items` (nested schema types allowed), with optional length/uniqueness. */
|
|
1729
|
+
array(items, opts = {}) {
|
|
1730
|
+
const field = { kind: "array", items, ...BASE, ...opts };
|
|
1731
|
+
return chain(field);
|
|
1732
|
+
},
|
|
1733
|
+
object: objectField,
|
|
1734
|
+
/** Enum union of literal string/number values. */
|
|
1735
|
+
enum(values) {
|
|
1736
|
+
const field = { kind: "enum", values, ...BASE };
|
|
1737
|
+
return chain(field);
|
|
1738
|
+
},
|
|
1739
|
+
/** Unconstrained field (no validator bounds). */
|
|
1740
|
+
any() {
|
|
1741
|
+
return chain({ kind: "any", ...BASE });
|
|
1742
|
+
},
|
|
1743
|
+
/** Explicitly-nullable field. */
|
|
1744
|
+
null() {
|
|
1745
|
+
return chain({ kind: "null", ...BASE });
|
|
1746
|
+
},
|
|
1747
|
+
/** GeoJSON Point field (type `Point` + `[lng, lat]` coordinates) for `2dsphere` queries. */
|
|
1748
|
+
geoPoint() {
|
|
1749
|
+
return chain({ kind: "geoPoint", ...BASE });
|
|
1750
|
+
},
|
|
1751
|
+
/**
|
|
1752
|
+
* Raw `$jsonSchema` escape hatch: pass an arbitrary fragment through verbatim
|
|
1753
|
+
* to the validator. Use for Mongo keywords the DSL doesn't model (e.g.
|
|
1754
|
+
* `patternProperties`, `allOf`, `anyOf`, `dependencies`).
|
|
1755
|
+
*/
|
|
1756
|
+
jsonSchema(fragment) {
|
|
1757
|
+
return chain({ kind: "raw", fragment, ...BASE });
|
|
1758
|
+
}
|
|
1759
|
+
};
|
|
1760
|
+
var optional = (field) => chain({ ...field, flags: { ...field.flags, optional: true } });
|
|
1761
|
+
var withDefault = (field, value) => chain({ ...field, flags: { ...field.flags, hasDefault: true, defaultValue: value } });
|
|
1762
|
+
var push = (issues, path, code, expected, message) => {
|
|
1763
|
+
issues.push({ path, code, expected, message });
|
|
1764
|
+
};
|
|
1765
|
+
var describeValue = (value) => {
|
|
1766
|
+
if (value instanceof ObjectId) return "ObjectId";
|
|
1767
|
+
if (value instanceof Date) return "Date";
|
|
1768
|
+
if (value === null) return "null";
|
|
1769
|
+
if (Array.isArray(value)) return "array";
|
|
1770
|
+
return typeof value;
|
|
1771
|
+
};
|
|
1772
|
+
var isPlainObject2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Date) && !(value instanceof ObjectId) && !(value instanceof RegExp);
|
|
1773
|
+
var numericValue = (value) => {
|
|
1774
|
+
if (value instanceof Decimal128) {
|
|
1775
|
+
const n = Number(value.toString());
|
|
1776
|
+
return Number.isNaN(n) ? void 0 : n;
|
|
1777
|
+
}
|
|
1778
|
+
if (typeof value === "number" && !Number.isNaN(value)) return value;
|
|
1779
|
+
return void 0;
|
|
1780
|
+
};
|
|
1781
|
+
var checkNumericBounds = (field, value, path, issues) => {
|
|
1782
|
+
const num = numericValue(value);
|
|
1783
|
+
if (num === void 0) return;
|
|
1784
|
+
if (field.minimum !== void 0 && (field.exclusiveMinimum ? num <= field.minimum : num < field.minimum)) {
|
|
1785
|
+
push(
|
|
1786
|
+
issues,
|
|
1787
|
+
path,
|
|
1788
|
+
"constraint",
|
|
1789
|
+
`minimum ${field.minimum}${field.exclusiveMinimum ? " (exclusive)" : ""}`,
|
|
1790
|
+
`${num} out of range`
|
|
1791
|
+
);
|
|
1792
|
+
}
|
|
1793
|
+
if (field.maximum !== void 0 && (field.exclusiveMaximum ? num >= field.maximum : num > field.maximum)) {
|
|
1794
|
+
push(
|
|
1795
|
+
issues,
|
|
1796
|
+
path,
|
|
1797
|
+
"constraint",
|
|
1798
|
+
`maximum ${field.maximum}${field.exclusiveMaximum ? " (exclusive)" : ""}`,
|
|
1799
|
+
`${num} out of range`
|
|
1800
|
+
);
|
|
1801
|
+
}
|
|
1802
|
+
if (field.multipleOf !== void 0 && field.multipleOf > 0 && num % field.multipleOf !== 0) {
|
|
1803
|
+
push(
|
|
1804
|
+
issues,
|
|
1805
|
+
path,
|
|
1806
|
+
"constraint",
|
|
1807
|
+
`multipleOf ${field.multipleOf}`,
|
|
1808
|
+
`${num} is not a multiple of ${field.multipleOf}`
|
|
1809
|
+
);
|
|
1810
|
+
}
|
|
1811
|
+
};
|
|
1812
|
+
var bsonTypeMatches = (bsonType, value) => {
|
|
1813
|
+
switch (bsonType) {
|
|
1814
|
+
case "objectId":
|
|
1815
|
+
return value instanceof ObjectId;
|
|
1816
|
+
case "int":
|
|
1817
|
+
return typeof value === "number" && Number.isInteger(value);
|
|
1818
|
+
case "number":
|
|
1819
|
+
return typeof value === "number" && !Number.isNaN(value);
|
|
1820
|
+
case "string":
|
|
1821
|
+
return typeof value === "string";
|
|
1822
|
+
case "bool":
|
|
1823
|
+
return typeof value === "boolean";
|
|
1824
|
+
case "date":
|
|
1825
|
+
return value instanceof Date;
|
|
1826
|
+
case "null":
|
|
1827
|
+
return value === null;
|
|
1828
|
+
case "array":
|
|
1829
|
+
return Array.isArray(value);
|
|
1830
|
+
case "object":
|
|
1831
|
+
return isPlainObject2(value);
|
|
1832
|
+
case "double":
|
|
1833
|
+
return typeof value === "number" && !Number.isNaN(value);
|
|
1834
|
+
case "long":
|
|
1835
|
+
return typeof value === "number" && Number.isInteger(value);
|
|
1836
|
+
case "decimal":
|
|
1837
|
+
return value instanceof Decimal128;
|
|
1838
|
+
default:
|
|
1839
|
+
return true;
|
|
1840
|
+
}
|
|
1841
|
+
};
|
|
1842
|
+
var checkReservedValue = (key, value, path, issues) => {
|
|
1843
|
+
const fragment = ORM_RESERVED_FIELDS[key];
|
|
1844
|
+
if (!fragment) return;
|
|
1845
|
+
const types = Array.isArray(fragment.bsonType) ? fragment.bsonType : [fragment.bsonType];
|
|
1846
|
+
if (types.some((t) => bsonTypeMatches(t, value))) return;
|
|
1847
|
+
push(
|
|
1848
|
+
issues,
|
|
1849
|
+
path,
|
|
1850
|
+
"type",
|
|
1851
|
+
types.join(" | "),
|
|
1852
|
+
`Reserved field "${key}" must be ${types.join(" | ")}`
|
|
1853
|
+
);
|
|
1854
|
+
};
|
|
1855
|
+
|
|
1856
|
+
// src/schema/validate-doc/field-validators.ts
|
|
1857
|
+
var stringValidator = (_deps, field, value, path, issues) => {
|
|
1858
|
+
const f = field;
|
|
1859
|
+
if (typeof value !== "string") {
|
|
1860
|
+
push(issues, path, "type", "string", `Expected string, got ${describeValue(value)}`);
|
|
1861
|
+
return;
|
|
1862
|
+
}
|
|
1863
|
+
if (f.minLength !== void 0 && value.length < f.minLength) {
|
|
1864
|
+
push(
|
|
1865
|
+
issues,
|
|
1866
|
+
path,
|
|
1867
|
+
"constraint",
|
|
1868
|
+
`string(minLength ${f.minLength})`,
|
|
1869
|
+
`Length ${value.length} < ${f.minLength}`
|
|
1870
|
+
);
|
|
1871
|
+
}
|
|
1872
|
+
if (f.maxLength !== void 0 && value.length > f.maxLength) {
|
|
1873
|
+
push(
|
|
1874
|
+
issues,
|
|
1875
|
+
path,
|
|
1876
|
+
"constraint",
|
|
1877
|
+
`string(maxLength ${f.maxLength})`,
|
|
1878
|
+
`Length ${value.length} > ${f.maxLength}`
|
|
1879
|
+
);
|
|
1880
|
+
}
|
|
1881
|
+
if (f.pattern) {
|
|
1882
|
+
try {
|
|
1883
|
+
if (!new RegExp(f.pattern).test(value)) {
|
|
1884
|
+
push(
|
|
1885
|
+
issues,
|
|
1886
|
+
path,
|
|
1887
|
+
"constraint",
|
|
1888
|
+
`string(pattern ${f.pattern})`,
|
|
1889
|
+
`"${value}" does not match pattern`
|
|
1890
|
+
);
|
|
1891
|
+
}
|
|
1892
|
+
} catch {
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
};
|
|
1896
|
+
var numberValidator = (_deps, field, value, path, issues) => {
|
|
1897
|
+
const f = field;
|
|
1898
|
+
const expected = f.integer ? "integer" : "number";
|
|
1899
|
+
if (typeof value !== "number" || Number.isNaN(value)) {
|
|
1900
|
+
push(issues, path, "type", expected, `Expected ${expected}, got ${describeValue(value)}`);
|
|
1901
|
+
return;
|
|
1902
|
+
}
|
|
1903
|
+
if (f.integer && !Number.isInteger(value)) {
|
|
1904
|
+
push(issues, path, "type", "integer", `Expected integer, got ${value}`);
|
|
1905
|
+
}
|
|
1906
|
+
checkNumericBounds(f, value, path, issues);
|
|
1907
|
+
};
|
|
1908
|
+
var floatingValidator = (_deps, field, value, path, issues) => {
|
|
1909
|
+
const f = field;
|
|
1910
|
+
const ok = f.kind === "decimal" ? value instanceof Decimal128 : f.kind === "long" ? typeof value === "number" && Number.isInteger(value) : typeof value === "number" && !Number.isNaN(value);
|
|
1911
|
+
if (!ok) {
|
|
1912
|
+
push(issues, path, "type", f.kind, `Expected ${f.kind}, got ${describeValue(value)}`);
|
|
1913
|
+
return;
|
|
1914
|
+
}
|
|
1915
|
+
checkNumericBounds(f, value, path, issues);
|
|
1916
|
+
};
|
|
1917
|
+
var booleanValidator = (_deps, _field, value, path, issues) => {
|
|
1918
|
+
if (typeof value !== "boolean") {
|
|
1919
|
+
push(issues, path, "type", "boolean", `Expected boolean, got ${describeValue(value)}`);
|
|
1920
|
+
}
|
|
1921
|
+
};
|
|
1922
|
+
var dateValidator = (_deps, _field, value, path, issues) => {
|
|
1923
|
+
if (!(value instanceof Date)) {
|
|
1924
|
+
push(issues, path, "type", "date", `Expected Date, got ${describeValue(value)}`);
|
|
1925
|
+
}
|
|
1926
|
+
};
|
|
1927
|
+
var objectIdValidator = (_deps, _field, value, path, issues) => {
|
|
1928
|
+
if (!(value instanceof ObjectId)) {
|
|
1929
|
+
push(issues, path, "type", "ObjectId", `Expected ObjectId, got ${describeValue(value)}`);
|
|
1930
|
+
}
|
|
1931
|
+
};
|
|
1932
|
+
var geoPointValidator = (_deps, _field, value, path, issues) => {
|
|
1933
|
+
if (!isPlainObject2(value)) {
|
|
1934
|
+
push(issues, path, "type", "GeoPoint", `Expected GeoPoint object, got ${describeValue(value)}`);
|
|
1935
|
+
return;
|
|
1936
|
+
}
|
|
1937
|
+
const gp = value;
|
|
1938
|
+
if (gp.type !== "Point") {
|
|
1939
|
+
push(
|
|
1940
|
+
issues,
|
|
1941
|
+
path,
|
|
1942
|
+
"enum",
|
|
1943
|
+
'GeoPoint(type "Point")',
|
|
1944
|
+
`Expected type "Point", got ${JSON.stringify(gp.type)}`
|
|
1945
|
+
);
|
|
1946
|
+
}
|
|
1947
|
+
const coords = gp.coordinates;
|
|
1948
|
+
if (!Array.isArray(coords) || coords.length !== 2 || !coords.every((c) => typeof c === "number" && Number.isFinite(c))) {
|
|
1949
|
+
push(
|
|
1950
|
+
issues,
|
|
1951
|
+
path,
|
|
1952
|
+
"constraint",
|
|
1953
|
+
"GeoPoint(coordinates: [lng, lat])",
|
|
1954
|
+
"Expected [lng, lat] pair of finite numbers"
|
|
1955
|
+
);
|
|
1956
|
+
}
|
|
1957
|
+
};
|
|
1958
|
+
var nullValidator = (_deps, _field, value, path, issues) => {
|
|
1959
|
+
if (value !== null) {
|
|
1960
|
+
push(issues, path, "type", "null", `Expected null, got ${describeValue(value)}`);
|
|
1961
|
+
}
|
|
1962
|
+
};
|
|
1963
|
+
var anyValidator = () => {
|
|
1964
|
+
};
|
|
1965
|
+
var rawValidator = () => {
|
|
1966
|
+
};
|
|
1967
|
+
var enumValidator = (_deps, field, value, path, issues) => {
|
|
1968
|
+
const f = field;
|
|
1969
|
+
if (!f.values.includes(value)) {
|
|
1970
|
+
push(
|
|
1971
|
+
issues,
|
|
1972
|
+
path,
|
|
1973
|
+
"enum",
|
|
1974
|
+
`enum[${f.values.join(", ")}]`,
|
|
1975
|
+
`Value ${JSON.stringify(value)} is not in the allowed set`
|
|
1976
|
+
);
|
|
1977
|
+
}
|
|
1978
|
+
};
|
|
1979
|
+
var arrayValidator = (deps2, field, value, path, issues) => {
|
|
1980
|
+
const f = field;
|
|
1981
|
+
if (!Array.isArray(value)) {
|
|
1982
|
+
push(
|
|
1983
|
+
issues,
|
|
1984
|
+
path,
|
|
1985
|
+
"type",
|
|
1986
|
+
`array<${f.items.kind}>`,
|
|
1987
|
+
`Expected array, got ${describeValue(value)}`
|
|
1988
|
+
);
|
|
1989
|
+
return;
|
|
1990
|
+
}
|
|
1991
|
+
if (f.minItems !== void 0 && value.length < f.minItems) {
|
|
1992
|
+
push(
|
|
1993
|
+
issues,
|
|
1994
|
+
path,
|
|
1995
|
+
"constraint",
|
|
1996
|
+
`array(minItems ${f.minItems})`,
|
|
1997
|
+
`Length ${value.length} < ${f.minItems}`
|
|
1998
|
+
);
|
|
1999
|
+
}
|
|
2000
|
+
if (f.maxItems !== void 0 && value.length > f.maxItems) {
|
|
2001
|
+
push(
|
|
2002
|
+
issues,
|
|
2003
|
+
path,
|
|
2004
|
+
"constraint",
|
|
2005
|
+
`array(maxItems ${f.maxItems})`,
|
|
2006
|
+
`Length ${value.length} > ${f.maxItems}`
|
|
2007
|
+
);
|
|
2008
|
+
}
|
|
2009
|
+
if (f.uniqueItems) {
|
|
2010
|
+
const seen = new Set(
|
|
2011
|
+
value.map(
|
|
2012
|
+
(item) => item instanceof ObjectId ? item.toHexString() : typeof item === "object" && item !== null ? JSON.stringify(item) : item
|
|
2013
|
+
)
|
|
2014
|
+
);
|
|
2015
|
+
if (seen.size !== value.length) {
|
|
2016
|
+
push(issues, path, "constraint", "array(uniqueItems)", "Array contains duplicate items");
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
value.forEach((item, i) => {
|
|
2020
|
+
deps2.validateValue(f.items, item, `${path}[${i}]`, issues);
|
|
2021
|
+
});
|
|
2022
|
+
};
|
|
2023
|
+
var objectValidator = (deps2, field, value, path, issues) => {
|
|
2024
|
+
const f = field;
|
|
2025
|
+
if (!isPlainObject2(value)) {
|
|
2026
|
+
push(issues, path, "type", "object", `Expected object, got ${describeValue(value)}`);
|
|
2027
|
+
return;
|
|
2028
|
+
}
|
|
2029
|
+
deps2.validateObjectProps(f, value, path, issues, false);
|
|
2030
|
+
};
|
|
2031
|
+
var FIELD_VALIDATORS = {
|
|
2032
|
+
string: stringValidator,
|
|
2033
|
+
number: numberValidator,
|
|
2034
|
+
double: floatingValidator,
|
|
2035
|
+
long: floatingValidator,
|
|
2036
|
+
decimal: floatingValidator,
|
|
2037
|
+
boolean: booleanValidator,
|
|
2038
|
+
date: dateValidator,
|
|
2039
|
+
objectId: objectIdValidator,
|
|
2040
|
+
geoPoint: geoPointValidator,
|
|
2041
|
+
null: nullValidator,
|
|
2042
|
+
any: anyValidator,
|
|
2043
|
+
raw: rawValidator,
|
|
2044
|
+
enum: enumValidator,
|
|
2045
|
+
array: arrayValidator,
|
|
2046
|
+
object: objectValidator
|
|
2047
|
+
};
|
|
2048
|
+
|
|
2049
|
+
// src/schema/validate-doc/validate.ts
|
|
2050
|
+
function validateValue(field, value, path, issues) {
|
|
2051
|
+
const validator = FIELD_VALIDATORS[field.kind];
|
|
2052
|
+
if (validator) validator(deps, field, value, path, issues);
|
|
2053
|
+
}
|
|
2054
|
+
function validateObjectProps(field, value, path, issues, topLevel) {
|
|
2055
|
+
const propPath = (key) => path ? `${path}.${key}` : key;
|
|
2056
|
+
for (const [key, prop] of Object.entries(field.properties)) {
|
|
2057
|
+
const present = key in value && value[key] !== void 0;
|
|
2058
|
+
if (!present) {
|
|
2059
|
+
if (!prop.flags.optional && !prop.flags.hasDefault) {
|
|
2060
|
+
push(issues, propPath(key), "missing", prop.kind, `Required field "${key}" is missing`);
|
|
2061
|
+
}
|
|
2062
|
+
continue;
|
|
2063
|
+
}
|
|
2064
|
+
validateValue(prop, value[key], propPath(key), issues);
|
|
2065
|
+
}
|
|
2066
|
+
if (field.additionalProperties !== true) {
|
|
2067
|
+
const allowedReserved = topLevel ? /* @__PURE__ */ new Set(["_id", ...Object.keys(ORM_RESERVED_FIELDS)]) : /* @__PURE__ */ new Set();
|
|
2068
|
+
for (const key of Object.keys(value)) {
|
|
2069
|
+
if (key in field.properties) continue;
|
|
2070
|
+
if (allowedReserved.has(key)) continue;
|
|
2071
|
+
push(
|
|
2072
|
+
issues,
|
|
2073
|
+
propPath(key),
|
|
2074
|
+
"unknown_key",
|
|
2075
|
+
"no additional properties",
|
|
2076
|
+
`Unexpected field "${key}"`
|
|
2077
|
+
);
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
const propCount = Object.keys(value).length;
|
|
2081
|
+
if (field.minProperties !== void 0 && propCount < field.minProperties) {
|
|
2082
|
+
push(
|
|
2083
|
+
issues,
|
|
2084
|
+
path,
|
|
2085
|
+
"constraint",
|
|
2086
|
+
`object(minProperties ${field.minProperties})`,
|
|
2087
|
+
`Has ${propCount} properties < ${field.minProperties}`
|
|
2088
|
+
);
|
|
2089
|
+
}
|
|
2090
|
+
if (field.maxProperties !== void 0 && propCount > field.maxProperties) {
|
|
2091
|
+
push(
|
|
2092
|
+
issues,
|
|
2093
|
+
path,
|
|
2094
|
+
"constraint",
|
|
2095
|
+
`object(maxProperties ${field.maxProperties})`,
|
|
2096
|
+
`Has ${propCount} properties > ${field.maxProperties}`
|
|
2097
|
+
);
|
|
2098
|
+
}
|
|
2099
|
+
if (topLevel) {
|
|
2100
|
+
if ("_id" in value && !("_id" in field.properties) && value._id !== void 0) {
|
|
2101
|
+
if (!(value._id instanceof ObjectId)) {
|
|
2102
|
+
push(issues, "_id", "type", "ObjectId", 'Reserved field "_id" must be ObjectId');
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
for (const key of Object.keys(ORM_RESERVED_FIELDS)) {
|
|
2106
|
+
if (key in field.properties) continue;
|
|
2107
|
+
if (key in value && value[key] !== void 0) {
|
|
2108
|
+
checkReservedValue(key, value[key], key, issues);
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
var deps = { validateValue, validateObjectProps };
|
|
2114
|
+
var validateDoc = (schema, doc) => {
|
|
2115
|
+
const issues = [];
|
|
2116
|
+
if (doc === null || typeof doc !== "object" || Array.isArray(doc)) {
|
|
2117
|
+
push(issues, "", "type", "object", `Expected a document object, got ${describeValue(doc)}`);
|
|
2118
|
+
return issues;
|
|
2119
|
+
}
|
|
2120
|
+
validateObjectProps(schema, doc, "", issues, true);
|
|
2121
|
+
return issues;
|
|
2122
|
+
};
|
|
2123
|
+
|
|
2124
|
+
// src/service/collection-name.ts
|
|
2125
|
+
var DEFAULT_COLLECTION_PREFIX_SEPARATOR = "-";
|
|
2126
|
+
var createResolveCollectionName = (config) => {
|
|
2127
|
+
const sep = config.collectionPrefixSeparator ?? DEFAULT_COLLECTION_PREFIX_SEPARATOR;
|
|
2128
|
+
return (logical) => {
|
|
2129
|
+
const override = config.collectionPhysicalNames?.[logical];
|
|
2130
|
+
if (override) return override;
|
|
2131
|
+
const prefix = config.collectionPrefix;
|
|
2132
|
+
if (!prefix) return logical;
|
|
2133
|
+
return `${prefix}${sep}${logical}`;
|
|
2134
|
+
};
|
|
2135
|
+
};
|
|
2136
|
+
|
|
2137
|
+
// src/service/connection-uri.ts
|
|
2138
|
+
var normalizeMongoUrl = (rawUrl) => {
|
|
2139
|
+
try {
|
|
2140
|
+
const url = new URL(rawUrl);
|
|
2141
|
+
if (!url.searchParams.has("retryWrites")) url.searchParams.set("retryWrites", "false");
|
|
2142
|
+
const hasWriteConcern = url.searchParams.has("w") || url.searchParams.has("wtimeoutMS") || url.searchParams.has("writeConcern");
|
|
2143
|
+
if (!hasWriteConcern) url.searchParams.set("w", "1");
|
|
2144
|
+
const hostCount = url.hostname.split(",").length;
|
|
2145
|
+
if (hostCount === 1 && !url.searchParams.has("replicaSet") && !url.searchParams.has("directConnection")) {
|
|
2146
|
+
url.searchParams.set("directConnection", "true");
|
|
2147
|
+
}
|
|
2148
|
+
return url.toString();
|
|
2149
|
+
} catch {
|
|
2150
|
+
return rawUrl;
|
|
2151
|
+
}
|
|
2152
|
+
};
|
|
2153
|
+
|
|
2154
|
+
// src/service/cache-invalidation.ts
|
|
2155
|
+
var SEP2 = "\0";
|
|
2156
|
+
var CacheInvalidator = class {
|
|
2157
|
+
cache;
|
|
2158
|
+
logger;
|
|
2159
|
+
streams = /* @__PURE__ */ new Map();
|
|
2160
|
+
disabled = false;
|
|
2161
|
+
stopped = true;
|
|
2162
|
+
warnedUnsupported = false;
|
|
2163
|
+
constructor(options) {
|
|
2164
|
+
this.cache = options.cache;
|
|
2165
|
+
this.logger = options.logger;
|
|
2166
|
+
}
|
|
2167
|
+
/** Number of currently open change streams. */
|
|
2168
|
+
get size() {
|
|
2169
|
+
return this.streams.size;
|
|
2170
|
+
}
|
|
2171
|
+
/**
|
|
2172
|
+
* Open one watcher per unique (database, physical collection). Idempotent:
|
|
2173
|
+
* already-open streams are skipped; after `stop()` a later `start()` reopens.
|
|
2174
|
+
* Never throws — failures are logged (on unsupported servers it disables once).
|
|
2175
|
+
*/
|
|
2176
|
+
async start(refs) {
|
|
2177
|
+
if (this.disabled) return;
|
|
2178
|
+
this.stopped = false;
|
|
2179
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2180
|
+
for (const ref of refs) {
|
|
2181
|
+
const key = `${ref.db.databaseName}${SEP2}${ref.collection}`;
|
|
2182
|
+
if (seen.has(key) || this.streams.has(key)) continue;
|
|
2183
|
+
seen.add(key);
|
|
2184
|
+
void this.watchLoop(key, ref);
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
/** Close every watcher. Safe to call repeatedly, and before any `start`. */
|
|
2188
|
+
async stop() {
|
|
2189
|
+
this.stopped = true;
|
|
2190
|
+
const closes = [...this.streams.values()].map((stream) => {
|
|
2191
|
+
try {
|
|
2192
|
+
return Promise.resolve(stream.close()).catch(() => {
|
|
2193
|
+
});
|
|
2194
|
+
} catch {
|
|
2195
|
+
return Promise.resolve();
|
|
2196
|
+
}
|
|
2197
|
+
});
|
|
2198
|
+
this.streams.clear();
|
|
2199
|
+
await Promise.allSettled(closes);
|
|
2200
|
+
}
|
|
2201
|
+
async watchLoop(key, ref) {
|
|
2202
|
+
let backoff = 0;
|
|
2203
|
+
while (!this.stopped) {
|
|
2204
|
+
try {
|
|
2205
|
+
const stream = ref.db.collection(ref.collection).watch([]);
|
|
2206
|
+
stream.on("error", () => {
|
|
2207
|
+
});
|
|
2208
|
+
this.streams.set(key, stream);
|
|
2209
|
+
if (backoff > 0) {
|
|
2210
|
+
this.cache.invalidateByCollection(
|
|
2211
|
+
cacheCollectionKey(ref.db.databaseName, ref.collection)
|
|
2212
|
+
);
|
|
2213
|
+
}
|
|
2214
|
+
const iterator = stream[Symbol.asyncIterator]();
|
|
2215
|
+
for await (const _change of iterator) {
|
|
2216
|
+
this.cache.invalidateByCollection(
|
|
2217
|
+
cacheCollectionKey(ref.db.databaseName, ref.collection)
|
|
2218
|
+
);
|
|
2219
|
+
}
|
|
2220
|
+
} catch (err) {
|
|
2221
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2222
|
+
const abandoned = this.streams.get(key);
|
|
2223
|
+
this.streams.delete(key);
|
|
2224
|
+
if (abandoned) {
|
|
2225
|
+
try {
|
|
2226
|
+
await abandoned.close();
|
|
2227
|
+
} catch {
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
if (/only supported on replica sets/i.test(message)) {
|
|
2231
|
+
this.disabled = true;
|
|
2232
|
+
if (!this.warnedUnsupported) {
|
|
2233
|
+
this.warnedUnsupported = true;
|
|
2234
|
+
this.logger.warn?.(
|
|
2235
|
+
{ collection: ref.collection, error: message },
|
|
2236
|
+
"cacheWatch: change streams are not supported here (standalone?) \u2014 the query cache stays write-through only; set cache: { ttlMs } or disable cacheWatch"
|
|
2237
|
+
);
|
|
2238
|
+
}
|
|
2239
|
+
return;
|
|
2240
|
+
}
|
|
2241
|
+
if (this.stopped) return;
|
|
2242
|
+
this.logger.warn?.(
|
|
2243
|
+
{ error: message, collection: ref.collection },
|
|
2244
|
+
"cacheWatch: change stream error, retrying with backoff"
|
|
2245
|
+
);
|
|
2246
|
+
backoff = Math.min(backoff + 1e3, 5e3);
|
|
2247
|
+
await sleepJittered(backoff);
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
};
|
|
2252
|
+
|
|
2253
|
+
// src/utils/logger.ts
|
|
2254
|
+
var createNoopLogger = () => ({
|
|
2255
|
+
debug: () => {
|
|
2256
|
+
},
|
|
2257
|
+
info: () => {
|
|
2258
|
+
},
|
|
2259
|
+
warn: () => {
|
|
2260
|
+
},
|
|
2261
|
+
error: () => {
|
|
2262
|
+
}
|
|
2263
|
+
});
|
|
2264
|
+
var LEVEL_RANK = { debug: 10, info: 20, warn: 30, error: 40 };
|
|
2265
|
+
var fmtObj = (obj) => {
|
|
2266
|
+
try {
|
|
2267
|
+
return JSON.stringify(obj);
|
|
2268
|
+
} catch {
|
|
2269
|
+
return String(obj);
|
|
2270
|
+
}
|
|
2271
|
+
};
|
|
2272
|
+
var createConsoleLogger = (level = "info") => {
|
|
2273
|
+
const threshold = LEVEL_RANK[level];
|
|
2274
|
+
const make = (method) => {
|
|
2275
|
+
const rank = LEVEL_RANK[method];
|
|
2276
|
+
const fn = (a, b) => {
|
|
2277
|
+
if (rank < threshold) return;
|
|
2278
|
+
const obj = typeof a === "string" ? void 0 : a;
|
|
2279
|
+
const msg = typeof a === "string" ? a : b;
|
|
2280
|
+
const prefix = obj !== void 0 ? fmtObj(obj) : "";
|
|
2281
|
+
if (msg && prefix) {
|
|
2282
|
+
console[method](`[${method.toUpperCase()}]`, prefix, msg);
|
|
2283
|
+
} else {
|
|
2284
|
+
console[method](`[${method.toUpperCase()}]`, msg ?? prefix);
|
|
2285
|
+
}
|
|
2286
|
+
};
|
|
2287
|
+
return fn;
|
|
2288
|
+
};
|
|
2289
|
+
return {
|
|
2290
|
+
debug: make("debug"),
|
|
2291
|
+
info: make("info"),
|
|
2292
|
+
warn: make("warn"),
|
|
2293
|
+
error: make("error")
|
|
2294
|
+
};
|
|
2295
|
+
};
|
|
2296
|
+
|
|
2297
|
+
// src/service/config.ts
|
|
2298
|
+
var resolveCache = (value) => {
|
|
2299
|
+
if (value === null) return void 0;
|
|
2300
|
+
if (value === void 0) return new QueryCache();
|
|
2301
|
+
if (value instanceof QueryCache) return value;
|
|
2302
|
+
return new QueryCache(value);
|
|
2303
|
+
};
|
|
2304
|
+
var resolveRuntimeConfig = (configOverrides) => {
|
|
2305
|
+
const config = {
|
|
2306
|
+
defaultDb: configOverrides.defaultDb ?? process.env.MONGO_URL ?? "mongodb://localhost:27017/",
|
|
2307
|
+
migrationDir: configOverrides.migrationDir ?? process.env.MIGRATION_DIR ?? "./migrations",
|
|
2308
|
+
appName: configOverrides.appName ?? process.env.APP_NAME ?? "unknown-app"
|
|
2309
|
+
};
|
|
2310
|
+
const logger = configOverrides.logger ?? createConsoleLogger();
|
|
2311
|
+
const perfEnabled = configOverrides.perf !== false;
|
|
2312
|
+
const sharedCache = perfEnabled ? resolveCache(configOverrides.cache) : void 0;
|
|
2313
|
+
const dedupeReads = perfEnabled ? configOverrides.dedupeReads ?? true : false;
|
|
2314
|
+
const wrapMongoErrors = configOverrides.wrapMongoErrors !== false;
|
|
2315
|
+
return { config, logger, perfEnabled, sharedCache, dedupeReads, wrapMongoErrors };
|
|
2316
|
+
};
|
|
2317
|
+
var makeGetDbUrl = (dbClients, config) => (name) => {
|
|
2318
|
+
const clientConfig = dbClients[name];
|
|
2319
|
+
if (clientConfig?.dbUrl) return normalizeMongoUrl(clientConfig.dbUrl);
|
|
2320
|
+
const envName = `DB_${name.replace(/[^a-zA-Z0-9]/g, "_").toUpperCase()}`;
|
|
2321
|
+
const envUrl = process.env[envName];
|
|
2322
|
+
if (envUrl) return normalizeMongoUrl(envUrl);
|
|
2323
|
+
return normalizeMongoUrl(config.defaultDb);
|
|
2324
|
+
};
|
|
2325
|
+
var makeConnectionFactory = (dbClients, db, getDbUrl, buildManager2, openedClients = /* @__PURE__ */ new Map()) => {
|
|
2326
|
+
return async (names) => {
|
|
2327
|
+
const keys = names && names.length > 0 ? names : Object.keys(dbClients);
|
|
2328
|
+
const openedThisCall = [];
|
|
2329
|
+
const assignedThisCall = [];
|
|
2330
|
+
try {
|
|
2331
|
+
for (const key of keys) {
|
|
2332
|
+
const definition = dbClients[key];
|
|
2333
|
+
const dbUrl = getDbUrl(key);
|
|
2334
|
+
let client = openedClients.get(dbUrl);
|
|
2335
|
+
if (!client) {
|
|
2336
|
+
client = new MongoClient(dbUrl, {
|
|
2337
|
+
connectTimeoutMS: definition.connectTimeoutMs ?? 1e4,
|
|
2338
|
+
serverSelectionTimeoutMS: definition.connectTimeoutMs ?? 1e4,
|
|
2339
|
+
...definition.readPreference && { readPreference: definition.readPreference },
|
|
2340
|
+
...definition.readConcern && { readConcern: definition.readConcern },
|
|
2341
|
+
...definition.writeConcern && { writeConcern: definition.writeConcern }
|
|
2342
|
+
});
|
|
2343
|
+
client.on("error", () => {
|
|
2344
|
+
});
|
|
2345
|
+
openedClients.set(dbUrl, client);
|
|
2346
|
+
openedThisCall.push({ url: dbUrl, client });
|
|
2347
|
+
await client.connect();
|
|
2348
|
+
}
|
|
2349
|
+
const dbKey = `${key}Client`;
|
|
2350
|
+
db[dbKey] = buildManager2(key, definition.name, client);
|
|
2351
|
+
assignedThisCall.push(dbKey);
|
|
2352
|
+
}
|
|
2353
|
+
} catch (err) {
|
|
2354
|
+
await Promise.allSettled(openedThisCall.map(({ client }) => client.close()));
|
|
2355
|
+
for (const { url } of openedThisCall) openedClients.delete(url);
|
|
2356
|
+
for (const dbKey of assignedThisCall) delete db[dbKey];
|
|
2357
|
+
throw err;
|
|
2358
|
+
}
|
|
2359
|
+
};
|
|
2360
|
+
};
|
|
2361
|
+
var closeConnections = async (openedClients) => {
|
|
2362
|
+
const closes = [...openedClients.values()].map((client) => {
|
|
2363
|
+
try {
|
|
2364
|
+
return Promise.resolve(client.close()).catch(() => {
|
|
2365
|
+
});
|
|
2366
|
+
} catch {
|
|
2367
|
+
return Promise.resolve();
|
|
2368
|
+
}
|
|
2369
|
+
});
|
|
2370
|
+
await Promise.allSettled(closes);
|
|
2371
|
+
openedClients.clear();
|
|
2372
|
+
};
|
|
2373
|
+
|
|
2374
|
+
// src/service/health.ts
|
|
2375
|
+
var createHealth = (db) => async () => {
|
|
2376
|
+
const started = performance.now();
|
|
2377
|
+
const dbs = await Promise.all(
|
|
2378
|
+
Object.entries(db).map(async ([key, manager]) => {
|
|
2379
|
+
const name = key.replace(/Client$/, "");
|
|
2380
|
+
const handle = manager?.client;
|
|
2381
|
+
if (!handle) return { name, ok: false };
|
|
2382
|
+
try {
|
|
2383
|
+
const t0 = performance.now();
|
|
2384
|
+
await withTimeout(handle.admin().command({ ping: 1 }), 2e3, `health:${name}`);
|
|
2385
|
+
return { name, ok: true, latencyMs: performance.now() - t0 };
|
|
2386
|
+
} catch {
|
|
2387
|
+
return { name, ok: false };
|
|
2388
|
+
}
|
|
2389
|
+
})
|
|
2390
|
+
);
|
|
2391
|
+
return { ok: dbs.every((r) => r.ok), latencyMs: performance.now() - started, dbs };
|
|
2392
|
+
};
|
|
2393
|
+
|
|
2394
|
+
// src/relation/populate.ts
|
|
2395
|
+
var createForeignKeyLoader = (deps2, collection, foreignField, options = {}) => new DataLoader({
|
|
2396
|
+
maxBatchSize: options.maxBatchSize,
|
|
2397
|
+
cache: options.cache,
|
|
2398
|
+
keyOf: canonicalKey,
|
|
2399
|
+
batch: async (keys) => {
|
|
2400
|
+
const docs = await deps2.findMany(
|
|
2401
|
+
collection,
|
|
2402
|
+
{ [foreignField]: { $in: keys } },
|
|
2403
|
+
{
|
|
2404
|
+
limit: options.maxDocs
|
|
2405
|
+
}
|
|
2406
|
+
);
|
|
2407
|
+
const index = /* @__PURE__ */ new Map();
|
|
2408
|
+
for (const doc of docs) {
|
|
2409
|
+
const value = doc[foreignField];
|
|
2410
|
+
const canonical = canonicalKey(value);
|
|
2411
|
+
const bucket = index.get(canonical) ?? [];
|
|
2412
|
+
bucket.push(doc);
|
|
2413
|
+
index.set(canonical, bucket);
|
|
2414
|
+
}
|
|
2415
|
+
const map = /* @__PURE__ */ new Map();
|
|
2416
|
+
for (const key of keys) {
|
|
2417
|
+
map.set(key, index.get(canonicalKey(key)) ?? []);
|
|
2418
|
+
}
|
|
2419
|
+
return map;
|
|
2420
|
+
}
|
|
2421
|
+
});
|
|
2422
|
+
var distinctKeys = (values) => {
|
|
2423
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2424
|
+
const out = [];
|
|
2425
|
+
for (const value of values) {
|
|
2426
|
+
if (value === void 0 || value === null) continue;
|
|
2427
|
+
const canonical = canonicalKey(value);
|
|
2428
|
+
if (!seen.has(canonical)) {
|
|
2429
|
+
seen.add(canonical);
|
|
2430
|
+
out.push(value);
|
|
2431
|
+
}
|
|
2432
|
+
}
|
|
2433
|
+
return out;
|
|
2434
|
+
};
|
|
2435
|
+
var makePopulator = (deps2) => {
|
|
2436
|
+
const populate = async (docs, relations, options = {}) => {
|
|
2437
|
+
for (const relation of relations) {
|
|
2438
|
+
const target = relation.foreignField ?? "_id";
|
|
2439
|
+
const sourceKeys = distinctKeys(docs.map((doc) => doc[relation.localField]));
|
|
2440
|
+
if (relation.type === "belongsTo") {
|
|
2441
|
+
const loader = createForeignKeyLoader(deps2, relation.collection, target, options);
|
|
2442
|
+
const values = await loader.loadMany(sourceKeys);
|
|
2443
|
+
const byKey = new Map(sourceKeys.map((key, i) => [canonicalKey(key), values[i]]));
|
|
2444
|
+
for (const doc of docs) {
|
|
2445
|
+
const key = doc[relation.localField];
|
|
2446
|
+
const match = key === void 0 || key === null ? void 0 : byKey.get(canonicalKey(key))?.[0];
|
|
2447
|
+
doc[relation.as] = match ?? (relation.optional ? void 0 : null);
|
|
2448
|
+
}
|
|
2449
|
+
} else if (relation.type === "hasMany") {
|
|
2450
|
+
const loader = createForeignKeyLoader(deps2, relation.collection, target, options);
|
|
2451
|
+
const values = await loader.loadMany(sourceKeys);
|
|
2452
|
+
const byKey = new Map(sourceKeys.map((key, i) => [canonicalKey(key), values[i] ?? []]));
|
|
2453
|
+
for (const doc of docs) {
|
|
2454
|
+
const key = doc[relation.localField];
|
|
2455
|
+
doc[relation.as] = key === void 0 || key === null ? [] : byKey.get(canonicalKey(key)) ?? [];
|
|
2456
|
+
}
|
|
2457
|
+
} else {
|
|
2458
|
+
const throughLoader = createForeignKeyLoader(
|
|
2459
|
+
deps2,
|
|
2460
|
+
relation.through.collection,
|
|
2461
|
+
relation.through.localField,
|
|
2462
|
+
options
|
|
2463
|
+
);
|
|
2464
|
+
const throughValues = await throughLoader.loadMany(sourceKeys);
|
|
2465
|
+
const throughByKey = new Map(
|
|
2466
|
+
sourceKeys.map((key, i) => [canonicalKey(key), throughValues[i] ?? []])
|
|
2467
|
+
);
|
|
2468
|
+
const joinDocs = throughValues.flat();
|
|
2469
|
+
const targetKeys = distinctKeys(
|
|
2470
|
+
joinDocs.map((join3) => join3[relation.through.foreignField])
|
|
2471
|
+
);
|
|
2472
|
+
const targetLoader = createForeignKeyLoader(deps2, relation.collection, target, options);
|
|
2473
|
+
const targetValues = await targetLoader.loadMany(targetKeys);
|
|
2474
|
+
const targetByKey = new Map(
|
|
2475
|
+
targetKeys.map((key, i) => [canonicalKey(key), targetValues[i] ?? []])
|
|
2476
|
+
);
|
|
2477
|
+
for (const doc of docs) {
|
|
2478
|
+
const key = doc[relation.localField];
|
|
2479
|
+
const joins = key === void 0 || key === null ? [] : throughByKey.get(canonicalKey(key)) ?? [];
|
|
2480
|
+
const targets = joins.flatMap(
|
|
2481
|
+
(join3) => targetByKey.get(canonicalKey(join3[relation.through.foreignField])) ?? []
|
|
2482
|
+
);
|
|
2483
|
+
doc[relation.as] = targets;
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
2486
|
+
}
|
|
2487
|
+
return docs;
|
|
2488
|
+
};
|
|
2489
|
+
return { populate };
|
|
2490
|
+
};
|
|
2491
|
+
|
|
2492
|
+
// src/types.ts
|
|
2493
|
+
var asCollectionDefinition = (value) => {
|
|
2494
|
+
if (value && typeof value === "object" && "schema" in value) {
|
|
2495
|
+
const def = value;
|
|
2496
|
+
const ts = def.timestamps;
|
|
2497
|
+
const timestamps = ts === true ? { createdAt: "createdAt", updatedAt: "updatedAt" } : ts && typeof ts === "object" ? ts : void 0;
|
|
2498
|
+
return {
|
|
2499
|
+
schema: def.schema,
|
|
2500
|
+
indexes: def.indexes ?? [],
|
|
2501
|
+
hooks: def.hooks ?? {},
|
|
2502
|
+
...timestamps ? { timestamps } : {}
|
|
2503
|
+
};
|
|
2504
|
+
}
|
|
2505
|
+
return { schema: value, indexes: [], hooks: {} };
|
|
2506
|
+
};
|
|
2507
|
+
|
|
2508
|
+
// src/service/trace-db-op.ts
|
|
2509
|
+
var traceDbOp = async (logger, meta, fn, options = {}) => {
|
|
2510
|
+
const start = performance.now();
|
|
2511
|
+
logger.debug({ ...meta, operation: "start" }, "start");
|
|
2512
|
+
try {
|
|
2513
|
+
const result = await fn();
|
|
2514
|
+
const durationMs = performance.now() - start;
|
|
2515
|
+
logger.info({ ...meta, durationMs }, "ok");
|
|
2516
|
+
return result;
|
|
2517
|
+
} catch (err) {
|
|
2518
|
+
const durationMs = performance.now() - start;
|
|
2519
|
+
const loggable = err instanceof Error ? { name: err.name, message: err.message, code: err.code } : err;
|
|
2520
|
+
logger.error({ ...meta, durationMs, error: loggable }, "error");
|
|
2521
|
+
if (options.wrapMongoErrors) throw mapMongoDriverError(err, meta);
|
|
2522
|
+
throw err;
|
|
2523
|
+
}
|
|
2524
|
+
};
|
|
2525
|
+
|
|
2526
|
+
// src/service/op-deps.ts
|
|
2527
|
+
var makeOpDeps = (logger, dbLabel, resolve, wrapMongoErrors) => {
|
|
2528
|
+
const trace = (meta2, fn) => traceDbOp(logger, meta2, fn, { wrapMongoErrors });
|
|
2529
|
+
const meta = (collection, op) => ({
|
|
2530
|
+
collection: String(collection),
|
|
2531
|
+
physicalCollection: resolve(String(collection)),
|
|
2532
|
+
db: dbLabel,
|
|
2533
|
+
op
|
|
2534
|
+
});
|
|
2535
|
+
return { trace, meta };
|
|
2536
|
+
};
|
|
2537
|
+
|
|
2538
|
+
// src/service/query-options.ts
|
|
2539
|
+
var resolveQueryOptions = (options) => {
|
|
2540
|
+
const maxAttempts = Math.max(1, options?.maxAttempts ?? 3);
|
|
2541
|
+
const retryDelayMs = options?.retryDelayMs ?? 150;
|
|
2542
|
+
const {
|
|
2543
|
+
session,
|
|
2544
|
+
maxTimeMS,
|
|
2545
|
+
hint,
|
|
2546
|
+
batchSize,
|
|
2547
|
+
drift,
|
|
2548
|
+
maxAttempts: _ma,
|
|
2549
|
+
retryDelayMs: _rd,
|
|
2550
|
+
dedupe,
|
|
2551
|
+
cache,
|
|
2552
|
+
retryWrites,
|
|
2553
|
+
...rest
|
|
2554
|
+
} = options ?? {};
|
|
2555
|
+
return {
|
|
2556
|
+
maxAttempts,
|
|
2557
|
+
retryDelayMs,
|
|
2558
|
+
dedupe,
|
|
2559
|
+
cache,
|
|
2560
|
+
retryWrites,
|
|
2561
|
+
sdk: { session, maxTimeMS, hint, batchSize, drift },
|
|
2562
|
+
driverOpts: rest
|
|
2563
|
+
};
|
|
2564
|
+
};
|
|
2565
|
+
var resolveDriftMode = (perOp, serviceDefault) => {
|
|
2566
|
+
if (perOp === true) return "throw";
|
|
2567
|
+
if (perOp === false) return "off";
|
|
2568
|
+
if (perOp === "off" || perOp === "report" || perOp === "throw") return perOp;
|
|
2569
|
+
return serviceDefault ?? "report";
|
|
2570
|
+
};
|
|
2571
|
+
|
|
2572
|
+
// src/service/crud-op.ts
|
|
2573
|
+
var defineCrudOp = async (deps2, collection, opName, execute, options, write = false) => {
|
|
2574
|
+
const resolved = resolveQueryOptions(options);
|
|
2575
|
+
const maxAttempts = write && resolved.retryWrites !== true ? 1 : resolved.maxAttempts;
|
|
2576
|
+
return deps2.trace(
|
|
2577
|
+
deps2.meta(collection, opName),
|
|
2578
|
+
async () => withRetry(async () => execute(resolved), {
|
|
2579
|
+
maxAttempts,
|
|
2580
|
+
delayMs: resolved.retryDelayMs
|
|
2581
|
+
})
|
|
2582
|
+
);
|
|
2583
|
+
};
|
|
2584
|
+
|
|
2585
|
+
// src/service/pipeline-builder.ts
|
|
2586
|
+
var PipelineBuilder = class _PipelineBuilder {
|
|
2587
|
+
constructor(deps2, stages = []) {
|
|
2588
|
+
this.deps = deps2;
|
|
2589
|
+
this.stages = stages;
|
|
2590
|
+
}
|
|
2591
|
+
/** Raw accumulated stages — embeddable into `$lookup` / `$facet` branches. */
|
|
2592
|
+
raw() {
|
|
2593
|
+
return this.stages;
|
|
2594
|
+
}
|
|
2595
|
+
assertNonNegativeFinite(name, n) {
|
|
2596
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
2597
|
+
throw new BadRequest(`pipeline.${name}(): ${name} must be a non-negative finite number`);
|
|
2598
|
+
}
|
|
2599
|
+
}
|
|
2600
|
+
/* --------------------------- shape-keeping --------------------------- */
|
|
2601
|
+
match(filter) {
|
|
2602
|
+
this.stages.push({ $match: filter });
|
|
2603
|
+
return this;
|
|
2604
|
+
}
|
|
2605
|
+
sort(sort) {
|
|
2606
|
+
this.stages.push({ $sort: sort });
|
|
2607
|
+
return this;
|
|
2608
|
+
}
|
|
2609
|
+
limit(n) {
|
|
2610
|
+
this.assertNonNegativeFinite("limit", n);
|
|
2611
|
+
this.stages.push({ $limit: n });
|
|
2612
|
+
return this;
|
|
2613
|
+
}
|
|
2614
|
+
skip(n) {
|
|
2615
|
+
this.assertNonNegativeFinite("skip", n);
|
|
2616
|
+
this.stages.push({ $skip: n });
|
|
2617
|
+
return this;
|
|
2618
|
+
}
|
|
2619
|
+
sample(n) {
|
|
2620
|
+
this.assertNonNegativeFinite("sample", n);
|
|
2621
|
+
this.stages.push({ $sample: { size: n } });
|
|
2622
|
+
return this;
|
|
2623
|
+
}
|
|
2624
|
+
/**
|
|
2625
|
+
* `$geoNear` — requires a `2dsphere`/`2d` index and MUST be the first stage.
|
|
2626
|
+
* Adds the computed distance to `spec.distanceField` on every output doc.
|
|
2627
|
+
*/
|
|
2628
|
+
geoNear(spec) {
|
|
2629
|
+
if (this.stages.length > 0) {
|
|
2630
|
+
throw new Error("pipeline.geoNear(): $geoNear must be the first pipeline stage");
|
|
2631
|
+
}
|
|
2632
|
+
return this.derive({ $geoNear: spec });
|
|
2633
|
+
}
|
|
2634
|
+
/* --------------------------- shape-changing -------------------------- */
|
|
2635
|
+
project(projection) {
|
|
2636
|
+
return this.derive({ $project: projection });
|
|
2637
|
+
}
|
|
2638
|
+
addFields(fields) {
|
|
2639
|
+
return this.derive({ $addFields: fields });
|
|
2640
|
+
}
|
|
2641
|
+
set(fields) {
|
|
2642
|
+
return this.derive({ $set: fields });
|
|
2643
|
+
}
|
|
2644
|
+
unset(fields) {
|
|
2645
|
+
return this.derive({ $unset: fields });
|
|
2646
|
+
}
|
|
2647
|
+
count(field) {
|
|
2648
|
+
return this.derive({ $count: field });
|
|
2649
|
+
}
|
|
2650
|
+
group(group) {
|
|
2651
|
+
return this.derive({ $group: group });
|
|
2652
|
+
}
|
|
2653
|
+
unwind(path) {
|
|
2654
|
+
return this.derive({ $unwind: path });
|
|
2655
|
+
}
|
|
2656
|
+
lookup(spec) {
|
|
2657
|
+
return this.derive({
|
|
2658
|
+
$lookup: this.evalLookup(spec)
|
|
2659
|
+
});
|
|
2660
|
+
}
|
|
2661
|
+
facet(facets) {
|
|
2662
|
+
return this.derive({ $facet: this.evalFacets(facets) });
|
|
2663
|
+
}
|
|
2664
|
+
/* ------------------------------ terminals ---------------------------- */
|
|
2665
|
+
/** Run the pipeline and return every output document. */
|
|
2666
|
+
toArray() {
|
|
2667
|
+
return defineCrudOp(
|
|
2668
|
+
this.deps.crudDeps,
|
|
2669
|
+
this.deps.logical,
|
|
2670
|
+
"mongo.pipeline",
|
|
2671
|
+
(r) => this.deps.coll().aggregate(this.stages, this.deps.mergeDriver(r.driverOpts, r.sdk)).toArray(),
|
|
2672
|
+
this.deps.options
|
|
2673
|
+
);
|
|
2674
|
+
}
|
|
2675
|
+
/** Run the pipeline and return the first output document (or null). */
|
|
2676
|
+
async first() {
|
|
2677
|
+
return defineCrudOp(
|
|
2678
|
+
this.deps.crudDeps,
|
|
2679
|
+
this.deps.logical,
|
|
2680
|
+
"mongo.pipeline",
|
|
2681
|
+
async (r) => {
|
|
2682
|
+
const cursor = this.deps.coll().aggregate(this.stages, this.deps.mergeDriver(r.driverOpts, r.sdk));
|
|
2683
|
+
try {
|
|
2684
|
+
return await cursor.tryNext();
|
|
2685
|
+
} finally {
|
|
2686
|
+
await cursor.close();
|
|
2687
|
+
}
|
|
2688
|
+
},
|
|
2689
|
+
this.deps.options
|
|
2690
|
+
);
|
|
2691
|
+
}
|
|
2692
|
+
/** Streaming cursor (raw driver cursor; no retry / trace). */
|
|
2693
|
+
cursor() {
|
|
2694
|
+
return this.deps.coll().aggregate(this.stages, this.deps.options);
|
|
2695
|
+
}
|
|
2696
|
+
/* ------------------------------ internals ---------------------------- */
|
|
2697
|
+
derive(stage) {
|
|
2698
|
+
return new _PipelineBuilder(this.deps, [...this.stages, stage]);
|
|
2699
|
+
}
|
|
2700
|
+
evalLookup(spec) {
|
|
2701
|
+
const { pipeline, ...rest } = spec;
|
|
2702
|
+
if (typeof pipeline === "function") {
|
|
2703
|
+
const sub = new _PipelineBuilder(this.deps, []);
|
|
2704
|
+
const result = pipeline(sub);
|
|
2705
|
+
return { ...rest, pipeline: normalizeSubPipeline(result, "$lookup") };
|
|
2706
|
+
}
|
|
2707
|
+
return spec;
|
|
2708
|
+
}
|
|
2709
|
+
evalFacets(facets) {
|
|
2710
|
+
const out = {};
|
|
2711
|
+
for (const [name, branch] of Object.entries(facets)) {
|
|
2712
|
+
if (typeof branch === "function") {
|
|
2713
|
+
const sub = new _PipelineBuilder(this.deps, []);
|
|
2714
|
+
const result = branch(sub);
|
|
2715
|
+
out[name] = normalizeSubPipeline(result, `$facet.${name}`);
|
|
2716
|
+
} else {
|
|
2717
|
+
out[name] = branch;
|
|
2718
|
+
}
|
|
2719
|
+
}
|
|
2720
|
+
return out;
|
|
2721
|
+
}
|
|
2722
|
+
};
|
|
2723
|
+
var normalizeSubPipeline = (result, where) => {
|
|
2724
|
+
if (Array.isArray(result) && result.some((s2) => s2 instanceof PipelineBuilder)) {
|
|
2725
|
+
throw new TypeError(
|
|
2726
|
+
`aggregation sub-pipeline (${where}) must be written as a chained builder \u2014 e.g. \`(o) => o.match(...).project(...)\` \u2014 not as an array of builder results like \`[o.match(...), o.project(...)]\` (each element is a builder, not a stage).`
|
|
2727
|
+
);
|
|
2728
|
+
}
|
|
2729
|
+
return result instanceof PipelineBuilder ? result.raw() : result;
|
|
2730
|
+
};
|
|
2731
|
+
var stageBuilder = () => new PipelineBuilder({
|
|
2732
|
+
logical: "",
|
|
2733
|
+
coll: () => {
|
|
2734
|
+
throw new Error("stage-only pipeline builders cannot be executed");
|
|
2735
|
+
},
|
|
2736
|
+
crudDeps: {
|
|
2737
|
+
trace: () => Promise.reject(new Error("stage-only pipeline builders cannot be executed")),
|
|
2738
|
+
meta: () => ({ collection: "", physicalCollection: "", db: "", op: "" })
|
|
2739
|
+
},
|
|
2740
|
+
mergeDriver: () => ({})
|
|
2741
|
+
});
|
|
2742
|
+
|
|
2743
|
+
// src/service/aggregation-stages.ts
|
|
2744
|
+
var buildAggregationStages = () => {
|
|
2745
|
+
let emitted = 0;
|
|
2746
|
+
const mark = (stage) => {
|
|
2747
|
+
emitted++;
|
|
2748
|
+
return stage;
|
|
2749
|
+
};
|
|
2750
|
+
return {
|
|
2751
|
+
match: (filter) => mark({ $match: filter }),
|
|
2752
|
+
project: (fields) => mark({ $project: fields }),
|
|
2753
|
+
addFields: (fields) => mark({ $addFields: fields }),
|
|
2754
|
+
set: (fields) => mark({ $set: fields }),
|
|
2755
|
+
unset: (fields) => mark({ $unset: fields }),
|
|
2756
|
+
sort: (sort) => mark({ $sort: sort }),
|
|
2757
|
+
limit: (n) => mark({ $limit: n }),
|
|
2758
|
+
skip: (n) => mark({ $skip: n }),
|
|
2759
|
+
count: (field) => mark({ $count: field }),
|
|
2760
|
+
group: (group) => mark({ $group: group }),
|
|
2761
|
+
lookup: (params) => {
|
|
2762
|
+
const { pipeline, ...rest } = params;
|
|
2763
|
+
if (typeof pipeline === "function") {
|
|
2764
|
+
const sub = stageBuilder();
|
|
2765
|
+
const result = pipeline(sub);
|
|
2766
|
+
return mark({
|
|
2767
|
+
$lookup: {
|
|
2768
|
+
...rest,
|
|
2769
|
+
pipeline: normalizeSubPipeline(result, "$lookup")
|
|
2770
|
+
}
|
|
2771
|
+
});
|
|
2772
|
+
}
|
|
2773
|
+
return mark({ $lookup: params });
|
|
2774
|
+
},
|
|
2775
|
+
facet: (facets) => {
|
|
2776
|
+
const out = {};
|
|
2777
|
+
for (const [name, branch] of Object.entries(facets)) {
|
|
2778
|
+
out[name] = typeof branch === "function" ? branch(
|
|
2779
|
+
buildAggregationStages()
|
|
2780
|
+
) : branch;
|
|
2781
|
+
}
|
|
2782
|
+
return mark({ $facet: out });
|
|
2783
|
+
},
|
|
2784
|
+
unwind: (path) => mark({ $unwind: path }),
|
|
2785
|
+
fill: (params) => mark({ $fill: params }),
|
|
2786
|
+
bucket: (params) => mark({ $bucket: params }),
|
|
2787
|
+
bucketAuto: (params) => mark({ $bucketAuto: params }),
|
|
2788
|
+
densify: (params) => mark({ $densify: params }),
|
|
2789
|
+
geoNear: (spec) => {
|
|
2790
|
+
if (emitted > 0) {
|
|
2791
|
+
throw new Error("aggregate.geoNear(): $geoNear must be the first pipeline stage");
|
|
2792
|
+
}
|
|
2793
|
+
return mark({ $geoNear: spec });
|
|
2794
|
+
}
|
|
2795
|
+
};
|
|
2796
|
+
};
|
|
2797
|
+
|
|
2798
|
+
// src/service/aggregation/aggregate.ts
|
|
2799
|
+
var makeAggregateOps = (ctx) => {
|
|
2800
|
+
const { deps: deps2, coll, mergeAggOptions: mergeAggOptions2 } = ctx;
|
|
2801
|
+
const aggregate = async (collection, pipelineBuilder, options) => {
|
|
2802
|
+
const stages = buildAggregationStages();
|
|
2803
|
+
const pipeline2 = pipelineBuilder(stages);
|
|
2804
|
+
return defineCrudOp(
|
|
2805
|
+
deps2,
|
|
2806
|
+
collection,
|
|
2807
|
+
"mongo.aggregate",
|
|
2808
|
+
(r) => coll(collection).aggregate(pipeline2, mergeAggOptions2(r.driverOpts, r.sdk)),
|
|
2809
|
+
options
|
|
2810
|
+
);
|
|
2811
|
+
};
|
|
2812
|
+
const pipeline = (collection, options) => new PipelineBuilder({
|
|
2813
|
+
logical: collection,
|
|
2814
|
+
coll: () => coll(collection),
|
|
2815
|
+
crudDeps: deps2,
|
|
2816
|
+
mergeDriver: mergeAggOptions2,
|
|
2817
|
+
options
|
|
2818
|
+
});
|
|
2819
|
+
return { aggregate, pipeline };
|
|
2820
|
+
};
|
|
2821
|
+
|
|
2822
|
+
// src/service/aggregation/helpers.ts
|
|
2823
|
+
var mergeAggOptions = (driverOpts, sdk) => ({
|
|
2824
|
+
...driverOpts,
|
|
2825
|
+
session: sdk.session,
|
|
2826
|
+
maxTimeMS: sdk.maxTimeMS,
|
|
2827
|
+
hint: sdk.hint,
|
|
2828
|
+
batchSize: sdk.batchSize
|
|
2829
|
+
});
|
|
2830
|
+
var DATE_PART_FORMATS = {
|
|
2831
|
+
hour: "%Y-%m-%dT%H",
|
|
2832
|
+
day: "%Y-%m-%d",
|
|
2833
|
+
week: "%Y-W%V",
|
|
2834
|
+
month: "%Y-%m",
|
|
2835
|
+
year: "%Y"
|
|
2836
|
+
};
|
|
2837
|
+
|
|
2838
|
+
// src/service/aggregation/group.ts
|
|
2839
|
+
var makeGroupOps = (ctx) => {
|
|
2840
|
+
const { deps: deps2, coll, mergeAggOptions: mergeAggOptions2 } = ctx;
|
|
2841
|
+
const groupBy = async (collection, baseFilter, config, customization = {}, options) => {
|
|
2842
|
+
if (config.limit !== void 0 && (!Number.isFinite(config.limit) || config.limit < 0)) {
|
|
2843
|
+
throw new BadRequest("groupBy: `limit` must be a non-negative finite number");
|
|
2844
|
+
}
|
|
2845
|
+
const pipeline = [
|
|
2846
|
+
...customization.prePipeline ?? [],
|
|
2847
|
+
{ $match: baseFilter },
|
|
2848
|
+
{ $group: { _id: config.groupBy, ...config.accumulate ?? {} } },
|
|
2849
|
+
...config.sort && Object.keys(config.sort).length > 0 ? [{ $sort: config.sort }] : [],
|
|
2850
|
+
...config.limit !== void 0 ? [{ $limit: config.limit }] : [],
|
|
2851
|
+
...customization.postPipeline ?? []
|
|
2852
|
+
];
|
|
2853
|
+
return defineCrudOp(
|
|
2854
|
+
deps2,
|
|
2855
|
+
collection,
|
|
2856
|
+
"mongo.groupBy",
|
|
2857
|
+
(r) => coll(collection).aggregate(pipeline, mergeAggOptions2(r.driverOpts, r.sdk)).toArray(),
|
|
2858
|
+
options
|
|
2859
|
+
);
|
|
2860
|
+
};
|
|
2861
|
+
const dateRangeAnalysis = async (collection, baseFilter, dateConfig, analysisConfig = {}, customization = {}, options) => {
|
|
2862
|
+
if (analysisConfig.limit !== void 0 && (!Number.isFinite(analysisConfig.limit) || analysisConfig.limit < 0)) {
|
|
2863
|
+
throw new BadRequest("dateRangeAnalysis: `limit` must be a non-negative finite number");
|
|
2864
|
+
}
|
|
2865
|
+
const granularity = dateConfig.granularity ?? "day";
|
|
2866
|
+
const format = DATE_PART_FORMATS[granularity];
|
|
2867
|
+
const datePart = {
|
|
2868
|
+
$dateToString: { format, date: `$${dateConfig.dateField}` }
|
|
2869
|
+
};
|
|
2870
|
+
const pipeline = [
|
|
2871
|
+
...customization.prePipeline ?? [],
|
|
2872
|
+
{
|
|
2873
|
+
$match: {
|
|
2874
|
+
...baseFilter,
|
|
2875
|
+
[dateConfig.dateField]: { $gte: dateConfig.startDate, $lte: dateConfig.endDate }
|
|
2876
|
+
}
|
|
2877
|
+
},
|
|
2878
|
+
{ $group: { _id: datePart, ...analysisConfig.accumulate ?? {} } },
|
|
2879
|
+
...analysisConfig.sort && Object.keys(analysisConfig.sort).length > 0 ? [{ $sort: analysisConfig.sort }] : [],
|
|
2880
|
+
...analysisConfig.limit !== void 0 ? [{ $limit: analysisConfig.limit }] : [],
|
|
2881
|
+
...customization.postPipeline ?? []
|
|
2882
|
+
];
|
|
2883
|
+
return defineCrudOp(
|
|
2884
|
+
deps2,
|
|
2885
|
+
collection,
|
|
2886
|
+
"mongo.dateRangeAnalysis",
|
|
2887
|
+
(r) => coll(collection).aggregate(pipeline, mergeAggOptions2(r.driverOpts, r.sdk)).toArray(),
|
|
2888
|
+
options
|
|
2889
|
+
);
|
|
2890
|
+
};
|
|
2891
|
+
return { groupBy, dateRangeAnalysis };
|
|
2892
|
+
};
|
|
2893
|
+
|
|
2894
|
+
// src/service/aggregation/lookup-join.ts
|
|
2895
|
+
var makeLookupJoinOp = (ctx) => {
|
|
2896
|
+
const { deps: deps2, coll, mergeAggOptions: mergeAggOptions2, resolve } = ctx;
|
|
2897
|
+
const lookupJoin = async (collection, baseFilter, lookups, customization = {}, options) => {
|
|
2898
|
+
const pipeline = [
|
|
2899
|
+
...customization.prePipeline ?? [],
|
|
2900
|
+
{ $match: baseFilter },
|
|
2901
|
+
...lookups.map((lookup) => ({
|
|
2902
|
+
$lookup: {
|
|
2903
|
+
from: resolve(lookup.fromCollection),
|
|
2904
|
+
localField: lookup.localField,
|
|
2905
|
+
foreignField: lookup.foreignField,
|
|
2906
|
+
as: lookup.as,
|
|
2907
|
+
...lookup.pipeline ? { pipeline: lookup.pipeline } : {}
|
|
2908
|
+
}
|
|
2909
|
+
})),
|
|
2910
|
+
...lookups.filter((lookup) => lookup.unwindSingle).map((lookup) => ({
|
|
2911
|
+
$unwind: { path: `$${lookup.as}`, preserveNullAndEmptyArrays: true }
|
|
2912
|
+
})),
|
|
2913
|
+
...customization.postPipeline ?? []
|
|
2914
|
+
];
|
|
2915
|
+
return defineCrudOp(
|
|
2916
|
+
deps2,
|
|
2917
|
+
collection,
|
|
2918
|
+
"mongo.lookupJoin",
|
|
2919
|
+
(r) => coll(collection).aggregate(pipeline, mergeAggOptions2(r.driverOpts, r.sdk)).toArray(),
|
|
2920
|
+
options
|
|
2921
|
+
);
|
|
2922
|
+
};
|
|
2923
|
+
return { lookupJoin };
|
|
2924
|
+
};
|
|
2925
|
+
|
|
2926
|
+
// src/shared/pagination-math.ts
|
|
2927
|
+
var normalizePageLimit = (query, maxLimit, defaults = {}) => {
|
|
2928
|
+
const rawPage = query.page ?? defaults.page ?? 1;
|
|
2929
|
+
const limit = query.limit ?? defaults.limit ?? 10;
|
|
2930
|
+
if (typeof rawPage !== "number" || !Number.isFinite(rawPage)) {
|
|
2931
|
+
throw new BadRequest("page must be a finite number");
|
|
2932
|
+
}
|
|
2933
|
+
if (typeof limit !== "number" || !Number.isFinite(limit)) {
|
|
2934
|
+
throw new BadRequest("limit must be a finite number");
|
|
2935
|
+
}
|
|
2936
|
+
if (limit < 1) throw new BadRequest("limit must be >= 1");
|
|
2937
|
+
if (limit > maxLimit) throw new BadRequest(`limit ${limit} exceeds maxLimit ${maxLimit}`);
|
|
2938
|
+
const page = Math.max(1, Math.floor(rawPage));
|
|
2939
|
+
return { page, limit, offset: (page - 1) * limit };
|
|
2940
|
+
};
|
|
2941
|
+
|
|
2942
|
+
// src/shared/pagination-result.ts
|
|
2943
|
+
var buildPaginationResult = (data, totalCount, page, limit) => {
|
|
2944
|
+
const totalPages = totalCount === 0 ? 0 : Math.ceil(totalCount / limit);
|
|
2945
|
+
return {
|
|
2946
|
+
data,
|
|
2947
|
+
totalCount,
|
|
2948
|
+
hasNextPage: page < totalPages,
|
|
2949
|
+
hasPrevPage: page > 1,
|
|
2950
|
+
currentPage: page,
|
|
2951
|
+
totalPages
|
|
2952
|
+
};
|
|
2953
|
+
};
|
|
2954
|
+
|
|
2955
|
+
// src/service/aggregation-pipeline.ts
|
|
2956
|
+
var escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2957
|
+
var buildRegexSearchStages = (baseFilter, searchConfig) => {
|
|
2958
|
+
const { searchFields, searchTerm, fuzzy = false, caseSensitive = false } = searchConfig;
|
|
2959
|
+
const escaped = escapeRegex(searchTerm);
|
|
2960
|
+
const pattern = fuzzy ? escaped.split("").join(".*") : escaped;
|
|
2961
|
+
const $or = searchFields.map((field) => ({
|
|
2962
|
+
[field]: { $regex: pattern, $options: caseSensitive ? "" : "i" }
|
|
2963
|
+
}));
|
|
2964
|
+
return [{ $match: { ...baseFilter, $or } }];
|
|
2965
|
+
};
|
|
2966
|
+
var buildTextSearchStages = (baseFilter, searchConfig) => [
|
|
2967
|
+
{
|
|
2968
|
+
$match: {
|
|
2969
|
+
...baseFilter,
|
|
2970
|
+
$text: {
|
|
2971
|
+
$search: searchConfig.searchTerm,
|
|
2972
|
+
...searchConfig.caseSensitive !== void 0 && {
|
|
2973
|
+
$caseSensitive: searchConfig.caseSensitive
|
|
2974
|
+
},
|
|
2975
|
+
...searchConfig.language !== void 0 && { $language: searchConfig.language },
|
|
2976
|
+
...searchConfig.diacriticSensitive !== void 0 && {
|
|
2977
|
+
$diacriticSensitive: searchConfig.diacriticSensitive
|
|
2978
|
+
}
|
|
2979
|
+
}
|
|
2980
|
+
}
|
|
2981
|
+
},
|
|
2982
|
+
{ $addFields: { searchScore: { $meta: "textScore" } } }
|
|
2983
|
+
];
|
|
2984
|
+
var buildSearchStages = (baseFilter, searchConfig) => searchConfig.useRegex ? buildRegexSearchStages(baseFilter, searchConfig) : buildTextSearchStages(baseFilter, searchConfig);
|
|
2985
|
+
|
|
2986
|
+
// src/service/aggregation/text-search.ts
|
|
2987
|
+
var makeTextSearchOp = (ctx) => {
|
|
2988
|
+
const { deps: deps2, coll, mergeAggOptions: mergeAggOptions2 } = ctx;
|
|
2989
|
+
const textSearch = async (collection, baseFilter, searchConfig, paginationConfig = {}, customization = {}, options) => {
|
|
2990
|
+
const normalized = normalizePageLimit(
|
|
2991
|
+
{ page: paginationConfig.page, limit: paginationConfig.limit },
|
|
2992
|
+
paginationConfig.maxLimit ?? DEFAULT_MAX_LIMIT
|
|
2993
|
+
);
|
|
2994
|
+
const searchStages = buildSearchStages(
|
|
2995
|
+
baseFilter,
|
|
2996
|
+
searchConfig
|
|
2997
|
+
);
|
|
2998
|
+
const pipeline = [
|
|
2999
|
+
...customization.prePipeline ?? [],
|
|
3000
|
+
...searchStages,
|
|
3001
|
+
{
|
|
3002
|
+
$facet: {
|
|
3003
|
+
data: [
|
|
3004
|
+
...searchConfig.sortByScore ? [{ $sort: { searchScore: { $meta: "textScore" } } }] : [],
|
|
3005
|
+
...paginationConfig.sort && Object.keys(paginationConfig.sort).length > 0 ? [{ $sort: paginationConfig.sort }] : [],
|
|
3006
|
+
{ $skip: normalized.offset },
|
|
3007
|
+
{ $limit: normalized.limit },
|
|
3008
|
+
...customization.postPipeline ?? []
|
|
3009
|
+
],
|
|
3010
|
+
totalCount: [{ $count: "count" }]
|
|
3011
|
+
}
|
|
3012
|
+
}
|
|
3013
|
+
];
|
|
3014
|
+
return defineCrudOp(
|
|
3015
|
+
deps2,
|
|
3016
|
+
collection,
|
|
3017
|
+
"mongo.textSearch",
|
|
3018
|
+
async (r) => {
|
|
3019
|
+
const rows = await coll(collection).aggregate(pipeline, mergeAggOptions2(r.driverOpts, r.sdk)).toArray();
|
|
3020
|
+
const row = rows[0];
|
|
3021
|
+
const data = row?.data ?? [];
|
|
3022
|
+
const totalCount = row?.totalCount?.[0]?.count ?? 0;
|
|
3023
|
+
return buildPaginationResult(data, totalCount, normalized.page, normalized.limit);
|
|
3024
|
+
},
|
|
3025
|
+
options
|
|
3026
|
+
);
|
|
3027
|
+
};
|
|
3028
|
+
return { textSearch };
|
|
3029
|
+
};
|
|
3030
|
+
|
|
3031
|
+
// src/service/aggregation/index.ts
|
|
3032
|
+
var makeAggregationOps = (client, dbLabel, logger, opts) => {
|
|
3033
|
+
const resolve = opts.resolveCollectionName;
|
|
3034
|
+
const deps2 = makeOpDeps(logger, dbLabel, resolve, opts.wrapMongoErrors === true);
|
|
3035
|
+
const coll = (collection) => client.collection(resolve(String(collection)));
|
|
3036
|
+
const ctx = {
|
|
3037
|
+
resolve,
|
|
3038
|
+
deps: deps2,
|
|
3039
|
+
coll,
|
|
3040
|
+
mergeAggOptions
|
|
3041
|
+
};
|
|
3042
|
+
return {
|
|
3043
|
+
...makeAggregateOps(ctx),
|
|
3044
|
+
...makeGroupOps(ctx),
|
|
3045
|
+
...makeTextSearchOp(ctx),
|
|
3046
|
+
...makeLookupJoinOp(ctx)
|
|
3047
|
+
};
|
|
3048
|
+
};
|
|
3049
|
+
|
|
3050
|
+
// src/service/drift.ts
|
|
3051
|
+
var checkDocsDrift = (options, collection, op, docs) => {
|
|
3052
|
+
if (options.drift === "off") return;
|
|
3053
|
+
const schema = options.getSchema?.(collection);
|
|
3054
|
+
if (!schema) return;
|
|
3055
|
+
for (const doc of docs) {
|
|
3056
|
+
const issues = validateDoc(schema, doc);
|
|
3057
|
+
if (issues.length === 0) continue;
|
|
3058
|
+
const detail = issues.map((i) => `${i.path}: ${i.message}`).join("; ");
|
|
3059
|
+
const extra = { collection, db: options.db, op, issues };
|
|
3060
|
+
if (options.drift === "throw") {
|
|
3061
|
+
throw new DomainError(
|
|
3062
|
+
"SCHEMA_DRIFT",
|
|
3063
|
+
`Document drifted from schema in "${collection}": ${detail}`,
|
|
3064
|
+
extra
|
|
3065
|
+
);
|
|
3066
|
+
}
|
|
3067
|
+
options.logger.warn(extra, `[drift] ${collection} (${options.db}, ${op}): ${detail}`);
|
|
3068
|
+
}
|
|
3069
|
+
};
|
|
3070
|
+
|
|
3071
|
+
// src/service/update-format.ts
|
|
3072
|
+
var hasUpdateOperator = (update) => {
|
|
3073
|
+
if (!update) return false;
|
|
3074
|
+
return Object.keys(update).some((key) => key.startsWith("$"));
|
|
3075
|
+
};
|
|
3076
|
+
var formatUpdateFilter = (update) => {
|
|
3077
|
+
if (hasUpdateOperator(update)) {
|
|
3078
|
+
return update;
|
|
3079
|
+
}
|
|
3080
|
+
return { $set: update };
|
|
3081
|
+
};
|
|
3082
|
+
|
|
3083
|
+
// src/service/crud/context.ts
|
|
3084
|
+
var createCrudContext = (client, dbLabel, logger, opts) => {
|
|
3085
|
+
const resolve = opts.resolveCollectionName;
|
|
3086
|
+
const deps2 = makeOpDeps(
|
|
3087
|
+
logger,
|
|
3088
|
+
dbLabel,
|
|
3089
|
+
resolve,
|
|
3090
|
+
opts.wrapMongoErrors === true
|
|
3091
|
+
);
|
|
3092
|
+
const coll = (name) => client.collection(resolve(String(name)));
|
|
3093
|
+
const toDriverFilter = (filter) => filter;
|
|
3094
|
+
const run = (collection, opName, execute, options) => defineCrudOp(deps2, collection, opName, execute, options);
|
|
3095
|
+
const writeRun = (collection, opName, execute, options) => defineCrudOp(deps2, collection, opName, execute, options, true);
|
|
3096
|
+
const invalidate = (collection) => {
|
|
3097
|
+
opts.cache?.invalidateByCollection(
|
|
3098
|
+
cacheCollectionKey(client.databaseName, resolve(String(collection)))
|
|
3099
|
+
);
|
|
3100
|
+
};
|
|
3101
|
+
const timestampsFor = (collection) => opts.timestamps?.[String(collection)];
|
|
3102
|
+
const stampCreate = (collection, doc) => {
|
|
3103
|
+
const ts = timestampsFor(collection);
|
|
3104
|
+
if (!ts) return;
|
|
3105
|
+
const now = /* @__PURE__ */ new Date();
|
|
3106
|
+
if (ts.createdAt && doc[ts.createdAt] === void 0) doc[ts.createdAt] = now;
|
|
3107
|
+
if (ts.updatedAt && doc[ts.updatedAt] === void 0) doc[ts.updatedAt] = now;
|
|
3108
|
+
};
|
|
3109
|
+
const stampUpdate = (collection, update) => {
|
|
3110
|
+
const formatted = formatUpdateFilter(update);
|
|
3111
|
+
const ts = timestampsFor(collection);
|
|
3112
|
+
if (ts?.updatedAt) {
|
|
3113
|
+
const $set = { ...formatted.$set, [ts.updatedAt]: /* @__PURE__ */ new Date() };
|
|
3114
|
+
return { ...formatted, $set };
|
|
3115
|
+
}
|
|
3116
|
+
return formatted;
|
|
3117
|
+
};
|
|
3118
|
+
const stampReplace = (collection, doc) => {
|
|
3119
|
+
const ts = timestampsFor(collection);
|
|
3120
|
+
if (!ts?.updatedAt) return doc;
|
|
3121
|
+
return { ...doc, [ts.updatedAt]: /* @__PURE__ */ new Date() };
|
|
3122
|
+
};
|
|
3123
|
+
const read = async (collection, opName, filter, options, execute, shape = "none") => {
|
|
3124
|
+
const physical = resolve(String(collection));
|
|
3125
|
+
const resolved = resolveQueryOptions(options);
|
|
3126
|
+
const noSession = !resolved.sdk.session;
|
|
3127
|
+
const useCache = opts.cache !== void 0 && noSession && resolved.cache !== false;
|
|
3128
|
+
const shouldDedupe = resolved.dedupe !== false && (resolved.dedupe === true || opts.dedupeReads === true && noSession) && opts.inFlight !== void 0;
|
|
3129
|
+
const checkDrift = (value) => {
|
|
3130
|
+
const mode = resolveDriftMode(resolved.sdk.drift, opts.drift);
|
|
3131
|
+
if (mode === "off") return;
|
|
3132
|
+
if (resolved.driverOpts.projection !== void 0) return;
|
|
3133
|
+
const docs = shape === "many" ? value : shape === "one" && value !== null ? [value] : [];
|
|
3134
|
+
checkDocsDrift(
|
|
3135
|
+
{ logger, db: dbLabel, drift: mode, getSchema: opts.getSchema },
|
|
3136
|
+
String(collection),
|
|
3137
|
+
opName,
|
|
3138
|
+
docs
|
|
3139
|
+
);
|
|
3140
|
+
};
|
|
3141
|
+
const executeWrapped = () => deps2.trace(
|
|
3142
|
+
deps2.meta(collection, opName),
|
|
3143
|
+
() => withRetry(() => execute(resolved), {
|
|
3144
|
+
maxAttempts: resolved.maxAttempts,
|
|
3145
|
+
delayMs: resolved.retryDelayMs
|
|
3146
|
+
})
|
|
3147
|
+
);
|
|
3148
|
+
const key = useCache ? opts.cache.key(
|
|
3149
|
+
cacheCollectionKey(client.databaseName, physical),
|
|
3150
|
+
stableHash([filter, options])
|
|
3151
|
+
) : void 0;
|
|
3152
|
+
const fetchAndCheck = () => executeWrapped().then((value) => {
|
|
3153
|
+
checkDrift(value);
|
|
3154
|
+
return value;
|
|
3155
|
+
});
|
|
3156
|
+
const runOnce = () => {
|
|
3157
|
+
if (useCache && key) {
|
|
3158
|
+
const hit = opts.cache.get(key);
|
|
3159
|
+
if (hit !== void 0) return Promise.resolve(hit);
|
|
3160
|
+
return fetchAndCheck().then((value) => {
|
|
3161
|
+
opts.cache.set(key, value);
|
|
3162
|
+
return value;
|
|
3163
|
+
});
|
|
3164
|
+
}
|
|
3165
|
+
return fetchAndCheck();
|
|
3166
|
+
};
|
|
3167
|
+
if (shouldDedupe) {
|
|
3168
|
+
const dedupeKey = `${cacheCollectionKey(client.databaseName, physical)}|${opName}|${stableHash([filter, options])}`;
|
|
3169
|
+
return opts.inFlight.run(dedupeKey, runOnce);
|
|
3170
|
+
}
|
|
3171
|
+
return runOnce();
|
|
3172
|
+
};
|
|
3173
|
+
const normalizeFindOptions = (options) => {
|
|
3174
|
+
if (!options) return {};
|
|
3175
|
+
const { select, ...rest } = options;
|
|
3176
|
+
if (!select || select.length === 0) return rest;
|
|
3177
|
+
const projection = {};
|
|
3178
|
+
for (const field of select) projection[String(field)] = 1;
|
|
3179
|
+
return { ...rest, projection };
|
|
3180
|
+
};
|
|
3181
|
+
return {
|
|
3182
|
+
client,
|
|
3183
|
+
dbLabel,
|
|
3184
|
+
logger,
|
|
3185
|
+
opts,
|
|
3186
|
+
resolve,
|
|
3187
|
+
trace: deps2.trace,
|
|
3188
|
+
meta: deps2.meta,
|
|
3189
|
+
coll,
|
|
3190
|
+
toDriverFilter,
|
|
3191
|
+
run,
|
|
3192
|
+
writeRun,
|
|
3193
|
+
invalidate,
|
|
3194
|
+
stampCreate,
|
|
3195
|
+
stampUpdate,
|
|
3196
|
+
stampReplace,
|
|
3197
|
+
read,
|
|
3198
|
+
normalizeFindOptions
|
|
3199
|
+
};
|
|
3200
|
+
};
|
|
3201
|
+
|
|
3202
|
+
// src/shared/soft-delete.ts
|
|
3203
|
+
var buildMongoActiveFilter = () => ({ $or: [{ deletedAt: { $exists: false } }, { deletedAt: null }] });
|
|
3204
|
+
var mergeMongoActiveFilter = (softDelete, filter) => {
|
|
3205
|
+
if (!softDelete) return filter ?? {};
|
|
3206
|
+
const active = buildMongoActiveFilter();
|
|
3207
|
+
if (!filter || Object.keys(filter).length === 0) return active;
|
|
3208
|
+
return { $and: [filter, active] };
|
|
3209
|
+
};
|
|
3210
|
+
|
|
3211
|
+
// src/service/crud/read-ops.ts
|
|
3212
|
+
var makeReadOps = (ctx) => {
|
|
3213
|
+
const { client, opts } = ctx;
|
|
3214
|
+
const { coll, toDriverFilter, read, normalizeFindOptions } = ctx;
|
|
3215
|
+
const getOne = async (collection, filter, options) => {
|
|
3216
|
+
const f = filter ?? {};
|
|
3217
|
+
const o = normalizeFindOptions(options);
|
|
3218
|
+
const doc = await read(
|
|
3219
|
+
collection,
|
|
3220
|
+
"mongo.getOne",
|
|
3221
|
+
f,
|
|
3222
|
+
o,
|
|
3223
|
+
async (r) => {
|
|
3224
|
+
const opts2 = {
|
|
3225
|
+
...r.driverOpts,
|
|
3226
|
+
session: r.sdk.session,
|
|
3227
|
+
maxTimeMS: r.sdk.maxTimeMS,
|
|
3228
|
+
hint: r.sdk.hint
|
|
3229
|
+
};
|
|
3230
|
+
return coll(collection).findOne(toDriverFilter(f), opts2);
|
|
3231
|
+
},
|
|
3232
|
+
"one"
|
|
3233
|
+
);
|
|
3234
|
+
if (doc !== null && hasHook(opts.hooks, String(collection), "afterRead")) {
|
|
3235
|
+
const exposed = cloneDeep(doc);
|
|
3236
|
+
await runHooks(opts.hooks, String(collection), "afterRead", {
|
|
3237
|
+
collection: String(collection),
|
|
3238
|
+
doc: exposed
|
|
3239
|
+
});
|
|
3240
|
+
return exposed;
|
|
3241
|
+
}
|
|
3242
|
+
return doc;
|
|
3243
|
+
};
|
|
3244
|
+
const getOneOrFail = async (collection, filter, options, errorFactory) => {
|
|
3245
|
+
const doc = await getOne(collection, filter, options);
|
|
3246
|
+
if (doc === null) {
|
|
3247
|
+
if (errorFactory) throw errorFactory(String(collection));
|
|
3248
|
+
throw new DomainError(
|
|
3249
|
+
"NOT_FOUND",
|
|
3250
|
+
`Document not found in collection "${String(collection)}"`,
|
|
3251
|
+
{
|
|
3252
|
+
collection: String(collection)
|
|
3253
|
+
}
|
|
3254
|
+
);
|
|
3255
|
+
}
|
|
3256
|
+
return doc;
|
|
3257
|
+
};
|
|
3258
|
+
const findMany = async (collection, filter, options) => {
|
|
3259
|
+
const merged = normalizeFindOptions({
|
|
3260
|
+
...options,
|
|
3261
|
+
limit: options?.limit ?? DEFAULT_FIND_LIMIT
|
|
3262
|
+
});
|
|
3263
|
+
const docs = await read(
|
|
3264
|
+
collection,
|
|
3265
|
+
"mongo.findMany",
|
|
3266
|
+
filter ?? {},
|
|
3267
|
+
merged,
|
|
3268
|
+
async (r) => {
|
|
3269
|
+
const opts2 = {
|
|
3270
|
+
...r.driverOpts,
|
|
3271
|
+
session: r.sdk.session,
|
|
3272
|
+
maxTimeMS: r.sdk.maxTimeMS,
|
|
3273
|
+
hint: r.sdk.hint
|
|
3274
|
+
};
|
|
3275
|
+
return coll(collection).find(toDriverFilter(filter ?? {}), opts2).toArray();
|
|
3276
|
+
},
|
|
3277
|
+
"many"
|
|
3278
|
+
);
|
|
3279
|
+
if (hasHook(opts.hooks, String(collection), "afterRead")) {
|
|
3280
|
+
const exposed = cloneDeep(docs);
|
|
3281
|
+
await runHooks(opts.hooks, String(collection), "afterRead", {
|
|
3282
|
+
collection: String(collection),
|
|
3283
|
+
docs: exposed
|
|
3284
|
+
});
|
|
3285
|
+
return exposed;
|
|
3286
|
+
}
|
|
3287
|
+
return docs;
|
|
3288
|
+
};
|
|
3289
|
+
const findManyCursor = (collection, filter, options) => {
|
|
3290
|
+
const {
|
|
3291
|
+
session,
|
|
3292
|
+
hint,
|
|
3293
|
+
maxTimeMS,
|
|
3294
|
+
batchSize,
|
|
3295
|
+
maxAttempts: _ma,
|
|
3296
|
+
retryDelayMs: _rd,
|
|
3297
|
+
dedupe: _d,
|
|
3298
|
+
cache: _c,
|
|
3299
|
+
...rest
|
|
3300
|
+
} = normalizeFindOptions(options);
|
|
3301
|
+
const findOpts = {
|
|
3302
|
+
...rest,
|
|
3303
|
+
...session && { session },
|
|
3304
|
+
...hint && { hint },
|
|
3305
|
+
...maxTimeMS && { maxTimeMS },
|
|
3306
|
+
...batchSize && { batchSize }
|
|
3307
|
+
};
|
|
3308
|
+
return coll(collection).find(toDriverFilter(filter ?? {}), findOpts);
|
|
3309
|
+
};
|
|
3310
|
+
const findActive = async (collection, filter, options) => findMany(collection, mergeMongoActiveFilter(true, filter), options);
|
|
3311
|
+
const findActiveOne = async (collection, filter, options) => getOne(collection, mergeMongoActiveFilter(true, filter), options);
|
|
3312
|
+
const countDocuments = async (collection, filter, options) => read(
|
|
3313
|
+
collection,
|
|
3314
|
+
"mongo.countDocuments",
|
|
3315
|
+
filter ?? {},
|
|
3316
|
+
options,
|
|
3317
|
+
async (r) => {
|
|
3318
|
+
const opts2 = {
|
|
3319
|
+
...r.driverOpts,
|
|
3320
|
+
session: r.sdk.session,
|
|
3321
|
+
maxTimeMS: r.sdk.maxTimeMS,
|
|
3322
|
+
hint: r.sdk.hint
|
|
3323
|
+
};
|
|
3324
|
+
return coll(collection).countDocuments(toDriverFilter(filter ?? {}), opts2);
|
|
3325
|
+
},
|
|
3326
|
+
"none"
|
|
3327
|
+
);
|
|
3328
|
+
const distinct = async (collection, field, filter, options) => {
|
|
3329
|
+
const f = filter ?? {};
|
|
3330
|
+
const o = normalizeFindOptions(options);
|
|
3331
|
+
return read(
|
|
3332
|
+
collection,
|
|
3333
|
+
"mongo.distinct",
|
|
3334
|
+
f,
|
|
3335
|
+
o,
|
|
3336
|
+
async (r) => {
|
|
3337
|
+
const opts2 = {
|
|
3338
|
+
...r.driverOpts,
|
|
3339
|
+
session: r.sdk.session,
|
|
3340
|
+
maxTimeMS: r.sdk.maxTimeMS,
|
|
3341
|
+
hint: r.sdk.hint
|
|
3342
|
+
};
|
|
3343
|
+
return coll(collection).distinct(
|
|
3344
|
+
String(field),
|
|
3345
|
+
toDriverFilter(f),
|
|
3346
|
+
opts2
|
|
3347
|
+
);
|
|
3348
|
+
},
|
|
3349
|
+
"none"
|
|
3350
|
+
);
|
|
3351
|
+
};
|
|
3352
|
+
const estimatedDocumentCount = async (collection, options) => ctx.run(
|
|
3353
|
+
collection,
|
|
3354
|
+
"mongo.estimatedDocumentCount",
|
|
3355
|
+
async (r) => {
|
|
3356
|
+
const opts2 = {
|
|
3357
|
+
...r.driverOpts,
|
|
3358
|
+
session: r.sdk.session,
|
|
3359
|
+
maxTimeMS: r.sdk.maxTimeMS
|
|
3360
|
+
};
|
|
3361
|
+
return coll(collection).estimatedDocumentCount(opts2);
|
|
3362
|
+
},
|
|
3363
|
+
options
|
|
3364
|
+
);
|
|
3365
|
+
return {
|
|
3366
|
+
getOne,
|
|
3367
|
+
getOneOrFail,
|
|
3368
|
+
findMany,
|
|
3369
|
+
findManyCursor,
|
|
3370
|
+
findActive,
|
|
3371
|
+
findActiveOne,
|
|
3372
|
+
countDocuments,
|
|
3373
|
+
distinct,
|
|
3374
|
+
estimatedDocumentCount
|
|
3375
|
+
};
|
|
3376
|
+
};
|
|
3377
|
+
|
|
3378
|
+
// src/service/crud/watch-ops.ts
|
|
3379
|
+
var makeWatchOps = (ctx) => {
|
|
3380
|
+
const { client, resolve, coll, read } = ctx;
|
|
3381
|
+
const watchCollection = (collection, pipeline, options) => coll(collection).watch(pipeline, options);
|
|
3382
|
+
const query = (collection) => {
|
|
3383
|
+
const physical = resolve(String(collection));
|
|
3384
|
+
const handle = client.collection(physical);
|
|
3385
|
+
return new QueryBuilder({
|
|
3386
|
+
physicalName: physical,
|
|
3387
|
+
collection: handle,
|
|
3388
|
+
run: async (opName, filter, execute, options) => read(
|
|
3389
|
+
collection,
|
|
3390
|
+
opName,
|
|
3391
|
+
filter,
|
|
3392
|
+
options,
|
|
3393
|
+
async (r) => {
|
|
3394
|
+
const opts = {
|
|
3395
|
+
...r.driverOpts,
|
|
3396
|
+
session: r.sdk.session,
|
|
3397
|
+
maxTimeMS: r.sdk.maxTimeMS,
|
|
3398
|
+
hint: r.sdk.hint
|
|
3399
|
+
};
|
|
3400
|
+
return execute(opts);
|
|
3401
|
+
},
|
|
3402
|
+
opName === "mongo.getOne" ? "one" : opName === "mongo.findMany" ? "many" : "none"
|
|
3403
|
+
)
|
|
3404
|
+
});
|
|
3405
|
+
};
|
|
3406
|
+
return {
|
|
3407
|
+
watchCollection,
|
|
3408
|
+
query
|
|
3409
|
+
};
|
|
3410
|
+
};
|
|
3411
|
+
|
|
3412
|
+
// src/service/crud/write-ops.ts
|
|
3413
|
+
var makeWriteOps = (ctx, readOps) => {
|
|
3414
|
+
const { opts } = ctx;
|
|
3415
|
+
const { coll, toDriverFilter, writeRun, invalidate, stampCreate, stampUpdate, stampReplace } = ctx;
|
|
3416
|
+
const { getOne } = readOps;
|
|
3417
|
+
const insertOne = async (collection, doc, options) => {
|
|
3418
|
+
await runHooks(opts.hooks, String(collection), "beforeCreate", {
|
|
3419
|
+
collection: String(collection),
|
|
3420
|
+
doc
|
|
3421
|
+
});
|
|
3422
|
+
stampCreate(collection, doc);
|
|
3423
|
+
const result = await writeRun(
|
|
3424
|
+
collection,
|
|
3425
|
+
"mongo.insertOne",
|
|
3426
|
+
async (r) => {
|
|
3427
|
+
const insertOpts = {
|
|
3428
|
+
...r.driverOpts,
|
|
3429
|
+
session: r.sdk.session
|
|
3430
|
+
};
|
|
3431
|
+
return coll(collection).insertOne(doc, insertOpts);
|
|
3432
|
+
},
|
|
3433
|
+
options
|
|
3434
|
+
);
|
|
3435
|
+
invalidate(collection);
|
|
3436
|
+
await runHooks(opts.hooks, String(collection), "afterCreate", {
|
|
3437
|
+
collection: String(collection),
|
|
3438
|
+
doc
|
|
3439
|
+
});
|
|
3440
|
+
return result;
|
|
3441
|
+
};
|
|
3442
|
+
const insertMany = async (collection, docs, options) => {
|
|
3443
|
+
await runHooks(opts.hooks, String(collection), "beforeCreate", {
|
|
3444
|
+
collection: String(collection),
|
|
3445
|
+
docs
|
|
3446
|
+
});
|
|
3447
|
+
for (const d of docs) stampCreate(collection, d);
|
|
3448
|
+
if (docs.length > MAX_BATCH_OPS) {
|
|
3449
|
+
throw new BadRequest(
|
|
3450
|
+
`insertMany: too many docs (${docs.length} > ${MAX_BATCH_OPS}); split the batch`
|
|
3451
|
+
);
|
|
3452
|
+
}
|
|
3453
|
+
const result = await writeRun(
|
|
3454
|
+
collection,
|
|
3455
|
+
"mongo.insertMany",
|
|
3456
|
+
async (r) => {
|
|
3457
|
+
const bulkOpts = {
|
|
3458
|
+
...r.driverOpts,
|
|
3459
|
+
session: r.sdk.session
|
|
3460
|
+
};
|
|
3461
|
+
return coll(collection).insertMany(
|
|
3462
|
+
docs,
|
|
3463
|
+
bulkOpts
|
|
3464
|
+
);
|
|
3465
|
+
},
|
|
3466
|
+
options
|
|
3467
|
+
);
|
|
3468
|
+
invalidate(collection);
|
|
3469
|
+
await runHooks(opts.hooks, String(collection), "afterCreate", {
|
|
3470
|
+
collection: String(collection),
|
|
3471
|
+
docs
|
|
3472
|
+
});
|
|
3473
|
+
return result;
|
|
3474
|
+
};
|
|
3475
|
+
const updateOne = async (collection, filter, update, options) => {
|
|
3476
|
+
await runHooks(opts.hooks, String(collection), "beforeUpdate", {
|
|
3477
|
+
collection: String(collection),
|
|
3478
|
+
filter
|
|
3479
|
+
});
|
|
3480
|
+
const effective = stampUpdate(collection, update);
|
|
3481
|
+
const result = await writeRun(
|
|
3482
|
+
collection,
|
|
3483
|
+
"mongo.updateOne",
|
|
3484
|
+
async (r) => {
|
|
3485
|
+
const updateOpts = {
|
|
3486
|
+
...r.driverOpts,
|
|
3487
|
+
session: r.sdk.session
|
|
3488
|
+
};
|
|
3489
|
+
return coll(collection).updateOne(
|
|
3490
|
+
toDriverFilter(filter),
|
|
3491
|
+
effective,
|
|
3492
|
+
updateOpts
|
|
3493
|
+
);
|
|
3494
|
+
},
|
|
3495
|
+
options
|
|
3496
|
+
);
|
|
3497
|
+
invalidate(collection);
|
|
3498
|
+
await runHooks(opts.hooks, String(collection), "afterUpdate", {
|
|
3499
|
+
collection: String(collection),
|
|
3500
|
+
filter
|
|
3501
|
+
});
|
|
3502
|
+
return result;
|
|
3503
|
+
};
|
|
3504
|
+
const updateMany = async (collection, filter, update, options) => {
|
|
3505
|
+
const effective = stampUpdate(collection, update);
|
|
3506
|
+
const result = await writeRun(
|
|
3507
|
+
collection,
|
|
3508
|
+
"mongo.updateMany",
|
|
3509
|
+
async (r) => {
|
|
3510
|
+
const updateOpts = {
|
|
3511
|
+
...r.driverOpts,
|
|
3512
|
+
session: r.sdk.session
|
|
3513
|
+
};
|
|
3514
|
+
return coll(collection).updateMany(
|
|
3515
|
+
toDriverFilter(filter),
|
|
3516
|
+
effective,
|
|
3517
|
+
updateOpts
|
|
3518
|
+
);
|
|
3519
|
+
},
|
|
3520
|
+
options
|
|
3521
|
+
);
|
|
3522
|
+
invalidate(collection);
|
|
3523
|
+
return result;
|
|
3524
|
+
};
|
|
3525
|
+
const findOneAndUpdate = async (collection, filter, update, options) => {
|
|
3526
|
+
const effective = stampUpdate(collection, update);
|
|
3527
|
+
const result = await writeRun(
|
|
3528
|
+
collection,
|
|
3529
|
+
"mongo.findOneAndUpdate",
|
|
3530
|
+
async (r) => {
|
|
3531
|
+
const fOpts = {
|
|
3532
|
+
...r.driverOpts,
|
|
3533
|
+
session: r.sdk.session,
|
|
3534
|
+
maxTimeMS: r.sdk.maxTimeMS,
|
|
3535
|
+
hint: r.sdk.hint,
|
|
3536
|
+
// `after` is the ORM's default, but honor an explicit caller override.
|
|
3537
|
+
returnDocument: r.driverOpts.returnDocument ?? "after"
|
|
3538
|
+
};
|
|
3539
|
+
return coll(collection).findOneAndUpdate(
|
|
3540
|
+
toDriverFilter(filter),
|
|
3541
|
+
effective,
|
|
3542
|
+
fOpts
|
|
3543
|
+
);
|
|
3544
|
+
},
|
|
3545
|
+
options
|
|
3546
|
+
);
|
|
3547
|
+
invalidate(collection);
|
|
3548
|
+
return result;
|
|
3549
|
+
};
|
|
3550
|
+
const findOneAndReplace = async (collection, filter, replacement, options) => {
|
|
3551
|
+
const effective = stampReplace(collection, replacement);
|
|
3552
|
+
const result = await writeRun(
|
|
3553
|
+
collection,
|
|
3554
|
+
"mongo.findOneAndReplace",
|
|
3555
|
+
async (r) => {
|
|
3556
|
+
const fOpts = {
|
|
3557
|
+
...r.driverOpts,
|
|
3558
|
+
session: r.sdk.session,
|
|
3559
|
+
maxTimeMS: r.sdk.maxTimeMS,
|
|
3560
|
+
hint: r.sdk.hint,
|
|
3561
|
+
// `after` is the ORM's default, but honor an explicit caller override.
|
|
3562
|
+
returnDocument: r.driverOpts.returnDocument ?? "after"
|
|
3563
|
+
};
|
|
3564
|
+
return coll(collection).findOneAndReplace(
|
|
3565
|
+
toDriverFilter(filter),
|
|
3566
|
+
effective,
|
|
3567
|
+
fOpts
|
|
3568
|
+
);
|
|
3569
|
+
},
|
|
3570
|
+
options
|
|
3571
|
+
);
|
|
3572
|
+
invalidate(collection);
|
|
3573
|
+
return result;
|
|
3574
|
+
};
|
|
3575
|
+
const replaceOne = async (collection, filter, replacement, options) => {
|
|
3576
|
+
const result = await writeRun(
|
|
3577
|
+
collection,
|
|
3578
|
+
"mongo.replaceOne",
|
|
3579
|
+
async (r) => {
|
|
3580
|
+
const repOpts = {
|
|
3581
|
+
...r.driverOpts,
|
|
3582
|
+
session: r.sdk.session
|
|
3583
|
+
};
|
|
3584
|
+
return coll(collection).replaceOne(
|
|
3585
|
+
toDriverFilter(filter),
|
|
3586
|
+
stampReplace(collection, replacement),
|
|
3587
|
+
repOpts
|
|
3588
|
+
);
|
|
3589
|
+
},
|
|
3590
|
+
options
|
|
3591
|
+
);
|
|
3592
|
+
invalidate(collection);
|
|
3593
|
+
return result;
|
|
3594
|
+
};
|
|
3595
|
+
const deleteOne = async (collection, filter, options) => {
|
|
3596
|
+
await runHooks(opts.hooks, String(collection), "beforeDelete", {
|
|
3597
|
+
collection: String(collection),
|
|
3598
|
+
filter
|
|
3599
|
+
});
|
|
3600
|
+
const result = await writeRun(
|
|
3601
|
+
collection,
|
|
3602
|
+
"mongo.deleteOne",
|
|
3603
|
+
async (r) => {
|
|
3604
|
+
const delOpts = {
|
|
3605
|
+
...r.driverOpts,
|
|
3606
|
+
session: r.sdk.session
|
|
3607
|
+
};
|
|
3608
|
+
return coll(collection).deleteOne(toDriverFilter(filter), delOpts);
|
|
3609
|
+
},
|
|
3610
|
+
options
|
|
3611
|
+
);
|
|
3612
|
+
invalidate(collection);
|
|
3613
|
+
await runHooks(opts.hooks, String(collection), "afterDelete", {
|
|
3614
|
+
collection: String(collection),
|
|
3615
|
+
filter
|
|
3616
|
+
});
|
|
3617
|
+
return result;
|
|
3618
|
+
};
|
|
3619
|
+
const deleteMany = async (collection, filter, options) => {
|
|
3620
|
+
const result = await writeRun(
|
|
3621
|
+
collection,
|
|
3622
|
+
"mongo.deleteMany",
|
|
3623
|
+
async (r) => {
|
|
3624
|
+
const delOpts = {
|
|
3625
|
+
...r.driverOpts,
|
|
3626
|
+
session: r.sdk.session
|
|
3627
|
+
};
|
|
3628
|
+
return coll(collection).deleteMany(toDriverFilter(filter), delOpts);
|
|
3629
|
+
},
|
|
3630
|
+
options
|
|
3631
|
+
);
|
|
3632
|
+
invalidate(collection);
|
|
3633
|
+
return result;
|
|
3634
|
+
};
|
|
3635
|
+
const findOneAndDelete = async (collection, filter, options) => {
|
|
3636
|
+
const result = await writeRun(
|
|
3637
|
+
collection,
|
|
3638
|
+
"mongo.findOneAndDelete",
|
|
3639
|
+
async (r) => {
|
|
3640
|
+
const fOpts = {
|
|
3641
|
+
...r.driverOpts,
|
|
3642
|
+
session: r.sdk.session,
|
|
3643
|
+
maxTimeMS: r.sdk.maxTimeMS,
|
|
3644
|
+
hint: r.sdk.hint
|
|
3645
|
+
};
|
|
3646
|
+
return coll(collection).findOneAndDelete(
|
|
3647
|
+
toDriverFilter(filter),
|
|
3648
|
+
fOpts
|
|
3649
|
+
);
|
|
3650
|
+
},
|
|
3651
|
+
options
|
|
3652
|
+
);
|
|
3653
|
+
invalidate(collection);
|
|
3654
|
+
return result;
|
|
3655
|
+
};
|
|
3656
|
+
const softDeleteOne = async (collection, filter, options) => {
|
|
3657
|
+
const active = mergeMongoActiveFilter(true, filter);
|
|
3658
|
+
const result = await writeRun(
|
|
3659
|
+
collection,
|
|
3660
|
+
"mongo.softDeleteOne",
|
|
3661
|
+
async (r) => {
|
|
3662
|
+
const updateOpts = {
|
|
3663
|
+
...r.driverOpts,
|
|
3664
|
+
session: r.sdk.session
|
|
3665
|
+
};
|
|
3666
|
+
return coll(collection).updateOne(
|
|
3667
|
+
active,
|
|
3668
|
+
{ $set: { deletedAt: /* @__PURE__ */ new Date() } },
|
|
3669
|
+
updateOpts
|
|
3670
|
+
);
|
|
3671
|
+
},
|
|
3672
|
+
options
|
|
3673
|
+
);
|
|
3674
|
+
invalidate(collection);
|
|
3675
|
+
return result;
|
|
3676
|
+
};
|
|
3677
|
+
const upsert = async (collection, filter, update, options) => {
|
|
3678
|
+
const formatted = stampUpdate(collection, update);
|
|
3679
|
+
const result = await writeRun(
|
|
3680
|
+
collection,
|
|
3681
|
+
"mongo.upsert",
|
|
3682
|
+
async (r) => {
|
|
3683
|
+
const updateOpts = {
|
|
3684
|
+
...r.driverOpts,
|
|
3685
|
+
session: r.sdk.session,
|
|
3686
|
+
upsert: true
|
|
3687
|
+
};
|
|
3688
|
+
return coll(collection).updateOne(toDriverFilter(filter), formatted, updateOpts);
|
|
3689
|
+
},
|
|
3690
|
+
options
|
|
3691
|
+
);
|
|
3692
|
+
invalidate(collection);
|
|
3693
|
+
return result;
|
|
3694
|
+
};
|
|
3695
|
+
const bulkUpsert = async (collection, operations, options) => {
|
|
3696
|
+
const writes = operations.map((op) => ({
|
|
3697
|
+
updateOne: {
|
|
3698
|
+
filter: op.filter,
|
|
3699
|
+
update: stampUpdate(collection, op.update),
|
|
3700
|
+
upsert: op.upsert ?? true
|
|
3701
|
+
}
|
|
3702
|
+
}));
|
|
3703
|
+
if (operations.length > MAX_BATCH_OPS) {
|
|
3704
|
+
throw new BadRequest(
|
|
3705
|
+
`bulkUpsert: too many operations (${operations.length} > ${MAX_BATCH_OPS}); split the batch`
|
|
3706
|
+
);
|
|
3707
|
+
}
|
|
3708
|
+
const result = await writeRun(
|
|
3709
|
+
collection,
|
|
3710
|
+
"mongo.bulkUpsert",
|
|
3711
|
+
async (r) => {
|
|
3712
|
+
const bulkOpts = {
|
|
3713
|
+
...r.driverOpts,
|
|
3714
|
+
session: r.sdk.session
|
|
3715
|
+
};
|
|
3716
|
+
return coll(collection).bulkWrite(writes, bulkOpts);
|
|
3717
|
+
},
|
|
3718
|
+
options
|
|
3719
|
+
);
|
|
3720
|
+
invalidate(collection);
|
|
3721
|
+
return result;
|
|
3722
|
+
};
|
|
3723
|
+
const bulkWrite = async (collection, operations, options) => {
|
|
3724
|
+
if (operations.length > MAX_BATCH_OPS) {
|
|
3725
|
+
throw new BadRequest(
|
|
3726
|
+
`bulkWrite: too many operations (${operations.length} > ${MAX_BATCH_OPS}); split the batch`
|
|
3727
|
+
);
|
|
3728
|
+
}
|
|
3729
|
+
const result = await writeRun(
|
|
3730
|
+
collection,
|
|
3731
|
+
"mongo.bulkWrite",
|
|
3732
|
+
async (r) => {
|
|
3733
|
+
const bulkOpts = {
|
|
3734
|
+
...r.driverOpts,
|
|
3735
|
+
session: r.sdk.session
|
|
3736
|
+
};
|
|
3737
|
+
return coll(collection).bulkWrite(operations, bulkOpts);
|
|
3738
|
+
},
|
|
3739
|
+
options
|
|
3740
|
+
);
|
|
3741
|
+
invalidate(collection);
|
|
3742
|
+
return result;
|
|
3743
|
+
};
|
|
3744
|
+
const updateWithVersion = async (collection, filter, update, options) => {
|
|
3745
|
+
const existing = await getOne(collection, filter, {
|
|
3746
|
+
...options,
|
|
3747
|
+
cache: false
|
|
3748
|
+
});
|
|
3749
|
+
if (!existing) return { ok: false, reason: "not_found" };
|
|
3750
|
+
const current = existing.__v;
|
|
3751
|
+
const versionFilter = current === void 0 ? { __v: { $exists: false } } : { __v: current };
|
|
3752
|
+
const formatted = stampUpdate(collection, update);
|
|
3753
|
+
const versionedUpdate = {
|
|
3754
|
+
...formatted,
|
|
3755
|
+
$inc: { ...formatted.$inc, __v: 1 }
|
|
3756
|
+
};
|
|
3757
|
+
const result = await findOneAndUpdate(
|
|
3758
|
+
collection,
|
|
3759
|
+
{ ...filter, ...versionFilter },
|
|
3760
|
+
versionedUpdate,
|
|
3761
|
+
{ ...options, returnDocument: "after" }
|
|
3762
|
+
);
|
|
3763
|
+
if (result === null) return { ok: false, reason: "version_conflict" };
|
|
3764
|
+
return { ok: true, doc: result };
|
|
3765
|
+
};
|
|
3766
|
+
return {
|
|
3767
|
+
insertOne,
|
|
3768
|
+
insertMany,
|
|
3769
|
+
updateOne,
|
|
3770
|
+
updateMany,
|
|
3771
|
+
findOneAndUpdate,
|
|
3772
|
+
findOneAndReplace,
|
|
3773
|
+
deleteOne,
|
|
3774
|
+
deleteMany,
|
|
3775
|
+
softDeleteOne,
|
|
3776
|
+
upsert,
|
|
3777
|
+
bulkUpsert,
|
|
3778
|
+
bulkWrite,
|
|
3779
|
+
updateWithVersion,
|
|
3780
|
+
findOneAndDelete,
|
|
3781
|
+
replaceOne
|
|
3782
|
+
};
|
|
3783
|
+
};
|
|
3784
|
+
|
|
3785
|
+
// src/service/crud/index.ts
|
|
3786
|
+
var makeCrudOps = (client, dbLabel, logger, opts) => {
|
|
3787
|
+
const ctx = createCrudContext(client, dbLabel, logger, opts);
|
|
3788
|
+
const readOps = makeReadOps(ctx);
|
|
3789
|
+
return {
|
|
3790
|
+
...readOps,
|
|
3791
|
+
...makeWriteOps(ctx, readOps),
|
|
3792
|
+
...makeWatchOps(ctx)
|
|
3793
|
+
};
|
|
3794
|
+
};
|
|
3795
|
+
var tag = (value) => {
|
|
3796
|
+
if (value instanceof ObjectId) return { t: "oid", v: value.toHexString() };
|
|
3797
|
+
if (value instanceof Date) return { t: "date", v: value.toISOString() };
|
|
3798
|
+
if (typeof value === "string") return { t: "string", v: value };
|
|
3799
|
+
if (typeof value === "number") return { t: "number", v: value };
|
|
3800
|
+
if (typeof value === "boolean") return { t: "boolean", v: value };
|
|
3801
|
+
if (value === null || value === void 0) return { t: "null" };
|
|
3802
|
+
throw new BadRequest(
|
|
3803
|
+
`paginateCursor: unsupported sort value type "${typeof value}" \u2014 key values must be ObjectId, Date, string, number, boolean, or null`
|
|
3804
|
+
);
|
|
3805
|
+
};
|
|
3806
|
+
var untag = (tagged) => {
|
|
3807
|
+
switch (tagged.t) {
|
|
3808
|
+
case "oid":
|
|
3809
|
+
return new ObjectId(tagged.v);
|
|
3810
|
+
case "date":
|
|
3811
|
+
return new Date(tagged.v);
|
|
3812
|
+
case "string":
|
|
3813
|
+
return tagged.v;
|
|
3814
|
+
case "number":
|
|
3815
|
+
return tagged.v;
|
|
3816
|
+
case "boolean":
|
|
3817
|
+
return tagged.v;
|
|
3818
|
+
case "null":
|
|
3819
|
+
return null;
|
|
3820
|
+
}
|
|
3821
|
+
};
|
|
3822
|
+
var encodeCursor = (cursor) => Buffer.from(
|
|
3823
|
+
JSON.stringify({
|
|
3824
|
+
sort: cursor.sort,
|
|
3825
|
+
values: cursor.values.map(tag)
|
|
3826
|
+
}),
|
|
3827
|
+
"utf8"
|
|
3828
|
+
).toString("base64url");
|
|
3829
|
+
var decodeCursor = (cursor) => {
|
|
3830
|
+
let parsed;
|
|
3831
|
+
try {
|
|
3832
|
+
parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
|
3833
|
+
} catch {
|
|
3834
|
+
throw new BadRequest("paginateCursor: invalid cursor");
|
|
3835
|
+
}
|
|
3836
|
+
if (!parsed || typeof parsed !== "object" || !parsed.sort || !Array.isArray(parsed.values)) {
|
|
3837
|
+
throw new BadRequest("paginateCursor: invalid cursor payload");
|
|
3838
|
+
}
|
|
3839
|
+
return { sort: parsed.sort, values: parsed.values.map(untag) };
|
|
3840
|
+
};
|
|
3841
|
+
var buildKeysetFilter = (sort, values, direction) => {
|
|
3842
|
+
const keys = Object.entries(sort);
|
|
3843
|
+
const ors = [];
|
|
3844
|
+
for (let i = 0; i < keys.length; i++) {
|
|
3845
|
+
const cond = {};
|
|
3846
|
+
for (let j = 0; j < i; j++) {
|
|
3847
|
+
const [f] = keys[j];
|
|
3848
|
+
cond[f] = values[j];
|
|
3849
|
+
}
|
|
3850
|
+
const [field, dir] = keys[i];
|
|
3851
|
+
const value = values[i];
|
|
3852
|
+
const cmp = direction === "after" ? dir === 1 ? "$gt" : "$lt" : dir === 1 ? "$lt" : "$gt";
|
|
3853
|
+
cond[field] = { [cmp]: value };
|
|
3854
|
+
ors.push(cond);
|
|
3855
|
+
}
|
|
3856
|
+
return { $or: ors };
|
|
3857
|
+
};
|
|
3858
|
+
|
|
3859
|
+
// src/service/pagination/cursor.ts
|
|
3860
|
+
var makePaginateCursor = (ctx) => {
|
|
3861
|
+
const { client, dbLabel, logger, opts, resolve, deps: deps2 } = ctx;
|
|
3862
|
+
const paginateCursor = async (collection, baseFilter, config) => {
|
|
3863
|
+
const sortKeys = Object.entries(config.sort);
|
|
3864
|
+
if (sortKeys.length === 0) {
|
|
3865
|
+
throw new BadRequest(
|
|
3866
|
+
"paginateCursor: `sort` is required (include a unique tiebreaker like `_id`)"
|
|
3867
|
+
);
|
|
3868
|
+
}
|
|
3869
|
+
if (config.before !== void 0 && config.after !== void 0) {
|
|
3870
|
+
throw new BadRequest("paginateCursor: provide either `after` or `before`, not both");
|
|
3871
|
+
}
|
|
3872
|
+
const limit = normalizePageLimit(
|
|
3873
|
+
{ page: 1, limit: config.limit },
|
|
3874
|
+
config.maxLimit ?? DEFAULT_MAX_LIMIT,
|
|
3875
|
+
{ limit: DEFAULT_FIND_LIMIT }
|
|
3876
|
+
).limit;
|
|
3877
|
+
const backwards = config.before !== void 0 && config.after === void 0;
|
|
3878
|
+
const effectiveSort = backwards ? Object.fromEntries(sortKeys.map(([f, d]) => [f, d * -1])) : config.sort;
|
|
3879
|
+
let filter = baseFilter;
|
|
3880
|
+
if (config.after !== void 0 || config.before !== void 0) {
|
|
3881
|
+
const cursor = config.after ?? config.before;
|
|
3882
|
+
const decoded = decodeCursor(cursor);
|
|
3883
|
+
if (JSON.stringify(decoded.sort) !== JSON.stringify(config.sort)) {
|
|
3884
|
+
throw new BadRequest("paginateCursor: cursor was created with a different sort");
|
|
3885
|
+
}
|
|
3886
|
+
if (decoded.values.length !== sortKeys.length) {
|
|
3887
|
+
throw new BadRequest("paginateCursor: cursor was created with a different sort");
|
|
3888
|
+
}
|
|
3889
|
+
filter = {
|
|
3890
|
+
$and: [baseFilter, buildKeysetFilter(effectiveSort, decoded.values, "after")]
|
|
3891
|
+
};
|
|
3892
|
+
}
|
|
3893
|
+
const rows = await defineCrudOp(
|
|
3894
|
+
deps2,
|
|
3895
|
+
collection,
|
|
3896
|
+
"mongo.paginateCursor",
|
|
3897
|
+
async (r) => {
|
|
3898
|
+
const findOpts = {
|
|
3899
|
+
...r.driverOpts,
|
|
3900
|
+
session: r.sdk.session,
|
|
3901
|
+
maxTimeMS: r.sdk.maxTimeMS,
|
|
3902
|
+
hint: r.sdk.hint
|
|
3903
|
+
};
|
|
3904
|
+
return client.collection(resolve(String(collection))).find(filter, findOpts).sort(effectiveSort).limit(limit + 1).toArray();
|
|
3905
|
+
},
|
|
3906
|
+
config.queryOptions
|
|
3907
|
+
);
|
|
3908
|
+
const hasMore = rows.length > limit;
|
|
3909
|
+
const page = backwards ? rows.slice(0, limit).reverse() : rows.slice(0, limit);
|
|
3910
|
+
checkDocsDrift(
|
|
3911
|
+
{
|
|
3912
|
+
logger,
|
|
3913
|
+
db: dbLabel,
|
|
3914
|
+
drift: resolveDriftMode(config.queryOptions?.drift, opts.drift),
|
|
3915
|
+
getSchema: opts.getSchema
|
|
3916
|
+
},
|
|
3917
|
+
String(collection),
|
|
3918
|
+
"mongo.paginateCursor",
|
|
3919
|
+
page
|
|
3920
|
+
);
|
|
3921
|
+
const boundary = backwards ? rows[limit] : page[page.length - 1];
|
|
3922
|
+
const nextCursor = hasMore && boundary !== void 0 ? encodeCursor({
|
|
3923
|
+
sort: config.sort,
|
|
3924
|
+
values: sortKeys.map(([f]) => boundary[f])
|
|
3925
|
+
}) : null;
|
|
3926
|
+
return { data: page, nextCursor, hasMore };
|
|
3927
|
+
};
|
|
3928
|
+
return { paginateCursor };
|
|
3929
|
+
};
|
|
3930
|
+
|
|
3931
|
+
// src/service/pagination/offset.ts
|
|
3932
|
+
var makePaginateFlexible = (ctx) => {
|
|
3933
|
+
const { client, dbLabel, logger, opts, resolve, deps: deps2 } = ctx;
|
|
3934
|
+
const paginateFlexible = async (collection, baseFilter, config = {}) => {
|
|
3935
|
+
const normalized = normalizePageLimit(
|
|
3936
|
+
{ page: config.page, limit: config.limit },
|
|
3937
|
+
config.maxLimit ?? DEFAULT_MAX_LIMIT
|
|
3938
|
+
);
|
|
3939
|
+
const dataStages = [
|
|
3940
|
+
...config.prePipeline ?? [],
|
|
3941
|
+
...config.sort && Object.keys(config.sort).length > 0 ? [{ $sort: config.sort }] : [],
|
|
3942
|
+
{ $skip: normalized.offset },
|
|
3943
|
+
{ $limit: normalized.limit },
|
|
3944
|
+
...config.postPipeline ?? []
|
|
3945
|
+
];
|
|
3946
|
+
const pipeline = [
|
|
3947
|
+
{ $match: baseFilter },
|
|
3948
|
+
{
|
|
3949
|
+
$facet: {
|
|
3950
|
+
data: dataStages,
|
|
3951
|
+
totalCount: [{ $count: "count" }]
|
|
3952
|
+
}
|
|
3953
|
+
}
|
|
3954
|
+
];
|
|
3955
|
+
return defineCrudOp(
|
|
3956
|
+
deps2,
|
|
3957
|
+
collection,
|
|
3958
|
+
"mongo.paginateFlexible",
|
|
3959
|
+
async (r) => {
|
|
3960
|
+
const aggOptions = {
|
|
3961
|
+
...r.driverOpts,
|
|
3962
|
+
session: r.sdk.session,
|
|
3963
|
+
maxTimeMS: r.sdk.maxTimeMS,
|
|
3964
|
+
hint: r.sdk.hint,
|
|
3965
|
+
batchSize: r.sdk.batchSize
|
|
3966
|
+
};
|
|
3967
|
+
const rows = await client.collection(resolve(String(collection))).aggregate(pipeline, aggOptions).toArray();
|
|
3968
|
+
const row = rows[0];
|
|
3969
|
+
const data = row?.data ?? [];
|
|
3970
|
+
const totalCount = row?.totalCount?.[0]?.count ?? 0;
|
|
3971
|
+
checkDocsDrift(
|
|
3972
|
+
{
|
|
3973
|
+
logger,
|
|
3974
|
+
db: dbLabel,
|
|
3975
|
+
drift: resolveDriftMode(config.queryOptions?.drift, opts.drift),
|
|
3976
|
+
getSchema: opts.getSchema
|
|
3977
|
+
},
|
|
3978
|
+
String(collection),
|
|
3979
|
+
"mongo.paginateFlexible",
|
|
3980
|
+
data
|
|
3981
|
+
);
|
|
3982
|
+
return buildPaginationResult(data, totalCount, normalized.page, normalized.limit);
|
|
3983
|
+
},
|
|
3984
|
+
config.queryOptions
|
|
3985
|
+
);
|
|
3986
|
+
};
|
|
3987
|
+
return { paginateFlexible };
|
|
3988
|
+
};
|
|
3989
|
+
|
|
3990
|
+
// src/service/pagination/index.ts
|
|
3991
|
+
var makePaginationOps = (client, dbLabel, logger, opts) => {
|
|
3992
|
+
const resolve = opts.resolveCollectionName;
|
|
3993
|
+
const deps2 = makeOpDeps(logger, dbLabel, resolve, opts.wrapMongoErrors === true);
|
|
3994
|
+
const ctx = {
|
|
3995
|
+
client,
|
|
3996
|
+
dbLabel,
|
|
3997
|
+
logger,
|
|
3998
|
+
opts,
|
|
3999
|
+
resolve,
|
|
4000
|
+
deps: deps2
|
|
4001
|
+
};
|
|
4002
|
+
return {
|
|
4003
|
+
...makePaginateFlexible(ctx),
|
|
4004
|
+
...makePaginateCursor(ctx)
|
|
4005
|
+
};
|
|
4006
|
+
};
|
|
4007
|
+
|
|
4008
|
+
// src/service/schema-ops.ts
|
|
4009
|
+
var indexKeyOf = (key) => JSON.stringify(
|
|
4010
|
+
Object.keys(key).sort().map((k) => [k, key[k]])
|
|
4011
|
+
);
|
|
4012
|
+
var makeSchemaOps = (client, _logger, opts) => {
|
|
4013
|
+
const createSchema = async (collection, createCollectionOptions) => {
|
|
4014
|
+
const physical = opts.resolveCollectionName(collection);
|
|
4015
|
+
const def = opts.getDefinition(collection);
|
|
4016
|
+
const validator = def?.schema ? toMongoValidator(def.schema) : void 0;
|
|
4017
|
+
try {
|
|
4018
|
+
await client.createCollection(physical, {
|
|
4019
|
+
...validator ? { validator } : {},
|
|
4020
|
+
...createCollectionOptions ?? {}
|
|
4021
|
+
});
|
|
4022
|
+
} catch (err) {
|
|
4023
|
+
if (err?.code === 48) {
|
|
4024
|
+
throw new DomainError("COLLECTION_EXISTS", `Collection "${collection}" already exists`, {
|
|
4025
|
+
physicalCollection: physical
|
|
4026
|
+
});
|
|
4027
|
+
}
|
|
4028
|
+
throw err;
|
|
4029
|
+
}
|
|
4030
|
+
const indexes = def?.indexes ?? [];
|
|
4031
|
+
if (indexes.length > 0) {
|
|
4032
|
+
try {
|
|
4033
|
+
await client.collection(physical).createIndexes(
|
|
4034
|
+
indexes.map((index) => ({
|
|
4035
|
+
key: index.key,
|
|
4036
|
+
...index.options ?? {}
|
|
4037
|
+
}))
|
|
4038
|
+
);
|
|
4039
|
+
} catch (err) {
|
|
4040
|
+
await client.dropCollection(physical).catch(() => {
|
|
4041
|
+
});
|
|
4042
|
+
throw err;
|
|
4043
|
+
}
|
|
4044
|
+
}
|
|
4045
|
+
};
|
|
4046
|
+
const updateSchema = async (collection, schema) => {
|
|
4047
|
+
const physical = opts.resolveCollectionName(collection);
|
|
4048
|
+
const def = opts.getDefinition(collection);
|
|
4049
|
+
const resolved = schema ?? def?.schema;
|
|
4050
|
+
if (!resolved) {
|
|
4051
|
+
throw new Error(`No schema available for collection "${collection}"`);
|
|
4052
|
+
}
|
|
4053
|
+
await client.command({
|
|
4054
|
+
collMod: physical,
|
|
4055
|
+
validator: toMongoValidator(resolved)
|
|
4056
|
+
});
|
|
4057
|
+
};
|
|
4058
|
+
const syncIndexes = async (collection) => {
|
|
4059
|
+
const physical = opts.resolveCollectionName(collection);
|
|
4060
|
+
const def = opts.getDefinition(collection);
|
|
4061
|
+
const declared = def?.indexes ?? [];
|
|
4062
|
+
const handle = client.collection(physical);
|
|
4063
|
+
const existing = (await handle.listIndexes().toArray()).filter((i) => i.name !== "_id_");
|
|
4064
|
+
const existingByKey = new Map(
|
|
4065
|
+
existing.map((i) => [indexKeyOf(i.key), i.name])
|
|
4066
|
+
);
|
|
4067
|
+
const declaredByKey = new Map(declared.map((d) => [indexKeyOf(d.key), d]));
|
|
4068
|
+
const created = [];
|
|
4069
|
+
const dropped = [];
|
|
4070
|
+
for (const [key] of declaredByKey) {
|
|
4071
|
+
if (!existingByKey.has(key)) {
|
|
4072
|
+
await handle.createIndex(
|
|
4073
|
+
declaredByKey.get(key).key,
|
|
4074
|
+
declaredByKey.get(key).options
|
|
4075
|
+
);
|
|
4076
|
+
}
|
|
4077
|
+
}
|
|
4078
|
+
const after = (await handle.listIndexes().toArray()).filter((i) => i.name !== "_id_");
|
|
4079
|
+
const afterByKey = new Map(after.map((i) => [indexKeyOf(i.key), i.name]));
|
|
4080
|
+
for (const [key, spec] of declaredByKey) {
|
|
4081
|
+
if (!existingByKey.has(key)) {
|
|
4082
|
+
created.push(spec.options?.name ?? afterByKey.get(key) ?? JSON.stringify(spec.key));
|
|
4083
|
+
}
|
|
4084
|
+
}
|
|
4085
|
+
for (const [key, name] of existingByKey) {
|
|
4086
|
+
if (!declaredByKey.has(key)) {
|
|
4087
|
+
await handle.dropIndex(name);
|
|
4088
|
+
dropped.push(name);
|
|
4089
|
+
}
|
|
4090
|
+
}
|
|
4091
|
+
return { created, dropped };
|
|
4092
|
+
};
|
|
4093
|
+
return { createSchema, updateSchema, syncIndexes };
|
|
4094
|
+
};
|
|
4095
|
+
|
|
4096
|
+
// src/service/transaction.ts
|
|
4097
|
+
var makeTransactionOps = (client, handle, logger, _config, opts) => {
|
|
4098
|
+
const transaction = async (fn, options) => {
|
|
4099
|
+
const session = client.startSession();
|
|
4100
|
+
try {
|
|
4101
|
+
return await session.withTransaction(async () => fn(session), options);
|
|
4102
|
+
} catch (err) {
|
|
4103
|
+
const message = err instanceof Error ? err.message : "";
|
|
4104
|
+
const code = err?.code;
|
|
4105
|
+
const unsupported = code === 20 || /Transaction numbers are only allowed/i.test(message) || /does not support retryable writes/i.test(message);
|
|
4106
|
+
if (unsupported) {
|
|
4107
|
+
logger.warn?.({ code }, "transactions unsupported, falling back to non-transactional");
|
|
4108
|
+
return fn(null);
|
|
4109
|
+
}
|
|
4110
|
+
if (opts.wrapMongoErrors) {
|
|
4111
|
+
throw mapMongoDriverError(err, { db: handle.databaseName, op: "transaction" });
|
|
4112
|
+
}
|
|
4113
|
+
throw err;
|
|
4114
|
+
} finally {
|
|
4115
|
+
await session.endSession();
|
|
4116
|
+
}
|
|
4117
|
+
};
|
|
4118
|
+
const migrate = async (name, fn, filePath) => {
|
|
4119
|
+
const journal = handle.collection(
|
|
4120
|
+
opts.migrationCollectionPhysical
|
|
4121
|
+
);
|
|
4122
|
+
await transaction(async (session) => {
|
|
4123
|
+
const sessionOpts = session ? { session } : {};
|
|
4124
|
+
const claim = await journal.findOneAndUpdate(
|
|
4125
|
+
{ name },
|
|
4126
|
+
{
|
|
4127
|
+
$setOnInsert: {
|
|
4128
|
+
name,
|
|
4129
|
+
appliedAt: /* @__PURE__ */ new Date(),
|
|
4130
|
+
...filePath ? { file: filePath } : {}
|
|
4131
|
+
}
|
|
4132
|
+
},
|
|
4133
|
+
{ upsert: true, returnDocument: "before", ...sessionOpts }
|
|
4134
|
+
);
|
|
4135
|
+
if (claim) {
|
|
4136
|
+
logger.info({ name }, "migration already applied, skipping");
|
|
4137
|
+
return;
|
|
4138
|
+
}
|
|
4139
|
+
try {
|
|
4140
|
+
await fn(session);
|
|
4141
|
+
} catch (err) {
|
|
4142
|
+
await journal.deleteOne({ name }, sessionOpts).catch(() => {
|
|
4143
|
+
});
|
|
4144
|
+
throw err;
|
|
4145
|
+
}
|
|
4146
|
+
});
|
|
4147
|
+
};
|
|
4148
|
+
return { transaction, migrate };
|
|
4149
|
+
};
|
|
4150
|
+
|
|
4151
|
+
// src/service/manager.ts
|
|
4152
|
+
var buildManager = (deps2, dbKey, dbName, client) => {
|
|
4153
|
+
const { dbClients, config, logger, sharedCache, dedupeReads, inFlight, collectionRefs } = deps2;
|
|
4154
|
+
const definition = dbClients[dbKey];
|
|
4155
|
+
const handle = client.db(dbName);
|
|
4156
|
+
const baseResolveCollectionName = createResolveCollectionName(definition);
|
|
4157
|
+
const registry = /* @__PURE__ */ new Map();
|
|
4158
|
+
const hooks = {};
|
|
4159
|
+
const timestamps = {};
|
|
4160
|
+
for (const [logical, value] of Object.entries(definition.collections)) {
|
|
4161
|
+
const def = asCollectionDefinition(value);
|
|
4162
|
+
registry.set(logical, def);
|
|
4163
|
+
hooks[logical] = def.hooks;
|
|
4164
|
+
if (def.timestamps) timestamps[logical] = def.timestamps;
|
|
4165
|
+
collectionRefs.push({ db: handle, collection: baseResolveCollectionName(logical) });
|
|
4166
|
+
}
|
|
4167
|
+
const getDefinition = (logical) => registry.get(logical);
|
|
4168
|
+
const resolveCollectionName = (logical) => {
|
|
4169
|
+
if (!registry.has(logical) && !logical.startsWith("_")) {
|
|
4170
|
+
throw new BadRequest(`Unknown collection "${logical}"`);
|
|
4171
|
+
}
|
|
4172
|
+
return baseResolveCollectionName(logical);
|
|
4173
|
+
};
|
|
4174
|
+
const crud = makeCrudOps(handle, dbName, logger, {
|
|
4175
|
+
resolveCollectionName,
|
|
4176
|
+
wrapMongoErrors: deps2.wrapMongoErrors,
|
|
4177
|
+
cache: sharedCache,
|
|
4178
|
+
dedupeReads,
|
|
4179
|
+
inFlight,
|
|
4180
|
+
hooks,
|
|
4181
|
+
timestamps,
|
|
4182
|
+
drift: deps2.drift,
|
|
4183
|
+
getSchema: (logical) => getDefinition(logical)?.schema
|
|
4184
|
+
});
|
|
4185
|
+
const pagination = makePaginationOps(handle, dbName, logger, {
|
|
4186
|
+
resolveCollectionName,
|
|
4187
|
+
wrapMongoErrors: deps2.wrapMongoErrors,
|
|
4188
|
+
drift: deps2.drift,
|
|
4189
|
+
getSchema: (logical) => getDefinition(logical)?.schema
|
|
4190
|
+
});
|
|
4191
|
+
const aggregation = makeAggregationOps(handle, dbName, logger, {
|
|
4192
|
+
resolveCollectionName,
|
|
4193
|
+
wrapMongoErrors: deps2.wrapMongoErrors
|
|
4194
|
+
});
|
|
4195
|
+
const schema = makeSchemaOps(handle, logger, { resolveCollectionName, getDefinition });
|
|
4196
|
+
const transactionOps = makeTransactionOps(client, handle, logger, config, {
|
|
4197
|
+
migrationCollectionPhysical: resolveCollectionName("_migrations"),
|
|
4198
|
+
wrapMongoErrors: deps2.wrapMongoErrors
|
|
4199
|
+
});
|
|
4200
|
+
const populator = makePopulator({
|
|
4201
|
+
findMany: async (logical, filter, options) => {
|
|
4202
|
+
const physical = resolveCollectionName(logical);
|
|
4203
|
+
return handle.collection(physical).find(filter, {
|
|
4204
|
+
projection: options?.projection,
|
|
4205
|
+
limit: options?.limit ?? 1e4
|
|
4206
|
+
}).toArray();
|
|
4207
|
+
}
|
|
4208
|
+
});
|
|
4209
|
+
const populate = (async (docs, relations, options = {}) => {
|
|
4210
|
+
const result = await populator.populate(docs, relations, options);
|
|
4211
|
+
return result;
|
|
4212
|
+
});
|
|
4213
|
+
return {
|
|
4214
|
+
...crud,
|
|
4215
|
+
...pagination,
|
|
4216
|
+
...aggregation,
|
|
4217
|
+
...schema,
|
|
4218
|
+
...transactionOps,
|
|
4219
|
+
populate,
|
|
4220
|
+
client: handle,
|
|
4221
|
+
db: deps2.db
|
|
4222
|
+
};
|
|
4223
|
+
};
|
|
4224
|
+
|
|
4225
|
+
// src/service/index.ts
|
|
4226
|
+
var createMongoService = (dbClients, configOverrides = {}) => {
|
|
4227
|
+
const runtime = resolveRuntimeConfig(configOverrides);
|
|
4228
|
+
const { config, logger, sharedCache, dedupeReads } = runtime;
|
|
4229
|
+
const db = {};
|
|
4230
|
+
const openedClients = /* @__PURE__ */ new Map();
|
|
4231
|
+
const inFlight = new InFlight();
|
|
4232
|
+
const getDbUrl = makeGetDbUrl(dbClients, config);
|
|
4233
|
+
const collectionRefs = [];
|
|
4234
|
+
const cacheInvalidator = sharedCache !== void 0 && configOverrides.cacheWatch === true ? new CacheInvalidator({ cache: sharedCache, logger }) : void 0;
|
|
4235
|
+
const baseMakeConnections = makeConnectionFactory(
|
|
4236
|
+
dbClients,
|
|
4237
|
+
db,
|
|
4238
|
+
getDbUrl,
|
|
4239
|
+
((key, dbName, client) => buildManager(
|
|
4240
|
+
{
|
|
4241
|
+
dbClients,
|
|
4242
|
+
config,
|
|
4243
|
+
logger,
|
|
4244
|
+
sharedCache,
|
|
4245
|
+
dedupeReads,
|
|
4246
|
+
inFlight,
|
|
4247
|
+
wrapMongoErrors: runtime.wrapMongoErrors,
|
|
4248
|
+
drift: configOverrides.drift,
|
|
4249
|
+
collectionRefs,
|
|
4250
|
+
db
|
|
4251
|
+
},
|
|
4252
|
+
key,
|
|
4253
|
+
dbName,
|
|
4254
|
+
client
|
|
4255
|
+
)),
|
|
4256
|
+
openedClients
|
|
4257
|
+
);
|
|
4258
|
+
const makeConnections = async (names) => {
|
|
4259
|
+
await baseMakeConnections(names);
|
|
4260
|
+
await cacheInvalidator?.start(collectionRefs);
|
|
4261
|
+
};
|
|
4262
|
+
const closeConnections2 = async () => {
|
|
4263
|
+
await cacheInvalidator?.stop();
|
|
4264
|
+
collectionRefs.length = 0;
|
|
4265
|
+
await closeConnections(openedClients);
|
|
4266
|
+
};
|
|
4267
|
+
const eachDb = async (fn) => {
|
|
4268
|
+
return Promise.all(Object.keys(db).map((key) => fn(db[key], key.replace(/Client$/, ""))));
|
|
4269
|
+
};
|
|
4270
|
+
const health = createHealth(db);
|
|
4271
|
+
return {
|
|
4272
|
+
db,
|
|
4273
|
+
makeConnections,
|
|
4274
|
+
closeConnections: closeConnections2,
|
|
4275
|
+
eachDb,
|
|
4276
|
+
health,
|
|
4277
|
+
getDbUrl,
|
|
4278
|
+
config,
|
|
4279
|
+
logger,
|
|
4280
|
+
ObjectId: ObjectId
|
|
4281
|
+
};
|
|
4282
|
+
};
|
|
4283
|
+
|
|
4284
|
+
// src/shared/merge-filters.ts
|
|
4285
|
+
var mergeMongoFilters = (...parts) => {
|
|
4286
|
+
const out = {};
|
|
4287
|
+
for (const part of parts) {
|
|
4288
|
+
if (!part) continue;
|
|
4289
|
+
Object.assign(out, part);
|
|
4290
|
+
}
|
|
4291
|
+
return out;
|
|
4292
|
+
};
|
|
4293
|
+
|
|
4294
|
+
// src/shared/strip-primary-key.ts
|
|
4295
|
+
var stripDocumentId = (doc) => {
|
|
4296
|
+
if (!("_id" in doc)) return doc;
|
|
4297
|
+
const { _id, ...rest } = doc;
|
|
4298
|
+
return rest;
|
|
4299
|
+
};
|
|
4300
|
+
var stripPrimaryKey = (patch, key) => {
|
|
4301
|
+
if (!(key in patch)) return patch;
|
|
4302
|
+
const { [key]: _, ...rest } = patch;
|
|
4303
|
+
return rest;
|
|
4304
|
+
};
|
|
4305
|
+
|
|
4306
|
+
// src/toolkit.ts
|
|
4307
|
+
var createMongoToolkit = (dbClients, options = {}) => {
|
|
4308
|
+
const service = createMongoService(dbClients, options);
|
|
4309
|
+
const migrations = createMongoMigrationRunner(service, {
|
|
4310
|
+
migrationDir: service.config.migrationDir
|
|
4311
|
+
});
|
|
4312
|
+
return { service, migrations };
|
|
4313
|
+
};
|
|
4314
|
+
|
|
4315
|
+
// src/utils/memoize.ts
|
|
4316
|
+
var makeStore = (maxSize) => {
|
|
4317
|
+
if (maxSize <= 0) {
|
|
4318
|
+
const map = /* @__PURE__ */ new Map();
|
|
4319
|
+
return {
|
|
4320
|
+
get: (k) => map.get(k),
|
|
4321
|
+
set: (k, v) => {
|
|
4322
|
+
map.set(k, v);
|
|
4323
|
+
},
|
|
4324
|
+
has: (k) => map.has(k),
|
|
4325
|
+
delete: (k) => {
|
|
4326
|
+
map.delete(k);
|
|
4327
|
+
}
|
|
4328
|
+
};
|
|
4329
|
+
}
|
|
4330
|
+
return new LRU({ max: maxSize });
|
|
4331
|
+
};
|
|
4332
|
+
var createCachedFactory = (factory, options = {}) => {
|
|
4333
|
+
const maxSize = options.maxSize ?? 100;
|
|
4334
|
+
const cache = makeStore(maxSize);
|
|
4335
|
+
return (key) => {
|
|
4336
|
+
const existing = cache.get(key);
|
|
4337
|
+
if (existing !== void 0) return existing;
|
|
4338
|
+
const value = factory(key);
|
|
4339
|
+
cache.set(key, value);
|
|
4340
|
+
return value;
|
|
4341
|
+
};
|
|
4342
|
+
};
|
|
4343
|
+
var createCachedAsyncFactory = (factory, options = {}) => {
|
|
4344
|
+
const maxSize = options.maxSize ?? 100;
|
|
4345
|
+
const cache = makeStore(maxSize);
|
|
4346
|
+
const inFlight = /* @__PURE__ */ new Map();
|
|
4347
|
+
return (key) => {
|
|
4348
|
+
const inflight = inFlight.get(key);
|
|
4349
|
+
if (inflight) return inflight;
|
|
4350
|
+
const cached = cache.get(key);
|
|
4351
|
+
if (cached) return cached;
|
|
4352
|
+
const promise = factory(key);
|
|
4353
|
+
inFlight.set(key, promise);
|
|
4354
|
+
cache.set(key, promise);
|
|
4355
|
+
promise.then(
|
|
4356
|
+
() => {
|
|
4357
|
+
inFlight.delete(key);
|
|
4358
|
+
},
|
|
4359
|
+
() => {
|
|
4360
|
+
inFlight.delete(key);
|
|
4361
|
+
if (cache.get(key) === promise) cache.delete(key);
|
|
4362
|
+
}
|
|
4363
|
+
);
|
|
4364
|
+
return promise;
|
|
4365
|
+
};
|
|
4366
|
+
};
|
|
4367
|
+
|
|
4368
|
+
export { AppError, BadRequest, CacheInvalidator, DEFAULT_FIND_LIMIT, DEFAULT_MAX_LIMIT, DataLoader, DomainError, ERROR_HTTP_STATUS, HOOK_NAMES, HotCache, InFlight, InfraError, LRU, MAX_BATCH_OPS, QueryBuilder, QueryCache, TRANSIENT_MONGO_ERROR_CODES, belongsTo, buildKeysetFilter, buildMongoActiveFilter, buildPaginationResult, cacheCollectionKey, canonicalKey, createCachedAsyncFactory, createCachedFactory, createConsoleLogger, createHotCache, createMongoCapabilitiesStore, createMongoMigrationRunner, createMongoService, createMongoToolkit, createNoopLogger, createRepository, createResolveCollectionName, decodeCursor, defineCollection, defineCollections, encodeCursor, hasMany, hashString, httpStatusForError, isAppError, isDomainError, isInfraError, isMappedMongoError, isMongoDuplicateKeyError, isMongoTransientError, manyToMany, mapMongoDriverError, mergeMongoActiveFilter, mergeMongoFilters, normalizeMongoUrl, normalizePageLimit, optional, probeMongoCapabilities, runHooks, s, serializeError, sleep, sleepJittered, stableHash, stableStringify, stripDocumentId, stripPrimaryKey, toMongoSchema, toMongoValidator, validateDoc, withDefault, withGracefulMongoTransaction, withRetry, withTimeout, withTransaction };
|
|
4369
|
+
//# sourceMappingURL=index.js.map
|
|
4370
|
+
//# sourceMappingURL=index.js.map
|