@classytic/mongokit 3.17.0 → 3.19.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 (32) hide show
  1. package/README.md +68 -1
  2. package/dist/PaginationEngine-4SjzfBQE.d.mts +103 -0
  3. package/dist/actions/index.d.mts +1 -1
  4. package/dist/adapter/index.mjs +1 -1
  5. package/dist/ai/index.d.mts +1 -1
  6. package/dist/better-auth/index.mjs +1 -1
  7. package/dist/create-repository-D5YX0wGs.mjs +29 -0
  8. package/dist/create-repository-K0yft2ht.d.mts +1663 -0
  9. package/dist/{index-fi91ycQc.d.mts → index-BgSBwlWu.d.mts} +1 -1
  10. package/dist/index.d.mts +11 -1665
  11. package/dist/index.mjs +5 -29
  12. package/dist/{mongooseToJsonSchema-BdQJU39w.d.mts → mongooseToJsonSchema-Bz47aMfH.d.mts} +1 -1
  13. package/dist/multi-tenant.plugin-D6lw3BsI.d.mts +487 -0
  14. package/dist/pagination/PaginationEngine.d.mts +1 -102
  15. package/dist/plugins/index.d.mts +19 -2
  16. package/dist/plugins/index.mjs +73 -2
  17. package/dist/query/primitives/coercion.d.mts +1 -1
  18. package/dist/query/primitives/indexes.d.mts +1 -1
  19. package/dist/testkit/index.d.mts +129 -0
  20. package/dist/testkit/index.mjs +139 -0
  21. package/dist/{validation-chain.plugin-DJtC0KJ-.mjs → timestamp.plugin-B1A9U-P7.mjs} +1 -1052
  22. package/dist/utils/index.d.mts +1 -1
  23. package/dist/utils/index.mjs +2 -2
  24. package/dist/{validation-chain.plugin-BpMfy18N.d.mts → validation-chain.plugin-CYOPfFGF.d.mts} +2 -486
  25. package/dist/validation-chain.plugin-DHZbpb4t.mjs +1056 -0
  26. package/package.json +16 -4
  27. /package/dist/{adapter-DYEMkLwK.mjs → adapter-BSxhT0Ip.mjs} +0 -0
  28. /package/dist/{coercion-TRMmR49h.d.mts → coercion-BxxHyLjP.d.mts} +0 -0
  29. /package/dist/{field-selection-CQW3WBiK.mjs → field-selection-d1qdRiMr.mjs} +0 -0
  30. /package/dist/{indexes-CgOF7Wjd.d.mts → indexes-3eHBWNZ2.d.mts} +0 -0
  31. /package/dist/{mongooseToJsonSchema-CBEQmeEa.mjs → mongooseToJsonSchema-ChtXSrLT.mjs} +0 -0
  32. /package/dist/{types-CU3SEDbD.d.mts → types-BA8oXjFe.d.mts} +0 -0
