mcp-usage-control-cloudflare 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/LICENSE +201 -0
- package/README.md +115 -0
- package/dist/auth.d.ts +16 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +42 -0
- package/dist/auth.js.map +1 -0
- package/dist/index.d.ts +384 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1487 -0
- package/dist/index.js.map +1 -0
- package/dist/maintenance-protocol.d.ts +22 -0
- package/dist/maintenance-protocol.d.ts.map +1 -0
- package/dist/maintenance-protocol.js +79 -0
- package/dist/maintenance-protocol.js.map +1 -0
- package/dist/maintenance.d.ts +35 -0
- package/dist/maintenance.d.ts.map +1 -0
- package/dist/maintenance.js +291 -0
- package/dist/maintenance.js.map +1 -0
- package/dist/reconciliation-protocol.d.ts +34 -0
- package/dist/reconciliation-protocol.d.ts.map +1 -0
- package/dist/reconciliation-protocol.js +97 -0
- package/dist/reconciliation-protocol.js.map +1 -0
- package/dist/reconciliation.d.ts +19 -0
- package/dist/reconciliation.d.ts.map +1 -0
- package/dist/reconciliation.js +402 -0
- package/dist/reconciliation.js.map +1 -0
- package/dist/schema.d.ts +12 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +265 -0
- package/dist/schema.js.map +1 -0
- package/dist/versioned-worker.d.ts +17 -0
- package/dist/versioned-worker.d.ts.map +1 -0
- package/dist/versioned-worker.js +24 -0
- package/dist/versioned-worker.js.map +1 -0
- package/dist/worker.d.ts +27 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +1152 -0
- package/dist/worker.js.map +1 -0
- package/package.json +108 -0
package/dist/worker.js
ADDED
|
@@ -0,0 +1,1152 @@
|
|
|
1
|
+
import { DurableObject, } from 'cloudflare:workers';
|
|
2
|
+
const RESERVATION_ID_PATTERN = /^cf1\.[a-f0-9]{64}$/;
|
|
3
|
+
const HASH_PATTERN = /^[a-f0-9]{64}$/;
|
|
4
|
+
const LEASE_EXPIRED_REASON = 'lease_expired_after_execution_started';
|
|
5
|
+
/**
|
|
6
|
+
* SQLite-backed Durable Object implementing one atomic usage-control domain.
|
|
7
|
+
* Export this class from a Worker and declare it with storage = "sqlite".
|
|
8
|
+
*/
|
|
9
|
+
export class UsageControlDurableObject extends DurableObject {
|
|
10
|
+
constructor(ctx, env) {
|
|
11
|
+
super(ctx, env);
|
|
12
|
+
this.ensureSchema();
|
|
13
|
+
}
|
|
14
|
+
async reserve(command) {
|
|
15
|
+
validateReserveCommand(command);
|
|
16
|
+
const now = Date.now();
|
|
17
|
+
return this.ctx.storage.transactionSync(() => {
|
|
18
|
+
const recovery = this.recoverExpiredBatch(now, command.cleanupBatchSize, command.idempotencyTtlMs);
|
|
19
|
+
const existing = this.ctx.storage.sql
|
|
20
|
+
.exec('SELECT id FROM reservations WHERE id = ?', command.reservationId)
|
|
21
|
+
.toArray();
|
|
22
|
+
if (existing.length > 0) {
|
|
23
|
+
return ok({ accepted: false, reason: 'duplicate_operation' }, recovery);
|
|
24
|
+
}
|
|
25
|
+
const balances = command.budgets.map(budget => {
|
|
26
|
+
const row = this.ctx.storage.sql
|
|
27
|
+
.exec('SELECT used FROM budgets WHERE id = ?', budget.id)
|
|
28
|
+
.toArray()[0];
|
|
29
|
+
const used = row ? integer(row.used, 'used') : 0;
|
|
30
|
+
return {
|
|
31
|
+
id: budget.id,
|
|
32
|
+
used,
|
|
33
|
+
remaining: Math.max(0, budget.limit - used),
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
const limiting = balances.find(balance => command.units > balance.remaining);
|
|
37
|
+
if (limiting) {
|
|
38
|
+
return ok({
|
|
39
|
+
accepted: false,
|
|
40
|
+
reason: 'quota_exceeded',
|
|
41
|
+
limitingBudgetId: limiting.id,
|
|
42
|
+
remaining: limiting.remaining,
|
|
43
|
+
}, recovery);
|
|
44
|
+
}
|
|
45
|
+
for (const budget of command.budgets) {
|
|
46
|
+
this.ctx.storage.sql.exec(`INSERT INTO budgets (id, used) VALUES (?, ?)
|
|
47
|
+
ON CONFLICT(id) DO UPDATE SET used = budgets.used + excluded.used`, budget.id, command.units);
|
|
48
|
+
}
|
|
49
|
+
const expiresAt = safeAdd(now, command.ttlMs, 'expiresAt');
|
|
50
|
+
this.ctx.storage.sql.exec('DELETE FROM reservation_growth WHERE reservation_id = ?', command.reservationId);
|
|
51
|
+
this.ctx.storage.sql.exec(`INSERT INTO reservations (
|
|
52
|
+
id, state, reserved_units, expires_at, actual_units, outcome_hash,
|
|
53
|
+
terminal_reason, tombstone_expires_at, budget_ids_json
|
|
54
|
+
) VALUES (?, 'pending', ?, ?, NULL, NULL, NULL, NULL, ?)`, command.reservationId, command.units, expiresAt, JSON.stringify(command.budgets.map(budget => budget.id)));
|
|
55
|
+
this.ctx.storage.sql.exec(`INSERT INTO reservation_growth (reservation_id, growth_cursor, last_growth_json)
|
|
56
|
+
VALUES (?, ?, NULL)`, command.reservationId, command.initialGrowthCursor);
|
|
57
|
+
return ok({
|
|
58
|
+
accepted: true,
|
|
59
|
+
expiresAt,
|
|
60
|
+
remainingByBudget: balances.map(balance => ({
|
|
61
|
+
id: balance.id,
|
|
62
|
+
remaining: balance.remaining - command.units,
|
|
63
|
+
})),
|
|
64
|
+
}, recovery);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
async reserveVector(command) {
|
|
68
|
+
validateVectorReserveCommand(command);
|
|
69
|
+
const now = Date.now();
|
|
70
|
+
return this.ctx.storage.transactionSync(() => {
|
|
71
|
+
const recovery = this.recoverExpiredBatch(now, command.cleanupBatchSize, command.idempotencyTtlMs);
|
|
72
|
+
const existing = this.ctx.storage.sql
|
|
73
|
+
.exec('SELECT id FROM reservations WHERE id = ?', command.reservationId)
|
|
74
|
+
.toArray();
|
|
75
|
+
if (existing.length > 0) {
|
|
76
|
+
return ok({ accepted: false, reason: 'duplicate_operation' }, recovery);
|
|
77
|
+
}
|
|
78
|
+
const balances = [];
|
|
79
|
+
let limiting;
|
|
80
|
+
for (const dimension of command.dimensions) {
|
|
81
|
+
for (const budget of dimension.budgets) {
|
|
82
|
+
const row = this.ctx.storage.sql
|
|
83
|
+
.exec('SELECT used FROM budgets WHERE id = ?', budget.id)
|
|
84
|
+
.toArray()[0];
|
|
85
|
+
const used = row ? integer(row.used, 'used') : 0;
|
|
86
|
+
const remaining = Math.max(0, budget.limit - used);
|
|
87
|
+
balances.push({ dimensionId: dimension.id, budgetId: budget.id, remaining });
|
|
88
|
+
if (!limiting && dimension.units > remaining) {
|
|
89
|
+
limiting = { dimensionId: dimension.id, budgetId: budget.id, remaining };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (limiting) {
|
|
94
|
+
return ok({
|
|
95
|
+
accepted: false,
|
|
96
|
+
reason: 'quota_exceeded',
|
|
97
|
+
limitingDimensionId: limiting.dimensionId,
|
|
98
|
+
limitingBudgetId: limiting.budgetId,
|
|
99
|
+
remaining: limiting.remaining,
|
|
100
|
+
}, recovery);
|
|
101
|
+
}
|
|
102
|
+
for (const dimension of command.dimensions) {
|
|
103
|
+
for (const budget of dimension.budgets) {
|
|
104
|
+
this.ctx.storage.sql.exec(`INSERT INTO budgets (id, used) VALUES (?, ?)
|
|
105
|
+
ON CONFLICT(id) DO UPDATE SET used = budgets.used + excluded.used`, budget.id, dimension.units);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const expiresAt = safeAdd(now, command.ttlMs, 'expiresAt');
|
|
109
|
+
this.ctx.storage.sql.exec(`INSERT INTO reservations (
|
|
110
|
+
id, state, reserved_units, expires_at, actual_units, outcome_hash,
|
|
111
|
+
terminal_reason, tombstone_expires_at, budget_ids_json
|
|
112
|
+
) VALUES (?, 'pending', 0, ?, NULL, NULL, NULL, NULL, '[]')`, command.reservationId, expiresAt);
|
|
113
|
+
this.ctx.storage.sql.exec(`INSERT INTO reservation_growth (reservation_id, growth_cursor, last_growth_json)
|
|
114
|
+
VALUES (?, ?, NULL)`, command.reservationId, command.initialGrowthCursor);
|
|
115
|
+
const dimensions = command.dimensions.map(dimension => ({
|
|
116
|
+
id: dimension.id,
|
|
117
|
+
reservedUnits: dimension.units,
|
|
118
|
+
budgetIds: dimension.budgets.map(budget => budget.id),
|
|
119
|
+
}));
|
|
120
|
+
this.ctx.storage.sql.exec(`INSERT INTO reservation_vectors (
|
|
121
|
+
reservation_id, dimensions_json, actual_dimensions_json, last_vector_growth_json
|
|
122
|
+
) VALUES (?, ?, NULL, NULL)`, command.reservationId, JSON.stringify(dimensions));
|
|
123
|
+
const unitsByDimension = new Map(command.dimensions.map(dimension => [dimension.id, dimension.units]));
|
|
124
|
+
return ok({
|
|
125
|
+
accepted: true,
|
|
126
|
+
expiresAt,
|
|
127
|
+
remainingByBudget: balances.map(balance => ({
|
|
128
|
+
...balance,
|
|
129
|
+
remaining: balance.remaining - (unitsByDimension.get(balance.dimensionId) ?? 0),
|
|
130
|
+
})),
|
|
131
|
+
}, recovery);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
async grow(command) {
|
|
135
|
+
validateGrowCommand(command);
|
|
136
|
+
const now = Date.now();
|
|
137
|
+
return this.ctx.storage.transactionSync(() => {
|
|
138
|
+
const direct = this.recoverSpecific(command.reservationId, now, command.idempotencyTtlMs);
|
|
139
|
+
const recovery = reportWithDirect(direct);
|
|
140
|
+
if (direct)
|
|
141
|
+
return fail('not_found_or_expired', recovery);
|
|
142
|
+
const row = this.ctx.storage.sql
|
|
143
|
+
.exec(`SELECT state, expires_at, reserved_units, actual_units, outcome_hash,
|
|
144
|
+
terminal_reason, budget_ids_json
|
|
145
|
+
FROM reservations WHERE id = ?`, command.reservationId)
|
|
146
|
+
.toArray()[0];
|
|
147
|
+
if (!row || (row.state !== 'pending' && row.state !== 'liable')) {
|
|
148
|
+
return fail('not_found_or_expired', recovery);
|
|
149
|
+
}
|
|
150
|
+
if (this.readVectorRow(command.reservationId)) {
|
|
151
|
+
return fail('usage_mode_mismatch', recovery);
|
|
152
|
+
}
|
|
153
|
+
const growthRow = this.ctx.storage.sql
|
|
154
|
+
.exec(`SELECT growth_cursor, last_growth_json
|
|
155
|
+
FROM reservation_growth WHERE reservation_id = ?`, command.reservationId)
|
|
156
|
+
.toArray()[0];
|
|
157
|
+
if (!growthRow)
|
|
158
|
+
return fail('growth_not_supported', recovery);
|
|
159
|
+
const currentCursor = nonEmptyString(growthRow.growth_cursor, 'growth cursor');
|
|
160
|
+
const lastGrowth = parseStoredGrowth(growthRow.last_growth_json);
|
|
161
|
+
if (lastGrowth?.incrementHash === command.incrementHash) {
|
|
162
|
+
if (lastGrowth.expectedGrowthCursor !== command.expectedGrowthCursor ||
|
|
163
|
+
lastGrowth.fingerprint !== command.fingerprint) {
|
|
164
|
+
return fail('growth_conflict', recovery);
|
|
165
|
+
}
|
|
166
|
+
return ok(growthReplyFromStored(lastGrowth, true), recovery);
|
|
167
|
+
}
|
|
168
|
+
if (currentCursor !== command.expectedGrowthCursor) {
|
|
169
|
+
return fail('growth_stale_cursor', recovery);
|
|
170
|
+
}
|
|
171
|
+
const storedBudgetIds = parseBudgetIds(row.budget_ids_json);
|
|
172
|
+
const commandBudgetIds = command.budgets.map(budget => budget.id);
|
|
173
|
+
if (!sameStrings(storedBudgetIds, commandBudgetIds)) {
|
|
174
|
+
return fail('growth_budget_mismatch', recovery);
|
|
175
|
+
}
|
|
176
|
+
const balances = command.budgets.map(budget => {
|
|
177
|
+
const usedRow = this.ctx.storage.sql
|
|
178
|
+
.exec('SELECT used FROM budgets WHERE id = ?', budget.id)
|
|
179
|
+
.toArray()[0];
|
|
180
|
+
const used = usedRow ? integer(usedRow.used, 'used') : 0;
|
|
181
|
+
return { id: budget.id, remaining: Math.max(0, budget.limit - used) };
|
|
182
|
+
});
|
|
183
|
+
const limiting = balances.find(balance => command.additionalUnits > balance.remaining);
|
|
184
|
+
if (limiting) {
|
|
185
|
+
const stored = {
|
|
186
|
+
incrementHash: command.incrementHash,
|
|
187
|
+
expectedGrowthCursor: command.expectedGrowthCursor,
|
|
188
|
+
fingerprint: command.fingerprint,
|
|
189
|
+
nextGrowthCursor: command.nextGrowthCursor,
|
|
190
|
+
accepted: false,
|
|
191
|
+
limitingBudgetId: limiting.id,
|
|
192
|
+
remaining: limiting.remaining,
|
|
193
|
+
};
|
|
194
|
+
this.ctx.storage.sql.exec(`UPDATE reservation_growth
|
|
195
|
+
SET growth_cursor = ?, last_growth_json = ?
|
|
196
|
+
WHERE reservation_id = ?`, command.nextGrowthCursor, JSON.stringify(stored), command.reservationId);
|
|
197
|
+
return ok(growthReplyFromStored(stored, false), recovery);
|
|
198
|
+
}
|
|
199
|
+
const previousReservedUnits = integer(row.reserved_units, 'reservedUnits');
|
|
200
|
+
const reservedUnits = safeAdd(previousReservedUnits, command.additionalUnits, 'reservedUnits');
|
|
201
|
+
for (const budget of command.budgets) {
|
|
202
|
+
this.ctx.storage.sql.exec('UPDATE budgets SET used = used + ? WHERE id = ?', command.additionalUnits, budget.id);
|
|
203
|
+
}
|
|
204
|
+
this.ctx.storage.sql.exec(`UPDATE reservations SET reserved_units = ? WHERE id = ? AND state IN ('pending', 'liable')`, reservedUnits, command.reservationId);
|
|
205
|
+
const stored = {
|
|
206
|
+
incrementHash: command.incrementHash,
|
|
207
|
+
expectedGrowthCursor: command.expectedGrowthCursor,
|
|
208
|
+
fingerprint: command.fingerprint,
|
|
209
|
+
nextGrowthCursor: command.nextGrowthCursor,
|
|
210
|
+
accepted: true,
|
|
211
|
+
previousReservedUnits,
|
|
212
|
+
reservedUnits,
|
|
213
|
+
remainingByBudget: balances.map(balance => ({
|
|
214
|
+
id: balance.id,
|
|
215
|
+
remaining: balance.remaining - command.additionalUnits,
|
|
216
|
+
})),
|
|
217
|
+
};
|
|
218
|
+
this.ctx.storage.sql.exec(`UPDATE reservation_growth
|
|
219
|
+
SET growth_cursor = ?, last_growth_json = ?
|
|
220
|
+
WHERE reservation_id = ?`, command.nextGrowthCursor, JSON.stringify(stored), command.reservationId);
|
|
221
|
+
return ok(growthReplyFromStored(stored, false), recovery);
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
async growVector(command) {
|
|
225
|
+
validateVectorGrowCommand(command);
|
|
226
|
+
const now = Date.now();
|
|
227
|
+
return this.ctx.storage.transactionSync(() => {
|
|
228
|
+
const direct = this.recoverSpecific(command.reservationId, now, command.idempotencyTtlMs);
|
|
229
|
+
const recovery = reportWithDirect(direct);
|
|
230
|
+
if (direct)
|
|
231
|
+
return fail('not_found_or_expired', recovery);
|
|
232
|
+
const row = this.ctx.storage.sql
|
|
233
|
+
.exec(`SELECT state, expires_at, reserved_units, actual_units, outcome_hash,
|
|
234
|
+
terminal_reason, budget_ids_json
|
|
235
|
+
FROM reservations WHERE id = ?`, command.reservationId)
|
|
236
|
+
.toArray()[0];
|
|
237
|
+
if (!row || (row.state !== 'pending' && row.state !== 'liable')) {
|
|
238
|
+
return fail('not_found_or_expired', recovery);
|
|
239
|
+
}
|
|
240
|
+
const vectorRow = this.readVectorRow(command.reservationId);
|
|
241
|
+
if (!vectorRow)
|
|
242
|
+
return fail('usage_mode_mismatch', recovery);
|
|
243
|
+
const dimensions = parseStoredVectorDimensions(vectorRow.dimensions_json);
|
|
244
|
+
const growthRow = this.ctx.storage.sql
|
|
245
|
+
.exec(`SELECT growth_cursor, last_growth_json
|
|
246
|
+
FROM reservation_growth WHERE reservation_id = ?`, command.reservationId)
|
|
247
|
+
.toArray()[0];
|
|
248
|
+
if (!growthRow)
|
|
249
|
+
return fail('growth_not_supported', recovery);
|
|
250
|
+
const currentCursor = nonEmptyString(growthRow.growth_cursor, 'growth cursor');
|
|
251
|
+
const lastGrowth = parseStoredVectorGrowth(vectorRow.last_vector_growth_json);
|
|
252
|
+
if (lastGrowth?.incrementHash === command.incrementHash) {
|
|
253
|
+
if (lastGrowth.expectedGrowthCursor !== command.expectedGrowthCursor ||
|
|
254
|
+
lastGrowth.fingerprint !== command.fingerprint) {
|
|
255
|
+
return fail('growth_conflict', recovery);
|
|
256
|
+
}
|
|
257
|
+
return ok(vectorGrowthReplyFromStored(lastGrowth, true), recovery);
|
|
258
|
+
}
|
|
259
|
+
if (currentCursor !== command.expectedGrowthCursor) {
|
|
260
|
+
return fail('growth_stale_cursor', recovery);
|
|
261
|
+
}
|
|
262
|
+
if (!sameVectorGrowthTopology(dimensions, command.dimensions)) {
|
|
263
|
+
return fail('vector_dimension_mismatch', recovery);
|
|
264
|
+
}
|
|
265
|
+
const balances = [];
|
|
266
|
+
let limiting;
|
|
267
|
+
for (const dimension of command.dimensions) {
|
|
268
|
+
for (const budget of dimension.budgets) {
|
|
269
|
+
const usedRow = this.ctx.storage.sql
|
|
270
|
+
.exec('SELECT used FROM budgets WHERE id = ?', budget.id)
|
|
271
|
+
.toArray()[0];
|
|
272
|
+
const used = usedRow ? integer(usedRow.used, 'used') : 0;
|
|
273
|
+
const remaining = Math.max(0, budget.limit - used);
|
|
274
|
+
balances.push({ dimensionId: dimension.id, budgetId: budget.id, remaining });
|
|
275
|
+
if (!limiting && dimension.additionalUnits > remaining) {
|
|
276
|
+
limiting = { dimensionId: dimension.id, budgetId: budget.id, remaining };
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (limiting) {
|
|
281
|
+
const stored = {
|
|
282
|
+
incrementHash: command.incrementHash,
|
|
283
|
+
expectedGrowthCursor: command.expectedGrowthCursor,
|
|
284
|
+
fingerprint: command.fingerprint,
|
|
285
|
+
nextGrowthCursor: command.nextGrowthCursor,
|
|
286
|
+
accepted: false,
|
|
287
|
+
limitingDimensionId: limiting.dimensionId,
|
|
288
|
+
limitingBudgetId: limiting.budgetId,
|
|
289
|
+
remaining: limiting.remaining,
|
|
290
|
+
};
|
|
291
|
+
this.ctx.storage.sql.exec(`UPDATE reservation_growth SET growth_cursor = ? WHERE reservation_id = ?`, command.nextGrowthCursor, command.reservationId);
|
|
292
|
+
this.ctx.storage.sql.exec(`UPDATE reservation_vectors SET last_vector_growth_json = ? WHERE reservation_id = ?`, JSON.stringify(stored), command.reservationId);
|
|
293
|
+
return ok(vectorGrowthReplyFromStored(stored, false), recovery);
|
|
294
|
+
}
|
|
295
|
+
const previousReservedByDimension = dimensions.map(dimension => ({
|
|
296
|
+
id: dimension.id,
|
|
297
|
+
reservedUnits: dimension.reservedUnits,
|
|
298
|
+
}));
|
|
299
|
+
const commandById = new Map(command.dimensions.map(dimension => [dimension.id, dimension]));
|
|
300
|
+
const nextDimensions = dimensions.map(dimension => {
|
|
301
|
+
const growth = commandById.get(dimension.id);
|
|
302
|
+
for (const budget of growth.budgets) {
|
|
303
|
+
if (growth.additionalUnits > 0) {
|
|
304
|
+
this.ctx.storage.sql.exec('UPDATE budgets SET used = used + ? WHERE id = ?', growth.additionalUnits, budget.id);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return {
|
|
308
|
+
...dimension,
|
|
309
|
+
reservedUnits: safeAdd(dimension.reservedUnits, growth.additionalUnits, 'vector reservedUnits'),
|
|
310
|
+
};
|
|
311
|
+
});
|
|
312
|
+
const reservedByDimension = nextDimensions.map(dimension => ({
|
|
313
|
+
id: dimension.id,
|
|
314
|
+
reservedUnits: dimension.reservedUnits,
|
|
315
|
+
}));
|
|
316
|
+
const stored = {
|
|
317
|
+
incrementHash: command.incrementHash,
|
|
318
|
+
expectedGrowthCursor: command.expectedGrowthCursor,
|
|
319
|
+
fingerprint: command.fingerprint,
|
|
320
|
+
nextGrowthCursor: command.nextGrowthCursor,
|
|
321
|
+
accepted: true,
|
|
322
|
+
previousReservedByDimension,
|
|
323
|
+
reservedByDimension,
|
|
324
|
+
remainingByBudget: balances.map(balance => ({
|
|
325
|
+
...balance,
|
|
326
|
+
remaining: balance.remaining - (commandById.get(balance.dimensionId)?.additionalUnits ?? 0),
|
|
327
|
+
})),
|
|
328
|
+
};
|
|
329
|
+
this.ctx.storage.sql.exec(`UPDATE reservation_growth SET growth_cursor = ? WHERE reservation_id = ?`, command.nextGrowthCursor, command.reservationId);
|
|
330
|
+
this.ctx.storage.sql.exec(`UPDATE reservation_vectors
|
|
331
|
+
SET dimensions_json = ?, last_vector_growth_json = ?
|
|
332
|
+
WHERE reservation_id = ?`, JSON.stringify(nextDimensions), JSON.stringify(stored), command.reservationId);
|
|
333
|
+
return ok(vectorGrowthReplyFromStored(stored, false), recovery);
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
async markLiable(command) {
|
|
337
|
+
validateReservationId(command.reservationId);
|
|
338
|
+
assertPositiveInteger(command.idempotencyTtlMs, 'idempotencyTtlMs');
|
|
339
|
+
const now = Date.now();
|
|
340
|
+
return this.ctx.storage.transactionSync(() => {
|
|
341
|
+
const direct = this.recoverSpecific(command.reservationId, now, command.idempotencyTtlMs);
|
|
342
|
+
const recovery = reportWithDirect(direct);
|
|
343
|
+
if (direct)
|
|
344
|
+
return fail('not_found_or_expired', recovery);
|
|
345
|
+
const row = this.ctx.storage.sql
|
|
346
|
+
.exec(`SELECT state, expires_at, reserved_units, actual_units, outcome_hash,
|
|
347
|
+
terminal_reason, budget_ids_json
|
|
348
|
+
FROM reservations WHERE id = ?`, command.reservationId)
|
|
349
|
+
.toArray()[0];
|
|
350
|
+
if (!row || row.state === 'settled')
|
|
351
|
+
return fail('not_found_or_expired', recovery);
|
|
352
|
+
const expiresAt = integer(row.expires_at, 'expiresAt');
|
|
353
|
+
if (row.state === 'pending') {
|
|
354
|
+
this.ctx.storage.sql.exec(`UPDATE reservations SET state = 'liable' WHERE id = ? AND state = 'pending'`, command.reservationId);
|
|
355
|
+
}
|
|
356
|
+
else if (row.state !== 'liable') {
|
|
357
|
+
return fail('not_found_or_expired', recovery);
|
|
358
|
+
}
|
|
359
|
+
return ok({ expiresAt }, recovery);
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
async renew(command) {
|
|
363
|
+
validateReservationId(command.reservationId);
|
|
364
|
+
assertPositiveInteger(command.ttlMs, 'ttlMs');
|
|
365
|
+
assertPositiveInteger(command.idempotencyTtlMs, 'idempotencyTtlMs');
|
|
366
|
+
const now = Date.now();
|
|
367
|
+
return this.ctx.storage.transactionSync(() => {
|
|
368
|
+
const direct = this.recoverSpecific(command.reservationId, now, command.idempotencyTtlMs);
|
|
369
|
+
const recovery = reportWithDirect(direct);
|
|
370
|
+
if (direct)
|
|
371
|
+
return fail('not_found_or_expired', recovery);
|
|
372
|
+
const row = this.ctx.storage.sql
|
|
373
|
+
.exec(`SELECT state, expires_at, reserved_units, actual_units, outcome_hash,
|
|
374
|
+
terminal_reason, budget_ids_json
|
|
375
|
+
FROM reservations WHERE id = ?`, command.reservationId)
|
|
376
|
+
.toArray()[0];
|
|
377
|
+
if (!row || (row.state !== 'pending' && row.state !== 'liable')) {
|
|
378
|
+
return fail('not_found_or_expired', recovery);
|
|
379
|
+
}
|
|
380
|
+
const expiresAt = safeAdd(now, command.ttlMs, 'expiresAt');
|
|
381
|
+
this.ctx.storage.sql.exec(`UPDATE reservations SET expires_at = ? WHERE id = ? AND state IN ('pending', 'liable')`, expiresAt, command.reservationId);
|
|
382
|
+
return ok({ expiresAt }, recovery);
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
async settle(command) {
|
|
386
|
+
validateReservationId(command.reservationId);
|
|
387
|
+
assertNonNegativeInteger(command.actualUnits, 'actualUnits');
|
|
388
|
+
validateHash(command.outcomeHash, 'outcomeHash');
|
|
389
|
+
assertPositiveInteger(command.idempotencyTtlMs, 'idempotencyTtlMs');
|
|
390
|
+
const now = Date.now();
|
|
391
|
+
return this.ctx.storage.transactionSync(() => {
|
|
392
|
+
const direct = this.recoverSpecific(command.reservationId, now, command.idempotencyTtlMs);
|
|
393
|
+
const recovery = reportWithDirect(direct);
|
|
394
|
+
if (direct)
|
|
395
|
+
return fail('not_found_or_expired', recovery);
|
|
396
|
+
const row = this.ctx.storage.sql
|
|
397
|
+
.exec(`SELECT state, expires_at, reserved_units, actual_units, outcome_hash,
|
|
398
|
+
terminal_reason, budget_ids_json
|
|
399
|
+
FROM reservations WHERE id = ?`, command.reservationId)
|
|
400
|
+
.toArray()[0];
|
|
401
|
+
if (!row)
|
|
402
|
+
return fail('not_found_or_expired', recovery);
|
|
403
|
+
if (this.readVectorRow(command.reservationId)) {
|
|
404
|
+
return fail('usage_mode_mismatch', recovery);
|
|
405
|
+
}
|
|
406
|
+
const reservedUnits = integer(row.reserved_units, 'reservedUnits');
|
|
407
|
+
if (row.state === 'settled') {
|
|
408
|
+
if (row.terminal_reason === LEASE_EXPIRED_REASON) {
|
|
409
|
+
return fail('not_found_or_expired', recovery);
|
|
410
|
+
}
|
|
411
|
+
const actualUnits = nullableInteger(row.actual_units, 'actualUnits');
|
|
412
|
+
if (actualUnits === command.actualUnits && row.outcome_hash === command.outcomeHash) {
|
|
413
|
+
return ok({
|
|
414
|
+
reservedUnits,
|
|
415
|
+
actualUnits,
|
|
416
|
+
releasedUnits: reservedUnits - actualUnits,
|
|
417
|
+
replayed: true,
|
|
418
|
+
}, recovery);
|
|
419
|
+
}
|
|
420
|
+
return fail('settlement_conflict', recovery);
|
|
421
|
+
}
|
|
422
|
+
if (row.state !== 'pending' && row.state !== 'liable') {
|
|
423
|
+
return fail('not_found_or_expired', recovery);
|
|
424
|
+
}
|
|
425
|
+
if (command.actualUnits > reservedUnits) {
|
|
426
|
+
return fail('actual_units_exceed_reserved', recovery);
|
|
427
|
+
}
|
|
428
|
+
const releasedUnits = reservedUnits - command.actualUnits;
|
|
429
|
+
if (releasedUnits > 0) {
|
|
430
|
+
for (const budgetId of parseBudgetIds(row.budget_ids_json)) {
|
|
431
|
+
this.ctx.storage.sql.exec('UPDATE budgets SET used = MAX(0, used - ?) WHERE id = ?', releasedUnits, budgetId);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
const tombstoneExpiresAt = safeAdd(now, command.idempotencyTtlMs, 'tombstoneExpiresAt');
|
|
435
|
+
this.ctx.storage.sql.exec(`UPDATE reservations
|
|
436
|
+
SET state = 'settled', actual_units = ?, outcome_hash = ?, terminal_reason = NULL,
|
|
437
|
+
tombstone_expires_at = ?
|
|
438
|
+
WHERE id = ?`, command.actualUnits, command.outcomeHash, tombstoneExpiresAt, command.reservationId);
|
|
439
|
+
return ok({
|
|
440
|
+
reservedUnits,
|
|
441
|
+
actualUnits: command.actualUnits,
|
|
442
|
+
releasedUnits,
|
|
443
|
+
replayed: false,
|
|
444
|
+
}, recovery);
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
async settleVector(command) {
|
|
448
|
+
validateVectorSettleCommand(command);
|
|
449
|
+
const now = Date.now();
|
|
450
|
+
return this.ctx.storage.transactionSync(() => {
|
|
451
|
+
const direct = this.recoverSpecific(command.reservationId, now, command.idempotencyTtlMs);
|
|
452
|
+
const recovery = reportWithDirect(direct);
|
|
453
|
+
if (direct)
|
|
454
|
+
return fail('not_found_or_expired', recovery);
|
|
455
|
+
const row = this.ctx.storage.sql
|
|
456
|
+
.exec(`SELECT state, expires_at, reserved_units, actual_units, outcome_hash,
|
|
457
|
+
terminal_reason, budget_ids_json
|
|
458
|
+
FROM reservations WHERE id = ?`, command.reservationId)
|
|
459
|
+
.toArray()[0];
|
|
460
|
+
if (!row)
|
|
461
|
+
return fail('not_found_or_expired', recovery);
|
|
462
|
+
const vectorRow = this.readVectorRow(command.reservationId);
|
|
463
|
+
if (!vectorRow)
|
|
464
|
+
return fail('usage_mode_mismatch', recovery);
|
|
465
|
+
const dimensions = parseStoredVectorDimensions(vectorRow.dimensions_json);
|
|
466
|
+
if (!sameVectorActualTopology(dimensions, command.actualByDimension)) {
|
|
467
|
+
return fail('vector_dimension_mismatch', recovery);
|
|
468
|
+
}
|
|
469
|
+
const settlementReply = (replayed) => ({
|
|
470
|
+
dimensions: dimensions.map((dimension, index) => {
|
|
471
|
+
const actualUnits = command.actualByDimension[index].actualUnits;
|
|
472
|
+
return {
|
|
473
|
+
id: dimension.id,
|
|
474
|
+
reservedUnits: dimension.reservedUnits,
|
|
475
|
+
actualUnits,
|
|
476
|
+
releasedUnits: dimension.reservedUnits - actualUnits,
|
|
477
|
+
};
|
|
478
|
+
}),
|
|
479
|
+
replayed,
|
|
480
|
+
});
|
|
481
|
+
if (row.state === 'settled') {
|
|
482
|
+
if (row.terminal_reason === LEASE_EXPIRED_REASON) {
|
|
483
|
+
return fail('not_found_or_expired', recovery);
|
|
484
|
+
}
|
|
485
|
+
const storedActual = parseStoredVectorActuals(vectorRow.actual_dimensions_json);
|
|
486
|
+
if (row.outcome_hash === command.outcomeHash &&
|
|
487
|
+
sameVectorActuals(storedActual, command.actualByDimension)) {
|
|
488
|
+
return ok(settlementReply(true), recovery);
|
|
489
|
+
}
|
|
490
|
+
return fail('settlement_conflict', recovery);
|
|
491
|
+
}
|
|
492
|
+
if (row.state !== 'pending' && row.state !== 'liable') {
|
|
493
|
+
return fail('not_found_or_expired', recovery);
|
|
494
|
+
}
|
|
495
|
+
for (let index = 0; index < dimensions.length; index += 1) {
|
|
496
|
+
if (command.actualByDimension[index].actualUnits > dimensions[index].reservedUnits) {
|
|
497
|
+
return fail('actual_units_exceed_reserved', recovery);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
for (let index = 0; index < dimensions.length; index += 1) {
|
|
501
|
+
const dimension = dimensions[index];
|
|
502
|
+
const actual = command.actualByDimension[index];
|
|
503
|
+
const released = dimension.reservedUnits - actual.actualUnits;
|
|
504
|
+
if (released > 0) {
|
|
505
|
+
for (const budgetId of dimension.budgetIds) {
|
|
506
|
+
this.ctx.storage.sql.exec('UPDATE budgets SET used = MAX(0, used - ?) WHERE id = ?', released, budgetId);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
const tombstoneExpiresAt = safeAdd(now, command.idempotencyTtlMs, 'tombstoneExpiresAt');
|
|
511
|
+
this.ctx.storage.sql.exec(`UPDATE reservations
|
|
512
|
+
SET state = 'settled', actual_units = NULL, outcome_hash = ?, terminal_reason = NULL,
|
|
513
|
+
tombstone_expires_at = ?
|
|
514
|
+
WHERE id = ?`, command.outcomeHash, tombstoneExpiresAt, command.reservationId);
|
|
515
|
+
this.ctx.storage.sql.exec(`UPDATE reservation_vectors SET actual_dimensions_json = ? WHERE reservation_id = ?`, JSON.stringify(command.actualByDimension), command.reservationId);
|
|
516
|
+
return ok(settlementReply(false), recovery);
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
ensureSchema() {
|
|
520
|
+
this.ctx.storage.sql.exec(`
|
|
521
|
+
CREATE TABLE IF NOT EXISTS budgets (
|
|
522
|
+
id TEXT PRIMARY KEY,
|
|
523
|
+
used INTEGER NOT NULL CHECK (used >= 0)
|
|
524
|
+
);
|
|
525
|
+
`);
|
|
526
|
+
this.ctx.storage.sql.exec(`
|
|
527
|
+
CREATE TABLE IF NOT EXISTS reservations (
|
|
528
|
+
id TEXT PRIMARY KEY,
|
|
529
|
+
state TEXT NOT NULL CHECK (state IN ('pending', 'liable', 'settled')),
|
|
530
|
+
reserved_units INTEGER NOT NULL CHECK (reserved_units >= 0),
|
|
531
|
+
expires_at INTEGER NOT NULL,
|
|
532
|
+
actual_units INTEGER,
|
|
533
|
+
outcome_hash TEXT,
|
|
534
|
+
terminal_reason TEXT,
|
|
535
|
+
tombstone_expires_at INTEGER,
|
|
536
|
+
budget_ids_json TEXT NOT NULL
|
|
537
|
+
);
|
|
538
|
+
`);
|
|
539
|
+
this.ctx.storage.sql.exec(`
|
|
540
|
+
CREATE TABLE IF NOT EXISTS reservation_growth (
|
|
541
|
+
reservation_id TEXT PRIMARY KEY,
|
|
542
|
+
growth_cursor TEXT NOT NULL,
|
|
543
|
+
last_growth_json TEXT
|
|
544
|
+
);
|
|
545
|
+
`);
|
|
546
|
+
this.ctx.storage.sql.exec(`
|
|
547
|
+
CREATE TABLE IF NOT EXISTS reservation_vectors (
|
|
548
|
+
reservation_id TEXT PRIMARY KEY,
|
|
549
|
+
dimensions_json TEXT NOT NULL,
|
|
550
|
+
actual_dimensions_json TEXT,
|
|
551
|
+
last_vector_growth_json TEXT
|
|
552
|
+
);
|
|
553
|
+
`);
|
|
554
|
+
this.ctx.storage.sql.exec(`CREATE INDEX IF NOT EXISTS reservations_active_expiry
|
|
555
|
+
ON reservations(state, expires_at)`);
|
|
556
|
+
this.ctx.storage.sql.exec(`CREATE INDEX IF NOT EXISTS reservations_tombstone_expiry
|
|
557
|
+
ON reservations(state, tombstone_expires_at)`);
|
|
558
|
+
}
|
|
559
|
+
readVectorRow(reservationId) {
|
|
560
|
+
return this.ctx.storage.sql
|
|
561
|
+
.exec(`SELECT dimensions_json, actual_dimensions_json, last_vector_growth_json
|
|
562
|
+
FROM reservation_vectors WHERE reservation_id = ?`, reservationId)
|
|
563
|
+
.toArray()[0];
|
|
564
|
+
}
|
|
565
|
+
recoverExpiredBatch(now, cleanupBatchSize, idempotencyTtlMs) {
|
|
566
|
+
const aggregate = emptySummary();
|
|
567
|
+
const rows = this.ctx.storage.sql
|
|
568
|
+
.exec(`SELECT id, state, reserved_units, expires_at, actual_units, outcome_hash,
|
|
569
|
+
terminal_reason, tombstone_expires_at, budget_ids_json
|
|
570
|
+
FROM reservations
|
|
571
|
+
WHERE state IN ('pending', 'liable') AND expires_at <= ?
|
|
572
|
+
ORDER BY expires_at, id
|
|
573
|
+
LIMIT ?`, now, cleanupBatchSize)
|
|
574
|
+
.toArray();
|
|
575
|
+
for (const row of rows) {
|
|
576
|
+
const recovered = this.recoverRow(row, now, idempotencyTtlMs);
|
|
577
|
+
if ('vector' in recovered) {
|
|
578
|
+
if (recovered.state === 'pending') {
|
|
579
|
+
aggregate.vectorPendingCount = safeAdd(aggregate.vectorPendingCount ?? 0, 1, 'vectorPendingCount');
|
|
580
|
+
}
|
|
581
|
+
else {
|
|
582
|
+
aggregate.vectorLiableCount = safeAdd(aggregate.vectorLiableCount ?? 0, 1, 'vectorLiableCount');
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
else if (recovered.state === 'pending') {
|
|
586
|
+
aggregate.pendingCount = safeAdd(aggregate.pendingCount, 1, 'pendingCount');
|
|
587
|
+
aggregate.pendingUnits = safeAdd(aggregate.pendingUnits, recovered.reservedUnits, 'pendingUnits');
|
|
588
|
+
}
|
|
589
|
+
else {
|
|
590
|
+
aggregate.liableCount = safeAdd(aggregate.liableCount, 1, 'liableCount');
|
|
591
|
+
aggregate.liableUnits = safeAdd(aggregate.liableUnits, recovered.reservedUnits, 'liableUnits');
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
this.ctx.storage.sql.exec(`DELETE FROM reservation_vectors
|
|
595
|
+
WHERE reservation_id IN (
|
|
596
|
+
SELECT id FROM reservations
|
|
597
|
+
WHERE state = 'settled' AND tombstone_expires_at IS NOT NULL
|
|
598
|
+
AND tombstone_expires_at <= ?
|
|
599
|
+
ORDER BY tombstone_expires_at, id
|
|
600
|
+
LIMIT ?
|
|
601
|
+
)`, now, cleanupBatchSize);
|
|
602
|
+
this.ctx.storage.sql.exec(`DELETE FROM reservation_growth
|
|
603
|
+
WHERE reservation_id IN (
|
|
604
|
+
SELECT id FROM reservations
|
|
605
|
+
WHERE state = 'settled' AND tombstone_expires_at IS NOT NULL
|
|
606
|
+
AND tombstone_expires_at <= ?
|
|
607
|
+
ORDER BY tombstone_expires_at, id
|
|
608
|
+
LIMIT ?
|
|
609
|
+
)`, now, cleanupBatchSize);
|
|
610
|
+
this.ctx.storage.sql.exec(`DELETE FROM reservations
|
|
611
|
+
WHERE id IN (
|
|
612
|
+
SELECT id FROM reservations
|
|
613
|
+
WHERE state = 'settled' AND tombstone_expires_at IS NOT NULL
|
|
614
|
+
AND tombstone_expires_at <= ?
|
|
615
|
+
ORDER BY tombstone_expires_at, id
|
|
616
|
+
LIMIT ?
|
|
617
|
+
)`, now, cleanupBatchSize);
|
|
618
|
+
return { aggregate };
|
|
619
|
+
}
|
|
620
|
+
recoverSpecific(reservationId, now, idempotencyTtlMs) {
|
|
621
|
+
const row = this.ctx.storage.sql
|
|
622
|
+
.exec(`SELECT id, state, reserved_units, expires_at, actual_units, outcome_hash,
|
|
623
|
+
terminal_reason, tombstone_expires_at, budget_ids_json
|
|
624
|
+
FROM reservations
|
|
625
|
+
WHERE id = ? AND state IN ('pending', 'liable')`, reservationId)
|
|
626
|
+
.toArray()[0];
|
|
627
|
+
if (!row || integer(row.expires_at, 'expiresAt') > now)
|
|
628
|
+
return undefined;
|
|
629
|
+
return this.recoverRow(row, now, idempotencyTtlMs);
|
|
630
|
+
}
|
|
631
|
+
recoverRow(row, now, idempotencyTtlMs) {
|
|
632
|
+
const reservationId = String(row.id);
|
|
633
|
+
validateReservationId(reservationId);
|
|
634
|
+
const vectorRow = this.readVectorRow(reservationId);
|
|
635
|
+
if (vectorRow) {
|
|
636
|
+
const dimensions = parseStoredVectorDimensions(vectorRow.dimensions_json);
|
|
637
|
+
const dimensionCount = dimensions.length;
|
|
638
|
+
const budgetCount = dimensions.reduce((count, dimension) => count + dimension.budgetIds.length, 0);
|
|
639
|
+
if (row.state === 'pending') {
|
|
640
|
+
for (const dimension of dimensions) {
|
|
641
|
+
for (const budgetId of dimension.budgetIds) {
|
|
642
|
+
this.ctx.storage.sql.exec('UPDATE budgets SET used = MAX(0, used - ?) WHERE id = ?', dimension.reservedUnits, budgetId);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
this.ctx.storage.sql.exec('DELETE FROM reservation_growth WHERE reservation_id = ?', reservationId);
|
|
646
|
+
this.ctx.storage.sql.exec('DELETE FROM reservation_vectors WHERE reservation_id = ?', reservationId);
|
|
647
|
+
this.ctx.storage.sql.exec('DELETE FROM reservations WHERE id = ?', reservationId);
|
|
648
|
+
return {
|
|
649
|
+
reservationId,
|
|
650
|
+
state: 'pending',
|
|
651
|
+
vector: true,
|
|
652
|
+
dimensionCount,
|
|
653
|
+
budgetCount,
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
if (row.state !== 'liable')
|
|
657
|
+
throw new Error('Invalid active vector reservation state');
|
|
658
|
+
const tombstoneExpiresAt = safeAdd(now, idempotencyTtlMs, 'tombstoneExpiresAt');
|
|
659
|
+
const actuals = dimensions.map(dimension => ({
|
|
660
|
+
id: dimension.id,
|
|
661
|
+
actualUnits: dimension.reservedUnits,
|
|
662
|
+
}));
|
|
663
|
+
this.ctx.storage.sql.exec(`UPDATE reservations
|
|
664
|
+
SET state = 'settled', actual_units = NULL, outcome_hash = NULL,
|
|
665
|
+
terminal_reason = ?, tombstone_expires_at = ?
|
|
666
|
+
WHERE id = ?`, LEASE_EXPIRED_REASON, tombstoneExpiresAt, reservationId);
|
|
667
|
+
this.ctx.storage.sql.exec('UPDATE reservation_vectors SET actual_dimensions_json = ? WHERE reservation_id = ?', JSON.stringify(actuals), reservationId);
|
|
668
|
+
return {
|
|
669
|
+
reservationId,
|
|
670
|
+
state: 'liable',
|
|
671
|
+
vector: true,
|
|
672
|
+
dimensionCount,
|
|
673
|
+
budgetCount,
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
const reservedUnits = integer(row.reserved_units, 'reservedUnits');
|
|
677
|
+
const budgetIds = parseBudgetIds(row.budget_ids_json);
|
|
678
|
+
if (row.state === 'pending') {
|
|
679
|
+
for (const budgetId of budgetIds) {
|
|
680
|
+
this.ctx.storage.sql.exec('UPDATE budgets SET used = MAX(0, used - ?) WHERE id = ?', reservedUnits, budgetId);
|
|
681
|
+
}
|
|
682
|
+
this.ctx.storage.sql.exec('DELETE FROM reservation_growth WHERE reservation_id = ?', reservationId);
|
|
683
|
+
this.ctx.storage.sql.exec('DELETE FROM reservations WHERE id = ?', reservationId);
|
|
684
|
+
return { reservationId, state: 'pending', reservedUnits };
|
|
685
|
+
}
|
|
686
|
+
if (row.state !== 'liable')
|
|
687
|
+
throw new Error('Invalid active reservation state');
|
|
688
|
+
const tombstoneExpiresAt = safeAdd(now, idempotencyTtlMs, 'tombstoneExpiresAt');
|
|
689
|
+
this.ctx.storage.sql.exec(`UPDATE reservations
|
|
690
|
+
SET state = 'settled', actual_units = reserved_units, outcome_hash = NULL,
|
|
691
|
+
terminal_reason = ?, tombstone_expires_at = ?
|
|
692
|
+
WHERE id = ?`, LEASE_EXPIRED_REASON, tombstoneExpiresAt, reservationId);
|
|
693
|
+
return { reservationId, state: 'liable', reservedUnits };
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
function validateVectorReserveCommand(command) {
|
|
697
|
+
validateReservationId(command.reservationId);
|
|
698
|
+
assertPositiveInteger(command.ttlMs, 'ttlMs');
|
|
699
|
+
assertPositiveInteger(command.cleanupBatchSize, 'cleanupBatchSize');
|
|
700
|
+
assertPositiveInteger(command.idempotencyTtlMs, 'idempotencyTtlMs');
|
|
701
|
+
nonEmptyString(command.initialGrowthCursor, 'initialGrowthCursor');
|
|
702
|
+
validateHashedDimensions(command.dimensions, 'reserve');
|
|
703
|
+
}
|
|
704
|
+
function validateVectorGrowCommand(command) {
|
|
705
|
+
validateReservationId(command.reservationId);
|
|
706
|
+
validateHash(command.incrementHash, 'incrementHash');
|
|
707
|
+
nonEmptyString(command.expectedGrowthCursor, 'expectedGrowthCursor');
|
|
708
|
+
validateHash(command.fingerprint, 'fingerprint');
|
|
709
|
+
nonEmptyString(command.nextGrowthCursor, 'nextGrowthCursor');
|
|
710
|
+
assertPositiveInteger(command.idempotencyTtlMs, 'idempotencyTtlMs');
|
|
711
|
+
validateHashedDimensions(command.dimensions, 'growth');
|
|
712
|
+
if (!command.dimensions.some(dimension => dimension.additionalUnits > 0)) {
|
|
713
|
+
throw new RangeError('vector growth must add units to at least one dimension');
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
function validateVectorSettleCommand(command) {
|
|
717
|
+
validateReservationId(command.reservationId);
|
|
718
|
+
validateHash(command.outcomeHash, 'outcomeHash');
|
|
719
|
+
assertPositiveInteger(command.idempotencyTtlMs, 'idempotencyTtlMs');
|
|
720
|
+
if (!Array.isArray(command.actualByDimension) || command.actualByDimension.length === 0) {
|
|
721
|
+
throw new RangeError('actualByDimension must contain at least one dimension');
|
|
722
|
+
}
|
|
723
|
+
const seen = new Set();
|
|
724
|
+
for (const dimension of command.actualByDimension) {
|
|
725
|
+
validateHash(dimension.id, 'dimension.id');
|
|
726
|
+
assertNonNegativeInteger(dimension.actualUnits, 'actualUnits');
|
|
727
|
+
if (seen.has(dimension.id))
|
|
728
|
+
throw new RangeError('duplicate dimension id');
|
|
729
|
+
seen.add(dimension.id);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
function validateHashedDimensions(dimensions, mode) {
|
|
733
|
+
if (!Array.isArray(dimensions) || dimensions.length === 0) {
|
|
734
|
+
throw new RangeError('dimensions must contain at least one dimension');
|
|
735
|
+
}
|
|
736
|
+
const dimensionIds = new Set();
|
|
737
|
+
const budgetIds = new Set();
|
|
738
|
+
for (const dimension of dimensions) {
|
|
739
|
+
validateHash(dimension.id, 'dimension.id');
|
|
740
|
+
if (dimensionIds.has(dimension.id))
|
|
741
|
+
throw new RangeError('duplicate dimension id');
|
|
742
|
+
dimensionIds.add(dimension.id);
|
|
743
|
+
const units = mode === 'reserve' ? ('units' in dimension ? dimension.units : -1) : ('additionalUnits' in dimension ? dimension.additionalUnits : -1);
|
|
744
|
+
assertNonNegativeInteger(units, mode === 'reserve' ? 'dimension.units' : 'dimension.additionalUnits');
|
|
745
|
+
if (!Array.isArray(dimension.budgets) || dimension.budgets.length === 0) {
|
|
746
|
+
throw new RangeError('dimension budgets must contain at least one budget');
|
|
747
|
+
}
|
|
748
|
+
for (const budget of dimension.budgets) {
|
|
749
|
+
validateHash(budget.id, 'budget.id');
|
|
750
|
+
assertNonNegativeInteger(budget.limit, 'budget.limit');
|
|
751
|
+
if (budgetIds.has(budget.id))
|
|
752
|
+
throw new RangeError('budget id cannot appear in multiple vector dimensions');
|
|
753
|
+
budgetIds.add(budget.id);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
function parseStoredVectorDimensions(raw) {
|
|
758
|
+
if (typeof raw !== 'string')
|
|
759
|
+
throw new Error('Invalid stored vector dimensions');
|
|
760
|
+
let value;
|
|
761
|
+
try {
|
|
762
|
+
value = JSON.parse(raw);
|
|
763
|
+
}
|
|
764
|
+
catch {
|
|
765
|
+
throw new Error('Invalid stored vector dimensions');
|
|
766
|
+
}
|
|
767
|
+
if (!Array.isArray(value) || value.length === 0)
|
|
768
|
+
throw new Error('Invalid stored vector dimensions');
|
|
769
|
+
const dimensionIds = new Set();
|
|
770
|
+
const budgetIds = new Set();
|
|
771
|
+
return value.map(item => {
|
|
772
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
773
|
+
throw new Error('Invalid stored vector dimension');
|
|
774
|
+
}
|
|
775
|
+
const data = item;
|
|
776
|
+
if (typeof data.id !== 'string' || !HASH_PATTERN.test(data.id)) {
|
|
777
|
+
throw new Error('Invalid stored vector dimension id');
|
|
778
|
+
}
|
|
779
|
+
if (dimensionIds.has(data.id))
|
|
780
|
+
throw new Error('Duplicate stored vector dimension id');
|
|
781
|
+
dimensionIds.add(data.id);
|
|
782
|
+
if (!Array.isArray(data.budgetIds) ||
|
|
783
|
+
data.budgetIds.length === 0 ||
|
|
784
|
+
!data.budgetIds.every(id => typeof id === 'string' && HASH_PATTERN.test(id))) {
|
|
785
|
+
throw new Error('Invalid stored vector budget ids');
|
|
786
|
+
}
|
|
787
|
+
for (const id of data.budgetIds) {
|
|
788
|
+
if (budgetIds.has(id))
|
|
789
|
+
throw new Error('Stored budget appeared in multiple vector dimensions');
|
|
790
|
+
budgetIds.add(id);
|
|
791
|
+
}
|
|
792
|
+
return {
|
|
793
|
+
id: data.id,
|
|
794
|
+
reservedUnits: storedNonNegativeInteger(data.reservedUnits, 'vector reservedUnits'),
|
|
795
|
+
budgetIds: [...data.budgetIds],
|
|
796
|
+
};
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
function parseStoredVectorActuals(raw) {
|
|
800
|
+
if (raw === null)
|
|
801
|
+
return undefined;
|
|
802
|
+
if (typeof raw !== 'string')
|
|
803
|
+
throw new Error('Invalid stored vector actuals');
|
|
804
|
+
let value;
|
|
805
|
+
try {
|
|
806
|
+
value = JSON.parse(raw);
|
|
807
|
+
}
|
|
808
|
+
catch {
|
|
809
|
+
throw new Error('Invalid stored vector actuals');
|
|
810
|
+
}
|
|
811
|
+
if (!Array.isArray(value) || value.length === 0)
|
|
812
|
+
throw new Error('Invalid stored vector actuals');
|
|
813
|
+
return value.map(item => {
|
|
814
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
815
|
+
throw new Error('Invalid stored vector actual');
|
|
816
|
+
}
|
|
817
|
+
const data = item;
|
|
818
|
+
if (typeof data.id !== 'string' || !HASH_PATTERN.test(data.id)) {
|
|
819
|
+
throw new Error('Invalid stored vector actual dimension id');
|
|
820
|
+
}
|
|
821
|
+
return { id: data.id, actualUnits: storedNonNegativeInteger(data.actualUnits, 'vector actualUnits') };
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
function parseStoredVectorGrowth(raw) {
|
|
825
|
+
if (raw === null)
|
|
826
|
+
return undefined;
|
|
827
|
+
if (typeof raw !== 'string')
|
|
828
|
+
throw new Error('Invalid stored vector growth replay');
|
|
829
|
+
let value;
|
|
830
|
+
try {
|
|
831
|
+
value = JSON.parse(raw);
|
|
832
|
+
}
|
|
833
|
+
catch {
|
|
834
|
+
throw new Error('Invalid stored vector growth replay');
|
|
835
|
+
}
|
|
836
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
837
|
+
throw new Error('Invalid stored vector growth replay');
|
|
838
|
+
}
|
|
839
|
+
const data = value;
|
|
840
|
+
if (typeof data.incrementHash !== 'string' || !HASH_PATTERN.test(data.incrementHash)) {
|
|
841
|
+
throw new Error('Invalid stored vector growth increment');
|
|
842
|
+
}
|
|
843
|
+
if (typeof data.fingerprint !== 'string' || !HASH_PATTERN.test(data.fingerprint)) {
|
|
844
|
+
throw new Error('Invalid stored vector growth fingerprint');
|
|
845
|
+
}
|
|
846
|
+
const result = {
|
|
847
|
+
incrementHash: data.incrementHash,
|
|
848
|
+
expectedGrowthCursor: nonEmptyString(data.expectedGrowthCursor, 'vector growth prior cursor'),
|
|
849
|
+
fingerprint: data.fingerprint,
|
|
850
|
+
nextGrowthCursor: nonEmptyString(data.nextGrowthCursor, 'vector growth next cursor'),
|
|
851
|
+
accepted: data.accepted === true,
|
|
852
|
+
};
|
|
853
|
+
if (typeof data.accepted !== 'boolean')
|
|
854
|
+
throw new Error('Invalid stored vector growth accepted state');
|
|
855
|
+
if (data.accepted) {
|
|
856
|
+
result.previousReservedByDimension = parseStoredReservedVector(data.previousReservedByDimension);
|
|
857
|
+
result.reservedByDimension = parseStoredReservedVector(data.reservedByDimension);
|
|
858
|
+
if (!Array.isArray(data.remainingByBudget))
|
|
859
|
+
throw new Error('Invalid stored vector growth balances');
|
|
860
|
+
result.remainingByBudget = data.remainingByBudget.map(item => parseStoredVectorBalance(item));
|
|
861
|
+
}
|
|
862
|
+
else {
|
|
863
|
+
if (typeof data.limitingDimensionId !== 'string' || !HASH_PATTERN.test(data.limitingDimensionId)) {
|
|
864
|
+
throw new Error('Invalid stored vector growth limiting dimension');
|
|
865
|
+
}
|
|
866
|
+
if (typeof data.limitingBudgetId !== 'string' || !HASH_PATTERN.test(data.limitingBudgetId)) {
|
|
867
|
+
throw new Error('Invalid stored vector growth limiting budget');
|
|
868
|
+
}
|
|
869
|
+
result.limitingDimensionId = data.limitingDimensionId;
|
|
870
|
+
result.limitingBudgetId = data.limitingBudgetId;
|
|
871
|
+
result.remaining = storedNonNegativeInteger(data.remaining, 'vector growth remaining');
|
|
872
|
+
}
|
|
873
|
+
return result;
|
|
874
|
+
}
|
|
875
|
+
function parseStoredReservedVector(value) {
|
|
876
|
+
if (!Array.isArray(value) || value.length === 0)
|
|
877
|
+
throw new Error('Invalid stored vector reserved values');
|
|
878
|
+
return value.map(item => {
|
|
879
|
+
if (!item || typeof item !== 'object' || Array.isArray(item))
|
|
880
|
+
throw new Error('Invalid stored vector reserved value');
|
|
881
|
+
const data = item;
|
|
882
|
+
if (typeof data.id !== 'string' || !HASH_PATTERN.test(data.id))
|
|
883
|
+
throw new Error('Invalid stored vector dimension id');
|
|
884
|
+
return { id: data.id, reservedUnits: storedNonNegativeInteger(data.reservedUnits, 'vector reservedUnits') };
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
function parseStoredVectorBalance(value) {
|
|
888
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
889
|
+
throw new Error('Invalid stored vector balance');
|
|
890
|
+
const data = value;
|
|
891
|
+
if (typeof data.dimensionId !== 'string' || !HASH_PATTERN.test(data.dimensionId))
|
|
892
|
+
throw new Error('Invalid stored vector balance dimension');
|
|
893
|
+
if (typeof data.budgetId !== 'string' || !HASH_PATTERN.test(data.budgetId))
|
|
894
|
+
throw new Error('Invalid stored vector balance budget');
|
|
895
|
+
return {
|
|
896
|
+
dimensionId: data.dimensionId,
|
|
897
|
+
budgetId: data.budgetId,
|
|
898
|
+
remaining: storedNonNegativeInteger(data.remaining, 'vector balance remaining'),
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
function vectorGrowthReplyFromStored(growth, replayed) {
|
|
902
|
+
if (growth.accepted) {
|
|
903
|
+
if (!growth.previousReservedByDimension || !growth.reservedByDimension || !growth.remainingByBudget) {
|
|
904
|
+
throw new Error('Incomplete stored accepted vector growth replay');
|
|
905
|
+
}
|
|
906
|
+
return {
|
|
907
|
+
accepted: true,
|
|
908
|
+
replayed,
|
|
909
|
+
previousReservedByDimension: growth.previousReservedByDimension.map(item => ({ ...item })),
|
|
910
|
+
reservedByDimension: growth.reservedByDimension.map(item => ({ ...item })),
|
|
911
|
+
growthCursor: growth.nextGrowthCursor,
|
|
912
|
+
remainingByBudget: growth.remainingByBudget.map(item => ({ ...item })),
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
if (!growth.limitingDimensionId || !growth.limitingBudgetId || growth.remaining === undefined) {
|
|
916
|
+
throw new Error('Incomplete stored denied vector growth replay');
|
|
917
|
+
}
|
|
918
|
+
return {
|
|
919
|
+
accepted: false,
|
|
920
|
+
reason: 'quota_exceeded',
|
|
921
|
+
replayed,
|
|
922
|
+
growthCursor: growth.nextGrowthCursor,
|
|
923
|
+
limitingDimensionId: growth.limitingDimensionId,
|
|
924
|
+
limitingBudgetId: growth.limitingBudgetId,
|
|
925
|
+
remaining: growth.remaining,
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
function sameVectorGrowthTopology(stored, current) {
|
|
929
|
+
return (stored.length === current.length &&
|
|
930
|
+
stored.every((dimension, index) => {
|
|
931
|
+
const candidate = current[index];
|
|
932
|
+
return !!candidate && dimension.id === candidate.id && sameStrings(dimension.budgetIds, candidate.budgets.map(budget => budget.id));
|
|
933
|
+
}));
|
|
934
|
+
}
|
|
935
|
+
function sameVectorActualTopology(stored, current) {
|
|
936
|
+
return stored.length === current.length && stored.every((dimension, index) => dimension.id === current[index]?.id);
|
|
937
|
+
}
|
|
938
|
+
function sameVectorActuals(stored, current) {
|
|
939
|
+
return !!stored && stored.length === current.length && stored.every((value, index) => value.id === current[index]?.id && value.actualUnits === current[index]?.actualUnits);
|
|
940
|
+
}
|
|
941
|
+
function validateReserveCommand(command) {
|
|
942
|
+
validateReservationId(command.reservationId);
|
|
943
|
+
assertNonNegativeInteger(command.units, 'units');
|
|
944
|
+
assertPositiveInteger(command.ttlMs, 'ttlMs');
|
|
945
|
+
assertPositiveInteger(command.cleanupBatchSize, 'cleanupBatchSize');
|
|
946
|
+
assertPositiveInteger(command.idempotencyTtlMs, 'idempotencyTtlMs');
|
|
947
|
+
nonEmptyString(command.initialGrowthCursor, 'initialGrowthCursor');
|
|
948
|
+
if (!Array.isArray(command.budgets) || command.budgets.length === 0) {
|
|
949
|
+
throw new RangeError('budgets must contain at least one budget');
|
|
950
|
+
}
|
|
951
|
+
const seen = new Set();
|
|
952
|
+
for (const budget of command.budgets) {
|
|
953
|
+
validateHash(budget.id, 'budget.id');
|
|
954
|
+
assertNonNegativeInteger(budget.limit, 'budget.limit');
|
|
955
|
+
if (seen.has(budget.id))
|
|
956
|
+
throw new RangeError('duplicate budget id');
|
|
957
|
+
seen.add(budget.id);
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
function validateGrowCommand(command) {
|
|
961
|
+
validateReservationId(command.reservationId);
|
|
962
|
+
validateHash(command.incrementHash, 'incrementHash');
|
|
963
|
+
nonEmptyString(command.expectedGrowthCursor, 'expectedGrowthCursor');
|
|
964
|
+
assertPositiveInteger(command.additionalUnits, 'additionalUnits');
|
|
965
|
+
validateHash(command.fingerprint, 'fingerprint');
|
|
966
|
+
nonEmptyString(command.nextGrowthCursor, 'nextGrowthCursor');
|
|
967
|
+
assertPositiveInteger(command.idempotencyTtlMs, 'idempotencyTtlMs');
|
|
968
|
+
if (!Array.isArray(command.budgets) || command.budgets.length === 0) {
|
|
969
|
+
throw new RangeError('budgets must contain at least one budget');
|
|
970
|
+
}
|
|
971
|
+
const seen = new Set();
|
|
972
|
+
for (const budget of command.budgets) {
|
|
973
|
+
validateHash(budget.id, 'budget.id');
|
|
974
|
+
assertNonNegativeInteger(budget.limit, 'budget.limit');
|
|
975
|
+
if (seen.has(budget.id))
|
|
976
|
+
throw new RangeError('duplicate budget id');
|
|
977
|
+
seen.add(budget.id);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
function nonEmptyString(value, name) {
|
|
981
|
+
if (typeof value !== 'string' || value.length === 0)
|
|
982
|
+
throw new Error(`Invalid ${name}`);
|
|
983
|
+
return value;
|
|
984
|
+
}
|
|
985
|
+
function sameStrings(left, right) {
|
|
986
|
+
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
987
|
+
}
|
|
988
|
+
function parseStoredGrowth(raw) {
|
|
989
|
+
if (raw === null)
|
|
990
|
+
return undefined;
|
|
991
|
+
if (typeof raw !== 'string')
|
|
992
|
+
throw new Error('Invalid stored growth replay');
|
|
993
|
+
let value;
|
|
994
|
+
try {
|
|
995
|
+
value = JSON.parse(raw);
|
|
996
|
+
}
|
|
997
|
+
catch {
|
|
998
|
+
throw new Error('Invalid stored growth replay');
|
|
999
|
+
}
|
|
1000
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
1001
|
+
throw new Error('Invalid stored growth replay');
|
|
1002
|
+
}
|
|
1003
|
+
const data = value;
|
|
1004
|
+
for (const field of ['incrementHash', 'fingerprint']) {
|
|
1005
|
+
if (typeof data[field] !== 'string' || !HASH_PATTERN.test(data[field])) {
|
|
1006
|
+
throw new Error(`Invalid stored growth ${field}`);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
const expectedGrowthCursor = nonEmptyString(data.expectedGrowthCursor, 'growth prior cursor');
|
|
1010
|
+
const nextGrowthCursor = nonEmptyString(data.nextGrowthCursor, 'growth next cursor');
|
|
1011
|
+
if (typeof data.accepted !== 'boolean')
|
|
1012
|
+
throw new Error('Invalid stored growth accepted state');
|
|
1013
|
+
const result = {
|
|
1014
|
+
incrementHash: data.incrementHash,
|
|
1015
|
+
expectedGrowthCursor,
|
|
1016
|
+
fingerprint: data.fingerprint,
|
|
1017
|
+
nextGrowthCursor,
|
|
1018
|
+
accepted: data.accepted,
|
|
1019
|
+
};
|
|
1020
|
+
if (data.accepted) {
|
|
1021
|
+
result.previousReservedUnits = storedNonNegativeInteger(data.previousReservedUnits, 'growth previousReservedUnits');
|
|
1022
|
+
result.reservedUnits = storedNonNegativeInteger(data.reservedUnits, 'growth reservedUnits');
|
|
1023
|
+
if (!Array.isArray(data.remainingByBudget) || data.remainingByBudget.length === 0) {
|
|
1024
|
+
throw new Error('Invalid stored growth balances');
|
|
1025
|
+
}
|
|
1026
|
+
result.remainingByBudget = data.remainingByBudget.map(entry => {
|
|
1027
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
1028
|
+
throw new Error('Invalid stored growth balance');
|
|
1029
|
+
}
|
|
1030
|
+
const balance = entry;
|
|
1031
|
+
if (typeof balance.id !== 'string' || !HASH_PATTERN.test(balance.id)) {
|
|
1032
|
+
throw new Error('Invalid stored growth budget id');
|
|
1033
|
+
}
|
|
1034
|
+
return {
|
|
1035
|
+
id: balance.id,
|
|
1036
|
+
remaining: storedNonNegativeInteger(balance.remaining, 'growth remaining'),
|
|
1037
|
+
};
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
else {
|
|
1041
|
+
if (typeof data.limitingBudgetId !== 'string' || !HASH_PATTERN.test(data.limitingBudgetId)) {
|
|
1042
|
+
throw new Error('Invalid stored growth limiting budget id');
|
|
1043
|
+
}
|
|
1044
|
+
result.limitingBudgetId = data.limitingBudgetId;
|
|
1045
|
+
result.remaining = storedNonNegativeInteger(data.remaining, 'growth remaining');
|
|
1046
|
+
}
|
|
1047
|
+
return result;
|
|
1048
|
+
}
|
|
1049
|
+
function growthReplyFromStored(growth, replayed) {
|
|
1050
|
+
if (growth.accepted) {
|
|
1051
|
+
if (growth.previousReservedUnits === undefined ||
|
|
1052
|
+
growth.reservedUnits === undefined ||
|
|
1053
|
+
growth.remainingByBudget === undefined) {
|
|
1054
|
+
throw new Error('Incomplete stored accepted growth replay');
|
|
1055
|
+
}
|
|
1056
|
+
return {
|
|
1057
|
+
accepted: true,
|
|
1058
|
+
replayed,
|
|
1059
|
+
previousReservedUnits: growth.previousReservedUnits,
|
|
1060
|
+
reservedUnits: growth.reservedUnits,
|
|
1061
|
+
growthCursor: growth.nextGrowthCursor,
|
|
1062
|
+
remainingByBudget: growth.remainingByBudget.map(balance => ({ ...balance })),
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
if (growth.limitingBudgetId === undefined || growth.remaining === undefined) {
|
|
1066
|
+
throw new Error('Incomplete stored denied growth replay');
|
|
1067
|
+
}
|
|
1068
|
+
return {
|
|
1069
|
+
accepted: false,
|
|
1070
|
+
reason: 'quota_exceeded',
|
|
1071
|
+
replayed,
|
|
1072
|
+
growthCursor: growth.nextGrowthCursor,
|
|
1073
|
+
limitingBudgetId: growth.limitingBudgetId,
|
|
1074
|
+
remaining: growth.remaining,
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
1077
|
+
function storedNonNegativeInteger(value, name) {
|
|
1078
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
|
|
1079
|
+
throw new Error(`Invalid stored ${name}`);
|
|
1080
|
+
}
|
|
1081
|
+
return value;
|
|
1082
|
+
}
|
|
1083
|
+
function validateReservationId(value) {
|
|
1084
|
+
if (!RESERVATION_ID_PATTERN.test(value))
|
|
1085
|
+
throw new RangeError('invalid reservationId');
|
|
1086
|
+
}
|
|
1087
|
+
function validateHash(value, name) {
|
|
1088
|
+
if (!HASH_PATTERN.test(value))
|
|
1089
|
+
throw new RangeError(`${name} must be a SHA-256 hex digest`);
|
|
1090
|
+
}
|
|
1091
|
+
function parseBudgetIds(raw) {
|
|
1092
|
+
if (typeof raw !== 'string')
|
|
1093
|
+
throw new Error('Invalid stored budget list');
|
|
1094
|
+
let value;
|
|
1095
|
+
try {
|
|
1096
|
+
value = JSON.parse(raw);
|
|
1097
|
+
}
|
|
1098
|
+
catch {
|
|
1099
|
+
throw new Error('Invalid stored budget list');
|
|
1100
|
+
}
|
|
1101
|
+
if (!Array.isArray(value) || value.length === 0 || !value.every(id => typeof id === 'string' && HASH_PATTERN.test(id))) {
|
|
1102
|
+
throw new Error('Invalid stored budget list');
|
|
1103
|
+
}
|
|
1104
|
+
return value;
|
|
1105
|
+
}
|
|
1106
|
+
function integer(value, name) {
|
|
1107
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value)) {
|
|
1108
|
+
throw new Error(`Invalid stored ${name}`);
|
|
1109
|
+
}
|
|
1110
|
+
return value;
|
|
1111
|
+
}
|
|
1112
|
+
function nullableInteger(value, name) {
|
|
1113
|
+
if (value === null)
|
|
1114
|
+
throw new Error(`Invalid stored ${name}`);
|
|
1115
|
+
return integer(value, name);
|
|
1116
|
+
}
|
|
1117
|
+
function assertNonNegativeInteger(value, name) {
|
|
1118
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
1119
|
+
throw new RangeError(`${name} must be a non-negative safe integer`);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
function assertPositiveInteger(value, name) {
|
|
1123
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
1124
|
+
throw new RangeError(`${name} must be a positive safe integer`);
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
function safeAdd(left, right, name) {
|
|
1128
|
+
const result = left + right;
|
|
1129
|
+
if (!Number.isSafeInteger(result) || result < 0)
|
|
1130
|
+
throw new RangeError(`${name} overflowed`);
|
|
1131
|
+
return result;
|
|
1132
|
+
}
|
|
1133
|
+
function emptySummary() {
|
|
1134
|
+
return {
|
|
1135
|
+
pendingCount: 0,
|
|
1136
|
+
pendingUnits: 0,
|
|
1137
|
+
liableCount: 0,
|
|
1138
|
+
liableUnits: 0,
|
|
1139
|
+
vectorPendingCount: 0,
|
|
1140
|
+
vectorLiableCount: 0,
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
function reportWithDirect(direct) {
|
|
1144
|
+
return direct ? { aggregate: emptySummary(), direct } : { aggregate: emptySummary() };
|
|
1145
|
+
}
|
|
1146
|
+
function ok(result, recovery) {
|
|
1147
|
+
return { ok: true, result, recovery };
|
|
1148
|
+
}
|
|
1149
|
+
function fail(error, recovery) {
|
|
1150
|
+
return { ok: false, error, recovery };
|
|
1151
|
+
}
|
|
1152
|
+
//# sourceMappingURL=worker.js.map
|