redis-graph-cache 1.0.1 → 1.0.2

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.
@@ -51,8 +51,18 @@ class HydrationEngine {
51
51
  }
52
52
  // Create separate context for each entity to prevent cross-contamination
53
53
  const results = await Promise.all(ids.map((id) => this.hydrateEntity(entityType, id, options)));
54
- // Filter out null results (deleted entities)
55
- return results.filter((result) => result !== null);
54
+ // Filter out null results (deleted entities). A non-zero gap between
55
+ // requested ids and returned results usually means entity keys have
56
+ // expired while the list ZSET is still alive (TTL coherence skew).
57
+ const hydrated = results.filter((result) => result !== null);
58
+ const missing = results.length - hydrated.length;
59
+ if (missing > 0) {
60
+ console.warn(`[RedisGraphCache] hydrateEntities: ${missing} of ${results.length} ` +
61
+ `'${entityType}' entity keys were missing in Redis. ` +
62
+ `This usually means entity TTL <= list TTL — set entity TTL to at least ` +
63
+ `2\u00d7 the list TTL to prevent silent result truncation.`);
64
+ }
65
+ return hydrated;
56
66
  }
57
67
  /**
58
68
  * Create hydration context with limits and tracking
@@ -174,7 +184,9 @@ class HydrationEngine {
174
184
  async hydrateRelation(relationName, relationDef, normalizedEntity, hydratedEntity, context) {
175
185
  const relationIdField = (0, types_1.generateRelationIdField)(relationName, relationDef.kind);
176
186
  const relationIds = normalizedEntity[relationIdField];
177
- if (!relationIds) {
187
+ // Explicit missing/cleared check (not falsy): a stored reference id of
188
+ // 0 is a legitimate key and must hydrate, not resolve to null.
189
+ if (relationIds === null || relationIds === undefined) {
178
190
  // No relation data, set appropriate default
179
191
  hydratedEntity[relationName] = relationDef.kind === 'one' ? null : [];
180
192
  return;
@@ -226,8 +238,17 @@ class HydrationEngine {
226
238
  return this.hydrateRecursive(entityType, id, entityContext);
227
239
  });
228
240
  const results = await Promise.all(hydrationPromises);
229
- // Filter out null results (deleted entities)
230
- return results.filter((result) => result !== null);
241
+ // Filter out null results (deleted entities). Log when the gap is
242
+ // non-zero so TTL coherence skew is visible in application logs.
243
+ const hydrated = results.filter((result) => result !== null);
244
+ const missing = results.length - hydrated.length;
245
+ if (missing > 0) {
246
+ console.warn(`[RedisGraphCache] batchHydrateEntities: ${missing} of ${results.length} ` +
247
+ `'${entityType}' entity keys were missing in Redis. ` +
248
+ `This usually means entity TTL <= list TTL — set entity TTL to at least ` +
249
+ `2\u00d7 the list TTL to prevent silent result truncation.`);
250
+ }
251
+ return hydrated;
231
252
  }
232
253
  /**
233
254
  * Get a single entity from Redis. Hits/misses are tracked by the
@@ -50,7 +50,7 @@ export declare const SET_IF_EXISTS_SCRIPT = "\nif redis.call('EXISTS', KEYS[1])
50
50
  *
51
51
  * Returns: 1 if added, 0 if already present.
52
52
  */
