@usagekit/store 0.3.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.
@@ -0,0 +1,2 @@
1
+ import type { StoreFactory } from "./factory.js";
2
+ export declare function counterTests(factory: StoreFactory): void;
@@ -0,0 +1,159 @@
1
+ import { beforeEach, afterEach, describe, expect, test } from "vitest";
2
+ import { budget, input, quantity } from "./helpers.js";
3
+ export function counterTests(factory) {
4
+ describe("request counter", () => {
5
+ let f;
6
+ beforeEach(async () => {
7
+ f = await factory();
8
+ });
9
+ afterEach(async () => f.close());
10
+ const count = (overrides = {}) => ({
11
+ commandId: crypto.randomUUID(),
12
+ scope: { namespace: "test", principal: "u1", connection: "c1" },
13
+ surface: "programmatic",
14
+ source: "proxy",
15
+ provider: "search",
16
+ operation: "search",
17
+ state: "passthrough",
18
+ ...overrides,
19
+ });
20
+ const month = { from: "2026-09-01T00:00:00.000Z", to: "2026-10-01T00:00:00.000Z" };
21
+ const principal = { kind: "principal", namespace: "test", principal: "u1" };
22
+ const query = (overrides = {}) => ({
23
+ scope: principal,
24
+ ...month,
25
+ groupBy: [],
26
+ ...overrides,
27
+ });
28
+ test("counted request ids cannot become reservations after a policy change", async () => {
29
+ const i = input();
30
+ await f.store.countRequest(count({ commandId: i.operationId, scope: i.scope }));
31
+ await expect(f.store.reserve(i)).rejects.toThrow("operationId");
32
+ const next = input();
33
+ expect(await f.store.reserve(next)).toMatchObject({ outcome: "reserved" });
34
+ expect(await f.store.countRequest(count({ commandId: next.operationId, scope: next.scope }))).toMatchObject({ outcome: "conflict" });
35
+ });
36
+ test("counts each state separately and reads them back", async () => {
37
+ for (const state of [
38
+ "passthrough",
39
+ "passthrough",
40
+ "unpriced",
41
+ "cached",
42
+ "rate_limited",
43
+ ])
44
+ expect(await f.store.countRequest(count({ state }))).toEqual({
45
+ outcome: "counted",
46
+ replayed: false,
47
+ });
48
+ const page = await f.store.requestCounts(query());
49
+ expect(page.rows).toEqual([
50
+ { dimensions: {}, state: "cached", count: 1n },
51
+ { dimensions: {}, state: "passthrough", count: 2n },
52
+ { dimensions: {}, state: "rate_limited", count: 1n },
53
+ { dimensions: {}, state: "unpriced", count: 1n },
54
+ ]);
55
+ expect(page.truncated).toBe(false);
56
+ expect(page.asOf).toBe(f.clock.now().toISOString());
57
+ });
58
+ test("an identical replay counts once; a changed payload under the same command conflicts", async () => {
59
+ const c = count();
60
+ await f.store.countRequest(c);
61
+ expect(await f.store.countRequest(c)).toEqual({ outcome: "counted", replayed: true });
62
+ expect(await f.store.countRequest({ ...c, state: "cached" })).toEqual({
63
+ outcome: "conflict",
64
+ reason: "command_mismatch",
65
+ });
66
+ expect((await f.store.requestCounts(query())).rows).toEqual([
67
+ { dimensions: {}, state: "passthrough", count: 1n },
68
+ ]);
69
+ });
70
+ test("the window is [from, to) at store time", async () => {
71
+ f.clock.set("2026-09-30T23:59:59.999Z");
72
+ await f.store.countRequest(count());
73
+ f.clock.set("2026-10-01T00:00:00.000Z");
74
+ await f.store.countRequest(count());
75
+ expect((await f.store.requestCounts(query())).rows).toEqual([
76
+ { dimensions: {}, state: "passthrough", count: 1n },
77
+ ]);
78
+ expect((await f.store.requestCounts(query({ from: "2026-10-01T00:00:00.000Z", to: "2026-10-02T00:00:00.000Z" }))).rows).toEqual([{ dimensions: {}, state: "passthrough", count: 1n }]);
79
+ });
80
+ test("scopes select principal, group, namespace and pool; connection filters", async () => {
81
+ await f.store.countRequest(count());
82
+ await f.store.countRequest(count({
83
+ scope: { namespace: "test", principal: "u2", group: "g1", connection: "c2" },
84
+ platformPools: ["pool_a"],
85
+ }));
86
+ await f.store.countRequest(count({ scope: { namespace: "other", principal: "u1", connection: "c1" } }));
87
+ const total = async (q) => (await f.store.requestCounts(query(q))).rows.reduce((a, r) => a + r.count, 0n);
88
+ expect(await total({})).toBe(1n);
89
+ expect(await total({ scope: { kind: "group", namespace: "test", group: "g1" } })).toBe(1n);
90
+ expect(await total({ scope: { kind: "namespace", namespace: "test" } })).toBe(2n);
91
+ expect(await total({ scope: { kind: "namespace", namespace: "test" }, connection: "c2" })).toBe(1n);
92
+ expect(await total({ scope: { kind: "platform_pool", namespace: "test", poolId: "pool_a" } })).toBe(1n);
93
+ expect(await total({ scope: { kind: "platform_pool", namespace: "test", poolId: "pool_b" } })).toBe(0n);
94
+ });
95
+ test("groupBy provider, operation, connection, source and day", async () => {
96
+ await f.store.countRequest(count({ operation: "a" }));
97
+ await f.store.countRequest(count({ operation: "b", source: "cli" }));
98
+ f.clock.advance(86400000);
99
+ await f.store.countRequest(count({ operation: "a", state: "unpriced" }));
100
+ const rows = (await f.store.requestCounts(query({ groupBy: ["provider", "operation", "connection", "source", "day"] }))).rows;
101
+ expect(rows).toEqual([
102
+ {
103
+ dimensions: {
104
+ provider: "search",
105
+ operation: "a",
106
+ connection: "c1",
107
+ source: "proxy",
108
+ day: "2026-09-23",
109
+ },
110
+ state: "passthrough",
111
+ count: 1n,
112
+ },
113
+ {
114
+ dimensions: {
115
+ provider: "search",
116
+ operation: "a",
117
+ connection: "c1",
118
+ source: "proxy",
119
+ day: "2026-09-24",
120
+ },
121
+ state: "unpriced",
122
+ count: 1n,
123
+ },
124
+ {
125
+ dimensions: {
126
+ provider: "search",
127
+ operation: "b",
128
+ connection: "c1",
129
+ source: "cli",
130
+ day: "2026-09-23",
131
+ },
132
+ state: "passthrough",
133
+ count: 1n,
134
+ },
135
+ ]);
136
+ });
137
+ test("limit truncates the ordered rows", async () => {
138
+ for (const operation of ["c", "a", "b"])
139
+ await f.store.countRequest(count({ operation }));
140
+ const page = await f.store.requestCounts(query({ groupBy: ["operation"], limit: 2 }));
141
+ expect(page.rows.map((r) => r.dimensions.operation)).toEqual(["a", "b"]);
142
+ expect(page.truncated).toBe(true);
143
+ });
144
+ test("counting never reserves or touches budgets", async () => {
145
+ f.budgets.push(budget({ limit: quantity(0n) }));
146
+ expect(await f.store.countRequest(count())).toMatchObject({ outcome: "counted" });
147
+ expect(await f.store.reserve(input())).toMatchObject({ outcome: "exceeded" });
148
+ const statuses = await f.store.applicableBudgets({
149
+ scope: { namespace: "test", principal: "u1", connection: "c1" },
150
+ surface: "app",
151
+ units: ["requests"],
152
+ });
153
+ expect(statuses[0]).toMatchObject({ used: quantity(0n), reserved: quantity(0n) });
154
+ });
155
+ test("an unknown state is a validation failure", async () => {
156
+ await expect(f.store.countRequest(count({ state: "metered" }))).rejects.toThrow("InvalidInput");
157
+ });
158
+ });
159
+ }
@@ -11,6 +11,7 @@ import { readsTests } from "./reads.js";
11
11
  import { operationsTests } from "./operations.js";
