@ignex/ninox 0.1.4 → 0.1.6

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.
package/README.md CHANGED
@@ -556,6 +556,7 @@ topProducts.invalidate(); // drop all entries
556
556
  topProducts.invalidate(3); // drop just the { limit: 3 } entry
557
557
  hot.invalidateParams('topProducts', 3); // same, via dynamic name lookup
558
558
  hot.invalidateCollection('products'); // drop every query watching that collection
559
+ hot.invalidateIds('products', [id]); // drop only entries depending on that document
559
560
  await hot.stop(); // close streams + ticker
560
561
  ```
561
562
 
@@ -565,6 +566,47 @@ parameters. Loader errors are never cached (they retry on the next read), and
565
566
  concurrent identical reads are in-flight-deduped. `has(name)` checks whether a
566
567
  query is registered.
567
568
 
569
+ ### Fine-tuned purges: watch ids (or groups of ids)
570
+
571
+ By default a write anywhere in a collection purges **every** query bound to it.
572
+ That's the safe contract — and often what you want for wide results like
573
+ aggregations. When a query's entries map cleanly onto specific documents,
574
+ declare `idsOf` on the watch ref and invalidation becomes surgical:
575
+
576
+ ```ts
577
+ const userFeed = hot.register('userFeed', {
578
+ loader: async (userId: string) => db.findMany('orders', { customerId: userId }),
579
+ watch: [
580
+ // Only the entries whose feed includes THIS order are purged; other
581
+ // users' feeds stay warm. Works across processes via change streams.
582
+ { db: db.client, collection: 'orders', idsOf: (userId: string) => feedOrderIds(userId) },
583
+ ],
584
+ });
585
+ // …or drive it yourself (works in standalone mode too):
586
+ hot.invalidateIds('orders', [changedOrderId]);
587
+ ```
588
+
589
+ - `idsOf` receives the query's loader arguments and returns **every document id
590
+ the result depends on** — one per doc, or many for grouped/aggregated rows.
591
+ Ids may be strings, numbers or ObjectIds; an ObjectId and its hex string
592
+ match. It must be pure and cheap (runs once per load).
593
+ - **Omit `idsOf` to keep burst semantics** — the right choice for whole-collection
594
+ aggregations (`countDocuments`, `$group`, …): any change to the collection
595
+ purges the query's entire LRU.
596
+ - A ref without an extractor next to one with (multi-collection watchers)
597
+ forces a full purge whenever *its* collection changes; the extracted
598
+ collection stays surgical.
599
+ - If `idsOf` throws or returns a non-array, affected entries are treated
600
+ conservatively: they are dropped by any targeted change instead of kept.
601
+ - Watch `stats().perQuery[name].idDrops` to see how many entries targeted
602
+ purges are saving you (each drop is a read that stayed warm elsewhere).
603
+
604
+ In replica mode the change stream feeds `invalidateIds` automatically from each
605
+ event's `documentKey._id`; in standalone mode call it from your ORM's
606
+ after-write hooks or wherever you learn about a change. Note that extraction
607
+ sees only the loader arguments — a by-sku lookup whose dependency `_id` isn't
608
+ derivable from `{ sku }` should either key entries by `_id` or stay coarse.
609
+
568
610
  Set `autoRefresh: false` to **turn off the global auto-refetch** entirely — the
569
611
  standalone ticker never runs, so data only updates via manual invalidation or
570
612
  `ttlMs` expiry. This keeps the DB from being polled when you'd rather drive
@@ -578,8 +620,9 @@ const hot = createHotCache({ probe, autoRefresh: false, defaultTtlMs: 5_000 });
578
620
  Pin the mode to skip the probe entirely when you already know the deployment
579
621
  (`mode: 'replica'` / `mode: 'standalone'`), and use `hot.stats()` for
580
622
  observability — it reports per-query `hits` / `misses` / `refreshes` /
581
- `loadErrors` / `sizeSkips` plus current sizes. Cap runaway single entries with a
582
- per-query `maxValueBytes` (larger values are still returned, just not cached).
623
+ `loadErrors` / `sizeSkips` / `idDrops` plus current sizes. Cap runaway single
624
+ entries with a per-query `maxValueBytes` (larger values are still returned,
625
+ just not cached).
583
626
 
584
627
  Cached results are shared **by reference** (zero clone overhead); if a caller might
585
628
  mutate a result, enable per-query `clone: true` (or `cache: { clone: true }`) so
@@ -744,10 +787,15 @@ you need the code → status mapping yourself.
744
787
  > transient errors.
745
788
 
746
789
  Schema validation is enforced **by MongoDB** — the DB `$jsonSchema` validator
747
- derived from your schema is installed with `createSchema` (and hot-swapped with
748
- `updateSchema`), and the DB rejects violating writes with `VALIDATION_FAILED`.
749
- ninox deliberately does **not** validate *incoming user input* at runtime (no
750
- client-side/zod layer).
790
+ derived from your schema is installed with `createSchema`, and the DB rejects
791
+ violating writes with `VALIDATION_FAILED`. `createSchema` is **idempotent and
792
+ self-reconciling**: it provisions a NEW collection, and when the collection
793
+ already exists it upgrades the DB schema to match the current ORM schema
794
+ (hot-swapping the validator via `collMod` only when it has drifted, and
795
+ creating any newly-declared indexes) — so calling it on every boot keeps
796
+ Mongo's schema in lockstep with your models as they change. `updateSchema`
797
+ hot-swaps a validator explicitly (via `collMod`). ninox deliberately does
798
+ **not** validate *incoming user input* at runtime (no client-side/zod layer).
751
799
 
752
800
  - **Strict by default** — `additionalProperties: false` is emitted unless a
753
801
  field opts out (`s.object({ ... }, { additionalProperties: true })`), so
@@ -771,7 +819,8 @@ client-side/zod layer).
771
819
  `DomainError` carries the offending field paths (`extra.fields`), the failing
772
820
  document id (`extra.documentId`), and the raw `extra.details`, so a rejected
773
821
  write names exactly what (and which document) violated the schema.
774
- - **Index lifecycle** — `createSchema` installs the declared indexes;
822
+ - **Index lifecycle** — `createSchema` installs the declared indexes (and, on
823
+ an existing collection, adds any newly-declared ones without dropping);
775
824
  `syncIndexes(collection)` reconciles drift (`await db.syncIndexes('products')`
776
825
  creates missing declared keys and drops undeclared ones, `_id_` kept).
777
826
 
@@ -972,9 +1021,10 @@ unknown keys in object literals AND non-literal patches. The only way an unknown
972
1021
  key passes is a variable explicitly typed with an index signature
973
1022
  (`Record<string, any>`) — prefer inline literals for full checking.
974
1023
 
975
- **Index drift.** `createSchema` installs declared indexes; if they drift out of
976
- band (manual drops, other tooling), `syncIndexes('collection')` reconciles —
977
- creates missing declared indexes and drops undeclared ones (`_id_` kept).
1024
+ **Index drift.** `createSchema` installs declared indexes on creation and adds
1025
+ newly-declared ones on re-provision; if indexes drift out of band (manual
1026
+ drops, other tooling), `syncIndexes('collection')` reconciles — creates
1027
+ missing declared indexes and drops undeclared ones (`_id_` kept).
978
1028
 
979
1029
  **Observability.** `QueryCache.stats()` (hits/misses/evictions/…) and
980
1030
  `hot.stats()` (per-query hits/misses/refreshes/loadErrors/evictions/…) give you
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as mongodb from 'mongodb';
2
2
  import { Db, MongoClient, ClientSession, Document, Filter, Hint, CollationOptions, ReadPreferenceLike, ReadConcern, WriteConcern, WithId, Condition, RootFilterOperators, Collection, FindOptions, Sort, FindCursor, ObjectId, Decimal128, ReadConcernLike, AggregateOptions, AggregationCursor, CreateCollectionOptions, TransactionOptions, InsertOneOptions, InsertOneResult, InsertManyResult, UpdateOptions, UpdateResult, FindOneAndUpdateOptions, DeleteResult, ChangeStreamOptions, ChangeStream } from 'mongodb';
3
- import { L as LoggerLike } from './timeout-BE_ULjdG.js';
4
- export { C as CacheOptions, a as LRU, b as LRUOptions, c as LogFn, d as LogLevel, e as createCachedAsyncFactory, f as createCachedFactory, g as createConsoleLogger, h as createNoopLogger, i as hashString, s as sleep, j as sleepJittered, k as stableHash, l as stableStringify, w as withTimeout } from './timeout-BE_ULjdG.js';
3
+ import { L as LoggerLike } from './timeout-B10CTiGm.js';
4
+ export { C as CacheOptions, a as LRU, b as LRUOptions, c as LogFn, d as LogLevel, e as createCachedAsyncFactory, f as createCachedFactory, g as createConsoleLogger, h as createNoopLogger, i as hashString, s as sleep, j as sleepJittered, k as stableHash, l as stableStringify, w as withTimeout } from './timeout-B10CTiGm.js';
5
5
  import * as bson from 'bson';
6
6
 
7
7
  /**
@@ -13,10 +13,37 @@ import * as bson from 'bson';
13
13
 
14
14
  /** A collection to watch for invalidation. `collection` is the PHYSICAL name. */
15
15
  interface HotCollectionRef {
16
- /** Raw `Db` handle (e.g. `service.db.primaryClient.client`). */
17
- db: Db;
16
+ /**
17
+ * Raw `Db` handle (e.g. `service.db.primaryClient.client`) — or a lazy
18
+ * `() => Db` accessor resolved when the watcher starts. The lazy form lets
19
+ * apps register queries at module scope (before the connection exists) and
20
+ * only touch the live handle at `start()` time.
21
+ */
22
+ db: Db | (() => Db);
18
23
  /** Physical collection name whose writes invalidate bound queries. */
19
24
  collection: string;
25
+ /**
26
+ * Fine-tuned invalidation: extract the document ids (or groups of ids) that
27
+ * one loader call's arguments depend on. Without this, ANY write to the
28
+ * collection purges every entry of every query bound to it; with it, a
29
+ * change-stream event (replica mode) or a manual
30
+ * `invalidateIds(collection, [id])` call purges ONLY the entries that
31
+ * extracted this id — sibling entries stay warm.
32
+ *
33
+ * Return every id the result depends on (one per document, or many for
34
+ * grouped/aggregated results). Ids may be strings, numbers, ObjectIds… —
35
+ * they are compared after stable normalization, so an ObjectId and its hex
36
+ * string match.
37
+ *
38
+ * Must be pure and cheap (it runs once per load). If it throws or returns a
39
+ * non-array the entry is marked conservative: it is dropped by ANY targeted
40
+ * change on that collection instead of being kept incorrectly.
41
+ *
42
+ * Typed as `never`-params (contravariance-safe, same trick as `LRU`'s
43
+ * `onEvict`): declare your own parameter types and they line up with the
44
+ * query's loader args — `idsOf: (team: string) => members(team)`.
45
+ */
46
+ idsOf?: (...args: never) => readonly unknown[];
20
47
  }
21
48
  /** Opt-in query registration. `TArgs` = the parameter tuple, `TResult` = loader result. */
22
49
  interface HotQueryConfig<TArgs extends readonly unknown[], TResult> {
@@ -124,6 +151,8 @@ interface HotQueryStats {
124
151
  loadErrors: number;
125
152
  /** Values too large to cache (`maxValueBytes`). */
126
153
  sizeSkips: number;
154
+ /** Entries dropped by id-targeted invalidation (`invalidateIds` / change events). */
155
+ idDrops: number;
127
156
  /** Capacity evictions (LRU). */
128
157
  evictions: number;
129
158
  }
@@ -187,18 +216,41 @@ declare class HotCache {
187
216
  * called manually to wire ORM write hooks in standalone mode.
188
217
  */
189
218
  invalidateCollection(collection: string): void;
219
+ /**
220
+ * Fine-tuned purge: drop only the entries that depend on specific document
221
+ * ids of a watched collection. Two complementary mechanisms feed it:
222
+ *
223
+ * - **Replica mode** — the change stream extracts `documentKey._id` from
224
+ * each event and calls this with just that id, so a write to one document
225
+ * no longer purges every query bound to the whole collection.
226
+ * - **Manual (any mode)** — call it from ORM after-write hooks or anywhere
227
+ * else you know exactly which ids changed.
228
+ *
229
+ * Per query bound to `collection`, semantics are:
230
+ * - watch ref declares `idsOf` → only LRU entries whose extracted ids
231
+ * include one of `ids` are dropped (`depsAll` entries — where extraction
232
+ * failed — are dropped too; conservative). Sibling entries stay warm and
233
+ * `stats().perQuery[name].idDrops` counts what was dropped.
234
+ * - no `idsOf` on any bound ref → full purge of that query (the
235
+ * `invalidateCollection` behavior — the safe default).
236
+ */
237
+ invalidateIds(collection: string, ids: readonly unknown[]): void;
190
238
  /** Drop every cached entry for every query. */
191
239
  clear(): void;
192
240
  /**
193
241
  * Runtime observability snapshot: total queries/entries plus per-query
194
- * counters (`hits`, `misses`, background `refreshes`, `loadErrors`, and
195
- * `sizeSkips` for entries dropped by `maxValueBytes`). Useful for dashboards
196
- * and cache-effectiveness checks.
242
+ * counters (`hits`, `misses`, background `refreshes`, `loadErrors`,
243
+ * `sizeSkips` for entries dropped by `maxValueBytes`, and `idDrops` for
244
+ * entries dropped by id-targeted invalidation). Useful for dashboards and
245
+ * cache-effectiveness checks.
197
246
  */
198
247
  stats(): HotCacheStats;
199
248
  /**
200
249
  * Probe replica support once, then start change-stream watchers (replica) or
201
250
  * the global refresh ticker (standalone). Idempotent — resolves the mode.
251
+ * A FAILED start does not stick: the memoized promise is cleared so the next
252
+ * `get()`/`start()` retries probing (one transient blip during startup must
253
+ * not disable background freshness for the life of the instance).
202
254
  */
203
255
  start(): Promise<HotCacheMode>;
204
256
  /**
@@ -207,11 +259,25 @@ declare class HotCache {
207
259
  * re-probes or re-opens background resources.
208
260
  */
209
261
  stop(): Promise<void>;
262
+ /**
263
+ * A FAILED start does not stick: clear the memoized state so the next
264
+ * `get()`/`start()` retries probing (one transient blip during startup must
265
+ * not disable background freshness for the life of the instance).
266
+ */
267
+ private forgetFailedStart;
210
268
  private runStart;
211
269
  private ensureStarted;
212
270
  private defaultProbe;
213
271
  private fetch;
214
272
  private setEntry;
273
+ /**
274
+ * Extract the watched-document dependencies of one loader call. Namespaced by
275
+ * collection so multi-collection watchers never cross-invalidate. An extractor
276
+ * that throws or returns a non-array yields `{ depsAll: true }` — the entry is
277
+ * then dropped by ANY targeted change on its watched collections instead of
278
+ * being incorrectly kept.
279
+ */
280
+ private computeDeps;
215
281
  }
216
282
  /** Create a new global hot-cache instance. */
217
283
  declare const createHotCache: (options?: HotCacheOptions) => HotCache;
@@ -286,6 +352,12 @@ declare const cacheCollectionKey: (dbName: string, physical: string) => string;
286
352
  declare class QueryCache {
287
353
  private lru;
288
354
  private index;
355
+ /**
356
+ * Reverse index: key → every collection it is registered under. Without it,
357
+ * keys dropped by TTL expiry / capacity eviction / version-guard would stay
358
+ * listed in `index` forever (unbounded growth + O(dead) invalidation scans).
359
+ */
360
+ private keyCollections;
289
361
  /** Per-collection version — bumped on every invalidation/clear of that collection. */
290
362
  private colVersions;
291
363
  private ttlMs;
@@ -309,6 +381,10 @@ declare class QueryCache {
309
381
  */
310
382
  versionOf(collection: string): number;
311
383
  get(key: string): unknown | undefined;
384
+ /** Remove a key from the LRU and every collection index that references it. */
385
+ private dropKey;
386
+ /** Remove `key` from `index` (called for evictions AND explicit drops). */
387
+ private unregisterKey;
312
388
  set(key: string, value: unknown, ttlMs?: number, collections?: string[], versions?: Record<string, number>): void;
313
389
  delete(key: string): void;
314
390
  /** Drop every cached entry belonging to a physical collection. */
@@ -439,6 +515,14 @@ declare const TRANSIENT_MONGO_ERROR_CODES: ReadonlySet<number>;
439
515
  declare const isMongoTransientError: (error: unknown) => boolean;
440
516
  /** True when the error is a duplicate-key violation (code 11000 / E11000 message). */
441
517
  declare const isMongoDuplicateKeyError: (error: unknown) => boolean;
518
+ /**
519
+ * True when the deployment cannot run transactions at all (standalone mongod,
520
+ * or storage engine without retryable writes) — callers fall back to
521
+ * non-transactional execution. Shared by BOTH fallback layers
522
+ * (`withGracefulMongoTransaction` and the service `transaction` op) so they
523
+ * classify identically.
524
+ */
525
+ declare const isTransactionUnsupportedError: (error: unknown) => boolean;
442
526
 
443
527
  /** Minimal transaction runner surface (satisfied by the service `transaction`). */
444
528
  interface MongoTransactionRunner {
@@ -453,8 +537,14 @@ interface GracefulTransactionOptions {
453
537
  }
454
538
  /**
455
539
  * Run `fn` inside a transaction when the deployment supports it; otherwise run
456
- * with a `null` session (no-op fallback). Also handles the "retryable writes
457
- * unsupported" error by re-running with a null session.
540
+ * with a `null` session (no-op fallback). Also handles "transactions
541
+ * unsupported" errors by re-running with a null session.
542
+ *
543
+ * When capability state is UNKNOWN (probe failed/timed out and no URL hint or
544
+ * env override says otherwise), we ATTEMPT the real transaction rather than
545
+ * silently downgrading — a transient startup blip must not turn an atomic
546
+ * write into a non-atomic one. The catch path still falls back cleanly on a
547
+ * genuinely unsupported deployment (at the cost of one failed round trip).
458
548
  */
459
549
  declare const withGracefulMongoTransaction: <T>(runner: MongoTransactionRunner, fn: (session: ClientSession | null) => Promise<T>, options?: GracefulTransactionOptions) => Promise<T>;
460
550
 
@@ -478,21 +568,6 @@ declare const HOOK_NAMES: readonly HookName[];
478
568
  /** Run a single hook (no-op when the collection has none registered). */
479
569
  declare const runHooks: <TSchema = Document>(registry: HooksRegistry | undefined, collection: string, name: HookName, ctx: HookContext<TSchema>) => Promise<void>;
480
570
 
481
- /**
482
- * A self-contained DataLoader — batch + cache loading that eliminates the
483
- * N+1 query problem (the classic GraphQL utility, implemented from scratch so
484
- * the ORM owns its batching behavior).
485
- *
486
- * Semantics:
487
- * - concurrent `load(key)` calls made in the same tick are coalesced into ONE
488
- * `batch(keys)` call (flushed on a microtask, or after `maxBatchDelayMs`)
489
- * - the batch result is a Map (key → value); a missing key resolves `undefined`
490
- * - results are cached per key (canonicalized via `keyOf`) for the loader's
491
- * lifetime; `clear`/`clearAll` reset the cache
492
- * - a FAILED batch is never cached: its keys are evicted so a later `load`
493
- * runs a fresh batch instead of re-surfacing the same rejection
494
- * - `maxBatchSize` splits oversized batches
495
- */
496
571
  interface DataLoaderOptions<Key, Value> {
497
572
  /** Resolve a batch of keys → one Map. Keys that have no row should be absent. */
498
573
  batch: (keys: Key[]) => Promise<Map<Key, Value>>;
@@ -549,7 +624,10 @@ interface MigrationModule {
549
624
  interface MongoMigrationRunner {
550
625
  /** Apply every not-yet-applied migration in numeric order. */
551
626
  up(): Promise<void>;
552
- /** Roll back applied migrations in reverse order (down to `targetName` when given). */
627
+ /**
628
+ * Roll back applied migrations in reverse order (down to `targetName` when
629
+ * given). An unknown `targetName` throws BEFORE anything is rolled back.
630
+ */
553
631
  down(targetName?: string): Promise<void>;
554
632
  status(): Promise<{
555
633
  applied: string[];
@@ -561,6 +639,18 @@ interface MongoMigrationRunner {
561
639
  interface MongoMigrationRunnerOptions {
562
640
  migrationDir?: string;
563
641
  logger?: LoggerLike;
642
+ /**
643
+ * Which connected database the journal + migrations target (key of the
644
+ * service's db map). Defaults to the FIRST connected key — set this explicitly
645
+ * in multi-database services to avoid silently migrating the wrong one.
646
+ */
647
+ db?: string;
648
+ /**
649
+ * Migration claim lease in ms (default 120_000). A `running` claim older than
650
+ * the lease is considered orphaned (hard crash) and may be stolen by another
651
+ * runner; live claims are renewed automatically during long `up()`s.
652
+ */
653
+ leaseMs?: number;
564
654
  }
565
655
 
566
656
  /**
@@ -568,11 +658,14 @@ interface MongoMigrationRunnerOptions {
568
658
  * two sub-systems:
569
659
  *
570
660
  * - `./files.ts` — discover / load / scaffold `NNN_name.ts` files
571
- * - `./journal.ts` — claim-based `_migrations` journal (atomic, crash-safe)
661
+ * - `./journal.ts` — lease-based `_migrations` journal (atomic, crash-safe)
572
662
  *
573
- * `up()` applies pending migrations in numeric order with an atomic claim;
574
- * `down()` rolls back applied migrations in reverse order; `status()` reports
575
- * applied/pending; `create()` scaffolds a new file.
663
+ * `up()` applies pending migrations in numeric order under an exclusive claim
664
+ * with an auto-renewed lease (long migrations can't be stolen by another
665
+ * runner, but a crashed runner's claim expires); `down()` rolls back applied
666
+ * migrations in reverse order and validates its target BEFORE destroying
667
+ * anything; `status()` reports applied/pending; `create()` scaffolds a new file
668
+ * atomically (`wx` — concurrent scaffolds never clobber each other).
576
669
  */
577
670
 
578
671
  /** The `service` shape the runner needs (one connected manager + config/logger). */
@@ -592,10 +685,12 @@ interface RetryOptions {
592
685
  }
593
686
  /**
594
687
  * Retry `fn` while it throws a *transient* Mongo error (network / not-primary /
595
- * sharding), with exponential backoff `delayMs * 2^attempt`. Domain errors and
596
- * genuine data errors are never retried.
688
+ * sharding / server-selection during failover), with exponential backoff
689
+ * `delayMs * 2^attempt` plus ±20% jitter — synchronized retry waves against a
690
+ * recovering cluster amplify outages instead of absorbing them. Domain errors
691
+ * and genuine data errors are never retried.
597
692
  */
598
- declare const withRetry: <T>(fn: () => Promise<T>, options?: RetryOptions, _ctx?: unknown) => Promise<T>;
693
+ declare const withRetry: <T>(fn: () => Promise<T>, options?: RetryOptions) => Promise<T>;
599
694
  /**
600
695
  * Manual transaction helper: start → run → commit, aborting + ending the
601
696
  * session on any failure. Prefer the service-level `transaction()` which also
@@ -939,14 +1034,22 @@ interface QueryOptions {
939
1034
  * a committed write whose ack was lost would be re-executed, so enable only
940
1035
  * for idempotent writes). Default: off for write ops.
941
1036
  */
942
- retryWrites?: boolean /**
1037
+ /**
1038
+ * Per-op write-retry override. Reads always retry transient errors; writes
1039
+ * retry ONLY when `retryWrites: true` (retrying a write is at-least-once —
1040
+ * a committed write whose ack was lost would be re-executed, so enable only
1041
+ * for idempotent writes). Default: off for write ops.
1042
+ */
1043
+ retryWrites?: boolean;
1044
+ /**
943
1045
  * Per-op schema-drift policy override for reads (see `MongoServiceConfig.drift`).
944
1046
  * `true` → `'throw'`, `false` → `'off'`, or an explicit `DriftMode` string.
945
1047
  * Unset inherits the service-level default (`'report'`). Only checked when a
946
1048
  * document is fetched from the DB (cache-miss); projected/partial reads are
947
1049
  * skipped.
948
- */;
949
- drift?: DriftMode | boolean /** Driver-level options forwarded untouched to the underlying call. */;
1050
+ */
1051
+ drift?: DriftMode | boolean;
1052
+ /** Driver-level options forwarded untouched to the underlying call. */
950
1053
  collation?: CollationOptions;
951
1054
  readPreference?: ReadPreferenceLike;
952
1055
  readConcern?: ReadConcern;
@@ -1013,16 +1116,16 @@ interface QueryBuilderContext<TDoc extends Document> {
1013
1116
  run: <T>(opName: string, filter: FilterInput<TDoc>, execute: (driverOpts: FindOptions) => Promise<T>, options?: QueryOptions) => Promise<T>;
1014
1117
  }
1015
1118
  /**
1016
- * Lazy, fluent, schema-typed query builder. Nothing hits the database until an
1017
- * executor (`one`/`many`/`cursor`/`count`/`exists`) is called. Projections via
1018
- * `select` are pushed to the driver so only requested fields are transferred.
1119
+ * Lazy, fluent, IMMUTABLE, schema-typed query builder. Every chain method
1120
+ * returns a NEW builder — a stored base query can be safely reused as a
1121
+ * template without derived chains contaminating it:
1122
+ *
1123
+ * const admins = users.query('users').where({ role: 'admin' });
1124
+ * await admins.where({ age: { $gte: 18 } }).many(); // base unchanged
1019
1125
  *
1020
- * await users.query('users')
1021
- * .where({ role: 'admin' })
1022
- * .sort({ createdAt: -1 })
1023
- * .select(['_id', 'email'])
1024
- * .limit(20)
1025
- * .many();
1126
+ * Nothing hits the database until an executor (`one`/`many`/`cursor`/`count`/
1127
+ * `exists`) is called. Projections via `select` are pushed to the driver so
1128
+ * only requested fields are transferred.
1026
1129
  */
1027
1130
  declare class QueryBuilder<TDoc extends Document, TSelection = TDoc> {
1028
1131
  private readonly ctx;
@@ -1034,34 +1137,39 @@ declare class QueryBuilder<TDoc extends Document, TSelection = TDoc> {
1034
1137
  private _options;
1035
1138
  constructor(ctx: QueryBuilderContext<TDoc>);
1036
1139
  get filter(): FilterInput<TDoc>;
1037
- /** Shallow-merge a filter into the current one (later keys win). */
1038
- where(filter: FilterInput<TDoc>): this;
1039
- /** AND-combine the current filter with a new one. */
1040
- and(filter: FilterInput<TDoc>): this;
1041
- /** OR-combine: current filter AND (filter1 OR filter2 ...). */
1042
- or(...filters: Array<FilterInput<TDoc>>): this;
1140
+ /** Copy-on-write fork used by every chain method. */
1141
+ private fork;
1142
+ /** Merge a filter into the current one (operator docs deep-merge per key). */
1143
+ where(filter: FilterInput<TDoc>): QueryBuilder<TDoc, TSelection>;
1144
+ /** AND-combine the current filter with a new one (stays flat across repeats). */
1145
+ and(filter: FilterInput<TDoc>): QueryBuilder<TDoc, TSelection>;
1146
+ /**
1147
+ * OR-combine: current filter AND (filter1 OR filter2 ...). Requires at least
1148
+ * one filter — an empty `$or` is rejected by the server.
1149
+ */
1150
+ or(...filters: Array<FilterInput<TDoc>>): QueryBuilder<TDoc, TSelection>;
1043
1151
  /** Set the sort order (`{ field: 1 | -1 }`). */
1044
- sort(sort: Sort): this;
1152
+ sort(sort: Sort): QueryBuilder<TDoc, TSelection>;
1045
1153
  /** Skip the first `n` matching documents. */
1046
- skip(n: number): this;
1154
+ skip(n: number): QueryBuilder<TDoc, TSelection>;
1047
1155
  /** Limit the result set to `n` documents. */
1048
- limit(n: number): this;
1156
+ limit(n: number): QueryBuilder<TDoc, TSelection>;
1049
1157
  /** Project only the given fields — the projection is pushed to the driver. */
1050
1158
  select<const K extends keyof TSelection>(fields: readonly K[]): QueryBuilder<TDoc, Pick<TSelection, K>>;
1051
1159
  /** Raw projection document (`{ field: 1 }` / `{ field: 0 }`). */
1052
- project(projection: Document): this;
1160
+ project(projection: Document): QueryBuilder<TDoc, TSelection>;
1053
1161
  /** Set the index hint. */
1054
- hint(hint: Hint): this;
1162
+ hint(hint: Hint): QueryBuilder<TDoc, TSelection>;
1055
1163
  /** Set the driver cursor batch size. */
1056
- batchSize(n: number): this;
1164
+ batchSize(n: number): QueryBuilder<TDoc, TSelection>;
1057
1165
  /** Run the query inside a transaction session. */
1058
- session(session: NonNullable<QueryOptions['session']>): this;
1166
+ session(session: NonNullable<QueryOptions['session']>): QueryBuilder<TDoc, TSelection>;
1059
1167
  /** Cap server-side execution time. */
1060
- maxTimeMS(ms: number): this;
1168
+ maxTimeMS(ms: number): QueryBuilder<TDoc, TSelection>;
1061
1169
  /** Bypass the read cache for this query when `false`. */
1062
- cache(on: boolean): this;
1170
+ cache(on: boolean): QueryBuilder<TDoc, TSelection>;
1063
1171
  /** Override in-flight dedup for this query when set. */
1064
- dedupe(on: boolean): this;
1172
+ dedupe(on: boolean): QueryBuilder<TDoc, TSelection>;
1065
1173
  /** Compile the builder into driver options (does not execute). */
1066
1174
  private compile;
1067
1175
  private countOptions;
@@ -1069,11 +1177,11 @@ declare class QueryBuilder<TDoc extends Document, TSelection = TDoc> {
1069
1177
  one(): Promise<WithId<TSelection> | null>;
1070
1178
  /** Fetch many documents (default limit 100 when unset). */
1071
1179
  many(): Promise<WithId<TSelection>[]>;
1072
- /** Streaming cursor (raw driver cursor; no retry, applies batchSize). */
1180
+ /** Streaming cursor (raw driver cursor; no retry/cache/drift — caller-owned). */
1073
1181
  cursor(): FindCursor<WithId<TDoc>>;
1074
1182
  /** Count matching documents. */
1075
1183
  count(): Promise<number>;
1076
- /** True when at least one document matches. */
1184
+ /** True when at least one document matches (`_id`-only projection — no doc transfer). */
1077
1185
  exists(): Promise<boolean>;
1078
1186
  }
1079
1187
 
@@ -1359,6 +1467,13 @@ interface DbOpMeta {
1359
1467
  physicalCollection?: string;
1360
1468
  db: string;
1361
1469
  op: string;
1470
+ /**
1471
+ * What was sent to the database (filter / document / update / pipeline /
1472
+ * options). Surfaced in the ignex debugbar as the span's `params`, so the
1473
+ * dashboard shows exactly what the query carried — not just its name.
1474
+ * Optional: ops without a payload worth showing omit it.
1475
+ */
1476
+ params?: unknown;
1362
1477
  }
1363
1478
 
1364
1479
  /**
@@ -1441,7 +1556,7 @@ interface TextSearchPagination {
1441
1556
 
1442
1557
  interface CrudOpDeps<TCollection> {
1443
1558
  trace: <T>(meta: DbOpMeta, fn: () => T | Promise<T>) => Promise<T>;
1444
- meta: (collection: TCollection, op: string) => DbOpMeta;
1559
+ meta: (collection: TCollection, op: string, params?: unknown) => DbOpMeta;
1445
1560
  }
1446
1561
 
1447
1562
  /**
@@ -2024,8 +2139,14 @@ interface DbHealthResult {
2024
2139
  ok: boolean;
2025
2140
  /** Ping round-trip time in ms (present when `ok`). */
2026
2141
  latencyMs?: number;
2142
+ /** Why the ping failed — timeout vs auth vs network (present when `!ok`). */
2143
+ error?: string;
2027
2144
  }
2028
2145
  interface HealthReport {
2146
+ /**
2147
+ * Overall status. `false` when ANY db fails — including the degenerate
2148
+ * "no dbs connected" case (an empty manager record is NOT healthy).
2149
+ */
2029
2150
  ok: boolean;
2030
2151
  /** Total elapsed time across all pings in ms. */
2031
2152
  latencyMs: number;
@@ -2138,6 +2259,14 @@ interface PaginationConfig {
2138
2259
  sort?: Record<string, 1 | -1>;
2139
2260
  /** Cap on the requested limit (default DEFAULT_MAX_LIMIT=1000). */
2140
2261
  maxLimit?: number;
2262
+ /**
2263
+ * Projection applied to the data facet (e.g. `{ _id: 1, updatedAt: 1 }`).
2264
+ *
2265
+ * Enables lean list reads — only the projected fields cross the wire. When
2266
+ * set, the schema-drift check is skipped for the page (projected docs
2267
+ * intentionally lack the collection's full field set).
2268
+ */
2269
+ projection?: Document;
2141
2270
  /** Stages prepended to the data facet (e.g. extra $match). */
2142
2271
  prePipeline?: Document[];
2143
2272
  /** Stages appended inside the data facet. */
@@ -2298,9 +2427,19 @@ interface SchemaOpsOptions {
2298
2427
  getDefinition: (logical: string) => CollectionRegistryEntry | undefined;
2299
2428
  }
2300
2429
  /**
2301
- * Schema-first collection management. `createSchema` provisions a collection
2302
- * with a `$jsonSchema` validator derived from the ORM schema (and creates any
2303
- * declared indexes); `updateSchema` hot-swaps the validator via `collMod`.
2430
+ * Schema-first collection management.
2431
+ *
2432
+ * `createSchema` is IDEMPOTENT and SELF-RECONCILING: on a NEW collection it
2433
+ * provisions the `$jsonSchema` validator derived from the ORM schema plus the
2434
+ * declared indexes; when the collection ALREADY exists it upgrades the DB
2435
+ * schema to match the current ORM schema — hot-swapping the validator via
2436
+ * `collMod` only when it has actually drifted, and creating any newly-declared
2437
+ * indexes. It never drops indexes or data (destructive reconciliation stays
2438
+ * explicit via `syncIndexes`), so it is safe to call on every boot: the Mongo
2439
+ * schema follows the app schema automatically as models change.
2440
+ *
2441
+ * `updateSchema` hot-swaps a validator explicitly (via `collMod`);
2442
+ * `syncIndexes` reconciles index drift (create missing, drop undeclared).
2304
2443
  */
2305
2444
  declare const makeSchemaOps: (client: Db, _logger: LoggerLike, opts: SchemaOpsOptions) => {
2306
2445
  createSchema: (collection: string, createCollectionOptions?: CreateCollectionOptions) => Promise<void>;
@@ -2506,7 +2645,13 @@ declare const createResolveCollectionName: (config: ResolveCollectionConfig) =>
2506
2645
  * explicit (mirrors sdk-db `normalizeMongoUrl`):
2507
2646
  * - retryWrites=false unless already set (keeps semantics predictable)
2508
2647
  * - w=1 unless another write concern is present
2509
- * - directConnection=true for single-host non-replicaSet URIs
2648
+ * - directConnection=true ONLY for single-host, non-replica-set,
2649
+ * non-SRV URIs (`mongodb+srv` implies replica-set discovery — appending
2650
+ * `directConnection` there makes the driver reject the URI)
2651
+ *
2652
+ * Parsing is manual (string split + URLSearchParams) instead of WHATWG `new
2653
+ * URL`: the URL parser rejects multi-host seed lists (`h1:27017,h2:27017` —
2654
+ * it reads `,h2` as a port) and re-encodes userinfo/path in surprising ways.
2510
2655
  */
2511
2656
  declare const normalizeMongoUrl: (rawUrl: string) => string;
2512
2657
 
@@ -2560,8 +2705,14 @@ declare const decodeCursor: (cursor: string) => KeysetCursor;
2560
2705
  declare const buildKeysetFilter: (sort: Record<string, 1 | -1>, values: unknown[], direction: "after" | "before") => Document;
2561
2706
 
2562
2707
  /**
2563
- * Merge multiple Mongo filters into one. Empty/undefined parts are skipped and
2564
- * later keys win on shallow conflicts (mirrors sdk-db `mergeMongoFilters`).
2708
+ * Merge multiple Mongo filters into one. Empty/undefined parts are skipped.
2709
+ *
2710
+ * Conflicting keys deep-merge when BOTH sides are plain objects (operator
2711
+ * documents like `{ $gte: 18 }` / `{ $in: [...] }`), so
2712
+ * `merge({ role: 'admin', age: { $gte: 18 } }, { age: { $lte: 65 } })`
2713
+ * keeps both range clauses instead of the old shallow-assign silently
2714
+ * dropping `$gte`. Scalars replace (last write wins). Top-level `$and`/`$or`
2715
+ * arrays CONCATENATE — dropping a sibling branch would change semantics.
2565
2716
  */
2566
2717
  declare const mergeMongoFilters: <T extends Document>(...parts: Array<Filter<T> | undefined>) => Filter<T>;
2567
2718
 
@@ -2617,4 +2768,4 @@ declare const createMongoToolkit: <TClients extends DbClientsDefinition = DbClie
2617
2768
  };
2618
2769
  type MongoToolkit<TClients extends DbClientsDefinition> = ReturnType<typeof createMongoToolkit<TClients>>;
2619
2770
 
2620
- export { type AccumulatorResult, type AccumulatorSpec, type Added, type AggregationMeta, type AggregationStages, type AnyField, AppError, type ArrayField, BadRequest, type BelongsToRelation, type BooleanField, type CacheInvalidationRef, CacheInvalidator, type Chainable, type CollectionDefinition, type CollectionDoc, type CollectionLike, type CollectionManager, type CollectionMap, type CollectionName, type CollectionTimestamps, type CollectionValue, type CrudListQuery, type CrudOps, type CursorPage, type CursorPaginationConfig, type DBClientDefinition, DEFAULT_FIND_LIMIT, DEFAULT_MAX_LIMIT, DataLoader, type DataLoaderOptions, type DateField, type DateRangeConfig, type DbClientsDefinition, type DbHealthResult, type DecimalField, DomainError, type DoubleField, type DriftIssue, type DriftIssueCode, type DriftMode, ERROR_HTTP_STATUS, type EnumField, type ErrorContext, type ExprResult, type ExtractCollectionNames, type ExtractCollectionType, type ExtractDbNames, type FacetOutput, type FacetPipelineStage, type FacetSpec, type FieldFlags, type FieldMeta, type FieldName, type FieldRef, type FilterInput, type FindQueryOptions, type ForeignDocOf, type GeoNearOutput, type GeoNearSpec, type GeoPoint, type GeoPointField, type GracefulTransactionOptions, type GroupByConfig, type GroupSpec, type Grouped, HOOK_NAMES, type HasManyRelation, type HealthReport, type Hook, type HookContext, type HookMap, type HookName, type HooksRegistry, HotCache, type HotCacheMode, type HotCacheOptions, type HotCacheStats, type HotCollectionRef, type HotQueryAccessor, type HotQueryConfig, type HotQueryStats, InFlight, type IndexSpec, type InferDoc, type InferField, InfraError, type InsertInput, type IntegerField, type JoinedFields, type KeysetCursor, LoggerLike, type LongField, type LookupConfig, type LookupJoinedFor, type LookupParams, type LookupSpecFor, MAX_BATCH_OPS, type ManyToManyRelation, type MigrationContext, type MigrationModule, type MongoCapabilities, type MongoCapabilitiesStore, type MongoJsonSchema, type MongoMigrationRunner, type MongoMigrationRunnerOptions, type MongoService, type MongoServiceConfig, type MongoToolkit, type MongoToolkitOptions, type MongoTransactionRunner, type NamedCollection, type NamedCollectionDefinition, type NamedObjectField, type NormalizedPagination, type NullField, type NumberBounds, type NumberField, type ObjectField, type ObjectIdField, type PaginationConfig, type PaginationResult, PipelineBuilder, type PipelineCustomization, type PipelineStage, type PopulateOptions, type Projected, type Projection, QueryBuilder, type QueryBuilderContext, QueryCache, type QueryCacheOptions, type QueryCacheStats, type QueryOptions, type RawField, type RelationDef, type RelationDefBase, type RemoveIndexSignature, type Repository, type RepositoryOptions, type ResolvedQueryOptions, type RetryOptions, type SchemaType, type SearchConfig, type SortKeys, type StringField, type SyncIndexesResult, TRANSIENT_MONGO_ERROR_CODES, type TimestampsByCollection, type Unsetted, type UnwindSpec, type Unwound, type UpdateInput, type UpdateOperators, type ValidRelation, type VersionedUpdateResult, belongsTo, buildKeysetFilter, buildMongoActiveFilter, buildPaginationResult, cacheCollectionKey, canonicalKey, createHotCache, createMongoCapabilitiesStore, createMongoMigrationRunner, createMongoService, createMongoToolkit, createRepository, createResolveCollectionName, decodeCursor, defineCollection, defineCollections, encodeCursor, hasMany, httpStatusForError, isAppError, isDomainError, isInfraError, isMappedMongoError, isMongoDuplicateKeyError, isMongoTransientError, manyToMany, mapMongoDriverError, mergeMongoActiveFilter, mergeMongoFilters, normalizeMongoUrl, normalizePageLimit, optional, probeMongoCapabilities, runHooks, s, serializeError, stripDocumentId, stripPrimaryKey, toMongoSchema, toMongoValidator, validateDoc, withDefault, withGracefulMongoTransaction, withRetry, withTransaction };
2771
+ export { type AccumulatorResult, type AccumulatorSpec, type Added, type AggregationMeta, type AggregationStages, type AnyField, AppError, type ArrayField, BadRequest, type BelongsToRelation, type BooleanField, type CacheInvalidationRef, CacheInvalidator, type Chainable, type CollectionDefinition, type CollectionDoc, type CollectionLike, type CollectionManager, type CollectionMap, type CollectionName, type CollectionTimestamps, type CollectionValue, type CrudListQuery, type CrudOps, type CursorPage, type CursorPaginationConfig, type DBClientDefinition, DEFAULT_FIND_LIMIT, DEFAULT_MAX_LIMIT, DataLoader, type DataLoaderOptions, type DateField, type DateRangeConfig, type DbClientsDefinition, type DbHealthResult, type DecimalField, DomainError, type DoubleField, type DriftIssue, type DriftIssueCode, type DriftMode, ERROR_HTTP_STATUS, type EnumField, type ErrorContext, type ExprResult, type ExtractCollectionNames, type ExtractCollectionType, type ExtractDbNames, type FacetOutput, type FacetPipelineStage, type FacetSpec, type FieldFlags, type FieldMeta, type FieldName, type FieldRef, type FilterInput, type FindQueryOptions, type ForeignDocOf, type GeoNearOutput, type GeoNearSpec, type GeoPoint, type GeoPointField, type GracefulTransactionOptions, type GroupByConfig, type GroupSpec, type Grouped, HOOK_NAMES, type HasManyRelation, type HealthReport, type Hook, type HookContext, type HookMap, type HookName, type HooksRegistry, HotCache, type HotCacheMode, type HotCacheOptions, type HotCacheStats, type HotCollectionRef, type HotQueryAccessor, type HotQueryConfig, type HotQueryStats, InFlight, type IndexSpec, type InferDoc, type InferField, InfraError, type InsertInput, type IntegerField, type JoinedFields, type KeysetCursor, LoggerLike, type LongField, type LookupConfig, type LookupJoinedFor, type LookupParams, type LookupSpecFor, MAX_BATCH_OPS, type ManyToManyRelation, type MigrationContext, type MigrationModule, type MongoCapabilities, type MongoCapabilitiesStore, type MongoJsonSchema, type MongoMigrationRunner, type MongoMigrationRunnerOptions, type MongoService, type MongoServiceConfig, type MongoToolkit, type MongoToolkitOptions, type MongoTransactionRunner, type NamedCollection, type NamedCollectionDefinition, type NamedObjectField, type NormalizedPagination, type NullField, type NumberBounds, type NumberField, type ObjectField, type ObjectIdField, type PaginationConfig, type PaginationResult, PipelineBuilder, type PipelineCustomization, type PipelineStage, type PopulateOptions, type Projected, type Projection, QueryBuilder, type QueryBuilderContext, QueryCache, type QueryCacheOptions, type QueryCacheStats, type QueryOptions, type RawField, type RelationDef, type RelationDefBase, type RemoveIndexSignature, type Repository, type RepositoryOptions, type ResolvedQueryOptions, type RetryOptions, type SchemaType, type SearchConfig, type SortKeys, type StringField, type SyncIndexesResult, TRANSIENT_MONGO_ERROR_CODES, type TimestampsByCollection, type Unsetted, type UnwindSpec, type Unwound, type UpdateInput, type UpdateOperators, type ValidRelation, type VersionedUpdateResult, belongsTo, buildKeysetFilter, buildMongoActiveFilter, buildPaginationResult, cacheCollectionKey, canonicalKey, createHotCache, createMongoCapabilitiesStore, createMongoMigrationRunner, createMongoService, createMongoToolkit, createRepository, createResolveCollectionName, decodeCursor, defineCollection, defineCollections, encodeCursor, hasMany, httpStatusForError, isAppError, isDomainError, isInfraError, isMappedMongoError, isMongoDuplicateKeyError, isMongoTransientError, isTransactionUnsupportedError, manyToMany, mapMongoDriverError, mergeMongoActiveFilter, mergeMongoFilters, normalizeMongoUrl, normalizePageLimit, optional, probeMongoCapabilities, runHooks, s, serializeError, stripDocumentId, stripPrimaryKey, toMongoSchema, toMongoValidator, validateDoc, withDefault, withGracefulMongoTransaction, withRetry, withTransaction };