@usagekit/store 0.1.0 → 0.3.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/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 +6 -0
- package/dist/conformance/tags.d.ts +2 -0
- package/dist/conformance/tags.js +101 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -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 +18 -7
- package/dist/memory/memory-store.js +4 -1
- package/dist/memory/reads.d.ts +3 -1
- package/dist/memory/reads.js +87 -33
- package/dist/memory/state.d.ts +16 -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
|
@@ -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 } 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.
|
|
@@ -22,6 +22,7 @@ export interface Store {
|
|
|
22
22
|
releaseUndispatched(input: ReleaseInput): Promise<ReleaseResult>;
|
|
23
23
|
getOperation(input: OperationRef): Promise<Operation | null>;
|
|
24
24
|
aggregate(query: UsageQuery): Promise<UsagePage>;
|
|
25
|
+
listOperations(query: OperationsQuery): Promise<OperationsPage>;
|
|
25
26
|
definedBudgets(query: DefinedBudgetsQuery): Promise<readonly Budget[]>;
|
|
26
27
|
applicableBudgets(query: ApplicableBudgetsQuery): Promise<readonly BudgetStatus[]>;
|
|
27
28
|
}
|
|
@@ -29,3 +30,4 @@ export { createMemoryStore } from "./memory/memory-store.js";
|
|
|
29
30
|
export { createManualClock } from "./clock.js";
|
|
30
31
|
export type { Clock, ManualClock } from "./clock.js";
|
|
31
32
|
export { InvalidInput } from "./memory/state.js";
|
|
33
|
+
export { validateBudget, reachedAlerts, alertKey } from "./memory/admission.js";
|
package/dist/index.js
CHANGED
|
@@ -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,6 +14,7 @@ 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) };
|
|
@@ -41,10 +43,13 @@ export function reserve(s, i, policy) {
|
|
|
41
43
|
s.operations.set(k, operation);
|
|
42
44
|
s.identities.set(k, semantics(i));
|
|
43
45
|
s.warnings.set(k, copy(check.warnings));
|
|
46
|
+
const alerts = recordAlerts(s, i.scope.namespace, check.alerts);
|
|
47
|
+
s.reserveAlerts.set(k, copy(alerts));
|
|
44
48
|
return {
|
|
45
49
|
outcome: "reserved",
|
|
46
50
|
replayed: false,
|
|
47
51
|
warnings: copy(check.warnings),
|
|
52
|
+
alerts: copy(alerts),
|
|
48
53
|
operation: copy(operation),
|
|
49
54
|
};
|
|
50
55
|
}
|
|
@@ -187,7 +192,13 @@ export function settle(s, i, correction = false) {
|
|
|
187
192
|
: "settled";
|
|
188
193
|
op.lease = null;
|
|
189
194
|
touch(s, op);
|
|
190
|
-
const
|
|
195
|
+
const alerts = recordAlerts(s, op.scope.namespace, reachedBySettlement(s, op));
|
|
196
|
+
const result = {
|
|
197
|
+
outcome: "settled",
|
|
198
|
+
replayed: false,
|
|
199
|
+
operation: copy(op),
|
|
200
|
+
alerts: copy(alerts),
|
|
201
|
+
};
|
|
191
202
|
s.commands.set(ck, { identity, result: copy(result) });
|
|
192
203
|
return result;
|
|
193
204
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { aggregate, definedBudgets, applicableBudgets } from "./reads.js";
|
|
1
|
+
import { aggregate, definedBudgets, applicableBudgets, listOperations } from "./reads.js";
|
|
2
2
|
import { copy, find } from "./state.js";
|
|
3
3
|
import { reserve, intent, renew, settle, release, claim, expireReservations } from "./commands.js";
|
|
4
4
|
export function createMemoryStore({ clock, budgets }) {
|
|
@@ -6,6 +6,8 @@ export function createMemoryStore({ clock, budgets }) {
|
|
|
6
6
|
clock,
|
|
7
7
|
budgets,
|
|
8
8
|
warnings: new Map(),
|
|
9
|
+
reserveAlerts: new Map(),
|
|
10
|
+
alerts: new Set(),
|
|
9
11
|
leaseKinds: new Map(),
|
|
10
12
|
commands: new Map(),
|
|
11
13
|
cursors: new Map(),
|
|
@@ -23,6 +25,7 @@ export function createMemoryStore({ clock, budgets }) {
|
|
|
23
25
|
correct: async (i) => settle(state, i, true),
|
|
24
26
|
releaseUndispatched: async (i) => release(state, i),
|
|
25
27
|
aggregate: async (q) => aggregate(state, q),
|
|
28
|
+
listOperations: async (q) => listOperations(state, q),
|
|
26
29
|
definedBudgets: async (q) => definedBudgets(state, q),
|
|
27
30
|
applicableBudgets: async (q) => applicableBudgets(state, q),
|
|
28
31
|
};
|
package/dist/memory/reads.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import type { UsageQuery, UsagePage, DefinedBudgetsQuery, ApplicableBudgetsQuery } from "@usagekit/core";
|
|
1
|
+
import type { UsageQuery, UsagePage, DefinedBudgetsQuery, ApplicableBudgetsQuery, OperationsQuery, OperationsPage } from "@usagekit/core";
|
|
2
2
|
import type { State } from "./state.js";
|
|
3
3
|
export declare const definedBudgets: (s: State, q: DefinedBudgetsQuery) => import("@usagekit/core").Budget[];
|
|
4
4
|
export declare const applicableBudgets: (s: State, q: ApplicableBudgetsQuery) => import("@usagekit/core").BudgetStatus[];
|
|
5
5
|
export declare function aggregate(s: State, q: UsageQuery): UsagePage;
|
|
6
|
+
/** Mirrors aggregate: the first page fixes membership and order; pages return current records. */
|
|
7
|
+
export declare function listOperations(s: State, q: OperationsQuery): OperationsPage;
|
package/dist/memory/reads.js
CHANGED
|
@@ -63,41 +63,50 @@ function rows(s, q) {
|
|
|
63
63
|
? op.platformPools
|
|
64
64
|
: [""]
|
|
65
65
|
: [""];
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
66
|
+
// Pool and tag rows explode per value; an operation without any contributes one empty row.
|
|
67
|
+
const tags = q.groupBy.includes("tag") && op.scope.tags?.length ? op.scope.tags : [""];
|
|
68
|
+
for (const pool of pools)
|
|
69
|
+
for (const tag of tags) {
|
|
70
|
+
const dimensions = {};
|
|
71
|
+
for (const dimension of q.groupBy) {
|
|
72
|
+
switch (dimension) {
|
|
73
|
+
case "principal":
|
|
74
|
+
dimensions[dimension] = op.scope.principal;
|
|
75
|
+
break;
|
|
76
|
+
case "connection":
|
|
77
|
+
dimensions[dimension] = op.scope.connection;
|
|
78
|
+
break;
|
|
79
|
+
case "day":
|
|
80
|
+
dimensions[dimension] = new Date(receipt.occurredAt).toISOString().slice(0, 10);
|
|
81
|
+
break;
|
|
82
|
+
case "access_credential":
|
|
83
|
+
dimensions[dimension] = op.scope.accessCredential
|
|
84
|
+
? canonical([op.scope.accessCredential.kind, op.scope.accessCredential.id])
|
|
85
|
+
: "";
|
|
86
|
+
break;
|
|
87
|
+
case "platform_pool":
|
|
88
|
+
dimensions[dimension] = pool;
|
|
89
|
+
break;
|
|
90
|
+
case "funding_source":
|
|
91
|
+
dimensions[dimension] = op.fundingSource;
|
|
92
|
+
break;
|
|
93
|
+
case "tag":
|
|
94
|
+
dimensions[dimension] = tag;
|
|
95
|
+
break;
|
|
96
|
+
default:
|
|
97
|
+
dimensions[dimension] = op[dimension];
|
|
98
|
+
}
|
|
89
99
|
}
|
|
100
|
+
const k = canonical([dimensions, op.fundingSource, op.costOwner]), prev = map.get(k), measurements = receipt.measurements.filter((m) => q.units.includes(m.unit));
|
|
101
|
+
map.set(k, {
|
|
102
|
+
dimensions,
|
|
103
|
+
fundingSource: op.fundingSource,
|
|
104
|
+
costOwner: op.costOwner,
|
|
105
|
+
measurements: combineMeasurements(prev?.measurements ?? [], measurements),
|
|
106
|
+
cost: prev ? combineCost(prev.cost, receipt.cost) : copy(receipt.cost),
|
|
107
|
+
unknownOperations: (prev?.unknownOperations ?? 0n) + (op.state === "pending" ? 1n : 0n),
|
|
108
|
+
});
|
|
90
109
|
}
|
|
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
110
|
}
|
|
102
111
|
return [...map.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([, r]) => r);
|
|
103
112
|
}
|
|
@@ -129,3 +138,48 @@ export function aggregate(s, q) {
|
|
|
129
138
|
}
|
|
130
139
|
return page;
|
|
131
140
|
}
|
|
141
|
+
const byCreation = (a, b) => a.createdAt === b.createdAt
|
|
142
|
+
? a.operationId < b.operationId
|
|
143
|
+
? -1
|
|
144
|
+
: 1
|
|
145
|
+
: Date.parse(a.createdAt) - Date.parse(b.createdAt);
|
|
146
|
+
/** Mirrors aggregate: the first page fixes membership and order; pages return current records. */
|
|
147
|
+
export function listOperations(s, q) {
|
|
148
|
+
const { cursor, ...query } = q;
|
|
149
|
+
const identity = canonical(query), cursors = (s.operationCursors ??= new Map());
|
|
150
|
+
let snapshot;
|
|
151
|
+
if (cursor) {
|
|
152
|
+
snapshot = cursors.get(cursor);
|
|
153
|
+
if (!snapshot || snapshot.query !== identity)
|
|
154
|
+
throw new InvalidInput("cursor", "unknown cursor or query mismatch");
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
const from = Date.parse(q.from), to = Date.parse(q.to);
|
|
158
|
+
const members = [...s.operations.values()]
|
|
159
|
+
.filter((op) => {
|
|
160
|
+
const created = Date.parse(op.createdAt);
|
|
161
|
+
return (inScope(op, q) &&
|
|
162
|
+
created >= from &&
|
|
163
|
+
created < to &&
|
|
164
|
+
(q.states === undefined || q.states.includes(op.state)));
|
|
165
|
+
})
|
|
166
|
+
.sort(byCreation);
|
|
167
|
+
snapshot = {
|
|
168
|
+
query: identity,
|
|
169
|
+
keys: members.map((op) => canonical([op.scope.namespace, op.operationId])),
|
|
170
|
+
asOf: s.clock.now().toISOString(),
|
|
171
|
+
watermark: crypto.randomUUID(),
|
|
172
|
+
offset: 0,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
const end = snapshot.offset + (q.limit ?? 1000), page = {
|
|
176
|
+
operations: snapshot.keys.slice(snapshot.offset, end).map((k) => copy(s.operations.get(k))),
|
|
177
|
+
asOf: snapshot.asOf,
|
|
178
|
+
watermark: snapshot.watermark,
|
|
179
|
+
};
|
|
180
|
+
if (end < snapshot.keys.length) {
|
|
181
|
+
page.nextCursor = crypto.randomUUID();
|
|
182
|
+
cursors.set(page.nextCursor, { ...snapshot, offset: end });
|
|
183
|
+
}
|
|
184
|
+
return page;
|
|
185
|
+
}
|