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,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic Lua scripts for race-free Redis operations.
|
|
3
|
+
*
|
|
4
|
+
* Each script is registered on the ioredis client via `defineCommand` and
|
|
5
|
+
* exposed as a typed method (see redis-connection-manager.ts).
|
|
6
|
+
*
|
|
7
|
+
* Why Lua: Redis executes a Lua script as a single atomic unit, so we get
|
|
8
|
+
* compare-and-set, conditional writes, and array mutations without
|
|
9
|
+
* client-side races, WATCH/MULTI overhead, or extra round-trips.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Compare-and-set. Writes `newValue` only if the current value at `key`
|
|
13
|
+
* exactly equals `expectedValue`. Empty-string `expectedValue` means
|
|
14
|
+
* "expect key to not exist".
|
|
15
|
+
*
|
|
16
|
+
* KEYS[1] = entity key
|
|
17
|
+
* ARGV[1] = expected current value (or "" to expect absence)
|
|
18
|
+
* ARGV[2] = new value
|
|
19
|
+
* ARGV[3] = ttl seconds (0 or negative = no expiry)
|
|
20
|
+
*
|
|
21
|
+
* Returns: 1 on success, 0 on conflict.
|
|
22
|
+
*/
|
|
23
|
+
export declare const CAS_SET_SCRIPT = "\nlocal current = redis.call('GET', KEYS[1])\nlocal expected = ARGV[1]\nif expected == \"\" then\n if current then return 0 end\nelse\n if current ~= expected then return 0 end\nend\nlocal ttl = tonumber(ARGV[3])\nif ttl and ttl > 0 then\n redis.call('SET', KEYS[1], ARGV[2], 'EX', ttl)\nelse\n redis.call('SET', KEYS[1], ARGV[2])\nend\nreturn 1\n";
|
|
24
|
+
/**
|
|
25
|
+
* Set the value only if the key already exists. Used for "update only if
|
|
26
|
+
* cached" semantics so we don't accidentally warm the cache for unread data.
|
|
27
|
+
*
|
|
28
|
+
* KEYS[1] = entity key
|
|
29
|
+
* ARGV[1] = new value
|
|
30
|
+
* ARGV[2] = ttl seconds (0 or negative = no expiry)
|
|
31
|
+
*
|
|
32
|
+
* Returns: 1 on success, 0 if key did not exist.
|
|
33
|
+
*/
|
|
34
|
+
export declare const SET_IF_EXISTS_SCRIPT = "\nif redis.call('EXISTS', KEYS[1]) == 0 then return 0 end\nlocal ttl = tonumber(ARGV[2])\nif ttl and ttl > 0 then\n redis.call('SET', KEYS[1], ARGV[1], 'EX', ttl)\nelse\n redis.call('SET', KEYS[1], ARGV[1])\nend\nreturn 1\n";
|
|
35
|
+
/**
|
|
36
|
+
* Atomically add an id to a JSON-array list value. Idempotent: if the id is
|
|
37
|
+
* already present, no write happens and 0 is returned. The list value is
|
|
38
|
+
* stored as a JSON array string (e.g. "[1,2,3]"). When the key is missing,
|
|
39
|
+
* a new single-element array is created.
|
|
40
|
+
*
|
|
41
|
+
* Note: cjson.encode of an empty Lua table yields "{}" rather than "[]".
|
|
42
|
+
* We never encode an empty list here because we always insert exactly one
|
|
43
|
+
* element, so this is safe.
|
|
44
|
+
*
|
|
45
|
+
* KEYS[1] = list key
|
|
46
|
+
* ARGV[1] = id (always passed as string; numeric ids are stringified by the
|
|
47
|
+
* caller and re-parsed when read)
|
|
48
|
+
* ARGV[2] = ttl seconds (0 or negative = no expiry)
|
|
49
|
+
* ARGV[3] = "1" if id should be coerced back to number when stored, else "0"
|
|
50
|
+
*
|
|
51
|
+
* Returns: 1 if added, 0 if already present.
|
|
52
|
+
*/
|
|
53
|
+
export declare const LIST_ADD_SCRIPT = "\nlocal current = redis.call('GET', KEYS[1])\nlocal ids\nif current then\n ids = cjson.decode(current)\n if type(ids) ~= 'table' then ids = {} end\nelse\n ids = {}\nend\nlocal needle = ARGV[1]\nfor i = 1, #ids do\n if tostring(ids[i]) == needle then return 0 end\nend\nlocal toInsert\nif ARGV[3] == \"1\" then\n toInsert = tonumber(needle)\n if toInsert == nil then toInsert = needle end\nelse\n toInsert = needle\nend\ntable.insert(ids, toInsert)\nlocal encoded = cjson.encode(ids)\nlocal ttl = tonumber(ARGV[2])\nif ttl and ttl > 0 then\n redis.call('SET', KEYS[1], encoded, 'EX', ttl)\nelse\n redis.call('SET', KEYS[1], encoded)\nend\nreturn 1\n";
|
|
54
|
+
/**
|
|
55
|
+
* Atomically remove an id from a JSON-array list value. If the resulting
|
|
56
|
+
* list is empty, writes the literal "[]" (avoiding cjson's {} ambiguity).
|
|
57
|
+
*
|
|
58
|
+
* KEYS[1] = list key
|
|
59
|
+
* ARGV[1] = id (always passed as string)
|
|
60
|
+
*
|
|
61
|
+
* Returns: 1 if removed, 0 if list missing or id not found.
|
|
62
|
+
*/
|
|
63
|
+
export declare const LIST_REMOVE_SCRIPT = "\nlocal current = redis.call('GET', KEYS[1])\nif not current then return 0 end\nlocal ids = cjson.decode(current)\nif type(ids) ~= 'table' then return 0 end\nlocal needle = ARGV[1]\nlocal found = false\nlocal out = {}\nfor i = 1, #ids do\n if tostring(ids[i]) == needle and not found then\n found = true\n else\n table.insert(out, ids[i])\n end\nend\nif not found then return 0 end\nlocal encoded\nif #out == 0 then\n encoded = '[]'\nelse\n encoded = cjson.encode(out)\nend\nlocal ttl = redis.call('TTL', KEYS[1])\nif ttl and ttl > 0 then\n redis.call('SET', KEYS[1], encoded, 'EX', ttl)\nelse\n redis.call('SET', KEYS[1], encoded)\nend\nreturn 1\n";
|
|
64
|
+
/**
|
|
65
|
+
* Atomic ZADD into a sorted set, with optional max-size trim and optional
|
|
66
|
+
* back-index update for cascade invalidation. Returns 1 if a new member
|
|
67
|
+
* was added, 0 if the score was updated on an existing member.
|
|
68
|
+
*
|
|
69
|
+
* Trimming uses ZREMRANGEBYRANK to discard the lowest-scored members so
|
|
70
|
+
* that exactly `maxSize` items remain. This works correctly whether the
|
|
71
|
+
* list is being read newest-first (ZREVRANGE) or oldest-first (ZRANGE):
|
|
72
|
+
* the lowest score is always the "oldest" by whatever score axis is in
|
|
73
|
+
* use.
|
|
74
|
+
*
|
|
75
|
+
* KEYS[1] = ZSET list key
|
|
76
|
+
* KEYS[2] = membership back-index key (ignored when ARGV[5] = "0")
|
|
77
|
+
* ARGV[1] = numeric score (passed as string; Redis parses)
|
|
78
|
+
* ARGV[2] = member id (always passed as string)
|
|
79
|
+
* ARGV[3] = ttl seconds (0 = no expiry refresh)
|
|
80
|
+
* ARGV[4] = max size (0 = no trim)
|
|
81
|
+
* ARGV[5] = "1" to maintain back-index, "0" otherwise
|
|
82
|
+
*/
|
|
83
|
+
export declare const ZLIST_ADD_SCRIPT = "\nlocal added = redis.call('ZADD', KEYS[1], ARGV[1], ARGV[2])\nif ARGV[5] == \"1\" then\n redis.call('SADD', KEYS[2], KEYS[1])\nend\nlocal maxSize = tonumber(ARGV[4])\nif maxSize and maxSize > 0 then\n local size = redis.call('ZCARD', KEYS[1])\n if size > maxSize then\n redis.call('ZREMRANGEBYRANK', KEYS[1], 0, size - maxSize - 1)\n end\nend\nlocal ttl = tonumber(ARGV[3])\nif ttl and ttl > 0 then\n redis.call('EXPIRE', KEYS[1], ttl)\n if ARGV[5] == \"1\" then\n -- Back-index TTL is set to a generous multiple of the list TTL so the\n -- index outlives any one list TTL refresh. Without this it could\n -- expire while the list still references it, leaving cascade\n -- invalidation unable to find tracked lists. 4x is empirically safe.\n redis.call('EXPIRE', KEYS[2], ttl * 4)\n end\nend\nreturn added\n";
|
|
84
|
+
/**
|
|
85
|
+
* Atomic ZREM with optional back-index cleanup. Returns 1 if removed, 0
|
|
86
|
+
* if the member was not in the set.
|
|
87
|
+
*
|
|
88
|
+
* KEYS[1] = ZSET list key
|
|
89
|
+
* KEYS[2] = membership back-index key (ignored when ARGV[2] = "0")
|
|
90
|
+
* ARGV[1] = member id
|
|
91
|
+
* ARGV[2] = "1" to maintain back-index, "0" otherwise
|
|
92
|
+
*
|
|
93
|
+
* Note: we don't SREM the list key from the back-index here because we
|
|
94
|
+
* only know that *this* id was removed; other ids in the same list may
|
|
95
|
+
* still reference it. The back-index entry is per-id, and the SADD/SREM
|
|
96
|
+
* pairing is balanced across `addIndexedListItem` and the cascade path.
|
|
97
|
+
*/
|
|
98
|
+
export declare const ZLIST_REMOVE_SCRIPT = "\nlocal removed = redis.call('ZREM', KEYS[1], ARGV[1])\nif removed > 0 and ARGV[2] == \"1\" then\n -- Membership entries are id-scoped: if THIS id has no other tracked\n -- lists pointing at it (i.e. we're the last reference), the back-index\n -- still keeps the listKey -> see comment above. We intentionally leave\n -- the back-index alone here; cascade invalidation cleans it up.\nend\nreturn removed\n";
|
|
99
|
+
/**
|
|
100
|
+
* Cascade-invalidate: read every list key from the back-index for an
|
|
101
|
+
* entity, ZREM the entity from each, then delete the entity key itself
|
|
102
|
+
* and the back-index. All atomic in one round-trip.
|
|
103
|
+
*
|
|
104
|
+
* KEYS[1] = membership back-index key (e.g. __rse_membership:post:42)
|
|
105
|
+
* KEYS[2] = entity key (e.g. post:42)
|
|
106
|
+
* ARGV[1] = member id (string form, used for ZREM)
|
|
107
|
+
*
|
|
108
|
+
* Returns: number of lists the entity was removed from.
|
|
109
|
+
*
|
|
110
|
+
* Robustness: if any list key has already been deleted (TTL expired,
|
|
111
|
+
* manually removed, etc.) ZREM is a no-op for that key, which is the
|
|
112
|
+
* desired behaviour.
|
|
113
|
+
*/
|
|
114
|
+
export declare const CASCADE_INVALIDATE_SCRIPT = "\nlocal lists = redis.call('SMEMBERS', KEYS[1])\nlocal removed = 0\nfor i = 1, #lists do\n local r = redis.call('ZREM', lists[i], ARGV[1])\n if r > 0 then removed = removed + 1 end\nend\nredis.call('DEL', KEYS[1])\nredis.call('DEL', KEYS[2])\nreturn removed\n";
|
|
115
|
+
/**
|
|
116
|
+
* Names used when registering scripts with ioredis.defineCommand.
|
|
117
|
+
* These become methods on the redis client instance with the same name.
|
|
118
|
+
*/
|
|
119
|
+
export declare const SCRIPT_DEFINITIONS: readonly [{
|
|
120
|
+
readonly name: "rseCasSet";
|
|
121
|
+
readonly script: "\nlocal current = redis.call('GET', KEYS[1])\nlocal expected = ARGV[1]\nif expected == \"\" then\n if current then return 0 end\nelse\n if current ~= expected then return 0 end\nend\nlocal ttl = tonumber(ARGV[3])\nif ttl and ttl > 0 then\n redis.call('SET', KEYS[1], ARGV[2], 'EX', ttl)\nelse\n redis.call('SET', KEYS[1], ARGV[2])\nend\nreturn 1\n";
|
|
122
|
+
readonly numberOfKeys: 1;
|
|
123
|
+
}, {
|
|
124
|
+
readonly name: "rseSetIfExists";
|
|
125
|
+
readonly script: "\nif redis.call('EXISTS', KEYS[1]) == 0 then return 0 end\nlocal ttl = tonumber(ARGV[2])\nif ttl and ttl > 0 then\n redis.call('SET', KEYS[1], ARGV[1], 'EX', ttl)\nelse\n redis.call('SET', KEYS[1], ARGV[1])\nend\nreturn 1\n";
|
|
126
|
+
readonly numberOfKeys: 1;
|
|
127
|
+
}, {
|
|
128
|
+
readonly name: "rseListAdd";
|
|
129
|
+
readonly script: "\nlocal current = redis.call('GET', KEYS[1])\nlocal ids\nif current then\n ids = cjson.decode(current)\n if type(ids) ~= 'table' then ids = {} end\nelse\n ids = {}\nend\nlocal needle = ARGV[1]\nfor i = 1, #ids do\n if tostring(ids[i]) == needle then return 0 end\nend\nlocal toInsert\nif ARGV[3] == \"1\" then\n toInsert = tonumber(needle)\n if toInsert == nil then toInsert = needle end\nelse\n toInsert = needle\nend\ntable.insert(ids, toInsert)\nlocal encoded = cjson.encode(ids)\nlocal ttl = tonumber(ARGV[2])\nif ttl and ttl > 0 then\n redis.call('SET', KEYS[1], encoded, 'EX', ttl)\nelse\n redis.call('SET', KEYS[1], encoded)\nend\nreturn 1\n";
|
|
130
|
+
readonly numberOfKeys: 1;
|
|
131
|
+
}, {
|
|
132
|
+
readonly name: "rseListRemove";
|
|
133
|
+
readonly script: "\nlocal current = redis.call('GET', KEYS[1])\nif not current then return 0 end\nlocal ids = cjson.decode(current)\nif type(ids) ~= 'table' then return 0 end\nlocal needle = ARGV[1]\nlocal found = false\nlocal out = {}\nfor i = 1, #ids do\n if tostring(ids[i]) == needle and not found then\n found = true\n else\n table.insert(out, ids[i])\n end\nend\nif not found then return 0 end\nlocal encoded\nif #out == 0 then\n encoded = '[]'\nelse\n encoded = cjson.encode(out)\nend\nlocal ttl = redis.call('TTL', KEYS[1])\nif ttl and ttl > 0 then\n redis.call('SET', KEYS[1], encoded, 'EX', ttl)\nelse\n redis.call('SET', KEYS[1], encoded)\nend\nreturn 1\n";
|
|
134
|
+
readonly numberOfKeys: 1;
|
|
135
|
+
}, {
|
|
136
|
+
readonly name: "rseZListAdd";
|
|
137
|
+
readonly script: "\nlocal added = redis.call('ZADD', KEYS[1], ARGV[1], ARGV[2])\nif ARGV[5] == \"1\" then\n redis.call('SADD', KEYS[2], KEYS[1])\nend\nlocal maxSize = tonumber(ARGV[4])\nif maxSize and maxSize > 0 then\n local size = redis.call('ZCARD', KEYS[1])\n if size > maxSize then\n redis.call('ZREMRANGEBYRANK', KEYS[1], 0, size - maxSize - 1)\n end\nend\nlocal ttl = tonumber(ARGV[3])\nif ttl and ttl > 0 then\n redis.call('EXPIRE', KEYS[1], ttl)\n if ARGV[5] == \"1\" then\n -- Back-index TTL is set to a generous multiple of the list TTL so the\n -- index outlives any one list TTL refresh. Without this it could\n -- expire while the list still references it, leaving cascade\n -- invalidation unable to find tracked lists. 4x is empirically safe.\n redis.call('EXPIRE', KEYS[2], ttl * 4)\n end\nend\nreturn added\n";
|
|
138
|
+
readonly numberOfKeys: 2;
|
|
139
|
+
}, {
|
|
140
|
+
readonly name: "rseZListRemove";
|
|
141
|
+
readonly script: "\nlocal removed = redis.call('ZREM', KEYS[1], ARGV[1])\nif removed > 0 and ARGV[2] == \"1\" then\n -- Membership entries are id-scoped: if THIS id has no other tracked\n -- lists pointing at it (i.e. we're the last reference), the back-index\n -- still keeps the listKey -> see comment above. We intentionally leave\n -- the back-index alone here; cascade invalidation cleans it up.\nend\nreturn removed\n";
|
|
142
|
+
readonly numberOfKeys: 2;
|
|
143
|
+
}, {
|
|
144
|
+
readonly name: "rseCascadeInvalidate";
|
|
145
|
+
readonly script: "\nlocal lists = redis.call('SMEMBERS', KEYS[1])\nlocal removed = 0\nfor i = 1, #lists do\n local r = redis.call('ZREM', lists[i], ARGV[1])\n if r > 0 then removed = removed + 1 end\nend\nredis.call('DEL', KEYS[1])\nredis.call('DEL', KEYS[2])\nreturn removed\n";
|
|
146
|
+
readonly numberOfKeys: 2;
|
|
147
|
+
}];
|
|
148
|
+
export type ScriptName = (typeof SCRIPT_DEFINITIONS)[number]['name'];
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Atomic Lua scripts for race-free Redis operations.
|
|
4
|
+
*
|
|
5
|
+
* Each script is registered on the ioredis client via `defineCommand` and
|
|
6
|
+
* exposed as a typed method (see redis-connection-manager.ts).
|
|
7
|
+
*
|
|
8
|
+
* Why Lua: Redis executes a Lua script as a single atomic unit, so we get
|
|
9
|
+
* compare-and-set, conditional writes, and array mutations without
|
|
10
|
+
* client-side races, WATCH/MULTI overhead, or extra round-trips.
|
|
11
|
+
*/
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.SCRIPT_DEFINITIONS = exports.CASCADE_INVALIDATE_SCRIPT = exports.ZLIST_REMOVE_SCRIPT = exports.ZLIST_ADD_SCRIPT = exports.LIST_REMOVE_SCRIPT = exports.LIST_ADD_SCRIPT = exports.SET_IF_EXISTS_SCRIPT = exports.CAS_SET_SCRIPT = void 0;
|
|
14
|
+
/**
|
|
15
|
+
* Compare-and-set. Writes `newValue` only if the current value at `key`
|
|
16
|
+
* exactly equals `expectedValue`. Empty-string `expectedValue` means
|
|
17
|
+
* "expect key to not exist".
|
|
18
|
+
*
|
|
19
|
+
* KEYS[1] = entity key
|
|
20
|
+
* ARGV[1] = expected current value (or "" to expect absence)
|
|
21
|
+
* ARGV[2] = new value
|
|
22
|
+
* ARGV[3] = ttl seconds (0 or negative = no expiry)
|
|
23
|
+
*
|
|
24
|
+
* Returns: 1 on success, 0 on conflict.
|
|
25
|
+
*/
|
|
26
|
+
exports.CAS_SET_SCRIPT = `
|
|
27
|
+
local current = redis.call('GET', KEYS[1])
|
|
28
|
+
local expected = ARGV[1]
|
|
29
|
+
if expected == "" then
|
|
30
|
+
if current then return 0 end
|
|
31
|
+
else
|
|
32
|
+
if current ~= expected then return 0 end
|
|
33
|
+
end
|
|
34
|
+
local ttl = tonumber(ARGV[3])
|
|
35
|
+
if ttl and ttl > 0 then
|
|
36
|
+
redis.call('SET', KEYS[1], ARGV[2], 'EX', ttl)
|
|
37
|
+
else
|
|
38
|
+
redis.call('SET', KEYS[1], ARGV[2])
|
|
39
|
+
end
|
|
40
|
+
return 1
|
|
41
|
+
`;
|
|
42
|
+
/**
|
|
43
|
+
* Set the value only if the key already exists. Used for "update only if
|
|
44
|
+
* cached" semantics so we don't accidentally warm the cache for unread data.
|
|
45
|
+
*
|
|
46
|
+
* KEYS[1] = entity key
|
|
47
|
+
* ARGV[1] = new value
|
|
48
|
+
* ARGV[2] = ttl seconds (0 or negative = no expiry)
|
|
49
|
+
*
|
|
50
|
+
* Returns: 1 on success, 0 if key did not exist.
|
|
51
|
+
*/
|
|
52
|
+
exports.SET_IF_EXISTS_SCRIPT = `
|
|
53
|
+
if redis.call('EXISTS', KEYS[1]) == 0 then return 0 end
|
|
54
|
+
local ttl = tonumber(ARGV[2])
|
|
55
|
+
if ttl and ttl > 0 then
|
|
56
|
+
redis.call('SET', KEYS[1], ARGV[1], 'EX', ttl)
|
|
57
|
+
else
|
|
58
|
+
redis.call('SET', KEYS[1], ARGV[1])
|
|
59
|
+
end
|
|
60
|
+
return 1
|
|
61
|
+
`;
|
|
62
|
+
/**
|
|
63
|
+
* Atomically add an id to a JSON-array list value. Idempotent: if the id is
|
|
64
|
+
* already present, no write happens and 0 is returned. The list value is
|
|
65
|
+
* stored as a JSON array string (e.g. "[1,2,3]"). When the key is missing,
|
|
66
|
+
* a new single-element array is created.
|
|
67
|
+
*
|
|
68
|
+
* Note: cjson.encode of an empty Lua table yields "{}" rather than "[]".
|
|
69
|
+
* We never encode an empty list here because we always insert exactly one
|
|
70
|
+
* element, so this is safe.
|
|
71
|
+
*
|
|
72
|
+
* KEYS[1] = list key
|
|
73
|
+
* ARGV[1] = id (always passed as string; numeric ids are stringified by the
|
|
74
|
+
* caller and re-parsed when read)
|
|
75
|
+
* ARGV[2] = ttl seconds (0 or negative = no expiry)
|
|
76
|
+
* ARGV[3] = "1" if id should be coerced back to number when stored, else "0"
|
|
77
|
+
*
|
|
78
|
+
* Returns: 1 if added, 0 if already present.
|
|
79
|
+
*/
|
|
80
|
+
exports.LIST_ADD_SCRIPT = `
|
|
81
|
+
local current = redis.call('GET', KEYS[1])
|
|
82
|
+
local ids
|
|
83
|
+
if current then
|
|
84
|
+
ids = cjson.decode(current)
|
|
85
|
+
if type(ids) ~= 'table' then ids = {} end
|
|
86
|
+
else
|
|
87
|
+
ids = {}
|
|
88
|
+
end
|
|
89
|
+
local needle = ARGV[1]
|
|
90
|
+
for i = 1, #ids do
|
|
91
|
+
if tostring(ids[i]) == needle then return 0 end
|
|
92
|
+
end
|
|
93
|
+
local toInsert
|
|
94
|
+
if ARGV[3] == "1" then
|
|
95
|
+
toInsert = tonumber(needle)
|
|
96
|
+
if toInsert == nil then toInsert = needle end
|
|
97
|
+
else
|
|
98
|
+
toInsert = needle
|
|
99
|
+
end
|
|
100
|
+
table.insert(ids, toInsert)
|
|
101
|
+
local encoded = cjson.encode(ids)
|
|
102
|
+
local ttl = tonumber(ARGV[2])
|
|
103
|
+
if ttl and ttl > 0 then
|
|
104
|
+
redis.call('SET', KEYS[1], encoded, 'EX', ttl)
|
|
105
|
+
else
|
|
106
|
+
redis.call('SET', KEYS[1], encoded)
|
|
107
|
+
end
|
|
108
|
+
return 1
|
|
109
|
+
`;
|
|
110
|
+
/**
|
|
111
|
+
* Atomically remove an id from a JSON-array list value. If the resulting
|
|
112
|
+
* list is empty, writes the literal "[]" (avoiding cjson's {} ambiguity).
|
|
113
|
+
*
|
|
114
|
+
* KEYS[1] = list key
|
|
115
|
+
* ARGV[1] = id (always passed as string)
|
|
116
|
+
*
|
|
117
|
+
* Returns: 1 if removed, 0 if list missing or id not found.
|
|
118
|
+
*/
|
|
119
|
+
exports.LIST_REMOVE_SCRIPT = `
|
|
120
|
+
local current = redis.call('GET', KEYS[1])
|
|
121
|
+
if not current then return 0 end
|
|
122
|
+
local ids = cjson.decode(current)
|
|
123
|
+
if type(ids) ~= 'table' then return 0 end
|
|
124
|
+
local needle = ARGV[1]
|
|
125
|
+
local found = false
|
|
126
|
+
local out = {}
|
|
127
|
+
for i = 1, #ids do
|
|
128
|
+
if tostring(ids[i]) == needle and not found then
|
|
129
|
+
found = true
|
|
130
|
+
else
|
|
131
|
+
table.insert(out, ids[i])
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
if not found then return 0 end
|
|
135
|
+
local encoded
|
|
136
|
+
if #out == 0 then
|
|
137
|
+
encoded = '[]'
|
|
138
|
+
else
|
|
139
|
+
encoded = cjson.encode(out)
|
|
140
|
+
end
|
|
141
|
+
local ttl = redis.call('TTL', KEYS[1])
|
|
142
|
+
if ttl and ttl > 0 then
|
|
143
|
+
redis.call('SET', KEYS[1], encoded, 'EX', ttl)
|
|
144
|
+
else
|
|
145
|
+
redis.call('SET', KEYS[1], encoded)
|
|
146
|
+
end
|
|
147
|
+
return 1
|
|
148
|
+
`;
|
|
149
|
+
/**
|
|
150
|
+
* Atomic ZADD into a sorted set, with optional max-size trim and optional
|
|
151
|
+
* back-index update for cascade invalidation. Returns 1 if a new member
|
|
152
|
+
* was added, 0 if the score was updated on an existing member.
|
|
153
|
+
*
|
|
154
|
+
* Trimming uses ZREMRANGEBYRANK to discard the lowest-scored members so
|
|
155
|
+
* that exactly `maxSize` items remain. This works correctly whether the
|
|
156
|
+
* list is being read newest-first (ZREVRANGE) or oldest-first (ZRANGE):
|
|
157
|
+
* the lowest score is always the "oldest" by whatever score axis is in
|
|
158
|
+
* use.
|
|
159
|
+
*
|
|
160
|
+
* KEYS[1] = ZSET list key
|
|
161
|
+
* KEYS[2] = membership back-index key (ignored when ARGV[5] = "0")
|
|
162
|
+
* ARGV[1] = numeric score (passed as string; Redis parses)
|
|
163
|
+
* ARGV[2] = member id (always passed as string)
|
|
164
|
+
* ARGV[3] = ttl seconds (0 = no expiry refresh)
|
|
165
|
+
* ARGV[4] = max size (0 = no trim)
|
|
166
|
+
* ARGV[5] = "1" to maintain back-index, "0" otherwise
|
|
167
|
+
*/
|
|
168
|
+
exports.ZLIST_ADD_SCRIPT = `
|
|
169
|
+
local added = redis.call('ZADD', KEYS[1], ARGV[1], ARGV[2])
|
|
170
|
+
if ARGV[5] == "1" then
|
|
171
|
+
redis.call('SADD', KEYS[2], KEYS[1])
|
|
172
|
+
end
|
|
173
|
+
local maxSize = tonumber(ARGV[4])
|
|
174
|
+
if maxSize and maxSize > 0 then
|
|
175
|
+
local size = redis.call('ZCARD', KEYS[1])
|
|
176
|
+
if size > maxSize then
|
|
177
|
+
redis.call('ZREMRANGEBYRANK', KEYS[1], 0, size - maxSize - 1)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
local ttl = tonumber(ARGV[3])
|
|
181
|
+
if ttl and ttl > 0 then
|
|
182
|
+
redis.call('EXPIRE', KEYS[1], ttl)
|
|
183
|
+
if ARGV[5] == "1" then
|
|
184
|
+
-- Back-index TTL is set to a generous multiple of the list TTL so the
|
|
185
|
+
-- index outlives any one list TTL refresh. Without this it could
|
|
186
|
+
-- expire while the list still references it, leaving cascade
|
|
187
|
+
-- invalidation unable to find tracked lists. 4x is empirically safe.
|
|
188
|
+
redis.call('EXPIRE', KEYS[2], ttl * 4)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
return added
|
|
192
|
+
`;
|
|
193
|
+
/**
|
|
194
|
+
* Atomic ZREM with optional back-index cleanup. Returns 1 if removed, 0
|
|
195
|
+
* if the member was not in the set.
|
|
196
|
+
*
|
|
197
|
+
* KEYS[1] = ZSET list key
|
|
198
|
+
* KEYS[2] = membership back-index key (ignored when ARGV[2] = "0")
|
|
199
|
+
* ARGV[1] = member id
|
|
200
|
+
* ARGV[2] = "1" to maintain back-index, "0" otherwise
|
|
201
|
+
*
|
|
202
|
+
* Note: we don't SREM the list key from the back-index here because we
|
|
203
|
+
* only know that *this* id was removed; other ids in the same list may
|
|
204
|
+
* still reference it. The back-index entry is per-id, and the SADD/SREM
|
|
205
|
+
* pairing is balanced across `addIndexedListItem` and the cascade path.
|
|
206
|
+
*/
|
|
207
|
+
exports.ZLIST_REMOVE_SCRIPT = `
|
|
208
|
+
local removed = redis.call('ZREM', KEYS[1], ARGV[1])
|
|
209
|
+
if removed > 0 and ARGV[2] == "1" then
|
|
210
|
+
-- Membership entries are id-scoped: if THIS id has no other tracked
|
|
211
|
+
-- lists pointing at it (i.e. we're the last reference), the back-index
|
|
212
|
+
-- still keeps the listKey -> see comment above. We intentionally leave
|
|
213
|
+
-- the back-index alone here; cascade invalidation cleans it up.
|
|
214
|
+
end
|
|
215
|
+
return removed
|
|
216
|
+
`;
|
|
217
|
+
/**
|
|
218
|
+
* Cascade-invalidate: read every list key from the back-index for an
|
|
219
|
+
* entity, ZREM the entity from each, then delete the entity key itself
|
|
220
|
+
* and the back-index. All atomic in one round-trip.
|
|
221
|
+
*
|
|
222
|
+
* KEYS[1] = membership back-index key (e.g. __rse_membership:post:42)
|
|
223
|
+
* KEYS[2] = entity key (e.g. post:42)
|
|
224
|
+
* ARGV[1] = member id (string form, used for ZREM)
|
|
225
|
+
*
|
|
226
|
+
* Returns: number of lists the entity was removed from.
|
|
227
|
+
*
|
|
228
|
+
* Robustness: if any list key has already been deleted (TTL expired,
|
|
229
|
+
* manually removed, etc.) ZREM is a no-op for that key, which is the
|
|
230
|
+
* desired behaviour.
|
|
231
|
+
*/
|
|
232
|
+
exports.CASCADE_INVALIDATE_SCRIPT = `
|
|
233
|
+
local lists = redis.call('SMEMBERS', KEYS[1])
|
|
234
|
+
local removed = 0
|
|
235
|
+
for i = 1, #lists do
|
|
236
|
+
local r = redis.call('ZREM', lists[i], ARGV[1])
|
|
237
|
+
if r > 0 then removed = removed + 1 end
|
|
238
|
+
end
|
|
239
|
+
redis.call('DEL', KEYS[1])
|
|
240
|
+
redis.call('DEL', KEYS[2])
|
|
241
|
+
return removed
|
|
242
|
+
`;
|
|
243
|
+
/**
|
|
244
|
+
* Names used when registering scripts with ioredis.defineCommand.
|
|
245
|
+
* These become methods on the redis client instance with the same name.
|
|
246
|
+
*/
|
|
247
|
+
exports.SCRIPT_DEFINITIONS = [
|
|
248
|
+
{ name: 'rseCasSet', script: exports.CAS_SET_SCRIPT, numberOfKeys: 1 },
|
|
249
|
+
{ name: 'rseSetIfExists', script: exports.SET_IF_EXISTS_SCRIPT, numberOfKeys: 1 },
|
|
250
|
+
{ name: 'rseListAdd', script: exports.LIST_ADD_SCRIPT, numberOfKeys: 1 },
|
|
251
|
+
{ name: 'rseListRemove', script: exports.LIST_REMOVE_SCRIPT, numberOfKeys: 1 },
|
|
252
|
+
{ name: 'rseZListAdd', script: exports.ZLIST_ADD_SCRIPT, numberOfKeys: 2 },
|
|
253
|
+
{ name: 'rseZListRemove', script: exports.ZLIST_REMOVE_SCRIPT, numberOfKeys: 2 },
|
|
254
|
+
{
|
|
255
|
+
name: 'rseCascadeInvalidate',
|
|
256
|
+
script: exports.CASCADE_INVALIDATE_SCRIPT,
|
|
257
|
+
numberOfKeys: 2,
|
|
258
|
+
},
|
|
259
|
+
];
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalization Engine - Converts nested objects into normalized Redis 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 { NormalizedEntity, WriteResult } from '../types';
|
|
9
|
+
export interface NormalizationResult {
|
|
10
|
+
normalizedEntities: Map<string, NormalizedEntity>;
|
|
11
|
+
primaryEntityKey: string;
|
|
12
|
+
operationId: string;
|
|
13
|
+
}
|
|
14
|
+
export declare class NormalizationEngine {
|
|
15
|
+
private schemaManager;
|
|
16
|
+
private connectionManager;
|
|
17
|
+
private codec;
|
|
18
|
+
private serializer;
|
|
19
|
+
constructor(schemaManager: SchemaManager, connectionManager: RedisConnectionManager, codec?: CompressionCodec, serializer?: Serializer);
|
|
20
|
+
/**
|
|
21
|
+
* Normalize a complex nested entity into separate Redis keys
|
|
22
|
+
*/
|
|
23
|
+
normalizeEntity(entityType: string, data: any, operationId?: string): Promise<NormalizationResult>;
|
|
24
|
+
/**
|
|
25
|
+
* Write normalized entities to Redis using a per-key compare-and-set so
|
|
26
|
+
* concurrent writers cannot clobber each other's partial updates. The
|
|
27
|
+
* caller-supplied entity-type lookup determines TTL per key.
|
|
28
|
+
*
|
|
29
|
+
* Flow per key:
|
|
30
|
+
* 1. GET current value
|
|
31
|
+
* 2. Merge in Node (preserves "missing fields keep existing" semantics)
|
|
32
|
+
* 3. CAS write via Lua. If another writer raced us, retry up to
|
|
33
|
+
* `maxCasAttempts` times with the freshly read value.
|
|
34
|
+
*
|
|
35
|
+
* All keys are processed in parallel (Promise.all) and ioredis pipelines
|
|
36
|
+
* them automatically across the same connection, so latency stays close
|
|
37
|
+
* to the original implementation while gaining atomicity.
|
|
38
|
+
*/
|
|
39
|
+
writeNormalizedEntities(normalizationResult: NormalizationResult, resolveTTL?: (key: string) => number): Promise<WriteResult>;
|
|
40
|
+
/**
|
|
41
|
+
* Per-key compare-and-set merge with bounded retry. Each retry re-reads
|
|
42
|
+
* the current value so the merge is always against the freshest view.
|
|
43
|
+
* Surfaces a RedisConnectionError if the contention budget is exhausted,
|
|
44
|
+
* which is the right behaviour: callers should retry at the request level
|
|
45
|
+
* rather than spin forever inside a single write.
|
|
46
|
+
*/
|
|
47
|
+
private atomicMergeWrite;
|
|
48
|
+
/**
|
|
49
|
+
* Recursively normalize entity and its relationships
|
|
50
|
+
*/
|
|
51
|
+
private normalizeRecursive;
|
|
52
|
+
/**
|
|
53
|
+
* Process a relationship and normalize related entities
|
|
54
|
+
*/
|
|
55
|
+
private processRelationship;
|
|
56
|
+
/**
|
|
57
|
+
* Smart merge strategy based on field types
|
|
58
|
+
*/
|
|
59
|
+
private smartMerge;
|
|
60
|
+
/**
|
|
61
|
+
* Check if value is primitive (string, number, boolean, null, undefined)
|
|
62
|
+
*/
|
|
63
|
+
private isPrimitiveValue;
|
|
64
|
+
/**
|
|
65
|
+
* Check if value is a plain object
|
|
66
|
+
*/
|
|
67
|
+
private isObject;
|
|
68
|
+
/**
|
|
69
|
+
* Deep merge two objects
|
|
70
|
+
*/
|
|
71
|
+
private deepMergeObjects;
|
|
72
|
+
/**
|
|
73
|
+
* Generate unique operation ID
|
|
74
|
+
*/
|
|
75
|
+
private generateOperationId;
|
|
76
|
+
}
|