@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.
Files changed (35) hide show
  1. package/dist/{PaginationEngine-DQ7beTCR.mjs → PaginationEngine-Dvl8jcRY.mjs} +33 -17
  2. package/dist/{Repository-D7fDpyFx.mjs → Repository-BMgPSkqZ.mjs} +279 -625
  3. package/dist/{Repository-IxI983_m.d.mts → Repository-CR8Y9g2E.d.mts} +57 -32
  4. package/dist/actions/index.mjs +1 -1
  5. package/dist/ai/index.d.mts +1 -1
  6. package/dist/better-auth/index.mjs +1 -1
  7. package/dist/{create-repository-DOe9E_5F.mjs → create-repository-BzBUMvd1.mjs} +2 -2
  8. package/dist/{create-repository-CRXjDa3N.d.mts → create-repository-CAle1MSU.d.mts} +4 -4
  9. package/dist/idempotency/index.d.mts +23 -0
  10. package/dist/idempotency/index.mjs +134 -0
  11. package/dist/index.d.mts +58 -32
  12. package/dist/index.mjs +285 -675
  13. package/dist/indexes/verify-indexes.d.mts +14 -1
  14. package/dist/indexes/verify-indexes.mjs +54 -7
  15. package/dist/{multi-tenant.plugin-B-tzNeYL.d.mts → multi-tenant.plugin-CBgCo9im.d.mts} +18 -2
  16. package/dist/outbox/index.d.mts +1 -1
  17. package/dist/outbox/index.mjs +6 -4
  18. package/dist/pagination/PaginationEngine.mjs +1 -1
  19. package/dist/{plugin-options-BuvtOSZf.d.mts → plugin-options-Zk46lD6F.d.mts} +15 -1
  20. package/dist/plugins/index.d.mts +8 -3
  21. package/dist/plugins/index.mjs +98 -25
  22. package/dist/{repository-ZbLtL7Nk.d.mts → repository-odl03582.d.mts} +20 -2
  23. package/dist/sync/index.d.mts +40 -0
  24. package/dist/sync/index.mjs +334 -0
  25. package/dist/tenant/index.mjs +2 -0
  26. package/dist/testkit/index.d.mts +2 -2
  27. package/dist/testkit/index.mjs +1 -1
  28. package/dist/{timestamp.plugin-B5eiaItQ.mjs → timestamp.plugin-DF0gJB6p.mjs} +86 -2
  29. package/dist/{to-plain-B8ufk3Qz.d.mts → to-plain-k7SjNlSS.d.mts} +2 -2
  30. package/dist/transaction-B5L9glO0.mjs +533 -0
  31. package/dist/{update-c_01A0if.mjs → update-DG0fCwRJ.mjs} +168 -11
  32. package/dist/utils/index.d.mts +1 -1
  33. package/dist/{validation-chain.plugin-Bju1D1Sn.mjs → validation-chain.plugin-BLY2gLEk.mjs} +192 -279
  34. package/dist/{validation-chain.plugin-DvrLJIbJ.d.mts → validation-chain.plugin-DYp9E7K2.d.mts} +25 -3
  35. package/package.json +13 -4
