nilometer 0.0.0-stage → 0.2.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.
Files changed (81) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/LICENSE +21 -0
  3. package/README.md +464 -2
  4. package/core/pricing/prices.json +348 -0
  5. package/core/schema/001_ingestion.sql +57 -0
  6. package/core/schema/002_line_count.sql +7 -0
  7. package/core/schema/003_derived.sql +70 -0
  8. package/core/schema/004_requests_dedup.sql +50 -0
  9. package/core/schema/005_repositories.sql +25 -0
  10. package/core/schema/006_status_readings.sql +33 -0
  11. package/core/schema/007_pricing.sql +95 -0
  12. package/core/schema/008_interruptions.sql +263 -0
  13. package/core/schema/009_headroom.sql +84 -0
  14. package/core/schema/010_attribution.sql +106 -0
  15. package/core/schema/011_projections.sql +83 -0
  16. package/core/schema/012_unsplit_cache_writes.sql +49 -0
  17. package/core/schema/013_repository_revalidation.sql +104 -0
  18. package/core/schema/014_observed_readings.sql +204 -0
  19. package/core/schema/015_response_observations.sql +127 -0
  20. package/core/schema/016_windows_repository_paths.sql +41 -0
  21. package/core/schema/017_unattributed_usage_once.sql +47 -0
  22. package/core/schema/018_request_time_index.sql +21 -0
  23. package/core/schema/019_dedup_earliest_of_equals.sql +61 -0
  24. package/core/schema/020_limit_quota_fields.sql +131 -0
  25. package/core/schema/021_plan_periods.sql +121 -0
  26. package/core/schema/022_attachment_walk.sql +223 -0
  27. package/core/schema/023_auto_continuations.sql +129 -0
  28. package/core/schema/024_counted_limit_groups.sql +29 -0
  29. package/core/schema/025_model_fallback.sql +77 -0
  30. package/core/schema/026_changed_readings.sql +106 -0
  31. package/core/schema/027_weekly_crossings.sql +32 -0
  32. package/core/schema/028_stale_readings.sql +196 -0
  33. package/core/schema/029_lockout_sessions.sql +68 -0
  34. package/core/schema/030_repository_aliases.sql +81 -0
  35. package/core/schema/031_cte_cursor_budget.sql +153 -0
  36. package/core/schema/032_stored_views.sql +233 -0
  37. package/dist/cli/main.cli.js +39 -0
  38. package/dist/cli/program.js +881 -0
  39. package/dist/core/db/database.js +182 -0
  40. package/dist/core/db/fingerprint.js +71 -0
  41. package/dist/core/ingest/attribution.js +131 -0
  42. package/dist/core/ingest/classify.js +382 -0
  43. package/dist/core/ingest/command.js +95 -0
  44. package/dist/core/ingest/derive.js +219 -0
  45. package/dist/core/ingest/discover.js +126 -0
  46. package/dist/core/ingest/ingest.js +160 -0
  47. package/dist/core/ingest/reader.js +89 -0
  48. package/dist/core/ingest/reset-time.js +97 -0
  49. package/dist/core/ingest/results.js +201 -0
  50. package/dist/core/ingest/spool.js +165 -0
  51. package/dist/core/ingest/store.js +99 -0
  52. package/dist/core/ingest/stored-views.js +65 -0
  53. package/dist/core/install/install.js +185 -0
  54. package/dist/core/install/locations.js +89 -0
  55. package/dist/core/install/private-files.js +259 -0
  56. package/dist/core/install/record.js +103 -0
  57. package/dist/core/plans/plan-prices.js +128 -0
  58. package/dist/core/plans/repository-aliases.js +136 -0
  59. package/dist/core/pricing/prices.js +147 -0
  60. package/dist/core/settings/settings-file.js +215 -0
  61. package/dist/core/settings/statusline.js +240 -0
  62. package/dist/core/util/commands.js +91 -0
  63. package/dist/core/util/node-version.js +58 -0
  64. package/dist/core/util/open-page.js +69 -0
  65. package/dist/core/util/paths.js +36 -0
  66. package/dist/core/verify/compare.js +77 -0
  67. package/dist/core/verify/verify.js +220 -0
  68. package/dist/core/view/server.js +142 -0
  69. package/dist/viewer/explain.js +574 -0
  70. package/dist/viewer/format.js +150 -0
  71. package/dist/viewer/html-charts.js +1292 -0
  72. package/dist/viewer/html-contract.js +2 -0
  73. package/dist/viewer/html-data.js +718 -0
  74. package/dist/viewer/html.js +1717 -0
  75. package/dist/viewer/queries.js +128 -0
  76. package/dist/viewer/render.js +602 -0
  77. package/dist/viewer/report.js +120 -0
  78. package/dist/viewer/save.js +110 -0
  79. package/docs/requirements.md +113 -0
  80. package/hooks/statusline.sh +104 -0
  81. package/package.json +61 -4
