@adonis-agora/telescope 0.23.0 → 0.24.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 (101) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/src/ai/index.d.ts +1 -1
  3. package/dist/src/ai/index.js +1 -1
  4. package/dist/src/alerts/index.d.ts +1 -1
  5. package/dist/src/alerts/index.js +1 -1
  6. package/dist/src/index.d.ts +1 -1
  7. package/dist/src/index.js +1 -1
  8. package/dist/src/mcp/index.d.ts +1 -1
  9. package/dist/src/mcp/index.js +1 -1
  10. package/dist/src/metrics/metrics_service.d.ts +5 -0
  11. package/dist/src/metrics/metrics_service.d.ts.map +1 -1
  12. package/dist/src/metrics/metrics_service.js +32 -4
  13. package/dist/src/metrics/metrics_service.js.map +1 -1
  14. package/dist/src/metrics/pulse.d.ts +8 -0
  15. package/dist/src/metrics/pulse.d.ts.map +1 -1
  16. package/dist/src/metrics/pulse.js +125 -37
  17. package/dist/src/metrics/pulse.js.map +1 -1
  18. package/dist/src/metrics/stats.d.ts +2 -0
  19. package/dist/src/metrics/stats.d.ts.map +1 -1
  20. package/dist/src/metrics/stats.js +8 -3
  21. package/dist/src/metrics/stats.js.map +1 -1
  22. package/dist/src/otel/otel_exporting_store.d.ts +1 -1
  23. package/dist/src/otel/otel_exporting_store.d.ts.map +1 -1
  24. package/dist/src/otel/otel_exporting_store.js +2 -0
  25. package/dist/src/otel/otel_exporting_store.js.map +1 -1
  26. package/dist/src/profiling/index.d.ts +1 -1
  27. package/dist/src/profiling/index.js +1 -1
  28. package/dist/src/redaction/redact.d.ts.map +1 -1
  29. package/dist/src/redaction/redact.js +28 -4
  30. package/dist/src/redaction/redact.js.map +1 -1
  31. package/dist/src/redaction/redacting_store.d.ts +1 -1
  32. package/dist/src/redaction/redacting_store.d.ts.map +1 -1
  33. package/dist/src/redaction/redacting_store.js +2 -0
  34. package/dist/src/redaction/redacting_store.js.map +1 -1
  35. package/dist/src/sampling/sampling_store.d.ts +1 -1
  36. package/dist/src/sampling/sampling_store.d.ts.map +1 -1
  37. package/dist/src/sampling/sampling_store.js +6 -1
  38. package/dist/src/sampling/sampling_store.js.map +1 -1
  39. package/dist/src/self_scope.d.ts +5 -0
  40. package/dist/src/self_scope.d.ts.map +1 -0
  41. package/dist/src/self_scope.js +28 -0
  42. package/dist/src/self_scope.js.map +1 -0
  43. package/dist/src/service.d.ts.map +1 -1
  44. package/dist/src/service.js +9 -2
  45. package/dist/src/service.js.map +1 -1
  46. package/dist/src/store.d.ts +46 -0
  47. package/dist/src/store.d.ts.map +1 -1
  48. package/dist/src/store.js +16 -1
  49. package/dist/src/store.js.map +1 -1
  50. package/dist/src/stores/lucid.d.ts +73 -28
  51. package/dist/src/stores/lucid.d.ts.map +1 -1
  52. package/dist/src/stores/lucid.js +487 -183
  53. package/dist/src/stores/lucid.js.map +1 -1
  54. package/dist/src/stores/memory.d.ts +3 -1
  55. package/dist/src/stores/memory.d.ts.map +1 -1
  56. package/dist/src/stores/memory.js +29 -0
  57. package/dist/src/stores/memory.js.map +1 -1
  58. package/dist/src/stores/tiered.d.ts +9 -4
  59. package/dist/src/stores/tiered.d.ts.map +1 -1
  60. package/dist/src/stores/tiered.js +34 -11
  61. package/dist/src/stores/tiered.js.map +1 -1
  62. package/dist/src/stream/streaming_store.d.ts +1 -1
  63. package/dist/src/stream/streaming_store.d.ts.map +1 -1
  64. package/dist/src/stream/streaming_store.js +2 -0
  65. package/dist/src/stream/streaming_store.js.map +1 -1
  66. package/dist/src/telescope_middleware.d.ts.map +1 -1
  67. package/dist/src/telescope_middleware.js +20 -11
  68. package/dist/src/telescope_middleware.js.map +1 -1
  69. package/dist/src/ui/index.d.ts +1 -1
  70. package/dist/src/ui/index.js +1 -1
  71. package/dist/src/watchers/cache_watcher.js +1 -1
  72. package/dist/src/watchers/cache_watcher.js.map +1 -1
  73. package/dist/src/watchers/events_watcher.js +1 -1
  74. package/dist/src/watchers/events_watcher.js.map +1 -1
  75. package/dist/src/watchers/http_client_watcher.js +1 -1
  76. package/dist/src/watchers/http_client_watcher.js.map +1 -1
  77. package/dist/src/watchers/index.d.ts +1 -1
  78. package/dist/src/watchers/index.js +1 -1
  79. package/dist/src/watchers/logs_watcher.js +1 -1
  80. package/dist/src/watchers/logs_watcher.js.map +1 -1
  81. package/dist/src/watchers/lucid_query_watcher.d.ts.map +1 -1
  82. package/dist/src/watchers/lucid_query_watcher.js +8 -2
  83. package/dist/src/watchers/lucid_query_watcher.js.map +1 -1
  84. package/dist/src/watchers/mail_watcher.js +1 -1
  85. package/dist/src/watchers/mail_watcher.js.map +1 -1
  86. package/dist/src/watchers/profiling_watcher.js +1 -1
  87. package/dist/src/watchers/profiling_watcher.js.map +1 -1
  88. package/dist/src/watchers/queue_watcher.js +1 -1
  89. package/dist/src/watchers/queue_watcher.js.map +1 -1
  90. package/dist/src/watchers/record.d.ts +5 -1
  91. package/dist/src/watchers/record.d.ts.map +1 -1
  92. package/dist/src/watchers/record.js +10 -1
  93. package/dist/src/watchers/record.js.map +1 -1
  94. package/dist/src/watchers/redis_watcher.js +1 -1
  95. package/dist/src/watchers/redis_watcher.js.map +1 -1
  96. package/dist/src/watchers/schedule_watcher.js +1 -1
  97. package/dist/src/watchers/schedule_watcher.js.map +1 -1
  98. package/dist/stubs/database/migrations/create_telescope_entries_table.stub +2 -2
  99. package/package.json +10 -9
  100. package/skills/telescope-storage-retention/SKILL.md +6 -3
  101. package/skills/telescope-watchers/SKILL.md +7 -5
