lovewebconnection 0.0.0-stage → 0.2.1

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 (68) hide show
  1. package/.env.example +38 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1937 -2
  4. package/dist/alert-queue.d.ts +72 -0
  5. package/dist/alert-queue.js +155 -0
  6. package/dist/alert-store.d.ts +29 -0
  7. package/dist/alert-store.js +75 -0
  8. package/dist/balances.d.ts +221 -0
  9. package/dist/balances.js +1232 -0
  10. package/dist/cache.d.ts +37 -0
  11. package/dist/cache.js +128 -0
  12. package/dist/call.d.ts +46 -0
  13. package/dist/call.js +377 -0
  14. package/dist/chain.d.ts +97 -0
  15. package/dist/chain.js +419 -0
  16. package/dist/cli.d.ts +2 -0
  17. package/dist/cli.js +1011 -0
  18. package/dist/compare-history.d.ts +82 -0
  19. package/dist/compare-history.js +340 -0
  20. package/dist/compare.d.ts +98 -0
  21. package/dist/compare.js +492 -0
  22. package/dist/contract.d.ts +107 -0
  23. package/dist/contract.js +162 -0
  24. package/dist/default-abi.d.ts +53 -0
  25. package/dist/default-abi.js +85 -0
  26. package/dist/history.d.ts +83 -0
  27. package/dist/history.js +422 -0
  28. package/dist/index.d.ts +54 -0
  29. package/dist/index.js +27 -0
  30. package/dist/market-page.d.ts +8 -0
  31. package/dist/market-page.js +554 -0
  32. package/dist/market-server.d.ts +46 -0
  33. package/dist/market-server.js +473 -0
  34. package/dist/market.d.ts +323 -0
  35. package/dist/market.js +835 -0
  36. package/dist/metadata-cache.d.ts +72 -0
  37. package/dist/metadata-cache.js +197 -0
  38. package/dist/metrics.d.ts +64 -0
  39. package/dist/metrics.js +138 -0
  40. package/dist/plot.d.ts +60 -0
  41. package/dist/plot.js +317 -0
  42. package/dist/presets-history.d.ts +372 -0
  43. package/dist/presets-history.js +1243 -0
  44. package/dist/presets-summary.d.ts +17 -0
  45. package/dist/presets-summary.js +55 -0
  46. package/dist/presets.d.ts +59 -0
  47. package/dist/presets.js +181 -0
  48. package/dist/prices.d.ts +63 -0
  49. package/dist/prices.js +154 -0
  50. package/dist/rotate.d.ts +19 -0
  51. package/dist/rotate.js +34 -0
  52. package/dist/scan.d.ts +81 -0
  53. package/dist/scan.js +501 -0
  54. package/dist/sessions.d.ts +104 -0
  55. package/dist/sessions.js +515 -0
  56. package/dist/signatures.d.ts +13 -0
  57. package/dist/signatures.js +43 -0
  58. package/dist/tokenfile.d.ts +50 -0
  59. package/dist/tokenfile.js +218 -0
  60. package/dist/views.d.ts +63 -0
  61. package/dist/views.js +415 -0
  62. package/dist/watch.d.ts +306 -0
  63. package/dist/watch.js +1121 -0
  64. package/dist/webhook.d.ts +17 -0
  65. package/dist/webhook.js +35 -0
  66. package/examples/erc20.abi.json +57 -0
  67. package/examples/xlayer.tokens.json +4 -0
  68. package/package.json +77 -4
