@classytic/mongokit 3.39.0 → 3.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{PaginationEngine-DQ7beTCR.mjs → PaginationEngine-Dvl8jcRY.mjs} +33 -17
- package/dist/{Repository-D7fDpyFx.mjs → Repository-BMgPSkqZ.mjs} +279 -625
- package/dist/{Repository-IxI983_m.d.mts → Repository-CR8Y9g2E.d.mts} +57 -32
- package/dist/actions/index.mjs +1 -1
- package/dist/ai/index.d.mts +1 -1
- package/dist/better-auth/index.mjs +1 -1
- package/dist/{create-repository-DOe9E_5F.mjs → create-repository-BzBUMvd1.mjs} +2 -2
- package/dist/{create-repository-CRXjDa3N.d.mts → create-repository-CAle1MSU.d.mts} +4 -4
- package/dist/idempotency/index.d.mts +23 -0
- package/dist/idempotency/index.mjs +134 -0
- package/dist/index.d.mts +58 -32
- package/dist/index.mjs +285 -675
- package/dist/indexes/verify-indexes.d.mts +14 -1
- package/dist/indexes/verify-indexes.mjs +54 -7
- package/dist/{multi-tenant.plugin-B-tzNeYL.d.mts → multi-tenant.plugin-CBgCo9im.d.mts} +18 -2
- package/dist/outbox/index.d.mts +1 -1
- package/dist/outbox/index.mjs +6 -4
- package/dist/pagination/PaginationEngine.mjs +1 -1
- package/dist/{plugin-options-BuvtOSZf.d.mts → plugin-options-Zk46lD6F.d.mts} +15 -1
- package/dist/plugins/index.d.mts +8 -3
- package/dist/plugins/index.mjs +98 -25
- package/dist/{repository-ZbLtL7Nk.d.mts → repository-odl03582.d.mts} +20 -2
- package/dist/sync/index.d.mts +40 -0
- package/dist/sync/index.mjs +334 -0
- package/dist/tenant/index.mjs +2 -0
- package/dist/testkit/index.d.mts +2 -2
- package/dist/testkit/index.mjs +1 -1
- package/dist/{timestamp.plugin-B5eiaItQ.mjs → timestamp.plugin-DF0gJB6p.mjs} +86 -2
- package/dist/{to-plain-B8ufk3Qz.d.mts → to-plain-k7SjNlSS.d.mts} +2 -2
- package/dist/transaction-B5L9glO0.mjs +533 -0
- package/dist/{update-c_01A0if.mjs → update-DG0fCwRJ.mjs} +168 -11
- package/dist/utils/index.d.mts +1 -1
- package/dist/{validation-chain.plugin-Bju1D1Sn.mjs → validation-chain.plugin-BLY2gLEk.mjs} +192 -279
- package/dist/{validation-chain.plugin-DvrLJIbJ.d.mts → validation-chain.plugin-DYp9E7K2.d.mts} +25 -3
- package/package.json +13 -4
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { i as isValidIdForType, t as getSchemaIdType } from "./id-resolution-BqDrk0S2.mjs";
|
|
2
2
|
import { a as narrowToIds, i as keysetFilter, o as selectKeysetChunk } from "./id-chunks-BmAwsqNk.mjs";
|
|
3
|
-
import { _ as
|
|
3
|
+
import { _ as SHORTHAND_OPS, a as count, b as create, c as getByQuery, d as deleteById, f as deleteByQuery, g as LookupBuilder, h as distinct, l as getOrCreate, n as update, o as exists, r as updateByQuery, s as getById, t as findOneAndUpdate, v as compileFilterToMongo, x as createMany, y as castFilterToSchema } from "./update-DG0fCwRJ.mjs";
|
|
4
4
|
import { n as warn } from "./logger-K7dPmu2U.mjs";
|
|
5
5
|
import { n as isDuplicateKeyError, r as parseDuplicateKeyError, t as createError } from "./error-BuByZwKU.mjs";
|
|
6
|
-
import {
|
|
6
|
+
import { a as createTxBoundRepo, d as resolveMongoCapabilities, i as withTransaction } from "./transaction-B5L9glO0.mjs";
|
|
7
|
+
import { c as resolveCursorSecrets, l as verifySignature, s as attachSignature, t as PaginationEngine } from "./PaginationEngine-Dvl8jcRY.mjs";
|
|
7
8
|
import { calculateTotalPages } from "./pagination/utils/limits.mjs";
|
|
8
9
|
import { hasNearOperator, rewriteNearForCount } from "./query/primitives/geo.mjs";
|
|
9
10
|
import { RepositoryBase, createDistributionGuard, runChunkedArchive, runChunkedPurge, throwIfAborted, validatePluginOrder, withRetry } from "@classytic/repo-core/repository";
|
|
@@ -118,257 +119,16 @@ async function purgeAnonymizeFunctional(repo, selectFilter, fields, limit, sessi
|
|
|
118
119
|
ordered: false,
|
|
119
120
|
session: session ?? void 0
|
|
120
121
|
});
|
|
122
|
+
await repo.emitAsync("after:anonymize", {
|
|
123
|
+
context: { ...session ? { session } : {} },
|
|
124
|
+
result: { ids: docs.map((doc) => doc._id) }
|
|
125
|
+
});
|
|
121
126
|
return {
|
|
122
127
|
processed: docs.length,
|
|
123
128
|
lastId: docs[docs.length - 1]?._id ?? null
|
|
124
129
|
};
|
|
125
130
|
}
|
|
126
131
|
//#endregion
|
|
127
|
-
//#region src/capabilities.ts
|
|
128
|
-
/**
|
|
129
|
-
* Read the provenance of a capability descriptor's `transactions` flag.
|
|
130
|
-
* A descriptor without the field is a static declaration (`'declared'`) —
|
|
131
|
-
* which is exactly right for sqlitekit / prismakit / any kit whose answer
|
|
132
|
-
* genuinely does not depend on a live deployment.
|
|
133
|
-
*/
|
|
134
|
-
function transactionResolutionOf(caps) {
|
|
135
|
-
const value = caps.transactionsResolution;
|
|
136
|
-
return value === "observed" || value === "unknown" ? value : "declared";
|
|
137
|
-
}
|
|
138
|
-
/**
|
|
139
|
-
* What MongoDB supports through mongokit's surface, DECLARED (not observed).
|
|
140
|
-
*
|
|
141
|
-
* Deployment-dependent flags (`transactions`, `changeStreams` — both need a
|
|
142
|
-
* replica set or mongos) are `true` here because they describe the product,
|
|
143
|
-
* not the server you happen to be pointed at. `nestedTransactions` is NOT one
|
|
144
|
-
* of them: it is `false` unconditionally, because the refusal comes from
|
|
145
|
-
* mongokit's own tx-bound proxy rather than from the deployment. **Do not read this constant to decide whether a live connection can run
|
|
146
|
-
* a transaction** — that is {@link resolveMongoCapabilities}, which is what
|
|
147
|
-
* `Repository#capabilities` returns. This one is for the cross-kit conformance
|
|
148
|
-
* harness (which overrides environment-specific limits where it is
|
|
149
|
-
* constructed) and as the base every observed descriptor is derived from.
|
|
150
|
-
*/
|
|
151
|
-
const MONGOKIT_CAPABILITIES = Object.freeze({
|
|
152
|
-
transactionsResolution: "declared",
|
|
153
|
-
transactions: true,
|
|
154
|
-
nestedTransactions: false,
|
|
155
|
-
transactionRetry: "managed",
|
|
156
|
-
upsert: true,
|
|
157
|
-
duplicateKeyError: true,
|
|
158
|
-
optimisticConcurrency: true,
|
|
159
|
-
distinct: true,
|
|
160
|
-
aggregate: true,
|
|
161
|
-
aggregateOps: Object.freeze({
|
|
162
|
-
percentile: true,
|
|
163
|
-
stddev: true,
|
|
164
|
-
topN: true,
|
|
165
|
-
dateBucketTimezone: true,
|
|
166
|
-
customDateBuckets: true,
|
|
167
|
-
dateBucketSubMinute: true,
|
|
168
|
-
cache: true
|
|
169
|
-
}),
|
|
170
|
-
getOrCreate: true,
|
|
171
|
-
countAndExists: true,
|
|
172
|
-
purgeByField: true,
|
|
173
|
-
purgeByFilter: true,
|
|
174
|
-
archiveByFilter: true,
|
|
175
|
-
arrayOperators: true,
|
|
176
|
-
regexFilter: true,
|
|
177
|
-
changeStreams: true,
|
|
178
|
-
lean: true,
|
|
179
|
-
lookupPopulate: true,
|
|
180
|
-
lookupCoerce: true,
|
|
181
|
-
streaming: true
|
|
182
|
-
});
|
|
183
|
-
/**
|
|
184
|
-
* Driver `ServerType`s that positively imply a session-capable, transaction-
|
|
185
|
-
* capable deployment. Verified against mongodb driver 7.x: a single-node
|
|
186
|
-
* replica set reports `RSPrimary` (topology `ReplicaSetWithPrimary`, or
|
|
187
|
-
* `Single` when reached with `directConnection`), a standalone `mongod`
|
|
188
|
-
* reports `Standalone`, and a mongos reports `Mongos`.
|
|
189
|
-
*
|
|
190
|
-
* **This is why the check reads SERVER type, not just topology type.** The
|
|
191
|
-
* pre-existing heuristic (`topology.description.type !== 'Single'`) has a
|
|
192
|
-
* false NEGATIVE: a directly-connected single-node replica set is `Single`
|
|
193
|
-
* yet runs transactions fine.
|
|
194
|
-
*/
|
|
195
|
-
const TRANSACTION_CAPABLE_SERVER_TYPES = /* @__PURE__ */ new Set([
|
|
196
|
-
"RSPrimary",
|
|
197
|
-
"RSSecondary",
|
|
198
|
-
"RSArbiter",
|
|
199
|
-
"RSOther",
|
|
200
|
-
"RSGhost",
|
|
201
|
-
"Mongos",
|
|
202
|
-
"LoadBalancer"
|
|
203
|
-
]);
|
|
204
|
-
/** Topology types that positively imply transactions regardless of server detail. */
|
|
205
|
-
const TRANSACTION_CAPABLE_TOPOLOGY_TYPES = /* @__PURE__ */ new Set([
|
|
206
|
-
"ReplicaSetWithPrimary",
|
|
207
|
-
"ReplicaSetNoPrimary",
|
|
208
|
-
"Sharded",
|
|
209
|
-
"LoadBalanced"
|
|
210
|
-
]);
|
|
211
|
-
function clientOf(connection) {
|
|
212
|
-
const probe = connection;
|
|
213
|
-
if (!probe || typeof probe !== "object") return void 0;
|
|
214
|
-
try {
|
|
215
|
-
return probe.getClient?.() ?? probe.client ?? (probe.topology !== void 0 ? probe : void 0);
|
|
216
|
-
} catch {
|
|
217
|
-
return;
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
function serverTypes(description) {
|
|
221
|
-
const servers = description.servers;
|
|
222
|
-
if (!servers) return [];
|
|
223
|
-
return (typeof servers.values === "function" ? [...servers.values()] : Object.values(servers)).map((s) => s?.type).filter((t) => typeof t === "string");
|
|
224
|
-
}
|
|
225
|
-
/**
|
|
226
|
-
* Read whether a connection's LIVE deployment supports multi-document
|
|
227
|
-
* transactions, synchronously, from the driver's SDAM topology description.
|
|
228
|
-
*
|
|
229
|
-
* Free (a few property reads against an object the driver keeps current), so
|
|
230
|
-
* it is deliberately NOT memoized — a connection that opens after the repo was
|
|
231
|
-
* constructed upgrades its own answer with no invalidation step. What IS
|
|
232
|
-
* memoized is the async {@link probeMongoCapabilities} result, which this
|
|
233
|
-
* function consults first so an exotic deployment that answered `hello` but
|
|
234
|
-
* exposes no topology still reports definitively.
|
|
235
|
-
*
|
|
236
|
-
* @returns `'yes'` / `'no'` when the topology is conclusive, `'unknown'`
|
|
237
|
-
* otherwise — never optimistic. See the module docblock on rule 3.
|
|
238
|
-
*/
|
|
239
|
-
function resolveTransactionSupport(connection) {
|
|
240
|
-
const cached = readProbeCache(connection);
|
|
241
|
-
if (cached) return cached;
|
|
242
|
-
const description = clientOf(connection)?.topology?.description;
|
|
243
|
-
if (!description) return "unknown";
|
|
244
|
-
const types = serverTypes(description);
|
|
245
|
-
if (types.some((t) => TRANSACTION_CAPABLE_SERVER_TYPES.has(t))) return "yes";
|
|
246
|
-
if (types.length > 0 && types.every((t) => t === "Standalone")) return "no";
|
|
247
|
-
const topologyType = description.type;
|
|
248
|
-
if (topologyType && TRANSACTION_CAPABLE_TOPOLOGY_TYPES.has(topologyType)) return "yes";
|
|
249
|
-
return "unknown";
|
|
250
|
-
}
|
|
251
|
-
function deriveCapabilities(support) {
|
|
252
|
-
const available = support === "yes";
|
|
253
|
-
return Object.freeze({
|
|
254
|
-
...MONGOKIT_CAPABILITIES,
|
|
255
|
-
transactionsResolution: support === "unknown" ? "unknown" : "observed",
|
|
256
|
-
transactions: available,
|
|
257
|
-
nestedTransactions: false,
|
|
258
|
-
changeStreams: available
|
|
259
|
-
});
|
|
260
|
-
}
|
|
261
|
-
/** Three possible answers ⇒ three frozen singletons; the getter allocates nothing. */
|
|
262
|
-
const DERIVED = Object.freeze({
|
|
263
|
-
yes: deriveCapabilities("yes"),
|
|
264
|
-
no: deriveCapabilities("no"),
|
|
265
|
-
unknown: deriveCapabilities("unknown")
|
|
266
|
-
});
|
|
267
|
-
/**
|
|
268
|
-
* The capability descriptor for a LIVE connection — what
|
|
269
|
-
* `Repository#capabilities` returns.
|
|
270
|
-
*
|
|
271
|
-
* Every deployment-dependent flag reflects the observed topology; everything
|
|
272
|
-
* else comes from {@link MONGOKIT_CAPABILITIES}. Cheap enough to call per
|
|
273
|
-
* access (topology read + one frozen-object lookup).
|
|
274
|
-
*/
|
|
275
|
-
function resolveMongoCapabilities(connection) {
|
|
276
|
-
return DERIVED[resolveTransactionSupport(connection)];
|
|
277
|
-
}
|
|
278
|
-
/** The three derived descriptors, for tests and for gates that want to compare identity. */
|
|
279
|
-
function capabilitiesForSupport(support) {
|
|
280
|
-
return DERIVED[support];
|
|
281
|
-
}
|
|
282
|
-
/** Definitive results only. Keyed on the connection object; never holds `'unknown'`. */
|
|
283
|
-
const PROBE_RESULTS = /* @__PURE__ */ new WeakMap();
|
|
284
|
-
/** In-flight de-duplication so N repos on one connection issue ONE `hello`. */
|
|
285
|
-
const PROBE_INFLIGHT = /* @__PURE__ */ new WeakMap();
|
|
286
|
-
function readProbeCache(connection) {
|
|
287
|
-
if (!connection || typeof connection !== "object") return void 0;
|
|
288
|
-
return PROBE_RESULTS.get(connection);
|
|
289
|
-
}
|
|
290
|
-
/** Test seam — drop memoized probe answers (a reused connection object across suites). */
|
|
291
|
-
function resetCapabilityProbeCache(connection) {
|
|
292
|
-
if (connection && typeof connection === "object") {
|
|
293
|
-
PROBE_RESULTS.delete(connection);
|
|
294
|
-
PROBE_INFLIGHT.delete(connection);
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
function withTimeout(promise, ms, onTimeout) {
|
|
298
|
-
return new Promise((resolve) => {
|
|
299
|
-
const timer = setTimeout(() => resolve(onTimeout), ms);
|
|
300
|
-
timer.unref?.();
|
|
301
|
-
promise.then((value) => {
|
|
302
|
-
clearTimeout(timer);
|
|
303
|
-
resolve(value);
|
|
304
|
-
}, () => {
|
|
305
|
-
clearTimeout(timer);
|
|
306
|
-
resolve(onTimeout);
|
|
307
|
-
});
|
|
308
|
-
});
|
|
309
|
-
}
|
|
310
|
-
/**
|
|
311
|
-
* Classify a `hello` (or legacy `isMaster`) reply.
|
|
312
|
-
*
|
|
313
|
-
* - `msg: 'isdbgrid'` ⇒ mongos ⇒ transactions (Mongo 4.2+ sharded).
|
|
314
|
-
* - `setName` ⇒ a replica-set member ⇒ transactions.
|
|
315
|
-
* - a reply that IS a hello (`isWritablePrimary` / `ismaster` present) with
|
|
316
|
-
* neither marker ⇒ standalone ⇒ **no**. Verified: a memory-server standalone
|
|
317
|
-
* returns `{ isWritablePrimary: true }` and nothing else.
|
|
318
|
-
* - anything unrecognised ⇒ `'unknown'`. A reply we cannot parse is not a yes.
|
|
319
|
-
*/
|
|
320
|
-
function classifyHelloReply(reply) {
|
|
321
|
-
if (!reply || typeof reply !== "object") return "unknown";
|
|
322
|
-
const { msg, setName } = reply;
|
|
323
|
-
if (msg === "isdbgrid") return "yes";
|
|
324
|
-
if (typeof setName === "string" && setName.length > 0) return "yes";
|
|
325
|
-
return "isWritablePrimary" in reply || "ismaster" in reply ? "no" : "unknown";
|
|
326
|
-
}
|
|
327
|
-
async function runProbe(connection, timeoutMs) {
|
|
328
|
-
if (connection.readyState === 2 && typeof connection.asPromise === "function") await withTimeout(connection.asPromise().then(() => void 0, () => void 0), timeoutMs, void 0);
|
|
329
|
-
const fromTopology = resolveTransactionSupport(connection);
|
|
330
|
-
if (fromTopology !== "unknown") return fromTopology;
|
|
331
|
-
const admin = connection.db?.admin?.();
|
|
332
|
-
if (!admin) return "unknown";
|
|
333
|
-
return classifyHelloReply(await withTimeout(admin.command({ hello: 1 }).catch(async () => admin.command({ isMaster: 1 }).catch(() => void 0)), timeoutMs, void 0));
|
|
334
|
-
}
|
|
335
|
-
/**
|
|
336
|
-
* Resolve a connection's real capabilities, waiting for (and if necessary
|
|
337
|
-
* asking) the deployment. Memoized per connection: N repositories and N
|
|
338
|
-
* kernels on one connection issue at most ONE `hello`.
|
|
339
|
-
*
|
|
340
|
-
* Hosts do not have to call this — `Repository#capabilities` reads the live
|
|
341
|
-
* topology synchronously, which is already conclusive once
|
|
342
|
-
* `await mongoose.connect()` has returned (the Kernel Construction Standard's
|
|
343
|
-
* Describe → **Bind** ordering guarantees that). It exists for the cases the
|
|
344
|
-
* sync read cannot cover: binding while a connection is still opening, or a
|
|
345
|
-
* deployment whose driver surfaces no topology description. Calling it makes
|
|
346
|
-
* the SUBSEQUENT synchronous reads definitive, so nothing else needs rewiring:
|
|
347
|
-
*
|
|
348
|
-
* ```ts
|
|
349
|
-
* await probeMongoCapabilities(connection); // once, before bind
|
|
350
|
-
* const engine = defineWallet().bind(connection); // gate now sees the truth
|
|
351
|
-
* ```
|
|
352
|
-
*
|
|
353
|
-
* Only definitive answers are cached — an `'unknown'` may be re-probed later
|
|
354
|
-
* (the deployment may simply not have been up yet).
|
|
355
|
-
*/
|
|
356
|
-
async function probeMongoCapabilities(connection, options = {}) {
|
|
357
|
-
if (!connection || typeof connection !== "object") return DERIVED.unknown;
|
|
358
|
-
const key = connection;
|
|
359
|
-
const cached = PROBE_RESULTS.get(key);
|
|
360
|
-
if (cached) return DERIVED[cached];
|
|
361
|
-
const inflight = PROBE_INFLIGHT.get(key);
|
|
362
|
-
if (inflight) return DERIVED[await inflight];
|
|
363
|
-
const run = runProbe(connection, options.timeoutMs ?? 2e3).catch(() => "unknown").then((support) => {
|
|
364
|
-
if (support !== "unknown") PROBE_RESULTS.set(key, support);
|
|
365
|
-
PROBE_INFLIGHT.delete(key);
|
|
366
|
-
return support;
|
|
367
|
-
});
|
|
368
|
-
PROBE_INFLIGHT.set(key, run);
|
|
369
|
-
return DERIVED[await run];
|
|
370
|
-
}
|
|
371
|
-
//#endregion
|
|
372
132
|
//#region src/operations.ts
|
|
373
133
|
/**
|
|
374
134
|
* The registry. Order intentionally groups by category for readability.
|
|
@@ -528,123 +288,32 @@ function applyExecutionHints(aggregation, hints) {
|
|
|
528
288
|
if (!hints) return;
|
|
529
289
|
if (hints.allowDiskUse) aggregation.allowDiskUse(true);
|
|
530
290
|
if (typeof hints.maxTimeMs === "number" && hints.maxTimeMs > 0) aggregation.option({ maxTimeMS: hints.maxTimeMs });
|
|
531
|
-
if (hints.indexHint
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
/** Operators whose operand is a single scalar to cast. */
|
|
536
|
-
const SCALAR_OPS = /* @__PURE__ */ new Set([
|
|
537
|
-
"$eq",
|
|
538
|
-
"$ne",
|
|
539
|
-
"$gt",
|
|
540
|
-
"$gte",
|
|
541
|
-
"$lt",
|
|
542
|
-
"$lte"
|
|
543
|
-
]);
|
|
544
|
-
/** Operators whose operand is an array of scalars to cast element-wise. */
|
|
545
|
-
const ARRAY_OPS = /* @__PURE__ */ new Set(["$in", "$nin"]);
|
|
546
|
-
/** Logical operators whose operand is an ARRAY of sub-filters. */
|
|
547
|
-
const LOGICAL_ARRAY_OPS = /* @__PURE__ */ new Set([
|
|
548
|
-
"$and",
|
|
549
|
-
"$or",
|
|
550
|
-
"$nor"
|
|
551
|
-
]);
|
|
552
|
-
/** Logical operators whose operand is a SINGLE nested sub-filter. */
|
|
553
|
-
const LOGICAL_OBJECT_OPS = /* @__PURE__ */ new Set(["$not"]);
|
|
554
|
-
function isPlainObject(value) {
|
|
555
|
-
return value !== null && typeof value === "object" && !Array.isArray(value) && !(value instanceof Date);
|
|
556
|
-
}
|
|
557
|
-
/**
|
|
558
|
-
* Cast one value through a schema path. Returns the original value when the
|
|
559
|
-
* path is unknown, the value is nullish, or the cast throws — never raises.
|
|
560
|
-
*/
|
|
561
|
-
function castValue(schema, field, value) {
|
|
562
|
-
if (value === null || value === void 0) return value;
|
|
563
|
-
if (typeof value === "object" && !(value instanceof Date)) return value;
|
|
564
|
-
let schemaType;
|
|
565
|
-
try {
|
|
566
|
-
schemaType = schema.path(field);
|
|
567
|
-
} catch {
|
|
568
|
-
return value;
|
|
569
|
-
}
|
|
570
|
-
if (!schemaType || typeof schemaType.cast !== "function") return value;
|
|
571
|
-
try {
|
|
572
|
-
return schemaType.cast(value);
|
|
573
|
-
} catch {
|
|
574
|
-
return value;
|
|
291
|
+
if (hints.indexHint === void 0) return;
|
|
292
|
+
if (isExpectedIndex(hints.indexHint)) {
|
|
293
|
+
assertExpectedIndex(aggregation.model(), hints.indexHint.leadingKeys);
|
|
294
|
+
return;
|
|
575
295
|
}
|
|
296
|
+
aggregation.option({ hint: hints.indexHint });
|
|
576
297
|
}
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
* don't understand (`$regex`, `$exists`, `$size`, `$elemMatch`, …) alone.
|
|
580
|
-
*/
|
|
581
|
-
function castOperatorObject(schema, field, ops) {
|
|
582
|
-
let changed = false;
|
|
583
|
-
const out = {};
|
|
584
|
-
for (const [op, operand] of Object.entries(ops)) if (SCALAR_OPS.has(op)) {
|
|
585
|
-
const next = castValue(schema, field, operand);
|
|
586
|
-
out[op] = next;
|
|
587
|
-
if (next !== operand) changed = true;
|
|
588
|
-
} else if (ARRAY_OPS.has(op) && Array.isArray(operand)) {
|
|
589
|
-
const arr = operand.map((entry) => castValue(schema, field, entry));
|
|
590
|
-
out[op] = arr;
|
|
591
|
-
if (arr.some((v, i) => v !== operand[i])) changed = true;
|
|
592
|
-
} else out[op] = operand;
|
|
593
|
-
return changed ? out : ops;
|
|
298
|
+
function isExpectedIndex(hint) {
|
|
299
|
+
return typeof hint === "object" && hint !== null && Array.isArray(hint.leadingKeys) && hint.leadingKeys.length > 0;
|
|
594
300
|
}
|
|
301
|
+
const checked = /* @__PURE__ */ new WeakMap();
|
|
595
302
|
/**
|
|
596
|
-
*
|
|
597
|
-
*
|
|
598
|
-
*
|
|
599
|
-
*
|
|
600
|
-
*
|
|
601
|
-
* in code (already correctly typed) and carry no schema paths to resolve.
|
|
602
|
-
*
|
|
603
|
-
* @example
|
|
604
|
-
* ```ts
|
|
605
|
-
* // organizationId is an ObjectId column; arc supplies the scope id as a string
|
|
606
|
-
* castFilterToSchema({ organizationId: '6a6a…' }, Order.schema)
|
|
607
|
-
* // → { organizationId: ObjectId('6a6a…') }
|
|
608
|
-
* ```
|
|
303
|
+
* Throw unless the model DECLARES an index whose key starts with `leadingKeys`, directly or after
|
|
304
|
+
* one leading scope field (a tenant / branch column the query pins by equality). Forcing such a
|
|
305
|
+
* description as a `hint` was wrong twice over: Mongo requires an index with EXACTLY that key, so a
|
|
306
|
+
* longer one failed `BadValue`; and a hint overrides the planner. The planner picks; this only
|
|
307
|
+
* guarantees the declared expectation holds. Checked once per model and key set.
|
|
609
308
|
*/
|
|
610
|
-
function
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
const
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
out[key] = arr;
|
|
619
|
-
if (arr.some((v, i) => v !== value[i])) changed = true;
|
|
620
|
-
continue;
|
|
621
|
-
}
|
|
622
|
-
if (LOGICAL_OBJECT_OPS.has(key) && isPlainObject(value)) {
|
|
623
|
-
const nested = castFilterToSchema(value, schema);
|
|
624
|
-
out[key] = nested;
|
|
625
|
-
if (nested !== value) changed = true;
|
|
626
|
-
continue;
|
|
627
|
-
}
|
|
628
|
-
if (key.startsWith("$")) {
|
|
629
|
-
out[key] = value;
|
|
630
|
-
continue;
|
|
631
|
-
}
|
|
632
|
-
if (isPlainObject(value)) {
|
|
633
|
-
const keys = Object.keys(value);
|
|
634
|
-
if (keys.length > 0 && keys.every((k) => k.startsWith("$"))) {
|
|
635
|
-
const nextOps = castOperatorObject(schema, key, value);
|
|
636
|
-
out[key] = nextOps;
|
|
637
|
-
if (nextOps !== value) changed = true;
|
|
638
|
-
continue;
|
|
639
|
-
}
|
|
640
|
-
out[key] = value;
|
|
641
|
-
continue;
|
|
642
|
-
}
|
|
643
|
-
const next = castValue(schema, key, value);
|
|
644
|
-
out[key] = next;
|
|
645
|
-
if (next !== value) changed = true;
|
|
646
|
-
}
|
|
647
|
-
return changed ? out : filter;
|
|
309
|
+
function assertExpectedIndex(model, leadingKeys) {
|
|
310
|
+
const id = leadingKeys.join(",");
|
|
311
|
+
const done = checked.get(model);
|
|
312
|
+
if (done?.has(id)) return;
|
|
313
|
+
const keys = model.schema.indexes().map(([fields]) => Object.keys(fields));
|
|
314
|
+
const startsWith = (key, at) => leadingKeys.every((k, i) => key[at + i] === k);
|
|
315
|
+
if (!keys.some((key) => startsWith(key, 0) || startsWith(key, 1))) throw new Error(`[mongokit] ${model.modelName}: an aggregation expects an index leading with (${id}), but none is declared — declared: ${keys.map((k) => `(${k.join(",")})`).join(" ") || "none"}. Declare one, or drop the hint.`);
|
|
316
|
+
checked.set(model, (done ?? /* @__PURE__ */ new Set()).add(id));
|
|
648
317
|
}
|
|
649
318
|
//#endregion
|
|
650
319
|
//#region src/actions/aggregate-ir/normalize.ts
|
|
@@ -831,11 +500,192 @@ function compileFilterToMongoExpr(input) {
|
|
|
831
500
|
*/
|
|
832
501
|
if (typeof input === "object") {
|
|
833
502
|
const keys = Object.keys(input);
|
|
503
|
+
/**
|
|
504
|
+
* Normalization is attempted FIRST, before the "all `$` keys means it is
|
|
505
|
+
* already an expression" fallback — because `{ $and: [...] }` satisfies
|
|
506
|
+
* both readings. As a query it is a conjunction of query objects; as an
|
|
507
|
+
* expression `$and` is a real operator whose operands would be those same
|
|
508
|
+
* objects, evaluated as TRUTHY. Taking the expression reading first made
|
|
509
|
+
* every `$and`/`$or` filter match all rows.
|
|
510
|
+
*
|
|
511
|
+
* Trying the query reading first disambiguates correctly: a genuine
|
|
512
|
+
* expression (`{ $eq: ['$a', 1] }`, or `$and` over expressions) fails to
|
|
513
|
+
* normalize — its operands are not field/operator objects — and falls
|
|
514
|
+
* through unchanged.
|
|
515
|
+
*/
|
|
516
|
+
const normalized = queryToFilterIr(input);
|
|
517
|
+
if (normalized) return compile(normalized);
|
|
834
518
|
if (keys.length > 0 && keys.every((k) => k.startsWith("$"))) return input;
|
|
835
|
-
throw createError(400, `mongokit/filter: \`where\`
|
|
519
|
+
throw createError(400, `mongokit/filter: could not express this \`where\` as an aggregation condition. Accepted: query syntax (\`{ field: { gte: 1 } }\`, the same language \`filter\` and \`lookup.where\` take), Filter IR (\`{ op, field, value }\`), or a MongoDB expression (\`{ \$eq: ['\$field', value] }\`). Got ${keys.length === 0 ? "an empty object" : `an object keyed by ${keys.map((k) => `'${k}'`).join(", ")}`}. \`mod\` and \`$where\` have no expression form and are refused by name. Refused rather than passed through: evaluated as an expression a plain object is always TRUE, so the filter would silently match every row.`);
|
|
836
520
|
}
|
|
837
521
|
return input;
|
|
838
522
|
}
|
|
523
|
+
/**
|
|
524
|
+
* Operators a query object may use, mapped to their Filter IR op.
|
|
525
|
+
*
|
|
526
|
+
* Exactly the set `compileFilterToMongo`'s `expandShorthands` accepts, minus
|
|
527
|
+
* the ones the IR cannot express — so this is COMPLETE over its input, not a
|
|
528
|
+
* best-effort subset. Anything outside it reaches the refusal below rather
|
|
529
|
+
* than being quietly dropped.
|
|
530
|
+
*
|
|
531
|
+
* `mod` is the one shorthand with no IR node. It is refused by name, because
|
|
532
|
+
* "translated everything except the operator you used" is the failure this
|
|
533
|
+
* whole function exists to stop.
|
|
534
|
+
*/
|
|
535
|
+
/**
|
|
536
|
+
* Query operators this translates, and the Filter IR node each becomes.
|
|
537
|
+
*
|
|
538
|
+
* Exactly the set `compileFilterToMongo`'s `expandShorthands` accepts, minus
|
|
539
|
+
* the ones the IR cannot express — so the translation is COMPLETE over its
|
|
540
|
+
* input rather than a best-effort subset. Anything outside reaches the refusal
|
|
541
|
+
* below instead of being quietly dropped.
|
|
542
|
+
*
|
|
543
|
+
* Each node is BUILT, not spread from a common shape: the IR is not uniform —
|
|
544
|
+
* `in`/`nin` carry `values`, `exists` carries `exists`, `regex` carries
|
|
545
|
+
* `pattern`/`flags`. Emitting `value` for all of them compiled without
|
|
546
|
+
* complaint and then matched nothing, which the parity suite caught and a
|
|
547
|
+
* hand-written expectation would not have.
|
|
548
|
+
*/
|
|
549
|
+
const QUERY_OPS = {
|
|
550
|
+
eq: (field, value) => ({
|
|
551
|
+
op: "eq",
|
|
552
|
+
field,
|
|
553
|
+
value
|
|
554
|
+
}),
|
|
555
|
+
ne: (field, value) => ({
|
|
556
|
+
op: "ne",
|
|
557
|
+
field,
|
|
558
|
+
value
|
|
559
|
+
}),
|
|
560
|
+
gt: (field, value) => ({
|
|
561
|
+
op: "gt",
|
|
562
|
+
field,
|
|
563
|
+
value
|
|
564
|
+
}),
|
|
565
|
+
gte: (field, value) => ({
|
|
566
|
+
op: "gte",
|
|
567
|
+
field,
|
|
568
|
+
value
|
|
569
|
+
}),
|
|
570
|
+
lt: (field, value) => ({
|
|
571
|
+
op: "lt",
|
|
572
|
+
field,
|
|
573
|
+
value
|
|
574
|
+
}),
|
|
575
|
+
lte: (field, value) => ({
|
|
576
|
+
op: "lte",
|
|
577
|
+
field,
|
|
578
|
+
value
|
|
579
|
+
}),
|
|
580
|
+
in: (field, operand) => ({
|
|
581
|
+
op: "in",
|
|
582
|
+
field,
|
|
583
|
+
values: toArray(operand)
|
|
584
|
+
}),
|
|
585
|
+
nin: (field, operand) => ({
|
|
586
|
+
op: "nin",
|
|
587
|
+
field,
|
|
588
|
+
values: toArray(operand)
|
|
589
|
+
}),
|
|
590
|
+
exists: (field, operand) => ({
|
|
591
|
+
op: "exists",
|
|
592
|
+
field,
|
|
593
|
+
exists: operand !== false
|
|
594
|
+
}),
|
|
595
|
+
regex: (field, operand) => buildRegex(field, operand)
|
|
596
|
+
};
|
|
597
|
+
for (const name of Object.keys(QUERY_OPS)) QUERY_OPS[`$${name}`] = QUERY_OPS[name];
|
|
598
|
+
function toArray(operand) {
|
|
599
|
+
return Array.isArray(operand) ? [...operand] : [operand];
|
|
600
|
+
}
|
|
601
|
+
/** `regex` accepts a string, `{ $regex, $options }`, or a real RegExp. */
|
|
602
|
+
function buildRegex(field, operand) {
|
|
603
|
+
if (operand instanceof RegExp) return operand.flags ? {
|
|
604
|
+
op: "regex",
|
|
605
|
+
field,
|
|
606
|
+
pattern: operand.source,
|
|
607
|
+
flags: operand.flags
|
|
608
|
+
} : {
|
|
609
|
+
op: "regex",
|
|
610
|
+
field,
|
|
611
|
+
pattern: operand.source
|
|
612
|
+
};
|
|
613
|
+
if (operand !== null && typeof operand === "object") {
|
|
614
|
+
const o = operand;
|
|
615
|
+
const flags = typeof o.$options === "string" ? o.$options : void 0;
|
|
616
|
+
return flags ? {
|
|
617
|
+
op: "regex",
|
|
618
|
+
field,
|
|
619
|
+
pattern: String(o.$regex ?? ""),
|
|
620
|
+
flags
|
|
621
|
+
} : {
|
|
622
|
+
op: "regex",
|
|
623
|
+
field,
|
|
624
|
+
pattern: String(o.$regex ?? "")
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
return {
|
|
628
|
+
op: "regex",
|
|
629
|
+
field,
|
|
630
|
+
pattern: String(operand)
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
const LOGICAL = {
|
|
634
|
+
$and: "and",
|
|
635
|
+
$or: "or"
|
|
636
|
+
};
|
|
637
|
+
/** `{ field: { gte: 1, lte: 9 } }` and `{ a: 1, b: 2 }` are both conjunctions. */
|
|
638
|
+
function allOf(children) {
|
|
639
|
+
if (children.length === 0) return { op: "true" };
|
|
640
|
+
if (children.length === 1) return children[0];
|
|
641
|
+
return {
|
|
642
|
+
op: "and",
|
|
643
|
+
children
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Translate a plain query object into Filter IR, or return `null` when it uses
|
|
648
|
+
* anything outside {@link QUERY_OPS}.
|
|
649
|
+
*
|
|
650
|
+
* `null` rather than a throw so the caller owns the error message — it has the
|
|
651
|
+
* context (which surface, what was passed) that makes the refusal actionable.
|
|
652
|
+
*/
|
|
653
|
+
function queryToFilterIr(query) {
|
|
654
|
+
const children = [];
|
|
655
|
+
for (const [key, value] of Object.entries(query)) {
|
|
656
|
+
const logical = LOGICAL[key];
|
|
657
|
+
if (logical) {
|
|
658
|
+
if (!Array.isArray(value)) return null;
|
|
659
|
+
const subs = [];
|
|
660
|
+
for (const entry of value) {
|
|
661
|
+
if (entry === null || typeof entry !== "object") return null;
|
|
662
|
+
const sub = queryToFilterIr(entry);
|
|
663
|
+
if (!sub) return null;
|
|
664
|
+
subs.push(sub);
|
|
665
|
+
}
|
|
666
|
+
children.push({
|
|
667
|
+
op: logical,
|
|
668
|
+
children: subs
|
|
669
|
+
});
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
if (key.startsWith("$")) return null;
|
|
673
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
674
|
+
const ops = Object.keys(value);
|
|
675
|
+
if (ops.length > 0 && ops.some((o) => o.startsWith("$") || SHORTHAND_OPS.has(o))) {
|
|
676
|
+
if (!ops.every((o) => o in QUERY_OPS)) return null;
|
|
677
|
+
for (const [op, operand] of Object.entries(value)) children.push(QUERY_OPS[op](key, operand));
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
children.push({
|
|
682
|
+
op: "eq",
|
|
683
|
+
field: key,
|
|
684
|
+
value
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
return allOf(children);
|
|
688
|
+
}
|
|
839
689
|
function compile(filter) {
|
|
840
690
|
switch (filter.op) {
|
|
841
691
|
case "true": return true;
|
|
@@ -852,7 +702,25 @@ function compile(filter) {
|
|
|
852
702
|
case "nin":
|
|
853
703
|
if (filter.values.length === 0) return true;
|
|
854
704
|
return { $not: [{ $in: [`$${filter.field}`, [...filter.values]] }] };
|
|
855
|
-
case "exists":
|
|
705
|
+
case "exists": {
|
|
706
|
+
/**
|
|
707
|
+
* SQL parity: `exists: false` matches NULL and MISSING alike (`IS
|
|
708
|
+
* NULL`); `exists: true` matches neither (`IS NOT NULL`).
|
|
709
|
+
*
|
|
710
|
+
* This was `$ne: [field, null]` / `$eq: [field, null]`, on the stated
|
|
711
|
+
* grounds that "mongo treats [null and missing] the same way for missing
|
|
712
|
+
* fields". It does not: a missing path in an expression is MISSING, not
|
|
713
|
+
* null, and `$ne: ['$absent', null]` is TRUE. So `exists: true` matched
|
|
714
|
+
* every document including the ones without the field, and
|
|
715
|
+
* `exists: false` matched none — the predicate inverted on exactly the
|
|
716
|
+
* documents it was asked about.
|
|
717
|
+
*
|
|
718
|
+
* `$type` is the only operator that distinguishes the three states, and
|
|
719
|
+
* it names both of the ones SQL folds together.
|
|
720
|
+
*/
|
|
721
|
+
const nullish = { $in: [{ $type: `$${filter.field}` }, ["missing", "null"]] };
|
|
722
|
+
return filter.exists ? { $not: [nullish] } : nullish;
|
|
723
|
+
}
|
|
856
724
|
case "like": return { $regexMatch: {
|
|
857
725
|
input: `$${filter.field}`,
|
|
858
726
|
regex: likeToRegexPattern(filter.pattern),
|
|
@@ -2092,242 +1960,6 @@ function appendLookupStages(pipeline, lookups, projection, keysetSort) {
|
|
|
2092
1960
|
if (finalProjection) pipeline.push({ $project: finalProjection });
|
|
2093
1961
|
}
|
|
2094
1962
|
//#endregion
|
|
2095
|
-
//#region src/tx-bound.ts
|
|
2096
|
-
/**
|
|
2097
|
-
* Map of session-aware method name → positional index of the `{ session }`
|
|
2098
|
-
* options bag. Keep this list in sync with the method signatures in
|
|
2099
|
-
* `Repository.ts` and the plugin method definitions. Unknown plugin
|
|
2100
|
-
* methods pass through unwrapped (callers use the standalone helper).
|
|
2101
|
-
*/
|
|
2102
|
-
const SESSION_OPTIONS_INDEX = Object.freeze({
|
|
2103
|
-
create: 1,
|
|
2104
|
-
update: 2,
|
|
2105
|
-
delete: 1,
|
|
2106
|
-
getById: 1,
|
|
2107
|
-
getAll: 1,
|
|
2108
|
-
createMany: 1,
|
|
2109
|
-
findAll: 1,
|
|
2110
|
-
getOrCreate: 2,
|
|
2111
|
-
count: 1,
|
|
2112
|
-
exists: 1,
|
|
2113
|
-
getByQuery: 1,
|
|
2114
|
-
getOne: 1,
|
|
2115
|
-
findOneAndUpdate: 2,
|
|
2116
|
-
distinct: 2,
|
|
2117
|
-
aggregate: 1,
|
|
2118
|
-
aggregatePaginate: 0,
|
|
2119
|
-
lookupPopulate: 0,
|
|
2120
|
-
upsert: 2,
|
|
2121
|
-
increment: 3,
|
|
2122
|
-
decrement: 3,
|
|
2123
|
-
multiplyField: 3,
|
|
2124
|
-
setMin: 3,
|
|
2125
|
-
setMax: 3,
|
|
2126
|
-
pushToArray: 3,
|
|
2127
|
-
pullFromArray: 3,
|
|
2128
|
-
addToSet: 3,
|
|
2129
|
-
setField: 3,
|
|
2130
|
-
unsetField: 2,
|
|
2131
|
-
renameField: 3,
|
|
2132
|
-
atomicUpdate: 2,
|
|
2133
|
-
updateMany: 2,
|
|
2134
|
-
deleteMany: 1,
|
|
2135
|
-
bulkWrite: 1,
|
|
2136
|
-
restore: 1,
|
|
2137
|
-
getDeleted: 1,
|
|
2138
|
-
addSubdocument: 3,
|
|
2139
|
-
getSubdocument: 3,
|
|
2140
|
-
updateSubdocument: 4,
|
|
2141
|
-
deleteSubdocument: 3,
|
|
2142
|
-
groupBy: 1,
|
|
2143
|
-
sum: 2,
|
|
2144
|
-
average: 2,
|
|
2145
|
-
min: 2,
|
|
2146
|
-
max: 2
|
|
2147
|
-
});
|
|
2148
|
-
/**
|
|
2149
|
-
* Build a session-threaded proxy over `outer`. The returned object has the
|
|
2150
|
-
* same method signatures, but every CRUD call auto-injects the supplied
|
|
2151
|
-
* session into the options bag. Non-CRUD properties (Model, modelName,
|
|
2152
|
-
* hook API, utility helpers) pass through.
|
|
2153
|
-
*/
|
|
2154
|
-
function createTxBoundRepo(outer, session) {
|
|
2155
|
-
return new Proxy(outer, { get(target, prop, receiver) {
|
|
2156
|
-
if (prop === "withTransaction") return () => {
|
|
2157
|
-
throw new Error("[mongokit] Nested withTransaction is not supported on a tx-bound repository. Reuse the outer `txRepo` directly, or collapse the nesting.");
|
|
2158
|
-
};
|
|
2159
|
-
const value = Reflect.get(target, prop, receiver);
|
|
2160
|
-
if (typeof value !== "function") return value;
|
|
2161
|
-
if (prop === "Model") return value;
|
|
2162
|
-
if (typeof prop === "symbol") return value.bind(target);
|
|
2163
|
-
if (prop.startsWith("_")) return value.bind(target);
|
|
2164
|
-
const optionsIndex = SESSION_OPTIONS_INDEX[prop];
|
|
2165
|
-
if (optionsIndex === void 0) return value.bind(target);
|
|
2166
|
-
return function txBoundMethod(...args) {
|
|
2167
|
-
while (args.length <= optionsIndex) args.push(void 0);
|
|
2168
|
-
const current = args[optionsIndex];
|
|
2169
|
-
if (current === void 0) args[optionsIndex] = { session };
|
|
2170
|
-
else if (typeof current === "object" && current !== null && !Array.isArray(current)) args[optionsIndex] = {
|
|
2171
|
-
...current,
|
|
2172
|
-
session
|
|
2173
|
-
};
|
|
2174
|
-
else return value.apply(target, args);
|
|
2175
|
-
return value.apply(target, args);
|
|
2176
|
-
};
|
|
2177
|
-
} });
|
|
2178
|
-
}
|
|
2179
|
-
//#endregion
|
|
2180
|
-
//#region src/transaction.ts
|
|
2181
|
-
/**
|
|
2182
|
-
* Run a callback inside a MongoDB transaction on the given connection.
|
|
2183
|
-
*
|
|
2184
|
-
* - Starts a session, runs `session.withTransaction(callback)` (which auto-retries
|
|
2185
|
-
* on `TransientTransactionError` and `UnknownTransactionCommitResult`), and
|
|
2186
|
-
* always ends the session in `finally`.
|
|
2187
|
-
* - When `allowFallback` is true and the deployment doesn't support transactions
|
|
2188
|
-
* (e.g. standalone MongoDB in dev), the callback runs once without a
|
|
2189
|
-
* transaction on the same session. `onFallback` is invoked with the original
|
|
2190
|
-
* error so hosts can log the degradation.
|
|
2191
|
-
*
|
|
2192
|
-
* @example
|
|
2193
|
-
* ```ts
|
|
2194
|
-
* import mongoose from 'mongoose';
|
|
2195
|
-
* import { withTransaction } from '@classytic/mongokit';
|
|
2196
|
-
*
|
|
2197
|
-
* await withTransaction(mongoose.connection, async (session) => {
|
|
2198
|
-
* const order = await orderRepo.create(data, { session });
|
|
2199
|
-
* await inventoryRepo.decrement(order.items, { session });
|
|
2200
|
-
* return order;
|
|
2201
|
-
* });
|
|
2202
|
-
* ```
|
|
2203
|
-
*/
|
|
2204
|
-
async function withTransaction(connection, callback, options = {}) {
|
|
2205
|
-
const session = await connection.startSession();
|
|
2206
|
-
try {
|
|
2207
|
-
return await session.withTransaction(() => callback(session), options.transactionOptions);
|
|
2208
|
-
} catch (error) {
|
|
2209
|
-
const err = error;
|
|
2210
|
-
if (options.allowFallback && isTransactionUnsupported(err)) {
|
|
2211
|
-
options.onFallback?.(err);
|
|
2212
|
-
return await callback(session);
|
|
2213
|
-
}
|
|
2214
|
-
throw err;
|
|
2215
|
-
} finally {
|
|
2216
|
-
await session.endSession();
|
|
2217
|
-
}
|
|
2218
|
-
}
|
|
2219
|
-
/**
|
|
2220
|
-
* Multi-repo transactional batch — every repo in `repos` becomes a
|
|
2221
|
-
* session-bound proxy inside the callback. Eliminates the per-call
|
|
2222
|
-
* `{ session }` threading that was repeated across ~20 call sites
|
|
2223
|
-
* (be-prod outbox writes, order placement, transfer source/dest pairs,
|
|
2224
|
-
* payrun saga steps).
|
|
2225
|
-
*
|
|
2226
|
-
* Same retry + fallback semantics as `withTransaction`: the
|
|
2227
|
-
* `transactionOptions` / `allowFallback` / `onFallback` knobs apply.
|
|
2228
|
-
*
|
|
2229
|
-
* Each property of the input `repos` map is rebound via the
|
|
2230
|
-
* `createTxBoundRepo` proxy — every CRUD method on the bound repo
|
|
2231
|
-
* auto-injects `session` into its options bag, including
|
|
2232
|
-
* `claim` / `claimVersion` / `findOneAndUpdate` and plugin-contributed
|
|
2233
|
-
* methods. Non-CRUD properties (Model, modelName, hook engine,
|
|
2234
|
-
* idField) pass through to the underlying repo. Nested
|
|
2235
|
-
* `boundRepo.withTransaction(...)` throws — reuse the outer bound
|
|
2236
|
-
* repos.
|
|
2237
|
-
*
|
|
2238
|
-
* @example Order placement across three repos in one transaction
|
|
2239
|
-
* ```ts
|
|
2240
|
-
* import { batchTransaction } from '@classytic/mongokit';
|
|
2241
|
-
*
|
|
2242
|
-
* const order = await batchTransaction(
|
|
2243
|
-
* mongoose.connection,
|
|
2244
|
-
* { orders: orderRepo, events: eventRepo, inventory: inventoryRepo },
|
|
2245
|
-
* async ({ orders, events, inventory }) => {
|
|
2246
|
-
* const created = await orders.create(orderData); // session auto-injected
|
|
2247
|
-
* await events.create({ type: 'order.placed', orderId: created._id });
|
|
2248
|
-
* await inventory.claim(skuId, { from: 'available', to: 'reserved' });
|
|
2249
|
-
* return created;
|
|
2250
|
-
* },
|
|
2251
|
-
* );
|
|
2252
|
-
* ```
|
|
2253
|
-
*
|
|
2254
|
-
* @example With fallback for standalone-mongo dev environments
|
|
2255
|
-
* ```ts
|
|
2256
|
-
* await batchTransaction(
|
|
2257
|
-
* mongoose.connection,
|
|
2258
|
-
* { orders, events },
|
|
2259
|
-
* async ({ orders, events }) => { ... },
|
|
2260
|
-
* { allowFallback: true, onFallback: (err) => log.warn(err) },
|
|
2261
|
-
* );
|
|
2262
|
-
* ```
|
|
2263
|
-
*
|
|
2264
|
-
* @param connection - Mongoose connection (or anything with
|
|
2265
|
-
* `startSession()`). Single source of session truth — every bound
|
|
2266
|
-
* repo shares this session.
|
|
2267
|
-
* @param repos - Map of repo instances to rebind. Keys become the
|
|
2268
|
-
* property names on the callback's argument.
|
|
2269
|
-
* @param callback - Receives the bound repo map. Return value flows
|
|
2270
|
-
* through to the outer `Promise`.
|
|
2271
|
-
*/
|
|
2272
|
-
async function batchTransaction(connection, repos, callback, options = {}) {
|
|
2273
|
-
return withTransaction(connection, async (session) => {
|
|
2274
|
-
const bound = {};
|
|
2275
|
-
for (const key of Object.keys(repos)) {
|
|
2276
|
-
const repo = repos[key];
|
|
2277
|
-
bound[key] = createTxBoundRepo(repo, session);
|
|
2278
|
-
}
|
|
2279
|
-
return callback(bound);
|
|
2280
|
-
}, options);
|
|
2281
|
-
}
|
|
2282
|
-
/**
|
|
2283
|
-
* Detect whether an error indicates the MongoDB deployment does not support
|
|
2284
|
-
* multi-document transactions (standalone server, older topology, etc.).
|
|
2285
|
-
*
|
|
2286
|
-
* Checks MongoDB error codes first — 263 (standalone) and 20 (unsupported
|
|
2287
|
-
* topology) — with a message-matching fallback for edge cases surfaced by
|
|
2288
|
-
* driver versions that throw before the proper code lands.
|
|
2289
|
-
*
|
|
2290
|
-
* **Driver-version drift:** modern mongoose / mongodb-driver versions hit
|
|
2291
|
-
* the standalone case via the retryable-writes precondition rather than
|
|
2292
|
-
* the transaction precondition (`retryWrites=true` is on by default,
|
|
2293
|
-
* standalone Mongo rejects it before the transaction is even attempted).
|
|
2294
|
-
* So we accept that message as equivalent — the underlying topology
|
|
2295
|
-
* problem is the same, and the fallback semantics are correct either way.
|
|
2296
|
-
*/
|
|
2297
|
-
function isTransactionUnsupported(error) {
|
|
2298
|
-
const code = error.code;
|
|
2299
|
-
if (code === 263 || code === 20) return true;
|
|
2300
|
-
const message = (error.message || "").toLowerCase();
|
|
2301
|
-
return message.includes("transaction numbers are only allowed on a replica set member") || message.includes("transaction is not supported") || message.includes("does not support retryable writes");
|
|
2302
|
-
}
|
|
2303
|
-
/**
|
|
2304
|
-
* PROACTIVELY report whether a connection's deployment supports multi-document
|
|
2305
|
-
* transactions — the read-side companion to {@link isTransactionUnsupported}
|
|
2306
|
-
* (which classifies a FAILED attempt). Lets a caller skip a doomed transaction
|
|
2307
|
-
* attempt on standalone dev Mongo rather than starting one just to catch the
|
|
2308
|
-
* error.
|
|
2309
|
-
*
|
|
2310
|
-
* Delegates to {@link resolveTransactionSupport} so mongokit has exactly ONE
|
|
2311
|
-
* topology reader. That also fixes a false NEGATIVE this function used to
|
|
2312
|
-
* have: it tested `description.type !== 'Single'`, but a single-node replica
|
|
2313
|
-
* set reached with `directConnection` is `Single` with a server type of
|
|
2314
|
-
* `RSPrimary` and runs transactions fine. The shared reader looks at the
|
|
2315
|
-
* SERVER descriptions first.
|
|
2316
|
-
*
|
|
2317
|
-
* **`unknown` is optimistic HERE and pessimistic in `capabilities.ts` — the
|
|
2318
|
-
* difference is deliberate.** This answers "should I bother ATTEMPTING a
|
|
2319
|
-
* transaction?", where guessing yes costs one caught error (`allowFallback`
|
|
2320
|
-
* still recovers). The capability descriptor answers "may this deployment be
|
|
2321
|
-
* trusted with money?", where guessing yes is the bug class this repo keeps
|
|
2322
|
-
* hitting (AGENTS.md FAIL LOUD rule 3). Do not "unify" them.
|
|
2323
|
-
*
|
|
2324
|
-
* Accepts a Mongoose `Connection` (or anything exposing `getClient()`/`client`
|
|
2325
|
-
* with a driver `topology`), so it stays decoupled from mongoose types.
|
|
2326
|
-
*/
|
|
2327
|
-
function supportsTransactions(connection) {
|
|
2328
|
-
return resolveTransactionSupport(connection) !== "no";
|
|
2329
|
-
}
|
|
2330
|
-
//#endregion
|
|
2331
1963
|
//#region src/Repository.ts
|
|
2332
1964
|
/**
|
|
2333
1965
|
* Validates that an update patch is homogeneous — either all top-level
|
|
@@ -3272,29 +2904,6 @@ var Repository = class extends RepositoryBase {
|
|
|
3272
2904
|
await closeStream();
|
|
3273
2905
|
}
|
|
3274
2906
|
}
|
|
3275
|
-
/**
|
|
3276
|
-
* Unified pagination - auto-detects offset vs keyset based on params
|
|
3277
|
-
*
|
|
3278
|
-
* Auto-detection logic:
|
|
3279
|
-
* - If params has 'cursor' or 'after' → uses keyset pagination (stream)
|
|
3280
|
-
* - If params has 'pagination' or 'page' → uses offset pagination (paginate)
|
|
3281
|
-
* - Else → defaults to offset pagination with page=1
|
|
3282
|
-
*
|
|
3283
|
-
* @example
|
|
3284
|
-
* // Offset pagination (page-based)
|
|
3285
|
-
* await repo.getAll({ page: 1, limit: 50, filters: { status: 'active' } });
|
|
3286
|
-
* await repo.getAll({ pagination: { page: 2, limit: 20 } });
|
|
3287
|
-
*
|
|
3288
|
-
* // Keyset pagination (cursor-based)
|
|
3289
|
-
* await repo.getAll({ cursor: 'eyJ2Ij...', limit: 50 });
|
|
3290
|
-
* await repo.getAll({ after: 'eyJ2Ij...', sort: { createdAt: -1 } });
|
|
3291
|
-
*
|
|
3292
|
-
* // Simple query (defaults to page 1)
|
|
3293
|
-
* await repo.getAll({ filters: { status: 'active' } });
|
|
3294
|
-
*
|
|
3295
|
-
* // Skip cache for fresh data
|
|
3296
|
-
* await repo.getAll({ filters: { status: 'active' } }, { skipCache: true });
|
|
3297
|
-
*/
|
|
3298
2907
|
async getAll(params = {}, options = {}) {
|
|
3299
2908
|
const normalizedParams = {
|
|
3300
2909
|
...params,
|
|
@@ -3862,6 +3471,11 @@ var Repository = class extends RepositoryBase {
|
|
|
3862
3471
|
[effectiveIdField]: id,
|
|
3863
3472
|
[stateField]: fromSpec
|
|
3864
3473
|
};
|
|
3474
|
+
const seeded = options.target;
|
|
3475
|
+
if (seeded !== void 0) {
|
|
3476
|
+
if (String(seeded[effectiveIdField]) !== String(id)) throw new Error(`[claim] options.target is ${effectiveIdField}=${String(seeded[effectiveIdField])}, not the claimed ${String(id)}`);
|
|
3477
|
+
for (const key of [this.versionField, "updatedAt"]) if (seeded[key] !== void 0 && !(key in filter)) filter[key] = seeded[key];
|
|
3478
|
+
}
|
|
3865
3479
|
assertNoMixedPatchShape("claim", patch);
|
|
3866
3480
|
const patchKeys = Object.keys(patch);
|
|
3867
3481
|
const operatorPatchKeys = patchKeys.filter((k) => k.startsWith("$"));
|
|
@@ -4716,9 +4330,9 @@ var Repository = class extends RepositoryBase {
|
|
|
4716
4330
|
const appendLookupStages$2 = (pipeline) => appendLookupStages(pipeline, lookups, projection, isKeyset && sort ? this._parseSort(sort) : void 0);
|
|
4717
4331
|
if (isKeyset && sort) {
|
|
4718
4332
|
const parsedSort = this._parseSort(sort);
|
|
4719
|
-
const { validateKeysetSort } = await import("./PaginationEngine-
|
|
4720
|
-
const { encodeCursor, resolveCursorFilter } = await import("./PaginationEngine-
|
|
4721
|
-
const { getPrimaryField } = await import("./PaginationEngine-
|
|
4333
|
+
const { validateKeysetSort } = await import("./PaginationEngine-Dvl8jcRY.mjs").then((n) => n.n);
|
|
4334
|
+
const { encodeCursor, resolveCursorFilter } = await import("./PaginationEngine-Dvl8jcRY.mjs").then((n) => n.r);
|
|
4335
|
+
const { getPrimaryField } = await import("./PaginationEngine-Dvl8jcRY.mjs").then((n) => n.n);
|
|
4722
4336
|
const normalizedSort = validateKeysetSort(parsedSort, this._pagination.config.strictKeysetSortFields);
|
|
4723
4337
|
const cursorVersion = this._pagination.config.cursorVersion ?? 1;
|
|
4724
4338
|
const minCursorVersion = this._pagination.config.minCursorVersion ?? 1;
|
|
@@ -4945,11 +4559,51 @@ var Repository = class extends RepositoryBase {
|
|
|
4945
4559
|
async _buildContext(operation, options) {
|
|
4946
4560
|
throwIfAborted(options.signal);
|
|
4947
4561
|
const normalized = this._normalizeFilterSlots(options);
|
|
4948
|
-
const base = await super._buildContext(operation,
|
|
4562
|
+
const base = await super._buildContext(operation, {
|
|
4563
|
+
...normalized,
|
|
4564
|
+
...this._sharedReadSeams(operation, normalized)
|
|
4565
|
+
});
|
|
4949
4566
|
this._guardDistribution(operation, base);
|
|
4950
4567
|
return base;
|
|
4951
4568
|
}
|
|
4952
4569
|
/**
|
|
4570
|
+
* `memo` and (for an id-addressed op) `loadTarget` — attached before the before-hooks
|
|
4571
|
+
* run so every hook of one operation shares one read. A caller that has just read the
|
|
4572
|
+
* document in the same session passes it as `target`, and nothing is read at all.
|
|
4573
|
+
*/
|
|
4574
|
+
_sharedReadSeams(operation, options) {
|
|
4575
|
+
const store = /* @__PURE__ */ new Map();
|
|
4576
|
+
const memo = (key, load) => {
|
|
4577
|
+
let pending = store.get(key);
|
|
4578
|
+
if (!pending) {
|
|
4579
|
+
pending = Promise.resolve().then(load);
|
|
4580
|
+
store.set(key, pending);
|
|
4581
|
+
pending.catch(() => store.delete(key));
|
|
4582
|
+
}
|
|
4583
|
+
return pending;
|
|
4584
|
+
};
|
|
4585
|
+
const id = options.id;
|
|
4586
|
+
if (!OP_REGISTRY[operation]?.hasIdContext || id === void 0) return { memo };
|
|
4587
|
+
const seeded = options.target;
|
|
4588
|
+
const idField = options.idField ?? this.idField;
|
|
4589
|
+
const session = options.session;
|
|
4590
|
+
const supplied = options.query ?? {};
|
|
4591
|
+
const Model = this.Model;
|
|
4592
|
+
function loadTarget() {
|
|
4593
|
+
if (!this || typeof this !== "object") throw new Error("context.loadTarget() must be called as a method (ctx.loadTarget()) — it reads the operation tenant scope");
|
|
4594
|
+
const query = this.query ?? {};
|
|
4595
|
+
const policyScope = Object.fromEntries(Object.entries(query).filter(([k]) => !(k in supplied)));
|
|
4596
|
+
return memo("mongokit:target", async () => seeded !== void 0 ? seeded : await Model.findOne({
|
|
4597
|
+
...policyScope,
|
|
4598
|
+
[idField]: id
|
|
4599
|
+
}, null, { session }).lean());
|
|
4600
|
+
}
|
|
4601
|
+
return {
|
|
4602
|
+
memo,
|
|
4603
|
+
loadTarget
|
|
4604
|
+
};
|
|
4605
|
+
}
|
|
4606
|
+
/**
|
|
4953
4607
|
* Feed the resolved filter of a guarded verb to the distribution guard.
|
|
4954
4608
|
* The slot follows `OP_REGISTRY[op].policyKey` (`query` vs `filters`);
|
|
4955
4609
|
* the portable aggregate verbs ALSO carry the caller's filter on
|
|
@@ -5196,4 +4850,4 @@ var Repository = class extends RepositoryBase {
|
|
|
5196
4850
|
}
|
|
5197
4851
|
};
|
|
5198
4852
|
//#endregion
|
|
5199
|
-
export {
|
|
4853
|
+
export { MUTATING_OPERATIONS as a, operationsByPolicyKey as c, ALL_OPERATIONS as i, createMongoPurgePort as l, Repository as n, OP_REGISTRY as o, AggregationBuilder as r, READ_OPERATIONS as s, HOOK_PRIORITY$1 as t };
|