@ignex/ninox 0.1.1

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 ADDED
@@ -0,0 +1,1000 @@
1
+ # ninox
2
+
3
+ **Schema-first MongoDB ORM for Bun & Node — typed, cached, and fast out of the box.**
4
+
5
+ Declare your collections once as TypeScript schemas. ninox turns that single definition into your types, a fluent query builder, and MongoDB `$jsonSchema` validation with indexes — then makes the everyday paths fast by default, so you can stop hand-rolling caches and N+1 workarounds.
6
+
7
+ **Node ≥ 18.17** · **Bun ≥ 1.0** · **TypeScript ≥ 5.5** (optional) · **MongoDB 4.4+** · **MIT**
8
+
9
+ ## Why you'll like it
10
+
11
+ - 🧠 **One schema, three jobs.** A single `s.*` schema produces your TypeScript types, the query builder's autocomplete, and the DB validator + indexes. One source of truth, nothing to keep in sync.
12
+ - ⚡ **Fast by default.** Read caching and in-flight dedup are on out of the box — repeated queries make *zero* DB calls, and identical concurrent queries share one.
13
+ - 🔗 **Relations without the N+1.** `populate()` batches lookups into a few `$in` queries, no matter how many related docs you load.
14
+ - 📄 **One-round-trip pagination.** Data and total count from a single `$facet` call, plus cursor (keyset) pagination that stays cheap on deep pages.
15
+ - 🛡️ **Errors you can actually handle.** Driver errors are mapped to a small taxonomy (`BadRequest` / `DomainError` / `InfraError`) with HTTP statuses and a client-safe serializer.
16
+ - 🔥 **A cache that stays fresh by itself.** The global HotCache serves your hottest queries from memory and keeps them fresh with change streams (replica sets) or a background refresh ticker (standalone) — reads never block.
17
+ - 🏭 **Production touches built in.** Graceful transactions, file-based migrations, health checks, multi-DB services, a high-throughput hot cache, and an opt-in schema-drift guard.
18
+
19
+ ## ninox vs native MongoDB vs Mongoose
20
+
21
+ Built on the official `mongodb` driver, but with the ergonomics of an ORM and
22
+ the performance you wouldn't expect from one. Side by side:
23
+
24
+ | | ninox | native `mongodb` driver | Mongoose |
25
+ | --- | --- | --- | --- |
26
+ | One schema → types + queries + DB validation | ✅ `InferDoc` + typed everything | 🟡 manual `<TDoc>` generics | 🟡 schemas, loose query typing |
27
+ | Validation enforcement | ✅ DB-level `$jsonSchema`, strict by default | ❌ you write `$jsonSchema` yourself | 🟡 client-side only, no DB validator |
28
+ | Read query cache (repeat reads = **0** round trips) | ✅ on by default | ❌ none | ❌ none |
29
+ | In-flight dedup (N identical reads → **1** call) | ✅ on by default | ❌ none | ❌ none |
30
+ | Relations without N+1 | ✅ DataLoader-batched `populate` | ❌ manual | 🟡 `populate`, can N+1 |
31
+ | Pagination in one round trip | ✅ `$facet` + keyset cursors | ❌ manual | 🟡 count + find (2 round trips) |
32
+ | Typed aggregation pipeline | ✅ stage-by-stage inferred types | ❌ untyped | ❌ loosely typed |
33
+ | Optimistic locking / soft delete | ✅ `updateWithVersion` / `softDeleteOne` | ❌ manual | 🟡 manual / plugin |
34
+ | Lifecycle hooks | ✅ `before/after` + `afterRead` | ❌ | ✅ |
35
+ | Hot cache (change-stream / background refresh) | ✅ | ❌ | ❌ |
36
+ | Error taxonomy + driver mapping | ✅ `BadRequest` / `DomainError` / `InfraError` | ❌ raw driver errors | 🟡 scattered error types |
37
+ | File-based migrations | ✅ runner + idempotency journal | ❌ | 🟡 plugin |
38
+ | Health checks + multi-DB | ✅ per-DB pings, namespaced caches | ❌ | 🟡 |
39
+ | Change tracking / doc magic | none — explicit load → mutate → persist | — | 🟡 dirty-checking overhead |
40
+ | Runtime | ✅ Bun & Node, ESM-first | ✅ | 🟡 Node-first |
41
+
42
+ **The headline numbers** (localhost, seeded with 200 users / 1000 orders):
43
+
44
+ | scenario | naive | ninox |
45
+ | --- | --- | --- |
46
+ | populate 100 orders | 17,000 queries (N+1) | **340 queries** (~50× fewer round trips, ~6× faster) |
47
+ | 50 concurrent identical reads | 50 server queries, 17 ms | **1 server query**, 1.5 ms |
48
+ | cache hit | 1 driver call | **0 driver calls** |
49
+ | deep aggregation (multi-`$lookup` join) | 17,000 queries (N+1) | **170 queries** (~100× fewer round trips) |
50
+
51
+ And you're never locked in — the raw `client` escape hatch is one property away
52
+ when you need driver-level access. [Full benchmark](#performance) ·
53
+ [Run the examples](#examples)
54
+
55
+ ## Flagship features
56
+
57
+ Three capabilities that set ninox apart — the ones you'll reach for when you need to go from "it works" to "it's fast and observable".
58
+
59
+ ### 🔥 Hot cache — keep your hottest queries in memory
60
+
61
+ Register the queries that run on every request with one global `createHotCache()`. Reads are served from a per-query LRU (call parameters become the cache key), so the DB is only touched on a cold miss. On a replica set, change-stream watchers invalidate the cache the moment a write lands — even from other processes. → [Full guide](#hot-cache-high-throughput)
62
+
63
+ ### 🔄 Background refresh — a self-updating cache
64
+
65
+ On standalone servers (no change streams), a global background ticker re-fetches your registered queries on an interval and swaps in fresh values — a stale value keeps being served until the replacement arrives, so **reads never block**. Opt in per query with `refreshIntervalMs`, or drive freshness entirely by hand (`autoRefresh: false`). → [Background refresh system](#background-refresh-system)
66
+
67
+ ### 🪝 MongoDB lifecycle hooks — middleware for your data
68
+
69
+ Per-collection `before/after` hooks for create, update, and delete, plus `afterRead`. Declare them on the collection and they fire around every ORM op — audit logging, cache invalidation, sensitive-field masking, you name it. `afterRead` runs on every read (cache hit or miss) with a fresh per-caller clone, so hooks can never poison the cache. → [Hooks guide](#hooks)
70
+
71
+ ## Install
72
+
73
+ ```bash
74
+ # bun
75
+ bun add @ignex/ninox mongodb
76
+
77
+ # npm
78
+ npm install @ignex/ninox mongodb
79
+
80
+ # pnpm / yarn
81
+ pnpm add @ignex/ninox mongodb
82
+ ```
83
+
84
+ `mongodb` is ninox's **only runtime dependency**. TypeScript is optional — plain JS works too, you just lose the inferred types.
85
+
86
+ **Requirements**
87
+
88
+ - Node ≥ 18.17 (ESM) or Bun ≥ 1.0
89
+ - MongoDB server 4.4+ (5.1+ for `$fill` / `$densify`)
90
+ - Transactions, change streams, and change-stream cache invalidation need a **replica set or `mongos`**
91
+
92
+ ## Quick start
93
+
94
+ ```ts
95
+ import {
96
+ createMongoToolkit, // service + migrations
97
+ s,
98
+ defineCollection, // name + schema + optional indexes/hooks
99
+ defineCollections, // derive the collections map from named schemas
100
+ belongsTo,
101
+ type InferDoc,
102
+ } from '@ignex/ninox';
103
+
104
+ // 1. Describe your collections. Names live on the schema itself.
105
+ const userSchema = s.object({
106
+ _id: s.objectId(),
107
+ email: s.string(),
108
+ role: s.enum(['admin', 'user'] as const),
109
+ createdAt: s.date(),
110
+ }, { name: 'users' });
111
+ type User = InferDoc<typeof userSchema>;
112
+
113
+ const orderSchema = s.object({
114
+ _id: s.objectId(),
115
+ userId: s.objectId(),
116
+ total: s.number({ minimum: 0 }),
117
+ }, { name: 'orders' });
118
+ type Order = InferDoc<typeof orderSchema>;
119
+
120
+ // 2. Attach a name (plus optional indexes and hooks), then derive the map.
121
+ const users = defineCollection('users', userSchema, {
122
+ indexes: [{ key: { email: 1 }, options: { unique: true } }],
123
+ hooks: { beforeCreate: (ctx) => console.log('creating', ctx.doc?.email) },
124
+ });
125
+
126
+ const { service } = createMongoToolkit({
127
+ primary: {
128
+ name: 'app',
129
+ dbUrl: process.env.MONGO_URL,
130
+ collectionPrefix: process.env.DB_PREFIX, // physical name: `app-users`
131
+ collections: defineCollections(users, orderSchema),
132
+ },
133
+ });
134
+ // Query cache + in-flight dedup are ON by default — nothing to enable.
135
+
136
+ // 3. Connect, install the validator + indexes, and start reading & writing.
137
+ await service.makeConnections();
138
+ const db = service.db.primaryClient; // fully typed manager
139
+
140
+ await db.createSchema('users');
141
+
142
+ const { insertedId } = await db.insertOne('users', {
143
+ email: 'ada@example.com', role: 'admin', createdAt: new Date(),
144
+ });
145
+
146
+ // Fluent query builder — projections are pushed down to the driver.
147
+ const admins = await db.query('users')
148
+ .where({ role: 'admin' })
149
+ .select(['_id', 'email'])
150
+ .limit(10)
151
+ .many();
152
+
153
+ // $facet pagination — data + count in one round trip.
154
+ const page = await db.paginateFlexible(
155
+ 'orders',
156
+ { userId: insertedId },
157
+ { page: 1, limit: 20, sort: { total: -1 } },
158
+ );
159
+
160
+ // DataLoader-batched population — no N+1.
161
+ const orders = await db.findMany('orders', { userId: insertedId });
162
+ await db.populate(orders, [
163
+ belongsTo({ collection: 'users', localField: 'userId', as: 'customer' }),
164
+ ]);
165
+
166
+ // Optimistic locking.
167
+ const result = await db.updateWithVersion('users', { _id: insertedId }, { $set: { role: 'user' } });
168
+
169
+ // Transactions — gracefully fall back on standalone servers.
170
+ await db.transaction(async (session) => {
171
+ await db.insertOne('orders', { userId: insertedId, total: 5 }, { session: session ?? undefined });
172
+ });
173
+
174
+ await service.closeConnections();
175
+ ```
176
+
177
+ ## Key features
178
+
179
+ A quick tour — each entry links to the full guide further down.
180
+
181
+ | | Feature | What it does for you |
182
+ | --- | --- | --- |
183
+ | 🧠 | [Schema DSL](#schema-dsl-reference) | One schema → types, typed queries, and DB validation |
184
+ | 🔎 | [Query builder + CRUD](#query-builder--full-crud) | Fluent, typed queries; the full insert/update/delete/upsert family |
185
+ | ⚡ | [Cache + dedup](#performance-by-default) | 0 round trips on repeat reads; N identical reads → 1 DB call |
186
+ | 🔗 | [Relations + DataLoader](#relations--populate) | `belongsTo` / `hasMany` / `manyToMany` without N+1 |
187
+ | 📄 | [Pagination](#pagination-offset--keyset) | `$facet` (data + count, 1 round trip) + keyset cursors |
188
+ | 📊 | [Typed aggregation](#typed-aggregation-pipeline) | Chainable `pipeline()` with inferred result types |
189
+ | 🔍 | [Search & joins](#search--joins) | `$text` / regex search + single-hop `lookupJoin` |
190
+ | 🗺️ | [Geospatial](#geospatial) | `s.geoPoint()` + typed `$geoNear` with distances |
191
+ | 📦 | [Repository layer](#repository-layer) | Optional domain-typed wrapper (`getById`, `create`, `page`, …) |
192
+ | ⏱️ | [Auto timestamps](#auto-timestamps) | `createdAt` / `updatedAt` for free, custom names supported |
193
+ | 🔁 | [Transactions & migrations](#transactions--migrations) | Graceful sessions + file-based runner with a journal |
194
+ | 🔥 | [Hot cache](#hot-cache-high-throughput) | Change-stream / ticker-driven in-memory query cache |
195
+ | 🔄 | [Background refresh](#background-refresh-system) | Self-updating standalone cache — reads never block |
196
+ | 🧹 | [Data invalidation](#data-invalidation-react-query-style) | React Query-style — invalidate by name, params, or collection |
197
+ | 🛡️ | [Error handling](#error-handling) | `BadRequest` / `DomainError` / `InfraError` + HTTP mapping |
198
+ | ✅ | [Validation & drift](#validation) | DB `$jsonSchema` (strict) + opt-in drift detection |
199
+ | 🪝 | [Mongo hooks](#hooks) | `before/after` create/update/delete + `afterRead` |
200
+ | 🩺 | [Health & multi-DB](#operations-health--multi-db) | Per-DB pings, multi-client services, change-stream watch |
201
+
202
+ ## Typed aggregation pipeline
203
+
204
+ `db.pipeline(collection)` is a chainable, fully type-safe aggregation builder.
205
+ Each stage is typed against the **current** document shape — field autocomplete
206
+ and the inferred result type follow the schema through `$match` / `$project` /
207
+ `$addFields` / `$group` / `$sort` / `$unwind` / `$lookup` / `$facet` and beyond.
208
+
209
+ ```ts
210
+ // The result type is inferred through the chain.
211
+ const byStatus = await db
212
+ .pipeline('orders')
213
+ .match({ status: 'paid' }) // Order fields autocomplete
214
+ .group({ _id: '$status', revenue: { $sum: '$total' } }) // → { _id: Order['status']; revenue: number }
215
+ .sort({ revenue: -1 })
216
+ .limit(10)
217
+ .toArray();
218
+
219
+ // $lookup sub-pipelines are scoped to the related collection's schema:
220
+ // inside the callback, `o` autocompletes Order fields and `orders` is typed as
221
+ // the sub-pipeline output (Array<{ _id; total }> here, since `status` was dropped).
222
+ const withOrders = await db
223
+ .pipeline('users')
224
+ .lookup({
225
+ from: 'orders', localField: '_id', foreignField: 'userId', as: 'orders',
226
+ pipeline: (o) => o.match({ total: { $gte: 50 } }).project({ total: 1 }),
227
+ })
228
+ .toArray();
229
+
230
+ // $facet branches each get their own typed sub-builder:
231
+ // → { byStatus: Array<{ _id; count: number }>; top3: Order[] }
232
+ const faceted = await db
233
+ .pipeline('orders')
234
+ .facet({
235
+ byStatus: (s) => s.group({ _id: '$status', count: { $sum: 1 } }),
236
+ top3: (s) => s.sort({ total: -1 }).limit(3),
237
+ })
238
+ .toArray();
239
+ ```
240
+
241
+ Terminals: `.toArray()`, `.first()`, `.cursor()`. The callback
242
+ `db.aggregate('orders', (stages) => [...])` is collection-typed too (its
243
+ `match` / `project` / `sort` / `group` / `lookup` / `facet` are checked against
244
+ the schema); reach for `db.pipeline()` when you also want inferred result types.
245
+ See [`examples/10-typed-pipeline.ts`](./examples/10-typed-pipeline.ts).
246
+
247
+ ## Query builder & full CRUD
248
+
249
+ Fluent queries: `.where().or().sort().skip().limit().select().hint()` →
250
+ `.one() / .many() / .cursor() / .count() / .exists()`. Projections are pushed
251
+ down to the driver, so you only transfer the fields you asked for.
252
+
253
+ The full CRUD family — all typed, all cache-aware:
254
+
255
+ `getOne` / `getOneOrFail` / `findMany` / `cursor` / `findActive*` / `insertOne` /
256
+ `insertMany` / `updateOne` / `updateMany` / `findOneAndUpdate` /
257
+ `findOneAndReplace` / `replaceOne` / `findOneAndDelete` / `delete*` /
258
+ `softDeleteOne` / `upsert` / `bulkUpsert` / `bulkWrite` / `distinct` /
259
+ `countDocuments` / `estimatedDocumentCount` / `watchCollection` /
260
+ `updateWithVersion` (optimistic `__v` locking).
261
+
262
+ ## Schema DSL reference
263
+
264
+ One `s.*` schema drives three things at once: the TypeScript document type
265
+ (`InferDoc`), the field typings for filters / updates / pipelines / the query
266
+ builder, and the MongoDB `$jsonSchema` validator + indexes installed by
267
+ `createSchema`. Fields are chainable (`.optional()`, `.default(v)`);
268
+ `optional` / `withDefault` are also exported standalone.
269
+
270
+ | field kind | `s.*` | inferred TS type |
271
+ | --- | --- | --- |
272
+ | string / int / number | `s.string()` / `s.integer()` / `s.number()` | `string` / `number` / `number` |
273
+ | exact BSON kinds | `s.double()` / `s.long()` / `s.decimal()` | `number` / `number` / `Decimal128` |
274
+ | other scalars | `s.boolean()` / `s.date()` / `s.objectId()` | `boolean` / `Date` / `ObjectId` |
275
+ | enum | `s.enum(['a', 'b'] as const)` | `'a' \| 'b'` (string or numeric literals) |
276
+ | array | `s.array(s.string())` | `string[]` |
277
+ | object | `s.object({ ... })` | nested object (optional/default keys are optional) |
278
+ | geo | `s.geoPoint()` | `GeoPoint` = `{ type: 'Point'; coordinates: [number, number] }` |
279
+ | nullable / any | `s.null()` / `s.any()` | `null` / `unknown` |
280
+ | raw escape hatch | `s.jsonSchema({ ... })` | `unknown` (fragment passed through verbatim) |
281
+
282
+ Constraints mirror `$jsonSchema`: strings (`minLength` / `maxLength` / `pattern`),
283
+ numbers (`minimum` / `maximum` / `multipleOf` / `exclusiveMinimum` /
284
+ `exclusiveMaximum`), arrays (`minItems` / `maxItems` / `uniqueItems`), objects
285
+ (`minProperties` / `maxProperties` / `additionalProperties`). `format` is
286
+ accepted but a no-op (MongoDB rejects the `$jsonSchema` keyword).
287
+
288
+ ```ts
289
+ const userSchema = s.object({
290
+ _id: s.objectId(),
291
+ email: s.string({ minLength: 3, maxLength: 254, pattern: '^[^@]+@[^@]+$' }),
292
+ age: s.integer({ minimum: 0 }).optional(),
293
+ role: s.enum(['admin', 'user'] as const).default('user'),
294
+ tags: s.array(s.string(), { maxItems: 10 }),
295
+ meta: s.object({ lastLogin: s.date().optional() }, { additionalProperties: true }),
296
+ score: s.decimal().optional(), // → Decimal128
297
+ home: s.geoPoint().optional(), // → GeoPoint
298
+ }, { name: 'users' });
299
+ type User = InferDoc<typeof userSchema>;
300
+ ```
301
+
302
+ > 💡 **Good to know:** ninox reserves `_id`, the optimistic-lock `__v`, and the
303
+ > soft-delete `deletedAt` in the validator automatically — strict validation
304
+ > never rejects them even when your schema omits them.
305
+
306
+ ## Pagination (offset + keyset)
307
+
308
+ Two styles, both cheap on the server:
309
+
310
+ - `paginateFlexible` — offset `$facet` pagination: **data and total in one round
311
+ trip** (`{ data, page, limit, totalCount, totalPages }`).
312
+ - `paginateCursor` — keyset/cursor pagination: O(log n) per page (no deep
313
+ `$skip`), stable under concurrent inserts, returns an opaque `nextCursor`.
314
+
315
+ ```ts
316
+ // $facet — data + count in one round trip:
317
+ const page = await db.paginateFlexible(
318
+ 'orders',
319
+ { status: 'paid' },
320
+ { page: 1, limit: 20, sort: { total: -1 } },
321
+ );
322
+
323
+ // Keyset — walk pages with an opaque cursor (sort needs a unique tiebreaker):
324
+ const sort = { createdAt: -1, _id: 1 };
325
+ let cursor: string | undefined;
326
+ do {
327
+ const p = await db.paginateCursor('orders', { userId }, { sort, limit: 5, after: cursor });
328
+ console.log(p.data, p.hasMore ? '(more)' : '(last)');
329
+ cursor = p.nextCursor ?? undefined;
330
+ } while (cursor);
331
+ ```
332
+
333
+ `paginateCursor` supports `after` (forward) and `before` (backward); a sort whose
334
+ keys don't match the cursor's is rejected at runtime.
335
+
336
+ ## Geospatial
337
+
338
+ `s.geoPoint()` declares a GeoJSON `Point`, and the `$geoNear` pipeline stage
339
+ (which MUST be the first stage) returns results sorted by distance with a
340
+ computed distance field. Requires a `2dsphere` index on the field.
341
+
342
+ ```ts
343
+ const places = defineCollection('places', placeSchema, {
344
+ indexes: [{ key: { location: '2dsphere' } }],
345
+ });
346
+
347
+ const nearest = await db
348
+ .pipeline('places')
349
+ .geoNear({
350
+ near: { type: 'Point', coordinates: [-74.006, 40.7128] }, // GeoJSON Point, NOT [lng, lat]
351
+ distanceField: 'dist',
352
+ spherical: true,
353
+ })
354
+ .limit(3)
355
+ .toArray(); // each doc gains `dist` (meters with spherical: true)
356
+ ```
357
+
358
+ ## Search & joins
359
+
360
+ - `textSearch` — `$text` mode (requires a text index on the searched fields) or
361
+ `$regex` mode (`useRegex: true`, no index needed) across one or more fields,
362
+ with `$facet` pagination, `sortByScore`, `$language`, and `$diacriticSensitive`.
363
+ - `lookupJoin` — a single-hop `$lookup` with optional `$unwind`, typed per
364
+ collection.
365
+
366
+ ```ts
367
+ // $text search (needs a text index on the searched fields):
368
+ await db.client.collection('products').createIndex({ name: 'text' });
369
+ const hits = await db.textSearch(
370
+ 'products',
371
+ {},
372
+ { searchFields: ['name'], searchTerm: 'gadget', sortByScore: true },
373
+ { page: 1, limit: 10 },
374
+ );
375
+
376
+ // Single-hop join, unwound into `customer`:
377
+ const joined = await db.lookupJoin<'orders', Order & { customer?: User }>('orders', {}, [{
378
+ fromCollection: 'users',
379
+ localField: 'userId',
380
+ foreignField: '_id',
381
+ as: 'customer',
382
+ unwindSingle: true,
383
+ }]);
384
+ ```
385
+
386
+ ## Relations + populate
387
+
388
+ `populate()` resolves schema-validated relations with **batched `$in` queries**
389
+ (no N+1). `belongsTo`, `hasMany`, and `manyToMany` are type-checked at the call
390
+ site — collection names, local/foreign fields, and the join collection are all
391
+ validated against the schema registry.
392
+
393
+ ```ts
394
+ import { belongsTo, hasMany, manyToMany } from '@ignex/ninox';
395
+
396
+ // belongsTo → `customer` is `User | null`
397
+ const orders = await db.findMany('orders', {});
398
+ await db.populate(orders, [
399
+ belongsTo({ collection: 'users', localField: 'userId', as: 'customer' }),
400
+ hasMany({ collection: 'orders', localField: '_id', as: 'more' }),
401
+ ]);
402
+
403
+ // manyToMany through the join collection → `tags` is `Tag[]`
404
+ await db.populate(products, [
405
+ manyToMany({
406
+ collection: 'tags',
407
+ localField: '_id',
408
+ as: 'tags',
409
+ through: { collection: 'product_tags', localField: 'productId', foreignField: 'tagId' },
410
+ }),
411
+ ]);
412
+ // orders[0].customer, orders[0].more, products[0].tags are all typed.
413
+ ```
414
+
415
+ ## Repository layer
416
+
417
+ `createRepository(manager, 'collection')` wraps a manager + collection into a
418
+ collection-free, domain-typed interface. No change tracking — load → mutate →
419
+ persist is explicit, matching the core op semantics.
420
+
421
+ ```ts
422
+ import { createRepository } from '@ignex/ninox';
423
+ const users = createRepository(db, 'users');
424
+
425
+ const { insertedId } = await users.create({ email, role: 'admin', createdAt: new Date() });
426
+ const u = await users.getById(insertedId);
427
+ await users.update(insertedId, { $set: { role: 'user' } });
428
+ const page = await users.page({ role: 'admin' }, { page: 1, limit: 20 });
429
+ ```
430
+
431
+ ## Auto timestamps
432
+
433
+ Set `timestamps` on a collection definition and ninox stamps `createdAt` /
434
+ `updatedAt` on insert and `updatedAt` on every update (including upserts and
435
+ replacements).
436
+
437
+ ```ts
438
+ const users = defineCollection('users', userSchema, {
439
+ // true → createdAt + updatedAt; or { createdAt: 'c_at', updatedAt: 'u_at' }
440
+ timestamps: true,
441
+ });
442
+ ```
443
+
444
+ ## Transactions & migrations
445
+
446
+ - `db.transaction(async (session) => ...)` — graceful session transactions with a
447
+ capability probe; on standalone servers (no transactions) it falls back to
448
+ plain non-transactional execution with `session === null`.
449
+ - `createMongoMigrationRunner(service, { migrationDir })` — file-based migrations
450
+ (`NNN_name.ts`, each exporting `up` / `down`) with a claim-based idempotency
451
+ journal (`_migrations`), safe under concurrent runners.
452
+
453
+ ```ts
454
+ const runner = createMongoMigrationRunner(service, { migrationDir: './migrations' });
455
+ await runner.up(); // apply pending migrations in numeric order
456
+ await runner.down('001_seed'); // roll back to a target migration
457
+ await runner.scaffold('add_tags'); // create a new NNN_add_tags.ts template
458
+ ```
459
+
460
+ ## Performance by default
461
+
462
+ The **default path is the fast path.** A `QueryCache` and in-flight dedup are
463
+ created and enabled automatically — repeated identical reads become cache hits
464
+ (0 driver calls) and identical concurrent reads coalesce into one driver call.
465
+ Opt out when you need to:
466
+
467
+ ```ts
468
+ // Service-wide: disable caching, dedup, or everything at once.
469
+ createMongoService(clients, { cache: null }); // no read cache
470
+ createMongoService(clients, { dedupeReads: false }); // no dedup
471
+ createMongoService(clients, { perf: false }); // both off
472
+ createMongoService(clients, { cache: { maxSize: 1000 } }); // configure the default cache
473
+
474
+ // Per-op / per-query:
475
+ db.findMany('users', {}, { cache: false, dedupe: false });
476
+ db.query('users').where({ role: 'admin' }).cache(false).dedupe(false).many();
477
+
478
+ // Field projection on flat find queries — only the listed fields are transferred.
479
+ db.findMany('users', {}, { select: ['_id', 'email'] });
480
+ db.getOne('users', { email: 'ada@example.com' }, { select: ['email'] });
481
+ ```
482
+
483
+ Pass a `QueryCache` instance you own to read cache health — `cache.stats()`
484
+ reports current size plus lifetime `hits` / `misses` / `sets` / `deletes` /
485
+ `invalidateEvents` / `clearEvents` / `evictions` (hit rate is the key signal).
486
+
487
+ > ⚠️ **Cache invalidation is write-through only.** Every ninox write drops that
488
+ > collection's cached reads, but reads are **not** invalidated by external
489
+ > writers — other processes, the raw `client` escape hatch, or direct DB
490
+ > writes. With the default `ttlMs: 0` those reads stay stale indefinitely, so
491
+ > multi-writer deployments should set `cache: { ttlMs }`, disable the cache,
492
+ > route latency-critical reads through the change-stream-aware
493
+ > [`createHotCache()`](#hot-cache-high-throughput), or — on a replica set — opt
494
+ > into `cacheWatch: true`:
495
+ >
496
+ > ```ts
497
+ > createMongoService(clients, { cacheWatch: true }); // change-stream invalidation
498
+ > ```
499
+ >
500
+ > `cacheWatch` opens one `$changeStream` watcher per registered collection so
501
+ > even external writes invalidate the shared cache. It requires a replica set /
502
+ > mongos; on standalone servers the streams are rejected, a warning is logged
503
+ > once, and invalidation silently disables (the cache stays write-through only).
504
+
505
+ ## Hot cache (high throughput)
506
+
507
+ For workloads that **can't afford DB latency per request**, register the hot
508
+ queries with one global `createHotCache()` instance. Reads are served from a
509
+ per-query LRU (call parameters become the cache key), and the cache stays fresh
510
+ based on the deployment:
511
+
512
+ - **Replica set / mongos** → change-stream watchers on the bound collections
513
+ invalidate the cache the moment a write lands — even from other processes.
514
+ - **Standalone** (no change streams) → a global ticker background-refreshes
515
+ entries at `refreshIntervalMs` (per-query, default `0` = off); stale values
516
+ keep being served until the fresh value swaps in (reads never block). Queries
517
+ with neither `refreshIntervalMs` nor `ttlMs` set serve values until manually
518
+ invalidated — `start()` logs a warning naming them (unbounded staleness).
519
+
520
+ ```ts
521
+ import { createHotCache, probeMongoCapabilities } from '@ignex/ninox';
522
+
523
+ const hot = createHotCache({
524
+ // Auto-detect: replicas → change streams, standalone → ticker.
525
+ probe: async () => (await probeMongoCapabilities(db.client)).transactionsSupported,
526
+ });
527
+
528
+ const topProducts = hot.register('topProducts', {
529
+ loader: async (limit: number) => db.findMany('products', {}, { limit }),
530
+ watch: [{ db: db.client, collection: 'products' }], // physical collection name
531
+ });
532
+ const orderStats = hot.register('orderStats', {
533
+ refreshIntervalMs: 500, // standalone: the ticker keeps this fresh
534
+ loader: async () => db.countDocuments('orders', {}),
535
+ });
536
+
537
+ const mode = await hot.start(); // 'replica' | 'standalone'
538
+ const products = await topProducts.get(3); // cold → loader, warm → LRU
539
+
540
+ // Manual invalidation — full control over staleness:
541
+ topProducts.invalidate(); // drop all entries
542
+ topProducts.invalidate(3); // drop just the { limit: 3 } entry
543
+ hot.invalidateParams('topProducts', 3); // same, via dynamic name lookup
544
+ hot.invalidateCollection('products'); // drop every query watching that collection
545
+ await hot.stop(); // close streams + ticker
546
+ ```
547
+
548
+ `register()` returns a **fully typed accessor** — `topProducts.get(limit)` and
549
+ `topProducts.invalidate(limit)` have complete intellisense on the loader
550
+ parameters. Loader errors are never cached (they retry on the next read), and
551
+ concurrent identical reads are in-flight-deduped. `has(name)` checks whether a
552
+ query is registered.
553
+
554
+ Set `autoRefresh: false` to **turn off the global auto-refetch** entirely — the
555
+ standalone ticker never runs, so data only updates via manual invalidation or
556
+ `ttlMs` expiry. This keeps the DB from being polled when you'd rather drive
557
+ freshness explicitly (replica-mode change streams are event-driven and
558
+ unaffected):
559
+
560
+ ```ts
561
+ const hot = createHotCache({ probe, autoRefresh: false, defaultTtlMs: 5_000 });
562
+ ```
563
+
564
+ Pin the mode to skip the probe entirely when you already know the deployment
565
+ (`mode: 'replica'` / `mode: 'standalone'`), and use `hot.stats()` for
566
+ observability — it reports per-query `hits` / `misses` / `refreshes` /
567
+ `loadErrors` / `sizeSkips` plus current sizes. Cap runaway single entries with a
568
+ per-query `maxValueBytes` (larger values are still returned, just not cached).
569
+
570
+ Cached results are shared **by reference** (zero clone overhead); if a caller might
571
+ mutate a result, enable per-query `clone: true` (or `cache: { clone: true }`) so
572
+ each read returns a fresh copy and can't poison the cache.
573
+
574
+ ## Background refresh system
575
+
576
+ The standalone-mode workhorse behind the HotCache: a single global background
577
+ ticker that keeps your registered queries fresh without a request ever blocking
578
+ on the DB.
579
+
580
+ **How it works**
581
+
582
+ - Every `tickIntervalMs` (default `1000` ms) the ticker scans the registered
583
+ queries and re-runs the loaders for any entry whose `refreshIntervalMs` is due.
584
+ - Fresh values **swap in behind the scenes** — a stale value keeps being served
585
+ until the replacement arrives, so reads never wait on a refresh.
586
+ - If a background refresh fails (transient error, DB hiccup), the stale value is
587
+ retained, a warning is logged, and the entry retries on the next pass — you
588
+ never serve an error where a cached value would do.
589
+ - Only queries that opt in with `refreshIntervalMs` are refreshed. A query with
590
+ neither `refreshIntervalMs` nor `ttlMs` is served until manually invalidated —
591
+ `start()` logs a warning naming those queries so you can opt in deliberately.
592
+
593
+ **Configuration**
594
+
595
+ ```ts
596
+ const hot = createHotCache({
597
+ probe, // auto-detect replica vs standalone
598
+ tickIntervalMs: 1000, // how often the background ticker runs
599
+ autoRefresh: true, // master switch for the ticker (default true)
600
+ });
601
+
602
+ // Per-query: refresh this entry every 500ms in the background.
603
+ hot.register('orderStats', {
604
+ refreshIntervalMs: 500,
605
+ loader: async () => db.countDocuments('orders', {}),
606
+ });
607
+
608
+ // Prefer full control? Turn the ticker off and drive freshness yourself:
609
+ const manual = createHotCache({ probe, autoRefresh: false, defaultTtlMs: 5_000 });
610
+ ```
611
+
612
+ On a replica set the same freshness is delivered event-driven by change streams
613
+ instead — see [Hot cache](#hot-cache-high-throughput).
614
+
615
+ ## Data invalidation (React Query style)
616
+
617
+ If you've used TanStack Query, ninox's cache invalidation will feel familiar —
618
+ same ideas of query keys, invalidation by key, and automatic refetch. Here's the
619
+ mapping:
620
+
621
+ | React Query | ninox |
622
+ | --- | --- |
623
+ | `queryKey` | the cache key — HotCache: **query name + call parameters**; query cache: **collection + filter hash** |
624
+ | `queryClient.invalidateQueries(['products'])` | `hot.invalidateCollection('products')` or `hot.invalidate('topProducts')` |
625
+ | invalidating one exact entry | `hot.invalidateParams('topProducts', 3)` / `topProducts.invalidate(3)` |
626
+ | `refetchInterval` (background refetch) | the standalone [background ticker](#background-refresh-system) (`refreshIntervalMs`) |
627
+ | `staleTime` / `gcTime` | `ttlMs` (per-query, or `defaultTtlMs` on the HotCache) |
628
+ | refetch after a mutation | **automatic** — every ninox write invalidates that collection's cache, no wiring needed |
629
+
630
+ **What keeps the cache correct, by default:**
631
+
632
+ 1. **Write-through invalidation.** Every ORM insert/update/delete drops that
633
+ collection's cached reads immediately — like React Query invalidating on a
634
+ mutation, but you never have to call it.
635
+ 2. **External-writer protection.** Writes from other processes, the raw `client`
636
+ escape hatch, or direct DB access can't invalidate on their own. Three ways to
637
+ stay correct in multi-writer deployments:
638
+ - set `cache: { ttlMs }` so stale entries expire,
639
+ - turn on `cacheWatch: true` (replica sets) so change streams invalidate the
640
+ shared cache on external writes too, or
641
+ - route hot reads through `createHotCache()`.
642
+
643
+ **The invalidation API at a glance:**
644
+
645
+ ```ts
646
+ // Query cache — invalidated automatically on every ORM write.
647
+ db.insertOne('orders', order); // drops all cached 'orders' reads
648
+
649
+ // HotCache — manual invalidation, React Query style:
650
+ topProducts.invalidate(); // drop every entry for this query
651
+ topProducts.invalidate(3); // drop just the { limit: 3 } entry
652
+ hot.invalidateParams('topProducts', 3); // same, via dynamic name lookup
653
+ hot.invalidateCollection('products'); // drop every query bound to 'products'
654
+
655
+ // Or let the cache invalidate itself:
656
+ hot.register('topProducts', {
657
+ loader: async (limit: number) => db.findMany('products', {}, { limit }),
658
+ watch: [{ db: db.client, collection: 'products' }], // change-stream invalidation (replica)
659
+ refreshIntervalMs: 500, // background refetch (standalone)
660
+ });
661
+ ```
662
+
663
+ One difference worth knowing: there's no component tree here. Invalidation is
664
+ collection-scoped and happens at the data layer, so *any* client (API, worker,
665
+ CLI) shares the same cache instead of one per component.
666
+
667
+ ## Error handling
668
+
669
+ ninox throws a small error taxonomy (`ninox` exports all of them):
670
+
671
+ | Error | Meaning | HTTP |
672
+ | --- | --- | --- |
673
+ | `BadRequest` | Malformed caller input (`BAD_REQUEST`) | 400 |
674
+ | `DomainError` | Expected business violation — `NOT_FOUND`, `DUPLICATE_KEY`, `VERSION_CONFLICT`, `VALIDATION_FAILED`, `SCHEMA_DRIFT`, `COLLECTION_EXISTS`, … | 404 / 409 / 422 (code-dependent, else 400) |
675
+ | `InfraError` | Infrastructure failure — `MONGO_TIMEOUT`, `MONGO_QUERY_ERROR`, … | 504 / 500 |
676
+
677
+ `mapMongoDriverError` converts raw driver errors (11000 dup, 112 conflict, 50
678
+ Timeout, 121 validation, bulk-write) into the taxonomy with `db` / `collection` /
679
+ `op` context. This is **on by default** — set `wrapMongoErrors: false` to surface
680
+ raw driver errors instead. `statusCode` is refined per code: `NOT_FOUND` → 404,
681
+ `DUPLICATE_KEY` / `VERSION_CONFLICT` / `COLLECTION_EXISTS` / `SCHEMA_DRIFT` → 409,
682
+ `VALIDATION_FAILED` → 422, `MONGO_TIMEOUT` → 504.
683
+
684
+ `isDomainError(err)` / `isInfraError(err)` / `isMappedMongoError(err)` are
685
+ exported type guards for HTTP layers, and every `AppError` exposes a stable
686
+ `toJSON()`. To forward an error to a client without any parsing, use the
687
+ `serializeError(err, ctx?)` one-liner (it maps raw driver errors first):
688
+
689
+ ```ts
690
+ import { serializeError } from '@ignex/ninox';
691
+
692
+ app.use((err, _req, res, _next) => {
693
+ const payload = serializeError(err, { db: 'app', collection: 'users' });
694
+ res.status(payload.statusCode).json(payload); // { name, code, message, statusCode, extra? }
695
+ });
696
+ ```
697
+
698
+ The `ERROR_HTTP_STATUS` table and `httpStatusForError(err)` are also exported if
699
+ you need the code → status mapping yourself.
700
+
701
+ > 💡 **Write-retry safety:** writes are **not** auto-retried by default
702
+ > (at-least-once); opt in per op with `retryWrites: true`. Reads always retry
703
+ > transient errors.
704
+
705
+ Schema validation is enforced **by MongoDB** — the DB `$jsonSchema` validator
706
+ derived from your schema is installed with `createSchema` (and hot-swapped with
707
+ `updateSchema`), and the DB rejects violating writes with `VALIDATION_FAILED`.
708
+ ninox deliberately does **not** validate *incoming user input* at runtime (no
709
+ client-side/zod layer).
710
+
711
+ - **Strict by default** — `additionalProperties: false` is emitted unless a
712
+ field opts out (`s.object({ ... }, { additionalProperties: true })`), so
713
+ unknown keys are rejected server-side and the DB validator is the complete
714
+ enforcement point.
715
+ - **ORM-reserved fields** — `_id`, the optimistic-lock `__v`, and the soft-delete
716
+ `deletedAt` (active = absent or `null`) are reserved in the validator, so
717
+ `updateWithVersion` / `softDeleteOne` keep working under strict validation
718
+ without declaring those fields.
719
+ - **Compile-time safety** — `InferDoc` types catch shape errors at build time;
720
+ the DB validator is the runtime backstop.
721
+ - **Rich `$jsonSchema` keywords** — the DSL emits `minLength` / `maxLength` /
722
+ `pattern` (regex), `minimum` / `maximum` / `multipleOf` / `exclusiveMinimum` /
723
+ `exclusiveMaximum`, `minItems` / `maxItems` / `uniqueItems`, `minProperties` /
724
+ `maxProperties`, strict `additionalProperties`, and distinct BSON kinds for
725
+ `s.double()` / `s.long()` / `s.decimal()` (Decimal128). For anything the DSL
726
+ doesn't model (e.g. `patternProperties`, `allOf` / `anyOf` / `oneOf` / `not`,
727
+ `dependencies`), embed a raw fragment with `s.jsonSchema({ ... })` — it is
728
+ passed through verbatim.
729
+ - **Validation error detail** — when `wrapMongoErrors` is on, a `VALIDATION_FAILED`
730
+ `DomainError` carries the offending field paths (`extra.fields`), the failing
731
+ document id (`extra.documentId`), and the raw `extra.details`, so a rejected
732
+ write names exactly what (and which document) violated the schema.
733
+ - **Index lifecycle** — `createSchema` installs the declared indexes;
734
+ `syncIndexes(collection)` reconciles drift (`await db.syncIndexes('products')`
735
+ creates missing declared keys and drops undeclared ones, `_id_` kept).
736
+
737
+ ### Schema drift (read-time detection)
738
+
739
+ A document can end up *in* MongoDB without matching its schema — written by an
740
+ older app version, another service, the raw driver with validation bypassed, or
741
+ left behind by a tightened `updateSchema`. Reads return these docs verbatim
742
+ (they were stored before the validator), so drift is invisible unless you look.
743
+
744
+ ninox can detect drift on read. On every **DB fetch** (cache-miss — cache hits
745
+ trust the already-validated stored value), freshly-read documents are checked
746
+ against the declared schema and reported per `MongoServiceConfig.drift`:
747
+
748
+ | mode | behavior |
749
+ | --- | --- |
750
+ | `'report'` (default) | log a warning naming the offending field paths, return the doc as-is |
751
+ | `'throw'` | throw `DomainError SCHEMA_DRIFT` (with `extra.issues`); the drifted doc is **never cached or returned** |
752
+ | `'off'` | no drift checking (pre-1.x behavior) |
753
+
754
+ ```ts
755
+ createMongoService(clients, { drift: 'throw' }); // fail closed on drift
756
+ ```
757
+
758
+ Detection is **read-only** — documents are never mutated or repaired. It covers
759
+ `getOne` / `getOneOrFail` / `findMany` / `findActive*`, the query builder, and
760
+ `paginateFlexible` / `paginateCursor`. Projected (`select`) reads are skipped
761
+ (partial documents would false-positive on excluded required fields). Override
762
+ per op with `QueryOptions.drift` (`true` → `'throw'`, `false` → `'off'`):
763
+
764
+ ```ts
765
+ await users.getOne('users', { email }, { drift: true }); // throw on drift for this read
766
+ ```
767
+
768
+ This pairs with the DB validator: the server rejects violating *writes*, and the
769
+ drift layer surfaces violating *stored* documents so they can't silently corrupt
770
+ reads.
771
+
772
+ > ⚠️ **Drift checking costs CPU per fetch.** `'report'` (the default) walks the
773
+ > full document tree against the schema on every DB fetch (cache-misses only —
774
+ > cache hits skip it). For very high-QPS read paths that don't need it, set
775
+ > `drift: 'off'` service-wide or per op (`{ drift: false }`).
776
+
777
+ ## Hooks
778
+
779
+ Per-collection lifecycle middleware, declared on `defineCollection(..., { hooks })`:
780
+
781
+ ```ts
782
+ const users = defineCollection('users', userSchema, {
783
+ hooks: {
784
+ beforeCreate: (ctx) => audit(`create ${ctx.doc?.email}`),
785
+ afterCreate: () => invalidateCount(),
786
+ afterRead: (ctx) => maskSensitive(ctx.doc),
787
+ },
788
+ });
789
+ ```
790
+
791
+ Hook names: `before/afterCreate`, `before/afterUpdate`, `before/afterDelete`,
792
+ `afterRead`. `runHooks` and `HOOK_NAMES` are exported for manual dispatch.
793
+ `afterRead` fires on **every** read — cache hit or miss — and receives a fresh
794
+ per-caller clone, so a mutating hook can't poison the shared cache entry.
795
+ `before/after` create/update/delete hooks fire on actual ninox writes (which
796
+ invalidate that collection's cache).
797
+
798
+ ## Logging & configuration
799
+
800
+ `MongoServiceConfig` beyond the defaults:
801
+
802
+ ```ts
803
+ createMongoService(clients, {
804
+ appName: 'orders-api', // Mongo client appName
805
+ logger, // pino-like: debug/info/warn/error
806
+ wrapMongoErrors: true, // map driver errors to the taxonomy
807
+ migrationDir: './migrations', // default for createMongoMigrationRunner
808
+ cache: { maxSize: 1000, ttlMs: 30_000, clone: true }, // configure the default cache
809
+ cacheWatch: true, // change-stream invalidation of the cache (replica sets)
810
+ drift: 'report', // read-time schema-drift detection ('report'|'throw'|'off')
811
+ dedupeReads: true,
812
+ perf: true, // master switch for cache + dedup
813
+ });
814
+ ```
815
+
816
+ ## Operations: health & multi-DB
817
+
818
+ - `service.health()` — ping every connected DB and report `{ ok, latencyMs, dbs }`.
819
+ - **Multi-DB services** — define several clients in one service; each gets its own
820
+ typed manager (`service.db.primaryClient`, `service.db.auditClient`, …), one
821
+ connection pool per URL, and **cache/dedup keys are namespaced by database** so
822
+ same-named collections in different databases never collide.
823
+ - `watchCollection` / repository `.watch()` — caller-owned change streams.
824
+ - `eachDb(fn)` — run a function over every connected manager.
825
+
826
+ ```ts
827
+ const service = createMongoService({
828
+ primary: { name: 'app', dbUrl: MONGO_URL, collections },
829
+ audit: { name: 'audit', dbUrl: MONGO_URL, collections: auditCollections },
830
+ }, { logger });
831
+ await service.makeConnections();
832
+
833
+ const report = await service.health(); // per-DB ping results
834
+ await service.db.auditClient.insertOne('events', { type: 'login', at: new Date() });
835
+
836
+ const stream = service.db.primaryClient.watchCollection('users'); // caller-owned change stream
837
+ for await (const change of stream) { /* react to changes */ }
838
+ ```
839
+
840
+ ## Utilities
841
+
842
+ Re-exported helpers for app code (also via `@ignex/ninox/utils`):
843
+
844
+ - `withRetry(fn, { maxAttempts, delayMs })` — transient-error retry with backoff.
845
+ - `withTransaction(client, fn(session))` — manual session transaction helper.
846
+ - `withTimeout(promise, ms, label)` / `sleep(ms)` — async timeouts.
847
+ - `LRU` / `createCachedFactory` / `createCachedAsyncFactory` — caches (the async
848
+ factory dedupes in-flight calls and **never caches failures**).
849
+ - `stableHash` / `stableStringify` — deterministic, order-insensitive hashing.
850
+ - `cloneDeep` — deep clone preserving `Date` / `ObjectId` / `RegExp`.
851
+ - `createConsoleLogger` / `createNoopLogger` — structured loggers.
852
+
853
+ ## Schema introspection
854
+
855
+ `toMongoValidator(schema)` returns the `{ $jsonSchema }` validator ninox installs
856
+ via `createSchema`; `toMongoSchema(schema)` returns the bare `$jsonSchema` if you
857
+ need it directly (e.g. in your own provisioning tooling).
858
+
859
+ ## Service ↔ repository naming
860
+
861
+ The service layer (op-based) and the optional repository layer cover the same
862
+ concepts with slightly different names. `createRepository(manager, 'users')` binds
863
+ a manager + collection, so `repo` methods are collection-free:
864
+
865
+ | Concept | Service (`db.*`) | Repository (`repo.*`) |
866
+ | --- | --- | --- |
867
+ | fetch one by id | `getOne('users', { _id })` | `getById(id)` |
868
+ | fetch one by filter | `getOne` / `getOneOrFail` | `findOne` / `findOneOrFail` |
869
+ | fetch many | `findMany` | `findMany` |
870
+ | insert | `insertOne` / `insertMany` | `create` / `createMany` |
871
+ | update | `updateOne('users', { _id }, …)` | `update(id, …)` |
872
+ | optimistic lock | `updateWithVersion` | `updateVersioned` |
873
+ | delete | `deleteOne('users', { _id }, …)` | `deleteById(id)` |
874
+ | soft delete | `softDeleteOne` | `softDelete` |
875
+ | count | `countDocuments` | `count` |
876
+ | distinct | `distinct` | `distinct` |
877
+ | `$facet` paginate | `paginateFlexible` | `page` |
878
+ | keyset paginate | `paginateCursor` | `pageCursor` |
879
+ | change stream | `watchCollection` | `watch` |
880
+ | aggregation | `pipeline` / `aggregate` | `pipeline` / `aggregate` |
881
+ | populate | `populate` | `populate` |
882
+
883
+ ## API reference
884
+
885
+ [`API.md`](./API.md) enumerates every public export with signatures and links to
886
+ examples.
887
+
888
+ ## Examples
889
+
890
+ Runnable, live-Mongo examples live in [`examples/`](./examples) — CRUD, query
891
+ builder, pagination, typed relations, cache+dedup defaults, the global HotCache
892
+ (replica change-stream watch / standalone ticker), `cacheWatch` (change-stream
893
+ invalidation of the shared query cache), transactions, aggregation, migrations,
894
+ and a perf-by-default comparison. Run any of them with
895
+ `bun run examples/<file>.ts` (see [`examples/README.md`](./examples/README.md)).
896
+
897
+ ## Performance
898
+
899
+ | scenario | naive | optimized |
900
+ | --- | --- | --- |
901
+ | populate 100 orders | 17,000 queries (N+1) | **340 queries** (~50× fewer round trips) |
902
+ | paginate | count + find (2 round trips) | `$facet` **1 round trip** |
903
+ | cache hit | 1 driver call | **0 driver calls** |
904
+ | 50 concurrent identical reads | 50 server queries, 17 ms | **1 server query**, 1.5 ms |
905
+
906
+ Run the full harness (requires local MongoDB, see `.env`):
907
+
908
+ ```bash
909
+ bun run bench # → bench/results/summary.json
910
+ ```
911
+
912
+ ## Production considerations
913
+
914
+ **Multi-DB cache isolation.** A single service can define several DB clients; the
915
+ query cache and in-flight dedup are shared across them, and their keys are
916
+ namespaced by **database** — so same-named physical collections in different
917
+ databases can never share (or coalesce) entries. No action needed; it's handled.
918
+
919
+ **Standalone HotCache freshness is opt-in per query.** In standalone mode the
920
+ ticker only refreshes queries that set `refreshIntervalMs`. A query with neither
921
+ `refreshIntervalMs` nor `ttlMs` is served until manually invalidated (unbounded
922
+ staleness); `start()` logs a warning naming those queries. This is deliberate —
923
+ a default background poll of every query would add DB load. For bounded
924
+ freshness set `refreshIntervalMs` (or `ttlMs`, or wire `invalidateCollection`),
925
+ or set `autoRefresh: false` to disable the ticker entirely.
926
+
927
+ **`cacheWatch` needs a replica set / mongos.** Change-stream invalidation of the
928
+ shared query cache is opt-in and requires change streams. On a standalone server
929
+ the watchers are rejected, a warning is logged once, and invalidation silently
930
+ disables (the cache stays write-through only) — set `cache: { ttlMs }` there.
931
+
932
+ **Update payloads are precise.** `updateOne` / `updateMany` / `upsert` / … reject
933
+ unknown keys in object literals AND non-literal patches. The only way an unknown
934
+ key passes is a variable explicitly typed with an index signature
935
+ (`Record<string, any>`) — prefer inline literals for full checking.
936
+
937
+ **Index drift.** `createSchema` installs declared indexes; if they drift out of
938
+ band (manual drops, other tooling), `syncIndexes('collection')` reconciles —
939
+ creates missing declared indexes and drops undeclared ones (`_id_` kept).
940
+
941
+ **Observability.** `QueryCache.stats()` (hits/misses/evictions/…) and
942
+ `hot.stats()` (per-query hits/misses/refreshes/loadErrors/evictions/…) give you
943
+ the cache-effectiveness signals to watch in production.
944
+
945
+ **Graceful shutdown.** `closeConnections()` closes the pool and tears down any
946
+ `cacheWatch` change streams. The HotCache is standalone and user-managed — call
947
+ `hot.stop()` on shutdown.
948
+
949
+ ## Tests
950
+
951
+ ```bash
952
+ bun test # unit + integration (integration skips when no Mongo is reachable)
953
+ ```
954
+
955
+ ## Development
956
+
957
+ ```bash
958
+ bun run typecheck # tsc --noEmit (includes the type-assertion suite)
959
+ bun run lint # Biome check (format + lint)
960
+ bun run lint:fix # apply Biome safe fixes
961
+ bun test # unit + integration
962
+ bun test --coverage # coverage report (≥ 85% lines enforced in CI)
963
+ bun run build # tsup → dist/ (ESM + .d.ts)
964
+ npm pack --dry-run # inspect the published tarball contents
965
+ ```
966
+
967
+ ## Publishing
968
+
969
+ `ninox` is published from the built `dist/` (ESM + type declarations).
970
+ The `files` allowlist ships `dist/`, `LICENSE`, and `README.md` — no `src/`,
971
+ tests, or examples. CI enforces typecheck, lint, dependency audit, coverage
972
+ (≥ 85%), the full example suite, a replica-set job for the HotCache
973
+ change-stream tests, and an `npm pack` → fresh Node consumer smoke test.
974
+
975
+ Release notes are kept in [`CHANGELOG.md`](./CHANGELOG.md). The publish gate
976
+ (`prepublishOnly`) runs typecheck + lint + build.
977
+
978
+ ### Support matrix
979
+
980
+ | runtime | minimum |
981
+ | --- | --- |
982
+ | Node.js | ≥ 18.17 (ESM) |
983
+ | Bun | ≥ 1.0 |
984
+ | TypeScript | ≥ 5.5 (peer, optional — pure JS works too) |
985
+ | MongoDB driver | `mongodb` ^7 (only runtime dependency) |
986
+ | MongoDB server | 4.4+ core; 5.1+ for `$fill` / `$densify`; **replica set / mongos** for transactions, change streams, and `cacheWatch` |
987
+
988
+ ```bash
989
+ bun run build
990
+ npm publish # after bumping the version (semver)
991
+ ```
992
+
993
+ ## License
994
+
995
+ MIT — see [`LICENSE`](./LICENSE).
996
+
997
+ ## Package layout
998
+
999
+ See [`STRUCTURE.md`](./STRUCTURE.md) for the full layer map.
1000
+