@classytic/mongokit 3.15.0 → 3.16.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 (28) hide show
  1. package/README.md +193 -319
  2. package/dist/{PaginationEngine-CfQRM1yz.mjs → PaginationEngine-0XXTW-9b.mjs} +2 -2
  3. package/dist/{Repository-BGL1Pq8w.mjs → Repository-DE7D2QeS.mjs} +240 -19
  4. package/dist/actions/index.d.mts +1 -1
  5. package/dist/ai/index.d.mts +1 -1
  6. package/dist/ai/index.mjs +14 -5
  7. package/dist/better-auth/index.d.mts +15 -0
  8. package/dist/better-auth/index.mjs +1 -1
  9. package/dist/{index-Dccha9dw.d.mts → index-CV5vv-8E.d.mts} +1 -1
  10. package/dist/index.d.mts +1182 -1079
  11. package/dist/index.mjs +32 -19
  12. package/dist/{mongooseToJsonSchema-BYbG894y.mjs → mongooseToJsonSchema-Bi0q0pYW.mjs} +1 -1
  13. package/dist/{mongooseToJsonSchema-BgRAaXX-.d.mts → mongooseToJsonSchema-GhvZUjRO.d.mts} +1 -1
  14. package/dist/pagination/PaginationEngine.d.mts +1 -1
  15. package/dist/pagination/PaginationEngine.mjs +1 -1
  16. package/dist/plugins/index.d.mts +1 -1
  17. package/dist/plugins/index.mjs +1 -1
  18. package/dist/query/primitives/coercion.mjs +1 -1
  19. package/dist/{types-BbaPZleN.d.mts → types-BWZILovv.d.mts} +26 -1
  20. package/dist/utils/index.d.mts +1 -1
  21. package/dist/utils/index.mjs +3 -3
  22. package/dist/{validation-chain.plugin-CWhvBRej.mjs → validation-chain.plugin-Crl5nQAY.mjs} +1172 -1137
  23. package/dist/{validation-chain.plugin-CAPYCj_T.d.mts → validation-chain.plugin-Iee2_puD.d.mts} +368 -344
  24. package/package.json +6 -6
  25. /package/dist/{cursor-J84rR6wG.mjs → cursor-4LMYa4ga.mjs} +0 -0
  26. /package/dist/{field-selection-Br3WPOEb.mjs → field-selection-CQW3WBiK.mjs} +0 -0
  27. /package/dist/{id-resolution-B0Odvvou.mjs → id-resolution-DduoO4LI.mjs} +0 -0
  28. /package/dist/{sort-CrZjTK25.mjs → sort-RPVz5u0w.mjs} +0 -0
package/dist/index.d.mts CHANGED
@@ -1,75 +1,19 @@
1
- import { $ as RepositoryEvent, A as KeysetPaginationOptions, B as ObjectId, C as FieldPreset, Ct as IControllerResponse, D as InferDocument, Dt as LookupOptions, E as HookMode, Et as LookupBuilder, F as MiddlewareContext, G as Plugin, H as OperationOptions, I as MinMaxResult, J as PopulateSpec, K as PluginFunction, L as MinimalRepoView, M as LookupPopulateOptions, N as LookupPopulateResult, O as InferRawDoc, P as Middleware, Q as RepositoryContext, R as MongoOperatorUpdate, S as EventPhase, St as IController, T as GroupResult, Tt as IResponseFormatter, U as PaginationConfig, V as OffsetPaginationOptions, W as PartialBy, X as ReadOptions, Y as PrioritizedHook, Z as ReadPreferenceType, _ as DeleteManyResult, _t as ValidationChainOptions, a as AnyModel, at as SessionOptions, b as EventHandlers, bt as WithPlugins, c as CacheableOptions, ct as SoftDeleteRepository, d as CollationOptions, dt as Strict, et as RepositoryInstance, f as CreateInput, ft as UpdateManyResult, g as DeepPartial, gt as UserContext, h as DecodedCursor, ht as UpdateWithValidationResult, i as AnyDocument, it as SelectSpec, j as Logger, k as KeysOfType, l as CascadeOptions, lt as SortDirection, m as CursorPayload, mt as UpdatePatch, n as AggregatePaginationOptions, nt as RepositoryOptions, o as BasePaginationOptions, ot as SoftDeleteFilterMode, p as CreateOptions, pt as UpdateOptions, q as PluginType, r as AllPluginMethods, rt as RequiredBy, s as CacheOperationOptions, st as SoftDeleteOptions, t as AggregateOptions, tt as RepositoryOperation, u as CascadeRelation, ut as SortSpec, v as DeleteResult, vt as ValidatorDefinition, w as FindOneAndUpdateOptions, wt as IRequestContext, x as EventPayload, xt as WithTransactionOptions, y as DocField, yt as ValueType, z as NonNullableFields } from "./types-BbaPZleN.mjs";
2
- import { t as index_d_exports } from "./index-Dccha9dw.mjs";
1
+ import { $ as RepositoryEvent, A as KeysetPaginationOptions, B as ObjectId, C as FieldPreset, Ct as IControllerResponse, D as InferDocument, Dt as LookupOptions, E as HookMode, Et as LookupBuilder, F as MiddlewareContext, G as Plugin, H as OperationOptions, I as MinMaxResult, J as PopulateSpec, K as PluginFunction, L as MinimalRepoView, M as LookupPopulateOptions, N as LookupPopulateResult, O as InferRawDoc, P as Middleware, Q as RepositoryContext, R as MongoOperatorUpdate, S as EventPhase, St as IController, T as GroupResult, Tt as IResponseFormatter, U as PaginationConfig, V as OffsetPaginationOptions, W as PartialBy, X as ReadOptions, Y as PrioritizedHook, Z as ReadPreferenceType, _ as DeleteManyResult, _t as ValidationChainOptions, a as AnyModel, at as SessionOptions, b as EventHandlers, bt as WithPlugins, c as CacheableOptions, ct as SoftDeleteRepository, d as CollationOptions, dt as Strict, et as RepositoryInstance, f as CreateInput, ft as UpdateManyResult, g as DeepPartial, gt as UserContext, h as DecodedCursor, ht as UpdateWithValidationResult, i as AnyDocument, it as SelectSpec, j as Logger, k as KeysOfType, l as CascadeOptions, lt as SortDirection, m as CursorPayload, mt as UpdatePatch, n as AggregatePaginationOptions, nt as RepositoryOptions, o as BasePaginationOptions, ot as SoftDeleteFilterMode, p as CreateOptions, pt as UpdateOptions, q as PluginType, r as AllPluginMethods, rt as RequiredBy, s as CacheOperationOptions, st as SoftDeleteOptions, t as AggregateOptions, tt as RepositoryOperation, u as CascadeRelation, ut as SortSpec, v as DeleteResult, vt as ValidatorDefinition, w as FindOneAndUpdateOptions, wt as IRequestContext, x as EventPayload, xt as WithTransactionOptions, y as DocField, yt as ValueType, z as NonNullableFields } from "./types-BWZILovv.mjs";
2
+ import { t as index_d_exports } from "./index-CV5vv-8E.mjs";
3
+ import { $ as AuditEntry, A as BulkWriteResult, B as IdGenerator, C as LeasePluginOptions, D as elasticSearchPlugin, E as ElasticSearchOptions, F as MultiTenantOptions, G as getNextSequence, H as SequentialIdOptions, I as adminBypass, J as CacheAdapter, K as prefixedId, L as multiTenantPlugin, M as auditLogPlugin, N as AggregateHelpersMethods, O as cascadePlugin, P as aggregateHelpersPlugin, Q as cachePlugin, R as CustomIdOptions, S as LeaseMethods, T as fieldFilterPlugin, U as customIdPlugin, V as PrefixedIdOptions, W as dateSequentialId, X as RepositoryCacheHandle, Y as CacheOptions, Z as RepositoryCachePluginOptions, _ as observabilityPlugin, a as uniqueField, at as AuditTrailQuery, c as TenantContext, d as SubdocumentMethods, et as AuditOperation, f as subdocumentPlugin, g as OperationMetric, h as ObservabilityOptions, i as requireField, it as AuditTrailOptions, j as batchOperationsPlugin, k as BatchOperationsMethods, l as TenantStore, m as softDeletePlugin, n as blockIf, nt as AuditQueryResult, o as validationChainPlugin, ot as auditTrailPlugin, p as SoftDeleteMethods, q as sequentialId, r as immutableField, rt as AuditTrailMethods, s as timestampPlugin, t as autoInject, tt as AuditQueryOptions, u as createTenantContext, v as MongoOperationsMethods, w as leasePlugin, x as methodRegistryPlugin, y as mongoOperationsPlugin, z as DateSequentialIdOptions } from "./validation-chain.plugin-Iee2_puD.mjs";
3
4
  import { PaginationEngine } from "./pagination/PaginationEngine.mjs";
4
- import { $ as AuditQueryResult, A as elasticSearchPlugin, B as sequentialId, D as leasePlugin, E as LeasePluginOptions, F as SequentialIdOptions, G as RepositoryCachePluginOptions, H as CacheAdapter, I as customIdPlugin, J as BulkWriteResult, K as cachePlugin, L as dateSequentialId, M as DateSequentialIdOptions, N as IdGenerator, O as fieldFilterPlugin, P as PrefixedIdOptions, Q as AuditQueryOptions, R as getNextSequence, S as mongoOperationsPlugin, T as LeaseMethods, U as CacheOptions, V as cascadePlugin, W as RepositoryCacheHandle, X as AuditEntry, Y as batchOperationsPlugin, Z as AuditOperation, _ as observabilityPlugin, a as uniqueField, at as AggregateHelpersMethods, b as multiTenantPlugin, c as TenantContext, d as SubdocumentMethods, et as AuditTrailMethods, f as subdocumentPlugin, g as OperationMetric, h as ObservabilityOptions, i as requireField, it as auditLogPlugin, j as CustomIdOptions, k as ElasticSearchOptions, l as TenantStore, m as softDeletePlugin, n as blockIf, nt as AuditTrailQuery, o as validationChainPlugin, ot as aggregateHelpersPlugin, p as SoftDeleteMethods, q as BatchOperationsMethods, r as immutableField, rt as auditTrailPlugin, s as timestampPlugin, t as autoInject, tt as AuditTrailOptions, u as createTenantContext, v as MultiTenantOptions, w as methodRegistryPlugin, x as MongoOperationsMethods, y as adminBypass, z as prefixedId } from "./validation-chain.plugin-CAPYCj_T.mjs";
5
5
  import { n as SchemaPathsLike, t as FieldType$1 } from "./coercion-TRMmR49h.mjs";
6
6
  import { n as SchemaIndexes } from "./indexes-CgOF7Wjd.mjs";
7
- import { a as createFieldPreset, c as getMongooseProjection, d as parseDuplicateKeyError, i as configureLogger, l as createError, n as buildCrudSchemasFromMongooseSchema, o as filterResponseData, r as createMemoryCache, s as getFieldsForUser, t as buildCrudSchemasFromModel, u as isDuplicateKeyError } from "./mongooseToJsonSchema-BgRAaXX-.mjs";
7
+ import { a as createFieldPreset, c as getMongooseProjection, d as parseDuplicateKeyError, i as configureLogger, l as createError, n as buildCrudSchemasFromMongooseSchema, o as filterResponseData, r as createMemoryCache, s as getFieldsForUser, t as buildCrudSchemasFromModel, u as isDuplicateKeyError } from "./mongooseToJsonSchema-GhvZUjRO.mjs";
8
8
  import * as _$mongoose from "mongoose";
9
9
  import { ClientSession, Expression, Model, PipelineStage, PopulateOptions } from "mongoose";
10
- import { AggPaginationRequest, AggRequest, AggResult, KeysetAggPaginationResult, QueryOptions, RepositoryBase, TenantPurgeOptions, TenantPurgeResult, TenantPurgeStrategy } from "@classytic/repo-core/repository";
10
+ import { AggPaginationRequest, AggRequest, AggResult, ChangeEvent, FilterInput, KeysetAggPaginationResult, QueryOptions, RepoCapabilities, RepositoryBase, TenantPurgeOptions, TenantPurgeResult, TenantPurgeStrategy, WatchOptions } from "@classytic/repo-core/repository";
11
11
  import { UpdateInput } from "@classytic/repo-core/update";
12
12
  import { AggregatePaginationResult, KeysetPaginationResult, OffsetPaginationResult, OffsetPaginationResultCore } from "@classytic/repo-core/pagination";
13
13
  import { HttpError } from "@classytic/repo-core/errors";
14
14
 
15
- //#region src/filter/compile.d.ts
16
- /**
17
- * Filter IR → MongoDB query compiler.
18
- *
19
- * Mirrors sqlitekit's `compileFilterToDrizzle`: takes a repo-core
20
- * `Filter` tree and emits a MongoDB query object. Used by `groupBy` /
21
- * `groupByPaginate` to translate the portable AggRequest's `filter`
22
- * + `having` into `$match` stages.
23
- *
24
- * Scope: the same operator set sqlitekit supports (`eq`, `ne`, `gt`,
25
- * `gte`, `lt`, `lte`, `in`, `nin`, `exists`, `like`, `regex`, `and`,
26
- * `or`, `not`, `raw`). Anything else throws so we fail loud instead
27
- * of silently dropping predicates.
28
- *
29
- * Why only this op set here and not everywhere in mongokit? Historic
30
- * reason: mongokit pre-dates repo-core's Filter IR, so its existing
31
- * policy plugins (multi-tenant, soft-delete) speak MongoDB query
32
- * language directly. Converting mongokit's entire filter pipeline
33
- * to Filter IR is out-of-scope for this change — we introduce the
34
- * compiler only where the new portable `groupBy` IR needs it.
35
- */
36
- declare function compileFilterToMongo(input: unknown): Record<string, unknown>;
37
- //#endregion
38
- //#region src/operations.d.ts
39
- /**
40
- * Where a plugin should inject its scoping filter (multi-tenant scope,
41
- * soft-delete filter, etc.) on the RepositoryContext.
42
- *
43
- * - `data` — single-doc create payload (`context.data`)
44
- * - `dataArray` — multi-doc create payload (`context.dataArray`)
45
- * - `query` — raw filter (`context.query`); the dominant convention
46
- * - `filters` — paginated list options' filter sub-bag (`context.filters`)
47
- * - `operations` — bulkWrite per-sub-op (special — plugins walk each entry)
48
- * - `none` — no scoping target (op accepts no filter input)
49
- */
50
- type PolicyKey = 'data' | 'dataArray' | 'query' | 'filters' | 'operations' | 'none';
51
- interface OperationDescriptor {
52
- /** Where multi-tenant / soft-delete plugins inject their scoping filter. */
53
- policyKey: PolicyKey;
54
- /** Whether this op writes to the database. Drives audit + cache invalidation. */
55
- mutates: boolean;
56
- /** True when `context.id` is populated by the time before/after hooks fire. */
57
- hasIdContext: boolean;
58
- }
59
- /**
60
- * The registry. Order intentionally groups by category for readability.
61
- * Adding a new RepositoryOperation requires exactly one entry here, after
62
- * which every bundled plugin auto-classifies it correctly.
63
- */
64
- declare const OP_REGISTRY: Readonly<Record<RepositoryOperation, OperationDescriptor>>;
65
- /** All known repository operations, in registry order. */
66
- declare const ALL_OPERATIONS: RepositoryOperation[];
67
- /** Operations that mutate the database. Drives audit + cache invalidation lists. */
68
- declare const MUTATING_OPERATIONS: readonly RepositoryOperation[];
69
- /** Operations that don't mutate. Drives default cacheable ops. */
70
- declare const READ_OPERATIONS: readonly RepositoryOperation[];
71
- /** Filter ops by their policy injection key. */
72
- declare function operationsByPolicyKey(key: PolicyKey): RepositoryOperation[];
15
+ //#region src/capabilities.d.ts
16
+ declare const MONGOKIT_CAPABILITIES: RepoCapabilities;
73
17
  //#endregion
74
18
  //#region src/query/AggregationBuilder.d.ts
75
19
  /** Options for $vectorSearch stage (Atlas only) */
@@ -492,745 +436,407 @@ declare class AggregationBuilder {
492
436
  static startWith(query: Record<string, unknown>): AggregationBuilder;
493
437
  }
494
438
  //#endregion
495
- //#region src/query/QueryParser.d.ts
496
- type SortSpec$1 = Record<string, 1 | -1>;
497
- type FilterQuery = Record<string, unknown>;
439
+ //#region src/Repository.d.ts
498
440
  /**
499
- * Mongoose-compatible populate option
500
- * Supports advanced populate with select, match, limit, sort, and nested populate
441
+ * Plugin phase priorities (lower = runs first).
501
442
  *
502
- * @example
503
- * ```typescript
504
- * // URL: ?populate[author][select]=name,email&populate[author][match][active]=true
505
- * // Generates: { path: 'author', select: 'name email', match: { active: true } }
506
- * ```
507
- */
508
- interface PopulateOption {
509
- /** Field path to populate */
510
- path: string;
511
- /** Fields to select (space-separated) */
512
- select?: string;
513
- /** Filter conditions for populated documents */
514
- match?: Record<string, unknown>;
515
- /** Query options (limit, sort, skip) */
516
- options?: {
517
- limit?: number;
518
- sort?: SortSpec$1;
519
- skip?: number;
520
- };
521
- /** Nested populate configuration */
522
- populate?: PopulateOption;
523
- }
524
- /** Parsed query result with optional lookup configuration */
525
- interface ParsedQuery {
526
- /** MongoDB filter query */
527
- filters: FilterQuery;
528
- /** Sort specification */
529
- sort?: SortSpec$1;
530
- /** Fields to populate (simple comma-separated string) */
531
- populate?: string;
532
- /**
533
- * Advanced populate options (Mongoose-compatible)
534
- * When this is set, `populate` will be undefined
535
- * @example [{ path: 'author', select: 'name email' }]
536
- */
537
- populateOptions?: PopulateOption[];
538
- /** Page number for offset pagination */
539
- page?: number;
540
- /** Cursor for keyset pagination */
541
- after?: string;
542
- /** Limit */
543
- limit: number;
544
- /** Full-text search query */
545
- search?: string;
546
- /** Lookup configurations for custom field joins */
547
- lookups?: LookupOptions[];
548
- /** Aggregation pipeline stages (advanced) */
549
- aggregation?: PipelineStage[];
550
- /** Select/project fields */
551
- select?: Record<string, 0 | 1>;
552
- }
553
- /** Search mode for query parser */
554
- type SearchMode = 'text' | 'regex';
555
- /**
556
- * Normalized field type used for schema-aware value coercion. Re-exported
557
- * from `primitives/coercion` so the public QueryParser API stays unchanged
558
- * while the actual logic lives in a single, unit-tested primitive module.
443
+ * Re-exported from `@classytic/repo-core/hooks` so mongokit 3.10 callers see
444
+ * the same constant whether they import from mongokit or directly from
445
+ * repo-core — priorities are identical (POLICY=100, CACHE=200,
446
+ * OBSERVABILITY=300, DEFAULT=500) and the two refs are reference-equal.
559
447
  */
