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,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
+ }