mcp-usage-control-redis 1.0.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/index.js ADDED
@@ -0,0 +1,1149 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { UsageStateError, assertUsageIdentifier, validateUsageBudgetEnvelope, validateUsageRequestEnvelope, validateUsageVectorEnvelope, validateUsageVectorGrowthEnvelope, emitUsageEvent, } from 'mcp-usage-control';
3
+ import { GROW_SCRIPT, GROW_VECTOR_SCRIPT, MARK_LIABLE_SCRIPT, RENEW_SCRIPT, RETIRE_HISTORICAL_BUDGETS_SCRIPT, RECONCILE_OPERATION_SCRIPT, RECONCILE_VECTOR_OPERATION_SCRIPT, RESERVE_SCRIPT, RESERVE_VECTOR_SCRIPT, SETTLE_SCRIPT, SETTLE_VECTOR_SCRIPT, } from './scripts.js';
4
+ const RESERVATION_ID_PATTERN = /^r2\.([a-f0-9]{64})$/;
5
+ export class RedisUsageStore {
6
+ client;
7
+ prefix;
8
+ hashTag;
9
+ cleanupBatchSize;
10
+ idempotencyTtlMs;
11
+ observer;
12
+ constructor(client, options = {}) {
13
+ this.client = client;
14
+ this.prefix = options.prefix ?? 'muc';
15
+ this.hashTag = options.hashTag ?? 'usage';
16
+ this.cleanupBatchSize = options.cleanupBatchSize ?? 256;
17
+ this.idempotencyTtlMs = options.idempotencyTtlMs ?? 86_400_000;
18
+ this.observer = options.observer;
19
+ if (this.prefix.includes('{') || this.prefix.includes('}')) {
20
+ throw new RangeError('prefix must not contain Redis hash-tag braces');
21
+ }
22
+ if (this.hashTag.length === 0 || this.hashTag.includes('{') || this.hashTag.includes('}')) {
23
+ throw new RangeError('hashTag must be non-empty and must not contain braces');
24
+ }
25
+ assertPositiveInteger(this.cleanupBatchSize, 'cleanupBatchSize');
26
+ assertPositiveInteger(this.idempotencyTtlMs, 'idempotencyTtlMs');
27
+ }
28
+ async retireHistoricalBudgets(input) {
29
+ if (!Array.isArray(input.budgetKeys) || input.budgetKeys.length === 0) {
30
+ throw new RangeError('budgetKeys must contain at least one exact historical key');
31
+ }
32
+ if (input.budgetKeys.length > 64)
33
+ throw new RangeError('budgetKeys exceed the 64-key maintenance batch limit');
34
+ const unique = [...new Set(input.budgetKeys)];
35
+ if (unique.length !== input.budgetKeys.length)
36
+ throw new RangeError('budgetKeys must not contain duplicates');
37
+ for (const key of unique)
38
+ assertUsageIdentifier(key, 'budgetKey');
39
+ const maxReservationsToInspect = input.maxReservationsToInspect ?? 10_000;
40
+ assertPositiveInteger(maxReservationsToInspect, 'maxReservationsToInspect');
41
+ const keys = this.keys();
42
+ const reply = parseReply(await this.client.eval(RETIRE_HISTORICAL_BUDGETS_SCRIPT, {
43
+ keys: [keys.used, keys.reservations],
44
+ arguments: [JSON.stringify(unique.map(digest)), String(maxReservationsToInspect)],
45
+ }));
46
+ if (reply[0] === 'active_reference') {
47
+ throw new UsageStateError('Historical budget retirement blocked by an active reservation reference');
48
+ }
49
+ if (reply[0] === 'scan_limit') {
50
+ throw new UsageStateError('Historical budget retirement inspection bound was exceeded');
51
+ }
52
+ if (reply[0] === 'unsupported_schema_version') {
53
+ throw new UsageStateError('Historical budget retirement encountered unsupported retained state');
54
+ }
55
+ if (reply[0] !== 'ok')
56
+ throw new UsageStateError('Redis historical budget retirement reply was invalid');
57
+ return {
58
+ requested: unique.length,
59
+ retired: parseInteger(reply[1], 'retired budget count'),
60
+ missing: parseInteger(reply[2], 'missing budget count'),
61
+ };
62
+ }
63
+ async reconcileOperation(input) {
64
+ assertNonNegativeInteger(input.units, 'units');
65
+ const budgets = canonicalizeBudgets(input.budgets);
66
+ validateRequestIdentity(input.request);
67
+ const operationKey = digest(JSON.stringify([
68
+ input.request.principal.tenantId ?? null,
69
+ input.request.principal.id,
70
+ input.request.tool,
71
+ input.request.operationId,
72
+ ]));
73
+ const reservationId = `r2.${operationKey}`;
74
+ const expectedBudgetHashes = budgets.map(budget => digest(budget.key));
75
+ const keys = this.keys();
76
+ const reply = parseReply(await this.client.eval(RECONCILE_OPERATION_SCRIPT, {
77
+ keys: [keys.reservations, keys.tombstones],
78
+ arguments: [reservationId],
79
+ }));
80
+ if (reply[0] === 'absent')
81
+ return { status: 'absent', reservationId };
82
+ if (reply[0] === 'mode_mismatch') {
83
+ throw new UsageStateError('Scalar operation reconciliation cannot target a vector reservation');
84
+ }
85
+ if (reply[0] === 'invalid_state') {
86
+ throw new UsageStateError('Redis reservation had invalid reconciliation state');
87
+ }
88
+ if (reply[0] === 'active' || reply[0] === 'expired') {
89
+ const state = reply[1];
90
+ const reservedUnits = parseInteger(reply[2], 'reservedUnits');
91
+ const expiresAt = parseInteger(reply[3], 'expiresAt');
92
+ const hashes = parseStringArray(reply[4], 'budget hashes');
93
+ const growthCursor = reply[5] ?? '';
94
+ if ((state !== 'pending' && state !== 'liable') ||
95
+ reservedUnits !== input.units ||
96
+ !sameStringArray(hashes, expectedBudgetHashes)) {
97
+ throw new UsageStateError('Operation reconciliation input does not match retained reservation state');
98
+ }
99
+ if (reply[0] === 'expired') {
100
+ return { status: 'expired', state, reservationId, expiredAt: expiresAt };
101
+ }
102
+ return {
103
+ status: 'active',
104
+ state,
105
+ reservation: {
106
+ id: reservationId,
107
+ operationId: input.request.operationId,
108
+ principalId: input.request.principal.id,
109
+ ...(input.request.principal.tenantId === undefined
110
+ ? {}
111
+ : { tenantId: input.request.principal.tenantId }),
112
+ ...(input.request.principal.plan === undefined
113
+ ? {}
114
+ : { plan: input.request.principal.plan }),
115
+ tool: input.request.tool,
116
+ budgetKeys: budgets.map(budget => budget.key),
117
+ reservedUnits,
118
+ expiresAt,
119
+ ...(growthCursor.length === 0 ? {} : { growthCursor }),
120
+ },
121
+ };
122
+ }
123
+ if (reply[0] === 'settled') {
124
+ const reservedUnits = parseInteger(reply[1], 'reservedUnits');
125
+ const actualUnits = parseInteger(reply[2], 'actualUnits');
126
+ const tombstoneExpiresAt = parseInteger(reply[3], 'tombstoneExpiresAt');
127
+ const hashes = parseStringArray(reply[4], 'budget hashes');
128
+ if (reservedUnits !== input.units || !sameStringArray(hashes, expectedBudgetHashes)) {
129
+ throw new UsageStateError('Operation reconciliation input does not match retained reservation state');
130
+ }
131
+ return {
132
+ status: 'settled',
133
+ reservationId,
134
+ reservedUnits,
135
+ actualUnits,
136
+ tombstoneExpiresAt,
137
+ };
138
+ }
139
+ throw new UsageStateError('Redis reconciliation reply was invalid');
140
+ }
141
+ async reconcileVectorOperation(input) {
142
+ validateRequestIdentity(input.request);
143
+ const dimensions = canonicalizeUsageDimensions(input.dimensions);
144
+ const operationKey = digest(JSON.stringify([
145
+ input.request.principal.tenantId ?? null,
146
+ input.request.principal.id,
147
+ input.request.tool,
148
+ input.request.operationId,
149
+ ]));
150
+ const reservationId = `r2.${operationKey}`;
151
+ const maps = vectorMaps(dimensions);
152
+ const reply = parseReply(await this.client.eval(RECONCILE_VECTOR_OPERATION_SCRIPT, {
153
+ keys: [this.keys().reservations, this.keys().tombstones],
154
+ arguments: [reservationId],
155
+ }));
156
+ if (reply[0] === 'absent')
157
+ return { status: 'absent', reservationId };
158
+ if (reply[0] === 'mode_mismatch') {
159
+ throw new UsageStateError('Vector operation reconciliation cannot target a scalar reservation');
160
+ }
161
+ if (reply[0] === 'unsupported_schema_version') {
162
+ throw new UsageStateError('Redis reservation schema version is not supported');
163
+ }
164
+ if (reply[0] === 'invalid_state') {
165
+ throw new UsageStateError('Redis vector reservation had invalid reconciliation state');
166
+ }
167
+ if (reply[0] === 'active' || reply[0] === 'expired') {
168
+ const state = reply[1];
169
+ const expiresAt = parseInteger(reply[2], 'expiresAt');
170
+ const stored = parseVectorReconciliationDimensions(reply[3], dimensions, maps);
171
+ const growthCursor = reply[4] ?? '';
172
+ if (state !== 'pending' && state !== 'liable') {
173
+ throw new UsageStateError('Redis vector reconciliation state was invalid');
174
+ }
175
+ if (reply[0] === 'expired') {
176
+ return { status: 'expired', state, reservationId, expiredAt: expiresAt };
177
+ }
178
+ return {
179
+ status: 'active',
180
+ state,
181
+ reservation: {
182
+ id: reservationId,
183
+ operationId: input.request.operationId,
184
+ principalId: input.request.principal.id,
185
+ ...(input.request.principal.tenantId === undefined ? {} : { tenantId: input.request.principal.tenantId }),
186
+ ...(input.request.principal.plan === undefined ? {} : { plan: input.request.principal.plan }),
187
+ tool: input.request.tool,
188
+ dimensions: stored,
189
+ expiresAt,
190
+ ...(growthCursor.length === 0 ? {} : { growthCursor }),
191
+ },
192
+ };
193
+ }
194
+ if (reply[0] === 'settled') {
195
+ const tombstoneExpiresAt = parseInteger(reply[1], 'tombstoneExpiresAt');
196
+ const stored = parseVectorReconciliationDimensions(reply[2], dimensions, maps);
197
+ const actualByDimension = parseVectorReconciliationActuals(reply[3], maps.dimensionByHash);
198
+ return {
199
+ status: 'settled',
200
+ reservationId,
201
+ reservedByDimension: stored.map(dimension => ({ key: dimension.key, reservedUnits: dimension.reservedUnits })),
202
+ actualByDimension,
203
+ tombstoneExpiresAt,
204
+ };
205
+ }
206
+ throw new UsageStateError('Redis vector reconciliation reply was invalid');
207
+ }
208
+ async reserve(input) {
209
+ assertNonNegativeInteger(input.units, 'units');
210
+ assertPositiveInteger(input.ttlMs, 'ttlMs');
211
+ const budgets = canonicalizeBudgets(input.budgets);
212
+ validateRequestIdentity(input.request);
213
+ const operationKey = digest(JSON.stringify([
214
+ input.request.principal.tenantId ?? null,
215
+ input.request.principal.id,
216
+ input.request.tool,
217
+ input.request.operationId,
218
+ ]));
219
+ const reservationId = `r2.${operationKey}`;
220
+ const keys = this.keys();
221
+ const budgetByHash = new Map();
222
+ const encodedBudgets = budgets.map(budget => {
223
+ const hash = digest(budget.key);
224
+ budgetByHash.set(hash, budget);
225
+ return { hash, limit: budget.limit };
226
+ });
227
+ const initialGrowthCursor = newGrowthCursor();
228
+ const parsed = parseReply(await this.client.eval(RESERVE_SCRIPT, {
229
+ keys: [keys.used, keys.leases, keys.reservations, keys.operations, keys.tombstones],
230
+ arguments: [
231
+ String(input.units),
232
+ String(input.ttlMs),
233
+ reservationId,
234
+ operationKey,
235
+ String(this.cleanupBatchSize),
236
+ String(this.idempotencyTtlMs),
237
+ JSON.stringify(encodedBudgets),
238
+ initialGrowthCursor,
239
+ ],
240
+ }));
241
+ assertRedisTimeReply(parsed);
242
+ const { payload: reply, recovery } = extractRecovery(parsed);
243
+ this.emitRecoverySummary(recovery);
244
+ switch (reply[0]) {
245
+ case 'accepted': {
246
+ const expiresAt = parseInteger(reply[1], 'expiresAt');
247
+ const remainingByBudget = [];
248
+ for (let index = 2; index < reply.length; index += 2) {
249
+ const hash = reply[index];
250
+ const remainingRaw = reply[index + 1];
251
+ if (!hash || remainingRaw === undefined) {
252
+ throw new UsageStateError('Redis reserve reply contained an incomplete budget balance');
253
+ }
254
+ const budget = budgetByHash.get(hash);
255
+ if (!budget)
256
+ throw new UsageStateError('Redis reserve reply referenced an unknown budget');
257
+ remainingByBudget.push({
258
+ key: budget.key,
259
+ remaining: parseInteger(remainingRaw, `remaining (${budget.key})`),
260
+ });
261
+ }
262
+ if (remainingByBudget.length !== budgets.length) {
263
+ throw new UsageStateError('Redis reserve reply omitted a budget balance');
264
+ }
265
+ return {
266
+ accepted: true,
267
+ reservation: {
268
+ id: reservationId,
269
+ operationId: input.request.operationId,
270
+ principalId: input.request.principal.id,
271
+ ...(input.request.principal.tenantId === undefined
272
+ ? {}
273
+ : { tenantId: input.request.principal.tenantId }),
274
+ ...(input.request.principal.plan === undefined
275
+ ? {}
276
+ : { plan: input.request.principal.plan }),
277
+ tool: input.request.tool,
278
+ budgetKeys: budgets.map(budget => budget.key),
279
+ reservedUnits: input.units,
280
+ expiresAt,
281
+ growthCursor: initialGrowthCursor,
282
+ },
283
+ remainingByBudget,
284
+ };
285
+ }
286
+ case 'quota_exceeded': {
287
+ const budgetHash = reply[1];
288
+ const remainingRaw = reply[2];
289
+ if (!budgetHash || remainingRaw === undefined) {
290
+ throw new UsageStateError('Redis quota reply was incomplete');
291
+ }
292
+ const budget = budgetByHash.get(budgetHash);
293
+ if (!budget)
294
+ throw new UsageStateError('Redis quota reply referenced an unknown budget');
295
+ return {
296
+ accepted: false,
297
+ reason: 'quota_exceeded',
298
+ limitingBudgetKey: budget.key,
299
+ remaining: parseInteger(remainingRaw, 'remaining'),
300
+ };
301
+ }
302
+ case 'duplicate_operation':
303
+ return { accepted: false, reason: 'duplicate_operation' };
304
+ default:
305
+ throw new UsageStateError(`Unexpected Redis reserve reply: ${reply[0] ?? '<empty>'}`);
306
+ }
307
+ }
308
+ async reserveVector(input) {
309
+ assertPositiveInteger(input.ttlMs, 'ttlMs');
310
+ validateRequestIdentity(input.request);
311
+ const dimensions = canonicalizeUsageDimensions(input.dimensions);
312
+ const operationKey = digest(JSON.stringify([
313
+ input.request.principal.tenantId ?? null,
314
+ input.request.principal.id,
315
+ input.request.tool,
316
+ input.request.operationId,
317
+ ]));
318
+ const reservationId = `r2.${operationKey}`;
319
+ const maps = vectorMaps(dimensions);
320
+ const encoded = dimensions.map(dimension => ({
321
+ hash: digest(dimension.key),
322
+ units: dimension.units,
323
+ budgets: dimension.budgets.map(budget => ({ hash: digest(budget.key), limit: budget.limit })),
324
+ }));
325
+ const initialGrowthCursor = newGrowthCursor();
326
+ const keys = this.keys();
327
+ const parsed = parseReply(await this.client.eval(RESERVE_VECTOR_SCRIPT, {
328
+ keys: [keys.used, keys.leases, keys.reservations, keys.operations, keys.tombstones],
329
+ arguments: [
330
+ String(input.ttlMs),
331
+ reservationId,
332
+ operationKey,
333
+ String(this.cleanupBatchSize),
334
+ String(this.idempotencyTtlMs),
335
+ JSON.stringify(encoded),
336
+ initialGrowthCursor,
337
+ ],
338
+ }));
339
+ assertRedisTimeReply(parsed);
340
+ const { payload: reply, recovery } = extractRecovery(parsed);
341
+ this.emitRecoverySummary(recovery);
342
+ if (reply[0] === 'accepted') {
343
+ const expiresAt = parseInteger(reply[1], 'expiresAt');
344
+ const balances = parseVectorBalances(reply[2], maps);
345
+ return {
346
+ accepted: true,
347
+ reservation: {
348
+ id: reservationId,
349
+ operationId: input.request.operationId,
350
+ principalId: input.request.principal.id,
351
+ ...(input.request.principal.tenantId === undefined
352
+ ? {}
353
+ : { tenantId: input.request.principal.tenantId }),
354
+ ...(input.request.principal.plan === undefined ? {} : { plan: input.request.principal.plan }),
355
+ tool: input.request.tool,
356
+ dimensions: dimensions.map(dimension => ({
357
+ key: dimension.key,
358
+ budgetKeys: dimension.budgets.map(budget => budget.key),
359
+ reservedUnits: dimension.units,
360
+ })),
361
+ expiresAt,
362
+ growthCursor: initialGrowthCursor,
363
+ },
364
+ remainingByBudget: balances,
365
+ };
366
+ }
367
+ if (reply[0] === 'quota_exceeded') {
368
+ const dimension = reply[1] ? maps.dimensionByHash.get(reply[1]) : undefined;
369
+ const budget = reply[2] ? maps.budgetByHash.get(reply[2]) : undefined;
370
+ if (!dimension || !budget || reply[3] === undefined) {
371
+ throw new UsageStateError('Redis vector quota reply was incomplete');
372
+ }
373
+ return {
374
+ accepted: false,
375
+ reason: 'quota_exceeded',
376
+ limitingDimensionKey: dimension.key,
377
+ limitingBudgetKey: budget.key,
378
+ remaining: parseInteger(reply[3], 'remaining'),
379
+ };
380
+ }
381
+ if (reply[0] === 'duplicate_operation') {
382
+ return { accepted: false, reason: 'duplicate_operation' };
383
+ }
384
+ throw new UsageStateError(`Unexpected Redis vector reserve reply: ${reply[0] ?? '<empty>'}`);
385
+ }
386
+ async growVectorReservation(input) {
387
+ assertReservationId(input.reservationId);
388
+ if (typeof input.incrementId !== 'string' || input.incrementId.length === 0) {
389
+ throw new RangeError('incrementId must be a non-empty string');
390
+ }
391
+ if (typeof input.expectedGrowthCursor !== 'string' || input.expectedGrowthCursor.length === 0) {
392
+ throw new RangeError('expectedGrowthCursor must be a non-empty string');
393
+ }
394
+ const dimensions = canonicalizeGrowthDimensions(input.dimensions);
395
+ const maps = vectorGrowthMaps(dimensions);
396
+ const encoded = dimensions.map(dimension => ({
397
+ hash: digest(dimension.key),
398
+ additionalUnits: dimension.additionalUnits,
399
+ budgets: dimension.budgets.map(budget => ({ hash: digest(budget.key), limit: budget.limit })),
400
+ }));
401
+ const incrementHash = digest(input.incrementId);
402
+ const fingerprint = digest(JSON.stringify(encoded));
403
+ const nextGrowthCursor = newGrowthCursor();
404
+ const keys = this.keys();
405
+ const reply = parseReply(await this.client.eval(GROW_VECTOR_SCRIPT, {
406
+ keys: [keys.used, keys.leases, keys.reservations, keys.operations, keys.tombstones],
407
+ arguments: [
408
+ input.reservationId,
409
+ incrementHash,
410
+ input.expectedGrowthCursor,
411
+ String(this.idempotencyTtlMs),
412
+ JSON.stringify(encoded),
413
+ fingerprint,
414
+ nextGrowthCursor,
415
+ ],
416
+ }));
417
+ assertRedisTimeReply(reply);
418
+ if (reply[0] === 'accepted' || reply[0] === 'accepted_replay') {
419
+ const cursor = reply[1];
420
+ if (!cursor)
421
+ throw new UsageStateError('Redis vector growth reply omitted growth cursor');
422
+ return {
423
+ accepted: true,
424
+ replayed: reply[0] === 'accepted_replay',
425
+ reservationId: input.reservationId,
426
+ incrementId: input.incrementId,
427
+ growthCursor: cursor,
428
+ previousReservedByDimension: parseVectorReserved(reply[2], maps.dimensionByHash),
429
+ reservedByDimension: parseVectorReserved(reply[3], maps.dimensionByHash),
430
+ remainingByBudget: parseVectorBalances(reply[4], maps),
431
+ };
432
+ }
433
+ if (reply[0] === 'quota_exceeded' || reply[0] === 'quota_replay') {
434
+ const cursor = reply[1];
435
+ const dimension = reply[2] ? maps.dimensionByHash.get(reply[2]) : undefined;
436
+ const budget = reply[3] ? maps.budgetByHash.get(reply[3]) : undefined;
437
+ if (!cursor || !dimension || !budget || reply[4] === undefined) {
438
+ throw new UsageStateError('Redis vector growth quota reply was incomplete');
439
+ }
440
+ return {
441
+ accepted: false,
442
+ reason: 'quota_exceeded',
443
+ replayed: reply[0] === 'quota_replay',
444
+ reservationId: input.reservationId,
445
+ incrementId: input.incrementId,
446
+ growthCursor: cursor,
447
+ limitingDimensionKey: dimension.key,
448
+ limitingBudgetKey: budget.key,
449
+ remaining: parseInteger(reply[4], 'remaining'),
450
+ };
451
+ }
452
+ if (reply[0] === 'expired_vector') {
453
+ this.emitDirectExpiry(reply, input.reservationId);
454
+ throw new UsageStateError('Reservation not found, expired, or no longer active');
455
+ }
456
+ if (reply[0] === 'conflict') {
457
+ throw new UsageStateError('Vector growth increment was already attempted with different parameters');
458
+ }
459
+ if (reply[0] === 'stale_cursor') {
460
+ throw new UsageStateError('Vector growth cursor is stale or conflicts with reservation state');
461
+ }
462
+ if (reply[0] === 'dimension_mismatch') {
463
+ throw new UsageStateError('Vector growth dimensions and budgets must match the reservation');
464
+ }
465
+ if (reply[0] === 'not_supported') {
466
+ throw new UsageStateError('Vector reservation does not support progressive growth');
467
+ }
468
+ if (reply[0] === 'mode_mismatch') {
469
+ throw new UsageStateError('Vector growth cannot target a scalar reservation');
470
+ }
471
+ if (reply[0] === 'terminal' || reply[0] === 'not_found') {
472
+ throw new UsageStateError('Reservation not found, expired, or no longer active');
473
+ }
474
+ throw new UsageStateError(`Unexpected Redis vector growth reply: ${reply[0] ?? '<empty>'}`);
475
+ }
476
+ async settleVector(input) {
477
+ assertReservationId(input.reservationId);
478
+ const actuals = canonicalizeActualDimensions(input.actualByDimension);
479
+ const dimensionByHash = new Map();
480
+ const encoded = actuals.map(actual => {
481
+ const hash = digest(actual.key);
482
+ dimensionByHash.set(hash, { key: actual.key });
483
+ return { hash, actualUnits: actual.actualUnits };
484
+ });
485
+ const keys = this.keys();
486
+ const reply = parseReply(await this.client.eval(SETTLE_VECTOR_SCRIPT, {
487
+ keys: [keys.used, keys.leases, keys.reservations, keys.operations, keys.tombstones],
488
+ arguments: [
489
+ input.reservationId,
490
+ JSON.stringify(encoded),
491
+ input.outcome,
492
+ String(this.idempotencyTtlMs),
493
+ ],
494
+ }));
495
+ assertRedisTimeReply(reply);
496
+ if (reply[0] === 'settled' || reply[0] === 'idempotent') {
497
+ return {
498
+ reservationId: input.reservationId,
499
+ dimensions: parseVectorSettlement(reply[1], dimensionByHash),
500
+ outcome: input.outcome,
501
+ };
502
+ }
503
+ if (reply[0] === 'conflict') {
504
+ throw new UsageStateError('Vector reservation was already settled with a different result');
505
+ }
506
+ if (reply[0] === 'invalid_units') {
507
+ throw new UsageStateError('actualUnits cannot exceed reservedUnits for a vector dimension');
508
+ }
509
+ if (reply[0] === 'dimension_mismatch') {
510
+ throw new UsageStateError('Vector settlement dimensions must exactly match the reservation');
511
+ }
512
+ if (reply[0] === 'mode_mismatch') {
513
+ throw new UsageStateError('Vector settlement cannot target a scalar reservation');
514
+ }
515
+ if (reply[0] === 'expired_vector') {
516
+ this.emitDirectExpiry(reply, input.reservationId);
517
+ throw new UsageStateError('Reservation not found, expired, or no longer active');
518
+ }
519
+ if (reply[0] === 'not_found' || reply[0] === 'not_pending') {
520
+ throw new UsageStateError('Reservation not found, expired, or no longer active');
521
+ }
522
+ throw new UsageStateError(`Unexpected Redis vector settle reply: ${reply[0] ?? '<empty>'}`);
523
+ }
524
+ async growReservation(input) {
525
+ assertReservationId(input.reservationId);
526
+ if (typeof input.incrementId !== 'string' || input.incrementId.length === 0) {
527
+ throw new RangeError('incrementId must be a non-empty string');
528
+ }
529
+ if (typeof input.expectedGrowthCursor !== 'string' ||
530
+ input.expectedGrowthCursor.length === 0) {
531
+ throw new RangeError('expectedGrowthCursor must be a non-empty string');
532
+ }
533
+ assertPositiveInteger(input.additionalUnits, 'additionalUnits');
534
+ const budgets = canonicalizeBudgets(input.budgets);
535
+ const budgetByHash = new Map();
536
+ const encodedBudgets = budgets.map(budget => {
537
+ const hash = digest(budget.key);
538
+ budgetByHash.set(hash, budget);
539
+ return { hash, limit: budget.limit };
540
+ });
541
+ const incrementHash = digest(input.incrementId);
542
+ const fingerprint = digest(JSON.stringify([input.additionalUnits, encodedBudgets]));
543
+ const nextGrowthCursor = newGrowthCursor();
544
+ const keys = this.keys();
545
+ const reply = parseReply(await this.client.eval(GROW_SCRIPT, {
546
+ keys: [keys.used, keys.leases, keys.reservations, keys.operations, keys.tombstones],
547
+ arguments: [
548
+ input.reservationId,
549
+ incrementHash,
550
+ input.expectedGrowthCursor,
551
+ String(input.additionalUnits),
552
+ String(this.idempotencyTtlMs),
553
+ JSON.stringify(encodedBudgets),
554
+ fingerprint,
555
+ nextGrowthCursor,
556
+ ],
557
+ }));
558
+ assertRedisTimeReply(reply);
559
+ if (reply[0] === 'accepted' || reply[0] === 'accepted_replay') {
560
+ const returnedCursor = reply[3];
561
+ const balancesRaw = reply[4];
562
+ if (!returnedCursor || balancesRaw === undefined) {
563
+ throw new UsageStateError('Redis growth reply was incomplete');
564
+ }
565
+ return {
566
+ accepted: true,
567
+ replayed: reply[0] === 'accepted_replay',
568
+ reservationId: input.reservationId,
569
+ incrementId: input.incrementId,
570
+ previousReservedUnits: parseInteger(reply[1], 'previousReservedUnits'),
571
+ reservedUnits: parseInteger(reply[2], 'reservedUnits'),
572
+ growthCursor: returnedCursor,
573
+ remainingByBudget: parseGrowthBalances(balancesRaw, budgetByHash),
574
+ };
575
+ }
576
+ if (reply[0] === 'quota_exceeded' || reply[0] === 'quota_replay') {
577
+ const returnedCursor = reply[1];
578
+ const limitingHash = reply[2];
579
+ if (!returnedCursor || !limitingHash) {
580
+ throw new UsageStateError('Redis growth quota reply was incomplete');
581
+ }
582
+ const limitingBudget = budgetByHash.get(limitingHash);
583
+ if (!limitingBudget) {
584
+ throw new UsageStateError('Redis growth quota reply referenced an unknown budget');
585
+ }
586
+ return {
587
+ accepted: false,
588
+ reason: 'quota_exceeded',
589
+ replayed: reply[0] === 'quota_replay',
590
+ reservationId: input.reservationId,
591
+ incrementId: input.incrementId,
592
+ growthCursor: returnedCursor,
593
+ limitingBudgetKey: limitingBudget.key,
594
+ remaining: parseInteger(reply[3], 'remaining'),
595
+ };
596
+ }
597
+ if (reply[0] === 'expired' || reply[0] === 'expired_vector') {
598
+ this.emitDirectExpiry(reply, input.reservationId);
599
+ throw new UsageStateError('Reservation not found, expired, or no longer active');
600
+ }
601
+ if (reply[0] === 'conflict') {
602
+ throw new UsageStateError('Growth increment was already attempted with different parameters');
603
+ }
604
+ if (reply[0] === 'stale_cursor') {
605
+ throw new UsageStateError('Growth cursor is stale or conflicts with reservation state');
606
+ }
607
+ if (reply[0] === 'budget_mismatch') {
608
+ throw new UsageStateError('Growth budgets must exactly match the reservation budget set');
609
+ }
610
+ if (reply[0] === 'not_supported') {
611
+ throw new UsageStateError('Reservation does not support progressive growth');
612
+ }
613
+ if (reply[0] === 'mode_mismatch') {
614
+ throw new UsageStateError('Scalar growth cannot target a vector reservation');
615
+ }
616
+ if (reply[0] === 'terminal' || reply[0] === 'not_found') {
617
+ throw new UsageStateError('Reservation not found, expired, or no longer active');
618
+ }
619
+ throw new UsageStateError(`Unexpected Redis growth reply: ${reply[0] ?? '<empty>'}`);
620
+ }
621
+ async markLiable(input) {
622
+ assertReservationId(input.reservationId);
623
+ const keys = this.keys();
624
+ const reply = parseReply(await this.client.eval(MARK_LIABLE_SCRIPT, {
625
+ keys: [keys.used, keys.leases, keys.reservations, keys.operations, keys.tombstones],
626
+ arguments: [input.reservationId, String(this.idempotencyTtlMs)],
627
+ }));
628
+ assertRedisTimeReply(reply);
629
+ if (reply[0] === 'marked') {
630
+ return {
631
+ reservationId: input.reservationId,
632
+ expiresAt: parseInteger(reply[1], 'expiresAt'),
633
+ };
634
+ }
635
+ if (reply[0] === 'expired' || reply[0] === 'expired_vector') {
636
+ this.emitDirectExpiry(reply, input.reservationId);
637
+ throw new UsageStateError('Active reservation not found or expired');
638
+ }
639
+ if (reply[0] === 'not_found' || reply[0] === 'not_pending') {
640
+ throw new UsageStateError('Active reservation not found or expired');
641
+ }
642
+ throw new UsageStateError(`Unexpected Redis mark-liable reply: ${reply[0] ?? '<empty>'}`);
643
+ }
644
+ async renew(input) {
645
+ assertPositiveInteger(input.ttlMs, 'ttlMs');
646
+ assertReservationId(input.reservationId);
647
+ const keys = this.keys();
648
+ const reply = parseReply(await this.client.eval(RENEW_SCRIPT, {
649
+ keys: [keys.used, keys.leases, keys.reservations, keys.operations, keys.tombstones],
650
+ arguments: [String(input.ttlMs), input.reservationId, String(this.idempotencyTtlMs)],
651
+ }));
652
+ assertRedisTimeReply(reply);
653
+ if (reply[0] === 'renewed') {
654
+ return {
655
+ reservationId: input.reservationId,
656
+ expiresAt: parseInteger(reply[1], 'expiresAt'),
657
+ };
658
+ }
659
+ if (reply[0] === 'expired' || reply[0] === 'expired_vector') {
660
+ this.emitDirectExpiry(reply, input.reservationId);
661
+ throw new UsageStateError('Active reservation not found or expired');
662
+ }
663
+ if (reply[0] === 'not_found' || reply[0] === 'not_pending') {
664
+ throw new UsageStateError('Active reservation not found or expired');
665
+ }
666
+ throw new UsageStateError(`Unexpected Redis renew reply: ${reply[0] ?? '<empty>'}`);
667
+ }
668
+ async settle(input) {
669
+ assertNonNegativeInteger(input.actualUnits, 'actualUnits');
670
+ assertReservationId(input.reservationId);
671
+ const keys = this.keys();
672
+ const reply = parseReply(await this.client.eval(SETTLE_SCRIPT, {
673
+ keys: [keys.used, keys.leases, keys.reservations, keys.operations, keys.tombstones],
674
+ arguments: [
675
+ input.reservationId,
676
+ String(input.actualUnits),
677
+ input.outcome,
678
+ String(this.idempotencyTtlMs),
679
+ ],
680
+ }));
681
+ assertRedisTimeReply(reply);
682
+ if (reply[0] === 'settled' || reply[0] === 'idempotent') {
683
+ return {
684
+ reservationId: input.reservationId,
685
+ reservedUnits: parseInteger(reply[1], 'reservedUnits'),
686
+ actualUnits: parseInteger(reply[2], 'actualUnits'),
687
+ releasedUnits: parseInteger(reply[3], 'releasedUnits'),
688
+ outcome: input.outcome,
689
+ };
690
+ }
691
+ if (reply[0] === 'conflict') {
692
+ throw new UsageStateError('Reservation was already settled with a different result');
693
+ }
694
+ if (reply[0] === 'invalid_units') {
695
+ throw new UsageStateError('actualUnits cannot exceed reservedUnits');
696
+ }
697
+ if (reply[0] === 'expired' || reply[0] === 'expired_vector') {
698
+ this.emitDirectExpiry(reply, input.reservationId);
699
+ throw new UsageStateError('Reservation not found, expired, or no longer active');
700
+ }
701
+ if (reply[0] === 'mode_mismatch') {
702
+ throw new UsageStateError('Scalar settlement cannot target a vector reservation');
703
+ }
704
+ if (reply[0] === 'not_found' || reply[0] === 'not_pending') {
705
+ throw new UsageStateError('Reservation not found, expired, or no longer active');
706
+ }
707
+ throw new UsageStateError(`Unexpected Redis settle reply: ${reply[0] ?? '<empty>'}`);
708
+ }
709
+ emitRecoverySummary(recovery) {
710
+ if (recovery.pendingCount > 0) {
711
+ emitUsageEvent(this.observer, {
712
+ type: 'reservation.recovered',
713
+ timestamp: Date.now(),
714
+ store: 'redis',
715
+ recovery: 'pending_released',
716
+ reservedUnits: recovery.pendingUnits,
717
+ count: recovery.pendingCount,
718
+ });
719
+ }
720
+ if (recovery.liableCount > 0) {
721
+ emitUsageEvent(this.observer, {
722
+ type: 'reservation.recovered',
723
+ timestamp: Date.now(),
724
+ store: 'redis',
725
+ recovery: 'liable_retained',
726
+ reservedUnits: recovery.liableUnits,
727
+ count: recovery.liableCount,
728
+ });
729
+ }
730
+ if (recovery.vectorPendingCount > 0) {
731
+ emitUsageEvent(this.observer, {
732
+ type: 'vector.reservation.recovered',
733
+ timestamp: Date.now(),
734
+ store: 'redis',
735
+ recovery: 'pending_released',
736
+ count: recovery.vectorPendingCount,
737
+ });
738
+ }
739
+ if (recovery.vectorLiableCount > 0) {
740
+ emitUsageEvent(this.observer, {
741
+ type: 'vector.reservation.recovered',
742
+ timestamp: Date.now(),
743
+ store: 'redis',
744
+ recovery: 'liable_retained',
745
+ count: recovery.vectorLiableCount,
746
+ });
747
+ }
748
+ }
749
+ emitDirectExpiry(reply, reservationId) {
750
+ const state = reply[1];
751
+ if (state !== 'pending' && state !== 'liable')
752
+ return;
753
+ if (reply[0] === 'expired_vector') {
754
+ emitUsageEvent(this.observer, {
755
+ type: 'vector.reservation.recovered',
756
+ timestamp: Date.now(),
757
+ store: 'redis',
758
+ recovery: state === 'pending' ? 'pending_released' : 'liable_retained',
759
+ reservationId,
760
+ dimensionCount: parseInteger(reply[2], 'expired dimension count'),
761
+ budgetCount: parseInteger(reply[3], 'expired budget count'),
762
+ count: 1,
763
+ });
764
+ return;
765
+ }
766
+ const reservedUnits = parseInteger(reply[2], 'expired reservedUnits');
767
+ emitUsageEvent(this.observer, {
768
+ type: 'reservation.recovered',
769
+ timestamp: Date.now(),
770
+ store: 'redis',
771
+ recovery: state === 'pending' ? 'pending_released' : 'liable_retained',
772
+ reservationId,
773
+ reservedUnits,
774
+ count: 1,
775
+ });
776
+ }
777
+ keys() {
778
+ const base = `${this.prefix}:{${this.hashTag}}`;
779
+ return {
780
+ used: `${base}:used`,
781
+ leases: `${base}:leases`,
782
+ reservations: `${base}:reservations`,
783
+ operations: `${base}:operations`,
784
+ tombstones: `${base}:tombstones`,
785
+ };
786
+ }
787
+ }
788
+ function assertReservationId(reservationId) {
789
+ if (!RESERVATION_ID_PATTERN.test(reservationId)) {
790
+ throw new UsageStateError('Invalid Redis reservation ID');
791
+ }
792
+ }
793
+ function canonicalizeBudgets(budgets) {
794
+ if (budgets.length === 0)
795
+ throw new RangeError('budgets must contain at least one budget');
796
+ const normalized = budgets.map(budget => {
797
+ if (typeof budget.key !== 'string' || budget.key.length === 0) {
798
+ throw new RangeError('budget.key must be a non-empty string');
799
+ }
800
+ assertNonNegativeInteger(budget.limit, `budget.limit (${budget.key})`);
801
+ return { key: budget.key, limit: budget.limit };
802
+ });
803
+ normalized.sort((a, b) => a.key.localeCompare(b.key));
804
+ for (let index = 1; index < normalized.length; index += 1) {
805
+ if (normalized[index - 1].key === normalized[index].key) {
806
+ throw new RangeError(`duplicate budget key: ${normalized[index].key}`);
807
+ }
808
+ }
809
+ return normalized;
810
+ }
811
+ function canonicalizeUsageDimensions(dimensions) {
812
+ if (dimensions.length === 0)
813
+ throw new RangeError('dimensions must contain at least one dimension');
814
+ const normalized = dimensions.map(dimension => {
815
+ if (typeof dimension.key !== 'string' || dimension.key.length === 0) {
816
+ throw new RangeError('dimension.key must be a non-empty string');
817
+ }
818
+ assertNonNegativeInteger(dimension.units, `dimension.units (${dimension.key})`);
819
+ return { key: dimension.key, units: dimension.units, budgets: canonicalizeBudgets(dimension.budgets) };
820
+ });
821
+ normalized.sort((a, b) => a.key.localeCompare(b.key));
822
+ validateVectorTopology(normalized);
823
+ return normalized;
824
+ }
825
+ function canonicalizeGrowthDimensions(dimensions) {
826
+ if (dimensions.length === 0)
827
+ throw new RangeError('dimensions must contain at least one dimension');
828
+ const normalized = dimensions.map(dimension => {
829
+ if (typeof dimension.key !== 'string' || dimension.key.length === 0) {
830
+ throw new RangeError('dimension.key must be a non-empty string');
831
+ }
832
+ assertNonNegativeInteger(dimension.additionalUnits, `dimension.additionalUnits (${dimension.key})`);
833
+ return {
834
+ key: dimension.key,
835
+ additionalUnits: dimension.additionalUnits,
836
+ budgets: canonicalizeBudgets(dimension.budgets),
837
+ };
838
+ });
839
+ normalized.sort((a, b) => a.key.localeCompare(b.key));
840
+ validateVectorTopology(normalized);
841
+ if (!normalized.some(dimension => dimension.additionalUnits > 0)) {
842
+ throw new RangeError('vector growth must add units to at least one dimension');
843
+ }
844
+ return normalized;
845
+ }
846
+ function canonicalizeActualDimensions(actuals) {
847
+ if (actuals.length === 0)
848
+ throw new RangeError('actualByDimension must contain at least one dimension');
849
+ const normalized = actuals.map(actual => {
850
+ if (typeof actual.key !== 'string' || actual.key.length === 0) {
851
+ throw new RangeError('actual dimension key must be a non-empty string');
852
+ }
853
+ assertNonNegativeInteger(actual.actualUnits, `actualUnits (${actual.key})`);
854
+ return { key: actual.key, actualUnits: actual.actualUnits };
855
+ });
856
+ normalized.sort((a, b) => a.key.localeCompare(b.key));
857
+ for (let index = 1; index < normalized.length; index += 1) {
858
+ if (normalized[index - 1].key === normalized[index].key) {
859
+ throw new RangeError(`duplicate dimension key: ${normalized[index].key}`);
860
+ }
861
+ }
862
+ return normalized;
863
+ }
864
+ function validateVectorTopology(dimensions) {
865
+ const dimensionKeys = new Set();
866
+ const budgetKeys = new Set();
867
+ for (const dimension of dimensions) {
868
+ if (dimensionKeys.has(dimension.key))
869
+ throw new RangeError(`duplicate dimension key: ${dimension.key}`);
870
+ dimensionKeys.add(dimension.key);
871
+ for (const budget of dimension.budgets) {
872
+ if (budgetKeys.has(budget.key)) {
873
+ throw new RangeError(`budget key cannot appear in multiple vector dimensions: ${budget.key}`);
874
+ }
875
+ budgetKeys.add(budget.key);
876
+ }
877
+ }
878
+ }
879
+ function vectorMaps(dimensions) {
880
+ const dimensionByHash = new Map();
881
+ const budgetByHash = new Map();
882
+ let budgetCount = 0;
883
+ for (const dimension of dimensions) {
884
+ dimensionByHash.set(digest(dimension.key), { key: dimension.key });
885
+ for (const budget of dimension.budgets) {
886
+ budgetByHash.set(digest(budget.key), { key: budget.key });
887
+ budgetCount += 1;
888
+ }
889
+ }
890
+ return { dimensionByHash, budgetByHash, budgetCount };
891
+ }
892
+ function vectorGrowthMaps(dimensions) {
893
+ const dimensionByHash = new Map();
894
+ const budgetByHash = new Map();
895
+ let budgetCount = 0;
896
+ for (const dimension of dimensions) {
897
+ dimensionByHash.set(digest(dimension.key), { key: dimension.key });
898
+ for (const budget of dimension.budgets) {
899
+ budgetByHash.set(digest(budget.key), { key: budget.key });
900
+ budgetCount += 1;
901
+ }
902
+ }
903
+ return { dimensionByHash, budgetByHash, budgetCount };
904
+ }
905
+ function parseJsonArray(raw, context) {
906
+ if (raw === undefined)
907
+ throw new UsageStateError(`${context} was missing`);
908
+ let value;
909
+ try {
910
+ value = JSON.parse(raw);
911
+ }
912
+ catch {
913
+ throw new UsageStateError(`${context} contained invalid JSON`);
914
+ }
915
+ if (!Array.isArray(value))
916
+ throw new UsageStateError(`${context} must be an array`);
917
+ return value;
918
+ }
919
+ function parseVectorReconciliationDimensions(raw, expected, maps) {
920
+ const entries = parseJsonArray(raw, 'Redis vector reconciliation dimensions');
921
+ if (entries.length !== expected.length) {
922
+ throw new UsageStateError('Vector reconciliation input does not match retained reservation state');
923
+ }
924
+ const expectedByHash = new Map(expected.map(dimension => [digest(dimension.key), dimension]));
925
+ const result = entries.map(entry => {
926
+ if (!entry || typeof entry !== 'object')
927
+ throw new UsageStateError('Redis vector reconciliation dimension was invalid');
928
+ const value = entry;
929
+ const candidate = typeof value.hash === 'string' ? expectedByHash.get(value.hash) : undefined;
930
+ if (!candidate || !Array.isArray(value.budgetHashes) || typeof value.reservedUnits !== 'number' || !Number.isSafeInteger(value.reservedUnits)) {
931
+ throw new UsageStateError('Vector reconciliation input does not match retained reservation state');
932
+ }
933
+ const expectedHashes = candidate.budgets.map(budget => digest(budget.key));
934
+ if (value.reservedUnits !== candidate.units || !sameStringArray(value.budgetHashes, expectedHashes)) {
935
+ throw new UsageStateError('Vector reconciliation input does not match retained reservation state');
936
+ }
937
+ return { key: candidate.key, budgetKeys: candidate.budgets.map(budget => budget.key), reservedUnits: value.reservedUnits };
938
+ });
939
+ result.sort((a, b) => a.key.localeCompare(b.key));
940
+ if (result.length !== maps.dimensionByHash.size)
941
+ throw new UsageStateError('Redis vector reconciliation topology was incomplete');
942
+ return result;
943
+ }
944
+ function parseVectorReconciliationActuals(raw, dimensionByHash) {
945
+ const entries = parseJsonArray(raw, 'Redis vector reconciliation actuals');
946
+ if (entries.length !== dimensionByHash.size)
947
+ throw new UsageStateError('Redis vector reconciliation actuals were incomplete');
948
+ const result = entries.map(entry => {
949
+ if (!entry || typeof entry !== 'object')
950
+ throw new UsageStateError('Redis vector reconciliation actual was invalid');
951
+ const value = entry;
952
+ const dimension = typeof value.hash === 'string' ? dimensionByHash.get(value.hash) : undefined;
953
+ if (!dimension || typeof value.actualUnits !== 'number' || !Number.isSafeInteger(value.actualUnits) || value.actualUnits < 0) {
954
+ throw new UsageStateError('Redis vector reconciliation actual referenced an unknown dimension');
955
+ }
956
+ return { key: dimension.key, actualUnits: value.actualUnits };
957
+ });
958
+ result.sort((a, b) => a.key.localeCompare(b.key));
959
+ return result;
960
+ }
961
+ function parseVectorBalances(raw, maps) {
962
+ const entries = parseJsonArray(raw, 'Redis vector balance reply');
963
+ if (entries.length !== maps.budgetCount) {
964
+ throw new UsageStateError('Redis vector balance reply omitted a budget');
965
+ }
966
+ const result = entries.map(entry => {
967
+ if (!entry || typeof entry !== 'object')
968
+ throw new UsageStateError('Redis vector balance entry was invalid');
969
+ const value = entry;
970
+ const dimension = typeof value.dimensionHash === 'string' ? maps.dimensionByHash.get(value.dimensionHash) : undefined;
971
+ const budget = typeof value.budgetHash === 'string' ? maps.budgetByHash.get(value.budgetHash) : undefined;
972
+ if (!dimension || !budget || typeof value.remaining !== 'number' || !Number.isSafeInteger(value.remaining)) {
973
+ throw new UsageStateError('Redis vector balance reply referenced an unknown dimension or budget');
974
+ }
975
+ return { dimensionKey: dimension.key, budgetKey: budget.key, remaining: value.remaining };
976
+ });
977
+ result.sort((a, b) => a.dimensionKey.localeCompare(b.dimensionKey) || a.budgetKey.localeCompare(b.budgetKey));
978
+ return result;
979
+ }
980
+ function parseVectorReserved(raw, dimensionByHash) {
981
+ const entries = parseJsonArray(raw, 'Redis vector reserved reply');
982
+ if (entries.length !== dimensionByHash.size) {
983
+ throw new UsageStateError('Redis vector reserved reply omitted a dimension');
984
+ }
985
+ const result = entries.map(entry => {
986
+ if (!entry || typeof entry !== 'object')
987
+ throw new UsageStateError('Redis vector reserved entry was invalid');
988
+ const value = entry;
989
+ const dimension = typeof value.hash === 'string' ? dimensionByHash.get(value.hash) : undefined;
990
+ if (!dimension || typeof value.reservedUnits !== 'number' || !Number.isSafeInteger(value.reservedUnits)) {
991
+ throw new UsageStateError('Redis vector reserved reply referenced an unknown dimension');
992
+ }
993
+ return { key: dimension.key, reservedUnits: value.reservedUnits };
994
+ });
995
+ result.sort((a, b) => a.key.localeCompare(b.key));
996
+ return result;
997
+ }
998
+ function parseVectorSettlement(raw, dimensionByHash) {
999
+ const entries = parseJsonArray(raw, 'Redis vector settlement reply');
1000
+ if (entries.length !== dimensionByHash.size) {
1001
+ throw new UsageStateError('Redis vector settlement reply omitted a dimension');
1002
+ }
1003
+ const result = entries.map(entry => {
1004
+ if (!entry || typeof entry !== 'object')
1005
+ throw new UsageStateError('Redis vector settlement entry was invalid');
1006
+ const value = entry;
1007
+ const dimension = typeof value.hash === 'string' ? dimensionByHash.get(value.hash) : undefined;
1008
+ if (!dimension ||
1009
+ typeof value.reservedUnits !== 'number' || !Number.isSafeInteger(value.reservedUnits) ||
1010
+ typeof value.actualUnits !== 'number' || !Number.isSafeInteger(value.actualUnits) ||
1011
+ typeof value.releasedUnits !== 'number' || !Number.isSafeInteger(value.releasedUnits)) {
1012
+ throw new UsageStateError('Redis vector settlement reply referenced an unknown dimension');
1013
+ }
1014
+ return {
1015
+ key: dimension.key,
1016
+ reservedUnits: value.reservedUnits,
1017
+ actualUnits: value.actualUnits,
1018
+ releasedUnits: value.releasedUnits,
1019
+ };
1020
+ });
1021
+ result.sort((a, b) => a.key.localeCompare(b.key));
1022
+ return result;
1023
+ }
1024
+ function validateRequestIdentity(request) {
1025
+ validateUsageRequestEnvelope(request);
1026
+ }
1027
+ function newGrowthCursor() {
1028
+ return `g1.${randomUUID()}`;
1029
+ }
1030
+ function parseGrowthBalances(raw, budgetByHash) {
1031
+ let parsed;
1032
+ try {
1033
+ parsed = JSON.parse(raw);
1034
+ }
1035
+ catch {
1036
+ throw new UsageStateError('Redis growth reply contained invalid budget balances');
1037
+ }
1038
+ if (!Array.isArray(parsed) || parsed.length !== budgetByHash.size) {
1039
+ throw new UsageStateError('Redis growth reply omitted a budget balance');
1040
+ }
1041
+ const balances = parsed.map(entry => {
1042
+ if (!entry || typeof entry !== 'object') {
1043
+ throw new UsageStateError('Redis growth reply contained an invalid budget balance');
1044
+ }
1045
+ const value = entry;
1046
+ if (typeof value.hash !== 'string') {
1047
+ throw new UsageStateError('Redis growth reply contained an invalid budget hash');
1048
+ }
1049
+ const budget = budgetByHash.get(value.hash);
1050
+ if (!budget)
1051
+ throw new UsageStateError('Redis growth reply referenced an unknown budget');
1052
+ if (typeof value.remaining !== 'number' || !Number.isSafeInteger(value.remaining)) {
1053
+ throw new UsageStateError('Redis growth reply contained an invalid remaining balance');
1054
+ }
1055
+ return { key: budget.key, remaining: value.remaining };
1056
+ });
1057
+ balances.sort((a, b) => a.key.localeCompare(b.key));
1058
+ return balances;
1059
+ }
1060
+ function digest(value) {
1061
+ return createHash('sha256').update(value, 'utf8').digest('hex');
1062
+ }
1063
+ function parseStringArray(value, name) {
1064
+ if (value === undefined)
1065
+ throw new UsageStateError(`Redis reply omitted ${name}`);
1066
+ let parsed;
1067
+ try {
1068
+ parsed = JSON.parse(value);
1069
+ }
1070
+ catch {
1071
+ throw new UsageStateError(`Redis reply had invalid ${name}`);
1072
+ }
1073
+ if (!Array.isArray(parsed) || !parsed.every(item => typeof item === 'string')) {
1074
+ throw new UsageStateError(`Redis reply had invalid ${name}`);
1075
+ }
1076
+ return parsed;
1077
+ }
1078
+ function sameStringArray(left, right) {
1079
+ return left.length === right.length && left.every((value, index) => value === right[index]);
1080
+ }
1081
+ function assertRedisTimeReply(reply) {
1082
+ if (reply[0] === 'invalid_time') {
1083
+ throw new RangeError('Redis timestamp arithmetic exceeds safe integer range');
1084
+ }
1085
+ }
1086
+ function parseReply(value) {
1087
+ if (!Array.isArray(value))
1088
+ throw new UsageStateError('Redis script returned a non-array reply');
1089
+ return value.map(part => {
1090
+ if (typeof part === 'string')
1091
+ return part;
1092
+ if (typeof part === 'number' || typeof part === 'bigint')
1093
+ return String(part);
1094
+ if (part instanceof Uint8Array)
1095
+ return Buffer.from(part).toString('utf8');
1096
+ throw new UsageStateError('Redis script returned an unsupported reply value');
1097
+ });
1098
+ }
1099
+ function extractRecovery(reply) {
1100
+ const marker = reply.lastIndexOf('recovery');
1101
+ if (marker < 0) {
1102
+ return {
1103
+ payload: reply,
1104
+ recovery: {
1105
+ pendingCount: 0,
1106
+ pendingUnits: 0,
1107
+ liableCount: 0,
1108
+ liableUnits: 0,
1109
+ vectorPendingCount: 0,
1110
+ vectorLiableCount: 0,
1111
+ },
1112
+ };
1113
+ }
1114
+ if (reply.length !== marker + 7) {
1115
+ throw new UsageStateError('Redis reserve reply contained an invalid recovery summary');
1116
+ }
1117
+ return {
1118
+ payload: reply.slice(0, marker),
1119
+ recovery: {
1120
+ pendingCount: parseInteger(reply[marker + 1], 'recovered pending count'),
1121
+ pendingUnits: parseInteger(reply[marker + 2], 'recovered pending units'),
1122
+ liableCount: parseInteger(reply[marker + 3], 'recovered liable count'),
1123
+ liableUnits: parseInteger(reply[marker + 4], 'recovered liable units'),
1124
+ vectorPendingCount: parseInteger(reply[marker + 5], 'recovered vector pending count'),
1125
+ vectorLiableCount: parseInteger(reply[marker + 6], 'recovered vector liable count'),
1126
+ },
1127
+ };
1128
+ }
1129
+ function parseInteger(value, name) {
1130
+ if (value === undefined || !/^-?\d+$/.test(value)) {
1131
+ throw new UsageStateError(`Redis script returned an invalid ${name}`);
1132
+ }
1133
+ const parsed = Number(value);
1134
+ if (!Number.isSafeInteger(parsed)) {
1135
+ throw new UsageStateError(`Redis script returned an unsafe ${name}`);
1136
+ }
1137
+ return parsed;
1138
+ }
1139
+ function assertNonNegativeInteger(value, name) {
1140
+ if (!Number.isSafeInteger(value) || value < 0) {
1141
+ throw new RangeError(`${name} must be a non-negative safe integer`);
1142
+ }
1143
+ }
1144
+ function assertPositiveInteger(value, name) {
1145
+ if (!Number.isSafeInteger(value) || value <= 0) {
1146
+ throw new RangeError(`${name} must be a positive safe integer`);
1147
+ }
1148
+ }
1149
+ //# sourceMappingURL=index.js.map