@usagekit/store 0.1.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/conformance/admission.js +99 -5
- package/dist/conformance/alerts.d.ts +2 -0
- package/dist/conformance/alerts.js +146 -0
- package/dist/conformance/counters.d.ts +2 -0
- package/dist/conformance/counters.js +159 -0
- package/dist/conformance/operations.d.ts +2 -0
- package/dist/conformance/operations.js +103 -0
- package/dist/conformance/properties.js +12 -5
- package/dist/conformance/reads.js +65 -1
- package/dist/conformance/reserve.js +2 -2
- package/dist/conformance/suite.js +8 -0
- package/dist/conformance/tags.d.ts +2 -0
- package/dist/conformance/tags.js +101 -0
- package/dist/index.d.ts +8 -1
- package/dist/index.js +2 -0
- package/dist/memory/admission.d.ts +29 -3
- package/dist/memory/admission.js +141 -40
- package/dist/memory/commands.d.ts +1 -1
- package/dist/memory/commands.js +20 -7
- package/dist/memory/counters.d.ts +33 -0
- package/dist/memory/counters.js +113 -0
- package/dist/memory/memory-store.js +7 -1
- package/dist/memory/reads.d.ts +3 -1
- package/dist/memory/reads.js +87 -33
- package/dist/memory/state.d.ts +21 -1
- package/dist/memory/state.js +14 -1
- package/dist/reference.d.ts +2 -2
- package/dist/reference.js +2 -2
- package/package.json +2 -2
|
@@ -15,7 +15,7 @@ export function readsTests(factory, capabilities) {
|
|
|
15
15
|
groupBy: ["provider"],
|
|
16
16
|
...overrides,
|
|
17
17
|
});
|
|
18
|
-
const settled = async (r = receipt(), principal = "u1", provider = "search") => {
|
|
18
|
+
const settled = async (r = receipt(), principal = "u1", provider = "search", overrides = {}) => {
|
|
19
19
|
const result = await f.store.reserve(input({
|
|
20
20
|
scope: {
|
|
21
21
|
namespace: "test",
|
|
@@ -23,9 +23,11 @@ export function readsTests(factory, capabilities) {
|
|
|
23
23
|
connection: "c1",
|
|
24
24
|
group: "g1",
|
|
25
25
|
accessCredential: { kind: "api_key", id: "k1" },
|
|
26
|
+
tags: ["t1"],
|
|
26
27
|
},
|
|
27
28
|
platformPools: ["p1"],
|
|
28
29
|
provider,
|
|
30
|
+
...overrides,
|
|
29
31
|
}));
|
|
30
32
|
if (result.outcome !== "reserved")
|
|
31
33
|
throw new Error("fixture");
|
|
@@ -52,6 +54,8 @@ export function readsTests(factory, capabilities) {
|
|
|
52
54
|
"day",
|
|
53
55
|
"access_credential",
|
|
54
56
|
"platform_pool",
|
|
57
|
+
"funding_source",
|
|
58
|
+
"tag",
|
|
55
59
|
])("group by %s uses only requested dimensions", async (dim) => {
|
|
56
60
|
await settled();
|
|
57
61
|
const page = await f.store.aggregate(query({ groupBy: [dim] }));
|
|
@@ -59,6 +63,65 @@ export function readsTests(factory, capabilities) {
|
|
|
59
63
|
expect(Object.keys(page.rows[0].dimensions)).toEqual([dim]);
|
|
60
64
|
expect(page.rows[0].measurements[0].quantity?.value).toBe(1n);
|
|
61
65
|
});
|
|
66
|
+
const cost = (units) => receipt({ cost: { certainty: "measured", money: { units, currency: "USD" } } });
|
|
67
|
+
const tagged = (tags) => ({
|
|
68
|
+
scope: {
|
|
69
|
+
namespace: "test",
|
|
70
|
+
principal: "u1",
|
|
71
|
+
connection: "c1",
|
|
72
|
+
...(tags ? { tags } : {}),
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
test("funding source totals equal the sum of their operations", async () => {
|
|
76
|
+
await settled(cost(1n));
|
|
77
|
+
await settled(cost(2n));
|
|
78
|
+
await settled(cost(4n), "u1", "search", { fundingSource: "platform", costOwner: "platform" });
|
|
79
|
+
const page = await f.store.aggregate(query({ groupBy: ["funding_source"] }));
|
|
80
|
+
expect(page.rows).toEqual([
|
|
81
|
+
expect.objectContaining({
|
|
82
|
+
dimensions: { funding_source: "byok" },
|
|
83
|
+
fundingSource: "byok",
|
|
84
|
+
cost: { certainty: "measured", money: { units: 3n, currency: "USD" } },
|
|
85
|
+
measurements: [{ unit: "requests", certainty: "measured", quantity: quantity(2n) }],
|
|
86
|
+
}),
|
|
87
|
+
expect.objectContaining({
|
|
88
|
+
dimensions: { funding_source: "platform" },
|
|
89
|
+
fundingSource: "platform",
|
|
90
|
+
cost: { certainty: "measured", money: { units: 4n, currency: "USD" } },
|
|
91
|
+
}),
|
|
92
|
+
]);
|
|
93
|
+
});
|
|
94
|
+
test("tag rows count a two-tag operation twice, so they are not additive across tags", async () => {
|
|
95
|
+
await settled(cost(3n), "u1", "search", tagged(["a", "b"]));
|
|
96
|
+
await settled(cost(5n), "u1", "search", tagged(undefined));
|
|
97
|
+
const page = await f.store.aggregate(query({ groupBy: ["tag"] }));
|
|
98
|
+
expect(page.rows.map((r) => [r.dimensions.tag, r.cost.money?.units])).toEqual([
|
|
99
|
+
["", 5n],
|
|
100
|
+
["a", 3n],
|
|
101
|
+
["b", 3n],
|
|
102
|
+
]);
|
|
103
|
+
expect((await f.store.aggregate(query({ groupBy: ["provider"] }))).rows[0]).toMatchObject({
|
|
104
|
+
cost: { money: { units: 8n } },
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
test.each(["funding_source", "tag"])("cursor pagination is stable when grouping by %s", async (dim) => {
|
|
108
|
+
await settled(cost(1n), "u1", "search", tagged(["a", "b"]));
|
|
109
|
+
await settled(cost(2n), "u1", "search", {
|
|
110
|
+
...tagged(["c"]),
|
|
111
|
+
fundingSource: "platform",
|
|
112
|
+
costOwner: "platform",
|
|
113
|
+
});
|
|
114
|
+
const all = await f.store.aggregate(query({ groupBy: [dim, "connection"] }));
|
|
115
|
+
const pages = [];
|
|
116
|
+
let cursor;
|
|
117
|
+
do {
|
|
118
|
+
const page = await f.store.aggregate(query({ groupBy: [dim, "connection"], limit: 1, ...(cursor ? { cursor } : {}) }));
|
|
119
|
+
pages.push(...page.rows);
|
|
120
|
+
cursor = page.nextCursor;
|
|
121
|
+
} while (cursor);
|
|
122
|
+
expect(pages).toEqual(all.rows);
|
|
123
|
+
expect(pages.length).toBe(dim === "tag" ? 3 : 2);
|
|
124
|
+
});
|
|
62
125
|
test("late receipt groups by occurrence; corrected history is not double-counted", async () => {
|
|
63
126
|
const r = receipt({ occurredAt: "2026-09-02T10:00:00.000Z" });
|
|
64
127
|
const op = await settled(r);
|
|
@@ -129,6 +192,7 @@ export function readsTests(factory, capabilities) {
|
|
|
129
192
|
accessCredential: { kind: "api_key", id: "k1" },
|
|
130
193
|
},
|
|
131
194
|
{ kind: "platform_pool", namespace: "test", poolId: "p1" },
|
|
195
|
+
{ kind: "tag", namespace: "test", tag: "t1" },
|
|
132
196
|
];
|
|
133
197
|
test.each(scopes)("defined budgets are exact: $kind", async (scope) => {
|
|
134
198
|
const b = budget({ scope });
|
|
@@ -29,8 +29,8 @@ export function reserveTests(factory) {
|
|
|
29
29
|
{ costOwner: "other" },
|
|
30
30
|
{ creditAccountRef: "wallet" },
|
|
31
31
|
{ customerPriceVersion: "v2" },
|
|
32
|
-
{ surface: "programmatic" },
|
|
33
|
-
{ source: "
|
|
32
|
+
{ surface: "programmatic", source: "api" },
|
|
33
|
+
{ source: "worker" },
|
|
34
34
|
{ provider: "other" },
|
|
35
35
|
{ operation: "other" },
|
|
36
36
|
{ estimate: [quantity(2n)] },
|
|
@@ -2,21 +2,29 @@ import { describe, afterAll } from "vitest";
|
|
|
2
2
|
import { expirationTests } from "./expiration.js";
|
|
3
3
|
import { reserveTests } from "./reserve.js";
|
|
4
4
|
import { admissionTests } from "./admission.js";
|
|
5
|
+
import { alertTests } from "./alerts.js";
|
|
6
|
+
import { tagTests } from "./tags.js";
|
|
5
7
|
import { dispatchTests } from "./dispatch.js";
|
|
6
8
|
import { authorityTests } from "./authority.js";
|
|
7
9
|
import { recoveryTests } from "./recovery.js";
|
|
8
10
|
import { readsTests } from "./reads.js";
|
|
11
|
+
import { operationsTests } from "./operations.js";
|
|
9
12
|
import { propertyTests } from "./properties.js";
|
|
10
13
|
import { capabilityTests } from "./capabilities.js";
|
|
14
|
+
import { counterTests } from "./counters.js";
|
|
11
15
|
export function runStoreConformance(factory, capabilities) {
|
|
12
16
|
describe("store conformance", () => {
|
|
13
17
|
reserveTests(factory);
|
|
14
18
|
expirationTests(factory);
|
|
15
19
|
admissionTests(factory);
|
|
20
|
+
alertTests(factory);
|
|
21
|
+
tagTests(factory);
|
|
16
22
|
dispatchTests(factory);
|
|
17
23
|
authorityTests(factory);
|
|
18
24
|
recoveryTests(factory);
|
|
19
25
|
readsTests(factory, capabilities);
|
|
26
|
+
operationsTests(factory);
|
|
27
|
+
counterTests(factory);
|
|
20
28
|
propertyTests(factory);
|
|
21
29
|
capabilityTests(factory, capabilities);
|
|
22
30
|
afterAll(() => console.info("Skipped guarantees: " +
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { beforeEach, afterEach, describe, expect, test } from "vitest";
|
|
2
|
+
import { input, budget, quantity } from "./helpers.js";
|
|
3
|
+
export function tagTests(factory) {
|
|
4
|
+
describe("connection tags", () => {
|
|
5
|
+
let f;
|
|
6
|
+
beforeEach(async () => {
|
|
7
|
+
f = await factory();
|
|
8
|
+
});
|
|
9
|
+
afterEach(async () => f.close());
|
|
10
|
+
const tagged = (tags, principal = "u1") => input({
|
|
11
|
+
scope: {
|
|
12
|
+
namespace: "test",
|
|
13
|
+
principal,
|
|
14
|
+
connection: `c_${principal}`,
|
|
15
|
+
...(tags ? { tags } : {}),
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
const prod = { kind: "tag", namespace: "test", tag: "prod" };
|
|
19
|
+
test("a tag budget spans principals and blocks the call that exhausts it", async () => {
|
|
20
|
+
const b = budget({ scope: prod, limit: quantity(2n) });
|
|
21
|
+
f.budgets.push(b);
|
|
22
|
+
expect(await f.store.reserve(tagged(["prod"], "u1"))).toMatchObject({ outcome: "reserved" });
|
|
23
|
+
expect(await f.store.reserve(tagged(["eu", "prod"], "u2"))).toMatchObject({
|
|
24
|
+
outcome: "reserved",
|
|
25
|
+
operation: { budgetEpochs: [{ budgetId: b.id }] },
|
|
26
|
+
});
|
|
27
|
+
expect(await f.store.reserve(tagged(["prod"], "u3"))).toMatchObject({
|
|
28
|
+
outcome: "exceeded",
|
|
29
|
+
exceeded: { budget: { id: b.id } },
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
test("operations without the tag are not bounded by tag budgets", async () => {
|
|
33
|
+
f.budgets.push(budget({ scope: prod, limit: quantity(0n) }));
|
|
34
|
+
expect(await f.store.reserve(tagged(undefined))).toMatchObject({
|
|
35
|
+
outcome: "reserved",
|
|
36
|
+
operation: { budgetEpochs: [] },
|
|
37
|
+
});
|
|
38
|
+
expect(await f.store.reserve(tagged(["staging"]))).toMatchObject({
|
|
39
|
+
outcome: "reserved",
|
|
40
|
+
operation: { budgetEpochs: [] },
|
|
41
|
+
});
|
|
42
|
+
expect(await f.store.reserve(tagged(["prod"]))).toMatchObject({ outcome: "exceeded" });
|
|
43
|
+
});
|
|
44
|
+
test("tag budgets sort after group and before connection in the default order", async () => {
|
|
45
|
+
const tag = budget({ scope: prod, limit: quantity(0n) }), connection = budget({
|
|
46
|
+
scope: { kind: "connection", namespace: "test", connection: "c_u1" },
|
|
47
|
+
limit: quantity(0n),
|
|
48
|
+
}), group = budget({
|
|
49
|
+
scope: { kind: "group", namespace: "test", group: "g1" },
|
|
50
|
+
limit: quantity(0n),
|
|
51
|
+
});
|
|
52
|
+
f.budgets.push(connection, tag);
|
|
53
|
+
expect(await f.store.reserve(tagged(["prod"]))).toMatchObject({
|
|
54
|
+
exceeded: { budget: { id: tag.id } },
|
|
55
|
+
});
|
|
56
|
+
f.budgets.push(group);
|
|
57
|
+
const i = tagged(["prod"]);
|
|
58
|
+
i.scope.group = "g1";
|
|
59
|
+
expect(await f.store.reserve(i)).toMatchObject({ exceeded: { budget: { id: group.id } } });
|
|
60
|
+
});
|
|
61
|
+
test.each([
|
|
62
|
+
{ name: "empty list", tags: [] },
|
|
63
|
+
{ name: "empty tag", tags: [""] },
|
|
64
|
+
{ name: "upper case", tags: ["Prod"] },
|
|
65
|
+
{ name: "leading punctuation", tags: ["-prod"] },
|
|
66
|
+
{ name: "whitespace", tags: ["pro d"] },
|
|
67
|
+
{ name: "duplicate", tags: ["prod", "prod"] },
|
|
68
|
+
{ name: "too long", tags: ["a".repeat(65)] },
|
|
69
|
+
{ name: "too many", tags: Array.from({ length: 17 }, (_, n) => `t${n}`) },
|
|
70
|
+
])("invalid tags are a validation failure: $name", async ({ tags }) => {
|
|
71
|
+
await expect(f.store.reserve(tagged(tags))).rejects.toThrow("InvalidInput");
|
|
72
|
+
});
|
|
73
|
+
test("tags are stored sorted and unique up to the limits", async () => {
|
|
74
|
+
const tags = ["z9", "a", "team-x", "eu:west", "v1.2", "under_score"];
|
|
75
|
+
const r = await f.store.reserve(tagged(tags));
|
|
76
|
+
expect(r).toMatchObject({
|
|
77
|
+
outcome: "reserved",
|
|
78
|
+
operation: { scope: { tags: [...tags].sort() } },
|
|
79
|
+
});
|
|
80
|
+
const sixteen = Array.from({ length: 16 }, (_, n) => `t${String(n).padStart(2, "0")}`);
|
|
81
|
+
expect(await f.store.reserve(tagged(sixteen))).toMatchObject({ outcome: "reserved" });
|
|
82
|
+
expect(await f.store.reserve(tagged(["a".repeat(64)]))).toMatchObject({
|
|
83
|
+
outcome: "reserved",
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
test("tags are attribution: replay with other tags keeps the first reservation's tags", async () => {
|
|
87
|
+
const i = tagged(["b", "a"]);
|
|
88
|
+
const first = await f.store.reserve(i);
|
|
89
|
+
if (first.outcome !== "reserved")
|
|
90
|
+
throw new Error("fixture");
|
|
91
|
+
expect(first.operation.scope.tags).toEqual(["a", "b"]);
|
|
92
|
+
const replay = await f.store.reserve({ ...i, scope: { ...i.scope, tags: ["c"] } });
|
|
93
|
+
expect(replay).toEqual({ ...first, replayed: true });
|
|
94
|
+
const { tags: _, ...untagged } = i.scope;
|
|
95
|
+
expect(await f.store.reserve({ ...i, scope: untagged })).toEqual({
|
|
96
|
+
...first,
|
|
97
|
+
replayed: true,
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AdmissionPolicy } from "@usagekit/core";
|
|
2
|
-
import type { ExpireReservationsInput, ExpireReservationsResult, Budget, ApplicableBudgetsQuery, BudgetStatus, DefinedBudgetsQuery, CorrectionInput, DispatchGrant, DispatchIntentInput, LeaseRenewal, LeaseRenewalInput, Operation, OperationRef, RecoveryClaim, RecoveryClaimInput, ReleaseInput, ReleaseResult, ReserveInput, ReserveResult, SettleInput, SettleResult, UsagePage, UsageQuery } from "@usagekit/core";
|
|
2
|
+
import type { ExpireReservationsInput, ExpireReservationsResult, Budget, ApplicableBudgetsQuery, BudgetStatus, DefinedBudgetsQuery, CorrectionInput, DispatchGrant, DispatchIntentInput, LeaseRenewal, LeaseRenewalInput, Operation, OperationRef, RecoveryClaim, RecoveryClaimInput, ReleaseInput, ReleaseResult, ReserveInput, ReserveResult, SettleInput, SettleResult, UsagePage, UsageQuery, OperationsQuery, OperationsPage, CountRequestInput, CountRequestResult, RequestCountsQuery, RequestCountsPage } from "@usagekit/core";
|
|
3
3
|
/**
|
|
4
4
|
* Authorization is the Meter's job; atomic condition checks are the Store's job.
|
|
5
5
|
* Store does not take AccessContext. Meter resolves applicable budgets, funding and price policy.
|
|
@@ -20,8 +20,12 @@ export interface Store {
|
|
|
20
20
|
settle(input: SettleInput): Promise<SettleResult>;
|
|
21
21
|
correct(input: CorrectionInput): Promise<SettleResult>;
|
|
22
22
|
releaseUndispatched(input: ReleaseInput): Promise<ReleaseResult>;
|
|
23
|
+
/** Atomic, replay-safe count of one non-operation request; never reserves or touches budgets. */
|
|
24
|
+
countRequest(input: CountRequestInput): Promise<CountRequestResult>;
|
|
23
25
|
getOperation(input: OperationRef): Promise<Operation | null>;
|
|
24
26
|
aggregate(query: UsageQuery): Promise<UsagePage>;
|
|
27
|
+
listOperations(query: OperationsQuery): Promise<OperationsPage>;
|
|
28
|
+
requestCounts(query: RequestCountsQuery): Promise<RequestCountsPage>;
|
|
25
29
|
definedBudgets(query: DefinedBudgetsQuery): Promise<readonly Budget[]>;
|
|
26
30
|
applicableBudgets(query: ApplicableBudgetsQuery): Promise<readonly BudgetStatus[]>;
|
|
27
31
|
}
|
|
@@ -29,3 +33,6 @@ export { createMemoryStore } from "./memory/memory-store.js";
|
|
|
29
33
|
export { createManualClock } from "./clock.js";
|
|
30
34
|
export type { Clock, ManualClock } from "./clock.js";
|
|
31
35
|
export { InvalidInput } from "./memory/state.js";
|
|
36
|
+
export { validateBudget, reachedAlerts, alertKey } from "./memory/admission.js";
|
|
37
|
+
export { validateCountRequest, validateRequestCountsQuery, countIdentity, countRows, bucketOf, requestCountDimensions, } from "./memory/counters.js";
|
|
38
|
+
export type { CountBucket } from "./memory/counters.js";
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export { createMemoryStore } from "./memory/memory-store.js";
|
|
2
2
|
export { createManualClock } from "./clock.js";
|
|
3
3
|
export { InvalidInput } from "./memory/state.js";
|
|
4
|
+
export { validateBudget, reachedAlerts, alertKey } from "./memory/admission.js";
|
|
5
|
+
export { validateCountRequest, validateRequestCountsQuery, countIdentity, countRows, bucketOf, requestCountDimensions, } from "./memory/counters.js";
|
|
@@ -1,13 +1,36 @@
|
|
|
1
1
|
import type { AdmissionPolicy } from "@usagekit/core";
|
|
2
|
-
import type { Budget, ReserveInput, ApplicableBudgetsQuery, Quantity, BudgetStatus, AllowanceExceeded, Operation } from "@usagekit/core";
|
|
2
|
+
import type { Budget, BudgetAlert, BudgetAlertCrossed, ReserveInput, ApplicableBudgetsQuery, Quantity, BudgetStatus, AllowanceExceeded, Operation } from "@usagekit/core";
|
|
3
3
|
import type { State } from "./state.js";
|
|
4
|
-
export declare function currentBudgets(s: State): Budget[];
|
|
5
|
-
export declare function matches(b: Budget, i: Pick<ReserveInput, "scope" | "surface" | "platformPools">): boolean;
|
|
6
4
|
export declare function plus(a: Quantity, b: Quantity): Quantity;
|
|
7
5
|
export declare const greater: (a: Quantity, b: Quantity) => boolean;
|
|
6
|
+
/** Absolute threshold of one alert; percent thresholds need the limit. */
|
|
7
|
+
export declare function alertThreshold(b: Budget, at: BudgetAlert["at"]): Quantity;
|
|
8
|
+
export declare const alertKey: (at: BudgetAlert["at"]) => string;
|
|
9
|
+
/** Thresholds at or below used plus reserved, in declaration order. Callers drop recorded ones. */
|
|
10
|
+
export declare function reachedAlerts(b: Budget, used: Quantity, reserved: Quantity): BudgetAlert["at"][];
|
|
11
|
+
/** Definition rules every adapter applies before a budget takes part in admission. */
|
|
12
|
+
export declare function validateBudget(b: Budget): void;
|
|
13
|
+
export declare function currentBudgets(s: State): Budget[];
|
|
14
|
+
/** The budget version an operation snapshotted, or its current definition when that version is gone. */
|
|
15
|
+
export declare function budgetAt(s: State, namespace: string, epoch: Operation["budgetEpochs"][number]): Budget | undefined;
|
|
16
|
+
export declare function matches(b: Budget, i: Pick<ReserveInput, "scope" | "surface" | "platformPools"> & Pick<ApplicableBudgetsQuery, "source">): boolean;
|
|
8
17
|
export declare function effective(op: Operation): import("@usagekit/core").Receipt | undefined;
|
|
18
|
+
/** Settled use and outstanding reservations of one budget epoch. */
|
|
19
|
+
export declare function usage(s: State, b: Budget, epoch: string): {
|
|
20
|
+
used: Quantity;
|
|
21
|
+
reserved: Quantity;
|
|
22
|
+
};
|
|
9
23
|
export declare function status(s: State, b: Budget): BudgetStatus;
|
|
10
24
|
export declare function applicable(s: State, i: ApplicableBudgetsQuery): BudgetStatus[];
|
|
25
|
+
/** Which bound a projected total crosses and whether that denies admission. */
|
|
26
|
+
export declare function crossing(b: Budget, total: Quantity): {
|
|
27
|
+
boundary: AllowanceExceeded["boundary"];
|
|
28
|
+
denied: boolean;
|
|
29
|
+
} | null;
|
|
30
|
+
/** Records unseen crossings atomically with the calling command and returns only those. */
|
|
31
|
+
export declare function recordAlerts(s: State, namespace: string, reached: readonly BudgetAlertCrossed[]): BudgetAlertCrossed[];
|
|
32
|
+
/** Crossings reached by an operation's current figures in its snapshotted epochs. */
|
|
33
|
+
export declare function reachedBySettlement(s: State, op: Operation): BudgetAlertCrossed[];
|
|
11
34
|
export declare function admission(s: State, i: ReserveInput, policy?: AdmissionPolicy): {
|
|
12
35
|
invalid: string;
|
|
13
36
|
warnings: AllowanceExceeded[];
|
|
@@ -18,6 +41,7 @@ export declare function admission(s: State, i: ReserveInput, policy?: AdmissionP
|
|
|
18
41
|
startsAt: string;
|
|
19
42
|
endsAt: string | null;
|
|
20
43
|
}[];
|
|
44
|
+
alerts: BudgetAlertCrossed[];
|
|
21
45
|
exceeded?: never;
|
|
22
46
|
} | {
|
|
23
47
|
exceeded: AllowanceExceeded;
|
|
@@ -29,6 +53,7 @@ export declare function admission(s: State, i: ReserveInput, policy?: AdmissionP
|
|
|
29
53
|
startsAt: string;
|
|
30
54
|
endsAt: string | null;
|
|
31
55
|
}[];
|
|
56
|
+
alerts: BudgetAlertCrossed[];
|
|
32
57
|
invalid?: never;
|
|
33
58
|
} | {
|
|
34
59
|
warnings: AllowanceExceeded[];
|
|
@@ -39,6 +64,7 @@ export declare function admission(s: State, i: ReserveInput, policy?: AdmissionP
|
|
|
39
64
|
startsAt: string;
|
|
40
65
|
endsAt: string | null;
|
|
41
66
|
}[];
|
|
67
|
+
alerts: BudgetAlertCrossed[];
|
|
42
68
|
invalid?: never;
|
|
43
69
|
exceeded?: never;
|
|
44
70
|
};
|
package/dist/memory/admission.js
CHANGED
|
@@ -1,6 +1,62 @@
|
|
|
1
|
-
import { defaultBudgetOrder } from "@usagekit/core";
|
|
1
|
+
import { defaultBudgetOrder, surfaceMatches } from "@usagekit/core";
|
|
2
2
|
import { resolveWindow } from "@usagekit/core";
|
|
3
|
-
import { canonical } from "./state.js";
|
|
3
|
+
import { canonical, InvalidInput, validateQuantity } from "./state.js";
|
|
4
|
+
export function plus(a, b) {
|
|
5
|
+
const scale = Math.max(a.scale, b.scale);
|
|
6
|
+
return {
|
|
7
|
+
unit: a.unit,
|
|
8
|
+
scale,
|
|
9
|
+
value: a.value * 10n ** BigInt(scale - a.scale) + b.value * 10n ** BigInt(scale - b.scale),
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export const greater = (a, b) => a.value * 10n ** BigInt(b.scale) > b.value * 10n ** BigInt(a.scale);
|
|
13
|
+
/** Absolute threshold of one alert; percent thresholds need the limit. */
|
|
14
|
+
export function alertThreshold(b, at) {
|
|
15
|
+
if (!("percent" in at))
|
|
16
|
+
return at;
|
|
17
|
+
return { unit: b.unit, value: b.limit.value * BigInt(at.percent), scale: b.limit.scale + 2 };
|
|
18
|
+
}
|
|
19
|
+
export const alertKey = (at) => canonical(at);
|
|
20
|
+
/** Thresholds at or below used plus reserved, in declaration order. Callers drop recorded ones. */
|
|
21
|
+
export function reachedAlerts(b, used, reserved) {
|
|
22
|
+
const total = plus(used, reserved);
|
|
23
|
+
return (b.alerts ?? []).map((a) => a.at).filter((at) => !greater(alertThreshold(b, at), total));
|
|
24
|
+
}
|
|
25
|
+
/** Definition rules every adapter applies before a budget takes part in admission. */
|
|
26
|
+
export function validateBudget(b) {
|
|
27
|
+
if (b.onExceed !== "block" && b.onExceed !== "allow")
|
|
28
|
+
throw new InvalidInput("budget.onExceed", "expected block or allow");
|
|
29
|
+
if (b.limit)
|
|
30
|
+
validateQuantity(b.limit);
|
|
31
|
+
if (b.hardLimit !== undefined) {
|
|
32
|
+
validateQuantity(b.hardLimit);
|
|
33
|
+
if (b.onExceed !== "allow")
|
|
34
|
+
throw new InvalidInput("budget.hardLimit", "requires onExceed allow");
|
|
35
|
+
if (!b.limit || b.hardLimit.unit !== b.limit.unit || !greater(b.hardLimit, b.limit))
|
|
36
|
+
throw new InvalidInput("budget.hardLimit", "must exceed limit in the same unit");
|
|
37
|
+
}
|
|
38
|
+
const alerts = b.alerts ?? [];
|
|
39
|
+
if (alerts.length > 8)
|
|
40
|
+
throw new InvalidInput("budget.alerts", "at most eight thresholds");
|
|
41
|
+
let previous = null;
|
|
42
|
+
for (const { at } of alerts) {
|
|
43
|
+
if ("percent" in at) {
|
|
44
|
+
if (!Number.isSafeInteger(at.percent) || at.percent < 1 || at.percent > 100)
|
|
45
|
+
throw new InvalidInput("budget.alerts", "percent must be an integer from 1 to 100");
|
|
46
|
+
if (!b.limit)
|
|
47
|
+
throw new InvalidInput("budget.alerts", "percent requires a limit");
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
validateQuantity(at);
|
|
51
|
+
if (at.unit !== b.unit)
|
|
52
|
+
throw new InvalidInput("budget.alerts", "unit must match the budget");
|
|
53
|
+
}
|
|
54
|
+
const threshold = alertThreshold(b, at);
|
|
55
|
+
if (previous && !greater(threshold, previous))
|
|
56
|
+
throw new InvalidInput("budget.alerts", "thresholds must ascend");
|
|
57
|
+
previous = threshold;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
4
60
|
export function currentBudgets(s) {
|
|
5
61
|
const map = new Map();
|
|
6
62
|
for (const b of s.budgets) {
|
|
@@ -10,8 +66,14 @@ export function currentBudgets(s) {
|
|
|
10
66
|
}
|
|
11
67
|
return [...map.values()];
|
|
12
68
|
}
|
|
69
|
+
/** The budget version an operation snapshotted, or its current definition when that version is gone. */
|
|
70
|
+
export function budgetAt(s, namespace, epoch) {
|
|
71
|
+
return (s.budgets.find((b) => b.scope.namespace === namespace &&
|
|
72
|
+
b.id === epoch.budgetId &&
|
|
73
|
+
b.version === epoch.budgetVersion) ?? currentBudgets(s).find((b) => b.scope.namespace === namespace && b.id === epoch.budgetId));
|
|
74
|
+
}
|
|
13
75
|
export function matches(b, i) {
|
|
14
|
-
if (b.scope.namespace !== i.scope.namespace || (b.surface
|
|
76
|
+
if (b.scope.namespace !== i.scope.namespace || !surfaceMatches(b.surface, i.surface, i.source))
|
|
15
77
|
return false;
|
|
16
78
|
switch (b.scope.kind) {
|
|
17
79
|
case "principal":
|
|
@@ -24,46 +86,41 @@ export function matches(b, i) {
|
|
|
24
86
|
return canonical(b.scope.accessCredential) === canonical(i.scope.accessCredential);
|
|
25
87
|
case "platform_pool":
|
|
26
88
|
return i.platformPools?.includes(b.scope.poolId) ?? false;
|
|
89
|
+
case "tag":
|
|
90
|
+
return i.scope.tags?.includes(b.scope.tag) ?? false;
|
|
27
91
|
}
|
|
28
92
|
}
|
|
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
93
|
export function effective(op) {
|
|
39
94
|
return [...op.receipts]
|
|
40
95
|
.reverse()
|
|
41
96
|
.find((r) => !op.receipts.some((next) => next.supersedes === r.id));
|
|
42
97
|
}
|
|
43
|
-
|
|
44
|
-
|
|
98
|
+
/** Settled use and outstanding reservations of one budget epoch. */
|
|
99
|
+
export function usage(s, b, epoch) {
|
|
100
|
+
const projected = s.readBudgetUsage?.(b, epoch);
|
|
101
|
+
if (projected)
|
|
102
|
+
return projected;
|
|
45
103
|
let used = { value: 0n, scale: 0, unit: b.unit }, reserved = { ...used };
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
}
|
|
104
|
+
for (const op of s.operations.values()) {
|
|
105
|
+
if (op.scope.namespace !== b.scope.namespace ||
|
|
106
|
+
!op.budgetEpochs.some((e) => e.budgetId === b.id && e.epoch === epoch))
|
|
107
|
+
continue;
|
|
108
|
+
if (op.state === "settled") {
|
|
109
|
+
const m = effective(op)?.measurements.find((m) => m.unit === b.unit);
|
|
110
|
+
if (m?.quantity)
|
|
111
|
+
used = plus(used, m.quantity);
|
|
66
112
|
}
|
|
113
|
+
else if (op.state !== "released") {
|
|
114
|
+
const q = op.estimate.find((q) => q.unit === b.unit);
|
|
115
|
+
if (q)
|
|
116
|
+
reserved = plus(reserved, q);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return { used, reserved };
|
|
120
|
+
}
|
|
121
|
+
export function status(s, b) {
|
|
122
|
+
const epoch = resolveWindow(b.window, s.clock.now());
|
|
123
|
+
const { used, reserved } = usage(s, b, epoch.epoch);
|
|
67
124
|
const total = plus(used, reserved);
|
|
68
125
|
const remaining = b.limit ? plus(b.limit, { ...total, value: -total.value }) : null;
|
|
69
126
|
return { budget: b, epoch, used, reserved, remaining };
|
|
@@ -73,33 +130,77 @@ export function applicable(s, i) {
|
|
|
73
130
|
.filter((b) => matches(b, i))
|
|
74
131
|
.map((b) => status(s, b));
|
|
75
132
|
}
|
|
133
|
+
/** Which bound a projected total crosses and whether that denies admission. */
|
|
134
|
+
export function crossing(b, total) {
|
|
135
|
+
if (!b.limit || !greater(total, b.limit))
|
|
136
|
+
return null;
|
|
137
|
+
if (b.onExceed === "block")
|
|
138
|
+
return { boundary: "limit", denied: true };
|
|
139
|
+
if (b.hardLimit && greater(total, b.hardLimit))
|
|
140
|
+
return { boundary: "hardLimit", denied: true };
|
|
141
|
+
return { boundary: "limit", denied: false };
|
|
142
|
+
}
|
|
143
|
+
const candidates = (b, epoch, used, reserved) => reachedAlerts(b, used, reserved).map((at) => ({
|
|
144
|
+
budgetId: b.id,
|
|
145
|
+
budgetVersion: b.version,
|
|
146
|
+
epoch,
|
|
147
|
+
at,
|
|
148
|
+
used,
|
|
149
|
+
reserved,
|
|
150
|
+
}));
|
|
151
|
+
/** Records unseen crossings atomically with the calling command and returns only those. */
|
|
152
|
+
export function recordAlerts(s, namespace, reached) {
|
|
153
|
+
const recorded = [];
|
|
154
|
+
for (const a of reached) {
|
|
155
|
+
const k = canonical([namespace, a.budgetId, a.epoch, alertKey(a.at)]);
|
|
156
|
+
if (s.alerts.has(k))
|
|
157
|
+
continue;
|
|
158
|
+
s.alerts.add(k);
|
|
159
|
+
recorded.push(a);
|
|
160
|
+
}
|
|
161
|
+
return recorded;
|
|
162
|
+
}
|
|
163
|
+
/** Crossings reached by an operation's current figures in its snapshotted epochs. */
|
|
164
|
+
export function reachedBySettlement(s, op) {
|
|
165
|
+
return op.budgetEpochs.flatMap((e) => {
|
|
166
|
+
const b = budgetAt(s, op.scope.namespace, e);
|
|
167
|
+
if (!b?.alerts?.length)
|
|
168
|
+
return [];
|
|
169
|
+
const { used, reserved } = usage(s, b, e.epoch);
|
|
170
|
+
return candidates(b, e.epoch, used, reserved);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
76
173
|
export function admission(s, i, policy) {
|
|
77
174
|
const order = policy?.budgetOrder ?? defaultBudgetOrder;
|
|
78
175
|
const budgets = currentBudgets(s)
|
|
79
176
|
.filter((b) => matches(b, i))
|
|
80
177
|
.sort((a, b) => order.indexOf(a.scope.kind) - order.indexOf(b.scope.kind) || a.id.localeCompare(b.id));
|
|
81
|
-
const warnings = [], epochs = [];
|
|
178
|
+
const warnings = [], epochs = [], alerts = [];
|
|
179
|
+
budgets.forEach(validateBudget);
|
|
82
180
|
for (const b of budgets) {
|
|
83
181
|
const estimate = i.estimate.find((q) => q.unit === b.unit);
|
|
84
182
|
if (!estimate)
|
|
85
|
-
return { invalid: b.unit, warnings, epochs };
|
|
183
|
+
return { invalid: b.unit, warnings, epochs, alerts };
|
|
86
184
|
}
|
|
87
185
|
for (const b of budgets) {
|
|
88
186
|
const st = status(s, b);
|
|
89
187
|
epochs.push({ budgetId: b.id, budgetVersion: b.version, ...st.epoch });
|
|
90
188
|
const estimate = i.estimate.find((q) => q.unit === b.unit);
|
|
91
|
-
|
|
189
|
+
const reserved = plus(st.reserved, estimate), outcome = crossing(b, plus(st.used, reserved));
|
|
190
|
+
if (outcome) {
|
|
92
191
|
const exceeded = {
|
|
93
192
|
code: "allowance_exceeded",
|
|
94
193
|
budget: b,
|
|
194
|
+
boundary: outcome.boundary,
|
|
95
195
|
used: st.used,
|
|
96
196
|
reserved: st.reserved,
|
|
97
197
|
resetsAt: st.epoch.endsAt,
|
|
98
198
|
};
|
|
99
|
-
if (
|
|
100
|
-
return { exceeded, warnings, epochs };
|
|
199
|
+
if (outcome.denied)
|
|
200
|
+
return { exceeded, warnings, epochs, alerts };
|
|
101
201
|
warnings.push(exceeded);
|
|
102
202
|
}
|
|
203
|
+
alerts.push(...candidates(b, st.epoch.epoch, st.used, reserved));
|
|
103
204
|
}
|
|
104
|
-
return { warnings, epochs };
|
|
205
|
+
return { warnings, epochs, alerts };
|
|
105
206
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { AdmissionPolicy } from "@usagekit/core";
|
|
2
2
|
import type { ReserveInput, ReserveResult, Operation } from "@usagekit/core";
|
|
3
3
|
import type { State } from "./state.js";
|
|
4
|
-
export declare function reserve(s: State,
|
|
4
|
+
export declare function reserve(s: State, raw: ReserveInput, policy?: AdmissionPolicy): ReserveResult;
|
|
5
5
|
import type { DispatchIntentInput, DispatchGrant, LeaseRenewalInput, LeaseRenewal } from "@usagekit/core";
|
|
6
6
|
export declare function active(s: State, op: Operation): boolean;
|
|
7
7
|
export declare function touch(s: State, op: Operation): void;
|
package/dist/memory/commands.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { admission } from "./admission.js";
|
|
2
|
-
import { copy, key, semantics, validateQuantity, InvalidInput } from "./state.js";
|
|
3
|
-
export function reserve(s,
|
|
4
|
-
validTtl(
|
|
5
|
-
|
|
6
|
-
if (new Set(
|
|
1
|
+
import { admission, recordAlerts, reachedBySettlement } from "./admission.js";
|
|
2
|
+
import { copy, key, semantics, validateQuantity, withNormalizedTags, InvalidInput, } from "./state.js";
|
|
3
|
+
export function reserve(s, raw, policy) {
|
|
4
|
+
validTtl(raw.reservationTtlMs ?? 300000, "reservationTtlMs");
|
|
5
|
+
raw.estimate.forEach(validateQuantity);
|
|
6
|
+
if (new Set(raw.estimate.map((q) => q.unit)).size !== raw.estimate.length)
|
|
7
7
|
throw new InvalidInput("estimate", "duplicate unit");
|
|
8
|
+
const i = withNormalizedTags(raw);
|
|
8
9
|
expireReservations(s, { namespace: i.scope.namespace });
|
|
9
10
|
const k = key(i.scope.namespace, i.operationId), existing = s.operations.get(k);
|
|
10
11
|
if (existing)
|
|
@@ -13,9 +14,12 @@ export function reserve(s, i, policy) {
|
|
|
13
14
|
outcome: "reserved",
|
|
14
15
|
replayed: true,
|
|
15
16
|
warnings: copy(s.warnings.get(k) ?? []),
|
|
17
|
+
alerts: copy(s.reserveAlerts.get(k) ?? []),
|
|
16
18
|
operation: copy(existing),
|
|
17
19
|
}
|
|
18
20
|
: { outcome: "conflict", reason: "semantic_mismatch", operation: copy(existing) };
|
|
21
|
+
if (s.requestCommands?.has(k))
|
|
22
|
+
throw new InvalidInput("operationId", "already counted without a reservation");
|
|
19
23
|
const check = admission(s, i, policy);
|
|
20
24
|
if (check.invalid)
|
|
21
25
|
return {
|
|
@@ -41,10 +45,13 @@ export function reserve(s, i, policy) {
|
|
|
41
45
|
s.operations.set(k, operation);
|
|
42
46
|
s.identities.set(k, semantics(i));
|
|
43
47
|
s.warnings.set(k, copy(check.warnings));
|
|
48
|
+
const alerts = recordAlerts(s, i.scope.namespace, check.alerts);
|
|
49
|
+
s.reserveAlerts.set(k, copy(alerts));
|
|
44
50
|
return {
|
|
45
51
|
outcome: "reserved",
|
|
46
52
|
replayed: false,
|
|
47
53
|
warnings: copy(check.warnings),
|
|
54
|
+
alerts: copy(alerts),
|
|
48
55
|
operation: copy(operation),
|
|
49
56
|
};
|
|
50
57
|
}
|
|
@@ -187,7 +194,13 @@ export function settle(s, i, correction = false) {
|
|
|
187
194
|
: "settled";
|
|
188
195
|
op.lease = null;
|
|
189
196
|
touch(s, op);
|
|
190
|
-
const
|
|
197
|
+
const alerts = recordAlerts(s, op.scope.namespace, reachedBySettlement(s, op));
|
|
198
|
+
const result = {
|
|
199
|
+
outcome: "settled",
|
|
200
|
+
replayed: false,
|
|
201
|
+
operation: copy(op),
|
|
202
|
+
alerts: copy(alerts),
|
|
203
|
+
};
|
|
191
204
|
s.commands.set(ck, { identity, result: copy(result) });
|
|
192
205
|
return result;
|
|
193
206
|
}
|