12
12
  import { propertyTests } from "./properties.js";
13
13
  import { capabilityTests } from "./capabilities.js";
14
+ import { counterTests } from "./counters.js";
14
15
  export function runStoreConformance(factory, capabilities) {
15
16
  describe("store conformance", () => {
16
17
  reserveTests(factory);
@@ -23,6 +24,7 @@ export function runStoreConformance(factory, capabilities) {
23
24
  recoveryTests(factory);
24
25
  readsTests(factory, capabilities);
25
26
  operationsTests(factory);
27
+ counterTests(factory);
26
28
  propertyTests(factory);
27
29
  capabilityTests(factory, capabilities);
28
30
  afterAll(() => console.info("Skipped guarantees: " +
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, OperationsQuery, OperationsPage } 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,9 +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>;
25
27
  listOperations(query: OperationsQuery): Promise<OperationsPage>;
28
+ requestCounts(query: RequestCountsQuery): Promise<RequestCountsPage>;
26
29
  definedBudgets(query: DefinedBudgetsQuery): Promise<readonly Budget[]>;
27
30
  applicableBudgets(query: ApplicableBudgetsQuery): Promise<readonly BudgetStatus[]>;
28
31
  }
@@ -31,3 +34,5 @@ export { createManualClock } from "./clock.js";
31
34
  export type { Clock, ManualClock } from "./clock.js";
32
35
  export { InvalidInput } from "./memory/state.js";
33
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
@@ -2,3 +2,4 @@ export { createMemoryStore } from "./memory/memory-store.js";
2
2
  export { createManualClock } from "./clock.js";
3
3
  export { InvalidInput } from "./memory/state.js";
4
4
  export { validateBudget, reachedAlerts, alertKey } from "./memory/admission.js";
5
+ export { validateCountRequest, validateRequestCountsQuery, countIdentity, countRows, bucketOf, requestCountDimensions, } from "./memory/counters.js";
@@ -18,6 +18,8 @@ export function reserve(s, raw, policy) {
18
18
  operation: copy(existing),
19
19
  }
20
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");
21
23
  const check = admission(s, i, policy);
22
24
  if (check.invalid)
23
25
  return {
@@ -0,0 +1,33 @@
1
+ import type { CountRequestInput, CountRequestResult, RequestCountsPage, RequestCountsQuery } from "@usagekit/core";
2
+ import type { State } from "./state.js";
3
+ /** Dimensions a request count can be grouped by. */
4
+ export declare const requestCountDimensions: string[];
5
+ /** Throws InvalidInput for a malformed count; every adapter calls it before writing. */
6
+ export declare function validateCountRequest(i: CountRequestInput): void;
7
+ /** Replay identity of a count: the whole input, pools sorted. */
8
+ export declare const countIdentity: (i: CountRequestInput) => string;
9
+ /**
10
+ * Counts are daily UTC buckets, so a window must start and end at UTC midnight; a narrower
11
+ * window would silently widen to whole days.
12
+ */
13
+ export declare function validateRequestCountsQuery(q: RequestCountsQuery): void;
14
+ /** One stored bucket: the counted fields and the UTC day. */
15
+ export type CountBucket = {
16
+ namespace: string;
17
+ principal: string;
18
+ group: string;
19
+ connection: string;
20
+ pools: readonly string[];
21
+ day: string;
22
+ provider: string;
23
+ operation: string;
24
+ source: string;
25
+ state: CountRequestInput["state"];
26
+ count: bigint;
27
+ };
28
+ export declare const bucketOf: (i: CountRequestInput, now: Date) => Omit<CountBucket, "count">;
29
+ export declare function bucketInScope(b: Omit<CountBucket, "count">, q: RequestCountsQuery): boolean;
30
+ /** Groups buckets into rows ordered by dimension values in groupBy order, then state. */
31
+ export declare function countRows(buckets: Iterable<CountBucket>, q: RequestCountsQuery, asOf: string): RequestCountsPage;
32
+ export declare function countRequest(s: State, i: CountRequestInput): CountRequestResult;
33
+ export declare function requestCounts(s: State, q: RequestCountsQuery): RequestCountsPage;
@@ -0,0 +1,113 @@
1
+ import { requestStates } from "@usagekit/core";
2
+ import { canonical, copy, InvalidInput, key } from "./state.js";
3
+ /** Dimensions a request count can be grouped by. */
4
+ export const requestCountDimensions = ["provider", "operation", "connection", "source", "day"];
5
+ const dayMs = 86400000;
6
+ /** Throws InvalidInput for a malformed count; every adapter calls it before writing. */
7
+ export function validateCountRequest(i) {
8
+ for (const [field, value] of [
9
+ ["commandId", i.commandId],
10
+ ["scope.namespace", i.scope?.namespace],
11
+ ["scope.principal", i.scope?.principal],
12
+ ["scope.connection", i.scope?.connection],
13
+ ["provider", i.provider],
14
+ ["operation", i.operation],
15
+ ])
16
+ if (typeof value !== "string" || !value.trim())
17
+ throw new InvalidInput(field, "empty string");
18
+ if (!requestStates.includes(i.state))
19
+ throw new InvalidInput("state", "unknown request state");
20
+ }
21
+ /** Replay identity of a count: the whole input, pools sorted. */
22
+ export const countIdentity = (i) => canonical({ ...i, platformPools: [...(i.platformPools ?? [])].sort() });
23
+ /**
24
+ * Counts are daily UTC buckets, so a window must start and end at UTC midnight; a narrower
25
+ * window would silently widen to whole days.
26
+ */
27
+ export function validateRequestCountsQuery(q) {
28
+ const from = Date.parse(q.from), to = Date.parse(q.to);
29
+ if (!Number.isFinite(from) || !Number.isFinite(to) || from > to)
30
+ throw new InvalidInput("window", "invalid time interval");
31
+ if (from % dayMs !== 0 || to % dayMs !== 0)
32
+ throw new InvalidInput("window", "request counts use whole UTC days");
33
+ if (q.limit !== undefined && (!Number.isInteger(q.limit) || q.limit < 1 || q.limit > 1000))
34
+ throw new InvalidInput("limit", "must be between 1 and 1000");
35
+ if (q.groupBy.some((d) => !requestCountDimensions.includes(d)))
36
+ throw new InvalidInput("groupBy", "unknown request count dimension");
37
+ }
38
+ export const bucketOf = (i, now) => ({
39
+ namespace: i.scope.namespace,
40
+ principal: i.scope.principal,
41
+ group: i.scope.group ?? "",
42
+ connection: i.scope.connection,
43
+ pools: [...(i.platformPools ?? [])].sort(),
44
+ day: now.toISOString().slice(0, 10),
45
+ provider: i.provider,
46
+ operation: i.operation,
47
+ source: i.source,
48
+ state: i.state,
49
+ });
50
+ export function bucketInScope(b, q) {
51
+ if (b.namespace !== q.scope.namespace || (q.connection && b.connection !== q.connection))
52
+ return false;
53
+ const day = Date.parse(`${b.day}T00:00:00.000Z`);
54
+ if (day < Date.parse(q.from) || day >= Date.parse(q.to))
55
+ return false;
56
+ switch (q.scope.kind) {
57
+ case "namespace":
58
+ return true;
59
+ case "principal":
60
+ return b.principal === q.scope.principal;
61
+ case "group":
62
+ return b.group === q.scope.group;
63
+ case "platform_pool":
64
+ return b.pools.includes(q.scope.poolId);
65
+ }
66
+ }
67
+ /** Groups buckets into rows ordered by dimension values in groupBy order, then state. */
68
+ export function countRows(buckets, q, asOf) {
69
+ const map = new Map();
70
+ for (const b of buckets) {
71
+ if (!bucketInScope(b, q))
72
+ continue;
73
+ const dimensions = {};
74
+ for (const d of q.groupBy)
75
+ dimensions[d] = b[d];
76
+ const order = [...q.groupBy.map((d) => dimensions[d]), b.state], k = canonical(order), prev = map.get(k);
77
+ map.set(k, { dimensions, state: b.state, count: (prev?.count ?? 0n) + b.count, order });
78
+ }
79
+ const sorted = [...map.values()].sort((a, b) => compare(a.order, b.order)), limit = q.limit ?? 1000;
80
+ return {
81
+ rows: sorted.slice(0, limit).map(({ dimensions, state, count }) => ({
82
+ dimensions,
83
+ state,
84
+ count,
85
+ })),
86
+ asOf,
87
+ truncated: sorted.length > limit,
88
+ };
89
+ }
90
+ function compare(a, b) {
91
+ for (let i = 0; i < a.length; i++)
92
+ if (a[i] !== b[i])
93
+ return a[i] < b[i] ? -1 : 1;
94
+ return 0;
95
+ }
96
+ export function countRequest(s, i) {
97
+ validateCountRequest(i);
98
+ const commandKey = key(i.scope.namespace, i.commandId), identity = countIdentity(i), commands = (s.requestCommands ??= new Map()), previous = commands.get(commandKey);
99
+ if (previous !== undefined)
100
+ return previous === identity
101
+ ? { outcome: "counted", replayed: true }
102
+ : { outcome: "conflict", reason: "command_mismatch" };
103
+ if (s.operations.has(commandKey))
104
+ return { outcome: "conflict", reason: "command_mismatch" };
105
+ const bucket = bucketOf(i, s.clock.now()), buckets = (s.requestBuckets ??= new Map()), bucketKey = canonical(bucket);
106
+ buckets.set(bucketKey, { ...bucket, count: (buckets.get(bucketKey)?.count ?? 0n) + 1n });
107
+ commands.set(commandKey, identity);
108
+ return { outcome: "counted", replayed: false };
109
+ }
110
+ export function requestCounts(s, q) {
111
+ validateRequestCountsQuery(q);
112
+ return copy(countRows(s.requestBuckets?.values() ?? [], q, s.clock.now().toISOString()));
113
+ }
@@ -1,5 +1,6 @@
1
1
  import { aggregate, definedBudgets, applicableBudgets, listOperations } from "./reads.js";
2
2
  import { copy, find } from "./state.js";
3
+ import { countRequest, requestCounts } from "./counters.js";
3
4
  import { reserve, intent, renew, settle, release, claim, expireReservations } from "./commands.js";
4
5
  export function createMemoryStore({ clock, budgets }) {
5
6
  const state = {
@@ -24,6 +25,8 @@ export function createMemoryStore({ clock, budgets }) {
24
25
  settle: async (i) => settle(state, i),
25
26
  correct: async (i) => settle(state, i, true),
26
27
  releaseUndispatched: async (i) => release(state, i),
28
+ countRequest: async (i) => countRequest(state, i),
29
+ requestCounts: async (q) => requestCounts(state, q),
27
30
  aggregate: async (q) => aggregate(state, q),
28
31
  listOperations: async (q) => listOperations(state, q),
29
32
  definedBudgets: async (q) => definedBudgets(state, q),
@@ -4,6 +4,7 @@ import type { SettleResult, ReleaseResult } from "@usagekit/core";
4
4
  import type { AllowanceExceeded, BudgetAlertCrossed } from "@usagekit/core";
5
5
  import type { Budget, Operation, ReserveInput, OperationRef, Quantity } from "@usagekit/core";
6
6
  import type { Clock } from "../clock.js";
7
+ import type { CountBucket } from "./counters.js";
7
8
  export declare class InvalidInput extends Error {
8
9
  readonly field: string;
9
10
  readonly reason: string;
@@ -12,7 +13,7 @@ export declare class InvalidInput extends Error {
12
13
  export declare const copy: <T>(value: T) => T;
13
14
  export declare function canonical(value: unknown): string;
14
15
  export declare const key: (namespace: string, id: string) => string;
15
- /** Reserve identity: diagnostic ids and attribution tags are not semantic. */
16
+ /** Reserve identity: diagnostic ids, estimate provenance and attribution tags are not semantic. */
16
17
  export declare function semantics(i: ReserveInput): string;
17
18
  /** Sorted tags on the stored input, or an InvalidInput when the list breaks the tag rules. */
18
19
  export declare function withNormalizedTags(i: ReserveInput): ReserveInput;
@@ -53,5 +54,9 @@ export type State = {
53
54
  reserveAlerts: Map<string, readonly BudgetAlertCrossed[]>;
54
55
  /** Recorded crossings keyed by namespace, budget id, epoch and threshold. */
55
56
  alerts: Set<string>;
57
+ /** Request counter: replay identities by namespace and command id. */
58
+ requestCommands?: Map<string, string>;
59
+ /** Request counter: daily buckets keyed by their canonical fields. */
60
+ requestBuckets?: Map<string, CountBucket>;
56
61
  };
57
62
  export declare function find(s: State, ref: OperationRef): Operation | null;
@@ -23,9 +23,9 @@ export function canonical(value) {
23
23
  return JSON.stringify(value) ?? "null";
24
24
  }
25
25
  export const key = (namespace, id) => canonical([namespace, id]);
26
- /** Reserve identity: diagnostic ids and attribution tags are not semantic. */
26
+ /** Reserve identity: diagnostic ids, estimate provenance and attribution tags are not semantic. */
27
27
  export function semantics(i) {
28
- const { correlationId: _, parentOperationId: __, scope, ...rest } = i;
28
+ const { correlationId: _, parentOperationId: __, estimateSource: _source, providerPriceVersion: _priceVersion, scope, ...rest } = i;
29
29
  const { tags: ___, ...scopeIdentity } = scope;
30
30
  return canonical({
31
31
  ...rest,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usagekit/store",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "Atomic store commands, reference store and conformance tests.",
@@ -31,7 +31,7 @@
31
31
  "prepack": "node ../../scripts/package-license.mjs"
32
32
  },
33
33
  "dependencies": {
34
- "@usagekit/core": "0.3.0"
34
+ "@usagekit/core": "0.4.0"
35
35
  },
36
36
  "sideEffects": false,
37
37
  "publishConfig": {