@@ -2,6 +2,7 @@ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { currentTraceId } from '../context_accessor.js';
3
3
  import { isBatchOrigin } from '../entry.js';
4
4
  import { resolveOrigin } from '../origin_scope.js';
5
+ import { runAsTelescopeWork } from '../self_scope.js';
5
6
  /**
6
7
  * The table {@link LucidTelescopeStore} reads and writes. Override via
7
8
  * {@link LucidStoreOptions.tableName} when `telescope_entries` collides with an
@@ -13,31 +14,65 @@ export const DEFAULT_TABLE_NAME = 'telescope_entries';
13
14
  * indexes `list` / `prune` / `topFamilies` lean on, as one statement per array
14
15
  * element so each can be issued through Lucid's `rawQuery`.
15
16
  *
16
- * Portable across sqlite / Postgres / MySQL (quoted identifiers, no dialect-only
17
- * column types). A real deployment should still prefer the bundled migration stub
17
+ * Identifiers are double-quoted (sqlite / Postgres) unless `dialect` is a Lucid
18
+ * MySQL dialect name (`mysql` / `mysql2`), which gets backticks and plain
19
+ * `CREATE INDEX`; the column types are portable. A real deployment should still prefer the bundled migration stub
18
20
  * so the schema is versioned, but this lets a store stand itself up in tests and
19
21
  * scripts without a migration runner.
20
22
  */
21
- export function createTableStatements(tableName = DEFAULT_TABLE_NAME) {
23
+ export function createTableStatements(tableName = DEFAULT_TABLE_NAME, dialect) {
24
+ const mysql = isMysql(dialect);
25
+ const q = (identifier) => (mysql ? `\`${identifier}\`` : `"${identifier}"`);
26
+ // MySQL has no `CREATE INDEX IF NOT EXISTS`; an index that already exists fails with
27
+ // ER_DUP_KEYNAME, which the store's reconcile treats as done.
28
+ const index = (suffix, columns) => `CREATE INDEX ${mysql ? '' : 'IF NOT EXISTS '}${q(`${tableName}_${suffix}`)} ON ${q(tableName)} (${columns.map(q).join(', ')})`;
22
29
  return [
23
- `CREATE TABLE IF NOT EXISTS "${tableName}" (
24
- "id" VARCHAR(255) PRIMARY KEY NOT NULL,
25
- "type" VARCHAR(255) NOT NULL,
26
- "family_hash" VARCHAR(255) NULL,
27
- "content" TEXT NOT NULL,
28
- "tags" TEXT NOT NULL,
29
- "sequence" INTEGER NOT NULL,
30
- "duration_ms" INTEGER NULL,
31
- "origin" VARCHAR(255) NOT NULL,
32
- "trace_id" VARCHAR(255) NULL,
33
- "created_at" BIGINT NOT NULL
30
+ `CREATE TABLE IF NOT EXISTS ${q(tableName)} (
31
+ ${q('id')} VARCHAR(255) PRIMARY KEY NOT NULL,
32
+ ${q('type')} VARCHAR(255) NOT NULL,
33
+ ${q('family_hash')} VARCHAR(255) NULL,
34
+ ${q('content')} TEXT NOT NULL,
35
+ ${q('tags')} TEXT NOT NULL,
36
+ ${q('sequence')} INTEGER NOT NULL,
37
+ ${q('duration_ms')} INTEGER NULL,
38
+ ${q('origin')} VARCHAR(255) NOT NULL,
39
+ ${q('trace_id')} VARCHAR(255) NULL,
40
+ ${q('created_at')} BIGINT NOT NULL
34
41
  )`,
35
- `CREATE INDEX IF NOT EXISTS "${tableName}_created_at_idx" ON "${tableName}" ("created_at")`,
36
- `CREATE INDEX IF NOT EXISTS "${tableName}_type_idx" ON "${tableName}" ("type")`,
37
- `CREATE INDEX IF NOT EXISTS "${tableName}_trace_id_idx" ON "${tableName}" ("trace_id")`,
38
- `CREATE INDEX IF NOT EXISTS "${tableName}_family_hash_idx" ON "${tableName}" ("family_hash")`,
42
+ index('created_at_idx', ['created_at']),
43
+ index('trace_id_idx', ['trace_id']),
44
+ // The dashboard's per-type reads are `WHERE type = ? AND created_at > ? ORDER BY
45
+ // created_at DESC, sequence DESC LIMIT n`: this one index answers them in order.
46
+ index('type_created_at_sequence_idx', ['type', 'created_at', 'sequence']),
47
+ index('family_hash_created_at_idx', ['family_hash', 'created_at']),
39
48
  ];
40
49
  }
50
+ /** MySQL (and MariaDB) quote identifiers with backticks; a double-quoted name is a string there. */
51
+ function isMysql(dialect) {
52
+ return dialect === 'mysql' || dialect === 'mysql2';
53
+ }
54
+ /** The Lucid dialect name of `db`, from the client itself or the facade's default connection. */
55
+ function dialectOf(db) {
56
+ return db.dialect?.name ?? db.connection?.().dialect?.name;
57
+ }
58
+ /** Whether `error` is MySQL's "index already exists". */
59
+ function isDuplicateIndex(error) {
60
+ const { code, errno } = (error ?? {});
61
+ return code === 'ER_DUP_KEYNAME' || errno === 1061;
62
+ }
63
+ /** Run the DDL for `tableName` on `db`, in its dialect. */
64
+ async function runTableStatements(db, tableName) {
65
+ const dialect = dialectOf(db);
66
+ for (const stmt of createTableStatements(tableName, dialect)) {
67
+ try {
68
+ await db.rawQuery(stmt);
69
+ }
70
+ catch (error) {
71
+ if (!(isMysql(dialect) && isDuplicateIndex(error)))
72
+ throw error;
73
+ }
74
+ }
75
+ }
41
76
  // ── Schema fingerprint gate ──────────────────────────────────────────────────
42
77
  //
43
78
  // With `autoCreateTable`, `doInit` used to re-issue every `createTableStatements`
@@ -68,11 +103,12 @@ export const SCHEMA_META_TABLE_NAME = 'telescope_schema_meta';
68
103
  * and introspection-free, portable across sqlite / Postgres / MySQL (quoted
69
104
  * identifiers, portable types) — mirroring {@link createTableStatements}' style.
70
105
  */
71
- function createSchemaMetaTableStatement() {
72
- return `CREATE TABLE IF NOT EXISTS "${SCHEMA_META_TABLE_NAME}" (
73
- "id" VARCHAR(255) PRIMARY KEY NOT NULL,
74
- "fingerprint" VARCHAR(64) NOT NULL,
75
- "applied_at" BIGINT NOT NULL
106
+ function createSchemaMetaTableStatement(dialect) {
107
+ const q = (identifier) => (isMysql(dialect) ? `\`${identifier}\`` : `"${identifier}"`);
108
+ return `CREATE TABLE IF NOT EXISTS ${q(SCHEMA_META_TABLE_NAME)} (
109
+ ${q('id')} VARCHAR(255) PRIMARY KEY NOT NULL,
110
+ ${q('fingerprint')} VARCHAR(64) NOT NULL,
111
+ ${q('applied_at')} BIGINT NOT NULL
76
112
  )`;
77
113
  }
