redis-graph-cache 1.0.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,286 @@
1
+ "use strict";
2
+ /**
3
+ * Normalization Engine - Converts nested objects into normalized Redis storage
4
+ */
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.NormalizationEngine = void 0;
7
+ const compression_1 = require("./compression");
8
+ const serializer_1 = require("./serializer");
9
+ const types_1 = require("../types");
10
+ class NormalizationEngine {
11
+ constructor(schemaManager, connectionManager, codec = compression_1.NOOP_CODEC,
12
+ // Pluggable (de)serializer. Defaults to the engine's lossless
13
+ // tagged JSON serializer so callers don't have to opt in to get
14
+ // correct `Date`/`BigInt`/`Map`/`Set` round-tripping. Passed in
15
+ // by `RedisSchemaEngine` so reads and writes share one instance.
16
+ serializer = serializer_1.TAGGED_SERIALIZER) {
17
+ this.schemaManager = schemaManager;
18
+ this.connectionManager = connectionManager;
19
+ this.codec = codec;
20
+ this.serializer = serializer;
21
+ }
22
+ /**
23
+ * Normalize a complex nested entity into separate Redis keys
24
+ */
25
+ async normalizeEntity(entityType, data, operationId = this.generateOperationId()) {
26
+ const schema = this.schemaManager.getEntitySchema(entityType);
27
+ const normalizedEntities = new Map();
28
+ // Normalize the main entity and all nested entities
29
+ await this.normalizeRecursive(entityType, data, normalizedEntities, operationId);
30
+ const entityId = data[schema.id];
31
+ const primaryEntityKey = this.schemaManager.generateEntityKey(entityType, entityId);
32
+ return {
33
+ normalizedEntities,
34
+ primaryEntityKey,
35
+ operationId,
36
+ };
37
+ }
38
+ /**
39
+ * Write normalized entities to Redis using a per-key compare-and-set so
40
+ * concurrent writers cannot clobber each other's partial updates. The
41
+ * caller-supplied entity-type lookup determines TTL per key.
42
+ *
43
+ * Flow per key:
44
+ * 1. GET current value
45
+ * 2. Merge in Node (preserves "missing fields keep existing" semantics)
46
+ * 3. CAS write via Lua. If another writer raced us, retry up to
47
+ * `maxCasAttempts` times with the freshly read value.
48
+ *
49
+ * All keys are processed in parallel (Promise.all) and ioredis pipelines
50
+ * them automatically across the same connection, so latency stays close
51
+ * to the original implementation while gaining atomicity.
52
+ */
53
+ async writeNormalizedEntities(normalizationResult, resolveTTL = () => 0) {
54
+ const { normalizedEntities, operationId } = normalizationResult;
55
+ const keys = Array.from(normalizedEntities.keys());
56
+ try {
57
+ await Promise.all(keys.map((key) => this.atomicMergeWrite(key, normalizedEntities.get(key), resolveTTL(key))));
58
+ return {
59
+ success: true,
60
+ keys,
61
+ operationId,
62
+ timestamp: Date.now(),
63
+ };
64
+ }
65
+ catch (error) {
66
+ throw new types_1.RedisConnectionError('Failed to write normalized entities', error);
67
+ }
68
+ }
69
+ /**
70
+ * Per-key compare-and-set merge with bounded retry. Each retry re-reads
71
+ * the current value so the merge is always against the freshest view.
72
+ * Surfaces a RedisConnectionError if the contention budget is exhausted,
73
+ * which is the right behaviour: callers should retry at the request level
74
+ * rather than spin forever inside a single write.
75
+ */
76
+ async atomicMergeWrite(key, incoming, ttlSeconds) {
77
+ const maxCasAttempts = 5;
78
+ for (let attempt = 1; attempt <= maxCasAttempts; attempt++) {
79
+ // currentRaw is the literal bytes stored in Redis; it may be a
80
+ // compressed envelope (`\x00z1:...base64...`) or plain JSON. We
81
+ // pass it back to CAS as `expected` unchanged so byte equality
82
+ // holds against whatever is actually stored.
83
+ const currentRaw = await this.connectionManager.get(key);
84
+ let merged;
85
+ if (currentRaw) {
86
+ // Decode for the merge step only; the merge needs structured
87
+ // data, not the storage envelope. Routing through the shared
88
+ // serializer ensures previously stored Dates/BigInts/Maps/etc.
89
+ // come back as their original JS types so smartMerge sees the
90
+ // same structure the original writer wrote.
91
+ let existing = null;
92
+ try {
93
+ existing = this.serializer.parse(this.codec.decode(currentRaw));
94
+ }
95
+ catch {
96
+ existing = null;
97
+ }
98
+ merged = existing ? this.smartMerge(existing, incoming) : incoming;
99
+ }
100
+ else {
101
+ merged = incoming;
102
+ }
103
+ // Serialize through the configured serializer so non-JSON-native
104
+ // values (Date, BigInt, Map, Set, RegExp, Buffer, NaN/Infinity)
105
+ // are tagged for lossless recovery on read. Plain JSON-native
106
+ // payloads still produce identical bytes to raw JSON.stringify.
107
+ const newJson = this.serializer.stringify(merged);
108
+ // Encode for storage. May or may not actually compress depending
109
+ // on the codec config and payload size; either way the result is
110
+ // a single string Redis stores verbatim.
111
+ const newEncoded = this.codec.encode(newJson);
112
+ const expected = currentRaw ?? '';
113
+ const ok = await this.connectionManager.casSet(key, expected, newEncoded, ttlSeconds);
114
+ if (ok)
115
+ return;
116
+ }
117
+ throw new types_1.RedisConnectionError(`CAS contention: failed to write '${key}' after retries`);
118
+ }
119
+ /**
120
+ * Recursively normalize entity and its relationships
121
+ */
122
+ async normalizeRecursive(entityType, data, result, operationId, visited = new Set()) {
123
+ if (!data || typeof data !== 'object') {
124
+ return;
125
+ }
126
+ const schema = this.schemaManager.getEntitySchema(entityType);
127
+ const entityId = data[schema.id];
128
+ if (!entityId) {
129
+ throw new types_1.InvalidOperationError('normalize', `Missing required ID field '${schema.id}' in entity '${entityType}'`, { entityType, data });
130
+ }
131
+ const entityKey = this.schemaManager.generateEntityKey(entityType, entityId);
132
+ // Prevent infinite recursion
133
+ const visitKey = `${entityType}:${entityId}`;
134
+ if (visited.has(visitKey)) {
135
+ return;
136
+ }
137
+ visited.add(visitKey);
138
+ // Create normalized entity
139
+ const normalizedEntity = {};
140
+ // Add metadata
141
+ normalizedEntity[types_1.VERSION_FIELD] = schema.version || '1.0.0';
142
+ normalizedEntity[types_1.TIMESTAMP_FIELD] = Date.now();
143
+ normalizedEntity[types_1.METADATA_FIELD] = {
144
+ entityType,
145
+ originalId: entityId,
146
+ relationshipIds: {},
147
+ lastUpdated: Date.now(),
148
+ source: operationId,
149
+ };
150
+ // Process regular fields
151
+ if (schema.fields) {
152
+ for (const [fieldName, fieldDef] of Object.entries(schema.fields)) {
153
+ if (fieldName in data) {
154
+ normalizedEntity[fieldName] = data[fieldName];
155
+ }
156
+ }
157
+ }
158
+ // Process relationships
159
+ if (schema.relations) {
160
+ for (const [relationName, relationDef] of Object.entries(schema.relations)) {
161
+ if (relationName in data) {
162
+ await this.processRelationship(relationName, relationDef, data[relationName], normalizedEntity, result, operationId, visited);
163
+ }
164
+ }
165
+ }
166
+ // Store normalized entity
167
+ result.set(entityKey, normalizedEntity);
168
+ }
169
+ /**
170
+ * Process a relationship and normalize related entities
171
+ */
172
+ async processRelationship(relationName, relationDef, relationData, parentEntity, result, operationId, visited) {
173
+ const relatedSchema = this.schemaManager.getEntitySchema(relationDef.type);
174
+ const relationIdField = (0, types_1.generateRelationIdField)(relationName, relationDef.kind);
175
+ if (relationDef.kind === 'one') {
176
+ // One-to-one relationship
177
+ if (relationData && typeof relationData === 'object') {
178
+ const relatedId = relationData[relatedSchema.id];
179
+ if (relatedId) {
180
+ parentEntity[relationIdField] = relatedId;
181
+ // Store relationship ID in metadata
182
+ const metadata = parentEntity[types_1.METADATA_FIELD];
183
+ metadata.relationshipIds[relationName] = relatedId;
184
+ // Recursively normalize related entity
185
+ await this.normalizeRecursive(relationDef.type, relationData, result, operationId, visited);
186
+ }
187
+ }
188
+ }
189
+ else if (relationDef.kind === 'many') {
190
+ // One-to-many relationship
191
+ if (Array.isArray(relationData)) {
192
+ const relatedIds = [];
193
+ for (const relatedItem of relationData) {
194
+ if (relatedItem && typeof relatedItem === 'object') {
195
+ const relatedId = relatedItem[relatedSchema.id];
196
+ if (relatedId) {
197
+ relatedIds.push(relatedId);
198
+ // Recursively normalize related entity
199
+ await this.normalizeRecursive(relationDef.type, relatedItem, result, operationId, visited);
200
+ }
201
+ }
202
+ }
203
+ if (relatedIds.length > 0) {
204
+ parentEntity[relationIdField] = relatedIds;
205
+ // Store relationship IDs in metadata
206
+ const metadata = parentEntity[types_1.METADATA_FIELD];
207
+ metadata.relationshipIds[relationName] = relatedIds;
208
+ }
209
+ }
210
+ }
211
+ }
212
+ /**
213
+ * Smart merge strategy based on field types
214
+ */
215
+ smartMerge(existingEntity, newEntity) {
216
+ const merged = { ...existingEntity };
217
+ for (const [key, newValue] of Object.entries(newEntity)) {
218
+ const existingValue = existingEntity[key];
219
+ if (key.startsWith(types_1.INTERNAL_FIELD_PREFIX)) {
220
+ // Always update internal fields
221
+ merged[key] = newValue;
222
+ }
223
+ else if (this.isPrimitiveValue(newValue)) {
224
+ // Primitive fields: replace with new value
225
+ merged[key] = newValue;
226
+ }
227
+ else if (Array.isArray(newValue)) {
228
+ // Array fields: replace entire array
229
+ merged[key] = newValue;
230
+ }
231
+ else if (this.isObject(newValue) && this.isObject(existingValue)) {
232
+ // Object fields: deep merge
233
+ merged[key] = this.deepMergeObjects(existingValue, newValue);
234
+ }
235
+ else {
236
+ // Default: replace with new value
237
+ merged[key] = newValue;
238
+ }
239
+ }
240
+ // Update timestamp
241
+ merged[types_1.TIMESTAMP_FIELD] = Date.now();
242
+ return merged;
243
+ }
244
+ /**
245
+ * Check if value is primitive (string, number, boolean, null, undefined)
246
+ */
247
+ isPrimitiveValue(value) {
248
+ return (value === null ||
249
+ value === undefined ||
250
+ typeof value === 'string' ||
251
+ typeof value === 'number' ||
252
+ typeof value === 'boolean');
253
+ }
254
+ /**
255
+ * Check if value is a plain object
256
+ */
257
+ isObject(value) {
258
+ return (value !== null &&
259
+ typeof value === 'object' &&
260
+ !Array.isArray(value) &&
261
+ value.constructor === Object);
262
+ }
263
+ /**
264
+ * Deep merge two objects
265
+ */
266
+ deepMergeObjects(existing, incoming) {
267
+ const result = { ...existing };
268
+ for (const [key, value] of Object.entries(incoming)) {
269
+ const existingValue = existing[key];
270
+ if (this.isObject(value) && this.isObject(existingValue)) {
271
+ result[key] = this.deepMergeObjects(existingValue, value);
272
+ }
273
+ else {
274
+ result[key] = value;
275
+ }
276
+ }
277
+ return result;
278
+ }
279
+ /**
280
+ * Generate unique operation ID
281
+ */
282
+ generateOperationId() {
283
+ return `op_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
284
+ }
285
+ }
286
+ exports.NormalizationEngine = NormalizationEngine;
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Redis Connection Manager
3
+ *
4
+ * Owns the ioredis client and exposes a small set of typed methods that all
5
+ * Redis traffic flows through. Every call goes through a circuit breaker +
6
+ * retry wrapper, records latency, and updates cache hit/miss counters where
7
+ * applicable. Atomic Lua scripts are registered on construction and exposed
8
+ * as typed helpers (casSet, setIfExists, listAdd, listRemove) so callers
9
+ * never need direct access to the underlying client for normal work.
10
+ */
11
+ import { Redis as RedisInstance, ChainableCommander } from 'ioredis';
12
+ import { RedisConfig, CircuitBreakerConfig, RetryConfig, CircuitBreakerState } from '../types';
13
+ export declare class RedisConnectionManager {
14
+ private config;
15
+ /**
16
+ * The pool of ioredis clients. Default size 1 (a single connection)
17
+ * for backward compatibility. When `redis.poolSize` is configured
18
+ * higher, every hot-path command is round-robined across these
19
+ * clients, giving genuine parallelism on the Redis socket.
20
+ */
21
+ private clients;
22
+ private rrIndex;
23
+ /**
24
+ * Per-client connected flags. `isConnected` (below) is the OR across
25
+ * the pool, so health checks succeed when *any* client is up.
26
+ */
27
+ private connectedFlags;
28
+ private circuitBreaker;
29
+ private retryManager;
30
+ private healthMetrics;
31
+ /**
32
+ * Typed view of `this.redis` that includes the Lua-script methods
33
+ * registered via `defineCommand`. Use this whenever you need to
34
+ * call an `rse*` script or a variadic ZRANGEBYSCORE-family command;
35
+ * ordinary commands (`get`, `set`, `del`, `exists`, etc.) are typed
36
+ * on the base instance and should keep using `this.redis`.
37
+ *
38
+ * Calling this advances the round-robin index identically to
39
+ * `this.redis`, so a single call site stays pinned to one client.
40
+ */
41
+ private get scripted();
42
+ /**
43
+ * Round-robin client picker. Hot-path methods read `this.redis` once
44
+ * per call which advances the index, distributing load uniformly.
45
+ * Pipelines obtained via `this.redis.pipeline()` are owned by a
46
+ * single client because the getter is called exactly once.
47
+ */
48
+ private get redis();
49
+ /**
50
+ * True when at least one pool client reports connected. Health checks
51
+ * succeed under partial pool failures, which is the right behaviour:
52
+ * one degraded socket shouldn't take the whole engine offline.
53
+ */
54
+ private get isConnected();
55
+ constructor(config: RedisConfig, circuitBreakerConfig: CircuitBreakerConfig, retryConfig: RetryConfig);
56
+ /**
57
+ * Initialize the Redis pool. Creates `poolSize` independent clients
58
+ * (defaulting to 1 for parity with the pre-pool implementation). Each
59
+ * client gets:
60
+ * - the same connection options
61
+ * - all atomic Lua scripts pre-registered as named commands
62
+ * - the same lifecycle event handlers
63
+ * Failures in any single client during init bubble up as connection
64
+ * errors so the engine fails closed rather than silently degrading.
65
+ */
66
+ private initializeConnection;
67
+ /**
68
+ * Attach lifecycle handlers to every pool client. Per-client connect/
69
+ * close events flip that client's slot in `connectedFlags` so the
70
+ * aggregate `isConnected` getter reflects pool-wide state without
71
+ * extra coordination.
72
+ */
73
+ private setupEventHandlers;
74
+ /**
75
+ * Manually connect every pool client. Used when `lazyConnect: true`
76
+ * was passed via redis options; otherwise clients connect at
77
+ * construction time. Errors from any single client bubble up.
78
+ */
79
+ connect(): Promise<void>;
80
+ /**
81
+ * Execute a Redis operation through circuit breaker + retry. All public
82
+ * helpers below funnel through this. Latency is recorded for both success
83
+ * and failure so health metrics reflect real traffic.
84
+ */
85
+ executeOperation<T>(operation: () => Promise<T>, operationName?: string): Promise<T>;
86
+ /**
87
+ * GET wrapper. Records cache hit/miss for visibility into real traffic.
88
+ */
89
+ get(key: string): Promise<string | null>;
90
+ /**
91
+ * MGET wrapper. Records hit/miss per key for accurate hit-rate stats.
92
+ */
93
+ mget(keys: string[]): Promise<Array<string | null>>;
94
+ /**
95
+ * SET wrapper with optional TTL in seconds.
96
+ */
97
+ set(key: string, value: string, ttlSeconds?: number): Promise<void>;
98
+ /**
99
+ * DEL wrapper. Returns the number of keys removed.
100
+ */
101
+ del(...keys: string[]): Promise<number>;
102
+ /**
103
+ * EXISTS wrapper. Returns the number of keys that exist (0 or 1 for single).
104
+ */
105
+ exists(key: string): Promise<number>;
106
+ /**
107
+ * Compare-and-set via Lua. Writes newValue only if current value at key
108
+ * exactly equals expectedValue. Pass empty string to expect absence.
109
+ * Returns true on success, false on conflict.
110
+ */
111
+ casSet(key: string, expectedValue: string, newValue: string, ttlSeconds?: number): Promise<boolean>;
112
+ /**
113
+ * Atomic set that only writes if the key already exists. Used for
114
+ * "update only if cached" semantics. Returns true if applied.
115
+ */
116
+ setIfExists(key: string, value: string, ttlSeconds?: number): Promise<boolean>;
117
+ /**
118
+ * Atomic add to a JSON-array list at key. Idempotent; returns true if
119
+ * the id was newly inserted, false if already present.
120
+ */
121
+ listAdd(listKey: string, id: string | number, ttlSeconds?: number): Promise<boolean>;
122
+ /**
123
+ * Atomic remove of an id from a JSON-array list. Preserves the existing
124
+ * TTL on the key. Returns true if the id was found and removed.
125
+ */
126
+ listRemove(listKey: string, id: string | number): Promise<boolean>;
127
+ /**
128
+ * Atomic ZADD with optional max-size trim and optional membership-index
129
+ * update. Returns true when a brand-new member was added; false when an
130
+ * existing member's score was updated (still considered "success").
131
+ */
132
+ zListAdd(listKey: string, membershipKey: string, score: number, id: string | number, ttlSeconds?: number, maxSize?: number, trackMembership?: boolean): Promise<boolean>;
133
+ /**
134
+ * Atomic ZREM. Returns true if the id was a member of the set.
135
+ */
136
+ zListRemove(listKey: string, membershipKey: string, id: string | number, trackMembership?: boolean): Promise<boolean>;
137
+ /**
138
+ * Paginated read of a ZSET. Range semantics:
139
+ * - `reverse: false` (default) returns members in ascending score
140
+ * order (oldest first when score is timestamp).
141
+ * - `reverse: true` returns members in descending score order
142
+ * (newest first when score is timestamp) — the common feed case.
143
+ * - `offset` and `limit` are applied AFTER the score filter.
144
+ * - `minScore`/`maxScore` filter by score; defaults are -inf/+inf.
145
+ *
146
+ * Returns just the ids by default. Pass `withScores: true` to get the
147
+ * raw `[id, score]` pairs for downstream filtering.
148
+ */
149
+ zListRange(listKey: string, opts?: {
150
+ offset?: number;
151
+ limit?: number;
152
+ reverse?: boolean;
153
+ minScore?: number;
154
+ maxScore?: number;
155
+ withScores?: boolean;
156
+ }): Promise<string[] | Array<{
157
+ id: string;
158
+ score: number;
159
+ }>>;
160
+ /**
161
+ * Number of members currently in the ZSET. Returns 0 when the key is
162
+ * absent (which Redis ZCARD reports natively).
163
+ */
164
+ zListSize(listKey: string): Promise<number>;
165
+ /**
166
+ * Cascade-invalidate an entity: ZREMs it from every tracked list and
167
+ * deletes both the entity key and the membership back-index. Returns
168
+ * the number of lists the entity was removed from. Safe to call when
169
+ * the back-index does not exist (returns 0).
170
+ */
171
+ cascadeInvalidate(membershipKey: string, entityKey: string, id: string | number): Promise<number>;
172
+ /**
173
+ * Run an arbitrary pipeline. Internal use by the normalization engine for
174
+ * bulk first-write operations. Errors inside the pipeline are surfaced.
175
+ */
176
+ runPipeline(build: (pipeline: ChainableCommander) => void): Promise<any[]>;
177
+ /**
178
+ * Escape hatch returning one client from the pool. Prefer the typed
179
+ * helpers above so traffic is observed and breaker-protected. This is
180
+ * here for migrations and tests only and should not be used in hot
181
+ * paths.
182
+ */
183
+ getRawClient(): RedisInstance;
184
+ /**
185
+ * Return all clients in the pool. Internal use for tests verifying
186
+ * pool size and per-client state.
187
+ */
188
+ getAllRawClients(): readonly RedisInstance[];
189
+ /**
190
+ * FLUSHDB wrapper. Caller is responsible for guarding this; the engine
191
+ * adds an additional confirmation gate at the public API.
192
+ */
193
+ flushDb(): Promise<void>;
194
+ /**
195
+ * Delete every key that starts with the configured `keyPrefix`, using
196
+ * non-blocking `SCAN` + `UNLINK` (async delete). This is the safe
197
+ * equivalent of `FLUSHDB` for deployments that share a Redis DB with
198
+ * other applications or environments: it only touches keys this engine
199
+ * owns and never blocks the Redis server on large keyspaces.
200
+ *
201
+ * Implementation notes:
202
+ * - `SCAN` with `{ match: '*' }` works correctly because ioredis
203
+ * automatically prepends `keyPrefix` to the `MATCH` pattern on
204
+ * the wire, so Redis only returns keys under our namespace.
205
+ * - Redis returns the fully-prefixed keys. We strip the prefix
206
+ * before calling `UNLINK`, because ioredis re-applies the prefix
207
+ * on outbound commands — passing the raw prefixed keys would
208
+ * produce a double prefix and delete nothing.
209
+ * - Pinned to a single pool client (`clients[0]`) so the SCAN cursor
210
+ * is consistent; UNLINK batches can go through any client but
211
+ * using the same one keeps this simple and deterministic.
212
+ * - Returns the count of deleted keys for observability.
213
+ *
214
+ * Throws if `keyPrefix` is empty; caller should use `flushDb()` in
215
+ * that case (FLUSHDB is still the right tool for a no-prefix setup).
216
+ */
217
+ clearPrefixedKeys(): Promise<number>;
218
+ /**
219
+ * The configured keyPrefix, or empty string if none. Consumers (e.g.
220
+ * `clearAllCache`) branch on this to pick between scoped delete and
221
+ * FLUSHDB.
222
+ */
223
+ getKeyPrefix(): string;
224
+ isHealthy(): Promise<boolean>;
225
+ /**
226
+ * Read Redis used_memory in bytes via the INFO command. Returns 0 if the
227
+ * value cannot be parsed (e.g. permission-restricted environments).
228
+ */
229
+ getRedisUsedMemory(): Promise<number>;
230
+ getHealthMetrics(): {
231
+ isConnected: boolean;
232
+ circuitBreakerState: CircuitBreakerState;
233
+ totalOperations: number;
234
+ successfulOperations: number;
235
+ failedOperations: number;
236
+ averageLatency: number;
237
+ successRate: number;
238
+ connections: number;
239
+ errors: number;
240
+ lastOperationTime: number;
241
+ cacheHits: number;
242
+ cacheMisses: number;
243
+ hitRate: number;
244
+ };
245
+ /**
246
+ * Cleanly close every pool client. Errors on individual clients are
247
+ * logged but don't stop the others from quitting; the goal here is
248
+ * resource cleanup, not strict success.
249
+ */
250
+ disconnect(): Promise<void>;
251
+ /**
252
+ * Number of clients in the pool. Useful for tests and observability.
253
+ */
254
+ getPoolSize(): number;
255
+ }