@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.
- package/LICENSE +105 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +19 -0
- package/dist/opaque-redis-scripts.d.ts +7 -0
- package/dist/opaque-redis-scripts.js +330 -0
- package/dist/opaque-store.d.ts +22 -0
- package/dist/opaque-store.js +295 -0
- package/dist/redis-connection.d.ts +16 -0
- package/dist/redis-connection.js +136 -0
- package/dist/redis-options.d.ts +15 -0
- package/dist/redis-options.js +2 -0
- package/dist/redis-values.d.ts +3 -0
- package/dist/redis-values.js +26 -0
- package/dist/relocation-store.d.ts +14 -0
- package/dist/relocation-store.js +103 -0
- package/package.json +18 -0
- package/src/index.ts +3 -0
- package/src/opaque-redis-scripts.ts +337 -0
- package/src/opaque-store.ts +387 -0
- package/src/redis-connection.ts +174 -0
- package/src/redis-options.ts +17 -0
- package/src/redis-values.ts +23 -0
- package/src/relocation-store.ts +150 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import type {
|
|
3
|
+
ZLinkLocationStore,
|
|
4
|
+
ZLinkStoreKey,
|
|
5
|
+
ZLinkStoreReadResult,
|
|
6
|
+
ZLinkStoreScanCursor,
|
|
7
|
+
ZLinkStoreScanRequest,
|
|
8
|
+
ZLinkStoreScanResult,
|
|
9
|
+
ZLinkStoreVersion,
|
|
10
|
+
ZLinkStoreWriteRequest,
|
|
11
|
+
ZLinkStoreWriteResult
|
|
12
|
+
} from '@zlink-systems/framework';
|
|
13
|
+
import type { ZLinkRedisLocationOptions } from './redis-options';
|
|
14
|
+
import { RedisConnection } from './redis-connection';
|
|
15
|
+
import {
|
|
16
|
+
OPAQUE_READ_SCRIPT,
|
|
17
|
+
OPAQUE_SCAN_CONTINUE_SCRIPT,
|
|
18
|
+
OPAQUE_SCAN_START_SCRIPT,
|
|
19
|
+
OPAQUE_WRITE_SCRIPT
|
|
20
|
+
} from './opaque-redis-scripts';
|
|
21
|
+
import { asArray, asString, toNumber } from './redis-values';
|
|
22
|
+
|
|
23
|
+
const MAX_VALUE_BYTES = 1024 * 1024;
|
|
24
|
+
const MAX_WRITE_KEYS = 2_048;
|
|
25
|
+
const MAX_WRITE_BYTES = 4 * 1024 * 1024;
|
|
26
|
+
|
|
27
|
+
// {prefix}:{zlink-location-v3}:opaque:{sha256hex(preimage)} is the public
|
|
28
|
+
// contract (21-location-runtime.md#2.4, 22-location-store-redis.md#7). The
|
|
29
|
+
// braces are a Redis Cluster hash tag: every key this provider's scripts
|
|
30
|
+
// touch in one EVAL (the record row plus the private auxiliary keys below)
|
|
31
|
+
// must land on the same hash slot, matching the dotnet/java reference. Only
|
|
32
|
+
// the six auxiliary keys below (index/map/cleanup/sequence/snapshot*) are a
|
|
33
|
+
// private implementation detail of this provider's point-in-time scan.
|
|
34
|
+
const NAMESPACE = '{zlink-location-v3}:opaque';
|
|
35
|
+
|
|
36
|
+
/** Redis implementation of the opaque Location Store provider SPI. */
|
|
37
|
+
export class ZLinkRedisLocationStore implements ZLinkLocationStore {
|
|
38
|
+
private readonly connection: RedisConnection;
|
|
39
|
+
private readonly domain: string;
|
|
40
|
+
|
|
41
|
+
constructor(options: ZLinkRedisLocationOptions) {
|
|
42
|
+
this.connection = new RedisConnection(options);
|
|
43
|
+
this.domain = `${options.keyPrefix}:${NAMESPACE}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async read(
|
|
47
|
+
key: ZLinkStoreKey,
|
|
48
|
+
signal?: AbortSignal
|
|
49
|
+
): Promise<ZLinkStoreReadResult> {
|
|
50
|
+
const logicalKey = requireKey(key);
|
|
51
|
+
const result = asArray(await this.connection.eval(
|
|
52
|
+
OPAQUE_READ_SCRIPT,
|
|
53
|
+
[this.rowKey(logicalKey)],
|
|
54
|
+
[],
|
|
55
|
+
signal
|
|
56
|
+
));
|
|
57
|
+
const storeNow = fromUnixMs(toNumber(result[1]));
|
|
58
|
+
if (toNumber(result[0]) !== 1) return { kind: 'missing', storeNow };
|
|
59
|
+
requireMatchingKey(asString(result[2]), logicalKey);
|
|
60
|
+
return {
|
|
61
|
+
kind: 'found',
|
|
62
|
+
value: {
|
|
63
|
+
bytes: rawBytes(result[3]),
|
|
64
|
+
version: storeVersion(asString(result[4])),
|
|
65
|
+
expiresAt: expiresAtOrUndefined(toNumber(result[5])),
|
|
66
|
+
storeNow
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async write(
|
|
72
|
+
request: ZLinkStoreWriteRequest,
|
|
73
|
+
signal?: AbortSignal
|
|
74
|
+
): Promise<ZLinkStoreWriteResult> {
|
|
75
|
+
const encoded = encodeWrite(request);
|
|
76
|
+
const result = asArray(await this.connection.eval(
|
|
77
|
+
OPAQUE_WRITE_SCRIPT,
|
|
78
|
+
[
|
|
79
|
+
this.indexKey(),
|
|
80
|
+
this.mapKey(),
|
|
81
|
+
this.cleanupKey(),
|
|
82
|
+
this.sequenceKey(),
|
|
83
|
+
this.snapshotExpiryKey(),
|
|
84
|
+
this.snapshotBoundaryKey(),
|
|
85
|
+
...encoded.keys.map(key => this.rowKey(key))
|
|
86
|
+
],
|
|
87
|
+
[
|
|
88
|
+
JSON.stringify(encoded.conditions),
|
|
89
|
+
JSON.stringify(encoded.mutations),
|
|
90
|
+
...encoded.putBytes
|
|
91
|
+
],
|
|
92
|
+
signal
|
|
93
|
+
));
|
|
94
|
+
const storeNow = fromUnixMs(toNumber(result[1]));
|
|
95
|
+
const outcome = asString(result[0]);
|
|
96
|
+
if (outcome === 'conflict') return { kind: 'conflict', storeNow };
|
|
97
|
+
if (outcome === 'backlog') {
|
|
98
|
+
throw new Error('Redis Location Store version backlog is full.');
|
|
99
|
+
}
|
|
100
|
+
if (outcome !== 'applied') {
|
|
101
|
+
throw new Error('Redis Location Store returned an unrecognized write outcome.');
|
|
102
|
+
}
|
|
103
|
+
const putVersions = [];
|
|
104
|
+
for (let index = 2; index < result.length; index += 2) {
|
|
105
|
+
putVersions.push({
|
|
106
|
+
key: storeKey(asString(result[index])),
|
|
107
|
+
version: storeVersion(asString(result[index + 1]))
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return { kind: 'applied', putVersions, storeNow };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async scan(
|
|
114
|
+
request: ZLinkStoreScanRequest,
|
|
115
|
+
signal?: AbortSignal
|
|
116
|
+
): Promise<ZLinkStoreScanResult> {
|
|
117
|
+
requireScanRequest(request);
|
|
118
|
+
if (request.cursor === undefined) {
|
|
119
|
+
const snapshotId = randomUUID();
|
|
120
|
+
return await this.readScanPage(
|
|
121
|
+
snapshotId,
|
|
122
|
+
await this.connection.eval(
|
|
123
|
+
OPAQUE_SCAN_START_SCRIPT,
|
|
124
|
+
this.scanKeys(snapshotId),
|
|
125
|
+
[request.prefix, String(request.limit), snapshotId],
|
|
126
|
+
signal
|
|
127
|
+
)
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
const cursor = parseCursor(request.cursor);
|
|
131
|
+
return await this.readScanPage(
|
|
132
|
+
cursor.snapshotId,
|
|
133
|
+
await this.connection.eval(
|
|
134
|
+
OPAQUE_SCAN_CONTINUE_SCRIPT,
|
|
135
|
+
this.scanKeys(cursor.snapshotId),
|
|
136
|
+
[request.prefix, cursor.lastKey, String(request.limit), cursor.snapshotId],
|
|
137
|
+
signal
|
|
138
|
+
)
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async dispose(): Promise<void> {
|
|
143
|
+
await this.connection.dispose();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private async readScanPage(
|
|
147
|
+
snapshotId: string,
|
|
148
|
+
raw: unknown
|
|
149
|
+
): Promise<ZLinkStoreScanResult> {
|
|
150
|
+
const result = asArray(raw);
|
|
151
|
+
const outcome = asString(result[0]);
|
|
152
|
+
if (outcome === 'expired') return { kind: 'expired' };
|
|
153
|
+
if (outcome === 'capacity') {
|
|
154
|
+
throw new Error('Redis Location Store snapshot capacity is full.');
|
|
155
|
+
}
|
|
156
|
+
if (outcome !== 'page') {
|
|
157
|
+
throw new Error('Redis Location Store returned an unrecognized scan outcome.');
|
|
158
|
+
}
|
|
159
|
+
const storeNow = fromUnixMs(toNumber(result[1]));
|
|
160
|
+
const nextKey = asString(result[2]);
|
|
161
|
+
const items = [];
|
|
162
|
+
for (let index = 3; index < result.length; index += 4) {
|
|
163
|
+
items.push({
|
|
164
|
+
key: storeKey(asString(result[index])),
|
|
165
|
+
value: {
|
|
166
|
+
bytes: rawBytes(result[index + 1]),
|
|
167
|
+
version: storeVersion(asString(result[index + 2])),
|
|
168
|
+
expiresAt: expiresAtOrUndefined(toNumber(result[index + 3])),
|
|
169
|
+
storeNow
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
kind: 'page',
|
|
175
|
+
value: {
|
|
176
|
+
items,
|
|
177
|
+
nextCursor: nextKey.length === 0
|
|
178
|
+
? undefined
|
|
179
|
+
: scanCursor(`${snapshotId}:${Buffer.from(nextKey, 'utf8').toString('hex')}`),
|
|
180
|
+
storeNow
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private scanKeys(snapshotId: string): readonly string[] {
|
|
186
|
+
return [
|
|
187
|
+
this.indexKey(),
|
|
188
|
+
this.mapKey(),
|
|
189
|
+
this.snapshotKey(snapshotId),
|
|
190
|
+
this.cleanupKey(),
|
|
191
|
+
this.sequenceKey(),
|
|
192
|
+
this.snapshotExpiryKey(),
|
|
193
|
+
this.snapshotBoundaryKey()
|
|
194
|
+
];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private indexKey(): string {
|
|
198
|
+
return `${this.domain}:index`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private mapKey(): string {
|
|
202
|
+
return `${this.domain}:map`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private cleanupKey(): string {
|
|
206
|
+
return `${this.domain}:cleanup`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private sequenceKey(): string {
|
|
210
|
+
return `${this.domain}:sequence`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private snapshotExpiryKey(): string {
|
|
214
|
+
return `${this.domain}:snapshot-expiry`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private snapshotBoundaryKey(): string {
|
|
218
|
+
return `${this.domain}:snapshot-boundary`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private snapshotKey(snapshotId: string): string {
|
|
222
|
+
return `${this.domain}:scan:${snapshotId}`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private rowKey(logicalKey: string): string {
|
|
226
|
+
return `${this.domain}:${digest(logicalKey)}`;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
interface EncodedWrite {
|
|
231
|
+
readonly keys: readonly string[];
|
|
232
|
+
readonly conditions: readonly unknown[];
|
|
233
|
+
readonly mutations: readonly unknown[];
|
|
234
|
+
readonly putBytes: readonly Buffer[];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function encodeWrite(request: ZLinkStoreWriteRequest): EncodedWrite {
|
|
238
|
+
const conditionKeys = request.conditions.map(condition => requireKey(condition.key));
|
|
239
|
+
const mutationKeys = request.mutations.map(mutation => requireKey(mutation.key));
|
|
240
|
+
if (
|
|
241
|
+
new Set(conditionKeys).size !== conditionKeys.length
|
|
242
|
+
|| new Set(mutationKeys).size !== mutationKeys.length
|
|
243
|
+
) {
|
|
244
|
+
throw new RangeError('Location Store condition and mutation keys must be unique.');
|
|
245
|
+
}
|
|
246
|
+
const keys = [...new Set([...conditionKeys, ...mutationKeys])];
|
|
247
|
+
if (keys.length > MAX_WRITE_KEYS) {
|
|
248
|
+
throw new RangeError('Location Store write exceeds 2,048 unique keys.');
|
|
249
|
+
}
|
|
250
|
+
// Row keys are appended after the six fixed auxiliary keys; the script
|
|
251
|
+
// adds 6 to this 1-based index before indexing into KEYS.
|
|
252
|
+
const keyIndex = new Map(keys.map((key, index) => [key, index + 1]));
|
|
253
|
+
let encodedBytes = 0;
|
|
254
|
+
const conditions = request.conditions.map(condition => {
|
|
255
|
+
const key = requireKey(condition.key);
|
|
256
|
+
encodedBytes += Buffer.byteLength(key, 'utf8');
|
|
257
|
+
if (condition.kind === 'missing') return ['missing', keyIndex.get(key), key];
|
|
258
|
+
const expected = requireVersion(condition.expected);
|
|
259
|
+
encodedBytes += Buffer.byteLength(expected, 'utf8');
|
|
260
|
+
return ['version', keyIndex.get(key), key, expected];
|
|
261
|
+
});
|
|
262
|
+
const putBytes: Buffer[] = [];
|
|
263
|
+
const mutations = request.mutations.map(mutation => {
|
|
264
|
+
const key = requireKey(mutation.key);
|
|
265
|
+
encodedBytes += Buffer.byteLength(key, 'utf8');
|
|
266
|
+
if (mutation.kind === 'delete') return ['delete', keyIndex.get(key), key];
|
|
267
|
+
requireValue(mutation.bytes, mutation.retentionMs);
|
|
268
|
+
encodedBytes += mutation.bytes.byteLength;
|
|
269
|
+
putBytes.push(Buffer.from(mutation.bytes));
|
|
270
|
+
return [
|
|
271
|
+
'put',
|
|
272
|
+
keyIndex.get(key),
|
|
273
|
+
key,
|
|
274
|
+
mutation.retentionMs ?? false
|
|
275
|
+
];
|
|
276
|
+
});
|
|
277
|
+
if (encodedBytes > MAX_WRITE_BYTES) {
|
|
278
|
+
throw new RangeError('Location Store write exceeds 4 MiB encoded input.');
|
|
279
|
+
}
|
|
280
|
+
return { keys, conditions, mutations, putBytes };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function requireScanRequest(request: ZLinkStoreScanRequest): void {
|
|
284
|
+
if (Buffer.byteLength(request.prefix, 'utf8') > 1_024) {
|
|
285
|
+
throw new RangeError('Location Store scan prefix exceeds 1,024 UTF-8 bytes.');
|
|
286
|
+
}
|
|
287
|
+
if (!Number.isSafeInteger(request.limit) || request.limit < 1 || request.limit > 1_000) {
|
|
288
|
+
throw new RangeError('Location Store scan limit must be in 1..1000.');
|
|
289
|
+
}
|
|
290
|
+
if (request.cursor !== undefined) requireCursor(request.cursor);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function requireKey(key: ZLinkStoreKey): string {
|
|
294
|
+
const value = key.value;
|
|
295
|
+
const bytes = Buffer.byteLength(value, 'utf8');
|
|
296
|
+
if (bytes < 1 || bytes > 1_024) {
|
|
297
|
+
throw new RangeError('Location Store key must contain 1..1,024 UTF-8 bytes.');
|
|
298
|
+
}
|
|
299
|
+
return value;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function requireMatchingKey(actual: string, expected: string): void {
|
|
303
|
+
if (actual !== expected) {
|
|
304
|
+
throw new Error('Redis opaque record key digest resolved to a different logical key.');
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function requireVersion(version: ZLinkStoreVersion): string {
|
|
309
|
+
const value = version.value;
|
|
310
|
+
const bytes = Buffer.byteLength(value, 'utf8');
|
|
311
|
+
if (bytes < 1 || bytes > 4_096) {
|
|
312
|
+
throw new RangeError('Location Store version must contain 1..4,096 UTF-8 bytes.');
|
|
313
|
+
}
|
|
314
|
+
return value;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function requireCursor(cursor: ZLinkStoreScanCursor): string {
|
|
318
|
+
const value = cursor.value;
|
|
319
|
+
const bytes = Buffer.byteLength(value, 'utf8');
|
|
320
|
+
if (bytes < 1 || bytes > 4_096) {
|
|
321
|
+
throw new RangeError('Location Store cursor must contain 1..4,096 UTF-8 bytes.');
|
|
322
|
+
}
|
|
323
|
+
return value;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function requireValue(bytes: Uint8Array, retentionMs: number | undefined): void {
|
|
327
|
+
if (bytes.byteLength > MAX_VALUE_BYTES) {
|
|
328
|
+
throw new RangeError('Location Store value exceeds 1 MiB.');
|
|
329
|
+
}
|
|
330
|
+
if (
|
|
331
|
+
retentionMs !== undefined
|
|
332
|
+
&& (!Number.isSafeInteger(retentionMs) || retentionMs < 1)
|
|
333
|
+
) {
|
|
334
|
+
throw new RangeError('Location Store retention must be a positive safe integer.');
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function parseCursor(cursor: ZLinkStoreScanCursor): {
|
|
339
|
+
readonly snapshotId: string;
|
|
340
|
+
readonly lastKey: string;
|
|
341
|
+
} {
|
|
342
|
+
const value = requireCursor(cursor);
|
|
343
|
+
const separator = value.lastIndexOf(':');
|
|
344
|
+
const snapshotId = separator < 0 ? '' : value.slice(0, separator);
|
|
345
|
+
const lastKeyHex = separator < 0 ? '' : value.slice(separator + 1);
|
|
346
|
+
if (
|
|
347
|
+
!/^[0-9a-f-]{36}$/.test(snapshotId)
|
|
348
|
+
|| !/^[0-9a-f]*$/.test(lastKeyHex)
|
|
349
|
+
|| lastKeyHex.length % 2 !== 0
|
|
350
|
+
) {
|
|
351
|
+
throw new RangeError('Location Store scan cursor is invalid.');
|
|
352
|
+
}
|
|
353
|
+
return { snapshotId, lastKey: Buffer.from(lastKeyHex, 'hex').toString('utf8') };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function rawBytes(value: unknown): Uint8Array {
|
|
357
|
+
if (Buffer.isBuffer(value)) return Uint8Array.from(value);
|
|
358
|
+
if (value instanceof Uint8Array) return Uint8Array.from(value);
|
|
359
|
+
return Uint8Array.from(Buffer.from(asString(value), 'utf8'));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function digest(value: string): string {
|
|
363
|
+
return createHash('sha256').update(value, 'utf8').digest('hex');
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function storeKey(value: string): ZLinkStoreKey {
|
|
367
|
+
return { value } as ZLinkStoreKey;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function storeVersion(value: string): ZLinkStoreVersion {
|
|
371
|
+
return { value } as ZLinkStoreVersion;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function scanCursor(value: string): ZLinkStoreScanCursor {
|
|
375
|
+
return { value } as ZLinkStoreScanCursor;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function expiresAtOrUndefined(expiresAtMs: number): Date | undefined {
|
|
379
|
+
return expiresAtMs === 0 ? undefined : fromUnixMs(expiresAtMs);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function fromUnixMs(value: number): Date {
|
|
383
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
384
|
+
throw new Error('Redis Store returned an invalid provider timestamp.');
|
|
385
|
+
}
|
|
386
|
+
return new Date(value);
|
|
387
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { createClient, RESP_TYPES } from 'redis';
|
|
2
|
+
import type { RedisClientType } from 'redis';
|
|
3
|
+
import type {
|
|
4
|
+
ZLinkRedisLocationOptions,
|
|
5
|
+
ZLinkRedisRelocationOptions
|
|
6
|
+
} from './redis-options';
|
|
7
|
+
|
|
8
|
+
export type RedisCommandValue = string | Buffer;
|
|
9
|
+
export type RedisCommandClient = Pick<
|
|
10
|
+
RedisClientType,
|
|
11
|
+
'isOpen' | 'isReady' | 'connect' | 'disconnect' | 'sendCommand' | 'quit' | 'on'
|
|
12
|
+
>;
|
|
13
|
+
|
|
14
|
+
export class RedisConnection {
|
|
15
|
+
private readonly providedClient?: RedisCommandClient;
|
|
16
|
+
private client?: RedisCommandClient;
|
|
17
|
+
private connectionAttempt?: Promise<void>;
|
|
18
|
+
private disposed = false;
|
|
19
|
+
private readonly operationTimeoutMs?: number;
|
|
20
|
+
|
|
21
|
+
constructor(options: ZLinkRedisLocationOptions | ZLinkRedisRelocationOptions) {
|
|
22
|
+
requireOptions(options);
|
|
23
|
+
this.providedClient = options.client;
|
|
24
|
+
this.operationTimeoutMs = options.operationTimeoutMs;
|
|
25
|
+
if (options.client === undefined) {
|
|
26
|
+
this.client = createClient({
|
|
27
|
+
disableOfflineQueue: true,
|
|
28
|
+
...(options.clientOptions ?? {}),
|
|
29
|
+
socket: {
|
|
30
|
+
reconnectStrategy: false,
|
|
31
|
+
...(options.clientOptions?.socket ?? {})
|
|
32
|
+
},
|
|
33
|
+
url: options.url ?? options.clientOptions?.url
|
|
34
|
+
}) as RedisCommandClient;
|
|
35
|
+
this.client.on('error', () => {});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async command(args: RedisCommandValue[], signal?: AbortSignal): Promise<unknown> {
|
|
40
|
+
signal?.throwIfAborted();
|
|
41
|
+
const client = await this.connected(signal);
|
|
42
|
+
// Store values are opaque bytes. Returning RESP bulk strings as UTF-8
|
|
43
|
+
// strings would replace invalid byte sequences before the provider can
|
|
44
|
+
// copy them into Uint8Array.
|
|
45
|
+
const operation = client.sendCommand(args, {
|
|
46
|
+
typeMapping: {
|
|
47
|
+
[RESP_TYPES.BLOB_STRING]: Buffer
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
return await waitForOperation(operation, signal, this.operationTimeoutMs);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async eval(
|
|
54
|
+
script: string,
|
|
55
|
+
keys: readonly string[],
|
|
56
|
+
args: readonly RedisCommandValue[],
|
|
57
|
+
signal?: AbortSignal
|
|
58
|
+
): Promise<unknown> {
|
|
59
|
+
return await this.command(
|
|
60
|
+
['EVAL', script, String(keys.length), ...keys, ...args],
|
|
61
|
+
signal
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async dispose(): Promise<void> {
|
|
66
|
+
if (this.disposed) return;
|
|
67
|
+
this.disposed = true;
|
|
68
|
+
const client = this.client;
|
|
69
|
+
this.client = undefined;
|
|
70
|
+
if (client !== undefined && client.isOpen === true) {
|
|
71
|
+
await client.quit();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private async connected(signal?: AbortSignal): Promise<RedisCommandClient> {
|
|
76
|
+
signal?.throwIfAborted();
|
|
77
|
+
if (this.disposed) throw new Error('Redis Store is disposed.');
|
|
78
|
+
const client = this.providedClient ?? this.client;
|
|
79
|
+
if (client === undefined) throw new Error('Redis Store is disposed.');
|
|
80
|
+
if (isClientReady(client)) return client;
|
|
81
|
+
if (this.connectionAttempt === undefined) {
|
|
82
|
+
// Publish the attempt before any await. A failed connection can leave
|
|
83
|
+
// node-redis open but not ready; the close and reconnect sequence must
|
|
84
|
+
// still be owned by this single promise when callers arrive together.
|
|
85
|
+
const attempt = (async () => {
|
|
86
|
+
if (client.isOpen === true && !isClientReady(client)) {
|
|
87
|
+
await client.disconnect();
|
|
88
|
+
}
|
|
89
|
+
await client.connect();
|
|
90
|
+
})();
|
|
91
|
+
this.connectionAttempt = attempt;
|
|
92
|
+
attempt.then(
|
|
93
|
+
() => {
|
|
94
|
+
if (this.connectionAttempt === attempt) this.connectionAttempt = undefined;
|
|
95
|
+
},
|
|
96
|
+
() => {
|
|
97
|
+
if (this.connectionAttempt === attempt) this.connectionAttempt = undefined;
|
|
98
|
+
}
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
await waitForOperation(this.connectionAttempt, signal, this.operationTimeoutMs);
|
|
102
|
+
if (!isClientReady(client)) {
|
|
103
|
+
throw new Error('Redis client connection completed before it became ready.');
|
|
104
|
+
}
|
|
105
|
+
return client;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isClientReady(client: RedisCommandClient): boolean {
|
|
110
|
+
return client.isReady === true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function requireOptions(
|
|
114
|
+
options: ZLinkRedisLocationOptions | ZLinkRedisRelocationOptions
|
|
115
|
+
): void {
|
|
116
|
+
if (options.keyPrefix.length === 0) {
|
|
117
|
+
throw new Error('Redis Store keyPrefix is required.');
|
|
118
|
+
}
|
|
119
|
+
if (options.keyPrefix.includes('{') || options.keyPrefix.includes('}')) {
|
|
120
|
+
throw new Error('Redis Store keyPrefix must not contain hash-tag braces.');
|
|
121
|
+
}
|
|
122
|
+
if (
|
|
123
|
+
options.client === undefined
|
|
124
|
+
&& options.url === undefined
|
|
125
|
+
&& options.clientOptions === undefined
|
|
126
|
+
) {
|
|
127
|
+
throw new Error('Redis Store requires url, clientOptions, or client.');
|
|
128
|
+
}
|
|
129
|
+
if (
|
|
130
|
+
options.operationTimeoutMs !== undefined
|
|
131
|
+
&& (!Number.isSafeInteger(options.operationTimeoutMs)
|
|
132
|
+
|| options.operationTimeoutMs < 1)
|
|
133
|
+
) {
|
|
134
|
+
throw new RangeError('Redis Store operationTimeoutMs must be a positive safe integer.');
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function waitForOperation<T>(
|
|
139
|
+
operation: Promise<T>,
|
|
140
|
+
signal: AbortSignal | undefined,
|
|
141
|
+
timeoutMs: number | undefined
|
|
142
|
+
): Promise<T> {
|
|
143
|
+
if (signal === undefined && timeoutMs === undefined) return await operation;
|
|
144
|
+
return await new Promise<T>((resolve, reject) => {
|
|
145
|
+
let timeout: NodeJS.Timeout | undefined;
|
|
146
|
+
let settled = false;
|
|
147
|
+
const cleanup = () => {
|
|
148
|
+
signal?.removeEventListener('abort', onAbort);
|
|
149
|
+
if (timeout !== undefined) clearTimeout(timeout);
|
|
150
|
+
};
|
|
151
|
+
const settle = (action: () => void) => {
|
|
152
|
+
if (settled) return;
|
|
153
|
+
settled = true;
|
|
154
|
+
cleanup();
|
|
155
|
+
action();
|
|
156
|
+
};
|
|
157
|
+
const onAbort = () => settle(
|
|
158
|
+
() => reject(signal?.reason ?? new Error('Redis Store operation aborted.'))
|
|
159
|
+
);
|
|
160
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
161
|
+
if (timeoutMs !== undefined) {
|
|
162
|
+
timeout = setTimeout(
|
|
163
|
+
() => settle(() =>
|
|
164
|
+
reject(new Error(`Redis Store operation timed out after ${timeoutMs} ms.`))
|
|
165
|
+
),
|
|
166
|
+
timeoutMs
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
operation.then(
|
|
170
|
+
value => settle(() => resolve(value)),
|
|
171
|
+
error => settle(() => reject(error))
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { RedisClientOptions, RedisClientType } from 'redis';
|
|
2
|
+
|
|
3
|
+
export interface ZLinkRedisLocationOptions {
|
|
4
|
+
readonly url?: string;
|
|
5
|
+
readonly client?: RedisClientType;
|
|
6
|
+
readonly clientOptions?: RedisClientOptions;
|
|
7
|
+
readonly keyPrefix: string;
|
|
8
|
+
readonly operationTimeoutMs?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ZLinkRedisRelocationOptions {
|
|
12
|
+
readonly url?: string;
|
|
13
|
+
readonly client?: RedisClientType;
|
|
14
|
+
readonly clientOptions?: RedisClientOptions;
|
|
15
|
+
readonly keyPrefix: string;
|
|
16
|
+
readonly operationTimeoutMs?: number;
|
|
17
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function asArray(value: unknown): readonly unknown[] {
|
|
2
|
+
if (!Array.isArray(value)) {
|
|
3
|
+
throw new TypeError('Redis command returned a non-array value.');
|
|
4
|
+
}
|
|
5
|
+
return value;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function asString(value: unknown): string {
|
|
9
|
+
if (typeof value === 'string') {
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
if (Buffer.isBuffer(value)) {
|
|
13
|
+
return value.toString();
|
|
14
|
+
}
|
|
15
|
+
if (typeof value === 'number' || typeof value === 'bigint') {
|
|
16
|
+
return String(value);
|
|
17
|
+
}
|
|
18
|
+
throw new TypeError('Redis command returned a non-string value.');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function toNumber(value: unknown): number {
|
|
22
|
+
return Number(asString(value));
|
|
23
|
+
}
|