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.
- package/LICENSE +21 -0
- package/README.md +857 -0
- package/dist/core/compression.d.ts +58 -0
- package/dist/core/compression.js +115 -0
- package/dist/core/hydration-engine.d.ts +68 -0
- package/dist/core/hydration-engine.js +288 -0
- package/dist/core/index.d.ts +13 -0
- package/dist/core/index.js +29 -0
- package/dist/core/lua-scripts.d.ts +148 -0
- package/dist/core/lua-scripts.js +259 -0
- package/dist/core/normalization-engine.d.ts +76 -0
- package/dist/core/normalization-engine.js +286 -0
- package/dist/core/redis-connection-manager.d.ts +255 -0
- package/dist/core/redis-connection-manager.js +745 -0
- package/dist/core/schema-manager.d.ts +133 -0
- package/dist/core/schema-manager.js +432 -0
- package/dist/core/serializer.d.ts +69 -0
- package/dist/core/serializer.js +187 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +32 -0
- package/dist/redis-graph-cache.d.ts +302 -0
- package/dist/redis-graph-cache.js +839 -0
- package/dist/types/config.types.d.ts +138 -0
- package/dist/types/config.types.js +5 -0
- package/dist/types/error.types.d.ts +54 -0
- package/dist/types/error.types.js +89 -0
- package/dist/types/index.d.ts +8 -0
- package/dist/types/index.js +29 -0
- package/dist/types/internal.types.d.ts +67 -0
- package/dist/types/internal.types.js +73 -0
- package/dist/types/operation.types.d.ts +101 -0
- package/dist/types/operation.types.js +5 -0
- package/dist/types/schema.types.d.ts +104 -0
- package/dist/types/schema.types.js +5 -0
- package/package.json +44 -0
|
@@ -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
|
+
}
|