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