78
114
  /**
@@ -89,6 +125,31 @@ function computeSchemaFingerprint(tableName) {
89
125
  });
90
126
  return createHash('sha256').update(payload).digest('hex');
91
127
  }
128
+ /** Rows per multi-row INSERT. Ten columns each keeps a statement at 900 bound values,
129
+ * under the smallest limit of any dialect Lucid drives (999 on older SQLite builds). */
130
+ const INSERT_BATCH_ROWS = 90;
131
+ /** Serialized bytes per INSERT, so a batch of large entries stays far below MySQL's
132
+ * default `max_allowed_packet`. A single entry over it is still written, alone. */
133
+ const INSERT_BATCH_BYTES = 4 * 1024 * 1024;
134
+ /** Entries waiting to be written. Past this, new entries are dropped and counted
135
+ * rather than queued: a database that stopped keeping up must not grow the heap. */
136
+ const MAX_PENDING_WRITES = 10_000;
137
+ /** Rows deleted per statement while pruning. */
138
+ const PRUNE_BATCH_ROWS = 5_000;
139
+ /** Rows read per page while counting tags. */
140
+ const TAG_SCAN_PAGE_ROWS = 5_000;
141
+ /** Every column but `content`, for reads that set {@link EntryQuery.omitContent}. */
142
+ const COLUMNS_WITHOUT_CONTENT = [
143
+ 'id',
144
+ 'type',
145
+ 'family_hash',
146
+ 'tags',
147
+ 'sequence',
148
+ 'duration_ms',
149
+ 'origin',
150
+ 'trace_id',
151
+ 'created_at',
152
+ ];
92
153
  /**
93
154
  * A persistent, SQL-backed {@link TelescopeStore} on AdonisJS Lucid. Uses Lucid's
94
155
  * **async** query builder directly, so it works on every dialect Lucid supports
@@ -99,6 +160,15 @@ function computeSchemaFingerprint(tableName) {
99
160
  * `sequence` is seeded from `MAX(sequence)` on first use so it keeps climbing
100
161
  * across process restarts.
101
162
  *
163
+ * Writes are buffered: every entry recorded during one tick of the event loop goes
164
+ * out in one multi-row INSERT (split to stay under each dialect's bind limit), one
165
+ * statement at a time, so the store never holds more than one pooled connection
166
+ * for writing and a burst of watcher events costs a handful of round trips instead
167
+ * of one each. `record()` still resolves once its entry is persisted.
168
+ *
169
+ * Every query the store runs is marked as telescope's own work (see
170
+ * `self_scope.ts`), so the query watcher never records them.
171
+ *
102
172
  * Usually you don't construct this directly: `config/telescope.ts` selects it via
103
173
  * `storage.lucid({ ... })` and the provider builds it for you, lazily importing
104
174
  * `@adonisjs/lucid` only when the `lucid` driver is the active one.
@@ -111,14 +181,11 @@ export class LucidTelescopeStore {
111
181
  /** Lazily-resolved init (table creation + sequence seed), run at most once. */
112
182
  ready = null;
113
183
  sequence = 0;
