usage-meter-test 0.1.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/README.md +86 -0
- package/dist/errors.d.ts +24 -0
- package/dist/errors.js +48 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/math.d.ts +7 -0
- package/dist/math.js +37 -0
- package/dist/pricing.d.ts +32 -0
- package/dist/pricing.js +57 -0
- package/dist/providers/inMemoryProvider.d.ts +66 -0
- package/dist/providers/inMemoryProvider.js +198 -0
- package/dist/providers/prismaProvider.d.ts +36 -0
- package/dist/providers/prismaProvider.js +323 -0
- package/dist/types.d.ts +168 -0
- package/dist/types.js +1 -0
- package/dist/usageMeter.d.ts +18 -0
- package/dist/usageMeter.js +320 -0
- package/package.json +25 -0
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# usage-meter
|
|
2
|
+
|
|
3
|
+
A small TypeScript library for **credits + quota + holds + ledger** metering (e.g. AI token billing).
|
|
4
|
+
|
|
5
|
+
## Core concepts
|
|
6
|
+
|
|
7
|
+
- **Credits**: integer unit (`bigint`) deducted from a user account.
|
|
8
|
+
- **Hold**: pre-authorize a maximum cost (freeze credits + reserve quota).
|
|
9
|
+
- **Capture**: settle actual cost (deduct balance + release unused hold/quota).
|
|
10
|
+
- **Release**: cancel a hold (unfreeze credits + unreserve quota).
|
|
11
|
+
- **Ledger**: append-only, idempotent charge records.
|
|
12
|
+
|
|
13
|
+
## Quick start (in-memory)
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { DEFAULT_PRICING_CONFIG, InMemoryMeteringPersistence, UsageMeter, bps, credits } from "usage-meter";
|
|
17
|
+
|
|
18
|
+
const persistence = new InMemoryMeteringPersistence({ quota: true, usage: true });
|
|
19
|
+
|
|
20
|
+
// Seed credits
|
|
21
|
+
await persistence.accounts.addCredits("u1", credits(10_000));
|
|
22
|
+
|
|
23
|
+
// Optional: quota policy (daily 5_000 credits)
|
|
24
|
+
persistence.quota?.setPolicy("u1", { policyId: "free-daily", period: "daily", limitCredits: credits(5_000) });
|
|
25
|
+
|
|
26
|
+
const meter = new UsageMeter(persistence, {
|
|
27
|
+
pricing: {
|
|
28
|
+
...DEFAULT_PRICING_CONFIG,
|
|
29
|
+
inputTokenCost: credits(1),
|
|
30
|
+
outputTokenCost: credits(2),
|
|
31
|
+
minCost: credits(1),
|
|
32
|
+
defaultModelMultiplierBps: bps(10_000),
|
|
33
|
+
modelRules: [{ model: "premium", multiplierBps: bps(20_000) }],
|
|
34
|
+
toolRules: [{ tool: "web_search", unitCost: credits(50) }],
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const estimate = meter.estimate({
|
|
39
|
+
userId: "u1",
|
|
40
|
+
requestId: "req-1",
|
|
41
|
+
model: "premium",
|
|
42
|
+
tokens: { inputTokens: 100, outputTokens: 200 },
|
|
43
|
+
maxOutputTokens: 800,
|
|
44
|
+
tools: [{ tool: "web_search", units: 1 }],
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Hold max possible cost (freeze + reserve quota)
|
|
48
|
+
await meter.hold({
|
|
49
|
+
userId: "u1",
|
|
50
|
+
requestId: "req-1",
|
|
51
|
+
amount: estimate.maxPossibleCost,
|
|
52
|
+
idempotencyKey: "hold:req-1",
|
|
53
|
+
expiresAt: new Date(Date.now() + 2 * 60 * 1000),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Capture actual cost when done
|
|
57
|
+
await meter.capture({
|
|
58
|
+
userId: "u1",
|
|
59
|
+
requestId: "req-1",
|
|
60
|
+
actualCost: estimate.estimatedCost,
|
|
61
|
+
idempotencyKey: "charge:req-1",
|
|
62
|
+
usage: {
|
|
63
|
+
requestId: "req-1",
|
|
64
|
+
model: "premium",
|
|
65
|
+
tokens: { inputTokens: 100, outputTokens: 200 },
|
|
66
|
+
tools: [{ tool: "web_search", units: 1 }],
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Prisma adapter (route A)
|
|
72
|
+
|
|
73
|
+
- Add the models/enums from `usage-meter/prisma/schema.prisma.snippet` into your app's `schema.prisma`
|
|
74
|
+
- Run `prisma migrate dev` (or `prisma migrate deploy`)
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
import { createPrismaPersistence, UsageMeter, DEFAULT_PRICING_CONFIG } from "usage-meter";
|
|
78
|
+
import { PrismaClient, Prisma } from "@prisma/client";
|
|
79
|
+
|
|
80
|
+
const prisma = new PrismaClient();
|
|
81
|
+
const persistence = createPrismaPersistence(prisma, {
|
|
82
|
+
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const meter = new UsageMeter(persistence, { pricing: DEFAULT_PRICING_CONFIG });
|
|
86
|
+
```
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export declare class UsageMeterError extends Error {
|
|
2
|
+
constructor(message?: string);
|
|
3
|
+
}
|
|
4
|
+
export declare class InsufficientCreditsError extends UsageMeterError {
|
|
5
|
+
constructor(message?: string);
|
|
6
|
+
}
|
|
7
|
+
export declare class QuotaExceededError extends UsageMeterError {
|
|
8
|
+
constructor(message?: string);
|
|
9
|
+
}
|
|
10
|
+
export declare class HoldNotFoundError extends UsageMeterError {
|
|
11
|
+
constructor(message?: string);
|
|
12
|
+
}
|
|
13
|
+
export declare class HoldExpiredError extends UsageMeterError {
|
|
14
|
+
constructor(message?: string);
|
|
15
|
+
}
|
|
16
|
+
export declare class HoldStateError extends UsageMeterError {
|
|
17
|
+
constructor(message?: string);
|
|
18
|
+
}
|
|
19
|
+
export declare class InvalidCaptureAmountError extends UsageMeterError {
|
|
20
|
+
constructor(message?: string);
|
|
21
|
+
}
|
|
22
|
+
export declare class IdempotencyConflictError extends UsageMeterError {
|
|
23
|
+
constructor(message?: string);
|
|
24
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export class UsageMeterError extends Error {
|
|
2
|
+
constructor(message) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = "UsageMeterError";
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
export class InsufficientCreditsError extends UsageMeterError {
|
|
8
|
+
constructor(message = "Insufficient credits") {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "InsufficientCreditsError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export class QuotaExceededError extends UsageMeterError {
|
|
14
|
+
constructor(message = "Quota exceeded") {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = "QuotaExceededError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export class HoldNotFoundError extends UsageMeterError {
|
|
20
|
+
constructor(message = "Hold not found") {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = "HoldNotFoundError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export class HoldExpiredError extends UsageMeterError {
|
|
26
|
+
constructor(message = "Hold expired") {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = "HoldExpiredError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export class HoldStateError extends UsageMeterError {
|
|
32
|
+
constructor(message = "Invalid hold state") {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = "HoldStateError";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export class InvalidCaptureAmountError extends UsageMeterError {
|
|
38
|
+
constructor(message = "Invalid capture amount") {
|
|
39
|
+
super(message);
|
|
40
|
+
this.name = "InvalidCaptureAmountError";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export class IdempotencyConflictError extends UsageMeterError {
|
|
44
|
+
constructor(message = "Idempotency conflict") {
|
|
45
|
+
super(message);
|
|
46
|
+
this.name = "IdempotencyConflictError";
|
|
47
|
+
}
|
|
48
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/math.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Bps, Credits } from "./types.js";
|
|
2
|
+
export declare function credits(value: number | string | bigint): Credits;
|
|
3
|
+
export declare function bps(value: number | string | bigint): Bps;
|
|
4
|
+
export declare function ceilDiv(numerator: bigint, denominator: bigint): bigint;
|
|
5
|
+
export declare function mulBps(amount: Credits, multiplierBps: Bps): Credits;
|
|
6
|
+
export declare function maxBigint(a: bigint, b: bigint): bigint;
|
|
7
|
+
export declare function clampNonNegative(value: bigint, label: string): bigint;
|
package/dist/math.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export function credits(value) {
|
|
2
|
+
if (typeof value === "bigint")
|
|
3
|
+
return value;
|
|
4
|
+
if (typeof value === "number") {
|
|
5
|
+
if (!Number.isSafeInteger(value)) {
|
|
6
|
+
throw new Error(`credits(number) must be a safe integer, got: ${value}`);
|
|
7
|
+
}
|
|
8
|
+
return BigInt(value);
|
|
9
|
+
}
|
|
10
|
+
if (typeof value === "string")
|
|
11
|
+
return BigInt(value);
|
|
12
|
+
throw new Error(`Unsupported credits input: ${String(value)}`);
|
|
13
|
+
}
|
|
14
|
+
export function bps(value) {
|
|
15
|
+
return credits(value);
|
|
16
|
+
}
|
|
17
|
+
export function ceilDiv(numerator, denominator) {
|
|
18
|
+
if (denominator === 0n)
|
|
19
|
+
throw new Error("Division by zero");
|
|
20
|
+
if (numerator === 0n)
|
|
21
|
+
return 0n;
|
|
22
|
+
if (numerator > 0n && denominator > 0n)
|
|
23
|
+
return (numerator + denominator - 1n) / denominator;
|
|
24
|
+
return numerator / denominator;
|
|
25
|
+
}
|
|
26
|
+
export function mulBps(amount, multiplierBps) {
|
|
27
|
+
// ceil(amount * bps / 10_000)
|
|
28
|
+
return ceilDiv(amount * multiplierBps, 10000n);
|
|
29
|
+
}
|
|
30
|
+
export function maxBigint(a, b) {
|
|
31
|
+
return a >= b ? a : b;
|
|
32
|
+
}
|
|
33
|
+
export function clampNonNegative(value, label) {
|
|
34
|
+
if (value < 0n)
|
|
35
|
+
throw new Error(`${label} must be >= 0`);
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Bps, Credits, PriceBreakdown, ToolUsage, UsageTokens } from "./types.js";
|
|
2
|
+
export interface ModelPricingRule {
|
|
3
|
+
model: string;
|
|
4
|
+
multiplierBps: Bps;
|
|
5
|
+
}
|
|
6
|
+
export interface ToolPricingRule {
|
|
7
|
+
tool: string;
|
|
8
|
+
unitCost: Credits;
|
|
9
|
+
}
|
|
10
|
+
export interface PricingConfig {
|
|
11
|
+
inputTokenCost: Credits;
|
|
12
|
+
outputTokenCost: Credits;
|
|
13
|
+
minCost: Credits;
|
|
14
|
+
defaultModelMultiplierBps: Bps;
|
|
15
|
+
modelRules?: ModelPricingRule[];
|
|
16
|
+
toolRules?: ToolPricingRule[];
|
|
17
|
+
}
|
|
18
|
+
export declare function resolveModelMultiplierBps(config: PricingConfig, model: string): Bps;
|
|
19
|
+
export declare function resolveToolUnitCost(config: PricingConfig, tool: string): Credits;
|
|
20
|
+
export declare function estimateToolCost(config: PricingConfig, tools: ToolUsage[] | undefined): Credits;
|
|
21
|
+
export declare function estimateCost(params: {
|
|
22
|
+
config: PricingConfig;
|
|
23
|
+
model: string;
|
|
24
|
+
tokens: UsageTokens;
|
|
25
|
+
tools?: ToolUsage[];
|
|
26
|
+
riskMultiplierBps?: Bps;
|
|
27
|
+
overrideModelMultiplierBps?: Bps;
|
|
28
|
+
}): {
|
|
29
|
+
cost: Credits;
|
|
30
|
+
breakdown: PriceBreakdown;
|
|
31
|
+
};
|
|
32
|
+
export declare const DEFAULT_PRICING_CONFIG: PricingConfig;
|
package/dist/pricing.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { bps, credits, maxBigint, mulBps } from "./math.js";
|
|
2
|
+
export function resolveModelMultiplierBps(config, model) {
|
|
3
|
+
const rule = config.modelRules?.find((r) => r.model === model);
|
|
4
|
+
return rule?.multiplierBps ?? config.defaultModelMultiplierBps;
|
|
5
|
+
}
|
|
6
|
+
export function resolveToolUnitCost(config, tool) {
|
|
7
|
+
const rule = config.toolRules?.find((r) => r.tool === tool);
|
|
8
|
+
return rule?.unitCost ?? 0n;
|
|
9
|
+
}
|
|
10
|
+
export function estimateToolCost(config, tools) {
|
|
11
|
+
if (!tools?.length)
|
|
12
|
+
return 0n;
|
|
13
|
+
let total = 0n;
|
|
14
|
+
for (const t of tools) {
|
|
15
|
+
if (!Number.isFinite(t.units) || t.units < 0)
|
|
16
|
+
throw new Error(`Invalid tool units for ${t.tool}: ${t.units}`);
|
|
17
|
+
total += resolveToolUnitCost(config, t.tool) * BigInt(Math.floor(t.units));
|
|
18
|
+
}
|
|
19
|
+
return total;
|
|
20
|
+
}
|
|
21
|
+
export function estimateCost(params) {
|
|
22
|
+
const { config, model, tokens, tools } = params;
|
|
23
|
+
const riskMultiplierBps = params.riskMultiplierBps ?? 10000n;
|
|
24
|
+
const modelMultiplierBps = params.overrideModelMultiplierBps ?? resolveModelMultiplierBps(config, model);
|
|
25
|
+
if (!Number.isFinite(tokens.inputTokens) || tokens.inputTokens < 0)
|
|
26
|
+
throw new Error("inputTokens must be >= 0");
|
|
27
|
+
if (!Number.isFinite(tokens.outputTokens) || tokens.outputTokens < 0)
|
|
28
|
+
throw new Error("outputTokens must be >= 0");
|
|
29
|
+
const inputCost = config.inputTokenCost * BigInt(Math.floor(tokens.inputTokens));
|
|
30
|
+
const outputCost = config.outputTokenCost * BigInt(Math.floor(tokens.outputTokens));
|
|
31
|
+
const toolCost = estimateToolCost(config, tools);
|
|
32
|
+
const totalBeforeMultipliers = inputCost + outputCost + toolCost;
|
|
33
|
+
const afterModel = mulBps(totalBeforeMultipliers, modelMultiplierBps);
|
|
34
|
+
const totalAfterMultipliers = mulBps(afterModel, riskMultiplierBps);
|
|
35
|
+
const totalFinal = maxBigint(config.minCost, totalAfterMultipliers);
|
|
36
|
+
const breakdown = {
|
|
37
|
+
model,
|
|
38
|
+
inputCost,
|
|
39
|
+
outputCost,
|
|
40
|
+
toolCost,
|
|
41
|
+
minCostApplied: totalFinal !== totalAfterMultipliers,
|
|
42
|
+
modelMultiplierBps,
|
|
43
|
+
riskMultiplierBps: riskMultiplierBps,
|
|
44
|
+
totalBeforeMultipliers,
|
|
45
|
+
totalAfterMultipliers,
|
|
46
|
+
totalFinal,
|
|
47
|
+
};
|
|
48
|
+
return { cost: totalFinal, breakdown };
|
|
49
|
+
}
|
|
50
|
+
export const DEFAULT_PRICING_CONFIG = {
|
|
51
|
+
inputTokenCost: credits(1),
|
|
52
|
+
outputTokenCost: credits(2),
|
|
53
|
+
minCost: credits(1),
|
|
54
|
+
defaultModelMultiplierBps: bps(10_000),
|
|
55
|
+
modelRules: [],
|
|
56
|
+
toolRules: [],
|
|
57
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { CreditAccount, CreditAccountStore, CreditHold, CreditHoldStore, CreditLedgerStore, IdempotencyKey, LedgerEntry, MeteringPersistence, MeteringTransaction, QuotaBucket, QuotaPolicy, QuotaStore, UsageFacts, UsageStore, UserId } from "../types.js";
|
|
2
|
+
export declare class InMemoryUsageStore implements UsageStore {
|
|
3
|
+
readonly rows: Array<{
|
|
4
|
+
userId: UserId;
|
|
5
|
+
usage: UsageFacts;
|
|
6
|
+
estimatedCost: bigint;
|
|
7
|
+
actualCost?: bigint;
|
|
8
|
+
}>;
|
|
9
|
+
recordUsage(row: {
|
|
10
|
+
userId: UserId;
|
|
11
|
+
usage: UsageFacts;
|
|
12
|
+
estimatedCost: bigint;
|
|
13
|
+
actualCost?: bigint;
|
|
14
|
+
}): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
export declare class InMemoryCreditAccountStore implements CreditAccountStore {
|
|
17
|
+
private readonly accounts;
|
|
18
|
+
private readonly now;
|
|
19
|
+
constructor(now: () => Date);
|
|
20
|
+
getOrCreate(userId: UserId): Promise<CreditAccount>;
|
|
21
|
+
update(userId: UserId, patch: Partial<Pick<CreditAccount, "balance" | "frozen">>): Promise<CreditAccount>;
|
|
22
|
+
addCredits(userId: UserId, amount: bigint): Promise<CreditAccount>;
|
|
23
|
+
}
|
|
24
|
+
export declare class InMemoryCreditHoldStore implements CreditHoldStore {
|
|
25
|
+
private readonly holds;
|
|
26
|
+
private readonly now;
|
|
27
|
+
constructor(now: () => Date);
|
|
28
|
+
getByRequestId(userId: UserId, requestId: string): Promise<CreditHold | null>;
|
|
29
|
+
create(hold: CreditHold): Promise<CreditHold>;
|
|
30
|
+
update(userId: UserId, requestId: string, patch: Partial<Pick<CreditHold, "status" | "expiresAt">>): Promise<CreditHold>;
|
|
31
|
+
lockCapture(userId: UserId, requestId: string, capturedIdempotencyKey: string, now: Date): Promise<CreditHold>;
|
|
32
|
+
finalizeCapture(userId: UserId, requestId: string, capturedIdempotencyKey: string): Promise<CreditHold>;
|
|
33
|
+
}
|
|
34
|
+
export declare class InMemoryCreditLedgerStore implements CreditLedgerStore {
|
|
35
|
+
private readonly entries;
|
|
36
|
+
private readonly byIdempotency;
|
|
37
|
+
getByIdempotencyKey(userId: UserId, idempotencyKey: IdempotencyKey): Promise<LedgerEntry | null>;
|
|
38
|
+
append(entry: LedgerEntry): Promise<LedgerEntry>;
|
|
39
|
+
list(): LedgerEntry[];
|
|
40
|
+
}
|
|
41
|
+
export declare class InMemoryQuotaStore implements QuotaStore {
|
|
42
|
+
private readonly now;
|
|
43
|
+
private readonly policyByUser;
|
|
44
|
+
private readonly buckets;
|
|
45
|
+
constructor(now: () => Date);
|
|
46
|
+
setPolicy(userId: UserId, policy: QuotaPolicy | null): void;
|
|
47
|
+
getActivePolicy(userId: UserId): Promise<QuotaPolicy | null>;
|
|
48
|
+
getOrCreateBucket(userId: UserId, policy: QuotaPolicy, now: Date): Promise<QuotaBucket>;
|
|
49
|
+
updateBucket(userId: UserId, policyId: string, periodStart: Date, patch: Partial<Pick<QuotaBucket, "usedCredits" | "reservedCredits" | "usedRequests" | "reservedRequests">>): Promise<QuotaBucket>;
|
|
50
|
+
private computePeriod;
|
|
51
|
+
}
|
|
52
|
+
export declare class InMemoryMeteringPersistence implements MeteringPersistence {
|
|
53
|
+
private readonly clock;
|
|
54
|
+
readonly accounts: InMemoryCreditAccountStore;
|
|
55
|
+
readonly holds: InMemoryCreditHoldStore;
|
|
56
|
+
readonly ledger: InMemoryCreditLedgerStore;
|
|
57
|
+
readonly quota: InMemoryQuotaStore | undefined;
|
|
58
|
+
readonly usage: InMemoryUsageStore | undefined;
|
|
59
|
+
constructor(params?: {
|
|
60
|
+
now?: () => Date;
|
|
61
|
+
quota?: boolean;
|
|
62
|
+
usage?: boolean;
|
|
63
|
+
});
|
|
64
|
+
now(): Date;
|
|
65
|
+
withTransaction<T>(fn: (tx: MeteringTransaction) => Promise<T>): Promise<T>;
|
|
66
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
function key(userId, requestId) {
|
|
2
|
+
return `${userId}::${requestId}`;
|
|
3
|
+
}
|
|
4
|
+
export class InMemoryUsageStore {
|
|
5
|
+
rows = [];
|
|
6
|
+
async recordUsage(row) {
|
|
7
|
+
this.rows.push(row);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export class InMemoryCreditAccountStore {
|
|
11
|
+
accounts = new Map();
|
|
12
|
+
now;
|
|
13
|
+
constructor(now) {
|
|
14
|
+
this.now = now;
|
|
15
|
+
}
|
|
16
|
+
async getOrCreate(userId) {
|
|
17
|
+
const existing = this.accounts.get(userId);
|
|
18
|
+
if (existing)
|
|
19
|
+
return existing;
|
|
20
|
+
const created = { userId, balance: 0n, frozen: 0n, updatedAt: this.now() };
|
|
21
|
+
this.accounts.set(userId, created);
|
|
22
|
+
return created;
|
|
23
|
+
}
|
|
24
|
+
async update(userId, patch) {
|
|
25
|
+
const existing = await this.getOrCreate(userId);
|
|
26
|
+
const updated = {
|
|
27
|
+
...existing,
|
|
28
|
+
balance: patch.balance ?? existing.balance,
|
|
29
|
+
frozen: patch.frozen ?? existing.frozen,
|
|
30
|
+
updatedAt: this.now(),
|
|
31
|
+
};
|
|
32
|
+
this.accounts.set(userId, updated);
|
|
33
|
+
return updated;
|
|
34
|
+
}
|
|
35
|
+
// Convenience for demos/tests.
|
|
36
|
+
async addCredits(userId, amount) {
|
|
37
|
+
const acc = await this.getOrCreate(userId);
|
|
38
|
+
return this.update(userId, { balance: acc.balance + amount });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export class InMemoryCreditHoldStore {
|
|
42
|
+
holds = new Map();
|
|
43
|
+
now;
|
|
44
|
+
constructor(now) {
|
|
45
|
+
this.now = now;
|
|
46
|
+
}
|
|
47
|
+
async getByRequestId(userId, requestId) {
|
|
48
|
+
return this.holds.get(key(userId, requestId)) ?? null;
|
|
49
|
+
}
|
|
50
|
+
async create(hold) {
|
|
51
|
+
this.holds.set(key(hold.userId, hold.requestId), { ...hold, updatedAt: this.now() });
|
|
52
|
+
return (await this.getByRequestId(hold.userId, hold.requestId));
|
|
53
|
+
}
|
|
54
|
+
async update(userId, requestId, patch) {
|
|
55
|
+
const existing = await this.getByRequestId(userId, requestId);
|
|
56
|
+
if (!existing)
|
|
57
|
+
throw new Error("Hold not found");
|
|
58
|
+
const updated = { ...existing, ...patch, updatedAt: this.now() };
|
|
59
|
+
this.holds.set(key(userId, requestId), updated);
|
|
60
|
+
return updated;
|
|
61
|
+
}
|
|
62
|
+
async lockCapture(userId, requestId, capturedIdempotencyKey, now) {
|
|
63
|
+
const existing = await this.getByRequestId(userId, requestId);
|
|
64
|
+
if (!existing)
|
|
65
|
+
throw new Error("Hold not found");
|
|
66
|
+
if (existing.expiresAt.getTime() <= now.getTime())
|
|
67
|
+
return existing;
|
|
68
|
+
if (existing.capturedIdempotencyKey && existing.capturedIdempotencyKey !== capturedIdempotencyKey)
|
|
69
|
+
return existing;
|
|
70
|
+
if (existing.capturedIdempotencyKey === capturedIdempotencyKey)
|
|
71
|
+
return existing;
|
|
72
|
+
const updated = { ...existing, capturedIdempotencyKey, updatedAt: this.now() };
|
|
73
|
+
this.holds.set(key(userId, requestId), updated);
|
|
74
|
+
return updated;
|
|
75
|
+
}
|
|
76
|
+
async finalizeCapture(userId, requestId, capturedIdempotencyKey) {
|
|
77
|
+
const existing = await this.getByRequestId(userId, requestId);
|
|
78
|
+
if (!existing)
|
|
79
|
+
throw new Error("Hold not found");
|
|
80
|
+
if (existing.capturedIdempotencyKey !== capturedIdempotencyKey)
|
|
81
|
+
return existing;
|
|
82
|
+
if (existing.status === "captured")
|
|
83
|
+
return existing;
|
|
84
|
+
const updated = { ...existing, status: "captured", updatedAt: this.now() };
|
|
85
|
+
this.holds.set(key(userId, requestId), updated);
|
|
86
|
+
return updated;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export class InMemoryCreditLedgerStore {
|
|
90
|
+
entries = [];
|
|
91
|
+
byIdempotency = new Map();
|
|
92
|
+
async getByIdempotencyKey(userId, idempotencyKey) {
|
|
93
|
+
return this.byIdempotency.get(key(userId, idempotencyKey)) ?? null;
|
|
94
|
+
}
|
|
95
|
+
async append(entry) {
|
|
96
|
+
const k = key(entry.userId, entry.idempotencyKey);
|
|
97
|
+
const existing = this.byIdempotency.get(k);
|
|
98
|
+
if (existing)
|
|
99
|
+
return existing;
|
|
100
|
+
this.entries.push(entry);
|
|
101
|
+
this.byIdempotency.set(k, entry);
|
|
102
|
+
return entry;
|
|
103
|
+
}
|
|
104
|
+
list() {
|
|
105
|
+
return [...this.entries];
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
export class InMemoryQuotaStore {
|
|
109
|
+
now;
|
|
110
|
+
policyByUser = new Map();
|
|
111
|
+
buckets = new Map();
|
|
112
|
+
constructor(now) {
|
|
113
|
+
this.now = now;
|
|
114
|
+
}
|
|
115
|
+
setPolicy(userId, policy) {
|
|
116
|
+
this.policyByUser.set(userId, policy);
|
|
117
|
+
}
|
|
118
|
+
async getActivePolicy(userId) {
|
|
119
|
+
return this.policyByUser.get(userId) ?? null;
|
|
120
|
+
}
|
|
121
|
+
async getOrCreateBucket(userId, policy, now) {
|
|
122
|
+
const { start, end } = this.computePeriod(policy.period, now);
|
|
123
|
+
const bucketKey = `${userId}::${policy.policyId}::${start.toISOString()}`;
|
|
124
|
+
const existing = this.buckets.get(bucketKey);
|
|
125
|
+
if (existing)
|
|
126
|
+
return existing;
|
|
127
|
+
const created = {
|
|
128
|
+
userId,
|
|
129
|
+
policyId: policy.policyId,
|
|
130
|
+
periodStart: start,
|
|
131
|
+
periodEnd: end,
|
|
132
|
+
usedCredits: 0n,
|
|
133
|
+
reservedCredits: 0n,
|
|
134
|
+
usedRequests: 0,
|
|
135
|
+
reservedRequests: 0,
|
|
136
|
+
updatedAt: this.now(),
|
|
137
|
+
};
|
|
138
|
+
this.buckets.set(bucketKey, created);
|
|
139
|
+
return created;
|
|
140
|
+
}
|
|
141
|
+
async updateBucket(userId, policyId, periodStart, patch) {
|
|
142
|
+
const bucketKey = `${userId}::${policyId}::${periodStart.toISOString()}`;
|
|
143
|
+
const existing = this.buckets.get(bucketKey);
|
|
144
|
+
if (!existing)
|
|
145
|
+
throw new Error("Quota bucket not found");
|
|
146
|
+
const updated = {
|
|
147
|
+
...existing,
|
|
148
|
+
usedCredits: patch.usedCredits ?? existing.usedCredits,
|
|
149
|
+
reservedCredits: patch.reservedCredits ?? existing.reservedCredits,
|
|
150
|
+
usedRequests: patch.usedRequests ?? existing.usedRequests,
|
|
151
|
+
reservedRequests: patch.reservedRequests ?? existing.reservedRequests,
|
|
152
|
+
updatedAt: this.now(),
|
|
153
|
+
};
|
|
154
|
+
this.buckets.set(bucketKey, updated);
|
|
155
|
+
return updated;
|
|
156
|
+
}
|
|
157
|
+
computePeriod(period, now) {
|
|
158
|
+
const d = new Date(now);
|
|
159
|
+
if (period === "daily") {
|
|
160
|
+
const start = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 0, 0, 0, 0));
|
|
161
|
+
const end = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 1, 0, 0, 0, 0));
|
|
162
|
+
return { start, end };
|
|
163
|
+
}
|
|
164
|
+
// monthly
|
|
165
|
+
const start = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1, 0, 0, 0, 0));
|
|
166
|
+
const end = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1, 0, 0, 0, 0));
|
|
167
|
+
return { start, end };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
export class InMemoryMeteringPersistence {
|
|
171
|
+
clock;
|
|
172
|
+
accounts;
|
|
173
|
+
holds;
|
|
174
|
+
ledger;
|
|
175
|
+
quota;
|
|
176
|
+
usage;
|
|
177
|
+
constructor(params) {
|
|
178
|
+
this.clock = params?.now ?? (() => new Date());
|
|
179
|
+
this.accounts = new InMemoryCreditAccountStore(this.clock);
|
|
180
|
+
this.holds = new InMemoryCreditHoldStore(this.clock);
|
|
181
|
+
this.ledger = new InMemoryCreditLedgerStore();
|
|
182
|
+
this.quota = params?.quota ? new InMemoryQuotaStore(this.clock) : undefined;
|
|
183
|
+
this.usage = params?.usage ? new InMemoryUsageStore() : undefined;
|
|
184
|
+
}
|
|
185
|
+
now() {
|
|
186
|
+
return this.clock();
|
|
187
|
+
}
|
|
188
|
+
async withTransaction(fn) {
|
|
189
|
+
// In-memory: operations are already single-threaded; treat as a "transaction".
|
|
190
|
+
return fn({
|
|
191
|
+
accounts: this.accounts,
|
|
192
|
+
holds: this.holds,
|
|
193
|
+
ledger: this.ledger,
|
|
194
|
+
...(this.quota ? { quota: this.quota } : {}),
|
|
195
|
+
...(this.usage ? { usage: this.usage } : {}),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { MeteringPersistence, UserId } from "../types.js";
|
|
2
|
+
interface PrismaDelegate<T> {
|
|
3
|
+
findUnique(args: any): Promise<T | null>;
|
|
4
|
+
findFirst(args: any): Promise<T | null>;
|
|
5
|
+
create(args: any): Promise<T>;
|
|
6
|
+
update(args: any): Promise<T>;
|
|
7
|
+
upsert(args: any): Promise<T>;
|
|
8
|
+
}
|
|
9
|
+
interface PrismaClientTx {
|
|
10
|
+
creditAccount: PrismaDelegate<any>;
|
|
11
|
+
creditHold: PrismaDelegate<any>;
|
|
12
|
+
creditLedger: PrismaDelegate<any>;
|
|
13
|
+
planQuotaPolicy: PrismaDelegate<any>;
|
|
14
|
+
quotaBucket: PrismaDelegate<any>;
|
|
15
|
+
user?: PrismaDelegate<any>;
|
|
16
|
+
creditAccountUpdateMany?: (args: any) => Promise<{
|
|
17
|
+
count: number;
|
|
18
|
+
}>;
|
|
19
|
+
quotaBucketUpdateMany?: (args: any) => Promise<{
|
|
20
|
+
count: number;
|
|
21
|
+
}>;
|
|
22
|
+
creditHoldUpdateMany?: (args: any) => Promise<{
|
|
23
|
+
count: number;
|
|
24
|
+
}>;
|
|
25
|
+
}
|
|
26
|
+
export interface PrismaClientLike {
|
|
27
|
+
$transaction<T>(fn: (tx: PrismaClientTx) => Promise<T>, options?: any): Promise<T>;
|
|
28
|
+
}
|
|
29
|
+
export interface PrismaPersistenceOptions {
|
|
30
|
+
isolationLevel?: any;
|
|
31
|
+
maxRetries?: number;
|
|
32
|
+
now?: () => Date;
|
|
33
|
+
resolvePlanKey?: (userId: UserId, tx: PrismaClientTx) => Promise<string>;
|
|
34
|
+
}
|
|
35
|
+
export declare function createPrismaPersistence(prisma: PrismaClientLike, options?: PrismaPersistenceOptions): MeteringPersistence;
|
|
36
|
+
export {};
|