@@ -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 { c as ALL_OPERATIONS, u as OP_REGISTRY } from "./Repository-D7fDpyFx.mjs";
3
- import { S as upsert } from "./update-c_01A0if.mjs";
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
- * Collect top-level parent-context fields that a target plugin (multi-tenant,
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
- * 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.
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 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);
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
- * ENFORCE that a guarded foreign key is indexed.
242
+ * RESTRICT — refuse while any child still references a parent being deleted.
235
243
  *
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.
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
- * 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.
249
+ * ## An application delete GUARD, not `ON DELETE RESTRICT`
245
250
  *
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.
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 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(...)\`.`);
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
- * 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.
276
+ * DETACH — the RDBMS `SET NULL`: clear the pointer, keep the child.
268
277
  *
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.
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 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.`);
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
- * `detach` — clear the reference through the target REPOSITORY (`SET NULL`).
293
+ * CASCADE — delete the children, soft when the parent was soft (or the relation
294
+ * says so).
283
295
  *
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.
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 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);
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
- 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
- }
302
- function collectScopeForward(context) {
303
- const out = {};
304
- if (context.organizationId !== void 0) out.organizationId = context.organizationId;
305
- if (context.tenantId !== void 0) out.tenantId = context.tenantId;
306
- if (context.user !== void 0) out.user = context.user;
307
- return out;
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 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.
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
- /** Everything else keeps the historical behaviour — an absent `onDelete` is `cascade`. */
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
- if (restrictions.length > 0) repo.on("before:delete", async (context) => {
341
- const id = context.id;
342
- if (!id) return;
343
- const scopeForward = collectScopeForward(context);
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 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
- });
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
- const isSoftParent = context.softDeleted === true;
369
- const scopeForward = collectScopeForward(context);
370
- const cascadeOne = async (relation) => {
371
- const shouldSoftDelete = relation.softDelete ?? isSoftParent;
372
- const cascadeCtx = {
373
- targetModelName: relation.repo ? relation.repo.Model?.modelName ?? "<unknown>" : relation.model ?? "<unknown>",
374
- session: context.session,
375
- shouldSoftDelete,
376
- user: context.user,
377
- scopeForward
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
- if (relation.repo) await cascadeViaRepoBulk(relation, deletedId, cascadeCtx);
381
- else await cascadeViaLegacyModel(relation, deletedId, cascadeCtx, logger);
376
+ await fn(relation);
382
377
  } catch (error) {
383
- logger?.error?.(`Cascade delete failed for '${cascadeCtx.targetModelName}'`, {
378
+ logger?.error?.(`Cascade ${verb} failed for '${targetNameOf(relation)}'`, {
384
379
  parentModel: context.model,
385
- parentId: String(deletedId),
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
- * `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);
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", async (context) => {
398
+ repo.on("before:deleteMany", (context) => {
409
399
  const query = context.query;
410
400
  if (!query || Object.keys(query).length === 0) return;
411
- const idField = repo.idField || "_id";
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
- * Repo-routed cascade for a single parent delete.
491
- * Calls `repo.deleteMany({ [fk]: parentId }, { mode })` — respects all target
492
- * hooks (multi-tenant, audit, target's own soft-delete plugin).
493
- */
494
- async function cascadeViaRepoBulk(relation, parentId, ctx) {
495
- const targetRepo = relation.repo;
496
- 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.`);
497
- await targetRepo.deleteMany({ [relation.foreignKey]: parentId }, {
498
- ...ctx.scopeForward,
499
- mode: ctx.shouldSoftDelete ? "soft" : "hard",
500
- session: ctx.session
501
- });
502
- }
503
- /**
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`).
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
- async function cascadeViaRepoBulkMany(relation, parentIds, batchSize, ctx) {
508
- const targetRepo = relation.repo;
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.`);
510
- for (const chunk of idChunks(parentIds, batchSize)) await targetRepo.deleteMany({ [relation.foreignKey]: { $in: chunk } }, {
511
- ...ctx.scopeForward,
512
- mode: ctx.shouldSoftDelete ? "soft" : "hard",
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
- * Legacy cascade via `mongoose.models[name]`. Bypasses target hooks.
518
- * Retained for backwards compatibility with existing cascadePlugin consumers.
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
- async function cascadeViaLegacyModel(relation, parentId, ctx, logger) {
521
- const RelatedModel = mongoose.models[relation.model];
522
- if (!RelatedModel) {
523
- logger?.warn?.(`Cascade delete skipped: model '${relation.model}' not found`);
524
- return;
525
- }
526
- const query = { [relation.foreignKey]: parentId };
527
- if (ctx.shouldSoftDelete) await RelatedModel.updateMany(query, {
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";
@@ -1,6 +1,6 @@
1
1
  import { _ as PopulateSpec, g as ObjectId, x as SortSpec, y as SelectSpec } from "./pagination-BpIv2bdS.mjs";
2
- import { h as RepositoryInstance, l as Plugin, p as RepositoryContext } from "./repository-ZbLtL7Nk.mjs";
3
- import { c as ValidationChainOptions, i as Logger, l as ValidatorDefinition, o as SoftDeleteOptions, r as FieldPreset, t as CascadeOptions } from "./plugin-options-BuvtOSZf.mjs";
2
+ import { h as RepositoryInstance, l as Plugin, p as RepositoryContext } from "./repository-odl03582.mjs";
3
+ import { c as ValidationChainOptions, i as Logger, l as ValidatorDefinition, o as SoftDeleteOptions, r as FieldPreset, t as CascadeOptions } from "./plugin-options-Zk46lD6F.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";
@@ -113,6 +113,19 @@ declare function appendOnlyPlugin(options?: AppendOnlyPluginOptions): Plugin;
113
113
  declare function auditLogPlugin(logger: Logger): Plugin;
114
114
  //#endregion
115
115
  //#region src/plugins/batch-operations.plugin.d.ts
116
+ /** Options accepted by `repo.bulkWrite`. Plugin context may ride alongside. */
117
+ interface BulkWriteOptions {
118
+ /** Stop at the first failure. Default `true`, matching the driver. */
119
+ ordered?: boolean;
120
+ session?: unknown;
121
+ writeConcern?: Record<string, unknown>;
122
+ bypassDocumentValidation?: boolean;
123
+ comment?: unknown;
124
+ let?: Record<string, unknown>;
125
+ ignoreUndefined?: boolean;
126
+ /** Plugin context (tenant id, actor, ...) - spread into the op context. */
127
+ [key: string]: unknown;
128
+ }
116
129
  declare function batchOperationsPlugin(): Plugin;
117
130
  interface BatchOperationsMethods {
118
131
  /**
@@ -303,6 +316,12 @@ interface IdempotencyStoreMethods<TProgress = Record<string, unknown>, TResult =
303
316
  error?: string;
304
317
  }): Promise<boolean>;
305
318
  }
319
+ /**
320
+ * @deprecated Use `runIdempotent` from `@classytic/repo-core/idempotency` over
321
+ * `createIdempotencyStore` from `@classytic/mongokit/idempotency` — one claim contract for every
322
+ * layer (body fingerprint, transaction participation, recovery points, `maxAttempts`, ambiguous
323
+ * failures, lapsed-claim sweeps). Removed in mongokit 4.
324
+ */
306
325
  declare function idempotencyStorePlugin(options?: IdempotencyStorePluginOptions): Plugin;
307
326
  //#endregion
308
327
  //#region src/plugins/immutable-states.plugin.d.ts
@@ -770,6 +789,9 @@ interface SoftDeleteMethods<TDoc> {
770
789
  */
771
790
  restore(id: string | ObjectId, options?: {
772
791
  session?: unknown;
792
+ organizationId?: unknown;
793
+ bypassTenant?: boolean;
794
+ [key: string]: unknown;
773
795
  }): Promise<TDoc>;
774
796
  /**
775
797
  * Get paginated list of soft-deleted documents
@@ -942,4 +964,4 @@ declare function immutableField(field: string): ValidatorDefinition;
942
964
  */
943
965
  declare function uniqueField(field: string, errorMessage?: string): ValidatorDefinition;
944
966
  //#endregion
945
- export { IdempotencyStoreMethods as A, auditLogPlugin as B, LeaseMethods as C, ImmutableStatesPluginOptions as D, ImmutableClaimView as E, elasticSearchPlugin as F, appendOnlyPlugin as H, cascadePlugin as I, BatchOperationsMethods as L, idempotencyStorePlugin as M, fieldFilterPlugin as N, immutableStatesPlugin as O, ElasticSearchOptions as P, BulkWriteResult as R, methodRegistryPlugin as S, leasePlugin as T, AggregateHelpersMethods as U, AppendOnlyPluginOptions as V, aggregateHelpersPlugin as W, OperationMetric as _, requireField as a, mongoOperationsPlugin as b, timestampPlugin as c, createTenantContext as d, SubdocumentMethods as f, ObservabilityOptions as g, softDeletePlugin as h, immutableField as i, IdempotencyStorePluginOptions as j, IdempotencyClaim as k, TenantContext as l, SoftDeleteMethods as m, autoInject as n, uniqueField as o, subdocumentPlugin as p, blockIf as r, validationChainPlugin as s, OperationType as t, TenantStore as u, observabilityPlugin as v, LeasePluginOptions as w, MethodRegistryRepository as x, MongoOperationsMethods as y, batchOperationsPlugin as z };
967
+ export { IdempotencyStoreMethods as A, batchOperationsPlugin as B, LeaseMethods as C, ImmutableStatesPluginOptions as D, ImmutableClaimView as E, elasticSearchPlugin as F, aggregateHelpersPlugin as G, AppendOnlyPluginOptions as H, cascadePlugin as I, BatchOperationsMethods as L, idempotencyStorePlugin as M, fieldFilterPlugin as N, immutableStatesPlugin as O, ElasticSearchOptions as P, BulkWriteOptions as R, methodRegistryPlugin as S, leasePlugin as T, appendOnlyPlugin as U, auditLogPlugin as V, AggregateHelpersMethods as W, OperationMetric as _, requireField as a, mongoOperationsPlugin as b, timestampPlugin as c, createTenantContext as d, SubdocumentMethods as f, ObservabilityOptions as g, softDeletePlugin as h, immutableField as i, IdempotencyStorePluginOptions as j, IdempotencyClaim as k, TenantContext as l, SoftDeleteMethods as m, autoInject as n, uniqueField as o, subdocumentPlugin as p, blockIf as r, validationChainPlugin as s, OperationType as t, TenantStore as u, observabilityPlugin as v, LeasePluginOptions as w, MethodRegistryRepository as x, MongoOperationsMethods as y, BulkWriteResult as z };