@zlink-systems/framework-locations-redis 0.25.0 → 0.27.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/dist/.tsbuildinfo +1 -1
- package/dist/framework-internal.d.ts +1 -0
- package/dist/framework-internal.js +12 -0
- package/dist/opaque-redis-scripts.d.ts +17 -2
- package/dist/opaque-redis-scripts.js +126 -114
- package/dist/opaque-store.js +50 -35
- package/dist/redis-connection.d.ts +1 -1
- package/dist/relocation-store.js +13 -10
- package/package.json +3 -3
- package/src/framework-internal.ts +20 -0
- package/src/opaque-redis-scripts.ts +135 -116
- package/src/opaque-store.ts +87 -41
- package/src/redis-connection.ts +1 -1
- package/src/relocation-store.ts +24 -10
package/src/opaque-store.ts
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ZLINK_PROVIDER_MAX_KEY_BYTES,
|
|
3
|
+
ZLINK_PROVIDER_MAX_VALUE_BYTES,
|
|
4
|
+
ZLINK_PROVIDER_MAX_VERSION_BYTES,
|
|
5
|
+
ZLINK_PROVIDER_MAX_WRITE_KEYS,
|
|
6
|
+
ZLINK_PROVIDER_MAX_WRITE_BYTES,
|
|
7
|
+
ZLINK_PROVIDER_MAX_SCAN_CURSOR_BYTES,
|
|
8
|
+
ZLINK_PROVIDER_MAX_PAGE_SIZE
|
|
9
|
+
} from './framework-internal';
|
|
1
10
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
11
|
import type {
|
|
3
12
|
ZLinkLocationStore,
|
|
@@ -13,6 +22,7 @@ import type {
|
|
|
13
22
|
import type { ZLinkRedisLocationOptions } from './redis-options';
|
|
14
23
|
import { RedisConnection } from './redis-connection';
|
|
15
24
|
import {
|
|
25
|
+
REDIS_STORE_TOKEN,
|
|
16
26
|
OPAQUE_READ_SCRIPT,
|
|
17
27
|
OPAQUE_SCAN_CONTINUE_SCRIPT,
|
|
18
28
|
OPAQUE_SCAN_START_SCRIPT,
|
|
@@ -20,10 +30,6 @@ import {
|
|
|
20
30
|
} from './opaque-redis-scripts';
|
|
21
31
|
import { asArray, asString, toNumber } from './redis-values';
|
|
22
32
|
|
|
23
|
-
const MAX_VALUE_BYTES = 1024 * 1024;
|
|
24
|
-
const MAX_WRITE_KEYS = 2_048;
|
|
25
|
-
const MAX_WRITE_BYTES = 4 * 1024 * 1024;
|
|
26
|
-
|
|
27
33
|
// {prefix}:{zlink-location-v3}:opaque:{sha256hex(preimage)} is the public
|
|
28
34
|
// contract (21-location-runtime.md#2.4, 22-location-store-redis.md#7). The
|
|
29
35
|
// braces are a Redis Cluster hash tag: every key this provider's scripts
|
|
@@ -82,6 +88,7 @@ export class ZLinkRedisLocationStore implements ZLinkLocationStore {
|
|
|
82
88
|
[
|
|
83
89
|
JSON.stringify(encoded.conditions),
|
|
84
90
|
JSON.stringify(encoded.mutations),
|
|
91
|
+
...encoded.expectedBytes,
|
|
85
92
|
...encoded.putBytes
|
|
86
93
|
],
|
|
87
94
|
signal
|
|
@@ -89,11 +96,11 @@ export class ZLinkRedisLocationStore implements ZLinkLocationStore {
|
|
|
89
96
|
);
|
|
90
97
|
const storeNow = fromUnixMs(toNumber(result[1]));
|
|
91
98
|
const outcome = asString(result[0]);
|
|
92
|
-
if (outcome ===
|
|
93
|
-
if (outcome ===
|
|
99
|
+
if (outcome === REDIS_STORE_TOKEN.Conflict) return { kind: 'conflict', storeNow };
|
|
100
|
+
if (outcome === REDIS_STORE_TOKEN.Backlog) {
|
|
94
101
|
throw new Error('Redis Location Store version backlog is full.');
|
|
95
102
|
}
|
|
96
|
-
if (outcome !==
|
|
103
|
+
if (outcome !== REDIS_STORE_TOKEN.Applied) {
|
|
97
104
|
throw new Error('Redis Location Store returned an unrecognized write outcome.');
|
|
98
105
|
}
|
|
99
106
|
const putVersions = [];
|
|
@@ -121,6 +128,9 @@ export class ZLinkRedisLocationStore implements ZLinkLocationStore {
|
|
|
121
128
|
);
|
|
122
129
|
}
|
|
123
130
|
const cursor = parseCursor(request.cursor);
|
|
131
|
+
if (cursor === undefined) {
|
|
132
|
+
return { kind: 'expired' };
|
|
133
|
+
}
|
|
124
134
|
return await this.readScanPage(
|
|
125
135
|
cursor.snapshotId,
|
|
126
136
|
await this.connection.eval(
|
|
@@ -139,11 +149,11 @@ export class ZLinkRedisLocationStore implements ZLinkLocationStore {
|
|
|
139
149
|
private async readScanPage(snapshotId: string, raw: unknown): Promise<ZLinkStoreScanResult> {
|
|
140
150
|
const result = asArray(raw);
|
|
141
151
|
const outcome = asString(result[0]);
|
|
142
|
-
if (outcome ===
|
|
143
|
-
if (outcome ===
|
|
152
|
+
if (outcome === REDIS_STORE_TOKEN.Expired) return { kind: 'expired' };
|
|
153
|
+
if (outcome === REDIS_STORE_TOKEN.Capacity) {
|
|
144
154
|
throw new Error('Redis Location Store snapshot capacity is full.');
|
|
145
155
|
}
|
|
146
|
-
if (outcome !==
|
|
156
|
+
if (outcome !== REDIS_STORE_TOKEN.Page) {
|
|
147
157
|
throw new Error('Redis Location Store returned an unrecognized scan outcome.');
|
|
148
158
|
}
|
|
149
159
|
const storeNow = fromUnixMs(toNumber(result[1]));
|
|
@@ -222,6 +232,7 @@ interface EncodedWrite {
|
|
|
222
232
|
readonly keys: readonly string[];
|
|
223
233
|
readonly conditions: readonly unknown[];
|
|
224
234
|
readonly mutations: readonly unknown[];
|
|
235
|
+
readonly expectedBytes: readonly Buffer[];
|
|
225
236
|
readonly putBytes: readonly Buffer[];
|
|
226
237
|
}
|
|
227
238
|
|
|
@@ -235,43 +246,62 @@ function encodeWrite(request: ZLinkStoreWriteRequest): EncodedWrite {
|
|
|
235
246
|
throw new RangeError('Location Store condition and mutation keys must be unique.');
|
|
236
247
|
}
|
|
237
248
|
const keys = [...new Set([...conditionKeys, ...mutationKeys])];
|
|
238
|
-
if (keys.length >
|
|
239
|
-
throw new RangeError(
|
|
249
|
+
if (keys.length > ZLINK_PROVIDER_MAX_WRITE_KEYS) {
|
|
250
|
+
throw new RangeError(
|
|
251
|
+
`Location Store write exceeds ${ZLINK_PROVIDER_MAX_WRITE_KEYS.toLocaleString('en-US')} unique keys.`
|
|
252
|
+
);
|
|
240
253
|
}
|
|
241
254
|
// Row keys are appended after the six fixed auxiliary keys; the script
|
|
242
255
|
// adds 6 to this 1-based index before indexing into KEYS.
|
|
243
256
|
const keyIndex = new Map(keys.map((key, index) => [key, index + 1]));
|
|
244
257
|
let encodedBytes = 0;
|
|
258
|
+
const expectedBytes: Buffer[] = [];
|
|
245
259
|
const conditions = request.conditions.map((condition) => {
|
|
246
260
|
const key = requireKey(condition.key);
|
|
247
261
|
encodedBytes += Buffer.byteLength(key, 'utf8');
|
|
248
|
-
if (condition.kind === 'missing') return [
|
|
262
|
+
if (condition.kind === 'missing') return [REDIS_STORE_TOKEN.Missing, keyIndex.get(key), key];
|
|
263
|
+
if (condition.kind === 'value') {
|
|
264
|
+
requireValue(condition.expected, undefined);
|
|
265
|
+
encodedBytes += condition.expected.byteLength;
|
|
266
|
+
expectedBytes.push(Buffer.from(condition.expected));
|
|
267
|
+
return [REDIS_STORE_TOKEN.Value, keyIndex.get(key), key, expectedBytes.length + 2];
|
|
268
|
+
}
|
|
249
269
|
const expected = requireVersion(condition.expected);
|
|
250
270
|
encodedBytes += Buffer.byteLength(expected, 'utf8');
|
|
251
|
-
return [
|
|
271
|
+
return [REDIS_STORE_TOKEN.Version, keyIndex.get(key), key, expected];
|
|
252
272
|
});
|
|
253
273
|
const putBytes: Buffer[] = [];
|
|
254
274
|
const mutations = request.mutations.map((mutation) => {
|
|
255
275
|
const key = requireKey(mutation.key);
|
|
256
276
|
encodedBytes += Buffer.byteLength(key, 'utf8');
|
|
257
|
-
if (mutation.kind === 'delete') return [
|
|
258
|
-
requireValue(mutation.bytes, mutation.retentionMs);
|
|
277
|
+
if (mutation.kind === 'delete') return [REDIS_STORE_TOKEN.Delete, keyIndex.get(key), key];
|
|
278
|
+
const retentionMs = requireValue(mutation.bytes, mutation.retentionMs);
|
|
259
279
|
encodedBytes += mutation.bytes.byteLength;
|
|
260
280
|
putBytes.push(Buffer.from(mutation.bytes));
|
|
261
|
-
return [
|
|
281
|
+
return [REDIS_STORE_TOKEN.Put, keyIndex.get(key), key, retentionMs ?? false];
|
|
262
282
|
});
|
|
263
|
-
if (encodedBytes >
|
|
264
|
-
throw new RangeError(
|
|
283
|
+
if (encodedBytes > ZLINK_PROVIDER_MAX_WRITE_BYTES) {
|
|
284
|
+
throw new RangeError(
|
|
285
|
+
`Location Store write exceeds ${ZLINK_PROVIDER_MAX_WRITE_BYTES / (1024 * 1024)} MiB encoded input.`
|
|
286
|
+
);
|
|
265
287
|
}
|
|
266
|
-
return { keys, conditions, mutations, putBytes };
|
|
288
|
+
return { keys, conditions, mutations, expectedBytes, putBytes };
|
|
267
289
|
}
|
|
268
290
|
|
|
269
291
|
function requireScanRequest(request: ZLinkStoreScanRequest): void {
|
|
270
|
-
if (Buffer.byteLength(request.prefix, 'utf8') >
|
|
271
|
-
throw new RangeError(
|
|
292
|
+
if (Buffer.byteLength(request.prefix, 'utf8') > ZLINK_PROVIDER_MAX_KEY_BYTES) {
|
|
293
|
+
throw new RangeError(
|
|
294
|
+
`Location Store scan prefix exceeds ${ZLINK_PROVIDER_MAX_KEY_BYTES.toLocaleString('en-US')} UTF-8 bytes.`
|
|
295
|
+
);
|
|
272
296
|
}
|
|
273
|
-
if (
|
|
274
|
-
|
|
297
|
+
if (
|
|
298
|
+
!Number.isSafeInteger(request.limit) ||
|
|
299
|
+
request.limit < 1 ||
|
|
300
|
+
request.limit > ZLINK_PROVIDER_MAX_PAGE_SIZE
|
|
301
|
+
) {
|
|
302
|
+
throw new RangeError(
|
|
303
|
+
`Location Store scan limit must be in 1..${ZLINK_PROVIDER_MAX_PAGE_SIZE}.`
|
|
304
|
+
);
|
|
275
305
|
}
|
|
276
306
|
if (request.cursor !== undefined) requireCursor(request.cursor);
|
|
277
307
|
}
|
|
@@ -279,8 +309,10 @@ function requireScanRequest(request: ZLinkStoreScanRequest): void {
|
|
|
279
309
|
function requireKey(key: ZLinkStoreKey): string {
|
|
280
310
|
const value = key.value;
|
|
281
311
|
const bytes = Buffer.byteLength(value, 'utf8');
|
|
282
|
-
if (bytes < 1 || bytes >
|
|
283
|
-
throw new RangeError(
|
|
312
|
+
if (bytes < 1 || bytes > ZLINK_PROVIDER_MAX_KEY_BYTES) {
|
|
313
|
+
throw new RangeError(
|
|
314
|
+
`Location Store key must contain 1..${ZLINK_PROVIDER_MAX_KEY_BYTES.toLocaleString('en-US')} UTF-8 bytes.`
|
|
315
|
+
);
|
|
284
316
|
}
|
|
285
317
|
return value;
|
|
286
318
|
}
|
|
@@ -294,8 +326,10 @@ function requireMatchingKey(actual: string, expected: string): void {
|
|
|
294
326
|
function requireVersion(version: ZLinkStoreVersion): string {
|
|
295
327
|
const value = version.value;
|
|
296
328
|
const bytes = Buffer.byteLength(value, 'utf8');
|
|
297
|
-
if (bytes < 1 || bytes >
|
|
298
|
-
throw new RangeError(
|
|
329
|
+
if (bytes < 1 || bytes > ZLINK_PROVIDER_MAX_VERSION_BYTES) {
|
|
330
|
+
throw new RangeError(
|
|
331
|
+
`Location Store version must contain 1..${ZLINK_PROVIDER_MAX_VERSION_BYTES.toLocaleString('en-US')} UTF-8 bytes.`
|
|
332
|
+
);
|
|
299
333
|
}
|
|
300
334
|
return value;
|
|
301
335
|
}
|
|
@@ -303,26 +337,38 @@ function requireVersion(version: ZLinkStoreVersion): string {
|
|
|
303
337
|
function requireCursor(cursor: ZLinkStoreScanCursor): string {
|
|
304
338
|
const value = cursor.value;
|
|
305
339
|
const bytes = Buffer.byteLength(value, 'utf8');
|
|
306
|
-
if (bytes < 1 || bytes >
|
|
307
|
-
throw new RangeError(
|
|
340
|
+
if (bytes < 1 || bytes > ZLINK_PROVIDER_MAX_SCAN_CURSOR_BYTES) {
|
|
341
|
+
throw new RangeError(
|
|
342
|
+
`Location Store cursor must contain 1..${ZLINK_PROVIDER_MAX_SCAN_CURSOR_BYTES.toLocaleString('en-US')} UTF-8 bytes.`
|
|
343
|
+
);
|
|
308
344
|
}
|
|
309
345
|
return value;
|
|
310
346
|
}
|
|
311
347
|
|
|
312
|
-
function requireValue(bytes: Uint8Array, retentionMs: number | undefined):
|
|
313
|
-
if (bytes.byteLength >
|
|
314
|
-
throw new RangeError(
|
|
348
|
+
function requireValue(bytes: Uint8Array, retentionMs: number | undefined): number | undefined {
|
|
349
|
+
if (bytes.byteLength > ZLINK_PROVIDER_MAX_VALUE_BYTES) {
|
|
350
|
+
throw new RangeError(
|
|
351
|
+
`Location Store value exceeds ${ZLINK_PROVIDER_MAX_VALUE_BYTES / (1024 * 1024)} MiB.`
|
|
352
|
+
);
|
|
315
353
|
}
|
|
316
|
-
if (
|
|
317
|
-
|
|
354
|
+
if (
|
|
355
|
+
retentionMs !== undefined &&
|
|
356
|
+
(!Number.isFinite(retentionMs) ||
|
|
357
|
+
retentionMs <= 0 ||
|
|
358
|
+
!Number.isSafeInteger(Math.ceil(retentionMs)))
|
|
359
|
+
) {
|
|
360
|
+
throw new RangeError('Location Store retention must round to a positive safe integer.');
|
|
318
361
|
}
|
|
362
|
+
return retentionMs === undefined ? undefined : Math.ceil(retentionMs);
|
|
319
363
|
}
|
|
320
364
|
|
|
321
|
-
function parseCursor(cursor: ZLinkStoreScanCursor):
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
365
|
+
function parseCursor(cursor: ZLinkStoreScanCursor):
|
|
366
|
+
| {
|
|
367
|
+
readonly snapshotId: string;
|
|
368
|
+
readonly lastKey: string;
|
|
369
|
+
}
|
|
370
|
+
| undefined {
|
|
371
|
+
const value = cursor.value;
|
|
326
372
|
const separator = value.lastIndexOf(':');
|
|
327
373
|
const snapshotId = separator < 0 ? '' : value.slice(0, separator);
|
|
328
374
|
const lastKeyHex = separator < 0 ? '' : value.slice(separator + 1);
|
|
@@ -331,7 +377,7 @@ function parseCursor(cursor: ZLinkStoreScanCursor): {
|
|
|
331
377
|
!/^[0-9a-f]*$/.test(lastKeyHex) ||
|
|
332
378
|
lastKeyHex.length % 2 !== 0
|
|
333
379
|
) {
|
|
334
|
-
|
|
380
|
+
return undefined;
|
|
335
381
|
}
|
|
336
382
|
return { snapshotId, lastKey: Buffer.from(lastKeyHex, 'hex').toString('utf8') };
|
|
337
383
|
}
|
package/src/redis-connection.ts
CHANGED
package/src/relocation-store.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ZLINK_RELOCATION_MAX_BLOB_REFERENCE_BYTES } from './framework-internal';
|
|
1
2
|
import type {
|
|
2
3
|
ZLinkBlobPutResult,
|
|
3
4
|
ZLinkBlobReadResult,
|
|
@@ -7,7 +8,12 @@ import type {
|
|
|
7
8
|
} from '@zlink-systems/framework';
|
|
8
9
|
import type { ZLinkRedisRelocationOptions } from './redis-options';
|
|
9
10
|
import { RedisConnection } from './redis-connection';
|
|
10
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
REDIS_STORE_TOKEN,
|
|
13
|
+
BLOB_PUT_SCRIPT,
|
|
14
|
+
BLOB_READ_SCRIPT,
|
|
15
|
+
BLOB_RENEW_SCRIPT
|
|
16
|
+
} from './opaque-redis-scripts';
|
|
11
17
|
import { asArray, asString, toNumber } from './redis-values';
|
|
12
18
|
|
|
13
19
|
const MAX_ENCODED_BLOB_BYTES = 64 * 1024 * 1024 + 23;
|
|
@@ -42,11 +48,11 @@ export class ZLinkRedisRelocationStore implements ZLinkRelocationStore {
|
|
|
42
48
|
signal
|
|
43
49
|
)
|
|
44
50
|
);
|
|
45
|
-
const
|
|
51
|
+
const outcome = asString(result[0]);
|
|
46
52
|
const storeNow = fromUnixMs(toNumber(result[1]));
|
|
47
|
-
if (
|
|
53
|
+
if (outcome === REDIS_STORE_TOKEN.Conflict) return { kind: 'conflict', storeNow };
|
|
48
54
|
return {
|
|
49
|
-
kind:
|
|
55
|
+
kind: outcome === REDIS_STORE_TOKEN.AlreadyStored ? 'alreadyStored' : 'stored',
|
|
50
56
|
storeNow,
|
|
51
57
|
expiresAt: fromUnixMs(toNumber(result[2]))
|
|
52
58
|
};
|
|
@@ -108,23 +114,31 @@ export class ZLinkRedisRelocationStore implements ZLinkRelocationStore {
|
|
|
108
114
|
function requireReference(reference: ZLinkBlobReference): string {
|
|
109
115
|
const value = reference.value;
|
|
110
116
|
const bytes = Buffer.byteLength(value, 'utf8');
|
|
111
|
-
if (bytes < 1 || bytes >
|
|
112
|
-
throw new RangeError(
|
|
117
|
+
if (bytes < 1 || bytes > ZLINK_RELOCATION_MAX_BLOB_REFERENCE_BYTES) {
|
|
118
|
+
throw new RangeError(
|
|
119
|
+
`Relocation Store reference must contain 1..${ZLINK_RELOCATION_MAX_BLOB_REFERENCE_BYTES.toLocaleString('en-US')} UTF-8 bytes.`
|
|
120
|
+
);
|
|
113
121
|
}
|
|
114
122
|
return value;
|
|
115
123
|
}
|
|
116
124
|
|
|
117
125
|
function requirePayload(payload: Uint8Array): void {
|
|
118
126
|
if (payload.byteLength > MAX_ENCODED_BLOB_BYTES) {
|
|
119
|
-
throw new RangeError(
|
|
127
|
+
throw new RangeError(
|
|
128
|
+
`Relocation Store encoded blob exceeds ${Math.floor(MAX_ENCODED_BLOB_BYTES / (1024 * 1024))} MiB + ${MAX_ENCODED_BLOB_BYTES % (1024 * 1024)} bytes.`
|
|
129
|
+
);
|
|
120
130
|
}
|
|
121
131
|
}
|
|
122
132
|
|
|
123
133
|
function requireRetention(retentionMs: number): number {
|
|
124
|
-
if (
|
|
125
|
-
|
|
134
|
+
if (
|
|
135
|
+
!Number.isFinite(retentionMs) ||
|
|
136
|
+
retentionMs <= 0 ||
|
|
137
|
+
!Number.isSafeInteger(Math.ceil(retentionMs))
|
|
138
|
+
) {
|
|
139
|
+
throw new RangeError('Relocation Store retention must round to a positive safe integer.');
|
|
126
140
|
}
|
|
127
|
-
return retentionMs;
|
|
141
|
+
return Math.ceil(retentionMs);
|
|
128
142
|
}
|
|
129
143
|
|
|
130
144
|
function asBuffer(value: unknown): Buffer {
|