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