@classytic/mongokit 3.32.0 → 3.34.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 (33) hide show
  1. package/dist/{PaginationEngine-CSh-P8Nh.mjs → PaginationEngine-GdyX29tE.mjs} +13 -11
  2. package/dist/{Repository-B_1y9cCf.mjs → Repository-BArvU32x.mjs} +149 -36
  3. package/dist/{Repository-BAzymuOz.d.mts → Repository-BdSkQ8vc.d.mts} +27 -4
  4. package/dist/actions/index.d.mts +1 -1
  5. package/dist/actions/index.mjs +1 -1
  6. package/dist/ai/index.d.mts +1 -1
  7. package/dist/better-auth/index.d.mts +79 -1
  8. package/dist/better-auth/index.mjs +59 -6
  9. package/dist/{create-repository-mEdfH9sz.d.mts → create-repository-BU4x9Kb0.d.mts} +4 -4
  10. package/dist/{create-repository-a3DQ5tzt.mjs → create-repository-CPNbktFG.mjs} +2 -2
  11. package/dist/id-chunks-BmAwsqNk.mjs +67 -0
  12. package/dist/{index-Beyr1r4d.d.mts → index-DKEql4cL.d.mts} +1 -1
  13. package/dist/index.d.mts +15 -14
  14. package/dist/index.mjs +60 -54
  15. package/dist/{multi-tenant.plugin-2VtSSm1Y.d.mts → multi-tenant.plugin-BwSvTirB.d.mts} +3 -3
  16. package/dist/{operations-CuzX9m2Z.d.mts → operations-DZodvjC-.d.mts} +22 -3
  17. package/dist/outbox/index.d.mts +1 -1
  18. package/dist/outbox/index.mjs +1 -1
  19. package/dist/pagination/PaginationEngine.mjs +1 -1
  20. package/dist/{plugin-options-BsFH0sTi.d.mts → plugin-options-DXnlKM3B.d.mts} +31 -1
  21. package/dist/plugins/index.d.mts +3 -3
  22. package/dist/plugins/index.mjs +2 -2
  23. package/dist/{repository-Q16ErTu8.d.mts → repository-H_ffGgpE.d.mts} +8 -0
  24. package/dist/testkit/index.d.mts +2 -2
  25. package/dist/testkit/index.mjs +1 -1
  26. package/dist/{timestamp.plugin-CAXzHs9y.mjs → timestamp.plugin-BEc6TANm.mjs} +7 -5
  27. package/dist/{to-plain-BuX1RUZV.d.mts → to-plain-Dyz_qvuo.d.mts} +3 -3
  28. package/dist/{update-w5i-pa_c.mjs → update-jSXp3nu3.mjs} +48 -28
  29. package/dist/utils/index.d.mts +54 -2
  30. package/dist/utils/index.mjs +2 -1
  31. package/dist/{validation-chain.plugin-BihbMQRq.mjs → validation-chain.plugin-CyK1s9Ze.mjs} +219 -20
  32. package/dist/{validation-chain.plugin-BSshLJTA.d.mts → validation-chain.plugin-yQRob3Av.d.mts} +2 -2
  33. package/package.json +17 -17
@@ -1,6 +1,6 @@
1
- import { a as customIdPlugin, c as prefixedId, d as AuditTrailQuery, f as auditTrailPlugin, i as multiTenantPlugin, l as sequentialId, m as auditLogPlugin, n as softDeletePlugin, o as dateSequentialId, p as ensureAuditTrailReady, s as getNextSequence, t as timestampPlugin, u as cachePlugin } from "../timestamp.plugin-CAXzHs9y.mjs";
1
+ import { a as customIdPlugin, c as prefixedId, d as AuditTrailQuery, f as auditTrailPlugin, i as multiTenantPlugin, l as sequentialId, m as auditLogPlugin, n as softDeletePlugin, o as dateSequentialId, p as ensureAuditTrailReady, s as getNextSequence, t as timestampPlugin, u as cachePlugin } from "../timestamp.plugin-BEc6TANm.mjs";
2
2
  import { n as batchOperationsPlugin, t as methodRegistryPlugin } from "../method-registry.plugin-DblM7V6j.mjs";
