@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,136 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RedisConnection = void 0;
4
+ const redis_1 = require("redis");
5
+ class RedisConnection {
6
+ providedClient;
7
+ client;
8
+ connectionAttempt;
9
+ disposed = false;
10
+ operationTimeoutMs;
11
+ constructor(options) {
12
+ requireOptions(options);
13
+ this.providedClient = options.client;
14
+ this.operationTimeoutMs = options.operationTimeoutMs;
15
+ if (options.client === undefined) {
16
+ this.client = (0, redis_1.createClient)({
17
+ disableOfflineQueue: true,
18
+ ...(options.clientOptions ?? {}),
19
+ socket: {
20
+ reconnectStrategy: false,
21
+ ...(options.clientOptions?.socket ?? {})
22
+ },
23
+ url: options.url ?? options.clientOptions?.url
24
+ });
25
+ this.client.on('error', () => { });
26
+ }
27
+ }
28
+ async command(args, signal) {
29
+ signal?.throwIfAborted();
30
+ const client = await this.connected(signal);
31
+ // Store values are opaque bytes. Returning RESP bulk strings as UTF-8
32
+ // strings would replace invalid byte sequences before the provider can
33
+ // copy them into Uint8Array.
34
+ const operation = client.sendCommand(args, {
35
+ typeMapping: {
36
+ [redis_1.RESP_TYPES.BLOB_STRING]: Buffer
37
+ }
38
+ });
39
+ return await waitForOperation(operation, signal, this.operationTimeoutMs);
40
+ }
41
+ async eval(script, keys, args, signal) {
42
+ return await this.command(['EVAL', script, String(keys.length), ...keys, ...args], signal);
43
+ }
44
+ async dispose() {
45
+ if (this.disposed)
46
+ return;
47
+ this.disposed = true;
48
+ const client = this.client;
49
+ this.client = undefined;
50
+ if (client !== undefined && client.isOpen === true) {
51
+ await client.quit();
52
+ }
53
+ }
54
+ async connected(signal) {
55
+ signal?.throwIfAborted();
56
+ if (this.disposed)
57
+ throw new Error('Redis Store is disposed.');
58
+ const client = this.providedClient ?? this.client;
59
+ if (client === undefined)
60
+ throw new Error('Redis Store is disposed.');
61
+ if (isClientReady(client))
62
+ return client;
63
+ if (this.connectionAttempt === undefined) {
64
+ // Publish the attempt before any await. A failed connection can leave
65
+ // node-redis open but not ready; the close and reconnect sequence must
66
+ // still be owned by this single promise when callers arrive together.
67
+ const attempt = (async () => {
68
+ if (client.isOpen === true && !isClientReady(client)) {
69
+ await client.disconnect();
70
+ }
71
+ await client.connect();
72
+ })();
73
+ this.connectionAttempt = attempt;
74
+ attempt.then(() => {
75
+ if (this.connectionAttempt === attempt)
76
+ this.connectionAttempt = undefined;
77
+ }, () => {
78
+ if (this.connectionAttempt === attempt)
79
+ this.connectionAttempt = undefined;
80
+ });
81
+ }
82
+ await waitForOperation(this.connectionAttempt, signal, this.operationTimeoutMs);
83
+ if (!isClientReady(client)) {
84
+ throw new Error('Redis client connection completed before it became ready.');
85
+ }
86
+ return client;
87
+ }
88
+ }
89
+ exports.RedisConnection = RedisConnection;
90
+ function isClientReady(client) {
91
+ return client.isReady === true;
92
+ }
93
+ function requireOptions(options) {
94
+ if (options.keyPrefix.length === 0) {
95
+ throw new Error('Redis Store keyPrefix is required.');
96
+ }
97
+ if (options.keyPrefix.includes('{') || options.keyPrefix.includes('}')) {
98
+ throw new Error('Redis Store keyPrefix must not contain hash-tag braces.');
99
+ }
100
+ if (options.client === undefined
101
+ && options.url === undefined
102
+ && options.clientOptions === undefined) {
103
+ throw new Error('Redis Store requires url, clientOptions, or client.');
104
+ }
105
+ if (options.operationTimeoutMs !== undefined
106
+ && (!Number.isSafeInteger(options.operationTimeoutMs)
107
+ || options.operationTimeoutMs < 1)) {
108
+ throw new RangeError('Redis Store operationTimeoutMs must be a positive safe integer.');
109
+ }
110
+ }
111
+ async function waitForOperation(operation, signal, timeoutMs) {
112
+ if (signal === undefined && timeoutMs === undefined)
113
+ return await operation;
114
+ return await new Promise((resolve, reject) => {
115
+ let timeout;
116
+ let settled = false;
117
+ const cleanup = () => {
118
+ signal?.removeEventListener('abort', onAbort);
119
+ if (timeout !== undefined)
120
+ clearTimeout(timeout);
121
+ };
122
+ const settle = (action) => {
123
+ if (settled)
124
+ return;
125
+ settled = true;
126
+ cleanup();
127
+ action();
128
+ };
129
+ const onAbort = () => settle(() => reject(signal?.reason ?? new Error('Redis Store operation aborted.')));
130
+ signal?.addEventListener('abort', onAbort, { once: true });
131
+ if (timeoutMs !== undefined) {
132
+ timeout = setTimeout(() => settle(() => reject(new Error(`Redis Store operation timed out after ${timeoutMs} ms.`))), timeoutMs);
133
+ }
134
+ operation.then(value => settle(() => resolve(value)), error => settle(() => reject(error)));
135
+ });
136
+ }
@@ -0,0 +1,15 @@
1
+ import type { RedisClientOptions, RedisClientType } from 'redis';
2
+ export interface ZLinkRedisLocationOptions {
3
+ readonly url?: string;
4
+ readonly client?: RedisClientType;
5
+ readonly clientOptions?: RedisClientOptions;
6
+ readonly keyPrefix: string;
7
+ readonly operationTimeoutMs?: number;
8
+ }
9
+ export interface ZLinkRedisRelocationOptions {
10
+ readonly url?: string;
11
+ readonly client?: RedisClientType;
12
+ readonly clientOptions?: RedisClientOptions;
13
+ readonly keyPrefix: string;
14
+ readonly operationTimeoutMs?: number;
15
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,3 @@
1
+ export declare function asArray(value: unknown): readonly unknown[];
2
+ export declare function asString(value: unknown): string;
3
+ export declare function toNumber(value: unknown): number;
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.asArray = asArray;
4
+ exports.asString = asString;
5
+ exports.toNumber = toNumber;
6
+ function asArray(value) {
7
+ if (!Array.isArray(value)) {
8
+ throw new TypeError('Redis command returned a non-array value.');
9
+ }
10
+ return value;
11
+ }
12
+ function asString(value) {
13
+ if (typeof value === 'string') {
14
+ return value;
15
+ }
16
+ if (Buffer.isBuffer(value)) {
17
+ return value.toString();
18
+ }
19
+ if (typeof value === 'number' || typeof value === 'bigint') {
20
+ return String(value);
21
+ }
22
+ throw new TypeError('Redis command returned a non-string value.');
23
+ }
24
+ function toNumber(value) {
25
+ return Number(asString(value));
26
+ }
@@ -0,0 +1,14 @@
1
+ import type { ZLinkBlobPutResult, ZLinkBlobReadResult, ZLinkBlobReference, ZLinkBlobRenewResult, ZLinkRelocationStore } from '@zlink-systems/framework';
2
+ import type { ZLinkRedisRelocationOptions } from './redis-options';
3
+ /** Redis implementation of immutable relocation blob storage. */
4
+ export declare class ZLinkRedisRelocationStore implements ZLinkRelocationStore {
5
+ private readonly connection;
6
+ private readonly domain;
7
+ constructor(options: ZLinkRedisRelocationOptions);
8
+ put(reference: ZLinkBlobReference, payload: Uint8Array, retentionMs: number, signal?: AbortSignal): Promise<ZLinkBlobPutResult>;
9
+ read(reference: ZLinkBlobReference, signal?: AbortSignal): Promise<ZLinkBlobReadResult>;
10
+ renew(reference: ZLinkBlobReference, retentionMs: number, signal?: AbortSignal): Promise<ZLinkBlobRenewResult>;
11
+ delete(reference: ZLinkBlobReference, signal?: AbortSignal): Promise<void>;
12
+ dispose(): Promise<void>;
13
+ private blobKey;
14
+ }
@@ -0,0 +1,103 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ZLinkRedisRelocationStore = void 0;
4
+ const redis_connection_1 = require("./redis-connection");
5
+ const opaque_redis_scripts_1 = require("./opaque-redis-scripts");
6
+ const redis_values_1 = require("./redis-values");
7
+ const MAX_ENCODED_BLOB_BYTES = 64 * 1024 * 1024 + 23;
8
+ /** Redis implementation of immutable relocation blob storage. */
9
+ class ZLinkRedisRelocationStore {
10
+ connection;
11
+ domain;
12
+ constructor(options) {
13
+ this.connection = new redis_connection_1.RedisConnection(options);
14
+ // {prefix}:{zlink-relocation-v1}:blob:{reference}
15
+ // (23-relocation-store-redis.md#8). Braced for Redis Cluster hash-tag
16
+ // co-location, matching the dotnet/java reference.
17
+ this.domain = `${options.keyPrefix}:{zlink-relocation-v1}`;
18
+ }
19
+ async put(reference, payload, retentionMs, signal) {
20
+ const referenceValue = requireReference(reference);
21
+ requirePayload(payload);
22
+ const retention = requireRetention(retentionMs);
23
+ const result = (0, redis_values_1.asArray)(await this.connection.eval(opaque_redis_scripts_1.BLOB_PUT_SCRIPT, [this.blobKey(referenceValue)], [Buffer.from(payload), String(retention)], signal));
24
+ const kind = (0, redis_values_1.asString)(result[0]);
25
+ const storeNow = fromUnixMs((0, redis_values_1.toNumber)(result[1]));
26
+ if (kind === 'conflict')
27
+ return { kind, storeNow };
28
+ return {
29
+ kind: kind === 'alreadyStored' ? 'alreadyStored' : 'stored',
30
+ storeNow,
31
+ expiresAt: fromUnixMs((0, redis_values_1.toNumber)(result[2]))
32
+ };
33
+ }
34
+ async read(reference, signal) {
35
+ const referenceValue = requireReference(reference);
36
+ const result = (0, redis_values_1.asArray)(await this.connection.eval(opaque_redis_scripts_1.BLOB_READ_SCRIPT, [this.blobKey(referenceValue)], [], signal));
37
+ const storeNow = fromUnixMs((0, redis_values_1.toNumber)(result[1]));
38
+ if ((0, redis_values_1.toNumber)(result[0]) !== 1)
39
+ return { kind: 'missing', storeNow };
40
+ return {
41
+ kind: 'found',
42
+ bytes: Uint8Array.from(asBuffer(result[2])),
43
+ expiresAt: fromUnixMs((0, redis_values_1.toNumber)(result[3])),
44
+ storeNow
45
+ };
46
+ }
47
+ async renew(reference, retentionMs, signal) {
48
+ const referenceValue = requireReference(reference);
49
+ const retention = requireRetention(retentionMs);
50
+ const result = (0, redis_values_1.asArray)(await this.connection.eval(opaque_redis_scripts_1.BLOB_RENEW_SCRIPT, [this.blobKey(referenceValue)], [String(retention)], signal));
51
+ const storeNow = fromUnixMs((0, redis_values_1.toNumber)(result[1]));
52
+ if ((0, redis_values_1.toNumber)(result[0]) !== 1)
53
+ return { kind: 'missing', storeNow };
54
+ return {
55
+ kind: 'renewed',
56
+ expiresAt: fromUnixMs((0, redis_values_1.toNumber)(result[2])),
57
+ storeNow
58
+ };
59
+ }
60
+ async delete(reference, signal) {
61
+ const referenceValue = requireReference(reference);
62
+ await this.connection.command(['DEL', this.blobKey(referenceValue)], signal);
63
+ }
64
+ async dispose() {
65
+ await this.connection.dispose();
66
+ }
67
+ blobKey(reference) {
68
+ return `${this.domain}:blob:${reference}`;
69
+ }
70
+ }
71
+ exports.ZLinkRedisRelocationStore = ZLinkRedisRelocationStore;
72
+ function requireReference(reference) {
73
+ const value = reference.value;
74
+ const bytes = Buffer.byteLength(value, 'utf8');
75
+ if (bytes < 1 || bytes > 4_096) {
76
+ throw new RangeError('Relocation Store reference must contain 1..4,096 UTF-8 bytes.');
77
+ }
78
+ return value;
79
+ }
80
+ function requirePayload(payload) {
81
+ if (payload.byteLength > MAX_ENCODED_BLOB_BYTES) {
82
+ throw new RangeError('Relocation Store encoded blob exceeds 64 MiB + 23 bytes.');
83
+ }
84
+ }
85
+ function requireRetention(retentionMs) {
86
+ if (!Number.isSafeInteger(retentionMs) || retentionMs < 1) {
87
+ throw new RangeError('Relocation Store retention must be a positive safe integer.');
88
+ }
89
+ return retentionMs;
90
+ }
91
+ function asBuffer(value) {
92
+ if (Buffer.isBuffer(value))
93
+ return Buffer.from(value);
94
+ if (value instanceof Uint8Array)
95
+ return Buffer.from(value);
96
+ return Buffer.from((0, redis_values_1.asString)(value), 'utf8');
97
+ }
98
+ function fromUnixMs(value) {
99
+ if (!Number.isSafeInteger(value) || value < 0) {
100
+ throw new Error('Redis Store returned an invalid provider timestamp.');
101
+ }
102
+ return new Date(value);
103
+ }
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "@zlink-systems/framework-locations-redis",
3
+ "version": "0.10.0",
4
+ "license": "SEE LICENSE IN LICENSE",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "dependencies": {
14
+ "@zlink-systems/framework": "0.10.0",
15
+ "@zlink-systems/zlink": "0.17.3",
16
+ "redis": "^6.1.0"
17
+ }
18
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from './redis-options';
2
+ export * from './opaque-store';
3
+ export * from './relocation-store';
@@ -0,0 +1,337 @@
1
+ const PROLOGUE = `
2
+ if redis.replicate_commands then redis.replicate_commands() end
3
+ local time = redis.call('TIME')
4
+ local nowMs = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
5
+ `;
6
+
7
+ // Shared decode helper. Every opaque row is a Redis ZSET append-log; the
8
+ // member with the highest score (the provider's monotonically increasing
9
+ // INCR sequence) is current. Each member is a 1-byte format tag (0x01)
10
+ // followed by a cmsgpack array {originalKey, rawBytes, version,
11
+ // expiresAtMs, tombstone}. expiresAtMs == 0 means no expiry (unsigned int
12
+ // family, never negative). tombstone is a real MessagePack bool.
13
+ // Unrecognized format tags fail explicitly rather than guessing how to
14
+ // read the value (21-location-runtime.md#2.4, 22-location-store-redis.md#7).
15
+ const DECODE_HELPERS = `
16
+ local function decodeMember(raw)
17
+ if string.byte(raw, 1) ~= 1 then
18
+ return redis.error_reply('zlink opaque record: unrecognized format tag')
19
+ end
20
+ return cmsgpack.unpack(string.sub(raw, 2))
21
+ end
22
+
23
+ local function liveRecordAt(rowKey, referenceMs)
24
+ local members = redis.call('ZREVRANGE', rowKey, 0, 0)
25
+ if #members == 0 then return nil end
26
+ local record = decodeMember(members[1])
27
+ local expiresAtMs = tonumber(record[4])
28
+ if record[5] == true or (expiresAtMs > 0 and expiresAtMs <= referenceMs) then
29
+ return nil
30
+ end
31
+ return record
32
+ end
33
+
34
+ local function encodeMember(originalKey, bytes, version, expiresAtMs, tombstone)
35
+ return string.char(1) .. cmsgpack.pack({
36
+ originalKey, bytes, version, expiresAtMs, tombstone
37
+ })
38
+ end
39
+ `;
40
+
41
+ export const OPAQUE_READ_SCRIPT = PROLOGUE + DECODE_HELPERS + `
42
+ local record = liveRecordAt(KEYS[1], nowMs)
43
+ if not record then return {0, nowMs} end
44
+ return {1, nowMs, record[1], record[2], record[3], tostring(tonumber(record[4]))}
45
+ `;
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
+ export const 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
+
164
+ // Shared body for point-in-time paged scanning. KEYS[1]=indexKey,
165
+ // KEYS[2]=mapKey, KEYS[3]=snapshotKey, KEYS[4]=cleanupKey,
166
+ // KEYS[5]=sequenceKey, KEYS[6]=snapshotExpiryKey, KEYS[7]=snapshotBoundaryKey.
167
+ const SCAN_CLEANUP_AND_BOUNDARY = DECODE_HELPERS + `
168
+ local expiredSnapshots = redis.call('ZRANGEBYSCORE', KEYS[6], '-inf', nowMs, 'LIMIT', 0, 128)
169
+ for _, expiredId in ipairs(expiredSnapshots) do
170
+ redis.call('ZREM', KEYS[6], expiredId)
171
+ redis.call('ZREM', KEYS[7], expiredId)
172
+ end
173
+ local minimumBoundary = nil
174
+ local boundaryEntry = redis.call('ZRANGE', KEYS[7], 0, 0, 'WITHSCORES')
175
+ if #boundaryEntry == 2 then minimumBoundary = tonumber(boundaryEntry[2]) end
176
+
177
+ local due = redis.call('ZRANGEBYSCORE', KEYS[4], '-inf', nowMs, 'LIMIT', 0, 32)
178
+ for _, original in ipairs(due) do
179
+ local rowKey = redis.call('HGET', KEYS[2], original)
180
+ local members = {}
181
+ if rowKey then
182
+ members = redis.call('ZREVRANGE', rowKey, 0, 0, 'WITHSCORES')
183
+ end
184
+ if #members == 0 then
185
+ redis.call('ZREM', KEYS[1], original)
186
+ redis.call('HDEL', KEYS[2], original)
187
+ redis.call('ZREM', KEYS[4], original)
188
+ elseif minimumBoundary then
189
+ local anchor = redis.call('ZREVRANGEBYSCORE', rowKey, minimumBoundary, '-inf', 'WITHSCORES', 'LIMIT', 0, 1)
190
+ if #anchor == 2 then
191
+ redis.call('ZREMRANGEBYSCORE', rowKey, '-inf', '(' .. anchor[2])
192
+ end
193
+ redis.call('ZADD', KEYS[4], nowMs + 1000, original)
194
+ else
195
+ local record = decodeMember(members[1])
196
+ local expiresAtMs = tonumber(record[4])
197
+ if record[5] == true or (expiresAtMs > 0 and expiresAtMs + 60000 <= nowMs) then
198
+ redis.call('DEL', rowKey)
199
+ redis.call('ZREM', KEYS[1], original)
200
+ redis.call('HDEL', KEYS[2], original)
201
+ redis.call('ZREM', KEYS[4], original)
202
+ else
203
+ redis.call('ZREMRANGEBYRANK', rowKey, 0, -2)
204
+ if expiresAtMs > 0 then
205
+ redis.call('ZADD', KEYS[4], math.max(nowMs + 1000, expiresAtMs + 60000), original)
206
+ else
207
+ redis.call('ZREM', KEYS[4], original)
208
+ end
209
+ end
210
+ end
211
+ end
212
+ `;
213
+
214
+ const SCAN_PAGE_READ = `
215
+ local metadata = redis.call('HMGET', KEYS[3], 'now', 'boundary', 'prefix')
216
+ if not metadata[1] or metadata[3] ~= prefix then
217
+ redis.call('ZREM', KEYS[6], snapshotId)
218
+ redis.call('ZREM', KEYS[7], snapshotId)
219
+ return {'expired'}
220
+ end
221
+ local snapshotNow = tonumber(metadata[1])
222
+ local boundary = tonumber(metadata[2])
223
+ local lower = '-'
224
+ if string.len(lastKey) > 0 then lower = '(' .. lastKey end
225
+ local workLimit = math.max(limit * 4, 128)
226
+ local originals = redis.call('ZRANGEBYLEX', KEYS[1], lower, '+', 'LIMIT', 0, workLimit + 1)
227
+ local emitted = 0
228
+ local encodedBytes = 0
229
+ local examined = 0
230
+ local result = {'page', tostring(snapshotNow), ''}
231
+ while examined < #originals and examined < workLimit and emitted < limit do
232
+ local original = originals[examined + 1]
233
+ examined = examined + 1
234
+ if string.sub(original, 1, string.len(prefix)) == prefix then
235
+ local rowKey = redis.call('HGET', KEYS[2], original)
236
+ if rowKey then
237
+ local members = redis.call('ZREVRANGEBYSCORE', rowKey, boundary, '-inf', 'LIMIT', 0, 1)
238
+ if #members > 0 then
239
+ local record = decodeMember(members[1])
240
+ local expiresAtMs = tonumber(record[4])
241
+ if record[1] == original and record[5] ~= true
242
+ and (expiresAtMs == 0 or expiresAtMs > snapshotNow) then
243
+ local itemBytes = string.len(original) + string.len(record[2]) + string.len(record[3]) + 128
244
+ if emitted > 0 and encodedBytes + itemBytes > 4194304 then
245
+ examined = examined - 1
246
+ break
247
+ end
248
+ table.insert(result, original)
249
+ table.insert(result, record[2])
250
+ table.insert(result, record[3])
251
+ table.insert(result, tostring(expiresAtMs))
252
+ encodedBytes = encodedBytes + itemBytes
253
+ emitted = emitted + 1
254
+ end
255
+ end
256
+ end
257
+ end
258
+ end
259
+
260
+ local hasMore = examined < #originals
261
+ if not hasMore and #originals > workLimit then hasMore = true end
262
+ if hasMore then
263
+ result[3] = originals[examined]
264
+ else
265
+ redis.call('DEL', KEYS[3])
266
+ redis.call('ZREM', KEYS[6], snapshotId)
267
+ redis.call('ZREM', KEYS[7], snapshotId)
268
+ end
269
+ return result
270
+ `;
271
+
272
+ // ARGV = [prefix, limit, snapshotId]
273
+ export const OPAQUE_SCAN_START_SCRIPT = PROLOGUE + SCAN_CLEANUP_AND_BOUNDARY + `
274
+ local prefix = ARGV[1]
275
+ local limit = tonumber(ARGV[2])
276
+ local snapshotId = ARGV[3]
277
+ local lastKey = ''
278
+
279
+ if redis.call('ZCARD', KEYS[6]) >= 4096 then
280
+ return {'capacity'}
281
+ end
282
+ redis.call('DEL', KEYS[3])
283
+ local boundary = tonumber(redis.call('GET', KEYS[5]) or '0')
284
+ redis.call('HSET', KEYS[3], 'now', tostring(nowMs), 'boundary', tostring(boundary), 'prefix', prefix)
285
+ redis.call('PEXPIRE', KEYS[3], 60000)
286
+ redis.call('ZADD', KEYS[6], nowMs + 60000, snapshotId)
287
+ redis.call('ZADD', KEYS[7], boundary, snapshotId)
288
+ ` + SCAN_PAGE_READ;
289
+
290
+ // ARGV = [prefix, lastKeyHex, limit, snapshotId]
291
+ export const OPAQUE_SCAN_CONTINUE_SCRIPT = PROLOGUE + SCAN_CLEANUP_AND_BOUNDARY + `
292
+ local prefix = ARGV[1]
293
+ local lastKey = ARGV[2]
294
+ local limit = tonumber(ARGV[3])
295
+ local snapshotId = ARGV[4]
296
+
297
+ if redis.call('EXISTS', KEYS[3]) == 0 then
298
+ redis.call('ZREM', KEYS[6], snapshotId)
299
+ redis.call('ZREM', KEYS[7], snapshotId)
300
+ return {'expired'}
301
+ end
302
+ ` + SCAN_PAGE_READ;
303
+
304
+ // Relocation Store: raw-bytes STRING payloads at
305
+ // {prefix}:zlink-relocation-v1:blob:{reference}, retention via PSETEX/PX
306
+ // (23-relocation-store-redis.md#8). KEYS[1] is the blob key -- the reference
307
+ // itself is already the key's last segment, so identity on retry is decided
308
+ // by comparing the stored bytes against ARGV[1].
309
+ export const BLOB_PUT_SCRIPT = PROLOGUE + `
310
+ local existing = redis.call('GET', KEYS[1])
311
+ if existing then
312
+ if existing ~= ARGV[1] then
313
+ return {'conflict', nowMs}
314
+ end
315
+ local ttl = redis.call('PTTL', KEYS[1])
316
+ local expiresAtMs = nowMs + math.max(ttl, 0)
317
+ return {'alreadyStored', nowMs, tostring(expiresAtMs)}
318
+ end
319
+ local retentionMs = tonumber(ARGV[2])
320
+ redis.call('SET', KEYS[1], ARGV[1], 'PX', retentionMs)
321
+ return {'stored', nowMs, tostring(nowMs + retentionMs)}
322
+ `;
323
+
324
+ export const BLOB_READ_SCRIPT = PROLOGUE + `
325
+ local bytes = redis.call('GET', KEYS[1])
326
+ if not bytes then return {0, nowMs} end
327
+ local ttl = redis.call('PTTL', KEYS[1])
328
+ local expiresAtMs = nowMs + math.max(ttl, 0)
329
+ return {1, nowMs, bytes, tostring(expiresAtMs)}
330
+ `;
331
+
332
+ export const BLOB_RENEW_SCRIPT = PROLOGUE + `
333
+ if redis.call('EXISTS', KEYS[1]) == 0 then return {0, nowMs} end
334
+ local retentionMs = tonumber(ARGV[1])
335
+ redis.call('PEXPIRE', KEYS[1], retentionMs)
336
+ return {1, nowMs, tostring(nowMs + retentionMs)}
337
+ `;