53
- export declare const LIST_ADD_SCRIPT = "\nlocal current = redis.call('GET', KEYS[1])\nlocal ids\nif current then\n ids = cjson.decode(current)\n if type(ids) ~= 'table' then ids = {} end\nelse\n ids = {}\nend\nlocal needle = ARGV[1]\nfor i = 1, #ids do\n if tostring(ids[i]) == needle then return 0 end\nend\nlocal toInsert\nif ARGV[3] == \"1\" then\n toInsert = tonumber(needle)\n if toInsert == nil then toInsert = needle end\nelse\n toInsert = needle\nend\ntable.insert(ids, toInsert)\nlocal encoded = cjson.encode(ids)\nlocal ttl = tonumber(ARGV[2])\nif ttl and ttl > 0 then\n redis.call('SET', KEYS[1], encoded, 'EX', ttl)\nelse\n redis.call('SET', KEYS[1], encoded)\nend\nreturn 1\n";
53
+ export declare const LIST_ADD_SCRIPT = "\nlocal current = redis.call('GET', KEYS[1])\nlocal ids\nif current then\n ids = cjson.decode(current)\n if type(ids) ~= 'table' then ids = {} end\nelse\n ids = {}\nend\nlocal needle = ARGV[1]\nfor i = 1, #ids do\n if tostring(ids[i]) == needle then return 0 end\nend\nlocal toInsert\nif ARGV[3] == \"1\" then\n toInsert = tonumber(needle)\n if toInsert == nil then toInsert = needle end\nelse\n toInsert = needle\nend\ntable.insert(ids, toInsert)\nlocal encoded = cjson.encode(ids)\nlocal ttl = tonumber(ARGV[2])\nif ttl and ttl > 0 then\n redis.call('SET', KEYS[1], encoded, 'EX', ttl)\nelse\n -- No ttl for this write: PRESERVE the key's remaining TTL instead of\n -- stripping it (a bare SET clears expiry). Mirrors LIST_REMOVE, which\n -- already carries the remaining TTL across the rewrite.\n local remaining = redis.call('TTL', KEYS[1])\n if remaining and remaining > 0 then\n redis.call('SET', KEYS[1], encoded, 'EX', remaining)\n else\n redis.call('SET', KEYS[1], encoded)\n end\nend\nreturn 1\n";
54
54
  /**
55
55
  * Atomically remove an id from a JSON-array list value. If the resulting
56
56
  * list is empty, writes the literal "[]" (avoiding cjson's {} ambiguity).
@@ -66,11 +66,16 @@ export declare const LIST_REMOVE_SCRIPT = "\nlocal current = redis.call('GET', K
66
66
  * back-index update for cascade invalidation. Returns 1 if a new member
67
67
  * was added, 0 if the score was updated on an existing member.
68
68
  *
69
- * Trimming uses ZREMRANGEBYRANK to discard the lowest-scored members so
70
- * that exactly `maxSize` items remain. This works correctly whether the
71
- * list is being read newest-first (ZREVRANGE) or oldest-first (ZRANGE):
72
- * the lowest score is always the "oldest" by whatever score axis is in
73
- * use.
69
+ * Trimming discards the lowest-scored members so that exactly `maxSize`
70
+ * items remain i.e. the retention policy is "keep the highest scores"
71
+ * (newest, for timestamp scores). Note for oldest-first consumers: a
72
+ * capped list always retains the top of the score axis regardless of
73
+ * read direction.
74
+ *
75
+ * The trim explicitly never evicts the member being added: with score
76
+ * ties Redis orders members lexicographically, so a plain
77
+ * ZREMRANGEBYRANK 0..N could nondeterministically remove the member
78
+ * that was just ZADDed (e.g. many same-second timestamps).
74
79
  *
75
80
  * KEYS[1] = ZSET list key
76
81
  * KEYS[2] = membership back-index key (ignored when ARGV[5] = "0")
@@ -80,7 +85,7 @@ export declare const LIST_REMOVE_SCRIPT = "\nlocal current = redis.call('GET', K
80
85
  * ARGV[4] = max size (0 = no trim)
81
86
  * ARGV[5] = "1" to maintain back-index, "0" otherwise
82
87
  */