@@ -0,0 +1,1663 @@
1
+ import { B as ObjectId, Dt as LookupOptions, Et as LookupBuilder, H as OperationOptions, J as PopulateSpec, M as LookupPopulateOptions, N as LookupPopulateResult, P as Middleware, Q as RepositoryContext, U as PaginationConfig, X as ReadOptions, Y as PrioritizedHook, Z as ReadPreferenceType, _ as DeleteManyResult, at as SessionOptions, c as CacheableOptions, d as CollationOptions, ft as UpdateManyResult, it as SelectSpec, j as Logger, n as AggregatePaginationOptions, nt as RepositoryOptions, p as CreateOptions, pt as UpdateOptions, q as PluginType, st as SoftDeleteOptions, t as AggregateOptions, ut as SortSpec$1, v as DeleteResult, w as FindOneAndUpdateOptions, xt as WithTransactionOptions } from "./types-BA8oXjFe.mjs";
2
+ import { _ as RepositoryCachePluginOptions, i as CustomIdOptions, t as MultiTenantOptions, w as AuditTrailOptions } from "./multi-tenant.plugin-D6lw3BsI.mjs";
3
+ import { t as PaginationEngine } from "./PaginationEngine-4SjzfBQE.mjs";
4
+ import { AggPaginationRequest, AggRequest, AggResult, ChangeEvent, FilterInput, KeysetAggPaginationResult, QueryOptions, RepoCapabilities, RepositoryBase, TenantPurgeOptions, TenantPurgeResult, TenantPurgeStrategy, WatchOptions } from "@classytic/repo-core/repository";
5
+ import * as _$mongoose from "mongoose";
6
+ import { Expression, Model, PipelineStage, PopulateOptions } from "mongoose";
7
+ import { UpdateInput } from "@classytic/repo-core/update";
8
+ import { AggregatePaginationResult, KeysetPaginationResult, OffsetPaginationResult, OffsetPaginationResultCore } from "@classytic/repo-core/pagination";
9
+ import { HttpError } from "@classytic/repo-core/errors";
10
+
11
+ //#region src/query/AggregationBuilder.d.ts
12
+ /** Options for $vectorSearch stage (Atlas only) */
13
+ interface VectorSearchOptions {
14
+ /** Atlas Search index name */
15
+ index: string;
16
+ /** Field path containing the vector embedding */
17
+ path: string;
18
+ /** Query vector to search against */
19
+ queryVector: number[];
20
+ /** Number of candidates to consider (higher = more accurate, slower) */
21
+ numCandidates?: number;
22
+ /** Max results to return */
23
+ limit: number;
24
+ /** Pre-filter documents before vector search */
25
+ filter?: Record<string, unknown>;
26
+ /** Use exact search instead of ANN (slower but precise) */
27
+ exact?: boolean;
28
+ }
29
+ /** Result from build() including execution options */
30
+ interface AggregationPlan {
31
+ pipeline: PipelineStage[];
32
+ allowDiskUse: boolean;
33
+ }
34
+ type SortOrder = 1 | -1 | 'asc' | 'desc';
35
+ type SortSpec = Record<string, SortOrder>;
36
+ type ProjectionSpec = Record<string, 0 | 1 | Expression>;
37
+ type GroupSpec = {
38
+ _id: string | Record<string, unknown> | null;
39
+ [key: string]: unknown;
40
+ };
41
+ /**
42
+ * Fluent builder for MongoDB aggregation pipelines
43
+ * Optimized for complex queries at scale
44
+ */
45
+ declare class AggregationBuilder {
46
+ private pipeline;
47
+ private _diskUse;
48
+ /**
49
+ * Get the current pipeline
50
+ */
51
+ get(): PipelineStage[];
52
+ /**
53
+ * Build and return the final pipeline
54
+ */
55
+ build(): PipelineStage[];
56
+ /**
57
+ * Build pipeline with execution options (allowDiskUse, etc.)
58
+ */
59
+ plan(): AggregationPlan;
60
+ /**
61
+ * Build and execute the pipeline against a model
62
+ *
63
+ * @example
64
+ * ```typescript
65
+ * const results = await new AggregationBuilder()
66
+ * .match({ status: 'active' })
67
+ * .allowDiskUse()
68
+ * .exec(MyModel);
69
+ * ```
70
+ */
71
+ exec<T = unknown>(model: Model<any>, session?: _$mongoose.ClientSession): Promise<T[]>;
72
+ /**
73
+ * Reset the pipeline
74
+ */
75
+ reset(): this;
76
+ /**
77
+ * Add a raw pipeline stage
78
+ */
79
+ addStage(stage: PipelineStage): this;
80
+ /**
81
+ * Add multiple raw pipeline stages
82
+ */
83
+ addStages(stages: PipelineStage[]): this;
84
+ /**
85
+ * $match - Filter documents
86
+ * IMPORTANT: Place $match as early as possible for performance
87
+ */
88
+ match(query: Record<string, unknown>): this;
89
+ /**
90
+ * $project - Include/exclude fields or compute new fields
91
+ */
92
+ project(projection: ProjectionSpec): this;
93
+ /**
94
+ * $group - Group documents and compute aggregations
95
+ *
96
+ * @example
97
+ * ```typescript
98
+ * .group({
99
+ * _id: '$department',
100
+ * count: { $sum: 1 },
101
+ * avgSalary: { $avg: '$salary' }
102
+ * })
103
+ * ```
104
+ */
105
+ group(groupSpec: GroupSpec): this;
106
+ /**
107
+ * $sort - Sort documents
108
+ */
109
+ sort(sortSpec: SortSpec | string): this;
110
+ /**
111
+ * $limit - Limit number of documents
112
+ */
113
+ limit(count: number): this;
114
+ /**
115
+ * $skip - Skip documents
116
+ */
117
+ skip(count: number): this;
118
+ /**
119
+ * $unwind - Deconstruct array field
120
+ */
121
+ unwind(path: string, preserveNullAndEmptyArrays?: boolean): this;
122
+ /**
123
+ * $addFields - Add new fields or replace existing fields
124
+ */
125
+ addFields(fields: Record<string, unknown>): this;
126
+ /**
127
+ * $set - Alias for $addFields
128
+ */
129
+ set(fields: Record<string, unknown>): this;
130
+ /**
131
+ * $unset - Remove fields
132
+ */
133
+ unset(fields: string | string[]): this;
134
+ /**
135
+ * $replaceRoot - Replace the root document
136
+ */
137
+ replaceRoot(newRoot: string | Record<string, unknown>): this;
138
+ /**
139
+ * $lookup - Join with another collection (simple form)
140
+ *
141
+ * @param from - Collection to join with
142
+ * @param localField - Field from source collection
143
+ * @param foreignField - Field from target collection
144
+ * @param as - Output field name
145
+ * @param single - Unwrap array to single object
146
+ *
147
+ * @example
148
+ * ```typescript
149
+ * // Join employees with departments by slug
150
+ * .lookup('departments', 'deptSlug', 'slug', 'department', true)
151
+ * ```
152
+ */
153
+ lookup(from: string, localField: string, foreignField: string, as?: string, single?: boolean): this;
154
+ /**
155
+ * $lookup - Join with another collection (advanced form with pipeline)
156
+ *
157
+ * @example
158
+ * ```typescript
159
+ * .lookupWithPipeline({
160
+ * from: 'products',
161
+ * localField: 'productIds',
162
+ * foreignField: 'sku',
163
+ * as: 'products',
164
+ * pipeline: [
165
+ * { $match: { status: 'active' } },
166
+ * { $project: { name: 1, price: 1 } }
167
+ * ]
168
+ * })
169
+ * ```
170
+ */
171
+ lookupWithPipeline(options: LookupOptions): this;
172
+ /**
173
+ * Multiple lookups at once
174
+ *
175
+ * @example
176
+ * ```typescript
177
+ * .multiLookup([
178
+ * { from: 'departments', localField: 'deptSlug', foreignField: 'slug', single: true },
179
+ * { from: 'managers', localField: 'managerId', foreignField: '_id', single: true }
180
+ * ])
181
+ * ```
182
+ */
183
+ multiLookup(lookups: LookupOptions[]): this;
184
+ /**
185
+ * $facet - Process multiple aggregation pipelines in a single stage
186
+ * Useful for computing multiple aggregations in parallel
187
+ *
188
+ * @example
189
+ * ```typescript
190
+ * .facet({
191
+ * totalCount: [{ $count: 'count' }],
192
+ * avgPrice: [{ $group: { _id: null, avg: { $avg: '$price' } } }],
193
+ * topProducts: [{ $sort: { sales: -1 } }, { $limit: 10 }]
194
+ * })
195
+ * ```
196
+ */
197
+ facet(facets: Record<string, PipelineStage[]>): this;
198
+ /**
199
+ * $bucket - Categorize documents into buckets
200
+ *
201
+ * @example
202
+ * ```typescript
203
+ * .bucket({
204
+ * groupBy: '$price',
205
+ * boundaries: [0, 50, 100, 200],
206
+ * default: 'Other',
207
+ * output: {
208
+ * count: { $sum: 1 },
209
+ * products: { $push: '$name' }
210
+ * }
211
+ * })
212
+ * ```
213
+ */
214
+ bucket(options: {
215
+ groupBy: string | Expression;
216
+ boundaries: unknown[];
217
+ default?: string;
218
+ output?: Record<string, unknown>;
219
+ }): this;
220
+ /**
221
+ * $bucketAuto - Automatically determine bucket boundaries
222
+ */
223
+ bucketAuto(options: {
224
+ groupBy: string | Expression;
225
+ buckets: number;
226
+ output?: Record<string, unknown>;
227
+ granularity?: string;
228
+ }): this;
229
+ /**
230
+ * $setWindowFields - Perform window functions (MongoDB 5.0+)
231
+ * Useful for rankings, running totals, moving averages
232
+ *
233
+ * @example
234
+ * ```typescript
235
+ * .setWindowFields({
236
+ * partitionBy: '$department',
237
+ * sortBy: { salary: -1 },
238
+ * output: {
239
+ * rank: { $rank: {} },
240
+ * runningTotal: { $sum: '$salary', window: { documents: ['unbounded', 'current'] } }
241
+ * }
242
+ * })
243
+ * ```
244
+ */
245
+ setWindowFields(options: {
246
+ partitionBy?: string | Expression;
247
+ sortBy?: SortSpec;
248
+ output: Record<string, unknown>;
249
+ }): this;
250
+ /**
251
+ * $unionWith - Combine results from multiple collections (MongoDB 4.4+)
252
+ *
253
+ * @example
254
+ * ```typescript
255
+ * .unionWith({
256
+ * coll: 'archivedOrders',
257
+ * pipeline: [{ $match: { year: 2024 } }]
258
+ * })
259
+ * ```
260
+ */
261
+ unionWith(options: {
262
+ coll: string;
263
+ pipeline?: PipelineStage[];
264
+ }): this;
265
+ /**
266
+ * $densify - Fill gaps in data (MongoDB 5.1+)
267
+ * Useful for time series data with missing points
268
+ */
269
+ densify(options: {
270
+ field: string;
271
+ range: {
272
+ step: number;
273
+ unit?: 'millisecond' | 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year';
274
+ bounds: 'full' | 'partition' | [unknown, unknown];
275
+ };
276
+ }): this;
277
+ /**
278
+ * $fill - Fill null or missing field values (MongoDB 5.3+)
279
+ */
280
+ fill(options: {
281
+ sortBy?: SortSpec;
282
+ output: Record<string, {
283
+ method: 'linear' | 'locf' | 'value';
284
+ value?: unknown;
285
+ }>;
286
+ }): this;
287
+ /**
288
+ * Enable allowDiskUse for large aggregations that exceed 100MB memory limit
289
+ *
290
+ * @example
291
+ * ```typescript
292
+ * const results = await new AggregationBuilder()
293
+ * .match({ status: 'active' })
294
+ * .group({ _id: '$category', total: { $sum: '$amount' } })
295
+ * .allowDiskUse()
296
+ * .exec(Model);
297
+ * ```
298
+ */
299
+ allowDiskUse(enable?: boolean): this;
300
+ /**
301
+ * Paginate - Add skip and limit for offset-based pagination
302
+ */
303
+ paginate(page: number, limit: number): this;
304
+ /**
305
+ * Count total documents (useful with $facet for pagination metadata)
306
+ */
307
+ count(outputField?: string): this;
308
+ /**
309
+ * Sample - Randomly select N documents
310
+ */
311
+ sample(size: number): this;
312
+ /**
313
+ * Out - Write results to a collection
314
+ */
315
+ out(collection: string): this;
316
+ /**
317
+ * Merge - Merge results into a collection
318
+ */
319
+ merge(options: string | {
320
+ into: string;
321
+ on?: string | string[];
322
+ whenMatched?: string;
323
+ whenNotMatched?: string;
324
+ }): this;
325
+ /**
326
+ * GeoNear - Perform geospatial queries
327
+ */
328
+ geoNear(options: {
329
+ near: {
330
+ type: 'Point';
331
+ coordinates: [number, number];
332
+ };
333
+ distanceField: string;
334
+ maxDistance?: number;
335
+ query?: Record<string, unknown>;
336
+ spherical?: boolean;
337
+ }): this;
338
+ /**
339
+ * GraphLookup - Perform recursive search (graph traversal)
340
+ */
341
+ graphLookup(options: {
342
+ from: string;
343
+ startWith: string | Expression;
344
+ connectFromField: string;
345
+ connectToField: string;
346
+ as: string;
347
+ maxDepth?: number;
348
+ depthField?: string;
349
+ restrictSearchWithMatch?: Record<string, unknown>;
350
+ }): this;
351
+ /**
352
+ * $search - Atlas Search full-text search (Atlas only)
353
+ *
354
+ * @example
355
+ * ```typescript
356
+ * .search({
357
+ * index: 'default',
358
+ * text: {
359
+ * query: 'laptop computer',
360
+ * path: ['title', 'description'],
361
+ * fuzzy: { maxEdits: 2 }
362
+ * }
363
+ * })
364
+ * ```
365
+ */
366
+ search(options: {
367
+ index?: string;
368
+ text?: {
369
+ query: string;
370
+ path: string | string[];
371
+ fuzzy?: {
372
+ maxEdits?: number;
373
+ prefixLength?: number;
374
+ };
375
+ score?: {
376
+ boost?: {
377
+ value?: number;
378
+ };
379
+ };
380
+ };
381
+ compound?: {
382
+ must?: unknown[];
383
+ mustNot?: unknown[];
384
+ should?: unknown[];
385
+ filter?: unknown[];
386
+ };
387
+ autocomplete?: unknown;
388
+ near?: unknown;
389
+ range?: unknown;
390
+ }): this;
391
+ /**
392
+ * $searchMeta - Get Atlas Search metadata (Atlas only)
393
+ */
394
+ searchMeta(options: Record<string, unknown>): this;
395
+ /**
396
+ * $vectorSearch - Semantic similarity search using vector embeddings (Atlas only)
397
+ *
398
+ * Requires an Atlas Vector Search index on the target field.
399
+ * Must be the first stage in the pipeline.
400
+ *
401
+ * @example
402
+ * ```typescript
403
+ * const results = await new AggregationBuilder()
404
+ * .vectorSearch({
405
+ * index: 'vector_index',
406
+ * path: 'embedding',
407
+ * queryVector: await getEmbedding('running shoes'),
408
+ * limit: 10,
409
+ * numCandidates: 100,
410
+ * filter: { category: 'footwear' }
411
+ * })
412
+ * .project({ embedding: 0, score: { $meta: 'vectorSearchScore' } })
413
+ * .exec(ProductModel);
414
+ * ```
415
+ */
416
+ vectorSearch(options: VectorSearchOptions): this;
417
+ /**
418
+ * Add vectorSearchScore as a field after $vectorSearch
419
+ * Convenience for `.addFields({ score: { $meta: 'vectorSearchScore' } })`
420
+ */
421
+ withVectorScore(fieldName?: string): this;
422
+ /**
423
+ * Create a builder from an existing pipeline
424
+ */
425
+ static from(pipeline: PipelineStage[]): AggregationBuilder;
426
+ /**
427
+ * Create a builder with initial match stage
428
+ */
429
+ static startWith(query: Record<string, unknown>): AggregationBuilder;
430
+ }
431
+ //#endregion
432
+ //#region src/Repository.d.ts
433
+ /**
434
+ * Plugin phase priorities (lower = runs first).
435
+ *
436
+ * Re-exported from `@classytic/repo-core/hooks` so mongokit 3.10 callers see
437
+ * the same constant whether they import from mongokit or directly from
438
+ * repo-core — priorities are identical (POLICY=100, CACHE=200,
439
+ * OBSERVABILITY=300, DEFAULT=500) and the two refs are reference-equal.
440
+ */
441
+ declare const HOOK_PRIORITY: {
442
+ readonly POLICY: 100;
443
+ readonly VALIDATION: 150;
444
+ readonly CACHE: 200;
445
+ readonly OBSERVABILITY: 300;
446
+ readonly DEFAULT: 500;
447
+ };
448
+ /**
449
+ * Production-grade repository for MongoDB.
450
+ *
451
+ * Extends `@classytic/repo-core/repository`'s `RepositoryBase` for the
452
+ * driver-agnostic hook + plugin plumbing (context builder, priority-sorted
453
+ * event engine, plugin-order validator) while layering every Mongo-specific
454
+ * concern — populate, aggregate, lookup, keyset + cursor pagination, $near
455
+ * rewriting — on top.
456
+ *
457
+ * Public surface is byte-stable with mongokit 3.9 (the `_hooks` property is
458
+ * preserved as a live read-through getter; all CRUD, event, and plugin method
459
+ * signatures unchanged). 3.10 is an internal re-plumbing release; consumers
460
+ * upgrading from 3.9 need no code changes.
461
+ */
462
+ declare class Repository<TDoc = unknown> extends RepositoryBase {
463
+ /**
464
+ * Runtime capability descriptor required by `StandardRepo<TDoc>`
465
+ * (repo-core 0.6.0). Hosts feature-detect once at boot instead of
466
+ * try/catching `UnsupportedOperationError` per call.
467
+ */
468
+ readonly capabilities: RepoCapabilities;
469
+ readonly Model: Model<TDoc>;
470
+ /**
471
+ * Mongoose model name. Duplicated on the instance for BC — arc / catalog /
472
+ * user code read `repo.model`. `RepositoryBase.modelName` points to the
473
+ * same string; prefer `modelName` in new code.
474
+ */
475
+ readonly model: string;
476
+ readonly _pagination: PaginationEngine<TDoc>;
477
+ readonly idField: string;
478
+ readonly searchMode: 'text' | 'regex' | 'auto';
479
+ readonly searchFields: string[] | undefined;
480
+ [key: string]: unknown;
481
+ private _hasTextIndex;
482
+ /**
483
+ * Wrap-style middleware chain. Composed around every `_runOp`
484
+ * invocation in registration order — earlier-registered middlewares
485
+ * are outer (run first / unwind last). See `useMiddleware()` and
486
+ * the `Middleware<TDoc>` type for the protocol.
487
+ */
488
+ private readonly _middlewares;
489
+ constructor(Model: Model<TDoc, any, any, any>, plugins?: PluginType[], paginationConfig?: PaginationConfig, options?: RepositoryOptions);
490
+ /**
491
+ * Live read-through view of the hook engine's listener registry.
492
+ *
493
+ * Preserved as a public field on the Repository for back-compat with
494
+ * mongokit ≤3.9 — existing tests and observability code pattern-match
495
+ * `repo._hooks.get('before:getAll')` / `repo._hooks.size`. Each access
496
+ * returns a fresh snapshot of the underlying HookEngine state.
497
+ *
498
+ * Read-only. To register listeners use `repo.on(event, listener, { priority })`.
499
+ */
500
+ get _hooks(): Map<string, PrioritizedHook[]>;
501
+ /**
502
+ * Fire-and-forget emit aliased through RepositoryBase's async path so
503
+ * error hooks converted to/from sync mode still route uniformly. Mongokit
504
+ * ≤3.9 made this a private method on its subclass; the base class exposes
505
+ * it publicly and wraps it in `_emitHook` below for internal use.
506
+ */
507
+ private _emitHook;
508
+ private _emitErrorHook;
509
+ /**
510
+ * Wire the `warnOnStrictQueryStrip` diagnostic. When enabled, every
511
+ * filter-shaped op (`getById`, `getByQuery`, `getOne`, `findAll`,
512
+ * `count`, `exists`, `update`, `delete`, `findOneAndUpdate`,
513
+ * `updateMany`, `deleteMany`, `claim`, `claimVersion`, `cursor`)
514
+ * checks its `context.query` keys against the cached set of schema
515
+ * paths. Top-level keys not on the schema get logged once per
516
+ * `(model, key)` pair — so a single bad caller surfaces in dev /
517
+ * staging instead of returning the wrong row in prod.
518
+ *
519
+ * Only runs when `strictQuery !== false` is in effect — if mongoose's
520
+ * strictQuery is off, unknown keys flow through to the driver and
521
+ * the trap doesn't apply.
522
+ */
523
+ private _wireStrictQueryStripDiagnostic;
524
+ /**
525
+ * Register a wrap-style middleware. Middleware composes AROUND every
526
+ * `_runOp` invocation — it sees the operation name, the live
527
+ * `RepositoryContext`, and a `next()` continuation that runs the
528
+ * remaining chain + the actual op + after/error hooks.
529
+ *
530
+ * **Composes with `repo.on()` and plugins, doesn't replace them.**
531
+ * Middleware is for ergonomics (timing, short-circuit, input/output
532
+ * mutation in a single closure) — NOT for security policy. Use the
533
+ * `before:*` hook engine for tenant scope, soft-delete filtering,
534
+ * cache invalidation, and audit; those plugins MUST run before
535
+ * middleware sees the op.
536
+ *
537
+ * **Execution order on every call** (build/before hooks fire BEFORE
538
+ * the middleware chain dispatches, so middleware cannot wrap a
539
+ * before-hook failure):
540
+ *
541
+ * ```text
542
+ * _buildContext + before:<op> ← repo.on('before:*') hooks
543
+ * [outer middleware pre] ← repo.useMiddleware() registrations
544
+ * [...inner middleware pre]
545
+ * fn (driver call)
546
+ * after:<op> | error:<op> ← repo.on('after:*' / 'error:*')
547
+ * [...inner middleware post]
548
+ * [outer middleware post]
549
+ * ```
550
+ *
551
+ * Why this order matters: a `before:create` hook that injects a
552
+ * tenant filter into `context.query` runs BEFORE the middleware chain
553
+ * is composed. Middleware sees a context that was already scoped by
554
+ * the policy plugins — it never has the authority to short-circuit a
555
+ * tenant check, because the throw from a `before:*` policy hook
556
+ * unwinds before middleware ever fires. That's by design — middleware
557
+ * as a security boundary would be impossible to audit, since
558
+ * registration order would determine whether scope wins.
559
+ *
560
+ * Practical consequence: if you want middleware to observe a policy
561
+ * failure (e.g. metric a tenant-scope rejection), use `repo.on('error:
562
+ * <op>', ...)` instead — that fires from inside the middleware chain
563
+ * and is reachable.
564
+ *
565
+ * Registration order = composition order: the first middleware
566
+ * registered runs outermost (wraps everything else).
567
+ *
568
+ * @example Time every op
569
+ * ```ts
570
+ * repo.useMiddleware(async ({ operation, next }) => {
571
+ * const start = performance.now();
572
+ * try { return await next(); }
573
+ * finally { metrics.record(operation, performance.now() - start); }
574
+ * });
575
+ * ```
576
+ *
577
+ * @example Inject `tenantId` on every create
578
+ * ```ts
579
+ * repo.useMiddleware(async ({ operation, context, next }) => {
580
+ * if (operation === 'create' && context.data) {
581
+ * context.data.tenantId = currentTenant();
582
+ * }
583
+ * return next();
584
+ * });
585
+ * ```
586
+ *
587
+ * @example Short-circuit (return without `next()` to skip the actual op)
588
+ * ```ts
589
+ * repo.useMiddleware(async ({ operation, context, next }) => {
590
+ * if (operation === 'getById' && readOnlyMaintenance) {
591
+ * return cachedReadOnlyResponse(context.id);
592
+ * }
593
+ * return next();
594
+ * });
595
+ * ```
596
+ */
597
+ useMiddleware(middleware: Middleware<TDoc>): this;
598
+ /**
599
+ * Compose the registered middleware chain around `exec`. Outermost
600
+ * middleware (first registered) wraps innermost. Each middleware sees
601
+ * the live `RepositoryContext` (already populated + run through
602
+ * `before:*` hooks at the call site — see `useMiddleware` JSDoc for
603
+ * the exact order). `after:` / `error:` hooks fire from inside
604
+ * `exec`, so middleware composes WITH plugins, not instead of them.
605
+ *
606
+ * `_runOp` calls this with its standard try/catch envelope; inline-try
607
+ * methods (`getById`, `update`, `delete`, `deleteMany`, `aggregatePaginate`,
608
+ * `lookupPopulate`, plus `getOne`/`getAll` cache-hit branches) call it
609
+ * directly so middleware sees every op — including cached reads — not
610
+ * just the ones routed through `_runOp`.
611
+ */
612
+ private _composeMiddleware;
613
+ /**
614
+ * Standard operation envelope: compose middleware around an exec that
615
+ * runs `fn`, emits `after:<op>` on success or `error:<op>` on failure.
616
+ * Methods with branched in-try logic that emit `after:*` from multiple
617
+ * paths (`getById`, `update`, `delete`, `deleteMany`, `aggregatePaginate`,
618
+ * `lookupPopulate`) keep their inline try/catch and call
619
+ * `_composeMiddleware` directly instead of being forced through this
620
+ * single-result helper.
621
+ */
622
+ private _runOp;
623
+ /**
624
+ * Resilience envelope around the DRIVER round-trip only — the repo-core
625
+ * 0.6.0 `QueryOptions.signal` / `QueryOptions.retryPolicy` contract.
626
+ *
627
+ * `throwIfAborted` stops a cancelled request before the next driver call;
628
+ * `withRetry` retries transient failures with exponential backoff (and is
629
+ * a zero-cost passthrough when no policy was passed, so we wrap
630
+ * unconditionally). Crucially this wraps `fn` AFTER `_buildContext` ran —
631
+ * before-hooks (validation, tenant scope, audit, events) execute exactly
632
+ * once per logical call and are NEVER re-run on retry; only the driver
633
+ * call repeats.
634
+ *
635
+ * Both knobs are read from the context (the options bag is spread into
636
+ * `_buildContext` inputs), so every op routed through `_runOp` — and the
637
+ * inline-try methods that call this directly — honors them uniformly.
638
+ */
639
+ private _withResilience;
640
+ /**
641
+ * Create single document
642
+ */
643
+ create(data: Record<string, unknown>, options?: CreateOptions): Promise<TDoc>;
644
+ /**
645
+ * Create multiple documents
646
+ */
647
+ createMany(dataArray: Record<string, unknown>[], options?: CreateOptions): Promise<TDoc[]>;
648
+ /**
649
+ * Get document by ID
650
+ */
651
+ getById(id: string | ObjectId, options?: CacheableOptions): Promise<TDoc | null>;
652
+ /**
653
+ * Get single document by query
654
+ */
655
+ getByQuery(query: Record<string, unknown>, options?: CacheableOptions & {
656
+ sort?: SortSpec$1;
657
+ }): Promise<TDoc | null>;
658
+ /**
659
+ * Get single document by arbitrary filter.
660
+ * Unlike getByQuery, this method is designed for Arc/controller use where
661
+ * compound filters (org scope + policy + id) are pre-built.
662
+ *
663
+ * @example
664
+ * const product = await repo.getOne({ slug: 'laptop', organizationId: 'org_1' });
665
+ */
666
+ getOne(query: Record<string, unknown>, options?: CacheableOptions & {
667
+ sort?: SortSpec$1;
668
+ }): Promise<TDoc | null>;
669
+ /**
670
+ * Fetch ALL documents matching filters without pagination.
671
+ * Use for background jobs, exports, batch processing where you need every doc.
672
+ *
673
+ * @example
674
+ * const all = await repo.findAll({ status: 'active' });
675
+ * const allLean = await repo.findAll({}, { select: 'name email', lean: true });
676
+ */
677
+ findAll(filters?: Record<string, unknown>, options?: OperationOptions & {
678
+ sort?: SortSpec$1 | string;
679
+ limit?: number;
680
+ }): Promise<TDoc[]>;
681
+ /**
682
+ * Streaming reads — async iterator over data, suitable for migrations,
683
+ * backfills, schema audits, and any once-a-quarter "touch every row"
684
+ * job. Goes through the standard `before:cursor` hook pipeline so
685
+ * multi-tenant scope, soft-delete filter, and access-control plugins
686
+ * inject scope BEFORE the underlying mongoose cursor is built.
687
+ *
688
+ * Replaces direct `Model.find().cursor()` usage which bypasses every
689
+ * plugin — that's how cross-tenant data ends up in migrations.
690
+ *
691
+ * Returns an `AsyncIterableIterator<TDoc>` — drive it with `for await`.
692
+ * The mongoose cursor is closed when iteration completes or breaks.
693
+ *
694
+ * @example Backfill a missing field across the whole tenant scope
695
+ * ```ts
696
+ * for await (const doc of repo.cursor({ filter: { migrated: { $ne: true }}}, { batchSize: 1000 })) {
697
+ * await transformer(doc);
698
+ * await repo.update(doc._id, { migrated: true });
699
+ * }
700
+ * ```
701
+ *
702
+ * @example Audit unscoped — opt out of multi-tenant for global sweeps
703
+ * ```ts
704
+ * for await (const doc of repo.cursor({}, { batchSize: 500, organizationId: undefined })) {
705
+ * audit(doc);
706
+ * }
707
+ * ```
708
+ */
709
+ cursor(filter?: Record<string, unknown>, options?: ReadOptions & {
710
+ sort?: SortSpec$1 | string;
711
+ batchSize?: number;
712
+ select?: SelectSpec;
713
+ lean?: boolean;
714
+ }): AsyncIterableIterator<TDoc>;
715
+ /**
716
+ * Portable change feed — `StandardRepo.watch()` over Mongo change
717
+ * streams (`Model.watch`). Requires a replica set (or mongos); a
718
+ * standalone mongod rejects change streams at open time — gate on
719
+ * `repo.capabilities.changeStreams` plus your deployment topology.
720
+ *
721
+ * Semantics:
722
+ * - **Plugin-routed like every other read.** The filter goes through
723
+ * the standard `before:watch` hook pipeline (`OP_REGISTRY.watch`,
724
+ * policyKey `'query'`) BEFORE the change-stream pipeline is built —
725
+ * `multiTenantPlugin` injects the tenant scope (and throws under
726
+ * `required: true` exactly like other reads), `softDeletePlugin`
727
+ * injects the deletion-state predicate. A tenant-scoped repo never
728
+ * streams cross-tenant changes.
729
+ * - `fullDocument: 'updateLookup'` — update events carry the post-
730
+ * image document when it still exists at lookup time.
731
+ * - `filter` (record or Filter IR) is compiled with the standard
732
+ * filter compiler and applied against `fullDocument.*` paths, so
733
+ * `watch({ status: 'pending' })` matches the post-image. Delete
734
+ * events have no `fullDocument` and therefore don't match a
735
+ * non-empty post-policy filter — use `bypassTenant` / an unscoped
736
+ * repo without a filter to observe deletes.
737
+ * - `options.signal` ends the iterator (closes the stream). A
738
+ * pre-aborted signal rejects at the op boundary like every other op.
739
+ * - `options.resumeAfter` forwards a previously captured Mongo
740
+ * resume token for at-least-once consumption across restarts.
741
+ *
742
+ * Context keys for policy plugins (`organizationId`, `bypassTenant`,
743
+ * `includeDeleted`, ...) ride the options bag at runtime — same as every
744
+ * other op. The contract's `WatchOptions` type doesn't declare an index
745
+ * signature, so typed callers widen at the call site (or rely on an
746
+ * ALS `resolveContext` so no per-call tenant key is needed).
747
+ *
748
+ * @example
749
+ * ```ts
750
+ * const ac = new AbortController();
751
+ * for await (const change of repo.watch({ status: 'pending' }, { signal: ac.signal })) {
752
+ * if (change.operation === 'create') enqueue(change.doc!);
753
+ * }
754
+ * ```
755
+ */
756
+ watch(filter?: FilterInput, options?: WatchOptions): AsyncIterable<ChangeEvent<TDoc>>;
757
+ /**
758
+ * Unified pagination - auto-detects offset vs keyset based on params
759
+ *
760
+ * Auto-detection logic:
761
+ * - If params has 'cursor' or 'after' → uses keyset pagination (stream)
762
+ * - If params has 'pagination' or 'page' → uses offset pagination (paginate)
763
+ * - Else → defaults to offset pagination with page=1
764
+ *
765
+ * @example
766
+ * // Offset pagination (page-based)
767
+ * await repo.getAll({ page: 1, limit: 50, filters: { status: 'active' } });
768
+ * await repo.getAll({ pagination: { page: 2, limit: 20 } });
769
+ *
770
+ * // Keyset pagination (cursor-based)
771
+ * await repo.getAll({ cursor: 'eyJ2Ij...', limit: 50 });
772
+ * await repo.getAll({ after: 'eyJ2Ij...', sort: { createdAt: -1 } });
773
+ *
774
+ * // Simple query (defaults to page 1)
775
+ * await repo.getAll({ filters: { status: 'active' } });
776
+ *
777
+ * // Skip cache for fresh data
778
+ * await repo.getAll({ filters: { status: 'active' } }, { skipCache: true });
779
+ */
780
+ getAll(params?: {
781
+ filters?: Record<string, unknown>;
782
+ sort?: SortSpec$1 | string;
783
+ cursor?: string;
784
+ after?: string;
785
+ page?: number;
786
+ pagination?: {
787
+ page?: number;
788
+ limit?: number;
789
+ };
790
+ limit?: number;
791
+ search?: string;
792
+ mode?: 'offset' | 'keyset';
793
+ hint?: string | Record<string, 1 | -1>;
794
+ maxTimeMS?: number;
795
+ countStrategy?: 'exact' | 'estimated' | 'none';
796
+ readPreference?: ReadPreferenceType; /** Advanced populate options (from QueryParser or Arc's BaseController) */
797
+ populateOptions?: PopulateOptions[]; /** Collation for locale-aware string comparison */
798
+ collation?: CollationOptions; /** Lookup configurations for $lookup joins (from QueryParser or manual) */
799
+ lookups?: LookupOptions[]; /** Skip pagination entirely — returns raw TDoc[] (same as findAll) */
800
+ noPagination?: boolean;
801
+ }, options?: CacheableOptions): Promise<OffsetPaginationResult<TDoc> | KeysetPaginationResult<TDoc> | TDoc[]>;
802
+ /**
803
+ * Atomic "look up by filter, insert `data` if missing, return the doc."
804
+ *
805
+ * Returns `{ doc, created }` so callers can tell whether *this* call
806
+ * inserted (race-win) or matched an existing row. Implements
807
+ * `StandardRepo.getOrCreate()` from `@classytic/repo-core/repository`
808
+ * — see that interface's JSDoc for the cross-kit contract.
809
+ *
810
+ * Routes through the hook system for policy enforcement (multi-tenant,
811
+ * soft-delete, audit). The hook context carries the resolved query +
812
+ * data; the action layer adds the `created` discriminator from the
813
+ * driver's `lastErrorObject.upserted`.
814
+ */
815
+ getOrCreate(query: Record<string, unknown>, createData: Record<string, unknown>, options?: SessionOptions): Promise<{
816
+ doc: TDoc;
817
+ created: boolean;
818
+ }>;
819
+ /**
820
+ * Count documents
821
+ * Routes through hook system for policy enforcement (multi-tenant, soft-delete)
822
+ */
823
+ count(query?: Record<string, unknown>, options?: ReadOptions): Promise<number>;
824
+ /**
825
+ * Check if document exists
826
+ * Routes through hook system for policy enforcement (multi-tenant, soft-delete)
827
+ */
828
+ exists(query: Record<string, unknown>, options?: ReadOptions): Promise<{
829
+ _id: unknown;
830
+ } | null>;
831
+ /**
832
+ * Update a document by id and return the post-update doc.
833
+ *
834
+ * Compiles to `Model.findOneAndUpdate(filter, { $set: data }, { new:
835
+ * true, runValidators: true })` under the hood and routes through the
836
+ * full plugin pipeline — `multiTenantPlugin` injects the tenant scope,
837
+ * `auditTrailPlugin` records the change, `cachePlugin` invalidates,
838
+ * `softDeletePlugin` etc. all compose. Returns `null` on miss (the
839
+ * `MinimalRepo` contract; pass `throwOnNotFound: true` for the legacy
840
+ * throw path).
841
+ *
842
+ * **Anti-pattern to avoid** — calling `Model.findOneAndUpdate()`
843
+ * directly on the raw mongoose model bypasses every plugin:
844
+ *
845
+ * ```ts
846
+ * // ❌ Bypasses multi-tenant scope, audit, cache invalidation:
847
+ * const doc = await CampaignModel.findOneAndUpdate(
848
+ * { _id: id, organizationId: ctx.orgId }, // hand-rolled tenant filter
849
+ * { $set: patch, $currentDate: { updatedAt: 1 } },
850
+ * { new: true, runValidators: true }
851
+ * );
852
+ * return doc ? toEntity(doc) : null;
853
+ *
854
+ * // ✅ Plugin-routed; tenant + audit + cache auto-applied:
855
+ * const doc = await campaignRepo.update(id, patch, repoOptionsFromCtx(ctx));
856
+ * return doc ? toEntity(doc) : null;
857
+ * ```
858
+ *
859
+ * The raw-mongoose path is a recurring source of cross-tenant leaks
860
+ * (tenant filter forgotten on the next refactor), audit gaps (changes
861
+ * invisible to the compliance trail), and stale-cache bugs (cache
862
+ * invalidation skipped). Always route mutations through this method —
863
+ * the `repo.update()` keystroke count beats raw mongoose AND composes
864
+ * with the policy stack.
865
+ *
866
+ * **CAS / status-precondition writes — use `repo.claim()`, not `update()`.**
867
+ * A `findOneAndUpdate` whose filter includes an expected-state check
868
+ * (`{ _id, status: 'pending' }` → `{ $set: { status: 'shipped' } }`)
869
+ * is a compare-and-set, not a plain update. Reach for the `claim()`
870
+ * primitive — it expresses the transition as `{ from, to }`, returns
871
+ * `null` on race-loss (so callers can branch on retry), and routes
872
+ * through the same plugin pipeline:
873
+ *
874
+ * ```ts
875
+ * // ❌ Hand-rolled CAS — bypasses plugins, no typed race-loss signal:
876
+ * const doc = await CampaignModel.findOneAndUpdate(
877
+ * { _id: id, organizationId: ctx.orgId, status: 'pending' },
878
+ * { $set: { status: 'sent', sentAt: new Date() } },
879
+ * { new: true }
880
+ * );
881
+ * if (!doc) throw new ConcurrentTransitionError(id);
882
+ *
883
+ * // ✅ Plugin-routed CAS with explicit transition + race-loss = null:
884
+ * const doc = await campaignRepo.claim(
885
+ * id,
886
+ * { from: 'pending', to: 'sent' },
887
+ * { sentAt: new Date() },
888
+ * repoOptionsFromCtx(ctx),
889
+ * );
890
+ * if (!doc) throw new ConcurrentTransitionError(id, 'pending', 'sent');
891
+ * ```
892
+ *
893
+ * Pair `claim()` with `defineStateMachine()` from
894
+ * `@classytic/primitives/state-machine` for the modelling layer
895
+ * (`assertTransition` catches illegal transitions sync, `claim` enforces
896
+ * the race-safe write) — see mongokit's CLAUDE.md "State-machine +
897
+ * claim() pairing" recipe. For pure dedup-on-id without a state
898
+ * transition (idempotency keys, webhook receivers), use `getOrCreate()`
899
+ * — `claim()` with `from === to === id` works too but is heavier.
900
+ *
901
+ * Domain-layer mapping (`doc → entity`) belongs on top of this call,
902
+ * not inside mongokit. The canonical pattern is a 3-line host helper:
903
+ *
904
+ * ```ts
905
+ * async function updateAndMap<TDoc, TEntity>(
906
+ * repo: Repository<TDoc>,
907
+ * id: string,
908
+ * patch: Partial<TDoc>,
909
+ * ctx: RequestContext,
910
+ * mapper: (doc: TDoc) => TEntity,
911
+ * ): Promise<TEntity | null> {
912
+ * const doc = await repo.update(id, patch, repoOptionsFromCtx(ctx));
913
+ * return doc ? mapper(doc) : null;
914
+ * }
915
+ * ```
916
+ *
917
+ * Mongokit deliberately doesn't ship `updateAndMap` — host mapper
918
+ * signatures vary (`Result<T, E>`, throw-on-miss, audit-stamped
919
+ * mappers that take `ctx`, tx-scoped mappers, …) and absorbing a
920
+ * single shape would lock every consumer into it.
921
+ */
922
+ update(id: string | ObjectId, data: Record<string, unknown>, options?: UpdateOptions): Promise<TDoc | null>;
923
+ /**
924
+ * Atomic findOneAndUpdate (compare-and-set primitive).
925
+ *
926
+ * Single round-trip match-and-mutate for outbox relays, distributed locks,
927
+ * and workflow semaphores. Goes through the full hook pipeline so
928
+ * multi-tenant scope, soft-delete, audit, and cache plugins all apply.
929
+ *
930
+ * Returns the matched document (post-update by default) or `null` when no
931
+ * document matches and `upsert` is false.
932
+ *
933
+ * @example FIFO claim-lease for an outbox worker
934
+ * ```ts
935
+ * const claimed = await outboxRepo.findOneAndUpdate(
936
+ * { status: 'pending', leaseExpiresAt: { $lt: new Date() } },
937
+ * { $set: { status: 'processing', leaseExpiresAt: leaseUntil, leasedBy: workerId } },
938
+ * { sort: { createdAt: 1 }, returnDocument: 'after' },
939
+ * );
940
+ * if (!claimed) return; // queue empty
941
+ * ```
942
+ *
943
+ * @example CAS upsert
944
+ * ```ts
945
+ * const lock = await locksRepo.findOneAndUpdate(
946
+ * { _id: lockKey, ownerId: { $in: [null, workerId] } },
947
+ * { $set: { ownerId: workerId, expiresAt: leaseUntil } },
948
+ * { upsert: true },
949
+ * );
950
+ * ```
951
+ */
952
+ findOneAndUpdate<TResult = TDoc>(filter: Record<string, unknown>, update: UpdateInput, options?: FindOneAndUpdateOptions): Promise<TResult | null>;
953
+ /**
954
+ * Atomic compare-and-swap state transition. Implements
955
+ * `StandardRepo.claim()` from `@classytic/repo-core/repository` (0.4+).
956
+ *
957
+ * Builds a `findOneAndUpdate({ _id, [field]: from, ...where }, { $set:
958
+ * { [field]: to, ...patch } })` in a single round-trip — race-free
959
+ * across concurrent callers. Returns the post-update doc on success,
960
+ * `null` when:
961
+ * - the row exists but its current state isn't `from` (someone
962
+ * else won the transition), OR
963
+ * - any predicate in `transition.where` fails (paused guard,
964
+ * retry-time guard, heartbeat-staleness, sub-document predicate),
965
+ * OR
966
+ * - no row matches the id at all.
967
+ *
968
+ * The caller can't distinguish "lost race" from "guard predicate
969
+ * failed" — both mean "don't proceed." That's by design: the only
970
+ * action either way is to back off.
971
+ *
972
+ * **Source-state — `from: T | T[]`.** Pass a literal for a single-
973
+ * source transition (`from: 'pending'`) or an array for the
974
+ * "transition from any of these states" pattern (`from: ['pending',
975
+ * 'approved']`). Compiles to `[stateField]: { $in: [...] }`. Real-
976
+ * world frequency: commission's `voidRecord` / `markClawedBack` /
977
+ * `endAgreement` / `_transition`, media-kit's error path,
978
+ * streamline's `cancelAt` — every state machine with more than one
979
+ * non-terminal source state hits this. Without array support those
980
+ * sites fall back to raw `findOneAndUpdate` and lose plugin routing.
981
+ *
982
+ * **`from` is value-only — no Mongo expressions.** `from` accepts a
983
+ * literal or an array of literals (`$in`-shaped). It does NOT
984
+ * accept `{ $ne: ... }`, `{ $lt: ... }`, `{ $exists: ... }`, or
985
+ * arbitrary expression objects. The CAS contract is "match if the
986
+ * state field equals one of these specific values" — admitting
987
+ * arbitrary predicates would collapse the contract into a generic
988
+ * filter, defeating the point of a state-machine primitive.
989
+ *
990
+ * For non-equality / range / existence predicates on the state
991
+ * field, use `where`:
992
+ *
993
+ * ```ts
994
+ * // ❌ from: { $ne: 'ended' } — not supported, would silently work-as-filter
995
+ * // ✅ Use `where` for $ne / $lt / $exists / arbitrary mongo predicates:
996
+ * await repo.claim(id, {
997
+ * from: 'active', // exact-match state
998
+ * to: 'closed',
999
+ * where: { closedAt: { $exists: false } }, // additional predicates
1000
+ * });
1001
+ * ```
1002
+ *
1003
+ * **Dotted-path `field` is supported** — `field: 'scheduling.status'`
1004
+ * for nested state fields works. Mongo's filter and `$set` both
1005
+ * honor dotted paths, and the from===to optimization handles them
1006
+ * correctly (literal comparison on the path string). Use for
1007
+ * domain models that nest the state under a sub-document
1008
+ * (`lpn.state`, `package.condition.state`, `scheduling.status`).
1009
+ *
1010
+ * **`from === to` is allowed — idempotent re-claim.** Yard's
1011
+ * `reviseDeparture` writes `departed → departed` to update the
1012
+ * row's payload while asserting the row hasn't moved on. The CAS
1013
+ * still returns `null` on race-loss (row left the source state),
1014
+ * so the safety property holds. Use the same semantic as a "touch
1015
+ * with state assertion" primitive.
1016
+ *
1017
+ * When `from === to` (and `from` is a literal, not an array), the
1018
+ * implementation **skips the redundant `$set: { [stateField]: to }`
1019
+ * write** — the filter has already pinned the state field to that
1020
+ * exact value, so writing it again is a no-op disk operation.
1021
+ * Workloads doing high-replay dedup (yard's `gate-event.append`,
1022
+ * outbox replay storms, idempotent first-write CAS) save one disk
1023
+ * write + one journal flush + one replication-log entry per replay.
1024
+ * Three observable behaviours follow:
1025
+ *
1026
+ * - With a non-empty patch: only the patch fields land in `$set`;
1027
+ * the redundant state-field key is dropped.
1028
+ * - With an empty patch and `upsert: false`: the call lowers to
1029
+ * a plain `findOne` round-trip — pure assertion, zero writes.
1030
+ * Plugin pipeline (before/after `claim` hooks) still fires
1031
+ * correctly — only the internal driver shape changes.
1032
+ * - With an empty patch and `upsert: true`: the update becomes
1033
+ * `{ $setOnInsert: { [stateField]: to } }` — no-op on match,
1034
+ * populates the inserted row's state field on miss. This is
1035
+ * the "pure dedup" insert-or-confirm path.
1036
+ *
1037
+ * For pure-dedup recipes (no real state column, just an external
1038
+ * id used as both filter and `from`/`to`), see CLAUDE.md — yard's
1039
+ * `gate-event.append` is the canonical worked example.
1040
+ *
1041
+ * **`upsert: true` — insert-or-claim.** Set `options.upsert: true`
1042
+ * for the upsert-claim pattern: insert when the row doesn't exist,
1043
+ * else CAS-transition the existing row. Yard's `gate-event.append`
1044
+ * uses this for idempotent event landing. With `upsert` on, claim
1045
+ * NEVER returns null on miss — it inserts. Pair with
1046
+ * `$setOnInsert` in the operator patch for insert-only fields.
1047
+ * The default (`upsert: false`) keeps the canonical "match
1048
+ * exactly, else null" semantic.
1049
+ *
1050
+ * **Patch shape** — accepts BOTH flat (`{ field: value }`) and Mongo
1051
+ * operator (`{ $set, $inc, $unset, $setOnInsert, ... }`) forms.
1052
+ * Operator form is load-bearing for versioned docs (`$inc: {
1053
+ * version: 1 }`) and upsert-claim (`$setOnInsert: { createdBy }`);
1054
+ * flat form is the textbook ergonomic case. Mixing flat keys with
1055
+ * `$`-keys throws — Mongo would silently drop the flat keys,
1056
+ * which would mask data-loss bugs.
1057
+ *
1058
+ * Multi-tenant scope, soft-delete filter, cache invalidation, and
1059
+ * audit hooks all flow through automatically — `claim` is registered
1060
+ * in `OP_REGISTRY` (policyKey: 'query', mutates: true), so plugins
1061
+ * that iterate the registry pick it up without changes.
1062
+ *
1063
+ * **Caller responsibility for tenant context.** When
1064
+ * `multiTenantPlugin` is mounted, plugin-injected scoping reads the
1065
+ * tenant key from the **options bag** — `claim(id, transition, patch,
1066
+ * { organizationId: '...' })`. Without it, the plugin throws
1067
+ * `'Missing organizationId'` at runtime. Use
1068
+ * `repoOptionsFromCtx(ctx)` (or your own context helper) to forward
1069
+ * request-scoped tenant fields; do not assume the plugin reads them
1070
+ * from a global.
1071
+ *
1072
+ * @example Textbook transition (flat patch)
1073
+ * ```ts
1074
+ * const claimed = await runRepo.claim(runId, { from: 'waiting', to: 'running' }, {
1075
+ * lastHeartbeat: new Date(),
1076
+ * workerId: 'worker-12',
1077
+ * });
1078
+ * if (!claimed) return; // someone else got it (or no match)
1079
+ * ```
1080
+ *
1081
+ * @example Versioned doc — operator patch with `$inc`
1082
+ * ```ts
1083
+ * const claimed = await orderRepo.claim(
1084
+ * orderId,
1085
+ * { from: 'pending', to: 'shipped' },
1086
+ * {
1087
+ * $set: { shippedAt: new Date() },
1088
+ * $inc: { version: 1 },
1089
+ * },
1090
+ * );
1091
+ * ```
1092
+ *
1093
+ * @example Compound-filter claim (paused guard + retry timer)
1094
+ * ```ts
1095
+ * const claimed = await runRepo.claim(
1096
+ * runId,
1097
+ * {
1098
+ * from: 'waiting',
1099
+ * to: 'running',
1100
+ * where: {
1101
+ * paused: { $ne: true },
1102
+ * 'scheduling.retryAfter': { $lte: new Date() },
1103
+ * },
1104
+ * },
1105
+ * { lastHeartbeat: new Date() },
1106
+ * { organizationId: ctx.orgId }, // tenant fwd — see note above
1107
+ * );
1108
+ * ```
1109
+ *
1110
+ * Pairs with `defineStateMachine()` from
1111
+ * `@classytic/primitives/state-machine`:
1112
+ * - State machine validates "is from→to legal in the model?"
1113
+ * - `claim()` performs the atomic "did we win?"
1114
+ */
1115
+ claim(id: string | ObjectId, transition: {
1116
+ field?: string;
1117
+ from: unknown | readonly unknown[];
1118
+ to: unknown;
1119
+ where?: Record<string, unknown>;
1120
+ }, patch?: Record<string, unknown>, options?: SessionOptions & {
1121
+ idField?: string;
1122
+ upsert?: boolean;
1123
+ }): Promise<TDoc | null>;
1124
+ /**
1125
+ * Optimistic-concurrency CAS via a version stamp.
1126
+ *
1127
+ * Sibling to `claim()` — distinct mental model:
1128
+ * - `claim()` is a state machine: "move from status A to status B, atomically"
1129
+ * - `claimVersion()` is optimistic locking: "I expect version N; if it
1130
+ * still is, apply this update and increment the version"
1131
+ *
1132
+ * Builds `findOneAndUpdate({ _id, [versionField]: from, ...where },
1133
+ * { ...update, $inc: { [versionField]: by ?? 1 } })` in one
1134
+ * round-trip. Returns the post-update doc on success, `null` when:
1135
+ * - the row doesn't exist, OR
1136
+ * - the row's version isn't `from` (someone else committed first —
1137
+ * standard race-loss signal), OR
1138
+ * - any predicate in `transition.where` fails (state guard, tenant
1139
+ * guard expressed inline, etc.)
1140
+ *
1141
+ * **`from: undefined` is tolerated.** Lean reads return `version:
1142
+ * number | undefined` because the field defaults are absent on
1143
+ * fresh-from-mongo POJOs. Passing `from: undefined` matches docs
1144
+ * whose version field is *missing entirely* — exactly what you want
1145
+ * on a first-write CAS. Callers no longer need `?? 0` at every site.
1146
+ *
1147
+ * The caller's `update` is freeform — pass either a Mongo operator
1148
+ * shape (`{ $set: { status: 'submitted', updatedAt: now } }`) or a
1149
+ * field-shape object (auto-wrapped in `$set`). The version `$inc` is
1150
+ * MERGED into the update, so callers don't have to remember to bump
1151
+ * the counter.
1152
+ *
1153
+ * **Compound CAS via `where`.** State machines that key on both
1154
+ * version AND status (yard's `transition()`, every package whose
1155
+ * "ready to commit" gate is more than a version stamp) need to
1156
+ * AND-merge additional predicates. Pass them via
1157
+ * `transition.where` — same shape and semantics as `claim`'s
1158
+ * `where` field. Without this, claimVersion was forced back to raw
1159
+ * `findOneAndUpdate` for every state-AND-version site.
1160
+ *
1161
+ * Multi-tenant scope, soft-delete filter, cache invalidation, and
1162
+ * audit hooks all fire — `claimVersion` is in `OP_REGISTRY`
1163
+ * (policyKey: 'query', mutates: true) so plugins iterate the registry
1164
+ * and pick it up automatically.
1165
+ *
1166
+ * **Caller responsibility for tenant context.** When
1167
+ * `multiTenantPlugin` is mounted, the plugin reads the tenant key
1168
+ * from the **options bag** (`{ organizationId: '...' }`). Without it,
1169
+ * the plugin throws `'Missing organizationId'` at runtime. Use
1170
+ * `repoOptionsFromCtx(ctx)` to forward — same as `claim()` and every
1171
+ * other repo op.
1172
+ *
1173
+ * @example Order submission with version check
1174
+ * ```ts
1175
+ * const submitted = await orderRepo.claimVersion(
1176
+ * orderId,
1177
+ * { from: order.version },
1178
+ * { $set: { status: 'submitted', submittedAt: new Date() } },
1179
+ * );
1180
+ * if (!submitted) throw new ConcurrentEditError();
1181
+ * ```
1182
+ *
1183
+ * @example Compound CAS — version + status
1184
+ * ```ts
1185
+ * const transitioned = await yardRepo.claimVersion(
1186
+ * loadId,
1187
+ * {
1188
+ * from: load.version, // number | undefined OK
1189
+ * where: { status: 'queued' },
1190
+ * },
1191
+ * { $set: { status: 'in-progress', startedAt: new Date() } },
1192
+ * );
1193
+ * if (!transitioned) throw new ConcurrentEditError();
1194
+ * ```
1195
+ *
1196
+ * @example Custom version field name + step
1197
+ * ```ts
1198
+ * await runRepo.claimVersion(
1199
+ * runId,
1200
+ * { field: 'rev', from: 12, by: 1 },
1201
+ * { lastHeartbeat: new Date() }, // field-shape auto-wraps in $set
1202
+ * );
1203
+ * ```
1204
+ */
1205
+ claimVersion(id: string | ObjectId, transition: {
1206
+ field?: string;
1207
+ from: number | undefined;
1208
+ by?: number;
1209
+ where?: Record<string, unknown>;
1210
+ }, update: Record<string, unknown>, options?: SessionOptions & {
1211
+ idField?: string;
1212
+ }): Promise<TDoc | null>;
1213
+ /**
1214
+ * Delete a document by ID.
1215
+ *
1216
+ * By default the behavior is decided by the plugin stack:
1217
+ * - With `softDeletePlugin` wired → soft delete (sets `deletedAt`)
1218
+ * - Without the plugin → physical delete
1219
+ *
1220
+ * Pass `mode: 'hard'` to force physical deletion regardless of plugins.
1221
+ * All policy hooks (multi-tenant scope, audit trails, cache invalidation,
1222
+ * cascade) still fire — only the soft-delete interception is skipped. This
1223
+ * is the GDPR / admin-cleanup path.
1224
+ *
1225
+ * @example
1226
+ * ```ts
1227
+ * // Respects softDeletePlugin — soft if wired
1228
+ * await userRepo.delete(userId);
1229
+ *
1230
+ * // GDPR erasure — physical delete, audit hooks still fire
1231
+ * await userRepo.delete(userId, { mode: 'hard', organizationId: 'org_123' });
1232
+ * ```
1233
+ */
1234
+ delete(id: string | ObjectId, options?: SessionOptions & {
1235
+ idField?: string;
1236
+ mode?: 'hard' | 'soft'; /** Legacy opt-in: throw a 404 error on miss instead of returning `null`. */
1237
+ throwOnNotFound?: boolean;
1238
+ }): Promise<DeleteResult | null>;
1239
+ /**
1240
+ * Update every document matching the filter.
1241
+ *
1242
+ * Promoted from `batchOperationsPlugin` to a class primitive in 3.11.0 so
1243
+ * the surface matches sqlitekit's always-available `updateMany`. Consumers
1244
+ * who forget to wire `batchOperationsPlugin` no longer get a runtime
1245
+ * `TypeError: repo.updateMany is not a function` — the method is always
1246
+ * here.
1247
+ *
1248
+ * Accepts three `update` forms via `UpdateInput`:
1249
+ * - Portable `UpdateSpec` from `@classytic/repo-core/update` (compiled to
1250
+ * `$set`/`$unset`/`$inc`/`$setOnInsert` before hooks run).
1251
+ * - Raw Mongo operator record (`{ $set, $inc, ... }`) — passed through.
1252
+ * - Mongo aggregation pipeline (`[...]`) — requires `updatePipeline: true`
1253
+ * to guard against accidental pipeline updates.
1254
+ *
1255
+ * Refuses empty filters (defense-in-depth against mass-update accidents)
1256
+ * both before and after policy hooks inject tenant scope.
1257
+ */
1258
+ updateMany(filter: Record<string, unknown>, update: UpdateInput, options?: SessionOptions & {
1259
+ updatePipeline?: boolean;
1260
+ [key: string]: unknown;
1261
+ }): Promise<UpdateManyResult>;
1262
+ /**
1263
+ * Delete every document matching the filter.
1264
+ *
1265
+ * Promoted from `batchOperationsPlugin` to a class primitive in 3.11.0 for
1266
+ * the same reason as `updateMany` — sqlitekit parity and no silent "method
1267
+ * is undefined" footgun when the batch plugin isn't wired.
1268
+ *
1269
+ * Behavior mirrors `delete()`: defaults to soft delete when
1270
+ * `softDeletePlugin` is wired, physical delete otherwise. Pass
1271
+ * `{ mode: 'hard' }` to bypass soft-delete — multi-tenant scoping, audit,
1272
+ * and cache hooks still fire.
1273
+ *
1274
+ * Rejects empty filters up front AND after policy hooks, so a policy-plugin
1275
+ * bug that zeroes out the query can't be exploited to wipe a collection.
1276
+ */
1277
+ deleteMany(filter: Record<string, unknown>, options?: SessionOptions & {
1278
+ mode?: 'hard' | 'soft';
1279
+ [key: string]: unknown;
1280
+ }): Promise<DeleteManyResult>;
1281
+ /**
1282
+ * Compliance-grade cleanup primitive — see `StandardRepo.purgeByField`
1283
+ * for the cross-kit contract. Mongokit composes the kit-agnostic
1284
+ * `runChunkedPurge` orchestrator (loop, signal, progress, result
1285
+ * envelope) with `createMongoPurgePort` (driver-specific id selection
1286
+ * + strategy writes). See [actions/purge.ts](./actions/purge.ts) for
1287
+ * the port implementation.
1288
+ */
1289
+ purgeByField(field: string, value: unknown, strategy: TenantPurgeStrategy, options?: TenantPurgeOptions): Promise<TenantPurgeResult>;
1290
+ /**
1291
+ * Kit-native aggregation pipeline — takes a MongoDB stage array and
1292
+ * returns the raw pipeline output. Use this for `$lookup`, `$unwind`,
1293
+ * `$facet`, `$graphLookup`, and any other mongo-specific power
1294
+ * features that don't translate to SQL.
1295
+ *
1296
+ * For backend-portable aggregations (filter + group + measures +
1297
+ * sort + limit) use `aggregate(req: AggRequest)` instead — it
1298
+ * compiles the repo-core IR to the equivalent pipeline internally
1299
+ * and produces the same row shape sqlitekit emits.
1300
+ *
1301
+ * Routes through the hook system for policy enforcement
1302
+ * (multi-tenant, soft-delete).
1303
+ *
1304
+ * @param pipeline - MongoDB aggregation stage array
1305
+ * @param options - Aggregation options including governance controls
1306
+ */
1307
+ aggregatePipeline<TResult = unknown>(pipeline: PipelineStage[], options?: AggregateOptions): Promise<TResult[]>;
1308
+ /**
1309
+ * Paginated kit-native aggregation pipeline. Same mongo-specific
1310
+ * scope as `aggregatePipeline` — reach for this when you need
1311
+ * `$lookup` / `$unwind` / `$facet` alongside pagination.
1312
+ *
1313
+ * Prefer `aggregatePaginate(req: AggPaginationRequest)` for
1314
+ * cross-backend dashboard code; that method compiles the portable
1315
+ * IR to an equivalent pipeline.
1316
+ *
1317
+ * Policy hooks (multi-tenant, soft-delete) inject context.filters
1318
+ * which are prepended as a `$match` stage.
1319
+ */
1320
+ aggregatePipelinePaginate(options?: AggregatePaginationOptions): Promise<AggregatePaginationResult<TDoc>>;
1321
+ /**
1322
+ * Portable aggregation. Compiles the repo-core `AggRequest` IR to
1323
+ * `[{$match}, {$group}, {$addFields}, {$project}, {$match}, {$sort},
1324
+ * {$skip}, {$limit}]` against this repo's collection. Output rows
1325
+ * carry one key per `groupBy` column plus one key per measure alias
1326
+ * — identical shape to sqlitekit's `aggregate(req)`, so dashboards
1327
+ * work unchanged across backends.
1328
+ *
1329
+ * Without `groupBy`: returns a single-row result with just the
1330
+ * measures (scalar aggregation). Pass
1331
+ * `{ measures: { total: { op: 'sum', field: 'amount' } } }` for a
1332
+ * simple summary.
1333
+ *
1334
+ * For mongo-specific power features (`$lookup`, `$unwind`, `$facet`,
1335
+ * `$graphLookup`, window operators) reach for `aggregatePipeline`
1336
+ * — that's the kit-native escape hatch.
1337
+ *
1338
+ * Routes through the hook system for policy enforcement
1339
+ * (multi-tenant, soft-delete) — plugins inject their scope into
1340
+ * `context.filter`, which is merged into the pre-aggregate `$match`
1341
+ * before compilation.
1342
+ */
1343
+ aggregate<TRow extends Record<string, unknown> = Record<string, unknown>>(req: AggRequest, options?: QueryOptions): Promise<AggResult<TRow>>;
1344
+ /**
1345
+ * Invalidate every aggregate-cache entry tagged with ANY of the
1346
+ * given tags. Delegates to the unified cache plugin's handle
1347
+ * (`repo.cache.invalidateByTags`) — wired by `cachePlugin({ adapter })`.
1348
+ *
1349
+ * Pass no tags to wipe the entire cache namespace (requires the
1350
+ * adapter to implement `clear`).
1351
+ *
1352
+ * Returns the count of distinct entries cleared (`-1` when the call
1353
+ * routed through `adapter.clear()` and the adapter doesn't report a
1354
+ * count). No-op (returns `0`) when no cache plugin is wired.
1355
+ */
1356
+ invalidateAggregateCache(tags?: readonly string[]): Promise<number>;
1357
+ /**
1358
+ * Paginated portable aggregation. Two pagination modes, picked by
1359
+ * the request shape:
1360
+ *
1361
+ * - **Offset (default)** — `page` + `limit`. Returns the standard
1362
+ * `{ method: 'offset', data, total, pages, hasNext, hasPrev, ... }`
1363
+ * envelope. `countStrategy: 'none'` skips the second round-trip
1364
+ * that computes `total`; the envelope reports `total: 0`,
1365
+ * `pages: 0`, and derives `hasNext` from a `LIMIT N+1` peek on
1366
+ * the data pipeline.
1367
+ * - **Keyset** — `pagination: 'keyset'` (or `after` set). Returns
1368
+ * `{ method: 'keyset', data, hasMore, next, limit }`. `sort` is
1369
+ * required — the cursor encodes the sort-key tuple of the last
1370
+ * row. Each subsequent page passes the previous `next` back as
1371
+ * `after`. Scales to arbitrary group counts because the planner
1372
+ * uses `(sort_keys) > (cursor)` instead of `OFFSET N`.
1373
+ */
1374
+ aggregatePaginate<TRow extends Record<string, unknown> = Record<string, unknown>>(req: AggPaginationRequest, options?: QueryOptions): Promise<OffsetPaginationResultCore<TRow> | KeysetAggPaginationResult<TRow>>;
1375
+ /**
1376
+ * Internal: the actual aggregate-paginate execution body. Extracted
1377
+ * so `aggregatePaginate` can wrap it in the cache layer without
1378
+ * duplicating the keyset / offset branch logic.
1379
+ *
1380
+ * Receives `finalReq` (already policy-scoped) so the cache key
1381
+ * built by the outer wrapper matches the request the executor
1382
+ * sees — no cross-tenant cache poisoning.
1383
+ */
1384
+ private _executeAggregatePaginate;
1385
+ /**
1386
+ * Merge policy-hook-injected filters (multi-tenant scope,
1387
+ * soft-delete) into the pre-aggregate `filter` slot of the request.
1388
+ * Plugins speak mongo-query shape (`context.query` / `context.filters`);
1389
+ * the caller's `req.filter` may be either Filter IR or a mongo query.
1390
+ *
1391
+ * Each part is compiled through `compileFilterToMongo` BEFORE the
1392
+ * `$and` merge — otherwise an IR node like `eq('status', 'paid')`
1393
+ * (`{ op, field, value }`) gets nested inside `$and` as a literal,
1394
+ * `compileFilterToMongo` of the merged value finds no top-level `op`
1395
+ * (so `isFilter` is false), and Mongo receives the IR object as a
1396
+ * raw query clause that matches no real document. Symptom: aggregates
1397
+ * silently return 0 rows whenever a callerpasses IR + a policy
1398
+ * plugin is installed.
1399
+ */
1400
+ private _injectPolicyScopeIntoAgg;
1401
+ /**
1402
+ * Get distinct values
1403
+ * Routes through hook system for policy enforcement (multi-tenant, soft-delete)
1404
+ */
1405
+ distinct<T = unknown>(field: string, query?: Record<string, unknown>, options?: ReadOptions): Promise<T[]>;
1406
+ /**
1407
+ * Query with custom field lookups ($lookup)
1408
+ * Best for: Joins on slugs, SKUs, codes, or other indexed custom fields
1409
+ *
1410
+ * @example
1411
+ * ```typescript
1412
+ * // Join employees with departments using slug instead of ObjectId
1413
+ * const employees = await employeeRepo.lookupPopulate({
1414
+ * filters: { status: 'active' },
1415
+ * lookups: [
1416
+ * {
1417
+ * from: 'departments',
1418
+ * localField: 'departmentSlug',
1419
+ * foreignField: 'slug',
1420
+ * as: 'department',
1421
+ * single: true
1422
+ * }
1423
+ * ],
1424
+ * sort: '-createdAt',
1425
+ * page: 1,
1426
+ * limit: 50
1427
+ * });
1428
+ * ```
1429
+ */
1430
+ lookupPopulate<TExtra extends Record<string, unknown> = Record<string, unknown>>(options: LookupPopulateOptions<TDoc>): Promise<LookupPopulateResult<TDoc, TExtra>>;
1431
+ /**
1432
+ * Create an aggregation builder for this model
1433
+ * Useful for building complex custom aggregations
1434
+ *
1435
+ * @example
1436
+ * ```typescript
1437
+ * const pipeline = repo.buildAggregation()
1438
+ * .match({ status: 'active' })
1439
+ * .lookup('departments', 'deptSlug', 'slug', 'department', true)
1440
+ * .group({ _id: '$department', count: { $sum: 1 } })
1441
+ * .sort({ count: -1 })
1442
+ * .build();
1443
+ *
1444
+ * const results = await repo.Model.aggregate(pipeline);
1445
+ * ```
1446
+ */
1447
+ buildAggregation(): AggregationBuilder;
1448
+ /**
1449
+ * Create a lookup builder
1450
+ * Useful for building $lookup stages independently
1451
+ *
1452
+ * @example
1453
+ * ```typescript
1454
+ * const lookupStages = repo.buildLookup('departments')
1455
+ * .localField('deptSlug')
1456
+ * .foreignField('slug')
1457
+ * .as('department')
1458
+ * .single()
1459
+ * .build();
1460
+ *
1461
+ * const pipeline = [
1462
+ * { $match: { status: 'active' } },
1463
+ * ...lookupStages
1464
+ * ];
1465
+ * ```
1466
+ */
1467
+ buildLookup(from?: string): LookupBuilder;
1468
+ /**
1469
+ * Execute callback within a MongoDB transaction with automatic retry on
1470
+ * transient failures.
1471
+ *
1472
+ * **BREAKING (3.10):** the callback now receives a **tx-bound repository**,
1473
+ * not a raw `ClientSession`. This matches repo-core's `StandardRepo.withTransaction`
1474
+ * contract that every kit (sqlitekit, pgkit, prismakit) already implements
1475
+ * — so cross-kit plugins and apps are portable. Session threading happens
1476
+ * transparently: every CRUD call on `txRepo` auto-injects the session into
1477
+ * its options bag, including plugin-added methods (`upsert`, `increment`,
1478
+ * `updateMany`, `restore`, ...). Nested `txRepo.withTransaction(...)` throws.
1479
+ *
1480
+ * For raw-session cross-repo workflows — multiple repositories coordinating
1481
+ * writes — use the standalone `withTransaction(connection, fn)` export
1482
+ * from `@classytic/mongokit`. That helper remains session-based by design.
1483
+ *
1484
+ * @param callback - Receives a tx-bound `Repository<TDoc>` — call its
1485
+ * methods directly, no need to pass `{ session }` manually
1486
+ * @param options.allowFallback - Run without transaction on standalone MongoDB (default: false)
1487
+ * @param options.onFallback - Called when falling back to non-transactional execution
1488
+ * @param options.transactionOptions - MongoDB driver transaction options (readConcern, writeConcern, etc.)
1489
+ *
1490
+ * @example
1491
+ * ```typescript
1492
+ * const order = await repo.withTransaction(async (txRepo) => {
1493
+ * const created = await txRepo.create({ total: 100 });
1494
+ * await txRepo.update(created._id, { confirmed: true });
1495
+ * return created;
1496
+ * });
1497
+ *
1498
+ * // With fallback for standalone/dev environments
1499
+ * await repo.withTransaction(
1500
+ * async (txRepo) => {
1501
+ * await txRepo.create(doc);
1502
+ * },
1503
+ * {
1504
+ * allowFallback: true,
1505
+ * onFallback: (err) => logger.warn('Running without transaction', err),
1506
+ * },
1507
+ * );
1508
+ *
1509
+ * // Cross-repo with explicit session:
1510
+ * import { withTransaction } from '@classytic/mongokit';
1511
+ * await withTransaction(mongoose.connection, async (session) => {
1512
+ * const order = await orderRepo.create(data, { session });
1513
+ * await inventoryRepo.decrement(..., { session });
1514
+ * });
1515
+ * ```
1516
+ */
1517
+ withTransaction<T>(callback: (txRepo: this) => Promise<T>, options?: WithTransactionOptions): Promise<T>;
1518
+ /**
1519
+ * Execute custom query with event emission
1520
+ */
1521
+ _executeQuery<T>(buildQuery: (Model: Model<TDoc>) => Promise<T>): Promise<T>;
1522
+ /**
1523
+ * Build operation context and run before-hooks (sorted by priority).
1524
+ *
1525
+ * Narrows `RepositoryBase._buildContext`'s return type to mongokit's
1526
+ * richer `RepositoryContext` (which carries `softDeleted`, `_cacheHit`,
1527
+ * populate / readPreference / session, etc.). Runtime is inherited from
1528
+ * the base class — this override is purely a type-narrowing shim.
1529
+ *
1530
+ * Hook execution order is deterministic:
1531
+ * 1. POLICY (100) — tenant isolation, soft-delete filtering, validation
1532
+ * 2. CACHE (200) — cache lookup (after policy filters are injected)
1533
+ * 3. OBSERVABILITY (300) — audit logging, metrics
1534
+ * 4. DEFAULT (500) — user-registered hooks
1535
+ */
1536
+ _buildContext(operation: string, options: Record<string, unknown> | object): Promise<RepositoryContext>;
1537
+ /**
1538
+ * Walk the well-known filter-carrying keys (`query`, `filters`,
1539
+ * `having`) and route any Filter IR through `compileFilterToMongo`.
1540
+ * Plain Mongo queries pass through untouched — `isFilter` is the
1541
+ * discriminator. Returns a shallow clone; never mutates input.
1542
+ */
1543
+ private _normalizeFilterSlots;
1544
+ /**
1545
+ * Resolve the effective sort for getAll. Handles $near conflict detection
1546
+ * (warns + drops explicit sort when $near is in filters).
1547
+ */
1548
+ private _resolveSort;
1549
+ /**
1550
+ * Detect whether to use keyset (cursor) or offset (page) pagination.
1551
+ */
1552
+ private _detectPaginationMode;
1553
+ /**
1554
+ * Build the MongoDB query filter with search merged in. Handles text
1555
+ * search ($text), regex search ($or of $regex), and the search-resolver
1556
+ * plugin contract (search already cleared by a before:getAll hook).
1557
+ */
1558
+ private _buildSearchQuery;
1559
+ /**
1560
+ * Parse sort string or object
1561
+ */
1562
+ _parseSort(sort: SortSpec$1 | string | undefined): SortSpec$1;
1563
+ /**
1564
+ * Parse populate specification
1565
+ */
1566
+ _parsePopulate(populate: PopulateSpec | undefined): string[] | PopulateOptions[];
1567
+ /**
1568
+ * Classify a driver error as an authoritative duplicate-key conflict.
1569
+ *
1570
+ * Used by arc's outbox / idempotency adapters to distinguish
1571
+ * "write already landed (idempotent no-op)" from "transient DB error
1572
+ * (must retry)". Every backend signals duplicate keys differently —
1573
+ * MongoDB `code: 11000`, Prisma `P2002`, Postgres `23505` — so this
1574
+ * predicate lives on the kit that knows its driver. Cross-driver
1575
+ * adapters then depend only on the boolean outcome, not the shape of
1576
+ * the underlying error.
1577
+ *
1578
+ * Deliberately narrow: matches ONLY `code === 11000` and
1579
+ * `codeName === 'DuplicateKey'`. Does NOT match
1580
+ * `err.name === 'MongoServerError'`, which is also true for
1581
+ * WriteConflict, NotWritablePrimary, ExceededTimeLimit, and every
1582
+ * other server-side error — treating those as duplicate keys would
1583
+ * silently swallow transactional retries.
1584
+ *
1585
+ * @example
1586
+ * try {
1587
+ * await outboxRepo.create(event);
1588
+ * } catch (err) {
1589
+ * if (outboxRepo.isDuplicateKeyError(err)) {
1590
+ * // Idempotent no-op — event was already written by a prior attempt.
1591
+ * return;
1592
+ * }
1593
+ * throw err; // transient DB error — upstream retries
1594
+ * }
1595
+ */
1596
+ isDuplicateKeyError(err: unknown): boolean;
1597
+ /**
1598
+ * Handle errors with proper HTTP status codes.
1599
+ *
1600
+ * **Transactional retry signal preservation.** MongoDB driver errors
1601
+ * carry `errorLabels` (e.g. `'TransientTransactionError'`,
1602
+ * `'UnknownTransactionCommitResult'`) that `mongoose.Connection.
1603
+ * withTransaction()` reads via `error.hasErrorLabel(label)` to decide
1604
+ * whether to auto-retry the transaction. Wrapping such an error in a
1605
+ * fresh `Error` via `createError(...)` strips both the labels and the
1606
+ * `hasErrorLabel` method — the retry signal vanishes and concurrent
1607
+ * writers see WriteConflicts surface to userland instead of being
1608
+ * retried by the driver. To keep the standard MongoDB transaction-
1609
+ * retry mechanism intact, we re-throw any error that carries
1610
+ * `errorLabels` exactly as we received it. The caller still sees a
1611
+ * thrown error — only the wrapping layer is skipped.
1612
+ */
1613
+ _handleError(error: Error): HttpError;
1614
+ }
1615
+ //#endregion
1616
+ //#region src/create-repository.d.ts
1617
+ /**
1618
+ * Audit config — either a `Logger` (routes to `auditLogPlugin`, which
1619
+ * writes structured log lines through your logger) or an
1620
+ * `AuditTrailOptions` object (routes to `auditTrailPlugin`, which
1621
+ * persists a queryable compliance ledger in its own collection).
1622
+ *
1623
+ * Discrimination: a value carrying any of the `Logger` function members
1624
+ * (`info` / `warn` / `error` / `debug`) is treated as a logger; anything
1625
+ * else is treated as `AuditTrailOptions` (all of whose members are
1626
+ * data-shaped except `metadata` / `onError`, which don't overlap with
1627
+ * the logger member names).
1628
+ */
1629
+ type AuditConfig = Logger | AuditTrailOptions;
1630
+ /**
1631
+ * Declarative repository config. Every key is optional; a feature's
1632
+ * plugin is installed only when its key is present. The remaining
1633
+ * `RepositoryOptions` keys (`idField`, `hooks`, `schema`, `updateSchema`,
1634
+ * `events`, `requirePlugins`, ...) pass through to the `Repository`
1635
+ * constructor unchanged.
1636
+ */
1637
+ interface CreateRepositoryConfig extends RepositoryOptions {
1638
+ /** Multi-tenant scoping — forwarded to `multiTenantPlugin`. */
1639
+ tenant?: MultiTenantOptions;
1640
+ /** Soft-delete — `true` for defaults, or `SoftDeleteOptions`. */
1641
+ softDelete?: boolean | SoftDeleteOptions;
1642
+ /** createdAt/updatedAt stamping — `timestampPlugin` takes no options. */
1643
+ timestamps?: boolean;
1644
+ /** Batch ops (`updateMany`/`deleteMany`/`bulkWrite`) via `batchOperationsPlugin`. */
1645
+ batch?: boolean;
1646
+ /** Read-through cache — forwarded to `cachePlugin`. */
1647
+ cache?: RepositoryCachePluginOptions;
1648
+ /** Audit — `Logger` → `auditLogPlugin`; `AuditTrailOptions` → `auditTrailPlugin`. */
1649
+ audit?: AuditConfig;
1650
+ /** Custom ID generation — forwarded to `customIdPlugin`. */
1651
+ customId?: CustomIdOptions;
1652
+ /** Extra plugins appended AFTER the canonical stack. */
1653
+ plugins?: PluginType[];
1654
+ /** Pagination engine config (defaultLimit, maxLimit, ...). */
1655
+ pagination?: PaginationConfig;
1656
+ }
1657
+ /**
1658
+ * Build a `Repository` from a declarative config. See module JSDoc for
1659
+ * ordering guarantees and an example.
1660
+ */
1661
+ declare function createRepository<TDoc>(model: Model<TDoc, any, any, any>, config?: CreateRepositoryConfig): Repository<TDoc>;
1662
+ //#endregion
1663
+ export { Repository as a, HOOK_PRIORITY as i, CreateRepositoryConfig as n, AggregationBuilder as o, createRepository as r, AuditConfig as t };