redis-graph-cache 1.0.1 → 1.0.3
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/dist/core/hydration-engine.js +42 -6
- package/dist/core/lua-scripts.d.ts +14 -9
- package/dist/core/lua-scripts.js +31 -7
- package/dist/core/normalization-engine.js +37 -11
- package/dist/core/redis-connection-manager.d.ts +6 -3
- package/dist/core/redis-connection-manager.js +59 -8
- package/dist/core/schema-manager.js +23 -0
- package/dist/core/serializer.js +48 -35
- package/dist/redis-graph-cache.js +10 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
@@ -162,7 +172,22 @@ class HydrationEngine {
|
|
|
162
172
|
if (context.excludeRelations?.includes(relationName)) {
|
|
163
173
|
continue;
|
|
164
174
|
}
|
|
165
|
-
|
|
175
|
+
// Fork the visited-set per relation branch, synchronously, before any
|
|
176
|
+
// async work starts. `visitedEntities` tracks the *current path* for
|
|
177
|
+
// cycle detection (add on enter, delete on exit in `hydrateRecursive`).
|
|
178
|
+
// Sibling relations are hydrated concurrently (Promise.all below), so if
|
|
179
|
+
// they shared one Set, one branch's transient in-progress entries would
|
|
180
|
+
// leak into a sibling's view — making a shared entity referenced by both
|
|
181
|
+
// a parent and its child collection (a diamond, not a cycle) resolve to a
|
|
182
|
+
// `$ref` stub instead of the full object. A per-branch clone captures
|
|
183
|
+
// only the ancestor path, so real cycles down a branch are still caught
|
|
184
|
+
// while concurrent siblings stay isolated. This mirrors the per-item
|
|
185
|
+
// clone `batchHydrateEntities` already performs for `many` relations.
|
|
186
|
+
const branchContext = {
|
|
187
|
+
...context,
|
|
188
|
+
visitedEntities: new Set(context.visitedEntities),
|
|
189
|
+
};
|
|
190
|
+
const promise = this.hydrateRelation(relationName, relationDef, normalizedEntity, hydratedEntity, branchContext);
|
|
166
191
|
relationPromises.push(promise);
|
|
167
192
|
}
|
|
168
193
|
// Execute all relationship hydrations in parallel
|
|
@@ -174,7 +199,9 @@ class HydrationEngine {
|
|
|
174
199
|
async hydrateRelation(relationName, relationDef, normalizedEntity, hydratedEntity, context) {
|
|
175
200
|
const relationIdField = (0, types_1.generateRelationIdField)(relationName, relationDef.kind);
|
|
176
201
|
const relationIds = normalizedEntity[relationIdField];
|
|
177
|
-
|
|
202
|
+
// Explicit missing/cleared check (not falsy): a stored reference id of
|
|
203
|
+
// 0 is a legitimate key and must hydrate, not resolve to null.
|
|
204
|
+
if (relationIds === null || relationIds === undefined) {
|
|
178
205
|
// No relation data, set appropriate default
|
|
179
206
|
hydratedEntity[relationName] = relationDef.kind === 'one' ? null : [];
|
|
180
207
|
return;
|
|
@@ -226,8 +253,17 @@ class HydrationEngine {
|
|
|
226
253
|
return this.hydrateRecursive(entityType, id, entityContext);
|
|
227
254
|
});
|
|
228
255
|
const results = await Promise.all(hydrationPromises);
|
|
229
|
-
// Filter out null results (deleted entities)
|
|
230
|
-
|
|
256
|
+
// Filter out null results (deleted entities). Log when the gap is
|
|
257
|
+
// non-zero so TTL coherence skew is visible in application logs.
|
|
258
|
+
const hydrated = results.filter((result) => result !== null);
|
|
259
|
+
const missing = results.length - hydrated.length;
|
|
260
|
+
if (missing > 0) {
|
|
261
|
+
console.warn(`[RedisGraphCache] batchHydrateEntities: ${missing} of ${results.length} ` +
|
|
262
|
+
`'${entityType}' entity keys were missing in Redis. ` +
|
|
263
|
+
`This usually means entity TTL <= list TTL — set entity TTL to at least ` +
|
|
264
|
+
`2\u00d7 the list TTL to prevent silent result truncation.`);
|
|
265
|
+
}
|
|
266
|
+
return hydrated;
|
|
231
267
|
}
|
|
232
268
|
/**
|
|
233
269
|
* 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
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
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('
|
|
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('
|
|
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";
|
package/dist/core/lua-scripts.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
* - `
|
|
203
|
-
*
|
|
204
|
-
*
|
|
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
|
-
|
|
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
|
-
* - `
|
|
460
|
-
*
|
|
461
|
-
*
|
|
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:
|
|
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
|
-
|
|
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,
|
package/dist/core/serializer.js
CHANGED
|
@@ -117,41 +117,54 @@ function tagReviver(_key, value) {
|
|
|
117
117
|
!(TAG in value)) {
|
|
118
118
|
return value;
|
|
119
119
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "1.0.3",
|
|
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",
|