560
- type FieldType = FieldType$1;
448
+ declare const HOOK_PRIORITY: {
449
+ readonly POLICY: 100;
450
+ readonly VALIDATION: 150;
451
+ readonly CACHE: 200;
452
+ readonly OBSERVABILITY: 300;
453
+ readonly DEFAULT: 500;
454
+ };
561
455
  /**
562
- * Minimal structural type for a Mongoose schema. We only read `.paths` and
563
- * (optionally) `.indexes()`, so the parser stays decoupled from a specific
564
- * Mongoose major version and is easy to mock in tests. Re-exported from the
565
- * primitives module — the canonical definition lives there.
456
+ * Production-grade repository for MongoDB.
457
+ *
458
+ * Extends `@classytic/repo-core/repository`'s `RepositoryBase` for the
459
+ * driver-agnostic hook + plugin plumbing (context builder, priority-sorted
460
+ * event engine, plugin-order validator) while layering every Mongo-specific
461
+ * concern — populate, aggregate, lookup, keyset + cursor pagination, $near
462
+ * rewriting — on top.
463
+ *
464
+ * Public surface is byte-stable with mongokit 3.9 (the `_hooks` property is
465
+ * preserved as a live read-through getter; all CRUD, event, and plugin method
466
+ * signatures unchanged). 3.10 is an internal re-plumbing release; consumers
467
+ * upgrading from 3.9 need no code changes.
566
468
  */
