@classytic/mongokit 3.13.3 → 3.14.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.
@@ -1,4 +1,4 @@
1
- import { a as count, b as LookupBuilder, c as getByQuery, d as deleteById, f as deleteByQuery, h as createMany, l as getOrCreate, m as create, n as update, o as exists, r as updateByQuery, s as getById, t as findOneAndUpdate, x as compileFilterToMongo, y as distinct } from "./update-C-3viiw1.mjs";
1
+ import { a as count, b as LookupBuilder, c as getByQuery, d as deleteById, f as deleteByQuery, h as createMany, l as getOrCreate, m as create, n as update, o as exists, r as updateByQuery, s as getById, t as findOneAndUpdate, x as compileFilterToMongo, y as distinct } from "./update-BXMnCvO6.mjs";
2
2
  import { n as warn } from "./logger-DprijP5h.mjs";
3
3
  import { n as isDuplicateKeyError, r as parseDuplicateKeyError, t as createError } from "./error-B00pI7dV.mjs";
4
4
  import { t as PaginationEngine } from "./PaginationEngine-CfQRM1yz.mjs";
@@ -7,7 +7,7 @@ import { r as isValidIdForType, t as getSchemaIdType } from "./id-resolution-B0O
7
7
  import { isFilter } from "@classytic/repo-core/filter";
8
8
  import mongoose from "mongoose";
9
9
  import { HOOK_PRIORITY } from "@classytic/repo-core/hooks";
10
- import { RepositoryBase, validatePluginOrder } from "@classytic/repo-core/repository";
10
+ import { RepositoryBase, runChunkedPurge, validatePluginOrder } from "@classytic/repo-core/repository";
11
11
  import { compileUpdateSpecToMongo, isUpdateSpec } from "@classytic/repo-core/update";
12
12
  import { decodeAggCursor, encodeAggCursor, isKeysetMode, normalizeGroupBy, validateMeasures } from "@classytic/repo-core/aggregate";
13
13
  //#region src/operations.ts
@@ -813,6 +813,74 @@ function buildKeysetPredicate(sort, cursor) {
813
813
  return { $match: branches.length === 1 ? branches[0] : { $or: branches } };
814
814
  }
815
815
  //#endregion
816
+ //#region src/actions/purge.ts
817
+ /**
818
+ * Build a `PurgePort` bound to a repository + the `field = value`
819
+ * predicate the purge targets.
820
+ */
821
+ function createMongoPurgePort(repo, field, value, session) {
822
+ const filter = { [field]: value };
823
+ const baseOpts = {
824
+ session,
825
+ bypassTenant: true
826
+ };
827
+ return { async purgeChunk(strategy, limit) {
828
+ if (strategy.type === "anonymize") {
829
+ if (Object.values(strategy.fields).some((v) => typeof v === "function")) return purgeAnonymizeFunctional(repo, filter, strategy.fields, limit, session);
830
+ }
831
+ const idDocs = await repo.Model.find(filter, { _id: 1 }).limit(limit).session(session ?? null).lean().exec();
832
+ if (idDocs.length === 0) return 0;
833
+ const chunkFilter = {
834
+ _id: { $in: idDocs.map((d) => d._id) },
835
+ [field]: value
836
+ };
837
+ switch (strategy.type) {
838
+ case "hard":
839
+ await repo.deleteMany(chunkFilter, {
840
+ ...baseOpts,
841
+ mode: "hard"
842
+ });
843
+ return idDocs.length;
844
+ case "soft":
845
+ await repo.updateMany(chunkFilter, { $set: {
846
+ [strategy.deletedField ?? "deleted"]: true,
847
+ [strategy.deletedAtField ?? "deletedAt"]: /* @__PURE__ */ new Date()
848
+ } }, baseOpts);
849
+ return idDocs.length;
850
+ case "anonymize":
851
+ await repo.updateMany(chunkFilter, { $set: strategy.fields }, baseOpts);
852
+ return idDocs.length;
853
+ }
854
+ } };
855
+ }
856
+ /**
857
+ * Anonymize with at least one function-form field replacer. Fetches the
858
+ * full docs (function needs the row) and issues ONE `bulkWrite` with N
859
+ * heterogeneous `updateOne` ops. Two round-trips per chunk regardless
860
+ * of N — vs the N+1 of per-doc `update()` fan-out.
861
+ *
862
+ * Mongo's `bulkWrite` is ordered: false → independent failures don't
863
+ * abort the batch. For idempotent anonymize (same row anonymized twice
864
+ * is the same write), this matches the at-least-once contract.
865
+ */
866
+ async function purgeAnonymizeFunctional(repo, filter, fields, limit, session) {
867
+ const docs = await repo.Model.find(filter).limit(limit).session(session ?? null).lean().exec();
868
+ if (docs.length === 0) return 0;
869
+ const operations = docs.map((doc) => {
870
+ const set = {};
871
+ for (const [k, v] of Object.entries(fields)) set[k] = typeof v === "function" ? v(doc) : v;
872
+ return { updateOne: {
873
+ filter: { _id: doc._id },
874
+ update: { $set: set }
875
+ } };
876
+ });
877
+ await repo.Model.bulkWrite(operations, {
878
+ ordered: false,
879
+ session: session ?? void 0
880
+ });
881
+ return docs.length;
882
+ }
883
+ //#endregion
816
884
  //#region src/query/AggregationBuilder.ts
