@tokenbuddy/tokenbuddy 1.0.9 → 1.0.11

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.
Files changed (71) hide show
  1. package/dist/src/buyer-store.d.ts +13 -0
  2. package/dist/src/buyer-store.d.ts.map +1 -1
  3. package/dist/src/buyer-store.js +21 -2
  4. package/dist/src/buyer-store.js.map +1 -1
  5. package/dist/src/cli.d.ts.map +1 -1
  6. package/dist/src/cli.js +54 -0
  7. package/dist/src/cli.js.map +1 -1
  8. package/dist/src/credit-tracker.d.ts +118 -0
  9. package/dist/src/credit-tracker.d.ts.map +1 -0
  10. package/dist/src/credit-tracker.js +220 -0
  11. package/dist/src/credit-tracker.js.map +1 -0
  12. package/dist/src/daemon.d.ts +49 -4
  13. package/dist/src/daemon.d.ts.map +1 -1
  14. package/dist/src/daemon.js +541 -405
  15. package/dist/src/daemon.js.map +1 -1
  16. package/dist/src/model-index.d.ts +86 -0
  17. package/dist/src/model-index.d.ts.map +1 -0
  18. package/dist/src/model-index.js +214 -0
  19. package/dist/src/model-index.js.map +1 -0
  20. package/dist/src/prewarm-cache.d.ts +149 -0
  21. package/dist/src/prewarm-cache.d.ts.map +1 -0
  22. package/dist/src/prewarm-cache.js +288 -0
  23. package/dist/src/prewarm-cache.js.map +1 -0
  24. package/dist/src/prewarm-scheduler.d.ts +150 -0
  25. package/dist/src/prewarm-scheduler.d.ts.map +1 -0
  26. package/dist/src/prewarm-scheduler.js +484 -0
  27. package/dist/src/prewarm-scheduler.js.map +1 -0
  28. package/dist/src/provider-install.d.ts.map +1 -1
  29. package/dist/src/provider-install.js +9 -1
  30. package/dist/src/provider-install.js.map +1 -1
  31. package/dist/src/route-failover.d.ts +96 -0
  32. package/dist/src/route-failover.d.ts.map +1 -0
  33. package/dist/src/route-failover.js +177 -0
  34. package/dist/src/route-failover.js.map +1 -0
  35. package/dist/src/seller-catalog.d.ts +26 -0
  36. package/dist/src/seller-catalog.d.ts.map +1 -1
  37. package/dist/src/seller-catalog.js +40 -0
  38. package/dist/src/seller-catalog.js.map +1 -1
  39. package/dist/src/seller-pool.d.ts +127 -0
  40. package/dist/src/seller-pool.d.ts.map +1 -0
  41. package/dist/src/seller-pool.js +243 -0
  42. package/dist/src/seller-pool.js.map +1 -0
  43. package/dist/src/stream-failover.d.ts +78 -0
  44. package/dist/src/stream-failover.d.ts.map +1 -0
  45. package/dist/src/stream-failover.js +93 -0
  46. package/dist/src/stream-failover.js.map +1 -0
  47. package/package.json +1 -1
  48. package/src/buyer-store.ts +32 -2
  49. package/src/cli.ts +61 -0
  50. package/src/credit-tracker.test.ts +165 -0
  51. package/src/credit-tracker.ts +269 -0
  52. package/src/daemon.ts +569 -445
  53. package/src/model-index.test.ts +184 -0
  54. package/src/model-index.ts +266 -0
  55. package/src/prewarm-cache.test.ts +281 -0
  56. package/src/prewarm-cache.ts +373 -0
  57. package/src/prewarm-scheduler.test.ts +367 -0
  58. package/src/prewarm-scheduler.ts +581 -0
  59. package/src/provider-install.ts +9 -1
  60. package/src/route-failover.test.ts +193 -0
  61. package/src/route-failover.ts +233 -0
  62. package/src/seller-catalog-413.test.ts +61 -0
  63. package/src/seller-catalog.ts +47 -0
  64. package/src/seller-pool.test.ts +231 -0
  65. package/src/seller-pool.ts +333 -0
  66. package/src/stream-failover.test.ts +52 -0
  67. package/src/stream-failover.ts +129 -0
  68. package/src/thousand-seller.test.ts +151 -0
  69. package/tests/daemon-413-fallback.test.ts +92 -0
  70. package/tests/e2e.test.ts +3 -2
  71. package/tests/tokenbuddy.test.ts +68 -11
