@adonis-agora/telescope 0.22.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.
- package/CHANGELOG.md +50 -0
- package/dist/src/ai/index.d.ts +1 -1
- package/dist/src/ai/index.js +1 -1
- package/dist/src/alerts/index.d.ts +1 -1
- package/dist/src/alerts/index.js +1 -1
- package/dist/src/index.d.ts +9 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +4 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcp/index.d.ts +1 -1
- package/dist/src/mcp/index.js +1 -1
- package/dist/src/metrics/metrics_service.d.ts +5 -0
- package/dist/src/metrics/metrics_service.d.ts.map +1 -1
- package/dist/src/metrics/metrics_service.js +32 -4
- package/dist/src/metrics/metrics_service.js.map +1 -1
- package/dist/src/metrics/pulse.d.ts +8 -0
- package/dist/src/metrics/pulse.d.ts.map +1 -1
- package/dist/src/metrics/pulse.js +125 -37
- package/dist/src/metrics/pulse.js.map +1 -1
- package/dist/src/metrics/stats.d.ts +2 -0
- package/dist/src/metrics/stats.d.ts.map +1 -1
- package/dist/src/metrics/stats.js +8 -3
- package/dist/src/metrics/stats.js.map +1 -1
- package/dist/src/otel/otel_exporting_store.d.ts +1 -1
- package/dist/src/otel/otel_exporting_store.d.ts.map +1 -1
- package/dist/src/otel/otel_exporting_store.js +2 -0
- package/dist/src/otel/otel_exporting_store.js.map +1 -1
- package/dist/src/profiling/index.d.ts +1 -1
- package/dist/src/profiling/index.js +1 -1
- package/dist/src/redaction/redact.d.ts.map +1 -1
- package/dist/src/redaction/redact.js +28 -4
- package/dist/src/redaction/redact.js.map +1 -1
- package/dist/src/redaction/redacting_store.d.ts +1 -1
- package/dist/src/redaction/redacting_store.d.ts.map +1 -1
- package/dist/src/redaction/redacting_store.js +2 -0
- package/dist/src/redaction/redacting_store.js.map +1 -1
- package/dist/src/sampling/sampling_store.d.ts +1 -1
- package/dist/src/sampling/sampling_store.d.ts.map +1 -1
- package/dist/src/sampling/sampling_store.js +6 -1
- package/dist/src/sampling/sampling_store.js.map +1 -1
- package/dist/src/self_scope.d.ts +5 -0
- package/dist/src/self_scope.d.ts.map +1 -0
- package/dist/src/self_scope.js +28 -0
- package/dist/src/self_scope.js.map +1 -0
- package/dist/src/service.d.ts.map +1 -1
- package/dist/src/service.js +9 -2
- package/dist/src/service.js.map +1 -1
- package/dist/src/store.d.ts +46 -0
- package/dist/src/store.d.ts.map +1 -1
- package/dist/src/store.js +16 -1
- package/dist/src/store.js.map +1 -1
- package/dist/src/stores/factory.d.ts +79 -0
- package/dist/src/stores/factory.d.ts.map +1 -1
- package/dist/src/stores/factory.js +71 -0
- package/dist/src/stores/factory.js.map +1 -1
- package/dist/src/stores/lucid.d.ts +73 -28
- package/dist/src/stores/lucid.d.ts.map +1 -1
- package/dist/src/stores/lucid.js +487 -183
- package/dist/src/stores/lucid.js.map +1 -1
- package/dist/src/stores/memory.d.ts +3 -1
- package/dist/src/stores/memory.d.ts.map +1 -1
- package/dist/src/stores/memory.js +29 -0
- package/dist/src/stores/memory.js.map +1 -1
- package/dist/src/stores/parquet.d.ts +184 -0
- package/dist/src/stores/parquet.d.ts.map +1 -0
- package/dist/src/stores/parquet.js +461 -0
- package/dist/src/stores/parquet.js.map +1 -0
- package/dist/src/stores/parquet_duckdb.d.ts +52 -0
- package/dist/src/stores/parquet_duckdb.d.ts.map +1 -0
- package/dist/src/stores/parquet_duckdb.js +266 -0
- package/dist/src/stores/parquet_duckdb.js.map +1 -0
- package/dist/src/stores/parquet_format.d.ts +108 -0
- package/dist/src/stores/parquet_format.d.ts.map +1 -0
- package/dist/src/stores/parquet_format.js +175 -0
- package/dist/src/stores/parquet_format.js.map +1 -0
- package/dist/src/stores/parquet_hyparquet.d.ts +34 -0
- package/dist/src/stores/parquet_hyparquet.d.ts.map +1 -0
- package/dist/src/stores/parquet_hyparquet.js +212 -0
- package/dist/src/stores/parquet_hyparquet.js.map +1 -0
- package/dist/src/stores/parquet_location.d.ts +88 -0
- package/dist/src/stores/parquet_location.d.ts.map +1 -0
- package/dist/src/stores/parquet_location.js +194 -0
- package/dist/src/stores/parquet_location.js.map +1 -0
- package/dist/src/stores/tiered.d.ts +96 -0
- package/dist/src/stores/tiered.d.ts.map +1 -0
- package/dist/src/stores/tiered.js +277 -0
- package/dist/src/stores/tiered.js.map +1 -0
- package/dist/src/stream/streaming_store.d.ts +1 -1
- package/dist/src/stream/streaming_store.d.ts.map +1 -1
- package/dist/src/stream/streaming_store.js +2 -0
- package/dist/src/stream/streaming_store.js.map +1 -1
- package/dist/src/telescope_middleware.d.ts.map +1 -1
- package/dist/src/telescope_middleware.js +20 -11
- package/dist/src/telescope_middleware.js.map +1 -1
- package/dist/src/ui/index.d.ts +1 -1
- package/dist/src/ui/index.js +1 -1
- package/dist/src/watchers/cache_watcher.js +1 -1
- package/dist/src/watchers/cache_watcher.js.map +1 -1
- package/dist/src/watchers/events_watcher.js +1 -1
- package/dist/src/watchers/events_watcher.js.map +1 -1
- package/dist/src/watchers/http_client_watcher.js +1 -1
- package/dist/src/watchers/http_client_watcher.js.map +1 -1
- package/dist/src/watchers/index.d.ts +1 -1
- package/dist/src/watchers/index.js +1 -1
- package/dist/src/watchers/logs_watcher.js +1 -1
- package/dist/src/watchers/logs_watcher.js.map +1 -1
- package/dist/src/watchers/lucid_query_watcher.d.ts.map +1 -1
- package/dist/src/watchers/lucid_query_watcher.js +8 -2
- package/dist/src/watchers/lucid_query_watcher.js.map +1 -1
- package/dist/src/watchers/mail_watcher.js +1 -1
- package/dist/src/watchers/mail_watcher.js.map +1 -1
- package/dist/src/watchers/profiling_watcher.js +1 -1
- package/dist/src/watchers/profiling_watcher.js.map +1 -1
- package/dist/src/watchers/queue_watcher.js +1 -1
- package/dist/src/watchers/queue_watcher.js.map +1 -1
- package/dist/src/watchers/record.d.ts +5 -1
- package/dist/src/watchers/record.d.ts.map +1 -1
- package/dist/src/watchers/record.js +10 -1
- package/dist/src/watchers/record.js.map +1 -1
- package/dist/src/watchers/redis_watcher.js +1 -1
- package/dist/src/watchers/redis_watcher.js.map +1 -1
- package/dist/src/watchers/schedule_watcher.js +1 -1
- package/dist/src/watchers/schedule_watcher.js.map +1 -1
- package/dist/stubs/database/migrations/create_telescope_entries_table.stub +2 -2
- package/package.json +35 -9
- package/skills/telescope-setup/SKILL.md +1 -1
- package/skills/telescope-storage-retention/SKILL.md +13 -7
- package/skills/telescope-watchers/SKILL.md +7 -5
package/dist/src/stores/lucid.js
CHANGED
|
@@ -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
|
-
*
|
|
17
|
-
*
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
`
|
|
38
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
* and
|
|
258
|
-
*
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
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
|
-
|
|
406
|
+
return [...tally.values()];
|
|
407
|
+
});
|
|
293
408
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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(/[
|
|
751
|
+
return value.replace(/[!%_]/g, (ch) => `!${ch}`);
|
|
448
752
|
}
|
|
449
753
|
//# sourceMappingURL=lucid.js.map
|