@@ -0,0 +1,1243 @@
1
+ import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { toPresetsJson } from "./presets.js";
4
+ import { csvCell } from "./scan.js";
5
+ export class PresetsHistoryArgError extends Error {
6
+ constructor(message) {
7
+ super(message);
8
+ this.name = "PresetsHistoryArgError";
9
+ }
10
+ }
11
+ /** Append this run's report, creating the file and its directory if needed. */
12
+ export function appendPresetHistory(path, report, now = new Date()) {
13
+ const record = { at: now.toISOString(), ...toPresetsJson(report) };
14
+ mkdirSync(dirname(path), { recursive: true });
15
+ appendFileSync(path, `${JSON.stringify(record)}\n`);
16
+ return record;
17
+ }
18
+ /**
19
+ * Read a history file, oldest first. A truncated or hand-edited line is skipped
20
+ * rather than losing every run that came before it. The first line may be a
21
+ * `lwc_history` header recording the retention the file was trimmed to, so a
22
+ * read that happens later can still explain which runs are simply gone.
23
+ */
24
+ export function readPresetHistoryFile(path) {
25
+ const records = [];
26
+ let retainMs = null;
27
+ for (const line of readFileSync(path, "utf8").split(/\r?\n/)) {
28
+ if (line.trim() === "")
29
+ continue;
30
+ try {
31
+ const value = JSON.parse(line);
32
+ if (value !== null && typeof value === "object" && "lwc_history" in value) {
33
+ const header = value.lwc_history;
34
+ if (typeof header?.retainMs === "number" && header.retainMs > 0) {
35
+ retainMs = header.retainMs;
36
+ }
37
+ continue;
38
+ }
39
+ const record = value;
40
+ if (typeof record.at !== "string" || Number.isNaN(Date.parse(record.at)))
41
+ continue;
42
+ if (typeof record.total !== "number" || !Array.isArray(record.endpoints))
43
+ continue;
44
+ records.push(record);
45
+ }
46
+ catch {
47
+ // Malformed line: keep reading.
48
+ }
49
+ }
50
+ return {
51
+ records: records.sort((a, b) => Date.parse(a.at) - Date.parse(b.at)),
52
+ retainMs,
53
+ };
54
+ }
55
+ /** The runs in a history file, oldest first; its retention header is ignored. */
56
+ export function readPresetHistory(path) {
57
+ return readPresetHistoryFile(path).records;
58
+ }
59
+ /**
60
+ * A record's identity for merging, independent of key order: two copies of one
61
+ * run serialise the same fields, while two runs recorded at the same instant
62
+ * with different results stay separate runs.
63
+ */
64
+ function recordKey(value) {
65
+ if (Array.isArray(value))
66
+ return `[${value.map(recordKey).join(",")}]`;
67
+ if (value !== null && typeof value === "object") {
68
+ const entries = Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
69
+ return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${recordKey(item)}`).join(",")}}`;
70
+ }
71
+ return JSON.stringify(value) ?? "null";
72
+ }
73
+ /**
74
+ * Combine history files into one. The cache and the release asset each keep
75
+ * runs the other may have lost, so restore their union instead of whichever
76
+ * file happens to be longer. Identical runs are kept once, oldest first.
77
+ */
78
+ /**
79
+ * Warns when a window being read starts before the runs retention kept.
80
+ *
81
+ * A trimmed history looks exactly like a provider that went quiet: the earlier
82
+ * side of a comparison has no runs for it, so its uptime is missing and no
83
+ * regression is reported. Naming the cutoff keeps that silence from being read
84
+ * as an outage.
85
+ */
86
+ export function retentionWindowWarnings(newestAt, retainMs, windows) {
87
+ if (newestAt === null || retainMs === null)
88
+ return [];
89
+ const cutoff = Date.parse(newestAt) - retainMs;
90
+ const retainedFrom = stamp(new Date(cutoff).toISOString());
91
+ return windows
92
+ .filter((window) => window.since !== null && window.since < cutoff)
93
+ .map((window) => `${window.label} starts at ${stamp(new Date(window.since).toISOString())}, before the history retention keeps (${retainedFrom}); runs missing from it may have been pruned by --retain, not failed.`);
94
+ }
95
+ export function mergePresetHistory(inputs, options = {}) {
96
+ const seen = new Set();
97
+ const records = [];
98
+ const counts = inputs.map((input) => input.length);
99
+ for (const input of inputs) {
100
+ for (const record of input) {
101
+ const key = recordKey(record);
102
+ if (seen.has(key))
103
+ continue;
104
+ seen.add(key);
105
+ records.push(record);
106
+ }
107
+ }
108
+ records.sort((a, b) => Date.parse(a.at) - Date.parse(b.at));
109
+ // The release is the durable copy, not an unbounded one. Retention is
110
+ // measured from the newest run rather than from now, so a paused schedule
111
+ // cannot prune the very record it stopped updating.
112
+ const retainMs = options.retainMs ?? null;
113
+ const newest = records.at(-1);
114
+ const kept = retainMs === null || newest === undefined
115
+ ? records
116
+ : records.filter((record) => Date.parse(record.at) >= Date.parse(newest.at) - retainMs);
117
+ return {
118
+ records: kept,
119
+ duplicates: counts.reduce((sum, count) => sum + count, 0) - records.length,
120
+ counts,
121
+ pruned: records.length - kept.length,
122
+ };
123
+ }
124
+ /**
125
+ * The tightest retention declared by the files being combined. A merge cannot
126
+ * hold runs that one input already pruned, so the union is bounded by the
127
+ * narrowest window rather than the widest.
128
+ */
129
+ export function narrowestRetentionMs(values) {
130
+ const known = values.filter((value) => value !== null);
131
+ return known.length === 0 ? null : Math.min(...known);
132
+ }
133
+ /**
134
+ * Write a whole history file, replacing it, and create its directory. A
135
+ * retention window is recorded as the header readPresetHistoryFile returns, so
136
+ * the file itself says what it does not keep.
137
+ */
138
+ export function savePresetHistory(path, records, options = {}) {
139
+ const retainMs = options.retainMs ?? null;
140
+ const header = retainMs === null ? "" : `${JSON.stringify({ lwc_history: { v: 1, retainMs } })}\n`;
141
+ mkdirSync(dirname(path), { recursive: true });
142
+ writeFileSync(path, header + records.map((record) => `${JSON.stringify(record)}\n`).join(""));
143
+ }
144
+ /** Nearest-rank percentile, matching the compare and sessions reports. */
145
+ function percentile(values, p) {
146
+ if (values.length === 0)
147
+ return null;
148
+ const sorted = [...values].sort((a, b) => a - b);
149
+ const rank = Math.max(1, Math.ceil((p / 100) * sorted.length));
150
+ return sorted[rank - 1] ?? null;
151
+ }
152
+ /**
153
+ * Apply --since/--until/--limit to a history, oldest first. A limit is only
154
+ * ever used to look at recent runs, so it keeps the tail.
155
+ */
156
+ export function selectPresetHistory(records, selection = {}) {
157
+ const since = selection.since ?? null;
158
+ const until = selection.until ?? null;
159
+ const limit = selection.limit ?? null;
160
+ const sorted = [...records].sort((a, b) => Date.parse(a.at) - Date.parse(b.at));
161
+ const inRange = sorted.filter((record) => {
162
+ const at = Date.parse(record.at);
163
+ if (since !== null && at < since)
164
+ return false;
165
+ if (until !== null && at > until)
166
+ return false;
167
+ return true;
168
+ });
169
+ return limit === null ? inRange : inRange.slice(Math.max(0, inRange.length - limit));
170
+ }
171
+ /**
172
+ * Attribute each probe to the host of the endpoint the preset is configured
173
+ * with, and pool those by provider: one host that serves several chains is
174
+ * judged once. A failover counts against the provider, because the backup
175
+ * answering is the same fact as the provider under test not answering.
176
+ */
177
+ export function summarizeProviderHistory(records, selection = {}) {
178
+ const buckets = new Map();
179
+ for (const record of selectPresetHistory(records, selection)) {
180
+ for (const endpoint of record.endpoints) {
181
+ const host = endpoint.primaryHost;
182
+ if (!host)
183
+ continue;
184
+ const bucket = buckets.get(host) ?? {
185
+ probes: 0,
186
+ healthy: 0,
187
+ failovers: 0,
188
+ failed: 0,
189
+ presets: new Set(),
190
+ };
191
+ bucket.probes += 1;
192
+ bucket.presets.add(endpoint.preset);
193
+ if (!endpoint.ok)
194
+ bucket.failed += 1;
195
+ else if (endpoint.viaFallback)
196
+ bucket.failovers += 1;
197
+ else
198
+ bucket.healthy += 1;
199
+ buckets.set(host, bucket);
200
+ }
201
+ }
202
+ return [...buckets.entries()]
203
+ .map(([host, bucket]) => ({
204
+ host,
205
+ probes: bucket.probes,
206
+ healthy: bucket.healthy,
207
+ failovers: bucket.failovers,
208
+ failed: bucket.failed,
209
+ presets: [...bucket.presets].sort(),
210
+ uptimePct: round1((bucket.healthy / bucket.probes) * 100),
211
+ }))
212
+ .sort((a, b) => a.host.localeCompare(b.host));
213
+ }
214
+ export function summarizePresetHistory(records, selection = {}, context = {}) {
215
+ const totalRuns = records.length;
216
+ let oldest = Number.POSITIVE_INFINITY;
217
+ let newest = Number.NEGATIVE_INFINITY;
218
+ for (const record of records) {
219
+ const at = Date.parse(record.at);
220
+ if (at < oldest)
221
+ oldest = at;
222
+ if (at > newest)
223
+ newest = at;
224
+ }
225
+ const since = selection.since ?? null;
226
+ const until = selection.until ?? null;
227
+ const limit = selection.limit ?? null;
228
+ const ordered = selectPresetHistory(records, selection);
229
+ const buckets = new Map();
230
+ let healthyRuns = 0;
231
+ let failedRuns = 0;
232
+ let failoverRuns = 0;
233
+ for (const record of ordered) {
234
+ if (record.failed === 0)
235
+ healthyRuns += 1;
236
+ else
237
+ failedRuns += 1;
238
+ if (record.endpoints.some((endpoint) => endpoint.ok && endpoint.viaFallback)) {
239
+ failoverRuns += 1;
240
+ }
241
+ for (const endpoint of record.endpoints) {
242
+ const bucket = buckets.get(endpoint.preset) ?? {
243
+ chainId: endpoint.chainId,
244
+ nativeSymbol: endpoint.nativeSymbol,
245
+ latencies: [],
246
+ runs: 0,
247
+ healthy: 0,
248
+ failed: 0,
249
+ failovers: 0,
250
+ lastAt: null,
251
+ lastFailureAt: null,
252
+ };
253
+ bucket.runs += 1;
254
+ bucket.chainId = endpoint.chainId;
255
+ bucket.nativeSymbol = endpoint.nativeSymbol;
256
+ bucket.lastAt = record.at;
257
+ if (endpoint.ok) {
258
+ bucket.healthy += 1;
259
+ if (endpoint.viaFallback)
260
+ bucket.failovers += 1;
261
+ if (typeof endpoint.latencyMs === "number")
262
+ bucket.latencies.push(endpoint.latencyMs);
263
+ }
264
+ else {
265
+ bucket.failed += 1;
266
+ bucket.lastFailureAt = record.at;
267
+ }
268
+ buckets.set(endpoint.preset, bucket);
269
+ }
270
+ }
271
+ const presets = [...buckets.entries()]
272
+ .map(([preset, bucket]) => ({
273
+ preset,
274
+ chainId: bucket.chainId,
275
+ nativeSymbol: bucket.nativeSymbol,
276
+ runs: bucket.runs,
277
+ healthy: bucket.healthy,
278
+ failed: bucket.failed,
279
+ failovers: bucket.failovers,
280
+ uptimePct: Math.round((bucket.healthy / bucket.runs) * 1000) / 10,
281
+ p50LatencyMs: percentile(bucket.latencies, 50),
282
+ maxLatencyMs: bucket.latencies.length === 0 ? null : Math.max(...bucket.latencies),
283
+ lastAt: bucket.lastAt,
284
+ lastFailureAt: bucket.lastFailureAt,
285
+ }))
286
+ .sort((a, b) => a.preset.localeCompare(b.preset));
287
+ return {
288
+ runs: ordered.length,
289
+ totalRuns,
290
+ from: ordered[0]?.at ?? null,
291
+ to: ordered.at(-1)?.at ?? null,
292
+ availableFrom: totalRuns === 0 ? null : new Date(oldest).toISOString(),
293
+ availableTo: totalRuns === 0 ? null : new Date(newest).toISOString(),
294
+ retainMs: context.retainMs ?? null,
295
+ since,
296
+ until,
297
+ limit,
298
+ healthyRuns,
299
+ failedRuns,
300
+ failoverRuns,
301
+ presets,
302
+ };
303
+ }
304
+ /** Presets below the threshold, worst first; empty when every preset clears it. */
305
+ export function presetsBelowUptime(summary, threshold) {
306
+ return summary.presets
307
+ .filter((row) => row.uptimePct < threshold)
308
+ .map((row) => ({
309
+ preset: row.preset,
310
+ chainId: row.chainId,
311
+ uptimePct: row.uptimePct,
312
+ runs: row.runs,
313
+ failed: row.failed,
314
+ }))
315
+ .sort((a, b) => a.uptimePct - b.uptimePct || a.preset.localeCompare(b.preset));
316
+ }
317
+ /**
318
+ * Judge a window against a minimum uptime for CI: markdown for the run summary
319
+ * plus the verdict the caller exits on. An empty window fails, because there is
320
+ * no run left that could clear the threshold.
321
+ */
322
+ export function summarizePresetGate(summary, threshold) {
323
+ const shortfalls = presetsBelowUptime(summary, threshold);
324
+ const lines = ["### Preset uptime gate", ""];
325
+ if (summary.runs === 0) {
326
+ lines.push(`**FAIL** — no runs in the window (${summary.totalRuns} recorded), so a ${threshold}% minimum cannot be verified.`);
327
+ return { passed: false, threshold, shortfalls, markdown: lines.join("\n") };
328
+ }
329
+ const filter = [
330
+ summary.since === null ? null : `since ${stamp(new Date(summary.since).toISOString())}`,
331
+ summary.until === null ? null : `until ${stamp(new Date(summary.until).toISOString())}`,
332
+ summary.limit === null ? null : `newest ${summary.limit}`,
333
+ ].filter((part) => part !== null);
334
+ const runs = `${summary.runs} run${summary.runs === 1 ? "" : "s"} (${stamp(summary.from)} → ${stamp(summary.to)})`;
335
+ const scope = filter.length === 0 ? runs : `${runs}, ${filter.join(", ")}`;
336
+ if (shortfalls.length === 0) {
337
+ const presets = `${summary.presets.length} preset${summary.presets.length === 1 ? "" : "s"}`;
338
+ lines.push(`**PASS** — all ${presets} stayed at or above ${threshold}% across ${scope}.`);
339
+ }
340
+ else {
341
+ lines.push(`**FAIL** — ${shortfalls.length} preset${shortfalls.length === 1 ? "" : "s"} below ${threshold}% across ${scope}.`, "", "| preset | chain | runs | uptime | failed runs |", "| --- | --- | --- | --- | --- |", ...shortfalls.map((row) => `| \`${row.preset}\` | ${row.chainId} | ${row.runs} | ${row.uptimePct}% | ${row.failed} |`));
342
+ }
343
+ return {
344
+ passed: shortfalls.length === 0,
345
+ threshold,
346
+ shortfalls,
347
+ markdown: lines.join("\n"),
348
+ };
349
+ }
350
+ function stamp(iso) {
351
+ return iso === null ? "—" : iso.slice(0, 16).replace("T", " ");
352
+ }
353
+ /** "from → to" for the whole file, or null when it has no runs. */
354
+ function coverageOf(summary) {
355
+ return summary.availableFrom === null || summary.availableTo === null
356
+ ? null
357
+ : `${stamp(summary.availableFrom)} → ${stamp(summary.availableTo)}`;
358
+ }
359
+ /** The date a declared retention cuts at, or null when the file has none. */
360
+ function retainedCutoff(summary) {
361
+ return summary.retainMs === null || summary.availableTo === null
362
+ ? null
363
+ : Date.parse(summary.availableTo) - summary.retainMs;
364
+ }
365
+ /** "3 days (nothing older than …)", or null when the file declares none. */
366
+ function describeRetention(summary) {
367
+ const cutoff = retainedCutoff(summary);
368
+ return summary.retainMs === null || cutoff === null
369
+ ? null
370
+ : `${formatRetention(summary.retainMs)} (nothing older than ${stamp(new Date(cutoff).toISOString())})`;
371
+ }
372
+ /** A freshness age as the largest whole unit it covers, so `1 day` reads. */
373
+ function formatAge(ms) {
374
+ const units = [
375
+ [86_400_000, "day"],
376
+ [3_600_000, "hour"],
377
+ [60_000, "minute"],
378
+ ];
379
+ for (const [size, name] of units) {
380
+ if (ms >= size) {
381
+ const count = Math.floor(ms / size);
382
+ return `${count} ${name}${count === 1 ? "" : "s"}`;
383
+ }
384
+ }
385
+ const seconds = Math.max(0, Math.floor(ms / 1000));
386
+ return `${seconds} second${seconds === 1 ? "" : "s"}`;
387
+ }
388
+ /** A retention span in the largest unit it divides exactly, so `180d` reads. */
389
+ function formatRetention(ms) {
390
+ const units = [
391
+ [86_400_000, "day"],
392
+ [3_600_000, "hour"],
393
+ [60_000, "minute"],
394
+ [1000, "second"],
395
+ ];
396
+ for (const [size, name] of units) {
397
+ if (ms % size === 0) {
398
+ const count = ms / size;
399
+ return `${count} ${name}${count === 1 ? "" : "s"}`;
400
+ }
401
+ }
402
+ return `${ms} ms`;
403
+ }
404
+ export function formatPresetHistory(summary) {
405
+ if (summary.runs === 0) {
406
+ if (summary.totalRuns > 0) {
407
+ return [
408
+ "lwc — preset health history",
409
+ "",
410
+ ` no runs in this window (${summary.totalRuns} recorded); widen the window or raise --limit.`,
411
+ ].join("\n");
412
+ }
413
+ return "lwc — preset health history\n\n no runs recorded yet; record one with `lwc presets --history <file>`.";
414
+ }
415
+ const runs = summary.runs === summary.totalRuns
416
+ ? String(summary.runs)
417
+ : `${summary.runs} of ${summary.totalRuns}`;
418
+ const window = [
419
+ summary.since === null ? null : `since ${stamp(new Date(summary.since).toISOString())}`,
420
+ summary.until === null ? null : `until ${stamp(new Date(summary.until).toISOString())}`,
421
+ summary.limit === null ? null : `last ${summary.limit} runs`,
422
+ ].filter((part) => part !== null);
423
+ // The file's own coverage and the retention it declares, so a windowed or
424
+ // trimmed view says what was left out instead of looking like the whole record.
425
+ const coverage = coverageOf(summary);
426
+ const retained = describeRetention(summary);
427
+ const lines = [
428
+ "lwc — preset health history",
429
+ "",
430
+ ` runs ${runs} (${stamp(summary.from)} → ${stamp(summary.to)})`,
431
+ ...(summary.runs === summary.totalRuns || coverage === null
432
+ ? []
433
+ : [` covers ${coverage} (${summary.totalRuns} recorded)`]),
434
+ ...(window.length === 0 ? [] : [` window ${window.join(", ")}`]),
435
+ ...(retained === null ? [] : [` retained ${retained}`]),
436
+ ` healthy ${summary.healthyRuns} of ${summary.runs} runs`,
437
+ ` failed ${summary.failedRuns} runs`,
438
+ ` failovers ${summary.failoverRuns} runs served by a backup`,
439
+ "",
440
+ ];
441
+ const rows = summary.presets.map((row) => [
442
+ row.preset,
443
+ String(row.chainId),
444
+ String(row.runs),
445
+ `${row.uptimePct}%`,
446
+ String(row.failovers),
447
+ row.p50LatencyMs === null ? "—" : `${row.p50LatencyMs} ms`,
448
+ row.maxLatencyMs === null ? "—" : `${row.maxLatencyMs} ms`,
449
+ stamp(row.lastFailureAt),
450
+ ]);
451
+ const headers = [
452
+ "preset",
453
+ "chain",
454
+ "runs",
455
+ "uptime",
456
+ "failovers",
457
+ "p50",
458
+ "max",
459
+ "last failure",
460
+ ];
461
+ const widths = headers.map((header, column) => Math.max(header.length, ...rows.map((row) => row[column].length)));
462
+ const line = (cells) => ` ${cells.map((cell, column) => cell.padEnd(widths[column])).join(" ").trimEnd()}`;
463
+ lines.push(line(headers), ...rows.map(line));
464
+ return lines.join("\n");
465
+ }
466
+ /**
467
+ * Build the local check: the gate over the selected window plus the file's
468
+ * coverage and declared retention, so one command answers "did it pass, and
469
+ * what was it able to see?" without a CI run to assemble the pieces.
470
+ */
471
+ export function presetHistoryCheck(path, summary, threshold, options = {}) {
472
+ const gate = summarizePresetGate(summary, threshold);
473
+ const cutoff = retainedCutoff(summary);
474
+ const maxAgeMs = options.maxAgeMs ?? null;
475
+ const now = options.now ?? Date.now();
476
+ // Retention is anchored to the newest run, but freshness cannot be: the gate
477
+ // has to know how long ago that run happened, or a file nobody appends to
478
+ // would keep passing on week-old evidence.
479
+ const newest = summary.availableTo;
480
+ const ageMs = maxAgeMs === null || newest === null ? null : Math.max(0, now - Date.parse(newest));
481
+ const stale = maxAgeMs !== null && newest !== null && ageMs !== null && ageMs > maxAgeMs
482
+ ? { newest, ageMs, age: formatAge(ageMs) }
483
+ : null;
484
+ return {
485
+ path,
486
+ runs: summary.runs,
487
+ presets: summary.presets.length,
488
+ coverage: {
489
+ from: summary.availableFrom,
490
+ to: summary.availableTo,
491
+ recorded: summary.totalRuns,
492
+ },
493
+ retention: summary.retainMs === null || cutoff === null
494
+ ? null
495
+ : {
496
+ ms: summary.retainMs,
497
+ label: formatRetention(summary.retainMs),
498
+ cutoff: new Date(cutoff).toISOString(),
499
+ },
500
+ maxAge: maxAgeMs === null ? null : { ms: maxAgeMs, label: formatRetention(maxAgeMs) },
501
+ stale,
502
+ window: { since: summary.since, until: summary.until, limit: summary.limit },
503
+ gate: { threshold, passed: gate.passed && stale === null, shortfalls: gate.shortfalls },
504
+ note: cutoff !== null && summary.since !== null && summary.since < cutoff
505
+ ? `the window starts ${stamp(new Date(summary.since).toISOString())}, before the retained history (${stamp(new Date(cutoff).toISOString())}); missing runs may have been pruned, not failed.`
506
+ : null,
507
+ };
508
+ }
509
+ /**
510
+ * One line per reason the check failed, in the order the report reads: a stale
511
+ * file first, then an empty window, then each preset below the threshold. The
512
+ * pre-commit hook prints these and CI annotates them, so both say the same
513
+ * thing without copying the wording.
514
+ */
515
+ export function presetHistoryFailureReasons(check) {
516
+ const reasons = [];
517
+ if (check.stale !== null) {
518
+ reasons.push(`newest run ${check.stale.newest} is ${check.stale.age} old, past the ${check.maxAge?.label ?? "maximum"} maximum.`);
519
+ }
520
+ if (check.runs === 0) {
521
+ reasons.push(`no runs in the window (${check.coverage.recorded} recorded), so ${check.gate.threshold}% cannot be verified.`);
522
+ }
523
+ else {
524
+ for (const row of check.gate.shortfalls) {
525
+ reasons.push(`${row.preset} uptime ${row.uptimePct}% over ${row.runs} run${row.runs === 1 ? "" : "s"} is below ${check.gate.threshold}%.`);
526
+ }
527
+ }
528
+ return reasons;
529
+ }
530
+ /**
531
+ * The newest runs with their age, newest first, or null when there is no bound
532
+ * to report or nothing recorded. Both the CI table and the hook report read
533
+ * this one list, so their rows cannot disagree.
534
+ */
535
+ function freshnessRows(check, records, options = {}) {
536
+ if (check.maxAge === null || records.length === 0)
537
+ return null;
538
+ const now = options.now ?? Date.now();
539
+ const limit = options.limit ?? 5;
540
+ const newestFirst = records
541
+ .map((entry) => ({ entry, at: Date.parse(entry.at) }))
542
+ .sort((left, right) => right.at - left.at);
543
+ const newest = newestFirst[0];
544
+ if (newest === undefined)
545
+ return null;
546
+ return {
547
+ rows: newestFirst.slice(0, limit).map(({ entry, at }) => ({
548
+ at: stamp(entry.at),
549
+ age: formatAge(Math.max(0, now - at)),
550
+ presets: entry.endpoints.length,
551
+ failed: entry.failed,
552
+ failovers: entry.endpoints.filter((endpoint) => endpoint.viaFallback).length,
553
+ })),
554
+ total: newestFirst.length,
555
+ newestAge: formatAge(Math.max(0, now - newest.at)),
556
+ };
557
+ }
558
+ /**
559
+ * The newest runs with their age, as a markdown table for the CI summary. A
560
+ * verdict line cannot show that every row is old; a table can. The gate uses it
561
+ * only for a stale file, but the table reports either verdict so the wording
562
+ * lives in one tested place.
563
+ */
564
+ export function presetFreshnessMarkdown(check, records, options = {}) {
565
+ const maxAge = check.maxAge;
566
+ const freshness = freshnessRows(check, records, options);
567
+ if (maxAge === null || freshness === null)
568
+ return null;
569
+ const staleAge = check.stale?.age ?? null;
570
+ const lines = [
571
+ `### Preset freshness — ${staleAge === null ? "PASS" : "FAIL"}`,
572
+ "",
573
+ "| Newest runs (UTC) | Age | Presets | Failed | Failovers |",
574
+ "| --- | --- | --- | --- | --- |",
575
+ ...freshness.rows.map((row) => `| ${row.at} | ${row.age} | ${row.presets} | ${row.failed} | ${row.failovers} |`),
576
+ "",
577
+ staleAge === null
578
+ ? `Newest recorded run is ${freshness.newestAge} old, within the ${maxAge.label} maximum.`
579
+ : `Newest recorded run is ${staleAge} old, past the ${maxAge.label} maximum, so the gate fails on freshness alone.`,
580
+ ];
581
+ if (freshness.rows.length < freshness.total) {
582
+ lines.push(`Showing the newest ${freshness.rows.length} of ${freshness.total} recorded runs.`);
583
+ }
584
+ return lines.join("\n");
585
+ }
586
+ /**
587
+ * Render the local check as one aligned pass, ending in the verdict. Passing the
588
+ * records replaces the single stale line with the newest runs and their age, so
589
+ * a stopped schedule is visible row by row (five by default, or `rows`); without
590
+ * them the summary line stays.
591
+ */
592
+ export function formatPresetHistoryCheck(check, records, options = {}) {
593
+ const field = (label, value) => ` ${label.padEnd(9)}${value}`;
594
+ const { coverage, retention, gate, window: applied } = check;
595
+ const runs = check.runs === 0
596
+ ? `no runs (${coverage.recorded} recorded)`
597
+ : `${check.runs} run${check.runs === 1 ? "" : "s"}`;
598
+ const lines = ["lwc — preset health check", "", field("file", check.path), field("runs", runs)];
599
+ if (coverage.from !== null && coverage.to !== null) {
600
+ lines.push(field("covers", `${stamp(coverage.from)} → ${stamp(coverage.to)} (${coverage.recorded} recorded)`));
601
+ }
602
+ const filters = [
603
+ applied.since === null ? null : `since ${stamp(new Date(applied.since).toISOString())}`,
604
+ applied.until === null ? null : `until ${stamp(new Date(applied.until).toISOString())}`,
605
+ applied.limit === null ? null : `newest ${applied.limit}`,
606
+ ].filter((part) => part !== null);
607
+ if (filters.length > 0)
608
+ lines.push(field("window", filters.join(", ")));
609
+ if (retention !== null) {
610
+ lines.push(field("retained", `${retention.label} (nothing older than ${stamp(retention.cutoff)})`));
611
+ }
612
+ if (check.note !== null)
613
+ lines.push(field("note", check.note));
614
+ if (check.maxAge !== null)
615
+ lines.push(field("max age", check.maxAge.label));
616
+ if (check.stale !== null && check.maxAge !== null) {
617
+ // Anchor the rows to the same instant the check judged, so the table's newest
618
+ // age is the age the verdict used rather than a few milliseconds later.
619
+ const freshness = freshnessRows(check, records ?? [], {
620
+ now: Date.parse(check.stale.newest) + check.stale.ageMs,
621
+ limit: options.rows,
622
+ });
623
+ if (freshness === null) {
624
+ lines.push(field("stale", `newest run ${stamp(check.stale.newest)} is ${check.stale.age} old, past the ${check.maxAge.label} maximum.`));
625
+ }
626
+ else {
627
+ lines.push(field("newest", `${check.stale.age} old, past the ${check.maxAge.label} maximum.`), ` freshness newest first`);
628
+ for (const row of freshness.rows) {
629
+ lines.push(` ${row.at.padEnd(17)}${row.age.padEnd(9)}${row.presets} preset${row.presets === 1 ? "" : "s"}, ${row.failed} failed, ${row.failovers} failover${row.failovers === 1 ? "" : "s"}`);
630
+ }
631
+ if (freshness.rows.length < freshness.total) {
632
+ lines.push(` showing the newest ${freshness.rows.length} of ${freshness.total} recorded runs`);
633
+ }
634
+ }
635
+ }
636
+ if (check.runs === 0) {
637
+ lines.push(field("gate", `FAIL — no runs in the window (${coverage.recorded} recorded), so ${gate.threshold}% cannot be verified.`));
638
+ return lines.join("\n");
639
+ }
640
+ if (gate.passed) {
641
+ const presets = `${check.presets} preset${check.presets === 1 ? "" : "s"}`;
642
+ lines.push(field("gate", `PASS — all ${presets} stayed at or above ${gate.threshold}% across ${runs}.`));
643
+ return lines.join("\n");
644
+ }
645
+ if (gate.shortfalls.length === 0) {
646
+ // Freshness is the only reason left: every preset cleared the threshold,
647
+ // but the newest run is too old for that verdict to cover today.
648
+ lines.push(field("gate", "FAIL — newest run is past the maximum age, so there is nothing fresh to judge."));
649
+ return lines.join("\n");
650
+ }
651
+ if (gate.shortfalls.length === 0) {
652
+ // Freshness is the only reason left: every preset cleared the threshold,
653
+ // but the newest run is too old for that verdict to cover today.
654
+ lines.push(field("gate", "FAIL — newest run is past the maximum age, so there is nothing fresh to judge."));
655
+ return lines.join("\n");
656
+ }
657
+ const below = gate.shortfalls.length;
658
+ lines.push(field("gate", `FAIL — ${below} preset${below === 1 ? "" : "s"} below ${gate.threshold}% across ${runs}.`), "");
659
+ const rows = gate.shortfalls.map((row) => [
660
+ row.preset,
661
+ String(row.chainId),
662
+ String(row.runs),
663
+ `${row.uptimePct}%`,
664
+ String(row.failed),
665
+ ]);
666
+ const headers = ["preset", "chain", "runs", "uptime", "failed"];
667
+ const widths = headers.map((header, column) => Math.max(header.length, ...rows.map((row) => row[column].length)));
668
+ const table = (cells) => ` ${cells.map((cell, column) => cell.padEnd(widths[column])).join(" ").trimEnd()}`;
669
+ lines.push(table(headers), ...rows.map(table));
670
+ return lines.join("\n");
671
+ }
672
+ /**
673
+ * Judge a provider change by reading the same presets in two windows: the
674
+ * current one from the usual filters, and an explicit earlier one. Both sides
675
+ * are ordinary summaries, so a delta cannot disagree with either table.
676
+ */
677
+ export function comparePresetHistory(records, selection, compare, context = {}) {
678
+ const after = summarizePresetHistory(records, selection, context);
679
+ const before = summarizePresetHistory(records, { since: compare.since, until: compare.until }, context);
680
+ const earlier = new Map(before.presets.map((row) => [row.preset, row]));
681
+ const later = new Map(after.presets.map((row) => [row.preset, row]));
682
+ const names = [...new Set([...earlier.keys(), ...later.keys()])];
683
+ const presets = names
684
+ .map((preset) => {
685
+ const was = earlier.get(preset) ?? null;
686
+ const now = later.get(preset) ?? null;
687
+ const known = (now ?? was);
688
+ return {
689
+ preset,
690
+ chainId: known.chainId,
691
+ nativeSymbol: known.nativeSymbol,
692
+ before: was,
693
+ after: now,
694
+ deltaPct: was === null || now === null ? null : round1(now.uptimePct - was.uptimePct),
695
+ };
696
+ })
697
+ // A regression is what a provider change is judged on, so it reads first.
698
+ // Presets only present on one side have no delta and close the table.
699
+ .sort((a, b) => {
700
+ if (a.deltaPct === null || b.deltaPct === null) {
701
+ if (a.deltaPct !== b.deltaPct)
702
+ return a.deltaPct === null ? 1 : -1;
703
+ return a.preset.localeCompare(b.preset);
704
+ }
705
+ return a.deltaPct - b.deltaPct || a.preset.localeCompare(b.preset);
706
+ });
707
+ const providers = compareProviders(summarizeProviderHistory(records, { since: compare.since, until: compare.until }), summarizeProviderHistory(records, selection));
708
+ return { before, after, presets, providers };
709
+ }
710
+ /** Pair two provider windows by host, listing a fall first, as presets do. */
711
+ function compareProviders(earlier, later) {
712
+ const was = new Map(earlier.map((row) => [row.host, row]));
713
+ const now = new Map(later.map((row) => [row.host, row]));
714
+ const hosts = [...new Set([...was.keys(), ...now.keys()])];
715
+ return hosts
716
+ .map((host) => {
717
+ const before = was.get(host) ?? null;
718
+ const after = now.get(host) ?? null;
719
+ return {
720
+ host,
721
+ before,
722
+ after,
723
+ deltaPct: before === null || after === null ? null : round1(after.uptimePct - before.uptimePct),
724
+ };
725
+ })
726
+ .sort((a, b) => {
727
+ if (a.deltaPct === null || b.deltaPct === null) {
728
+ if (a.deltaPct !== b.deltaPct)
729
+ return a.deltaPct === null ? 1 : -1;
730
+ return a.host.localeCompare(b.host);
731
+ }
732
+ return a.deltaPct - b.deltaPct || a.host.localeCompare(b.host);
733
+ });
734
+ }
735
+ function round1(value) {
736
+ return Math.round(value * 10) / 10;
737
+ }
738
+ /** A percentage-point change with its sign, so an improvement reads as one. */
739
+ function pp(value) {
740
+ const rounded = round1(value);
741
+ if (rounded === 0)
742
+ return "0pp";
743
+ return `${rounded > 0 ? "+" : ""}${rounded}pp`;
744
+ }
745
+ /** Overall uptime of a window to one decimal, or null when it has no runs. */
746
+ function windowUptime(summary) {
747
+ return summary.runs === 0 ? null : round1((summary.healthyRuns / summary.runs) * 100);
748
+ }
749
+ export function formatPresetHistoryComparison(comparison) {
750
+ const { before, after } = comparison;
751
+ const lines = ["lwc — preset health comparison", ""];
752
+ const describe = (label, summary) => {
753
+ const uptime = windowUptime(summary);
754
+ if (uptime === null) {
755
+ return ` ${label.padEnd(9)}no runs (${summary.totalRuns} recorded)`;
756
+ }
757
+ const runs = `${summary.runs} run${summary.runs === 1 ? "" : "s"}`;
758
+ return ` ${label.padEnd(9)}${runs} (${stamp(summary.from)} → ${stamp(summary.to)}), uptime ${uptime}%`;
759
+ };
760
+ lines.push(describe("before", before), describe("after", after));
761
+ // The file's coverage and declared retention, and a warning when the earlier
762
+ // window starts before the retained history: without it a short before side
763
+ // reads as a provider fading out rather than as a trim.
764
+ const coverage = coverageOf(after);
765
+ const retained = describeRetention(after);
766
+ const cutoff = retainedCutoff(after);
767
+ if (coverage !== null) {
768
+ lines.push(` ${"covers".padEnd(9)}${coverage} (${after.totalRuns} recorded)`);
769
+ }
770
+ if (retained !== null) {
771
+ lines.push(` ${"retained".padEnd(9)}${retained}`);
772
+ }
773
+ if (cutoff !== null && before.since !== null && before.since < cutoff) {
774
+ lines.push(` ${"note".padEnd(9)}the earlier window starts ${stamp(new Date(before.since).toISOString())}, before the retained history (${stamp(new Date(cutoff).toISOString())}), so its delta only covers the runs that remain.`);
775
+ }
776
+ if (after.runs === 0) {
777
+ lines.push("", " no runs in the current window; widen the window and try again.");
778
+ return lines.join("\n");
779
+ }
780
+ const earlierUptime = windowUptime(before);
781
+ const overall = earlierUptime === null ? null : round1((windowUptime(after) ?? 0) - earlierUptime);
782
+ const changes = [];
783
+ if (overall !== null)
784
+ changes.push(`${pp(overall)} overall`);
785
+ const worse = comparison.presets.filter((row) => row.deltaPct !== null && row.deltaPct < 0).length;
786
+ const better = comparison.presets.filter((row) => row.deltaPct !== null && row.deltaPct > 0).length;
787
+ if (worse > 0)
788
+ changes.push(`${worse} preset${worse === 1 ? "" : "s"} worse`);
789
+ if (better > 0)
790
+ changes.push(`${better} better`);
791
+ lines.push(` ${"change".padEnd(9)}${changes.length === 0 ? "—" : changes.join(", ")}`, "");
792
+ const rows = comparison.presets.map((row) => [
793
+ row.preset,
794
+ String(row.chainId),
795
+ row.before === null ? "—" : String(row.before.runs),
796
+ row.before === null ? "—" : `${row.before.uptimePct}%`,
797
+ row.after === null ? "—" : String(row.after.runs),
798
+ row.after === null ? "—" : `${row.after.uptimePct}%`,
799
+ row.deltaPct === null ? "—" : pp(row.deltaPct),
800
+ ]);
801
+ const headers = [
802
+ "preset",
803
+ "chain",
804
+ "before runs",
805
+ "before uptime",
806
+ "after runs",
807
+ "after uptime",
808
+ "change",
809
+ ];
810
+ const widths = headers.map((header, column) => Math.max(header.length, ...rows.map((row) => row[column].length)));
811
+ const line = (cells) => ` ${cells.map((cell, column) => cell.padEnd(widths[column])).join(" ").trimEnd()}`;
812
+ lines.push(line(headers), ...rows.map(line));
813
+ return lines.join("\n");
814
+ }
815
+ /** Table cells in a CI summary cannot hold pipes or newlines. */
816
+ function mdCell(value) {
817
+ return value.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
818
+ }
819
+ /**
820
+ * Markdown for a CI run summary: the current window beside an earlier one, so
821
+ * a scheduled run reports the week-over-week delta without anyone asking.
822
+ */
823
+ export function presetComparisonMarkdown(comparison, heading = "Preset comparison") {
824
+ const { before, after } = comparison;
825
+ const lines = [`### ${heading}`, ""];
826
+ const coverage = coverageOf(after);
827
+ const retained = describeRetention(after);
828
+ const cutoff = retainedCutoff(after);
829
+ const short = cutoff !== null && before.since !== null && before.since < cutoff;
830
+ const span = (summary) => summary.runs === 0
831
+ ? `no runs (${summary.totalRuns} recorded)`
832
+ : `${summary.runs} run${summary.runs === 1 ? "" : "s"} (${stamp(summary.from)} → ${stamp(summary.to)})`;
833
+ // What the file holds is true of every branch, including the ones with no
834
+ // earlier runs to compare against.
835
+ const facts = [
836
+ ...(coverage === null ? [] : [`Coverage: ${coverage} (${after.totalRuns} recorded).`]),
837
+ ...(retained === null ? [] : [`Retained: ${retained}.`]),
838
+ ];
839
+ if (after.runs === 0) {
840
+ lines.push(`**No runs in the current window** — ${span(after)}; nothing to compare.`, ...facts);
841
+ return lines.join("\n");
842
+ }
843
+ if (before.runs === 0) {
844
+ lines.push(`**No earlier runs to compare against** — ${span(before)}; the current window is ${span(after)}.`, ...facts, ...(short && cutoff !== null
845
+ ? [
846
+ `The earlier window starts before the retained history (${stamp(new Date(cutoff).toISOString())}), so its runs may have been pruned rather than missing.`,
847
+ ]
848
+ : []));
849
+ return lines.join("\n");
850
+ }
851
+ const regressions = comparison.presets.filter((row) => row.deltaPct !== null && row.deltaPct < 0);
852
+ const improvements = comparison.presets.filter((row) => row.deltaPct !== null && row.deltaPct > 0);
853
+ const overall = round1((windowUptime(after) ?? 0) - (windowUptime(before) ?? 0));
854
+ lines.push(`**${regressions.length} regressed, ${improvements.length} improved** across ${comparison.presets.length} preset${comparison.presets.length === 1 ? "" : "s"}.`, "", `Earlier: ${span(before)} — current: ${span(after)}. Overall uptime ${pp(overall)}.`, ...facts, ...(short && cutoff !== null
855
+ ? [
856
+ `The earlier window starts before the retained history (${stamp(new Date(cutoff).toISOString())}), so its delta only covers the runs that remain.`,
857
+ ]
858
+ : []), "", "| preset | chain | before runs | before uptime | after runs | after uptime | change |", "| --- | --- | --- | --- | --- | --- | --- |", ...comparison.presets.map((row) => {
859
+ const beforeRuns = row.before === null ? "—" : String(row.before.runs);
860
+ const beforeUptime = row.before === null ? "—" : `${row.before.uptimePct}%`;
861
+ const afterRuns = row.after === null ? "—" : String(row.after.runs);
862
+ const afterUptime = row.after === null ? "—" : `${row.after.uptimePct}%`;
863
+ const change = row.deltaPct === null ? "—" : pp(row.deltaPct);
864
+ return `| \`${mdCell(row.preset)}\` | ${row.chainId} | ${beforeRuns} | ${beforeUptime} | ${afterRuns} | ${afterUptime} | ${change} |`;
865
+ }));
866
+ return lines.join("\n");
867
+ }
868
+ /**
869
+ * One line per preset whose uptime fell, worst first, for a CI annotation. A
870
+ * provider change that looks fine in this week's numbers still shows up here if
871
+ * it made any preset worse than the week it replaced.
872
+ */
873
+ export function presetRegressionWarnings(comparison) {
874
+ const line = (row) => {
875
+ if (row.deltaPct === null || row.deltaPct >= 0 || row.before === null || row.after === null) {
876
+ return null;
877
+ }
878
+ const runs = (count) => `${count} run${count === 1 ? "" : "s"}`;
879
+ return `${row.preset} uptime ${pp(row.deltaPct)}: ${row.before.uptimePct}% over ${runs(row.before.runs)} → ${row.after.uptimePct}% over ${runs(row.after.runs)}`;
880
+ };
881
+ return comparison.presets
882
+ .map(line)
883
+ .filter((value) => value !== null);
884
+ }
885
+ /**
886
+ * One line per provider host whose uptime fell, worst first. The same week can
887
+ * regress several presets for one reason; this names the upstream once, so the
888
+ * run log says which provider got worse rather than only which chains did.
889
+ */
890
+ export function providerRegressionWarnings(comparison) {
891
+ const line = (row) => {
892
+ if (row.deltaPct === null || row.deltaPct >= 0 || row.before === null || row.after === null) {
893
+ return null;
894
+ }
895
+ const probes = (count) => `${count} probe${count === 1 ? "" : "s"}`;
896
+ return `${row.host} uptime ${pp(row.deltaPct)}: ${row.before.uptimePct}% over ${probes(row.before.probes)} → ${row.after.uptimePct}% over ${probes(row.after.probes)}`;
897
+ };
898
+ return comparison.providers
899
+ .map(line)
900
+ .filter((value) => value !== null);
901
+ }
902
+ export function toPresetHistoryJson(summary) {
903
+ return summary;
904
+ }
905
+ /** One CSV row per preset in the window, for charting uptime and latency. */
906
+ export function presetHistoryCsv(summary) {
907
+ const header = [
908
+ "preset",
909
+ "chain",
910
+ "native",
911
+ "runs",
912
+ "healthy",
913
+ "failed",
914
+ "failovers",
915
+ "uptime_pct",
916
+ "p50_latency_ms",
917
+ "max_latency_ms",
918
+ "last_run",
919
+ "last_failure",
920
+ ];
921
+ const rows = summary.presets.map((row) => [
922
+ csvCell(row.preset),
923
+ String(row.chainId),
924
+ csvCell(row.nativeSymbol),
925
+ String(row.runs),
926
+ String(row.healthy),
927
+ String(row.failed),
928
+ String(row.failovers),
929
+ String(row.uptimePct),
930
+ row.p50LatencyMs === null ? "" : String(row.p50LatencyMs),
931
+ row.maxLatencyMs === null ? "" : String(row.maxLatencyMs),
932
+ row.lastAt ?? "",
933
+ row.lastFailureAt ?? "",
934
+ ]);
935
+ return [header.join(","), ...rows.map((cells) => cells.join(","))].join("\n");
936
+ }
937
+ /** Write the CSV to a file, creating its directory. */
938
+ export function savePresetHistoryCsv(path, csv) {
939
+ mkdirSync(dirname(path), { recursive: true });
940
+ writeFileSync(path, `${csv}\n`);
941
+ }
942
+ /**
943
+ * One CSV row per preset per run, oldest first, for charting health over time
944
+ * rather than summarizing it: a failure keeps its latency and via cells blank.
945
+ */
946
+ export function presetHistoryTimelineCsv(records, selection = {}) {
947
+ const header = [
948
+ "at",
949
+ "preset",
950
+ "chain",
951
+ "native",
952
+ "ok",
953
+ "via",
954
+ "latency_ms",
955
+ "block_number",
956
+ "rpc_host",
957
+ "error",
958
+ "primary_error",
959
+ ];
960
+ const rows = selectPresetHistory(records, selection).flatMap((record) => record.endpoints.map((endpoint) => [
961
+ record.at,
962
+ csvCell(endpoint.preset),
963
+ String(endpoint.chainId),
964
+ csvCell(endpoint.nativeSymbol),
965
+ endpoint.ok ? "true" : "false",
966
+ endpoint.ok ? (endpoint.viaFallback ? "backup" : "primary") : "",
967
+ endpoint.latencyMs === null ? "" : String(endpoint.latencyMs),
968
+ endpoint.blockNumber ?? "",
969
+ csvCell(endpoint.rpcHost),
970
+ csvCell(endpoint.error ?? ""),
971
+ csvCell(endpoint.primaryError ?? ""),
972
+ ]));
973
+ return [header.join(","), ...rows.map((cells) => cells.join(","))].join("\n");
974
+ }
975
+ const SINCE_UNITS_MS = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 };
976
+ /**
977
+ * A window bound: an ISO timestamp, or a span measured back from now (`24h`),
978
+ * so `--since 7d --until 1d` keeps the week before yesterday.
979
+ */
980
+ function parseBound(raw, now, flag) {
981
+ const relative = /^(\d+)([smhd])$/.exec(raw);
982
+ if (relative) {
983
+ const amount = Number(relative[1]);
984
+ const unit = SINCE_UNITS_MS[relative[2]];
985
+ return now - amount * unit;
986
+ }
987
+ const parsed = Date.parse(raw);
988
+ if (!Number.isFinite(parsed)) {
989
+ throw new PresetsHistoryArgError(`${flag} must be an ISO timestamp or a span like 24h, got "${raw}".`);
990
+ }
991
+ return parsed;
992
+ }
993
+ export function parsePresetHistoryArgs(argv, now = Date.now()) {
994
+ let path = null;
995
+ let since = null;
996
+ let until = null;
997
+ let limit = null;
998
+ let failUnder = null;
999
+ let csvPath = null;
1000
+ let timelinePath = null;
1001
+ let compare = null;
1002
+ const merge = [];
1003
+ let out = null;
1004
+ let retain = null;
1005
+ let maxAge = null;
1006
+ let freshnessRows = null;
1007
+ let check = false;
1008
+ for (let index = 0; index < argv.length; index += 1) {
1009
+ const arg = argv[index] ?? "";
1010
+ const readValue = (flag) => {
1011
+ const value = argv[index + 1];
1012
+ if (!value)
1013
+ throw new PresetsHistoryArgError(`${flag} requires a value.`);
1014
+ index += 1;
1015
+ return value;
1016
+ };
1017
+ if (arg === "--json")
1018
+ continue;
1019
+ if (arg === "--check") {
1020
+ check = true;
1021
+ continue;
1022
+ }
1023
+ if (arg === "--since" || arg.startsWith("--since=")) {
1024
+ const value = arg.startsWith("--since=")
1025
+ ? arg.slice("--since=".length)
1026
+ : readValue("--since");
1027
+ if (!value) {
1028
+ throw new PresetsHistoryArgError("--since requires a timestamp or a span like 24h.");
1029
+ }
1030
+ since = parseBound(value, now, "--since");
1031
+ }
1032
+ else if (arg === "--until" || arg.startsWith("--until=")) {
1033
+ const value = arg.startsWith("--until=")
1034
+ ? arg.slice("--until=".length)
1035
+ : readValue("--until");
1036
+ if (!value) {
1037
+ throw new PresetsHistoryArgError("--until requires a timestamp or a span like 24h.");
1038
+ }
1039
+ until = parseBound(value, now, "--until");
1040
+ }
1041
+ else if (arg === "--limit" || arg.startsWith("--limit=")) {
1042
+ const value = arg.startsWith("--limit=")
1043
+ ? arg.slice("--limit=".length)
1044
+ : readValue("--limit");
1045
+ if (!/^\d+$/.test(value) || Number(value) < 1) {
1046
+ throw new PresetsHistoryArgError(`--limit must be a positive whole number, got "${value}".`);
1047
+ }
1048
+ limit = Number(value);
1049
+ }
1050
+ else if (arg === "--fail-under" || arg.startsWith("--fail-under=")) {
1051
+ const value = arg.startsWith("--fail-under=")
1052
+ ? arg.slice("--fail-under=".length)
1053
+ : readValue("--fail-under");
1054
+ const percentage = Number(value);
1055
+ if (!/^\d+(\.\d+)?$/.test(value) || percentage > 100) {
1056
+ throw new PresetsHistoryArgError(`--fail-under must be a percentage from 0 to 100, got "${value}".`);
1057
+ }
1058
+ failUnder = percentage;
1059
+ }
1060
+ else if (arg === "--csv" || arg.startsWith("--csv=")) {
1061
+ const inline = arg.startsWith("--csv=") ? arg.slice("--csv=".length) : null;
1062
+ const value = inline ?? argv[index + 1] ?? "";
1063
+ if (inline === null)
1064
+ index += 1;
1065
+ if (!value)
1066
+ throw new PresetsHistoryArgError("--csv requires a path, or - for stdout.");
1067
+ csvPath = value;
1068
+ }
1069
+ else if (arg === "--timeline" || arg.startsWith("--timeline=")) {
1070
+ const inline = arg.startsWith("--timeline=") ? arg.slice("--timeline=".length) : null;
1071
+ const value = inline ?? argv[index + 1] ?? "";
1072
+ if (inline === null)
1073
+ index += 1;
1074
+ if (!value) {
1075
+ throw new PresetsHistoryArgError("--timeline requires a path, or - for stdout.");
1076
+ }
1077
+ timelinePath = value;
1078
+ }
1079
+ else if (arg === "--compare" || arg.startsWith("--compare=")) {
1080
+ const inline = arg.startsWith("--compare=") ? arg.slice("--compare=".length) : null;
1081
+ const value = inline ?? argv[index + 1] ?? "";
1082
+ if (inline === null)
1083
+ index += 1;
1084
+ if (!value)
1085
+ throw new PresetsHistoryArgError("--compare requires a window like 14d..7d.");
1086
+ const parts = value.split("..");
1087
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
1088
+ throw new PresetsHistoryArgError(`--compare must be a window like 14d..7d, got "${value}".`);
1089
+ }
1090
+ const from = parseBound(parts[0], now, "--compare");
1091
+ const to = parseBound(parts[1], now, "--compare");
1092
+ if (to < from)
1093
+ throw new PresetsHistoryArgError("--compare must start before it ends.");
1094
+ compare = { since: from, until: to };
1095
+ }
1096
+ else if (arg === "--merge" || arg.startsWith("--merge=")) {
1097
+ const inline = arg.startsWith("--merge=") ? arg.slice("--merge=".length) : null;
1098
+ const value = inline ?? argv[index + 1] ?? "";
1099
+ if (inline === null)
1100
+ index += 1;
1101
+ if (!value) {
1102
+ throw new PresetsHistoryArgError("--merge requires a history file to fold in.");
1103
+ }
1104
+ merge.push(value);
1105
+ }
1106
+ else if (arg === "--out" || arg.startsWith("--out=")) {
1107
+ const inline = arg.startsWith("--out=") ? arg.slice("--out=".length) : null;
1108
+ const value = inline ?? argv[index + 1] ?? "";
1109
+ if (inline === null)
1110
+ index += 1;
1111
+ if (!value) {
1112
+ throw new PresetsHistoryArgError("--out requires a path for the merged history.");
1113
+ }
1114
+ if (value === "-") {
1115
+ throw new PresetsHistoryArgError("--out writes history JSONL, so it needs a file path; - is already the table's output.");
1116
+ }
1117
+ out = value;
1118
+ }
1119
+ else if (arg === "--retain" || arg.startsWith("--retain=")) {
1120
+ const inline = arg.startsWith("--retain=") ? arg.slice("--retain=".length) : null;
1121
+ const value = inline ?? argv[index + 1] ?? "";
1122
+ if (inline === null)
1123
+ index += 1;
1124
+ retain = parseRetentionMs(value);
1125
+ }
1126
+ else if (arg === "--max-age" || arg.startsWith("--max-age=")) {
1127
+ const inline = arg.startsWith("--max-age=") ? arg.slice("--max-age=".length) : null;
1128
+ const value = inline ?? argv[index + 1] ?? "";
1129
+ if (inline === null)
1130
+ index += 1;
1131
+ maxAge = parseMaxAgeMs(value);
1132
+ }
1133
+ else if (arg === "--freshness-rows" || arg.startsWith("--freshness-rows=")) {
1134
+ const inline = arg.startsWith("--freshness-rows=")
1135
+ ? arg.slice("--freshness-rows=".length)
1136
+ : null;
1137
+ const value = inline ?? argv[index + 1] ?? "";
1138
+ if (inline === null)
1139
+ index += 1;
1140
+ freshnessRows = parseFreshnessRows(value);
1141
+ }
1142
+ else if (arg.startsWith("-")) {
1143
+ throw new PresetsHistoryArgError(`unexpected flag "${arg}".`);
1144
+ }
1145
+ else if (path !== null) {
1146
+ throw new PresetsHistoryArgError("takes a single history file.");
1147
+ }
1148
+ else {
1149
+ path = arg;
1150
+ }
1151
+ }
1152
+ if (path === null)
1153
+ throw new PresetsHistoryArgError("a history file is required.");
1154
+ if (since !== null && until !== null && until < since) {
1155
+ throw new PresetsHistoryArgError("--until must not be earlier than --since.");
1156
+ }
1157
+ if (csvPath !== null && timelinePath !== null) {
1158
+ throw new PresetsHistoryArgError("--csv and --timeline are different exports; pass one.");
1159
+ }
1160
+ if (out !== null && merge.length === 0) {
1161
+ throw new PresetsHistoryArgError("--out writes the combined history, so pass at least one --merge <file>.");
1162
+ }
1163
+ if (retain !== null && merge.length === 0) {
1164
+ throw new PresetsHistoryArgError("--retain bounds the combined history, so pass at least one --merge <file>.");
1165
+ }
1166
+ if (maxAge !== null && !check) {
1167
+ throw new PresetsHistoryArgError("--max-age bounds the gate, so pass --check with it.");
1168
+ }
1169
+ if (freshnessRows !== null && !check) {
1170
+ throw new PresetsHistoryArgError("--freshness-rows shapes the check report, so pass --check with it.");
1171
+ }
1172
+ if (check) {
1173
+ // The check report is the output, so an export has nowhere to go, and a
1174
+ // comparison is a different report rather than part of this one.
1175
+ if (failUnder === null) {
1176
+ throw new PresetsHistoryArgError("--check reports a gate, so pass --fail-under <pct> with it.");
1177
+ }
1178
+ if (csvPath !== null || timelinePath !== null) {
1179
+ throw new PresetsHistoryArgError("--check cannot be combined with --csv or --timeline.");
1180
+ }
1181
+ if (compare !== null) {
1182
+ throw new PresetsHistoryArgError("--check reports one window; use --compare without it.");
1183
+ }
1184
+ }
1185
+ if (compare !== null) {
1186
+ if (csvPath !== null || timelinePath !== null) {
1187
+ throw new PresetsHistoryArgError("--compare cannot be combined with --csv or --timeline.");
1188
+ }
1189
+ if (limit !== null) {
1190
+ throw new PresetsHistoryArgError("--compare cannot be combined with --limit.");
1191
+ }
1192
+ // The two windows have to be tellable apart, so the current one needs a
1193
+ // start and the earlier one has to end before it.
1194
+ if (since === null) {
1195
+ throw new PresetsHistoryArgError("--compare needs --since to mark where the current window starts.");
1196
+ }
1197
+ if (compare.until > since) {
1198
+ throw new PresetsHistoryArgError("--compare must end at or before --since.");
1199
+ }
1200
+ }
1201
+ return {
1202
+ path,
1203
+ since,
1204
+ until,
1205
+ limit,
1206
+ failUnder,
1207
+ csvPath,
1208
+ timelinePath,
1209
+ compare,
1210
+ merge,
1211
+ out,
1212
+ retain,
1213
+ maxAge,
1214
+ freshnessRows,
1215
+ check,
1216
+ };
1217
+ }
1218
+ /** A retention window as a positive span (`180d`), for --retain and the CI merge. */
1219
+ export function parseRetentionMs(raw) {
1220
+ const match = /^([1-9]\d*)([smhd])$/.exec(raw.trim());
1221
+ if (!match) {
1222
+ throw new PresetsHistoryArgError(`--retain must be a positive span like 180d, got "${raw}".`);
1223
+ }
1224
+ const unit = SINCE_UNITS_MS[match[2]];
1225
+ return Number(match[1]) * unit;
1226
+ }
1227
+ /** A freshness row count as a positive whole number (`5`), for --freshness-rows. */
1228
+ export function parseFreshnessRows(raw) {
1229
+ const match = /^([1-9]\d*)$/.exec(raw.trim());
1230
+ if (!match) {
1231
+ throw new PresetsHistoryArgError(`--freshness-rows must be a positive whole number like 5, got "${raw}".`);
1232
+ }
1233
+ return Number(match[1]);
1234
+ }
1235
+ /** A freshness bound as a positive span (`7d`), for --max-age. */
1236
+ export function parseMaxAgeMs(raw) {
1237
+ const match = /^([1-9]\d*)([smhd])$/.exec(raw.trim());
1238
+ if (!match) {
1239
+ throw new PresetsHistoryArgError(`--max-age must be a positive span like 7d, got "${raw}".`);
1240
+ }
1241
+ const unit = SINCE_UNITS_MS[match[2]];
1242
+ return Number(match[1]) * unit;
1243
+ }