lduck 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1131 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+ import { serve } from "@hono/node-server";
4
+ import { serveStatic } from "@hono/node-server/serve-static";
5
+ import { createNodeWebSocket } from "@hono/node-ws";
6
+ import { jsx, Fragment, jsxs } from "react/jsx-runtime";
7
+ import { Hono } from "hono";
8
+ import { renderToString } from "react-dom/server";
9
+ import { zValidator } from "@hono/zod-validator";
10
+ import { z } from "zod";
11
+ import { DuckDBInstance, DuckDBTimestampValue } from "@duckdb/node-api";
12
+ import { EventEmitter } from "node:events";
13
+ import { createInterface } from "node:readline";
14
+ const __vite_import_meta_env__ = {};
15
+ var ViteClient = () => {
16
+ if (__vite_import_meta_env__ && true) return /* @__PURE__ */ jsx(Fragment, {});
17
+ return /* @__PURE__ */ jsx("script", { type: "module", src: "/@vite/client" });
18
+ };
19
+ var ReactRefresh = () => {
20
+ if (__vite_import_meta_env__ && true) return /* @__PURE__ */ jsx(Fragment, {});
21
+ const refreshScript = `
22
+ import RefreshRuntime from '/@react-refresh';
23
+ RefreshRuntime.injectIntoGlobalHook(window);
24
+ window.$RefreshReg$ = () => {};
25
+ window.$RefreshSig$ = () => (type) => type;
26
+ window.__vite_plugin_react_preamble_installed__ = true;
27
+ `;
28
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
29
+ /* @__PURE__ */ jsx("script", { type: "module", src: "/@react-refresh" }),
30
+ /* @__PURE__ */ jsx("script", { type: "module", dangerouslySetInnerHTML: { __html: refreshScript } })
31
+ ] });
32
+ };
33
+ function createApp(scriptSrc, setup) {
34
+ const app = new Hono();
35
+ setup?.(app);
36
+ app.get("*", (c) => {
37
+ const html = renderToString(
38
+ /* @__PURE__ */ jsxs("html", { lang: "en", style: { margin: 0, padding: 0, height: "100%" }, children: [
39
+ /* @__PURE__ */ jsxs("head", { children: [
40
+ /* @__PURE__ */ jsx("meta", { charSet: "UTF-8" }),
41
+ /* @__PURE__ */ jsx("meta", { name: "viewport", content: "width=device-width,initial-scale=1.0" }),
42
+ /* @__PURE__ */ jsx("title", { children: "lduck" }),
43
+ /* @__PURE__ */ jsx(ViteClient, {}),
44
+ /* @__PURE__ */ jsx(ReactRefresh, {})
45
+ ] }),
46
+ /* @__PURE__ */ jsxs("body", { style: { margin: 0, padding: 0, height: "100%" }, children: [
47
+ /* @__PURE__ */ jsx("div", { id: "root" }),
48
+ /* @__PURE__ */ jsx("script", { type: "module", src: scriptSrc })
49
+ ] })
50
+ ] })
51
+ );
52
+ return c.html(`<!DOCTYPE html>${html}`);
53
+ });
54
+ return app;
55
+ }
56
+ const logQuerySchema = z.object({
57
+ level: z.enum(["DEBUG", "INFO", "WARN", "ERROR", "FATAL"]).optional(),
58
+ service: z.string().optional(),
59
+ source: z.string().optional(),
60
+ search: z.string().optional(),
61
+ startTime: z.coerce.date().optional(),
62
+ endTime: z.coerce.date().optional(),
63
+ limit: z.coerce.number().int().min(1).max(1e4).default(200),
64
+ offset: z.coerce.number().int().min(0).default(0),
65
+ order: z.enum(["asc", "desc"]).default("desc"),
66
+ jsonFilters: z.string().optional()
67
+ });
68
+ const statsQuerySchema = z.object({
69
+ source: z.string().optional()
70
+ });
71
+ const sqlQuerySchema = z.object({
72
+ sql: z.string().min(1).max(1e4)
73
+ });
74
+ const exportSchema = z.object({
75
+ format: z.enum(["csv", "json"]),
76
+ level: z.enum(["DEBUG", "INFO", "WARN", "ERROR", "FATAL"]).optional(),
77
+ service: z.string().optional(),
78
+ source: z.string().optional(),
79
+ search: z.string().optional(),
80
+ startTime: z.coerce.date().optional(),
81
+ endTime: z.coerce.date().optional()
82
+ });
83
+ const ingestBodySchema = z.union([
84
+ z.record(z.string(), z.unknown()),
85
+ z.array(z.record(z.string(), z.unknown()))
86
+ ]);
87
+ const facetQuerySchema = z.object({
88
+ field: z.string(),
89
+ jsonPath: z.string().optional(),
90
+ level: z.enum(["DEBUG", "INFO", "WARN", "ERROR", "FATAL"]).optional(),
91
+ service: z.string().optional(),
92
+ source: z.string().optional(),
93
+ search: z.string().optional(),
94
+ startTime: z.coerce.date().optional(),
95
+ endTime: z.coerce.date().optional(),
96
+ jsonFilters: z.string().optional()
97
+ });
98
+ function validationHook(result, c) {
99
+ if (!result.success) {
100
+ return c.json(
101
+ {
102
+ error: {
103
+ code: "VALIDATION_ERROR",
104
+ message: "Invalid request parameters",
105
+ details: result.error?.issues
106
+ }
107
+ },
108
+ 400
109
+ );
110
+ }
111
+ }
112
+ function serializeLogEntry(log) {
113
+ return {
114
+ _id: String(log._id),
115
+ _ingested: log._ingested.toISOString(),
116
+ _raw: log._raw,
117
+ timestamp: log.timestamp?.toISOString() ?? null,
118
+ level: log.level,
119
+ message: log.message,
120
+ service: log.service,
121
+ trace_id: log.trace_id,
122
+ host: log.host,
123
+ duration_ms: log.duration_ms,
124
+ source: log.source
125
+ };
126
+ }
127
+ function serializeStats$1(stats) {
128
+ return {
129
+ total: stats.total,
130
+ byLevel: stats.byLevel,
131
+ errorRate: stats.errorRate,
132
+ timeRange: {
133
+ min: stats.timeRange.min?.toISOString() ?? null,
134
+ max: stats.timeRange.max?.toISOString() ?? null
135
+ }
136
+ };
137
+ }
138
+ function createApiApp(db, ingester) {
139
+ const startTime = Date.now();
140
+ const app = new Hono().basePath("/api").get("/health", (c) => {
141
+ const uptime = (Date.now() - startTime) / 1e3;
142
+ return c.json({ status: "ok", uptime });
143
+ }).get("/schema", async (c) => {
144
+ try {
145
+ const schema = await db.getSchema();
146
+ return c.json(schema);
147
+ } catch (err) {
148
+ return c.json(
149
+ {
150
+ error: {
151
+ code: "INTERNAL_ERROR",
152
+ message: err instanceof Error ? err.message : "Unknown error"
153
+ }
154
+ },
155
+ 500
156
+ );
157
+ }
158
+ }).get("/stats", zValidator("query", statsQuerySchema, validationHook), async (c) => {
159
+ try {
160
+ const { source } = c.req.valid("query");
161
+ const stats = await db.getStats(source ? { source } : void 0);
162
+ return c.json(serializeStats$1(stats));
163
+ } catch (err) {
164
+ return c.json(
165
+ {
166
+ error: {
167
+ code: "INTERNAL_ERROR",
168
+ message: err instanceof Error ? err.message : "Unknown error"
169
+ }
170
+ },
171
+ 500
172
+ );
173
+ }
174
+ }).get("/logs", zValidator("query", logQuerySchema, validationHook), async (c) => {
175
+ try {
176
+ const params = c.req.valid("query");
177
+ let jsonFilters;
178
+ if (params.jsonFilters) {
179
+ try {
180
+ jsonFilters = JSON.parse(params.jsonFilters);
181
+ } catch {
182
+ }
183
+ }
184
+ const queryParams = {
185
+ level: params.level,
186
+ service: params.service,
187
+ source: params.source,
188
+ search: params.search,
189
+ startTime: params.startTime,
190
+ endTime: params.endTime,
191
+ limit: params.limit,
192
+ offset: params.offset,
193
+ order: params.order,
194
+ jsonFilters
195
+ };
196
+ const result = await db.queryLogs(queryParams);
197
+ return c.json({
198
+ logs: result.logs.map(serializeLogEntry),
199
+ total: result.total
200
+ });
201
+ } catch (err) {
202
+ return c.json(
203
+ {
204
+ error: {
205
+ code: "QUERY_ERROR",
206
+ message: err instanceof Error ? err.message : "Unknown error"
207
+ }
208
+ },
209
+ 500
210
+ );
211
+ }
212
+ }).post("/query", zValidator("json", sqlQuerySchema, validationHook), async (c) => {
213
+ try {
214
+ const { sql } = c.req.valid("json");
215
+ const result = await db.executeQuery(sql);
216
+ return c.json(result);
217
+ } catch (err) {
218
+ const message = err instanceof Error ? err.message : "Unknown error";
219
+ if (message.startsWith("Forbidden SQL:")) {
220
+ return c.json(
221
+ {
222
+ error: {
223
+ code: "FORBIDDEN_SQL",
224
+ message
225
+ }
226
+ },
227
+ 403
228
+ );
229
+ }
230
+ return c.json(
231
+ {
232
+ error: {
233
+ code: "QUERY_ERROR",
234
+ message
235
+ }
236
+ },
237
+ 500
238
+ );
239
+ }
240
+ }).post("/export", zValidator("json", exportSchema, validationHook), async (c) => {
241
+ try {
242
+ const params = c.req.valid("json");
243
+ const queryParams = {
244
+ level: params.level,
245
+ service: params.service,
246
+ source: params.source,
247
+ search: params.search,
248
+ startTime: params.startTime,
249
+ endTime: params.endTime,
250
+ limit: 1e4,
251
+ // Export up to max
252
+ offset: 0,
253
+ order: "desc"
254
+ };
255
+ const stream = await db.exportLogs(queryParams, params.format);
256
+ const contentType = params.format === "csv" ? "text/csv; charset=utf-8" : "application/json; charset=utf-8";
257
+ const ext = params.format === "csv" ? "csv" : "json";
258
+ return new Response(stream, {
259
+ status: 200,
260
+ headers: {
261
+ "Content-Type": contentType,
262
+ "Content-Disposition": `attachment; filename="logs-export.${ext}"`
263
+ }
264
+ });
265
+ } catch (err) {
266
+ return c.json(
267
+ {
268
+ error: {
269
+ code: "INTERNAL_ERROR",
270
+ message: err instanceof Error ? err.message : "Unknown error"
271
+ }
272
+ },
273
+ 500
274
+ );
275
+ }
276
+ }).post("/ingest", zValidator("json", ingestBodySchema, validationHook), (c) => {
277
+ if (!ingester) {
278
+ return c.json(
279
+ {
280
+ error: {
281
+ code: "NOT_AVAILABLE",
282
+ message: "Ingestion not available"
283
+ }
284
+ },
285
+ 503
286
+ );
287
+ }
288
+ const body = c.req.valid("json");
289
+ const items = Array.isArray(body) ? body : [body];
290
+ const lines = items.map((item) => JSON.stringify(item));
291
+ ingester.ingestLines(lines);
292
+ return c.json({ accepted: lines.length });
293
+ }).get("/facets", zValidator("query", facetQuerySchema, validationHook), async (c) => {
294
+ try {
295
+ const params = c.req.valid("query");
296
+ let jsonFilters;
297
+ if (params.jsonFilters) {
298
+ try {
299
+ jsonFilters = JSON.parse(params.jsonFilters);
300
+ } catch {
301
+ }
302
+ }
303
+ const filters = {
304
+ level: params.level,
305
+ service: params.service,
306
+ source: params.source,
307
+ search: params.search,
308
+ startTime: params.startTime,
309
+ endTime: params.endTime,
310
+ jsonFilters
311
+ };
312
+ const distribution = await db.getFacetDistribution(
313
+ params.field,
314
+ params.jsonPath ?? null,
315
+ filters
316
+ );
317
+ return c.json(distribution);
318
+ } catch (err) {
319
+ return c.json(
320
+ {
321
+ error: {
322
+ code: "QUERY_ERROR",
323
+ message: err instanceof Error ? err.message : "Unknown error"
324
+ }
325
+ },
326
+ 500
327
+ );
328
+ }
329
+ });
330
+ return app;
331
+ }
332
+ new Hono().basePath("/api").get(
333
+ "/health",
334
+ (c) => c.json({ status: "ok", uptime: 0 })
335
+ );
336
+ const CREATE_TABLE_SQL = `
337
+ CREATE TABLE IF NOT EXISTS logs (
338
+ _id BIGINT PRIMARY KEY,
339
+ _ingested TIMESTAMP DEFAULT current_timestamp,
340
+ _raw JSON,
341
+ timestamp TIMESTAMP,
342
+ level VARCHAR,
343
+ message VARCHAR,
344
+ service VARCHAR,
345
+ trace_id VARCHAR,
346
+ host VARCHAR,
347
+ duration_ms DOUBLE,
348
+ source VARCHAR DEFAULT 'default'
349
+ );
350
+ `;
351
+ const CREATE_INDEXES_SQL = [
352
+ "CREATE INDEX IF NOT EXISTS idx_level ON logs(level);",
353
+ "CREATE INDEX IF NOT EXISTS idx_ts ON logs(timestamp DESC);",
354
+ "CREATE INDEX IF NOT EXISTS idx_service ON logs(service);",
355
+ "CREATE INDEX IF NOT EXISTS idx_source ON logs(source);"
356
+ ];
357
+ const LOG_COLUMNS = "_id, _ingested, _raw, timestamp, level, message, service, trace_id, host, duration_ms, source";
358
+ const ALLOWED_SQL_KEYWORDS = ["SELECT", "WITH", "EXPLAIN"];
359
+ class LogDatabase {
360
+ instance = null;
361
+ connection = null;
362
+ // -------------------------------------------------------------------------
363
+ // Lifecycle
364
+ // -------------------------------------------------------------------------
365
+ async initialize(dbPath) {
366
+ if (dbPath === ":memory:") {
367
+ this.instance = await DuckDBInstance.create();
368
+ } else {
369
+ this.instance = await DuckDBInstance.create(dbPath);
370
+ }
371
+ this.connection = await this.instance.connect();
372
+ await this.connection.run(CREATE_TABLE_SQL);
373
+ for (const sql of CREATE_INDEXES_SQL) {
374
+ await this.connection.run(sql);
375
+ }
376
+ }
377
+ async close() {
378
+ if (this.connection) {
379
+ this.connection.closeSync();
380
+ this.connection = null;
381
+ }
382
+ if (this.instance) {
383
+ this.instance.closeSync();
384
+ this.instance = null;
385
+ }
386
+ }
387
+ getConnection() {
388
+ if (!this.connection) {
389
+ throw new Error("Database is not initialized or has been closed");
390
+ }
391
+ return this.connection;
392
+ }
393
+ // -------------------------------------------------------------------------
394
+ // Batch INSERT
395
+ // -------------------------------------------------------------------------
396
+ async insertBatch(logs) {
397
+ if (logs.length === 0) return;
398
+ const conn = this.getConnection();
399
+ const appender = await conn.createAppender("logs");
400
+ for (const log of logs) {
401
+ appender.appendBigInt(log._id);
402
+ appender.appendTimestamp(dateToTimestamp(log._ingested));
403
+ appender.appendVarchar(log._raw);
404
+ appendNullableTimestamp(appender, log.timestamp);
405
+ appendNullableVarchar(appender, log.level);
406
+ appendNullableVarchar(appender, log.message);
407
+ appendNullableVarchar(appender, log.service);
408
+ appendNullableVarchar(appender, log.trace_id);
409
+ appendNullableVarchar(appender, log.host);
410
+ appendNullableDouble(appender, log.duration_ms);
411
+ appender.appendVarchar(log.source);
412
+ appender.endRow();
413
+ }
414
+ appender.flushSync();
415
+ appender.closeSync();
416
+ }
417
+ // -------------------------------------------------------------------------
418
+ // Eviction
419
+ // -------------------------------------------------------------------------
420
+ async evictOldRows(maxRows) {
421
+ const conn = this.getConnection();
422
+ await conn.run(
423
+ `DELETE FROM logs WHERE _id <= (SELECT MAX(_id) - ${maxRows} FROM logs)`
424
+ );
425
+ }
426
+ // -------------------------------------------------------------------------
427
+ // Query
428
+ // -------------------------------------------------------------------------
429
+ async queryLogs(params) {
430
+ const conn = this.getConnection();
431
+ const whereClause = buildWhereClause(params);
432
+ const countReader = await conn.runAndReadAll(
433
+ `SELECT COUNT(*) FROM logs ${whereClause}`
434
+ );
435
+ const total = Number(countReader.getRowsJS()[0][0]);
436
+ const orderDir = params.order === "asc" ? "ASC" : "DESC";
437
+ const dataReader = await conn.runAndReadAll(
438
+ `SELECT ${LOG_COLUMNS} FROM logs ${whereClause}
439
+ ORDER BY timestamp ${orderDir} NULLS LAST, _id ${orderDir}
440
+ LIMIT ${params.limit} OFFSET ${params.offset}`
441
+ );
442
+ const logs = dataReader.getRowsJS().map(rowToLogEntry);
443
+ return { logs, total };
444
+ }
445
+ // -------------------------------------------------------------------------
446
+ // Statistics
447
+ // -------------------------------------------------------------------------
448
+ async getStats(params) {
449
+ const conn = this.getConnection();
450
+ const whereClause = params?.source ? `WHERE source = '${escapeSql(params.source)}'` : "";
451
+ const totalReader = await conn.runAndReadAll(
452
+ `SELECT COUNT(*) FROM logs ${whereClause}`
453
+ );
454
+ const total = Number(totalReader.getRowsJS()[0][0]);
455
+ if (total === 0) {
456
+ return {
457
+ total: 0,
458
+ byLevel: {},
459
+ errorRate: 0,
460
+ timeRange: { min: null, max: null }
461
+ };
462
+ }
463
+ const levelWhereClause = whereClause ? `${whereClause} AND level IS NOT NULL` : "WHERE level IS NOT NULL";
464
+ const levelReader = await conn.runAndReadAll(
465
+ `SELECT level, COUNT(*) as cnt FROM logs ${levelWhereClause} GROUP BY level ORDER BY cnt DESC`
466
+ );
467
+ const byLevel = {};
468
+ for (const row of levelReader.getRowsJS()) {
469
+ byLevel[row[0]] = Number(row[1]);
470
+ }
471
+ const errorCount = (byLevel["ERROR"] ?? 0) + (byLevel["FATAL"] ?? 0);
472
+ const errorRate = errorCount / total;
473
+ const timeReader = await conn.runAndReadAll(
474
+ `SELECT MIN(timestamp), MAX(timestamp) FROM logs ${whereClause}`
475
+ );
476
+ const timeRow = timeReader.getRowsJS()[0];
477
+ return {
478
+ total,
479
+ byLevel,
480
+ errorRate,
481
+ timeRange: {
482
+ min: timeRow[0] !== null ? jsToDate(timeRow[0]) : null,
483
+ max: timeRow[1] !== null ? jsToDate(timeRow[1]) : null
484
+ }
485
+ };
486
+ }
487
+ // -------------------------------------------------------------------------
488
+ // Facets
489
+ // -------------------------------------------------------------------------
490
+ async getFacetDistribution(field, jsonPath, filters) {
491
+ const conn = this.getConnection();
492
+ const columnExpr = jsonPath !== null ? `CAST(json_extract(_raw, '${escapeSql("$." + jsonPath)}') AS VARCHAR)` : `"${field}"`;
493
+ const conditions = buildFilterConditions(filters);
494
+ conditions.push(`${columnExpr} IS NOT NULL`);
495
+ const whereClause = `WHERE ${conditions.join(" AND ")}`;
496
+ const reader = await conn.runAndReadAll(
497
+ `SELECT ${columnExpr} as val, COUNT(*) as cnt FROM logs ${whereClause} GROUP BY val ORDER BY cnt DESC, val ASC`
498
+ );
499
+ const values = reader.getRowsJS().map((row) => {
500
+ let value = String(row[0]);
501
+ if (value.startsWith('"') && value.endsWith('"')) {
502
+ value = value.slice(1, -1);
503
+ }
504
+ return { value, count: Number(row[1]) };
505
+ });
506
+ return { field, values };
507
+ }
508
+ // -------------------------------------------------------------------------
509
+ // Custom SQL Execution
510
+ // -------------------------------------------------------------------------
511
+ async executeQuery(sql) {
512
+ const conn = this.getConnection();
513
+ const firstKeyword = sql.trimStart().split(/\s+/)[0].toUpperCase();
514
+ if (!ALLOWED_SQL_KEYWORDS.includes(firstKeyword)) {
515
+ throw new Error(
516
+ `Forbidden SQL: only SELECT, WITH, and EXPLAIN statements are allowed. Got: ${firstKeyword}`
517
+ );
518
+ }
519
+ const reader = await conn.runAndReadAll(sql);
520
+ const columns = reader.columnNames();
521
+ const rows = reader.getRowsJS().map(
522
+ (row) => row.map((val) => typeof val === "bigint" ? Number(val) : val)
523
+ );
524
+ return { columns, rows };
525
+ }
526
+ // -------------------------------------------------------------------------
527
+ // Export
528
+ // -------------------------------------------------------------------------
529
+ async exportLogs(params, format) {
530
+ const conn = this.getConnection();
531
+ const whereClause = buildWhereClause(params);
532
+ const orderDir = params.order === "asc" ? "ASC" : "DESC";
533
+ const reader = await conn.runAndReadAll(
534
+ `SELECT ${LOG_COLUMNS} FROM logs ${whereClause}
535
+ ORDER BY timestamp ${orderDir} NULLS LAST, _id ${orderDir}
536
+ LIMIT ${params.limit} OFFSET ${params.offset}`
537
+ );
538
+ const columns = reader.columnNames();
539
+ const rows = reader.getRowsJS();
540
+ const encoder = new TextEncoder();
541
+ if (format === "csv") {
542
+ return new ReadableStream({
543
+ start(controller) {
544
+ controller.enqueue(encoder.encode(columns.join(",") + "\n"));
545
+ for (const row of rows) {
546
+ controller.enqueue(
547
+ encoder.encode(row.map(csvEscape).join(",") + "\n")
548
+ );
549
+ }
550
+ controller.close();
551
+ }
552
+ });
553
+ }
554
+ const jsonRows = rows.map((row) => {
555
+ const obj = {};
556
+ for (let i = 0; i < columns.length; i++) {
557
+ let val = row[i];
558
+ if (typeof val === "bigint") val = Number(val);
559
+ if (columns[i] === "_raw" && typeof val === "string") {
560
+ try {
561
+ val = JSON.parse(val);
562
+ } catch {
563
+ }
564
+ }
565
+ obj[columns[i]] = val;
566
+ }
567
+ return obj;
568
+ });
569
+ return new ReadableStream({
570
+ start(controller) {
571
+ controller.enqueue(encoder.encode(JSON.stringify(jsonRows)));
572
+ controller.close();
573
+ }
574
+ });
575
+ }
576
+ // -------------------------------------------------------------------------
577
+ // Schema
578
+ // -------------------------------------------------------------------------
579
+ async getSchema() {
580
+ const conn = this.getConnection();
581
+ const reader = await conn.runAndReadAll(
582
+ "SELECT column_name, data_type, is_nullable FROM information_schema.columns WHERE table_name = 'logs' ORDER BY ordinal_position"
583
+ );
584
+ return reader.getRowsJS().map((row) => ({
585
+ name: row[0],
586
+ type: row[1],
587
+ nullable: row[2] === "YES"
588
+ }));
589
+ }
590
+ }
591
+ function dateToTimestamp(date) {
592
+ return new DuckDBTimestampValue(BigInt(date.getTime()) * 1000n);
593
+ }
594
+ function jsToDate(value) {
595
+ if (value instanceof Date) return value;
596
+ if (typeof value === "string" || typeof value === "number") return new Date(value);
597
+ return new Date(String(value));
598
+ }
599
+ function escapeSql(value) {
600
+ return value.replace(/'/g, "''");
601
+ }
602
+ function csvEscape(value) {
603
+ if (value === null || value === void 0) return "";
604
+ const str = String(value);
605
+ if (str.includes(",") || str.includes('"') || str.includes("\n")) {
606
+ return '"' + str.replace(/"/g, '""') + '"';
607
+ }
608
+ return str;
609
+ }
610
+ function appendNullableVarchar(appender, value) {
611
+ if (value !== null) {
612
+ appender.appendVarchar(value);
613
+ } else {
614
+ appender.appendNull();
615
+ }
616
+ }
617
+ function appendNullableTimestamp(appender, value) {
618
+ if (value !== null) {
619
+ appender.appendTimestamp(dateToTimestamp(value));
620
+ } else {
621
+ appender.appendNull();
622
+ }
623
+ }
624
+ function appendNullableDouble(appender, value) {
625
+ if (value !== null) {
626
+ appender.appendDouble(value);
627
+ } else {
628
+ appender.appendNull();
629
+ }
630
+ }
631
+ function buildFilterConditions(params) {
632
+ const conditions = [];
633
+ if (params.level) {
634
+ conditions.push(`level = '${escapeSql(params.level)}'`);
635
+ }
636
+ if (params.service) {
637
+ conditions.push(`service = '${escapeSql(params.service)}'`);
638
+ }
639
+ if (params.source) {
640
+ conditions.push(`source = '${escapeSql(params.source)}'`);
641
+ }
642
+ if (params.search) {
643
+ conditions.push(`message ILIKE '%${escapeSql(params.search)}%'`);
644
+ }
645
+ if (params.startTime) {
646
+ conditions.push(`timestamp >= '${params.startTime.toISOString()}'`);
647
+ }
648
+ if (params.endTime) {
649
+ conditions.push(`timestamp <= '${params.endTime.toISOString()}'`);
650
+ }
651
+ if (params.jsonFilters) {
652
+ for (const [jsonPath, value] of Object.entries(params.jsonFilters)) {
653
+ const expr = `CAST(json_extract(_raw, '${escapeSql("$." + jsonPath)}') AS VARCHAR)`;
654
+ conditions.push(`${expr} = '${escapeSql(value)}'`);
655
+ }
656
+ }
657
+ return conditions;
658
+ }
659
+ function buildWhereClause(params) {
660
+ const conditions = buildFilterConditions(params);
661
+ return conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
662
+ }
663
+ function rowToLogEntry(row) {
664
+ return {
665
+ _id: BigInt(row[0]),
666
+ _ingested: jsToDate(row[1]),
667
+ _raw: typeof row[2] === "string" ? JSON.parse(row[2]) : row[2],
668
+ timestamp: row[3] !== null ? jsToDate(row[3]) : null,
669
+ level: row[4],
670
+ message: row[5],
671
+ service: row[6],
672
+ trace_id: row[7],
673
+ host: row[8],
674
+ duration_ms: row[9],
675
+ source: row[10]
676
+ };
677
+ }
678
+ const FIELD_MAPPINGS = {
679
+ timestamp: ["timestamp", "ts", "time", "@timestamp", "datetime"],
680
+ level: ["level", "severity", "loglevel", "lvl", "priority"],
681
+ message: ["message", "msg", "body", "text"],
682
+ service: ["service", "svc", "app", "component", "logger"],
683
+ trace_id: ["trace_id", "traceId", "request_id", "correlation_id"]
684
+ };
685
+ const REVERSE_FIELD_MAP = (() => {
686
+ const map = /* @__PURE__ */ new Map();
687
+ for (const [canonical, aliases] of Object.entries(FIELD_MAPPINGS)) {
688
+ for (const alias of aliases) {
689
+ map.set(alias, canonical);
690
+ }
691
+ }
692
+ return map;
693
+ })();
694
+ function resolveField(fieldName) {
695
+ return REVERSE_FIELD_MAP.get(fieldName) ?? null;
696
+ }
697
+ class Ingester {
698
+ db;
699
+ options;
700
+ emitter = new EventEmitter();
701
+ nextId = 1n;
702
+ buffer = [];
703
+ flushTimer = null;
704
+ rl = null;
705
+ stopped = false;
706
+ flushing = null;
707
+ constructor(db, options) {
708
+ this.db = db;
709
+ this.options = options;
710
+ }
711
+ // -------------------------------------------------------------------------
712
+ // Public API: IngesterService interface
713
+ // -------------------------------------------------------------------------
714
+ /**
715
+ * Start the periodic flush timer without binding to a readable stream.
716
+ * Use this when ingesting logs via `ingestLines()` (e.g. HTTP endpoint).
717
+ */
718
+ startTimer() {
719
+ this.stopped = false;
720
+ this.resetFlushTimer();
721
+ }
722
+ /**
723
+ * Feed JSON lines directly (without a stream).
724
+ * Each string is processed through the same normalization pipeline as stdin.
725
+ */
726
+ ingestLines(lines) {
727
+ for (const line of lines) {
728
+ this.handleLine(line);
729
+ }
730
+ if (this.flushTimer === null && !this.stopped) {
731
+ this.resetFlushTimer();
732
+ }
733
+ }
734
+ start(input) {
735
+ this.startTimer();
736
+ this.rl = createInterface({
737
+ input,
738
+ crlfDelay: Infinity
739
+ });
740
+ this.rl.on("line", (line) => {
741
+ this.handleLine(line);
742
+ });
743
+ this.rl.on("close", () => {
744
+ this.flushBuffer();
745
+ });
746
+ }
747
+ async stop() {
748
+ if (this.stopped) return;
749
+ this.stopped = true;
750
+ if (this.flushTimer !== null) {
751
+ clearTimeout(this.flushTimer);
752
+ this.flushTimer = null;
753
+ }
754
+ if (this.rl) {
755
+ this.rl.close();
756
+ this.rl = null;
757
+ }
758
+ if (this.flushing) {
759
+ await this.flushing;
760
+ }
761
+ await this.flushBuffer();
762
+ }
763
+ on(event, listener) {
764
+ this.emitter.on(event, listener);
765
+ }
766
+ // -------------------------------------------------------------------------
767
+ // Line processing
768
+ // -------------------------------------------------------------------------
769
+ handleLine(line) {
770
+ const trimmed = line.trim();
771
+ if (trimmed === "") return;
772
+ let parsed;
773
+ try {
774
+ parsed = JSON.parse(trimmed);
775
+ } catch {
776
+ return;
777
+ }
778
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
779
+ return;
780
+ }
781
+ const normalized = this.normalize(parsed, trimmed);
782
+ this.buffer.push(normalized);
783
+ if (this.buffer.length >= this.options.batchSize) {
784
+ this.flushBuffer();
785
+ } else if (this.flushTimer === null && !this.stopped) {
786
+ this.resetFlushTimer();
787
+ }
788
+ }
789
+ // -------------------------------------------------------------------------
790
+ // Normalization
791
+ // -------------------------------------------------------------------------
792
+ normalize(obj, raw) {
793
+ const resolved = {};
794
+ for (const [key, value] of Object.entries(obj)) {
795
+ const canonical = resolveField(key);
796
+ if (canonical !== null && !(canonical in resolved)) {
797
+ resolved[canonical] = value;
798
+ }
799
+ }
800
+ let timestamp = null;
801
+ if (resolved.timestamp !== void 0 && resolved.timestamp !== null) {
802
+ const d = new Date(resolved.timestamp);
803
+ if (!isNaN(d.getTime())) {
804
+ timestamp = d;
805
+ }
806
+ }
807
+ let duration_ms = null;
808
+ if (obj.duration_ms !== void 0 && obj.duration_ms !== null) {
809
+ const n = Number(obj.duration_ms);
810
+ if (!isNaN(n)) {
811
+ duration_ms = n;
812
+ }
813
+ }
814
+ const source = typeof obj.source === "string" && obj.source !== "" ? obj.source : this.options.defaultSource;
815
+ const host = typeof obj.host === "string" && obj.host !== "" ? obj.host : null;
816
+ const id = this.nextId++;
817
+ return {
818
+ _id: id,
819
+ _ingested: /* @__PURE__ */ new Date(),
820
+ _raw: raw,
821
+ timestamp,
822
+ level: typeof resolved.level === "string" ? resolved.level : null,
823
+ message: typeof resolved.message === "string" ? resolved.message : null,
824
+ service: typeof resolved.service === "string" ? resolved.service : null,
825
+ trace_id: typeof resolved.trace_id === "string" ? resolved.trace_id : null,
826
+ host,
827
+ duration_ms,
828
+ source
829
+ };
830
+ }
831
+ // -------------------------------------------------------------------------
832
+ // Buffer flush
833
+ // -------------------------------------------------------------------------
834
+ flushBuffer() {
835
+ if (this.buffer.length === 0) return;
836
+ const batch = this.buffer.slice();
837
+ this.buffer = [];
838
+ this.resetFlushTimer();
839
+ const prev = this.flushing ?? Promise.resolve();
840
+ this.flushing = prev.then(() => this.doFlush(batch));
841
+ }
842
+ async doFlush(batch) {
843
+ try {
844
+ await this.db.insertBatch(batch);
845
+ await this.db.evictOldRows(this.options.maxRows);
846
+ this.emitter.emit("batch", batch);
847
+ } catch (err) {
848
+ console.error("[ingester] doFlush error:", err);
849
+ }
850
+ }
851
+ // -------------------------------------------------------------------------
852
+ // Flush timer management
853
+ // -------------------------------------------------------------------------
854
+ resetFlushTimer() {
855
+ if (this.flushTimer !== null) {
856
+ clearTimeout(this.flushTimer);
857
+ }
858
+ if (!this.stopped) {
859
+ this.flushTimer = setTimeout(() => {
860
+ this.flushTimer = null;
861
+ this.flushBuffer();
862
+ }, this.options.flushIntervalMs);
863
+ }
864
+ }
865
+ }
866
+ function matchesFilter(log, filter) {
867
+ if (filter.level !== void 0 && log.level !== filter.level) {
868
+ return false;
869
+ }
870
+ if (filter.service !== void 0 && log.service !== filter.service) {
871
+ return false;
872
+ }
873
+ if (filter.source !== void 0 && log.source !== filter.source) {
874
+ return false;
875
+ }
876
+ if (filter.search !== void 0 && filter.search !== "") {
877
+ const needle = filter.search.toLowerCase();
878
+ if (!log.message?.toLowerCase().includes(needle)) {
879
+ return false;
880
+ }
881
+ }
882
+ return true;
883
+ }
884
+ function serializeLog(log) {
885
+ let rawParsed;
886
+ try {
887
+ rawParsed = JSON.parse(log._raw);
888
+ } catch {
889
+ rawParsed = {};
890
+ }
891
+ return {
892
+ _id: String(log._id),
893
+ _ingested: log._ingested.toISOString(),
894
+ _raw: rawParsed,
895
+ timestamp: log.timestamp?.toISOString() ?? null,
896
+ level: log.level,
897
+ message: log.message,
898
+ service: log.service,
899
+ trace_id: log.trace_id,
900
+ host: log.host,
901
+ duration_ms: log.duration_ms,
902
+ source: log.source
903
+ };
904
+ }
905
+ function serializeStats(stats) {
906
+ return {
907
+ total: stats.total,
908
+ byLevel: stats.byLevel,
909
+ errorRate: stats.errorRate,
910
+ timeRange: {
911
+ min: stats.timeRange.min?.toISOString() ?? null,
912
+ max: stats.timeRange.max?.toISOString() ?? null
913
+ }
914
+ };
915
+ }
916
+ class WSHandler {
917
+ clients = /* @__PURE__ */ new Map();
918
+ db = null;
919
+ /** Number of currently connected clients */
920
+ get clientCount() {
921
+ return this.clients.size;
922
+ }
923
+ /**
924
+ * Set the LogDatabase reference for stats retrieval during broadcast.
925
+ */
926
+ setDatabase(db) {
927
+ this.db = db;
928
+ }
929
+ /**
930
+ * Subscribe to an Ingester's "batch" event.
931
+ * When a batch is ingested, broadcasts matching logs to clients.
932
+ */
933
+ subscribe(ingester) {
934
+ ingester.on("batch", async (logs) => {
935
+ let stats = null;
936
+ if (this.db) {
937
+ try {
938
+ stats = await this.db.getStats();
939
+ } catch {
940
+ }
941
+ }
942
+ if (stats) {
943
+ this.broadcast([...logs], stats);
944
+ }
945
+ });
946
+ }
947
+ /**
948
+ * Handle a new WebSocket connection.
949
+ * Registers the client with an empty (match-all) filter.
950
+ */
951
+ handleConnection(ws) {
952
+ this.clients.set(ws, {});
953
+ }
954
+ /**
955
+ * Handle a message from a WebSocket client.
956
+ * Expects JSON messages of type WSClientMessage.
957
+ * Invalid messages are silently ignored.
958
+ */
959
+ handleMessage(ws, message) {
960
+ if (!this.clients.has(ws)) return;
961
+ let parsed;
962
+ try {
963
+ parsed = JSON.parse(message);
964
+ } catch {
965
+ return;
966
+ }
967
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
968
+ return;
969
+ }
970
+ const msg = parsed;
971
+ if (msg.type !== "filter") {
972
+ return;
973
+ }
974
+ if (typeof msg.filter !== "object" || msg.filter === null || Array.isArray(msg.filter)) {
975
+ return;
976
+ }
977
+ const filterObj = msg.filter;
978
+ const filter = {};
979
+ if (typeof filterObj.level === "string") {
980
+ filter.level = filterObj.level;
981
+ }
982
+ if (typeof filterObj.service === "string") {
983
+ filter.service = filterObj.service;
984
+ }
985
+ if (typeof filterObj.source === "string") {
986
+ filter.source = filterObj.source;
987
+ }
988
+ if (typeof filterObj.search === "string") {
989
+ filter.search = filterObj.search;
990
+ }
991
+ this.clients.set(ws, filter);
992
+ }
993
+ /**
994
+ * Handle WebSocket connection close.
995
+ * Removes the client from the registry.
996
+ */
997
+ handleClose(ws) {
998
+ this.clients.delete(ws);
999
+ }
1000
+ /**
1001
+ * Get the current filter for a client.
1002
+ * Returns undefined if the client is not registered.
1003
+ */
1004
+ getClientFilter(ws) {
1005
+ return this.clients.get(ws);
1006
+ }
1007
+ /**
1008
+ * Broadcast new logs and stats to all connected clients.
1009
+ * Logs are filtered per-client based on their active filter.
1010
+ * Stats are always sent to all clients.
1011
+ * If no logs match a client's filter, only stats are sent.
1012
+ */
1013
+ broadcast(logs, stats) {
1014
+ const serializedStats = JSON.stringify({
1015
+ type: "stats",
1016
+ data: serializeStats(stats)
1017
+ });
1018
+ for (const [ws, filter] of this.clients) {
1019
+ if (ws.readyState !== 1) continue;
1020
+ const matched = logs.filter((log) => matchesFilter(log, filter));
1021
+ if (matched.length > 0) {
1022
+ const logsMessage = JSON.stringify({
1023
+ type: "logs",
1024
+ data: matched.map(serializeLog)
1025
+ });
1026
+ ws.send(logsMessage);
1027
+ }
1028
+ ws.send(serializedStats);
1029
+ }
1030
+ }
1031
+ }
1032
+ function parseCLIArgs(args) {
1033
+ const { values } = parseArgs({
1034
+ args,
1035
+ options: {
1036
+ port: { type: "string", short: "p" },
1037
+ "max-rows": { type: "string", short: "m" },
1038
+ "batch-size": { type: "string" },
1039
+ db: { type: "string" },
1040
+ "no-ui": { type: "boolean" },
1041
+ help: { type: "boolean", short: "h" }
1042
+ },
1043
+ strict: true
1044
+ });
1045
+ const result = {
1046
+ port: values.port !== void 0 ? parseInt(values.port, 10) : 8080,
1047
+ maxRows: values["max-rows"] !== void 0 ? parseInt(values["max-rows"], 10) : 1e5,
1048
+ batchSize: values["batch-size"] !== void 0 ? parseInt(values["batch-size"], 10) : 5e3,
1049
+ db: values.db ?? ":memory:",
1050
+ noUi: values["no-ui"] ?? false
1051
+ };
1052
+ if (values.help) {
1053
+ result.help = true;
1054
+ }
1055
+ return result;
1056
+ }
1057
+ const USAGE = `
1058
+ Usage: <command> | lduck [options]
1059
+
1060
+ Options:
1061
+ -p, --port <port> Server port (default: 8080)
1062
+ -m, --max-rows <n> Maximum rows to keep (default: 100000)
1063
+ --batch-size <n> Batch INSERT size (default: 5000)
1064
+ --db <path> DuckDB persistence path (default: :memory:)
1065
+ --no-ui Disable Web UI, API server only
1066
+ -h, --help Show this help message
1067
+
1068
+ Examples:
1069
+ kubectl logs -f deploy/api | lduck --port 8080
1070
+ cat app.log | lduck --db ./logs.duckdb
1071
+ docker logs -f myapp | lduck --no-ui -p 9090
1072
+ `.trimStart();
1073
+ async function main() {
1074
+ const opts = parseCLIArgs(process.argv.slice(2));
1075
+ if (opts.help) {
1076
+ process.stdout.write(USAGE);
1077
+ process.exit(0);
1078
+ }
1079
+ const db = new LogDatabase();
1080
+ await db.initialize(opts.db);
1081
+ const ingester = new Ingester(db, {
1082
+ batchSize: opts.batchSize,
1083
+ flushIntervalMs: 500,
1084
+ maxRows: opts.maxRows,
1085
+ defaultSource: "default"
1086
+ });
1087
+ const wsHandler = new WSHandler();
1088
+ wsHandler.setDatabase(db);
1089
+ wsHandler.subscribe(ingester);
1090
+ const apiApp = createApiApp(db, ingester);
1091
+ const wsInit = { app: null };
1092
+ const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket(wsInit);
1093
+ const fullApp = createApp("/static/client.js", (app) => {
1094
+ app.route("/", apiApp);
1095
+ app.get(
1096
+ "/api/ws/tail",
1097
+ upgradeWebSocket(() => ({
1098
+ onOpen(_evt, ws) {
1099
+ wsHandler.handleConnection(ws);
1100
+ },
1101
+ onMessage(evt, ws) {
1102
+ wsHandler.handleMessage(ws, evt.data.toString());
1103
+ },
1104
+ onClose(_evt, ws) {
1105
+ wsHandler.handleClose(ws);
1106
+ }
1107
+ }))
1108
+ );
1109
+ if (!opts.noUi) {
1110
+ app.use("/static/*", serveStatic({ root: import.meta.dirname }));
1111
+ }
1112
+ });
1113
+ wsInit.app = fullApp;
1114
+ const server = serve(
1115
+ { fetch: fullApp.fetch, port: opts.port, hostname: "127.0.0.1" },
1116
+ (info) => {
1117
+ console.log(`lduck server listening on http://localhost:${info.port}`);
1118
+ }
1119
+ );
1120
+ injectWebSocket(server);
1121
+ ingester.start(process.stdin);
1122
+ }
1123
+ if (!process.env.VITEST) {
1124
+ main().catch((err) => {
1125
+ console.error("Failed to start lduck:", err);
1126
+ process.exit(1);
1127
+ });
1128
+ }
1129
+ export {
1130
+ parseCLIArgs
1131
+ };