@@ -0,0 +1,165 @@
1
+ import {
2
+ CreditTracker,
3
+ DEFAULT_FRESH_PURCHASE_THRESHOLD,
4
+ DEFAULT_FRESH_PURCHASE_WINDOW_MS,
5
+ DEFAULT_PURCHASE_BUDGET_PER_MINUTE
6
+ } from "../src/credit-tracker.js";
7
+
8
+ describe("CreditTracker", () => {
9
+ function makeClock(start = 1_000_000): { now: number; advance: (ms: number) => void } {
10
+ const clock = { now: start, advance: (ms: number) => { clock.now += ms; } };
11
+ return clock;
12
+ }
13
+
14
+ test("recordPurchase creates an entry with the supplied balance", () => {
15
+ const clock = makeClock();
16
+ const tracker = new CreditTracker({ now: () => clock.now });
17
+ const entry = tracker.recordPurchase("s1", 1_000_000, 1_000_000);
18
+ expect(entry.sellerId).toBe("s1");
19
+ expect(entry.lastPurchaseAmountMicros).toBe(1_000_000);
20
+ expect(entry.currentBalanceMicros).toBe(1_000_000);
21
+ expect(entry.leftoverCreditMicros).toBe(0);
22
+ expect(tracker.summary().purchasesInLastMinute).toBe(1);
23
+ });
24
+
25
+ test("recordPurchase rejects non-positive amounts", () => {
26
+ const tracker = new CreditTracker();
27
+ expect(() => tracker.recordPurchase("s1", 0, 0)).toThrow();
28
+ expect(() => tracker.recordPurchase("s1", -1, -1)).toThrow();
29
+ });
30
+
31
+ test("recordSpend clamps to zero and is a no-op for unknown sellers", () => {
32
+ const tracker = new CreditTracker();
33
+ expect(tracker.recordSpend("missing", 100)).toBeUndefined();
34
+ tracker.recordPurchase("s1", 1_000_000, 1_000_000);
35
+ const updated = tracker.recordSpend("s1", 600_000);
36
+ expect(updated?.currentBalanceMicros).toBe(600_000);
37
+ const zeroed = tracker.recordSpend("s1", -10);
38
+ expect(zeroed?.currentBalanceMicros).toBe(0);
39
+ });
40
+
41
+ test("isInFreshPurchaseWindow is true only when balance ratio and time window are both satisfied", () => {
42
+ const clock = makeClock();
43
+ const tracker = new CreditTracker({ now: () => clock.now });
44
+ tracker.recordPurchase("s1", 1_000_000, 1_000_000);
45
+
46
+ // Right after purchase: 100% balance, well within the window.
47
+ expect(tracker.isInFreshPurchaseWindow("s1")).toBe(true);
48
+
49
+ // After 50% spend: ratio = 0.5, still at the default threshold.
50
+ tracker.recordSpend("s1", 500_000);
51
+ expect(tracker.isInFreshPurchaseWindow("s1")).toBe(true);
52
+
53
+ // After more spend the ratio drops below threshold.
54
+ tracker.recordSpend("s1", 100_000);
55
+ expect(tracker.isInFreshPurchaseWindow("s1")).toBe(false);
56
+
57
+ // Replenish via a new purchase and verify window restarts.
58
+ clock.advance(1_000);
59
+ tracker.recordPurchase("s1", 1_000_000, 1_000_000);
60
+ expect(tracker.isInFreshPurchaseWindow("s1")).toBe(true);
61
+
62
+ // Advance past the window: even a full balance no longer counts.
63
+ clock.advance(DEFAULT_FRESH_PURCHASE_WINDOW_MS + 1);
64
+ expect(tracker.isInFreshPurchaseWindow("s1")).toBe(false);
65
+ });
66
+
67
+ test("isInFreshPurchaseWindow returns false for sellers without any purchase", () => {
68
+ const tracker = new CreditTracker();
69
+ expect(tracker.isInFreshPurchaseWindow("never-purchased")).toBe(false);
70
+ });
71
+
72
+ test("canAutoPurchase enforces a per-minute budget and prunes old timestamps", () => {
73
+ const clock = makeClock();
74
+ const tracker = new CreditTracker({ now: () => clock.now });
75
+
76
+ expect(tracker.canAutoPurchase()).toBe(true);
77
+ tracker.recordPurchase("s1", 100, 100);
78
+ tracker.recordPurchase("s2", 100, 100);
79
+ tracker.recordPurchase("s3", 100, 100);
80
+ expect(tracker.canAutoPurchase()).toBe(false); // budget hit
81
+
82
+ // Advance past the 60s window: oldest timestamps fall out.
83
+ clock.advance(60_001);
84
+ expect(tracker.canAutoPurchase()).toBe(true);
85
+ });
86
+
87
+ test("transferLeftoverToWasted moves the current balance into the leftover bucket and tracks waste", () => {
88
+ const clock = makeClock();
89
+ const tracker = new CreditTracker({ now: () => clock.now });
90
+ tracker.recordPurchase("s1", 1_000_000, 800_000);
91
+ const wasted = tracker.transferLeftoverToWasted("s1", "failover");
92
+ expect(wasted).toBe(800_000);
93
+ const entry = tracker.getEntry("s1");
94
+ expect(entry?.currentBalanceMicros).toBe(0);
95
+ expect(entry?.leftoverCreditMicros).toBe(800_000);
96
+ const summary = tracker.summary();
97
+ expect(summary.totalWastedMicros).toBe(800_000);
98
+ expect(summary.wastedSinceLastDoctorRun).toBe(800_000);
99
+ });
100
+
101
+ test("transferLeftoverToWasted is a no-op for unknown sellers or empty balances", () => {
102
+ const tracker = new CreditTracker();
103
+ expect(tracker.transferLeftoverToWasted("missing", "x")).toBe(0);
104
+ tracker.recordPurchase("s1", 100, 0);
105
+ expect(tracker.transferLeftoverToWasted("s1", "x")).toBe(0);
106
+ });
107
+
108
+ test("consumeLeftover drains the leftover bucket up to the requested amount", () => {
109
+ const tracker = new CreditTracker();
110
+ tracker.recordPurchase("s1", 1_000_000, 500_000);
111
+ tracker.transferLeftoverToWasted("s1", "failover");
112
+ expect(tracker.consumeLeftover("s1", 300_000)).toBe(300_000);
113
+ expect(tracker.getEntry("s1")?.leftoverCreditMicros).toBe(200_000);
114
+ // Requesting more than available returns only the available amount.
115
+ expect(tracker.consumeLeftover("s1", 999_999)).toBe(200_000);
116
+ expect(tracker.getEntry("s1")?.leftoverCreditMicros).toBe(0);
117
+ });
118
+
119
+ test("consumeLeftover is a no-op for sellers without leftover", () => {
120
+ const tracker = new CreditTracker();
121
+ expect(tracker.consumeLeftover("missing", 100)).toBe(0);
122
+ });
123
+
124
+ test("summary includes per-seller entries with latest values", () => {
125
+ const clock = makeClock();
126
+ const tracker = new CreditTracker({ now: () => clock.now });
127
+ tracker.recordPurchase("s1", 1_000_000, 1_000_000);
128
+ clock.advance(1_000);
129
+ tracker.recordPurchase("s2", 500_000, 500_000);
130
+ const summary = tracker.summary();
131
+ expect(summary.perSeller).toHaveLength(2);
132
+ const s2 = summary.perSeller.find((entry) => entry.sellerId === "s2");
133
+ expect(s2?.currentBalanceMicros).toBe(500_000);
134
+ expect(s2?.lastPurchaseAt).toBe(clock.now);
135
+ });
136
+
137
+ test("resetDoctorRunCounter clears the wasted-since-last-doctor counter", () => {
138
+ const tracker = new CreditTracker();
139
+ tracker.recordPurchase("s1", 1_000_000, 500_000);
140
+ tracker.transferLeftoverToWasted("s1", "failover");
141
+ expect(tracker.summary().wastedSinceLastDoctorRun).toBe(500_000);
142
+ tracker.resetDoctorRunCounter();
143
+ expect(tracker.summary().wastedSinceLastDoctorRun).toBe(0);
144
+ // totalWastedMicros is preserved.
145
+ expect(tracker.summary().totalWastedMicros).toBe(500_000);
146
+ });
147
+
148
+ test("clear removes all entries and resets all counters", () => {
149
+ const tracker = new CreditTracker();
150
+ tracker.recordPurchase("s1", 100, 100);
151
+ tracker.transferLeftoverToWasted("s1", "failover");
152
+ tracker.clear();
153
+ const summary = tracker.summary();
154
+ expect(summary.totalWastedMicros).toBe(0);
155
+ expect(summary.wastedSinceLastDoctorRun).toBe(0);
156
+ expect(summary.purchasesInLastMinute).toBe(0);
157
+ expect(summary.perSeller).toEqual([]);
158
+ });
159
+
160
+ test("default constants match the v1.1 / v1.2 design", () => {
161
+ expect(DEFAULT_FRESH_PURCHASE_WINDOW_MS).toBe(30_000);
162
+ expect(DEFAULT_FRESH_PURCHASE_THRESHOLD).toBe(0.5);
163
+ expect(DEFAULT_PURCHASE_BUDGET_PER_MINUTE).toBe(3);
164
+ });
165
+ });
@@ -0,0 +1,269 @@
1
+ import { createModuleLogger } from "@tokenbuddy/logging";
2
+
3
+ const logger = createModuleLogger("tb-proxyd:credit-tracker");
4
+
5
+ /**
6
+ * Default window after a successful purchase during which failover is
7
+ * softened (see buyer-driven-fallback-design.md §17.3). 30 seconds is the
8
+ * v1.1 starting point; v1.2 keeps the same value.
9
+ */
10
+ export const DEFAULT_FRESH_PURCHASE_WINDOW_MS = 30_000;
11
+
12
+ /**
13
+ * Default threshold of "still considered fresh": the current balance must
14
+ * still cover this fraction of the last purchase amount for the entry to
15
+ * be treated as "刚买". 0.5 means "at least half of the purchased credit is
16
+ * still untouched".
17
+ */
18
+ export const DEFAULT_FRESH_PURCHASE_THRESHOLD = 0.5;
19
+
20
+ /**
21
+ * Maximum number of automatic purchases a single session may trigger per
22
+ * minute. When the budget is exhausted, route-failover must `切` rather than
23
+ * `重买` (see §17.5).
24
+ */
25
+ export const DEFAULT_PURCHASE_BUDGET_PER_MINUTE = 3;
26
+
27
+ export interface CreditTrackerOptions {
28
+ freshPurchaseWindowMs?: number;
29
+ freshPurchaseThreshold?: number;
30
+ purchaseBudgetPerMinute?: number;
31
+ now?: () => number;
32
+ }
33
+
34
+ interface SellerCredit {
35
+ sellerId: string;
36
+ lastPurchaseAt: number;
37
+ lastPurchaseAmountMicros: number;
38
+ currentBalanceMicros: number;
39
+ leftoverCreditMicros: number;
40
+ lastUpdatedAt: number;
41
+ }
42
+
43
+ /**
44
+ * Tracks per-seller credit state for the buyer-driven-fallback design's
45
+ * "balance protection" rules. The tracker is intentionally agnostic of the
46
+ * actual seller HTTP layer: callers feed in `recordPurchase`,
47
+ * `recordSpend`, and `transferLeftover` events as they observe them, and
48
+ * the tracker answers "is this seller still inside its fresh-purchase
49
+ * window?" / "may I auto-purchase again this minute?".
50
+ *
51
+ * The tracker is process-local: a buyer restart resets the session
52
+ * counters. See §17.11.4 for the rationale.
53
+ */
54
+ export class CreditTracker {
55
+ private readonly freshPurchaseWindowMs: number;
56
+ private readonly freshPurchaseThreshold: number;
57
+ private readonly purchaseBudgetPerMinute: number;
58
+ private readonly now: () => number;
59
+
60
+ private readonly entries = new Map<string, SellerCredit>();
61
+ private readonly purchaseTimestamps: number[] = [];
62
+ private totalWastedMicros = 0;
63
+ private wastedSinceLastDoctorRun = 0;
64
+
65
+ constructor(options: CreditTrackerOptions = {}) {
66
+ this.freshPurchaseWindowMs = options.freshPurchaseWindowMs ?? DEFAULT_FRESH_PURCHASE_WINDOW_MS;
67
+ this.freshPurchaseThreshold = options.freshPurchaseThreshold ?? DEFAULT_FRESH_PURCHASE_THRESHOLD;
68
+ this.purchaseBudgetPerMinute = options.purchaseBudgetPerMinute ?? DEFAULT_PURCHASE_BUDGET_PER_MINUTE;
69
+ this.now = options.now ?? Date.now;
70
+ }
71
+
72
+ /**
73
+ * Register a successful purchase. Updates the seller's balance, the
74
+ * session-wide budget window, and the "fresh purchase" timestamps. If
75
+ * the seller is unknown, an entry is created.
76
+ */
77
+ recordPurchase(sellerId: string, amountMicros: number, balanceMicros: number): SellerCredit {
78
+ if (!Number.isFinite(amountMicros) || amountMicros <= 0) {
79
+ throw new Error("recordPurchase requires a positive amountMicros");
80
+ }
81
+ const ts = this.now();
82
+ const previous = this.entries.get(sellerId);
83
+ const entry: SellerCredit = {
84
+ sellerId,
85
+ lastPurchaseAt: ts,
86
+ lastPurchaseAmountMicros: amountMicros,
87
+ currentBalanceMicros: Math.max(0, balanceMicros),
88
+ leftoverCreditMicros: previous?.leftoverCreditMicros ?? 0,
89
+ lastUpdatedAt: ts
90
+ };
91
+ this.entries.set(sellerId, entry);
92
+ this.purchaseTimestamps.push(ts);
93
+ this.prunePurchaseTimestamps(ts);
94
+ logger.info("credit.purchase.recorded", "seller credit purchase recorded", {
95
+ sellerId,
96
+ amountMicros,
97
+ balanceMicros: entry.currentBalanceMicros,
98
+ purchasesInLastMinute: this.purchaseTimestamps.length
99
+ });
100
+ return entry;
101
+ }
102
+
103
+ /**
104
+ * Update the seller's current balance after a successful inference. The
105
+ * amount spent is implicit (`previous - next`) so the caller does not
106
+ * have to track it. A non-positive `balanceMicros` is treated as 0.
107
+ */
108
+ recordSpend(sellerId: string, balanceMicros: number): SellerCredit | undefined {
109
+ const previous = this.entries.get(sellerId);
110
+ if (!previous) {
111
+ return undefined;
112
+ }
113
+ const next: SellerCredit = {
114
+ ...previous,
115
+ currentBalanceMicros: Math.max(0, balanceMicros),
116
+ lastUpdatedAt: this.now()
117
+ };
118
+ this.entries.set(sellerId, next);
119
+ return next;
120
+ }
121
+
122
+ /**
123
+ * Move the seller's remaining balance to the `leftoverCreditMicros`
124
+ * bucket. Called when failover triggers while a balance is still
125
+ * available. The pool's pre-warm / probe logic can later attempt to
126
+ * consume the leftover credit (see PR-3.1). The wasted amount is also
127
+ * added to the session-level counters.
128
+ */
129
+ transferLeftoverToWasted(sellerId: string, reason: string): number {
130
+ const previous = this.entries.get(sellerId);
131
+ if (!previous) {
132
+ return 0;
133
+ }
134
+ const wasted = previous.currentBalanceMicros;
135
+ if (wasted <= 0) {
136
+ return 0;
137
+ }
138
+ const next: SellerCredit = {
139
+ ...previous,
140
+ currentBalanceMicros: 0,
141
+ leftoverCreditMicros: previous.leftoverCreditMicros + wasted,
142
+ lastUpdatedAt: this.now()
143
+ };
144
+ this.entries.set(sellerId, next);
145
+ this.totalWastedMicros += wasted;
146
+ this.wastedSinceLastDoctorRun += wasted;
147
+ logger.warn("credit.leftover.wasted", "leftover credit logged as wasted on failover", {
148
+ sellerId,
149
+ wastedMicros: wasted,
150
+ reason,
151
+ totalWastedMicros: this.totalWastedMicros
152
+ });
153
+ return wasted;
154
+ }
155
+
156
+ /**
157
+ * Consume leftover credit when the seller recovers and a probe
158
+ * successfully burns it down. Returns the amount actually consumed.
159
+ */
160
+ consumeLeftover(sellerId: string, amountMicros: number): number {
161
+ const previous = this.entries.get(sellerId);
162
+ if (!previous || previous.leftoverCreditMicros <= 0) {
163
+ return 0;
164
+ }
165
+ const consume = Math.max(0, Math.min(amountMicros, previous.leftoverCreditMicros));
166
+ if (consume <= 0) {
167
+ return 0;
168
+ }
169
+ const next: SellerCredit = {
170
+ ...previous,
171
+ leftoverCreditMicros: previous.leftoverCreditMicros - consume,
172
+ lastUpdatedAt: this.now()
173
+ };
174
+ this.entries.set(sellerId, next);
175
+ logger.info("credit.leftover.consumed", "leftover credit consumed by recovery probe", {
176
+ sellerId,
177
+ consumedMicros: consume,
178
+ remainingMicros: next.leftoverCreditMicros
179
+ });
180
+ return consume;
181
+ }
182
+
183
+ /**
184
+ * Returns `true` when the seller is still inside the fresh-purchase
185
+ * window: at least `freshPurchaseThreshold` of the most recent
186
+ * purchase is still unused, and the window has not elapsed. Sellers
187
+ * with no recorded purchase are never "fresh".
188
+ */
189
+ isInFreshPurchaseWindow(sellerId: string, now: number = this.now()): boolean {
190
+ const entry = this.entries.get(sellerId);
191
+ if (!entry || entry.lastPurchaseAmountMicros <= 0) {
192
+ return false;
193
+ }
194
+ if (now - entry.lastPurchaseAt > this.freshPurchaseWindowMs) {
195
+ return false;
196
+ }
197
+ const ratio = entry.currentBalanceMicros / entry.lastPurchaseAmountMicros;
198
+ return ratio >= this.freshPurchaseThreshold;
199
+ }
200
+
201
+ /**
202
+ * Returns `true` when the per-minute auto-purchase budget is still
203
+ * available. Used by `route-failover` to refuse auto-repurchase and
204
+ * force a clean `切` once the session is at risk of over-spending.
205
+ */
206
+ canAutoPurchase(now: number = this.now()): boolean {
207
+ this.prunePurchaseTimestamps(now);
208
+ return this.purchaseTimestamps.length < this.purchaseBudgetPerMinute;
209
+ }
210
+
211
+ /**
212
+ * Snapshot for `tb doctor`. Mirrors the design's "Credit Usage" block.
213
+ */
214
+ summary(): CreditSummary {
215
+ return {
216
+ totalWastedMicros: this.totalWastedMicros,
217
+ wastedSinceLastDoctorRun: this.wastedSinceLastDoctorRun,
218
+ purchasesInLastMinute: this.purchaseTimestamps.length,
219
+ purchaseBudgetPerMinute: this.purchaseBudgetPerMinute,
220
+ freshPurchaseWindowMs: this.freshPurchaseWindowMs,
221
+ freshPurchaseThreshold: this.freshPurchaseThreshold,
222
+ perSeller: Array.from(this.entries.values()).map((entry) => ({
223
+ sellerId: entry.sellerId,
224
+ currentBalanceMicros: entry.currentBalanceMicros,
225
+ lastPurchaseAmountMicros: entry.lastPurchaseAmountMicros,
226
+ lastPurchaseAt: entry.lastPurchaseAt,
227
+ leftoverCreditMicros: entry.leftoverCreditMicros
228
+ }))
229
+ };
230
+ }
231
+
232
+ getEntry(sellerId: string): SellerCredit | undefined {
233
+ return this.entries.get(sellerId);
234
+ }
235
+
236
+ resetDoctorRunCounter(): void {
237
+ this.wastedSinceLastDoctorRun = 0;
238
+ }
239
+
240
+ clear(): void {
241
+ this.entries.clear();
242
+ this.purchaseTimestamps.length = 0;
243
+ this.totalWastedMicros = 0;
244
+ this.wastedSinceLastDoctorRun = 0;
245
+ }
246
+
247
+ private prunePurchaseTimestamps(now: number): void {
248
+ const cutoff = now - 60_000;
249
+ while (this.purchaseTimestamps.length > 0 && this.purchaseTimestamps[0] < cutoff) {
250
+ this.purchaseTimestamps.shift();
251
+ }
252
+ }
253
+ }
254
+
255
+ export interface CreditSummary {
256
+ totalWastedMicros: number;
257
+ wastedSinceLastDoctorRun: number;
258
+ purchasesInLastMinute: number;
259
+ purchaseBudgetPerMinute: number;
260
+ freshPurchaseWindowMs: number;
261
+ freshPurchaseThreshold: number;
262
+ perSeller: Array<{
263
+ sellerId: string;
264
+ currentBalanceMicros: number;
265
+ lastPurchaseAmountMicros: number;
266
+ lastPurchaseAt: number;
267
+ leftoverCreditMicros: number;
268
+ }>;
269
+ }