@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.
@@ -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,11 +1,14 @@
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
+ 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 = {
6
7
  clock,
7
8
  budgets,
8
9
  warnings: new Map(),
10
+ reserveAlerts: new Map(),
11
+ alerts: new Set(),
9
12
  leaseKinds: new Map(),
10
13
  commands: new Map(),
11
14
  cursors: new Map(),
@@ -22,7 +25,10 @@ export function createMemoryStore({ clock, budgets }) {
22
25
  settle: async (i) => settle(state, i),
23
26
  correct: async (i) => settle(state, i, true),
24
27
  releaseUndispatched: async (i) => release(state, i),
28
+ countRequest: async (i) => countRequest(state, i),
29
+ requestCounts: async (q) => requestCounts(state, q),
25
30
  aggregate: async (q) => aggregate(state, q),
31
+ listOperations: async (q) => listOperations(state, q),
26
32
  definedBudgets: async (q) => definedBudgets(state, q),
27
33
  applicableBudgets: async (q) => applicableBudgets(state, q),
28
34
  };
@@ -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;
@@ -63,41 +63,50 @@ function rows(s, q) {
63
63
  ? op.platformPools
64
64
  : [""]
65
65
  : [""];
66
- for (const pool of pools) {
67
- const dimensions = {};
68
- for (const dimension of q.groupBy) {
69
- switch (dimension) {
70
- case "principal":
71
- dimensions[dimension] = op.scope.principal;
72
- break;
73
- case "connection":
74
- dimensions[dimension] = op.scope.connection;
75
- break;
76
- case "day":
77
- dimensions[dimension] = new Date(receipt.occurredAt).toISOString().slice(0, 10);
78
- break;
79
- case "access_credential":
80
- dimensions[dimension] = op.scope.accessCredential
81
- ? canonical([op.scope.accessCredential.kind, op.scope.accessCredential.id])
82
- : "";
83
- break;
84
- case "platform_pool":
85
- dimensions[dimension] = pool;
86
- break;
87
- default:
88
- dimensions[dimension] = op[dimension];
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
+ }
@@ -1,9 +1,10 @@
1
1
  import type { DispatchGrant } from "@usagekit/core";
2
2
  import type { UsageRow } from "@usagekit/core";
3
3
  import type { SettleResult, ReleaseResult } from "@usagekit/core";
4
- import type { AllowanceExceeded } from "@usagekit/core";
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,10 @@ 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;
16
+ /** Reserve identity: diagnostic ids, estimate provenance and attribution tags are not semantic. */
15
17
  export declare function semantics(i: ReserveInput): string;
18
+ /** Sorted tags on the stored input, or an InvalidInput when the list breaks the tag rules. */
19
+ export declare function withNormalizedTags(i: ReserveInput): ReserveInput;
16
20
  export declare const maxMoneyUnits: bigint;
17
21
  export declare function validateQuantity(q: Quantity): void;
18
22
  export type State = {
@@ -28,6 +32,14 @@ export type State = {
28
32
  watermark: string;
29
33
  offset: number;
30
34
  }>;
35
+ /** Operation listing snapshots: membership and order fixed by the first page. */
36
+ operationCursors?: Map<string, {
37
+ query: string;
38
+ keys: string[];
39
+ asOf: string;
40
+ watermark: string;
41
+ offset: number;
42
+ }>;
31
43
  clock: Clock;
32
44
  budgets: Budget[];
33
45
  operations: Map<string, Operation>;
@@ -38,5 +50,13 @@ export type State = {
38
50
  }>;
39
51
  leaseKinds: Map<string, "lease" | "recovery">;
40
52
  warnings: Map<string, readonly AllowanceExceeded[]>;
53
+ /** Crossings each reserve reported, replayed verbatim. */
54
+ reserveAlerts: Map<string, readonly BudgetAlertCrossed[]>;
55
+ /** Recorded crossings keyed by namespace, budget id, epoch and threshold. */
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>;
41
61
  };
42
62
  export declare function find(s: State, ref: OperationRef): Operation | null;
@@ -1,3 +1,4 @@
1
+ import { normalizeTags } from "@usagekit/core";
1
2
  export class InvalidInput extends Error {
2
3
  field;
3
4
  reason;
@@ -22,14 +23,26 @@ export function canonical(value) {
22
23
  return JSON.stringify(value) ?? "null";
23
24
  }
24
25
  export const key = (namespace, id) => canonical([namespace, id]);
26
+ /** Reserve identity: diagnostic ids, estimate provenance and attribution tags are not semantic. */
25
27
  export function semantics(i) {
26
- const { correlationId: _, parentOperationId: __, ...rest } = i;
28
+ const { correlationId: _, parentOperationId: __, estimateSource: _source, providerPriceVersion: _priceVersion, scope, ...rest } = i;
29
+ const { tags: ___, ...scopeIdentity } = scope;
27
30
  return canonical({
28
31
  ...rest,
32
+ scope: scopeIdentity,
29
33
  estimate: [...i.estimate].sort((a, b) => a.unit.localeCompare(b.unit)),
30
34
  platformPools: [...(i.platformPools ?? [])].sort(),
31
35
  });
32
36
  }
37
+ /** Sorted tags on the stored input, or an InvalidInput when the list breaks the tag rules. */
38
+ export function withNormalizedTags(i) {
39
+ if (i.scope.tags === undefined)
40
+ return i;
41
+ const tags = normalizeTags(i.scope.tags);
42
+ if (!tags)
43
+ throw new InvalidInput("scope.tags", "1 to 16 unique tags matching the tag pattern");
44
+ return { ...i, scope: { ...i.scope, tags } };
45
+ }
33
46
  export const maxMoneyUnits = 2n ** 63n - 1n;
34
47
  export function validateQuantity(q) {
35
48
  if (typeof q.value !== "bigint" ||
@@ -1,6 +1,6 @@
1
1
  /** Synchronous reference rules shared by adapters inside their own atomic transactions. */
2
2
  export { reserve, intent, renew, claim, settle, release, expireReservations, } from "./memory/commands.js";
3
- export { aggregate, definedBudgets, applicableBudgets } from "./memory/reads.js";
3
+ export { aggregate, definedBudgets, applicableBudgets, listOperations } from "./memory/reads.js";
4
4
  export { copy, find, key, canonical, InvalidInput } from "./memory/state.js";
5
5
  export type { State } from "./memory/state.js";
6
- export { plus, effective, currentBudgets } from "./memory/admission.js";
6
+ export { plus, effective, currentBudgets, validateBudget, crossing, reachedAlerts, alertKey, alertThreshold, } from "./memory/admission.js";
package/dist/reference.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Synchronous reference rules shared by adapters inside their own atomic transactions. */
2
2
  export { reserve, intent, renew, claim, settle, release, expireReservations, } from "./memory/commands.js";
3
- export { aggregate, definedBudgets, applicableBudgets } from "./memory/reads.js";
3
+ export { aggregate, definedBudgets, applicableBudgets, listOperations } from "./memory/reads.js";
4
4
  export { copy, find, key, canonical, InvalidInput } from "./memory/state.js";
5
- export { plus, effective, currentBudgets } from "./memory/admission.js";
5
+ export { plus, effective, currentBudgets, validateBudget, crossing, reachedAlerts, alertKey, alertThreshold, } from "./memory/admission.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usagekit/store",
3
- "version": "0.1.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.1.0"
34
+ "@usagekit/core": "0.4.0"
35
35
  },
36
36
  "sideEffects": false,
37
37
  "publishConfig": {