@trazum/cli 1.41.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/README.md +1 -0
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +50 -1
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +51 -1
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/types.d.ts +30 -1
- package/dist/i18n/types.d.ts.map +1 -1
- package/dist/index.js +211 -34
- package/dist/index.js.map +1 -1
- package/dist/store-fs.d.ts +59 -0
- package/dist/store-fs.d.ts.map +1 -0
- package/dist/store-fs.js +145 -0
- package/dist/store-fs.js.map +1 -0
- package/package.json +2 -2
- package/src/i18n/en.ts +64 -1
- package/src/i18n/es.ts +65 -1
- package/src/i18n/types.ts +31 -1
- package/src/index.ts +279 -33
- package/src/store-fs.ts +157 -0
package/src/index.ts
CHANGED
|
@@ -19,6 +19,10 @@ import {
|
|
|
19
19
|
CONNECTORS,
|
|
20
20
|
normalizeAnthropicUsage,
|
|
21
21
|
normalizeOpenAIUsage,
|
|
22
|
+
bucketsFromRecords,
|
|
23
|
+
pruneRecords,
|
|
24
|
+
recordsFromBuckets,
|
|
25
|
+
storeInventory,
|
|
22
26
|
storedReportFrom,
|
|
23
27
|
verifyPlan,
|
|
24
28
|
cacheEconomics,
|
|
@@ -141,6 +145,7 @@ import {
|
|
|
141
145
|
} from './git.js';
|
|
142
146
|
import type { Revision } from './git.js';
|
|
143
147
|
import { fetchProviderUsage } from './connect.js';
|
|
148
|
+
import { STORE_DIR, appendRecords, readStore, rewriteStore } from './store-fs.js';
|
|
144
149
|
import { detectLocale, getCliMessages } from './i18n/index.js';
|
|
145
150
|
import {
|
|
146
151
|
MAX_SUMMARY_CHARS,
|
|
@@ -190,6 +195,7 @@ const VALUE_FLAGS = new Set([
|
|
|
190
195
|
'from-log',
|
|
191
196
|
'min-usd',
|
|
192
197
|
'payload',
|
|
198
|
+
'keep',
|
|
193
199
|
// `route` takes a path here, and the flag is deliberately not `--prompt`:
|
|
194
200
|
// everywhere else in this tool `--prompt` names a marked prompt *inside* a
|
|
195
201
|
// source file, and reusing it for a path would be a trap laid for the reader.
|
|
@@ -492,8 +498,9 @@ const COMMAND_FLAGS: Record<string, string[]> = {
|
|
|
492
498
|
profile: ['json', 'pricing', 'pricing-live', 'against', 'what-if', 'markdown-out', 'csv-out', 'csv-shape', 'max-usd', 'max-growth-usd', 'max-cache-loss-usd', 'max-day-usd', 'max-session-usd', 'label', 'since', 'until', 'dry-run', 'markdown-summary', 'by-source'],
|
|
493
499
|
plan: ['json', 'out', 'markdown-out', 'min-usd', 'pricing', 'pricing-live'],
|
|
494
500
|
verify: ['against', 'gate', 'json', 'markdown-out', 'pricing', 'pricing-live'],
|
|
495
|
-
history: ['json', 'markdown-out'],
|
|
496
|
-
connect: ['since', 'until', 'payload', 'json', 'out', 'markdown-out', 'pricing', 'pricing-live', 'dry-run'],
|
|
501
|
+
history: ['store', 'json', 'markdown-out'],
|
|
502
|
+
connect: ['since', 'until', 'payload', 'store', 'json', 'out', 'markdown-out', 'pricing', 'pricing-live', 'dry-run'],
|
|
503
|
+
store: ['prune', 'keep', 'json', 'pricing', 'pricing-live', 'dry-run'],
|
|
497
504
|
route: ['prompt-file', 'cases', 'label', 'concurrency', 'json', 'yes', 'pricing', 'pricing-live'],
|
|
498
505
|
eval: ['cases', 'level', 'concurrency', 'export', 'out', 'o', 'model'],
|
|
499
506
|
prune: ['cases', 'concurrency', 'json', 'yes'],
|
|
@@ -2275,6 +2282,162 @@ function parseWhen(
|
|
|
2275
2282
|
throw new Error(t.profile.badWhen(flag, value));
|
|
2276
2283
|
}
|
|
2277
2284
|
|
|
2285
|
+
/**
|
|
2286
|
+
* `trazum store` — what is kept, and what a prune would take.
|
|
2287
|
+
*
|
|
2288
|
+
* The store is the one thing in this product that *deletes* something, so the
|
|
2289
|
+
* errands around it are written to make that visible: the inventory says what
|
|
2290
|
+
* is there and how far back, and `--prune` names what went with the span it
|
|
2291
|
+
* covered. Retention with no policy written down is refused rather than
|
|
2292
|
+
* defaulted — deleting measurements on a guess is not something anybody
|
|
2293
|
+
* should receive by accident.
|
|
2294
|
+
*/
|
|
2295
|
+
async function commandStore(
|
|
2296
|
+
args: Args,
|
|
2297
|
+
config: TrazumConfig,
|
|
2298
|
+
pricing: PricingCatalogue,
|
|
2299
|
+
t: CliMessages,
|
|
2300
|
+
): Promise<void> {
|
|
2301
|
+
const root = process.cwd();
|
|
2302
|
+
const { resolved, unreadable, files } = await readStore(root);
|
|
2303
|
+
const inventory = storeInventory(resolved);
|
|
2304
|
+
const n = (value: number): string => value.toLocaleString(t.numberLocale);
|
|
2305
|
+
const day = (msValue: number): string => new Date(msValue).toISOString().slice(0, 10);
|
|
2306
|
+
|
|
2307
|
+
const priced = bucketedProfile(
|
|
2308
|
+
{
|
|
2309
|
+
provider: 'store',
|
|
2310
|
+
granularity: 'bucketed',
|
|
2311
|
+
buckets: bucketsFromRecords(resolved.records),
|
|
2312
|
+
window: inventory.span,
|
|
2313
|
+
gaps: [],
|
|
2314
|
+
unavailable: [],
|
|
2315
|
+
},
|
|
2316
|
+
{ catalogue: pricing },
|
|
2317
|
+
);
|
|
2318
|
+
|
|
2319
|
+
if (boolFlag(args, 'prune')) {
|
|
2320
|
+
const keepFlag = stringFlag(args, 'keep');
|
|
2321
|
+
const keepDays = keepFlag !== undefined
|
|
2322
|
+
? Number(/^(\d+)d?$/.exec(keepFlag)?.[1] ?? NaN)
|
|
2323
|
+
: config.store?.keepDays;
|
|
2324
|
+
if (keepDays === undefined || !Number.isFinite(keepDays) || keepDays <= 0) {
|
|
2325
|
+
throw new Error(t.store.pruneNeedsPolicy());
|
|
2326
|
+
}
|
|
2327
|
+
const cutoff = Date.now() - keepDays * 86_400_000;
|
|
2328
|
+
const result = pruneRecords(resolved.records, cutoff);
|
|
2329
|
+
const droppedUsd = bucketedProfile(
|
|
2330
|
+
{
|
|
2331
|
+
provider: 'store',
|
|
2332
|
+
granularity: 'bucketed',
|
|
2333
|
+
buckets: bucketsFromRecords(result.dropped),
|
|
2334
|
+
window: null,
|
|
2335
|
+
gaps: [],
|
|
2336
|
+
unavailable: [],
|
|
2337
|
+
},
|
|
2338
|
+
{ catalogue: pricing },
|
|
2339
|
+
).total.totalUsd;
|
|
2340
|
+
|
|
2341
|
+
if (boolFlag(args, 'dry-run')) {
|
|
2342
|
+
console.log(
|
|
2343
|
+
wrap(
|
|
2344
|
+
t.store.pruneDryRun(
|
|
2345
|
+
n(result.dropped.length),
|
|
2346
|
+
String(keepDays),
|
|
2347
|
+
result.droppedSpan === null
|
|
2348
|
+
? null
|
|
2349
|
+
: `${day(result.droppedSpan.fromMs)} → ${day(result.droppedSpan.toMs)}`,
|
|
2350
|
+
formatUsd(droppedUsd),
|
|
2351
|
+
),
|
|
2352
|
+
76,
|
|
2353
|
+
' ',
|
|
2354
|
+
),
|
|
2355
|
+
);
|
|
2356
|
+
return;
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
// The prune also collapses the append log to what the store resolves to,
|
|
2360
|
+
// which is the only moment a rewrite is safe: it is what the reader was
|
|
2361
|
+
// already seeing.
|
|
2362
|
+
await rewriteStore(root, result.kept);
|
|
2363
|
+
console.log(
|
|
2364
|
+
wrap(
|
|
2365
|
+
t.store.pruned(
|
|
2366
|
+
n(result.dropped.length),
|
|
2367
|
+
String(keepDays),
|
|
2368
|
+
result.droppedSpan === null
|
|
2369
|
+
? null
|
|
2370
|
+
: `${day(result.droppedSpan.fromMs)} → ${day(result.droppedSpan.toMs)}`,
|
|
2371
|
+
formatUsd(droppedUsd),
|
|
2372
|
+
n(result.kept.length),
|
|
2373
|
+
),
|
|
2374
|
+
76,
|
|
2375
|
+
' ',
|
|
2376
|
+
),
|
|
2377
|
+
);
|
|
2378
|
+
return;
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
if (boolFlag(args, 'json')) {
|
|
2382
|
+
console.log(JSON.stringify({ ...inventory, totalUsd: priced.total.totalUsd, unreadable }, null, 2));
|
|
2383
|
+
return;
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
/**
|
|
2387
|
+
* Empty means *nothing at all* — not "nothing I could resolve".
|
|
2388
|
+
*
|
|
2389
|
+
* Records the store could not tell apart, lines it could not parse and
|
|
2390
|
+
* records from a newer schema are all real measurements sitting on disk.
|
|
2391
|
+
* Reporting an empty store over them would hide exactly what the reader
|
|
2392
|
+
* needs to see, which is the failure this whole module is written against.
|
|
2393
|
+
*/
|
|
2394
|
+
const nothingAtAll =
|
|
2395
|
+
inventory.totalRecords === 0 &&
|
|
2396
|
+
inventory.possiblyDouble === 0 &&
|
|
2397
|
+
inventory.unknownVersion === 0 &&
|
|
2398
|
+
unreadable.length === 0;
|
|
2399
|
+
if (nothingAtAll) {
|
|
2400
|
+
console.log(wrap(t.store.empty(STORE_DIR), 76, ' '));
|
|
2401
|
+
return;
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
console.log(
|
|
2405
|
+
c.bold(
|
|
2406
|
+
t.store.heading(
|
|
2407
|
+
n(inventory.totalRecords),
|
|
2408
|
+
formatUsd(priced.total.totalUsd),
|
|
2409
|
+
inventory.span === null ? '' : day(inventory.span.fromMs),
|
|
2410
|
+
inventory.span === null ? '' : day(inventory.span.toMs),
|
|
2411
|
+
),
|
|
2412
|
+
),
|
|
2413
|
+
);
|
|
2414
|
+
for (const provider of inventory.providers) {
|
|
2415
|
+
console.log(
|
|
2416
|
+
` ${t.store.providerRow(
|
|
2417
|
+
provider.provider,
|
|
2418
|
+
n(provider.records),
|
|
2419
|
+
provider.span === null ? '' : `${day(provider.span.fromMs)} → ${day(provider.span.toMs)}`,
|
|
2420
|
+
n(provider.models.length),
|
|
2421
|
+
)}`,
|
|
2422
|
+
);
|
|
2423
|
+
}
|
|
2424
|
+
console.log();
|
|
2425
|
+
console.log(` ${c.dim(wrap(t.store.holds(n(files.length)), 74, ' '))}`);
|
|
2426
|
+
if (inventory.possiblyDouble > 0) {
|
|
2427
|
+
console.log(` ${c.yellow(wrap(t.store.possiblyDouble(n(inventory.possiblyDouble)), 74, ' '))}`);
|
|
2428
|
+
}
|
|
2429
|
+
if (inventory.unknownVersion > 0) {
|
|
2430
|
+
console.log(` ${c.yellow(wrap(t.store.unknownVersion(n(inventory.unknownVersion)), 74, ' '))}`);
|
|
2431
|
+
}
|
|
2432
|
+
for (const bad of unreadable) {
|
|
2433
|
+
console.log(` ${c.yellow(wrap(t.store.unreadable(bad.file, String(bad.line)), 74, ' '))}`);
|
|
2434
|
+
}
|
|
2435
|
+
const keepDays = config.store?.keepDays;
|
|
2436
|
+
console.log(
|
|
2437
|
+
` ${c.dim(wrap(keepDays === undefined ? t.store.noRetention() : t.store.retention(String(keepDays)), 74, ' '))}`,
|
|
2438
|
+
);
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2278
2441
|
/**
|
|
2279
2442
|
* `trazum connect <provider>` — the bill, read from the provider.
|
|
2280
2443
|
*
|
|
@@ -2348,6 +2511,17 @@ async function commandConnect(
|
|
|
2348
2511
|
const cache = bucketedCacheEconomics(report);
|
|
2349
2512
|
const n = (value: number): string => value.toLocaleString(t.numberLocale);
|
|
2350
2513
|
|
|
2514
|
+
/**
|
|
2515
|
+
* `--store` keeps what was pulled, so the next run does not download it
|
|
2516
|
+
* again and `history` has a series without anybody curating a folder. Opt
|
|
2517
|
+
* in rather than automatic: a command that starts writing to a hidden
|
|
2518
|
+
* directory on its own is a command nobody trusts twice.
|
|
2519
|
+
*/
|
|
2520
|
+
let stored = 0;
|
|
2521
|
+
if (boolFlag(args, 'store')) {
|
|
2522
|
+
stored = await appendRecords(process.cwd(), recordsFromBuckets(pull.provider, pull.buckets, Date.now()));
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2351
2525
|
const outPath = stringFlag(args, 'out');
|
|
2352
2526
|
if (outPath !== undefined) {
|
|
2353
2527
|
await writeFile(outPath, `${JSON.stringify({ ...report, pulledFrom: source.variable }, null, 2)}\n`);
|
|
@@ -2438,6 +2612,7 @@ async function commandConnect(
|
|
|
2438
2612
|
else console.log(` ${wrap(row, 74, ' ')}`);
|
|
2439
2613
|
}
|
|
2440
2614
|
if (outPath !== undefined) console.log(c.dim(t.connect.wrote(outPath)));
|
|
2615
|
+
if (stored > 0) console.log(c.dim(t.store.appended(n(stored), STORE_DIR)));
|
|
2441
2616
|
}
|
|
2442
2617
|
|
|
2443
2618
|
/**
|
|
@@ -2449,40 +2624,103 @@ async function commandConnect(
|
|
|
2449
2624
|
* same action planned twice — and no series, however long, becomes a
|
|
2450
2625
|
* forecast.
|
|
2451
2626
|
*/
|
|
2452
|
-
async function commandHistory(args: Args, t: CliMessages): Promise<void> {
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
.sort((a, b) => a.localeCompare(b));
|
|
2463
|
-
|
|
2627
|
+
async function commandHistory(args: Args, pricing: PricingCatalogue, t: CliMessages): Promise<void> {
|
|
2628
|
+
/**
|
|
2629
|
+
* `--store` builds the series from measured spend already on disk.
|
|
2630
|
+
*
|
|
2631
|
+
* Bucketed sources carry no label — a usage API groups by model and
|
|
2632
|
+
* workspace, never by workload — so the label series is *absent and named*
|
|
2633
|
+
* rather than empty and misread, the same discipline the connected report
|
|
2634
|
+
* uses for the findings a sum cannot support. The model-share and
|
|
2635
|
+
* cache-share series are exactly what a series exists for, and both work.
|
|
2636
|
+
*/
|
|
2464
2637
|
const reports: StoredReport[] = [];
|
|
2465
2638
|
const plans: (PlanDocument & { createdAt?: string })[] = [];
|
|
2466
2639
|
const unrecognized: string[] = [];
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2640
|
+
const fromStore = boolFlag(args, 'store');
|
|
2641
|
+
|
|
2642
|
+
if (fromStore) {
|
|
2643
|
+
const { resolved } = await readStore(process.cwd());
|
|
2644
|
+
if (resolved.records.length === 0) throw new Error(t.store.empty(STORE_DIR));
|
|
2645
|
+
|
|
2646
|
+
/**
|
|
2647
|
+
* One period per UTC day of stored measurement, priced exactly as a fresh
|
|
2648
|
+
* pull prices it.
|
|
2649
|
+
*
|
|
2650
|
+
* The label series is deliberately absent: a usage API groups by model
|
|
2651
|
+
* and workspace, never by workload, so there is no label to carry.
|
|
2652
|
+
* Rendering an empty label series would read as "no workload moved",
|
|
2653
|
+
* which is a statement about traffic rather than about the source, and
|
|
2654
|
+
* the footer says which it is.
|
|
2655
|
+
*/
|
|
2656
|
+
const byDay = new Map<string, typeof resolved.records>();
|
|
2657
|
+
for (const record of resolved.records) {
|
|
2658
|
+
const key = new Date(record.fromMs).toISOString().slice(0, 10);
|
|
2659
|
+
const list = byDay.get(key) ?? [];
|
|
2660
|
+
list.push(record);
|
|
2661
|
+
byDay.set(key, list);
|
|
2662
|
+
}
|
|
2663
|
+
for (const [dayKey, records] of [...byDay.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
2664
|
+
const day = bucketedProfile(
|
|
2665
|
+
{
|
|
2666
|
+
provider: 'store',
|
|
2667
|
+
granularity: 'bucketed',
|
|
2668
|
+
buckets: bucketsFromRecords(records),
|
|
2669
|
+
window: {
|
|
2670
|
+
fromMs: Math.min(...records.map((r) => r.fromMs)),
|
|
2671
|
+
toMs: Math.max(...records.map((r) => r.toMs)),
|
|
2672
|
+
},
|
|
2673
|
+
gaps: [],
|
|
2674
|
+
unavailable: [],
|
|
2675
|
+
},
|
|
2676
|
+
{ catalogue: pricing },
|
|
2677
|
+
);
|
|
2678
|
+
const cacheTouched = day.total.cacheReadTokens + day.total.cacheWriteTokens;
|
|
2679
|
+
reports.push({
|
|
2680
|
+
name: dayKey,
|
|
2681
|
+
span: day.span,
|
|
2682
|
+
totalUsd: day.total.totalUsd,
|
|
2683
|
+
calls: day.total.calls,
|
|
2684
|
+
byLabel: new Map(),
|
|
2685
|
+
byModel: new Map(day.byModel.map((slice) => [slice.model, slice.totalUsd])),
|
|
2686
|
+
cacheReadShare:
|
|
2687
|
+
day.total.inputTokens + cacheTouched > 0
|
|
2688
|
+
? day.total.cacheReadTokens / (day.total.inputTokens + cacheTouched)
|
|
2689
|
+
: null,
|
|
2690
|
+
});
|
|
2479
2691
|
}
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2692
|
+
} else {
|
|
2693
|
+
const path = args.positional[0];
|
|
2694
|
+
if (path === undefined) throw new Error(t.history.noTarget());
|
|
2695
|
+
const target = await stat(path).catch(() => null);
|
|
2696
|
+
if (!target?.isDirectory()) throw new Error(t.history.noTarget());
|
|
2697
|
+
|
|
2698
|
+
const entries = await readdir(path, { withFileTypes: true });
|
|
2699
|
+
const files = entries
|
|
2700
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
|
|
2701
|
+
.map((entry) => join(path, entry.name))
|
|
2702
|
+
.sort((a, b) => a.localeCompare(b));
|
|
2703
|
+
|
|
2704
|
+
for (const file of files) {
|
|
2705
|
+
let parsed: unknown;
|
|
2706
|
+
try {
|
|
2707
|
+
parsed = JSON.parse(await readFile(file, 'utf8'));
|
|
2708
|
+
} catch {
|
|
2709
|
+
unrecognized.push(file);
|
|
2710
|
+
continue;
|
|
2711
|
+
}
|
|
2712
|
+
const report = storedReportFrom(file, parsed);
|
|
2713
|
+
if (report !== null) {
|
|
2714
|
+
reports.push(report);
|
|
2715
|
+
continue;
|
|
2716
|
+
}
|
|
2717
|
+
const maybePlan = parsed as PlanDocument & { createdAt?: string };
|
|
2718
|
+
if (maybePlan?.schemaVersion === 1 && Array.isArray(maybePlan.actions)) {
|
|
2719
|
+
plans.push(maybePlan);
|
|
2720
|
+
continue;
|
|
2721
|
+
}
|
|
2722
|
+
unrecognized.push(file);
|
|
2484
2723
|
}
|
|
2485
|
-
unrecognized.push(file);
|
|
2486
2724
|
}
|
|
2487
2725
|
|
|
2488
2726
|
const history = buildHistory(reports, plans);
|
|
@@ -2516,7 +2754,7 @@ async function commandHistory(args: Args, t: CliMessages): Promise<void> {
|
|
|
2516
2754
|
const row = t.history.periodRow(
|
|
2517
2755
|
period.name,
|
|
2518
2756
|
formatUsd(period.totalUsd),
|
|
2519
|
-
n(period.calls),
|
|
2757
|
+
period.calls === null ? null : n(period.calls),
|
|
2520
2758
|
((period.toMs - period.fromMs) / 86_400_000).toFixed(1),
|
|
2521
2759
|
);
|
|
2522
2760
|
out.push(md ? `- ${row}` : ` ${row}`);
|
|
@@ -2544,6 +2782,11 @@ async function commandHistory(args: Args, t: CliMessages): Promise<void> {
|
|
|
2544
2782
|
for (const name of unrecognized) {
|
|
2545
2783
|
out.push(md ? `- ${t.history.unrecognized(name)}` : ` ${t.history.unrecognized(name)}`);
|
|
2546
2784
|
}
|
|
2785
|
+
if (fromStore) {
|
|
2786
|
+
out.push('');
|
|
2787
|
+
const note = t.history.storeNoLabels();
|
|
2788
|
+
out.push(md ? `_${note}_` : ` ${note}`);
|
|
2789
|
+
}
|
|
2547
2790
|
out.push('');
|
|
2548
2791
|
out.push(md ? `_${t.history.footer()}_` : ` ${t.history.footer()}`);
|
|
2549
2792
|
return out;
|
|
@@ -6615,11 +6858,14 @@ async function main(): Promise<void> {
|
|
|
6615
6858
|
await commandVerify(args, pricing, t);
|
|
6616
6859
|
break;
|
|
6617
6860
|
case 'history':
|
|
6618
|
-
await commandHistory(args, t);
|
|
6861
|
+
await commandHistory(args, pricing, t);
|
|
6619
6862
|
break;
|
|
6620
6863
|
case 'connect':
|
|
6621
6864
|
await commandConnect(args, pricing, t);
|
|
6622
6865
|
break;
|
|
6866
|
+
case 'store':
|
|
6867
|
+
await commandStore(args, config, pricing, t);
|
|
6868
|
+
break;
|
|
6623
6869
|
case 'route':
|
|
6624
6870
|
await commandRoute(args, pricing, t);
|
|
6625
6871
|
break;
|
package/src/store-fs.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the store actually lives.
|
|
3
|
+
*
|
|
4
|
+
* The core decides what a record is and when two are the same; this decides
|
|
5
|
+
* where the bytes go. Split that way for the reason every module here is:
|
|
6
|
+
* `@trazum/core` stays browser-safe, and the CLI keeps its monopoly on I/O.
|
|
7
|
+
*
|
|
8
|
+
* **Append-only, one buffer per write.** A pull appends a single block and
|
|
9
|
+
* never rewrites what is already there. Two consequences worth stating: a
|
|
10
|
+
* crash during a write loses the tail of one block rather than a year of
|
|
11
|
+
* measurements, and two runs writing at once interleave whole blocks rather
|
|
12
|
+
* than half-lines. Compaction is a separate, explicit errand — `store
|
|
13
|
+
* --prune` — because collapsing a log is the one operation that destroys
|
|
14
|
+
* something, and it should never happen as a side effect of a pull.
|
|
15
|
+
*
|
|
16
|
+
* **A line that will not parse is kept, counted and skipped.** The store is a
|
|
17
|
+
* file a human may open, a backup may truncate and a merge may mangle. Losing
|
|
18
|
+
* the whole month because one line is broken would be the worst possible
|
|
19
|
+
* response; so would silently pretending the month is complete.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
import { resolveStore } from '@trazum/core';
|
|
25
|
+
import type { ResolvedStore, StoreRecord } from '@trazum/core';
|
|
26
|
+
|
|
27
|
+
/** The directory name, relative to wherever the caller roots the store. */
|
|
28
|
+
export const STORE_DIR = '.trazum/store';
|
|
29
|
+
|
|
30
|
+
/** Records are filed by the UTC month their window starts in. */
|
|
31
|
+
function monthOf(record: StoreRecord): string {
|
|
32
|
+
return new Date(record.fromMs).toISOString().slice(0, 7);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface StoreReadResult {
|
|
36
|
+
resolved: ResolvedStore;
|
|
37
|
+
/** Lines that would not parse: counted and named by file, never dropped quietly. */
|
|
38
|
+
unreadable: { file: string; line: number }[];
|
|
39
|
+
/** Files read, so an empty store can be told from an unread one. */
|
|
40
|
+
files: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Reads every record in the store.
|
|
45
|
+
*
|
|
46
|
+
* Returns an empty result rather than throwing when the store does not exist:
|
|
47
|
+
* "you have not stored anything yet" is a state, not an error, and the caller
|
|
48
|
+
* says so in a sentence that names `trazum connect`.
|
|
49
|
+
*/
|
|
50
|
+
export async function readStore(root: string): Promise<StoreReadResult> {
|
|
51
|
+
const dir = join(root, STORE_DIR);
|
|
52
|
+
const records: StoreRecord[] = [];
|
|
53
|
+
const unreadable: { file: string; line: number }[] = [];
|
|
54
|
+
const files: string[] = [];
|
|
55
|
+
|
|
56
|
+
let providers: string[];
|
|
57
|
+
try {
|
|
58
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
59
|
+
providers = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
60
|
+
} catch {
|
|
61
|
+
return { resolved: resolveStore([]), unreadable, files };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
for (const provider of providers.sort()) {
|
|
65
|
+
const providerDir = join(dir, provider);
|
|
66
|
+
let months: string[];
|
|
67
|
+
try {
|
|
68
|
+
months = (await readdir(providerDir)).filter((name) => name.endsWith('.jsonl')).sort();
|
|
69
|
+
} catch {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
for (const month of months) {
|
|
73
|
+
const path = join(providerDir, month);
|
|
74
|
+
files.push(join(STORE_DIR, provider, month));
|
|
75
|
+
const text = await readFile(path, 'utf8');
|
|
76
|
+
for (const [index, line] of text.split('\n').entries()) {
|
|
77
|
+
if (line.trim() === '') continue;
|
|
78
|
+
try {
|
|
79
|
+
const parsed = JSON.parse(line) as StoreRecord;
|
|
80
|
+
if (typeof parsed?.provider === 'string' && typeof parsed?.fromMs === 'number') {
|
|
81
|
+
records.push(parsed);
|
|
82
|
+
} else {
|
|
83
|
+
unreadable.push({ file: join(STORE_DIR, provider, month), line: index + 1 });
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
unreadable.push({ file: join(STORE_DIR, provider, month), line: index + 1 });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { resolved: resolveStore(records), unreadable, files };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Appends records, grouped into one write per month file.
|
|
97
|
+
*
|
|
98
|
+
* Nothing already on disk is read, rewritten or resolved here: convergence
|
|
99
|
+
* happens when the store is *read*, which is what keeps a write cheap enough
|
|
100
|
+
* to run on a schedule and impossible to corrupt by racing.
|
|
101
|
+
*/
|
|
102
|
+
export async function appendRecords(root: string, records: readonly StoreRecord[]): Promise<number> {
|
|
103
|
+
if (records.length === 0) return 0;
|
|
104
|
+
const byFile = new Map<string, StoreRecord[]>();
|
|
105
|
+
for (const record of records) {
|
|
106
|
+
const key = join(record.provider, `${monthOf(record)}.jsonl`);
|
|
107
|
+
const list = byFile.get(key) ?? [];
|
|
108
|
+
list.push(record);
|
|
109
|
+
byFile.set(key, list);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (const [relative, list] of byFile) {
|
|
113
|
+
const path = join(root, STORE_DIR, relative);
|
|
114
|
+
await mkdir(join(path, '..'), { recursive: true });
|
|
115
|
+
const block = `${list.map((record) => JSON.stringify(record)).join('\n')}\n`;
|
|
116
|
+
await writeFile(path, block, { flag: 'a', mode: 0o600 });
|
|
117
|
+
}
|
|
118
|
+
return records.length;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Rewrites the store with exactly the records given.
|
|
123
|
+
*
|
|
124
|
+
* The one operation that destroys something, so it is only ever reached from
|
|
125
|
+
* an explicit `--prune`. Each month file is written whole, and a month left
|
|
126
|
+
* with nothing is written empty rather than removed — a missing file and an
|
|
127
|
+
* empty one say different things to whoever looks next.
|
|
128
|
+
*/
|
|
129
|
+
export async function rewriteStore(root: string, records: readonly StoreRecord[]): Promise<void> {
|
|
130
|
+
const dir = join(root, STORE_DIR);
|
|
131
|
+
const existing = new Set<string>();
|
|
132
|
+
try {
|
|
133
|
+
for (const provider of await readdir(dir)) {
|
|
134
|
+
for (const month of await readdir(join(dir, provider)).catch(() => [])) {
|
|
135
|
+
if (month.endsWith('.jsonl')) existing.add(join(provider, month));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
} catch {
|
|
139
|
+
// Nothing stored yet: the writes below create what is needed.
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const byFile = new Map<string, StoreRecord[]>();
|
|
143
|
+
for (const record of records) {
|
|
144
|
+
const key = join(record.provider, `${monthOf(record)}.jsonl`);
|
|
145
|
+
const list = byFile.get(key) ?? [];
|
|
146
|
+
list.push(record);
|
|
147
|
+
byFile.set(key, list);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
for (const relative of new Set([...existing, ...byFile.keys()])) {
|
|
151
|
+
const list = byFile.get(relative) ?? [];
|
|
152
|
+
const path = join(dir, relative);
|
|
153
|
+
await mkdir(join(path, '..'), { recursive: true });
|
|
154
|
+
const block = list.length === 0 ? '' : `${list.map((r) => JSON.stringify(r)).join('\n')}\n`;
|
|
155
|
+
await writeFile(path, block, { mode: 0o600 });
|
|
156
|
+
}
|
|
157
|
+
}
|