@usagekit/store 0.1.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 +202 -0
- package/NOTICE +2 -0
- package/dist/clock.d.ts +8 -0
- package/dist/clock.js +12 -0
- package/dist/conformance/admission.d.ts +2 -0
- package/dist/conformance/admission.js +124 -0
- package/dist/conformance/authority.d.ts +2 -0
- package/dist/conformance/authority.js +285 -0
- package/dist/conformance/capabilities.d.ts +2 -0
- package/dist/conformance/capabilities.js +66 -0
- package/dist/conformance/dispatch.d.ts +2 -0
- package/dist/conformance/dispatch.js +80 -0
- package/dist/conformance/expiration.d.ts +2 -0
- package/dist/conformance/expiration.js +131 -0
- package/dist/conformance/factory.d.ts +17 -0
- package/dist/conformance/factory.js +1 -0
- package/dist/conformance/helpers.d.ts +18 -0
- package/dist/conformance/helpers.js +49 -0
- package/dist/conformance/index.d.ts +4 -0
- package/dist/conformance/index.js +2 -0
- package/dist/conformance/properties.d.ts +2 -0
- package/dist/conformance/properties.js +220 -0
- package/dist/conformance/reads.d.ts +2 -0
- package/dist/conformance/reads.js +181 -0
- package/dist/conformance/recovery.d.ts +2 -0
- package/dist/conformance/recovery.js +126 -0
- package/dist/conformance/reserve.d.ts +2 -0
- package/dist/conformance/reserve.js +94 -0
- package/dist/conformance/scaling.d.ts +13 -0
- package/dist/conformance/scaling.js +67 -0
- package/dist/conformance/suite.d.ts +2 -0
- package/dist/conformance/suite.js +27 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +3 -0
- package/dist/memory/admission.d.ts +44 -0
- package/dist/memory/admission.js +105 -0
- package/dist/memory/commands.d.ts +22 -0
- package/dist/memory/commands.js +249 -0
- package/dist/memory/memory-store.d.ts +7 -0
- package/dist/memory/memory-store.js +29 -0
- package/dist/memory/reads.d.ts +5 -0
- package/dist/memory/reads.js +131 -0
- package/dist/memory/state.d.ts +42 -0
- package/dist/memory/state.js +48 -0
- package/dist/reference.d.ts +6 -0
- package/dist/reference.js +5 -0
- package/package.json +53 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { defaultBudgetOrder } from "@usagekit/core";
|
|
2
|
+
import { resolveWindow } from "@usagekit/core";
|
|
3
|
+
import { canonical } from "./state.js";
|
|
4
|
+
export function currentBudgets(s) {
|
|
5
|
+
const map = new Map();
|
|
6
|
+
for (const b of s.budgets) {
|
|
7
|
+
const k = canonical([b.scope.namespace, b.id]);
|
|
8
|
+
if ((map.get(k)?.version ?? -1) < b.version)
|
|
9
|
+
map.set(k, b);
|
|
10
|
+
}
|
|
11
|
+
return [...map.values()];
|
|
12
|
+
}
|
|
13
|
+
export function matches(b, i) {
|
|
14
|
+
if (b.scope.namespace !== i.scope.namespace || (b.surface !== "any" && b.surface !== i.surface))
|
|
15
|
+
return false;
|
|
16
|
+
switch (b.scope.kind) {
|
|
17
|
+
case "principal":
|
|
18
|
+
return b.scope.principal === i.scope.principal;
|
|
19
|
+
case "group":
|
|
20
|
+
return b.scope.group === i.scope.group;
|
|
21
|
+
case "connection":
|
|
22
|
+
return b.scope.connection === i.scope.connection;
|
|
23
|
+
case "access_credential":
|
|
24
|
+
return canonical(b.scope.accessCredential) === canonical(i.scope.accessCredential);
|
|
25
|
+
case "platform_pool":
|
|
26
|
+
return i.platformPools?.includes(b.scope.poolId) ?? false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function plus(a, b) {
|
|
30
|
+
const scale = Math.max(a.scale, b.scale);
|
|
31
|
+
return {
|
|
32
|
+
unit: a.unit,
|
|
33
|
+
scale,
|
|
34
|
+
value: a.value * 10n ** BigInt(scale - a.scale) + b.value * 10n ** BigInt(scale - b.scale),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export const greater = (a, b) => a.value * 10n ** BigInt(b.scale) > b.value * 10n ** BigInt(a.scale);
|
|
38
|
+
export function effective(op) {
|
|
39
|
+
return [...op.receipts]
|
|
40
|
+
.reverse()
|
|
41
|
+
.find((r) => !op.receipts.some((next) => next.supersedes === r.id));
|
|
42
|
+
}
|
|
43
|
+
export function status(s, b) {
|
|
44
|
+
const epoch = resolveWindow(b.window, s.clock.now());
|
|
45
|
+
let used = { value: 0n, scale: 0, unit: b.unit }, reserved = { ...used };
|
|
46
|
+
const projected = s.readBudgetUsage?.(b, epoch.epoch);
|
|
47
|
+
if (projected) {
|
|
48
|
+
used = projected.used;
|
|
49
|
+
reserved = projected.reserved;
|
|
50
|
+
}
|
|
51
|
+
else
|
|
52
|
+
for (const op of s.operations.values()) {
|
|
53
|
+
if (op.scope.namespace !== b.scope.namespace ||
|
|
54
|
+
!op.budgetEpochs.some((e) => e.budgetId === b.id && e.epoch === epoch.epoch))
|
|
55
|
+
continue;
|
|
56
|
+
if (op.state === "settled") {
|
|
57
|
+
const m = effective(op)?.measurements.find((m) => m.unit === b.unit);
|
|
58
|
+
if (m?.quantity)
|
|
59
|
+
used = plus(used, m.quantity);
|
|
60
|
+
}
|
|
61
|
+
else if (op.state !== "released") {
|
|
62
|
+
const q = op.estimate.find((q) => q.unit === b.unit);
|
|
63
|
+
if (q)
|
|
64
|
+
reserved = plus(reserved, q);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const total = plus(used, reserved);
|
|
68
|
+
const remaining = b.limit ? plus(b.limit, { ...total, value: -total.value }) : null;
|
|
69
|
+
return { budget: b, epoch, used, reserved, remaining };
|
|
70
|
+
}
|
|
71
|
+
export function applicable(s, i) {
|
|
72
|
+
return currentBudgets(s)
|
|
73
|
+
.filter((b) => matches(b, i))
|
|
74
|
+
.map((b) => status(s, b));
|
|
75
|
+
}
|
|
76
|
+
export function admission(s, i, policy) {
|
|
77
|
+
const order = policy?.budgetOrder ?? defaultBudgetOrder;
|
|
78
|
+
const budgets = currentBudgets(s)
|
|
79
|
+
.filter((b) => matches(b, i))
|
|
80
|
+
.sort((a, b) => order.indexOf(a.scope.kind) - order.indexOf(b.scope.kind) || a.id.localeCompare(b.id));
|
|
81
|
+
const warnings = [], epochs = [];
|
|
82
|
+
for (const b of budgets) {
|
|
83
|
+
const estimate = i.estimate.find((q) => q.unit === b.unit);
|
|
84
|
+
if (!estimate)
|
|
85
|
+
return { invalid: b.unit, warnings, epochs };
|
|
86
|
+
}
|
|
87
|
+
for (const b of budgets) {
|
|
88
|
+
const st = status(s, b);
|
|
89
|
+
epochs.push({ budgetId: b.id, budgetVersion: b.version, ...st.epoch });
|
|
90
|
+
const estimate = i.estimate.find((q) => q.unit === b.unit);
|
|
91
|
+
if (b.limit && greater(plus(plus(st.used, st.reserved), estimate), b.limit)) {
|
|
92
|
+
const exceeded = {
|
|
93
|
+
code: "allowance_exceeded",
|
|
94
|
+
budget: b,
|
|
95
|
+
used: st.used,
|
|
96
|
+
reserved: st.reserved,
|
|
97
|
+
resetsAt: st.epoch.endsAt,
|
|
98
|
+
};
|
|
99
|
+
if (b.onExceed === "block")
|
|
100
|
+
return { exceeded, warnings, epochs };
|
|
101
|
+
warnings.push(exceeded);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return { warnings, epochs };
|
|
105
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { AdmissionPolicy } from "@usagekit/core";
|
|
2
|
+
import type { ReserveInput, ReserveResult, Operation } from "@usagekit/core";
|
|
3
|
+
import type { State } from "./state.js";
|
|
4
|
+
export declare function reserve(s: State, i: ReserveInput, policy?: AdmissionPolicy): ReserveResult;
|
|
5
|
+
import type { DispatchIntentInput, DispatchGrant, LeaseRenewalInput, LeaseRenewal } from "@usagekit/core";
|
|
6
|
+
export declare function active(s: State, op: Operation): boolean;
|
|
7
|
+
export declare function touch(s: State, op: Operation): void;
|
|
8
|
+
export declare function validTtl(ttl: number, field?: string): void;
|
|
9
|
+
export declare function intent(s: State, i: DispatchIntentInput): DispatchGrant;
|
|
10
|
+
export declare function renew(s: State, i: LeaseRenewalInput): LeaseRenewal;
|
|
11
|
+
import type { SettleInput, CorrectionInput, SettleResult, ReleaseInput, ReleaseResult, Receipt } from "@usagekit/core";
|
|
12
|
+
export declare function validateReceipt(r: Receipt): void;
|
|
13
|
+
export declare function settle(s: State, i: SettleInput | CorrectionInput, correction?: boolean): SettleResult;
|
|
14
|
+
export declare function release(s: State, i: ReleaseInput): ReleaseResult;
|
|
15
|
+
import type { RecoveryClaimInput, RecoveryClaim } from "@usagekit/core";
|
|
16
|
+
export declare function claim(s: State, i: RecoveryClaimInput): RecoveryClaim;
|
|
17
|
+
import type { ExpireReservationsInput } from "@usagekit/core";
|
|
18
|
+
export declare function expireReservations(s: State, i: ExpireReservationsInput): {
|
|
19
|
+
outcome: "expired";
|
|
20
|
+
count: number;
|
|
21
|
+
hasMore: boolean;
|
|
22
|
+
};
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { admission } from "./admission.js";
|
|
2
|
+
import { copy, key, semantics, validateQuantity, InvalidInput } from "./state.js";
|
|
3
|
+
export function reserve(s, i, policy) {
|
|
4
|
+
validTtl(i.reservationTtlMs ?? 300000, "reservationTtlMs");
|
|
5
|
+
i.estimate.forEach(validateQuantity);
|
|
6
|
+
if (new Set(i.estimate.map((q) => q.unit)).size !== i.estimate.length)
|
|
7
|
+
throw new InvalidInput("estimate", "duplicate unit");
|
|
8
|
+
expireReservations(s, { namespace: i.scope.namespace });
|
|
9
|
+
const k = key(i.scope.namespace, i.operationId), existing = s.operations.get(k);
|
|
10
|
+
if (existing)
|
|
11
|
+
return s.identities.get(k) === semantics(i)
|
|
12
|
+
? {
|
|
13
|
+
outcome: "reserved",
|
|
14
|
+
replayed: true,
|
|
15
|
+
warnings: copy(s.warnings.get(k) ?? []),
|
|
16
|
+
operation: copy(existing),
|
|
17
|
+
}
|
|
18
|
+
: { outcome: "conflict", reason: "semantic_mismatch", operation: copy(existing) };
|
|
19
|
+
const check = admission(s, i, policy);
|
|
20
|
+
if (check.invalid)
|
|
21
|
+
return {
|
|
22
|
+
outcome: "invalid",
|
|
23
|
+
reason: "missing_estimate_unit",
|
|
24
|
+
unit: check.invalid,
|
|
25
|
+
operation: null,
|
|
26
|
+
};
|
|
27
|
+
if (check.exceeded)
|
|
28
|
+
return { outcome: "exceeded", exceeded: copy(check.exceeded), operation: null };
|
|
29
|
+
const now = s.clock.now().toISOString();
|
|
30
|
+
const operation = {
|
|
31
|
+
...copy(i),
|
|
32
|
+
reservationExpiresAt: new Date(s.clock.now().getTime() + (i.reservationTtlMs ?? 300000)).toISOString(),
|
|
33
|
+
state: "reserved",
|
|
34
|
+
version: 1,
|
|
35
|
+
createdAt: now,
|
|
36
|
+
updatedAt: now,
|
|
37
|
+
budgetEpochs: copy(check.epochs),
|
|
38
|
+
receipts: [],
|
|
39
|
+
lease: null,
|
|
40
|
+
};
|
|
41
|
+
s.operations.set(k, operation);
|
|
42
|
+
s.identities.set(k, semantics(i));
|
|
43
|
+
s.warnings.set(k, copy(check.warnings));
|
|
44
|
+
return {
|
|
45
|
+
outcome: "reserved",
|
|
46
|
+
replayed: false,
|
|
47
|
+
warnings: copy(check.warnings),
|
|
48
|
+
operation: copy(operation),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
import { find } from "./state.js";
|
|
52
|
+
export function active(s, op) {
|
|
53
|
+
return !!op.lease && Date.parse(op.lease.expiresAt) > s.clock.now().getTime();
|
|
54
|
+
}
|
|
55
|
+
export function touch(s, op) {
|
|
56
|
+
op.version++;
|
|
57
|
+
op.updatedAt = s.clock.now().toISOString();
|
|
58
|
+
}
|
|
59
|
+
export function validTtl(ttl, field = "leaseTtlMs") {
|
|
60
|
+
if (!Number.isSafeInteger(ttl) || ttl <= 0 || ttl > 86400000)
|
|
61
|
+
throw new InvalidInput(field);
|
|
62
|
+
}
|
|
63
|
+
export function intent(s, i) {
|
|
64
|
+
validTtl(i.leaseTtlMs);
|
|
65
|
+
const op = find(s, i);
|
|
66
|
+
if (!op)
|
|
67
|
+
return { granted: false, reason: "not_reserved", operation: null };
|
|
68
|
+
if (op.state === "released")
|
|
69
|
+
return { granted: false, reason: "released", operation: copy(op) };
|
|
70
|
+
if (op.state !== "reserved")
|
|
71
|
+
return { granted: false, reason: "already_dispatched", operation: copy(op) };
|
|
72
|
+
if (Date.parse(op.reservationExpiresAt) <= s.clock.now().getTime()) {
|
|
73
|
+
release(s, {
|
|
74
|
+
...i,
|
|
75
|
+
commandId: crypto.randomUUID(),
|
|
76
|
+
expectedVersion: op.version,
|
|
77
|
+
reason: "reservation_expired",
|
|
78
|
+
});
|
|
79
|
+
return { granted: false, reason: "reservation_expired", operation: copy(op) };
|
|
80
|
+
}
|
|
81
|
+
if (op.version !== i.expectedVersion)
|
|
82
|
+
return { granted: false, reason: "version_conflict", operation: copy(op) };
|
|
83
|
+
op.lease = {
|
|
84
|
+
leaseId: crypto.randomUUID(),
|
|
85
|
+
holder: i.holder,
|
|
86
|
+
expiresAt: new Date(s.clock.now().getTime() + i.leaseTtlMs).toISOString(),
|
|
87
|
+
};
|
|
88
|
+
op.state = "dispatch_intended";
|
|
89
|
+
touch(s, op);
|
|
90
|
+
s.leaseKinds.set(op.lease.leaseId, "lease");
|
|
91
|
+
const result = { granted: true, operation: copy(op), lease: copy(op.lease) };
|
|
92
|
+
s.commands.set(canonical([i.namespace, i.operationId, i.commandId]), {
|
|
93
|
+
identity: canonical({ kind: "intent", input: i }),
|
|
94
|
+
result: copy(result),
|
|
95
|
+
});
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
98
|
+
export function renew(s, i) {
|
|
99
|
+
validTtl(i.leaseTtlMs);
|
|
100
|
+
const op = find(s, i);
|
|
101
|
+
if (!op?.lease || !["dispatch_intended", "pending"].includes(op.state))
|
|
102
|
+
return { renewed: false, reason: "not_active" };
|
|
103
|
+
if (op.lease.leaseId !== i.leaseId)
|
|
104
|
+
return { renewed: false, reason: "not_holder" };
|
|
105
|
+
if (!active(s, op))
|
|
106
|
+
return { renewed: false, reason: "expired" };
|
|
107
|
+
op.lease.expiresAt = new Date(Math.max(Date.parse(op.lease.expiresAt), s.clock.now().getTime() + i.leaseTtlMs)).toISOString();
|
|
108
|
+
return { renewed: true, lease: copy(op.lease) };
|
|
109
|
+
}
|
|
110
|
+
import { canonical, maxMoneyUnits } from "./state.js";
|
|
111
|
+
function authorityError(s, op, a, correction) {
|
|
112
|
+
if (a.kind === "late_evidence")
|
|
113
|
+
return (op.state === "pending" || (correction && op.state === "settled")) && !active(s, op)
|
|
114
|
+
? null
|
|
115
|
+
: "invalid_state";
|
|
116
|
+
if (!["dispatch_intended", "pending"].includes(op.state))
|
|
117
|
+
return "invalid_state";
|
|
118
|
+
if (op.lease?.leaseId !== a.leaseId || s.leaseKinds.get(a.leaseId) !== a.kind)
|
|
119
|
+
return "not_holder";
|
|
120
|
+
return active(s, op) ? null : "lease_expired";
|
|
121
|
+
}
|
|
122
|
+
export function validateReceipt(r) {
|
|
123
|
+
if (!r.id?.trim() ||
|
|
124
|
+
!Number.isFinite(Date.parse(r.occurredAt)) ||
|
|
125
|
+
!Number.isFinite(Date.parse(r.recordedAt)))
|
|
126
|
+
throw new InvalidInput("receipt");
|
|
127
|
+
if (r.cost.certainty === "unknown"
|
|
128
|
+
? r.cost.money !== null
|
|
129
|
+
: !r.cost.money ||
|
|
130
|
+
typeof r.cost.money.units !== "bigint" ||
|
|
131
|
+
r.cost.money.units < 0n ||
|
|
132
|
+
r.cost.money.units > maxMoneyUnits ||
|
|
133
|
+
r.cost.money.currency !== "USD")
|
|
134
|
+
throw new InvalidInput("receipt.cost");
|
|
135
|
+
for (const m of r.measurements) {
|
|
136
|
+
if (m.certainty === "unknown") {
|
|
137
|
+
if (m.quantity !== null || !m.unit)
|
|
138
|
+
throw new InvalidInput("measurement");
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
validateQuantity(m.quantity);
|
|
142
|
+
if (m.unit !== m.quantity.unit)
|
|
143
|
+
throw new InvalidInput("measurement.unit");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (new Set(r.measurements.map((m) => m.unit)).size !== r.measurements.length)
|
|
147
|
+
throw new InvalidInput("measurements", "duplicate unit");
|
|
148
|
+
}
|
|
149
|
+
export function settle(s, i, correction = false) {
|
|
150
|
+
validateReceipt(i.receipt);
|
|
151
|
+
const op = find(s, i);
|
|
152
|
+
const reject = (reason) => ({ outcome: "rejected", reason, operation: copy(op) });
|
|
153
|
+
if (!op)
|
|
154
|
+
return reject("invalid_state");
|
|
155
|
+
const ck = canonical([i.namespace, i.operationId, i.commandId]), identity = canonical({ kind: correction ? "correct" : "settle", input: i }), previous = s.commands.get(ck);
|
|
156
|
+
if (previous) {
|
|
157
|
+
if (previous.identity !== identity)
|
|
158
|
+
return reject("receipt_conflict");
|
|
159
|
+
return {
|
|
160
|
+
...copy(previous.result),
|
|
161
|
+
replayed: true,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
if (op.version !== i.expectedVersion)
|
|
165
|
+
return reject("version_conflict");
|
|
166
|
+
const denied = authorityError(s, op, i.authority, correction);
|
|
167
|
+
if (denied)
|
|
168
|
+
return reject(denied);
|
|
169
|
+
const target = correction ? i.replacesReceiptId : undefined;
|
|
170
|
+
if (target && !op.receipts.some((r) => r.id === target))
|
|
171
|
+
return reject("invalid_state");
|
|
172
|
+
if (correction && (!target || op.receipts.some((r) => r.supersedes === target)))
|
|
173
|
+
return reject("invalid_state");
|
|
174
|
+
if (op.receipts.some((r) => r.id === i.receipt.id))
|
|
175
|
+
return reject("receipt_conflict");
|
|
176
|
+
if (i.receipt.supersedes && i.receipt.supersedes !== target)
|
|
177
|
+
return reject("receipt_conflict");
|
|
178
|
+
const receipt = copy(i.receipt);
|
|
179
|
+
if (target)
|
|
180
|
+
receipt.supersedes = target;
|
|
181
|
+
op.receipts = [...op.receipts, receipt];
|
|
182
|
+
op.state =
|
|
183
|
+
receipt.cost.certainty === "unknown" ||
|
|
184
|
+
receipt.measurements.some((m) => m.certainty === "unknown") ||
|
|
185
|
+
op.estimate.some((q) => !receipt.measurements.some((m) => m.unit === q.unit))
|
|
186
|
+
? "pending"
|
|
187
|
+
: "settled";
|
|
188
|
+
op.lease = null;
|
|
189
|
+
touch(s, op);
|
|
190
|
+
const result = { outcome: "settled", replayed: false, operation: copy(op) };
|
|
191
|
+
s.commands.set(ck, { identity, result: copy(result) });
|
|
192
|
+
return result;
|
|
193
|
+
}
|
|
194
|
+
export function release(s, i) {
|
|
195
|
+
const op = find(s, i), ck = canonical([i.namespace, i.operationId, i.commandId]), identity = canonical({ kind: "release", input: i }), previous = s.commands.get(ck);
|
|
196
|
+
if (previous?.identity === identity)
|
|
197
|
+
return {
|
|
198
|
+
...copy(previous.result),
|
|
199
|
+
replayed: true,
|
|
200
|
+
};
|
|
201
|
+
if (!op || op.state !== "reserved")
|
|
202
|
+
return { outcome: "rejected", reason: "not_reserved", operation: copy(op) };
|
|
203
|
+
if (previous || op.version !== i.expectedVersion)
|
|
204
|
+
return { outcome: "rejected", reason: "version_conflict", operation: copy(op) };
|
|
205
|
+
op.state = "released";
|
|
206
|
+
touch(s, op);
|
|
207
|
+
const result = { outcome: "released", replayed: false, operation: copy(op) };
|
|
208
|
+
s.commands.set(ck, { identity, result: copy(result) });
|
|
209
|
+
return result;
|
|
210
|
+
}
|
|
211
|
+
export function claim(s, i) {
|
|
212
|
+
validTtl(i.leaseTtlMs);
|
|
213
|
+
const op = find(s, i);
|
|
214
|
+
if (!op)
|
|
215
|
+
return { claimed: false, reason: "not_found", operation: null };
|
|
216
|
+
if (!["dispatch_intended", "pending"].includes(op.state))
|
|
217
|
+
return { claimed: false, reason: "not_recoverable", operation: copy(op) };
|
|
218
|
+
if (active(s, op))
|
|
219
|
+
return { claimed: false, reason: "lease_active", operation: copy(op) };
|
|
220
|
+
op.lease = {
|
|
221
|
+
leaseId: crypto.randomUUID(),
|
|
222
|
+
holder: i.holder,
|
|
223
|
+
expiresAt: new Date(s.clock.now().getTime() + i.leaseTtlMs).toISOString(),
|
|
224
|
+
};
|
|
225
|
+
s.leaseKinds.set(op.lease.leaseId, "recovery");
|
|
226
|
+
touch(s, op);
|
|
227
|
+
return { claimed: true, operation: copy(op), lease: copy(op.lease) };
|
|
228
|
+
}
|
|
229
|
+
export function expireReservations(s, i) {
|
|
230
|
+
const limit = i.limit ?? 100;
|
|
231
|
+
if (!i.namespace?.trim() || !Number.isInteger(limit) || limit < 1 || limit > 1000)
|
|
232
|
+
throw new InvalidInput("expiration");
|
|
233
|
+
const due = [...s.operations.values()]
|
|
234
|
+
.filter((op) => op.scope.namespace === i.namespace &&
|
|
235
|
+
op.state === "reserved" &&
|
|
236
|
+
Date.parse(op.reservationExpiresAt) <= s.clock.now().getTime())
|
|
237
|
+
.sort((a, b) => a.reservationExpiresAt.localeCompare(b.reservationExpiresAt) ||
|
|
238
|
+
a.operationId.localeCompare(b.operationId));
|
|
239
|
+
for (const op of due.slice(0, limit))
|
|
240
|
+
release(s, {
|
|
241
|
+
namespace: i.namespace,
|
|
242
|
+
principal: op.scope.principal,
|
|
243
|
+
operationId: op.operationId,
|
|
244
|
+
expectedVersion: op.version,
|
|
245
|
+
commandId: crypto.randomUUID(),
|
|
246
|
+
reason: "reservation_expired",
|
|
247
|
+
});
|
|
248
|
+
return { outcome: "expired", count: Math.min(limit, due.length), hasMore: due.length > limit };
|
|
249
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { aggregate, definedBudgets, applicableBudgets } from "./reads.js";
|
|
2
|
+
import { copy, find } from "./state.js";
|
|
3
|
+
import { reserve, intent, renew, settle, release, claim, expireReservations } from "./commands.js";
|
|
4
|
+
export function createMemoryStore({ clock, budgets }) {
|
|
5
|
+
const state = {
|
|
6
|
+
clock,
|
|
7
|
+
budgets,
|
|
8
|
+
warnings: new Map(),
|
|
9
|
+
leaseKinds: new Map(),
|
|
10
|
+
commands: new Map(),
|
|
11
|
+
cursors: new Map(),
|
|
12
|
+
operations: new Map(),
|
|
13
|
+
identities: new Map(),
|
|
14
|
+
};
|
|
15
|
+
return {
|
|
16
|
+
expireReservations: async (i) => expireReservations(state, i),
|
|
17
|
+
reserve: async (i, policy) => reserve(state, i, policy),
|
|
18
|
+
getOperation: async (ref) => copy(find(state, ref)),
|
|
19
|
+
markDispatchIntent: async (i) => intent(state, i),
|
|
20
|
+
renewLease: async (i) => renew(state, i),
|
|
21
|
+
claimForRecovery: async (i) => claim(state, i),
|
|
22
|
+
settle: async (i) => settle(state, i),
|
|
23
|
+
correct: async (i) => settle(state, i, true),
|
|
24
|
+
releaseUndispatched: async (i) => release(state, i),
|
|
25
|
+
aggregate: async (q) => aggregate(state, q),
|
|
26
|
+
definedBudgets: async (q) => definedBudgets(state, q),
|
|
27
|
+
applicableBudgets: async (q) => applicableBudgets(state, q),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { UsageQuery, UsagePage, DefinedBudgetsQuery, ApplicableBudgetsQuery } from "@usagekit/core";
|
|
2
|
+
import type { State } from "./state.js";
|
|
3
|
+
export declare const definedBudgets: (s: State, q: DefinedBudgetsQuery) => import("@usagekit/core").Budget[];
|
|
4
|
+
export declare const applicableBudgets: (s: State, q: ApplicableBudgetsQuery) => import("@usagekit/core").BudgetStatus[];
|
|
5
|
+
export declare function aggregate(s: State, q: UsageQuery): UsagePage;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { copy, canonical, InvalidInput } from "./state.js";
|
|
2
|
+
import { currentBudgets, applicable, effective, plus } from "./admission.js";
|
|
3
|
+
export const definedBudgets = (s, q) => copy(currentBudgets(s).filter((b) => canonical(b.scope) === canonical(q.scope)));
|
|
4
|
+
export const applicableBudgets = (s, q) => copy(applicable(s, q));
|
|
5
|
+
function inScope(op, q) {
|
|
6
|
+
if (op.scope.namespace !== q.scope.namespace ||
|
|
7
|
+
(q.connection && op.scope.connection !== q.connection))
|
|
8
|
+
return false;
|
|
9
|
+
switch (q.scope.kind) {
|
|
10
|
+
case "namespace":
|
|
11
|
+
return true;
|
|
12
|
+
case "principal":
|
|
13
|
+
return op.scope.principal === q.scope.principal;
|
|
14
|
+
case "group":
|
|
15
|
+
return op.scope.group === q.scope.group;
|
|
16
|
+
case "platform_pool":
|
|
17
|
+
return op.platformPools?.includes(q.scope.poolId) ?? false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function combineCost(a, b) {
|
|
21
|
+
if (a.certainty === "unknown" || b.certainty === "unknown")
|
|
22
|
+
return { certainty: "unknown", money: null };
|
|
23
|
+
return {
|
|
24
|
+
certainty: a.certainty === "estimated" || b.certainty === "estimated" ? "estimated" : "measured",
|
|
25
|
+
money: { currency: "USD", units: a.money.units + b.money.units },
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function combineMeasurements(a, b) {
|
|
29
|
+
const map = new Map(a.map((m) => [m.unit, copy(m)]));
|
|
30
|
+
for (const m of b) {
|
|
31
|
+
const prev = map.get(m.unit);
|
|
32
|
+
if (!prev) {
|
|
33
|
+
map.set(m.unit, copy(m));
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
map.set(m.unit, prev.certainty === "unknown" || m.certainty === "unknown"
|
|
37
|
+
? { unit: m.unit, certainty: "unknown", quantity: null }
|
|
38
|
+
: {
|
|
39
|
+
unit: m.unit,
|
|
40
|
+
certainty: prev.certainty === "estimated" || m.certainty === "estimated"
|
|
41
|
+
? "estimated"
|
|
42
|
+
: "measured",
|
|
43
|
+
quantity: plus(prev.quantity, m.quantity),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return [...map.values()].sort((a, b) => a.unit.localeCompare(b.unit));
|
|
47
|
+
}
|
|
48
|
+
function rows(s, q) {
|
|
49
|
+
const map = new Map();
|
|
50
|
+
for (const op of s.operations.values()) {
|
|
51
|
+
if (!inScope(op, q) || !["pending", "settled"].includes(op.state))
|
|
52
|
+
continue;
|
|
53
|
+
const receipt = effective(op);
|
|
54
|
+
if (!receipt)
|
|
55
|
+
continue;
|
|
56
|
+
const occurred = Date.parse(receipt.occurredAt);
|
|
57
|
+
if (occurred < Date.parse(q.from) || occurred >= Date.parse(q.to))
|
|
58
|
+
continue;
|
|
59
|
+
const pools = q.groupBy.includes("platform_pool")
|
|
60
|
+
? q.scope.kind === "platform_pool"
|
|
61
|
+
? [q.scope.poolId]
|
|
62
|
+
: op.platformPools?.length
|
|
63
|
+
? op.platformPools
|
|
64
|
+
: [""]
|
|
65
|
+
: [""];
|
|
66
|
+
for (const pool of pools) {
|
|
67
|
+
const dimensions = {};
|
|
68
|
+
for (const dimension of q.groupBy) {
|
|
69
|
+
switch (dimension) {
|
|
70
|
+
case "principal":
|
|
71
|
+
dimensions[dimension] = op.scope.principal;
|
|
72
|
+
break;
|
|
73
|
+
case "connection":
|
|
74
|
+
dimensions[dimension] = op.scope.connection;
|
|
75
|
+
break;
|
|
76
|
+
case "day":
|
|
77
|
+
dimensions[dimension] = new Date(receipt.occurredAt).toISOString().slice(0, 10);
|
|
78
|
+
break;
|
|
79
|
+
case "access_credential":
|
|
80
|
+
dimensions[dimension] = op.scope.accessCredential
|
|
81
|
+
? canonical([op.scope.accessCredential.kind, op.scope.accessCredential.id])
|
|
82
|
+
: "";
|
|
83
|
+
break;
|
|
84
|
+
case "platform_pool":
|
|
85
|
+
dimensions[dimension] = pool;
|
|
86
|
+
break;
|
|
87
|
+
default:
|
|
88
|
+
dimensions[dimension] = op[dimension];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const k = canonical([dimensions, op.fundingSource, op.costOwner]), prev = map.get(k), measurements = receipt.measurements.filter((m) => q.units.includes(m.unit));
|
|
92
|
+
map.set(k, {
|
|
93
|
+
dimensions,
|
|
94
|
+
fundingSource: op.fundingSource,
|
|
95
|
+
costOwner: op.costOwner,
|
|
96
|
+
measurements: combineMeasurements(prev?.measurements ?? [], measurements),
|
|
97
|
+
cost: prev ? combineCost(prev.cost, receipt.cost) : copy(receipt.cost),
|
|
98
|
+
unknownOperations: (prev?.unknownOperations ?? 0n) + (op.state === "pending" ? 1n : 0n),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return [...map.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([, r]) => r);
|
|
103
|
+
}
|
|
104
|
+
export function aggregate(s, q) {
|
|
105
|
+
const { cursor, ...query } = q;
|
|
106
|
+
const identity = canonical(query);
|
|
107
|
+
let snapshot;
|
|
108
|
+
if (cursor) {
|
|
109
|
+
snapshot = s.cursors.get(cursor);
|
|
110
|
+
if (!snapshot || snapshot.query !== identity)
|
|
111
|
+
throw new InvalidInput("cursor", "unknown cursor or query mismatch");
|
|
112
|
+
}
|
|
113
|
+
else
|
|
114
|
+
snapshot = {
|
|
115
|
+
query: identity,
|
|
116
|
+
rows: rows(s, q),
|
|
117
|
+
asOf: s.clock.now().toISOString(),
|
|
118
|
+
watermark: crypto.randomUUID(),
|
|
119
|
+
offset: 0,
|
|
120
|
+
};
|
|
121
|
+
const end = snapshot.offset + (q.limit ?? 1000), page = {
|
|
122
|
+
rows: copy(snapshot.rows.slice(snapshot.offset, end)),
|
|
123
|
+
asOf: snapshot.asOf,
|
|
124
|
+
watermark: snapshot.watermark,
|
|
125
|
+
};
|
|
126
|
+
if (end < snapshot.rows.length) {
|
|
127
|
+
page.nextCursor = crypto.randomUUID();
|
|
128
|
+
s.cursors.set(page.nextCursor, { ...snapshot, offset: end });
|
|
129
|
+
}
|
|
130
|
+
return page;
|
|
131
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { DispatchGrant } from "@usagekit/core";
|
|
2
|
+
import type { UsageRow } from "@usagekit/core";
|
|
3
|
+
import type { SettleResult, ReleaseResult } from "@usagekit/core";
|
|
4
|
+
import type { AllowanceExceeded } from "@usagekit/core";
|
|
5
|
+
import type { Budget, Operation, ReserveInput, OperationRef, Quantity } from "@usagekit/core";
|
|
6
|
+
import type { Clock } from "../clock.js";
|
|
7
|
+
export declare class InvalidInput extends Error {
|
|
8
|
+
readonly field: string;
|
|
9
|
+
readonly reason: string;
|
|
10
|
+
constructor(field: string, reason?: string);
|
|
11
|
+
}
|
|
12
|
+
export declare const copy: <T>(value: T) => T;
|
|
13
|
+
export declare function canonical(value: unknown): string;
|
|
14
|
+
export declare const key: (namespace: string, id: string) => string;
|
|
15
|
+
export declare function semantics(i: ReserveInput): string;
|
|
16
|
+
export declare const maxMoneyUnits: bigint;
|
|
17
|
+
export declare function validateQuantity(q: Quantity): void;
|
|
18
|
+
export type State = {
|
|
19
|
+
/** Optional adapter projection, read in the same transaction as admission. */
|
|
20
|
+
readBudgetUsage?: (budget: Budget, epoch: string) => {
|
|
21
|
+
used: Quantity;
|
|
22
|
+
reserved: Quantity;
|
|
23
|
+
};
|
|
24
|
+
cursors: Map<string, {
|
|
25
|
+
query: string;
|
|
26
|
+
rows: UsageRow[];
|
|
27
|
+
asOf: string;
|
|
28
|
+
watermark: string;
|
|
29
|
+
offset: number;
|
|
30
|
+
}>;
|
|
31
|
+
clock: Clock;
|
|
32
|
+
budgets: Budget[];
|
|
33
|
+
operations: Map<string, Operation>;
|
|
34
|
+
identities: Map<string, string>;
|
|
35
|
+
commands: Map<string, {
|
|
36
|
+
identity: string;
|
|
37
|
+
result: SettleResult | ReleaseResult | DispatchGrant;
|
|
38
|
+
}>;
|
|
39
|
+
leaseKinds: Map<string, "lease" | "recovery">;
|
|
40
|
+
warnings: Map<string, readonly AllowanceExceeded[]>;
|
|
41
|
+
};
|
|
42
|
+
export declare function find(s: State, ref: OperationRef): Operation | null;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export class InvalidInput extends Error {
|
|
2
|
+
field;
|
|
3
|
+
reason;
|
|
4
|
+
constructor(field, reason = "invalid value") {
|
|
5
|
+
super(`InvalidInput: ${field}: ${reason}`);
|
|
6
|
+
this.field = field;
|
|
7
|
+
this.reason = reason;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export const copy = (value) => structuredClone(value);
|
|
11
|
+
export function canonical(value) {
|
|
12
|
+
if (typeof value === "bigint")
|
|
13
|
+
return `"bigint:${value}"`;
|
|
14
|
+
if (Array.isArray(value))
|
|
15
|
+
return `[${value.map(canonical).join(",")}]`;
|
|
16
|
+
if (value && typeof value === "object")
|
|
17
|
+
return `{${Object.entries(value)
|
|
18
|
+
.filter(([, v]) => v !== undefined)
|
|
19
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
20
|
+
.map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`)
|
|
21
|
+
.join(",")}}`;
|
|
22
|
+
return JSON.stringify(value) ?? "null";
|
|
23
|
+
}
|
|
24
|
+
export const key = (namespace, id) => canonical([namespace, id]);
|
|
25
|
+
export function semantics(i) {
|
|
26
|
+
const { correlationId: _, parentOperationId: __, ...rest } = i;
|
|
27
|
+
return canonical({
|
|
28
|
+
...rest,
|
|
29
|
+
estimate: [...i.estimate].sort((a, b) => a.unit.localeCompare(b.unit)),
|
|
30
|
+
platformPools: [...(i.platformPools ?? [])].sort(),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
export const maxMoneyUnits = 2n ** 63n - 1n;
|
|
34
|
+
export function validateQuantity(q) {
|
|
35
|
+
if (typeof q.value !== "bigint" ||
|
|
36
|
+
q.value < 0n ||
|
|
37
|
+
!Number.isInteger(q.scale) ||
|
|
38
|
+
q.scale < 0 ||
|
|
39
|
+
q.scale > 18 ||
|
|
40
|
+
!q.unit?.trim())
|
|
41
|
+
throw new InvalidInput("quantity");
|
|
42
|
+
if (q.unit === "cents" && q.value * 10000n > maxMoneyUnits * 10n ** BigInt(q.scale))
|
|
43
|
+
throw new InvalidInput("quantity", "money storage bound");
|
|
44
|
+
}
|
|
45
|
+
export function find(s, ref) {
|
|
46
|
+
const op = s.operations.get(key(ref.namespace, ref.operationId));
|
|
47
|
+
return op?.scope.principal === ref.principal ? op : null;
|
|
48
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Synchronous reference rules shared by adapters inside their own atomic transactions. */
|
|
2
|
+
export { reserve, intent, renew, claim, settle, release, expireReservations, } from "./memory/commands.js";
|
|
3
|
+
export { aggregate, definedBudgets, applicableBudgets } from "./memory/reads.js";
|
|
4
|
+
export { copy, find, key, canonical, InvalidInput } from "./memory/state.js";
|
|
5
|
+
export type { State } from "./memory/state.js";
|
|
6
|
+
export { plus, effective, currentBudgets } from "./memory/admission.js";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Synchronous reference rules shared by adapters inside their own atomic transactions. */
|
|
2
|
+
export { reserve, intent, renew, claim, settle, release, expireReservations, } from "./memory/commands.js";
|
|
3
|
+
export { aggregate, definedBudgets, applicableBudgets } from "./memory/reads.js";
|
|
4
|
+
export { copy, find, key, canonical, InvalidInput } from "./memory/state.js";
|
|
5
|
+
export { plus, effective, currentBudgets } from "./memory/admission.js";
|