3
- import { _ as cascadePlugin, a as uniqueField, c as subdocumentPlugin, d as mongoOperationsPlugin, f as leasePlugin, g as elasticSearchPlugin, h as fieldFilterPlugin, i as requireField, l as observabilityPlugin, m as idempotencyStorePlugin, n as blockIf, o as validationChainPlugin, p as immutableStatesPlugin, r as immutableField, s as createTenantContext, t as autoInject, u as MONGOKIT_PLUGIN_NAMES, v as appendOnlyPlugin, y as aggregateHelpersPlugin } from "../validation-chain.plugin-BihbMQRq.mjs";
3
+ import { _ as cascadePlugin, a as uniqueField, c as subdocumentPlugin, d as mongoOperationsPlugin, f as leasePlugin, g as elasticSearchPlugin, h as fieldFilterPlugin, i as requireField, l as observabilityPlugin, m as idempotencyStorePlugin, n as blockIf, o as validationChainPlugin, p as immutableStatesPlugin, r as immutableField, s as createTenantContext, t as autoInject, u as MONGOKIT_PLUGIN_NAMES, v as appendOnlyPlugin, y as aggregateHelpersPlugin } from "../validation-chain.plugin-CyK1s9Ze.mjs";
4
4
  //#region src/plugins/change-log.plugin.ts
5
5
  const PLUGIN_NAME = "changeLog";
