redis-graph-cache 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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;