114
- /**
115
- * Single-flight write chain: every `record()` insert is queued behind the previous one so the
116
- * store holds at most ONE pooled connection at a time. A burst of watcher events can therefore
117
- * never exhaust the host app's connection pool (which telescope may share). Reads
118
- * (`get`/`list`/`prune`) are user-initiated and low-frequency, so they are intentionally NOT
119
- * serialized.
120
- */
121
- writeTail = Promise.resolve();
184
+ pending = [];
185
+ /** The running drain, while one is scheduled or writing. */
186
+ draining = null;
187
+ dropped = 0;
188
+ warnedDrop = false;
122
189
  constructor(db, options = {}) {
123
190
  this.db = db;
124
191
  this.tableName = options.tableName ?? DEFAULT_TABLE_NAME;
@@ -158,64 +225,95 @@ export class LucidTelescopeStore {
158
225
  trace_id: entry.traceId,
159
226
  created_at: entry.createdAt.getTime(),
160
227
  };
161
- // Queue the insert behind any in-flight write (see `writeTail`): at most one telescope
162
- // connection is ever checked out of the pool. The entry is built eagerly above, so
163
- // `sequence`/`created_at` reflect event order even though the insert itself is serialized.
164
- const write = this.writeTail.then(() => this.db.table(this.tableName).insert(row));
165
- // Advance the chain regardless of this write's outcome — a single failed insert must not
166
- // wedge every subsequent write.
167
- this.writeTail = write.then(() => undefined, () => undefined);
168
- await write;
228
+ if (this.pending.length >= MAX_PENDING_WRITES) {
229
+ this.dropped += 1;
230
+ if (!this.warnedDrop) {
231
+ this.warnedDrop = true;
232
+ console.warn(`@adonis-agora/telescope: ${MAX_PENDING_WRITES} entries are waiting to be written to ` +
233
+ `"${this.tableName}"; new entries are dropped until the database catches up.`);
234
+ }
235
+ return entry;
236
+ }
237
+ await new Promise((resolve, reject) => {
238
+ this.pending.push({
239
+ row,
240
+ bytes: row.content.length + row.tags.length,
241
+ resolve,
242
+ reject,
243
+ });
244
+ this.scheduleDrain();
245
+ });
169
246
  return entry;
170
247
  }