817
885
  /**
818
886
  * Normalize SortSpec to MongoDB's strict format (1 | -1)
@@ -2249,7 +2317,95 @@ var Repository = class extends RepositoryBase {
2249
2317
  return this._runOp("exists", context, () => exists(this.Model, context.query || query, options));
2250
2318
  }
2251
2319
  /**
2252
- * Update document by ID
2320
+ * Update a document by id and return the post-update doc.
2321
+ *
2322
+ * Compiles to `Model.findOneAndUpdate(filter, { $set: data }, { new:
2323
+ * true, runValidators: true })` under the hood and routes through the
2324
+ * full plugin pipeline — `multiTenantPlugin` injects the tenant scope,
2325
+ * `auditTrailPlugin` records the change, `cachePlugin` invalidates,
2326
+ * `softDeletePlugin` etc. all compose. Returns `null` on miss (the
2327
+ * `MinimalRepo` contract; pass `throwOnNotFound: true` for the legacy
2328
+ * throw path).
2329
+ *
2330
+ * **Anti-pattern to avoid** — calling `Model.findOneAndUpdate()`
2331
+ * directly on the raw mongoose model bypasses every plugin:
2332
+ *
2333
+ * ```ts
2334
+ * // ❌ Bypasses multi-tenant scope, audit, cache invalidation:
2335
+ * const doc = await CampaignModel.findOneAndUpdate(
2336
+ * { _id: id, organizationId: ctx.orgId }, // hand-rolled tenant filter
2337
+ * { $set: patch, $currentDate: { updatedAt: 1 } },
2338
+ * { new: true, runValidators: true }
2339
+ * );
2340
+ * return doc ? toEntity(doc) : null;
2341
+ *
2342
+ * // ✅ Plugin-routed; tenant + audit + cache auto-applied:
2343
+ * const doc = await campaignRepo.update(id, patch, repoOptionsFromCtx(ctx));
2344
+ * return doc ? toEntity(doc) : null;
2345
+ * ```
2346
+ *
2347
+ * The raw-mongoose path is a recurring source of cross-tenant leaks
2348
+ * (tenant filter forgotten on the next refactor), audit gaps (changes
2349
+ * invisible to the compliance trail), and stale-cache bugs (cache
2350
+ * invalidation skipped). Always route mutations through this method —
2351
+ * the `repo.update()` keystroke count beats raw mongoose AND composes
2352
+ * with the policy stack.
2353
+ *
2354
+ * **CAS / status-precondition writes — use `repo.claim()`, not `update()`.**
2355
+ * A `findOneAndUpdate` whose filter includes an expected-state check
2356
+ * (`{ _id, status: 'pending' }` → `{ $set: { status: 'shipped' } }`)
2357
+ * is a compare-and-set, not a plain update. Reach for the `claim()`
2358
+ * primitive — it expresses the transition as `{ from, to }`, returns
2359
+ * `null` on race-loss (so callers can branch on retry), and routes
2360
+ * through the same plugin pipeline:
2361
+ *
2362
+ * ```ts
2363
+ * // ❌ Hand-rolled CAS — bypasses plugins, no typed race-loss signal:
2364
+ * const doc = await CampaignModel.findOneAndUpdate(
2365
+ * { _id: id, organizationId: ctx.orgId, status: 'pending' },
2366
+ * { $set: { status: 'sent', sentAt: new Date() } },
2367
+ * { new: true }
2368
+ * );
2369
+ * if (!doc) throw new ConcurrentTransitionError(id);
2370
+ *
2371
+ * // ✅ Plugin-routed CAS with explicit transition + race-loss = null:
2372
+ * const doc = await campaignRepo.claim(
2373
+ * id,
2374
+ * { from: 'pending', to: 'sent' },
2375
+ * { sentAt: new Date() },
2376
+ * repoOptionsFromCtx(ctx),
2377
+ * );
2378
+ * if (!doc) throw new ConcurrentTransitionError(id, 'pending', 'sent');
2379
+ * ```
2380
+ *
2381
+ * Pair `claim()` with `defineStateMachine()` from
2382
+ * `@classytic/primitives/state-machine` for the modelling layer
2383
+ * (`assertTransition` catches illegal transitions sync, `claim` enforces
2384
+ * the race-safe write) — see mongokit's CLAUDE.md "State-machine +
2385
+ * claim() pairing" recipe. For pure dedup-on-id without a state
2386
+ * transition (idempotency keys, webhook receivers), use `getOrCreate()`
2387
+ * — `claim()` with `from === to === id` works too but is heavier.
2388
+ *
2389
+ * Domain-layer mapping (`doc → entity`) belongs on top of this call,
2390
+ * not inside mongokit. The canonical pattern is a 3-line host helper:
2391
+ *
2392
+ * ```ts
2393
+ * async function updateAndMap<TDoc, TEntity>(
2394
+ * repo: Repository<TDoc>,
2395
+ * id: string,
2396
+ * patch: Partial<TDoc>,
2397
+ * ctx: RequestContext,
2398
+ * mapper: (doc: TDoc) => TEntity,
2399
+ * ): Promise<TEntity | null> {
2400
+ * const doc = await repo.update(id, patch, repoOptionsFromCtx(ctx));
2401
+ * return doc ? mapper(doc) : null;
2402
+ * }
2403
+ * ```
2404
+ *
2405
+ * Mongokit deliberately doesn't ship `updateAndMap` — host mapper
2406
+ * signatures vary (`Result<T, E>`, throw-on-miss, audit-stamped
2407
+ * mappers that take `ctx`, tx-scoped mappers, …) and absorbing a
2408
+ * single shape would lock every consumer into it.
2253
2409
  */
