@usagekit/store 0.3.0 → 0.5.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,22 @@
1
+ import type { BillingImportInput, BillingImportRecord, BillingImportResult, BillingImportsPage, BillingImportsQuery, BillingOperationChange, Operation, ValidationFailure } from "@usagekit/core";
2
+ export type StoredBillingImport = {
3
+ identity: string;
4
+ record: BillingImportRecord;
5
+ };
6
+ export type BillingSnapshot = {
7
+ operations: readonly Operation[];
8
+ imports: readonly StoredBillingImport[];
9
+ latestImportId: string | null;
10
+ };
11
+ export type BillingPreparation = {
12
+ result: BillingImportResult;
13
+ changes: readonly BillingOperationChange[];
14
+ stored?: StoredBillingImport;
15
+ familyId: string;
16
+ };
17
+ export declare function validateBillingImportInput(i: BillingImportInput): ValidationFailure | null;
18
+ export declare function validateBillingImportsQuery(q: BillingImportsQuery): ValidationFailure | null;
19
+ export declare const billingFamilyId: (i: BillingImportInput) => string;
20
+ /** Pure preparation. Adapters commit every change, projection and journal in one transaction. */
21
+ export declare function prepareBillingImport(i: BillingImportInput, snapshot: BillingSnapshot, now: Date): BillingPreparation;
22
+ export declare function readBillingImports(records: readonly BillingImportRecord[], q: BillingImportsQuery, now: Date): BillingImportsPage;
@@ -0,0 +1,352 @@
1
+ import { normalizeTags } from "@usagekit/core";
2
+ import { canonical, copy, maxMoneyUnits } from "./memory/state.js";
3
+ const invalid = (field, reason) => ({
4
+ outcome: "invalid",
5
+ field,
6
+ reason,
7
+ });
8
+ const name = (value) => typeof value === "string" && value.trim().length > 0 && value.length <= 256;
9
+ const date = (value) => typeof value === "string" && Number.isFinite(Date.parse(value));
10
+ const interval = (value) => {
11
+ if (!value || typeof value !== "object")
12
+ return false;
13
+ const w = value;
14
+ return date(w.from) && date(w.to) && Date.parse(w.from) < Date.parse(w.to);
15
+ };
16
+ const normalizedWindow = (w) => ({
17
+ from: new Date(w.from).toISOString(),
18
+ to: new Date(w.to).toISOString(),
19
+ });
20
+ export function validateBillingImportInput(i) {
21
+ if (!i || typeof i !== "object")
22
+ return invalid("input", "expected billing import");
23
+ if (!i.scope || !name(i.scope.namespace) || !name(i.scope.principal) || !name(i.scope.connection))
24
+ return invalid("scope", "namespace, principal and connection are required");
25
+ for (const field of ["actor", "group", "providerCredentialVersion"])
26
+ if (i.scope[field] !== undefined && !name(i.scope[field]))
27
+ return invalid("scope." + field, "nonempty identifier required");
28
+ if (i.scope.accessCredential !== undefined &&
29
+ (!i.scope.accessCredential ||
30
+ !name(i.scope.accessCredential.kind) ||
31
+ !name(i.scope.accessCredential.id)))
32
+ return invalid("scope.accessCredential", "credential kind and id required");
33
+ if (i.scope.tags !== undefined && !normalizeTags(i.scope.tags))
34
+ return invalid("scope.tags", "invalid tags");
35
+ if (!name(i.provider))
36
+ return invalid("provider", "provider is required");
37
+ if (typeof i.fileHash !== "string" || !/^[a-f0-9]{64}$/.test(i.fileHash))
38
+ return invalid("fileHash", "expected lower-case SHA-256");
39
+ if (!interval(i.window))
40
+ return invalid("window", "expected a nonempty half-open interval");
41
+ if (i.expectedPreviousImportId !== null &&
42
+ (typeof i.expectedPreviousImportId !== "string" || !i.expectedPreviousImportId))
43
+ return invalid("expectedPreviousImportId", "explicit null or current import id required");
44
+ if (!i.attribution ||
45
+ !["byok", "platform"].includes(i.attribution.fundingSource) ||
46
+ !name(i.attribution.costOwner))
47
+ return invalid("attribution", "trusted historical funding and cost owner required");
48
+ for (const field of ["creditAccountRef", "customerPriceVersion", "providerPriceVersion"])
49
+ if (i.attribution[field] !== undefined && !name(i.attribution[field]))
50
+ return invalid("attribution." + field, "nonempty identifier required");
51
+ if (i.attribution.platformPools !== undefined &&
52
+ (!Array.isArray(i.attribution.platformPools) ||
53
+ i.attribution.platformPools.some((p) => !name(p)) ||
54
+ new Set(i.attribution.platformPools).size !== i.attribution.platformPools.length))
55
+ return invalid("attribution.platformPools", "unique pool ids required");
56
+ if (!Array.isArray(i.lines) || i.lines.length > 1000)
57
+ return invalid("lines", "at most 1000 export lines per atomic import");
58
+ const requestIds = new Set();
59
+ const aggregateWindows = [];
60
+ for (const [n, line] of i.lines.entries()) {
61
+ const field = "lines." + n;
62
+ if (!line ||
63
+ !date(line.occurredAt) ||
64
+ Date.parse(line.occurredAt) < Date.parse(i.window.from) ||
65
+ Date.parse(line.occurredAt) >= Date.parse(i.window.to))
66
+ return invalid(field, "occurrence must belong to the import window");
67
+ if (!line.cost ||
68
+ line.cost.currency !== "USD" ||
69
+ typeof line.cost.units !== "bigint" ||
70
+ line.cost.units < 0n ||
71
+ line.cost.units > maxMoneyUnits)
72
+ return invalid(field + ".cost", "exact nonnegative USD within storage capability required");
73
+ if (line.operation !== undefined && !name(line.operation))
74
+ return invalid(field + ".operation", "invalid operation");
75
+ if (line.providerRequestId !== undefined) {
76
+ if (!name(line.providerRequestId) || requestIds.has(line.providerRequestId))
77
+ return invalid(field + ".providerRequestId", "unique nonempty request id required");
78
+ if (line.window !== undefined)
79
+ return invalid(field, "a request line cannot also be an aggregate");
80
+ requestIds.add(line.providerRequestId);
81
+ }
82
+ else {
83
+ if (!interval(line.window) ||
84
+ Date.parse(line.window.from) < Date.parse(i.window.from) ||
85
+ Date.parse(line.window.to) > Date.parse(i.window.to))
86
+ return invalid(field + ".window", "an aggregate line needs a contained window");
87
+ if (aggregateWindows.some((w) => Date.parse(w.from) < Date.parse(line.window.to) &&
88
+ Date.parse(line.window.from) < Date.parse(w.to) &&
89
+ (w.operation === undefined ||
90
+ line.operation === undefined ||
91
+ w.operation === line.operation)))
92
+ return invalid(field + ".window", "overlapping aggregate lines double-count evidence");
93
+ aggregateWindows.push({
94
+ ...line.window,
95
+ ...(line.operation === undefined ? {} : { operation: line.operation }),
96
+ });
97
+ }
98
+ }
99
+ if (i.lines.some((line) => line.providerRequestId !== undefined &&
100
+ aggregateWindows.some((w) => Date.parse(line.occurredAt) >= Date.parse(w.from) &&
101
+ Date.parse(line.occurredAt) < Date.parse(w.to) &&
102
+ (w.operation === undefined ||
103
+ line.operation === undefined ||
104
+ w.operation === line.operation))))
105
+ return invalid("lines", "itemized and aggregate evidence cannot overlap");
106
+ return null;
107
+ }
108
+ export function validateBillingImportsQuery(q) {
109
+ if (!q || !q.scope || !name(q.scope.namespace) || !name(q.scope.principal) || !name(q.connection))
110
+ return invalid("scope", "namespace, principal and connection required");
111
+ if (!interval(q))
112
+ return invalid("window", "expected a nonempty interval");
113
+ if (q.limit !== undefined && (!Number.isInteger(q.limit) || q.limit < 1 || q.limit > 1000))
114
+ return invalid("limit", "1 to 1000 required");
115
+ if (q.history !== undefined && typeof q.history !== "boolean")
116
+ return invalid("history", "boolean required");
117
+ return null;
118
+ }
119
+ export const billingFamilyId = (i) => canonical([i.scope.namespace, i.scope.connection, i.provider, normalizedWindow(i.window)]);
120
+ const currentReceipt = (op) => {
121
+ const superseded = new Set(op.receipts.map((r) => r.supersedes));
122
+ return [...op.receipts].reverse().find((r) => !superseded.has(r.id));
123
+ };
124
+ /** Billing comparisons retain the application's own evidence rather than echoing a prior invoice. */
125
+ const observedReceipt = (op) => {
126
+ const receipts = op.receipts.filter((r) => r.source !== "import");
127
+ const superseded = new Set(receipts.map((r) => r.supersedes));
128
+ return [...receipts].reverse().find((r) => !superseded.has(r.id));
129
+ };
130
+ function ledger(operations, window, operation) {
131
+ let units = 0n, unknown = 0n, estimated = false;
132
+ for (const op of operations) {
133
+ if (op.source === "import" ||
134
+ op.state === "released" ||
135
+ (operation !== undefined && op.operation !== operation))
136
+ continue;
137
+ const r = observedReceipt(op);
138
+ const occurredAt = r?.occurredAt ?? op.createdAt;
139
+ if (Date.parse(occurredAt) < Date.parse(window.from) ||
140
+ Date.parse(occurredAt) >= Date.parse(window.to))
141
+ continue;
142
+ if (!r || r.cost.certainty === "unknown") {
143
+ unknown++;
144
+ continue;
145
+ }
146
+ units += r.cost.money.units;
147
+ if (r.cost.certainty === "estimated")
148
+ estimated = true;
149
+ }
150
+ return {
151
+ total: unknown > 0n
152
+ ? { certainty: "unknown", money: null }
153
+ : { certainty: estimated ? "estimated" : "measured", money: { units, currency: "USD" } },
154
+ unknown,
155
+ };
156
+ }
157
+ /** Pure preparation. Adapters commit every change, projection and journal in one transaction. */
158
+ export function prepareBillingImport(i, snapshot, now) {
159
+ const error = validateBillingImportInput(i);
160
+ if (error)
161
+ return { result: error, changes: [], familyId: "" };
162
+ const window = normalizedWindow(i.window), familyId = billingFamilyId(i);
163
+ const id = canonical([familyId, i.fileHash]);
164
+ // expectedPreviousImportId is a concurrency precondition, not imported content.
165
+ const { expectedPreviousImportId: _, ...content } = i;
166
+ const identity = canonical({ ...content, window });
167
+ const reject = (reason, line) => ({
168
+ familyId,
169
+ changes: [],
170
+ result: {
171
+ outcome: "rejected",
172
+ reason,
173
+ latestImportId: snapshot.latestImportId,
174
+ ...(line === undefined ? {} : { line }),
175
+ },
176
+ });
177
+ const previous = snapshot.imports.find((x) => x.record.id === id);
178
+ if (previous)
179
+ return previous.identity === identity
180
+ ? {
181
+ familyId,
182
+ changes: [],
183
+ result: { outcome: "imported", replayed: true, record: copy(previous.record) },
184
+ }
185
+ : reject("payload_conflict");
186
+ if (snapshot.latestImportId !== i.expectedPreviousImportId)
187
+ return reject("previous_import_conflict");
188
+ // A retained export cannot change its accounting owner when a connection transfers.
189
+ if (snapshot.imports.some((x) => x.record.scope.principal !== i.scope.principal))
190
+ return reject("evidence_conflict");
191
+ const scope = {
192
+ ...copy(i.scope),
193
+ ...(i.scope.tags ? { tags: normalizeTags(i.scope.tags) } : {}),
194
+ };
195
+ const operations = snapshot.operations.filter((op) => op.scope.namespace === scope.namespace &&
196
+ op.scope.principal === scope.principal &&
197
+ op.scope.connection === scope.connection &&
198
+ op.provider === i.provider);
199
+ const recordedAt = now.toISOString();
200
+ const changes = [], matched = [], unobserved = [];
201
+ const reconciliation = (kind, entryWindow, units, suffix, operation) => {
202
+ const { total, unknown } = ledger(operations, entryWindow, operation);
203
+ return {
204
+ id: id + ":" + suffix,
205
+ importId: id,
206
+ scope: copy(scope),
207
+ provider: i.provider,
208
+ ...(operation === undefined ? {} : { operation }),
209
+ window: normalizedWindow(entryWindow),
210
+ kind,
211
+ ledgerTotal: total,
212
+ evidenceTotal: { units, currency: "USD" },
213
+ differenceUnits: total.money === null ? null : units - total.money.units,
214
+ unknownOperations: unknown,
215
+ evidenceRef: id,
216
+ };
217
+ };
218
+ const reconciliations = [];
219
+ for (const [n, line] of i.lines.entries()) {
220
+ if (line.providerRequestId === undefined) {
221
+ reconciliations.push(reconciliation("aggregate", line.window, line.cost.units, "aggregate:" + n, line.operation));
222
+ continue;
223
+ }
224
+ const candidates = operations.filter((op) => op.receipts.some((r) => r.providerRequestId === line.providerRequestId));
225
+ if (candidates.length > 1)
226
+ return reject("ambiguous_request", n);
227
+ const before = candidates[0] ?? null;
228
+ // Historical receipts may carry different request ids for the same operation.
229
+ // Two lines must never create competing updates from the same version.
230
+ if (before && matched.includes(before.operationId))
231
+ return reject("ambiguous_request", n);
232
+ if (before?.lease && Date.parse(before.lease.expiresAt) > now.getTime())
233
+ return reject("active_operation", n);
234
+ if (before && !["pending", "settled"].includes(before.state))
235
+ return reject("operation_state", n);
236
+ if (before && line.operation !== undefined && before.operation !== line.operation)
237
+ return reject("evidence_conflict", n);
238
+ const effective = before ? currentReceipt(before) : undefined;
239
+ if (before?.state === "settled" && !effective)
240
+ return reject("evidence_conflict", n);
241
+ const receipt = {
242
+ id: id + ":receipt:" + n,
243
+ source: "import",
244
+ ...(effective ? { supersedes: effective.id } : {}),
245
+ measurements: copy(effective?.measurements ?? [{ unit: "requests", quantity: null, certainty: "unknown" }]),
246
+ cost: { certainty: "measured", money: copy(line.cost) },
247
+ providerRequestId: line.providerRequestId,
248
+ evidenceRef: id,
249
+ occurredAt: new Date(line.occurredAt).toISOString(),
250
+ recordedAt,
251
+ cached: effective?.cached ?? false,
252
+ failed: effective?.failed ?? false,
253
+ ...(effective?.providerPriceVersion
254
+ ? { providerPriceVersion: effective.providerPriceVersion }
255
+ : {}),
256
+ };
257
+ // cents is a cost measurement, never a customer credit-price measurement.
258
+ receipt.measurements = receipt.measurements.map((m) => m.unit === "cents"
259
+ ? {
260
+ unit: "cents",
261
+ certainty: "measured",
262
+ quantity: { value: line.cost.units, scale: 4, unit: "cents" },
263
+ }
264
+ : m);
265
+ if (before?.estimate.some((q) => q.unit === "cents") &&
266
+ !receipt.measurements.some((m) => m.unit === "cents"))
267
+ receipt.measurements = [
268
+ ...receipt.measurements,
269
+ {
270
+ unit: "cents",
271
+ certainty: "measured",
272
+ quantity: { value: line.cost.units, scale: 4, unit: "cents" },
273
+ },
274
+ ];
275
+ const state = receipt.measurements.some((m) => m.certainty === "unknown") ||
276
+ before?.estimate.some((q) => !receipt.measurements.some((m) => m.unit === q.unit && m.quantity !== null))
277
+ ? "pending"
278
+ : "settled";
279
+ const after = before
280
+ ? {
281
+ ...copy(before),
282
+ state,
283
+ version: before.version + 1,
284
+ updatedAt: recordedAt,
285
+ lease: null,
286
+ receipts: [...copy(before.receipts), receipt],
287
+ }
288
+ : {
289
+ ...copy(i.attribution),
290
+ operationId: id + ":unobserved:" + n,
291
+ scope: copy(scope),
292
+ source: "import",
293
+ surface: "programmatic",
294
+ provider: i.provider,
295
+ operation: line.operation ?? "unobserved",
296
+ estimate: [],
297
+ budgetEpochs: [],
298
+ state,
299
+ version: 1,
300
+ reservationExpiresAt: recordedAt,
301
+ createdAt: recordedAt,
302
+ updatedAt: recordedAt,
303
+ lease: null,
304
+ receipts: [receipt],
305
+ };
306
+ if (before)
307
+ matched.push(before.operationId);
308
+ else
309
+ unobserved.push(after.operationId);
310
+ changes.push({ before: before ? copy(before) : null, after, receipt });
311
+ }
312
+ reconciliations.push(reconciliation("import_total", window, i.lines.reduce((sum, line) => sum + line.cost.units, 0n), "total"));
313
+ const record = {
314
+ id,
315
+ scope,
316
+ provider: i.provider,
317
+ fileHash: i.fileHash,
318
+ window,
319
+ recordedAt,
320
+ ...(snapshot.latestImportId ? { supersedes: snapshot.latestImportId } : {}),
321
+ matchedOperationIds: matched,
322
+ unobservedOperationIds: unobserved,
323
+ reconciliations,
324
+ alerts: [],
325
+ };
326
+ return {
327
+ familyId,
328
+ changes,
329
+ stored: { identity, record },
330
+ result: { outcome: "imported", replayed: false, record },
331
+ };
332
+ }
333
+ export function readBillingImports(records, q, now) {
334
+ const successors = new Map(records.filter((r) => r.supersedes).map((r) => [r.supersedes, r.id]));
335
+ const scoped = records
336
+ .filter((r) => r.scope.namespace === q.scope.namespace &&
337
+ r.scope.principal === q.scope.principal &&
338
+ r.scope.connection === q.connection &&
339
+ Date.parse(r.window.from) < Date.parse(q.to) &&
340
+ Date.parse(q.from) < Date.parse(r.window.to) &&
341
+ (q.history || !successors.has(r.id)))
342
+ .sort((a, b) => b.recordedAt.localeCompare(a.recordedAt) || a.id.localeCompare(b.id));
343
+ const limit = q.limit ?? 1000;
344
+ return {
345
+ records: scoped.slice(0, limit).map((r) => ({
346
+ ...copy(r),
347
+ ...(successors.has(r.id) ? { supersededBy: successors.get(r.id) } : {}),
348
+ })),
349
+ asOf: now.toISOString(),
350
+ truncated: scoped.length > limit,
351
+ };
352
+ }
@@ -0,0 +1,2 @@
1
+ import type { StoreFactory, StoreCapabilities } from "./factory.js";
2
+ export declare function billingTests(factory: StoreFactory, capabilities: StoreCapabilities): void;
@@ -0,0 +1,372 @@
1
+ import { beforeEach, afterEach, describe, expect, test } from "vitest";
2
+ import { input, receipt, command, ref, quantity, budget } from "./helpers.js";
3
+ export function billingTests(factory, capabilities) {
4
+ describe("billing import guarantees", () => {
5
+ let f;
6
+ beforeEach(async () => {
7
+ f = await factory();
8
+ });
9
+ afterEach(async () => f.close());
10
+ const window = { from: "2026-09-01T00:00:00.000Z", to: "2026-10-01T00:00:00.000Z" };
11
+ const bill = (overrides = {}) => ({
12
+ scope: { namespace: "test", principal: "u1", connection: "c1" },
13
+ provider: "search",
14
+ fileHash: "a".repeat(64),
15
+ window,
16
+ expectedPreviousImportId: null,
17
+ attribution: { fundingSource: "byok", costOwner: "u1" },
18
+ lines: [
19
+ {
20
+ providerRequestId: "request-a",
21
+ operation: "search",
22
+ occurredAt: "2026-09-23T12:00:00.000Z",
23
+ cost: { units: 100n, currency: "USD" },
24
+ },
25
+ ],
26
+ ...overrides,
27
+ });
28
+ const read = (history = false) => f.store.billingImports({
29
+ scope: { namespace: "test", principal: "u1" },
30
+ connection: "c1",
31
+ ...window,
32
+ history,
33
+ });
34
+ const imported = async (i = bill()) => {
35
+ const result = await f.store.importBilling(i);
36
+ if (result.outcome !== "imported")
37
+ throw new Error("Import fixture: " + result.outcome);
38
+ return result.record;
39
+ };
40
+ const observed = async (id = "request-a", r = receipt(), overrides = {}) => {
41
+ const i = input(overrides), reservation = await f.store.reserve(i);
42
+ if (reservation.outcome !== "reserved")
43
+ throw new Error("Reservation fixture");
44
+ const grant = await f.store.markDispatchIntent({
45
+ ...command(reservation.operation),
46
+ holder: "h",
47
+ leaseTtlMs: 1000,
48
+ });
49
+ if (!("granted" in grant) || !grant.granted)
50
+ throw new Error("Dispatch fixture");
51
+ const result = await f.store.settle({
52
+ ...command(grant.operation),
53
+ authority: { kind: "lease", leaseId: grant.lease.leaseId },
54
+ receipt: { ...r, providerRequestId: id },
55
+ });
56
+ if (result.outcome !== "settled")
57
+ throw new Error("Settlement fixture");
58
+ return result.operation;
59
+ };
60
+ test("exact correction preserves frozen funding, customer price and quantity certainty", async () => {
61
+ const before = await observed("request-a", receipt({
62
+ measurements: [
63
+ { unit: "requests", quantity: quantity(3n), certainty: "estimated" },
64
+ {
65
+ unit: "customer_cents",
66
+ quantity: quantity(900n, "customer_cents", 4),
67
+ certainty: "measured",
68
+ },
69
+ ],
70
+ }), {
71
+ fundingSource: "platform",
72
+ costOwner: "platform",
73
+ creditAccountRef: "original-wallet",
74
+ customerPriceVersion: "tariff-v1",
75
+ providerPriceVersion: "provider-v1",
76
+ platformPools: ["original-pool"],
77
+ scope: {
78
+ namespace: "test",
79
+ principal: "u1",
80
+ connection: "c1",
81
+ providerCredentialVersion: "key-v1",
82
+ accessCredential: { kind: "api_key", id: "caller-v1" },
83
+ },
84
+ });
85
+ const record = await imported(bill({
86
+ attribution: {
87
+ fundingSource: "byok",
88
+ costOwner: "new-owner",
89
+ customerPriceVersion: "tariff-v2",
90
+ },
91
+ lines: [{ ...bill().lines[0], cost: { units: 9007199254740993n, currency: "USD" } }],
92
+ }));
93
+ const after = (await f.store.getOperation(ref(before)));
94
+ expect(after).toMatchObject({
95
+ fundingSource: "platform",
96
+ costOwner: "platform",
97
+ creditAccountRef: "original-wallet",
98
+ customerPriceVersion: "tariff-v1",
99
+ providerPriceVersion: "provider-v1",
100
+ platformPools: ["original-pool"],
101
+ scope: before.scope,
102
+ budgetEpochs: before.budgetEpochs,
103
+ });
104
+ expect(after.receipts).toHaveLength(2);
105
+ expect(after.receipts[0]).toEqual(before.receipts[0]);
106
+ expect(after.receipts[1]).toMatchObject({
107
+ source: "import",
108
+ supersedes: before.receipts[0].id,
109
+ evidenceRef: record.id,
110
+ cost: { certainty: "measured", money: { units: 9007199254740993n, currency: "USD" } },
111
+ measurements: before.receipts[0].measurements,
112
+ });
113
+ expect(after.version).toBe(before.version + 1);
114
+ });
115
+ test("identical replay survives new commands and does not reapply any receipt", async () => {
116
+ const before = await observed(), first = await imported(), after = await f.store.getOperation(ref(before));
117
+ const replay = await f.store.importBilling(bill());
118
+ expect(replay).toEqual({ outcome: "imported", replayed: true, record: first });
119
+ expect(await f.store.getOperation(ref(before))).toEqual(after);
120
+ expect((await read(true)).records).toHaveLength(1);
121
+ expect(await f.store.importBilling(bill({ lines: [{ ...bill().lines[0], cost: { units: 101n, currency: "USD" } }] }))).toMatchObject({ outcome: "rejected", reason: "payload_conflict" });
122
+ expect(await f.store.getOperation(ref(before))).toEqual(after);
123
+ });
124
+ test("revisions supersede journal evidence; replay of an older file cannot restore old costs", async () => {
125
+ const op = await observed(), first = await imported();
126
+ const second = await imported(bill({
127
+ fileHash: "b".repeat(64),
128
+ expectedPreviousImportId: first.id,
129
+ lines: [{ ...bill().lines[0], cost: { units: 200n, currency: "USD" } }],
130
+ }));
131
+ expect(second.supersedes).toBe(first.id);
132
+ expect((await read()).records.map((r) => r.id)).toEqual([second.id]);
133
+ const history = (await read(true)).records;
134
+ expect(history).toHaveLength(2);
135
+ expect(history.find((r) => r.id === first.id)?.supersededBy).toBe(second.id);
136
+ await f.store.importBilling(bill());
137
+ expect((await f.store.getOperation(ref(op))).receipts.at(-1).cost.money?.units).toBe(200n);
138
+ expect(await f.store.importBilling(bill({ fileHash: "c".repeat(64), expectedPreviousImportId: first.id }))).toMatchObject({
139
+ outcome: "rejected",
140
+ reason: "previous_import_conflict",
141
+ latestImportId: second.id,
142
+ });
143
+ });
144
+ test("concurrent same-file replays and competing first imports each write once", async () => {
145
+ await observed();
146
+ const results = await Promise.all([
147
+ f.store.importBilling(bill()),
148
+ f.store.importBilling(bill()),
149
+ ]);
150
+ expect(results.filter((r) => r.outcome === "imported" && !r.replayed)).toHaveLength(1);
151
+ expect(results.filter((r) => r.outcome === "imported" && r.replayed)).toHaveLength(1);
152
+ const first = (await read()).records[0];
153
+ const revisions = await Promise.all(["b", "c"].map((h) => f.store.importBilling(bill({ fileHash: h.repeat(64), expectedPreviousImportId: first.id }))));
154
+ expect(revisions.filter((r) => r.outcome === "imported")).toHaveLength(1);
155
+ expect(revisions.filter((r) => r.outcome === "rejected" && r.reason === "previous_import_conflict")).toHaveLength(1);
156
+ });
157
+ test.each([
158
+ { scope: { namespace: "test", principal: "u2", connection: "c1" } },
159
+ { scope: { namespace: "test", principal: "u1", connection: "c2" } },
160
+ { provider: "other-provider" },
161
+ { scope: { namespace: "other", principal: "u1", connection: "c1" } },
162
+ ])("request ids are scoped independently to owner, connection, provider and namespace", async (foreignScope) => {
163
+ const own = await observed("request-a", receipt({ cost: { certainty: "estimated", money: { units: 20n, currency: "USD" } } }));
164
+ const foreign = await observed("request-a", receipt(), foreignScope);
165
+ const record = await imported();
166
+ expect(record.matchedOperationIds).toEqual([own.operationId]);
167
+ expect(await f.store.getOperation(ref(foreign))).toEqual(foreign);
168
+ expect((await f.store.billingImports({
169
+ scope: { namespace: "test", principal: "u2" },
170
+ connection: "c1",
171
+ ...window,
172
+ })).records).toEqual([]);
173
+ });
174
+ test("ambiguous matching rejects the complete batch before any accounting mutation", async () => {
175
+ const good = await observed("good"), a = await observed("duplicate"), b = await observed("duplicate");
176
+ const result = await f.store.importBilling(bill({
177
+ lines: [
178
+ { ...bill().lines[0], providerRequestId: "good" },
179
+ { ...bill().lines[0], providerRequestId: "duplicate" },
180
+ ],
181
+ }));
182
+ expect(result).toMatchObject({ outcome: "rejected", reason: "ambiguous_request", line: 1 });
183
+ for (const op of [good, a, b])
184
+ expect(await f.store.getOperation(ref(op))).toEqual(op);
185
+ expect((await read(true)).records).toEqual([]);
186
+ });
187
+ test("aggregate lines report exact signed differences without allocating cost to operations", async () => {
188
+ const a = await observed("a", receipt({ cost: { certainty: "measured", money: { units: 700n, currency: "USD" } } }));
189
+ const b = await observed("b", receipt({ cost: { certainty: "estimated", money: { units: 300n, currency: "USD" } } }));
190
+ const record = await imported(bill({
191
+ lines: [{ occurredAt: window.from, window, cost: { units: 900n, currency: "USD" } }],
192
+ }));
193
+ expect(record.reconciliations.find((e) => e.kind === "aggregate")).toMatchObject({
194
+ ledgerTotal: { certainty: "estimated", money: { units: 1000n } },
195
+ evidenceTotal: { units: 900n },
196
+ differenceUnits: -100n,
197
+ unknownOperations: 0n,
198
+ });
199
+ expect(record.matchedOperationIds).toEqual([]);
200
+ expect(record.unobservedOperationIds).toEqual([]);
201
+ expect(await f.store.getOperation(ref(a))).toEqual(a);
202
+ expect(await f.store.getOperation(ref(b))).toEqual(b);
203
+ });
204
+ test("two historical request ids cannot apply competing corrections to one operation", async () => {
205
+ const original = await observed("old-id");
206
+ const corrected = await f.store.correct({
207
+ ...command(original),
208
+ replacesReceiptId: original.receipts[0].id,
209
+ reason: "corrected provider identity",
210
+ authority: { kind: "late_evidence", source: "provider correction" },
211
+ receipt: receipt({ providerRequestId: "new-id" }),
212
+ });
213
+ if (corrected.outcome !== "settled")
214
+ throw new Error("Correction fixture");
215
+ const result = await f.store.importBilling(bill({
216
+ lines: [
217
+ { ...bill().lines[0], providerRequestId: "old-id" },
218
+ { ...bill().lines[0], providerRequestId: "new-id" },
219
+ ],
220
+ }));
221
+ expect(result).toMatchObject({ outcome: "rejected", reason: "ambiguous_request", line: 1 });
222
+ expect(await f.store.getOperation(ref(corrected.operation))).toEqual(corrected.operation);
223
+ expect((await read(true)).records).toEqual([]);
224
+ });
225
+ test("operation-specific aggregate comparisons exclude other operations and allow disjoint categories", async () => {
226
+ const search = await observed("search-a", receipt({ cost: { certainty: "measured", money: { units: 700n, currency: "USD" } } }));
227
+ const images = await observed("images-a", receipt({ cost: { certainty: "measured", money: { units: 300n, currency: "USD" } } }), { operation: "images" });
228
+ const record = await imported(bill({
229
+ lines: [
230
+ {
231
+ occurredAt: window.from,
232
+ window,
233
+ operation: "search",
234
+ cost: { units: 600n, currency: "USD" },
235
+ },
236
+ {
237
+ occurredAt: window.from,
238
+ window,
239
+ operation: "images",
240
+ cost: { units: 350n, currency: "USD" },
241
+ },
242
+ ],
243
+ }));
244
+ expect(record.reconciliations.filter((e) => e.kind === "aggregate")).toMatchObject([
245
+ { operation: "search", ledgerTotal: { money: { units: 700n } }, differenceUnits: -100n },
246
+ { operation: "images", ledgerTotal: { money: { units: 300n } }, differenceUnits: 50n },
247
+ ]);
248
+ expect(await f.store.getOperation(ref(search))).toEqual(search);
249
+ expect(await f.store.getOperation(ref(images))).toEqual(images);
250
+ });
251
+ test("unknown ledger costs remain unknown and an omitted revised line does not manufacture zero", async () => {
252
+ const a = await observed("a", receipt({ cost: { certainty: "unknown", money: null } }));
253
+ const record = await imported(bill({
254
+ lines: [{ occurredAt: window.from, window, cost: { units: 900n, currency: "USD" } }],
255
+ }));
256
+ expect(record.reconciliations[0]).toMatchObject({
257
+ ledgerTotal: { certainty: "unknown", money: null },
258
+ differenceUnits: null,
259
+ unknownOperations: 1n,
260
+ });
261
+ expect(await f.store.getOperation(ref(a))).toEqual(a);
262
+ const revision = await imported(bill({ fileHash: "b".repeat(64), expectedPreviousImportId: record.id, lines: [] }));
263
+ expect(revision.reconciliations[0]).toMatchObject({
264
+ evidenceTotal: { units: 0n },
265
+ ledgerTotal: { certainty: "unknown", money: null },
266
+ differenceUnits: null,
267
+ });
268
+ expect(await f.store.getOperation(ref(a))).toEqual(a);
269
+ });
270
+ test("unobserved calls retain unknown quantity, never gain dispatch and revise without duplicate operations", async () => {
271
+ const first = await imported(), id = first.unobservedOperationIds[0];
272
+ const op = (await f.store.getOperation({
273
+ namespace: "test",
274
+ principal: "u1",
275
+ operationId: id,
276
+ }));
277
+ expect(op).toMatchObject({
278
+ source: "import",
279
+ surface: "programmatic",
280
+ state: "pending",
281
+ budgetEpochs: [],
282
+ estimate: [],
283
+ lease: null,
284
+ });
285
+ expect(op.receipts[0]).toMatchObject({
286
+ source: "import",
287
+ cost: { certainty: "measured" },
288
+ measurements: [{ unit: "requests", certainty: "unknown", quantity: null }],
289
+ });
290
+ expect(await f.store.markDispatchIntent({ ...command(op), holder: "h", leaseTtlMs: 1000 })).toMatchObject({ granted: false });
291
+ const second = await imported(bill({ fileHash: "b".repeat(64), expectedPreviousImportId: first.id }));
292
+ expect(second.unobservedOperationIds).toEqual([]);
293
+ expect(second.matchedOperationIds).toEqual([id]);
294
+ expect((await f.store.getOperation(ref(op))).receipts).toHaveLength(2);
295
+ });
296
+ test("known invoice cost does not release unresolved quantities or original customer exposure", async () => {
297
+ const before = await observed("request-a", receipt({
298
+ measurements: [
299
+ { unit: "requests", certainty: "unknown", quantity: null },
300
+ {
301
+ unit: "customer_cents",
302
+ certainty: "measured",
303
+ quantity: quantity(99n, "customer_cents"),
304
+ },
305
+ ],
306
+ cost: { certainty: "unknown", money: null },
307
+ }));
308
+ const record = await imported(), after = (await f.store.getOperation(ref(before)));
309
+ expect(after.state).toBe("pending");
310
+ expect(after.receipts.at(-1)).toMatchObject({
311
+ measurements: before.receipts[0].measurements,
312
+ cost: { certainty: "measured", money: { units: 100n } },
313
+ evidenceRef: record.id,
314
+ });
315
+ expect(after.budgetEpochs).toEqual(before.budgetEpochs);
316
+ });
317
+ test("cost import updates existing budget exposure and reports threshold crossings once", async () => {
318
+ f.budgets.push(budget({
319
+ unit: "cents",
320
+ limit: quantity(20n, "cents"),
321
+ onExceed: "allow",
322
+ alerts: [{ at: { percent: 50 } }, { at: { percent: 100 } }],
323
+ }));
324
+ const before = await observed("request-a", receipt({
325
+ cost: { certainty: "measured", money: { units: 100000n, currency: "USD" } },
326
+ measurements: [
327
+ { unit: "requests", certainty: "measured", quantity: quantity() },
328
+ { unit: "cents", certainty: "measured", quantity: quantity(10n, "cents") },
329
+ ],
330
+ }), { estimate: [quantity(), quantity(5n, "cents")] });
331
+ const i = bill({
332
+ lines: [{ ...bill().lines[0], cost: { units: 300000n, currency: "USD" } }],
333
+ }), first = await imported(i);
334
+ expect(first.alerts).toHaveLength(1);
335
+ expect(first.alerts[0].at).toEqual({ percent: 100 });
336
+ const [status] = await f.store.applicableBudgets({
337
+ scope: before.scope,
338
+ surface: before.surface,
339
+ source: before.source,
340
+ units: ["cents"],
341
+ });
342
+ expect(status.used).toEqual(quantity(300000n, "cents", 4));
343
+ expect(await f.store.importBilling(i)).toMatchObject({
344
+ outcome: "imported",
345
+ replayed: true,
346
+ record: { alerts: first.alerts },
347
+ });
348
+ const second = await imported({
349
+ ...i,
350
+ fileHash: "b".repeat(64),
351
+ expectedPreviousImportId: first.id,
352
+ });
353
+ expect(second.alerts).toEqual([]);
354
+ });
355
+ test.skipIf(!capabilities.durable)("durable import and revision survive runtime recreation", async () => {
356
+ const before = await observed(), first = await imported();
357
+ if (!f.restart)
358
+ throw new Error("Durable billing adapter must provide restart");
359
+ f.store = await f.restart();
360
+ expect(await f.store.importBilling(bill())).toEqual({
361
+ outcome: "imported",
362
+ replayed: true,
363
+ record: first,
364
+ });
365
+ expect((await f.store.getOperation(ref(before))).receipts).toHaveLength(2);
366
+ const second = await imported(bill({ fileHash: "b".repeat(64), expectedPreviousImportId: first.id }));
367
+ f.store = await f.restart();
368
+ expect((await read()).records[0].id).toBe(second.id);
369
+ expect((await f.store.getOperation(ref(before))).receipts).toHaveLength(3);
370
+ });
371
+ });
372
+ }
@@ -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,8 @@ 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";
15
+ import { billingTests } from "./billing.js";
14
16
  export function runStoreConformance(factory, capabilities) {
15
17
  describe("store conformance", () => {
16
18
  reserveTests(factory);
@@ -23,6 +25,8 @@ export function runStoreConformance(factory, capabilities) {
23
25
  recoveryTests(factory);
24
26
  readsTests(factory, capabilities);
25
27
  operationsTests(factory);
28
+ counterTests(factory);
29
+ billingTests(factory, capabilities);
26
30
  propertyTests(factory);
27
31
  capabilityTests(factory, capabilities);
28
32
  afterAll(() => console.info("Skipped guarantees: " +
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
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 { BillingImportInput, BillingImportResult, BillingImportsQuery, BillingImportsPage } from "@usagekit/core";
3
+ 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
4
  /**
4
5
  * Authorization is the Meter's job; atomic condition checks are the Store's job.
5
6
  * Store does not take AccessContext. Meter resolves applicable budgets, funding and price policy.
@@ -12,6 +13,8 @@ import type { ExpireReservationsInput, ExpireReservationsResult, Budget, Applica
12
13
  * Adapters prove these guarantees through one conformance suite, without callback locks.
13
14
  */
14
15
  export interface Store {
16
+ importBilling(input: BillingImportInput): Promise<BillingImportResult>;
17
+ billingImports(query: BillingImportsQuery): Promise<BillingImportsPage>;
15
18
  expireReservations(input: ExpireReservationsInput): Promise<ExpireReservationsResult>;
16
19
  reserve(input: ReserveInput, policy?: AdmissionPolicy): Promise<ReserveResult>;
17
20
  markDispatchIntent(input: DispatchIntentInput): Promise<DispatchGrant>;
@@ -20,9 +23,12 @@ export interface Store {
20
23
  settle(input: SettleInput): Promise<SettleResult>;
21
24
  correct(input: CorrectionInput): Promise<SettleResult>;
22
25
  releaseUndispatched(input: ReleaseInput): Promise<ReleaseResult>;
26
+ /** Atomic, replay-safe count of one non-operation request; never reserves or touches budgets. */
27
+ countRequest(input: CountRequestInput): Promise<CountRequestResult>;
23
28
  getOperation(input: OperationRef): Promise<Operation | null>;
24
29
  aggregate(query: UsageQuery): Promise<UsagePage>;
25
30
  listOperations(query: OperationsQuery): Promise<OperationsPage>;
31
+ requestCounts(query: RequestCountsQuery): Promise<RequestCountsPage>;
26
32
  definedBudgets(query: DefinedBudgetsQuery): Promise<readonly Budget[]>;
27
33
  applicableBudgets(query: ApplicableBudgetsQuery): Promise<readonly BudgetStatus[]>;
28
34
  }
@@ -31,3 +37,7 @@ export { createManualClock } from "./clock.js";
31
37
  export type { Clock, ManualClock } from "./clock.js";
32
38
  export { InvalidInput } from "./memory/state.js";
33
39
  export { validateBudget, reachedAlerts, alertKey } from "./memory/admission.js";
40
+ export { validateCountRequest, validateRequestCountsQuery, countIdentity, countRows, bucketOf, requestCountDimensions, } from "./memory/counters.js";
41
+ export type { CountBucket } from "./memory/counters.js";
42
+ export { prepareBillingImport, billingFamilyId, readBillingImports, validateBillingImportInput, validateBillingImportsQuery, } from "./billing.js";
43
+ export type { StoredBillingImport, BillingSnapshot, BillingPreparation } from "./billing.js";
package/dist/index.js CHANGED
@@ -2,3 +2,5 @@ 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";
6
+ export { prepareBillingImport, billingFamilyId, readBillingImports, validateBillingImportInput, validateBillingImportsQuery, } from "./billing.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,6 +1,10 @@
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";
5
+ import { billingFamilyId, prepareBillingImport, readBillingImports, validateBillingImportInput, validateBillingImportsQuery, } from "../billing.js";
6
+ import { reachedBySettlement, recordAlerts } from "./admission.js";
7
+ import { InvalidInput, key, semantics } from "./state.js";
4
8
  export function createMemoryStore({ clock, budgets }) {
5
9
  const state = {
6
10
  clock,
@@ -15,6 +19,51 @@ export function createMemoryStore({ clock, budgets }) {
15
19
  identities: new Map(),
16
20
  };
17
21
  return {
22
+ importBilling: async (i) => {
23
+ const invalid = validateBillingImportInput(i);
24
+ if (invalid)
25
+ return invalid;
26
+ const family = billingFamilyId(i), imports = (state.billingImports ??= new Map()), families = (state.billingFamilies ??= new Map());
27
+ const preparation = prepareBillingImport(i, {
28
+ operations: [...state.operations.values()],
29
+ imports: [...imports.values()].filter((x) => billingFamilyId({
30
+ ...i,
31
+ scope: x.record.scope,
32
+ provider: x.record.provider,
33
+ window: x.record.window,
34
+ }) === family),
35
+ latestImportId: families.get(family) ?? null,
36
+ }, clock.now());
37
+ if (preparation.stored) {
38
+ const staged = {
39
+ ...state,
40
+ operations: new Map(state.operations),
41
+ alerts: new Set(state.alerts),
42
+ };
43
+ for (const change of preparation.changes) {
44
+ const op = copy(change.after), pk = key(op.scope.namespace, op.operationId);
45
+ staged.operations.set(pk, op);
46
+ }
47
+ preparation.stored.record.alerts = preparation.changes.flatMap((change) => recordAlerts(staged, i.scope.namespace, reachedBySettlement(staged, change.after)));
48
+ state.operations = staged.operations;
49
+ state.alerts = staged.alerts;
50
+ for (const change of preparation.changes) {
51
+ if (!change.before)
52
+ state.identities.set(key(change.after.scope.namespace, change.after.operationId), semantics(change.after));
53
+ if (change.before?.lease)
54
+ state.leaseKinds.delete(change.before.lease.leaseId);
55
+ }
56
+ imports.set(preparation.stored.record.id, copy(preparation.stored));
57
+ families.set(family, preparation.stored.record.id);
58
+ }
59
+ return copy(preparation.result);
60
+ },
61
+ billingImports: async (q) => {
62
+ const invalid = validateBillingImportsQuery(q);
63
+ if (invalid)
64
+ throw new InvalidInput(invalid.field, invalid.reason);
65
+ return readBillingImports([...(state.billingImports?.values() ?? [])].map((x) => x.record), q, clock.now());
66
+ },
18
67
  expireReservations: async (i) => expireReservations(state, i),
19
68
  reserve: async (i, policy) => reserve(state, i, policy),
20
69
  getOperation: async (ref) => copy(find(state, ref)),
@@ -24,6 +73,8 @@ export function createMemoryStore({ clock, budgets }) {
24
73
  settle: async (i) => settle(state, i),
25
74
  correct: async (i) => settle(state, i, true),
26
75
  releaseUndispatched: async (i) => release(state, i),
76
+ countRequest: async (i) => countRequest(state, i),
77
+ requestCounts: async (q) => requestCounts(state, q),
27
78
  aggregate: async (q) => aggregate(state, q),
28
79
  listOperations: async (q) => listOperations(state, q),
29
80
  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,13 +13,15 @@ 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;
19
20
  export declare const maxMoneyUnits: bigint;
20
21
  export declare function validateQuantity(q: Quantity): void;
21
22
  export type State = {
23
+ billingImports?: Map<string, import("../billing.js").StoredBillingImport>;
24
+ billingFamilies?: Map<string, string>;
22
25
  /** Optional adapter projection, read in the same transaction as admission. */
23
26
  readBudgetUsage?: (budget: Budget, epoch: string) => {
24
27
  used: Quantity;
@@ -53,5 +56,9 @@ export type State = {
53
56
  reserveAlerts: Map<string, readonly BudgetAlertCrossed[]>;
54
57
  /** Recorded crossings keyed by namespace, budget id, epoch and threshold. */
55
58
  alerts: Set<string>;
59
+ /** Request counter: replay identities by namespace and command id. */
60
+ requestCommands?: Map<string, string>;
61
+ /** Request counter: daily buckets keyed by their canonical fields. */
62
+ requestBuckets?: Map<string, CountBucket>;
56
63
  };
57
64
  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.5.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.5.0"
35
35
  },
36
36
  "sideEffects": false,
37
37
  "publishConfig": {