171
- async get(id) {
172
- await this.init();
173
- const row = await this.db.from(this.tableName).where('id', id).first();
174
- return row ? hydrate(row) : null;
248
+ /** Entries dropped because the write buffer was full. */
249
+ get droppedCount() {
250
+ return this.dropped;
175
251
  }
176
- async list(query = {}) {
177
- await this.init();
178
- let builder = this.db.from(this.tableName);
179
- if (query.type !== undefined)
180
- builder = builder.where('type', query.type);
181
- if (query.familyHash !== undefined)
182
- builder = builder.where('family_hash', query.familyHash);
183
- if (query.traceId !== undefined)
184
- builder = builder.where('trace_id', query.traceId);
185
- if (query.traceIds !== undefined) {
186
- // An empty list means "no traces", not "no filter" — whereIn([]) already yields
187
- // zero rows, but being explicit keeps a caller's empty page from becoming a scan.
188
- if (query.traceIds.length === 0)
189
- return [];
190
- builder = builder.whereIn('trace_id', query.traceIds);
191
- }
192
- if (query.before !== undefined)
193
- builder = builder.where('created_at', '<', query.before.getTime());
194
- if (query.after !== undefined)
195
- builder = builder.where('created_at', '>', query.after.getTime());
196
- if (query.tag !== undefined) {
197
- // A tag is one element of the JSON array text, e.g. `["lib:billing",...]`.
198
- // Match the quoted token so `lib:bill` never matches `lib:billing`.
199
- builder = builder.whereRaw(`"tags" LIKE ? ESCAPE '\\'`, [
200
- `%${likeEscape(JSON.stringify(query.tag))}%`,
201
- ]);
202
- }
203
- if (query.search !== undefined) {
204
- const needle = `%${likeEscape(query.search.toLowerCase())}%`;
205
- builder = builder.whereRaw(`(LOWER("content") LIKE ? ESCAPE '\\' OR LOWER("tags") LIKE ? ESCAPE '\\')`, [needle, needle]);
206
- }
207
- // Newest-first: created_at desc, sequence desc as a deterministic tiebreaker.
208
- builder = builder.orderBy('created_at', 'desc').orderBy('sequence', 'desc');
209
- // `{ page, size }` in, `LIMIT/OFFSET` out: the 1-based page becomes `(page - 1) * size`.
210
- if (query.size !== undefined && query.size > 0) {
211
- const size = Math.floor(query.size);
212
- const page = Math.max(1, Math.floor(query.page ?? 1));
213
- builder = builder.limit(size);
214
- if (page > 1)
215
- builder = builder.offset((page - 1) * size);
216
- }
217
- const rows = await builder.select('*');
218
- return rows.map((r) => hydrate(r));
252
+ /** Resolves once every entry recorded so far has been written (or has failed). */
253
+ async flush() {
254
+ while (this.draining !== null)
255
+ await this.draining;
256
+ }
257
+ get(id) {
258
+ return runAsTelescopeWork(async () => {
259
+ await this.init();
260
+ const row = await this.db.from(this.tableName).where('id', id).first();
261
+ return row ? hydrate(row) : null;
262
+ });
263
+ }
264
+ list(query = {}) {
265
+ return runAsTelescopeWork(async () => {
266
+ await this.init();
267
+ let builder = this.db.from(this.tableName);
268
+ if (query.type !== undefined)
269
+ builder = builder.where('type', query.type);
270
+ if (query.familyHash !== undefined)
271
+ builder = builder.where('family_hash', query.familyHash);
272
+ if (query.traceId !== undefined)
273
+ builder = builder.where('trace_id', query.traceId);
274
+ if (query.traceIds !== undefined) {
275
+ // An empty list means "no traces", not "no filter" — whereIn([]) already yields
276
+ // zero rows, but being explicit keeps a caller's empty page from becoming a scan.
277
+ if (query.traceIds.length === 0)
278
+ return [];
279
+ builder = builder.whereIn('trace_id', query.traceIds);
280
+ }
281
+ if (query.before !== undefined)
282
+ builder = builder.where('created_at', '<', query.before.getTime());
283
+ if (query.after !== undefined)
284
+ builder = builder.where('created_at', '>', query.after.getTime());
285
+ if (query.tag !== undefined) {
286
+ // A tag is one element of the JSON array text, e.g. `["lib:billing",...]`.
287
+ // Match the quoted token so `lib:bill` never matches `lib:billing`.
288
+ builder = builder.whereRaw(`?? LIKE ? ESCAPE '!'`, [
289
+ 'tags',
290
+ `%${likeEscape(JSON.stringify(query.tag))}%`,
291
+ ]);
292
+ }
293
+ if (query.search !== undefined) {
294
+ const needle = `%${likeEscape(query.search.toLowerCase())}%`;
295
+ builder = builder.whereRaw(`(LOWER(??) LIKE ? ESCAPE '!' OR LOWER(??) LIKE ? ESCAPE '!')`, [
296
+ 'content',
297
+ needle,
298
+ 'tags',
299
+ needle,
300
+ ]);
301
+ }
302
+ // Newest-first: created_at desc, sequence desc as a deterministic tiebreaker.
303
+ builder = builder.orderBy('created_at', 'desc').orderBy('sequence', 'desc');
304
+ // `{ page, size }` in, `LIMIT/OFFSET` out: the 1-based page becomes `(page - 1) * size`.
305
+ if (query.size !== undefined && query.size > 0) {
306
+ const size = Math.floor(query.size);
307
+ const page = Math.max(1, Math.floor(query.page ?? 1));
308
+ builder = builder.limit(size);
309
+ if (page > 1)
310
+ builder = builder.offset((page - 1) * size);
311
+ }
312
+ const rows = await (query.omitContent === true
313
+ ? builder.select(...COLUMNS_WITHOUT_CONTENT)
314
+ : builder.select('*'));
315
+ return rows.map((r) => hydrate(r));
316
+ });
219
317
  }
220
318
  /**
221
319
  * The page of distinct trace ids, most-recently-seen first — the SQL fast path
@@ -226,110 +324,302 @@ export class LucidTelescopeStore {
226
324
  * the window. Entries with a NULL `trace_id` are excluded: they belong to no trace
227
325
  * and would collapse into one meaningless bucket.
228
326
  */
229
- async listTraceIds(query) {
230
- await this.init();
231
- let builder = this.db.from(this.tableName);
232
- if (query.before !== undefined)
233
- builder = builder.where('created_at', '<', query.before.getTime());
234
- if (query.after !== undefined)
235
- builder = builder.where('created_at', '>', query.after.getTime());
236
- // `select()` resolves the query, so it goes LAST — every chainable clause
237
- // (including the MAX aggregate) has to be applied before it.
238
- const rows = await builder
239
- .whereNotNull('trace_id')
240
- .groupBy('trace_id')
241
- .max('created_at as last_at')
242
- .orderBy('last_at', 'desc')
243
- .limit(query.size)
244
- .offset((Math.max(1, Math.floor(query.page ?? 1)) - 1) * Math.max(0, Math.floor(query.size)))
245
- .select('trace_id');
246
- return rows.map((row) => ({
247
- traceId: row.trace_id,
248
- lastAt: new Date(toInt(row.last_at)),
249
- }));
327
+ listTraceIds(query) {
328
+ return runAsTelescopeWork(async () => {
329
+ await this.init();
330
+ let builder = this.db.from(this.tableName);
331
+ if (query.before !== undefined)
332
+ builder = builder.where('created_at', '<', query.before.getTime());
333
+ if (query.after !== undefined)
334
+ builder = builder.where('created_at', '>', query.after.getTime());
335
+ // `select()` resolves the query, so it goes LAST — every chainable clause
336
+ // (including the MAX aggregate) has to be applied before it.
337
+ const rows = await builder
338
+ .whereNotNull('trace_id')
339
+ .groupBy('trace_id')
340
+ .max('created_at as last_at')
341
+ .orderBy('last_at', 'desc')
342
+ .limit(query.size)
343
+ .offset((Math.max(1, Math.floor(query.page ?? 1)) - 1) * Math.max(0, Math.floor(query.size)))
344
+ .select('trace_id');
345
+ return rows.map((row) => ({
346
+ traceId: row.trace_id,
347
+ lastAt: new Date(toInt(row.last_at)),
348
+ }));
349
+ });
250
350
  }
251
351
  /**
252
- * Per-bucket, per-type counts for the throughput chart.
352
+ * Per-bucket, per-type counts for the throughput chart, grouped in SQL so one row
353
+ * per `(bucket, type)` leaves the database instead of one per entry.
253
354
  *
254
- * The win here is the PROJECTION, not the grouping: the chart needs `created_at`
255
- * and `type` and nothing else, but the only way to get them was `list()`, which
256
- * does `select('*')` — shipping every row's `content` JSON blob out of the database
257
- * and running it through `hydrate()`, to then discard all of it except a timestamp
258
- * and a string. Two scalar columns instead of a blob is orders of magnitude less
259
- * bytes on the wire and no per-row JSON parse.
260
- *
261
- * The bucketing itself stays in JS, deliberately. Doing it in SQL needs a raw
262
- * select expression (`(created_at - ?) / ?`), and Lucid exposes `groupByRaw` but no
263
- * `selectRaw` — the alternative is `rawQuery`, whose result shape differs per driver
264
- * (`{ rows }` on Postgres, an array on SQLite). Trading a portability bug for the
265
- * second-order win is a bad deal; the row count is what would justify it, and after
266
- * the redis-watcher filter that count drops by roughly an order of magnitude. If
267
- * this is still hot with a clean table, THAT is when it earns the raw query.
355
+ * The bucket is `(created_at - start) - ((created_at - start) % bucketMs)`: integer
356
+ * arithmetic every dialect evaluates the same way, where `/` would be integer
357
+ * division on some and decimal on MySQL. Each group reports its newest
358
+ * `created_at`, and the bucket index is computed from that in JS. A builder
359
+ * without `groupByRaw` gets the per-row projection, bucketed here.
268
360
  */
269
- async countByBucket(query) {
270
- await this.init();
271
- const startMs = query.after.getTime();
272
- const bucketMs = Math.max(1, Math.floor(query.bucketMs));
273
- let builder = this.db
274
- .from(this.tableName)
275
- .where('created_at', '>', startMs)
276
- .where('created_at', '<=', query.before.getTime());
277
- if (query.type !== undefined)
278
- builder = builder.where('type', query.type);
279
- const rows = (await builder.select('created_at', 'type'));
280
- const tally = new Map();
281
- for (const row of rows) {
282
- const index = Math.floor((toInt(row.created_at) - startMs) / bucketMs);
283
- const key = `${index}\u0000${row.type}`;
284
- const seen = tally.get(key);
285
- if (seen === undefined) {
286
- tally.set(key, { index, type: row.type, count: 1 });
361
+ countByBucket(query) {
362
+ return runAsTelescopeWork(async () => {
363
+ await this.init();
364
+ const startMs = query.after.getTime();
365
+ const bucketMs = Math.max(1, Math.floor(query.bucketMs));
366
+ let builder = this.db
367
+ .from(this.tableName)
368
+ .where('created_at', '>', startMs)
369
+ .where('created_at', '<=', query.before.getTime());
370
+ if (query.type !== undefined)
371
+ builder = builder.where('type', query.type);
372
+ const tally = new Map();
373
+ const add = (createdAt, type, count) => {
374
+ const index = Math.floor((toInt(createdAt) - startMs) / bucketMs);
375
+ const key = `${index}\u0000${type}`;
376
+ const seen = tally.get(key);
377
+ if (seen === undefined)
378
+ tally.set(key, { index, type, count });
379
+ else
380
+ seen.count += count;
381
+ };
382
+ if (typeof builder.groupByRaw === 'function') {
383
+ // `type` is a GROUP BY column; MAX() is how this builder surface selects it
384
+ // next to the aggregates. Aliased apart from the column so MySQL, which
385
+ // resolves GROUP BY names to select aliases first, still groups by the column.
386
+ const rows = (await builder
387
+ .groupByRaw('(?? - ?) - ((?? - ?) % ?)', [
388
+ 'created_at',
389
+ startMs,
390
+ 'created_at',
391
+ startMs,
392
+ bucketMs,
393
+ ])
394
+ .groupBy('type')
395
+ .max('type as entry_type')
396
+ .max('created_at as last_at')
397
+ .count('*', 'total'));
398
+ for (const row of rows)
399
+ add(row.last_at, row.entry_type, toInt(row.total));
287
400
  }
288
401
  else {
289
- seen.count += 1;
402
+ const rows = (await builder.select('created_at', 'type'));
403
+ for (const row of rows)
404
+ add(row.created_at, row.type, 1);
290
405
  }
291
- }
292
- return [...tally.values()];
406
+ return [...tally.values()];
407
+ });
293
408
  }
294
- async count() {
295
- await this.init();
296
- const rows = await this.db.from(this.tableName).count('*', 'total');
297
- return toInt(rows[0]?.total);
409
+ /**
410
+ * The busiest families, counted by `GROUP BY family_hash` — see
411
+ * {@link TelescopeStore.countFamilies}.
412
+ */
413
+ countFamilies(query) {
414
+ return runAsTelescopeWork(async () => {
415
+ await this.init();
416
+ const limit = Math.max(0, Math.floor(query.limit));
417
+ if (limit === 0)
418
+ return [];
419
+ let builder = this.db
420
+ .from(this.tableName)
421
+ .whereNotNull('family_hash')
422
+ .where('family_hash', '<>', '');
423
+ if (query.type !== undefined)
424
+ builder = builder.where('type', query.type);
425
+ // `family_hash` is the GROUP BY column; MAX() selects it next to the aggregates
426
+ // (see countByBucket for the alias).
427
+ const rows = (await builder
428
+ .groupBy('family_hash')
429
+ .max('family_hash as family_key')
430
+ .max('created_at as last_at')
431
+ .max('sequence as last_sequence')
432
+ .orderBy('total', 'desc')
433
+ .orderBy('last_at', 'desc')
434
+ .orderBy('last_sequence', 'desc')
435
+ .limit(limit)
436
+ .count('*', 'total'));
437
+ return rows.map((row) => ({ key: row.family_key, count: toInt(row.total) }));
438
+ });
298
439
  }
299
- async prune(olderThan, keepLast) {
300
- await this.init();
301
- const cutoff = olderThan.getTime();
302
- if (keepLast === undefined) {
303
- return this.db.from(this.tableName).where('created_at', '<', cutoff).delete();
304
- }
305
- // Keep the newest `keepLast` of the doomed (older-than-cutoff) entries: find
306
- // their ids ordered newest-first, skip the first `keepLast`, delete the rest.
307
- const doomed = await this.db
308
- .from(this.tableName)
309
- .where('created_at', '<', cutoff)
310
- .orderBy('created_at', 'desc')
311
- .orderBy('sequence', 'desc')
312
- .select('id');
313
- const toDelete = doomed.slice(keepLast).map((r) => String(r.id));
314
- if (toDelete.length === 0)
315
- return 0;
316
- return this.db
317
- .from(this.tableName)
318
- .whereRaw(`"id" IN (${toDelete.map(() => '?').join(', ')})`, toDelete)
319
- .delete();
440
+ /**
441
+ * The most common tags — see {@link TelescopeStore.countTags}. Tags are JSON text,
442
+ * so they are counted here, reading only the `tags` column a page at a time in
443
+ * primary-key order.
444
+ */
445
+ countTags(query) {
446
+ return runAsTelescopeWork(async () => {
447
+ await this.init();
448
+ const limit = Math.max(0, Math.floor(query.limit));
449
+ if (limit === 0)
450
+ return [];
451
+ const tally = new Map();
452
+ let afterId = null;
453
+ for (;;) {
454
+ let builder = this.db.from(this.tableName);
455
+ if (afterId !== null)
456
+ builder = builder.where('id', '>', afterId);
457
+ const rows = (await builder
458
+ .orderBy('id', 'asc')
459
+ .limit(TAG_SCAN_PAGE_ROWS)
460
+ .select('id', 'tags', 'created_at', 'sequence'));
461
+ for (const row of rows) {
462
+ const position = { createdAt: toInt(row.created_at), sequence: toInt(row.sequence) };
463
+ for (const tag of parseTags(row.tags)) {
464
+ if (query.prefix !== undefined && !tag.startsWith(query.prefix))
465
+ continue;
466
+ const seen = tally.get(tag);
467
+ if (seen === undefined) {
468
+ tally.set(tag, { count: 1, newest: position });
469
+ }
470
+ else {
471
+ seen.count += 1;
472
+ if (isNewer(position, seen.newest))
473
+ seen.newest = position;
474
+ }
475
+ }
476
+ }
477
+ if (rows.length < TAG_SCAN_PAGE_ROWS)
478
+ break;
479
+ afterId = String(rows[rows.length - 1].id);
480
+ }
481
+ return [...tally.entries()]
482
+ .sort(([, a], [, b]) => b.count - a.count || (isNewer(a.newest, b.newest) ? -1 : 1))
483
+ .slice(0, limit)
484
+ .map(([key, { count }]) => ({ key, count }));
485
+ });
320
486
  }
321
- async clear() {
322
- await this.init();
323
- await this.db.from(this.tableName).whereRaw('1 = 1').delete();
487
+ count() {
488
+ return runAsTelescopeWork(async () => {
489
+ await this.init();
490
+ const rows = await this.db.from(this.tableName).count('*', 'total');
491
+ return toInt(rows[0]?.total);
492
+ });
493
+ }
494
+ /**
495
+ * Deletes oldest-first, {@link PRUNE_BATCH_ROWS} rows per statement, each batch
496
+ * addressed by its newest row's position rather than by id: a backlog of any size
497
+ * is pruned in bounded statements that never approach a bind limit.
498
+ */
499
+ prune(olderThan, keepLast) {
500
+ return runAsTelescopeWork(async () => {
501
+ await this.init();
502
+ const cutoff = olderThan.getTime();
503
+ // With `keepLast`, the doomed rows end at the first one past the newest
504
+ // `keepLast` of those older than the cutoff.
505
+ let newestDoomed = null;
506
+ if (keepLast !== undefined) {
507
+ newestDoomed = await this.position(this.db
508
+ .from(this.tableName)
509
+ .where('created_at', '<', cutoff)
510
+ .orderBy('created_at', 'desc')
511
+ .orderBy('sequence', 'desc')
512
+ .offset(Math.max(0, Math.floor(keepLast))));
513
+ if (newestDoomed === null)
514
+ return 0;
515
+ }
516
+ const doomed = () => {
517
+ const builder = this.db.from(this.tableName);
518
+ return newestDoomed === null
519
+ ? builder.where('created_at', '<', cutoff)
520
+ : atOrBefore(builder, newestDoomed);
521
+ };
522
+ let deleted = 0;
523
+ for (;;) {
524
+ const batchEdge = await this.position(doomed()
525
+ .orderBy('created_at', 'asc')
526
+ .orderBy('sequence', 'asc')
527
+ .offset(PRUNE_BATCH_ROWS - 1));
528
+ if (batchEdge === null)
529
+ return deleted + toInt(await doomed().delete());
530
+ deleted += toInt(await atOrBefore(doomed(), batchEdge).delete());
531
+ }
532
+ });
533
+ }
534
+ clear() {
535
+ return runAsTelescopeWork(async () => {
536
+ await this.init();
537
+ await this.flush();
538
+ await this.db.from(this.tableName).whereRaw('1 = 1').delete();
539
+ });
324
540
  }
325
541
  /** Cap enforcement helper for callers running a scheduled trim. */
326
542
  get capacity() {
327
543
  return this.maxEntries;
328
544
  }
545
+ /** The first row of an ordered query, as a position. */
546
+ async position(builder) {
547
+ const rows = await builder.limit(1).select('created_at', 'sequence');
548
+ const row = rows[0];
549
+ return row === undefined
550
+ ? null
551
+ : { createdAt: toInt(row.created_at), sequence: toInt(row.sequence) };
552
+ }
553
+ /** Start a drain on the next tick unless one is already running. */
554
+ scheduleDrain() {
555
+ if (this.draining !== null)
556
+ return;
557
+ this.draining = new Promise((resolve) => {
558
+ setImmediate(() => {
559
+ void runAsTelescopeWork(() => this.drain()).finally(() => {
560
+ this.draining = null;
561
+ resolve();
562
+ // A write can arrive between the drain emptying the buffer and this callback.
563
+ if (this.pending.length > 0)
564
+ this.scheduleDrain();
565
+ });
566
+ });
567
+ });
568
+ }
569
+ /** Write the buffer out, batch by batch, until it is empty. */
570
+ async drain() {
571
+ while (this.pending.length > 0) {
572
+ await this.writeBatch(this.takeBatch());
573
+ }
574
+ this.warnedDrop = false;
575
+ }
576
+ /** The oldest pending writes that fit one INSERT. */
577
+ takeBatch() {
578
+ let count = 0;
579
+ let bytes = 0;
580
+ for (const write of this.pending) {
581
+ if (count === INSERT_BATCH_ROWS)
582
+ break;
583
+ if (count > 0 && bytes + write.bytes > INSERT_BATCH_BYTES)
584
+ break;
585
+ count += 1;
586
+ bytes += write.bytes;
587
+ }
588
+ return this.pending.splice(0, count);
589
+ }
590
+ /**
591
+ * One multi-row INSERT. When it fails, its rows are retried one at a time, so a
592
+ * row the database rejects fails only its own `record()`.
593
+ */
594
+ async writeBatch(batch) {
595
+ try {
596
+ await this.db
597
+ .table(this.tableName)
598
+ .insert(batch.map((write) => write.row));
599
+ for (const write of batch)
600
+ write.resolve();
601
+ return;
602
+ }
603
+ catch (error) {
604
+ if (batch.length === 1) {
605
+ batch[0].reject(error);
606
+ return;
607
+ }
608
+ }
609
+ for (const write of batch) {
610
+ try {
611
+ await this.db.table(this.tableName).insert(write.row);
612
+ write.resolve();
613
+ }
614
+ catch (error) {
615
+ write.reject(error);
616
+ }
617
+ }
618
+ }
329
619
  /** Run table creation (optional) + sequence seeding exactly once. */
330
620
  init() {
331
621
  if (!this.ready)
332
- this.ready = this.doInit();
622
+ this.ready = runAsTelescopeWork(() => this.doInit());
333
623
  return this.ready;
334
624
  }
335
625
  async doInit() {
@@ -355,7 +645,7 @@ export class LucidTelescopeStore {
355
645
  */
356
646
  async reconcileSchema() {
357
647
  // (1) Idempotent, introspection-free marker table.
358
- await this.db.rawQuery(createSchemaMetaTableStatement());
648
+ await this.db.rawQuery(createSchemaMetaTableStatement(dialectOf(this.db)));
359
649
  // (2) Read the stored fingerprint + (3) compute the expected one in memory.
360
650
  const stored = await this.readStoredFingerprint();
361
651
  const expected = computeSchemaFingerprint(this.tableName);
@@ -363,9 +653,7 @@ export class LucidTelescopeStore {
363
653
  if (stored === expected)
364
654
  return;
365
655
  // (5) Absent/mismatch ⇒ (re)create the table + indexes, then re-cache.
366
- for (const stmt of createTableStatements(this.tableName)) {
367
- await this.db.rawQuery(stmt);
368
- }
656
+ await runTableStatements(this.db, this.tableName);
369
657
  await this.writeStoredFingerprint(expected);
370
658
  }
371
659
  /** Reads this table's stored fingerprint, or null when the marker row is absent. */
@@ -393,10 +681,7 @@ export class LucidTelescopeStore {
393
681
  * versioned and runs through `node ace migration:run`.
394
682
  */
395
683
  export async function createTelescopeTable(db, options = {}) {
396
- const tableName = options.tableName ?? DEFAULT_TABLE_NAME;
397
- for (const stmt of createTableStatements(tableName)) {
398
- await db.rawQuery(stmt);
399
- }
684
+ await runTableStatements(db, options.tableName ?? DEFAULT_TABLE_NAME);
400
685
  }
401
686
  /** Parse JSON text columns back into an {@link Entry}. */
402
687
  function hydrate(row) {
@@ -404,7 +689,7 @@ function hydrate(row) {
404
689
  id: row.id,
405
690
  type: row.type,
406
691
  familyHash: row.family_hash ?? null,
407
- content: parseJson(row.content),
692
+ content: 'content' in row ? parseJson(row.content) : null,
408
693
  tags: parseTags(row.tags),
409
694
  sequence: toInt(row.sequence),
410
695
  durationMs: row.duration_ms === null || row.duration_ms === undefined ? null : toInt(row.duration_ms),
@@ -413,6 +698,21 @@ function hydrate(row) {
413
698
  createdAt: new Date(toInt(row.created_at)),
414
699
  };
415
700
  }
701
+ /** Restrict `builder` to rows at or before `position` in oldest-first order. */
702
+ function atOrBefore(builder, position) {
703
+ return builder.whereRaw('(?? < ? OR (?? = ? AND ?? <= ?))', [
704
+ 'created_at',
705
+ position.createdAt,
706
+ 'created_at',
707
+ position.createdAt,
708
+ 'sequence',
709
+ position.sequence,
710
+ ]);
711
+ }
712
+ /** Whether `a` comes before `b` in newest-first order. */
713
+ function isNewer(a, b) {
714
+ return a.createdAt > b.createdAt || (a.createdAt === b.createdAt && a.sequence > b.sequence);
715
+ }
416
716
  function safeJson(value) {
417
717
  try {
418
718
  return JSON.stringify(value) ?? 'null';
@@ -442,8 +742,12 @@ function toInt(value) {
442
742
  return Number.parseInt(value, 10) || 0;
443
743
  return 0;
444
744
  }
445
- /** Escape LIKE wildcards so user input matches literally (escape char `\`). */
745
+ /**
746
+ * Escape LIKE wildcards so user input matches literally. The escape character is `!`
747
+ * rather than a backslash, which MySQL would read as escaping the closing quote of
748
+ * `ESCAPE '\'`.
749
+ */
446
750
  function likeEscape(value) {
447
- return value.replace(/[\\%_]/g, (ch) => `\\${ch}`);
751
+ return value.replace(/[!%_]/g, (ch) => `!${ch}`);
448
752
  }
449
753
  //# sourceMappingURL=lucid.js.map