@break-limits/mongoose-cache 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Break Limits
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,344 @@
1
+ <div align="center">
2
+
3
+ # @break-limits/mongoose-cache
4
+
5
+ **A transparent, correctness-first caching layer for Mongoose, backed by Redis.**
6
+
7
+ Install it once and every read is cached and automatically invalidated โ€” with **zero changes** to your models or queries.
8
+
9
+ [![npm](https://img.shields.io/npm/v/@break-limits/mongoose-cache.svg)](https://www.npmjs.com/package/@break-limits/mongoose-cache)
10
+ [![license](https://img.shields.io/badge/license-MIT-blue.svg)](#license)
11
+ [![tests](https://img.shields.io/badge/tests-183%20passing-brightgreen.svg)](#correctness--testing)
12
+
13
+ </div>
14
+
15
+ ---
16
+
17
+ > **The promise:** this library may serve fewer cache hits than a naive wrapper, but it **never serves stale data**. When it cannot *prove* a cached value is fresh, it doesn't cache it โ€” it falls back to MongoDB. Correctness is the product.
18
+
19
+ ## Table of contents
20
+
21
+ - [Why](#why)
22
+ - [Features](#features)
23
+ - [Install](#install)
24
+ - [Quick start](#quick-start)
25
+ - [How it works](#how-it-works)
26
+ - [Configuration](#configuration)
27
+ - [What gets cached & invalidated](#what-gets-cached--invalidated)
28
+ - [Supported query operators](#supported-query-operators)
29
+ - [Examples](#examples)
30
+ - [Events](#events)
31
+ - [Correctness & testing](#correctness--testing)
32
+ - [Limitations](#limitations)
33
+ - [Requirements & compatibility](#requirements--compatibility)
34
+ - [Roadmap](#roadmap)
35
+ - [API reference](#api-reference)
36
+ - [Development](#development)
37
+ - [License](#license)
38
+
39
+ ## Why
40
+
41
+ Most Mongoose caching libraries make you choose: cache aggressively and risk serving stale data, or invalidate by hand and get it wrong. This library takes a different stance โ€” it classifies every query by how safely it can be invalidated, caches precisely where it can prove freshness, conservatively where it can't, and never where it would be wrong.
42
+
43
+ The result is a cache you can put in front of production traffic without auditing every query for staleness bugs.
44
+
45
+ ## Features
46
+
47
+ - ๐Ÿง  **Transparent** โ€” no changes to your models or queries. `createCache({ mongoose, redis })` and you're done.
48
+ - ๐ŸŽฏ **Precise invalidation** โ€” point reads and predicate queries are invalidated per-document via a membership-transition engine (enter / leave / change / top-N edges).
49
+ - ๐Ÿงฎ **Aggregations & joins** โ€” `aggregate()` (including `$lookup`, `$unionWith`, `$graphLookup`) is cached and invalidated by every collection it touches.
50
+ - ๐Ÿ”— **Everything else too** โ€” `find`, `findOne`, `countDocuments`, `distinct`, `estimatedDocumentCount`, and populated queries are all covered.
51
+ - โœ๏ธ **Every write path invalidates** โ€” query writes, `save`/`create`, `insertMany`, `replaceOne`, upserts, and `bulkWrite`.
52
+ - ๐Ÿ›ก๏ธ **Never stale** โ€” verified by a differential fuzzer that runs thousands of random ops against real MongoDB and asserts the cache always matches.
53
+ - โšก **Stampede-safe** โ€” in-process single-flight collapses concurrent misses for the same key.
54
+ - ๐Ÿ”’ **Race-safe** โ€” a version-token guard prevents caching a value that a concurrent write invalidated mid-load.
55
+ - ๐Ÿš๏ธ **Degrade, never fail** โ€” if Redis is down, reads fall through to MongoDB and writes still succeed.
56
+ - ๐Ÿข **Multi-tenant** โ€” first-class tenant keyspace isolation.
57
+ - ๐Ÿ“ฆ **Lossless** โ€” BSON-aware serialization preserves `ObjectId`, `Date`, `Decimal128`, and `Buffer`.
58
+ - ๐Ÿ“Š **Observable** โ€” `hit` / `miss` / `invalidate` / `error` events.
59
+ - ๐Ÿงฉ **TypeScript-first** โ€” ships ESM + CJS with full type definitions.
60
+
61
+ ## Install
62
+
63
+ ```bash
64
+ npm install @break-limits/mongoose-cache ioredis mongoose
65
+ ```
66
+
67
+ `mongoose` (>= 7) and `ioredis` (>= 5) are peer dependencies.
68
+
69
+ ## Quick start
70
+
71
+ ```ts
72
+ import mongoose from "mongoose";
73
+ import Redis from "ioredis";
74
+ import { createCache } from "@break-limits/mongoose-cache";
75
+
76
+ await mongoose.connect(process.env.MONGO_URI!);
77
+ const redis = new Redis(process.env.REDIS_URL!);
78
+
79
+ const cache = createCache({
80
+ mongoose,
81
+ redis,
82
+ models: {
83
+ Product: { ttlMs: 1_800_000 }, // opt-in, optional TTL backstop
84
+ User: { ttlMs: 300_000 },
85
+ },
86
+ });
87
+
88
+ // From here on, reads are cached and invalidated automatically.
89
+ await Product.find({ status: "active" }).lean(); // miss โ†’ MongoDB โ†’ cached
90
+ await Product.find({ status: "active" }).lean(); // hit โ†’ Redis
91
+
92
+ await Product.findOneAndUpdate({ name: "Widget" }, { status: "active" });
93
+ // ^ "Widget" enters the "active" set โ†’ the cached query is invalidated.
94
+
95
+ await Product.find({ status: "active" }).lean(); // miss โ†’ reloads with Widget
96
+
97
+ // Observe what's happening:
98
+ cache.on("hit", ({ key }) => {});
99
+ cache.on("miss", ({ key }) => {});
100
+ cache.on("invalidate", ({ keys }) => {});
101
+ ```
102
+
103
+ ## How it works
104
+
105
+ Every query is classified into a **cacheability tier** that determines how it is cached and invalidated:
106
+
107
+ | Tier | Query shape | Caching | Invalidation |
108
+ |------|-------------|---------|--------------|
109
+ | **T0** | point read (`findById`, `findOne({_id})`, `find({_id:{$in:[โ€ฆ]}})`) | by document id | **precise** โ€” surgical by id |
110
+ | **T1** | predicate query the engine can evaluate in memory | result + predicate | **precise** โ€” membership transitions |
111
+ | **T2** | `$where` / `$text` / regex / geo, `distinct`, `estimatedDocumentCount`, populated queries | result + collection tags | **conservative** โ€” any write to a tagged collection |
112
+ | **T3** | aggregations (incl. `$lookup` / `$unionWith` / `$graphLookup`) | result + collection tags | **conservative** โ€” any write to any touched collection |
113
+ | **T4** | inside a transaction / cursor / `$out` / `$merge` | not cached | n/a |
114
+
115
+ Two complementary invalidation mechanisms keep both correct:
116
+
117
+ **Precise (T0/T1).** On every write, the engine takes the changed document's *before-* and *after-images* and evaluates each cached query's predicate against them. It invalidates a cached result when the document:
118
+ - was in the result set and changed (direct membership),
119
+ - newly matches the predicate (entering),
120
+ - no longer matches (leaving), or
121
+ - could affect a `limit`ed top-N window.
122
+
123
+ A predicate is only handled this way if every operator in it is one the engine can faithfully evaluate (the [supported operators](#supported-query-operators)). Anything else is downgraded to conservative โ€” we never run precise invalidation on a predicate we can't reproduce exactly.
124
+
125
+ **Conservative (T2/T3).** Each entry is tagged with the collection(s) it reads โ€” including foreign collections pulled in by `$lookup` or `populate`. Any write to a tagged collection drains all of its entries. Lower hit rate, still never stale.
126
+
127
+ Underneath, the cache is protected against the two classic correctness bugs:
128
+
129
+ - **Cache stampede** โ€” concurrent misses for the same key collapse into a single MongoDB load (in-process single-flight).
130
+ - **Write-after-read race** โ€” a per-model version token is captured before the DB read and re-checked before the cache write; if a write bumped it mid-load, the (possibly stale) value is not cached.
131
+
132
+ ## Configuration
133
+
134
+ ```ts
135
+ createCache({
136
+ mongoose, // your Mongoose instance (required)
137
+ redis, // an ioredis client (required)
138
+
139
+ // Opt-in model map. Omit to cache every registered model.
140
+ models: {
141
+ Product: { ttlMs: 1_800_000 },
142
+ Invoice: { enabled: false }, // never cache this model
143
+ },
144
+
145
+ defaults: { ttlMs: 600_000 }, // fallback TTL backstop for all models
146
+
147
+ tenant: () => getTenantId(), // keyspace isolation (see below)
148
+
149
+ store, // optional: override the storage backend
150
+ });
151
+ ```
152
+
153
+ ### Options
154
+
155
+ | Option | Type | Description |
156
+ |--------|------|-------------|
157
+ | `mongoose` | `Mongoose` | **Required.** Your Mongoose instance. |
158
+ | `redis` | `Redis` | **Required.** An `ioredis` client. |
159
+ | `models` | `Record<string, ModelConfig>` | Opt-in model map. Omit to cache all registered models. |
160
+ | `defaults` | `{ ttlMs?: number }` | Default TTL backstop applied to every model. |
161
+ | `tenant` | `() => string \| undefined` | Resolves the current tenant id for keyspace isolation. |
162
+ | `store` | `CacheStore` | Override the storage backend (defaults to Redis). |
163
+
164
+ ### Per-model config (`ModelConfig`)
165
+
166
+ | Field | Type | Description |
167
+ |-------|------|-------------|
168
+ | `ttlMs` | `number` | TTL backstop in milliseconds. TTL is a *safety net*, not the freshness mechanism โ€” precise/tag invalidation is. |
169
+ | `enabled` | `boolean` | Set `false` to bypass caching for this model entirely. |
170
+
171
+ > **TTL is a backstop, not the source of truth.** Freshness comes from invalidation; TTL only bounds how long an entry can live if something is ever missed. Models that must never be slightly stale (e.g. `Invoice`) can set `enabled: false`.
172
+
173
+ ### Cleanup
174
+
175
+ `createCache` patches Mongoose interception points on the instance. Call `cache.close()` to restore them (useful in tests or on graceful shutdown):
176
+
177
+ ```ts
178
+ const cache = createCache({ mongoose, redis });
179
+ // ...
180
+ cache.close();
181
+ ```
182
+
183
+ ## What gets cached & invalidated
184
+
185
+ **Read operations cached**
186
+
187
+ | Operation | Tier | Notes |
188
+ |-----------|------|-------|
189
+ | `find`, `findOne`, `findById` | T0 / T1 | precise; `.lean()` is ideal, hydrated docs are re-hydrated on hit |
190
+ | `countDocuments` | T1 | precise |
191
+ | `distinct` | T2 | conservative (depends on field values) |
192
+ | `estimatedDocumentCount` | T2 | conservative (collection metadata) |
193
+ | `aggregate` | T3 | conservative; tagged with every touched collection |
194
+ | `find().populate(โ€ฆ)` | T2 | conservative; tagged with root + foreign collections |
195
+
196
+ Cursor/streaming reads and any query bound to a session/transaction are **never cached**.
197
+
198
+ **Write operations that invalidate**
199
+
200
+ `save` ยท `create` ยท `insertMany` ยท `updateOne` ยท `updateMany` ยท `findOneAndUpdate` ยท `replaceOne` ยท `findOneAndReplace` ยท `deleteOne` ยท `deleteMany` ยท `findOneAndDelete` ยท upserts ยท `bulkWrite`
201
+
202
+ `bulkWrite` and upsert-created documents can't be individually imaged, so they fall back to a conservative model-wide flush โ€” coarse, but never stale.
203
+
204
+ ## Supported query operators
205
+
206
+ A predicate is invalidated **precisely (T1)** only if every operator in it is one the engine can evaluate exactly. The supported set is:
207
+
208
+ - **Comparison:** `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$in`, `$nin`
209
+ - **Logical:** `$and`, `$or`, `$nor`, `$not`
210
+ - **Element:** `$exists`
211
+ - Implicit equality, dot-path nesting (`"a.b"`), and array-contains semantics
212
+
213
+ Anything else โ€” `$where`, `$text`, `$regex`/RegExp literals, geo operators, `$expr`, a RegExp inside `$in`/`$nin` โ€” is sound to *run* but not to invalidate precisely, so such queries are transparently downgraded to conservative (collection-tag) caching. You don't lose caching; you lose only per-document precision.
214
+
215
+ ## Examples
216
+
217
+ ### Aggregation with a join
218
+
219
+ ```ts
220
+ const cache = createCache({ mongoose, redis, models: { Book: {}, Author: {} } });
221
+
222
+ const byAuthor = await Book.aggregate([
223
+ { $lookup: { from: "authors", localField: "author", foreignField: "_id", as: "a" } },
224
+ { $unwind: "$a" },
225
+ { $group: { _id: "$a.name", count: { $sum: 1 } } },
226
+ ]);
227
+ // Cached, tagged with both `books` and `authors`.
228
+
229
+ await Author.updateOne({ _id }, { name: "New Name" });
230
+ // A write to the authors collection invalidates the cached aggregation.
231
+ ```
232
+
233
+ ### Multi-tenant isolation
234
+
235
+ ```ts
236
+ import { AsyncLocalStorage } from "node:async_hooks";
237
+
238
+ const als = new AsyncLocalStorage<{ tenantId: string }>();
239
+ const cache = createCache({
240
+ mongoose,
241
+ redis,
242
+ models: { Order: {} },
243
+ tenant: () => als.getStore()?.tenantId,
244
+ });
245
+
246
+ // Each tenant gets an isolated cache keyspace; the same query under a different
247
+ // tenant is a separate cache entry.
248
+ als.run({ tenantId: "acme" }, () => Order.find({ status: "open" }).lean());
249
+ ```
250
+
251
+ ### Metrics from events
252
+
253
+ ```ts
254
+ let hits = 0, misses = 0;
255
+ cache.on("hit", () => hits++);
256
+ cache.on("miss", () => misses++);
257
+
258
+ setInterval(() => {
259
+ const total = hits + misses;
260
+ console.log(`hit rate: ${total ? ((hits / total) * 100).toFixed(1) : 0}%`);
261
+ }, 10_000);
262
+ ```
263
+
264
+ ## Events
265
+
266
+ `createCache` returns an `EventEmitter`. All payloads are plain objects.
267
+
268
+ | Event | Payload | Emitted when |
269
+ |-------|---------|--------------|
270
+ | `hit` | `{ key, model }` | a read is served from cache |
271
+ | `miss` | `{ key, model }` | a read falls through to MongoDB |
272
+ | `invalidate` | `{ keys, model? , collection? }` | cache keys are deleted by a write |
273
+ | `error` | `Error` | a cache (Redis) operation fails โ€” the request still succeeds against MongoDB |
274
+
275
+ > The `error` event is only emitted if you attach a listener, so an unhandled cache error never crashes your process.
276
+
277
+ ## Correctness & testing
278
+
279
+ Correctness is treated as the product, so it's tested like one โ€” **183 tests**, including a differential fuzzer that is the strongest evidence of the never-stale guarantee:
280
+
281
+ - **Differential fuzzer** โ€” 6 seeds ร— 200 random operations against **real MongoDB**. Every random read (`find`, `count`, `findOne`, `findById`, `distinct`, order-sensitive top-N) is compared against the *same query run un-cached*; any divergence fails the test with a reproducible seed. Writes cover every path including upserts and `bulkWrite`.
282
+ - **Concurrency stress** โ€” interleaved parallel reads and writes, asserting no permanently-stale entry survives.
283
+ - **Membership-transition matrix** โ€” entering / leaving / direct / top-N invalidation edges.
284
+ - **No-stale-read race** โ€” a write landing mid-load must not be cached.
285
+ - **Degradation** โ€” Redis failures fall through to MongoDB without throwing.
286
+ - **Full integration** โ€” aggregation, `$lookup`, populate, distinct, `bulkWrite`, sessions, multi-tenant, pagination โ€” all against `mongodb-memory-server`.
287
+
288
+ Run them yourself:
289
+
290
+ ```bash
291
+ npm test
292
+ ```
293
+
294
+ ## Limitations
295
+
296
+ By design, we cache less rather than cache wrong:
297
+
298
+ 1. **Aggregations and T2 queries are invalidated conservatively** (by collection), never per-document. High write volume on involved collections means a lower hit rate for those entries.
299
+ 2. **Out-of-band writes** โ€” changes made by another service or the Mongo shell are not yet synced (MongoDB Change Streams are on the roadmap). Today, invalidation covers writes made through the Mongoose instance you passed to `createCache`.
300
+ 3. **Single-node focus** โ€” a shared in-process L1 layer and cross-node pub/sub are on the roadmap. The Redis layer is already shared and safe across nodes; only the optional in-process layer and an atomic Lua version-guard remain.
301
+ 4. **Conservative entries have a small cross-collection write-race window** โ€” the version guard covers the root collection only; a subsequent write to either collection clears it. Precise (T0/T1) entries have no such window.
302
+
303
+ ## Requirements & compatibility
304
+
305
+ | | |
306
+ |--|--|
307
+ | Node.js | >= 18 |
308
+ | Mongoose | >= 7 (peer) |
309
+ | ioredis | >= 5 (peer) |
310
+ | Module formats | ESM + CommonJS, with type definitions |
311
+
312
+ ## Roadmap
313
+
314
+ - [ ] MongoDB Change Streams for out-of-band write synchronization
315
+ - [ ] Cross-node invalidation via Redis Pub/Sub
316
+ - [ ] In-process L1 (LRU) layer for microsecond reads
317
+ - [ ] Refresh-ahead for conservative entries
318
+ - [ ] Atomic Lua version-guarded writes for multi-node precision
319
+ - [ ] Prometheus metrics endpoint
320
+
321
+ ## API reference
322
+
323
+ The primary API is `createCache(options): Cache`. The package also exports its internals for advanced use and custom backends:
324
+
325
+ - **Plugin:** `createCache`, `Cache`, `CreateCacheOptions`, `ModelConfig`
326
+ - **Storage:** `CacheStore`, `InMemoryCacheStore`, `RedisCacheStore`, `DependencyIndex`, `InMemoryDependencyIndex`, `RedisDependencyIndex`
327
+ - **Engine:** `CacheManager`, `InvalidationEngine`, `classifyQuery`, `matches`, `isSupportedPredicate`
328
+ - **Keys & serialization:** `buildQueryKey`, `buildDocKey`, `normalizeQuery`, `stableHash`, `serialize`, `deserialize`
329
+ - **Types:** `Tier`, `Filter`, `Doc`, `CachedQuery`, `CacheKeyInput`, `QueryMeta`, `RegisteredQuery`, `WriteReport`
330
+
331
+ All are fully typed; see the bundled `.d.ts` for signatures.
332
+
333
+ ## Development
334
+
335
+ ```bash
336
+ npm install
337
+ npm test # vitest: unit + integration (mongodb-memory-server) + fuzzer
338
+ npm run typecheck
339
+ npm run build # tsup โ†’ dist/ (ESM + CJS + types)
340
+ ```
341
+
342
+ ## License
343
+
344
+ MIT