@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.
@@ -0,0 +1,296 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { Mongoose } from 'mongoose';
3
+ import { Redis } from 'ioredis';
4
+
5
+ /**
6
+ * Storage contract used by the CacheManager. The in-memory implementation is
7
+ * the reference (and powers unit tests); a Redis-backed adapter (Phase 2)
8
+ * implements the same contract with Lua scripts for the atomic version guard.
9
+ *
10
+ * The per-model version counter underpins the write-after-read race guard:
11
+ * a reader captures the version before loading from Mongo and only writes the
12
+ * value to cache if no write bumped the version meanwhile.
13
+ */
14
+ interface CacheStore {
15
+ get(key: string): Promise<Buffer | null>;
16
+ set(key: string, value: Buffer, ttlMs?: number): Promise<void>;
17
+ del(keys: string[]): Promise<void>;
18
+ getVersion(model: string): Promise<number>;
19
+ bumpVersion(model: string): Promise<number>;
20
+ /** Tag membership — backs conservative (collection-tag) invalidation. */
21
+ addToSet(setKey: string, member: string): Promise<void>;
22
+ /** Return all members of a set and delete the set (atomic drain). */
23
+ drainSet(setKey: string): Promise<string[]>;
24
+ }
25
+ declare class InMemoryCacheStore implements CacheStore {
26
+ private readonly data;
27
+ private readonly versions;
28
+ private readonly sets;
29
+ get(key: string): Promise<Buffer | null>;
30
+ set(key: string, value: Buffer, _ttlMs?: number): Promise<void>;
31
+ del(keys: string[]): Promise<void>;
32
+ getVersion(model: string): Promise<number>;
33
+ bumpVersion(model: string): Promise<number>;
34
+ addToSet(setKey: string, member: string): Promise<void>;
35
+ drainSet(setKey: string): Promise<string[]>;
36
+ }
37
+
38
+ interface ModelConfig {
39
+ ttlMs?: number;
40
+ enabled?: boolean;
41
+ }
42
+ interface CreateCacheOptions {
43
+ mongoose: Mongoose;
44
+ redis: Redis;
45
+ /** Override the storage backend (e.g. for testing). Defaults to Redis. */
46
+ store?: CacheStore;
47
+ /** Opt-in model map. Omit to cache every currently-registered model. */
48
+ models?: Record<string, ModelConfig>;
49
+ defaults?: {
50
+ ttlMs?: number;
51
+ };
52
+ /** Resolve the current tenant id for keyspace isolation. */
53
+ tenant?: () => string | undefined;
54
+ }
55
+ interface Cache extends EventEmitter {
56
+ /** Restore all patched Mongoose methods. */
57
+ close(): void;
58
+ }
59
+ declare function createCache(options: CreateCacheOptions): Cache;
60
+
61
+ /**
62
+ * Redis-backed {@link CacheStore}. Implements the same contract proven against
63
+ * the in-memory store, so all CacheManager correctness logic carries over.
64
+ *
65
+ * Phase 1 targets single-node correctness (in-process single-flight + a
66
+ * per-model version counter). Phase 2 hardens the version guard into an atomic
67
+ * Lua compare-and-set for multi-node deployments.
68
+ */
69
+ declare class RedisCacheStore implements CacheStore {
70
+ private readonly redis;
71
+ private readonly versionPrefix;
72
+ constructor(redis: Redis, versionPrefix?: string);
73
+ get(key: string): Promise<Buffer | null>;
74
+ set(key: string, value: Buffer, ttlMs?: number): Promise<void>;
75
+ del(keys: string[]): Promise<void>;
76
+ getVersion(model: string): Promise<number>;
77
+ bumpVersion(model: string): Promise<number>;
78
+ addToSet(setKey: string, member: string): Promise<void>;
79
+ drainSet(setKey: string): Promise<string[]>;
80
+ private versionKey;
81
+ }
82
+
83
+ /**
84
+ * In-memory evaluation of a MongoDB filter against a single plain document.
85
+ *
86
+ * This is the engine that powers precise (T1) invalidation: on a write we
87
+ * evaluate whether a document entered or left a cached query's result set.
88
+ *
89
+ * CRITICAL CORRECTNESS CONTRACT: `matches` must agree with MongoDB's matching
90
+ * semantics for every operator that {@link isSupportedPredicate} accepts. A
91
+ * predicate using any operator we cannot faithfully evaluate must be rejected
92
+ * by `isSupportedPredicate` so the tier classifier downgrades it to T2
93
+ * (collection-tag invalidation) rather than risk a missed invalidation.
94
+ */
95
+ type Filter = Record<string, unknown>;
96
+ type Doc = Record<string, unknown>;
97
+ declare function matches(filter: Filter, doc: Doc): boolean;
98
+ declare function isSupportedPredicate(filter: unknown): boolean;
99
+
100
+ /**
101
+ * Metadata recorded for every cached T1 query so that a later write can decide,
102
+ * precisely, whether the query's result set could have changed.
103
+ */
104
+ interface QueryMeta {
105
+ predicate: Filter;
106
+ /** True when the query has a limit (top-N) — see InvalidationEngine step 5. */
107
+ limited: boolean;
108
+ /** Stable string ids of the documents currently in the cached result. */
109
+ resultDocIds: string[];
110
+ }
111
+ interface RegisteredQuery extends QueryMeta {
112
+ queryKey: string;
113
+ }
114
+ /**
115
+ * Index of active T1 queries, keyed by model. The in-memory implementation is
116
+ * the source of truth for unit tests and single-process use; a Redis-backed
117
+ * implementation (Phase 2) provides the same contract across nodes.
118
+ */
119
+ interface DependencyIndex {
120
+ addQuery(model: string, queryKey: string, meta: QueryMeta): Promise<void>;
121
+ removeQuery(model: string, queryKey: string): Promise<void>;
122
+ getQueries(model: string): Promise<RegisteredQuery[]>;
123
+ /** Remove every registered query for a model, returning the keys removed. */
124
+ clearModel(model: string): Promise<string[]>;
125
+ }
126
+ declare class InMemoryDependencyIndex implements DependencyIndex {
127
+ private readonly byModel;
128
+ addQuery(model: string, queryKey: string, meta: QueryMeta): Promise<void>;
129
+ removeQuery(model: string, queryKey: string): Promise<void>;
130
+ getQueries(model: string): Promise<RegisteredQuery[]>;
131
+ clearModel(model: string): Promise<string[]>;
132
+ }
133
+
134
+ /**
135
+ * Redis-backed {@link DependencyIndex}. Persisting the registry alongside the
136
+ * cached entries themselves is a correctness requirement: if the registry were
137
+ * only in-process, a restart would orphan still-cached query results — a later
138
+ * write could no longer find them to invalidate, serving stale data.
139
+ *
140
+ * Predicate metadata is stored as BSON (not JSON) so ObjectId/Date values
141
+ * inside a predicate survive the round-trip and the matcher stays accurate.
142
+ */
143
+ declare class RedisDependencyIndex implements DependencyIndex {
144
+ private readonly redis;
145
+ private readonly prefix;
146
+ constructor(redis: Redis, prefix?: string);
147
+ addQuery(model: string, queryKey: string, meta: QueryMeta): Promise<void>;
148
+ removeQuery(model: string, queryKey: string): Promise<void>;
149
+ getQueries(model: string): Promise<RegisteredQuery[]>;
150
+ clearModel(model: string): Promise<string[]>;
151
+ private setKey;
152
+ private metaKey;
153
+ }
154
+
155
+ /**
156
+ * Query fingerprinting: convert an arbitrary Mongoose query shape into a
157
+ * deterministic, canonical structure suitable for hashing.
158
+ *
159
+ * Two semantically-equal queries (e.g. `{a:1,b:2}` and `{b:2,a:1}`) must
160
+ * produce identical fingerprints. Special BSON-ish types (ObjectId, Date,
161
+ * RegExp) are normalized to stable tagged forms.
162
+ */
163
+ declare function normalizeQuery(value: unknown): unknown;
164
+
165
+ /**
166
+ * Deterministic hash of an arbitrary value. The value is first canonicalized
167
+ * via {@link normalizeQuery} so semantically-equal inputs hash identically,
168
+ * then hashed with SHA-256 and truncated for compactness.
169
+ */
170
+ declare function stableHash(value: unknown): string;
171
+
172
+ /**
173
+ * Deterministic cache-key construction. Keys are namespaced by tenant (for
174
+ * isolation), kind (`q` for query results, `doc` for point reads), and model.
175
+ */
176
+ interface CacheKeyInput {
177
+ model: string;
178
+ op: string;
179
+ filter?: unknown;
180
+ projection?: unknown;
181
+ sort?: unknown;
182
+ skip?: number | undefined;
183
+ limit?: number | undefined;
184
+ populate?: unknown;
185
+ collation?: unknown;
186
+ /** The field name for a `distinct` query (so distinct('a') ≠ distinct('b')). */
187
+ distinct?: unknown;
188
+ tenant?: string | undefined;
189
+ schemaVersion?: string | undefined;
190
+ }
191
+ declare function buildQueryKey(input: CacheKeyInput): string;
192
+ declare function buildDocKey(model: string, id: string, tenant?: string): string;
193
+
194
+ /**
195
+ * Lossless serialization of cache values. We never store hydrated Mongoose
196
+ * documents — only plain (lean) data — but that data still contains BSON types
197
+ * (ObjectId, Date, Decimal128, Buffer) that must survive a Redis round-trip.
198
+ *
199
+ * Values are wrapped (`{ v: value }`) before BSON encoding so that arrays and
200
+ * primitives — not just documents — can be stored.
201
+ */
202
+ declare function serialize(value: unknown): Buffer;
203
+ declare function deserialize(buffer: Buffer): unknown;
204
+
205
+ interface WriteReport {
206
+ invalidatedQueryKeys: string[];
207
+ }
208
+ /**
209
+ * Drives precise (T1) invalidation via the membership-transition algorithm
210
+ * (see plan.md "On write"). Given the before- and after-images of a changed
211
+ * document, it determines exactly which cached queries could have changed.
212
+ */
213
+ declare class InvalidationEngine {
214
+ private readonly index;
215
+ constructor(index: DependencyIndex);
216
+ registerQuery(model: string, queryKey: string, meta: QueryMeta): Promise<void>;
217
+ onWrite(model: string, before: Doc | null, after: Doc | null): Promise<WriteReport>;
218
+ /**
219
+ * Remove every registered query for a model and return their cache keys.
220
+ * Used as a conservative fallback (bulkWrite, upserts) where per-document
221
+ * before/after images aren't available.
222
+ */
223
+ clearQueries(model: string): Promise<string[]>;
224
+ }
225
+
226
+ /**
227
+ * Cacheability tiers. See plan.md "Cacheability Tiers".
228
+ *
229
+ * - T0: point read keyed by document id — surgical invalidation.
230
+ * - T1: predicate query we can evaluate in-memory — precise invalidation.
231
+ * - T2: bounded but unpredicatable — conservative collection-tag invalidation.
232
+ * - T3: aggregation — conservative by touched collection(s).
233
+ * - T4: never cache (active session/transaction, cursor/streaming).
234
+ */
235
+ type Tier = "T0" | "T1" | "T2" | "T3" | "T4";
236
+ interface ClassifyInput {
237
+ op: string;
238
+ filter?: Filter;
239
+ hasSession?: boolean;
240
+ isCursor?: boolean;
241
+ }
242
+ declare function classifyQuery(input: ClassifyInput): Tier;
243
+
244
+ interface CachedQuery {
245
+ key: string;
246
+ model: string;
247
+ tier: Tier;
248
+ predicate?: Filter | undefined;
249
+ limited?: boolean | undefined;
250
+ ttlMs?: number | undefined;
251
+ /** Collection names to tag this entry with (conservative T2/T3 invalidation). */
252
+ tags?: string[] | undefined;
253
+ }
254
+ /**
255
+ * Ties together the store, serializer, and invalidation engine into the
256
+ * read/write caching brain. Guarantees the no-stale-read invariant via a
257
+ * version-token race guard and prevents stampedes via in-process single-flight.
258
+ */
259
+ declare class CacheManager {
260
+ private readonly store;
261
+ private readonly engine;
262
+ private readonly events?;
263
+ private readonly inflight;
264
+ constructor(store: CacheStore, engine: InvalidationEngine, events?: EventEmitter | undefined);
265
+ /**
266
+ * Return the cached value for a query, or load it via `loader`, cache it
267
+ * (when safe), and register it for precise invalidation.
268
+ *
269
+ * @param extractIds maps a loaded result to the stable string ids of the
270
+ * documents it contains — required for T1 direct-membership invalidation.
271
+ */
272
+ getOrLoad<T>(query: CachedQuery, loader: () => Promise<T>, extractIds?: (result: T) => string[]): Promise<T>;
273
+ private loadAndCache;
274
+ /**
275
+ * Apply a write: bump the model version (closing the race window), run the
276
+ * invalidation engine, and delete every affected cache key.
277
+ */
278
+ onWrite(model: string, before: Doc | null, after: Doc | null, extraKeys?: string[]): Promise<string[]>;
279
+ /**
280
+ * Conservative invalidation: drain a collection's tag and delete every
281
+ * entry tagged with it (T2/T3/distinct/populate). Degrade-safe.
282
+ */
283
+ invalidateCollection(collection: string): Promise<string[]>;
284
+ /**
285
+ * Nuclear precise fallback: remove every registered T1 query for a model and
286
+ * delete its cache keys. Used for writes we cannot image (bulkWrite, upserts).
287
+ */
288
+ flushModelPrecise(model: string): Promise<string[]>;
289
+ /**
290
+ * Emit an `error` event without crashing: a bare `error` emit on an
291
+ * EventEmitter with no listeners would itself throw.
292
+ */
293
+ private emitError;
294
+ }
295
+
296
+ export { type Cache, type CacheKeyInput, CacheManager, type CacheStore, type CachedQuery, type ClassifyInput, type CreateCacheOptions, type DependencyIndex, type Doc, type Filter, InMemoryCacheStore, InMemoryDependencyIndex, InvalidationEngine, type ModelConfig, type QueryMeta, RedisCacheStore, RedisDependencyIndex, type RegisteredQuery, type Tier, type WriteReport, buildDocKey, buildQueryKey, classifyQuery, createCache, deserialize, isSupportedPredicate, matches, normalizeQuery, serialize, stableHash };