@usagekit/store 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/billing.d.ts +22 -0
- package/dist/billing.js +352 -0
- package/dist/conformance/billing.d.ts +2 -0
- package/dist/conformance/billing.js +372 -0
- package/dist/conformance/properties.js +3 -1
- package/dist/conformance/suite.js +2 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +1 -0
- package/dist/memory/memory-store.js +48 -0
- package/dist/memory/state.d.ts +2 -0
- package/package.json +11 -2
|
@@ -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;
|
package/dist/billing.js
ADDED
|
@@ -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,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
|
+
}
|
|
@@ -2,8 +2,10 @@ import { describe, expect, test } from "vitest";
|
|
|
2
2
|
import fc from "fast-check";
|
|
3
3
|
import { input, command, ref, receipt, unknownReceipt, budget, quantity } from "./helpers.js";
|
|
4
4
|
const numRuns = globalThis.process?.env?.CI === "true" ? 1000 : 200;
|
|
5
|
+
// CI executes five times as many cases, including isolated durable database fixtures.
|
|
6
|
+
const timeout = numRuns * 300;
|
|
5
7
|
export function propertyTests(factory) {
|
|
6
|
-
describe("generated command interleavings", () => {
|
|
8
|
+
describe("generated command interleavings", { timeout }, () => {
|
|
7
9
|
test("holders never reacquire dispatch and mutations preserve state invariants", async () => {
|
|
8
10
|
await fc.assert(fc.asyncProperty(fc.integer({ min: 2, max: 5 }), fc.array(fc.record({
|
|
9
11
|
kind: fc.constantFrom("intent", "renew", "settle", "correct", "release", "claim", "advanceClock"),
|
|
@@ -12,6 +12,7 @@ import { operationsTests } from "./operations.js";
|
|
|
12
12
|
import { propertyTests } from "./properties.js";
|
|
13
13
|
import { capabilityTests } from "./capabilities.js";
|
|
14
14
|
import { counterTests } from "./counters.js";
|
|
15
|
+
import { billingTests } from "./billing.js";
|
|
15
16
|
export function runStoreConformance(factory, capabilities) {
|
|
16
17
|
describe("store conformance", () => {
|
|
17
18
|
reserveTests(factory);
|
|
@@ -25,6 +26,7 @@ export function runStoreConformance(factory, capabilities) {
|
|
|
25
26
|
readsTests(factory, capabilities);
|
|
26
27
|
operationsTests(factory);
|
|
27
28
|
counterTests(factory);
|
|
29
|
+
billingTests(factory, capabilities);
|
|
28
30
|
propertyTests(factory);
|
|
29
31
|
capabilityTests(factory, capabilities);
|
|
30
32
|
afterAll(() => console.info("Skipped guarantees: " +
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AdmissionPolicy } from "@usagekit/core";
|
|
2
|
+
import type { BillingImportInput, BillingImportResult, BillingImportsQuery, BillingImportsPage } from "@usagekit/core";
|
|
2
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.
|
|
@@ -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>;
|
|
@@ -36,3 +39,5 @@ export { InvalidInput } from "./memory/state.js";
|
|
|
36
39
|
export { validateBudget, reachedAlerts, alertKey } from "./memory/admission.js";
|
|
37
40
|
export { validateCountRequest, validateRequestCountsQuery, countIdentity, countRows, bucketOf, requestCountDimensions, } from "./memory/counters.js";
|
|
38
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
|
@@ -3,3 +3,4 @@ export { createManualClock } from "./clock.js";
|
|
|
3
3
|
export { InvalidInput } from "./memory/state.js";
|
|
4
4
|
export { validateBudget, reachedAlerts, alertKey } from "./memory/admission.js";
|
|
5
5
|
export { validateCountRequest, validateRequestCountsQuery, countIdentity, countRows, bucketOf, requestCountDimensions, } from "./memory/counters.js";
|
|
6
|
+
export { prepareBillingImport, billingFamilyId, readBillingImports, validateBillingImportInput, validateBillingImportsQuery, } from "./billing.js";
|
|
@@ -2,6 +2,9 @@ import { aggregate, definedBudgets, applicableBudgets, listOperations } from "./
|
|
|
2
2
|
import { copy, find } from "./state.js";
|
|
3
3
|
import { countRequest, requestCounts } from "./counters.js";
|
|
4
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";
|
|
5
8
|
export function createMemoryStore({ clock, budgets }) {
|
|
6
9
|
const state = {
|
|
7
10
|
clock,
|
|
@@ -16,6 +19,51 @@ export function createMemoryStore({ clock, budgets }) {
|
|
|
16
19
|
identities: new Map(),
|
|
17
20
|
};
|
|
18
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
|
+
},
|
|
19
67
|
expireReservations: async (i) => expireReservations(state, i),
|
|
20
68
|
reserve: async (i, policy) => reserve(state, i, policy),
|
|
21
69
|
getOperation: async (ref) => copy(find(state, ref)),
|
package/dist/memory/state.d.ts
CHANGED
|
@@ -20,6 +20,8 @@ export declare function withNormalizedTags(i: ReserveInput): ReserveInput;
|
|
|
20
20
|
export declare const maxMoneyUnits: bigint;
|
|
21
21
|
export declare function validateQuantity(q: Quantity): void;
|
|
22
22
|
export type State = {
|
|
23
|
+
billingImports?: Map<string, import("../billing.js").StoredBillingImport>;
|
|
24
|
+
billingFamilies?: Map<string, string>;
|
|
23
25
|
/** Optional adapter projection, read in the same transaction as admission. */
|
|
24
26
|
readBudgetUsage?: (budget: Budget, epoch: string) => {
|
|
25
27
|
used: Quantity;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@usagekit/store",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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.
|
|
34
|
+
"@usagekit/core": "0.6.0"
|
|
35
35
|
},
|
|
36
36
|
"sideEffects": false,
|
|
37
37
|
"publishConfig": {
|
|
@@ -49,5 +49,14 @@
|
|
|
49
49
|
"fast-check": {
|
|
50
50
|
"optional": true
|
|
51
51
|
}
|
|
52
|
+
},
|
|
53
|
+
"repository": {
|
|
54
|
+
"type": "git",
|
|
55
|
+
"url": "git+https://github.com/msniezynski/usagekit.git",
|
|
56
|
+
"directory": "packages/store"
|
|
57
|
+
},
|
|
58
|
+
"homepage": "https://usagekit.dev",
|
|
59
|
+
"bugs": {
|
|
60
|
+
"url": "https://github.com/msniezynski/usagekit/issues"
|
|
52
61
|
}
|
|
53
62
|
}
|