83
- export declare const ZLIST_ADD_SCRIPT = "\nlocal added = redis.call('ZADD', KEYS[1], ARGV[1], ARGV[2])\nif ARGV[5] == \"1\" then\n redis.call('SADD', KEYS[2], KEYS[1])\nend\nlocal maxSize = tonumber(ARGV[4])\nif maxSize and maxSize > 0 then\n local size = redis.call('ZCARD', KEYS[1])\n if size > maxSize then\n redis.call('ZREMRANGEBYRANK', KEYS[1], 0, size - maxSize - 1)\n end\nend\nlocal ttl = tonumber(ARGV[3])\nif ttl and ttl > 0 then\n redis.call('EXPIRE', KEYS[1], ttl)\n if ARGV[5] == \"1\" then\n -- Back-index TTL is set to a generous multiple of the list TTL so the\n -- index outlives any one list TTL refresh. Without this it could\n -- expire while the list still references it, leaving cascade\n -- invalidation unable to find tracked lists. 4x is empirically safe.\n redis.call('EXPIRE', KEYS[2], ttl * 4)\n end\nend\nreturn added\n";
88
+ export declare const ZLIST_ADD_SCRIPT = "\nlocal added = redis.call('ZADD', KEYS[1], ARGV[1], ARGV[2])\nif ARGV[5] == \"1\" then\n redis.call('SADD', KEYS[2], KEYS[1])\nend\nlocal maxSize = tonumber(ARGV[4])\nif maxSize and maxSize > 0 then\n local size = redis.call('ZCARD', KEYS[1])\n if size > maxSize then\n local toRemove = size - maxSize\n -- Fetch one extra candidate so we can skip the just-added member if\n -- a score tie placed it among the lowest ranks.\n local victims = redis.call('ZRANGE', KEYS[1], 0, toRemove)\n local removed = 0\n for i = 1, #victims do\n if removed >= toRemove then break end\n if victims[i] ~= ARGV[2] then\n redis.call('ZREM', KEYS[1], victims[i])\n removed = removed + 1\n end\n end\n end\nend\nlocal ttl = tonumber(ARGV[3])\nif ttl and ttl > 0 then\n redis.call('EXPIRE', KEYS[1], ttl)\n if ARGV[5] == \"1\" then\n -- Back-index TTL is set to a generous multiple of the list TTL so the\n -- index outlives any one list TTL refresh. Without this it could\n -- expire while the list still references it, leaving cascade\n -- invalidation unable to find tracked lists. 4x is empirically safe.\n redis.call('EXPIRE', KEYS[2], ttl * 4)\n end\nend\nreturn added\n";
84
89
  /**
85
90
  * Atomic ZREM with optional back-index cleanup. Returns 1 if removed, 0
86
91
  * if the member was not in the set.
@@ -126,7 +131,7 @@ export declare const SCRIPT_DEFINITIONS: readonly [{
126
131
  readonly numberOfKeys: 1;
127
132
  }, {
128
133
  readonly name: "rseListAdd";
129
- readonly script: "\nlocal current = redis.call('GET', KEYS[1])\nlocal ids\nif current then\n ids = cjson.decode(current)\n if type(ids) ~= 'table' then ids = {} end\nelse\n ids = {}\nend\nlocal needle = ARGV[1]\nfor i = 1, #ids do\n if tostring(ids[i]) == needle then return 0 end\nend\nlocal toInsert\nif ARGV[3] == \"1\" then\n toInsert = tonumber(needle)\n if toInsert == nil then toInsert = needle end\nelse\n toInsert = needle\nend\ntable.insert(ids, toInsert)\nlocal encoded = cjson.encode(ids)\nlocal ttl = tonumber(ARGV[2])\nif ttl and ttl > 0 then\n redis.call('SET', KEYS[1], encoded, 'EX', ttl)\nelse\n redis.call('SET', KEYS[1], encoded)\nend\nreturn 1\n";
134
+ readonly script: "\nlocal current = redis.call('GET', KEYS[1])\nlocal ids\nif current then\n ids = cjson.decode(current)\n if type(ids) ~= 'table' then ids = {} end\nelse\n ids = {}\nend\nlocal needle = ARGV[1]\nfor i = 1, #ids do\n if tostring(ids[i]) == needle then return 0 end\nend\nlocal toInsert\nif ARGV[3] == \"1\" then\n toInsert = tonumber(needle)\n if toInsert == nil then toInsert = needle end\nelse\n toInsert = needle\nend\ntable.insert(ids, toInsert)\nlocal encoded = cjson.encode(ids)\nlocal ttl = tonumber(ARGV[2])\nif ttl and ttl > 0 then\n redis.call('SET', KEYS[1], encoded, 'EX', ttl)\nelse\n -- No ttl for this write: PRESERVE the key's remaining TTL instead of\n -- stripping it (a bare SET clears expiry). Mirrors LIST_REMOVE, which\n -- already carries the remaining TTL across the rewrite.\n local remaining = redis.call('TTL', KEYS[1])\n if remaining and remaining > 0 then\n redis.call('SET', KEYS[1], encoded, 'EX', remaining)\n else\n redis.call('SET', KEYS[1], encoded)\n end\nend\nreturn 1\n";
130
135
  readonly numberOfKeys: 1;
131
136
  }, {
132
137
  readonly name: "rseListRemove";
@@ -134,7 +139,7 @@ export declare const SCRIPT_DEFINITIONS: readonly [{
134
139
  readonly numberOfKeys: 1;
135
140
  }, {
136
141
  readonly name: "rseZListAdd";
137
- readonly script: "\nlocal added = redis.call('ZADD', KEYS[1], ARGV[1], ARGV[2])\nif ARGV[5] == \"1\" then\n redis.call('SADD', KEYS[2], KEYS[1])\nend\nlocal maxSize = tonumber(ARGV[4])\nif maxSize and maxSize > 0 then\n local size = redis.call('ZCARD', KEYS[1])\n if size > maxSize then\n redis.call('ZREMRANGEBYRANK', KEYS[1], 0, size - maxSize - 1)\n end\nend\nlocal ttl = tonumber(ARGV[3])\nif ttl and ttl > 0 then\n redis.call('EXPIRE', KEYS[1], ttl)\n if ARGV[5] == \"1\" then\n -- Back-index TTL is set to a generous multiple of the list TTL so the\n -- index outlives any one list TTL refresh. Without this it could\n -- expire while the list still references it, leaving cascade\n -- invalidation unable to find tracked lists. 4x is empirically safe.\n redis.call('EXPIRE', KEYS[2], ttl * 4)\n end\nend\nreturn added\n";
142
+ readonly script: "\nlocal added = redis.call('ZADD', KEYS[1], ARGV[1], ARGV[2])\nif ARGV[5] == \"1\" then\n redis.call('SADD', KEYS[2], KEYS[1])\nend\nlocal maxSize = tonumber(ARGV[4])\nif maxSize and maxSize > 0 then\n local size = redis.call('ZCARD', KEYS[1])\n if size > maxSize then\n local toRemove = size - maxSize\n -- Fetch one extra candidate so we can skip the just-added member if\n -- a score tie placed it among the lowest ranks.\n local victims = redis.call('ZRANGE', KEYS[1], 0, toRemove)\n local removed = 0\n for i = 1, #victims do\n if removed >= toRemove then break end\n if victims[i] ~= ARGV[2] then\n redis.call('ZREM', KEYS[1], victims[i])\n removed = removed + 1\n end\n end\n end\nend\nlocal ttl = tonumber(ARGV[3])\nif ttl and ttl > 0 then\n redis.call('EXPIRE', KEYS[1], ttl)\n if ARGV[5] == \"1\" then\n -- Back-index TTL is set to a generous multiple of the list TTL so the\n -- index outlives any one list TTL refresh. Without this it could\n -- expire while the list still references it, leaving cascade\n -- invalidation unable to find tracked lists. 4x is empirically safe.\n redis.call('EXPIRE', KEYS[2], ttl * 4)\n end\nend\nreturn added\n";
138
143
  readonly numberOfKeys: 2;
139
144
  }, {
140
145
  readonly name: "rseZListRemove";
@@ -103,7 +103,15 @@ local ttl = tonumber(ARGV[2])
103
103
  if ttl and ttl > 0 then
104
104
  redis.call('SET', KEYS[1], encoded, 'EX', ttl)
105
105
  else
106
- redis.call('SET', KEYS[1], encoded)
106
+ -- No ttl for this write: PRESERVE the key's remaining TTL instead of
107
+ -- stripping it (a bare SET clears expiry). Mirrors LIST_REMOVE, which
108
+ -- already carries the remaining TTL across the rewrite.
109
+ local remaining = redis.call('TTL', KEYS[1])
110
+ if remaining and remaining > 0 then
111
+ redis.call('SET', KEYS[1], encoded, 'EX', remaining)
112
+ else
113
+ redis.call('SET', KEYS[1], encoded)
114
+ end
107
115
  end
108
116
  return 1
109
117
  `;
@@ -151,11 +159,16 @@ return 1
151
159
  * back-index update for cascade invalidation. Returns 1 if a new member
152
160
  * was added, 0 if the score was updated on an existing member.
153
161
  *
154
- * Trimming uses ZREMRANGEBYRANK to discard the lowest-scored members so
155
- * that exactly `maxSize` items remain. This works correctly whether the
156
- * list is being read newest-first (ZREVRANGE) or oldest-first (ZRANGE):
157
- * the lowest score is always the "oldest" by whatever score axis is in
158
- * use.
162
+ * Trimming discards the lowest-scored members so that exactly `maxSize`
163
+ * items remain i.e. the retention policy is "keep the highest scores"
164
+ * (newest, for timestamp scores). Note for oldest-first consumers: a
165
+ * capped list always retains the top of the score axis regardless of
166
+ * read direction.
167
+ *
168
+ * The trim explicitly never evicts the member being added: with score
169
+ * ties Redis orders members lexicographically, so a plain
170
+ * ZREMRANGEBYRANK 0..N could nondeterministically remove the member
171
+ * that was just ZADDed (e.g. many same-second timestamps).
159
172
  *
160
173
  * KEYS[1] = ZSET list key
161
174
  * KEYS[2] = membership back-index key (ignored when ARGV[5] = "0")
@@ -174,7 +187,18 @@ local maxSize = tonumber(ARGV[4])
174
187
  if maxSize and maxSize > 0 then
175
188
  local size = redis.call('ZCARD', KEYS[1])
176
189
  if size > maxSize then
177
- redis.call('ZREMRANGEBYRANK', KEYS[1], 0, size - maxSize - 1)
190
+ local toRemove = size - maxSize
191
+ -- Fetch one extra candidate so we can skip the just-added member if
192
+ -- a score tie placed it among the lowest ranks.
193
+ local victims = redis.call('ZRANGE', KEYS[1], 0, toRemove)
194
+ local removed = 0
195
+ for i = 1, #victims do
196
+ if removed >= toRemove then break end
197
+ if victims[i] ~= ARGV[2] then
198
+ redis.call('ZREM', KEYS[1], victims[i])
199
+ removed = removed + 1
200
+ end
201
+ end
178
202
  end
179
203
  end
180
204
  local ttl = tonumber(ARGV[3])
@@ -125,7 +125,9 @@ class NormalizationEngine {
125
125
  }
126
126
  const schema = this.schemaManager.getEntitySchema(entityType);
127
127
  const entityId = data[schema.id];
128
- if (!entityId) {
128
+ // Explicit check (not falsy): numeric id 0 is a legitimate primary key
129
+ // and must not be rejected as "missing".
130
+ if (entityId === undefined || entityId === null || entityId === '') {
129
131
  throw new types_1.InvalidOperationError('normalize', `Missing required ID field '${schema.id}' in entity '${entityType}'`, { entityType, data });
130
132
  }
131
133
  const entityKey = this.schemaManager.generateEntityKey(entityType, entityId);
@@ -174,9 +176,20 @@ class NormalizationEngine {
174
176
  const relationIdField = (0, types_1.generateRelationIdField)(relationName, relationDef.kind);
175
177
  if (relationDef.kind === 'one') {
176
178
  // One-to-one relationship
177
- if (relationData && typeof relationData === 'object') {
179
+ if (relationData === null) {
180
+ // Explicit null CLEARS the reference — same contract as regular
181
+ // fields ("explicit null is a real overwrite"). Without this, a
182
+ // caller switching a relation off (e.g. comment.ownerPage → null)
183
+ // leaves the old __rse_<name>Id in place forever via smart-merge,
184
+ // and reads keep hydrating the stale related entity.
185
+ parentEntity[relationIdField] = null;
186
+ const metadata = parentEntity[types_1.METADATA_FIELD];
187
+ metadata.relationshipIds[relationName] = null;
188
+ }
189
+ else if (relationData && typeof relationData === 'object') {
178
190
  const relatedId = relationData[relatedSchema.id];
179
- if (relatedId) {
191
+ // Explicit check: id 0 is a valid key and must not be skipped.
192
+ if (relatedId !== undefined && relatedId !== null && relatedId !== '') {
180
193
  parentEntity[relationIdField] = relatedId;
181
194
  // Store relationship ID in metadata
182
195
  const metadata = parentEntity[types_1.METADATA_FIELD];
@@ -185,28 +198,41 @@ class NormalizationEngine {
185
198
  await this.normalizeRecursive(relationDef.type, relationData, result, operationId, visited);
186
199
  }
187
200
  }
201
+ // undefined / field omitted: preserved by smart-merge (unchanged).
188
202
  }
189
203
  else if (relationDef.kind === 'many') {
190
204
  // One-to-many relationship
191
- if (Array.isArray(relationData)) {
205
+ if (relationData === null) {
206
+ // Explicit null clears the whole collection (stored as [] so
207
+ // hydration keeps returning an array).
208
+ parentEntity[relationIdField] = [];
209
+ const metadata = parentEntity[types_1.METADATA_FIELD];
210
+ metadata.relationshipIds[relationName] = [];
211
+ }
212
+ else if (Array.isArray(relationData)) {
192
213
  const relatedIds = [];
193
214
  for (const relatedItem of relationData) {
194
215
  if (relatedItem && typeof relatedItem === 'object') {
195
216
  const relatedId = relatedItem[relatedSchema.id];
196
- if (relatedId) {
217
+ // Explicit check: id 0 is a valid key and must not be skipped.
218
+ if (relatedId !== undefined &&
219
+ relatedId !== null &&
220
+ relatedId !== '') {
197
221
  relatedIds.push(relatedId);
198
222
  // Recursively normalize related entity
199
223
  await this.normalizeRecursive(relationDef.type, relatedItem, result, operationId, visited);
200
224
  }
201
225
  }
202
226
  }
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
- }
227
+ // Arrays have documented replace-entirely semantics, so an empty
228
+ // array must clear the collection too — the old `length > 0`
229
+ // guard made `[]` silently preserve stale members.
230
+ parentEntity[relationIdField] = relatedIds;
231
+ // Store relationship IDs in metadata
232
+ const metadata = parentEntity[types_1.METADATA_FIELD];
233
+ metadata.relationshipIds[relationName] = relatedIds;
209
234
  }
235
+ // undefined / field omitted: preserved by smart-merge (unchanged).
210
236
  }
211
237
  }
212
238
  /**
@@ -199,9 +199,12 @@ export declare class RedisConnectionManager {
199
199
  * owns and never blocks the Redis server on large keyspaces.
200
200
  *
201
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.
202
+ * - The `MATCH` pattern must include the prefix explicitly: ioredis
203
+ * only prefixes recognized KEY arguments, and SCAN's MATCH is a
204
+ * pattern, not a key it goes to the wire verbatim. (An earlier
205
+ * version assumed auto-prefixing and scanned the ENTIRE keyspace
206
+ * with `match: '*'`; the startsWith filter below kept that safe
207
+ * but O(total DB size) on shared Redis instances.)
205
208
  * - Redis returns the fully-prefixed keys. We strip the prefix
206
209
  * before calling `UNLINK`, because ioredis re-applies the prefix
207
210
  * on outbound commands — passing the raw prefixed keys would
@@ -140,13 +140,19 @@ class RedisConnectionManager {
140
140
  this.connectedFlags[idx] = true;
141
141
  this.healthMetrics.recordConnection();
142
142
  });
143
+ // The circuit breaker is deliberately NOT wired to socket events:
144
+ // it is driven exclusively by real operation outcomes in
145
+ // `executeOperation`. Wiring it here had two failure modes — a
146
+ // flapping socket's background 'error' events (fired every ~2s by
147
+ // the ioredis retryStrategy) tripped the breaker for the whole pool
148
+ // with zero user traffic, and each such event refreshed
149
+ // `lastFailureTime`, starving `shouldAttemptReset` so the breaker
150
+ // could never reach HALF_OPEN on its own.
143
151
  client.on('ready', () => {
144
152
  this.connectedFlags[idx] = true;
145
- this.circuitBreaker.onSuccess();
146
153
  });
147
154
  client.on('error', (error) => {
148
155
  this.healthMetrics.recordError();
149
- this.circuitBreaker.onFailure();
150
156
  console.error(`[RedisSchemaEngine] Redis client #${idx} error:`, error.message);
151
157
  });
152
158
  client.on('close', () => {
@@ -165,7 +171,14 @@ class RedisConnectionManager {
165
171
  async connect() {
166
172
  try {
167
173
  await Promise.all(this.clients.map(async (client, idx) => {
168
- await client.connect();
174
+ // ioredis rejects connect() unless the client is in 'wait'
175
+ // (lazyConnect) or 'end' state. Clients auto-connect at
176
+ // construction, so calling connect() on an already-connecting/
177
+ // connected client used to throw every time — making this
178
+ // method unusable. Skip clients that are already on their way.
179
+ if (client.status === 'wait' || client.status === 'end') {
180
+ await client.connect();
181
+ }
169
182
  this.connectedFlags[idx] = true;
170
183
  }));
171
184
  }
@@ -456,9 +469,12 @@ class RedisConnectionManager {
456
469
  * owns and never blocks the Redis server on large keyspaces.
457
470
  *
458
471
  * Implementation notes:
459
- * - `SCAN` with `{ match: '*' }` works correctly because ioredis
460
- * automatically prepends `keyPrefix` to the `MATCH` pattern on
461
- * the wire, so Redis only returns keys under our namespace.
472
+ * - The `MATCH` pattern must include the prefix explicitly: ioredis
473
+ * only prefixes recognized KEY arguments, and SCAN's MATCH is a
474
+ * pattern, not a key it goes to the wire verbatim. (An earlier
475
+ * version assumed auto-prefixing and scanned the ENTIRE keyspace
476
+ * with `match: '*'`; the startsWith filter below kept that safe
477
+ * but O(total DB size) on shared Redis instances.)
462
478
  * - Redis returns the fully-prefixed keys. We strip the prefix
463
479
  * before calling `UNLINK`, because ioredis re-applies the prefix
464
480
  * on outbound commands — passing the raw prefixed keys would
@@ -485,7 +501,7 @@ class RedisConnectionManager {
485
501
  // SCAN in batches of 500. Larger counts reduce round-trips but
486
502
  // increase per-call work on the Redis side; 500 is a safe middle
487
503
  // ground that performs well on keyspaces well past the 100k mark.
488
- const stream = client.scanStream({ match: '*', count: 500 });
504
+ const stream = client.scanStream({ match: `${prefix}*`, count: 500 });
489
505
  await new Promise((resolve, reject) => {
490
506
  stream.on('data', (keys) => {
491
507
  if (!keys || keys.length === 0)
@@ -572,9 +588,24 @@ class RedisConnectionManager {
572
588
  if (this.connectedFlags[idx]) {
573
589
  await client.quit();
574
590
  }
591
+ else {
592
+ // A client that is mid-reconnect (flag false) still owns
593
+ // active retry timers and a socket; skipping it left those
594
+ // alive and kept the Node event loop from exiting after
595
+ // disconnect(). Force-close it.
596
+ client.disconnect();
597
+ }
575
598
  }
576
599
  catch (error) {
577
600
  console.error(`[RedisSchemaEngine] Disconnect error on client #${idx}:`, error);
601
+ // quit() can fail on a broken connection; force-close so no
602
+ // retry timers survive.
603
+ try {
604
+ client.disconnect();
605
+ }
606
+ catch {
607
+ // already closed
608
+ }
578
609
  }
579
610
  finally {
580
611
  this.connectedFlags[idx] = false;
@@ -671,9 +702,29 @@ class RetryManager {
671
702
  'ETIMEDOUT',
672
703
  'ENOTFOUND',
673
704
  'ECONNREFUSED',
705
+ 'EPIPE',
674
706
  'Connection is closed',
707
+ 'Command timed out',
675
708
  ];
676
- return retryableMessages.some((msg) => error.message.includes(msg) || error.name.includes(msg));
709
+ // Operations reach this classifier already wrapped (executeOperation
710
+ // rethrows a RedisConnectionError whose message never contains the
711
+ // network error code — the raw ioredis error lives only in `.cause`).
712
+ // Matching only the top error's message/name made every real network
713
+ // failure look non-retryable, silently disabling retries entirely.
714
+ // Walk the cause chain and match message, name, and code per level.
715
+ let current = error;
716
+ let depth = 0;
717
+ while (current && depth < 5) {
718
+ const message = String(current.message ?? '');
719
+ const name = String(current.name ?? '');
720
+ const code = String(current.code ?? '');
721
+ if (retryableMessages.some((msg) => message.includes(msg) || name.includes(msg) || code === msg)) {
722
+ return true;
723
+ }
724
+ current = current.cause ?? current.originalError;
725
+ depth++;
726
+ }
727
+ return false;
677
728
  }
678
729
  sleep(ms) {
679
730
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -85,6 +85,12 @@ class SchemaManager {
85
85
  warnings: validationResult.warnings,
86
86
  });
87
87
  }
88
+ // Emit warnings even when the schema is valid. These are non-fatal
89
+ // design issues (e.g. TTL coherence skew) that will not prevent the
90
+ // cache from running but can cause subtle bugs in production.
91
+ for (const warning of validationResult.warnings) {
92
+ console.warn(`[RedisGraphCache] Schema warning: ${warning}`);
93
+ }
88
94
  }
89
95
  /**
90
96
  * Get entity schema by name
@@ -191,6 +197,23 @@ class SchemaManager {
191
197
  if (circularDeps.length > 0) {
192
198
  warnings.push(`Circular dependencies detected: ${circularDeps.join(', ')}`);
193
199
  }
200
+ // TTL coherence check: warn when an indexedList TTL >= its entity TTL.
201
+ // addIndexedListItem refreshes the ZSET TTL on every insert but does NOT
202
+ // refresh existing member entity TTLs. If both TTLs are equal, late
203
+ // inserts push the ZSET expiry forward while older entity keys expire at
204
+ // the original deadline — reads silently return fewer items than exist.
205
+ // Entity TTL should be at least 2× the list TTL to prevent this.
206
+ for (const [listName, listSchema] of this.indexedListSchemas) {
207
+ const entityTtl = this.resolveEntityTTL(listSchema.entityType);
208
+ const listTtl = listSchema.ttl ?? this.defaultTTL;
209
+ // ttl=0 means "no expiry" — no skew risk.
210
+ if (listTtl > 0 && entityTtl > 0 && entityTtl <= listTtl) {
211
+ warnings.push(`TTL coherence: indexed list '${listName}' has ttl=${listTtl}s but ` +
212
+ `its entity type '${listSchema.entityType}' has ttl=${entityTtl}s. ` +
213
+ `Entity TTL must be greater than list TTL (recommend >= 2×) to prevent ` +
214
+ `silent result truncation when the list ZSET outlives its member entity keys.`);
215
+ }
216
+ }
194
217
  return {
195
218
  isValid: errors.length === 0,
196
219
  errors,
@@ -117,41 +117,54 @@ function tagReviver(_key, value) {
117
117
  !(TAG in value)) {
118
118
  return value;
119
119
  }
120
- switch (value[TAG]) {
121
- case 'Date':
122
- // `null` in `v` means we serialized an Invalid Date; reconstruct
123
- // it so identity round-trips even for that weird case.
124
- return value.v === null ? new Date(NaN) : new Date(value.v);
125
- case 'BigInt':
126
- return BigInt(value.v);
127
- case 'Map':
128
- return new Map(value.v);
129
- case 'Set':
130
- return new Set(value.v);
131
- case 'RegExp':
132
- return new RegExp(value.src, value.flags);
133
- case 'Buffer':
134
- // Guarded for non-Node environments. If Buffer is unavailable
135
- // we return the raw envelope so the consumer at least sees the
136
- // base64 payload rather than getting `undefined`.
137
- if (typeof Buffer !== 'undefined') {
138
- return Buffer.from(value.v, 'base64');
139
- }
140
- return value;
141
- case 'Number':
142
- if (value.v === 'NaN')
143
- return NaN;
144
- if (value.v === 'Infinity')
145
- return Infinity;
146
- if (value.v === '-Infinity')
147
- return -Infinity;
148
- return Number(value.v);
149
- default:
150
- // Unknown tag — return the envelope as-is. This is forward
151
- // compatible: a future engine version may add new tags and
152
- // older readers will simply pass them through instead of
153
- // crashing.
154
- return value;
120
+ // The tag guard above only proves the object LOOKS like an engine
121
+ // envelope. User data stored inside an `object`-typed field can contain
122
+ // a lookalike (e.g. `{ __rse_t: 'BigInt', v: 'abc' }`) schema
123
+ // validation rejects `__rse_`-prefixed FIELD NAMES, but not nested
124
+ // object contents. A malformed lookalike must never throw out of the
125
+ // reviver: that would abort JSON.parse and make the whole entity
126
+ // permanently unreadable. On any revival failure, return the raw
127
+ // envelope so the caller at least sees the stored data.
128
+ try {
129
+ switch (value[TAG]) {
130
+ case 'Date':
131
+ // `null` in `v` means we serialized an Invalid Date; reconstruct
132
+ // it so identity round-trips even for that weird case.
133
+ return value.v === null ? new Date(NaN) : new Date(value.v);
134
+ case 'BigInt':
135
+ return BigInt(value.v);
136
+ case 'Map':
137
+ return new Map(value.v);
138
+ case 'Set':
139
+ return new Set(value.v);
140
+ case 'RegExp':
141
+ return new RegExp(value.src, value.flags);
142
+ case 'Buffer':
143
+ // Guarded for non-Node environments. If Buffer is unavailable
144
+ // we return the raw envelope so the consumer at least sees the
145
+ // base64 payload rather than getting `undefined`.
146
+ if (typeof Buffer !== 'undefined') {
147
+ return Buffer.from(value.v, 'base64');
148
+ }
149
+ return value;
150
+ case 'Number':
151
+ if (value.v === 'NaN')
152
+ return NaN;
153
+ if (value.v === 'Infinity')
154
+ return Infinity;
155
+ if (value.v === '-Infinity')
156
+ return -Infinity;
157
+ return Number(value.v);
158
+ default:
159
+ // Unknown tag — return the envelope as-is. This is forward
160
+ // compatible: a future engine version may add new tags and
161
+ // older readers will simply pass them through instead of
162
+ // crashing.
163
+ return value;
164
+ }
165
+ }
166
+ catch {
167
+ return value;
155
168
  }
156
169
  }
157
170
  /**
@@ -90,8 +90,17 @@ class RedisGraphCache {
90
90
  // that value wins for that key regardless of cascade rules. Every
91
91
  // other key still goes through `base`, so embedded children keep
92
92
  // their schema TTL unless `cascadeTTL` lifts them.
93
- if (overrideKey === undefined || overrideTtl === undefined)
93
+ //
94
+ // NaN / negative overrides are ignored per the documented contract
95
+ // ("falls back to schema TTL") — previously a NaN ttl leaked through
96
+ // here and the write landed with NO expiry, silently overriding the
97
+ // schema TTL.
98
+ if (overrideKey === undefined ||
99
+ overrideTtl === undefined ||
100
+ !Number.isFinite(overrideTtl) ||
101
+ overrideTtl < 0) {
94
102
  return base;
103
+ }
95
104
  return (key) => (key === overrideKey ? overrideTtl : base(key));
96
105
  }
97
106
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "redis-graph-cache",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "A TypeScript-first Redis data layer with schema-driven normalization, relationship management, and graph-based hydration for high-performance Node.js applications.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",