567
- interface SchemaLike extends SchemaPathsLike {
568
- /** Optional — when present, schema indexes are introspected for geo/text fields */
569
- indexes?: () => Array<[Record<string, unknown>, Record<string, unknown>?]>;
570
- }
571
- interface QueryParserOptions {
572
- /** Maximum allowed regex pattern length (default: 500) */
573
- maxRegexLength?: number;
574
- /** Maximum allowed text search query length (default: 200) */
575
- maxSearchLength?: number;
576
- /** Maximum allowed filter depth (default: 10) */
577
- maxFilterDepth?: number;
578
- /** Maximum allowed limit value (default: 1000) */
579
- maxLimit?: number;
580
- /** Additional operators to block */
581
- additionalDangerousOperators?: string[];
582
- /** Enable lookup parsing (default: true) */
583
- enableLookups?: boolean;
584
- /** Enable aggregation parsing (default: false - requires explicit opt-in) */
585
- enableAggregations?: boolean;
469
+ declare class Repository<TDoc = unknown> extends RepositoryBase {
586
470
  /**
587
- * Search mode (default: 'text')
588
- * - 'text': Uses MongoDB $text search (requires text index)
589
- * - 'regex': Uses $or with $regex across searchFields (no index required)
471
+ * Runtime capability descriptor required by `StandardRepo<TDoc>`
472
+ * (repo-core 0.6.0). Hosts feature-detect once at boot instead of
473
+ * try/catching `UnsupportedOperationError` per call.
590
474
  */
591
- searchMode?: SearchMode;
475
+ readonly capabilities: RepoCapabilities;
476
+ readonly Model: Model<TDoc>;
592
477
  /**
593
- * Fields to search when searchMode is 'regex'
594
- * Required when searchMode is 'regex'
595
- * @example ['name', 'description', 'sku', 'tags']
478
+ * Mongoose model name. Duplicated on the instance for BC — arc / catalog /
479
+ * user code read `repo.model`. `RepositoryBase.modelName` points to the
480
+ * same string; prefer `modelName` in new code.
596
481
  */
597
- searchFields?: string[];
482
+ readonly model: string;
483
+ readonly _pagination: PaginationEngine<TDoc>;
484
+ readonly idField: string;
485
+ readonly searchMode: 'text' | 'regex' | 'auto';
486
+ readonly searchFields: string[] | undefined;
487
+ [key: string]: unknown;
488
+ private _hasTextIndex;
598
489
  /**
599
- * Whitelist of collection names allowed in lookups.
600
- * When set, only these collections can be used in $lookup stages.
601
- * When undefined, all collection names are allowed.
602
- * @example ['departments', 'categories', 'users']
490
+ * Wrap-style middleware chain. Composed around every `_runOp`
491
+ * invocation in registration order — earlier-registered middlewares
492
+ * are outer (run first / unwind last). See `useMiddleware()` and
493
+ * the `Middleware<TDoc>` type for the protocol.
603
494
  */
604
- allowedLookupCollections?: string[];
605
- /** Allowed fields for filtering. If set, ignores unknown fields. */
606
- allowedFilterFields?: string[];
607
- /** Allowed fields for sorting. If set, ignores unknown fields. */
608
- allowedSortFields?: string[];
495
+ private readonly _middlewares;
496
+ constructor(Model: Model<TDoc, any, any, any>, plugins?: PluginType[], paginationConfig?: PaginationConfig, options?: RepositoryOptions);
609
497
  /**
610
- * Whitelist of allowed filter operators.
611
- * When set, only these operators can be used in filters.
612
- * When undefined, all built-in operators are allowed.
613
- * Values are human-readable keys: 'eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'nin',
614
- * 'like', 'contains', 'regex', 'exists', 'size', 'type'
615
- * @example ['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in']
498
+ * Live read-through view of the hook engine's listener registry.
499
+ *
500
+ * Preserved as a public field on the Repository for back-compat with
501
+ * mongokit ≤3.9 — existing tests and observability code pattern-match
502
+ * `repo._hooks.get('before:getAll')` / `repo._hooks.size`. Each access
503
+ * returns a fresh snapshot of the underlying HookEngine state.
504
+ *
505
+ * Read-only. To register listeners use `repo.on(event, listener, { priority })`.
616
506
  */
617
- allowedOperators?: string[];
507
+ get _hooks(): Map<string, PrioritizedHook[]>;
618
508
  /**
619
- * Mongoose schema (or schema-like object exposing `.paths`) used for
620
- * authoritative, schema-aware value coercion. When provided, filter values
621
- * are coerced to the declared field type instead of guessed from the
622
- * string shape — `?stock=50` becomes a number against a `Number` field but
623
- * `?name=12345` stays a string against a `String` field. Nested paths like
624
- * `address.zip` are looked up via dot-notation, matching Mongoose's
625
- * `schema.paths` reflection. Unknown fields fall through to the heuristic.
509
+ * Fire-and-forget emit aliased through RepositoryBase's async path so
510
+ * error hooks converted to/from sync mode still route uniformly. Mongokit
511
+ * ≤3.9 made this a private method on its subclass; the base class exposes
512
+ * it publicly and wraps it in `_emitHook` below for internal use.
626
513
  */
627
- schema?: SchemaLike;
514
+ private _emitHook;
515
+ private _emitErrorHook;
628
516
  /**
629
- * Plain field-type map used for authoritative coercion when no Mongoose
630
- * schema is available (raw MongoDB, Prisma, computed fields, upstream
631
- * models you don't own). Takes precedence over `schema` for paths declared
632
- * in both — useful for runtime overrides. Use dot-notation for nested
633
- * paths (`'address.zip': 'string'`).
634
- * @example { stock: 'number', active: 'boolean', releasedAt: 'date' }
517
+ * Wire the `warnOnStrictQueryStrip` diagnostic. When enabled, every
518
+ * filter-shaped op (`getById`, `getByQuery`, `getOne`, `findAll`,
519
+ * `count`, `exists`, `update`, `delete`, `findOneAndUpdate`,
520
+ * `updateMany`, `deleteMany`, `claim`, `claimVersion`, `cursor`)
521
+ * checks its `context.query` keys against the cached set of schema
522
+ * paths. Top-level keys not on the schema get logged once per
523
+ * `(model, key)` pair — so a single bad caller surfaces in dev /
524
+ * staging instead of returning the wrong row in prod.
525
+ *
526
+ * Only runs when `strictQuery !== false` is in effect — if mongoose's
527
+ * strictQuery is off, unknown keys flow through to the driver and
528
+ * the trap doesn't apply.
635
529
  */
636
- fieldTypes?: Record<string, FieldType>;
637
- }
638
- /**
639
- * Modern Query Parser
640
- * Converts URL parameters to MongoDB queries with $lookup support
641
- */
642
- declare class QueryParser {
643
- private readonly options;
644
- private readonly operators;
645
- private readonly dangerousOperators;
646
- /**
647
- * Normalized dot-notation field-type map. Built once at construction from
648
- * `options.schema` (Mongoose) and `options.fieldTypes` (override map), with
649
- * `fieldTypes` taking precedence. An empty map means schema-aware coercion
650
- * is disabled and the heuristic in `_convertValue` runs unchanged — this
651
- * preserves backwards compatibility for callers that don't opt in.
652
- */
653
- private readonly _fieldTypes;
654
- /**
655
- * Structured schema-index info (geo / text / other), built once from
656
- * `options.schema?.indexes()`. Used by the operator router to validate
657
- * geo operators against actual geo-indexed fields, and exposed publicly
658
- * via `parser.schemaIndexes` for downstream tools (Arc MCP, query planners).
659
- */
660
- private readonly _schemaIndexes;
530
+ private _wireStrictQueryStripDiagnostic;
661
531
  /**
662
- * Regex patterns that can cause catastrophic backtracking (ReDoS attacks).
663
- * Detects common footguns:
664
- * - Quantifiers: {n,m}
665
- * - Possessive quantifiers: *+, ++, ?+
666
- * - Nested quantifiers: (a+)+, (a*)*
667
- * - Backreferences: \1, \2, etc.
668
- * - Complex character classes: [...]...[...]
532
+ * Register a wrap-style middleware. Middleware composes AROUND every
533
+ * `_runOp` invocation — it sees the operation name, the live
534
+ * `RepositoryContext`, and a `next()` continuation that runs the
535
+ * remaining chain + the actual op + after/error hooks.
669
536
  *
670
- * This pattern detector is FAIL-SAFE (on match, the input is escaped), but
671
- * it is not exhaustive. {@link _regexComplexityExceedsBudget} complements
672
- * it with a deterministic static complexity budget so patterns that happen
673
- * to evade the detector still fail over when their unbounded-quantifier
674
- * density is pathological.
675
- */
676
- private readonly dangerousRegexPatterns;
677
- /**
678
- * Static complexity budget for user-supplied regex patterns.
679
- * Returns `true` if the pattern crosses a budget that empirically correlates
680
- * with ReDoS risk — independent of the heuristic regex detector.
537
+ * **Composes with `repo.on()` and plugins, doesn't replace them.**
538
+ * Middleware is for ergonomics (timing, short-circuit, input/output
539
+ * mutation in a single closure) — NOT for security policy. Use the
540
+ * `before:*` hook engine for tenant scope, soft-delete filtering,
541
+ * cache invalidation, and audit; those plugins MUST run before
542
+ * middleware sees the op.
681
543
  *
682
- * Budget components (any single threshold crossed → over budget):
683
- * - unbounded quantifiers (`*`, `+`, `?`) — hard cap 20
684
- * - nested groups `(` — hard cap 8
685
- * - alternations `|` — hard cap 10
686
- * - combined: (quantifiers * groups) — hard cap 40
544
+ * **Execution order on every call** (build/before hooks fire BEFORE
545
+ * the middleware chain dispatches, so middleware cannot wrap a
546
+ * before-hook failure):
687
547
  *
688
- * These thresholds are intentionally permissive for legitimate queries and
689
- * tight enough to refuse known-pathological patterns like `(a+)+b` with
690
- * heavy repetition.
691
- */
692
- private _regexComplexityExceedsBudget;
693
- constructor(options?: QueryParserOptions);
694
- /**
695
- * Structured view of the configured schema's indexes — geo fields, text
696
- * fields, and other compound indexes. Empty arrays when no schema was
697
- * provided. Stable across the parser's lifetime; mutating the returned
698
- * object does not affect parser behavior.
548
+ * ```text
549
+ * _buildContext + before:<op> ← repo.on('before:*') hooks
550
+ * [outer middleware pre] ← repo.useMiddleware() registrations
551
+ * [...inner middleware pre]
552
+ * fn (driver call)
553
+ * after:<op> | error:<op> ← repo.on('after:*' / 'error:*')
554
+ * [...inner middleware post]
555
+ * [outer middleware post]
556
+ * ```
699
557
  *
700
- * Useful for downstream tooling (Arc MCP, query planners, doc generators)
701
- * that needs to know which fields support which query types.
702
- */
703
- get schemaIndexes(): SchemaIndexes;
704
- /**
705
- * Get the configured allowed filter fields.
706
- * Returns `undefined` if no whitelist is set (all fields allowed).
558
+ * Why this order matters: a `before:create` hook that injects a
559
+ * tenant filter into `context.query` runs BEFORE the middleware chain
560
+ * is composed. Middleware sees a context that was already scoped by
561
+ * the policy plugins — it never has the authority to short-circuit a
562
+ * tenant check, because the throw from a `before:*` policy hook
563
+ * unwinds before middleware ever fires. That's by design — middleware
564
+ * as a security boundary would be impossible to audit, since
565
+ * registration order would determine whether scope wins.
707
566
  *
708
- * Used by Arc's MCP integration to auto-derive `filterableFields`
709
- * from the QueryParser when `schemaOptions.filterableFields` is not set.
710
- */
711
- get allowedFilterFields(): string[] | undefined;
712
- /**
713
- * Get the configured allowed sort fields.
714
- * Returns `undefined` if no whitelist is set (all fields allowed).
715
- */
716
- get allowedSortFields(): string[] | undefined;
717
- /**
718
- * Get the configured allowed operators.
719
- * Returns `undefined` if no whitelist is set (all built-in operators allowed).
720
- */
721
- get allowedOperators(): string[] | undefined;
722
- /**
723
- * Parse URL query parameters into MongoDB query format
567
+ * Practical consequence: if you want middleware to observe a policy
568
+ * failure (e.g. metric a tenant-scope rejection), use `repo.on('error:
569
+ * <op>', ...)` instead — that fires from inside the middleware chain
570
+ * and is reachable.
724
571
  *
725
- * @example
726
- * ```typescript
727
- * // URL: ?status=active&lookup[department][foreignField]=slug&sort=-createdAt&page=1
728
- * const query = parser.parse(req.query);
729
- * // Returns: { filters: {...}, lookups: [...], sort: {...}, page: 1 }
572
+ * Registration order = composition order: the first middleware
573
+ * registered runs outermost (wraps everything else).
574
+ *
575
+ * @example Time every op
576
+ * ```ts
577
+ * repo.useMiddleware(async ({ operation, next }) => {
578
+ * const start = performance.now();
579
+ * try { return await next(); }
580
+ * finally { metrics.record(operation, performance.now() - start); }
581
+ * });
582
+ * ```
583
+ *
584
+ * @example Inject `tenantId` on every create
585
+ * ```ts
586
+ * repo.useMiddleware(async ({ operation, context, next }) => {
587
+ * if (operation === 'create' && context.data) {
588
+ * context.data.tenantId = currentTenant();
589
+ * }
590
+ * return next();
591
+ * });
592
+ * ```
593
+ *
594
+ * @example Short-circuit (return without `next()` to skip the actual op)
595
+ * ```ts
596
+ * repo.useMiddleware(async ({ operation, context, next }) => {
597
+ * if (operation === 'getById' && readOnlyMaintenance) {
598
+ * return cachedReadOnlyResponse(context.id);
599
+ * }
600
+ * return next();
601
+ * });
730
602
  * ```
731
603
  */
732
- parse(query: Record<string, unknown> | null | undefined): ParsedQuery;
604
+ useMiddleware(middleware: Middleware<TDoc>): this;
733
605
  /**
734
- * Generate OpenAPI-compatible JSON Schema for query parameters.
735
- * Arc's defineResource() auto-detects this method and uses it
736
- * to document list endpoint query parameters in OpenAPI/Swagger.
606
+ * Compose the registered middleware chain around `exec`. Outermost
607
+ * middleware (first registered) wraps innermost. Each middleware sees
608
+ * the live `RepositoryContext` (already populated + run through
609
+ * `before:*` hooks at the call site — see `useMiddleware` JSDoc for
610
+ * the exact order). `after:` / `error:` hooks fire from inside
611
+ * `exec`, so middleware composes WITH plugins, not instead of them.
737
612
  *
738
- * The schema respects parser configuration:
739
- * - `allowedOperators`: only documents allowed operators
740
- * - `allowedFilterFields`: generates explicit field[op] entries
741
- * - `enableLookups` / `enableAggregations`: includes/excludes lookup/aggregate params
742
- * - `maxLimit` / `maxSearchLength`: reflected in schema constraints
613
+ * `_runOp` calls this with its standard try/catch envelope; inline-try
614
+ * methods (`getById`, `update`, `delete`, `deleteMany`, `aggregatePaginate`,
615
+ * `lookupPopulate`, plus `getOne`/`getAll` cache-hit branches) call it
616
+ * directly so middleware sees every op — including cached reads — not
617
+ * just the ones routed through `_runOp`.
743
618
  */
744
- getQuerySchema(): {
745
- type: 'object';
746
- properties: Record<string, unknown>;
747
- required?: string[];
748
- };
619
+ private _composeMiddleware;
749
620
  /**
750
- * Get the query schema with OpenAPI extensions (x-internal metadata).
751
- * Use this when generating OpenAPI/Swagger docs — it includes a documentary
752
- * `_filterOperators` property describing available filter operators.
753
- * For validation-only schemas, use `getQuerySchema()` instead.
621
+ * Standard operation envelope: compose middleware around an exec that
622
+ * runs `fn`, emits `after:<op>` on success or `error:<op>` on failure.
623
+ * Methods with branched in-try logic that emit `after:*` from multiple
624
+ * paths (`getById`, `update`, `delete`, `deleteMany`, `aggregatePaginate`,
625
+ * `lookupPopulate`) keep their inline try/catch and call
626
+ * `_composeMiddleware` directly instead of being forced through this
627
+ * single-result helper.
754
628
  */
755
- getOpenAPIQuerySchema(): {
756
- type: 'object';
757
- properties: Record<string, unknown>;
758
- };
629
+ private _runOp;
759
630
  /**
760
- * Get the JSON Schema type for a filter operator
631
+ * Resilience envelope around the DRIVER round-trip only — the repo-core
632
+ * 0.6.0 `QueryOptions.signal` / `QueryOptions.retryPolicy` contract.
633
+ *
634
+ * `throwIfAborted` stops a cancelled request before the next driver call;
635
+ * `withRetry` retries transient failures with exponential backoff (and is
636
+ * a zero-cost passthrough when no policy was passed, so we wrap
637
+ * unconditionally). Crucially this wraps `fn` AFTER `_buildContext` ran —
638
+ * before-hooks (validation, tenant scope, audit, events) execute exactly
639
+ * once per logical call and are NEVER re-run on retry; only the driver
640
+ * call repeats.
641
+ *
642
+ * Both knobs are read from the context (the options bag is spread into
643
+ * `_buildContext` inputs), so every op routed through `_runOp` — and the
644
+ * inline-try methods that call this directly — honors them uniformly.
761
645
  */
762
- private _getOperatorSchemaType;
646
+ private _withResilience;
763
647
  /**
764
- * Get a human-readable description for a filter operator
648
+ * Create single document
765
649
  */
766
- private _getOperatorDescription;
650
+ create(data: Record<string, unknown>, options?: CreateOptions): Promise<TDoc>;
767
651
  /**
768
- * Build a summary description of all available filter operators
652
+ * Create multiple documents
769
653
  */
770
- private _buildOperatorDescription;
654
+ createMany(dataArray: Record<string, unknown>[], options?: CreateOptions): Promise<TDoc[]>;
771
655
  /**
772
- * Parse lookup configurations from URL parameters
773
- *
774
- * Supported formats:
775
- * 1. Simple: ?lookup[department]=slug
776
- * → Join with 'departments' collection on slug field
777
- *
778
- * 2. Detailed: ?lookup[department][localField]=deptSlug&lookup[department][foreignField]=slug
779
- * → Full control over join configuration
780
- *
781
- * 3. Multiple: ?lookup[department]=slug&lookup[category]=categorySlug
782
- * → Multiple lookups
783
- *
784
- * @example
785
- * ```typescript
786
- * // URL: ?lookup[department][localField]=deptSlug&lookup[department][foreignField]=slug&lookup[department][single]=true
787
- * const lookups = parser._parseLookups({
788
- * department: { localField: 'deptSlug', foreignField: 'slug', single: 'true' }
789
- * });
790
- * // Returns: [{ from: 'departments', localField: 'deptSlug', foreignField: 'slug', single: true }]
791
- * ```
656
+ * Get document by ID
792
657
  */
793
- private _parseLookups;
658
+ getById(id: string | ObjectId, options?: CacheableOptions): Promise<TDoc | null>;
794
659
  /**
795
- * Parse a single lookup configuration
660
+ * Get single document by query
796
661
  */
797
- private _parseSingleLookup;
662
+ getByQuery(query: Record<string, unknown>, options?: CacheableOptions & {
663
+ sort?: SortSpec;
664
+ }): Promise<TDoc | null>;
798
665
  /**
799
- * Parse aggregation pipeline from URL (advanced feature)
666
+ * Get single document by arbitrary filter.
667
+ * Unlike getByQuery, this method is designed for Arc/controller use where
668
+ * compound filters (org scope + policy + id) are pre-built.
800
669
  *
801
670
  * @example
802
- * ```typescript
803
- * // URL: ?aggregate[group][_id]=$status&aggregate[group][count]=$sum:1
804
- * const pipeline = parser._parseAggregation({
805
- * group: { _id: '$status', count: '$sum:1' }
806
- * });
807
- * ```
671
+ * const product = await repo.getOne({ slug: 'laptop', organizationId: 'org_1' });
808
672
  */
809
- private _parseAggregation;
673
+ getOne(query: Record<string, unknown>, options?: CacheableOptions & {
674
+ sort?: SortSpec;
675
+ }): Promise<TDoc | null>;
810
676
  /**
811
- * Parse select/project fields
677
+ * Fetch ALL documents matching filters without pagination.
678
+ * Use for background jobs, exports, batch processing where you need every doc.
812
679
  *
813
680
  * @example
814
- * ```typescript
815
- * // URL: ?select=name,email,-password
816
- * // Returns: { name: 1, email: 1, password: 0 }
817
- * ```
681
+ * const all = await repo.findAll({ status: 'active' });
682
+ * const allLean = await repo.findAll({}, { select: 'name email', lean: true });
818
683
  */
819
- private _parseSelect;
684
+ findAll(filters?: Record<string, unknown>, options?: OperationOptions & {
685
+ sort?: SortSpec | string;
686
+ limit?: number;
687
+ }): Promise<TDoc[]>;
820
688
  /**
821
- * Parse populate parameter - handles both simple string and advanced object format
689
+ * Streaming reads — async iterator over data, suitable for migrations,
690
+ * backfills, schema audits, and any once-a-quarter "touch every row"
691
+ * job. Goes through the standard `before:cursor` hook pipeline so
692
+ * multi-tenant scope, soft-delete filter, and access-control plugins
693
+ * inject scope BEFORE the underlying mongoose cursor is built.
822
694
  *
823
- * @example
824
- * ```typescript
825
- * // Simple: ?populate=author,category
826
- * // Returns: { simplePopulate: 'author,category', populateOptions: undefined }
695
+ * Replaces direct `Model.find().cursor()` usage which bypasses every
696
+ * plugin — that's how cross-tenant data ends up in migrations.
827
697
  *
828
- * // Advanced: ?populate[author][select]=name,email
829
- * // Returns: { simplePopulate: undefined, populateOptions: [{ path: 'author', select: 'name email' }] }
698
+ * Returns an `AsyncIterableIterator<TDoc>` — drive it with `for await`.
699
+ * The mongoose cursor is closed when iteration completes or breaks.
700
+ *
701
+ * @example Backfill a missing field across the whole tenant scope
702
+ * ```ts
703
+ * for await (const doc of repo.cursor({ filter: { migrated: { $ne: true }}}, { batchSize: 1000 })) {
704
+ * await transformer(doc);
705
+ * await repo.update(doc._id, { migrated: true });
706
+ * }
830
707
  * ```
831
- */
832
- private _parsePopulate;
833
- /**
834
- * Parse a single populate configuration
835
- */
836
- private _parseSinglePopulate;
837
- /**
838
- * Convert populate match values (handles boolean strings, etc.)
839
- */
840
- private _convertPopulateMatch;
841
- /**
842
- * Parse filter parameters
843
- */
844
- private _parseFilters;
845
- /**
846
- * Handle operator syntax: field[operator]=value
847
- */
848
- private _handleOperatorSyntax;
849
- /**
850
- * Handle bracket syntax with object value
851
- */
852
- private _handleBracketSyntax;
853
- private _parseSort;
854
- private _toMongoOperator;
855
- private _createSafeRegex;
856
- private _escapeRegex;
857
- /**
858
- * Sanitize $match configuration to prevent dangerous operators
859
- * Recursively filters out operators like $where, $function, $accumulator
860
- */
861
- private _sanitizeMatchConfig;
862
- /**
863
- * Sanitize pipeline stages for use in $lookup.
864
- * Blocks dangerous stages ($out, $merge, etc.) and recursively sanitizes
865
- * operator expressions within $match, $addFields, and $set stages.
866
- */
867
- private _sanitizePipeline;
868
- /**
869
- * Recursively sanitize expression objects, blocking dangerous operators
870
- * like $where, $function, $accumulator inside $addFields/$set stages.
871
708
  *
872
- * Depth-guarded — same protection `_sanitizeMatchConfig` ships, since
873
- * a hostile `$cond`/`$switch` chain can recurse arbitrarily deep and
874
- * exhaust JS stack. Without the guard, this is a one-liner DoS against
875
- * any host that exposes pipeline parsing to untrusted callers.
709
+ * @example Audit unscoped — opt out of multi-tenant for global sweeps
710
+ * ```ts
711
+ * for await (const doc of repo.cursor({}, { batchSize: 500, organizationId: undefined })) {
712
+ * audit(doc);
713
+ * }
714
+ * ```
876
715
  */
877
- private _sanitizeExpressions;
878
- private _sanitizeSearch;
716
+ cursor(filter?: Record<string, unknown>, options?: ReadOptions & {
717
+ sort?: SortSpec | string;
718
+ batchSize?: number;
719
+ select?: SelectSpec;
720
+ lean?: boolean;
721
+ }): AsyncIterableIterator<TDoc>;
879
722
  /**
880
- * Build regex-based multi-field search filters
881
- * Creates an $or query with case-insensitive regex across all searchFields
723
+ * Portable change feed — `StandardRepo.watch()` over Mongo change
724
+ * streams (`Model.watch`). Requires a replica set (or mongos); a
725
+ * standalone mongod rejects change streams at open time — gate on
726
+ * `repo.capabilities.changeStreams` plus your deployment topology.
727
+ *
728
+ * Semantics:
729
+ * - **Plugin-routed like every other read.** The filter goes through
730
+ * the standard `before:watch` hook pipeline (`OP_REGISTRY.watch`,
731
+ * policyKey `'query'`) BEFORE the change-stream pipeline is built —
732
+ * `multiTenantPlugin` injects the tenant scope (and throws under
733
+ * `required: true` exactly like other reads), `softDeletePlugin`
734
+ * injects the deletion-state predicate. A tenant-scoped repo never
735
+ * streams cross-tenant changes.
736
+ * - `fullDocument: 'updateLookup'` — update events carry the post-
737
+ * image document when it still exists at lookup time.
738
+ * - `filter` (record or Filter IR) is compiled with the standard
739
+ * filter compiler and applied against `fullDocument.*` paths, so
740
+ * `watch({ status: 'pending' })` matches the post-image. Delete
741
+ * events have no `fullDocument` and therefore don't match a
742
+ * non-empty post-policy filter — use `bypassTenant` / an unscoped
743
+ * repo without a filter to observe deletes.
744
+ * - `options.signal` ends the iterator (closes the stream). A
745
+ * pre-aborted signal rejects at the op boundary like every other op.
746
+ * - `options.resumeAfter` forwards a previously captured Mongo
747
+ * resume token for at-least-once consumption across restarts.
748
+ *
749
+ * Context keys for policy plugins (`organizationId`, `bypassTenant`,
750
+ * `includeDeleted`, ...) ride the options bag at runtime — same as every
751
+ * other op. The contract's `WatchOptions` type doesn't declare an index
752
+ * signature, so typed callers widen at the call site (or rely on an
753
+ * ALS `resolveContext` so no per-call tenant key is needed).
882
754
  *
883
755
  * @example
884
- * // searchFields: ['name', 'description', 'sku']
885
- * // search: 'azure'
886
- * // Returns: [
887
- * // { name: { $regex: /azure/i } },
888
- * // { description: { $regex: /azure/i } },
889
- * // { sku: { $regex: /azure/i } }
890
- * // ]
891
- */
892
- private _buildRegexSearch;
893
- private _parseOr;
894
- private _enhanceWithBetween;
895
- private _pluralize;
896
- private _capitalize;
897
- }
898
- //#endregion
899
- //#region src/Repository.d.ts
900
- /**
901
- * Plugin phase priorities (lower = runs first).
902
- *
903
- * Re-exported from `@classytic/repo-core/hooks` so mongokit 3.10 callers see
904
- * the same constant whether they import from mongokit or directly from
905
- * repo-core — priorities are identical (POLICY=100, CACHE=200,
906
- * OBSERVABILITY=300, DEFAULT=500) and the two refs are reference-equal.
907
- */
908
- declare const HOOK_PRIORITY: {
909
- readonly POLICY: 100;
910
- readonly CACHE: 200;
911
- readonly OBSERVABILITY: 300;
912
- readonly DEFAULT: 500;
913
- };
914
- /**
915
- * Production-grade repository for MongoDB.
916
- *
917
- * Extends `@classytic/repo-core/repository`'s `RepositoryBase` for the
918
- * driver-agnostic hook + plugin plumbing (context builder, priority-sorted
919
- * event engine, plugin-order validator) while layering every Mongo-specific
920
- * concern — populate, aggregate, lookup, keyset + cursor pagination, $near
921
- * rewriting — on top.
922
- *
923
- * Public surface is byte-stable with mongokit 3.9 (the `_hooks` property is
924
- * preserved as a live read-through getter; all CRUD, event, and plugin method
925
- * signatures unchanged). 3.10 is an internal re-plumbing release; consumers
926
- * upgrading from 3.9 need no code changes.
927
- */
928
- declare class Repository<TDoc = unknown> extends RepositoryBase {
929
- readonly Model: Model<TDoc>;
930
- /**
931
- * Mongoose model name. Duplicated on the instance for BC — arc / catalog /
932
- * user code read `repo.model`. `RepositoryBase.modelName` points to the
933
- * same string; prefer `modelName` in new code.
756
+ * ```ts
757
+ * const ac = new AbortController();
758
+ * for await (const change of repo.watch({ status: 'pending' }, { signal: ac.signal })) {
759
+ * if (change.operation === 'create') enqueue(change.doc!);
760
+ * }
761
+ * ```
934
762
  */
935
- readonly model: string;
936
- readonly _pagination: PaginationEngine<TDoc>;
937
- readonly idField: string;
938
- readonly searchMode: 'text' | 'regex' | 'auto';
939
- readonly searchFields: string[] | undefined;
940
- [key: string]: unknown;
941
- private _hasTextIndex;
763
+ watch(filter?: FilterInput, options?: WatchOptions): AsyncIterable<ChangeEvent<TDoc>>;
942
764
  /**
943
- * Wrap-style middleware chain. Composed around every `_runOp`
944
- * invocation in registration order — earlier-registered middlewares
945
- * are outer (run first / unwind last). See `useMiddleware()` and
946
- * the `Middleware<TDoc>` type for the protocol.
765
+ * Unified pagination - auto-detects offset vs keyset based on params
766
+ *
767
+ * Auto-detection logic:
768
+ * - If params has 'cursor' or 'after' → uses keyset pagination (stream)
769
+ * - If params has 'pagination' or 'page' → uses offset pagination (paginate)
770
+ * - Else → defaults to offset pagination with page=1
771
+ *
772
+ * @example
773
+ * // Offset pagination (page-based)
774
+ * await repo.getAll({ page: 1, limit: 50, filters: { status: 'active' } });
775
+ * await repo.getAll({ pagination: { page: 2, limit: 20 } });
776
+ *
777
+ * // Keyset pagination (cursor-based)
778
+ * await repo.getAll({ cursor: 'eyJ2Ij...', limit: 50 });
779
+ * await repo.getAll({ after: 'eyJ2Ij...', sort: { createdAt: -1 } });
780
+ *
781
+ * // Simple query (defaults to page 1)
782
+ * await repo.getAll({ filters: { status: 'active' } });
783
+ *
784
+ * // Skip cache for fresh data
785
+ * await repo.getAll({ filters: { status: 'active' } }, { skipCache: true });
947
786
  */
948
- private readonly _middlewares;
949
- constructor(Model: Model<TDoc, any, any, any>, plugins?: PluginType[], paginationConfig?: PaginationConfig, options?: RepositoryOptions);
787
+ getAll(params?: {
788
+ filters?: Record<string, unknown>;
789
+ sort?: SortSpec | string;
790
+ cursor?: string;
791
+ after?: string;
792
+ page?: number;
793
+ pagination?: {
794
+ page?: number;
795
+ limit?: number;
796
+ };
797
+ limit?: number;
798
+ search?: string;
799
+ mode?: 'offset' | 'keyset';
800
+ hint?: string | Record<string, 1 | -1>;
801
+ maxTimeMS?: number;
802
+ countStrategy?: 'exact' | 'estimated' | 'none';
803
+ readPreference?: ReadPreferenceType; /** Advanced populate options (from QueryParser or Arc's BaseController) */
804
+ populateOptions?: PopulateOptions[]; /** Collation for locale-aware string comparison */
805
+ collation?: CollationOptions; /** Lookup configurations for $lookup joins (from QueryParser or manual) */
806
+ lookups?: LookupOptions[]; /** Skip pagination entirely — returns raw TDoc[] (same as findAll) */
807
+ noPagination?: boolean;
808
+ }, options?: CacheableOptions): Promise<OffsetPaginationResult<TDoc> | KeysetPaginationResult<TDoc> | TDoc[]>;
950
809
  /**
951
- * Live read-through view of the hook engine's listener registry.
810
+ * Atomic "look up by filter, insert `data` if missing, return the doc."
952
811
  *
953
- * Preserved as a public field on the Repository for back-compat with
954
- * mongokit ≤3.9 — existing tests and observability code pattern-match
955
- * `repo._hooks.get('before:getAll')` / `repo._hooks.size`. Each access
956
- * returns a fresh snapshot of the underlying HookEngine state.
812
+ * Returns `{ doc, created }` so callers can tell whether *this* call
813
+ * inserted (race-win) or matched an existing row. Implements
814
+ * `StandardRepo.getOrCreate()` from `@classytic/repo-core/repository`
815
+ * — see that interface's JSDoc for the cross-kit contract.
957
816
  *
958
- * Read-only. To register listeners use `repo.on(event, listener, { priority })`.
817
+ * Routes through the hook system for policy enforcement (multi-tenant,
818
+ * soft-delete, audit). The hook context carries the resolved query +
819
+ * data; the action layer adds the `created` discriminator from the
820
+ * driver's `lastErrorObject.upserted`.
959
821
  */
960
- get _hooks(): Map<string, PrioritizedHook[]>;
822
+ getOrCreate(query: Record<string, unknown>, createData: Record<string, unknown>, options?: SessionOptions): Promise<{
823
+ doc: TDoc;
824
+ created: boolean;
825
+ }>;
961
826
  /**
962
- * Fire-and-forget emit aliased through RepositoryBase's async path so
963
- * error hooks converted to/from sync mode still route uniformly. Mongokit
964
- * ≤3.9 made this a private method on its subclass; the base class exposes
965
- * it publicly and wraps it in `_emitHook` below for internal use.
827
+ * Count documents
828
+ * Routes through hook system for policy enforcement (multi-tenant, soft-delete)
966
829
  */
967
- private _emitHook;
968
- private _emitErrorHook;
830
+ count(query?: Record<string, unknown>, options?: ReadOptions): Promise<number>;
969
831
  /**
970
- * Wire the `warnOnStrictQueryStrip` diagnostic. When enabled, every
971
- * filter-shaped op (`getById`, `getByQuery`, `getOne`, `findAll`,
972
- * `count`, `exists`, `update`, `delete`, `findOneAndUpdate`,
973
- * `updateMany`, `deleteMany`, `claim`, `claimVersion`, `cursor`)
974
- * checks its `context.query` keys against the cached set of schema
975
- * paths. Top-level keys not on the schema get logged once per
976
- * `(model, key)` pair — so a single bad caller surfaces in dev /
977
- * staging instead of returning the wrong row in prod.
978
- *
979
- * Only runs when `strictQuery !== false` is in effect — if mongoose's
980
- * strictQuery is off, unknown keys flow through to the driver and
981
- * the trap doesn't apply.
832
+ * Check if document exists
833
+ * Routes through hook system for policy enforcement (multi-tenant, soft-delete)
982
834
  */
983
- private _wireStrictQueryStripDiagnostic;
835
+ exists(query: Record<string, unknown>, options?: ReadOptions): Promise<{
836
+ _id: unknown;
837
+ } | null>;
984
838
  /**
985
- * Register a wrap-style middleware. Middleware composes AROUND every
986
- * `_runOp` invocation — it sees the operation name, the live
987
- * `RepositoryContext`, and a `next()` continuation that runs the
988
- * remaining chain + the actual op + after/error hooks.
989
- *
990
- * **Composes with `repo.on()` and plugins, doesn't replace them.**
991
- * Middleware is for ergonomics (timing, short-circuit, input/output
992
- * mutation in a single closure) — NOT for security policy. Use the
993
- * `before:*` hook engine for tenant scope, soft-delete filtering,
994
- * cache invalidation, and audit; those plugins MUST run before
995
- * middleware sees the op.
996
- *
997
- * **Execution order on every call** (build/before hooks fire BEFORE
998
- * the middleware chain dispatches, so middleware cannot wrap a
999
- * before-hook failure):
1000
- *
1001
- * ```text
1002
- * _buildContext + before:<op> ← repo.on('before:*') hooks
1003
- * [outer middleware pre] ← repo.useMiddleware() registrations
1004
- * [...inner middleware pre]
1005
- * fn (driver call)
1006
- * after:<op> | error:<op> ← repo.on('after:*' / 'error:*')
1007
- * [...inner middleware post]
1008
- * [outer middleware post]
1009
- * ```
1010
- *
1011
- * Why this order matters: a `before:create` hook that injects a
1012
- * tenant filter into `context.query` runs BEFORE the middleware chain
1013
- * is composed. Middleware sees a context that was already scoped by
1014
- * the policy plugins — it never has the authority to short-circuit a
1015
- * tenant check, because the throw from a `before:*` policy hook
1016
- * unwinds before middleware ever fires. That's by design — middleware
1017
- * as a security boundary would be impossible to audit, since
1018
- * registration order would determine whether scope wins.
1019
- *
1020
- * Practical consequence: if you want middleware to observe a policy
1021
- * failure (e.g. metric a tenant-scope rejection), use `repo.on('error:
1022
- * <op>', ...)` instead — that fires from inside the middleware chain
1023
- * and is reachable.
1024
- *
1025
- * Registration order = composition order: the first middleware
1026
- * registered runs outermost (wraps everything else).
1027
- *
1028
- * @example Time every op
1029
- * ```ts
1030
- * repo.useMiddleware(async ({ operation, next }) => {
1031
- * const start = performance.now();
1032
- * try { return await next(); }
1033
- * finally { metrics.record(operation, performance.now() - start); }
1034
- * });
1035
- * ```
1036
- *
1037
- * @example Inject `tenantId` on every create
1038
- * ```ts
1039
- * repo.useMiddleware(async ({ operation, context, next }) => {
1040
- * if (operation === 'create' && context.data) {
1041
- * context.data.tenantId = currentTenant();
1042
- * }
1043
- * return next();
1044
- * });
1045
- * ```
1046
- *
1047
- * @example Short-circuit (return without `next()` to skip the actual op)
1048
- * ```ts
1049
- * repo.useMiddleware(async ({ operation, context, next }) => {
1050
- * if (operation === 'getById' && readOnlyMaintenance) {
1051
- * return cachedReadOnlyResponse(context.id);
1052
- * }
1053
- * return next();
1054
- * });
1055
- * ```
1056
- */
1057
- useMiddleware(middleware: Middleware<TDoc>): this;
1058
- /**
1059
- * Compose the registered middleware chain around `exec`. Outermost
1060
- * middleware (first registered) wraps innermost. Each middleware sees
1061
- * the live `RepositoryContext` (already populated + run through
1062
- * `before:*` hooks at the call site — see `useMiddleware` JSDoc for
1063
- * the exact order). `after:` / `error:` hooks fire from inside
1064
- * `exec`, so middleware composes WITH plugins, not instead of them.
1065
- *
1066
- * `_runOp` calls this with its standard try/catch envelope; inline-try
1067
- * methods (`getById`, `update`, `delete`, `deleteMany`, `aggregatePaginate`,
1068
- * `lookupPopulate`, plus `getOne`/`getAll` cache-hit branches) call it
1069
- * directly so middleware sees every op — including cached reads — not
1070
- * just the ones routed through `_runOp`.
1071
- */
1072
- private _composeMiddleware;
1073
- /**
1074
- * Standard operation envelope: compose middleware around an exec that
1075
- * runs `fn`, emits `after:<op>` on success or `error:<op>` on failure.
1076
- * Methods with branched in-try logic that emit `after:*` from multiple
1077
- * paths (`getById`, `update`, `delete`, `deleteMany`, `aggregatePaginate`,
1078
- * `lookupPopulate`) keep their inline try/catch and call
1079
- * `_composeMiddleware` directly instead of being forced through this
1080
- * single-result helper.
1081
- */
1082
- private _runOp;
1083
- /**
1084
- * Create single document
1085
- */
1086
- create(data: Record<string, unknown>, options?: CreateOptions): Promise<TDoc>;
1087
- /**
1088
- * Create multiple documents
1089
- */
1090
- createMany(dataArray: Record<string, unknown>[], options?: CreateOptions): Promise<TDoc[]>;
1091
- /**
1092
- * Get document by ID
1093
- */
1094
- getById(id: string | ObjectId, options?: CacheableOptions): Promise<TDoc | null>;
1095
- /**
1096
- * Get single document by query
1097
- */
1098
- getByQuery(query: Record<string, unknown>, options?: CacheableOptions & {
1099
- sort?: SortSpec;
1100
- }): Promise<TDoc | null>;
1101
- /**
1102
- * Get single document by arbitrary filter.
1103
- * Unlike getByQuery, this method is designed for Arc/controller use where
1104
- * compound filters (org scope + policy + id) are pre-built.
1105
- *
1106
- * @example
1107
- * const product = await repo.getOne({ slug: 'laptop', organizationId: 'org_1' });
1108
- */
1109
- getOne(query: Record<string, unknown>, options?: CacheableOptions & {
1110
- sort?: SortSpec;
1111
- }): Promise<TDoc | null>;
1112
- /**
1113
- * Fetch ALL documents matching filters without pagination.
1114
- * Use for background jobs, exports, batch processing where you need every doc.
1115
- *
1116
- * @example
1117
- * const all = await repo.findAll({ status: 'active' });
1118
- * const allLean = await repo.findAll({}, { select: 'name email', lean: true });
1119
- */
1120
- findAll(filters?: Record<string, unknown>, options?: OperationOptions & {
1121
- sort?: SortSpec | string;
1122
- limit?: number;
1123
- }): Promise<TDoc[]>;
1124
- /**
1125
- * Streaming reads — async iterator over data, suitable for migrations,
1126
- * backfills, schema audits, and any once-a-quarter "touch every row"
1127
- * job. Goes through the standard `before:cursor` hook pipeline so
1128
- * multi-tenant scope, soft-delete filter, and access-control plugins
1129
- * inject scope BEFORE the underlying mongoose cursor is built.
1130
- *
1131
- * Replaces direct `Model.find().cursor()` usage which bypasses every
1132
- * plugin — that's how cross-tenant data ends up in migrations.
1133
- *
1134
- * Returns an `AsyncIterableIterator<TDoc>` — drive it with `for await`.
1135
- * The mongoose cursor is closed when iteration completes or breaks.
1136
- *
1137
- * @example Backfill a missing field across the whole tenant scope
1138
- * ```ts
1139
- * for await (const doc of repo.cursor({ filter: { migrated: { $ne: true }}}, { batchSize: 1000 })) {
1140
- * await transformer(doc);
1141
- * await repo.update(doc._id, { migrated: true });
1142
- * }
1143
- * ```
1144
- *
1145
- * @example Audit unscoped — opt out of multi-tenant for global sweeps
1146
- * ```ts
1147
- * for await (const doc of repo.cursor({}, { batchSize: 500, organizationId: undefined })) {
1148
- * audit(doc);
1149
- * }
1150
- * ```
1151
- */
1152
- cursor(filter?: Record<string, unknown>, options?: ReadOptions & {
1153
- sort?: SortSpec | string;
1154
- batchSize?: number;
1155
- select?: SelectSpec;
1156
- lean?: boolean;
1157
- }): AsyncIterableIterator<TDoc>;
1158
- /**
1159
- * Unified pagination - auto-detects offset vs keyset based on params
1160
- *
1161
- * Auto-detection logic:
1162
- * - If params has 'cursor' or 'after' → uses keyset pagination (stream)
1163
- * - If params has 'pagination' or 'page' → uses offset pagination (paginate)
1164
- * - Else → defaults to offset pagination with page=1
1165
- *
1166
- * @example
1167
- * // Offset pagination (page-based)
1168
- * await repo.getAll({ page: 1, limit: 50, filters: { status: 'active' } });
1169
- * await repo.getAll({ pagination: { page: 2, limit: 20 } });
1170
- *
1171
- * // Keyset pagination (cursor-based)
1172
- * await repo.getAll({ cursor: 'eyJ2Ij...', limit: 50 });
1173
- * await repo.getAll({ after: 'eyJ2Ij...', sort: { createdAt: -1 } });
1174
- *
1175
- * // Simple query (defaults to page 1)
1176
- * await repo.getAll({ filters: { status: 'active' } });
1177
- *
1178
- * // Skip cache for fresh data
1179
- * await repo.getAll({ filters: { status: 'active' } }, { skipCache: true });
1180
- */
1181
- getAll(params?: {
1182
- filters?: Record<string, unknown>;
1183
- sort?: SortSpec | string;
1184
- cursor?: string;
1185
- after?: string;
1186
- page?: number;
1187
- pagination?: {
1188
- page?: number;
1189
- limit?: number;
1190
- };
1191
- limit?: number;
1192
- search?: string;
1193
- mode?: 'offset' | 'keyset';
1194
- hint?: string | Record<string, 1 | -1>;
1195
- maxTimeMS?: number;
1196
- countStrategy?: 'exact' | 'estimated' | 'none';
1197
- readPreference?: ReadPreferenceType; /** Advanced populate options (from QueryParser or Arc's BaseController) */
1198
- populateOptions?: PopulateOptions[]; /** Collation for locale-aware string comparison */
1199
- collation?: CollationOptions; /** Lookup configurations for $lookup joins (from QueryParser or manual) */
1200
- lookups?: LookupOptions[]; /** Skip pagination entirely — returns raw TDoc[] (same as findAll) */
1201
- noPagination?: boolean;
1202
- }, options?: CacheableOptions): Promise<OffsetPaginationResult<TDoc> | KeysetPaginationResult<TDoc> | TDoc[]>;
1203
- /**
1204
- * Atomic "look up by filter, insert `data` if missing, return the doc."
1205
- *
1206
- * Returns `{ doc, created }` so callers can tell whether *this* call
1207
- * inserted (race-win) or matched an existing row. Implements
1208
- * `StandardRepo.getOrCreate()` from `@classytic/repo-core/repository`
1209
- * — see that interface's JSDoc for the cross-kit contract.
1210
- *
1211
- * Routes through the hook system for policy enforcement (multi-tenant,
1212
- * soft-delete, audit). The hook context carries the resolved query +
1213
- * data; the action layer adds the `created` discriminator from the
1214
- * driver's `lastErrorObject.upserted`.
1215
- */
1216
- getOrCreate(query: Record<string, unknown>, createData: Record<string, unknown>, options?: SessionOptions): Promise<{
1217
- doc: TDoc;
1218
- created: boolean;
1219
- }>;
1220
- /**
1221
- * Count documents
1222
- * Routes through hook system for policy enforcement (multi-tenant, soft-delete)
1223
- */
1224
- count(query?: Record<string, unknown>, options?: ReadOptions): Promise<number>;
1225
- /**
1226
- * Check if document exists
1227
- * Routes through hook system for policy enforcement (multi-tenant, soft-delete)
1228
- */
1229
- exists(query: Record<string, unknown>, options?: ReadOptions): Promise<{
1230
- _id: unknown;
1231
- } | null>;
1232
- /**
1233
- * Update a document by id and return the post-update doc.
839
+ * Update a document by id and return the post-update doc.
1234
840
  *
1235
841
  * Compiles to `Model.findOneAndUpdate(filter, { $set: data }, { new:
1236
842
  * true, runValidators: true })` under the hood and routes through the
@@ -1603,415 +1209,925 @@ declare class Repository<TDoc = unknown> extends RepositoryBase {
1603
1209
  * );
1604
1210
  * ```
1605
1211
  */
1606
- claimVersion(id: string | ObjectId, transition: {
1607
- field?: string;
1608
- from: number | undefined;
1609
- by?: number;
1610
- where?: Record<string, unknown>;
1611
- }, update: Record<string, unknown>, options?: SessionOptions & {
1612
- idField?: string;
1613
- }): Promise<TDoc | null>;
1212
+ claimVersion(id: string | ObjectId, transition: {
1213
+ field?: string;
1214
+ from: number | undefined;
1215
+ by?: number;
1216
+ where?: Record<string, unknown>;
1217
+ }, update: Record<string, unknown>, options?: SessionOptions & {
1218
+ idField?: string;
1219
+ }): Promise<TDoc | null>;
1220
+ /**
1221
+ * Delete a document by ID.
1222
+ *
1223
+ * By default the behavior is decided by the plugin stack:
1224
+ * - With `softDeletePlugin` wired → soft delete (sets `deletedAt`)
1225
+ * - Without the plugin → physical delete
1226
+ *
1227
+ * Pass `mode: 'hard'` to force physical deletion regardless of plugins.
1228
+ * All policy hooks (multi-tenant scope, audit trails, cache invalidation,
1229
+ * cascade) still fire — only the soft-delete interception is skipped. This
1230
+ * is the GDPR / admin-cleanup path.
1231
+ *
1232
+ * @example
1233
+ * ```ts
1234
+ * // Respects softDeletePlugin — soft if wired
1235
+ * await userRepo.delete(userId);
1236
+ *
1237
+ * // GDPR erasure — physical delete, audit hooks still fire
1238
+ * await userRepo.delete(userId, { mode: 'hard', organizationId: 'org_123' });
1239
+ * ```
1240
+ */
1241
+ delete(id: string | ObjectId, options?: SessionOptions & {
1242
+ idField?: string;
1243
+ mode?: 'hard' | 'soft'; /** Legacy opt-in: throw a 404 error on miss instead of returning `null`. */
1244
+ throwOnNotFound?: boolean;
1245
+ }): Promise<DeleteResult | null>;
1246
+ /**
1247
+ * Update every document matching the filter.
1248
+ *
1249
+ * Promoted from `batchOperationsPlugin` to a class primitive in 3.11.0 so
1250
+ * the surface matches sqlitekit's always-available `updateMany`. Consumers
1251
+ * who forget to wire `batchOperationsPlugin` no longer get a runtime
1252
+ * `TypeError: repo.updateMany is not a function` — the method is always
1253
+ * here.
1254
+ *
1255
+ * Accepts three `update` forms via `UpdateInput`:
1256
+ * - Portable `UpdateSpec` from `@classytic/repo-core/update` (compiled to
1257
+ * `$set`/`$unset`/`$inc`/`$setOnInsert` before hooks run).
1258
+ * - Raw Mongo operator record (`{ $set, $inc, ... }`) — passed through.
1259
+ * - Mongo aggregation pipeline (`[...]`) — requires `updatePipeline: true`
1260
+ * to guard against accidental pipeline updates.
1261
+ *
1262
+ * Refuses empty filters (defense-in-depth against mass-update accidents)
1263
+ * both before and after policy hooks inject tenant scope.
1264
+ */
1265
+ updateMany(filter: Record<string, unknown>, update: UpdateInput, options?: SessionOptions & {
1266
+ updatePipeline?: boolean;
1267
+ [key: string]: unknown;
1268
+ }): Promise<UpdateManyResult>;
1269
+ /**
1270
+ * Delete every document matching the filter.
1271
+ *
1272
+ * Promoted from `batchOperationsPlugin` to a class primitive in 3.11.0 for
1273
+ * the same reason as `updateMany` — sqlitekit parity and no silent "method
1274
+ * is undefined" footgun when the batch plugin isn't wired.
1275
+ *
1276
+ * Behavior mirrors `delete()`: defaults to soft delete when
1277
+ * `softDeletePlugin` is wired, physical delete otherwise. Pass
1278
+ * `{ mode: 'hard' }` to bypass soft-delete — multi-tenant scoping, audit,
1279
+ * and cache hooks still fire.
1280
+ *
1281
+ * Rejects empty filters up front AND after policy hooks, so a policy-plugin
1282
+ * bug that zeroes out the query can't be exploited to wipe a collection.
1283
+ */
1284
+ deleteMany(filter: Record<string, unknown>, options?: SessionOptions & {
1285
+ mode?: 'hard' | 'soft';
1286
+ [key: string]: unknown;
1287
+ }): Promise<DeleteManyResult>;
1288
+ /**
1289
+ * Compliance-grade cleanup primitive — see `StandardRepo.purgeByField`
1290
+ * for the cross-kit contract. Mongokit composes the kit-agnostic
1291
+ * `runChunkedPurge` orchestrator (loop, signal, progress, result
1292
+ * envelope) with `createMongoPurgePort` (driver-specific id selection
1293
+ * + strategy writes). See [actions/purge.ts](./actions/purge.ts) for
1294
+ * the port implementation.
1295
+ */
1296
+ purgeByField(field: string, value: unknown, strategy: TenantPurgeStrategy, options?: TenantPurgeOptions): Promise<TenantPurgeResult>;
1297
+ /**
1298
+ * Kit-native aggregation pipeline — takes a MongoDB stage array and
1299
+ * returns the raw pipeline output. Use this for `$lookup`, `$unwind`,
1300
+ * `$facet`, `$graphLookup`, and any other mongo-specific power
1301
+ * features that don't translate to SQL.
1302
+ *
1303
+ * For backend-portable aggregations (filter + group + measures +
1304
+ * sort + limit) use `aggregate(req: AggRequest)` instead — it
1305
+ * compiles the repo-core IR to the equivalent pipeline internally
1306
+ * and produces the same row shape sqlitekit emits.
1307
+ *
1308
+ * Routes through the hook system for policy enforcement
1309
+ * (multi-tenant, soft-delete).
1310
+ *
1311
+ * @param pipeline - MongoDB aggregation stage array
1312
+ * @param options - Aggregation options including governance controls
1313
+ */
1314
+ aggregatePipeline<TResult = unknown>(pipeline: PipelineStage[], options?: AggregateOptions): Promise<TResult[]>;
1315
+ /**
1316
+ * Paginated kit-native aggregation pipeline. Same mongo-specific
1317
+ * scope as `aggregatePipeline` — reach for this when you need
1318
+ * `$lookup` / `$unwind` / `$facet` alongside pagination.
1319
+ *
1320
+ * Prefer `aggregatePaginate(req: AggPaginationRequest)` for
1321
+ * cross-backend dashboard code; that method compiles the portable
1322
+ * IR to an equivalent pipeline.
1323
+ *
1324
+ * Policy hooks (multi-tenant, soft-delete) inject context.filters
1325
+ * which are prepended as a `$match` stage.
1326
+ */
1327
+ aggregatePipelinePaginate(options?: AggregatePaginationOptions): Promise<AggregatePaginationResult<TDoc>>;
1328
+ /**
1329
+ * Portable aggregation. Compiles the repo-core `AggRequest` IR to
1330
+ * `[{$match}, {$group}, {$addFields}, {$project}, {$match}, {$sort},
1331
+ * {$skip}, {$limit}]` against this repo's collection. Output rows
1332
+ * carry one key per `groupBy` column plus one key per measure alias
1333
+ * — identical shape to sqlitekit's `aggregate(req)`, so dashboards
1334
+ * work unchanged across backends.
1335
+ *
1336
+ * Without `groupBy`: returns a single-row result with just the
1337
+ * measures (scalar aggregation). Pass
1338
+ * `{ measures: { total: { op: 'sum', field: 'amount' } } }` for a
1339
+ * simple summary.
1340
+ *
1341
+ * For mongo-specific power features (`$lookup`, `$unwind`, `$facet`,
1342
+ * `$graphLookup`, window operators) reach for `aggregatePipeline`
1343
+ * — that's the kit-native escape hatch.
1344
+ *
1345
+ * Routes through the hook system for policy enforcement
1346
+ * (multi-tenant, soft-delete) — plugins inject their scope into
1347
+ * `context.filter`, which is merged into the pre-aggregate `$match`
1348
+ * before compilation.
1349
+ */
1350
+ aggregate<TRow extends Record<string, unknown> = Record<string, unknown>>(req: AggRequest, options?: QueryOptions): Promise<AggResult<TRow>>;
1351
+ /**
1352
+ * Invalidate every aggregate-cache entry tagged with ANY of the
1353
+ * given tags. Delegates to the unified cache plugin's handle
1354
+ * (`repo.cache.invalidateByTags`) — wired by `cachePlugin({ adapter })`.
1355
+ *
1356
+ * Pass no tags to wipe the entire cache namespace (requires the
1357
+ * adapter to implement `clear`).
1358
+ *
1359
+ * Returns the count of distinct entries cleared (`-1` when the call
1360
+ * routed through `adapter.clear()` and the adapter doesn't report a
1361
+ * count). No-op (returns `0`) when no cache plugin is wired.
1362
+ */
1363
+ invalidateAggregateCache(tags?: readonly string[]): Promise<number>;
1364
+ /**
1365
+ * Paginated portable aggregation. Two pagination modes, picked by
1366
+ * the request shape:
1367
+ *
1368
+ * - **Offset (default)** — `page` + `limit`. Returns the standard
1369
+ * `{ method: 'offset', data, total, pages, hasNext, hasPrev, ... }`
1370
+ * envelope. `countStrategy: 'none'` skips the second round-trip
1371
+ * that computes `total`; the envelope reports `total: 0`,
1372
+ * `pages: 0`, and derives `hasNext` from a `LIMIT N+1` peek on
1373
+ * the data pipeline.
1374
+ * - **Keyset** — `pagination: 'keyset'` (or `after` set). Returns
1375
+ * `{ method: 'keyset', data, hasMore, next, limit }`. `sort` is
1376
+ * required — the cursor encodes the sort-key tuple of the last
1377
+ * row. Each subsequent page passes the previous `next` back as
1378
+ * `after`. Scales to arbitrary group counts because the planner
1379
+ * uses `(sort_keys) > (cursor)` instead of `OFFSET N`.
1380
+ */
1381
+ aggregatePaginate<TRow extends Record<string, unknown> = Record<string, unknown>>(req: AggPaginationRequest, options?: QueryOptions): Promise<OffsetPaginationResultCore<TRow> | KeysetAggPaginationResult<TRow>>;
1382
+ /**
1383
+ * Internal: the actual aggregate-paginate execution body. Extracted
1384
+ * so `aggregatePaginate` can wrap it in the cache layer without
1385
+ * duplicating the keyset / offset branch logic.
1386
+ *
1387
+ * Receives `finalReq` (already policy-scoped) so the cache key
1388
+ * built by the outer wrapper matches the request the executor
1389
+ * sees — no cross-tenant cache poisoning.
1390
+ */
1391
+ private _executeAggregatePaginate;
1392
+ /**
1393
+ * Merge policy-hook-injected filters (multi-tenant scope,
1394
+ * soft-delete) into the pre-aggregate `filter` slot of the request.
1395
+ * Plugins speak mongo-query shape (`context.query` / `context.filters`);
1396
+ * the caller's `req.filter` may be either Filter IR or a mongo query.
1397
+ *
1398
+ * Each part is compiled through `compileFilterToMongo` BEFORE the
1399
+ * `$and` merge — otherwise an IR node like `eq('status', 'paid')`
1400
+ * (`{ op, field, value }`) gets nested inside `$and` as a literal,
1401
+ * `compileFilterToMongo` of the merged value finds no top-level `op`
1402
+ * (so `isFilter` is false), and Mongo receives the IR object as a
1403
+ * raw query clause that matches no real document. Symptom: aggregates
1404
+ * silently return 0 rows whenever a callerpasses IR + a policy
1405
+ * plugin is installed.
1406
+ */
1407
+ private _injectPolicyScopeIntoAgg;
1408
+ /**
1409
+ * Get distinct values
1410
+ * Routes through hook system for policy enforcement (multi-tenant, soft-delete)
1411
+ */
1412
+ distinct<T = unknown>(field: string, query?: Record<string, unknown>, options?: ReadOptions): Promise<T[]>;
1413
+ /**
1414
+ * Query with custom field lookups ($lookup)
1415
+ * Best for: Joins on slugs, SKUs, codes, or other indexed custom fields
1416
+ *
1417
+ * @example
1418
+ * ```typescript
1419
+ * // Join employees with departments using slug instead of ObjectId
1420
+ * const employees = await employeeRepo.lookupPopulate({
1421
+ * filters: { status: 'active' },
1422
+ * lookups: [
1423
+ * {
1424
+ * from: 'departments',
1425
+ * localField: 'departmentSlug',
1426
+ * foreignField: 'slug',
1427
+ * as: 'department',
1428
+ * single: true
1429
+ * }
1430
+ * ],
1431
+ * sort: '-createdAt',
1432
+ * page: 1,
1433
+ * limit: 50
1434
+ * });
1435
+ * ```
1436
+ */
1437
+ lookupPopulate<TExtra extends Record<string, unknown> = Record<string, unknown>>(options: LookupPopulateOptions<TDoc>): Promise<LookupPopulateResult<TDoc, TExtra>>;
1438
+ /**
1439
+ * Create an aggregation builder for this model
1440
+ * Useful for building complex custom aggregations
1441
+ *
1442
+ * @example
1443
+ * ```typescript
1444
+ * const pipeline = repo.buildAggregation()
1445
+ * .match({ status: 'active' })
1446
+ * .lookup('departments', 'deptSlug', 'slug', 'department', true)
1447
+ * .group({ _id: '$department', count: { $sum: 1 } })
1448
+ * .sort({ count: -1 })
1449
+ * .build();
1450
+ *
1451
+ * const results = await repo.Model.aggregate(pipeline);
1452
+ * ```
1453
+ */
1454
+ buildAggregation(): AggregationBuilder;
1455
+ /**
1456
+ * Create a lookup builder
1457
+ * Useful for building $lookup stages independently
1458
+ *
1459
+ * @example
1460
+ * ```typescript
1461
+ * const lookupStages = repo.buildLookup('departments')
1462
+ * .localField('deptSlug')
1463
+ * .foreignField('slug')
1464
+ * .as('department')
1465
+ * .single()
1466
+ * .build();
1467
+ *
1468
+ * const pipeline = [
1469
+ * { $match: { status: 'active' } },
1470
+ * ...lookupStages
1471
+ * ];
1472
+ * ```
1473
+ */
1474
+ buildLookup(from?: string): LookupBuilder;
1475
+ /**
1476
+ * Execute callback within a MongoDB transaction with automatic retry on
1477
+ * transient failures.
1478
+ *
1479
+ * **BREAKING (3.10):** the callback now receives a **tx-bound repository**,
1480
+ * not a raw `ClientSession`. This matches repo-core's `StandardRepo.withTransaction`
1481
+ * contract that every kit (sqlitekit, pgkit, prismakit) already implements
1482
+ * — so cross-kit plugins and apps are portable. Session threading happens
1483
+ * transparently: every CRUD call on `txRepo` auto-injects the session into
1484
+ * its options bag, including plugin-added methods (`upsert`, `increment`,
1485
+ * `updateMany`, `restore`, ...). Nested `txRepo.withTransaction(...)` throws.
1486
+ *
1487
+ * For raw-session cross-repo workflows — multiple repositories coordinating
1488
+ * writes — use the standalone `withTransaction(connection, fn)` export
1489
+ * from `@classytic/mongokit`. That helper remains session-based by design.
1490
+ *
1491
+ * @param callback - Receives a tx-bound `Repository<TDoc>` — call its
1492
+ * methods directly, no need to pass `{ session }` manually
1493
+ * @param options.allowFallback - Run without transaction on standalone MongoDB (default: false)
1494
+ * @param options.onFallback - Called when falling back to non-transactional execution
1495
+ * @param options.transactionOptions - MongoDB driver transaction options (readConcern, writeConcern, etc.)
1496
+ *
1497
+ * @example
1498
+ * ```typescript
1499
+ * const order = await repo.withTransaction(async (txRepo) => {
1500
+ * const created = await txRepo.create({ total: 100 });
1501
+ * await txRepo.update(created._id, { confirmed: true });
1502
+ * return created;
1503
+ * });
1504
+ *
1505
+ * // With fallback for standalone/dev environments
1506
+ * await repo.withTransaction(
1507
+ * async (txRepo) => {
1508
+ * await txRepo.create(doc);
1509
+ * },
1510
+ * {
1511
+ * allowFallback: true,
1512
+ * onFallback: (err) => logger.warn('Running without transaction', err),
1513
+ * },
1514
+ * );
1515
+ *
1516
+ * // Cross-repo with explicit session:
1517
+ * import { withTransaction } from '@classytic/mongokit';
1518
+ * await withTransaction(mongoose.connection, async (session) => {
1519
+ * const order = await orderRepo.create(data, { session });
1520
+ * await inventoryRepo.decrement(..., { session });
1521
+ * });
1522
+ * ```
1523
+ */
1524
+ withTransaction<T>(callback: (txRepo: this) => Promise<T>, options?: WithTransactionOptions): Promise<T>;
1525
+ /**
1526
+ * Execute custom query with event emission
1527
+ */
1528
+ _executeQuery<T>(buildQuery: (Model: Model<TDoc>) => Promise<T>): Promise<T>;
1529
+ /**
1530
+ * Build operation context and run before-hooks (sorted by priority).
1531
+ *
1532
+ * Narrows `RepositoryBase._buildContext`'s return type to mongokit's
1533
+ * richer `RepositoryContext` (which carries `softDeleted`, `_cacheHit`,
1534
+ * populate / readPreference / session, etc.). Runtime is inherited from
1535
+ * the base class — this override is purely a type-narrowing shim.
1536
+ *
1537
+ * Hook execution order is deterministic:
1538
+ * 1. POLICY (100) — tenant isolation, soft-delete filtering, validation
1539
+ * 2. CACHE (200) — cache lookup (after policy filters are injected)
1540
+ * 3. OBSERVABILITY (300) — audit logging, metrics
1541
+ * 4. DEFAULT (500) — user-registered hooks
1542
+ */
1543
+ _buildContext(operation: string, options: Record<string, unknown> | object): Promise<RepositoryContext>;
1544
+ /**
1545
+ * Walk the well-known filter-carrying keys (`query`, `filters`,
1546
+ * `having`) and route any Filter IR through `compileFilterToMongo`.
1547
+ * Plain Mongo queries pass through untouched — `isFilter` is the
1548
+ * discriminator. Returns a shallow clone; never mutates input.
1549
+ */
1550
+ private _normalizeFilterSlots;
1551
+ /**
1552
+ * Resolve the effective sort for getAll. Handles $near conflict detection
1553
+ * (warns + drops explicit sort when $near is in filters).
1554
+ */
1555
+ private _resolveSort;
1556
+ /**
1557
+ * Detect whether to use keyset (cursor) or offset (page) pagination.
1558
+ */
1559
+ private _detectPaginationMode;
1560
+ /**
1561
+ * Build the MongoDB query filter with search merged in. Handles text
1562
+ * search ($text), regex search ($or of $regex), and the search-resolver
1563
+ * plugin contract (search already cleared by a before:getAll hook).
1564
+ */
1565
+ private _buildSearchQuery;
1566
+ /**
1567
+ * Parse sort string or object
1568
+ */
1569
+ _parseSort(sort: SortSpec | string | undefined): SortSpec;
1570
+ /**
1571
+ * Parse populate specification
1572
+ */
1573
+ _parsePopulate(populate: PopulateSpec | undefined): string[] | PopulateOptions[];
1574
+ /**
1575
+ * Classify a driver error as an authoritative duplicate-key conflict.
1576
+ *
1577
+ * Used by arc's outbox / idempotency adapters to distinguish
1578
+ * "write already landed (idempotent no-op)" from "transient DB error
1579
+ * (must retry)". Every backend signals duplicate keys differently —
1580
+ * MongoDB `code: 11000`, Prisma `P2002`, Postgres `23505` — so this
1581
+ * predicate lives on the kit that knows its driver. Cross-driver
1582
+ * adapters then depend only on the boolean outcome, not the shape of
1583
+ * the underlying error.
1584
+ *
1585
+ * Deliberately narrow: matches ONLY `code === 11000` and
1586
+ * `codeName === 'DuplicateKey'`. Does NOT match
1587
+ * `err.name === 'MongoServerError'`, which is also true for
1588
+ * WriteConflict, NotWritablePrimary, ExceededTimeLimit, and every
1589
+ * other server-side error — treating those as duplicate keys would
1590
+ * silently swallow transactional retries.
1591
+ *
1592
+ * @example
1593
+ * try {
1594
+ * await outboxRepo.create(event);
1595
+ * } catch (err) {
1596
+ * if (outboxRepo.isDuplicateKeyError(err)) {
1597
+ * // Idempotent no-op — event was already written by a prior attempt.
1598
+ * return;
1599
+ * }
1600
+ * throw err; // transient DB error — upstream retries
1601
+ * }
1602
+ */
1603
+ isDuplicateKeyError(err: unknown): boolean;
1604
+ /**
1605
+ * Handle errors with proper HTTP status codes.
1606
+ *
1607
+ * **Transactional retry signal preservation.** MongoDB driver errors
1608
+ * carry `errorLabels` (e.g. `'TransientTransactionError'`,
1609
+ * `'UnknownTransactionCommitResult'`) that `mongoose.Connection.
1610
+ * withTransaction()` reads via `error.hasErrorLabel(label)` to decide
1611
+ * whether to auto-retry the transaction. Wrapping such an error in a
1612
+ * fresh `Error` via `createError(...)` strips both the labels and the
1613
+ * `hasErrorLabel` method — the retry signal vanishes and concurrent
1614
+ * writers see WriteConflicts surface to userland instead of being
1615
+ * retried by the driver. To keep the standard MongoDB transaction-
1616
+ * retry mechanism intact, we re-throw any error that carries
1617
+ * `errorLabels` exactly as we received it. The caller still sees a
1618
+ * thrown error — only the wrapping layer is skipped.
1619
+ */
1620
+ _handleError(error: Error): HttpError;
1621
+ }
1622
+ //#endregion
1623
+ //#region src/create-repository.d.ts
1624
+ /**
1625
+ * Audit config — either a `Logger` (routes to `auditLogPlugin`, which
1626
+ * writes structured log lines through your logger) or an
1627
+ * `AuditTrailOptions` object (routes to `auditTrailPlugin`, which
1628
+ * persists a queryable compliance ledger in its own collection).
1629
+ *
1630
+ * Discrimination: a value carrying any of the `Logger` function members
1631
+ * (`info` / `warn` / `error` / `debug`) is treated as a logger; anything
1632
+ * else is treated as `AuditTrailOptions` (all of whose members are
1633
+ * data-shaped except `metadata` / `onError`, which don't overlap with
1634
+ * the logger member names).
1635
+ */
1636
+ type AuditConfig = Logger | AuditTrailOptions;
1637
+ /**
1638
+ * Declarative repository config. Every key is optional; a feature's
1639
+ * plugin is installed only when its key is present. The remaining
1640
+ * `RepositoryOptions` keys (`idField`, `hooks`, `schema`, `updateSchema`,
1641
+ * `events`, `requirePlugins`, ...) pass through to the `Repository`
1642
+ * constructor unchanged.
1643
+ */
1644
+ interface CreateRepositoryConfig extends RepositoryOptions {
1645
+ /** Multi-tenant scoping — forwarded to `multiTenantPlugin`. */
1646
+ tenant?: MultiTenantOptions;
1647
+ /** Soft-delete — `true` for defaults, or `SoftDeleteOptions`. */
1648
+ softDelete?: boolean | SoftDeleteOptions;
1649
+ /** createdAt/updatedAt stamping — `timestampPlugin` takes no options. */
1650
+ timestamps?: boolean;
1651
+ /** Batch ops (`updateMany`/`deleteMany`/`bulkWrite`) via `batchOperationsPlugin`. */
1652
+ batch?: boolean;
1653
+ /** Read-through cache — forwarded to `cachePlugin`. */
1654
+ cache?: RepositoryCachePluginOptions;
1655
+ /** Audit — `Logger` → `auditLogPlugin`; `AuditTrailOptions` → `auditTrailPlugin`. */
1656
+ audit?: AuditConfig;
1657
+ /** Custom ID generation — forwarded to `customIdPlugin`. */
1658
+ customId?: CustomIdOptions;
1659
+ /** Extra plugins appended AFTER the canonical stack. */
1660
+ plugins?: PluginType[];
1661
+ /** Pagination engine config (defaultLimit, maxLimit, ...). */
1662
+ pagination?: PaginationConfig;
1663
+ }
1664
+ /**
1665
+ * Build a `Repository` from a declarative config. See module JSDoc for
1666
+ * ordering guarantees and an example.
1667
+ */
1668
+ declare function createRepository<TDoc>(model: Model<TDoc, any, any, any>, config?: CreateRepositoryConfig): Repository<TDoc>;
1669
+ //#endregion
1670
+ //#region src/filter/compile.d.ts
1671
+ /**
1672
+ * Filter IR → MongoDB query compiler.
1673
+ *
1674
+ * Mirrors sqlitekit's `compileFilterToDrizzle`: takes a repo-core
1675
+ * `Filter` tree and emits a MongoDB query object. Used by `groupBy` /
1676
+ * `groupByPaginate` to translate the portable AggRequest's `filter`
1677
+ * + `having` into `$match` stages.
1678
+ *
1679
+ * Scope: the same operator set sqlitekit supports (`eq`, `ne`, `gt`,
1680
+ * `gte`, `lt`, `lte`, `in`, `nin`, `exists`, `like`, `regex`, `and`,
1681
+ * `or`, `not`, `raw`). Anything else throws so we fail loud instead
1682
+ * of silently dropping predicates.
1683
+ *
1684
+ * Why only this op set here and not everywhere in mongokit? Historic
1685
+ * reason: mongokit pre-dates repo-core's Filter IR, so its existing
1686
+ * policy plugins (multi-tenant, soft-delete) speak MongoDB query
1687
+ * language directly. Converting mongokit's entire filter pipeline
1688
+ * to Filter IR is out-of-scope for this change — we introduce the
1689
+ * compiler only where the new portable `groupBy` IR needs it.
1690
+ */
1691
+ declare function compileFilterToMongo(input: unknown): Record<string, unknown>;
1692
+ //#endregion
1693
+ //#region src/operations.d.ts
1694
+ /**
1695
+ * Where a plugin should inject its scoping filter (multi-tenant scope,
1696
+ * soft-delete filter, etc.) on the RepositoryContext.
1697
+ *
1698
+ * - `data` — single-doc create payload (`context.data`)
1699
+ * - `dataArray` — multi-doc create payload (`context.dataArray`)
1700
+ * - `query` — raw filter (`context.query`); the dominant convention
1701
+ * - `filters` — paginated list options' filter sub-bag (`context.filters`)
1702
+ * - `operations` — bulkWrite per-sub-op (special — plugins walk each entry)
1703
+ * - `none` — no scoping target (op accepts no filter input)
1704
+ */
1705
+ type PolicyKey = 'data' | 'dataArray' | 'query' | 'filters' | 'operations' | 'none';
1706
+ interface OperationDescriptor {
1707
+ /** Where multi-tenant / soft-delete plugins inject their scoping filter. */
1708
+ policyKey: PolicyKey;
1709
+ /** Whether this op writes to the database. Drives audit + cache invalidation. */
1710
+ mutates: boolean;
1711
+ /** True when `context.id` is populated by the time before/after hooks fire. */
1712
+ hasIdContext: boolean;
1713
+ }
1714
+ /**
1715
+ * The registry. Order intentionally groups by category for readability.
1716
+ * Adding a new RepositoryOperation requires exactly one entry here, after
1717
+ * which every bundled plugin auto-classifies it correctly.
1718
+ */
1719
+ declare const OP_REGISTRY: Readonly<Record<RepositoryOperation, OperationDescriptor>>;
1720
+ /** All known repository operations, in registry order. */
1721
+ declare const ALL_OPERATIONS: RepositoryOperation[];
1722
+ /** Operations that mutate the database. Drives audit + cache invalidation lists. */
1723
+ declare const MUTATING_OPERATIONS: readonly RepositoryOperation[];
1724
+ /** Operations that don't mutate. Drives default cacheable ops. */
1725
+ declare const READ_OPERATIONS: readonly RepositoryOperation[];
1726
+ /** Filter ops by their policy injection key. */
1727
+ declare function operationsByPolicyKey(key: PolicyKey): RepositoryOperation[];
1728
+ //#endregion
1729
+ //#region src/query/QueryParser.d.ts
1730
+ type SortSpec$1 = Record<string, 1 | -1>;
1731
+ type FilterQuery = Record<string, unknown>;
1732
+ /**
1733
+ * Mongoose-compatible populate option
1734
+ * Supports advanced populate with select, match, limit, sort, and nested populate
1735
+ *
1736
+ * @example
1737
+ * ```typescript
1738
+ * // URL: ?populate[author][select]=name,email&populate[author][match][active]=true
1739
+ * // Generates: { path: 'author', select: 'name email', match: { active: true } }
1740
+ * ```
1741
+ */
1742
+ interface PopulateOption {
1743
+ /** Field path to populate */
1744
+ path: string;
1745
+ /** Fields to select (space-separated) */
1746
+ select?: string;
1747
+ /** Filter conditions for populated documents */
1748
+ match?: Record<string, unknown>;
1749
+ /** Query options (limit, sort, skip) */
1750
+ options?: {
1751
+ limit?: number;
1752
+ sort?: SortSpec$1;
1753
+ skip?: number;
1754
+ };
1755
+ /** Nested populate configuration */
1756
+ populate?: PopulateOption;
1757
+ }
1758
+ /** Parsed query result with optional lookup configuration */
1759
+ interface ParsedQuery {
1760
+ /** MongoDB filter query */
1761
+ filters: FilterQuery;
1762
+ /** Sort specification */
1763
+ sort?: SortSpec$1;
1764
+ /** Fields to populate (simple comma-separated string) */
1765
+ populate?: string;
1766
+ /**
1767
+ * Advanced populate options (Mongoose-compatible)
1768
+ * When this is set, `populate` will be undefined
1769
+ * @example [{ path: 'author', select: 'name email' }]
1770
+ */
1771
+ populateOptions?: PopulateOption[];
1772
+ /** Page number for offset pagination */
1773
+ page?: number;
1774
+ /** Cursor for keyset pagination */
1775
+ after?: string;
1776
+ /** Limit */
1777
+ limit: number;
1778
+ /** Full-text search query */
1779
+ search?: string;
1780
+ /** Lookup configurations for custom field joins */
1781
+ lookups?: LookupOptions[];
1782
+ /** Aggregation pipeline stages (advanced) */
1783
+ aggregation?: PipelineStage[];
1784
+ /** Select/project fields */
1785
+ select?: Record<string, 0 | 1>;
1786
+ }
1787
+ /** Search mode for query parser */
1788
+ type SearchMode = 'text' | 'regex';
1789
+ /**
1790
+ * Normalized field type used for schema-aware value coercion. Re-exported
1791
+ * from `primitives/coercion` so the public QueryParser API stays unchanged
1792
+ * while the actual logic lives in a single, unit-tested primitive module.
1793
+ */
1794
+ type FieldType = FieldType$1;
1795
+ /**
1796
+ * Minimal structural type for a Mongoose schema. We only read `.paths` and
1797
+ * (optionally) `.indexes()`, so the parser stays decoupled from a specific
1798
+ * Mongoose major version and is easy to mock in tests. Re-exported from the
1799
+ * primitives module — the canonical definition lives there.
1800
+ */
1801
+ interface SchemaLike extends SchemaPathsLike {
1802
+ /** Optional — when present, schema indexes are introspected for geo/text fields */
1803
+ indexes?: () => Array<[Record<string, unknown>, Record<string, unknown>?]>;
1804
+ }
1805
+ interface QueryParserOptions {
1806
+ /** Maximum allowed regex pattern length (default: 500) */
1807
+ maxRegexLength?: number;
1808
+ /** Maximum allowed text search query length (default: 200) */
1809
+ maxSearchLength?: number;
1810
+ /** Maximum allowed filter depth (default: 10) */
1811
+ maxFilterDepth?: number;
1812
+ /** Maximum allowed limit value (default: 1000) */
1813
+ maxLimit?: number;
1814
+ /** Additional operators to block */
1815
+ additionalDangerousOperators?: string[];
1816
+ /** Enable lookup parsing (default: true) */
1817
+ enableLookups?: boolean;
1818
+ /** Enable aggregation parsing (default: false - requires explicit opt-in) */
1819
+ enableAggregations?: boolean;
1820
+ /**
1821
+ * Search mode (default: 'text')
1822
+ * - 'text': Uses MongoDB $text search (requires text index)
1823
+ * - 'regex': Uses $or with $regex across searchFields (no index required)
1824
+ */
1825
+ searchMode?: SearchMode;
1826
+ /**
1827
+ * Fields to search when searchMode is 'regex'
1828
+ * Required when searchMode is 'regex'
1829
+ * @example ['name', 'description', 'sku', 'tags']
1830
+ */
1831
+ searchFields?: string[];
1832
+ /**
1833
+ * Whitelist of collection names allowed in lookups.
1834
+ * When set, only these collections can be used in $lookup stages.
1835
+ * When undefined, all collection names are allowed.
1836
+ * @example ['departments', 'categories', 'users']
1837
+ */
1838
+ allowedLookupCollections?: string[];
1839
+ /** Allowed fields for filtering. If set, ignores unknown fields. */
1840
+ allowedFilterFields?: string[];
1841
+ /** Allowed fields for sorting. If set, ignores unknown fields. */
1842
+ allowedSortFields?: string[];
1843
+ /**
1844
+ * Whitelist of allowed filter operators.
1845
+ * When set, only these operators can be used in filters.
1846
+ * When undefined, all built-in operators are allowed.
1847
+ * Values are human-readable keys: 'eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'nin',
1848
+ * 'like', 'contains', 'regex', 'exists', 'size', 'type'
1849
+ * @example ['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in']
1850
+ */
1851
+ allowedOperators?: string[];
1852
+ /**
1853
+ * Mongoose schema (or schema-like object exposing `.paths`) used for
1854
+ * authoritative, schema-aware value coercion. When provided, filter values
1855
+ * are coerced to the declared field type instead of guessed from the
1856
+ * string shape — `?stock=50` becomes a number against a `Number` field but
1857
+ * `?name=12345` stays a string against a `String` field. Nested paths like
1858
+ * `address.zip` are looked up via dot-notation, matching Mongoose's
1859
+ * `schema.paths` reflection. Unknown fields fall through to the heuristic.
1860
+ */
1861
+ schema?: SchemaLike;
1862
+ /**
1863
+ * Plain field-type map used for authoritative coercion when no Mongoose
1864
+ * schema is available (raw MongoDB, Prisma, computed fields, upstream
1865
+ * models you don't own). Takes precedence over `schema` for paths declared
1866
+ * in both — useful for runtime overrides. Use dot-notation for nested
1867
+ * paths (`'address.zip': 'string'`).
1868
+ * @example { stock: 'number', active: 'boolean', releasedAt: 'date' }
1869
+ */
1870
+ fieldTypes?: Record<string, FieldType>;
1871
+ }
1872
+ /**
1873
+ * Modern Query Parser
1874
+ * Converts URL parameters to MongoDB queries with $lookup support
1875
+ */
1876
+ declare class QueryParser {
1877
+ private readonly options;
1878
+ private readonly operators;
1879
+ private readonly dangerousOperators;
1880
+ /**
1881
+ * Normalized dot-notation field-type map. Built once at construction from
1882
+ * `options.schema` (Mongoose) and `options.fieldTypes` (override map), with
1883
+ * `fieldTypes` taking precedence. An empty map means schema-aware coercion
1884
+ * is disabled and the heuristic in `_convertValue` runs unchanged — this
1885
+ * preserves backwards compatibility for callers that don't opt in.
1886
+ */
1887
+ private readonly _fieldTypes;
1614
1888
  /**
1615
- * Delete a document by ID.
1616
- *
1617
- * By default the behavior is decided by the plugin stack:
1618
- * - With `softDeletePlugin` wired → soft delete (sets `deletedAt`)
1619
- * - Without the plugin → physical delete
1620
- *
1621
- * Pass `mode: 'hard'` to force physical deletion regardless of plugins.
1622
- * All policy hooks (multi-tenant scope, audit trails, cache invalidation,
1623
- * cascade) still fire — only the soft-delete interception is skipped. This
1624
- * is the GDPR / admin-cleanup path.
1625
- *
1626
- * @example
1627
- * ```ts
1628
- * // Respects softDeletePlugin — soft if wired
1629
- * await userRepo.delete(userId);
1630
- *
1631
- * // GDPR erasure — physical delete, audit hooks still fire
1632
- * await userRepo.delete(userId, { mode: 'hard', organizationId: 'org_123' });
1633
- * ```
1889
+ * Structured schema-index info (geo / text / other), built once from
1890
+ * `options.schema?.indexes()`. Used by the operator router to validate
1891
+ * geo operators against actual geo-indexed fields, and exposed publicly
1892
+ * via `parser.schemaIndexes` for downstream tools (Arc MCP, query planners).
1634
1893
  */
1635
- delete(id: string | ObjectId, options?: SessionOptions & {
1636
- idField?: string;
1637
- mode?: 'hard' | 'soft'; /** Legacy opt-in: throw a 404 error on miss instead of returning `null`. */
1638
- throwOnNotFound?: boolean;
1639
- }): Promise<DeleteResult | null>;
1894
+ private readonly _schemaIndexes;
1640
1895
  /**
1641
- * Update every document matching the filter.
1642
- *
1643
- * Promoted from `batchOperationsPlugin` to a class primitive in 3.11.0 so
1644
- * the surface matches sqlitekit's always-available `updateMany`. Consumers
1645
- * who forget to wire `batchOperationsPlugin` no longer get a runtime
1646
- * `TypeError: repo.updateMany is not a function` — the method is always
1647
- * here.
1648
- *
1649
- * Accepts three `update` forms via `UpdateInput`:
1650
- * - Portable `UpdateSpec` from `@classytic/repo-core/update` (compiled to
1651
- * `$set`/`$unset`/`$inc`/`$setOnInsert` before hooks run).
1652
- * - Raw Mongo operator record (`{ $set, $inc, ... }`) — passed through.
1653
- * - Mongo aggregation pipeline (`[...]`) — requires `updatePipeline: true`
1654
- * to guard against accidental pipeline updates.
1896
+ * Regex patterns that can cause catastrophic backtracking (ReDoS attacks).
1897
+ * Detects common footguns:
1898
+ * - Quantifiers: {n,m}
1899
+ * - Possessive quantifiers: *+, ++, ?+
1900
+ * - Nested quantifiers: (a+)+, (a*)*
1901
+ * - Backreferences: \1, \2, etc.
1902
+ * - Complex character classes: [...]...[...]
1655
1903
  *
1656
- * Refuses empty filters (defense-in-depth against mass-update accidents)
1657
- * both before and after policy hooks inject tenant scope.
1904
+ * This pattern detector is FAIL-SAFE (on match, the input is escaped), but
1905
+ * it is not exhaustive. {@link _regexComplexityExceedsBudget} complements
1906
+ * it with a deterministic static complexity budget so patterns that happen
1907
+ * to evade the detector still fail over when their unbounded-quantifier
1908
+ * density is pathological.
1658
1909
  */
1659
- updateMany(filter: Record<string, unknown>, update: UpdateInput, options?: SessionOptions & {
1660
- updatePipeline?: boolean;
1661
- [key: string]: unknown;
1662
- }): Promise<UpdateManyResult>;
1910
+ private readonly dangerousRegexPatterns;
1663
1911
  /**
1664
- * Delete every document matching the filter.
1665
- *
1666
- * Promoted from `batchOperationsPlugin` to a class primitive in 3.11.0 for
1667
- * the same reason as `updateMany` — sqlitekit parity and no silent "method
1668
- * is undefined" footgun when the batch plugin isn't wired.
1912
+ * Static complexity budget for user-supplied regex patterns.
1913
+ * Returns `true` if the pattern crosses a budget that empirically correlates
1914
+ * with ReDoS risk — independent of the heuristic regex detector.
1669
1915
  *
1670
- * Behavior mirrors `delete()`: defaults to soft delete when
1671
- * `softDeletePlugin` is wired, physical delete otherwise. Pass
1672
- * `{ mode: 'hard' }` to bypass soft-delete — multi-tenant scoping, audit,
1673
- * and cache hooks still fire.
1916
+ * Budget components (any single threshold crossed → over budget):
1917
+ * - unbounded quantifiers (`*`, `+`, `?`) — hard cap 20
1918
+ * - nested groups `(` — hard cap 8
1919
+ * - alternations `|` — hard cap 10
1920
+ * - combined: (quantifiers * groups) — hard cap 40
1674
1921
  *
1675
- * Rejects empty filters up front AND after policy hooks, so a policy-plugin
1676
- * bug that zeroes out the query can't be exploited to wipe a collection.
1922
+ * These thresholds are intentionally permissive for legitimate queries and
1923
+ * tight enough to refuse known-pathological patterns like `(a+)+b` with
1924
+ * heavy repetition.
1677
1925
  */
1678
- deleteMany(filter: Record<string, unknown>, options?: SessionOptions & {
1679
- mode?: 'hard' | 'soft';
1680
- [key: string]: unknown;
1681
- }): Promise<DeleteManyResult>;
1926
+ private _regexComplexityExceedsBudget;
1927
+ constructor(options?: QueryParserOptions);
1682
1928
  /**
1683
- * Compliance-grade cleanup primitive — see `StandardRepo.purgeByField`
1684
- * for the cross-kit contract. Mongokit composes the kit-agnostic
1685
- * `runChunkedPurge` orchestrator (loop, signal, progress, result
1686
- * envelope) with `createMongoPurgePort` (driver-specific id selection
1687
- * + strategy writes). See [actions/purge.ts](./actions/purge.ts) for
1688
- * the port implementation.
1929
+ * Structured view of the configured schema's indexes — geo fields, text
1930
+ * fields, and other compound indexes. Empty arrays when no schema was
1931
+ * provided. Stable across the parser's lifetime; mutating the returned
1932
+ * object does not affect parser behavior.
1933
+ *
1934
+ * Useful for downstream tooling (Arc MCP, query planners, doc generators)
1935
+ * that needs to know which fields support which query types.
1689
1936
  */
1690
- purgeByField(field: string, value: unknown, strategy: TenantPurgeStrategy, options?: TenantPurgeOptions): Promise<TenantPurgeResult>;
1937
+ get schemaIndexes(): SchemaIndexes;
1691
1938
  /**
1692
- * Kit-native aggregation pipeline — takes a MongoDB stage array and
1693
- * returns the raw pipeline output. Use this for `$lookup`, `$unwind`,
1694
- * `$facet`, `$graphLookup`, and any other mongo-specific power
1695
- * features that don't translate to SQL.
1696
- *
1697
- * For backend-portable aggregations (filter + group + measures +
1698
- * sort + limit) use `aggregate(req: AggRequest)` instead — it
1699
- * compiles the repo-core IR to the equivalent pipeline internally
1700
- * and produces the same row shape sqlitekit emits.
1701
- *
1702
- * Routes through the hook system for policy enforcement
1703
- * (multi-tenant, soft-delete).
1939
+ * Get the configured allowed filter fields.
1940
+ * Returns `undefined` if no whitelist is set (all fields allowed).
1704
1941
  *
1705
- * @param pipeline - MongoDB aggregation stage array
1706
- * @param options - Aggregation options including governance controls
1942
+ * Used by Arc's MCP integration to auto-derive `filterableFields`
1943
+ * from the QueryParser when `schemaOptions.filterableFields` is not set.
1707
1944
  */
1708
- aggregatePipeline<TResult = unknown>(pipeline: PipelineStage[], options?: AggregateOptions): Promise<TResult[]>;
1945
+ get allowedFilterFields(): string[] | undefined;
1709
1946
  /**
1710
- * Paginated kit-native aggregation pipeline. Same mongo-specific
1711
- * scope as `aggregatePipeline` — reach for this when you need
1712
- * `$lookup` / `$unwind` / `$facet` alongside pagination.
1713
- *
1714
- * Prefer `aggregatePaginate(req: AggPaginationRequest)` for
1715
- * cross-backend dashboard code; that method compiles the portable
1716
- * IR to an equivalent pipeline.
1717
- *
1718
- * Policy hooks (multi-tenant, soft-delete) inject context.filters
1719
- * which are prepended as a `$match` stage.
1947
+ * Get the configured allowed sort fields.
1948
+ * Returns `undefined` if no whitelist is set (all fields allowed).
1720
1949
  */
1721
- aggregatePipelinePaginate(options?: AggregatePaginationOptions): Promise<AggregatePaginationResult<TDoc>>;
1950
+ get allowedSortFields(): string[] | undefined;
1722
1951
  /**
1723
- * Portable aggregation. Compiles the repo-core `AggRequest` IR to
1724
- * `[{$match}, {$group}, {$addFields}, {$project}, {$match}, {$sort},
1725
- * {$skip}, {$limit}]` against this repo's collection. Output rows
1726
- * carry one key per `groupBy` column plus one key per measure alias
1727
- * — identical shape to sqlitekit's `aggregate(req)`, so dashboards
1728
- * work unchanged across backends.
1729
- *
1730
- * Without `groupBy`: returns a single-row result with just the
1731
- * measures (scalar aggregation). Pass
1732
- * `{ measures: { total: { op: 'sum', field: 'amount' } } }` for a
1733
- * simple summary.
1734
- *
1735
- * For mongo-specific power features (`$lookup`, `$unwind`, `$facet`,
1736
- * `$graphLookup`, window operators) reach for `aggregatePipeline`
1737
- * — that's the kit-native escape hatch.
1738
- *
1739
- * Routes through the hook system for policy enforcement
1740
- * (multi-tenant, soft-delete) — plugins inject their scope into
1741
- * `context.filter`, which is merged into the pre-aggregate `$match`
1742
- * before compilation.
1952
+ * Get the configured allowed operators.
1953
+ * Returns `undefined` if no whitelist is set (all built-in operators allowed).
1743
1954
  */
1744
- aggregate<TRow extends Record<string, unknown> = Record<string, unknown>>(req: AggRequest, options?: QueryOptions): Promise<AggResult<TRow>>;
1955
+ get allowedOperators(): string[] | undefined;
1745
1956
  /**
1746
- * Invalidate every aggregate-cache entry tagged with ANY of the
1747
- * given tags. Delegates to the unified cache plugin's handle
1748
- * (`repo.cache.invalidateByTags`) — wired by `cachePlugin({ adapter })`.
1749
- *
1750
- * Pass no tags to wipe the entire cache namespace (requires the
1751
- * adapter to implement `clear`).
1957
+ * Parse URL query parameters into MongoDB query format
1752
1958
  *
1753
- * Returns the count of distinct entries cleared (`-1` when the call
1754
- * routed through `adapter.clear()` and the adapter doesn't report a
1755
- * count). No-op (returns `0`) when no cache plugin is wired.
1959
+ * @example
1960
+ * ```typescript
1961
+ * // URL: ?status=active&lookup[department][foreignField]=slug&sort=-createdAt&page=1
1962
+ * const query = parser.parse(req.query);
1963
+ * // Returns: { filters: {...}, lookups: [...], sort: {...}, page: 1 }
1964
+ * ```
1756
1965
  */
1757
- invalidateAggregateCache(tags?: readonly string[]): Promise<number>;
1966
+ parse(query: Record<string, unknown> | null | undefined): ParsedQuery;
1758
1967
  /**
1759
- * Paginated portable aggregation. Two pagination modes, picked by
1760
- * the request shape:
1968
+ * Generate OpenAPI-compatible JSON Schema for query parameters.
1969
+ * Arc's defineResource() auto-detects this method and uses it
1970
+ * to document list endpoint query parameters in OpenAPI/Swagger.
1761
1971
  *
1762
- * - **Offset (default)** — `page` + `limit`. Returns the standard
1763
- * `{ method: 'offset', data, total, pages, hasNext, hasPrev, ... }`
1764
- * envelope. `countStrategy: 'none'` skips the second round-trip
1765
- * that computes `total`; the envelope reports `total: 0`,
1766
- * `pages: 0`, and derives `hasNext` from a `LIMIT N+1` peek on
1767
- * the data pipeline.
1768
- * - **Keyset** — `pagination: 'keyset'` (or `after` set). Returns
1769
- * `{ method: 'keyset', data, hasMore, next, limit }`. `sort` is
1770
- * required — the cursor encodes the sort-key tuple of the last
1771
- * row. Each subsequent page passes the previous `next` back as
1772
- * `after`. Scales to arbitrary group counts because the planner
1773
- * uses `(sort_keys) > (cursor)` instead of `OFFSET N`.
1972
+ * The schema respects parser configuration:
1973
+ * - `allowedOperators`: only documents allowed operators
1974
+ * - `allowedFilterFields`: generates explicit field[op] entries
1975
+ * - `enableLookups` / `enableAggregations`: includes/excludes lookup/aggregate params
1976
+ * - `maxLimit` / `maxSearchLength`: reflected in schema constraints
1774
1977
  */
1775
- aggregatePaginate<TRow extends Record<string, unknown> = Record<string, unknown>>(req: AggPaginationRequest, options?: QueryOptions): Promise<OffsetPaginationResultCore<TRow> | KeysetAggPaginationResult<TRow>>;
1978
+ getQuerySchema(): {
1979
+ type: 'object';
1980
+ properties: Record<string, unknown>;
1981
+ required?: string[];
1982
+ };
1776
1983
  /**
1777
- * Internal: the actual aggregate-paginate execution body. Extracted
1778
- * so `aggregatePaginate` can wrap it in the cache layer without
1779
- * duplicating the keyset / offset branch logic.
1780
- *
1781
- * Receives `finalReq` (already policy-scoped) so the cache key
1782
- * built by the outer wrapper matches the request the executor
1783
- * sees — no cross-tenant cache poisoning.
1984
+ * Get the query schema with OpenAPI extensions (x-internal metadata).
1985
+ * Use this when generating OpenAPI/Swagger docs — it includes a documentary
1986
+ * `_filterOperators` property describing available filter operators.
1987
+ * For validation-only schemas, use `getQuerySchema()` instead.
1784
1988
  */
1785
- private _executeAggregatePaginate;
1989
+ getOpenAPIQuerySchema(): {
1990
+ type: 'object';
1991
+ properties: Record<string, unknown>;
1992
+ };
1786
1993
  /**
1787
- * Merge policy-hook-injected filters (multi-tenant scope,
1788
- * soft-delete) into the pre-aggregate `filter` slot of the request.
1789
- * Plugins speak mongo-query shape (`context.query` / `context.filters`);
1790
- * the caller's `req.filter` may be either Filter IR or a mongo query.
1791
- *
1792
- * Each part is compiled through `compileFilterToMongo` BEFORE the
1793
- * `$and` merge — otherwise an IR node like `eq('status', 'paid')`
1794
- * (`{ op, field, value }`) gets nested inside `$and` as a literal,
1795
- * `compileFilterToMongo` of the merged value finds no top-level `op`
1796
- * (so `isFilter` is false), and Mongo receives the IR object as a
1797
- * raw query clause that matches no real document. Symptom: aggregates
1798
- * silently return 0 rows whenever a callerpasses IR + a policy
1799
- * plugin is installed.
1994
+ * Get the JSON Schema type for a filter operator
1995
+ */
1996
+ private _getOperatorSchemaType;
1997
+ /**
1998
+ * Get a human-readable description for a filter operator
1800
1999
  */
1801
- private _injectPolicyScopeIntoAgg;
2000
+ private _getOperatorDescription;
1802
2001
  /**
1803
- * Get distinct values
1804
- * Routes through hook system for policy enforcement (multi-tenant, soft-delete)
2002
+ * Build a summary description of all available filter operators
1805
2003
  */
1806
- distinct<T = unknown>(field: string, query?: Record<string, unknown>, options?: ReadOptions): Promise<T[]>;
2004
+ private _buildOperatorDescription;
1807
2005
  /**
1808
- * Query with custom field lookups ($lookup)
1809
- * Best for: Joins on slugs, SKUs, codes, or other indexed custom fields
2006
+ * Parse lookup configurations from URL parameters
2007
+ *
2008
+ * Supported formats:
2009
+ * 1. Simple: ?lookup[department]=slug
2010
+ * → Join with 'departments' collection on slug field
2011
+ *
2012
+ * 2. Detailed: ?lookup[department][localField]=deptSlug&lookup[department][foreignField]=slug
2013
+ * → Full control over join configuration
2014
+ *
2015
+ * 3. Multiple: ?lookup[department]=slug&lookup[category]=categorySlug
2016
+ * → Multiple lookups
1810
2017
  *
1811
2018
  * @example
1812
2019
  * ```typescript
1813
- * // Join employees with departments using slug instead of ObjectId
1814
- * const employees = await employeeRepo.lookupPopulate({
1815
- * filters: { status: 'active' },
1816
- * lookups: [
1817
- * {
1818
- * from: 'departments',
1819
- * localField: 'departmentSlug',
1820
- * foreignField: 'slug',
1821
- * as: 'department',
1822
- * single: true
1823
- * }
1824
- * ],
1825
- * sort: '-createdAt',
1826
- * page: 1,
1827
- * limit: 50
2020
+ * // URL: ?lookup[department][localField]=deptSlug&lookup[department][foreignField]=slug&lookup[department][single]=true
2021
+ * const lookups = parser._parseLookups({
2022
+ * department: { localField: 'deptSlug', foreignField: 'slug', single: 'true' }
1828
2023
  * });
2024
+ * // Returns: [{ from: 'departments', localField: 'deptSlug', foreignField: 'slug', single: true }]
1829
2025
  * ```
1830
2026
  */
1831
- lookupPopulate<TExtra extends Record<string, unknown> = Record<string, unknown>>(options: LookupPopulateOptions<TDoc>): Promise<LookupPopulateResult<TDoc, TExtra>>;
2027
+ private _parseLookups;
1832
2028
  /**
1833
- * Create an aggregation builder for this model
1834
- * Useful for building complex custom aggregations
2029
+ * Parse a single lookup configuration
2030
+ */
2031
+ private _parseSingleLookup;
2032
+ /**
2033
+ * Parse aggregation pipeline from URL (advanced feature)
1835
2034
  *
1836
2035
  * @example
1837
2036
  * ```typescript
1838
- * const pipeline = repo.buildAggregation()
1839
- * .match({ status: 'active' })
1840
- * .lookup('departments', 'deptSlug', 'slug', 'department', true)
1841
- * .group({ _id: '$department', count: { $sum: 1 } })
1842
- * .sort({ count: -1 })
1843
- * .build();
1844
- *
1845
- * const results = await repo.Model.aggregate(pipeline);
2037
+ * // URL: ?aggregate[group][_id]=$status&aggregate[group][count]=$sum:1
2038
+ * const pipeline = parser._parseAggregation({
2039
+ * group: { _id: '$status', count: '$sum:1' }
2040
+ * });
1846
2041
  * ```
1847
2042
  */
1848
- buildAggregation(): AggregationBuilder;
2043
+ private _parseAggregation;
1849
2044
  /**
1850
- * Create a lookup builder
1851
- * Useful for building $lookup stages independently
2045
+ * Parse select/project fields
1852
2046
  *
1853
2047
  * @example
1854
2048
  * ```typescript
1855
- * const lookupStages = repo.buildLookup('departments')
1856
- * .localField('deptSlug')
1857
- * .foreignField('slug')
1858
- * .as('department')
1859
- * .single()
1860
- * .build();
1861
- *
1862
- * const pipeline = [
1863
- * { $match: { status: 'active' } },
1864
- * ...lookupStages
1865
- * ];
2049
+ * // URL: ?select=name,email,-password
2050
+ * // Returns: { name: 1, email: 1, password: 0 }
1866
2051
  * ```
1867
2052
  */
1868
- buildLookup(from?: string): LookupBuilder;
2053
+ private _parseSelect;
1869
2054
  /**
1870
- * Execute callback within a MongoDB transaction with automatic retry on
1871
- * transient failures.
1872
- *
1873
- * **BREAKING (3.10):** the callback now receives a **tx-bound repository**,
1874
- * not a raw `ClientSession`. This matches repo-core's `StandardRepo.withTransaction`
1875
- * contract that every kit (sqlitekit, pgkit, prismakit) already implements
1876
- * — so cross-kit plugins and apps are portable. Session threading happens
1877
- * transparently: every CRUD call on `txRepo` auto-injects the session into
1878
- * its options bag, including plugin-added methods (`upsert`, `increment`,
1879
- * `updateMany`, `restore`, ...). Nested `txRepo.withTransaction(...)` throws.
1880
- *
1881
- * For raw-session cross-repo workflows — multiple repositories coordinating
1882
- * writes — use the standalone `withTransaction(connection, fn)` export
1883
- * from `@classytic/mongokit`. That helper remains session-based by design.
1884
- *
1885
- * @param callback - Receives a tx-bound `Repository<TDoc>` — call its
1886
- * methods directly, no need to pass `{ session }` manually
1887
- * @param options.allowFallback - Run without transaction on standalone MongoDB (default: false)
1888
- * @param options.onFallback - Called when falling back to non-transactional execution
1889
- * @param options.transactionOptions - MongoDB driver transaction options (readConcern, writeConcern, etc.)
2055
+ * Parse populate parameter - handles both simple string and advanced object format
1890
2056
  *
1891
2057
  * @example
1892
2058
  * ```typescript
1893
- * const order = await repo.withTransaction(async (txRepo) => {
1894
- * const created = await txRepo.create({ total: 100 });
1895
- * await txRepo.update(created._id, { confirmed: true });
1896
- * return created;
1897
- * });
1898
- *
1899
- * // With fallback for standalone/dev environments
1900
- * await repo.withTransaction(
1901
- * async (txRepo) => {
1902
- * await txRepo.create(doc);
1903
- * },
1904
- * {
1905
- * allowFallback: true,
1906
- * onFallback: (err) => logger.warn('Running without transaction', err),
1907
- * },
1908
- * );
2059
+ * // Simple: ?populate=author,category
2060
+ * // Returns: { simplePopulate: 'author,category', populateOptions: undefined }
1909
2061
  *
1910
- * // Cross-repo with explicit session:
1911
- * import { withTransaction } from '@classytic/mongokit';
1912
- * await withTransaction(mongoose.connection, async (session) => {
1913
- * const order = await orderRepo.create(data, { session });
1914
- * await inventoryRepo.decrement(..., { session });
1915
- * });
2062
+ * // Advanced: ?populate[author][select]=name,email
2063
+ * // Returns: { simplePopulate: undefined, populateOptions: [{ path: 'author', select: 'name email' }] }
1916
2064
  * ```
1917
2065
  */
1918
- withTransaction<T>(callback: (txRepo: this) => Promise<T>, options?: WithTransactionOptions): Promise<T>;
1919
- /**
1920
- * Execute custom query with event emission
1921
- */
1922
- _executeQuery<T>(buildQuery: (Model: Model<TDoc>) => Promise<T>): Promise<T>;
2066
+ private _parsePopulate;
1923
2067
  /**
1924
- * Build operation context and run before-hooks (sorted by priority).
1925
- *
1926
- * Narrows `RepositoryBase._buildContext`'s return type to mongokit's
1927
- * richer `RepositoryContext` (which carries `softDeleted`, `_cacheHit`,
1928
- * populate / readPreference / session, etc.). Runtime is inherited from
1929
- * the base class — this override is purely a type-narrowing shim.
1930
- *
1931
- * Hook execution order is deterministic:
1932
- * 1. POLICY (100) — tenant isolation, soft-delete filtering, validation
1933
- * 2. CACHE (200) — cache lookup (after policy filters are injected)
1934
- * 3. OBSERVABILITY (300) — audit logging, metrics
1935
- * 4. DEFAULT (500) — user-registered hooks
2068
+ * Parse a single populate configuration
1936
2069
  */
1937
- _buildContext(operation: string, options: Record<string, unknown> | object): Promise<RepositoryContext>;
2070
+ private _parseSinglePopulate;
1938
2071
  /**
1939
- * Walk the well-known filter-carrying keys (`query`, `filters`,
1940
- * `having`) and route any Filter IR through `compileFilterToMongo`.
1941
- * Plain Mongo queries pass through untouched — `isFilter` is the
1942
- * discriminator. Returns a shallow clone; never mutates input.
2072
+ * Convert populate match values (handles boolean strings, etc.)
1943
2073
  */
1944
- private _normalizeFilterSlots;
2074
+ private _convertPopulateMatch;
1945
2075
  /**
1946
- * Resolve the effective sort for getAll. Handles $near conflict detection
1947
- * (warns + drops explicit sort when $near is in filters).
2076
+ * Parse filter parameters
1948
2077
  */
1949
- private _resolveSort;
2078
+ private _parseFilters;
1950
2079
  /**
1951
- * Detect whether to use keyset (cursor) or offset (page) pagination.
2080
+ * Handle operator syntax: field[operator]=value
1952
2081
  */
1953
- private _detectPaginationMode;
2082
+ private _handleOperatorSyntax;
1954
2083
  /**
1955
- * Build the MongoDB query filter with search merged in. Handles text
1956
- * search ($text), regex search ($or of $regex), and the search-resolver
1957
- * plugin contract (search already cleared by a before:getAll hook).
2084
+ * Handle bracket syntax with object value
1958
2085
  */
1959
- private _buildSearchQuery;
2086
+ private _handleBracketSyntax;
2087
+ private _parseSort;
2088
+ private _toMongoOperator;
2089
+ private _createSafeRegex;
2090
+ private _escapeRegex;
1960
2091
  /**
1961
- * Parse sort string or object
2092
+ * Sanitize $match configuration to prevent dangerous operators
2093
+ * Recursively filters out operators like $where, $function, $accumulator
1962
2094
  */
1963
- _parseSort(sort: SortSpec | string | undefined): SortSpec;
2095
+ private _sanitizeMatchConfig;
1964
2096
  /**
1965
- * Parse populate specification
2097
+ * Sanitize pipeline stages for use in $lookup.
2098
+ * Blocks dangerous stages ($out, $merge, etc.) and recursively sanitizes
2099
+ * operator expressions within $match, $addFields, and $set stages.
1966
2100
  */
1967
- _parsePopulate(populate: PopulateSpec | undefined): string[] | PopulateOptions[];
2101
+ private _sanitizePipeline;
1968
2102
  /**
1969
- * Classify a driver error as an authoritative duplicate-key conflict.
1970
- *
1971
- * Used by arc's outbox / idempotency adapters to distinguish
1972
- * "write already landed (idempotent no-op)" from "transient DB error
1973
- * (must retry)". Every backend signals duplicate keys differently —
1974
- * MongoDB `code: 11000`, Prisma `P2002`, Postgres `23505` — so this
1975
- * predicate lives on the kit that knows its driver. Cross-driver
1976
- * adapters then depend only on the boolean outcome, not the shape of
1977
- * the underlying error.
1978
- *
1979
- * Deliberately narrow: matches ONLY `code === 11000` and
1980
- * `codeName === 'DuplicateKey'`. Does NOT match
1981
- * `err.name === 'MongoServerError'`, which is also true for
1982
- * WriteConflict, NotWritablePrimary, ExceededTimeLimit, and every
1983
- * other server-side error — treating those as duplicate keys would
1984
- * silently swallow transactional retries.
2103
+ * Recursively sanitize expression objects, blocking dangerous operators
2104
+ * like $where, $function, $accumulator inside $addFields/$set stages.
1985
2105
  *
1986
- * @example
1987
- * try {
1988
- * await outboxRepo.create(event);
1989
- * } catch (err) {
1990
- * if (outboxRepo.isDuplicateKeyError(err)) {
1991
- * // Idempotent no-op — event was already written by a prior attempt.
1992
- * return;
1993
- * }
1994
- * throw err; // transient DB error — upstream retries
1995
- * }
2106
+ * Depth-guarded — same protection `_sanitizeMatchConfig` ships, since
2107
+ * a hostile `$cond`/`$switch` chain can recurse arbitrarily deep and
2108
+ * exhaust JS stack. Without the guard, this is a one-liner DoS against
2109
+ * any host that exposes pipeline parsing to untrusted callers.
1996
2110
  */
1997
- isDuplicateKeyError(err: unknown): boolean;
2111
+ private _sanitizeExpressions;
2112
+ private _sanitizeSearch;
1998
2113
  /**
1999
- * Handle errors with proper HTTP status codes.
2114
+ * Build regex-based multi-field search filters
2115
+ * Creates an $or query with case-insensitive regex across all searchFields
2000
2116
  *
2001
- * **Transactional retry signal preservation.** MongoDB driver errors
2002
- * carry `errorLabels` (e.g. `'TransientTransactionError'`,
2003
- * `'UnknownTransactionCommitResult'`) that `mongoose.Connection.
2004
- * withTransaction()` reads via `error.hasErrorLabel(label)` to decide
2005
- * whether to auto-retry the transaction. Wrapping such an error in a
2006
- * fresh `Error` via `createError(...)` strips both the labels and the
2007
- * `hasErrorLabel` method — the retry signal vanishes and concurrent
2008
- * writers see WriteConflicts surface to userland instead of being
2009
- * retried by the driver. To keep the standard MongoDB transaction-
2010
- * retry mechanism intact, we re-throw any error that carries
2011
- * `errorLabels` exactly as we received it. The caller still sees a
2012
- * thrown error — only the wrapping layer is skipped.
2117
+ * @example
2118
+ * // searchFields: ['name', 'description', 'sku']
2119
+ * // search: 'azure'
2120
+ * // Returns: [
2121
+ * // { name: { $regex: /azure/i } },
2122
+ * // { description: { $regex: /azure/i } },
2123
+ * // { sku: { $regex: /azure/i } }
2124
+ * // ]
2013
2125
  */
2014
- _handleError(error: Error): HttpError;
2126
+ private _buildRegexSearch;
2127
+ private _parseOr;
2128
+ private _enhanceWithBetween;
2129
+ private _pluralize;
2130
+ private _capitalize;
2015
2131
  }
2016
2132
  //#endregion
2017
2133
  //#region src/transaction.d.ts
@@ -2313,17 +2429,4 @@ declare function createOptionsExtractor<TCtx extends Record<string, unknown>>(fi
2313
2429
  */
2314
2430
  declare function systemContext(extra?: SessionOptions): SessionOptions;
2315
2431
  //#endregion
2316
- //#region src/index.d.ts
2317
- /**
2318
- * Factory function to create a repository instance
2319
- *
2320
- * @param Model - Mongoose model
2321
- * @param plugins - Array of plugins to apply
2322
- * @returns Repository instance
2323
- *
2324
- * @example
2325
- * const userRepo = createRepository(UserModel, [timestampPlugin()]);
2326
- */
2327
- declare function createRepository<TDoc>(Model: _$mongoose.Model<TDoc, any, any, any>, plugins?: PluginType[], paginationConfig?: PaginationConfig, options?: RepositoryOptions): Repository<TDoc>;
2328
- //#endregion
2329
- export { ALL_OPERATIONS, type AggregateHelpersMethods, type AggregateOptions, type AggregatePaginationOptions, AggregationBuilder, type AllPluginMethods, type AnyDocument, type AnyModel, type AuditEntry, type AuditOperation, type AuditQueryOptions, type AuditQueryResult, type AuditTrailMethods, type AuditTrailOptions, AuditTrailQuery, type BasePaginationOptions, type BatchOperationsMethods, type BulkWriteResult, type CacheAdapter, type CacheOperationOptions, type CacheOptions, type CacheableOptions, type CascadeOptions, type CascadeRelation, type CollationOptions, type CreateInput, type CreateOptions, type CursorPayload, type CustomIdOptions, type DateSequentialIdOptions, type DecodedCursor, type DeepPartial, type DeleteResult, type DocField, type ElasticSearchOptions, type EventHandlers, type EventPayload, type EventPhase, type FieldPreset, type FieldType, type FilterQuery, type FindOneAndUpdateOptions, type GroupResult, HOOK_PRIORITY, type HookMode, type IController, type IControllerResponse, type IRequestContext, type IResponseFormatter, type IdGenerator, type InferDocument, type InferRawDoc, type KeysOfType, type KeysetPaginationOptions, type LeaseMethods, type LeasePluginOptions, type Logger, LookupBuilder, type LookupOptions, type LookupPopulateOptions, type LookupPopulateResult, MUTATING_OPERATIONS, type Middleware, type MiddlewareContext, type MinMaxResult, type MinimalRepoView, type MongoOperationsMethods, type MongoOperatorUpdate, type MultiTenantOptions, type NonNullableFields, OP_REGISTRY, type ObjectId, type ObservabilityOptions, type OffsetPaginationOptions, type OperationDescriptor, type OperationMetric, type OperationOptions, type PaginationConfig, PaginationEngine, type ParsedQuery, type PartialBy, type Plugin, type PluginFunction, type PluginType, type PolicyKey, type PopulateOption, type PopulateSpec, type PrefixedIdOptions, type PrioritizedHook, QueryParser, type QueryParserOptions, READ_OPERATIONS, type ReadOptions, type ReadPreferenceType, Repository, Repository as default, type RepositoryCacheHandle, type RepositoryCachePluginOptions, type RepositoryContext, type RepositoryEvent, type RepositoryInstance, type RepositoryOperation, type RepositoryOptions, type RequiredBy, type SchemaLike, type SearchMode, type SelectSpec, type SequentialIdOptions, type SessionOptions, type SoftDeleteFilterMode, type SoftDeleteMethods, type SoftDeleteOptions, type SoftDeleteRepository, type SortDirection, type SortSpec, type Strict, type SubdocumentMethods, type TenantContext, type TenantStore, type UpdateManyResult, type UpdateOptions, type UpdatePatch, type UpdateWithValidationResult, type UserContext, type ValidationChainOptions, type ValidatorDefinition, type ValueType, type WithPlugins, type WithTransactionOptions, index_d_exports as actions, adminBypass, aggregateHelpersPlugin, auditLogPlugin, auditTrailPlugin, autoInject, batchOperationsPlugin, batchTransaction, blockIf, buildCrudSchemasFromModel, buildCrudSchemasFromMongooseSchema, cachePlugin, cascadePlugin, compileFilterToMongo, configureLogger, createError, createFieldPreset, createMemoryCache, createOptionsExtractor, createRepository, createTenantContext, customIdPlugin, dateSequentialId, elasticSearchPlugin, fieldFilterPlugin, filterResponseData, getFieldsForUser, getMongooseProjection, getNextSequence, immutableField, isDuplicateKeyError, isTransactionUnsupported, leasePlugin, methodRegistryPlugin, mongoOperationsPlugin, multiTenantPlugin, observabilityPlugin, operationsByPolicyKey, parseDuplicateKeyError, prefixedId, repoOptionsFromCtx, requireField, sequentialId, softDeletePlugin, subdocumentPlugin, systemContext, timestampPlugin, uniqueField, validationChainPlugin, withTransaction };
2432
+ export { ALL_OPERATIONS, type AggregateHelpersMethods, type AggregateOptions, type AggregatePaginationOptions, AggregationBuilder, type AllPluginMethods, type AnyDocument, type AnyModel, type AuditConfig, type AuditEntry, type AuditOperation, type AuditQueryOptions, type AuditQueryResult, type AuditTrailMethods, type AuditTrailOptions, AuditTrailQuery, type BasePaginationOptions, type BatchOperationsMethods, type BulkWriteResult, type CacheAdapter, type CacheOperationOptions, type CacheOptions, type CacheableOptions, type CascadeOptions, type CascadeRelation, type CollationOptions, type CreateInput, type CreateOptions, type CreateRepositoryConfig, type CursorPayload, type CustomIdOptions, type DateSequentialIdOptions, type DecodedCursor, type DeepPartial, type DeleteResult, type DocField, type ElasticSearchOptions, type EventHandlers, type EventPayload, type EventPhase, type FieldPreset, type FieldType, type FilterQuery, type FindOneAndUpdateOptions, type GroupResult, HOOK_PRIORITY, type HookMode, type IController, type IControllerResponse, type IRequestContext, type IResponseFormatter, type IdGenerator, type InferDocument, type InferRawDoc, type KeysOfType, type KeysetPaginationOptions, type LeaseMethods, type LeasePluginOptions, type Logger, LookupBuilder, type LookupOptions, type LookupPopulateOptions, type LookupPopulateResult, MONGOKIT_CAPABILITIES, MUTATING_OPERATIONS, type Middleware, type MiddlewareContext, type MinMaxResult, type MinimalRepoView, type MongoOperationsMethods, type MongoOperatorUpdate, type MultiTenantOptions, type NonNullableFields, OP_REGISTRY, type ObjectId, type ObservabilityOptions, type OffsetPaginationOptions, type OperationDescriptor, type OperationMetric, type OperationOptions, type PaginationConfig, PaginationEngine, type ParsedQuery, type PartialBy, type Plugin, type PluginFunction, type PluginType, type PolicyKey, type PopulateOption, type PopulateSpec, type PrefixedIdOptions, type PrioritizedHook, QueryParser, type QueryParserOptions, READ_OPERATIONS, type ReadOptions, type ReadPreferenceType, Repository, Repository as default, type RepositoryCacheHandle, type RepositoryCachePluginOptions, type RepositoryContext, type RepositoryEvent, type RepositoryInstance, type RepositoryOperation, type RepositoryOptions, type RequiredBy, type SchemaLike, type SearchMode, type SelectSpec, type SequentialIdOptions, type SessionOptions, type SoftDeleteFilterMode, type SoftDeleteMethods, type SoftDeleteOptions, type SoftDeleteRepository, type SortDirection, type SortSpec, type Strict, type SubdocumentMethods, type TenantContext, type TenantStore, type UpdateManyResult, type UpdateOptions, type UpdatePatch, type UpdateWithValidationResult, type UserContext, type ValidationChainOptions, type ValidatorDefinition, type ValueType, type WithPlugins, type WithTransactionOptions, index_d_exports as actions, adminBypass, aggregateHelpersPlugin, auditLogPlugin, auditTrailPlugin, autoInject, batchOperationsPlugin, batchTransaction, blockIf, buildCrudSchemasFromModel, buildCrudSchemasFromMongooseSchema, cachePlugin, cascadePlugin, compileFilterToMongo, configureLogger, createError, createFieldPreset, createMemoryCache, createOptionsExtractor, createRepository, createTenantContext, customIdPlugin, dateSequentialId, elasticSearchPlugin, fieldFilterPlugin, filterResponseData, getFieldsForUser, getMongooseProjection, getNextSequence, immutableField, isDuplicateKeyError, isTransactionUnsupported, leasePlugin, methodRegistryPlugin, mongoOperationsPlugin, multiTenantPlugin, observabilityPlugin, operationsByPolicyKey, parseDuplicateKeyError, prefixedId, repoOptionsFromCtx, requireField, sequentialId, softDeletePlugin, subdocumentPlugin, systemContext, timestampPlugin, uniqueField, validationChainPlugin, withTransaction };