2254
2410
  async update(id, data, options = {}) {
2255
2411
  const context = await this._buildContext("update", {
@@ -2859,6 +3015,17 @@ var Repository = class extends RepositoryBase {
2859
3015
  });
2860
3016
  }
2861
3017
  /**
3018
+ * Compliance-grade cleanup primitive — see `StandardRepo.purgeByField`
3019
+ * for the cross-kit contract. Mongokit composes the kit-agnostic
3020
+ * `runChunkedPurge` orchestrator (loop, signal, progress, result
3021
+ * envelope) with `createMongoPurgePort` (driver-specific id selection
3022
+ * + strategy writes). See [actions/purge.ts](./actions/purge.ts) for
3023
+ * the port implementation.
3024
+ */
3025
+ async purgeByField(field, value, strategy, options = {}) {
3026
+ return runChunkedPurge(strategy, options, createMongoPurgePort(this, field, value, options.session));
3027
+ }
3028
+ /**
2862
3029
  * Kit-native aggregation pipeline — takes a MongoDB stage array and
2863
3030
  * returns the raw pipeline output. Use this for `$lookup`, `$unwind`,
2864
3031
  * `$facet`, `$graphLookup`, and any other mongo-specific power
@@ -1,2 +1,2 @@
1
- import { a as create_d_exports, i as delete_d_exports, n as update_d_exports, o as aggregate_d_exports, r as read_d_exports } from "../index-BbHaKwi5.mjs";
1
+ import { a as create_d_exports, i as delete_d_exports, n as update_d_exports, o as aggregate_d_exports, r as read_d_exports } from "../index-Dccha9dw.mjs";
2
2
  export { aggregate_d_exports as aggregate, create_d_exports as create, delete_d_exports as deleteActions, read_d_exports as read, update_d_exports as update };
@@ -1,5 +1,5 @@
1
1
  import { t as __exportAll } from "../chunk-CfYAbeIz.mjs";
2
- import { g as create_exports, i as update_exports, p as delete_exports, u as read_exports, v as aggregate_exports } from "../update-C-3viiw1.mjs";
2
+ import { g as create_exports, i as update_exports, p as delete_exports, u as read_exports, v as aggregate_exports } from "../update-BXMnCvO6.mjs";
3
3
  //#region src/actions/index.ts
4
4
  var actions_exports = /* @__PURE__ */ __exportAll({
5
5
  aggregate: () => aggregate_exports,
@@ -1,2 +1,2 @@
1
- import { n as createMongooseAdapter, r as isMongooseModel, t as MongooseAdapter } from "../adapter-DTDMnUOH.mjs";
1
+ import { n as createMongooseAdapter, r as isMongooseModel, t as MongooseAdapter } from "../adapter-gG_uyFSZ.mjs";
2
2
  export { MongooseAdapter, createMongooseAdapter, isMongooseModel };
@@ -10,6 +10,58 @@ function isMongooseModel(value) {
10
10
  }
11
11
  //#endregion
12
12
  //#region src/adapter/mongoose-adapter.ts
13
+ const REQUIRED_REPO_METHODS = [
14
+ "getAll",
15
+ "getById",
16
+ "create",
17
+ "update",
18
+ "delete"
19
+ ];
20
+ function describeValue(value) {
21
+ if (value === null) return "null";
22
+ if (value === void 0) return "undefined";
23
+ const t = typeof value;
24
+ if (t !== "object" && t !== "function") return t;
25
+ const ctor = value.constructor?.name;
26
+ return ctor && ctor !== "Object" ? `${t} (${ctor})` : t;
27
+ }
28
+ function formatInvalidModelError(model) {
29
+ const got = describeValue(model);
30
+ const hint = typeof model === "object" && model !== null && "paths" in model ? "Got a Schema — pass the value returned by mongoose.model('Name', schema), not the schema itself." : "Pass the value returned by mongoose.model('Name', schema).";
31
+ return [
32
+ "MongooseAdapter: invalid `model` — expected a Mongoose Model.",
33
+ `Got: ${got}`,
34
+ hint,
35
+ "",
36
+ "Example:",
37
+ " import { Repository, createMongooseAdapter } from '@classytic/mongokit';",
38
+ " const ProductModel = mongoose.model('Product', schema);",
39
+ " const adapter = createMongooseAdapter({",
40
+ " model: ProductModel,",
41
+ " repository: new Repository(ProductModel),",
42
+ " });"
43
+ ].join("\n");
44
+ }
45
+ function formatInvalidRepositoryError(repository, model) {
46
+ const passedTheModel = repository != null && typeof repository === "object" && repository === model;
47
+ const looksLikeMongooseModel = isMongooseModel(repository);
48
+ const missing = repository && typeof repository === "object" ? REQUIRED_REPO_METHODS.filter((m) => typeof repository[m] !== "function") : [...REQUIRED_REPO_METHODS];
49
+ const cause = passedTheModel ? "Got the same Mongoose Model that was passed as `model` — a Model is not a Repository." : looksLikeMongooseModel ? "Got a Mongoose Model — wrap it in a Repository first." : `Got: ${describeValue(repository)}` + (missing.length && missing.length < REQUIRED_REPO_METHODS.length ? ` (missing method${missing.length === 1 ? "" : "s"}: ${missing.join(", ")})` : "");
50
+ return [
51
+ "MongooseAdapter: invalid `repository` — expected a Repository instance",
52
+ `(or any object implementing ${REQUIRED_REPO_METHODS.join(", ")}).`,
53
+ cause,
54
+ "",
55
+ "Fix: pass `new Repository(model)` from @classytic/mongokit.",
56
+ "",
57
+ "Example:",
58
+ " import { Repository, createMongooseAdapter } from '@classytic/mongokit';",
59
+ " const adapter = createMongooseAdapter({",
60
+ " model: ProductModel,",
61
+ " repository: new Repository(ProductModel),",
62
+ " });"
63
+ ].join("\n");
64
+ }
13
65
  /**
14
66
  * Mongoose data adapter — implements the `DataAdapter<TDoc>` contract from
15
67
  * `@classytic/repo-core/adapter`.
@@ -21,8 +73,8 @@ var MongooseAdapter = class {
21
73
  repository;
22
74
  schemaGenerator;
23
75
  constructor(options) {
24
- if (!isMongooseModel(options.model)) throw new TypeError("MongooseAdapter: Invalid model. Expected Mongoose Model instance.\nUsage: createMongooseAdapter({ model: YourModel, repository: yourRepo })");
25
- if (!isRepository(options.repository)) throw new TypeError("MongooseAdapter: Invalid repository. Expected MinimalRepo instance.\nUsage: createMongooseAdapter({ model: YourModel, repository: yourRepo })");
76
+ if (!isMongooseModel(options.model)) throw new TypeError(formatInvalidModelError(options.model));
77
+ if (!isRepository(options.repository)) throw new TypeError(formatInvalidRepositoryError(options.repository, options.model));
26
78
  this.model = options.model;
27
79
  this.repository = asRepositoryLike(options.repository);
28
80
  this.schemaGenerator = options.schemaGenerator;
@@ -146,7 +198,15 @@ var MongooseAdapter = class {
146
198
  };
147
199
  function createMongooseAdapter(modelOrOptions, repository) {
148
200
  if (isMongooseModel(modelOrOptions)) {
149
- if (!repository) throw new TypeError("createMongooseAdapter: repository is required when using 2-arg form.\nUsage: createMongooseAdapter(Model, repository)");
201
+ if (!repository) throw new TypeError([
202
+ "createMongooseAdapter: `repository` argument is required.",
203
+ "",
204
+ "Fix: pass `new Repository(model)` from @classytic/mongokit.",
205
+ "",
206
+ "Example:",
207
+ " import { Repository, createMongooseAdapter } from '@classytic/mongokit';",
208
+ " const adapter = createMongooseAdapter(ProductModel, new Repository(ProductModel));"
209
+ ].join("\n"));
150
210
  return new MongooseAdapter({
151
211
  model: modelOrOptions,
152
212
  repository
@@ -1,5 +1,5 @@
1
- import { n as Repository } from "../Repository-CNBTCVat.mjs";
2
- import { n as createMongooseAdapter } from "../adapter-DTDMnUOH.mjs";
1
+ import { n as Repository } from "../Repository-BGL1Pq8w.mjs";
2
+ import { n as createMongooseAdapter } from "../adapter-gG_uyFSZ.mjs";
3
3
  import { pluralizeBetterAuthCollection, resolveBetterAuthCollections } from "@classytic/repo-core/better-auth";
4
4
  //#region src/better-auth/index.ts
5
5
  /**
@@ -175,7 +175,9 @@ declare function getById<TDoc = AnyDocument>(Model: Model<TDoc>, id: string | Ob
175
175
  * @returns Document or null
176
176
  * @throws Error if document not found and throwOnNotFound is true
177
177
  */
178
- declare function getByQuery<TDoc = AnyDocument>(Model: Model<TDoc>, query: Record<string, unknown>, options?: OperationOptions): Promise<TDoc | null>;
178
+ declare function getByQuery<TDoc = AnyDocument>(Model: Model<TDoc>, query: Record<string, unknown>, options?: OperationOptions & {
179
+ sort?: SortSpec;
180
+ }): Promise<TDoc | null>;
179
181
  /**
180
182
  * Get document by query without throwing (returns null if not found)
181
183
  */
package/dist/index.d.mts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { $ as RepositoryEvent, A as KeysetPaginationOptions, B as ObjectId, C as FieldPreset, Ct as IControllerResponse, D as InferDocument, Dt as LookupOptions, E as HookMode, Et as LookupBuilder, F as MiddlewareContext, G as Plugin, H as OperationOptions, I as MinMaxResult, J as PopulateSpec, K as PluginFunction, L as MinimalRepoView, M as LookupPopulateOptions, N as LookupPopulateResult, O as InferRawDoc, P as Middleware, Q as RepositoryContext, R as MongoOperatorUpdate, S as EventPhase, St as IController, T as GroupResult, Tt as IResponseFormatter, U as PaginationConfig, V as OffsetPaginationOptions, W as PartialBy, X as ReadOptions, Y as PrioritizedHook, Z as ReadPreferenceType, _ as DeleteManyResult, _t as ValidationChainOptions, a as AnyModel, at as SessionOptions, b as EventHandlers, bt as WithPlugins, c as CacheableOptions, ct as SoftDeleteRepository, d as CollationOptions, dt as Strict, et as RepositoryInstance, f as CreateInput, ft as UpdateManyResult, g as DeepPartial, gt as UserContext, h as DecodedCursor, ht as UpdateWithValidationResult, i as AnyDocument, it as SelectSpec, j as Logger, k as KeysOfType, l as CascadeOptions, lt as SortDirection, m as CursorPayload, mt as UpdatePatch, n as AggregatePaginationOptions, nt as RepositoryOptions, o as BasePaginationOptions, ot as SoftDeleteFilterMode, p as CreateOptions, pt as UpdateOptions, q as PluginType, r as AllPluginMethods, rt as RequiredBy, s as CacheOperationOptions, st as SoftDeleteOptions, t as AggregateOptions, tt as RepositoryOperation, u as CascadeRelation, ut as SortSpec, v as DeleteResult, vt as ValidatorDefinition, w as FindOneAndUpdateOptions, wt as IRequestContext, x as EventPayload, xt as WithTransactionOptions, y as DocField, yt as ValueType, z as NonNullableFields } from "./types-BbaPZleN.mjs";
2
- import { t as index_d_exports } from "./index-BbHaKwi5.mjs";
2
+ import { t as index_d_exports } from "./index-Dccha9dw.mjs";
3
3
  import { PaginationEngine } from "./pagination/PaginationEngine.mjs";
4
4
  import { $ as AuditQueryResult, A as elasticSearchPlugin, B as sequentialId, D as leasePlugin, E as LeasePluginOptions, F as SequentialIdOptions, G as RepositoryCachePluginOptions, H as CacheAdapter, I as customIdPlugin, J as BulkWriteResult, K as cachePlugin, L as dateSequentialId, M as DateSequentialIdOptions, N as IdGenerator, O as fieldFilterPlugin, P as PrefixedIdOptions, Q as AuditQueryOptions, R as getNextSequence, S as mongoOperationsPlugin, T as LeaseMethods, U as CacheOptions, V as cascadePlugin, W as RepositoryCacheHandle, X as AuditEntry, Y as batchOperationsPlugin, Z as AuditOperation, _ as observabilityPlugin, a as uniqueField, at as AggregateHelpersMethods, b as multiTenantPlugin, c as TenantContext, d as SubdocumentMethods, et as AuditTrailMethods, f as subdocumentPlugin, g as OperationMetric, h as ObservabilityOptions, i as requireField, it as auditLogPlugin, j as CustomIdOptions, k as ElasticSearchOptions, l as TenantStore, m as softDeletePlugin, n as blockIf, nt as AuditTrailQuery, o as validationChainPlugin, ot as aggregateHelpersPlugin, p as SoftDeleteMethods, q as BatchOperationsMethods, r as immutableField, rt as auditTrailPlugin, s as timestampPlugin, t as autoInject, tt as AuditTrailOptions, u as createTenantContext, v as MultiTenantOptions, w as methodRegistryPlugin, x as MongoOperationsMethods, y as adminBypass, z as prefixedId } from "./validation-chain.plugin-CAPYCj_T.mjs";
5
5
  import { n as SchemaPathsLike, t as FieldType$1 } from "./coercion-TRMmR49h.mjs";
@@ -7,7 +7,7 @@ import { n as SchemaIndexes } from "./indexes-CgOF7Wjd.mjs";
7
7
  import { a as createFieldPreset, c as getMongooseProjection, d as parseDuplicateKeyError, i as configureLogger, l as createError, n as buildCrudSchemasFromMongooseSchema, o as filterResponseData, r as createMemoryCache, s as getFieldsForUser, t as buildCrudSchemasFromModel, u as isDuplicateKeyError } from "./mongooseToJsonSchema-BgRAaXX-.mjs";
8
8
  import * as _$mongoose from "mongoose";
9
9
  import { ClientSession, Expression, Model, PipelineStage, PopulateOptions } from "mongoose";
10
- import { AggPaginationRequest, AggRequest, AggResult, KeysetAggPaginationResult, QueryOptions, RepositoryBase } from "@classytic/repo-core/repository";
10
+ import { AggPaginationRequest, AggRequest, AggResult, KeysetAggPaginationResult, QueryOptions, RepositoryBase, TenantPurgeOptions, TenantPurgeResult, TenantPurgeStrategy } from "@classytic/repo-core/repository";
11
11
  import { UpdateInput } from "@classytic/repo-core/update";
12
12
  import { AggregatePaginationResult, KeysetPaginationResult, OffsetPaginationResult, OffsetPaginationResultCore } from "@classytic/repo-core/pagination";
13
13
  import { HttpError } from "@classytic/repo-core/errors";
@@ -1095,7 +1095,9 @@ declare class Repository<TDoc = unknown> extends RepositoryBase {
1095
1095
  /**
1096
1096
  * Get single document by query
1097
1097
  */
1098
- getByQuery(query: Record<string, unknown>, options?: CacheableOptions): Promise<TDoc | null>;
1098
+ getByQuery(query: Record<string, unknown>, options?: CacheableOptions & {
1099
+ sort?: SortSpec;
1100
+ }): Promise<TDoc | null>;
1099
1101
  /**
1100
1102
  * Get single document by arbitrary filter.
1101
1103
  * Unlike getByQuery, this method is designed for Arc/controller use where
@@ -1104,7 +1106,9 @@ declare class Repository<TDoc = unknown> extends RepositoryBase {
1104
1106
  * @example
1105
1107
  * const product = await repo.getOne({ slug: 'laptop', organizationId: 'org_1' });
1106
1108
  */
1107
- getOne(query: Record<string, unknown>, options?: CacheableOptions): Promise<TDoc | null>;
1109
+ getOne(query: Record<string, unknown>, options?: CacheableOptions & {
1110
+ sort?: SortSpec;
1111
+ }): Promise<TDoc | null>;
1108
1112
  /**
1109
1113
  * Fetch ALL documents matching filters without pagination.
1110
1114
  * Use for background jobs, exports, batch processing where you need every doc.
@@ -1226,7 +1230,95 @@ declare class Repository<TDoc = unknown> extends RepositoryBase {
1226
1230
  _id: unknown;
1227
1231
  } | null>;
1228
1232
  /**
1229
- * Update document by ID
1233
+ * Update a document by id and return the post-update doc.
1234
+ *
1235
+ * Compiles to `Model.findOneAndUpdate(filter, { $set: data }, { new:
1236
+ * true, runValidators: true })` under the hood and routes through the
1237
+ * full plugin pipeline — `multiTenantPlugin` injects the tenant scope,
1238
+ * `auditTrailPlugin` records the change, `cachePlugin` invalidates,
1239
+ * `softDeletePlugin` etc. all compose. Returns `null` on miss (the
1240
+ * `MinimalRepo` contract; pass `throwOnNotFound: true` for the legacy
1241
+ * throw path).
1242
+ *
1243
+ * **Anti-pattern to avoid** — calling `Model.findOneAndUpdate()`
1244
+ * directly on the raw mongoose model bypasses every plugin:
1245
+ *
1246
+ * ```ts
1247
+ * // ❌ Bypasses multi-tenant scope, audit, cache invalidation:
1248
+ * const doc = await CampaignModel.findOneAndUpdate(
1249
+ * { _id: id, organizationId: ctx.orgId }, // hand-rolled tenant filter
1250
+ * { $set: patch, $currentDate: { updatedAt: 1 } },
1251
+ * { new: true, runValidators: true }
1252
+ * );
1253
+ * return doc ? toEntity(doc) : null;
1254
+ *
1255
+ * // ✅ Plugin-routed; tenant + audit + cache auto-applied:
1256
+ * const doc = await campaignRepo.update(id, patch, repoOptionsFromCtx(ctx));
1257
+ * return doc ? toEntity(doc) : null;
1258
+ * ```
1259
+ *
1260
+ * The raw-mongoose path is a recurring source of cross-tenant leaks
1261
+ * (tenant filter forgotten on the next refactor), audit gaps (changes
1262
+ * invisible to the compliance trail), and stale-cache bugs (cache
1263
+ * invalidation skipped). Always route mutations through this method —
1264
+ * the `repo.update()` keystroke count beats raw mongoose AND composes
1265
+ * with the policy stack.
1266
+ *
1267
+ * **CAS / status-precondition writes — use `repo.claim()`, not `update()`.**
1268
+ * A `findOneAndUpdate` whose filter includes an expected-state check
1269
+ * (`{ _id, status: 'pending' }` → `{ $set: { status: 'shipped' } }`)
1270
+ * is a compare-and-set, not a plain update. Reach for the `claim()`
1271
+ * primitive — it expresses the transition as `{ from, to }`, returns
1272
+ * `null` on race-loss (so callers can branch on retry), and routes
1273
+ * through the same plugin pipeline:
1274
+ *
1275
+ * ```ts
1276
+ * // ❌ Hand-rolled CAS — bypasses plugins, no typed race-loss signal:
1277
+ * const doc = await CampaignModel.findOneAndUpdate(
1278
+ * { _id: id, organizationId: ctx.orgId, status: 'pending' },
1279
+ * { $set: { status: 'sent', sentAt: new Date() } },
1280
+ * { new: true }
1281
+ * );
1282
+ * if (!doc) throw new ConcurrentTransitionError(id);
1283
+ *
1284
+ * // ✅ Plugin-routed CAS with explicit transition + race-loss = null:
1285
+ * const doc = await campaignRepo.claim(
1286
+ * id,
1287
+ * { from: 'pending', to: 'sent' },
1288
+ * { sentAt: new Date() },
1289
+ * repoOptionsFromCtx(ctx),
1290
+ * );
1291
+ * if (!doc) throw new ConcurrentTransitionError(id, 'pending', 'sent');
1292
+ * ```
1293
+ *
1294
+ * Pair `claim()` with `defineStateMachine()` from
1295
+ * `@classytic/primitives/state-machine` for the modelling layer
1296
+ * (`assertTransition` catches illegal transitions sync, `claim` enforces
1297
+ * the race-safe write) — see mongokit's CLAUDE.md "State-machine +
1298
+ * claim() pairing" recipe. For pure dedup-on-id without a state
1299
+ * transition (idempotency keys, webhook receivers), use `getOrCreate()`
1300
+ * — `claim()` with `from === to === id` works too but is heavier.
1301
+ *
1302
+ * Domain-layer mapping (`doc → entity`) belongs on top of this call,
1303
+ * not inside mongokit. The canonical pattern is a 3-line host helper:
1304
+ *
1305
+ * ```ts
1306
+ * async function updateAndMap<TDoc, TEntity>(
1307
+ * repo: Repository<TDoc>,
1308
+ * id: string,
1309
+ * patch: Partial<TDoc>,
1310
+ * ctx: RequestContext,
1311
+ * mapper: (doc: TDoc) => TEntity,
1312
+ * ): Promise<TEntity | null> {
1313
+ * const doc = await repo.update(id, patch, repoOptionsFromCtx(ctx));
1314
+ * return doc ? mapper(doc) : null;
1315
+ * }
1316
+ * ```
1317
+ *
1318
+ * Mongokit deliberately doesn't ship `updateAndMap` — host mapper
1319
+ * signatures vary (`Result<T, E>`, throw-on-miss, audit-stamped
1320
+ * mappers that take `ctx`, tx-scoped mappers, …) and absorbing a
1321
+ * single shape would lock every consumer into it.
1230
1322
  */
1231
1323
  update(id: string | ObjectId, data: Record<string, unknown>, options?: UpdateOptions): Promise<TDoc | null>;
1232
1324
  /**
@@ -1587,6 +1679,15 @@ declare class Repository<TDoc = unknown> extends RepositoryBase {
1587
1679
  mode?: 'hard' | 'soft';
1588
1680
  [key: string]: unknown;
1589
1681
  }): Promise<DeleteManyResult>;
1682
+ /**
1683
+ * Compliance-grade cleanup primitive — see `StandardRepo.purgeByField`
1684
+ * for the cross-kit contract. Mongokit composes the kit-agnostic
1685
+ * `runChunkedPurge` orchestrator (loop, signal, progress, result
1686
+ * envelope) with `createMongoPurgePort` (driver-specific id selection
1687
+ * + strategy writes). See [actions/purge.ts](./actions/purge.ts) for
1688
+ * the port implementation.
1689
+ */
1690
+ purgeByField(field: string, value: unknown, strategy: TenantPurgeStrategy, options?: TenantPurgeOptions): Promise<TenantPurgeResult>;
1590
1691
  /**
1591
1692
  * Kit-native aggregation pipeline — takes a MongoDB stage array and
1592
1693
  * returns the raw pipeline output. Use this for `$lookup`, `$unwind`,
@@ -2137,6 +2238,80 @@ declare function repoOptionsFromCtx<TCtx extends Record<string, unknown>>(ctx: T
2137
2238
  * options bag never erases inherited values).
2138
2239
  */
2139
2240
  declare function createOptionsExtractor<TCtx extends Record<string, unknown>>(fields: readonly (keyof TCtx & string)[]): (ctx: TCtx | undefined | null) => Record<string, unknown>;
2241
+ /**
2242
+ * Canonical options bag for repo calls made OUTSIDE any request scope —
2243
+ * event handlers, cron jobs, queue workers, migration scripts.
2244
+ *
2245
+ * `multiTenantPlugin` requires `organizationId` in the operation
2246
+ * context so reads/writes can't leak across tenants from the request
2247
+ * layer. Background work has no inherited request scope; calling a
2248
+ * tenant-aware repo from an event subscriber without a bypass throws:
2249
+ *
2250
+ * "[mongokit] Multi-tenant: Missing 'organizationId' in context for ..."
2251
+ *
2252
+ * Worse, if the call site is wrapped in `try/catch` (as event handlers
2253
+ * routinely are — they fire-and-forget), the error is swallowed and
2254
+ * the side effect silently doesn't happen. We've shipped that bug.
2255
+ *
2256
+ * `systemContext()` returns the canonical opts bag for that case —
2257
+ * currently `{ bypassTenant: true }`. The multi-tenant plugin still
2258
+ * emits an `after:tenant-bypass` audit event with `reason: 'option'`
2259
+ * so observability sees these calls distinct from request-scoped ones.
2260
+ *
2261
+ * **Greppable on purpose.** A reviewer scanning the codebase can find
2262
+ * every "I'm running outside any tenant scope" callsite by searching
2263
+ * `systemContext(` — no chasing magic option spreads.
2264
+ *
2265
+ * **NOT a substitute for proper access control.** Same caveat as raw
2266
+ * `bypassTenant`: this bypasses tenant scoping; it does NOT bypass
2267
+ * authentication or RBAC. Use only at trusted boundaries — event
2268
+ * handlers wired at startup, cron jobs scheduled in code, scripts
2269
+ * running with admin credentials.
2270
+ *
2271
+ * The `extra` overload composes cleanly with transaction sessions and
2272
+ * audit-attribution fields; the function's `bypassTenant: true` is
2273
+ * applied last so it cannot be accidentally overridden.
2274
+ *
2275
+ * @example Event handler
2276
+ * ```ts
2277
+ * import { systemContext } from '@classytic/mongokit';
2278
+ *
2279
+ * order.events.subscribe('order:completed', async (event) => {
2280
+ * const matches = await enrollmentRepo.aggregatePipeline(
2281
+ * [{ $match: { orderId: event.payload.orderId, status: 'active' } }],
2282
+ * systemContext(),
2283
+ * );
2284
+ * for (const m of matches) {
2285
+ * await enrollmentRepo.update(m._id, { status: 'fulfilled' }, systemContext());
2286
+ * }
2287
+ * });
2288
+ * ```
2289
+ *
2290
+ * @example Composed with a transaction
2291
+ * ```ts
2292
+ * await withTransaction(conn, async (session) => {
2293
+ * await enrollmentRepo.update(id, data, systemContext({ session }));
2294
+ * await auditRepo.create(entry, systemContext({ session }));
2295
+ * });
2296
+ * ```
2297
+ *
2298
+ * @example Cron / scheduled cleanup
2299
+ * ```ts
2300
+ * for await (const doc of repo.cursor(
2301
+ * { expiresAt: { $lt: new Date() } },
2302
+ * systemContext(),
2303
+ * )) {
2304
+ * await repo.delete(doc._id, systemContext());
2305
+ * }
2306
+ * ```
2307
+ *
2308
+ * @param extra - Optional extra options to merge in (e.g. `session`
2309
+ * for transactions, `userId` for audit attribution). Any
2310
+ * `bypassTenant` value here is overridden — the function name is
2311
+ * load-bearing.
2312
+ * @returns A `SessionOptions` object with `bypassTenant: true` set.
2313
+ */
2314
+ declare function systemContext(extra?: SessionOptions): SessionOptions;
2140
2315
  //#endregion
2141
2316
  //#region src/index.d.ts
2142
2317
  /**
@@ -2151,4 +2326,4 @@ declare function createOptionsExtractor<TCtx extends Record<string, unknown>>(fi
2151
2326
  */
2152
2327
  declare function createRepository<TDoc>(Model: _$mongoose.Model<TDoc, any, any, any>, plugins?: PluginType[], paginationConfig?: PaginationConfig, options?: RepositoryOptions): Repository<TDoc>;
2153
2328
  //#endregion
2154
- export { ALL_OPERATIONS, type AggregateHelpersMethods, type AggregateOptions, type AggregatePaginationOptions, AggregationBuilder, type AllPluginMethods, type AnyDocument, type AnyModel, type AuditEntry, type AuditOperation, type AuditQueryOptions, type AuditQueryResult, type AuditTrailMethods, type AuditTrailOptions, AuditTrailQuery, type BasePaginationOptions, type BatchOperationsMethods, type BulkWriteResult, type CacheAdapter, type CacheOperationOptions, type CacheOptions, type CacheableOptions, type CascadeOptions, type CascadeRelation, type CollationOptions, type CreateInput, type CreateOptions, type CursorPayload, type CustomIdOptions, type DateSequentialIdOptions, type DecodedCursor, type DeepPartial, type DeleteResult, type DocField, type ElasticSearchOptions, type EventHandlers, type EventPayload, type EventPhase, type FieldPreset, type FieldType, type FilterQuery, type FindOneAndUpdateOptions, type GroupResult, HOOK_PRIORITY, type HookMode, type IController, type IControllerResponse, type IRequestContext, type IResponseFormatter, type IdGenerator, type InferDocument, type InferRawDoc, type KeysOfType, type KeysetPaginationOptions, type LeaseMethods, type LeasePluginOptions, type Logger, LookupBuilder, type LookupOptions, type LookupPopulateOptions, type LookupPopulateResult, MUTATING_OPERATIONS, type Middleware, type MiddlewareContext, type MinMaxResult, type MinimalRepoView, type MongoOperationsMethods, type MongoOperatorUpdate, type MultiTenantOptions, type NonNullableFields, OP_REGISTRY, type ObjectId, type ObservabilityOptions, type OffsetPaginationOptions, type OperationDescriptor, type OperationMetric, type OperationOptions, type PaginationConfig, PaginationEngine, type ParsedQuery, type PartialBy, type Plugin, type PluginFunction, type PluginType, type PolicyKey, type PopulateOption, type PopulateSpec, type PrefixedIdOptions, type PrioritizedHook, QueryParser, type QueryParserOptions, READ_OPERATIONS, type ReadOptions, type ReadPreferenceType, Repository, Repository as default, type RepositoryCacheHandle, type RepositoryCachePluginOptions, type RepositoryContext, type RepositoryEvent, type RepositoryInstance, type RepositoryOperation, type RepositoryOptions, type RequiredBy, type SchemaLike, type SearchMode, type SelectSpec, type SequentialIdOptions, type SessionOptions, type SoftDeleteFilterMode, type SoftDeleteMethods, type SoftDeleteOptions, type SoftDeleteRepository, type SortDirection, type SortSpec, type Strict, type SubdocumentMethods, type TenantContext, type TenantStore, type UpdateManyResult, type UpdateOptions, type UpdatePatch, type UpdateWithValidationResult, type UserContext, type ValidationChainOptions, type ValidatorDefinition, type ValueType, type WithPlugins, type WithTransactionOptions, index_d_exports as actions, adminBypass, aggregateHelpersPlugin, auditLogPlugin, auditTrailPlugin, autoInject, batchOperationsPlugin, batchTransaction, blockIf, buildCrudSchemasFromModel, buildCrudSchemasFromMongooseSchema, cachePlugin, cascadePlugin, compileFilterToMongo, configureLogger, createError, createFieldPreset, createMemoryCache, createOptionsExtractor, createRepository, createTenantContext, customIdPlugin, dateSequentialId, elasticSearchPlugin, fieldFilterPlugin, filterResponseData, getFieldsForUser, getMongooseProjection, getNextSequence, immutableField, isDuplicateKeyError, isTransactionUnsupported, leasePlugin, methodRegistryPlugin, mongoOperationsPlugin, multiTenantPlugin, observabilityPlugin, operationsByPolicyKey, parseDuplicateKeyError, prefixedId, repoOptionsFromCtx, requireField, sequentialId, softDeletePlugin, subdocumentPlugin, timestampPlugin, uniqueField, validationChainPlugin, withTransaction };
2329
+ export { ALL_OPERATIONS, type AggregateHelpersMethods, type AggregateOptions, type AggregatePaginationOptions, AggregationBuilder, type AllPluginMethods, type AnyDocument, type AnyModel, type AuditEntry, type AuditOperation, type AuditQueryOptions, type AuditQueryResult, type AuditTrailMethods, type AuditTrailOptions, AuditTrailQuery, type BasePaginationOptions, type BatchOperationsMethods, type BulkWriteResult, type CacheAdapter, type CacheOperationOptions, type CacheOptions, type CacheableOptions, type CascadeOptions, type CascadeRelation, type CollationOptions, type CreateInput, type CreateOptions, type CursorPayload, type CustomIdOptions, type DateSequentialIdOptions, type DecodedCursor, type DeepPartial, type DeleteResult, type DocField, type ElasticSearchOptions, type EventHandlers, type EventPayload, type EventPhase, type FieldPreset, type FieldType, type FilterQuery, type FindOneAndUpdateOptions, type GroupResult, HOOK_PRIORITY, type HookMode, type IController, type IControllerResponse, type IRequestContext, type IResponseFormatter, type IdGenerator, type InferDocument, type InferRawDoc, type KeysOfType, type KeysetPaginationOptions, type LeaseMethods, type LeasePluginOptions, type Logger, LookupBuilder, type LookupOptions, type LookupPopulateOptions, type LookupPopulateResult, MUTATING_OPERATIONS, type Middleware, type MiddlewareContext, type MinMaxResult, type MinimalRepoView, type MongoOperationsMethods, type MongoOperatorUpdate, type MultiTenantOptions, type NonNullableFields, OP_REGISTRY, type ObjectId, type ObservabilityOptions, type OffsetPaginationOptions, type OperationDescriptor, type OperationMetric, type OperationOptions, type PaginationConfig, PaginationEngine, type ParsedQuery, type PartialBy, type Plugin, type PluginFunction, type PluginType, type PolicyKey, type PopulateOption, type PopulateSpec, type PrefixedIdOptions, type PrioritizedHook, QueryParser, type QueryParserOptions, READ_OPERATIONS, type ReadOptions, type ReadPreferenceType, Repository, Repository as default, type RepositoryCacheHandle, type RepositoryCachePluginOptions, type RepositoryContext, type RepositoryEvent, type RepositoryInstance, type RepositoryOperation, type RepositoryOptions, type RequiredBy, type SchemaLike, type SearchMode, type SelectSpec, type SequentialIdOptions, type SessionOptions, type SoftDeleteFilterMode, type SoftDeleteMethods, type SoftDeleteOptions, type SoftDeleteRepository, type SortDirection, type SortSpec, type Strict, type SubdocumentMethods, type TenantContext, type TenantStore, type UpdateManyResult, type UpdateOptions, type UpdatePatch, type UpdateWithValidationResult, type UserContext, type ValidationChainOptions, type ValidatorDefinition, type ValueType, type WithPlugins, type WithTransactionOptions, index_d_exports as actions, adminBypass, aggregateHelpersPlugin, auditLogPlugin, auditTrailPlugin, autoInject, batchOperationsPlugin, batchTransaction, blockIf, buildCrudSchemasFromModel, buildCrudSchemasFromMongooseSchema, cachePlugin, cascadePlugin, compileFilterToMongo, configureLogger, createError, createFieldPreset, createMemoryCache, createOptionsExtractor, createRepository, createTenantContext, customIdPlugin, dateSequentialId, elasticSearchPlugin, fieldFilterPlugin, filterResponseData, getFieldsForUser, getMongooseProjection, getNextSequence, immutableField, isDuplicateKeyError, isTransactionUnsupported, leasePlugin, methodRegistryPlugin, mongoOperationsPlugin, multiTenantPlugin, observabilityPlugin, operationsByPolicyKey, parseDuplicateKeyError, prefixedId, repoOptionsFromCtx, requireField, sequentialId, softDeletePlugin, subdocumentPlugin, systemContext, timestampPlugin, uniqueField, validationChainPlugin, withTransaction };
package/dist/index.mjs CHANGED
@@ -1,10 +1,10 @@
1
- import { b as LookupBuilder, x as compileFilterToMongo } from "./update-C-3viiw1.mjs";
1
+ import { b as LookupBuilder, x as compileFilterToMongo } from "./update-BXMnCvO6.mjs";
2
2
  import { n as warn, t as configureLogger } from "./logger-DprijP5h.mjs";
3
3
  import { n as isDuplicateKeyError, r as parseDuplicateKeyError, t as createError } from "./error-B00pI7dV.mjs";
4
4
  import { t as actions_exports } from "./actions/index.mjs";
5
- import { a as withTransaction, c as MUTATING_OPERATIONS, d as operationsByPolicyKey, i as isTransactionUnsupported, l as OP_REGISTRY, n as Repository, o as AggregationBuilder, r as batchTransaction, s as ALL_OPERATIONS, t as HOOK_PRIORITY, u as READ_OPERATIONS } from "./Repository-CNBTCVat.mjs";
5
+ import { a as withTransaction, c as MUTATING_OPERATIONS, d as operationsByPolicyKey, i as isTransactionUnsupported, l as OP_REGISTRY, n as Repository, o as AggregationBuilder, r as batchTransaction, s as ALL_OPERATIONS, t as HOOK_PRIORITY, u as READ_OPERATIONS } from "./Repository-BGL1Pq8w.mjs";
6
6
  import { t as PaginationEngine } from "./PaginationEngine-CfQRM1yz.mjs";
7
- import { A as aggregateHelpersPlugin, C as sequentialId, D as AuditTrailQuery, E as batchOperationsPlugin, O as auditTrailPlugin, S as prefixedId, T as cachePlugin, _ as fieldFilterPlugin, a as uniqueField, b as dateSequentialId, c as createTenantContext, d as observabilityPlugin, f as adminBypass, g as leasePlugin, h as methodRegistryPlugin, i as requireField, k as auditLogPlugin, l as subdocumentPlugin, m as mongoOperationsPlugin, n as blockIf, o as validationChainPlugin, p as multiTenantPlugin, r as immutableField, s as timestampPlugin, t as autoInject, u as softDeletePlugin, v as elasticSearchPlugin, w as cascadePlugin, x as getNextSequence, y as customIdPlugin } from "./validation-chain.plugin-Bbbnq8ay.mjs";
7
+ import { A as aggregateHelpersPlugin, C as sequentialId, D as AuditTrailQuery, E as batchOperationsPlugin, O as auditTrailPlugin, S as prefixedId, T as cachePlugin, _ as fieldFilterPlugin, a as uniqueField, b as dateSequentialId, c as createTenantContext, d as observabilityPlugin, f as adminBypass, g as leasePlugin, h as methodRegistryPlugin, i as requireField, k as auditLogPlugin, l as subdocumentPlugin, m as mongoOperationsPlugin, n as blockIf, o as validationChainPlugin, p as multiTenantPlugin, r as immutableField, s as timestampPlugin, t as autoInject, u as softDeletePlugin, v as elasticSearchPlugin, w as cascadePlugin, x as getNextSequence, y as customIdPlugin } from "./validation-chain.plugin-CWhvBRej.mjs";
8
8
  import { i as getMongooseProjection, n as filterResponseData, r as getFieldsForUser, t as createFieldPreset } from "./field-selection-Br3WPOEb.mjs";
9
9
  import { isGeoOperator, parseGeoFilter } from "./query/primitives/geo.mjs";
10
10
  import { buildFieldTypeMap, coerceFieldValue, coerceHeuristic } from "./query/primitives/coercion.mjs";
@@ -1207,6 +1207,85 @@ function createOptionsExtractor(fields) {
1207
1207
  return out;
1208
1208
  };
1209
1209
  }
1210
+ /**
1211
+ * Canonical options bag for repo calls made OUTSIDE any request scope —
1212
+ * event handlers, cron jobs, queue workers, migration scripts.
1213
+ *
1214
+ * `multiTenantPlugin` requires `organizationId` in the operation
1215
+ * context so reads/writes can't leak across tenants from the request
1216
+ * layer. Background work has no inherited request scope; calling a
1217
+ * tenant-aware repo from an event subscriber without a bypass throws:
1218
+ *
1219
+ * "[mongokit] Multi-tenant: Missing 'organizationId' in context for ..."
1220
+ *
1221
+ * Worse, if the call site is wrapped in `try/catch` (as event handlers
1222
+ * routinely are — they fire-and-forget), the error is swallowed and
1223
+ * the side effect silently doesn't happen. We've shipped that bug.
1224
+ *
1225
+ * `systemContext()` returns the canonical opts bag for that case —
1226
+ * currently `{ bypassTenant: true }`. The multi-tenant plugin still
1227
+ * emits an `after:tenant-bypass` audit event with `reason: 'option'`
1228
+ * so observability sees these calls distinct from request-scoped ones.
1229
+ *
1230
+ * **Greppable on purpose.** A reviewer scanning the codebase can find
1231
+ * every "I'm running outside any tenant scope" callsite by searching
1232
+ * `systemContext(` — no chasing magic option spreads.
1233
+ *
1234
+ * **NOT a substitute for proper access control.** Same caveat as raw
1235
+ * `bypassTenant`: this bypasses tenant scoping; it does NOT bypass
1236
+ * authentication or RBAC. Use only at trusted boundaries — event
1237
+ * handlers wired at startup, cron jobs scheduled in code, scripts
1238
+ * running with admin credentials.
1239
+ *
1240
+ * The `extra` overload composes cleanly with transaction sessions and
1241
+ * audit-attribution fields; the function's `bypassTenant: true` is
1242
+ * applied last so it cannot be accidentally overridden.
1243
+ *
1244
+ * @example Event handler
1245
+ * ```ts
1246
+ * import { systemContext } from '@classytic/mongokit';
1247
+ *
1248
+ * order.events.subscribe('order:completed', async (event) => {
1249
+ * const matches = await enrollmentRepo.aggregatePipeline(
1250
+ * [{ $match: { orderId: event.payload.orderId, status: 'active' } }],
1251
+ * systemContext(),
1252
+ * );
1253
+ * for (const m of matches) {
1254
+ * await enrollmentRepo.update(m._id, { status: 'fulfilled' }, systemContext());
1255
+ * }
1256
+ * });
1257
+ * ```
1258
+ *
1259
+ * @example Composed with a transaction
1260
+ * ```ts
1261
+ * await withTransaction(conn, async (session) => {
1262
+ * await enrollmentRepo.update(id, data, systemContext({ session }));
1263
+ * await auditRepo.create(entry, systemContext({ session }));
1264
+ * });
1265
+ * ```
1266
+ *
1267
+ * @example Cron / scheduled cleanup
1268
+ * ```ts
1269
+ * for await (const doc of repo.cursor(
1270
+ * { expiresAt: { $lt: new Date() } },
1271
+ * systemContext(),
1272
+ * )) {
1273
+ * await repo.delete(doc._id, systemContext());
1274
+ * }
1275
+ * ```
1276
+ *
1277
+ * @param extra - Optional extra options to merge in (e.g. `session`
1278
+ * for transactions, `userId` for audit attribution). Any
1279
+ * `bypassTenant` value here is overridden — the function name is
1280
+ * load-bearing.
1281
+ * @returns A `SessionOptions` object with `bypassTenant: true` set.
1282
+ */
1283
+ function systemContext(extra) {
1284
+ return {
1285
+ ...extra,
1286
+ bypassTenant: true
1287
+ };
1288
+ }
1210
1289
  //#endregion
1211
1290
  //#region src/index.ts
1212
1291
  /**
@@ -1224,4 +1303,4 @@ function createRepository(Model, plugins = [], paginationConfig = {}, options =
1224
1303
  }
1225
1304
  var src_default = Repository;
1226
1305
  //#endregion
1227
- export { ALL_OPERATIONS, AggregationBuilder, AuditTrailQuery, HOOK_PRIORITY, LookupBuilder, MUTATING_OPERATIONS, OP_REGISTRY, PaginationEngine, QueryParser, READ_OPERATIONS, Repository, actions_exports as actions, adminBypass, aggregateHelpersPlugin, auditLogPlugin, auditTrailPlugin, autoInject, batchOperationsPlugin, batchTransaction, blockIf, buildCrudSchemasFromModel, buildCrudSchemasFromMongooseSchema, cachePlugin, cascadePlugin, compileFilterToMongo, configureLogger, createError, createFieldPreset, createMemoryCache, createOptionsExtractor, createRepository, createTenantContext, customIdPlugin, dateSequentialId, src_default as default, elasticSearchPlugin, fieldFilterPlugin, filterResponseData, getFieldsForUser, getMongooseProjection, getNextSequence, immutableField, isDuplicateKeyError, isTransactionUnsupported, leasePlugin, methodRegistryPlugin, mongoOperationsPlugin, multiTenantPlugin, observabilityPlugin, operationsByPolicyKey, parseDuplicateKeyError, prefixedId, repoOptionsFromCtx, requireField, sequentialId, softDeletePlugin, subdocumentPlugin, timestampPlugin, uniqueField, validationChainPlugin, withTransaction };
1306
+ export { ALL_OPERATIONS, AggregationBuilder, AuditTrailQuery, HOOK_PRIORITY, LookupBuilder, MUTATING_OPERATIONS, OP_REGISTRY, PaginationEngine, QueryParser, READ_OPERATIONS, Repository, actions_exports as actions, adminBypass, aggregateHelpersPlugin, auditLogPlugin, auditTrailPlugin, autoInject, batchOperationsPlugin, batchTransaction, blockIf, buildCrudSchemasFromModel, buildCrudSchemasFromMongooseSchema, cachePlugin, cascadePlugin, compileFilterToMongo, configureLogger, createError, createFieldPreset, createMemoryCache, createOptionsExtractor, createRepository, createTenantContext, customIdPlugin, dateSequentialId, src_default as default, elasticSearchPlugin, fieldFilterPlugin, filterResponseData, getFieldsForUser, getMongooseProjection, getNextSequence, immutableField, isDuplicateKeyError, isTransactionUnsupported, leasePlugin, methodRegistryPlugin, mongoOperationsPlugin, multiTenantPlugin, observabilityPlugin, operationsByPolicyKey, parseDuplicateKeyError, prefixedId, repoOptionsFromCtx, requireField, sequentialId, softDeletePlugin, subdocumentPlugin, systemContext, timestampPlugin, uniqueField, validationChainPlugin, withTransaction };
@@ -1,2 +1,2 @@
1
- import { A as aggregateHelpersPlugin, C as sequentialId, D as AuditTrailQuery, E as batchOperationsPlugin, O as auditTrailPlugin, S as prefixedId, T as cachePlugin, _ as fieldFilterPlugin, a as uniqueField, b as dateSequentialId, c as createTenantContext, d as observabilityPlugin, g as leasePlugin, h as methodRegistryPlugin, i as requireField, k as auditLogPlugin, l as subdocumentPlugin, m as mongoOperationsPlugin, n as blockIf, o as validationChainPlugin, p as multiTenantPlugin, r as immutableField, s as timestampPlugin, t as autoInject, u as softDeletePlugin, v as elasticSearchPlugin, w as cascadePlugin, x as getNextSequence, y as customIdPlugin } from "../validation-chain.plugin-Bbbnq8ay.mjs";
1
+ import { A as aggregateHelpersPlugin, C as sequentialId, D as AuditTrailQuery, E as batchOperationsPlugin, O as auditTrailPlugin, S as prefixedId, T as cachePlugin, _ as fieldFilterPlugin, a as uniqueField, b as dateSequentialId, c as createTenantContext, d as observabilityPlugin, g as leasePlugin, h as methodRegistryPlugin, i as requireField, k as auditLogPlugin, l as subdocumentPlugin, m as mongoOperationsPlugin, n as blockIf, o as validationChainPlugin, p as multiTenantPlugin, r as immutableField, s as timestampPlugin, t as autoInject, u as softDeletePlugin, v as elasticSearchPlugin, w as cascadePlugin, x as getNextSequence, y as customIdPlugin } from "../validation-chain.plugin-CWhvBRej.mjs";
2
2
  export { AuditTrailQuery, aggregateHelpersPlugin, auditLogPlugin, auditTrailPlugin, autoInject, batchOperationsPlugin, blockIf, cachePlugin, cascadePlugin, createTenantContext, customIdPlugin, dateSequentialId, elasticSearchPlugin, fieldFilterPlugin, getNextSequence, immutableField, leasePlugin, methodRegistryPlugin, mongoOperationsPlugin, multiTenantPlugin, observabilityPlugin, prefixedId, requireField, sequentialId, softDeletePlugin, subdocumentPlugin, timestampPlugin, uniqueField, validationChainPlugin };
@@ -864,6 +864,7 @@ async function getByQuery(Model, query, options = {}) {
864
864
  const mongoQuery = Model.findOne(query);
865
865
  if (options.select) mongoQuery.select(options.select);
866
866
  if (options.populate) mongoQuery.populate(parsePopulate$1(options.populate));
867
+ if (options.sort) mongoQuery.sort(options.sort);
867
868
  if (options.lean) mongoQuery.lean();
868
869
  if (options.session) mongoQuery.session(options.session);
869
870
  if (options.readPreference) mongoQuery.read(options.readPreference);
@@ -1,7 +1,7 @@
1
- import { _ as upsert } from "./update-C-3viiw1.mjs";
1
+ import { _ as upsert } from "./update-BXMnCvO6.mjs";
2
2
  import { n as warn } from "./logger-DprijP5h.mjs";
3
3
  import { t as createError } from "./error-B00pI7dV.mjs";
4
- import { l as OP_REGISTRY, s as ALL_OPERATIONS, t as HOOK_PRIORITY } from "./Repository-CNBTCVat.mjs";
4
+ import { l as OP_REGISTRY, s as ALL_OPERATIONS, t as HOOK_PRIORITY } from "./Repository-BGL1Pq8w.mjs";
5
5
  import { r as getFieldsForUser } from "./field-selection-Br3WPOEb.mjs";
6
6
  import mongoose from "mongoose";
7
7
  import { cachePlugin } from "@classytic/repo-core/cache";
@@ -1651,7 +1651,23 @@ function multiTenantPlugin(options = {}) {
1651
1651
  if (tenantId) context[contextKey] = tenantId;
1652
1652
  }
1653
1653
  if (!tenantId && allowDataInjection && payloadHasTenantField(context, policyKey, tenantField)) return;
1654
- if (!tenantId && required) throw new Error(`[mongokit] Multi-tenant: Missing '${contextKey}' in context for '${op}'. Pass it via options or set required: false. For deliberate cross-tenant access (admin / migration / support), pass \`bypassTenant: true\` in the options bag for this call, or wire \`skipWhen: adminBypass({...})\` at plugin construction.`);
1654
+ if (!tenantId && required) throw new Error([
1655
+ `[mongokit] Multi-tenant: Missing '${contextKey}' in context for '${op}'.`,
1656
+ "",
1657
+ "Three ways to provide it — pick the one that fits the call site:",
1658
+ "",
1659
+ ` 1. Per-call: repo.${op}(..., { ${contextKey}: '<id>' })`,
1660
+ " 2. Ambient (ALS): `createTenantContext()` + `resolveContext` —",
1661
+ " wrap the request in `tenantContext.run({ tenantId }, fn)`,",
1662
+ " then every repo call inside the closure inherits the",
1663
+ " tenant with no options-bag plumbing. See",
1664
+ " `createTenantContext` in @classytic/mongokit.",
1665
+ " 3. Cross-tenant: `bypassTenant: true` per-call (admin / migration /",
1666
+ " support), or `skipWhen: adminBypass({...})` at plugin",
1667
+ " construction for an always-on role-based bypass.",
1668
+ "",
1669
+ "Or set `required: false` if this collection is genuinely not tenant-scoped."
1670
+ ].join("\n"));
1655
1671
  if (!tenantId) return;
1656
1672
  const castId = fieldType === "objectId" ? new mongoose.Types.ObjectId(tenantId) : tenantId;
1657
1673
  switch (policyKey) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/mongokit",
3
- "version": "3.13.3",
3
+ "version": "3.14.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,
@@ -105,7 +105,7 @@
105
105
  },
106
106
  "homepage": "https://github.com/classytic/mongokit#readme",
107
107
  "peerDependencies": {
108
- "@classytic/repo-core": "^0.4.2",
108
+ "@classytic/repo-core": "^0.5.0",
109
109
  "better-auth": ">=1.6.0",
110
110
  "mongoose": ">=9.4.1"
111
111
  },
@@ -149,7 +149,7 @@
149
149
  "@better-auth/mongo-adapter": "^1.6.9",
150
150
  "@biomejs/biome": "^2.4.10",
151
151
  "@classytic/dev-tools": "^0.2.0",
152
- "@classytic/repo-core": "0.4.2",
152
+ "@classytic/repo-core": "0.5.0",
153
153
  "@types/node": "^22.0.0",
154
154
  "@vitest/coverage-v8": "^3.2.4",
155
155
  "ajv": "^8.18.0",