@trazum/core 1.40.0 → 1.42.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/src/store.ts ADDED
@@ -0,0 +1,281 @@
1
+ /**
2
+ * A year of measured spend on disk, and not one prompt inside it.
3
+ *
4
+ * A connector that re-downloads a month every time it runs is a connector
5
+ * nobody leaves on, and `history` needs stored reports — which until now meant
6
+ * a human curating a directory of `--json` files. The store is where pulled
7
+ * usage lands so neither is true any more.
8
+ *
9
+ * **Pure, and in the core.** This module decides what a record *is*, when two
10
+ * records are the same record, and what a set of them adds up to. The
11
+ * filesystem lives in the CLI, the same split every other browser-safe module
12
+ * here keeps.
13
+ *
14
+ * **Convergence, not accumulation.** Re-pulling an overlapping window must not
15
+ * double the bill. Two records covering the same window, from the same
16
+ * provider, for the same model and grouping, are the *same fact restated* —
17
+ * the later pull wins, because a window pulled again is at worst as complete
18
+ * as it was. That makes overlapping pulls idempotent, which is what lets a
19
+ * scheduled job run every hour over a rolling day without inventing money.
20
+ *
21
+ * **Deduplication that cannot lie.** Two records the store cannot tell apart —
22
+ * a source that served no window, or no model — are kept as *two* and reported
23
+ * as possibly-double. Merging them on a guess makes a bill quietly smaller,
24
+ * and quietly smaller is the flattering direction this repository refuses
25
+ * everywhere it can occur.
26
+ */
27
+
28
+ import type { UsageBucket } from './connector.js';
29
+
30
+ /** Bump when the meaning of a field changes. Readers keep what they cannot read. */
31
+ export const STORE_SCHEMA_VERSION = 1;
32
+
33
+ /**
34
+ * One stored measurement.
35
+ *
36
+ * Short keys because a year of these is a file somebody has to keep, and the
37
+ * shape is documented rather than inferred from its own verbosity.
38
+ */
39
+ export interface StoreRecord {
40
+ /** Schema version of this line, so an old reader keeps what it cannot parse. */
41
+ v: number;
42
+ provider: string;
43
+ fromMs: number;
44
+ toMs: number;
45
+ model: string;
46
+ /** null when the source serves no request count. Never zero for absent. */
47
+ calls: number | null;
48
+ input: number;
49
+ cacheRead: number;
50
+ write5m: number;
51
+ write1h: number;
52
+ /** False when the source reported writes without saying which TTL. */
53
+ ttlKnown: boolean;
54
+ output: number;
55
+ /**
56
+ * The account's own opaque identifiers — workspace, key, tier — as the
57
+ * provider served them.
58
+ *
59
+ * These are identifiers, not secrets: they name a workspace, not a way into
60
+ * it, and per-owner attribution needs them. Prompt text, completion text and
61
+ * credentials are never here, and the documentation says exactly what a
62
+ * store holds rather than leaving somebody to guess about their own file.
63
+ */
64
+ group: Record<string, string>;
65
+ /** When this record was pulled, which is how the later restatement wins. */
66
+ pulledAtMs: number;
67
+ }
68
+
69
+ /** What makes two records the same record. See the module note. */
70
+ export function identityOf(record: StoreRecord): string {
71
+ return [
72
+ record.provider,
73
+ record.fromMs,
74
+ record.toMs,
75
+ record.model,
76
+ JSON.stringify(record.group),
77
+ ].join('\n');
78
+ }
79
+
80
+ /**
81
+ * A record whose identity is not trustworthy enough to converge on.
82
+ *
83
+ * A window of zero length, or a record with no model, cannot be told apart
84
+ * from another like it. Those are kept whole and counted separately.
85
+ */
86
+ function identifiable(record: StoreRecord): boolean {
87
+ return record.model !== '' && record.toMs > record.fromMs;
88
+ }
89
+
90
+ export interface ResolvedStore {
91
+ /** One record per identity, newest pull winning. */
92
+ records: StoreRecord[];
93
+ /**
94
+ * Records the store could not tell apart, kept in full rather than merged.
95
+ *
96
+ * Reported so a total built on them can be read for what it is: possibly
97
+ * counting the same spend twice, and saying so beats a smaller number
98
+ * nobody can check.
99
+ */
100
+ possiblyDouble: StoreRecord[];
101
+ /** Lines from a schema version this binary does not know, kept and counted. */
102
+ unknownVersion: number;
103
+ }
104
+
105
+ /**
106
+ * Collapses an append-only log into the current truth.
107
+ *
108
+ * Append-only on disk and last-wins at read time, rather than rewriting a file
109
+ * in place: a crash during a rewrite can lose a year, and a crash during an
110
+ * append loses the tail of one line.
111
+ */
112
+ export function resolveStore(records: readonly StoreRecord[]): ResolvedStore {
113
+ const byIdentity = new Map<string, StoreRecord>();
114
+ const possiblyDouble: StoreRecord[] = [];
115
+ let unknownVersion = 0;
116
+
117
+ for (const record of records) {
118
+ if (record.v > STORE_SCHEMA_VERSION) {
119
+ unknownVersion += 1;
120
+ continue;
121
+ }
122
+ if (!identifiable(record)) {
123
+ possiblyDouble.push(record);
124
+ continue;
125
+ }
126
+ const key = identityOf(record);
127
+ const seen = byIdentity.get(key);
128
+ if (seen === undefined || record.pulledAtMs >= seen.pulledAtMs) {
129
+ byIdentity.set(key, record);
130
+ }
131
+ }
132
+
133
+ return {
134
+ records: [...byIdentity.values()].sort(
135
+ (a, b) => a.fromMs - b.fromMs || a.model.localeCompare(b.model),
136
+ ),
137
+ possiblyDouble,
138
+ unknownVersion,
139
+ };
140
+ }
141
+
142
+ /** Turns a connector's buckets into records ready to append. */
143
+ export function recordsFromBuckets(
144
+ provider: string,
145
+ buckets: readonly UsageBucket[],
146
+ pulledAtMs: number,
147
+ ): StoreRecord[] {
148
+ return buckets.map((bucket) => ({
149
+ v: STORE_SCHEMA_VERSION,
150
+ provider,
151
+ fromMs: bucket.fromMs,
152
+ toMs: bucket.toMs,
153
+ model: bucket.model,
154
+ calls: bucket.calls,
155
+ input: bucket.inputTokens,
156
+ cacheRead: bucket.cacheReadTokens,
157
+ write5m: bucket.cacheWrite5mTokens,
158
+ write1h: bucket.cacheWrite1hTokens,
159
+ ttlKnown: bucket.writeTtlKnown,
160
+ output: bucket.outputTokens,
161
+ group: bucket.group,
162
+ pulledAtMs,
163
+ }));
164
+ }
165
+
166
+ /** The reverse, so a stored month prices exactly as a fresh pull does. */
167
+ export function bucketsFromRecords(records: readonly StoreRecord[]): UsageBucket[] {
168
+ return records.map((record) => ({
169
+ fromMs: record.fromMs,
170
+ toMs: record.toMs,
171
+ model: record.model,
172
+ calls: record.calls,
173
+ inputTokens: record.input,
174
+ cacheReadTokens: record.cacheRead,
175
+ cacheWrite5mTokens: record.write5m,
176
+ cacheWrite1hTokens: record.write1h,
177
+ writeTtlKnown: record.ttlKnown,
178
+ outputTokens: record.output,
179
+ group: record.group,
180
+ }));
181
+ }
182
+
183
+ // --------------------------------------------------------------------------
184
+ // What is in the store
185
+ // --------------------------------------------------------------------------
186
+
187
+ export interface StoreInventory {
188
+ schemaVersion: 1;
189
+ /** Per provider, oldest first by the span it covers. */
190
+ providers: {
191
+ provider: string;
192
+ records: number;
193
+ span: { fromMs: number; toMs: number } | null;
194
+ /** null when no provider in the set serves request counts. */
195
+ calls: number | null;
196
+ models: string[];
197
+ }[];
198
+ totalRecords: number;
199
+ span: { fromMs: number; toMs: number } | null;
200
+ possiblyDouble: number;
201
+ unknownVersion: number;
202
+ }
203
+
204
+ export function storeInventory(resolved: ResolvedStore): StoreInventory {
205
+ const byProvider = new Map<string, StoreRecord[]>();
206
+ for (const record of resolved.records) {
207
+ const list = byProvider.get(record.provider) ?? [];
208
+ list.push(record);
209
+ byProvider.set(record.provider, list);
210
+ }
211
+
212
+ const providers = [...byProvider.entries()]
213
+ .map(([provider, records]) => {
214
+ const anyUnknown = records.some((r) => r.calls === null);
215
+ return {
216
+ provider,
217
+ records: records.length,
218
+ span: {
219
+ fromMs: Math.min(...records.map((r) => r.fromMs)),
220
+ toMs: Math.max(...records.map((r) => r.toMs)),
221
+ },
222
+ calls: anyUnknown ? null : records.reduce((sum, r) => sum + (r.calls ?? 0), 0),
223
+ models: [...new Set(records.map((r) => r.model))].sort(),
224
+ };
225
+ })
226
+ .sort((a, b) => a.provider.localeCompare(b.provider));
227
+
228
+ const all = resolved.records;
229
+ return {
230
+ schemaVersion: 1,
231
+ providers,
232
+ totalRecords: all.length,
233
+ span:
234
+ all.length === 0
235
+ ? null
236
+ : {
237
+ fromMs: Math.min(...all.map((r) => r.fromMs)),
238
+ toMs: Math.max(...all.map((r) => r.toMs)),
239
+ },
240
+ possiblyDouble: resolved.possiblyDouble.length,
241
+ unknownVersion: resolved.unknownVersion,
242
+ };
243
+ }
244
+
245
+ // --------------------------------------------------------------------------
246
+ // Retention
247
+ // --------------------------------------------------------------------------
248
+
249
+ export interface PruneResult {
250
+ kept: StoreRecord[];
251
+ dropped: StoreRecord[];
252
+ /** The span the dropped records covered — what a reader loses by pruning. */
253
+ droppedSpan: { fromMs: number; toMs: number } | null;
254
+ }
255
+
256
+ /**
257
+ * Drops records whose window ended before the cutoff.
258
+ *
259
+ * Judged on `toMs`: a bucket that *ends* inside the retained period is
260
+ * retained whole, because half a bucket is a measurement of nothing. What goes
261
+ * is returned rather than counted, so the caller can say what went — silence
262
+ * about deleted measurements is the one thing a store must not do.
263
+ */
264
+ export function pruneRecords(records: readonly StoreRecord[], cutoffMs: number): PruneResult {
265
+ const kept: StoreRecord[] = [];
266
+ const dropped: StoreRecord[] = [];
267
+ for (const record of records) {
268
+ (record.toMs < cutoffMs ? dropped : kept).push(record);
269
+ }
270
+ return {
271
+ kept,
272
+ dropped,
273
+ droppedSpan:
274
+ dropped.length === 0
275
+ ? null
276
+ : {
277
+ fromMs: Math.min(...dropped.map((r) => r.fromMs)),
278
+ toMs: Math.max(...dropped.map((r) => r.toMs)),
279
+ },
280
+ };
281
+ }