@@ -0,0 +1,718 @@
1
+ /**
2
+ * @file The HTML report's data layer (step G1.1, D-070): everything the page draws, read once
3
+ * from the same views the text report reads, with the raw line IDs behind every drawn element.
4
+ *
5
+ * Implements README, section MVP scope, as a picture, under the Measurement Principles: observed rows and
6
+ * projected rows are loaded by separate functions from separate views (principle 2), every element
7
+ * carries the raw line IDs of its events (principle 4), and every chart's span comes from the
8
+ * views' own coverage columns (principle 5). All selection, grouping, and arithmetic stays in SQL;
9
+ * this module only maps rows onto the contract's types and collects the events they reference.
10
+ * Numbers come back unrounded, so rounding happens only at display.
11
+ *
12
+ * Which events stand behind each element follows `viewer/explain.ts`, so the page and `nilometer
13
+ * explain` list the same events for the same number. Each view is read in one pass, with each
14
+ * element's events gathered by an ordered `json_group_array`, rather than one query per element:
15
+ * the views behind limit hits and readings are costly to evaluate, and a month of real data has
16
+ * hundreds of window instances. See D-023, D-024, D-045, D-067, D-068, D-070.
17
+ *
18
+ * Limit hits sit in gauge columns by time, not by reset (step G1.7): a hit belongs to the column of
19
+ * its own window kind whose span holds it, the rule D-023 merges hits by, so a hit whose logged
20
+ * reset is unknown or a second off the status line's still lands in its window. Every hit no
21
+ * column holds is counted beside the lockouts, so the columns and that count add up to the text
22
+ * report's total.
23
+ */
24
+ import { listAliases } from "../core/plans/repository-aliases.js";
25
+ import { lastIngestAt, } from "./queries.js";
26
+ /**
27
+ * The canonical raw line order (file, then first run, then line) from `001_ingestion.sql`, for a
28
+ * `raw_lines` row aliased `l`. Every {@link EventRef} is in this order, as the contract promises.
29
+ */
30
+ const CANONICAL = "l.source_file_id, l.first_run_id, l.line_number";
31
+ /**
32
+ * Two CTEs, for the head of a `WITH`: `columns`, the gauge's columns, and `placed`, every row of
33
+ * `obs_limit_hits_events` with the reset of the column that holds it (`column_reset_at_utc`, null
34
+ * when none does). Shared text, so the columns' hits and the count of hits in no column come from
35
+ * one rule and always add up to `obs_limit_hits.hits`.
36
+ *
37
+ * The rule is the contract's (GaugeWindow.limitHits): same window kind, and hit time in
38
+ * (reset minus the window's length, reset], compared in epoch seconds as `obs_limit_hits_events`
39
+ * compares them when it merges hits (D-023). The hit's own `reset_at_utc` plays no part.
40
+ */
41
+ const PLACED_HITS = `columns AS MATERIALIZED (
42
+ -- The gauge's columns: every window instance with a counted reading (D-024), the rows
43
+ -- obs_window_headroom has with readings > 0. Read from window_readings, whose counted rows
44
+ -- window_instances groups into those instances, because the headroom view's other columns
45
+ -- cost seconds on a month of real data and nothing here needs them.
46
+ SELECT r.window, r.reset_at_utc, r.resets_at AS reset_s,
47
+ CASE r.window WHEN 'five_hour' THEN 5 * 3600 ELSE 7 * 86400 END AS window_seconds
48
+ FROM window_readings r
49
+ WHERE r.after_reset = 0
50
+ GROUP BY r.window, r.resets_at
51
+ ),
52
+ placed AS MATERIALIZED (
53
+ -- Materialized because the events view is costly and several aggregates read this.
54
+ SELECT e.raw_line_id, e.window, e.source, e.hit_at_utc, e.reset_at_utc,
55
+ -- An unknown window or hit time compares as NULL, so such a hit is in no column. Where
56
+ -- jittered resets make two same-kind spans overlap, the earliest reset takes the hit, as
57
+ -- obs_limit_hits_events picks the earliest status line group, so no hit counts twice.
58
+ (SELECT c.reset_at_utc FROM columns c
59
+ WHERE c.window = e.window
60
+ AND unixepoch(e.hit_at_utc, 'subsec') > c.reset_s - c.window_seconds
61
+ AND unixepoch(e.hit_at_utc, 'subsec') <= c.reset_s
62
+ ORDER BY c.reset_s
63
+ LIMIT 1) AS column_reset_at_utc
64
+ FROM obs_limit_hits_events e
65
+ )`;
66
+ /**
67
+ * Parses a JSON array of raw line IDs built by `json_group_array`.
68
+ * @param json - The array's text; `[]` when the group is empty.
69
+ * @returns The IDs, in the array's order.
70
+ */
71
+ function parseIds(json) {
72
+ // Integers below 2^53 round-trip through JSON exactly, so no ID changes on the way.
73
+ return JSON.parse(json);
74
+ }
75
+ /**
76
+ * Wraps a single optional raw line ID as an {@link EventRef}.
77
+ * @param id - A raw line ID, or null when the value has no event behind it.
78
+ * @returns A one-element list, or an empty one for null.
79
+ */
80
+ function single(id) {
81
+ return id === null ? [] : [id];
82
+ }
83
+ /**
84
+ * Loads how far back each source reaches.
85
+ * @param db - Open, migrated database.
86
+ * @returns One row per source, `session_logs` then `status_line`; spans are null for a source with no data.
87
+ */
88
+ function loadCoverage(db) {
89
+ return db
90
+ .prepare("SELECT source, covers_from, covers_to FROM source_coverage ORDER BY source")
91
+ .all().map((row) => ({ source: row.source, span: { from: row.covers_from, to: row.covers_to } }));
92
+ }
93
+ /**
94
+ * Loads every window instance with at least one counted reading, with its readings and limit hits.
95
+ * @param db - Open, migrated database.
96
+ * @param at - The page's time in Unix seconds: a window is open only until its reset, as of it (D-083).
97
+ * @returns One row per window instance, oldest reset first; empty with no counted reading.
98
+ * @see D-024 for what a window instance is and which readings count, D-045 for observed times,
99
+ * {@link PLACED_HITS} for which hits a window holds.
100
+ */
101
+ function loadWindows(db, at) {
102
+ return db
103
+ .prepare(`WITH ${PLACED_HITS},
104
+ hits AS (
105
+ -- Each column's hits by the shared rule. A placed hit always has a time, so every hit in
106
+ -- the gauge also sits on its window's line over time.
107
+ SELECT x.window, x.column_reset_at_utc AS reset_at_utc,
108
+ COUNT(*) AS limit_hits,
109
+ json_group_array(x.raw_line_id ORDER BY ${CANONICAL}) AS hit_ids,
110
+ json_group_array(json_array(x.hit_at_utc, x.raw_line_id) ORDER BY x.hit_at_utc, x.raw_line_id)
111
+ AS series_hits
112
+ FROM placed x
113
+ JOIN raw_lines l ON l.id = x.raw_line_id
114
+ WHERE x.column_reset_at_utc IS NOT NULL
115
+ GROUP BY x.window, x.column_reset_at_utc
116
+ ),
117
+ points AS (
118
+ -- The readings window_instances counts (after_reset = 0), in the order the reading-pair
119
+ -- view walks them, so a series has exactly the window's readings count of points (D-024).
120
+ SELECT r.window, r.reset_at_utc,
121
+ json_group_array(json_array(r.observed_at_utc, r.used_percentage, r.raw_line_id, r.stale)
122
+ ORDER BY r.observed_at_s, r.raw_line_id) AS points
123
+ FROM window_readings r
124
+ WHERE r.after_reset = 0
125
+ GROUP BY r.window, r.resets_at
126
+ )
127
+ -- Open only until its reset, as of the page's time (D-083), as the text report reads it.
128
+ SELECT h.window, h.reset_at_utc,
129
+ (h.window_open = 1 AND unixepoch(h.reset_at_utc, 'subsec') > :at) AS window_open,
130
+ p.peak_used_percentage, p.peak_reading_at_utc, p.peak_reading_raw_line_id,
131
+ h.last_used_percentage, h.last_reading_at_utc, h.last_reading_raw_line_id,
132
+ h.readings,
133
+ COALESCE(x.limit_hits, 0) AS limit_hits,
134
+ COALESCE(x.hit_ids, '[]') AS hit_ids,
135
+ COALESCE(x.series_hits, '[]') AS series_hits,
136
+ COALESCE(r.points, '[]') AS points
137
+ FROM obs_window_headroom h
138
+ -- The same join loadObserved uses, so every number here equals the text report's.
139
+ JOIN obs_window_peak p ON p.window = h.window AND p.reset_at_utc = h.reset_at_utc
140
+ LEFT JOIN hits x ON x.window = h.window AND x.reset_at_utc = h.reset_at_utc
141
+ LEFT JOIN points r ON r.window = h.window AND r.reset_at_utc = h.reset_at_utc
142
+ -- A window whose every reading came after its reset has nothing to draw (D-024).
143
+ WHERE h.readings > 0
144
+ ORDER BY h.reset_at_utc, h.window`)
145
+ .all({ at });
146
+ }
147
+ /**
148
+ * Maps a window instance onto its gauge column.
149
+ * @param row - From {@link loadWindows}.
150
+ * @returns The column; `peak` may exceed 100 (D-068).
151
+ */
152
+ function toGauge(row) {
153
+ return {
154
+ window: row.window,
155
+ resetAtUtc: row.reset_at_utc,
156
+ // NULL can't occur with a counted reading, but it would mean "not known open", so it reads false.
157
+ open: row.window_open === 1,
158
+ peak: row.peak_used_percentage,
159
+ peakAtUtc: row.peak_reading_at_utc,
160
+ last: row.last_used_percentage,
161
+ lastAtUtc: row.last_reading_at_utc,
162
+ readings: row.readings,
163
+ limitHits: row.limit_hits,
164
+ peakEvents: single(row.peak_reading_raw_line_id),
165
+ lastEvents: single(row.last_reading_raw_line_id),
166
+ hitEvents: parseIds(row.hit_ids),
167
+ };
168
+ }
169
+ /**
170
+ * Maps a window instance onto its usage-over-time series.
171
+ * @param row - From {@link loadWindows}.
172
+ * @returns The series: one point per counted reading, dated when observed (D-045), and its hits.
173
+ */
174
+ function toSeries(row) {
175
+ // Real values round-trip through SQLite's JSON exactly (17 significant digits), so each point
176
+ // keeps the recorded used_percentage unrounded, including values above 100 (D-068).
177
+ const points = JSON.parse(row.points).map(([atUtc, used, event, stale]) => ({ atUtc, used, event, stale: stale === 1 }));
178
+ const hits = JSON.parse(row.series_hits).map(([atUtc, event]) => ({ atUtc, event }));
179
+ return { window: row.window, resetAtUtc: row.reset_at_utc, points, hits };
180
+ }
181
+ /**
182
+ * Maps a plan period's columns onto the contract's reference, with its last month as the view
183
+ * computes it for the per-plan table (the month before `until_month`).
184
+ * @param row - The period's columns.
185
+ * @returns The reference.
186
+ */
187
+ function periodRef(row) {
188
+ return {
189
+ kind: row.period_kind,
190
+ fromMonth: row.from_month,
191
+ toMonth: row.to_month ?? null,
192
+ untilMonth: row.until_month,
193
+ planName: row.plan_name,
194
+ };
195
+ }
196
+ /**
197
+ * Loads the lockout intervals, each with the hits merged into it.
198
+ * @param db - Open, migrated database.
199
+ * @param withPeriods - Whether to tag each interval with its plan period (D-075): only once a plan
200
+ * has been entered, as the text report only then prints the split.
201
+ * @returns Intervals in time order; empty with no hit whose reset is known.
202
+ * @see D-023 for the interval (hit to reset, overlaps merged) and reset to next request.
203
+ */
204
+ function loadLockouts(db, withPeriods = false) {
205
+ const rows = db
206
+ .prepare(`WITH members AS (
207
+ SELECT h.interval_number, json_group_array(h.raw_line_id ORDER BY ${CANONICAL}) AS hit_ids
208
+ FROM obs_lockout_interval_hits h
209
+ JOIN raw_lines l ON l.id = h.raw_line_id
210
+ GROUP BY h.interval_number
211
+ )
212
+ SELECT i.interval_number, i.locked_from_utc, i.locked_until_utc, i.hits, i.refused_requests,
213
+ i.sessions, i.lockout_seconds,
214
+ i.auto_continuations, i.first_auto_continuation_seconds,
215
+ -- The markers obs_lockout_intervals counts, so the list re-adds to auto_continuations.
216
+ COALESCE((SELECT json_group_array(a.raw_line_id ORDER BY ${CANONICAL})
217
+ FROM obs_auto_continuation_events a
218
+ JOIN raw_lines l ON l.id = a.raw_line_id
219
+ WHERE a.interval_number = i.interval_number), '[]') AS auto_continuation_ids,
220
+ i.reset_to_next_request_seconds, i.reset_after_coverage,
221
+ COALESCE(m.hit_ids, '[]') AS hit_ids,
222
+ -- The request behind the view's next_request_at_utc: matching that value, rather than
223
+ -- restating its rule, keeps the event tied to the number shown. Requests sharing that
224
+ -- instant are equally behind it, so the first in canonical order stands for them.
225
+ (SELECT d.raw_line_id FROM requests_dedup d
226
+ JOIN raw_lines l ON l.id = d.raw_line_id
227
+ WHERE d.timestamp_utc = i.next_request_at_utc
228
+ ORDER BY ${CANONICAL}
229
+ LIMIT 1) AS next_request_raw_line_id
230
+ FROM obs_lockout_intervals i
231
+ LEFT JOIN members m ON m.interval_number = i.interval_number
232
+ -- Intervals are numbered in start order, as loadObserved reads them.
233
+ ORDER BY i.interval_number`)
234
+ .all();
235
+ const periods = new Map((withPeriods
236
+ ? db
237
+ .prepare(
238
+ // to_month as obs_interruptions_by_plan computes it, so a lane names the same months.
239
+ `SELECT interval_number, period_kind, from_month, until_month, plan_name,
240
+ CASE WHEN until_month IS NOT NULL
241
+ THEN strftime('%Y-%m', until_month || '-01', '-1 month') END AS to_month
242
+ FROM obs_lockout_by_plan_events`)
243
+ .all()
244
+ : []).map((row) => [row.interval_number, periodRef(row)]));
245
+ return rows.map((row) => ({
246
+ ...(periods.has(row.interval_number)
247
+ ? { period: periods.get(row.interval_number) }
248
+ : {}),
249
+ lockedFromUtc: row.locked_from_utc,
250
+ lockedUntilUtc: row.locked_until_utc,
251
+ seconds: row.lockout_seconds,
252
+ hits: row.hits,
253
+ refusedRequests: row.refused_requests,
254
+ sessions: row.sessions,
255
+ resetToNextRequestSeconds: row.reset_to_next_request_seconds,
256
+ // NULL means there are no session logs to compare with; nothing says the reset came after
257
+ // them, so it reads false and the null reset-to-next-request stands on its own.
258
+ resetAfterCoverage: row.reset_after_coverage === 1,
259
+ events: parseIds(row.hit_ids),
260
+ nextRequestEvents: single(row.next_request_raw_line_id),
261
+ autoContinuations: row.auto_continuations,
262
+ firstAutoContinuationSeconds: row.first_auto_continuation_seconds,
263
+ autoContinuationEvents: parseIds(row.auto_continuation_ids),
264
+ }));
265
+ }
266
+ /**
267
+ * Loads the interruption counts the text report prints beside the lockouts, with their hits.
268
+ * @param db - Open, migrated database.
269
+ * @param totals - The text report's `obs_limit_hits` and `obs_lockout_time` rows, when the caller
270
+ * already read them in the same transaction; read here otherwise. Both views are among the
271
+ * costliest on real data, so reading them once matters.
272
+ * @returns The totals, and the hits behind each count, including those no gauge column holds.
273
+ * @see D-023 for merging and unknown resets, {@link PLACED_HITS} for which hits a column holds.
274
+ */
275
+ function loadInterruptions(db, totals) {
276
+ const { limitHits, lockout, interruptionsByPlan } = totals ?? {
277
+ limitHits: db.prepare("SELECT * FROM obs_limit_hits").get(),
278
+ lockout: db.prepare("SELECT * FROM obs_lockout_time").get(),
279
+ interruptionsByPlan: db
280
+ .prepare(`SELECT * FROM obs_interruptions_by_plan
281
+ ORDER BY CASE period_kind WHEN 'none' THEN 0 WHEN 'plan' THEN 1 ELSE 2 END, from_month`)
282
+ .all(),
283
+ };
284
+ const row = db
285
+ .prepare(`WITH ${PLACED_HITS},
286
+ lists AS (
287
+ -- placed is obs_limit_hits_events row for row, so these lists are the view's hits.
288
+ SELECT
289
+ json_group_array(p.raw_line_id ORDER BY ${CANONICAL}) AS hit_ids,
290
+ -- The same test obs_lockout_time counts hits_with_unknown_reset by.
291
+ json_group_array(p.raw_line_id ORDER BY ${CANONICAL})
292
+ FILTER (WHERE p.reset_at_utc IS NULL OR p.hit_at_utc IS NULL) AS unknown_reset_ids,
293
+ COUNT(*) FILTER (WHERE p.column_reset_at_utc IS NULL) AS hits_in_no_column,
294
+ json_group_array(p.raw_line_id ORDER BY ${CANONICAL})
295
+ FILTER (WHERE p.column_reset_at_utc IS NULL) AS no_column_ids,
296
+ -- The same FILTERs obs_limit_hits counts its splits by, so each split number and its
297
+ -- list come from one rule (principle 4).
298
+ json_group_array(p.raw_line_id ORDER BY ${CANONICAL})
299
+ FILTER (WHERE p.window = 'five_hour') AS five_hour_ids,
300
+ json_group_array(p.raw_line_id ORDER BY ${CANONICAL})
301
+ FILTER (WHERE p.window = 'seven_day') AS seven_day_ids,
302
+ json_group_array(p.raw_line_id ORDER BY ${CANONICAL})
303
+ FILTER (WHERE p.window IS NULL) AS unknown_window_ids,
304
+ json_group_array(p.raw_line_id ORDER BY ${CANONICAL})
305
+ FILTER (WHERE p.source = 'session_log') AS logged_ids,
306
+ json_group_array(p.raw_line_id ORDER BY ${CANONICAL})
307
+ FILTER (WHERE p.source = 'status_line') AS status_line_only_ids
308
+ FROM placed p
309
+ JOIN raw_lines l ON l.id = p.raw_line_id
310
+ )
311
+ -- Ungrouped, lists always has one row, and json_group_array gives [] over no rows.
312
+ SELECT * FROM lists`)
313
+ .get();
314
+ return {
315
+ hits: limitHits.hits,
316
+ fiveHourHits: limitHits.five_hour_hits,
317
+ sevenDayHits: limitHits.seven_day_hits,
318
+ unknownWindowHits: limitHits.unknown_window_hits,
319
+ loggedHits: limitHits.logged_hits,
320
+ statusLineOnlyHits: limitHits.status_line_only_hits,
321
+ lockoutSeconds: lockout.lockout_seconds,
322
+ lockoutIntervals: lockout.intervals,
323
+ hitsWithUnknownReset: lockout.hits_with_unknown_reset,
324
+ unknownResetEvents: parseIds(row.unknown_reset_ids),
325
+ hitsInNoColumn: row.hits_in_no_column,
326
+ hitsInNoColumnEvents: parseIds(row.no_column_ids),
327
+ events: parseIds(row.hit_ids),
328
+ fiveHourEvents: parseIds(row.five_hour_ids),
329
+ sevenDayEvents: parseIds(row.seven_day_ids),
330
+ unknownWindowEvents: parseIds(row.unknown_window_ids),
331
+ loggedEvents: parseIds(row.logged_ids),
332
+ statusLineOnlyEvents: parseIds(row.status_line_only_ids),
333
+ // obs_limit_hits carries the session logs' span from source_coverage, the span the text
334
+ // report prints beside these counts (principle 5).
335
+ span: { from: limitHits.covers_from, to: limitHits.covers_to },
336
+ byPlan: loadByPlan(db, interruptionsByPlan),
337
+ };
338
+ }
339
+ /**
340
+ * Loads the per-plan interruptions (D-075), with the events behind each count, only once a plan
341
+ * has been entered: the text report prints the split only then.
342
+ * @param db - Open, migrated database.
343
+ * @param rows - `obs_interruptions_by_plan`, as the text report read it.
344
+ * @returns One entry per row, in the rows' order; empty with no plan entered.
345
+ */
346
+ function loadByPlan(db, rows) {
347
+ if (!rows.some((row) => row.period_kind === "plan")) {
348
+ return [];
349
+ }
350
+ // Grouped by the same two columns obs_interruptions_by_plan counts by, so each list re-adds.
351
+ const hitLists = db
352
+ .prepare(`SELECT b.period_kind, b.from_month, json_group_array(b.raw_line_id ORDER BY ${CANONICAL}) AS ids
353
+ FROM obs_limit_hits_by_plan_events b
354
+ JOIN raw_lines l ON l.id = b.raw_line_id
355
+ GROUP BY b.period_kind, b.from_month`)
356
+ .all();
357
+ const lockoutLists = db
358
+ .prepare(`SELECT i.period_kind, i.from_month, json_group_array(h.raw_line_id ORDER BY ${CANONICAL}) AS ids
359
+ FROM obs_lockout_by_plan_events i
360
+ JOIN obs_lockout_interval_hits h ON h.interval_number = i.interval_number
361
+ JOIN raw_lines l ON l.id = h.raw_line_id
362
+ GROUP BY i.period_kind, i.from_month`)
363
+ .all();
364
+ /**
365
+ * Finds a period's list.
366
+ * @param lists - Lists keyed by kind and first month.
367
+ * @param row - The period.
368
+ * @returns Its IDs; empty when it has none.
369
+ */
370
+ const listOf = (lists, row) => parseIds(lists.find((x) => x.period_kind === row.period_kind && x.from_month === row.from_month)
371
+ ?.ids ?? "[]");
372
+ return rows.map((row) => ({
373
+ period: periodRef(row),
374
+ hits: row.hits,
375
+ lockouts: row.lockouts,
376
+ lockoutSeconds: row.lockout_seconds,
377
+ span: { from: row.covers_from, to: row.covers_to },
378
+ hitEvents: listOf(hitLists, row),
379
+ lockoutEvents: listOf(lockoutLists, row),
380
+ }));
381
+ }
382
+ /**
383
+ * Loads the weekly windows' first readings at or above 75% and 90% (D-087), reusing the text
384
+ * report's rows when it has read them from the same snapshot.
385
+ * @param db - Open, migrated database.
386
+ * @param observed - What the text report read, if it did.
387
+ * @returns One entry per window and level, newest window first.
388
+ */
389
+ function loadCrossings(db, observed) {
390
+ // The same view and order as loadObserved, so the page and the text list the same rows.
391
+ const rows = observed?.weeklyCrossings ??
392
+ db
393
+ .prepare("SELECT * FROM obs_weekly_crossings ORDER BY reset_at_utc DESC, threshold")
394
+ .all();
395
+ return rows.map((r) => ({
396
+ resetAtUtc: r.reset_at_utc,
397
+ threshold: r.threshold,
398
+ firstAtUtc: r.first_at_utc,
399
+ usedPercentage: r.used_percentage,
400
+ secondsBeforeReset: r.seconds_before_reset,
401
+ events: [r.raw_line_id],
402
+ }));
403
+ }
404
+ /**
405
+ * Loads the readings captured after their window's reset, which no gauge column counts.
406
+ * @param db - Open, migrated database.
407
+ * @returns How many, and which.
408
+ * @see D-024 for why a reading after its reset belongs to no window.
409
+ */
410
+ function loadAfterReset(db) {
411
+ const row = db
412
+ .prepare(
413
+ // window_instances counts readings_after_reset as these rows grouped by window instance, so
414
+ // counting them ungrouped gives the sum the text report prints, without the headroom view.
415
+ `SELECT COUNT(*) AS count,
416
+ json_group_array(r.raw_line_id ORDER BY ${CANONICAL}) AS ids
417
+ FROM window_readings r
418
+ JOIN raw_lines l ON l.id = r.raw_line_id
419
+ WHERE r.after_reset = 1`)
420
+ .get();
421
+ return { count: row.count, events: parseIds(row.ids) };
422
+ }
423
+ /**
424
+ * Loads output tokens by model, each with its deduplicated requests.
425
+ * @param db - Open, migrated database.
426
+ * @returns Models by output tokens, largest first, ties by name as the text report orders them.
427
+ * @see D-001 for deduplication, D-007 for per-request attribution.
428
+ */
429
+ function loadModels(db) {
430
+ const rows = db
431
+ .prepare(`WITH members AS (
432
+ -- obs_usage_events is the set obs_usage_by_model counts, so the IDs re-add to its numbers.
433
+ SELECT e.model, json_group_array(e.raw_line_id ORDER BY ${CANONICAL}) AS request_ids
434
+ FROM obs_usage_events e
435
+ JOIN raw_lines l ON l.id = e.raw_line_id
436
+ GROUP BY e.model
437
+ )
438
+ SELECT m.model, m.requests, m.output_tokens, COALESCE(x.request_ids, '[]') AS request_ids
439
+ FROM obs_usage_by_model m
440
+ LEFT JOIN members x ON x.model = m.model
441
+ ORDER BY m.output_tokens DESC, m.model`)
442
+ .all();
443
+ return rows.map((row) => ({
444
+ model: row.model,
445
+ requests: row.requests,
446
+ outputTokens: row.output_tokens,
447
+ events: parseIds(row.request_ids),
448
+ }));
449
+ }
450
+ /**
451
+ * Loads output tokens by repository, each with its deduplicated requests (step G2.1).
452
+ * @param db - Open, migrated database.
453
+ * @returns Repositories by output tokens, largest first, ties by name, as the text report orders them.
454
+ * @see D-010 and D-028 for attribution, D-001 for deduplication.
455
+ */
456
+ function loadRepos(db) {
457
+ const rows = db
458
+ .prepare(`WITH members AS (
459
+ -- obs_usage_events is the set obs_usage_by_repo counts, grouped the same way, so each
460
+ -- list's IDs re-add to its row. explain by-repo matches on the same two columns.
461
+ SELECT e.repository, e.repo_kind,
462
+ json_group_array(e.raw_line_id ORDER BY ${CANONICAL}) AS request_ids
463
+ FROM obs_usage_events e
464
+ JOIN raw_lines l ON l.id = e.raw_line_id
465
+ GROUP BY e.repository, e.repo_kind
466
+ )
467
+ SELECT r.repository, r.repo_kind, r.requests, r.output_tokens,
468
+ COALESCE(x.request_ids, '[]') AS request_ids
469
+ FROM obs_usage_by_repo r
470
+ -- IS, not =: a request with no cwd has a NULL repository, and its group must still match.
471
+ LEFT JOIN members x ON x.repository IS r.repository AND x.repo_kind = r.repo_kind
472
+ ORDER BY r.output_tokens DESC, r.repository`)
473
+ .all();
474
+ return rows.map((row) => ({
475
+ repository: row.repository,
476
+ kind: row.repo_kind,
477
+ requests: row.requests,
478
+ outputTokens: row.output_tokens,
479
+ events: parseIds(row.request_ids),
480
+ }));
481
+ }
482
+ /**
483
+ * Loads the burn rate of every open window, a projection, each with the reading it starts from
484
+ * (step G2.1). Read by its own function from its own view, never mixed into observed rows
485
+ * (principle 2).
486
+ * @param db - Open, migrated database.
487
+ * @param at - The page's time in Unix seconds: only windows still open at it are projected (D-083).
488
+ * @returns Open windows in the text report's order: latest reset first, then by window.
489
+ * @see D-026 for the premise, D-024 for what an open window is.
490
+ */
491
+ function loadBurn(db, at) {
492
+ const rows = db
493
+ .prepare(
494
+ // The same join explain burn-rate uses for the reading behind each projection, and the same
495
+ // filter renderProjected prints by (window_open = 1), so the page and the text report agree.
496
+ `SELECT b.window, b.reset_at_utc, b.window_start_utc, b.last_used_percentage,
497
+ b.last_reading_at_utc, b.limit_reached, b.projected_percentage_points_per_hour,
498
+ b.projected_limit_at_utc, b.projected_limit_before_reset, b.covers_from, b.covers_to,
499
+ h.last_reading_raw_line_id
500
+ FROM proj_burn_rate b
501
+ JOIN obs_window_headroom h ON h.window = b.window AND h.reset_at_utc = b.reset_at_utc
502
+ -- Open at the page's time too: a window that has reset has nothing to project (D-083).
503
+ WHERE b.window_open = 1 AND unixepoch(b.reset_at_utc, 'subsec') > :at
504
+ ORDER BY b.reset_at_utc DESC, b.window`)
505
+ .all({ at });
506
+ return rows.map((row) => ({
507
+ window: row.window,
508
+ resetAtUtc: row.reset_at_utc,
509
+ windowStartUtc: row.window_start_utc,
510
+ last: row.last_used_percentage,
511
+ lastAtUtc: row.last_reading_at_utc,
512
+ // NULL means no reading to judge by; nothing says the limit was reached, so it reads false.
513
+ limitReached: row.limit_reached === 1,
514
+ ratePerHour: row.projected_percentage_points_per_hour,
515
+ projectedLimitAtUtc: row.projected_limit_at_utc,
516
+ limitBeforeReset: row.projected_limit_before_reset === 1,
517
+ span: { from: row.covers_from, to: row.covers_to },
518
+ events: single(row.last_reading_raw_line_id),
519
+ }));
520
+ }
521
+ /**
522
+ * Loads observed tokens at API list price by month, a projection, each with its priced requests.
523
+ * @param db - Open, migrated database.
524
+ * @returns Months in order; empty with no request that has a timestamp.
525
+ * @see D-005 (unpriced is never $0), D-007 (local months), D-027 (repricing is a labeled projection).
526
+ */
527
+ function loadMonths(db) {
528
+ const rows = db
529
+ .prepare(`WITH members AS (
530
+ -- The same month expression and filters as proj_api_list_price, split by the same
531
+ -- unpriced_reason test as its counts, so each month's lists have exactly priced_requests
532
+ -- and unpriced_requests IDs, and the priced list re-adds to its price.
533
+ SELECT strftime('%Y-%m', e.timestamp_utc, 'localtime') AS month,
534
+ json_group_array(e.raw_line_id ORDER BY ${CANONICAL})
535
+ FILTER (WHERE e.unpriced_reason IS NULL) AS request_ids,
536
+ json_group_array(e.raw_line_id ORDER BY ${CANONICAL})
537
+ FILTER (WHERE e.unpriced_reason IS NOT NULL) AS unpriced_ids
538
+ FROM request_costs e
539
+ JOIN raw_lines l ON l.id = e.raw_line_id
540
+ WHERE e.timestamp_utc IS NOT NULL
541
+ GROUP BY month
542
+ )
543
+ SELECT p.month, p.api_list_price_usd, p.plan_name, p.plan_usd_per_month,
544
+ p.priced_requests, p.unpriced_requests, p.covers_from, p.covers_to,
545
+ COALESCE(x.request_ids, '[]') AS request_ids,
546
+ COALESCE(x.unpriced_ids, '[]') AS unpriced_ids
547
+ FROM proj_api_list_price p
548
+ -- Both group the same rows by the same expression, so every month finds its lists; the
549
+ -- outer join and COALESCE only keep a month's row should that ever stop holding.
550
+ LEFT JOIN members x ON x.month = p.month
551
+ ORDER BY p.month`)
552
+ .all();
553
+ return rows.map((row) => ({
554
+ month: row.month,
555
+ apiListPriceUsd: row.api_list_price_usd,
556
+ planName: row.plan_name,
557
+ planUsdPerMonth: row.plan_usd_per_month,
558
+ pricedRequests: row.priced_requests,
559
+ unpricedRequests: row.unpriced_requests,
560
+ unpricedEvents: parseIds(row.unpriced_ids),
561
+ span: { from: row.covers_from, to: row.covers_to },
562
+ events: parseIds(row.request_ids),
563
+ }));
564
+ }
565
+ /**
566
+ * Looks up the source file, line, and time of every given raw line.
567
+ * @param db - Open, migrated database.
568
+ * @param ids - Raw line IDs, duplicates allowed.
569
+ * @returns The files, each path stored once, and each event pointing at its file by index.
570
+ */
571
+ function loadEvents(db, ids) {
572
+ const rows = db
573
+ .prepare(`SELECT l.id, f.relative_path, l.line_number,
574
+ -- A log line's time is its parsed timestamp. A spool line has no parsed line, so its time
575
+ -- is the capture: where the reading was recorded, while the point on the chart is dated by
576
+ -- the response it reports (D-045).
577
+ CASE f.kind
578
+ WHEN 'spool' THEN strftime('%Y-%m-%dT%H:%M:%fZ', s.captured_at_s, 'unixepoch')
579
+ ELSE p.timestamp_utc
580
+ END AS at_utc
581
+ FROM raw_lines l
582
+ JOIN source_files f ON f.id = l.source_file_id
583
+ LEFT JOIN parsed_lines p ON p.raw_line_id = l.id
584
+ LEFT JOIN status_readings s ON s.raw_line_id = l.id
585
+ -- One JSON parameter carries any number of IDs, where a placeholder list would hit SQLite's limit.
586
+ WHERE l.id IN (SELECT value FROM json_each(?))
587
+ ORDER BY ${CANONICAL}`)
588
+ .all(JSON.stringify(ids));
589
+ const files = [];
590
+ const fileIndex = new Map();
591
+ const events = {};
592
+ for (const row of rows) {
593
+ // relative_path, never root: the file must not carry machine-specific absolute paths (CLAUDE.md).
594
+ let file = fileIndex.get(row.relative_path);
595
+ if (file === undefined) {
596
+ file = files.length;
597
+ files.push(row.relative_path);
598
+ fileIndex.set(row.relative_path, file);
599
+ }
600
+ events[row.id] = { file, line: row.line_number, atUtc: row.at_utc };
601
+ }
602
+ return { files, events };
603
+ }
604
+ /**
605
+ * Loads the notes under the monthly amounts with the requests behind each count: the rate-reading
606
+ * counts by the text report's own rule (`loadProjected`'s rateReadings query) and the lower-bound
607
+ * count by `proj_api_list_price`'s (`is_lower_bound = 1` over dated requests).
608
+ * @param db - Open, migrated database.
609
+ * @returns The notes; empty lists and 0 when no request qualifies.
610
+ * @see D-020 for rates verified after a request's date, D-005 for lower-bound pricing.
611
+ */
612
+ function loadPriceNotes(db) {
613
+ const readings = db
614
+ .prepare(`SELECT c.verified_on, COUNT(*) AS requests,
615
+ json_group_array(c.raw_line_id ORDER BY ${CANONICAL}) AS ids
616
+ FROM request_costs c
617
+ JOIN raw_lines l ON l.id = c.raw_line_id
618
+ WHERE c.timestamp_utc IS NOT NULL AND c.unpriced_reason IS NULL AND c.day_utc < c.verified_on
619
+ GROUP BY c.verified_on
620
+ ORDER BY c.verified_on`)
621
+ .all();
622
+ const lower = db
623
+ .prepare(
624
+ // Ungrouped, this always returns one row, with [] for no rows.
625
+ `SELECT COUNT(*) AS requests, json_group_array(c.raw_line_id ORDER BY ${CANONICAL}) AS ids
626
+ FROM request_costs c
627
+ JOIN raw_lines l ON l.id = c.raw_line_id
628
+ WHERE c.timestamp_utc IS NOT NULL AND c.is_lower_bound = 1`)
629
+ .get();
630
+ return {
631
+ rateReadings: readings.map((row) => ({
632
+ verifiedOn: row.verified_on,
633
+ pricedBeforeVerifiedRequests: row.requests,
634
+ events: parseIds(row.ids),
635
+ })),
636
+ lowerBoundRequests: lower.requests,
637
+ lowerBoundEvents: parseIds(lower.ids),
638
+ };
639
+ }
640
+ /**
641
+ * Loads everything the HTML report draws, from the same views as the text report.
642
+ * @param db - Open, migrated database.
643
+ * @param meta - The display zone and the production time.
644
+ * @param loaded - What the text report already read from this database in the same transaction;
645
+ * its interruption totals are reused instead of computed again. Omitted, they're read here, with
646
+ * the same result.
647
+ * @returns The report's data: observed charts, projected months, and every event they refer to.
648
+ * @see D-070.
649
+ */
650
+ export const loadHtmlData = (db, meta, loaded) => {
651
+ // The page's own time, in Unix seconds: "open" is as of it (D-083).
652
+ const at = meta.generatedAt.getTime() / 1000;
653
+ const windows = loadWindows(db, at);
654
+ const gauge = windows.map(toGauge);
655
+ const series = windows.map(toSeries);
656
+ const interruptions = loadInterruptions(db, loaded?.observed);
657
+ // Lockouts carry their plan period only once a plan has been entered, as the split does.
658
+ const lockouts = loadLockouts(db, interruptions.byPlan.length > 0);
659
+ const afterReset = loadAfterReset(db);
660
+ const crossings = loadCrossings(db, loaded?.observed);
661
+ const models = loadModels(db);
662
+ const repos = loadRepos(db);
663
+ // Projections are read by their own function from their own view, never mixed into the rows above.
664
+ const burn = loadBurn(db, at);
665
+ const months = loadMonths(db);
666
+ const priceNotes = loadPriceNotes(db);
667
+ const referenced = [
668
+ ...gauge.flatMap((g) => [...g.peakEvents, ...g.lastEvents, ...g.hitEvents]),
669
+ ...series.flatMap((s) => [...s.points.map((p) => p.event), ...s.hits.map((h) => h.event)]),
670
+ ...lockouts.flatMap((bar) => [
671
+ ...bar.events,
672
+ ...bar.nextRequestEvents,
673
+ ...bar.autoContinuationEvents,
674
+ ]),
675
+ // interruptions.events already holds every hit; the two sublists are listed anyway, so the
676
+ // table can't miss one should either list ever reach beyond it.
677
+ ...interruptions.events,
678
+ ...interruptions.unknownResetEvents,
679
+ ...interruptions.hitsInNoColumnEvents,
680
+ ...interruptions.byPlan.flatMap((p) => [...p.hitEvents, ...p.lockoutEvents]),
681
+ ...afterReset.events,
682
+ ...crossings.flatMap((c) => c.events),
683
+ ...models.flatMap((bar) => bar.events),
684
+ ...repos.flatMap((bar) => bar.events),
685
+ ...burn.flatMap((window) => window.events),
686
+ ...months.flatMap((bar) => [...bar.events, ...bar.unpricedEvents]),
687
+ ...priceNotes.rateReadings.flatMap((reading) => reading.events),
688
+ ...priceNotes.lowerBoundEvents,
689
+ ];
690
+ const { files, events } = loadEvents(db, [...new Set(referenced)]);
691
+ return {
692
+ timeZone: meta.timeZone,
693
+ lastIngestAt: lastIngestAt(db),
694
+ generatedAtUtc: meta.generatedAt.toISOString(),
695
+ coverage: loadCoverage(db),
696
+ gauge,
697
+ series,
698
+ lockouts,
699
+ interruptions,
700
+ afterReset,
701
+ crossings,
702
+ models,
703
+ repos,
704
+ home: meta.home ?? "",
705
+ // The user's own links, as the text report lists them (D-092).
706
+ repositoryAliases: (loaded?.observed.repositoryAliases ?? listAliases(db)).map((a) => ({
707
+ from: a.from,
708
+ to: a.to,
709
+ })),
710
+ missingHookPath: meta.missingHookPath ?? null,
711
+ burn,
712
+ months,
713
+ priceNotes,
714
+ files,
715
+ events,
716
+ };
717
+ };
718
+ //# sourceMappingURL=html-data.js.map