6
6
  function isSkipped(context) {
@@ -109,6 +109,14 @@ interface RepositoryOptions {
109
109
  /** Custom ID field used by getById/update/delete (default: '_id').
110
110
  * Set to 'slug', 'code', 'chatId', etc. for non-ObjectId lookups. */
111
111
  idField?: string;
112
+ /**
113
+ * Field carrying the optimistic-concurrency version for `ifVersion` CAS
114
+ * writes. Default `'__v'` — the versionKey every mongoose schema already
115
+ * carries, so CAS works with zero schema changes. Note mongoose only
116
+ * auto-increments `__v` on array-mutating `save()`; mongokit's CAS path
117
+ * `$inc`s it explicitly on every versioned write, which is the contract.
118
+ */
119
+ versionField?: string;
112
120
  /**
113
121
  * How Repository.getAll handles the `search` parameter.
114
122
  * - 'text' (default): uses MongoDB $text; requires a text index.
@@ -1,5 +1,5 @@
1
- import { n as Repository } from "../Repository-BAzymuOz.mjs";
2
- import { n as CreateRepositoryConfig } from "../create-repository-mEdfH9sz.mjs";
1
+ import { n as Repository } from "../Repository-BdSkQ8vc.mjs";
2
+ import { n as CreateRepositoryConfig } from "../create-repository-BU4x9Kb0.mjs";
3
3
  import { Connection, Model, Schema } from "mongoose";
4
4
  import { MongoMemoryReplSet, MongoMemoryServer } from "mongodb-memory-server";
5
5
  //#region src/testkit/index.d.ts
@@ -1,4 +1,4 @@
1
- import { t as createRepository } from "../create-repository-a3DQ5tzt.mjs";
1
+ import { t as createRepository } from "../create-repository-CPNbktFG.mjs";
2
2
  import { createConnection } from "mongoose";
3
3
  //#region src/testkit/index.ts
4
4
  function idempotentStop(server) {
@@ -1,4 +1,4 @@
1
- import { c as ALL_OPERATIONS, t as HOOK_PRIORITY, u as OP_REGISTRY } from "./Repository-B_1y9cCf.mjs";
1
+ import { c as ALL_OPERATIONS, t as HOOK_PRIORITY, u as OP_REGISTRY } from "./Repository-BArvU32x.mjs";
2
2
  import { n as warn } from "./logger-K7dPmu2U.mjs";
3
3
  import { throwIfAborted, withRetry } from "@classytic/repo-core/repository";
4
4
  import mongoose from "mongoose";
@@ -1355,10 +1355,12 @@ function softDeletePlugin(options = {}) {
1355
1355
  const limit = params.limit || 20;
1356
1356
  const skip = (page - 1) * limit;
1357
1357
  let sortSpec = { [deletedField]: -1 };
1358
- if (params.sort) if (typeof params.sort === "string") {
1359
- const sortOrder = params.sort.startsWith("-") ? -1 : 1;
1360
- sortSpec = { [params.sort.startsWith("-") ? params.sort.substring(1) : params.sort]: sortOrder };
1361
- } else sortSpec = params.sort;
1358
+ if (params.sort) {
1359
+ if (typeof params.sort === "string") {
1360
+ const sortOrder = params.sort.startsWith("-") ? -1 : 1;
1361
+ sortSpec = { [params.sort.startsWith("-") ? params.sort.substring(1) : params.sort]: sortOrder };
1362
+ } else sortSpec = params.sort;
1363
+ }
1362
1364
  let query = this.Model.find(combinedFilters).sort(sortSpec).skip(skip).limit(limit);
1363
1365
  if (getDeletedOptions.session) query = query.session(getDeletedOptions.session);
1364
1366
  if (getDeletedOptions.select) {
@@ -1,9 +1,9 @@
1
- import { v as UserContext } from "./repository-Q16ErTu8.mjs";
2
- import { r as FieldPreset } from "./plugin-options-BsFH0sTi.mjs";
1
+ import { v as UserContext } from "./repository-H_ffGgpE.mjs";
2
+ import { r as FieldPreset } from "./plugin-options-DXnlKM3B.mjs";
3
3
  import mongoose, { Schema } from "mongoose";
4
4
  import { CacheAdapter } from "@classytic/repo-core/cache";
5
- import { CrudSchemas, SchemaBuilderOptions } from "@classytic/repo-core/schema";
6
5
  import { HttpError } from "@classytic/repo-core/errors";
6
+ import { CrudSchemas, SchemaBuilderOptions } from "@classytic/repo-core/schema";
7
7
  //#region src/utils/error.d.ts
8
8
  /** Optional structured fields for createError. */
9
9
  interface CreateErrorOptions {
@@ -119,7 +119,26 @@ function compile(filter) {
119
119
  return { [filter.field]: { $nin: [...filter.values] } };
120
120
  case "exists": return filter.exists ? { [filter.field]: { $ne: null } } : { [filter.field]: null };
121
121
  case "like": return { [filter.field]: likeToRegex(filter.pattern, filter.caseSensitivity) };
122
- case "regex": return { [filter.field]: { $regex: filter.pattern } };
122
+ /**
123
+ * `flags` is part of the IR (`FilterRegex.flags` in repo-core) and MUST be
124
+ * carried into `$options` — dropping it silently downgrades every
125
+ * case-insensitive search to case-sensitive.
126
+ *
127
+ * This was live: arc's CRUD list route normalises the parsed query through
128
+ * `toRepositoryFilter` → `policyRecordToFilter`, which correctly produced
129
+ * `{ op:'regex', pattern:'heritage', flags:'i' }` from BOTH parser shapes
130
+ * (mongokit's `/heritage/i` and arc's `{$regex,$options:'i'}`). This line
131
+ * then rebuilt the query without the flags, so `?name[like]=heritage`
132
+ * returned 0 while `?name[like]=Heritage` returned 3 — in a product whose
133
+ * OpenAPI descriptions promise "contains substring (CASE-INSENSITIVE)".
134
+ * Nothing errored; every operator search box in the app just under-returned,
135
+ * and an operator typing a name in lower case concluded the record did not
136
+ * exist.
137
+ */
138
+ case "regex": return { [filter.field]: filter.flags ? {
139
+ $regex: filter.pattern,
140
+ $options: filter.flags
141
+ } : { $regex: filter.pattern } };
123
142
  case "and": {
124
143
  if (filter.children.length === 0) return {};
125
144
  const parts = filter.children.map(compile);
@@ -330,34 +349,35 @@ var LookupBuilder = class LookupBuilder {
330
349
  const stages = [];
331
350
  const usePipelineForm = pipeline || letVars || coerce;
332
351
  let lookupStage;
333
- if (usePipelineForm) if (!pipeline || pipeline.length === 0) {
334
- if (!localField || !foreignField) throw new Error("LookupBuilder: When using pipeline form without a custom pipeline, both localField and foreignField are required to auto-generate the pipeline");
335
- const autoPipeline = [{ $match: { $expr: { $eq: [`$${foreignField}`, `$$${localField}`] } } }];
336
- lookupStage = { $lookup: {
337
- from,
338
- let: {
339
- [localField]: localCorrelation(localField, coerce),
340
- ...letVars || {}
341
- },
342
- pipeline: autoPipeline,
343
- as: outputField
344
- } };
345
- } else {
346
- const safePipeline = this.options.sanitize !== false ? LookupBuilder.sanitizePipeline(pipeline) : pipeline;
347
- let effectiveLet = letVars;
348
- let effectivePipeline = safePipeline;
349
- if (localField && foreignField && !letVars) {
350
- effectiveLet = { lookupJoinVal: localCorrelation(localField, coerce) };
351
- effectivePipeline = [{ $match: { $expr: { $eq: [`$${foreignField}`, "$$lookupJoinVal"] } } }, ...safePipeline];
352
+ if (usePipelineForm) {
353
+ if (!pipeline || pipeline.length === 0) {
354
+ if (!localField || !foreignField) throw new Error("LookupBuilder: When using pipeline form without a custom pipeline, both localField and foreignField are required to auto-generate the pipeline");
355
+ const autoPipeline = [{ $match: { $expr: { $eq: [`$${foreignField}`, `$$${localField}`] } } }];
356
+ lookupStage = { $lookup: {
357
+ from,
358
+ let: {
359
+ [localField]: localCorrelation(localField, coerce),
360
+ ...letVars || {}
361
+ },
362
+ pipeline: autoPipeline,
363
+ as: outputField
364
+ } };
365
+ } else {
366
+ const safePipeline = this.options.sanitize !== false ? LookupBuilder.sanitizePipeline(pipeline) : pipeline;
367
+ let effectiveLet = letVars;
368
+ let effectivePipeline = safePipeline;
369
+ if (localField && foreignField && !letVars) {
370
+ effectiveLet = { lookupJoinVal: localCorrelation(localField, coerce) };
371
+ effectivePipeline = [{ $match: { $expr: { $eq: [`$${foreignField}`, "$$lookupJoinVal"] } } }, ...safePipeline];
372
+ }
373
+ lookupStage = { $lookup: {
374
+ from,
375
+ ...effectiveLet && { let: effectiveLet },
376
+ pipeline: effectivePipeline,
377
+ as: outputField
378
+ } };
352
379
  }
353
- lookupStage = { $lookup: {
354
- from,
355
- ...effectiveLet && { let: effectiveLet },
356
- pipeline: effectivePipeline,
357
- as: outputField
358
- } };
359
- }
360
- else {
380
+ } else {
361
381
  if (!localField || !foreignField) throw new Error("LookupBuilder: localField and foreignField are required for simple lookup");
362
382
  lookupStage = { $lookup: {
363
383
  from,
@@ -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-BuX1RUZV.mjs";
2
- export { type CreateErrorOptions, type CrudSchemasFramework, type IdType, type LoggerConfig, type ParseDuplicateKeyOptions, buildCrudSchemasFromModel, buildCrudSchemasFromMongooseSchema, configureLogger, createError, createFieldPreset, createMemoryCache, filterResponseData, getFieldsForUser, getMongooseProjection, getSchemaIdType, isObjectIdInstance, isValidIdForType, toObjectId, toPlain };
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 };
@@ -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 { c as ALL_OPERATIONS, u as OP_REGISTRY } from "./Repository-B_1y9cCf.mjs";
2
- import { _ as upsert } from "./update-w5i-pa_c.mjs";
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
- await runCascades(relations, cascadeOne, parallel);
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
- context._cascadeIds = (await repo.Model.find(query, { [idField]: 1 }).lean().session(context.session ?? null)).map((doc) => doc[idField]);
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
- await runCascades(relations, cascadeBulk, parallel);
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: parentIds } }, {
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 query = { [relation.foreignKey]: { $in: parentIds } };
353
- if (ctx.shouldSoftDelete) await RelatedModel.updateMany(query, {
354
- deletedAt: /* @__PURE__ */ new Date(),
355
- ...ctx.user ? { deletedBy: ctx.user._id || ctx.user.id } : {}
356
- }, { session: ctx.session });
357
- else await RelatedModel.deleteMany(query, { session: ctx.session });
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;
@@ -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-Q16ErTu8.mjs";
3
- import { c as ValidationChainOptions, i as Logger, l as ValidatorDefinition, o as SoftDeleteOptions, r as FieldPreset, t as CascadeOptions } from "./plugin-options-BsFH0sTi.mjs";
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.32.0",
3
+ "version": "3.34.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.20.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.2",
173
- "@better-auth/api-key": "^1.6.22",
174
- "@better-auth/mongo-adapter": "^1.6.22",
175
- "@biomejs/biome": "^2.4.10",
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.21.0",
178
- "@types/node": "^22.0.0",
179
- "@vitest/coverage-v8": "^4.1.4",
180
- "ajv": "^8.18.0",
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.23",
182
+ "better-auth": "^1.6.29",
183
183
  "dotenv": "^17.4.2",
184
- "knip": "^6.31.0",
184
+ "knip": "^6.32.2",
185
185
  "mongodb": "^7.5.0",
186
- "mongodb-memory-server": "^10.4.3",
187
- "mongoose": "^9.9.1",
188
- "publint": "^0.3.18",
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.0",
191
- "vitest": "^4.1.4"
190
+ "typescript": "~5.9.3",
191
+ "vitest": "^4.1.10"
192
192
  }
193
193
  }