@classytic/mongokit 3.32.0 → 3.33.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-CSh-P8Nh.mjs → PaginationEngine-GdyX29tE.mjs} +13 -11
- package/dist/{Repository-B_1y9cCf.mjs → Repository-BArvU32x.mjs} +149 -36
- package/dist/{Repository-BAzymuOz.d.mts → Repository-BdSkQ8vc.d.mts} +27 -4
- package/dist/actions/index.d.mts +1 -1
- package/dist/actions/index.mjs +1 -1
- package/dist/ai/index.d.mts +1 -1
- package/dist/better-auth/index.d.mts +19 -0
- package/dist/better-auth/index.mjs +5 -3
- package/dist/{create-repository-mEdfH9sz.d.mts → create-repository-BU4x9Kb0.d.mts} +4 -4
- package/dist/{create-repository-a3DQ5tzt.mjs → create-repository-CPNbktFG.mjs} +2 -2
- package/dist/id-chunks-BmAwsqNk.mjs +67 -0
- package/dist/{index-Beyr1r4d.d.mts → index-DKEql4cL.d.mts} +1 -1
- package/dist/index.d.mts +15 -14
- package/dist/index.mjs +60 -54
- package/dist/{multi-tenant.plugin-2VtSSm1Y.d.mts → multi-tenant.plugin-BwSvTirB.d.mts} +3 -3
- package/dist/{operations-CuzX9m2Z.d.mts → operations-DZodvjC-.d.mts} +22 -3
- package/dist/outbox/index.d.mts +1 -1
- package/dist/outbox/index.mjs +1 -1
- package/dist/pagination/PaginationEngine.mjs +1 -1
- package/dist/{plugin-options-BsFH0sTi.d.mts → plugin-options-DXnlKM3B.d.mts} +31 -1
- package/dist/plugins/index.d.mts +3 -3
- package/dist/plugins/index.mjs +2 -2
- package/dist/{repository-Q16ErTu8.d.mts → repository-H_ffGgpE.d.mts} +8 -0
- package/dist/testkit/index.d.mts +2 -2
- package/dist/testkit/index.mjs +1 -1
- package/dist/{timestamp.plugin-CAXzHs9y.mjs → timestamp.plugin-BEc6TANm.mjs} +7 -5
- package/dist/{to-plain-BuX1RUZV.d.mts → to-plain-Dyz_qvuo.d.mts} +3 -3
- package/dist/{update-w5i-pa_c.mjs → update-jSXp3nu3.mjs} +48 -28
- package/dist/utils/index.d.mts +54 -2
- package/dist/utils/index.mjs +2 -1
- package/dist/{validation-chain.plugin-BihbMQRq.mjs → validation-chain.plugin-CyK1s9Ze.mjs} +219 -20
- package/dist/{validation-chain.plugin-BSshLJTA.d.mts → validation-chain.plugin-yQRob3Av.d.mts} +2 -2
- package/package.json +17 -17
package/dist/utils/index.d.mts
CHANGED
|
@@ -1,2 +1,54 @@
|
|
|
1
|
-
import { _ as getMongooseProjection, a as createMemoryCache, b as createError, c as IdType, d as isObjectIdInstance, f as isValidIdForType, g as getFieldsForUser, h as filterResponseData, i as buildCrudSchemasFromMongooseSchema, l as getSchemaIdType, m as createFieldPreset, n as CrudSchemasFramework, o as LoggerConfig, p as toObjectId, r as buildCrudSchemasFromModel, s as configureLogger, t as toPlain, v as CreateErrorOptions, y as ParseDuplicateKeyOptions } from "../to-plain-
|
|
2
|
-
|
|
1
|
+
import { _ as getMongooseProjection, a as createMemoryCache, b as createError, c as IdType, d as isObjectIdInstance, f as isValidIdForType, g as getFieldsForUser, h as filterResponseData, i as buildCrudSchemasFromMongooseSchema, l as getSchemaIdType, m as createFieldPreset, n as CrudSchemasFramework, o as LoggerConfig, p as toObjectId, r as buildCrudSchemasFromModel, s as configureLogger, t as toPlain, v as CreateErrorOptions, y as ParseDuplicateKeyOptions } from "../to-plain-Dyz_qvuo.mjs";
|
|
2
|
+
import { ClientSession, Model } from "mongoose";
|
|
3
|
+
//#region src/utils/id-chunks.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Default slice for `$in` fan-out. Larger than `runChunkedPurge`'s 1000 on purpose:
|
|
6
|
+
* a purge chunk bounds the WRITE LOCK SCOPE of each progressive strategy step, while a
|
|
7
|
+
* snapshot slice bounds only the query-document size and the per-call working set —
|
|
8
|
+
* 10k ObjectIds is ~120 KB of `$in`, far under every limit, at 10× fewer round trips.
|
|
9
|
+
*/
|
|
10
|
+
declare const DEFAULT_ID_CHUNK = 10000;
|
|
11
|
+
/**
|
|
12
|
+
* Collect the bare id values matching `filter`, streaming via a driver cursor so the
|
|
13
|
+
* only O(n) allocation is the returned array itself (a `find().lean()` materializes an
|
|
14
|
+
* intermediate array of `{_id}` documents first — roughly double the peak for large n).
|
|
15
|
+
*/
|
|
16
|
+
declare function collectIds(model: Model<unknown>, filter: Record<string, unknown>, options?: {
|
|
17
|
+
session?: ClientSession | undefined;
|
|
18
|
+
idField?: string;
|
|
19
|
+
}): Promise<unknown[]>;
|
|
20
|
+
/**
|
|
21
|
+
* Yield `ids` in consecutive slices of at most `size`. Yields nothing for an empty
|
|
22
|
+
* array. `size` must be a positive integer — sliced `$in` correctness depends on it,
|
|
23
|
+
* so a nonsense size throws instead of silently degrading to one giant slice.
|
|
24
|
+
*/
|
|
25
|
+
declare function idChunks<T>(ids: readonly T[], size: number): Generator<T[], void, undefined>;
|
|
26
|
+
/**
|
|
27
|
+
* Compose the keyset-progressed select filter: the base predicate, narrowed to rows past
|
|
28
|
+
* the last committed `_id`. `cursor == null` means "from the start". Selection paired
|
|
29
|
+
* with this must sort `_id`-ascending ({@link selectKeysetChunk} does), so `$gt: cursor`
|
|
30
|
+
* never revisits a processed row and never skips an unprocessed one. Never mutates
|
|
31
|
+
* `baseFilter`.
|
|
32
|
+
*/
|
|
33
|
+
declare function keysetFilter(baseFilter: Record<string, unknown>, cursor: unknown): Record<string, unknown>;
|
|
34
|
+
/**
|
|
35
|
+
* The `_id`-ascending bounded chunk select both action ports share:
|
|
36
|
+
* `find(filter).sort({_id: 1}).limit(limit).lean()`. Lean on purpose — chunk consumers
|
|
37
|
+
* want plain objects, not hydrated documents. Pass `projection: {_id: 1}` when only ids
|
|
38
|
+
* are needed (the purge port's common path); omit it for full docs (archive's readChunk,
|
|
39
|
+
* anonymize-functional).
|
|
40
|
+
*/
|
|
41
|
+
declare function selectKeysetChunk<T>(model: Model<T>, filter: Record<string, unknown>, limit: number, options?: {
|
|
42
|
+
session?: ClientSession | undefined;
|
|
43
|
+
projection?: Record<string, 1 | 0> | undefined;
|
|
44
|
+
}): Promise<T[]>;
|
|
45
|
+
/**
|
|
46
|
+
* Re-assert the base predicate on an id-narrowed WRITE: `{...base, _id: {$in: ids}}`.
|
|
47
|
+
* The `$in` alone would be enough to address the chunk, but carrying the base predicate
|
|
48
|
+
* defends against a row whose scope fields changed between the chunk select and the
|
|
49
|
+
* write — such a row is silently skipped instead of written outside its cohort. Both
|
|
50
|
+
* action ports use this shape; use it for any select-then-write over a chunk.
|
|
51
|
+
*/
|
|
52
|
+
declare function narrowToIds(baseFilter: Record<string, unknown>, ids: readonly unknown[]): Record<string, unknown>;
|
|
53
|
+
//#endregion
|
|
54
|
+
export { type CreateErrorOptions, type CrudSchemasFramework, DEFAULT_ID_CHUNK, type IdType, type LoggerConfig, type ParseDuplicateKeyOptions, buildCrudSchemasFromModel, buildCrudSchemasFromMongooseSchema, collectIds, configureLogger, createError, createFieldPreset, createMemoryCache, filterResponseData, getFieldsForUser, getMongooseProjection, getSchemaIdType, idChunks, isObjectIdInstance, isValidIdForType, keysetFilter, narrowToIds, selectKeysetChunk, toObjectId, toPlain };
|
package/dist/utils/index.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { a as toObjectId, i as isValidIdForType, r as isObjectIdInstance, t as getSchemaIdType } from "../id-resolution-BqDrk0S2.mjs";
|
|
2
|
+
import { a as narrowToIds, i as keysetFilter, n as collectIds, o as selectKeysetChunk, r as idChunks, t as DEFAULT_ID_CHUNK } from "../id-chunks-BmAwsqNk.mjs";
|
|
2
3
|
import { t as configureLogger } from "../logger-K7dPmu2U.mjs";
|
|
3
4
|
import { t as createError } from "../error-BuByZwKU.mjs";
|
|
4
5
|
import { i as getMongooseProjection, n as filterResponseData, r as getFieldsForUser, t as createFieldPreset } from "../field-selection-FSQFLpsU.mjs";
|
|
5
6
|
import { n as createMemoryCache, t as toPlain } from "../to-plain-vksRrLtc.mjs";
|
|
6
7
|
import { n as buildCrudSchemasFromMongooseSchema, t as buildCrudSchemasFromModel } from "../mongooseToJsonSchema-DKbnKduN.mjs";
|
|
7
|
-
export { buildCrudSchemasFromModel, buildCrudSchemasFromMongooseSchema, configureLogger, createError, createFieldPreset, createMemoryCache, filterResponseData, getFieldsForUser, getMongooseProjection, getSchemaIdType, isObjectIdInstance, isValidIdForType, toObjectId, toPlain };
|
|
8
|
+
export { DEFAULT_ID_CHUNK, buildCrudSchemasFromModel, buildCrudSchemasFromMongooseSchema, collectIds, configureLogger, createError, createFieldPreset, createMemoryCache, filterResponseData, getFieldsForUser, getMongooseProjection, getSchemaIdType, idChunks, isObjectIdInstance, isValidIdForType, keysetFilter, narrowToIds, selectKeysetChunk, toObjectId, toPlain };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { n as collectIds, r as idChunks, t as DEFAULT_ID_CHUNK } from "./id-chunks-BmAwsqNk.mjs";
|
|
2
|
+
import { c as ALL_OPERATIONS, u as OP_REGISTRY } from "./Repository-BArvU32x.mjs";
|
|
3
|
+
import { _ as upsert } from "./update-jSXp3nu3.mjs";
|
|
3
4
|
import { n as warn } from "./logger-K7dPmu2U.mjs";
|
|
4
5
|
import { t as createError } from "./error-BuByZwKU.mjs";
|
|
5
6
|
import { r as getFieldsForUser } from "./field-selection-FSQFLpsU.mjs";
|
|
@@ -207,6 +208,97 @@ function appendOnlyPlugin(options = {}) {
|
|
|
207
208
|
* forward a well-known allow-list: `organizationId`, `tenantId`, and `user`.
|
|
208
209
|
* These are the conventions enforced by the built-in plugins.
|
|
209
210
|
*/
|
|
211
|
+
/**
|
|
212
|
+
* Count referencing documents through the target REPOSITORY, carrying the parent's session
|
|
213
|
+
* and scope.
|
|
214
|
+
*
|
|
215
|
+
* Both are load-bearing and were missing in the first cut:
|
|
216
|
+
* - without `session`, the count reads OUTSIDE the parent's transaction, so children
|
|
217
|
+
* created in the same uncommitted transaction are invisible and the guard passes;
|
|
218
|
+
* - without the forwarded scope, a tenant-scoped target either reads unscoped (refusing a
|
|
219
|
+
* delete because ANOTHER tenant references the row) or relies on ambient context that a
|
|
220
|
+
* background job does not have.
|
|
221
|
+
*
|
|
222
|
+
* REFUSES rather than falling back to `Model.countDocuments` when the repository has no
|
|
223
|
+
* `count`: a raw count silently bypasses the target's own policy, which is the thing this
|
|
224
|
+
* guard exists to respect.
|
|
225
|
+
*/
|
|
226
|
+
async function countReferencing(relation, filter, session, scopeForward) {
|
|
227
|
+
const targetRepo = relation.repo;
|
|
228
|
+
if (!targetRepo || typeof targetRepo.count !== "function") throw new Error(`cascadePlugin: onDelete:'restrict' on '${relation.foreignKey}' needs a repository with \`count()\` — a raw model count would bypass the target's tenant scoping and policy`);
|
|
229
|
+
const options = { ...scopeForward };
|
|
230
|
+
if (session) options.session = session;
|
|
231
|
+
return targetRepo.count(filter, options);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* ENFORCE that a guarded foreign key is indexed.
|
|
235
|
+
*
|
|
236
|
+
* `restrict` and `detach` issue a query per relation on every delete. MongoDB gives no index
|
|
237
|
+
* for free the way an FK constraint does, so an unindexed `foreignKey` turns each delete into
|
|
238
|
+
* a COLLECTION SCAN of the child collection — invisible in tests (small fixtures) and
|
|
239
|
+
* crippling in production, which is the shape of defect this codebase treats as the default
|
|
240
|
+
* hazard.
|
|
241
|
+
*
|
|
242
|
+
* Documentation was not enough: a type comment saying the key "SHOULD" be indexed stops no
|
|
243
|
+
* scan. This is checked at BIND time, where the declaration is, and THROWS — the policies are
|
|
244
|
+
* opt-in and brand new, so nothing pre-existing can be broken by refusing.
|
|
245
|
+
*
|
|
246
|
+
* Prefix rule, matching Mongo's own: an index on `{a:1, b:1}` serves a query on `a`, so the
|
|
247
|
+
* foreign key qualifies when it is the FIRST key of any declared index. `schema.indexes()`
|
|
248
|
+
* includes path-level `{index: true}` as well as explicit `schema.index(...)` calls.
|
|
249
|
+
*/
|
|
250
|
+
function assertForeignKeyIndexed(relation) {
|
|
251
|
+
const schema = relation.repo?.Model?.schema;
|
|
252
|
+
if (!schema || typeof schema.indexes !== "function") return;
|
|
253
|
+
if (schema.indexes().some(([keys]) => Object.keys(keys)[0] === relation.foreignKey)) return;
|
|
254
|
+
const target = relation.repo?.Model?.modelName ?? relation.model ?? "<unknown>";
|
|
255
|
+
throw new Error(`cascadePlugin: onDelete:'${relation.onDelete}' on '${relation.foreignKey}' requires an index on ${target}.${relation.foreignKey} — the guard queries it on every delete, and Mongo has no foreign-key index. Add \`index: true\` to the path or a leading \`schema.index(...)\`.`);
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* A TTL index on the PROTECTED parent makes `restrict` a promise the plugin cannot keep.
|
|
259
|
+
*
|
|
260
|
+
* `restrict` means "do not delete this document while anything references it". A TTL index
|
|
261
|
+
* means "delete this document N seconds after its date field, server-side". Mongo's expiry
|
|
262
|
+
* thread runs no application code — no repository, no hook, no count — so it will remove a
|
|
263
|
+
* referenced parent and leave every child dangling, silently, at a time nobody chose.
|
|
264
|
+
*
|
|
265
|
+
* The two are a direct contradiction, so this THROWS rather than warns. Refusing at bind time
|
|
266
|
+
* is the only point where anyone can see both declarations at once; at runtime the expiry
|
|
267
|
+
* simply happens.
|
|
268
|
+
*
|
|
269
|
+
* NOTE this is about the PARENT (the repo declaring the relations). A TTL on the CHILD is
|
|
270
|
+
* fine and even useful — expiring children is how references legitimately drain away.
|
|
271
|
+
*/
|
|
272
|
+
function assertNoTtlOnProtectedParent(repo, relation) {
|
|
273
|
+
const schema = repo.Model?.schema;
|
|
274
|
+
if (!schema || typeof schema.indexes !== "function") return;
|
|
275
|
+
const ttl = schema.indexes().find(([, options]) => options?.expireAfterSeconds !== void 0);
|
|
276
|
+
if (!ttl) return;
|
|
277
|
+
const parent = repo.Model?.modelName ?? "<unknown>";
|
|
278
|
+
const key = Object.keys(ttl[0])[0];
|
|
279
|
+
throw new Error(`cascadePlugin: ${parent} declares onDelete:'restrict' on '${relation.foreignKey}' but also carries a TTL index on '${key}' (expireAfterSeconds). Mongo's expiry runs server-side and fires no hooks, so it would delete a referenced document and orphan its children — the restrict guarantee cannot hold. Remove the TTL, or use a retention sweep that deletes through the repository.`);
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* `detach` — clear the reference through the target REPOSITORY (`SET NULL`).
|
|
283
|
+
*
|
|
284
|
+
* Routed through `repo.updateMany`, not `repo.Model.updateMany`. The first cut used the raw
|
|
285
|
+
* model while the comment beside it claimed the target's hooks ran: they did not, so tenant
|
|
286
|
+
* policy, audit hooks, cache invalidation and update hooks were all skipped on a write that
|
|
287
|
+
* mutates the child. A comment asserting a guarantee the code does not provide is worse than
|
|
288
|
+
* no comment.
|
|
289
|
+
*/
|
|
290
|
+
async function detachReferences(relation, filter, ctx) {
|
|
291
|
+
const update = { $unset: { [relation.foreignKey]: "" } };
|
|
292
|
+
const options = { ...ctx.scopeForward };
|
|
293
|
+
if (ctx.session) options.session = ctx.session;
|
|
294
|
+
const targetRepo = relation.repo;
|
|
295
|
+
if (targetRepo && typeof targetRepo.updateMany === "function") {
|
|
296
|
+
await targetRepo.updateMany(filter, update, options);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (targetRepo) throw new Error(`cascadePlugin: onDelete:'detach' on '${relation.foreignKey}' needs a repository with \`updateMany()\` — a raw model update would skip the child's hooks and tenant policy`);
|
|
300
|
+
await mongoose.models[relation.model]?.updateMany(filter, update, ctx.session ? { session: ctx.session } : {}).exec();
|
|
301
|
+
}
|
|
210
302
|
function collectScopeForward(context) {
|
|
211
303
|
const out = {};
|
|
212
304
|
if (context.organizationId !== void 0) out.organizationId = context.organizationId;
|
|
@@ -218,15 +310,54 @@ function collectScopeForward(context) {
|
|
|
218
310
|
* Cascade delete plugin.
|
|
219
311
|
*/
|
|
220
312
|
function cascadePlugin(options) {
|
|
221
|
-
const { relations, parallel = true, logger } = options;
|
|
313
|
+
const { relations, parallel = true, batchSize = DEFAULT_ID_CHUNK, logger } = options;
|
|
222
314
|
if (!relations || relations.length === 0) throw new Error("cascadePlugin requires at least one relation");
|
|
315
|
+
if (!Number.isInteger(batchSize) || batchSize < 1) throw new Error(`cascadePlugin: batchSize must be a positive integer, got ${String(batchSize)}`);
|
|
223
316
|
for (const rel of relations) {
|
|
224
317
|
if (!rel.repo && !rel.model) throw new Error("cascadePlugin: each relation needs either `repo` (preferred) or `model` (legacy)");
|
|
225
318
|
if (!rel.foreignKey) throw new Error("cascadePlugin: each relation needs `foreignKey`");
|
|
319
|
+
/**
|
|
320
|
+
* `restrict` must be able to COUNT the referencing documents before the parent goes,
|
|
321
|
+
* and the legacy model-routed path writes through `mongoose.models[...]` without the
|
|
322
|
+
* target's scoping. A restrict that counted unscoped rows would refuse a delete on
|
|
323
|
+
* evidence from another tenant, so it requires the repo-routed form.
|
|
324
|
+
*/
|
|
325
|
+
if (rel.onDelete === "restrict" && !rel.repo) throw new Error(`cascadePlugin: relation on '${rel.foreignKey}' uses onDelete:'restrict', which requires \`repo\` (the legacy \`model\` route cannot apply the target's tenant scoping to the count)`);
|
|
226
326
|
}
|
|
327
|
+
const restrictions = relations.filter((r) => r.onDelete === "restrict");
|
|
328
|
+
const detachments = relations.filter((r) => r.onDelete === "detach");
|
|
329
|
+
/** Everything else keeps the historical behaviour — an absent `onDelete` is `cascade`. */
|
|
330
|
+
const cascades = relations.filter((r) => (r.onDelete ?? "cascade") === "cascade");
|
|
227
331
|
return {
|
|
228
332
|
name: "cascade",
|
|
229
333
|
apply(repo) {
|
|
334
|
+
/**
|
|
335
|
+
* Bind time, not construction time: the relation carries a repo whose Model (and
|
|
336
|
+
* therefore its schema) is only reliably available once the plugin is applied.
|
|
337
|
+
*/
|
|
338
|
+
for (const relation of [...restrictions, ...detachments]) assertForeignKeyIndexed(relation);
|
|
339
|
+
for (const relation of restrictions) assertNoTtlOnProtectedParent(repo, relation);
|
|
340
|
+
if (restrictions.length > 0) repo.on("before:delete", async (context) => {
|
|
341
|
+
const id = context.id;
|
|
342
|
+
if (!id) return;
|
|
343
|
+
const scopeForward = collectScopeForward(context);
|
|
344
|
+
for (const relation of restrictions) {
|
|
345
|
+
const remaining = await countReferencing(relation, { [relation.foreignKey]: id }, context.session, scopeForward);
|
|
346
|
+
if (remaining > 0) {
|
|
347
|
+
const target = relation.repo?.Model?.modelName ?? relation.model ?? "<unknown>";
|
|
348
|
+
const err = /* @__PURE__ */ new Error(`Cannot delete this ${context.model ?? "document"}: ${remaining} ${target} record(s) still reference it via '${relation.foreignKey}'. Remove or reassign them first.`);
|
|
349
|
+
err.code = "REFERENCE_RESTRICTED";
|
|
350
|
+
err.details = {
|
|
351
|
+
model: context.model,
|
|
352
|
+
id: String(id),
|
|
353
|
+
referencedBy: target,
|
|
354
|
+
foreignKey: relation.foreignKey,
|
|
355
|
+
count: remaining
|
|
356
|
+
};
|
|
357
|
+
throw err;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
});
|
|
230
361
|
repo.on("after:delete", async (payload) => {
|
|
231
362
|
const { context } = payload;
|
|
232
363
|
const deletedId = context.id;
|
|
@@ -259,13 +390,59 @@ function cascadePlugin(options) {
|
|
|
259
390
|
throw error;
|
|
260
391
|
}
|
|
261
392
|
};
|
|
262
|
-
|
|
393
|
+
/**
|
|
394
|
+
* `detach` — the RDBMS `SET NULL`. Clear the pointer, keep the child.
|
|
395
|
+
*
|
|
396
|
+
* Runs beside the cascades and before them in intent: a detached child must not be
|
|
397
|
+
* reachable through a reference to a document that no longer exists. Routed through
|
|
398
|
+
* the target repo so its hooks and scoping apply, exactly as cascade is.
|
|
399
|
+
*/
|
|
400
|
+
const detachOne = async (relation) => detachReferences(relation, { [relation.foreignKey]: deletedId }, {
|
|
401
|
+
session: context.session,
|
|
402
|
+
scopeForward
|
|
403
|
+
});
|
|
404
|
+
const txSession = context.session;
|
|
405
|
+
if (detachments.length > 0) await runCascades(detachments, detachOne, parallel, txSession);
|
|
406
|
+
await runCascades(cascades, cascadeOne, parallel, txSession);
|
|
263
407
|
});
|
|
264
408
|
repo.on("before:deleteMany", async (context) => {
|
|
265
409
|
const query = context.query;
|
|
266
410
|
if (!query || Object.keys(query).length === 0) return;
|
|
267
411
|
const idField = repo.idField || "_id";
|
|
268
|
-
|
|
412
|
+
const ids = await collectIds(repo.Model, query, {
|
|
413
|
+
session: context.session,
|
|
414
|
+
idField
|
|
415
|
+
});
|
|
416
|
+
context._cascadeIds = ids;
|
|
417
|
+
/**
|
|
418
|
+
* RESTRICT applies to bulk too — and its absence here was a destructive bug.
|
|
419
|
+
*
|
|
420
|
+
* The first cut split the policies on the single-delete path only and then passed
|
|
421
|
+
* ALL relations to the bulk cascade, so `deleteMany` DELETED the children of a
|
|
422
|
+
* `restrict` relation (and of a `detach` one). Bulk was therefore strictly worse
|
|
423
|
+
* than declaring nothing at all. Checked in the same hook that snapshots the ids,
|
|
424
|
+
* so it cannot be reordered apart from them.
|
|
425
|
+
*/
|
|
426
|
+
if (restrictions.length > 0 && ids.length > 0) {
|
|
427
|
+
const scopeForward = collectScopeForward(context);
|
|
428
|
+
for (const relation of restrictions) {
|
|
429
|
+
let remaining = 0;
|
|
430
|
+
for (const chunk of idChunks(ids, batchSize)) remaining += await countReferencing(relation, { [relation.foreignKey]: { $in: chunk } }, context.session, scopeForward);
|
|
431
|
+
if (remaining > 0) {
|
|
432
|
+
const target = relation.repo?.Model?.modelName ?? relation.model ?? "<unknown>";
|
|
433
|
+
const err = /* @__PURE__ */ new Error(`Cannot delete these ${context.model ?? "documents"}: ${remaining} ${target} record(s) still reference ${ids.length} of them via '${relation.foreignKey}'. Remove or reassign them first.`);
|
|
434
|
+
err.code = "REFERENCE_RESTRICTED";
|
|
435
|
+
err.details = {
|
|
436
|
+
model: context.model,
|
|
437
|
+
ids: ids.map(String),
|
|
438
|
+
referencedBy: target,
|
|
439
|
+
foreignKey: relation.foreignKey,
|
|
440
|
+
count: remaining
|
|
441
|
+
};
|
|
442
|
+
throw err;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
269
446
|
});
|
|
270
447
|
repo.on("after:deleteMany", async (payload) => {
|
|
271
448
|
const { context } = payload;
|
|
@@ -283,8 +460,8 @@ function cascadePlugin(options) {
|
|
|
283
460
|
scopeForward
|
|
284
461
|
};
|
|
285
462
|
try {
|
|
286
|
-
if (relation.repo) await cascadeViaRepoBulkMany(relation, ids, cascadeCtx);
|
|
287
|
-
else await cascadeViaLegacyModelMany(relation, ids, cascadeCtx, logger);
|
|
463
|
+
if (relation.repo) await cascadeViaRepoBulkMany(relation, ids, batchSize, cascadeCtx);
|
|
464
|
+
else await cascadeViaLegacyModelMany(relation, ids, batchSize, cascadeCtx, logger);
|
|
288
465
|
} catch (error) {
|
|
289
466
|
logger?.error?.(`Cascade deleteMany failed for '${cascadeCtx.targetModelName}'`, {
|
|
290
467
|
parentModel: context.model,
|
|
@@ -295,7 +472,16 @@ function cascadePlugin(options) {
|
|
|
295
472
|
throw error;
|
|
296
473
|
}
|
|
297
474
|
};
|
|
298
|
-
|
|
475
|
+
/** `detach` in bulk — the same `$unset`, routed through the target repository, sliced. */
|
|
476
|
+
const detachBulk = async (relation) => {
|
|
477
|
+
for (const chunk of idChunks(ids, batchSize)) await detachReferences(relation, { [relation.foreignKey]: { $in: chunk } }, {
|
|
478
|
+
session: context.session,
|
|
479
|
+
scopeForward
|
|
480
|
+
});
|
|
481
|
+
};
|
|
482
|
+
const txSession = context.session;
|
|
483
|
+
if (detachments.length > 0) await runCascades(detachments, detachBulk, parallel, txSession);
|
|
484
|
+
await runCascades(cascades, cascadeBulk, parallel, txSession);
|
|
299
485
|
});
|
|
300
486
|
}
|
|
301
487
|
};
|
|
@@ -315,12 +501,13 @@ async function cascadeViaRepoBulk(relation, parentId, ctx) {
|
|
|
315
501
|
});
|
|
316
502
|
}
|
|
317
503
|
/**
|
|
318
|
-
* Repo-routed cascade for a bulk parent deleteMany — `$in` over snapshot ids
|
|
504
|
+
* Repo-routed cascade for a bulk parent deleteMany — sliced `$in` over snapshot ids
|
|
505
|
+
* (`batchSize` bounds each query document; see `utils/id-chunks.ts`).
|
|
319
506
|
*/
|
|
320
|
-
async function cascadeViaRepoBulkMany(relation, parentIds, ctx) {
|
|
507
|
+
async function cascadeViaRepoBulkMany(relation, parentIds, batchSize, ctx) {
|
|
321
508
|
const targetRepo = relation.repo;
|
|
322
509
|
if (typeof targetRepo.deleteMany !== "function") throw new Error(`cascadePlugin: target repo for '${ctx.targetModelName}' is missing deleteMany(). Ensure the target is a mongokit Repository or implements the StandardRepo deleteMany contract.`);
|
|
323
|
-
await targetRepo.deleteMany({ [relation.foreignKey]: { $in:
|
|
510
|
+
for (const chunk of idChunks(parentIds, batchSize)) await targetRepo.deleteMany({ [relation.foreignKey]: { $in: chunk } }, {
|
|
324
511
|
...ctx.scopeForward,
|
|
325
512
|
mode: ctx.shouldSoftDelete ? "soft" : "hard",
|
|
326
513
|
session: ctx.session
|
|
@@ -343,26 +530,38 @@ async function cascadeViaLegacyModel(relation, parentId, ctx, logger) {
|
|
|
343
530
|
}, { session: ctx.session });
|
|
344
531
|
else await RelatedModel.deleteMany(query, { session: ctx.session });
|
|
345
532
|
}
|
|
346
|
-
async function cascadeViaLegacyModelMany(relation, parentIds, ctx, logger) {
|
|
533
|
+
async function cascadeViaLegacyModelMany(relation, parentIds, batchSize, ctx, logger) {
|
|
347
534
|
const RelatedModel = mongoose.models[relation.model];
|
|
348
535
|
if (!RelatedModel) {
|
|
349
536
|
logger?.warn?.(`Cascade deleteMany skipped: model '${relation.model}' not found`);
|
|
350
537
|
return;
|
|
351
538
|
}
|
|
352
|
-
const
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
539
|
+
for (const chunk of idChunks(parentIds, batchSize)) {
|
|
540
|
+
const query = { [relation.foreignKey]: { $in: chunk } };
|
|
541
|
+
if (ctx.shouldSoftDelete) await RelatedModel.updateMany(query, {
|
|
542
|
+
deletedAt: /* @__PURE__ */ new Date(),
|
|
543
|
+
...ctx.user ? { deletedBy: ctx.user._id || ctx.user.id } : {}
|
|
544
|
+
}, { session: ctx.session });
|
|
545
|
+
else await RelatedModel.deleteMany(query, { session: ctx.session });
|
|
546
|
+
}
|
|
358
547
|
}
|
|
359
548
|
/**
|
|
360
549
|
* Execute a list of cascade operations, honoring the `parallel` flag.
|
|
361
550
|
* Uses `allSettled` so one failure doesn't abort siblings; throws the first
|
|
362
551
|
* rejection (with a composite message if several failed) after all complete.
|
|
552
|
+
*
|
|
553
|
+
* **`parallel` is IGNORED inside a transaction.** A `ClientSession` is not
|
|
554
|
+
* safe for concurrent operations — MongoDB's driver serialises commands per
|
|
555
|
+
* session, and issuing several at once is undefined behaviour that surfaces
|
|
556
|
+
* as transaction-state errors rather than a clean failure. Since cascades
|
|
557
|
+
* began propagating the parent session, `parallel: true` (the default) meant
|
|
558
|
+
* every multi-relation cascade inside `withTransaction()` was doing exactly
|
|
559
|
+
* that. Sequential execution is the only correct mode there, so the flag is
|
|
560
|
+
* downgraded rather than obeyed — the alternative is a throughput option
|
|
561
|
+
* that silently corrupts transactions.
|
|
363
562
|
*/
|
|
364
|
-
async function runCascades(relations, fn, parallel) {
|
|
365
|
-
if (parallel) {
|
|
563
|
+
async function runCascades(relations, fn, parallel, session) {
|
|
564
|
+
if (parallel && !session) {
|
|
366
565
|
const failures = (await Promise.allSettled(relations.map(fn))).filter((r) => r.status === "rejected");
|
|
367
566
|
if (failures.length) {
|
|
368
567
|
const err = failures[0].reason;
|
package/dist/{validation-chain.plugin-BSshLJTA.d.mts → validation-chain.plugin-yQRob3Av.d.mts}
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { a as PopulateSpec, i as ObjectId, l as SortSpec, s as SelectSpec } from "./core-62oo3N1C.mjs";
|
|
2
|
-
import { h as RepositoryInstance, l as Plugin, p as RepositoryContext } from "./repository-
|
|
3
|
-
import { c as ValidationChainOptions, i as Logger, l as ValidatorDefinition, o as SoftDeleteOptions, r as FieldPreset, t as CascadeOptions } from "./plugin-options-
|
|
2
|
+
import { h as RepositoryInstance, l as Plugin, p as RepositoryContext } from "./repository-H_ffGgpE.mjs";
|
|
3
|
+
import { c as ValidationChainOptions, i as Logger, l as ValidatorDefinition, o as SoftDeleteOptions, r as FieldPreset, t as CascadeOptions } from "./plugin-options-DXnlKM3B.mjs";
|
|
4
4
|
import { BulkWriteResult } from "@classytic/repo-core/repository";
|
|
5
5
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
6
6
|
import { OffsetPaginationResult } from "@classytic/repo-core/pagination";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@classytic/mongokit",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.33.0",
|
|
4
4
|
"description": "Production-grade MongoDB repositories - smart pagination, events, and plugins. Built on @classytic/repo-core.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -122,7 +122,7 @@
|
|
|
122
122
|
},
|
|
123
123
|
"homepage": "https://github.com/classytic/mongokit#readme",
|
|
124
124
|
"peerDependencies": {
|
|
125
|
-
"@classytic/repo-core": ">=0.
|
|
125
|
+
"@classytic/repo-core": ">=0.23.0",
|
|
126
126
|
"mongodb-memory-server": ">=10.4.3",
|
|
127
127
|
"mongoose": ">=9.4.1"
|
|
128
128
|
},
|
|
@@ -169,25 +169,25 @@
|
|
|
169
169
|
"check:forgotten-exports": "node scripts/check-forgotten-exports.mjs"
|
|
170
170
|
},
|
|
171
171
|
"devDependencies": {
|
|
172
|
-
"@arethetypeswrong/cli": "^0.18.
|
|
173
|
-
"@better-auth/api-key": "^1.6.
|
|
174
|
-
"@better-auth/mongo-adapter": "^1.6.
|
|
175
|
-
"@biomejs/biome": "^2.
|
|
172
|
+
"@arethetypeswrong/cli": "^0.18.5",
|
|
173
|
+
"@better-auth/api-key": "^1.6.29",
|
|
174
|
+
"@better-auth/mongo-adapter": "^1.6.29",
|
|
175
|
+
"@biomejs/biome": "^2.5.8",
|
|
176
176
|
"@classytic/dev-tools": "^0.2.0",
|
|
177
|
-
"@classytic/repo-core": "^0.
|
|
178
|
-
"@types/node": "^
|
|
179
|
-
"@vitest/coverage-v8": "^4.1.
|
|
180
|
-
"ajv": "^8.
|
|
177
|
+
"@classytic/repo-core": "^0.23.0",
|
|
178
|
+
"@types/node": "^26.2.0",
|
|
179
|
+
"@vitest/coverage-v8": "^4.1.10",
|
|
180
|
+
"ajv": "^8.20.0",
|
|
181
181
|
"ajv-formats": "^3.0.1",
|
|
182
|
-
"better-auth": "^1.6.
|
|
182
|
+
"better-auth": "^1.6.29",
|
|
183
183
|
"dotenv": "^17.4.2",
|
|
184
|
-
"knip": "^6.
|
|
184
|
+
"knip": "^6.32.2",
|
|
185
185
|
"mongodb": "^7.5.0",
|
|
186
|
-
"mongodb-memory-server": "^
|
|
187
|
-
"mongoose": "^9.9.
|
|
188
|
-
"publint": "^0.3.
|
|
186
|
+
"mongodb-memory-server": "^11.2.0",
|
|
187
|
+
"mongoose": "^9.9.2",
|
|
188
|
+
"publint": "^0.3.23",
|
|
189
189
|
"tsdown": "^0.22.14",
|
|
190
|
-
"typescript": "~5.9.
|
|
191
|
-
"vitest": "^4.1.
|
|
190
|
+
"typescript": "~5.9.3",
|
|
191
|
+
"vitest": "^4.1.10"
|
|
192
192
|
}
|
|
193
193
|
}
|