@uniquedivine/tg-sai 0.0.4 → 0.1.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.
@@ -0,0 +1,475 @@
1
+ /**
2
+ * Read-only queries over the telemetry `event` table. Every function takes an
3
+ * open `bun:sqlite` Database and plain filter values so tests can run them
4
+ * against a temp database populated with real rows.
5
+ */
6
+ import { Database } from "bun:sqlite"
7
+
8
+ import {
9
+ ATTEMPT_EVENT,
10
+ LOG_LEVELS,
11
+ CALL_EVENT,
12
+ FLOOD_WAIT_EVENT,
13
+ RUN_END_EVENT,
14
+ RUN_START_EVENT,
15
+ WORKER_TICK_EVENT,
16
+ } from "./db"
17
+ import type { EventRow, LogLevel } from "./db"
18
+
19
+ /** Outcomes that count as failures. */
20
+ export const ERROR_OUTCOMES = ["error", "timeout", "aborted"] as const
21
+
22
+ const ERROR_OUTCOMES_SQL = ERROR_OUTCOMES.map((o) => `'${o}'`).join(", ")
23
+
24
+ type SqlParam = string | number | null
25
+
26
+ export interface EventFilter {
27
+ /** Lower bound on `ts_ms`, inclusive. */
28
+ sinceMs?: number
29
+ accountId?: number
30
+ method?: string
31
+ runId?: string
32
+ /** Only failed rows and flood waits. */
33
+ errorsOnly?: boolean
34
+ }
35
+
36
+ interface WhereParts {
37
+ clauses: string[]
38
+ params: SqlParam[]
39
+ }
40
+
41
+ /** Builds `AND`-joined filter clauses on table alias `e`. */
42
+ export function filterClauses(filter: EventFilter): WhereParts {
43
+ const clauses: string[] = []
44
+ const params: SqlParam[] = []
45
+ if (filter.sinceMs !== undefined) {
46
+ clauses.push("e.ts_ms >= ?")
47
+ params.push(filter.sinceMs)
48
+ }
49
+ if (filter.accountId !== undefined) {
50
+ clauses.push("e.account_id = ?")
51
+ params.push(filter.accountId)
52
+ }
53
+ if (filter.method !== undefined) {
54
+ clauses.push("e.method = ?")
55
+ params.push(filter.method)
56
+ }
57
+ if (filter.runId !== undefined) {
58
+ clauses.push("e.run_id = ?")
59
+ params.push(filter.runId)
60
+ }
61
+ if (filter.errorsOnly) {
62
+ clauses.push(
63
+ `(e.outcome IN (${ERROR_OUTCOMES_SQL}) ` +
64
+ `OR e.event = '${FLOOD_WAIT_EVENT}')`,
65
+ )
66
+ }
67
+ return { clauses, params }
68
+ }
69
+
70
+ function andAll(clauses: string[]): string {
71
+ return clauses.length === 0 ? "" : ` AND ${clauses.join(" AND ")}`
72
+ }
73
+
74
+ /** True when a completion row exists for call row `e`. */
75
+ const HAS_COMPLETION_SQL = `EXISTS (
76
+ SELECT 1 FROM event c
77
+ WHERE c.request_id = e.request_id
78
+ AND c.event = '${CALL_EVENT}'
79
+ AND c.outcome != 'started'
80
+ )`
81
+
82
+ export interface TailOptions extends EventFilter {
83
+ limit: number
84
+ }
85
+
86
+ /**
87
+ * Newest-first call, attempt, and flood-wait rows. A call appears once: as
88
+ * its completion row, or as its `started` row while no completion exists.
89
+ */
90
+ export function tailEvents(db: Database, options: TailOptions): EventRow[] {
91
+ const { clauses, params } = filterClauses(options)
92
+ const sql = `SELECT e.* FROM event e
93
+ WHERE e.event IN ('${CALL_EVENT}', '${ATTEMPT_EVENT}', '${FLOOD_WAIT_EVENT}')
94
+ AND NOT (e.event = '${CALL_EVENT}' AND e.outcome = 'started'
95
+ AND ${HAS_COMPLETION_SQL})${andAll(clauses)}
96
+ ORDER BY e.ts_ms DESC, e.event_id DESC
97
+ LIMIT ?`
98
+ return db.query<EventRow, SqlParam[]>(sql).all(...params, options.limit)
99
+ }
100
+
101
+ /**
102
+ * Chronological rows sharing `id` as request, run, job, or event id.
103
+ */
104
+ export function showTimeline(db: Database, id: string): EventRow[] {
105
+ return db
106
+ .query<EventRow, SqlParam[]>(
107
+ `SELECT * FROM event
108
+ WHERE request_id = ? OR run_id = ? OR job_id = ? OR event_id = ?
109
+ ORDER BY ts_ms ASC, event_id ASC`,
110
+ )
111
+ .all(id, id, id, id)
112
+ }
113
+
114
+ /** `--by` names accepted by `countEvents`, mapped to columns. */
115
+ export const COUNT_COLUMNS: Readonly<Record<string, string>> = {
116
+ method: "method",
117
+ outcome: "outcome",
118
+ account: "account_id",
119
+ account_id: "account_id",
120
+ event: "event",
121
+ subsystem: "subsystem",
122
+ command: "command",
123
+ profile: "profile",
124
+ error_code: "error_code",
125
+ err: "err",
126
+ run: "run_id",
127
+ run_id: "run_id",
128
+ }
129
+
130
+ export type CountLayer = "attempt" | "call"
131
+
132
+ export interface CountOptions {
133
+ sinceMs?: number
134
+ accountId?: number
135
+ by: string[]
136
+ layer: CountLayer
137
+ }
138
+
139
+ export type CountRow = Record<string, string | number | null> & {
140
+ count: number
141
+ }
142
+
143
+ /**
144
+ * Parses a comma-separated `--by` list into column names. Throws on an
145
+ * unknown name or an empty list.
146
+ */
147
+ export function parseCountBy(value: string): string[] {
148
+ const names = value
149
+ .split(",")
150
+ .map((name) => name.trim())
151
+ .filter((name) => name.length > 0)
152
+ if (names.length === 0) throw new Error("--by needs at least one field")
153
+ const columns = names.map((name) => {
154
+ const column = COUNT_COLUMNS[name]
155
+ if (!column) {
156
+ throw new Error(
157
+ `Unknown --by field "${name}"; use one of: ` +
158
+ Object.keys(COUNT_COLUMNS).join(", "),
159
+ )
160
+ }
161
+ return column
162
+ })
163
+ return [...new Set(columns)]
164
+ }
165
+
166
+ /**
167
+ * Row counts grouped by `by` columns. The attempt layer counts requests sent
168
+ * to Telegram; the call layer counts finished logical calls.
169
+ */
170
+ export function countEvents(db: Database, options: CountOptions): CountRow[] {
171
+ const known = new Set(Object.values(COUNT_COLUMNS))
172
+ for (const column of options.by) {
173
+ if (!known.has(column)) throw new Error(`Unknown count column: ${column}`)
174
+ }
175
+ if (options.by.length === 0) throw new Error("count needs a group column")
176
+ const { clauses, params } = filterClauses({
177
+ sinceMs: options.sinceMs,
178
+ accountId: options.accountId,
179
+ })
180
+ const layer =
181
+ options.layer === "call"
182
+ ? `e.event = '${CALL_EVENT}' AND e.outcome != 'started'`
183
+ : `e.event = '${ATTEMPT_EVENT}'`
184
+ const cols = options.by.map((c) => `e.${c} AS ${c}`).join(", ")
185
+ const group = options.by.map((c) => `e.${c}`).join(", ")
186
+ const sql = `SELECT ${cols}, COUNT(*) AS count FROM event e
187
+ WHERE ${layer}${andAll(clauses)}
188
+ GROUP BY ${group}
189
+ ORDER BY count DESC, ${group}`
190
+ return db.query<CountRow, SqlParam[]>(sql).all(...params)
191
+ }
192
+
193
+ export interface FloodSummaryRow {
194
+ event: string
195
+ account_id: number | null
196
+ method: string | null
197
+ wait_s: number
198
+ count: number
199
+ latest_ms: number
200
+ }
201
+
202
+ export interface ActiveFloodRow {
203
+ account_id: number | null
204
+ method: string | null
205
+ wait_s: number
206
+ started_ms: number
207
+ ends_ms: number
208
+ request_id: string | null
209
+ }
210
+
211
+ export interface FloodReport {
212
+ floods: FloodSummaryRow[]
213
+ active: ActiveFloodRow[]
214
+ }
215
+
216
+ export interface FloodOptions {
217
+ sinceMs?: number
218
+ accountId?: number
219
+ nowMs: number
220
+ }
221
+
222
+ /** Flood waits: mtcute sleeps and attempts that returned a wait. */
223
+ const FLOOD_ROWS_SQL = `(e.event = '${FLOOD_WAIT_EVENT}'
224
+ OR (e.event = '${ATTEMPT_EVENT}' AND e.wait_s IS NOT NULL))`
225
+
226
+ /**
227
+ * Flood waits grouped by layer, account, method, and wait, plus each
228
+ * account/method pair whose latest wait has not ended by `nowMs`. Active
229
+ * waits ignore `sinceMs` so a long wait that began earlier still shows.
230
+ */
231
+ export function floodReport(db: Database, options: FloodOptions): FloodReport {
232
+ const summary = filterClauses({
233
+ sinceMs: options.sinceMs,
234
+ accountId: options.accountId,
235
+ })
236
+ const floods = db
237
+ .query<FloodSummaryRow, SqlParam[]>(
238
+ `SELECT e.event AS event, e.account_id AS account_id,
239
+ e.method AS method, e.wait_s AS wait_s, COUNT(*) AS count,
240
+ MAX(e.ts_ms) AS latest_ms
241
+ FROM event e
242
+ WHERE ${FLOOD_ROWS_SQL} AND e.wait_s IS NOT NULL${andAll(summary.clauses)}
243
+ GROUP BY e.event, e.account_id, e.method, e.wait_s
244
+ ORDER BY latest_ms DESC`,
245
+ )
246
+ .all(...summary.params)
247
+ const activeFilter = filterClauses({ accountId: options.accountId })
248
+ // SQLite takes bare columns from the row that supplied MAX().
249
+ const active = db
250
+ .query<ActiveFloodRow, SqlParam[]>(
251
+ `SELECT e.account_id AS account_id, e.method AS method,
252
+ e.wait_s AS wait_s, e.ts_ms AS started_ms,
253
+ MAX(e.ts_ms + e.wait_s * 1000) AS ends_ms, e.request_id AS request_id
254
+ FROM event e
255
+ WHERE ${FLOOD_ROWS_SQL} AND e.wait_s IS NOT NULL${andAll(activeFilter.clauses)}
256
+ GROUP BY e.account_id, e.method
257
+ HAVING ends_ms > ?
258
+ ORDER BY ends_ms DESC`,
259
+ )
260
+ .all(...activeFilter.params, options.nowMs)
261
+ return { floods, active }
262
+ }
263
+
264
+ /**
265
+ * `tg.rpc.call` rows still `started` with no completion row for their
266
+ * `request_id`: the caller cannot know whether Telegram applied them.
267
+ */
268
+ export function uncertainCalls(
269
+ db: Database,
270
+ options: { sinceMs?: number } = {},
271
+ ): EventRow[] {
272
+ const { clauses, params } = filterClauses({ sinceMs: options.sinceMs })
273
+ return db
274
+ .query<EventRow, SqlParam[]>(
275
+ `SELECT e.* FROM event e
276
+ WHERE e.event = '${CALL_EVENT}' AND e.outcome = 'started'
277
+ AND NOT ${HAS_COMPLETION_SQL}${andAll(clauses)}
278
+ ORDER BY e.ts_ms DESC, e.event_id DESC`,
279
+ )
280
+ .all(...params)
281
+ }
282
+
283
+ export interface RunRow {
284
+ run_id: string
285
+ started_ms: number
286
+ command: string | null
287
+ subsystem: string
288
+ account_id: number | null
289
+ profile: string | null
290
+ host: string | null
291
+ pid: number | null
292
+ ended_ms: number | null
293
+ /** `run.end` outcome, or null when the run has no end row. */
294
+ outcome: string | null
295
+ error_code: number | null
296
+ err: string | null
297
+ duration_ms: number | null
298
+ calls: number
299
+ errors: number
300
+ attempts: number
301
+ ticks: number
302
+ }
303
+
304
+ /**
305
+ * Newest `run.start` rows joined to their latest `run.end`, with call,
306
+ * failed-call, attempt, and worker-tick counts. Duration falls back to the
307
+ * gap between the two rows when `run.end` lacks `duration_ms`.
308
+ */
309
+ export function recentRuns(db: Database, limit: number): RunRow[] {
310
+ return db
311
+ .query<RunRow, SqlParam[]>(
312
+ `SELECT s.run_id AS run_id, s.ts_ms AS started_ms, s.command AS command,
313
+ s.subsystem AS subsystem, s.account_id AS account_id, s.profile AS profile,
314
+ s.host AS host, s.pid AS pid, en.ts_ms AS ended_ms, en.outcome AS outcome,
315
+ en.error_code AS error_code, en.err AS err,
316
+ COALESCE(en.duration_ms, en.ts_ms - s.ts_ms) AS duration_ms,
317
+ (SELECT COUNT(*) FROM event x WHERE x.run_id = s.run_id
318
+ AND x.event = '${CALL_EVENT}' AND x.outcome != 'started') AS calls,
319
+ (SELECT COUNT(*) FROM event x WHERE x.run_id = s.run_id
320
+ AND x.event = '${CALL_EVENT}'
321
+ AND x.outcome IN (${ERROR_OUTCOMES_SQL})) AS errors,
322
+ (SELECT COUNT(*) FROM event x WHERE x.run_id = s.run_id
323
+ AND x.event = '${ATTEMPT_EVENT}') AS attempts,
324
+ (SELECT COUNT(*) FROM event x WHERE x.run_id = s.run_id
325
+ AND x.event = '${WORKER_TICK_EVENT}') AS ticks
326
+ FROM event s
327
+ LEFT JOIN event en ON en.event_id = (
328
+ SELECT y.event_id FROM event y
329
+ WHERE y.run_id = s.run_id AND y.event = '${RUN_END_EVENT}'
330
+ ORDER BY y.ts_ms DESC, y.event_id DESC LIMIT 1
331
+ )
332
+ WHERE s.event = '${RUN_START_EVENT}'
333
+ ORDER BY s.ts_ms DESC, s.event_id DESC
334
+ LIMIT ?`,
335
+ )
336
+ .all(limit)
337
+ }
338
+
339
+ /**
340
+ * True when the `event` table has the 0003 log columns. Read-only opens skip
341
+ * migrations, so an old database may lack them.
342
+ */
343
+ export function hasLogColumns(db: Database): boolean {
344
+ const columns = db
345
+ .query<{ name: string }, []>("PRAGMA table_info(event)")
346
+ .all()
347
+ .map((row) => row.name)
348
+ return columns.includes("level")
349
+ }
350
+
351
+ /** `level` and every more severe level, e.g. `warn` → `warn`, `error`. */
352
+ export function levelsAtOrAbove(level: LogLevel): LogLevel[] {
353
+ const index = LOG_LEVELS.indexOf(level)
354
+ if (index === -1) throw new Error(`Unknown log level: ${level}`)
355
+ return LOG_LEVELS.slice(index)
356
+ }
357
+
358
+ export interface LogQuery {
359
+ limit: number
360
+ sinceMs?: number
361
+ /** Minimum level; rows at this level or more severe. */
362
+ minLevel?: LogLevel
363
+ subsystem?: string
364
+ runId?: string
365
+ jobId?: string
366
+ }
367
+
368
+ /** Newest-first app log rows (`level IS NOT NULL`). */
369
+ export function logEvents(db: Database, options: LogQuery): EventRow[] {
370
+ if (!hasLogColumns(db)) return []
371
+ const { clauses, params } = filterClauses({
372
+ sinceMs: options.sinceMs,
373
+ runId: options.runId,
374
+ })
375
+ if (options.minLevel !== undefined) {
376
+ const levels = levelsAtOrAbove(options.minLevel)
377
+ clauses.push(`e.level IN (${levels.map(() => "?").join(", ")})`)
378
+ params.push(...levels)
379
+ }
380
+ if (options.subsystem !== undefined) {
381
+ clauses.push("e.subsystem = ?")
382
+ params.push(options.subsystem)
383
+ }
384
+ if (options.jobId !== undefined) {
385
+ clauses.push("e.job_id = ?")
386
+ params.push(options.jobId)
387
+ }
388
+ const sql = `SELECT e.* FROM event e
389
+ WHERE e.level IS NOT NULL${andAll(clauses)}
390
+ ORDER BY e.ts_ms DESC, e.event_id DESC
391
+ LIMIT ?`
392
+ return db.query<EventRow, SqlParam[]>(sql).all(...params, options.limit)
393
+ }
394
+
395
+ export interface LevelCount {
396
+ level: LogLevel
397
+ rows: number
398
+ }
399
+
400
+ export interface KindCount {
401
+ event: string
402
+ rows: number
403
+ }
404
+
405
+ export interface EventStats {
406
+ rows: number
407
+ oldest_ms: number | null
408
+ newest_ms: number | null
409
+ /** App log rows (`level IS NOT NULL`). */
410
+ log_rows: number
411
+ kinds: KindCount[]
412
+ levels: LevelCount[]
413
+ }
414
+
415
+ export function eventStats(db: Database): EventStats {
416
+ const totals = db
417
+ .query<
418
+ { rows: number; oldest_ms: number | null; newest_ms: number | null },
419
+ []
420
+ >(
421
+ "SELECT COUNT(*) AS rows, MIN(ts_ms) AS oldest_ms, " +
422
+ "MAX(ts_ms) AS newest_ms FROM event",
423
+ )
424
+ .get()
425
+ const kinds = db
426
+ .query<KindCount, []>(
427
+ "SELECT event, COUNT(*) AS rows FROM event GROUP BY event " +
428
+ "ORDER BY rows DESC, event",
429
+ )
430
+ .all()
431
+ const levels = hasLogColumns(db)
432
+ ? db
433
+ .query<LevelCount, []>(
434
+ "SELECT level, COUNT(*) AS rows FROM event " +
435
+ "WHERE level IS NOT NULL GROUP BY level ORDER BY rows DESC, level",
436
+ )
437
+ .all()
438
+ : []
439
+ return {
440
+ rows: totals?.rows ?? 0,
441
+ oldest_ms: totals?.oldest_ms ?? null,
442
+ newest_ms: totals?.newest_ms ?? null,
443
+ log_rows: levels.reduce((sum, row) => sum + row.rows, 0),
444
+ kinds,
445
+ levels,
446
+ }
447
+ }
448
+
449
+ /** Oldest-first rows since `sinceMs`, streamed. */
450
+ export function* exportEvents(
451
+ db: Database,
452
+ options: { sinceMs?: number } = {},
453
+ ): Generator<EventRow> {
454
+ const { clauses, params } = filterClauses({ sinceMs: options.sinceMs })
455
+ const where = clauses.length === 0 ? "" : ` WHERE ${clauses.join(" AND ")}`
456
+ const statement = db.query<EventRow, SqlParam[]>(
457
+ `SELECT e.* FROM event e${where} ORDER BY e.ts_ms ASC, e.event_id ASC`,
458
+ )
459
+ for (const row of statement.iterate(...params)) yield row
460
+ }
461
+
462
+ /**
463
+ * Opens an existing telemetry database read-only: no create, no pragmas
464
+ * that write, no migrations.
465
+ */
466
+ export function openTelemetryReadOnly(path: string): Database {
467
+ const db = new Database(path, { readonly: true, strict: true })
468
+ try {
469
+ db.run("PRAGMA busy_timeout = 5000")
470
+ return db
471
+ } catch (error) {
472
+ db.close()
473
+ throw error
474
+ }
475
+ }