@zlink-systems/framework-locations-redis 0.10.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,330 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BLOB_RENEW_SCRIPT = exports.BLOB_READ_SCRIPT = exports.BLOB_PUT_SCRIPT = exports.OPAQUE_SCAN_CONTINUE_SCRIPT = exports.OPAQUE_SCAN_START_SCRIPT = exports.OPAQUE_WRITE_SCRIPT = exports.OPAQUE_READ_SCRIPT = void 0;
4
+ const PROLOGUE = `
5
+ if redis.replicate_commands then redis.replicate_commands() end
6
+ local time = redis.call('TIME')
7
+ local nowMs = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
8
+ `;
9
+ // Shared decode helper. Every opaque row is a Redis ZSET append-log; the
10
+ // member with the highest score (the provider's monotonically increasing
11
+ // INCR sequence) is current. Each member is a 1-byte format tag (0x01)
12
+ // followed by a cmsgpack array {originalKey, rawBytes, version,
13
+ // expiresAtMs, tombstone}. expiresAtMs == 0 means no expiry (unsigned int
14
+ // family, never negative). tombstone is a real MessagePack bool.
15
+ // Unrecognized format tags fail explicitly rather than guessing how to
16
+ // read the value (21-location-runtime.md#2.4, 22-location-store-redis.md#7).
17
+ const DECODE_HELPERS = `
18
+ local function decodeMember(raw)
19
+ if string.byte(raw, 1) ~= 1 then
20
+ return redis.error_reply('zlink opaque record: unrecognized format tag')
21
+ end
22
+ return cmsgpack.unpack(string.sub(raw, 2))
23
+ end
24
+
25
+ local function liveRecordAt(rowKey, referenceMs)
26
+ local members = redis.call('ZREVRANGE', rowKey, 0, 0)
27
+ if #members == 0 then return nil end
28
+ local record = decodeMember(members[1])
29
+ local expiresAtMs = tonumber(record[4])
30
+ if record[5] == true or (expiresAtMs > 0 and expiresAtMs <= referenceMs) then
31
+ return nil
32
+ end
33
+ return record
34
+ end
35
+
36
+ local function encodeMember(originalKey, bytes, version, expiresAtMs, tombstone)
37
+ return string.char(1) .. cmsgpack.pack({
38
+ originalKey, bytes, version, expiresAtMs, tombstone
39
+ })
40
+ end
41
+ `;
42
+ exports.OPAQUE_READ_SCRIPT = PROLOGUE + DECODE_HELPERS + `
43
+ local record = liveRecordAt(KEYS[1], nowMs)
44
+ if not record then return {0, nowMs} end
45
+ return {1, nowMs, record[1], record[2], record[3], tostring(tonumber(record[4]))}
46
+ `;
47
+ // KEYS[1..6] = indexKey, mapKey, cleanupKey, sequenceKey, snapshotExpiryKey,
48
+ // snapshotBoundaryKey (private auxiliary keys, not part of the public
49
+ // contract). KEYS[7..] = the opaque record row keys referenced by
50
+ // conditions/mutations, in the order fixed by ARGV[1]/ARGV[2].
51
+ // ARGV[1] = JSON conditions: ['missing', keyIndex, originalKey]
52
+ // | ['version', keyIndex, originalKey, expectedVersion]
53
+ // ARGV[2] = JSON mutation metadata (no raw bytes, JSON must stay text-safe):
54
+ // ['put', keyIndex, originalKey, retentionMsOrFalse]
55
+ // | ['delete', keyIndex, originalKey]
56
+ // ARGV[3..] = one raw-bytes argument per 'put' mutation, in mutation order.
57
+ exports.OPAQUE_WRITE_SCRIPT = PROLOGUE + DECODE_HELPERS + `
58
+ local indexKey = KEYS[1]
59
+ local mapKey = KEYS[2]
60
+ local cleanupKey = KEYS[3]
61
+ local sequenceKey = KEYS[4]
62
+ local snapshotExpiryKey = KEYS[5]
63
+ local snapshotBoundaryKey = KEYS[6]
64
+
65
+ local expiredSnapshots = redis.call('ZRANGEBYSCORE', snapshotExpiryKey, '-inf', nowMs, 'LIMIT', 0, 128)
66
+ for _, snapshotId in ipairs(expiredSnapshots) do
67
+ redis.call('ZREM', snapshotExpiryKey, snapshotId)
68
+ redis.call('ZREM', snapshotBoundaryKey, snapshotId)
69
+ end
70
+ local minimumBoundary = nil
71
+ local boundaryEntry = redis.call('ZRANGE', snapshotBoundaryKey, 0, 0, 'WITHSCORES')
72
+ if #boundaryEntry == 2 then minimumBoundary = tonumber(boundaryEntry[2]) end
73
+
74
+ local due = redis.call('ZRANGEBYSCORE', cleanupKey, '-inf', nowMs, 'LIMIT', 0, 32)
75
+ for _, original in ipairs(due) do
76
+ local rowKey = redis.call('HGET', mapKey, original)
77
+ local members = {}
78
+ if rowKey then
79
+ members = redis.call('ZREVRANGE', rowKey, 0, 0, 'WITHSCORES')
80
+ end
81
+ if #members == 0 then
82
+ redis.call('ZREM', indexKey, original)
83
+ redis.call('HDEL', mapKey, original)
84
+ redis.call('ZREM', cleanupKey, original)
85
+ elseif minimumBoundary then
86
+ local anchor = redis.call('ZREVRANGEBYSCORE', rowKey, minimumBoundary, '-inf', 'WITHSCORES', 'LIMIT', 0, 1)
87
+ if #anchor == 2 then
88
+ redis.call('ZREMRANGEBYSCORE', rowKey, '-inf', '(' .. anchor[2])
89
+ end
90
+ redis.call('ZADD', cleanupKey, nowMs + 1000, original)
91
+ else
92
+ local record = decodeMember(members[1])
93
+ local expiresAtMs = tonumber(record[4])
94
+ if record[5] == true or (expiresAtMs > 0 and expiresAtMs + 60000 <= nowMs) then
95
+ redis.call('DEL', rowKey)
96
+ redis.call('ZREM', indexKey, original)
97
+ redis.call('HDEL', mapKey, original)
98
+ redis.call('ZREM', cleanupKey, original)
99
+ else
100
+ redis.call('ZREMRANGEBYRANK', rowKey, 0, -2)
101
+ if expiresAtMs > 0 then
102
+ redis.call('ZADD', cleanupKey, math.max(nowMs + 1000, expiresAtMs + 60000), original)
103
+ else
104
+ redis.call('ZREM', cleanupKey, original)
105
+ end
106
+ end
107
+ end
108
+ end
109
+
110
+ local conditions = cjson.decode(ARGV[1])
111
+ local mutations = cjson.decode(ARGV[2])
112
+
113
+ for _, condition in ipairs(conditions) do
114
+ local record = liveRecordAt(KEYS[condition[2] + 6], nowMs)
115
+ local currentVersion = record and record[3] or nil
116
+ if condition[1] == 'missing' then
117
+ if currentVersion ~= nil then return {'conflict', nowMs} end
118
+ elseif currentVersion ~= condition[4] then
119
+ return {'conflict', nowMs}
120
+ end
121
+ end
122
+
123
+ for _, mutation in ipairs(mutations) do
124
+ local rowKey = KEYS[mutation[2] + 6]
125
+ if not minimumBoundary then
126
+ redis.call('ZREMRANGEBYRANK', rowKey, 0, -2)
127
+ end
128
+ if redis.call('ZCARD', rowKey) >= 128 then
129
+ return {'backlog', nowMs}
130
+ end
131
+ end
132
+
133
+ local sequence = tostring(redis.call('INCR', sequenceKey))
134
+ local byteArg = 3
135
+ local result = {'applied', nowMs}
136
+ for _, mutation in ipairs(mutations) do
137
+ local rowKey = KEYS[mutation[2] + 6]
138
+ local originalKey = mutation[3]
139
+ if mutation[1] == 'put' then
140
+ local bytes = ARGV[byteArg]
141
+ byteArg = byteArg + 1
142
+ local retention = mutation[4]
143
+ local expiresAtMs = 0
144
+ if retention ~= false then expiresAtMs = nowMs + tonumber(retention) end
145
+ redis.call('ZADD', rowKey, sequence, encodeMember(originalKey, bytes, sequence, expiresAtMs, false))
146
+ redis.call('ZADD', indexKey, 0, originalKey)
147
+ redis.call('HSET', mapKey, originalKey, rowKey)
148
+ table.insert(result, originalKey)
149
+ table.insert(result, sequence)
150
+ else
151
+ redis.call('ZADD', rowKey, sequence, encodeMember(originalKey, '', sequence, 0, true))
152
+ redis.call('ZADD', indexKey, 0, originalKey)
153
+ redis.call('HSET', mapKey, originalKey, rowKey)
154
+ end
155
+ local dueAt = nowMs + 1000
156
+ local scheduled = redis.call('ZSCORE', cleanupKey, originalKey)
157
+ if not scheduled or tonumber(scheduled) > dueAt then
158
+ redis.call('ZADD', cleanupKey, dueAt, originalKey)
159
+ end
160
+ end
161
+ return result
162
+ `;
163
+ // Shared body for point-in-time paged scanning. KEYS[1]=indexKey,
164
+ // KEYS[2]=mapKey, KEYS[3]=snapshotKey, KEYS[4]=cleanupKey,
165
+ // KEYS[5]=sequenceKey, KEYS[6]=snapshotExpiryKey, KEYS[7]=snapshotBoundaryKey.
166
+ const SCAN_CLEANUP_AND_BOUNDARY = DECODE_HELPERS + `
167
+ local expiredSnapshots = redis.call('ZRANGEBYSCORE', KEYS[6], '-inf', nowMs, 'LIMIT', 0, 128)
168
+ for _, expiredId in ipairs(expiredSnapshots) do
169
+ redis.call('ZREM', KEYS[6], expiredId)
170
+ redis.call('ZREM', KEYS[7], expiredId)
171
+ end
172
+ local minimumBoundary = nil
173
+ local boundaryEntry = redis.call('ZRANGE', KEYS[7], 0, 0, 'WITHSCORES')
174
+ if #boundaryEntry == 2 then minimumBoundary = tonumber(boundaryEntry[2]) end
175
+
176
+ local due = redis.call('ZRANGEBYSCORE', KEYS[4], '-inf', nowMs, 'LIMIT', 0, 32)
177
+ for _, original in ipairs(due) do
178
+ local rowKey = redis.call('HGET', KEYS[2], original)
179
+ local members = {}
180
+ if rowKey then
181
+ members = redis.call('ZREVRANGE', rowKey, 0, 0, 'WITHSCORES')
182
+ end
183
+ if #members == 0 then
184
+ redis.call('ZREM', KEYS[1], original)
185
+ redis.call('HDEL', KEYS[2], original)
186
+ redis.call('ZREM', KEYS[4], original)
187
+ elseif minimumBoundary then
188
+ local anchor = redis.call('ZREVRANGEBYSCORE', rowKey, minimumBoundary, '-inf', 'WITHSCORES', 'LIMIT', 0, 1)
189
+ if #anchor == 2 then
190
+ redis.call('ZREMRANGEBYSCORE', rowKey, '-inf', '(' .. anchor[2])
191
+ end
192
+ redis.call('ZADD', KEYS[4], nowMs + 1000, original)
193
+ else
194
+ local record = decodeMember(members[1])
195
+ local expiresAtMs = tonumber(record[4])
196
+ if record[5] == true or (expiresAtMs > 0 and expiresAtMs + 60000 <= nowMs) then
197
+ redis.call('DEL', rowKey)
198
+ redis.call('ZREM', KEYS[1], original)
199
+ redis.call('HDEL', KEYS[2], original)
200
+ redis.call('ZREM', KEYS[4], original)
201
+ else
202
+ redis.call('ZREMRANGEBYRANK', rowKey, 0, -2)
203
+ if expiresAtMs > 0 then
204
+ redis.call('ZADD', KEYS[4], math.max(nowMs + 1000, expiresAtMs + 60000), original)
205
+ else
206
+ redis.call('ZREM', KEYS[4], original)
207
+ end
208
+ end
209
+ end
210
+ end
211
+ `;
212
+ const SCAN_PAGE_READ = `
213
+ local metadata = redis.call('HMGET', KEYS[3], 'now', 'boundary', 'prefix')
214
+ if not metadata[1] or metadata[3] ~= prefix then
215
+ redis.call('ZREM', KEYS[6], snapshotId)
216
+ redis.call('ZREM', KEYS[7], snapshotId)
217
+ return {'expired'}
218
+ end
219
+ local snapshotNow = tonumber(metadata[1])
220
+ local boundary = tonumber(metadata[2])
221
+ local lower = '-'
222
+ if string.len(lastKey) > 0 then lower = '(' .. lastKey end
223
+ local workLimit = math.max(limit * 4, 128)
224
+ local originals = redis.call('ZRANGEBYLEX', KEYS[1], lower, '+', 'LIMIT', 0, workLimit + 1)
225
+ local emitted = 0
226
+ local encodedBytes = 0
227
+ local examined = 0
228
+ local result = {'page', tostring(snapshotNow), ''}
229
+ while examined < #originals and examined < workLimit and emitted < limit do
230
+ local original = originals[examined + 1]
231
+ examined = examined + 1
232
+ if string.sub(original, 1, string.len(prefix)) == prefix then
233
+ local rowKey = redis.call('HGET', KEYS[2], original)
234
+ if rowKey then
235
+ local members = redis.call('ZREVRANGEBYSCORE', rowKey, boundary, '-inf', 'LIMIT', 0, 1)
236
+ if #members > 0 then
237
+ local record = decodeMember(members[1])
238
+ local expiresAtMs = tonumber(record[4])
239
+ if record[1] == original and record[5] ~= true
240
+ and (expiresAtMs == 0 or expiresAtMs > snapshotNow) then
241
+ local itemBytes = string.len(original) + string.len(record[2]) + string.len(record[3]) + 128
242
+ if emitted > 0 and encodedBytes + itemBytes > 4194304 then
243
+ examined = examined - 1
244
+ break
245
+ end
246
+ table.insert(result, original)
247
+ table.insert(result, record[2])
248
+ table.insert(result, record[3])
249
+ table.insert(result, tostring(expiresAtMs))
250
+ encodedBytes = encodedBytes + itemBytes
251
+ emitted = emitted + 1
252
+ end
253
+ end
254
+ end
255
+ end
256
+ end
257
+
258
+ local hasMore = examined < #originals
259
+ if not hasMore and #originals > workLimit then hasMore = true end
260
+ if hasMore then
261
+ result[3] = originals[examined]
262
+ else
263
+ redis.call('DEL', KEYS[3])
264
+ redis.call('ZREM', KEYS[6], snapshotId)
265
+ redis.call('ZREM', KEYS[7], snapshotId)
266
+ end
267
+ return result
268
+ `;
269
+ // ARGV = [prefix, limit, snapshotId]
270
+ exports.OPAQUE_SCAN_START_SCRIPT = PROLOGUE + SCAN_CLEANUP_AND_BOUNDARY + `
271
+ local prefix = ARGV[1]
272
+ local limit = tonumber(ARGV[2])
273
+ local snapshotId = ARGV[3]
274
+ local lastKey = ''
275
+
276
+ if redis.call('ZCARD', KEYS[6]) >= 4096 then
277
+ return {'capacity'}
278
+ end
279
+ redis.call('DEL', KEYS[3])
280
+ local boundary = tonumber(redis.call('GET', KEYS[5]) or '0')
281
+ redis.call('HSET', KEYS[3], 'now', tostring(nowMs), 'boundary', tostring(boundary), 'prefix', prefix)
282
+ redis.call('PEXPIRE', KEYS[3], 60000)
283
+ redis.call('ZADD', KEYS[6], nowMs + 60000, snapshotId)
284
+ redis.call('ZADD', KEYS[7], boundary, snapshotId)
285
+ ` + SCAN_PAGE_READ;
286
+ // ARGV = [prefix, lastKeyHex, limit, snapshotId]
287
+ exports.OPAQUE_SCAN_CONTINUE_SCRIPT = PROLOGUE + SCAN_CLEANUP_AND_BOUNDARY + `
288
+ local prefix = ARGV[1]
289
+ local lastKey = ARGV[2]
290
+ local limit = tonumber(ARGV[3])
291
+ local snapshotId = ARGV[4]
292
+
293
+ if redis.call('EXISTS', KEYS[3]) == 0 then
294
+ redis.call('ZREM', KEYS[6], snapshotId)
295
+ redis.call('ZREM', KEYS[7], snapshotId)
296
+ return {'expired'}
297
+ end
298
+ ` + SCAN_PAGE_READ;
299
+ // Relocation Store: raw-bytes STRING payloads at
300
+ // {prefix}:zlink-relocation-v1:blob:{reference}, retention via PSETEX/PX
301
+ // (23-relocation-store-redis.md#8). KEYS[1] is the blob key -- the reference
302
+ // itself is already the key's last segment, so identity on retry is decided
303
+ // by comparing the stored bytes against ARGV[1].
304
+ exports.BLOB_PUT_SCRIPT = PROLOGUE + `
305
+ local existing = redis.call('GET', KEYS[1])
306
+ if existing then
307
+ if existing ~= ARGV[1] then
308
+ return {'conflict', nowMs}
309
+ end
310
+ local ttl = redis.call('PTTL', KEYS[1])
311
+ local expiresAtMs = nowMs + math.max(ttl, 0)
312
+ return {'alreadyStored', nowMs, tostring(expiresAtMs)}
313
+ end
314
+ local retentionMs = tonumber(ARGV[2])
315
+ redis.call('SET', KEYS[1], ARGV[1], 'PX', retentionMs)
316
+ return {'stored', nowMs, tostring(nowMs + retentionMs)}
317
+ `;
318
+ exports.BLOB_READ_SCRIPT = PROLOGUE + `
319
+ local bytes = redis.call('GET', KEYS[1])
320
+ if not bytes then return {0, nowMs} end
321
+ local ttl = redis.call('PTTL', KEYS[1])
322
+ local expiresAtMs = nowMs + math.max(ttl, 0)
323
+ return {1, nowMs, bytes, tostring(expiresAtMs)}
324
+ `;
325
+ exports.BLOB_RENEW_SCRIPT = PROLOGUE + `
326
+ if redis.call('EXISTS', KEYS[1]) == 0 then return {0, nowMs} end
327
+ local retentionMs = tonumber(ARGV[1])
328
+ redis.call('PEXPIRE', KEYS[1], retentionMs)
329
+ return {1, nowMs, tostring(nowMs + retentionMs)}
330
+ `;
@@ -0,0 +1,22 @@
1
+ import type { ZLinkLocationStore, ZLinkStoreKey, ZLinkStoreReadResult, ZLinkStoreScanRequest, ZLinkStoreScanResult, ZLinkStoreWriteRequest, ZLinkStoreWriteResult } from '@zlink-systems/framework';
2
+ import type { ZLinkRedisLocationOptions } from './redis-options';
3
+ /** Redis implementation of the opaque Location Store provider SPI. */
4
+ export declare class ZLinkRedisLocationStore implements ZLinkLocationStore {
5
+ private readonly connection;
6
+ private readonly domain;
7
+ constructor(options: ZLinkRedisLocationOptions);
8
+ read(key: ZLinkStoreKey, signal?: AbortSignal): Promise<ZLinkStoreReadResult>;
9
+ write(request: ZLinkStoreWriteRequest, signal?: AbortSignal): Promise<ZLinkStoreWriteResult>;
10
+ scan(request: ZLinkStoreScanRequest, signal?: AbortSignal): Promise<ZLinkStoreScanResult>;
11
+ dispose(): Promise<void>;
12
+ private readScanPage;
13
+ private scanKeys;
14
+ private indexKey;
15
+ private mapKey;
16
+ private cleanupKey;
17
+ private sequenceKey;
18
+ private snapshotExpiryKey;
19
+ private snapshotBoundaryKey;
20
+ private snapshotKey;
21
+ private rowKey;
22
+ }
@@ -0,0 +1,295 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ZLinkRedisLocationStore = void 0;
4
+ const node_crypto_1 = require("node:crypto");
5
+ const redis_connection_1 = require("./redis-connection");
6
+ const opaque_redis_scripts_1 = require("./opaque-redis-scripts");
7
+ const redis_values_1 = require("./redis-values");
8
+ const MAX_VALUE_BYTES = 1024 * 1024;
9
+ const MAX_WRITE_KEYS = 2_048;
10
+ const MAX_WRITE_BYTES = 4 * 1024 * 1024;
11
+ // {prefix}:{zlink-location-v3}:opaque:{sha256hex(preimage)} is the public
12
+ // contract (21-location-runtime.md#2.4, 22-location-store-redis.md#7). The
13
+ // braces are a Redis Cluster hash tag: every key this provider's scripts
14
+ // touch in one EVAL (the record row plus the private auxiliary keys below)
15
+ // must land on the same hash slot, matching the dotnet/java reference. Only
16
+ // the six auxiliary keys below (index/map/cleanup/sequence/snapshot*) are a
17
+ // private implementation detail of this provider's point-in-time scan.
18
+ const NAMESPACE = '{zlink-location-v3}:opaque';
19
+ /** Redis implementation of the opaque Location Store provider SPI. */
20
+ class ZLinkRedisLocationStore {
21
+ connection;
22
+ domain;
23
+ constructor(options) {
24
+ this.connection = new redis_connection_1.RedisConnection(options);
25
+ this.domain = `${options.keyPrefix}:${NAMESPACE}`;
26
+ }
27
+ async read(key, signal) {
28
+ const logicalKey = requireKey(key);
29
+ const result = (0, redis_values_1.asArray)(await this.connection.eval(opaque_redis_scripts_1.OPAQUE_READ_SCRIPT, [this.rowKey(logicalKey)], [], signal));
30
+ const storeNow = fromUnixMs((0, redis_values_1.toNumber)(result[1]));
31
+ if ((0, redis_values_1.toNumber)(result[0]) !== 1)
32
+ return { kind: 'missing', storeNow };
33
+ requireMatchingKey((0, redis_values_1.asString)(result[2]), logicalKey);
34
+ return {
35
+ kind: 'found',
36
+ value: {
37
+ bytes: rawBytes(result[3]),
38
+ version: storeVersion((0, redis_values_1.asString)(result[4])),
39
+ expiresAt: expiresAtOrUndefined((0, redis_values_1.toNumber)(result[5])),
40
+ storeNow
41
+ }
42
+ };
43
+ }
44
+ async write(request, signal) {
45
+ const encoded = encodeWrite(request);
46
+ const result = (0, redis_values_1.asArray)(await this.connection.eval(opaque_redis_scripts_1.OPAQUE_WRITE_SCRIPT, [
47
+ this.indexKey(),
48
+ this.mapKey(),
49
+ this.cleanupKey(),
50
+ this.sequenceKey(),
51
+ this.snapshotExpiryKey(),
52
+ this.snapshotBoundaryKey(),
53
+ ...encoded.keys.map(key => this.rowKey(key))
54
+ ], [
55
+ JSON.stringify(encoded.conditions),
56
+ JSON.stringify(encoded.mutations),
57
+ ...encoded.putBytes
58
+ ], signal));
59
+ const storeNow = fromUnixMs((0, redis_values_1.toNumber)(result[1]));
60
+ const outcome = (0, redis_values_1.asString)(result[0]);
61
+ if (outcome === 'conflict')
62
+ return { kind: 'conflict', storeNow };
63
+ if (outcome === 'backlog') {
64
+ throw new Error('Redis Location Store version backlog is full.');
65
+ }
66
+ if (outcome !== 'applied') {
67
+ throw new Error('Redis Location Store returned an unrecognized write outcome.');
68
+ }
69
+ const putVersions = [];
70
+ for (let index = 2; index < result.length; index += 2) {
71
+ putVersions.push({
72
+ key: storeKey((0, redis_values_1.asString)(result[index])),
73
+ version: storeVersion((0, redis_values_1.asString)(result[index + 1]))
74
+ });
75
+ }
76
+ return { kind: 'applied', putVersions, storeNow };
77
+ }
78
+ async scan(request, signal) {
79
+ requireScanRequest(request);
80
+ if (request.cursor === undefined) {
81
+ const snapshotId = (0, node_crypto_1.randomUUID)();
82
+ return await this.readScanPage(snapshotId, await this.connection.eval(opaque_redis_scripts_1.OPAQUE_SCAN_START_SCRIPT, this.scanKeys(snapshotId), [request.prefix, String(request.limit), snapshotId], signal));
83
+ }
84
+ const cursor = parseCursor(request.cursor);
85
+ return await this.readScanPage(cursor.snapshotId, await this.connection.eval(opaque_redis_scripts_1.OPAQUE_SCAN_CONTINUE_SCRIPT, this.scanKeys(cursor.snapshotId), [request.prefix, cursor.lastKey, String(request.limit), cursor.snapshotId], signal));
86
+ }
87
+ async dispose() {
88
+ await this.connection.dispose();
89
+ }
90
+ async readScanPage(snapshotId, raw) {
91
+ const result = (0, redis_values_1.asArray)(raw);
92
+ const outcome = (0, redis_values_1.asString)(result[0]);
93
+ if (outcome === 'expired')
94
+ return { kind: 'expired' };
95
+ if (outcome === 'capacity') {
96
+ throw new Error('Redis Location Store snapshot capacity is full.');
97
+ }
98
+ if (outcome !== 'page') {
99
+ throw new Error('Redis Location Store returned an unrecognized scan outcome.');
100
+ }
101
+ const storeNow = fromUnixMs((0, redis_values_1.toNumber)(result[1]));
102
+ const nextKey = (0, redis_values_1.asString)(result[2]);
103
+ const items = [];
104
+ for (let index = 3; index < result.length; index += 4) {
105
+ items.push({
106
+ key: storeKey((0, redis_values_1.asString)(result[index])),
107
+ value: {
108
+ bytes: rawBytes(result[index + 1]),
109
+ version: storeVersion((0, redis_values_1.asString)(result[index + 2])),
110
+ expiresAt: expiresAtOrUndefined((0, redis_values_1.toNumber)(result[index + 3])),
111
+ storeNow
112
+ }
113
+ });
114
+ }
115
+ return {
116
+ kind: 'page',
117
+ value: {
118
+ items,
119
+ nextCursor: nextKey.length === 0
120
+ ? undefined
121
+ : scanCursor(`${snapshotId}:${Buffer.from(nextKey, 'utf8').toString('hex')}`),
122
+ storeNow
123
+ }
124
+ };
125
+ }
126
+ scanKeys(snapshotId) {
127
+ return [
128
+ this.indexKey(),
129
+ this.mapKey(),
130
+ this.snapshotKey(snapshotId),
131
+ this.cleanupKey(),
132
+ this.sequenceKey(),
133
+ this.snapshotExpiryKey(),
134
+ this.snapshotBoundaryKey()
135
+ ];
136
+ }
137
+ indexKey() {
138
+ return `${this.domain}:index`;
139
+ }
140
+ mapKey() {
141
+ return `${this.domain}:map`;
142
+ }
143
+ cleanupKey() {
144
+ return `${this.domain}:cleanup`;
145
+ }
146
+ sequenceKey() {
147
+ return `${this.domain}:sequence`;
148
+ }
149
+ snapshotExpiryKey() {
150
+ return `${this.domain}:snapshot-expiry`;
151
+ }
152
+ snapshotBoundaryKey() {
153
+ return `${this.domain}:snapshot-boundary`;
154
+ }
155
+ snapshotKey(snapshotId) {
156
+ return `${this.domain}:scan:${snapshotId}`;
157
+ }
158
+ rowKey(logicalKey) {
159
+ return `${this.domain}:${digest(logicalKey)}`;
160
+ }
161
+ }
162
+ exports.ZLinkRedisLocationStore = ZLinkRedisLocationStore;
163
+ function encodeWrite(request) {
164
+ const conditionKeys = request.conditions.map(condition => requireKey(condition.key));
165
+ const mutationKeys = request.mutations.map(mutation => requireKey(mutation.key));
166
+ if (new Set(conditionKeys).size !== conditionKeys.length
167
+ || new Set(mutationKeys).size !== mutationKeys.length) {
168
+ throw new RangeError('Location Store condition and mutation keys must be unique.');
169
+ }
170
+ const keys = [...new Set([...conditionKeys, ...mutationKeys])];
171
+ if (keys.length > MAX_WRITE_KEYS) {
172
+ throw new RangeError('Location Store write exceeds 2,048 unique keys.');
173
+ }
174
+ // Row keys are appended after the six fixed auxiliary keys; the script
175
+ // adds 6 to this 1-based index before indexing into KEYS.
176
+ const keyIndex = new Map(keys.map((key, index) => [key, index + 1]));
177
+ let encodedBytes = 0;
178
+ const conditions = request.conditions.map(condition => {
179
+ const key = requireKey(condition.key);
180
+ encodedBytes += Buffer.byteLength(key, 'utf8');
181
+ if (condition.kind === 'missing')
182
+ return ['missing', keyIndex.get(key), key];
183
+ const expected = requireVersion(condition.expected);
184
+ encodedBytes += Buffer.byteLength(expected, 'utf8');
185
+ return ['version', keyIndex.get(key), key, expected];
186
+ });
187
+ const putBytes = [];
188
+ const mutations = request.mutations.map(mutation => {
189
+ const key = requireKey(mutation.key);
190
+ encodedBytes += Buffer.byteLength(key, 'utf8');
191
+ if (mutation.kind === 'delete')
192
+ return ['delete', keyIndex.get(key), key];
193
+ requireValue(mutation.bytes, mutation.retentionMs);
194
+ encodedBytes += mutation.bytes.byteLength;
195
+ putBytes.push(Buffer.from(mutation.bytes));
196
+ return [
197
+ 'put',
198
+ keyIndex.get(key),
199
+ key,
200
+ mutation.retentionMs ?? false
201
+ ];
202
+ });
203
+ if (encodedBytes > MAX_WRITE_BYTES) {
204
+ throw new RangeError('Location Store write exceeds 4 MiB encoded input.');
205
+ }
206
+ return { keys, conditions, mutations, putBytes };
207
+ }
208
+ function requireScanRequest(request) {
209
+ if (Buffer.byteLength(request.prefix, 'utf8') > 1_024) {
210
+ throw new RangeError('Location Store scan prefix exceeds 1,024 UTF-8 bytes.');
211
+ }
212
+ if (!Number.isSafeInteger(request.limit) || request.limit < 1 || request.limit > 1_000) {
213
+ throw new RangeError('Location Store scan limit must be in 1..1000.');
214
+ }
215
+ if (request.cursor !== undefined)
216
+ requireCursor(request.cursor);
217
+ }
218
+ function requireKey(key) {
219
+ const value = key.value;
220
+ const bytes = Buffer.byteLength(value, 'utf8');
221
+ if (bytes < 1 || bytes > 1_024) {
222
+ throw new RangeError('Location Store key must contain 1..1,024 UTF-8 bytes.');
223
+ }
224
+ return value;
225
+ }
226
+ function requireMatchingKey(actual, expected) {
227
+ if (actual !== expected) {
228
+ throw new Error('Redis opaque record key digest resolved to a different logical key.');
229
+ }
230
+ }
231
+ function requireVersion(version) {
232
+ const value = version.value;
233
+ const bytes = Buffer.byteLength(value, 'utf8');
234
+ if (bytes < 1 || bytes > 4_096) {
235
+ throw new RangeError('Location Store version must contain 1..4,096 UTF-8 bytes.');
236
+ }
237
+ return value;
238
+ }
239
+ function requireCursor(cursor) {
240
+ const value = cursor.value;
241
+ const bytes = Buffer.byteLength(value, 'utf8');
242
+ if (bytes < 1 || bytes > 4_096) {
243
+ throw new RangeError('Location Store cursor must contain 1..4,096 UTF-8 bytes.');
244
+ }
245
+ return value;
246
+ }
247
+ function requireValue(bytes, retentionMs) {
248
+ if (bytes.byteLength > MAX_VALUE_BYTES) {
249
+ throw new RangeError('Location Store value exceeds 1 MiB.');
250
+ }
251
+ if (retentionMs !== undefined
252
+ && (!Number.isSafeInteger(retentionMs) || retentionMs < 1)) {
253
+ throw new RangeError('Location Store retention must be a positive safe integer.');
254
+ }
255
+ }
256
+ function parseCursor(cursor) {
257
+ const value = requireCursor(cursor);
258
+ const separator = value.lastIndexOf(':');
259
+ const snapshotId = separator < 0 ? '' : value.slice(0, separator);
260
+ const lastKeyHex = separator < 0 ? '' : value.slice(separator + 1);
261
+ if (!/^[0-9a-f-]{36}$/.test(snapshotId)
262
+ || !/^[0-9a-f]*$/.test(lastKeyHex)
263
+ || lastKeyHex.length % 2 !== 0) {
264
+ throw new RangeError('Location Store scan cursor is invalid.');
265
+ }
266
+ return { snapshotId, lastKey: Buffer.from(lastKeyHex, 'hex').toString('utf8') };
267
+ }
268
+ function rawBytes(value) {
269
+ if (Buffer.isBuffer(value))
270
+ return Uint8Array.from(value);
271
+ if (value instanceof Uint8Array)
272
+ return Uint8Array.from(value);
273
+ return Uint8Array.from(Buffer.from((0, redis_values_1.asString)(value), 'utf8'));
274
+ }
275
+ function digest(value) {
276
+ return (0, node_crypto_1.createHash)('sha256').update(value, 'utf8').digest('hex');
277
+ }
278
+ function storeKey(value) {
279
+ return { value };
280
+ }
281
+ function storeVersion(value) {
282
+ return { value };
283
+ }
284
+ function scanCursor(value) {
285
+ return { value };
286
+ }
287
+ function expiresAtOrUndefined(expiresAtMs) {
288
+ return expiresAtMs === 0 ? undefined : fromUnixMs(expiresAtMs);
289
+ }
290
+ function fromUnixMs(value) {
291
+ if (!Number.isSafeInteger(value) || value < 0) {
292
+ throw new Error('Redis Store returned an invalid provider timestamp.');
293
+ }
294
+ return new Date(value);
295
+ }
@@ -0,0 +1,16 @@
1
+ import type { RedisClientType } from 'redis';
2
+ import type { ZLinkRedisLocationOptions, ZLinkRedisRelocationOptions } from './redis-options';
3
+ export type RedisCommandValue = string | Buffer;
4
+ export type RedisCommandClient = Pick<RedisClientType, 'isOpen' | 'isReady' | 'connect' | 'disconnect' | 'sendCommand' | 'quit' | 'on'>;
5
+ export declare class RedisConnection {
6
+ private readonly providedClient?;
7
+ private client?;
8
+ private connectionAttempt?;
9
+ private disposed;
10
+ private readonly operationTimeoutMs?;
11
+ constructor(options: ZLinkRedisLocationOptions | ZLinkRedisRelocationOptions);
12
+ command(args: RedisCommandValue[], signal?: AbortSignal): Promise<unknown>;
13
+ eval(script: string, keys: readonly string[], args: readonly RedisCommandValue[], signal?: AbortSignal): Promise<unknown>;
14
+ dispose(): Promise<void>;
15
+ private connected;
16
+ }