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,839 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Redis Schema Engine - Main class providing the public API
|
|
4
|
+
*/
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.RedisGraphCache = void 0;
|
|
7
|
+
const crypto_1 = require("crypto");
|
|
8
|
+
const core_1 = require("./core");
|
|
9
|
+
const types_1 = require("./types");
|
|
10
|
+
class RedisGraphCache {
|
|
11
|
+
constructor(schema, config = {}) {
|
|
12
|
+
this.activeOperations = 0;
|
|
13
|
+
/**
|
|
14
|
+
* Resolve the TTL for a given normalized entity key. The key format is
|
|
15
|
+
* always `entityType:id` so the prefix tells us which schema to consult.
|
|
16
|
+
*/
|
|
17
|
+
this.resolveEntityKeyTTL = (key) => {
|
|
18
|
+
const idx = key.indexOf(':');
|
|
19
|
+
if (idx <= 0)
|
|
20
|
+
return 0;
|
|
21
|
+
const entityType = key.slice(0, idx);
|
|
22
|
+
if (!this.schemaManager.hasEntitySchema(entityType))
|
|
23
|
+
return 0;
|
|
24
|
+
return this.schemaManager.resolveEntityTTL(entityType);
|
|
25
|
+
};
|
|
26
|
+
this.config = this.mergeWithDefaults(config);
|
|
27
|
+
this.initializeComponents(schema);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Wrap a public operation with active-counter bookkeeping. Lets the
|
|
31
|
+
* health endpoint report a real (not stubbed) `activeOperations` value.
|
|
32
|
+
*/
|
|
33
|
+
async tracked(fn) {
|
|
34
|
+
this.activeOperations++;
|
|
35
|
+
try {
|
|
36
|
+
return await fn();
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
this.activeOperations--;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Build a TTL resolver for a single write call that, when `cascadeTTL`
|
|
44
|
+
* is true, applies a floor: every entity written in this call uses at
|
|
45
|
+
* least `parentTtl` even if its own schema TTL is shorter. This is the
|
|
46
|
+
* fix for the "parent post (1h) embeds short-TTL category (1m); after
|
|
47
|
+
* 1m the post hydrates without its category" coherence bug.
|
|
48
|
+
*
|
|
49
|
+
* Special cases:
|
|
50
|
+
* - parentTtl = 0 (no expiry on parent): children also get 0,
|
|
51
|
+
* guaranteeing they outlive a never-expiring parent.
|
|
52
|
+
* - cascadeTTL = false (default): returns the unmodified resolver,
|
|
53
|
+
* so existing behaviour is bit-for-bit preserved.
|
|
54
|
+
* - parentTtl negative or NaN: ignored, falls back to per-key TTL.
|
|
55
|
+
*/
|
|
56
|
+
buildTTLResolver(cascadeTTL, parentTtl, overrideKey, overrideTtl, forceTTL) {
|
|
57
|
+
// forceTTL is the strongest mode: every key in this call gets
|
|
58
|
+
// exactly `parentTtl`, ignoring its own schema TTL and ignoring
|
|
59
|
+
// the cascade-as-floor logic. Use this when the caller explicitly
|
|
60
|
+
// wants to pin the entire write to a uniform TTL (e.g. shortening
|
|
61
|
+
// a whole batch for testing, or matching an external eviction
|
|
62
|
+
// policy). Negative / NaN parentTtl falls through to base rules.
|
|
63
|
+
if (forceTTL && Number.isFinite(parentTtl) && parentTtl >= 0) {
|
|
64
|
+
return () => parentTtl;
|
|
65
|
+
}
|
|
66
|
+
// Base resolver: cascade rules without any per-key override.
|
|
67
|
+
const base = (() => {
|
|
68
|
+
if (!cascadeTTL)
|
|
69
|
+
return this.resolveEntityKeyTTL;
|
|
70
|
+
if (parentTtl === 0) {
|
|
71
|
+
// Parent has no expiry; every child must also have no expiry
|
|
72
|
+
// to keep the graph consistent under the cascade contract.
|
|
73
|
+
return () => 0;
|
|
74
|
+
}
|
|
75
|
+
if (!Number.isFinite(parentTtl) || parentTtl < 0) {
|
|
76
|
+
return this.resolveEntityKeyTTL;
|
|
77
|
+
}
|
|
78
|
+
return (key) => {
|
|
79
|
+
const ownTtl = this.resolveEntityKeyTTL(key);
|
|
80
|
+
// 0 means "no expiry". Treat it as effectively infinite when
|
|
81
|
+
// comparing against parentTtl so we never demote an explicitly
|
|
82
|
+
// persistent key to a shorter TTL.
|
|
83
|
+
if (ownTtl === 0)
|
|
84
|
+
return 0;
|
|
85
|
+
return Math.max(ownTtl, parentTtl);
|
|
86
|
+
};
|
|
87
|
+
})();
|
|
88
|
+
// When the caller pinned an explicit TTL for a specific key
|
|
89
|
+
// (typically the root entity of a `writeEntity({ ttl })` call),
|
|
90
|
+
// that value wins for that key regardless of cascade rules. Every
|
|
91
|
+
// other key still goes through `base`, so embedded children keep
|
|
92
|
+
// their schema TTL unless `cascadeTTL` lifts them.
|
|
93
|
+
if (overrideKey === undefined || overrideTtl === undefined)
|
|
94
|
+
return base;
|
|
95
|
+
return (key) => (key === overrideKey ? overrideTtl : base(key));
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Runtime helper for the dual positional/object-style public API.
|
|
99
|
+
* `typeof x === 'object' && x !== null` distinguishes the object
|
|
100
|
+
* form from the positional-string-first form, since `entityType`
|
|
101
|
+
* and `listType` are always strings.
|
|
102
|
+
*/
|
|
103
|
+
isArgsObject(x) {
|
|
104
|
+
return typeof x === 'object' && x !== null;
|
|
105
|
+
}
|
|
106
|
+
async writeEntity(a, b, c) {
|
|
107
|
+
const { entityType, data, ttl, cascadeTTL, forceTTL } = this.isArgsObject(a)
|
|
108
|
+
? a
|
|
109
|
+
: {
|
|
110
|
+
entityType: a,
|
|
111
|
+
data: b,
|
|
112
|
+
ttl: c?.ttl,
|
|
113
|
+
cascadeTTL: c?.cascadeTTL,
|
|
114
|
+
forceTTL: c?.forceTTL,
|
|
115
|
+
};
|
|
116
|
+
return this.tracked(async () => {
|
|
117
|
+
try {
|
|
118
|
+
const normalizationResult = await this.normalizationEngine.normalizeEntity(entityType, data);
|
|
119
|
+
// Effective parent TTL drives both (a) the cascade floor when
|
|
120
|
+
// cascadeTTL is on and (b) the override for the root key.
|
|
121
|
+
const schemaTtl = this.schemaManager.resolveEntityTTL(entityType);
|
|
122
|
+
const effectiveParentTtl = ttl ?? schemaTtl;
|
|
123
|
+
const resolver = this.buildTTLResolver(cascadeTTL, effectiveParentTtl,
|
|
124
|
+
// Pin the override only when the caller supplied ttl; otherwise
|
|
125
|
+
// the root entity goes through the usual per-key resolver.
|
|
126
|
+
ttl !== undefined ? normalizationResult.primaryEntityKey : undefined, ttl, forceTTL);
|
|
127
|
+
return await this.normalizationEngine.writeNormalizedEntities(normalizationResult, resolver);
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
if (error instanceof types_1.InvalidOperationError)
|
|
131
|
+
throw error;
|
|
132
|
+
throw new types_1.InvalidOperationError('writeEntity', `Failed to write entity '${String(entityType)}'`, { entityType, error: error.message }, this.generateRequestId());
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Read and hydrate an entity with all its relationships
|
|
138
|
+
*/
|
|
139
|
+
async readEntity(entityType, id, options = {}) {
|
|
140
|
+
return this.tracked(async () => {
|
|
141
|
+
try {
|
|
142
|
+
return await this.hydrationEngine.hydrateEntity(entityType, id, options);
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
throw new types_1.InvalidOperationError('readEntity', `Failed to read entity '${String(entityType)}' with id '${id}'`, { entityType, id, error: error.message }, this.generateRequestId());
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Delete an entity from Redis
|
|
151
|
+
*/
|
|
152
|
+
async deleteEntity(entityType, id) {
|
|
153
|
+
return this.tracked(async () => {
|
|
154
|
+
try {
|
|
155
|
+
const entityKey = this.schemaManager.generateEntityKey(entityType, id);
|
|
156
|
+
const result = await this.connectionManager.del(entityKey);
|
|
157
|
+
return result > 0;
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
throw new types_1.InvalidOperationError('deleteEntity', `Failed to delete entity '${String(entityType)}' with id '${id}'`, { entityType, id, error: error.message }, this.generateRequestId());
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
async updateEntityIfExists(a, b, c) {
|
|
165
|
+
const { entityType, data, ttl, cascadeTTL, forceTTL } = this.isArgsObject(a)
|
|
166
|
+
? a
|
|
167
|
+
: {
|
|
168
|
+
entityType: a,
|
|
169
|
+
data: b,
|
|
170
|
+
ttl: c?.ttl,
|
|
171
|
+
cascadeTTL: c?.cascadeTTL,
|
|
172
|
+
forceTTL: c?.forceTTL,
|
|
173
|
+
};
|
|
174
|
+
return this.tracked(async () => {
|
|
175
|
+
try {
|
|
176
|
+
const entitySchema = this.schemaManager.getEntitySchema(entityType);
|
|
177
|
+
const entityId = data[entitySchema.id];
|
|
178
|
+
if (entityId === undefined || entityId === null) {
|
|
179
|
+
throw new types_1.InvalidOperationError('updateEntityIfExists', `Missing ID field '${entitySchema.id}' in entity data`, { entityType, data });
|
|
180
|
+
}
|
|
181
|
+
// EXISTS is cheaper than GET; we only need presence here. There is
|
|
182
|
+
// an unavoidable but harmless race window: the key could be evicted
|
|
183
|
+
// between EXISTS and the subsequent atomic write. The CAS path used
|
|
184
|
+
// by writeEntity will recover correctly in either outcome.
|
|
185
|
+
const entityKey = this.schemaManager.generateEntityKey(entityType, entityId);
|
|
186
|
+
const exists = await this.connectionManager.exists(entityKey);
|
|
187
|
+
if (exists === 0)
|
|
188
|
+
return null;
|
|
189
|
+
return await this.writeEntity({
|
|
190
|
+
entityType: entityType,
|
|
191
|
+
data,
|
|
192
|
+
ttl,
|
|
193
|
+
cascadeTTL,
|
|
194
|
+
forceTTL,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
if (error instanceof types_1.InvalidOperationError)
|
|
199
|
+
throw error;
|
|
200
|
+
throw new types_1.InvalidOperationError('updateEntityIfExists', `Failed to update entity '${String(entityType)}'`, { entityType, data, error: error.message }, this.generateRequestId());
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
async writeList(a, b, c, d) {
|
|
205
|
+
const { listType, params, items, ttl, cascadeTTL, forceTTL } = this.isArgsObject(a)
|
|
206
|
+
? a
|
|
207
|
+
: {
|
|
208
|
+
listType: a,
|
|
209
|
+
params: b,
|
|
210
|
+
items: c,
|
|
211
|
+
ttl: d?.ttl,
|
|
212
|
+
cascadeTTL: d?.cascadeTTL,
|
|
213
|
+
forceTTL: d?.forceTTL,
|
|
214
|
+
};
|
|
215
|
+
return this.tracked(async () => {
|
|
216
|
+
try {
|
|
217
|
+
const listSchema = this.schemaManager.getListSchema(listType);
|
|
218
|
+
const operationId = this.generateOperationId();
|
|
219
|
+
const ids = items.map((item) => {
|
|
220
|
+
const id = item[listSchema.idField];
|
|
221
|
+
if (id === undefined || id === null) {
|
|
222
|
+
throw new types_1.InvalidOperationError('writeList', `Missing ID field '${listSchema.idField}' in list item`, { listType, item });
|
|
223
|
+
}
|
|
224
|
+
return id;
|
|
225
|
+
});
|
|
226
|
+
const listKey = this.schemaManager.generateListKey(listType, ...Object.values(params));
|
|
227
|
+
// Normalize all items into a single combined plan so we issue one
|
|
228
|
+
// batch of CAS writes instead of N sequential round-trips. Each
|
|
229
|
+
// entity still gets its TTL applied via resolveEntityKeyTTL.
|
|
230
|
+
const combined = new Map();
|
|
231
|
+
for (const item of items) {
|
|
232
|
+
const result = await this.normalizationEngine.normalizeEntity(listSchema.entityType, item, operationId);
|
|
233
|
+
for (const [k, v] of result.normalizedEntities) {
|
|
234
|
+
combined.set(k, v);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// For cascadeTTL, the "parent" of a writeList call is the list
|
|
238
|
+
// itself; its TTL becomes the floor for every embedded entity
|
|
239
|
+
// so embedded relations (e.g. a Category embedded inside posts)
|
|
240
|
+
// cannot expire before the list does. `ttl` (if supplied)
|
|
241
|
+
// overrides the list's schema TTL for this call, and therefore
|
|
242
|
+
// also becomes the new cascade floor.
|
|
243
|
+
const listTtl = ttl ?? this.schemaManager.resolveListTTL(listType);
|
|
244
|
+
const resolver = this.buildTTLResolver(cascadeTTL, listTtl, undefined, undefined, forceTTL);
|
|
245
|
+
if (combined.size > 0) {
|
|
246
|
+
await this.normalizationEngine.writeNormalizedEntities({
|
|
247
|
+
normalizedEntities: combined,
|
|
248
|
+
primaryEntityKey: listKey,
|
|
249
|
+
operationId,
|
|
250
|
+
}, resolver);
|
|
251
|
+
}
|
|
252
|
+
// List body is a JSON array of ids; written with the list-level TTL.
|
|
253
|
+
await this.connectionManager.set(listKey, JSON.stringify(ids), listTtl);
|
|
254
|
+
return {
|
|
255
|
+
success: true,
|
|
256
|
+
key: listKey,
|
|
257
|
+
ids,
|
|
258
|
+
operationId,
|
|
259
|
+
timestamp: Date.now(),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
if (error instanceof types_1.InvalidOperationError)
|
|
264
|
+
throw error;
|
|
265
|
+
throw new types_1.InvalidOperationError('writeList', `Failed to write list '${String(listType)}'`, { listType, params, error: error.message }, this.generateRequestId());
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Read and hydrate a list of entities
|
|
271
|
+
*/
|
|
272
|
+
async readList(listType, params, options = {}) {
|
|
273
|
+
return this.tracked(async () => {
|
|
274
|
+
try {
|
|
275
|
+
const listSchema = this.schemaManager.getListSchema(listType);
|
|
276
|
+
const listKey = this.schemaManager.generateListKey(listType, ...Object.values(params));
|
|
277
|
+
const listData = await this.connectionManager.get(listKey);
|
|
278
|
+
if (!listData)
|
|
279
|
+
return [];
|
|
280
|
+
let ids;
|
|
281
|
+
try {
|
|
282
|
+
ids = JSON.parse(listData);
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
return [];
|
|
286
|
+
}
|
|
287
|
+
if (!Array.isArray(ids))
|
|
288
|
+
return [];
|
|
289
|
+
return await this.hydrationEngine.hydrateEntities(listSchema.entityType, ids, options);
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
throw new types_1.InvalidOperationError('readList', `Failed to read list '${String(listType)}'`, { listType, params, error: error.message }, this.generateRequestId());
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Delete a list from Redis
|
|
298
|
+
*/
|
|
299
|
+
async deleteList(listType, params) {
|
|
300
|
+
return this.tracked(async () => {
|
|
301
|
+
try {
|
|
302
|
+
const listKey = this.schemaManager.generateListKey(listType, ...Object.values(params));
|
|
303
|
+
const result = await this.connectionManager.del(listKey);
|
|
304
|
+
return result > 0;
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
throw new types_1.InvalidOperationError('deleteList', `Failed to delete list '${String(listType)}'`, { listType, params, error: error.message }, this.generateRequestId());
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
async addListItem(a, b, c, d) {
|
|
312
|
+
const { listType, params, entityData, ttl, cascadeTTL, forceTTL } = this.isArgsObject(a)
|
|
313
|
+
? a
|
|
314
|
+
: {
|
|
315
|
+
listType: a,
|
|
316
|
+
params: b,
|
|
317
|
+
entityData: c,
|
|
318
|
+
ttl: d?.ttl,
|
|
319
|
+
cascadeTTL: d?.cascadeTTL,
|
|
320
|
+
forceTTL: d?.forceTTL,
|
|
321
|
+
};
|
|
322
|
+
return this.tracked(async () => {
|
|
323
|
+
try {
|
|
324
|
+
const listSchema = this.schemaManager.getListSchema(listType);
|
|
325
|
+
const entityId = entityData[listSchema.idField];
|
|
326
|
+
if (entityId === undefined || entityId === null) {
|
|
327
|
+
throw new types_1.InvalidOperationError('addListItem', `Missing ID field '${listSchema.idField}' in entity data`, { listType, entityData });
|
|
328
|
+
}
|
|
329
|
+
// Step 1: write/refresh the entity itself (with full normalization).
|
|
330
|
+
// `ttl` here overrides the ENTITY's TTL (scope: the thing being
|
|
331
|
+
// added), not the list key's TTL — the list key is shared state
|
|
332
|
+
// and can't sensibly be per-item-overridden.
|
|
333
|
+
await this.writeEntity({
|
|
334
|
+
entityType: listSchema.entityType,
|
|
335
|
+
data: entityData,
|
|
336
|
+
ttl,
|
|
337
|
+
cascadeTTL,
|
|
338
|
+
forceTTL,
|
|
339
|
+
});
|
|
340
|
+
// Step 2: atomically append to the list via Lua. This replaces the
|
|
341
|
+
// previous read-modify-write that lost concurrent inserts.
|
|
342
|
+
const listKey = this.schemaManager.generateListKey(listType, ...Object.values(params));
|
|
343
|
+
const listTtl = this.schemaManager.resolveListTTL(listType);
|
|
344
|
+
return await this.connectionManager.listAdd(listKey, entityId, listTtl);
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
if (error instanceof types_1.InvalidOperationError)
|
|
348
|
+
throw error;
|
|
349
|
+
throw new types_1.InvalidOperationError('addListItem', `Failed to add item to list '${String(listType)}'`, { listType, params, entityData, error: error.message }, this.generateRequestId());
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Remove an item from a list (and optionally delete the entity)
|
|
355
|
+
*/
|
|
356
|
+
async removeListItem(listType, params, entityId, options = {}) {
|
|
357
|
+
return this.tracked(async () => {
|
|
358
|
+
try {
|
|
359
|
+
const listSchema = this.schemaManager.getListSchema(listType);
|
|
360
|
+
const listKey = this.schemaManager.generateListKey(listType, ...Object.values(params));
|
|
361
|
+
// Atomic remove via Lua so concurrent removes/adds don't race.
|
|
362
|
+
const removed = await this.connectionManager.listRemove(listKey, entityId);
|
|
363
|
+
if (removed && options.deleteEntity) {
|
|
364
|
+
await this.deleteEntity(listSchema.entityType, entityId);
|
|
365
|
+
}
|
|
366
|
+
return removed;
|
|
367
|
+
}
|
|
368
|
+
catch (error) {
|
|
369
|
+
throw new types_1.InvalidOperationError('removeListItem', `Failed to remove item from list '${String(listType)}'`, { listType, params, entityId, error: error.message }, this.generateRequestId());
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
// =====================================================
|
|
374
|
+
// INDEXED LIST OPERATIONS (ZSET-backed)
|
|
375
|
+
// =====================================================
|
|
376
|
+
/**
|
|
377
|
+
* Resolve the ZSET score for an entity from its schema's `scoreField`,
|
|
378
|
+
* with fallbacks: numeric strings, ISO timestamps via `Date.parse`, or
|
|
379
|
+
* insertion time when no `scoreField` is configured. Throws on values
|
|
380
|
+
* that cannot be coerced to a finite number, since silently scoring at
|
|
381
|
+
* 0 would scramble feed ordering.
|
|
382
|
+
*/
|
|
383
|
+
resolveIndexedListScore(schema, entity) {
|
|
384
|
+
if (!schema.scoreField)
|
|
385
|
+
return Date.now();
|
|
386
|
+
const raw = entity[schema.scoreField];
|
|
387
|
+
if (raw === undefined || raw === null) {
|
|
388
|
+
throw new types_1.InvalidOperationError('resolveScore', `Entity missing scoreField '${schema.scoreField}'`, { entity }, this.generateRequestId());
|
|
389
|
+
}
|
|
390
|
+
const direct = Number(raw);
|
|
391
|
+
if (Number.isFinite(direct))
|
|
392
|
+
return direct;
|
|
393
|
+
const fromDate = new Date(raw).getTime();
|
|
394
|
+
if (Number.isFinite(fromDate))
|
|
395
|
+
return fromDate;
|
|
396
|
+
throw new types_1.InvalidOperationError('resolveScore', `Cannot derive numeric score from value of '${schema.scoreField}'`, { value: raw }, this.generateRequestId());
|
|
397
|
+
}
|
|
398
|
+
async writeIndexedList(a, b, c, d) {
|
|
399
|
+
const { listType, params, items, ttl: ttlOverride, cascadeTTL, forceTTL, } = this.isArgsObject(a)
|
|
400
|
+
? a
|
|
401
|
+
: {
|
|
402
|
+
listType: a,
|
|
403
|
+
params: b,
|
|
404
|
+
items: c,
|
|
405
|
+
ttl: d?.ttl,
|
|
406
|
+
cascadeTTL: d?.cascadeTTL,
|
|
407
|
+
forceTTL: d?.forceTTL,
|
|
408
|
+
};
|
|
409
|
+
return this.tracked(async () => {
|
|
410
|
+
try {
|
|
411
|
+
const listSchema = this.schemaManager.getIndexedListSchema(listType);
|
|
412
|
+
const operationId = this.generateOperationId();
|
|
413
|
+
const listKey = this.schemaManager.generateIndexedListKey(listType, ...Object.values(params));
|
|
414
|
+
// Normalize all items in one combined plan so the entity write is
|
|
415
|
+
// a single batched CAS round-trip.
|
|
416
|
+
const combined = new Map();
|
|
417
|
+
const planned = [];
|
|
418
|
+
for (const item of items) {
|
|
419
|
+
const id = item[listSchema.idField];
|
|
420
|
+
if (id === undefined || id === null) {
|
|
421
|
+
throw new types_1.InvalidOperationError('writeIndexedList', `Missing ID field '${listSchema.idField}' in list item`, { listType, item });
|
|
422
|
+
}
|
|
423
|
+
const score = this.resolveIndexedListScore(listSchema, item);
|
|
424
|
+
planned.push({ id, score });
|
|
425
|
+
const result = await this.normalizationEngine.normalizeEntity(listSchema.entityType, item, operationId);
|
|
426
|
+
for (const [k, v] of result.normalizedEntities) {
|
|
427
|
+
combined.set(k, v);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
// The indexed list itself is the cascade parent: its TTL becomes
|
|
431
|
+
// the floor for every embedded entity in this batch. `ttlOverride`
|
|
432
|
+
// (if supplied) replaces the list's schema TTL for this call and
|
|
433
|
+
// therefore also shifts the cascade floor.
|
|
434
|
+
const ttl = ttlOverride ??
|
|
435
|
+
this.schemaManager.resolveIndexedListTTL(listType);
|
|
436
|
+
const resolver = this.buildTTLResolver(cascadeTTL, ttl, undefined, undefined, forceTTL);
|
|
437
|
+
if (combined.size > 0) {
|
|
438
|
+
await this.normalizationEngine.writeNormalizedEntities({
|
|
439
|
+
normalizedEntities: combined,
|
|
440
|
+
primaryEntityKey: listKey,
|
|
441
|
+
operationId,
|
|
442
|
+
}, resolver);
|
|
443
|
+
}
|
|
444
|
+
// ZADD each member atomically with optional trim + back-index. We
|
|
445
|
+
// run these in parallel; ioredis pipelines them on the connection.
|
|
446
|
+
const maxSize = listSchema.maxSize ?? 0;
|
|
447
|
+
const tracked = !!listSchema.trackMembership;
|
|
448
|
+
await Promise.all(planned.map(({ id, score }) => this.connectionManager.zListAdd(listKey, this.schemaManager.generateMembershipKey(listSchema.entityType, id), score, id, ttl, maxSize, tracked)));
|
|
449
|
+
return {
|
|
450
|
+
success: true,
|
|
451
|
+
key: listKey,
|
|
452
|
+
ids: planned.map((p) => p.id),
|
|
453
|
+
operationId,
|
|
454
|
+
timestamp: Date.now(),
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
catch (error) {
|
|
458
|
+
if (error instanceof types_1.InvalidOperationError)
|
|
459
|
+
throw error;
|
|
460
|
+
throw new types_1.InvalidOperationError('writeIndexedList', `Failed to write indexed list '${String(listType)}'`, { listType, params, error: error.message }, this.generateRequestId());
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Read a hydrated, paginated slice of an indexed list. Returns at most
|
|
466
|
+
* `limit` (default 50) hydrated entities. Combines ZSET pagination
|
|
467
|
+
* options (`offset`, `limit`, `reverse`, `minScore`, `maxScore`) with
|
|
468
|
+
* the standard hydration options (`maxDepth`, `selectiveFields`,
|
|
469
|
+
* `excludeRelations`).
|
|
470
|
+
*/
|
|
471
|
+
async readIndexedList(listType, params, opts = {}) {
|
|
472
|
+
return this.tracked(async () => {
|
|
473
|
+
try {
|
|
474
|
+
const listSchema = this.schemaManager.getIndexedListSchema(listType);
|
|
475
|
+
const listKey = this.schemaManager.generateIndexedListKey(listType, ...Object.values(params));
|
|
476
|
+
const ids = (await this.connectionManager.zListRange(listKey, {
|
|
477
|
+
offset: opts.offset,
|
|
478
|
+
limit: opts.limit,
|
|
479
|
+
reverse: opts.reverse,
|
|
480
|
+
minScore: opts.minScore,
|
|
481
|
+
maxScore: opts.maxScore,
|
|
482
|
+
}));
|
|
483
|
+
if (ids.length === 0)
|
|
484
|
+
return [];
|
|
485
|
+
return await this.hydrationEngine.hydrateEntities(listSchema.entityType, ids, {
|
|
486
|
+
maxDepth: opts.maxDepth,
|
|
487
|
+
selectiveFields: opts.selectiveFields,
|
|
488
|
+
excludeRelations: opts.excludeRelations,
|
|
489
|
+
memoryLimit: opts.memoryLimit,
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
catch (error) {
|
|
493
|
+
throw new types_1.InvalidOperationError('readIndexedList', `Failed to read indexed list '${String(listType)}'`, { listType, params, error: error.message }, this.generateRequestId());
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
async addIndexedListItem(a, b, c, d) {
|
|
498
|
+
const { listType, params, entityData, ttl, cascadeTTL, forceTTL } = this.isArgsObject(a)
|
|
499
|
+
? a
|
|
500
|
+
: {
|
|
501
|
+
listType: a,
|
|
502
|
+
params: b,
|
|
503
|
+
entityData: c,
|
|
504
|
+
ttl: d?.ttl,
|
|
505
|
+
cascadeTTL: d?.cascadeTTL,
|
|
506
|
+
forceTTL: d?.forceTTL,
|
|
507
|
+
};
|
|
508
|
+
return this.tracked(async () => {
|
|
509
|
+
try {
|
|
510
|
+
const listSchema = this.schemaManager.getIndexedListSchema(listType);
|
|
511
|
+
const id = entityData[listSchema.idField];
|
|
512
|
+
if (id === undefined || id === null) {
|
|
513
|
+
throw new types_1.InvalidOperationError('addIndexedListItem', `Missing ID field '${listSchema.idField}' in entity data`, { listType, entityData });
|
|
514
|
+
}
|
|
515
|
+
// `ttl` here overrides the ENTITY's TTL, not the list's (scope:
|
|
516
|
+
// the item being added). The list key's TTL is preserved as-is.
|
|
517
|
+
await this.writeEntity({
|
|
518
|
+
entityType: listSchema.entityType,
|
|
519
|
+
data: entityData,
|
|
520
|
+
ttl,
|
|
521
|
+
cascadeTTL,
|
|
522
|
+
forceTTL,
|
|
523
|
+
});
|
|
524
|
+
const score = this.resolveIndexedListScore(listSchema, entityData);
|
|
525
|
+
const listKey = this.schemaManager.generateIndexedListKey(listType, ...Object.values(params));
|
|
526
|
+
return await this.connectionManager.zListAdd(listKey, this.schemaManager.generateMembershipKey(listSchema.entityType, id), score, id, this.schemaManager.resolveIndexedListTTL(listType), listSchema.maxSize ?? 0, !!listSchema.trackMembership);
|
|
527
|
+
}
|
|
528
|
+
catch (error) {
|
|
529
|
+
if (error instanceof types_1.InvalidOperationError)
|
|
530
|
+
throw error;
|
|
531
|
+
throw new types_1.InvalidOperationError('addIndexedListItem', `Failed to add to indexed list '${String(listType)}'`, { listType, params, error: error.message }, this.generateRequestId());
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Atomic remove of a member from a single indexed list. By default the
|
|
537
|
+
* entity itself remains intact (it may still be referenced by other
|
|
538
|
+
* lists). Pass `{ deleteEntity: true }` to also call `invalidateEntity`,
|
|
539
|
+
* which will cascade-remove the entity from every tracked list.
|
|
540
|
+
*/
|
|
541
|
+
async removeIndexedListItem(listType, params, entityId, options = {}) {
|
|
542
|
+
return this.tracked(async () => {
|
|
543
|
+
try {
|
|
544
|
+
const listSchema = this.schemaManager.getIndexedListSchema(listType);
|
|
545
|
+
const listKey = this.schemaManager.generateIndexedListKey(listType, ...Object.values(params));
|
|
546
|
+
const removed = await this.connectionManager.zListRemove(listKey, this.schemaManager.generateMembershipKey(listSchema.entityType, entityId), entityId, !!listSchema.trackMembership);
|
|
547
|
+
if (options.deleteEntity) {
|
|
548
|
+
await this.invalidateEntity(listSchema.entityType, entityId);
|
|
549
|
+
}
|
|
550
|
+
return removed;
|
|
551
|
+
}
|
|
552
|
+
catch (error) {
|
|
553
|
+
throw new types_1.InvalidOperationError('removeIndexedListItem', `Failed to remove from indexed list '${String(listType)}'`, { listType, params, entityId, error: error.message }, this.generateRequestId());
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Number of members currently in the indexed list. O(1).
|
|
559
|
+
*/
|
|
560
|
+
async indexedListSize(listType, params) {
|
|
561
|
+
return this.tracked(async () => {
|
|
562
|
+
const listKey = this.schemaManager.generateIndexedListKey(listType, ...Object.values(params));
|
|
563
|
+
return this.connectionManager.zListSize(listKey);
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Delete the entire indexed list. Entities remain in cache. Membership
|
|
568
|
+
* back-indexes are NOT pruned here for performance reasons (they're
|
|
569
|
+
* harmless and self-clean as part of cascade invalidation or via TTL).
|
|
570
|
+
*/
|
|
571
|
+
async deleteIndexedList(listType, params) {
|
|
572
|
+
return this.tracked(async () => {
|
|
573
|
+
const listKey = this.schemaManager.generateIndexedListKey(listType, ...Object.values(params));
|
|
574
|
+
const result = await this.connectionManager.del(listKey);
|
|
575
|
+
return result > 0;
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
// =====================================================
|
|
579
|
+
// CACHE MANAGEMENT
|
|
580
|
+
// =====================================================
|
|
581
|
+
/**
|
|
582
|
+
* Invalidate an entity from cache. When the entity is referenced by any
|
|
583
|
+
* indexed list with `trackMembership: true`, this also atomically ZREMs
|
|
584
|
+
* the entity from every such list — so consumers don't have to chase
|
|
585
|
+
* down list keys themselves. For entities not tracked by any list, the
|
|
586
|
+
* behaviour is identical to a plain entity delete.
|
|
587
|
+
*
|
|
588
|
+
* Returns the number of tracked lists the entity was removed from (0
|
|
589
|
+
* when no membership back-index exists, which is the common case).
|
|
590
|
+
*/
|
|
591
|
+
async invalidateEntity(entityType, id) {
|
|
592
|
+
return this.tracked(async () => {
|
|
593
|
+
try {
|
|
594
|
+
const entityKey = this.schemaManager.generateEntityKey(entityType, id);
|
|
595
|
+
const membershipKey = this.schemaManager.generateMembershipKey(entityType, id);
|
|
596
|
+
return await this.connectionManager.cascadeInvalidate(membershipKey, entityKey, id);
|
|
597
|
+
}
|
|
598
|
+
catch (error) {
|
|
599
|
+
throw new types_1.InvalidOperationError('invalidateEntity', `Failed to invalidate entity '${String(entityType)}' with id '${id}'`, { entityType, id, error: error.message }, this.generateRequestId());
|
|
600
|
+
}
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Clear every cache key owned by this engine. Destructive and
|
|
605
|
+
* irreversible.
|
|
606
|
+
*
|
|
607
|
+
* Behaviour depends on `redis.keyPrefix`:
|
|
608
|
+
* - **Prefix set** (recommended): uses non-blocking `SCAN` +
|
|
609
|
+
* `UNLINK` to delete only keys starting with the prefix. Safe to
|
|
610
|
+
* run against a Redis instance shared with other applications or
|
|
611
|
+
* environments — their keys are untouched.
|
|
612
|
+
* - **Prefix empty** (default, backward-compatible): falls back to
|
|
613
|
+
* `FLUSHDB`, wiping the entire selected database. Only use this
|
|
614
|
+
* when the engine owns the whole DB.
|
|
615
|
+
*
|
|
616
|
+
* Two safeguards apply in both modes:
|
|
617
|
+
* 1. The caller MUST pass `{ confirm: 'YES_WIPE_ALL' }` explicitly.
|
|
618
|
+
* 2. When `safety.productionMode` is true the operation is blocked
|
|
619
|
+
* unless `allowProduction: true` is also set.
|
|
620
|
+
*
|
|
621
|
+
* `safety.productionMode` defaults to `process.env.NODE_ENV ===
|
|
622
|
+
* 'production'` at construction time but is fully consumer-overridable
|
|
623
|
+
* via the engine config. The check below reads only the resolved
|
|
624
|
+
* config — no env access at call time.
|
|
625
|
+
*/
|
|
626
|
+
async clearAllCache(opts) {
|
|
627
|
+
if (!opts || opts.confirm !== 'YES_WIPE_ALL') {
|
|
628
|
+
throw new types_1.InvalidOperationError('clearAllCache', "Refusing to wipe cache without explicit confirmation. Pass { confirm: 'YES_WIPE_ALL' }.", {}, this.generateRequestId());
|
|
629
|
+
}
|
|
630
|
+
if (this.config.safety.productionMode && !opts.allowProduction) {
|
|
631
|
+
throw new types_1.InvalidOperationError('clearAllCache', 'Refusing to wipe cache in production without { allowProduction: true }.', {}, this.generateRequestId());
|
|
632
|
+
}
|
|
633
|
+
try {
|
|
634
|
+
// Prefer scoped delete whenever a keyPrefix is set so we never
|
|
635
|
+
// stomp on keys that don't belong to this engine. Falls back to
|
|
636
|
+
// FLUSHDB only when no prefix is configured (prior behaviour).
|
|
637
|
+
const prefix = this.connectionManager.getKeyPrefix();
|
|
638
|
+
if (prefix) {
|
|
639
|
+
await this.connectionManager.clearPrefixedKeys();
|
|
640
|
+
}
|
|
641
|
+
else {
|
|
642
|
+
await this.connectionManager.flushDb();
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
catch (error) {
|
|
646
|
+
throw new types_1.RedisConnectionError('Failed to clear all cache', error);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
// =====================================================
|
|
650
|
+
// MONITORING & DEBUGGING
|
|
651
|
+
// =====================================================
|
|
652
|
+
/**
|
|
653
|
+
* Real cache metrics. Hits/misses are counted at the connection-manager
|
|
654
|
+
* boundary on every GET/MGET; latency, totals, failures come from the
|
|
655
|
+
* same shared HealthMetrics tracker. `memoryUsage` reports the Node-side
|
|
656
|
+
* estimate and is 0 when no operation has run yet; Redis-side memory is
|
|
657
|
+
* exposed via `getHealthStatus()`.
|
|
658
|
+
*/
|
|
659
|
+
getMetrics() {
|
|
660
|
+
const m = this.connectionManager.getHealthMetrics();
|
|
661
|
+
return {
|
|
662
|
+
cacheHits: m.cacheHits,
|
|
663
|
+
cacheMisses: m.cacheMisses,
|
|
664
|
+
hitRate: m.hitRate,
|
|
665
|
+
totalOperations: m.totalOperations,
|
|
666
|
+
avgResponseTime: m.averageLatency,
|
|
667
|
+
memoryUsage: 0,
|
|
668
|
+
activeConnections: m.isConnected ? 1 : 0,
|
|
669
|
+
failedOperations: m.failedOperations,
|
|
670
|
+
lastUpdated: Date.now(),
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Get health status of the engine and Redis connection
|
|
675
|
+
*/
|
|
676
|
+
async getHealthStatus() {
|
|
677
|
+
const isHealthy = await this.connectionManager.isHealthy();
|
|
678
|
+
const m = this.connectionManager.getHealthMetrics();
|
|
679
|
+
const redisMemory = await this.connectionManager.getRedisUsedMemory();
|
|
680
|
+
const errorRate = m.totalOperations > 0 ? m.failedOperations / m.totalOperations : 0;
|
|
681
|
+
let status;
|
|
682
|
+
if (!isHealthy)
|
|
683
|
+
status = 'unhealthy';
|
|
684
|
+
else if (errorRate > 0.05 || m.circuitBreakerState.state !== 'CLOSED')
|
|
685
|
+
status = 'degraded';
|
|
686
|
+
else
|
|
687
|
+
status = 'healthy';
|
|
688
|
+
return {
|
|
689
|
+
status,
|
|
690
|
+
redis: {
|
|
691
|
+
connected: m.isConnected,
|
|
692
|
+
latency: m.averageLatency,
|
|
693
|
+
memoryUsage: redisMemory,
|
|
694
|
+
},
|
|
695
|
+
engine: {
|
|
696
|
+
activeOperations: this.activeOperations,
|
|
697
|
+
memoryUsage: 0,
|
|
698
|
+
errorRate,
|
|
699
|
+
},
|
|
700
|
+
timestamp: Date.now(),
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Enable or disable debug mode
|
|
705
|
+
*/
|
|
706
|
+
enableDebugMode(enabled) {
|
|
707
|
+
this.config.monitoring.enableDebugMode = enabled;
|
|
708
|
+
// TODO: Implement debug mode functionality
|
|
709
|
+
}
|
|
710
|
+
// =====================================================
|
|
711
|
+
// LIFECYCLE MANAGEMENT
|
|
712
|
+
// =====================================================
|
|
713
|
+
/**
|
|
714
|
+
* Gracefully disconnect and cleanup resources
|
|
715
|
+
*/
|
|
716
|
+
async disconnect() {
|
|
717
|
+
await this.connectionManager.disconnect();
|
|
718
|
+
}
|
|
719
|
+
// =====================================================
|
|
720
|
+
// PRIVATE METHODS
|
|
721
|
+
// =====================================================
|
|
722
|
+
/**
|
|
723
|
+
* Initialize all engine components
|
|
724
|
+
*/
|
|
725
|
+
initializeComponents(schema) {
|
|
726
|
+
this.schemaManager = new core_1.SchemaManager(schema, this.config.cache.defaultTTL);
|
|
727
|
+
this.connectionManager = new core_1.RedisConnectionManager(this.config.redis, this.config.resilience.circuitBreaker, this.config.resilience.retry);
|
|
728
|
+
// Build a single shared codec from cache config; both engines must
|
|
729
|
+
// use the same instance so writes encode and reads decode through
|
|
730
|
+
// the same threshold/algorithm. With enableCompression=false the
|
|
731
|
+
// codec is a no-op fast path.
|
|
732
|
+
const codec = new core_1.CompressionCodec(!!this.config.cache.enableCompression, this.config.cache.compressionThreshold);
|
|
733
|
+
// Resolve the (de)serializer once and share it between the
|
|
734
|
+
// normalization and hydration engines so writes encode and reads
|
|
735
|
+
// decode through the same codec. Defaults to the lossless
|
|
736
|
+
// `TAGGED_SERIALIZER` so consumers automatically get correct
|
|
737
|
+
// round-tripping for `Date`/`BigInt`/`Map`/`Set`/etc. without
|
|
738
|
+
// having to opt in. Consumers wanting raw `JSON.stringify`
|
|
739
|
+
// behaviour can pass `cache.serializer = JSON_SERIALIZER`.
|
|
740
|
+
const serializer = this.config.cache.serializer ?? core_1.TAGGED_SERIALIZER;
|
|
741
|
+
this.normalizationEngine = new core_1.NormalizationEngine(this.schemaManager, this.connectionManager, codec, serializer);
|
|
742
|
+
this.hydrationEngine = new core_1.HydrationEngine(this.schemaManager, this.connectionManager, this.config.limits.maxHydrationDepth, this.config.limits.maxMemoryUsagePerOperation, codec, serializer);
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* Merge user config with defaults
|
|
746
|
+
*/
|
|
747
|
+
mergeWithDefaults(config) {
|
|
748
|
+
return {
|
|
749
|
+
redis: (() => {
|
|
750
|
+
const merged = {
|
|
751
|
+
host: 'localhost',
|
|
752
|
+
port: 6379,
|
|
753
|
+
db: 0,
|
|
754
|
+
...config.redis,
|
|
755
|
+
};
|
|
756
|
+
// Auto-append the conventional `:` separator when a prefix is
|
|
757
|
+
// configured without one. This prevents accidents like
|
|
758
|
+
// `keyPrefix: 'mygouripur'` producing `mygouripurpost:42`
|
|
759
|
+
// instead of the intended `mygouripur:post:42`. Empty prefix
|
|
760
|
+
// (the default) is left untouched.
|
|
761
|
+
if (typeof merged.keyPrefix === 'string' &&
|
|
762
|
+
merged.keyPrefix.length > 0 &&
|
|
763
|
+
!merged.keyPrefix.endsWith(':')) {
|
|
764
|
+
merged.keyPrefix = `${merged.keyPrefix}:`;
|
|
765
|
+
}
|
|
766
|
+
return merged;
|
|
767
|
+
})(),
|
|
768
|
+
limits: {
|
|
769
|
+
maxHydrationDepth: 5,
|
|
770
|
+
maxEntitiesPerRequest: 1000,
|
|
771
|
+
maxMemoryUsagePerOperation: 100 * 1024 * 1024, // 100MB
|
|
772
|
+
maxConcurrentOperations: 100,
|
|
773
|
+
batchSize: 100,
|
|
774
|
+
...config.limits,
|
|
775
|
+
},
|
|
776
|
+
resilience: {
|
|
777
|
+
circuitBreaker: {
|
|
778
|
+
threshold: 5,
|
|
779
|
+
timeout: 60000,
|
|
780
|
+
resetTimeout: 30000,
|
|
781
|
+
...config.resilience?.circuitBreaker,
|
|
782
|
+
},
|
|
783
|
+
retry: {
|
|
784
|
+
maxAttempts: 3,
|
|
785
|
+
baseDelay: 100,
|
|
786
|
+
maxDelay: 2000,
|
|
787
|
+
backoffFactor: 2,
|
|
788
|
+
...config.resilience?.retry,
|
|
789
|
+
},
|
|
790
|
+
fallback: {
|
|
791
|
+
enabled: true,
|
|
792
|
+
strategy: 'null',
|
|
793
|
+
...config.resilience?.fallback,
|
|
794
|
+
},
|
|
795
|
+
},
|
|
796
|
+
monitoring: {
|
|
797
|
+
enableMetrics: true,
|
|
798
|
+
enableDebugMode: false,
|
|
799
|
+
enableAuditLog: false,
|
|
800
|
+
metricsInterval: 60000,
|
|
801
|
+
logLevel: 'info',
|
|
802
|
+
...config.monitoring,
|
|
803
|
+
},
|
|
804
|
+
cache: {
|
|
805
|
+
defaultTTL: 3600,
|
|
806
|
+
enableL1Cache: false,
|
|
807
|
+
l1CacheSize: 1000,
|
|
808
|
+
enableCompression: false,
|
|
809
|
+
compressionThreshold: 1024,
|
|
810
|
+
// `serializer` deliberately stays optional in the merged
|
|
811
|
+
// config — `undefined` means "use the engine default
|
|
812
|
+
// (TAGGED_SERIALIZER)" and `initializeComponents` resolves it
|
|
813
|
+
// there. We don't pin a default here so consumers can later
|
|
814
|
+
// distinguish "explicitly set to default" from "not set" if
|
|
815
|
+
// we ever expose introspection of the active codec.
|
|
816
|
+
...config.cache,
|
|
817
|
+
},
|
|
818
|
+
safety: {
|
|
819
|
+
// Env default kept for backward compatibility with existing
|
|
820
|
+
// consumers that rely on NODE_ENV. Library consumers should
|
|
821
|
+
// override this explicitly via config so behaviour does not
|
|
822
|
+
// depend on the host process's environment variables.
|
|
823
|
+
productionMode: process.env.NODE_ENV === 'production',
|
|
824
|
+
...config.safety,
|
|
825
|
+
},
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
/**
|
|
829
|
+
* UUIDv4 collisions are practically impossible, unlike the previous
|
|
830
|
+
* Math.random()-based IDs which collided regularly under high concurrency.
|
|
831
|
+
*/
|
|
832
|
+
generateOperationId() {
|
|
833
|
+
return `op_${(0, crypto_1.randomUUID)()}`;
|
|
834
|
+
}
|
|
835
|
+
generateRequestId() {
|
|
836
|
+
return `req_${(0, crypto_1.randomUUID)()}`;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
exports.RedisGraphCache = RedisGraphCache;
|