@classytic/mongokit 3.38.1 → 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-CBS1XGzp.d.mts → PaginationEngine-D-P19Y9m.d.mts} +34 -9
- package/dist/PaginationEngine-Dvl8jcRY.mjs +1198 -0
- package/dist/{Repository-Ck5x43wF.mjs → Repository-BMgPSkqZ.mjs} +672 -655
- package/dist/{Repository-BdSkQ8vc.d.mts → Repository-CR8Y9g2E.d.mts} +106 -34
- package/dist/actions/index.d.mts +1 -1
- package/dist/actions/index.mjs +1 -1
- package/dist/adapter/index.d.mts +21 -0
- package/dist/adapter/index.mjs +1 -1
- package/dist/{adapter-Bfqz8yHE.mjs → adapter-BrVfPFSW.mjs} +45 -1
- package/dist/ai/index.d.mts +1 -1
- package/dist/better-auth/index.mjs +2 -2
- package/dist/{create-repository-B5EulQ31.mjs → create-repository-BzBUMvd1.mjs} +3 -3
- package/dist/{create-repository-BU4x9Kb0.d.mts → create-repository-CAle1MSU.d.mts} +5 -5
- package/dist/idempotency/index.d.mts +23 -0
- package/dist/idempotency/index.mjs +134 -0
- package/dist/{index-DKEql4cL.d.mts → index-CmjNyulu.d.mts} +67 -8
- package/dist/index.d.mts +125 -35
- package/dist/index.mjs +286 -676
- package/dist/indexes/verify-indexes.d.mts +14 -1
- package/dist/indexes/verify-indexes.mjs +54 -7
- package/dist/{method-registry.plugin-DblM7V6j.mjs → method-registry.plugin-C63pGn1d.mjs} +54 -4
- package/dist/{multi-tenant.plugin-BwSvTirB.d.mts → multi-tenant.plugin-CBgCo9im.d.mts} +77 -5
- package/dist/{operations-DZodvjC-.d.mts → operations-CrRIoc8Z.d.mts} +22 -7
- package/dist/outbox/index.d.mts +1 -1
- package/dist/outbox/index.mjs +7 -5
- package/dist/pagination/PaginationEngine.d.mts +1 -1
- package/dist/pagination/PaginationEngine.mjs +1 -1
- package/dist/pagination/utils/limits.d.mts +1 -1
- package/dist/pagination/utils/limits.mjs +22 -1
- package/dist/pagination-BpIv2bdS.d.mts +364 -0
- package/dist/{plugin-options-DXnlKM3B.d.mts → plugin-options-Zk46lD6F.d.mts} +16 -2
- package/dist/plugins/index.d.mts +8 -3
- package/dist/plugins/index.mjs +99 -26
- package/dist/{repository-H_ffGgpE.d.mts → repository-odl03582.d.mts} +50 -6
- package/dist/sync/index.d.mts +40 -0
- package/dist/sync/index.mjs +334 -0
- package/dist/tenant/index.d.mts +89 -0
- package/dist/tenant/index.mjs +96 -0
- package/dist/testkit/index.d.mts +2 -2
- package/dist/testkit/index.mjs +1 -1
- package/dist/{timestamp.plugin-CBQukQzV.mjs → timestamp.plugin-DF0gJB6p.mjs} +116 -9
- package/dist/{to-plain-Dyz_qvuo.d.mts → to-plain-k7SjNlSS.d.mts} +2 -2
- package/dist/transaction-B5L9glO0.mjs +533 -0
- package/dist/{update-jSXp3nu3.mjs → update-DG0fCwRJ.mjs} +293 -83
- package/dist/utils/index.d.mts +1 -1
- package/dist/{validation-chain.plugin-DhNyxNCw.mjs → validation-chain.plugin-BLY2gLEk.mjs} +192 -279
- package/dist/{validation-chain.plugin-yQRob3Av.d.mts → validation-chain.plugin-DYp9E7K2.d.mts} +26 -20
- package/package.json +17 -4
- package/dist/PaginationEngine-BX9o_-4R.mjs +0 -735
- package/dist/core-62oo3N1C.d.mts +0 -50
- package/dist/pagination-jSUdrACK.d.mts +0 -158
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { n as collectIds, r as idChunks, t as DEFAULT_ID_CHUNK } from "./id-chunks-BmAwsqNk.mjs";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { i as ALL_OPERATIONS, o as OP_REGISTRY } from "./Repository-BMgPSkqZ.mjs";
|
|
3
|
+
import { w as upsert } from "./update-DG0fCwRJ.mjs";
|
|
4
4
|
import { n as warn } from "./logger-K7dPmu2U.mjs";
|
|
5
5
|
import { t as createError } from "./error-BuByZwKU.mjs";
|
|
6
|
+
import { c as tenantContextKeysOf, o as forwardScope } from "./timestamp.plugin-DF0gJB6p.mjs";
|
|
6
7
|
import { r as getFieldsForUser } from "./field-selection-FSQFLpsU.mjs";
|
|
7
8
|
import mongoose from "mongoose";
|
|
8
9
|
import { HOOK_PRIORITY } from "@classytic/repo-core/hooks";
|
|
@@ -202,109 +203,121 @@ function appendOnlyPlugin(options = {}) {
|
|
|
202
203
|
* });
|
|
203
204
|
* ```
|
|
204
205
|
*/
|
|
206
|
+
/** The repository's own id field — the default `parentKey`. */
|
|
207
|
+
function idFieldOf(repo) {
|
|
208
|
+
return repo.idField || "_id";
|
|
209
|
+
}
|
|
210
|
+
const parentKeyOf = (relation, repo) => relation.parentKey ?? idFieldOf(repo);
|
|
211
|
+
/** `{ fk: v }` for one parent (the shape a target's hooks expect), a sliced `$in` otherwise. */
|
|
212
|
+
const referencing = (relation, chunk) => ({ [relation.foreignKey]: chunk.length === 1 ? chunk[0] : { $in: chunk } });
|
|
213
|
+
const targetNameOf = (relation) => relation.repo?.Model?.modelName ?? relation.model ?? "<unknown>";
|
|
205
214
|
/**
|
|
206
|
-
*
|
|
207
|
-
* audit) will likely need. We can't know the target's contextKey, so we
|
|
208
|
-
* forward a well-known allow-list: `organizationId`, `tenantId`, and `user`.
|
|
209
|
-
* These are the conventions enforced by the built-in plugins.
|
|
210
|
-
*/
|
|
211
|
-
/**
|
|
212
|
-
* Count referencing documents through the target REPOSITORY, carrying the parent's session
|
|
213
|
-
* and scope.
|
|
215
|
+
* Parent key values for ONE id-addressed delete.
|
|
214
216
|
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
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.
|
|
217
|
+
* The key the call was addressed by is already known (`context.id`). Any other
|
|
218
|
+
* key a relation needs (`parentKey: '_id'` on an `orderNumber` repo) is read from
|
|
219
|
+
* the target document, before the delete, through `loadTarget` (shared with
|
|
220
|
+
* every other hook, under the operation's session).
|
|
225
221
|
*/
|
|
226
|
-
async function
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
222
|
+
async function parentKeysOfDelete(context, repo, keys) {
|
|
223
|
+
const out = /* @__PURE__ */ new Map();
|
|
224
|
+
const addressedBy = context.idField ?? idFieldOf(repo);
|
|
225
|
+
const target = [...keys].some((key) => key !== addressedBy) ? await context.loadTarget?.() ?? await repo.Model.findOne({ [addressedBy]: context.id }, null, { session: context.session }).lean() : null;
|
|
226
|
+
for (const key of keys) {
|
|
227
|
+
const value = key === addressedBy ? context.id : target?.[key];
|
|
228
|
+
out.set(key, value === void 0 || value === null ? [] : [value]);
|
|
229
|
+
}
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
/** Parent key values for a `deleteMany` — streamed from the (already tenant-scoped) filter. */
|
|
233
|
+
async function parentKeysOfDeleteMany(context, repo, keys) {
|
|
234
|
+
const out = /* @__PURE__ */ new Map();
|
|
235
|
+
for (const key of keys) out.set(key, await collectIds(repo.Model, context.query, {
|
|
236
|
+
session: context.session,
|
|
237
|
+
idField: key
|
|
238
|
+
}));
|
|
239
|
+
return out;
|
|
232
240
|
}
|
|
233
241
|
/**
|
|
234
|
-
*
|
|
242
|
+
* RESTRICT — refuse while any child still references a parent being deleted.
|
|
235
243
|
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
* hazard.
|
|
244
|
+
* Counted through the target REPOSITORY with the forwarded scope and session:
|
|
245
|
+
* a raw model count would bypass the target's tenant scoping (refusing on
|
|
246
|
+
* another tenant's evidence) or read outside the transaction (missing children
|
|
247
|
+
* created in it). Sliced `$in`, SUMMED, so the refusal reports the true count.
|
|
241
248
|
*
|
|
242
|
-
*
|
|
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.
|
|
249
|
+
* ## An application delete GUARD, not `ON DELETE RESTRICT`
|
|
245
250
|
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
251
|
+
* This is count-then-delete. A child inserted between the two survives as a
|
|
252
|
+
* dangling reference, and a transaction narrows but does not close that window
|
|
253
|
+
* (Mongo has no predicate lock; the child's insert is not in this write set).
|
|
254
|
+
* It stops the ordinary case, an operator deleting a referenced parent, and must
|
|
255
|
+
* not be described as a concurrency-safe integrity guarantee.
|
|
249
256
|
*/
|
|
250
|
-
function
|
|
251
|
-
const
|
|
252
|
-
if (
|
|
253
|
-
|
|
254
|
-
const
|
|
255
|
-
|
|
257
|
+
async function assertUnreferenced(relation, values, scope, batchSize, context, single) {
|
|
258
|
+
const target = relation.repo;
|
|
259
|
+
if (typeof target?.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`);
|
|
260
|
+
let remaining = 0;
|
|
261
|
+
for (const chunk of idChunks(values, batchSize)) remaining += await target.count(referencing(relation, chunk), { ...scope });
|
|
262
|
+
if (remaining === 0) return;
|
|
263
|
+
const referencedBy = targetNameOf(relation);
|
|
264
|
+
const err = /* @__PURE__ */ new Error(single ? `Cannot delete this ${context.model ?? "document"}: ${remaining} ${referencedBy} record(s) still reference it via '${relation.foreignKey}'. Remove or reassign them first.` : `Cannot delete these ${context.model ?? "documents"}: ${remaining} ${referencedBy} record(s) still reference ${values.length} of them via '${relation.foreignKey}'. Remove or reassign them first.`);
|
|
265
|
+
err.code = "REFERENCE_RESTRICTED";
|
|
266
|
+
err.details = {
|
|
267
|
+
model: context.model,
|
|
268
|
+
...single ? { id: String(context.id) } : { ids: values.map(String) },
|
|
269
|
+
referencedBy,
|
|
270
|
+
foreignKey: relation.foreignKey,
|
|
271
|
+
count: remaining
|
|
272
|
+
};
|
|
273
|
+
throw err;
|
|
256
274
|
}
|
|
257
275
|
/**
|
|
258
|
-
*
|
|
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.
|
|
276
|
+
* DETACH — the RDBMS `SET NULL`: clear the pointer, keep the child.
|
|
268
277
|
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
278
|
+
* Through the target repository's `updateMany`, so its tenant policy, audit,
|
|
279
|
+
* cache and update hooks run. A repo without one is refused rather than written
|
|
280
|
+
* raw; the legacy `model` route is documented as hook-bypassing.
|
|
271
281
|
*/
|
|
272
|
-
function
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
282
|
+
async function detach(relation, values, scope, batchSize) {
|
|
283
|
+
const update = { $unset: { [relation.foreignKey]: "" } };
|
|
284
|
+
const target = relation.repo;
|
|
285
|
+
if (target && typeof target.updateMany !== "function") 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`);
|
|
286
|
+
for (const chunk of idChunks(values, batchSize)) {
|
|
287
|
+
const filter = referencing(relation, chunk);
|
|
288
|
+
if (target?.updateMany) await target.updateMany(filter, update, { ...scope });
|
|
289
|
+
else await mongoose.models[relation.model]?.updateMany(filter, update, { session: scope.session }).exec();
|
|
290
|
+
}
|
|
280
291
|
}
|
|
281
292
|
/**
|
|
282
|
-
*
|
|
293
|
+
* CASCADE — delete the children, soft when the parent was soft (or the relation
|
|
294
|
+
* says so).
|
|
283
295
|
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
* mutates the child. A comment asserting a guarantee the code does not provide is worse than
|
|
288
|
-
* no comment.
|
|
296
|
+
* Repo-routed: `target.deleteMany` with the forwarded scope, so the target's own
|
|
297
|
+
* tenant policy, audit and soft-delete plugin (with ITS `deletedField`) apply.
|
|
298
|
+
* Legacy `model` route: a raw write that bypasses the target's hooks.
|
|
289
299
|
*/
|
|
290
|
-
async function
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
await targetRepo.updateMany(filter, update, options);
|
|
300
|
+
async function cascade(relation, values, scope, batchSize, soft, logger) {
|
|
301
|
+
const target = relation.repo;
|
|
302
|
+
if (target && typeof target.deleteMany !== "function") throw new Error(`cascadePlugin: target repo for '${targetNameOf(relation)}' is missing deleteMany(). Ensure the target is a mongokit Repository or implements the StandardRepo deleteMany contract.`);
|
|
303
|
+
const RelatedModel = target ? void 0 : mongoose.models[relation.model];
|
|
304
|
+
if (!target && !RelatedModel) {
|
|
305
|
+
logger?.warn?.(`Cascade delete skipped: model '${relation.model}' not found`);
|
|
297
306
|
return;
|
|
298
307
|
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
+
const user = scope.user;
|
|
309
|
+
for (const chunk of idChunks(values, batchSize)) {
|
|
310
|
+
const filter = referencing(relation, chunk);
|
|
311
|
+
if (target?.deleteMany) await target.deleteMany(filter, {
|
|
312
|
+
...scope,
|
|
313
|
+
mode: soft ? "soft" : "hard"
|
|
314
|
+
});
|
|
315
|
+
else if (soft) await RelatedModel?.updateMany(filter, {
|
|
316
|
+
deletedAt: /* @__PURE__ */ new Date(),
|
|
317
|
+
...user ? { deletedBy: user._id || user.id } : {}
|
|
318
|
+
}, { session: scope.session });
|
|
319
|
+
else await RelatedModel?.deleteMany(filter, { session: scope.session });
|
|
320
|
+
}
|
|
308
321
|
}
|
|
309
322
|
/**
|
|
310
323
|
* Cascade delete plugin.
|
|
@@ -317,233 +330,127 @@ function cascadePlugin(options) {
|
|
|
317
330
|
if (!rel.repo && !rel.model) throw new Error("cascadePlugin: each relation needs either `repo` (preferred) or `model` (legacy)");
|
|
318
331
|
if (!rel.foreignKey) throw new Error("cascadePlugin: each relation needs `foreignKey`");
|
|
319
332
|
/**
|
|
320
|
-
* `restrict` must
|
|
321
|
-
*
|
|
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.
|
|
333
|
+
* `restrict` must COUNT the referencing documents through the target's scoping;
|
|
334
|
+
* the legacy `model` route cannot, and would refuse on another tenant's evidence.
|
|
324
335
|
*/
|
|
325
336
|
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)`);
|
|
326
337
|
}
|
|
327
338
|
const restrictions = relations.filter((r) => r.onDelete === "restrict");
|
|
328
339
|
const detachments = relations.filter((r) => r.onDelete === "detach");
|
|
329
|
-
/**
|
|
340
|
+
/** An absent `onDelete` is `cascade`, the historical behaviour. */
|
|
330
341
|
const cascades = relations.filter((r) => (r.onDelete ?? "cascade") === "cascade");
|
|
331
342
|
return {
|
|
332
343
|
name: "cascade",
|
|
333
344
|
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
345
|
for (const relation of [...restrictions, ...detachments]) assertForeignKeyIndexed(relation);
|
|
339
346
|
for (const relation of restrictions) assertNoTtlOnProtectedParent(repo, relation);
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
347
|
+
const keys = new Set(relations.map((relation) => parentKeyOf(relation, repo)));
|
|
348
|
+
/** Tenant keys of the parent AND every target, so each hop carries what its plugin reads. */
|
|
349
|
+
const tenantKeys = tenantContextKeysOf(repo, ...relations.map((relation) => relation.repo));
|
|
350
|
+
const scopeOf = (context) => forwardScope(context, tenantKeys);
|
|
351
|
+
/**
|
|
352
|
+
* BEFORE the parent goes: capture the key values (they cannot be read after a
|
|
353
|
+
* hard delete) and refuse on a restrict. `before`, because a refusal issued
|
|
354
|
+
* once the parent is gone is not a refusal.
|
|
355
|
+
*/
|
|
356
|
+
const prepare = async (context, single) => {
|
|
357
|
+
const parentKeys = single ? await parentKeysOfDelete(context, repo, keys) : await parentKeysOfDeleteMany(context, repo, keys);
|
|
358
|
+
context._cascadeParentKeys = parentKeys;
|
|
359
|
+
const scope = scopeOf(context);
|
|
344
360
|
for (const relation of restrictions) {
|
|
345
|
-
const
|
|
346
|
-
if (
|
|
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
|
-
});
|
|
361
|
-
repo.on("after:delete", async (payload) => {
|
|
362
|
-
const { context } = payload;
|
|
363
|
-
const deletedId = context.id;
|
|
364
|
-
if (!deletedId) {
|
|
365
|
-
logger?.warn?.("Cascade delete skipped: no document ID in context", { model: context.model });
|
|
366
|
-
return;
|
|
361
|
+
const values = parentKeys.get(parentKeyOf(relation, repo)) ?? [];
|
|
362
|
+
if (values.length > 0) await assertUnreferenced(relation, values, scope, batchSize, context, single);
|
|
367
363
|
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
364
|
+
};
|
|
365
|
+
/** AFTER: detach, then cascade — over the captured values, with the forwarded scope. */
|
|
366
|
+
const propagate = async (context) => {
|
|
367
|
+
const parentKeys = context._cascadeParentKeys;
|
|
368
|
+
if (!parentKeys) return;
|
|
369
|
+
const scope = scopeOf(context);
|
|
370
|
+
const soft = context.softDeleted === true;
|
|
371
|
+
const session = context.session;
|
|
372
|
+
const valuesOf = (relation) => parentKeys.get(parentKeyOf(relation, repo)) ?? [];
|
|
373
|
+
const guarded = (verb, fn) => async (relation) => {
|
|
374
|
+
if (valuesOf(relation).length === 0) return;
|
|
379
375
|
try {
|
|
380
|
-
|
|
381
|
-
else await cascadeViaLegacyModel(relation, deletedId, cascadeCtx, logger);
|
|
376
|
+
await fn(relation);
|
|
382
377
|
} catch (error) {
|
|
383
|
-
logger?.error?.(`Cascade
|
|
378
|
+
logger?.error?.(`Cascade ${verb} failed for '${targetNameOf(relation)}'`, {
|
|
384
379
|
parentModel: context.model,
|
|
385
|
-
|
|
386
|
-
relatedModel: cascadeCtx.targetModelName,
|
|
380
|
+
relatedModel: targetNameOf(relation),
|
|
387
381
|
foreignKey: relation.foreignKey,
|
|
388
382
|
error: error.message
|
|
389
383
|
});
|
|
390
384
|
throw error;
|
|
391
385
|
}
|
|
392
386
|
};
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
});
|
|
404
|
-
const txSession = context.session;
|
|
405
|
-
if (detachments.length > 0) await runCascades(detachments, detachOne, parallel, txSession);
|
|
406
|
-
await runCascades(cascades, cascadeOne, parallel, txSession);
|
|
387
|
+
await runCascades(detachments, guarded("detach", (relation) => detach(relation, valuesOf(relation), scope, batchSize)), parallel, session);
|
|
388
|
+
await runCascades(cascades, guarded("delete", (relation) => cascade(relation, valuesOf(relation), scope, batchSize, relation.softDelete ?? soft, logger)), parallel, session);
|
|
389
|
+
};
|
|
390
|
+
repo.on("before:delete", (context) => {
|
|
391
|
+
if (context.id === void 0 || context.id === null) return;
|
|
392
|
+
return prepare(context, true);
|
|
393
|
+
});
|
|
394
|
+
repo.on("after:delete", async (payload) => {
|
|
395
|
+
if (!payload.result) return;
|
|
396
|
+
await propagate(payload.context);
|
|
407
397
|
});
|
|
408
|
-
repo.on("before:deleteMany",
|
|
398
|
+
repo.on("before:deleteMany", (context) => {
|
|
409
399
|
const query = context.query;
|
|
410
400
|
if (!query || Object.keys(query).length === 0) return;
|
|
411
|
-
|
|
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
|
-
}
|
|
446
|
-
});
|
|
447
|
-
repo.on("after:deleteMany", async (payload) => {
|
|
448
|
-
const { context } = payload;
|
|
449
|
-
const ids = context._cascadeIds;
|
|
450
|
-
if (!ids || ids.length === 0) return;
|
|
451
|
-
const isSoftParent = context.softDeleted === true;
|
|
452
|
-
const scopeForward = collectScopeForward(context);
|
|
453
|
-
const cascadeBulk = async (relation) => {
|
|
454
|
-
const shouldSoftDelete = relation.softDelete ?? isSoftParent;
|
|
455
|
-
const cascadeCtx = {
|
|
456
|
-
targetModelName: relation.repo ? relation.repo.Model?.modelName ?? "<unknown>" : relation.model ?? "<unknown>",
|
|
457
|
-
session: context.session,
|
|
458
|
-
shouldSoftDelete,
|
|
459
|
-
user: context.user,
|
|
460
|
-
scopeForward
|
|
461
|
-
};
|
|
462
|
-
try {
|
|
463
|
-
if (relation.repo) await cascadeViaRepoBulkMany(relation, ids, batchSize, cascadeCtx);
|
|
464
|
-
else await cascadeViaLegacyModelMany(relation, ids, batchSize, cascadeCtx, logger);
|
|
465
|
-
} catch (error) {
|
|
466
|
-
logger?.error?.(`Cascade deleteMany failed for '${cascadeCtx.targetModelName}'`, {
|
|
467
|
-
parentModel: context.model,
|
|
468
|
-
relatedModel: cascadeCtx.targetModelName,
|
|
469
|
-
foreignKey: relation.foreignKey,
|
|
470
|
-
error: error.message
|
|
471
|
-
});
|
|
472
|
-
throw error;
|
|
473
|
-
}
|
|
474
|
-
};
|
|
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);
|
|
401
|
+
return prepare(context, false);
|
|
485
402
|
});
|
|
403
|
+
repo.on("after:deleteMany", (payload) => propagate(payload.context));
|
|
486
404
|
}
|
|
487
405
|
};
|
|
488
406
|
}
|
|
489
407
|
/**
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
*
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
*
|
|
505
|
-
* (`batchSize` bounds each query document; see `utils/id-chunks.ts`).
|
|
408
|
+
* ENFORCE that a guarded foreign key is indexed.
|
|
409
|
+
*
|
|
410
|
+
* `restrict` and `detach` issue a query per relation on every delete. MongoDB gives no index
|
|
411
|
+
* for free the way an FK constraint does, so an unindexed `foreignKey` turns each delete into
|
|
412
|
+
* a COLLECTION SCAN of the child collection — invisible in tests (small fixtures) and
|
|
413
|
+
* crippling in production, which is the shape of defect this codebase treats as the default
|
|
414
|
+
* hazard.
|
|
415
|
+
*
|
|
416
|
+
* Documentation was not enough: a type comment saying the key "SHOULD" be indexed stops no
|
|
417
|
+
* scan. This is checked at BIND time, where the declaration is, and THROWS — the policies are
|
|
418
|
+
* opt-in and brand new, so nothing pre-existing can be broken by refusing.
|
|
419
|
+
*
|
|
420
|
+
* Prefix rule, matching Mongo's own: an index on `{a:1, b:1}` serves a query on `a`, so the
|
|
421
|
+
* foreign key qualifies when it is the FIRST key of any declared index. `schema.indexes()`
|
|
422
|
+
* includes path-level `{index: true}` as well as explicit `schema.index(...)` calls.
|
|
506
423
|
*/
|
|
507
|
-
|
|
508
|
-
const
|
|
509
|
-
if (typeof
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
session: ctx.session
|
|
514
|
-
});
|
|
424
|
+
function assertForeignKeyIndexed(relation) {
|
|
425
|
+
const schema = relation.repo?.Model?.schema;
|
|
426
|
+
if (!schema || typeof schema.indexes !== "function") return;
|
|
427
|
+
if (schema.indexes().some(([keys]) => Object.keys(keys)[0] === relation.foreignKey)) return;
|
|
428
|
+
const target = relation.repo?.Model?.modelName ?? relation.model ?? "<unknown>";
|
|
429
|
+
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(...)\`.`);
|
|
515
430
|
}
|
|
516
431
|
/**
|
|
517
|
-
*
|
|
518
|
-
*
|
|
432
|
+
* A TTL index on the PROTECTED parent makes `restrict` a promise the plugin cannot keep.
|
|
433
|
+
*
|
|
434
|
+
* `restrict` means "do not delete this document while anything references it". A TTL index
|
|
435
|
+
* means "delete this document N seconds after its date field, server-side". Mongo's expiry
|
|
436
|
+
* thread runs no application code — no repository, no hook, no count — so it will remove a
|
|
437
|
+
* referenced parent and leave every child dangling, silently, at a time nobody chose.
|
|
438
|
+
*
|
|
439
|
+
* The two are a direct contradiction, so this THROWS rather than warns. Refusing at bind time
|
|
440
|
+
* is the only point where anyone can see both declarations at once; at runtime the expiry
|
|
441
|
+
* simply happens.
|
|
442
|
+
*
|
|
443
|
+
* NOTE this is about the PARENT (the repo declaring the relations). A TTL on the CHILD is
|
|
444
|
+
* fine and even useful — expiring children is how references legitimately drain away.
|
|
519
445
|
*/
|
|
520
|
-
|
|
521
|
-
const
|
|
522
|
-
if (!
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
const
|
|
527
|
-
|
|
528
|
-
deletedAt: /* @__PURE__ */ new Date(),
|
|
529
|
-
...ctx.user ? { deletedBy: ctx.user._id || ctx.user.id } : {}
|
|
530
|
-
}, { session: ctx.session });
|
|
531
|
-
else await RelatedModel.deleteMany(query, { session: ctx.session });
|
|
532
|
-
}
|
|
533
|
-
async function cascadeViaLegacyModelMany(relation, parentIds, batchSize, ctx, logger) {
|
|
534
|
-
const RelatedModel = mongoose.models[relation.model];
|
|
535
|
-
if (!RelatedModel) {
|
|
536
|
-
logger?.warn?.(`Cascade deleteMany skipped: model '${relation.model}' not found`);
|
|
537
|
-
return;
|
|
538
|
-
}
|
|
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
|
-
}
|
|
446
|
+
function assertNoTtlOnProtectedParent(repo, relation) {
|
|
447
|
+
const schema = repo.Model?.schema;
|
|
448
|
+
if (!schema || typeof schema.indexes !== "function") return;
|
|
449
|
+
const ttl = schema.indexes().find(([, options]) => options?.expireAfterSeconds !== void 0);
|
|
450
|
+
if (!ttl) return;
|
|
451
|
+
const parent = repo.Model?.modelName ?? "<unknown>";
|
|
452
|
+
const key = Object.keys(ttl[0])[0];
|
|
453
|
+
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.`);
|
|
547
454
|
}
|
|
548
455
|
/**
|
|
549
456
|
* Execute a list of cascade operations, honoring the `parallel` flag.
|
|
@@ -682,6 +589,12 @@ function randomToken() {
|
|
|
682
589
|
globalThis.crypto.getRandomValues(bytes);
|
|
683
590
|
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
684
591
|
}
|
|
592
|
+
/**
|
|
593
|
+
* @deprecated Use `runIdempotent` from `@classytic/repo-core/idempotency` over
|
|
594
|
+
* `createIdempotencyStore` from `@classytic/mongokit/idempotency` — one claim contract for every
|
|
595
|
+
* layer (body fingerprint, transaction participation, recovery points, `maxAttempts`, ambiguous
|
|
596
|
+
* failures, lapsed-claim sweeps). Removed in mongokit 4.
|
|
597
|
+
*/
|
|
685
598
|
function idempotencyStorePlugin(options = {}) {
|
|
686
599
|
const keyField = options.keyField ?? "key";
|
|
687
600
|
const statusField = options.statusField ?? "status";
|