@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/LICENSE +21 -0
- package/README.md +1000 -0
- package/dist/index.d.ts +2570 -0
- package/dist/index.js +4370 -0
- package/dist/index.js.map +1 -0
- package/dist/timeout-BgrqtMnd.d.ts +95 -0
- package/dist/utils.d.ts +13 -0
- package/dist/utils.js +223 -0
- package/dist/utils.js.map +1 -0
- package/package.json +83 -0
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
|
+
|