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,187 @@
1
+ "use strict";
2
+ /**
3
+ * Serializer - Pluggable (de)serialization for entity payloads.
4
+ *
5
+ * The default serializer (`TAGGED_SERIALIZER`) is a lossless wrapper
6
+ * around `JSON.stringify` / `JSON.parse` that preserves JS types JSON
7
+ * cannot represent natively. It uses a tag-based envelope only for
8
+ * non-JSON-native values; everything else round-trips identically to
9
+ * plain `JSON.stringify` so the on-wire format is unchanged for
10
+ * ordinary data (strings, numbers, booleans, plain objects, arrays,
11
+ * nulls).
12
+ *
13
+ * Why the engine needs this:
14
+ * - `Date` becomes an ISO string via `Date.prototype.toJSON` and
15
+ * parses back as a string, breaking `post.createdAt.getTime()`.
16
+ * - `BigInt` throws `TypeError` from `JSON.stringify`.
17
+ * - `Map`, `Set`, `RegExp`, `Buffer` serialize to `{}`, losing all
18
+ * contents.
19
+ * - `NaN`, `Infinity`, `-Infinity` become `null`, silently corrupting
20
+ * numeric stats fields.
21
+ *
22
+ * Out of scope (intentionally NOT tagged):
23
+ * - `undefined`. The engine's merge semantics treat
24
+ * "field is undefined" identically to "field is omitted" so that
25
+ * partial updates don't accidentally clobber existing values.
26
+ * See `NULL_UNDEFINED_HANDLING.md`. Tagging undefined would
27
+ * silently change that contract.
28
+ *
29
+ * Backward compatibility:
30
+ * - Reads of plain JSON written by older versions (or by any process
31
+ * without this engine) work unchanged. The reviver only transforms
32
+ * objects that carry the internal tag key.
33
+ * - Writes that contain only JSON-native values produce byte-for-byte
34
+ * identical output to the previous `JSON.stringify` path.
35
+ *
36
+ * Pluggability:
37
+ * - Consumers can opt out by passing `cache.serializer = JSON_SERIALIZER`
38
+ * (raw, no tagging — original behaviour).
39
+ * - Consumers can plug in a third-party codec like `superjson` or
40
+ * `devalue` by passing any object that implements `Serializer`.
41
+ */
42
+ Object.defineProperty(exports, "__esModule", { value: true });
43
+ exports.JSON_SERIALIZER = exports.TAGGED_SERIALIZER = void 0;
44
+ /**
45
+ * Internal tag key used by the default tagged serializer. It is
46
+ * deliberately under the engine's reserved `__rse_` prefix so it
47
+ * cannot collide with user fields (schema validation rejects field
48
+ * names starting with `__rse_`).
49
+ */
50
+ const TAG = '__rse_t';
51
+ /**
52
+ * Replacer used by `TAGGED_SERIALIZER.stringify`. Receives the
53
+ * pre-`toJSON` value via `this[key]` so we can detect `Date`,
54
+ * `Buffer`, etc. before their built-in `toJSON` clobbers them.
55
+ *
56
+ * Any value not matched here is returned unchanged, so plain JSON
57
+ * data round-trips with zero envelope overhead.
58
+ */
59
+ function tagReplacer(key, value) {
60
+ // `this` is the holder object; `value` is the post-`toJSON` value.
61
+ // We need the raw value to detect Date/Buffer/etc. since their
62
+ // `toJSON` runs before the replacer.
63
+ const original = this[key];
64
+ if (original instanceof Date) {
65
+ // `getTime()` preserves invalid dates (NaN) which `toISOString`
66
+ // would throw on. Storing the numeric timestamp is also slightly
67
+ // smaller and unambiguous across timezones.
68
+ const t = original.getTime();
69
+ return { [TAG]: 'Date', v: Number.isNaN(t) ? null : t };
70
+ }
71
+ if (typeof original === 'bigint') {
72
+ return { [TAG]: 'BigInt', v: original.toString() };
73
+ }
74
+ if (original instanceof Map) {
75
+ // Entries are arrays of [key, value]; both will recurse through
76
+ // the replacer so nested Dates/BigInts inside a Map work too.
77
+ return { [TAG]: 'Map', v: Array.from(original.entries()) };
78
+ }
79
+ if (original instanceof Set) {
80
+ return { [TAG]: 'Set', v: Array.from(original.values()) };
81
+ }
82
+ if (original instanceof RegExp) {
83
+ return { [TAG]: 'RegExp', src: original.source, flags: original.flags };
84
+ }
85
+ // Buffer check is guarded so the serializer also works in non-Node
86
+ // environments (Edge, browsers) where `Buffer` is undefined.
87
+ if (typeof Buffer !== 'undefined' &&
88
+ typeof Buffer.isBuffer === 'function' &&
89
+ Buffer.isBuffer(original)) {
90
+ return { [TAG]: 'Buffer', v: original.toString('base64') };
91
+ }
92
+ if (typeof original === 'number' && !Number.isFinite(original)) {
93
+ if (Number.isNaN(original))
94
+ return { [TAG]: 'Number', v: 'NaN' };
95
+ return {
96
+ [TAG]: 'Number',
97
+ v: original > 0 ? 'Infinity' : '-Infinity',
98
+ };
99
+ }
100
+ return value;
101
+ }
102
+ /**
103
+ * Reviver used by `TAGGED_SERIALIZER.parse`. JSON.parse calls the
104
+ * reviver bottom-up (children first), so by the time we see a tagged
105
+ * envelope its inner `v` has already been revived. That means
106
+ * `Map<Date, BigInt>` round-trips correctly: the inner Dates and
107
+ * BigInts are reconstructed before the Map wraps them.
108
+ *
109
+ * Untagged objects (plain JSON written by any other producer) are
110
+ * returned unchanged, which is what makes this safe to deploy on top
111
+ * of an existing cache.
112
+ */
113
+ function tagReviver(_key, value) {
114
+ if (value === null ||
115
+ typeof value !== 'object' ||
116
+ Array.isArray(value) ||
117
+ !(TAG in value)) {
118
+ return value;
119
+ }
120
+ switch (value[TAG]) {
121
+ case 'Date':
122
+ // `null` in `v` means we serialized an Invalid Date; reconstruct
123
+ // it so identity round-trips even for that weird case.
124
+ return value.v === null ? new Date(NaN) : new Date(value.v);
125
+ case 'BigInt':
126
+ return BigInt(value.v);
127
+ case 'Map':
128
+ return new Map(value.v);
129
+ case 'Set':
130
+ return new Set(value.v);
131
+ case 'RegExp':
132
+ return new RegExp(value.src, value.flags);
133
+ case 'Buffer':
134
+ // Guarded for non-Node environments. If Buffer is unavailable
135
+ // we return the raw envelope so the consumer at least sees the
136
+ // base64 payload rather than getting `undefined`.
137
+ if (typeof Buffer !== 'undefined') {
138
+ return Buffer.from(value.v, 'base64');
139
+ }
140
+ return value;
141
+ case 'Number':
142
+ if (value.v === 'NaN')
143
+ return NaN;
144
+ if (value.v === 'Infinity')
145
+ return Infinity;
146
+ if (value.v === '-Infinity')
147
+ return -Infinity;
148
+ return Number(value.v);
149
+ default:
150
+ // Unknown tag — return the envelope as-is. This is forward
151
+ // compatible: a future engine version may add new tags and
152
+ // older readers will simply pass them through instead of
153
+ // crashing.
154
+ return value;
155
+ }
156
+ }
157
+ /**
158
+ * Default serializer. Lossless for `Date`, `BigInt`, `Map`, `Set`,
159
+ * `RegExp`, `Buffer`, `NaN`, `+Infinity`, `-Infinity`. Identical to
160
+ * plain JSON for all other values.
161
+ */
162
+ exports.TAGGED_SERIALIZER = {
163
+ stringify(value) {
164
+ return JSON.stringify(value, tagReplacer);
165
+ },
166
+ parse(value) {
167
+ return JSON.parse(value, tagReviver);
168
+ },
169
+ };
170
+ /**
171
+ * Plain JSON serializer. No tagging, no reviver. Use this when you
172
+ * know your data is JSON-native and want zero serialization overhead,
173
+ * or when interoperating with non-engine readers that expect raw JSON.
174
+ *
175
+ * Trade-off: `Date` survives as ISO string only, `BigInt` will throw,
176
+ * `Map`/`Set`/`RegExp`/`Buffer` lose all data, `NaN`/`Infinity` become
177
+ * `null`. This is the historical engine behaviour; kept available for
178
+ * opt-out.
179
+ */
180
+ exports.JSON_SERIALIZER = {
181
+ stringify(value) {
182
+ return JSON.stringify(value);
183
+ },
184
+ parse(value) {
185
+ return JSON.parse(value);
186
+ },
187
+ };
@@ -0,0 +1,11 @@
1
+ /**
2
+ * redis-graph-cache
3
+ *
4
+ * A TypeScript-first Redis data layer with schema-driven normalization,
5
+ * relationship management, and graph-based hydration for high-performance
6
+ * Node.js applications handling millions of entities with complex
7
+ * relationships.
8
+ */
9
+ export { RedisGraphCache } from './redis-graph-cache';
10
+ export * from './core';
11
+ export * from './types';
package/dist/index.js ADDED
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ /**
3
+ * redis-graph-cache
4
+ *
5
+ * A TypeScript-first Redis data layer with schema-driven normalization,
6
+ * relationship management, and graph-based hydration for high-performance
7
+ * Node.js applications handling millions of entities with complex
8
+ * relationships.
9
+ */
10
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
11
+ if (k2 === undefined) k2 = k;
12
+ var desc = Object.getOwnPropertyDescriptor(m, k);
13
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
14
+ desc = { enumerable: true, get: function() { return m[k]; } };
15
+ }
16
+ Object.defineProperty(o, k2, desc);
17
+ }) : (function(o, m, k, k2) {
18
+ if (k2 === undefined) k2 = k;
19
+ o[k2] = m[k];
20
+ }));
21
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
22
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
23
+ };
24
+ Object.defineProperty(exports, "__esModule", { value: true });
25
+ exports.RedisGraphCache = void 0;
26
+ // Main engine class (the primary export — what 95% of consumers need)
27
+ var redis_graph_cache_1 = require("./redis-graph-cache");
28
+ Object.defineProperty(exports, "RedisGraphCache", { enumerable: true, get: function () { return redis_graph_cache_1.RedisGraphCache; } });
29
+ // Core components, utilities, and serializers
30
+ __exportStar(require("./core"), exports);
31
+ // All public types (schemas, configs, operations, errors, utilities)
32
+ __exportStar(require("./types"), exports);
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Redis Schema Engine - Main class providing the public API
3
+ */
4
+ import { Schema, PartialRedisSchemaEngineConfig, WriteResult, ListWriteResult, CacheMetrics, HealthStatus, HydrationOptions } from './types';
5
+ export declare class RedisGraphCache<TSchema extends Schema> {
6
+ private schemaManager;
7
+ private connectionManager;
8
+ private normalizationEngine;
9
+ private hydrationEngine;
10
+ private config;
11
+ private activeOperations;
12
+ constructor(schema: TSchema, config?: PartialRedisSchemaEngineConfig);
13
+ /**
14
+ * Wrap a public operation with active-counter bookkeeping. Lets the
15
+ * health endpoint report a real (not stubbed) `activeOperations` value.
16
+ */
17
+ private tracked;
18
+ /**
19
+ * Resolve the TTL for a given normalized entity key. The key format is
20
+ * always `entityType:id` so the prefix tells us which schema to consult.
21
+ */
22
+ private resolveEntityKeyTTL;
23
+ /**
24
+ * Build a TTL resolver for a single write call that, when `cascadeTTL`
25
+ * is true, applies a floor: every entity written in this call uses at
26
+ * least `parentTtl` even if its own schema TTL is shorter. This is the
27
+ * fix for the "parent post (1h) embeds short-TTL category (1m); after
28
+ * 1m the post hydrates without its category" coherence bug.
29
+ *
30
+ * Special cases:
31
+ * - parentTtl = 0 (no expiry on parent): children also get 0,
32
+ * guaranteeing they outlive a never-expiring parent.
33
+ * - cascadeTTL = false (default): returns the unmodified resolver,
34
+ * so existing behaviour is bit-for-bit preserved.
35
+ * - parentTtl negative or NaN: ignored, falls back to per-key TTL.
36
+ */
37
+ private buildTTLResolver;
38
+ /**
39
+ * Runtime helper for the dual positional/object-style public API.
40
+ * `typeof x === 'object' && x !== null` distinguishes the object
41
+ * form from the positional-string-first form, since `entityType`
42
+ * and `listType` are always strings.
43
+ */
44
+ private isArgsObject;
45
+ /**
46
+ * Write an entity with nested relationships to Redis.
47
+ *
48
+ * Two equivalent call styles are supported:
49
+ *
50
+ * ```ts
51
+ * // Object form (recommended for new code)
52
+ * await engine.writeEntity({ entityType: 'post', data, ttl, cascadeTTL });
53
+ *
54
+ * // Positional form (preserved for existing callers)
55
+ * await engine.writeEntity('post', data, { ttl, cascadeTTL });
56
+ * ```
57
+ *
58
+ * When `ttl` is provided it overrides the entity's schema TTL for
59
+ * THIS WRITE ONLY, pinned to the root entity's key. Embedded
60
+ * relations still use their own schema TTL unless `cascadeTTL: true`
61
+ * is set — in which case the override also becomes the cascade
62
+ * floor for every child written by this call.
63
+ */
64
+ writeEntity<T>(args: {
65
+ entityType: keyof TSchema;
66
+ data: T;
67
+ ttl?: number;
68
+ cascadeTTL?: boolean;
69
+ forceTTL?: boolean;
70
+ }): Promise<WriteResult>;
71
+ writeEntity<T>(entityType: keyof TSchema, data: T, options?: {
72
+ ttl?: number;
73
+ cascadeTTL?: boolean;
74
+ forceTTL?: boolean;
75
+ }): Promise<WriteResult>;
76
+ /**
77
+ * Read and hydrate an entity with all its relationships
78
+ */
79
+ readEntity(entityType: keyof TSchema, id: string | number, options?: HydrationOptions): Promise<any>;
80
+ /**
81
+ * Delete an entity from Redis
82
+ */
83
+ deleteEntity(entityType: keyof TSchema, id: string | number): Promise<boolean>;
84
+ /**
85
+ * Update an entity ONLY if it already exists in cache
86
+ * This is useful for cache invalidation scenarios where you don't want to
87
+ * create cache entries for data that hasn't been accessed yet
88
+ *
89
+ * @returns WriteResult if entity exists and was updated, null if entity doesn't exist
90
+ */
91
+ updateEntityIfExists(args: {
92
+ entityType: keyof TSchema;
93
+ data: any;
94
+ ttl?: number;
95
+ cascadeTTL?: boolean;
96
+ forceTTL?: boolean;
97
+ }): Promise<WriteResult | null>;
98
+ updateEntityIfExists(entityType: keyof TSchema, data: any, options?: {
99
+ ttl?: number;
100
+ cascadeTTL?: boolean;
101
+ forceTTL?: boolean;
102
+ }): Promise<WriteResult | null>;
103
+ /**
104
+ * Write a list of entities to Redis
105
+ */
106
+ writeList(args: {
107
+ listType: keyof TSchema;
108
+ params: any;
109
+ items: any[];
110
+ ttl?: number;
111
+ cascadeTTL?: boolean;
112
+ forceTTL?: boolean;
113
+ }): Promise<ListWriteResult>;
114
+ writeList(listType: keyof TSchema, params: any, items: any[], options?: {
115
+ ttl?: number;
116
+ cascadeTTL?: boolean;
117
+ forceTTL?: boolean;
118
+ }): Promise<ListWriteResult>;
119
+ /**
120
+ * Read and hydrate a list of entities
121
+ */
122
+ readList(listType: keyof TSchema, params: any, options?: HydrationOptions): Promise<any[]>;
123
+ /**
124
+ * Delete a list from Redis
125
+ */
126
+ deleteList(listType: keyof TSchema, params: any): Promise<boolean>;
127
+ /**
128
+ * Add an item to a list (with entity data)
129
+ * This method writes the entity to cache and adds it to the list
130
+ */
131
+ addListItem(args: {
132
+ listType: keyof TSchema;
133
+ params: any;
134
+ entityData: any;
135
+ ttl?: number;
136
+ cascadeTTL?: boolean;
137
+ forceTTL?: boolean;
138
+ }): Promise<boolean>;
139
+ addListItem(listType: keyof TSchema, params: any, entityData: any, options?: {
140
+ ttl?: number;
141
+ cascadeTTL?: boolean;
142
+ forceTTL?: boolean;
143
+ }): Promise<boolean>;
144
+ /**
145
+ * Remove an item from a list (and optionally delete the entity)
146
+ */
147
+ removeListItem(listType: keyof TSchema, params: any, entityId: any, options?: {
148
+ deleteEntity?: boolean;
149
+ }): Promise<boolean>;
150
+ /**
151
+ * Resolve the ZSET score for an entity from its schema's `scoreField`,
152
+ * with fallbacks: numeric strings, ISO timestamps via `Date.parse`, or
153
+ * insertion time when no `scoreField` is configured. Throws on values
154
+ * that cannot be coerced to a finite number, since silently scoring at
155
+ * 0 would scramble feed ordering.
156
+ */
157
+ private resolveIndexedListScore;
158
+ /**
159
+ * Bulk upsert an indexed list. Each item is normalized + written like a
160
+ * regular entity, then added to the ZSET with its computed score. This
161
+ * does NOT clear existing members; for full replace, call
162
+ * `deleteIndexedList` first.
163
+ */
164
+ writeIndexedList(args: {
165
+ listType: keyof TSchema;
166
+ params: any;
167
+ items: any[];
168
+ ttl?: number;
169
+ cascadeTTL?: boolean;
170
+ forceTTL?: boolean;
171
+ }): Promise<ListWriteResult>;
172
+ writeIndexedList(listType: keyof TSchema, params: any, items: any[], options?: {
173
+ ttl?: number;
174
+ cascadeTTL?: boolean;
175
+ forceTTL?: boolean;
176
+ }): Promise<ListWriteResult>;
177
+ /**
178
+ * Read a hydrated, paginated slice of an indexed list. Returns at most
179
+ * `limit` (default 50) hydrated entities. Combines ZSET pagination
180
+ * options (`offset`, `limit`, `reverse`, `minScore`, `maxScore`) with
181
+ * the standard hydration options (`maxDepth`, `selectiveFields`,
182
+ * `excludeRelations`).
183
+ */
184
+ readIndexedList(listType: keyof TSchema, params: any, opts?: HydrationOptions & {
185
+ offset?: number;
186
+ limit?: number;
187
+ reverse?: boolean;
188
+ minScore?: number;
189
+ maxScore?: number;
190
+ }): Promise<any[]>;
191
+ /**
192
+ * Atomic single-item upsert into an indexed list. Writes the entity in
193
+ * full normalized form first, then issues an atomic ZADD with the
194
+ * resolved score and any configured trim/membership tracking. Returns
195
+ * true if a new member was added, false if the score was updated on
196
+ * an already-present id (still considered success).
197
+ */
198
+ addIndexedListItem(args: {
199
+ listType: keyof TSchema;
200
+ params: any;
201
+ entityData: any;
202
+ ttl?: number;
203
+ cascadeTTL?: boolean;
204
+ forceTTL?: boolean;
205
+ }): Promise<boolean>;
206
+ addIndexedListItem(listType: keyof TSchema, params: any, entityData: any, options?: {
207
+ ttl?: number;
208
+ cascadeTTL?: boolean;
209
+ forceTTL?: boolean;
210
+ }): Promise<boolean>;
211
+ /**
212
+ * Atomic remove of a member from a single indexed list. By default the
213
+ * entity itself remains intact (it may still be referenced by other
214
+ * lists). Pass `{ deleteEntity: true }` to also call `invalidateEntity`,
215
+ * which will cascade-remove the entity from every tracked list.
216
+ */
217
+ removeIndexedListItem(listType: keyof TSchema, params: any, entityId: any, options?: {
218
+ deleteEntity?: boolean;
219
+ }): Promise<boolean>;
220
+ /**
221
+ * Number of members currently in the indexed list. O(1).
222
+ */
223
+ indexedListSize(listType: keyof TSchema, params: any): Promise<number>;
224
+ /**
225
+ * Delete the entire indexed list. Entities remain in cache. Membership
226
+ * back-indexes are NOT pruned here for performance reasons (they're
227
+ * harmless and self-clean as part of cascade invalidation or via TTL).
228
+ */
229
+ deleteIndexedList(listType: keyof TSchema, params: any): Promise<boolean>;
230
+ /**
231
+ * Invalidate an entity from cache. When the entity is referenced by any
232
+ * indexed list with `trackMembership: true`, this also atomically ZREMs
233
+ * the entity from every such list — so consumers don't have to chase
234
+ * down list keys themselves. For entities not tracked by any list, the
235
+ * behaviour is identical to a plain entity delete.
236
+ *
237
+ * Returns the number of tracked lists the entity was removed from (0
238
+ * when no membership back-index exists, which is the common case).
239
+ */
240
+ invalidateEntity(entityType: keyof TSchema, id: string | number): Promise<number>;
241
+ /**
242
+ * Clear every cache key owned by this engine. Destructive and
243
+ * irreversible.
244
+ *
245
+ * Behaviour depends on `redis.keyPrefix`:
246
+ * - **Prefix set** (recommended): uses non-blocking `SCAN` +
247
+ * `UNLINK` to delete only keys starting with the prefix. Safe to
248
+ * run against a Redis instance shared with other applications or
249
+ * environments — their keys are untouched.
250
+ * - **Prefix empty** (default, backward-compatible): falls back to
251
+ * `FLUSHDB`, wiping the entire selected database. Only use this
252
+ * when the engine owns the whole DB.
253
+ *
254
+ * Two safeguards apply in both modes:
255
+ * 1. The caller MUST pass `{ confirm: 'YES_WIPE_ALL' }` explicitly.
256
+ * 2. When `safety.productionMode` is true the operation is blocked
257
+ * unless `allowProduction: true` is also set.
258
+ *
259
+ * `safety.productionMode` defaults to `process.env.NODE_ENV ===
260
+ * 'production'` at construction time but is fully consumer-overridable
261
+ * via the engine config. The check below reads only the resolved
262
+ * config — no env access at call time.
263
+ */
264
+ clearAllCache(opts: {
265
+ confirm: 'YES_WIPE_ALL';
266
+ allowProduction?: boolean;
267
+ }): Promise<void>;
268
+ /**
269
+ * Real cache metrics. Hits/misses are counted at the connection-manager
270
+ * boundary on every GET/MGET; latency, totals, failures come from the
271
+ * same shared HealthMetrics tracker. `memoryUsage` reports the Node-side
272
+ * estimate and is 0 when no operation has run yet; Redis-side memory is
273
+ * exposed via `getHealthStatus()`.
274
+ */
275
+ getMetrics(): CacheMetrics;
276
+ /**
277
+ * Get health status of the engine and Redis connection
278
+ */
279
+ getHealthStatus(): Promise<HealthStatus>;
280
+ /**
281
+ * Enable or disable debug mode
282
+ */
283
+ enableDebugMode(enabled: boolean): void;
284
+ /**
285
+ * Gracefully disconnect and cleanup resources
286
+ */
287
+ disconnect(): Promise<void>;
288
+ /**
289
+ * Initialize all engine components
290
+ */
291
+ private initializeComponents;
292
+ /**
293
+ * Merge user config with defaults
294
+ */
295
+ private mergeWithDefaults;
296
+ /**
297
+ * UUIDv4 collisions are practically impossible, unlike the previous
298
+ * Math.random()-based IDs which collided regularly under high concurrency.
299
+ */
300
+ private generateOperationId;
301
+ private generateRequestId;
302
+ }