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,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional value compression for entity payloads.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists:
|
|
5
|
+
* - Entity payloads (post + nested relationships) can be 5-50 KB.
|
|
6
|
+
* - At 50k concurrent users this is meaningful network bandwidth and
|
|
7
|
+
* Redis memory pressure.
|
|
8
|
+
* - Compression cuts typical JSON to ~30% of original size; even with
|
|
9
|
+
* base64 envelope overhead the net saving is 50-60%.
|
|
10
|
+
*
|
|
11
|
+
* Why ONLY entity values are compressed (not list values):
|
|
12
|
+
* - List values are JSON arrays of ids that the atomic Lua scripts
|
|
13
|
+
* parse server-side via `cjson.decode`. Compressed payloads would
|
|
14
|
+
* not parse and would break list-add / list-remove atomic ops.
|
|
15
|
+
* - Entity values are always parsed in Node, never inside Redis, so
|
|
16
|
+
* they're safe to ship in a compressed envelope.
|
|
17
|
+
*
|
|
18
|
+
* Backward compatibility:
|
|
19
|
+
* - Plain JSON (starts with `{` or `[`) is returned untouched on
|
|
20
|
+
* decode, so existing keys keep working when compression is enabled.
|
|
21
|
+
* - Compressed values carry a 4-character magic prefix `\x00z1:` that
|
|
22
|
+
* cannot occur at the start of valid JSON.
|
|
23
|
+
* - Writers can disable compression at any time and readers will
|
|
24
|
+
* transparently handle a mix of compressed and uncompressed values.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* Wraps the (de)serialisation of an entity JSON string. A no-op codec
|
|
28
|
+
* (`enabled = false`) returns inputs unchanged on both encode and decode,
|
|
29
|
+
* with a fast-path so the engine pays virtually zero cost when the
|
|
30
|
+
* feature is off.
|
|
31
|
+
*/
|
|
32
|
+
export declare class CompressionCodec {
|
|
33
|
+
readonly enabled: boolean;
|
|
34
|
+
readonly threshold: number;
|
|
35
|
+
constructor(enabled: boolean, threshold: number);
|
|
36
|
+
/**
|
|
37
|
+
* Encode a value for storage. When compression is disabled or the
|
|
38
|
+
* payload is below the threshold, returns the input unchanged so we
|
|
39
|
+
* don't add overhead to small entities.
|
|
40
|
+
*/
|
|
41
|
+
encode(value: string): string;
|
|
42
|
+
/**
|
|
43
|
+
* Decode a value read from Redis. Detects the magic prefix to decide
|
|
44
|
+
* whether to inflate. Untagged values pass through, which is what
|
|
45
|
+
* makes this safe to deploy on top of an existing cache: any data
|
|
46
|
+
* written before compression was enabled keeps reading correctly.
|
|
47
|
+
*
|
|
48
|
+
* Throws if a tagged value is malformed; that's a hard error worth
|
|
49
|
+
* surfacing because it indicates corruption rather than a stale
|
|
50
|
+
* format.
|
|
51
|
+
*/
|
|
52
|
+
decode(value: string): string;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Fast-path codec used when compression is disabled. Defined separately
|
|
56
|
+
* so the hot path doesn't even branch on `enabled` per call.
|
|
57
|
+
*/
|
|
58
|
+
export declare const NOOP_CODEC: CompressionCodec;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Optional value compression for entity payloads.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists:
|
|
6
|
+
* - Entity payloads (post + nested relationships) can be 5-50 KB.
|
|
7
|
+
* - At 50k concurrent users this is meaningful network bandwidth and
|
|
8
|
+
* Redis memory pressure.
|
|
9
|
+
* - Compression cuts typical JSON to ~30% of original size; even with
|
|
10
|
+
* base64 envelope overhead the net saving is 50-60%.
|
|
11
|
+
*
|
|
12
|
+
* Why ONLY entity values are compressed (not list values):
|
|
13
|
+
* - List values are JSON arrays of ids that the atomic Lua scripts
|
|
14
|
+
* parse server-side via `cjson.decode`. Compressed payloads would
|
|
15
|
+
* not parse and would break list-add / list-remove atomic ops.
|
|
16
|
+
* - Entity values are always parsed in Node, never inside Redis, so
|
|
17
|
+
* they're safe to ship in a compressed envelope.
|
|
18
|
+
*
|
|
19
|
+
* Backward compatibility:
|
|
20
|
+
* - Plain JSON (starts with `{` or `[`) is returned untouched on
|
|
21
|
+
* decode, so existing keys keep working when compression is enabled.
|
|
22
|
+
* - Compressed values carry a 4-character magic prefix `\x00z1:` that
|
|
23
|
+
* cannot occur at the start of valid JSON.
|
|
24
|
+
* - Writers can disable compression at any time and readers will
|
|
25
|
+
* transparently handle a mix of compressed and uncompressed values.
|
|
26
|
+
*/
|
|
27
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
28
|
+
if (k2 === undefined) k2 = k;
|
|
29
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
30
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
31
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
32
|
+
}
|
|
33
|
+
Object.defineProperty(o, k2, desc);
|
|
34
|
+
}) : (function(o, m, k, k2) {
|
|
35
|
+
if (k2 === undefined) k2 = k;
|
|
36
|
+
o[k2] = m[k];
|
|
37
|
+
}));
|
|
38
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
39
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
40
|
+
}) : function(o, v) {
|
|
41
|
+
o["default"] = v;
|
|
42
|
+
});
|
|
43
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
44
|
+
var ownKeys = function(o) {
|
|
45
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
46
|
+
var ar = [];
|
|
47
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
48
|
+
return ar;
|
|
49
|
+
};
|
|
50
|
+
return ownKeys(o);
|
|
51
|
+
};
|
|
52
|
+
return function (mod) {
|
|
53
|
+
if (mod && mod.__esModule) return mod;
|
|
54
|
+
var result = {};
|
|
55
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
56
|
+
__setModuleDefault(result, mod);
|
|
57
|
+
return result;
|
|
58
|
+
};
|
|
59
|
+
})();
|
|
60
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
61
|
+
exports.NOOP_CODEC = exports.CompressionCodec = void 0;
|
|
62
|
+
const zlib = __importStar(require("zlib"));
|
|
63
|
+
const MAGIC = '\x00z1:';
|
|
64
|
+
const MAGIC_LENGTH = MAGIC.length;
|
|
65
|
+
/**
|
|
66
|
+
* Wraps the (de)serialisation of an entity JSON string. A no-op codec
|
|
67
|
+
* (`enabled = false`) returns inputs unchanged on both encode and decode,
|
|
68
|
+
* with a fast-path so the engine pays virtually zero cost when the
|
|
69
|
+
* feature is off.
|
|
70
|
+
*/
|
|
71
|
+
class CompressionCodec {
|
|
72
|
+
constructor(enabled, threshold) {
|
|
73
|
+
this.enabled = enabled;
|
|
74
|
+
// Threshold is in bytes; clamp to a sensible minimum to avoid
|
|
75
|
+
// compressing tiny values where the envelope overhead outweighs the
|
|
76
|
+
// saving.
|
|
77
|
+
this.threshold = Math.max(64, threshold || 1024);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Encode a value for storage. When compression is disabled or the
|
|
81
|
+
* payload is below the threshold, returns the input unchanged so we
|
|
82
|
+
* don't add overhead to small entities.
|
|
83
|
+
*/
|
|
84
|
+
encode(value) {
|
|
85
|
+
if (!this.enabled)
|
|
86
|
+
return value;
|
|
87
|
+
if (Buffer.byteLength(value, 'utf8') < this.threshold)
|
|
88
|
+
return value;
|
|
89
|
+
const compressed = zlib.deflateRawSync(Buffer.from(value, 'utf8'));
|
|
90
|
+
return MAGIC + compressed.toString('base64');
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Decode a value read from Redis. Detects the magic prefix to decide
|
|
94
|
+
* whether to inflate. Untagged values pass through, which is what
|
|
95
|
+
* makes this safe to deploy on top of an existing cache: any data
|
|
96
|
+
* written before compression was enabled keeps reading correctly.
|
|
97
|
+
*
|
|
98
|
+
* Throws if a tagged value is malformed; that's a hard error worth
|
|
99
|
+
* surfacing because it indicates corruption rather than a stale
|
|
100
|
+
* format.
|
|
101
|
+
*/
|
|
102
|
+
decode(value) {
|
|
103
|
+
if (!value.startsWith(MAGIC))
|
|
104
|
+
return value;
|
|
105
|
+
const b64 = value.slice(MAGIC_LENGTH);
|
|
106
|
+
const buf = Buffer.from(b64, 'base64');
|
|
107
|
+
return zlib.inflateRawSync(buf).toString('utf8');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
exports.CompressionCodec = CompressionCodec;
|
|
111
|
+
/**
|
|
112
|
+
* Fast-path codec used when compression is disabled. Defined separately
|
|
113
|
+
* so the hot path doesn't even branch on `enabled` per call.
|
|
114
|
+
*/
|
|
115
|
+
exports.NOOP_CODEC = new CompressionCodec(false, 0);
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hydration Engine - Reconstructs full objects from normalized storage
|
|
3
|
+
*/
|
|
4
|
+
import { SchemaManager } from './schema-manager';
|
|
5
|
+
import { RedisConnectionManager } from './redis-connection-manager';
|
|
6
|
+
import { CompressionCodec } from './compression';
|
|
7
|
+
import { Serializer } from './serializer';
|
|
8
|
+
import { HydrationOptions } from '../types';
|
|
9
|
+
export declare class HydrationEngine {
|
|
10
|
+
private schemaManager;
|
|
11
|
+
private connectionManager;
|
|
12
|
+
private defaultMaxDepth;
|
|
13
|
+
private defaultMaxMemory;
|
|
14
|
+
private codec;
|
|
15
|
+
private serializer;
|
|
16
|
+
constructor(schemaManager: SchemaManager, connectionManager: RedisConnectionManager, defaultMaxDepth?: number, defaultMaxMemory?: number, // 100MB
|
|
17
|
+
codec?: CompressionCodec, serializer?: Serializer);
|
|
18
|
+
/**
|
|
19
|
+
* Hydrate a single entity with all its relationships
|
|
20
|
+
*/
|
|
21
|
+
hydrateEntity(entityType: string, id: string | number, options?: HydrationOptions): Promise<any>;
|
|
22
|
+
/**
|
|
23
|
+
* Hydrate multiple entities in batch
|
|
24
|
+
*/
|
|
25
|
+
hydrateEntities(entityType: string, ids: (string | number)[], options?: HydrationOptions): Promise<any[]>;
|
|
26
|
+
/**
|
|
27
|
+
* Create hydration context with limits and tracking
|
|
28
|
+
*/
|
|
29
|
+
private createHydrationContext;
|
|
30
|
+
/**
|
|
31
|
+
* Recursively hydrate entity and its relationships
|
|
32
|
+
*/
|
|
33
|
+
private hydrateRecursive;
|
|
34
|
+
/**
|
|
35
|
+
* Hydrate all relationships for an entity
|
|
36
|
+
*/
|
|
37
|
+
private hydrateRelationships;
|
|
38
|
+
/**
|
|
39
|
+
* Hydrate a single relationship
|
|
40
|
+
*/
|
|
41
|
+
private hydrateRelation;
|
|
42
|
+
/**
|
|
43
|
+
* Batch hydrate multiple entities efficiently
|
|
44
|
+
*/
|
|
45
|
+
private batchHydrateEntities;
|
|
46
|
+
/**
|
|
47
|
+
* Get a single entity from Redis. Hits/misses are tracked by the
|
|
48
|
+
* connection-manager wrapper so we don't double-count here. The codec
|
|
49
|
+
* decodes the storage envelope (decompressing if needed) before JSON
|
|
50
|
+
* parsing; untagged values pass through, preserving compatibility
|
|
51
|
+
* with caches that pre-date compression.
|
|
52
|
+
*/
|
|
53
|
+
private getEntityFromRedis;
|
|
54
|
+
/**
|
|
55
|
+
* Batch fetch multiple entities via MGET. Each value is independently
|
|
56
|
+
* decoded (codec) and JSON-parsed. Per-element parse failures surface
|
|
57
|
+
* as null so the rest of the batch is still usable.
|
|
58
|
+
*/
|
|
59
|
+
private batchGetEntitiesFromRedis;
|
|
60
|
+
/**
|
|
61
|
+
* Check if relations should be skipped based on context
|
|
62
|
+
*/
|
|
63
|
+
private shouldSkipRelations;
|
|
64
|
+
/**
|
|
65
|
+
* Generate unique request ID for tracking
|
|
66
|
+
*/
|
|
67
|
+
private generateRequestId;
|
|
68
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Hydration Engine - Reconstructs full objects from normalized storage
|
|
4
|
+
*/
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.HydrationEngine = void 0;
|
|
7
|
+
const compression_1 = require("./compression");
|
|
8
|
+
const serializer_1 = require("./serializer");
|
|
9
|
+
const types_1 = require("../types");
|
|
10
|
+
const types_2 = require("../types");
|
|
11
|
+
class HydrationEngine {
|
|
12
|
+
constructor(schemaManager, connectionManager, defaultMaxDepth = 5, defaultMaxMemory = 100 * 1024 * 1024, // 100MB
|
|
13
|
+
codec = compression_1.NOOP_CODEC,
|
|
14
|
+
// Pluggable (de)serializer. Defaults to the engine's lossless
|
|
15
|
+
// tagged JSON serializer so reads recover `Date`/`BigInt`/`Map`/
|
|
16
|
+
// `Set`/`RegExp`/`Buffer`/`NaN`/`±Infinity` as their original JS
|
|
17
|
+
// types instead of degrading them to strings, nulls, or `{}`.
|
|
18
|
+
serializer = serializer_1.TAGGED_SERIALIZER) {
|
|
19
|
+
this.schemaManager = schemaManager;
|
|
20
|
+
this.connectionManager = connectionManager;
|
|
21
|
+
this.defaultMaxDepth = defaultMaxDepth;
|
|
22
|
+
this.defaultMaxMemory = defaultMaxMemory;
|
|
23
|
+
this.codec = codec;
|
|
24
|
+
this.serializer = serializer;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Hydrate a single entity with all its relationships
|
|
28
|
+
*/
|
|
29
|
+
async hydrateEntity(entityType, id, options = {}) {
|
|
30
|
+
const context = this.createHydrationContext(options);
|
|
31
|
+
try {
|
|
32
|
+
const result = await this.hydrateRecursive(entityType, id, context);
|
|
33
|
+
return result ? (0, types_1.stripInternalFields)(result) : null;
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (error instanceof types_2.HydrationDepthExceededError ||
|
|
37
|
+
error instanceof types_2.MemoryLimitError) {
|
|
38
|
+
// Return partial result with warning for limit exceeded errors
|
|
39
|
+
console.warn(`Hydration limit exceeded for ${entityType}:${id}`, error.message);
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Hydrate multiple entities in batch
|
|
47
|
+
*/
|
|
48
|
+
async hydrateEntities(entityType, ids, options = {}) {
|
|
49
|
+
if (ids.length === 0) {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
// Create separate context for each entity to prevent cross-contamination
|
|
53
|
+
const results = await Promise.all(ids.map((id) => this.hydrateEntity(entityType, id, options)));
|
|
54
|
+
// Filter out null results (deleted entities)
|
|
55
|
+
return results.filter((result) => result !== null);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Create hydration context with limits and tracking
|
|
59
|
+
*/
|
|
60
|
+
createHydrationContext(options) {
|
|
61
|
+
return {
|
|
62
|
+
visitedEntities: new Set(),
|
|
63
|
+
currentDepth: 0,
|
|
64
|
+
maxDepth: options.maxDepth || this.defaultMaxDepth,
|
|
65
|
+
memoryUsage: 0,
|
|
66
|
+
maxMemory: options.memoryLimit || this.defaultMaxMemory,
|
|
67
|
+
requestId: this.generateRequestId(),
|
|
68
|
+
selectiveFields: options.selectiveFields,
|
|
69
|
+
excludeRelations: options.excludeRelations,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Recursively hydrate entity and its relationships
|
|
74
|
+
*/
|
|
75
|
+
async hydrateRecursive(entityType, id, context) {
|
|
76
|
+
// Check depth limit
|
|
77
|
+
if (context.currentDepth >= context.maxDepth) {
|
|
78
|
+
throw new types_2.HydrationDepthExceededError(context.currentDepth, context.maxDepth, context.requestId);
|
|
79
|
+
}
|
|
80
|
+
// Check memory limit (rough estimation)
|
|
81
|
+
if (context.memoryUsage > context.maxMemory) {
|
|
82
|
+
throw new types_2.MemoryLimitError(Math.round(context.memoryUsage / (1024 * 1024)), Math.round(context.maxMemory / (1024 * 1024)), context.requestId);
|
|
83
|
+
}
|
|
84
|
+
const schema = this.schemaManager.getEntitySchema(entityType);
|
|
85
|
+
const entityKey = this.schemaManager.generateEntityKey(entityType, id);
|
|
86
|
+
// Prevent circular references. Instead of silently dropping the related
|
|
87
|
+
// entity (which corrupts the shape consumers expect) we emit a stub
|
|
88
|
+
// that contains the id and a `$ref` marker so consumers can detect a
|
|
89
|
+
// cycle and break recursion themselves. `$ref` is intentionally
|
|
90
|
+
// un-prefixed so it survives `stripInternalFields`.
|
|
91
|
+
const visitKey = `${entityType}:${id}`;
|
|
92
|
+
if (context.visitedEntities.has(visitKey)) {
|
|
93
|
+
return { [schema.id]: id, $ref: `${entityType}:${id}` };
|
|
94
|
+
}
|
|
95
|
+
context.visitedEntities.add(visitKey);
|
|
96
|
+
try {
|
|
97
|
+
// Get entity from Redis
|
|
98
|
+
const normalizedEntity = await this.getEntityFromRedis(entityKey);
|
|
99
|
+
if (!normalizedEntity) {
|
|
100
|
+
return null; // Entity not found
|
|
101
|
+
}
|
|
102
|
+
// Update memory usage estimation. We use the configured
|
|
103
|
+
// serializer here too because plain `JSON.stringify` would throw
|
|
104
|
+
// on values that the serializer specifically handles (BigInt) or
|
|
105
|
+
// silently understate the size (Buffer, Map). Wrapped in
|
|
106
|
+
// try/catch so a serialization quirk on a single entity can
|
|
107
|
+
// never abort hydration of the whole graph — the size estimate
|
|
108
|
+
// is best-effort.
|
|
109
|
+
try {
|
|
110
|
+
context.memoryUsage +=
|
|
111
|
+
this.serializer.stringify(normalizedEntity).length;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// Fall back to a conservative constant if even the serializer
|
|
115
|
+
// can't stringify the value. This is intentionally generous so
|
|
116
|
+
// we still apply some memory pressure rather than treating the
|
|
117
|
+
// entity as zero bytes.
|
|
118
|
+
context.memoryUsage += 4096;
|
|
119
|
+
}
|
|
120
|
+
// Build the hydrated entity. When `selectiveFields` is supplied the
|
|
121
|
+
// output is restricted to only those keys plus the ID field; we still
|
|
122
|
+
// keep the internal `__rse_*` markers in scope so relationship
|
|
123
|
+
// hydration can resolve referenced ids, then `stripInternalFields`
|
|
124
|
+
// removes them at the public boundary.
|
|
125
|
+
let hydratedEntity;
|
|
126
|
+
if (context.selectiveFields && context.selectiveFields.length > 0) {
|
|
127
|
+
const allowed = new Set(context.selectiveFields);
|
|
128
|
+
allowed.add(schema.id);
|
|
129
|
+
const filtered = {};
|
|
130
|
+
for (const [k, v] of Object.entries(normalizedEntity)) {
|
|
131
|
+
if (allowed.has(k) || k.startsWith('__rse_')) {
|
|
132
|
+
filtered[k] = v;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
hydratedEntity = filtered;
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
hydratedEntity = { ...normalizedEntity };
|
|
139
|
+
}
|
|
140
|
+
// Hydrate relationships
|
|
141
|
+
if (schema.relations && !this.shouldSkipRelations(context)) {
|
|
142
|
+
context.currentDepth++;
|
|
143
|
+
await this.hydrateRelationships(schema, normalizedEntity, hydratedEntity, context);
|
|
144
|
+
context.currentDepth--;
|
|
145
|
+
}
|
|
146
|
+
return hydratedEntity;
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
// Remove from visited set to allow re-visiting in different branches
|
|
150
|
+
context.visitedEntities.delete(visitKey);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Hydrate all relationships for an entity
|
|
155
|
+
*/
|
|
156
|
+
async hydrateRelationships(schema, normalizedEntity, hydratedEntity, context) {
|
|
157
|
+
if (!schema.relations)
|
|
158
|
+
return;
|
|
159
|
+
const relationPromises = [];
|
|
160
|
+
for (const [relationName, relationDef] of Object.entries(schema.relations)) {
|
|
161
|
+
// Skip excluded relations
|
|
162
|
+
if (context.excludeRelations?.includes(relationName)) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const promise = this.hydrateRelation(relationName, relationDef, normalizedEntity, hydratedEntity, context);
|
|
166
|
+
relationPromises.push(promise);
|
|
167
|
+
}
|
|
168
|
+
// Execute all relationship hydrations in parallel
|
|
169
|
+
await Promise.all(relationPromises);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Hydrate a single relationship
|
|
173
|
+
*/
|
|
174
|
+
async hydrateRelation(relationName, relationDef, normalizedEntity, hydratedEntity, context) {
|
|
175
|
+
const relationIdField = (0, types_1.generateRelationIdField)(relationName, relationDef.kind);
|
|
176
|
+
const relationIds = normalizedEntity[relationIdField];
|
|
177
|
+
if (!relationIds) {
|
|
178
|
+
// No relation data, set appropriate default
|
|
179
|
+
hydratedEntity[relationName] = relationDef.kind === 'one' ? null : [];
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
if (relationDef.kind === 'one') {
|
|
184
|
+
// One-to-one relationship
|
|
185
|
+
const relatedEntity = await this.hydrateRecursive(relationDef.type, relationIds, { ...context });
|
|
186
|
+
hydratedEntity[relationName] = relatedEntity;
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
// One-to-many relationship
|
|
190
|
+
if (Array.isArray(relationIds)) {
|
|
191
|
+
const relatedEntities = await this.batchHydrateEntities(relationDef.type, relationIds, context);
|
|
192
|
+
hydratedEntity[relationName] = relatedEntities;
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
hydratedEntity[relationName] = [];
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
// Handle relation hydration errors gracefully
|
|
201
|
+
console.warn(`Failed to hydrate relation '${relationName}':`, error);
|
|
202
|
+
hydratedEntity[relationName] = relationDef.kind === 'one' ? null : [];
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Batch hydrate multiple entities efficiently
|
|
207
|
+
*/
|
|
208
|
+
async batchHydrateEntities(entityType, ids, context) {
|
|
209
|
+
if (ids.length === 0) {
|
|
210
|
+
return [];
|
|
211
|
+
}
|
|
212
|
+
// First, batch fetch all entities from Redis
|
|
213
|
+
const entityKeys = ids.map((id) => this.schemaManager.generateEntityKey(entityType, id));
|
|
214
|
+
const normalizedEntities = await this.batchGetEntitiesFromRedis(entityKeys);
|
|
215
|
+
// Then hydrate each entity
|
|
216
|
+
const hydrationPromises = ids.map(async (id, index) => {
|
|
217
|
+
const normalizedEntity = normalizedEntities[index];
|
|
218
|
+
if (!normalizedEntity) {
|
|
219
|
+
return null; // Entity not found
|
|
220
|
+
}
|
|
221
|
+
// Create a copy of context for each entity
|
|
222
|
+
const entityContext = {
|
|
223
|
+
...context,
|
|
224
|
+
visitedEntities: new Set(context.visitedEntities),
|
|
225
|
+
};
|
|
226
|
+
return this.hydrateRecursive(entityType, id, entityContext);
|
|
227
|
+
});
|
|
228
|
+
const results = await Promise.all(hydrationPromises);
|
|
229
|
+
// Filter out null results (deleted entities)
|
|
230
|
+
return results.filter((result) => result !== null);
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Get a single entity from Redis. Hits/misses are tracked by the
|
|
234
|
+
* connection-manager wrapper so we don't double-count here. The codec
|
|
235
|
+
* decodes the storage envelope (decompressing if needed) before JSON
|
|
236
|
+
* parsing; untagged values pass through, preserving compatibility
|
|
237
|
+
* with caches that pre-date compression.
|
|
238
|
+
*/
|
|
239
|
+
async getEntityFromRedis(key) {
|
|
240
|
+
try {
|
|
241
|
+
const value = await this.connectionManager.get(key);
|
|
242
|
+
if (!value)
|
|
243
|
+
return null;
|
|
244
|
+
return this.serializer.parse(this.codec.decode(value));
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
throw new types_2.RedisConnectionError(`Failed to get entity from Redis: ${key}`, error);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Batch fetch multiple entities via MGET. Each value is independently
|
|
252
|
+
* decoded (codec) and JSON-parsed. Per-element parse failures surface
|
|
253
|
+
* as null so the rest of the batch is still usable.
|
|
254
|
+
*/
|
|
255
|
+
async batchGetEntitiesFromRedis(keys) {
|
|
256
|
+
if (keys.length === 0)
|
|
257
|
+
return [];
|
|
258
|
+
try {
|
|
259
|
+
const values = await this.connectionManager.mget(keys);
|
|
260
|
+
return values.map((value) => {
|
|
261
|
+
if (!value)
|
|
262
|
+
return null;
|
|
263
|
+
try {
|
|
264
|
+
return this.serializer.parse(this.codec.decode(value));
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
throw new types_2.RedisConnectionError('Failed to batch get entities from Redis', error);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Check if relations should be skipped based on context
|
|
277
|
+
*/
|
|
278
|
+
shouldSkipRelations(context) {
|
|
279
|
+
return context.excludeRelations?.includes('*') || false;
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Generate unique request ID for tracking
|
|
283
|
+
*/
|
|
284
|
+
generateRequestId() {
|
|
285
|
+
return `req_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
exports.HydrationEngine = HydrationEngine;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Barrel export for public core components.
|
|
3
|
+
*
|
|
4
|
+
* NOTE: `lua-scripts` is intentionally excluded — it contains raw Lua
|
|
5
|
+
* source strings that are implementation details of the connection
|
|
6
|
+
* manager and must not become part of the public API.
|
|
7
|
+
*/
|
|
8
|
+
export * from './compression';
|
|
9
|
+
export * from './hydration-engine';
|
|
10
|
+
export * from './normalization-engine';
|
|
11
|
+
export * from './redis-connection-manager';
|
|
12
|
+
export * from './schema-manager';
|
|
13
|
+
export * from './serializer';
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Barrel export for public core components.
|
|
4
|
+
*
|
|
5
|
+
* NOTE: `lua-scripts` is intentionally excluded — it contains raw Lua
|
|
6
|
+
* source strings that are implementation details of the connection
|
|
7
|
+
* manager and must not become part of the public API.
|
|
8
|
+
*/
|
|
9
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
12
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
13
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
14
|
+
}
|
|
15
|
+
Object.defineProperty(o, k2, desc);
|
|
16
|
+
}) : (function(o, m, k, k2) {
|
|
17
|
+
if (k2 === undefined) k2 = k;
|
|
18
|
+
o[k2] = m[k];
|
|
19
|
+
}));
|
|
20
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
21
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
22
|
+
};
|
|
23
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
24
|
+
__exportStar(require("./compression"), exports);
|
|
25
|
+
__exportStar(require("./hydration-engine"), exports);
|
|
26
|
+
__exportStar(require("./normalization-engine"), exports);
|
|
27
|
+
__exportStar(require("./redis-connection-manager"), exports);
|
|
28
|
+
__exportStar(require("./schema-manager"), exports);
|
|
29
|
+
__exportStar(require("./serializer"), exports);
|