@web-ts-toolkit/moo 0.43.0 → 0.47.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/index.js CHANGED
@@ -92,6 +92,121 @@ var resolveFilter = (value, document) => {
92
92
  }
93
93
  return result;
94
94
  };
95
+ var isNonEmptyString = (value) => typeof value === "string" && value.trim().length > 0;
96
+ var isEmptyFilterObject = (value) => Object.keys(value).length === 0;
97
+ var EMPTY_FULL_FILTER_MESSAGE = "cascadeDeletePlugin rejects empty foreignFilter by default because {} would match every document in the dependent collection; provide a non-empty filter object. Broad deletes are not implicitly enabled.";
98
+ var DEFAULT_MAX_CONCURRENCY = 8;
99
+ var DEFAULT_BATCH_SIZE = 100;
100
+ var assertPositiveIntOption = (value, name, modelName) => {
101
+ if (value === void 0) return;
102
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
103
+ throw new Error(
104
+ `cascadeDeletePlugin for model "${modelName}" requires \`${name}\` to be an integer >= 1 when provided`
105
+ );
106
+ }
107
+ };
108
+ var assertPluginOptions = (options) => {
109
+ if (!(0, import_utils2.isPlainObject)(options)) {
110
+ throw new Error("cascadeDeletePlugin requires options with a non-empty `model` name");
111
+ }
112
+ const { model, localField, foreignField, foreignFilter, extraForeignFilter } = options;
113
+ if (!isNonEmptyString(model)) {
114
+ throw new Error("cascadeDeletePlugin requires a non-empty `model` name");
115
+ }
116
+ if (localField !== void 0 && !isNonEmptyString(localField)) {
117
+ throw new Error("cascadeDeletePlugin requires `localField` to be a non-empty string when provided");
118
+ }
119
+ assertPositiveIntOption(options.maxConcurrency, "maxConcurrency", model);
120
+ assertPositiveIntOption(options.batchSize, "batchSize", model);
121
+ if (foreignField !== void 0 && !isNonEmptyString(foreignField)) {
122
+ throw new Error("cascadeDeletePlugin requires `foreignField` to be a non-empty string when provided");
123
+ }
124
+ if (foreignFilter !== void 0) {
125
+ if (!(0, import_utils2.isFunction)(foreignFilter) && !(0, import_utils2.isPlainObject)(foreignFilter)) {
126
+ throw new Error(
127
+ `cascadeDeletePlugin invalid \`foreignFilter\` for model "${model}": expected a plain filter object or a resolver function`
128
+ );
129
+ }
130
+ if ((0, import_utils2.isPlainObject)(foreignFilter) && isEmptyFilterObject(foreignFilter)) {
131
+ throw new Error(`cascadeDeletePlugin ${EMPTY_FULL_FILTER_MESSAGE}`);
132
+ }
133
+ } else if (!isNonEmptyString(localField) || !isNonEmptyString(foreignField)) {
134
+ throw new Error(
135
+ `cascadeDeletePlugin for model "${model}" requires either \`foreignFilter\` or both \`localField\` and \`foreignField\``
136
+ );
137
+ }
138
+ if (extraForeignFilter !== void 0) {
139
+ if (!(0, import_utils2.isFunction)(extraForeignFilter) && !(0, import_utils2.isPlainObject)(extraForeignFilter)) {
140
+ throw new Error(
141
+ `cascadeDeletePlugin invalid \`extraForeignFilter\` for model "${model}": expected a plain filter object or a resolver function`
142
+ );
143
+ }
144
+ }
145
+ };
146
+ var assertFullFilterNotEmpty = (filter, modelName) => {
147
+ if (isEmptyFilterObject(filter)) {
148
+ throw new Error(`cascadeDeletePlugin for model "${modelName}" ${EMPTY_FULL_FILTER_MESSAGE}`);
149
+ }
150
+ };
151
+ var isMissingScalarKey = (value) => {
152
+ if (value === void 0 || value === null) return true;
153
+ if (typeof value === "string" && value.trim().length === 0) return true;
154
+ return false;
155
+ };
156
+ var unwrapKeyValue = (value) => {
157
+ if ((0, import_utils2.isPlainObject)(value)) {
158
+ const candidate = value._id;
159
+ if (candidate !== void 0 && candidate !== null) return candidate;
160
+ }
161
+ if ((0, import_utils2.isObject)(value)) {
162
+ try {
163
+ const candidate = value._id;
164
+ if (candidate !== void 0 && candidate !== null) return candidate;
165
+ } catch {
166
+ }
167
+ }
168
+ return value;
169
+ };
170
+ var normalizeKeyList = (localValue) => {
171
+ if (Array.isArray(localValue)) {
172
+ const keys = [];
173
+ for (const entry of localValue) {
174
+ const unwrapped2 = unwrapKeyValue(entry);
175
+ if (!isMissingScalarKey(unwrapped2)) keys.push(unwrapped2);
176
+ }
177
+ return keys.length > 0 ? keys : null;
178
+ }
179
+ const unwrapped = unwrapKeyValue(localValue);
180
+ if (isMissingScalarKey(unwrapped)) return null;
181
+ return [unwrapped];
182
+ };
183
+ var isPathDeselected = (owner, path) => {
184
+ try {
185
+ const candidate = owner;
186
+ if (typeof candidate.$isSelected === "function") {
187
+ const result = candidate.$isSelected(path);
188
+ if (typeof result === "boolean") return result === false;
189
+ }
190
+ if (typeof candidate.isSelected === "function") {
191
+ const result = candidate.isSelected(path);
192
+ if (typeof result === "boolean") return result === false;
193
+ }
194
+ } catch {
195
+ }
196
+ return false;
197
+ };
198
+ var buildRelationshipPredicate = (foreignField, localValue) => {
199
+ const keys = normalizeKeyList(localValue);
200
+ if (!keys) return null;
201
+ if (Array.isArray(localValue)) {
202
+ return { [foreignField]: { $in: keys } };
203
+ }
204
+ return { [foreignField]: keys[0] };
205
+ };
206
+ var combineWithRelationship = (relationship, extra) => {
207
+ if (!extra || isEmptyFilterObject(extra)) return relationship;
208
+ return { $and: [relationship, extra] };
209
+ };
95
210
  var applyQueryOptions = (query, options) => {
96
211
  let builder = query;
97
212
  if (options.select) {
@@ -106,8 +221,81 @@ var applyQueryOptions = (query, options) => {
106
221
  if (options.lean) {
107
222
  builder = builder.lean();
108
223
  }
224
+ if (options.session) {
225
+ builder = builder.session(options.session);
226
+ }
109
227
  return builder;
110
228
  };
229
+ var getOwningConnection = (owner) => {
230
+ const candidate = owner?.collection?.conn ?? owner?.constructor?.collection?.conn ?? null;
231
+ if (candidate && typeof candidate.model === "function") {
232
+ return candidate;
233
+ }
234
+ return import_mongoose3.default;
235
+ };
236
+ var getBoundSession = (owner) => {
237
+ try {
238
+ const session = owner?.$session?.() ?? null;
239
+ return session ?? null;
240
+ } catch {
241
+ return null;
242
+ }
243
+ };
244
+ var resolveExecutionContext = (owner, targetModelName, explicitSession) => {
245
+ const session = explicitSession ?? getBoundSession(owner) ?? null;
246
+ const connection = getOwningConnection(owner);
247
+ const Target = connection.model(targetModelName);
248
+ return { connection, Target, session };
249
+ };
250
+ var isInTransaction = (session) => {
251
+ try {
252
+ return typeof session?.inTransaction === "function" ? session.inTransaction() : false;
253
+ } catch {
254
+ return false;
255
+ }
256
+ };
257
+ var getSessionClient = (session) => {
258
+ if (!session) return null;
259
+ return session.client ?? null;
260
+ };
261
+ var getConnectionClient = (connection) => {
262
+ try {
263
+ if (connection === import_mongoose3.default) {
264
+ return import_mongoose3.default.connection.getClient?.() ?? null;
265
+ }
266
+ return connection.getClient?.() ?? null;
267
+ } catch {
268
+ return null;
269
+ }
270
+ };
271
+ var getTopologyType = (client) => {
272
+ try {
273
+ const topology = client?.topology;
274
+ const type = topology?.description?.type;
275
+ return typeof type === "string" ? type : null;
276
+ } catch {
277
+ return null;
278
+ }
279
+ };
280
+ var assertTransactionSupported = (context) => {
281
+ const { connection, session } = context;
282
+ if (!session) return;
283
+ const sessionClient = getSessionClient(session);
284
+ const connectionClient = getConnectionClient(connection);
285
+ if (sessionClient && connectionClient && sessionClient !== connectionClient) {
286
+ throw new Error(
287
+ "cascadeDeletePlugin does not support sessions bound to a different connection/client; pass a session from the owning model connection"
288
+ );
289
+ }
290
+ if (isInTransaction(session)) {
291
+ const topologyType = getTopologyType(sessionClient);
292
+ if (topologyType === "Single") {
293
+ throw new Error(
294
+ "cascadeDeletePlugin does not support transactions on a standalone MongoDB deployment; use a replica set or sharded cluster, or delete without a transaction"
295
+ );
296
+ }
297
+ }
298
+ };
111
299
  var supportsDeleteOne = (value) => (0, import_utils2.isObject)(value) && typeof value.deleteOne === "function";
112
300
  var supportsRemove = (value) => (0, import_utils2.isObject)(value) && typeof value.remove === "function";
113
301
  var mergeResults = (previousResults, modelName, currentResults) => {
@@ -120,56 +308,189 @@ var mergeResults = (previousResults, modelName, currentResults) => {
120
308
  [modelName]: currentResults
121
309
  };
122
310
  };
311
+ var resolveDependentFilter = (owner, config) => {
312
+ const { model, localField, foreignField, foreignFilter, extraForeignFilter } = config;
313
+ const document = owner.toObject({ virtuals: false });
314
+ if (foreignFilter !== void 0) {
315
+ const resolved = resolveFilter(foreignFilter, document);
316
+ if (resolved === null) return null;
317
+ assertFullFilterNotEmpty(resolved, model);
318
+ return resolved;
319
+ }
320
+ if (localField && foreignField) {
321
+ const localValue = owner.get(localField);
322
+ if (normalizeKeyList(localValue) === null) {
323
+ if (isPathDeselected(owner, localField)) {
324
+ throw new Error(
325
+ `cascadeDeletePlugin for model "${model}" cannot resolve dependents: localField "${localField}" was not selected on the document; load the document with that path projected instead of assuming an empty relation`
326
+ );
327
+ }
328
+ return null;
329
+ }
330
+ const relationship = buildRelationshipPredicate(foreignField, localValue);
331
+ if (!relationship) return null;
332
+ const extraFilter = resolveFilter(extraForeignFilter, document);
333
+ return combineWithRelationship(relationship, extraFilter);
334
+ }
335
+ return null;
336
+ };
337
+ var resolveTraversalLimits = (options) => ({
338
+ maxConcurrency: typeof options.maxConcurrency === "number" ? options.maxConcurrency : DEFAULT_MAX_CONCURRENCY,
339
+ batchSize: typeof options.batchSize === "number" ? options.batchSize : DEFAULT_BATCH_SIZE
340
+ });
341
+ var extractIdList = (rows) => rows.map((row) => {
342
+ if ((0, import_utils2.isPlainObject)(row)) return row._id;
343
+ if ((0, import_utils2.isObject)(row)) {
344
+ try {
345
+ const candidate = row._id;
346
+ if (candidate !== void 0) return candidate;
347
+ } catch {
348
+ }
349
+ }
350
+ return row;
351
+ });
352
+ var fetchDependentIdPage = async (Target, filter, session, lastId, batchSize) => {
353
+ const paged = lastId === void 0 ? filter : { $and: [filter, { _id: { $gt: lastId } }] };
354
+ let query = Target.find(paged).select("_id").sort({ _id: 1 }).limit(batchSize).lean();
355
+ if (session) query = query.session(session);
356
+ const rows = await query;
357
+ return extractIdList(Array.isArray(rows) ? rows : []);
358
+ };
359
+ var loadHydratedDependent = async (Target, id, session) => {
360
+ const query = Target.findById(id);
361
+ const result = session ? await query.session(session) : await query;
362
+ return result ?? null;
363
+ };
364
+ var runWithConcurrencyLimit = async (items, limit, task) => {
365
+ if (items.length === 0) return;
366
+ const workerCount = Math.max(1, Math.min(limit, items.length));
367
+ let next = 0;
368
+ let failed = false;
369
+ let firstError = null;
370
+ const workers = Array.from({ length: workerCount }, () => {
371
+ const run = async () => {
372
+ while (true) {
373
+ if (failed) return;
374
+ const index = next;
375
+ next += 1;
376
+ if (index >= items.length) return;
377
+ try {
378
+ await task(items[index]);
379
+ } catch (error) {
380
+ if (!failed) {
381
+ failed = true;
382
+ firstError = error;
383
+ }
384
+ throw error;
385
+ }
386
+ }
387
+ };
388
+ return run();
389
+ });
390
+ try {
391
+ await Promise.all(workers);
392
+ } catch {
393
+ throw firstError;
394
+ }
395
+ };
123
396
  function cascadeDeletePlugin(schema, options) {
397
+ assertPluginOptions(options);
124
398
  const { model, localField, foreignField, foreignFilter, extraForeignFilter } = options ?? {};
399
+ const filterConfig = {
400
+ model,
401
+ localField,
402
+ foreignField,
403
+ foreignFilter,
404
+ extraForeignFilter
405
+ };
406
+ const traversalLimits = resolveTraversalLimits(options ?? {});
125
407
  const findDependents = async function(queryOptions) {
126
- const Target = import_mongoose3.default.model(model);
127
- let query = null;
128
- const document = this.toObject({ virtuals: false });
129
- if (foreignFilter) {
130
- query = resolveFilter(foreignFilter, document);
131
- } else if (localField && foreignField) {
132
- const localValue = this.get(localField);
133
- const extraFilter = resolveFilter(extraForeignFilter, document) ?? {};
134
- query = {
135
- [foreignField]: Array.isArray(localValue) ? { $in: localValue } : localValue,
136
- ...extraFilter
137
- };
138
- }
408
+ const context = resolveExecutionContext(this, model, queryOptions?.session ?? null);
409
+ const { Target, session } = context;
410
+ const query = resolveDependentFilter(this, filterConfig);
139
411
  if (!query || !(0, import_utils2.isPlainObject)(query)) {
140
412
  return [];
141
413
  }
142
- return await applyQueryOptions(Target.find(query), queryOptions);
414
+ const mergedOptions = session ? { ...queryOptions, session } : queryOptions;
415
+ return await applyQueryOptions(Target.find(query), mergedOptions);
143
416
  };
144
417
  const findOrphans = async function(queryOptions) {
145
418
  if (!localField || !foreignField) return null;
146
- const Target = import_mongoose3.default.model(model);
419
+ const context = resolveExecutionContext(this, model, queryOptions?.session ?? null);
420
+ const { Target, session } = context;
147
421
  const schemaValue = Target.schema.obj[foreignField];
148
422
  if (!schemaValue) return null;
149
423
  const isMyRef = isReference(schemaValue, this.modelName);
150
424
  if (!isMyRef) return null;
151
- const ids = await this.distinct("_id");
152
- const extraFilter = resolveFilter(extraForeignFilter) ?? {};
153
- const query = {
154
- [foreignField]: { $not: { $in: ids } },
155
- ...extraFilter
425
+ const ids = session != null ? await this.distinct("_id", {}, { session }) : await this.distinct("_id");
426
+ const extraFilter = resolveFilter(extraForeignFilter);
427
+ const relationship = {
428
+ [foreignField]: { $not: { $in: ids } }
156
429
  };
157
- return await applyQueryOptions(Target.find(query), queryOptions);
430
+ const query = combineWithRelationship(
431
+ relationship,
432
+ extraFilter
433
+ );
434
+ const mergedOptions = session ? { ...queryOptions, session } : queryOptions;
435
+ return await applyQueryOptions(Target.find(query), mergedOptions);
158
436
  };
159
- const deleteDependents = async function() {
160
- const documents = await findDependents.call(this, { select: "_id" });
161
- await Promise.all(
162
- documents.map((document) => {
163
- if (supportsDeleteOne(document)) {
164
- return document.deleteOne();
165
- }
166
- if (supportsRemove(document)) {
167
- return document.remove();
437
+ const deleteDependents = async function(operationOptions) {
438
+ const context = resolveExecutionContext(this, model, operationOptions?.session ?? null);
439
+ assertTransactionSupported(context);
440
+ const { Target, session } = context;
441
+ const filter = resolveDependentFilter(this, filterConfig);
442
+ if (!filter || !(0, import_utils2.isPlainObject)(filter)) {
443
+ return;
444
+ }
445
+ const deleteSingle = async (document) => {
446
+ if (session != null && typeof document.$session === "function") {
447
+ try {
448
+ await document.$session?.(session);
449
+ } catch {
168
450
  }
169
- return Promise.resolve();
170
- })
171
- );
451
+ }
452
+ if (supportsDeleteOne(document)) {
453
+ return session != null ? document.deleteOne({ session }) : document.deleteOne();
454
+ }
455
+ if (supportsRemove(document)) {
456
+ return session != null ? document.remove({ session }) : document.remove();
457
+ }
458
+ return Promise.resolve();
459
+ };
460
+ const deleteById = async (id) => {
461
+ const hydrated = await loadHydratedDependent(Target, id, session);
462
+ if (!hydrated) return;
463
+ await deleteSingle(hydrated);
464
+ };
465
+ const effectiveConcurrency = session != null && isInTransaction(session) ? 1 : traversalLimits.maxConcurrency;
466
+ const seen = /* @__PURE__ */ new Set();
467
+ let lastId;
468
+ let firstPage = true;
469
+ for (; ; ) {
470
+ const ids = await fetchDependentIdPage(
471
+ Target,
472
+ filter,
473
+ session,
474
+ firstPage ? void 0 : lastId,
475
+ traversalLimits.batchSize
476
+ );
477
+ firstPage = false;
478
+ if (ids.length === 0) return;
479
+ const fresh = ids.filter((id) => {
480
+ const key = typeof id === "object" && id !== null ? String(id) : `${typeof id}:${String(id)}`;
481
+ if (seen.has(key)) return false;
482
+ seen.add(key);
483
+ return true;
484
+ });
485
+ await runWithConcurrencyLimit(fresh, effectiveConcurrency, deleteById);
486
+ lastId = ids[ids.length - 1];
487
+ if (ids.length < traversalLimits.batchSize) return;
488
+ }
172
489
  };
490
+ schema.pre("deleteOne", { document: true, query: false }, async function preDeleteOne(...args) {
491
+ const explicitSession = args[1]?.session ?? args[0]?.session ?? null;
492
+ assertTransactionSupported(resolveExecutionContext(this, model, explicitSession));
493
+ });
173
494
  schema.post("deleteOne", { document: true, query: false }, async function postDeleteOne() {
174
495
  await deleteDependents.call(this);
175
496
  });
@@ -214,36 +535,44 @@ function newDocumentPlugin(schema, options) {
214
535
  }
215
536
 
216
537
  // src/schema.ts
217
- var uniqueNullableString = (field, options = {}) => ({
218
- type: String,
219
- index: { unique: true, partialFilterExpression: { [field]: { $type: "string" } } },
220
- trim: true,
221
- default: null,
222
- ...options
223
- });
224
- var uniqueEmptiableString = (field, options = {}) => ({
225
- type: String,
226
- index: {
227
- unique: true,
228
- partialFilterExpression: { [field]: { $type: "string", $gt: "" } }
229
- },
230
- trim: true,
231
- default: null,
232
- ...options
233
- });
538
+ var uniqueNullableString = (field, options) => (
539
+ // The runtime spread applies overrides over the defaults; the cast expresses
540
+ // that override-wins merge because TypeScript cannot narrow a generic spread.
541
+ {
542
+ type: String,
543
+ index: { unique: true, partialFilterExpression: { [field]: { $type: "string" } } },
544
+ trim: true,
545
+ default: null,
546
+ ...options
547
+ }
548
+ );
549
+ var uniqueEmptiableString = (field, options) => (
550
+ // Same override-wins spread contract as uniqueNullableString above.
551
+ {
552
+ type: String,
553
+ index: {
554
+ unique: true,
555
+ partialFilterExpression: { [field]: { $type: "string", $gt: "" } }
556
+ },
557
+ trim: true,
558
+ default: null,
559
+ ...options
560
+ }
561
+ );
234
562
 
235
563
  // src/is.ts
236
564
  var import_mongoose4 = require("mongoose");
237
565
  var { ObjectId } = import_mongoose4.Types;
566
+ var CANONICAL_OBJECT_ID_STRING = /^[0-9a-f]{24}$/;
238
567
  function isObjectId(value) {
239
- if (!ObjectId.isValid(value)) {
240
- return false;
241
- }
242
568
  try {
243
- const asString = String(value);
244
- const asObjectId = new ObjectId(asString);
245
- const asStringifiedObjectId = asObjectId.toString();
246
- return asString === asStringifiedObjectId;
569
+ if (typeof value === "string") {
570
+ return CANONICAL_OBJECT_ID_STRING.test(value);
571
+ }
572
+ if (value instanceof ObjectId) {
573
+ return true;
574
+ }
575
+ return false;
247
576
  } catch {
248
577
  return false;
249
578
  }