trickle-backend 0.1.63 → 0.1.65

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/Dockerfile ADDED
@@ -0,0 +1,23 @@
1
+ FROM node:20-alpine
2
+
3
+ WORKDIR /app
4
+
5
+ # Install dependencies
6
+ COPY package.json package-lock.json* ./
7
+ RUN npm ci --production 2>/dev/null || npm install --production
8
+
9
+ # Copy built files
10
+ COPY dist/ dist/
11
+
12
+ # Create data directory
13
+ RUN mkdir -p /data
14
+
15
+ ENV PORT=4888
16
+ ENV TRICKLE_DB_PATH=/data/trickle.db
17
+
18
+ EXPOSE 4888
19
+
20
+ HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
21
+ CMD wget -qO- http://localhost:4888/api/health || exit 1
22
+
23
+ CMD ["node", "dist/index.js"]
@@ -0,0 +1,2 @@
1
+ import type Database from "better-sqlite3";
2
+ export declare function runCloudMigrations(db: Database.Database): void;
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runCloudMigrations = runCloudMigrations;
4
+ function runCloudMigrations(db) {
5
+ db.exec(`
6
+ -- Projects table: each project is an isolated workspace
7
+ CREATE TABLE IF NOT EXISTS projects (
8
+ id TEXT PRIMARY KEY,
9
+ name TEXT NOT NULL,
10
+ owner_key_id TEXT NOT NULL,
11
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
12
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
13
+ settings TEXT DEFAULT '{}'
14
+ );
15
+
16
+ -- API keys for authentication
17
+ CREATE TABLE IF NOT EXISTS api_keys (
18
+ id TEXT PRIMARY KEY,
19
+ key_hash TEXT NOT NULL UNIQUE,
20
+ key_prefix TEXT NOT NULL,
21
+ name TEXT NOT NULL DEFAULT 'default',
22
+ owner_email TEXT,
23
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
24
+ last_used_at TEXT,
25
+ revoked INTEGER NOT NULL DEFAULT 0
26
+ );
27
+
28
+ -- Project data: stores all JSONL/JSON files per project
29
+ CREATE TABLE IF NOT EXISTS project_data (
30
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
31
+ project_id TEXT NOT NULL REFERENCES projects(id),
32
+ filename TEXT NOT NULL,
33
+ content TEXT NOT NULL,
34
+ size_bytes INTEGER NOT NULL DEFAULT 0,
35
+ pushed_at TEXT NOT NULL DEFAULT (datetime('now')),
36
+ UNIQUE(project_id, filename)
37
+ );
38
+
39
+ -- Push history for audit trail
40
+ CREATE TABLE IF NOT EXISTS push_history (
41
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
42
+ project_id TEXT NOT NULL REFERENCES projects(id),
43
+ key_id TEXT NOT NULL,
44
+ file_count INTEGER NOT NULL,
45
+ total_bytes INTEGER NOT NULL,
46
+ pushed_at TEXT NOT NULL DEFAULT (datetime('now'))
47
+ );
48
+
49
+ -- Shared dashboards (public read-only links)
50
+ CREATE TABLE IF NOT EXISTS share_links (
51
+ id TEXT PRIMARY KEY,
52
+ project_id TEXT NOT NULL REFERENCES projects(id),
53
+ created_by TEXT NOT NULL,
54
+ expires_at TEXT,
55
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
56
+ );
57
+
58
+ CREATE INDEX IF NOT EXISTS idx_project_data_project ON project_data(project_id);
59
+ CREATE INDEX IF NOT EXISTS idx_push_history_project ON push_history(project_id);
60
+ CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash);
61
+ `);
62
+ }
@@ -7,9 +7,9 @@ exports.db = void 0;
7
7
  const path_1 = __importDefault(require("path"));
8
8
  const fs_1 = __importDefault(require("fs"));
9
9
  const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
10
- const trickleDir = path_1.default.join(process.env.HOME || "~", ".trickle");
10
+ const dbPath = process.env.TRICKLE_DB_PATH || path_1.default.join(process.env.HOME || "~", ".trickle", "trickle.db");
11
+ const trickleDir = path_1.default.dirname(dbPath);
11
12
  fs_1.default.mkdirSync(trickleDir, { recursive: true });
12
- const dbPath = path_1.default.join(trickleDir, "trickle.db");
13
13
  const db = new better_sqlite3_1.default(dbPath);
14
14
  exports.db = db;
15
15
  db.pragma("journal_mode = WAL");
package/dist/index.js CHANGED
@@ -3,7 +3,9 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const server_1 = require("./server");
4
4
  const connection_1 = require("./db/connection");
5
5
  const migrations_1 = require("./db/migrations");
6
+ const cloud_migrations_1 = require("./db/cloud-migrations");
6
7
  (0, migrations_1.runMigrations)(connection_1.db);
8
+ (0, cloud_migrations_1.runCloudMigrations)(connection_1.db);
7
9
  const PORT = parseInt(process.env.PORT || "4888", 10);
8
10
  server_1.app.listen(PORT, () => {
9
11
  console.log(`[trickle] Backend listening on http://localhost:${PORT}`);
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Cloud API routes — /api/v1/*
3
+ *
4
+ * Provides multi-tenant cloud observability:
5
+ * POST /api/v1/push — Upload .trickle/ data for a project
6
+ * GET /api/v1/pull — Download project data
7
+ * GET /api/v1/projects — List projects for authenticated user
8
+ * POST /api/v1/projects — Create a new project
9
+ * POST /api/v1/keys — Generate a new API key
10
+ * POST /api/v1/share — Create a shareable dashboard link
11
+ * GET /api/v1/shared/:id — View shared dashboard data (no auth required)
12
+ * GET /api/v1/dashboard/:projectId — Get dashboard data for a project
13
+ */
14
+ declare const router: import("express-serve-static-core").Router;
15
+ export default router;
@@ -0,0 +1,465 @@
1
+ "use strict";
2
+ /**
3
+ * Cloud API routes — /api/v1/*
4
+ *
5
+ * Provides multi-tenant cloud observability:
6
+ * POST /api/v1/push — Upload .trickle/ data for a project
7
+ * GET /api/v1/pull — Download project data
8
+ * GET /api/v1/projects — List projects for authenticated user
9
+ * POST /api/v1/projects — Create a new project
10
+ * POST /api/v1/keys — Generate a new API key
11
+ * POST /api/v1/share — Create a shareable dashboard link
12
+ * GET /api/v1/shared/:id — View shared dashboard data (no auth required)
13
+ * GET /api/v1/dashboard/:projectId — Get dashboard data for a project
14
+ */
15
+ var __importDefault = (this && this.__importDefault) || function (mod) {
16
+ return (mod && mod.__esModule) ? mod : { "default": mod };
17
+ };
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ const express_1 = require("express");
20
+ const crypto_1 = __importDefault(require("crypto"));
21
+ const connection_1 = require("../db/connection");
22
+ const router = (0, express_1.Router)();
23
+ // ── Helpers ──
24
+ function generateId() {
25
+ return crypto_1.default.randomBytes(12).toString("hex");
26
+ }
27
+ function generateApiKey() {
28
+ const key = `tk_${crypto_1.default.randomBytes(24).toString("hex")}`;
29
+ const hash = crypto_1.default.createHash("sha256").update(key).digest("hex");
30
+ const prefix = key.slice(0, 7);
31
+ return { key, hash, prefix };
32
+ }
33
+ function hashKey(key) {
34
+ return crypto_1.default.createHash("sha256").update(key).digest("hex");
35
+ }
36
+ function requireAuth(req, res, next) {
37
+ const authHeader = req.headers.authorization;
38
+ if (!authHeader?.startsWith("Bearer ")) {
39
+ res.status(401).json({ error: "Missing or invalid Authorization header. Use: Bearer <api-key>" });
40
+ return;
41
+ }
42
+ const token = authHeader.slice(7);
43
+ const tokenHash = hashKey(token);
44
+ const key = connection_1.db.prepare("SELECT id, name, revoked FROM api_keys WHERE key_hash = ?").get(tokenHash);
45
+ if (!key) {
46
+ res.status(401).json({ error: "Invalid API key" });
47
+ return;
48
+ }
49
+ if (key.revoked) {
50
+ res.status(403).json({ error: "API key has been revoked" });
51
+ return;
52
+ }
53
+ // Update last_used_at
54
+ connection_1.db.prepare("UPDATE api_keys SET last_used_at = datetime('now') WHERE id = ?").run(key.id);
55
+ req.keyId = key.id;
56
+ req.keyName = key.name;
57
+ next();
58
+ }
59
+ // ── POST /api/v1/keys — Generate a new API key ──
60
+ router.post("/keys", (req, res) => {
61
+ const { name, email } = req.body || {};
62
+ const { key, hash, prefix } = generateApiKey();
63
+ const id = generateId();
64
+ connection_1.db.prepare("INSERT INTO api_keys (id, key_hash, key_prefix, name, owner_email) VALUES (?, ?, ?, ?, ?)").run(id, hash, prefix, name || "default", email || null);
65
+ res.status(201).json({
66
+ id,
67
+ key, // Only returned once — user must save it
68
+ prefix,
69
+ name: name || "default",
70
+ message: "Save this key — it cannot be retrieved later.",
71
+ });
72
+ });
73
+ // ── POST /api/v1/ingest — Real-time streaming ingest ──
74
+ // Accepts batched observations and appends to project data files.
75
+ // This enables `trickle run` to stream data to the cloud in real-time.
76
+ router.post("/ingest", requireAuth, (req, res) => {
77
+ const { project, file, lines } = req.body;
78
+ if (!project || !file || !lines) {
79
+ res.status(400).json({ error: "project, file, and lines required" });
80
+ return;
81
+ }
82
+ const projectId = `${req.keyId}:${project}`;
83
+ // Auto-create project
84
+ connection_1.db.prepare(`
85
+ INSERT INTO projects (id, name, owner_key_id, updated_at)
86
+ VALUES (?, ?, ?, datetime('now'))
87
+ ON CONFLICT(id) DO UPDATE SET updated_at = datetime('now')
88
+ `).run(projectId, project, req.keyId);
89
+ // Append to existing content (or create new)
90
+ const existing = connection_1.db.prepare("SELECT content FROM project_data WHERE project_id = ? AND filename = ?").get(projectId, file);
91
+ const newContent = typeof lines === "string" ? lines : lines.join("\n") + "\n";
92
+ const content = existing ? existing.content + newContent : newContent;
93
+ const bytes = Buffer.byteLength(content, "utf-8");
94
+ connection_1.db.prepare(`
95
+ INSERT INTO project_data (project_id, filename, content, size_bytes, pushed_at)
96
+ VALUES (?, ?, ?, ?, datetime('now'))
97
+ ON CONFLICT(project_id, filename) DO UPDATE SET
98
+ content = excluded.content,
99
+ size_bytes = excluded.size_bytes,
100
+ pushed_at = datetime('now')
101
+ `).run(projectId, file, content, bytes);
102
+ res.json({ ok: true, file, bytes });
103
+ });
104
+ // ── POST /api/v1/push — Upload project data (full replace) ──
105
+ router.post("/push", requireAuth, (req, res) => {
106
+ const { project, files, timestamp } = req.body;
107
+ if (!project || typeof project !== "string") {
108
+ res.status(400).json({ error: "project name required" });
109
+ return;
110
+ }
111
+ if (!files || typeof files !== "object") {
112
+ res.status(400).json({ error: "files object required" });
113
+ return;
114
+ }
115
+ const projectId = `${req.keyId}:${project}`;
116
+ // Upsert project
117
+ connection_1.db.prepare(`
118
+ INSERT INTO projects (id, name, owner_key_id, updated_at)
119
+ VALUES (?, ?, ?, datetime('now'))
120
+ ON CONFLICT(id) DO UPDATE SET updated_at = datetime('now')
121
+ `).run(projectId, project, req.keyId);
122
+ // Upsert each file
123
+ const upsertFile = connection_1.db.prepare(`
124
+ INSERT INTO project_data (project_id, filename, content, size_bytes, pushed_at)
125
+ VALUES (?, ?, ?, ?, datetime('now'))
126
+ ON CONFLICT(project_id, filename) DO UPDATE SET
127
+ content = excluded.content,
128
+ size_bytes = excluded.size_bytes,
129
+ pushed_at = datetime('now')
130
+ `);
131
+ let totalBytes = 0;
132
+ let fileCount = 0;
133
+ const insertMany = connection_1.db.transaction(() => {
134
+ for (const [filename, content] of Object.entries(files)) {
135
+ if (typeof content !== "string")
136
+ continue;
137
+ const bytes = Buffer.byteLength(content, "utf-8");
138
+ upsertFile.run(projectId, filename, content, bytes);
139
+ totalBytes += bytes;
140
+ fileCount++;
141
+ }
142
+ });
143
+ insertMany();
144
+ // Record push history
145
+ connection_1.db.prepare("INSERT INTO push_history (project_id, key_id, file_count, total_bytes) VALUES (?, ?, ?, ?)").run(projectId, req.keyId, fileCount, totalBytes);
146
+ const dashboardUrl = `${req.protocol}://${req.get("host")}/api/v1/dashboard/${encodeURIComponent(projectId)}`;
147
+ res.json({
148
+ ok: true,
149
+ project: projectId,
150
+ files: fileCount,
151
+ bytes: totalBytes,
152
+ url: dashboardUrl,
153
+ });
154
+ });
155
+ // ── GET /api/v1/pull — Download project data ──
156
+ router.get("/pull", requireAuth, (req, res) => {
157
+ const project = req.query.project;
158
+ if (!project) {
159
+ res.status(400).json({ error: "project query parameter required" });
160
+ return;
161
+ }
162
+ const projectId = `${req.keyId}:${project}`;
163
+ const rows = connection_1.db.prepare("SELECT filename, content FROM project_data WHERE project_id = ?").all(projectId);
164
+ if (rows.length === 0) {
165
+ res.status(404).json({ error: "No data found for this project" });
166
+ return;
167
+ }
168
+ const files = {};
169
+ for (const row of rows) {
170
+ files[row.filename] = row.content;
171
+ }
172
+ res.json({ project, files, fileCount: rows.length });
173
+ });
174
+ // ── GET /api/v1/projects — List projects ──
175
+ router.get("/projects", requireAuth, (req, res) => {
176
+ const rows = connection_1.db.prepare(`
177
+ SELECT p.id, p.name, p.created_at, p.updated_at,
178
+ (SELECT COUNT(*) FROM project_data pd WHERE pd.project_id = p.id) as file_count,
179
+ (SELECT SUM(pd.size_bytes) FROM project_data pd WHERE pd.project_id = p.id) as total_bytes
180
+ FROM projects p
181
+ WHERE p.owner_key_id = ?
182
+ ORDER BY p.updated_at DESC
183
+ `).all(req.keyId);
184
+ res.json({
185
+ projects: rows.map((r) => ({
186
+ id: r.id,
187
+ name: r.name,
188
+ files: r.file_count || 0,
189
+ size: r.total_bytes || 0,
190
+ createdAt: r.created_at,
191
+ updatedAt: r.updated_at,
192
+ })),
193
+ });
194
+ });
195
+ // ── POST /api/v1/projects — Create project ──
196
+ router.post("/projects", requireAuth, (req, res) => {
197
+ const { name } = req.body;
198
+ if (!name) {
199
+ res.status(400).json({ error: "name required" });
200
+ return;
201
+ }
202
+ const projectId = `${req.keyId}:${name}`;
203
+ connection_1.db.prepare(`
204
+ INSERT INTO projects (id, name, owner_key_id)
205
+ VALUES (?, ?, ?)
206
+ ON CONFLICT(id) DO NOTHING
207
+ `).run(projectId, name, req.keyId);
208
+ res.status(201).json({ id: projectId, name });
209
+ });
210
+ // ── POST /api/v1/share — Create shareable link ──
211
+ router.post("/share", requireAuth, (req, res) => {
212
+ const { project, expiresInHours } = req.body;
213
+ if (!project) {
214
+ res.status(400).json({ error: "project name required" });
215
+ return;
216
+ }
217
+ const projectId = `${req.keyId}:${project}`;
218
+ // Verify project exists
219
+ const proj = connection_1.db.prepare("SELECT id FROM projects WHERE id = ?").get(projectId);
220
+ if (!proj) {
221
+ res.status(404).json({ error: "Project not found" });
222
+ return;
223
+ }
224
+ const shareId = generateId();
225
+ const expiresAt = expiresInHours
226
+ ? new Date(Date.now() + expiresInHours * 3600000).toISOString()
227
+ : null;
228
+ connection_1.db.prepare("INSERT INTO share_links (id, project_id, created_by, expires_at) VALUES (?, ?, ?, ?)").run(shareId, projectId, req.keyId, expiresAt);
229
+ const shareUrl = `${req.protocol}://${req.get("host")}/api/v1/shared/${shareId}`;
230
+ res.status(201).json({
231
+ shareId,
232
+ url: shareUrl,
233
+ expiresAt,
234
+ });
235
+ });
236
+ // ── GET /api/v1/shared/:id — View shared data (no auth) ──
237
+ router.get("/shared/:id", (req, res) => {
238
+ const link = connection_1.db.prepare(`
239
+ SELECT sl.project_id, sl.expires_at, p.name as project_name
240
+ FROM share_links sl
241
+ JOIN projects p ON p.id = sl.project_id
242
+ WHERE sl.id = ?
243
+ `).get(req.params.id);
244
+ if (!link) {
245
+ res.status(404).json({ error: "Share link not found" });
246
+ return;
247
+ }
248
+ if (link.expires_at && new Date(link.expires_at) < new Date()) {
249
+ res.status(410).json({ error: "Share link has expired" });
250
+ return;
251
+ }
252
+ const rows = connection_1.db.prepare("SELECT filename, content FROM project_data WHERE project_id = ?").all(link.project_id);
253
+ const files = {};
254
+ for (const row of rows) {
255
+ files[row.filename] = row.content;
256
+ }
257
+ // If request accepts HTML, serve dashboard
258
+ if (req.accepts("html")) {
259
+ res.send(generateDashboardHtml(link.project_name, files));
260
+ return;
261
+ }
262
+ res.json({ project: link.project_name, files, fileCount: rows.length });
263
+ });
264
+ // ── GET /api/v1/dashboard/:projectId — Authenticated dashboard ──
265
+ router.get("/dashboard/:projectId", requireAuth, (req, res) => {
266
+ const projectId = decodeURIComponent(req.params.projectId);
267
+ // Verify ownership
268
+ const proj = connection_1.db.prepare("SELECT name FROM projects WHERE id = ? AND owner_key_id = ?").get(projectId, req.keyId);
269
+ if (!proj) {
270
+ res.status(404).json({ error: "Project not found" });
271
+ return;
272
+ }
273
+ const rows = connection_1.db.prepare("SELECT filename, content FROM project_data WHERE project_id = ?").all(projectId);
274
+ const files = {};
275
+ for (const row of rows) {
276
+ files[row.filename] = row.content;
277
+ }
278
+ if (req.accepts("html")) {
279
+ res.send(generateDashboardHtml(proj.name, files));
280
+ return;
281
+ }
282
+ res.json({ project: proj.name, files, fileCount: rows.length });
283
+ });
284
+ // ── Dashboard HTML generator ──
285
+ function generateDashboardHtml(projectName, files) {
286
+ // Parse data files
287
+ const parseJsonl = (content) => content.split("\n").filter(Boolean).map(l => { try {
288
+ return JSON.parse(l);
289
+ }
290
+ catch {
291
+ return null;
292
+ } }).filter(Boolean);
293
+ const observations = files["observations.jsonl"] ? parseJsonl(files["observations.jsonl"]) : [];
294
+ const variables = files["variables.jsonl"] ? parseJsonl(files["variables.jsonl"]) : [];
295
+ const calltrace = files["calltrace.jsonl"] ? parseJsonl(files["calltrace.jsonl"]) : [];
296
+ const queries = files["queries.jsonl"] ? parseJsonl(files["queries.jsonl"]) : [];
297
+ const errors = files["errors.jsonl"] ? parseJsonl(files["errors.jsonl"]) : [];
298
+ const alerts = files["alerts.jsonl"] ? parseJsonl(files["alerts.jsonl"]) : [];
299
+ const profile = files["profile.jsonl"] ? parseJsonl(files["profile.jsonl"]) : [];
300
+ let environment = {};
301
+ try {
302
+ if (files["environment.json"])
303
+ environment = JSON.parse(files["environment.json"]);
304
+ }
305
+ catch { }
306
+ const criticalAlerts = alerts.filter((a) => a.severity === "critical");
307
+ const warningAlerts = alerts.filter((a) => a.severity === "warning");
308
+ const endProfile = profile.find((p) => p.event === "end");
309
+ const status = criticalAlerts.length > 0 ? "critical" :
310
+ errors.length > 0 ? "error" :
311
+ warningAlerts.length > 0 ? "warning" : "healthy";
312
+ const statusColors = {
313
+ healthy: "#22c55e", warning: "#eab308", error: "#ef4444", critical: "#dc2626",
314
+ };
315
+ const slowFuncs = observations
316
+ .filter((o) => o.durationMs && o.durationMs > 10)
317
+ .sort((a, b) => (b.durationMs || 0) - (a.durationMs || 0))
318
+ .slice(0, 10);
319
+ return `<!DOCTYPE html>
320
+ <html lang="en">
321
+ <head>
322
+ <meta charset="utf-8">
323
+ <meta name="viewport" content="width=device-width, initial-scale=1">
324
+ <title>${projectName} — trickle dashboard</title>
325
+ <style>
326
+ * { margin: 0; padding: 0; box-sizing: border-box; }
327
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f1117; color: #e5e7eb; line-height: 1.5; }
328
+ .container { max-width: 1200px; margin: 0 auto; padding: 24px; }
329
+ h1 { font-size: 24px; font-weight: 600; margin-bottom: 8px; }
330
+ h2 { font-size: 18px; font-weight: 600; margin: 24px 0 12px; color: #9ca3af; }
331
+ .subtitle { color: #6b7280; font-size: 14px; margin-bottom: 24px; }
332
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }
333
+ .card { background: #1f2937; border-radius: 8px; padding: 16px; border: 1px solid #374151; }
334
+ .card-label { font-size: 12px; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.05em; }
335
+ .card-value { font-size: 28px; font-weight: 700; margin-top: 4px; }
336
+ .status-badge { display: inline-block; padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 600; text-transform: uppercase; }
337
+ table { width: 100%; border-collapse: collapse; }
338
+ th { text-align: left; padding: 8px 12px; font-size: 12px; color: #9ca3af; text-transform: uppercase; border-bottom: 1px solid #374151; }
339
+ td { padding: 8px 12px; border-bottom: 1px solid #1f2937; font-size: 14px; }
340
+ .mono { font-family: 'SF Mono', Monaco, Consolas, monospace; font-size: 13px; }
341
+ .bar { height: 8px; border-radius: 4px; background: #3b82f6; display: inline-block; min-width: 4px; }
342
+ .alert-critical { border-left: 3px solid #ef4444; }
343
+ .alert-warning { border-left: 3px solid #eab308; }
344
+ .error-card { background: #1f2937; border-left: 3px solid #ef4444; padding: 12px; margin-bottom: 8px; border-radius: 4px; }
345
+ .section { background: #1f2937; border-radius: 8px; padding: 16px; border: 1px solid #374151; margin-bottom: 16px; }
346
+ .tag { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; background: #374151; color: #9ca3af; margin-right: 4px; }
347
+ footer { text-align: center; padding: 24px; color: #4b5563; font-size: 12px; }
348
+ a { color: #3b82f6; text-decoration: none; }
349
+ </style>
350
+ </head>
351
+ <body>
352
+ <div class="container">
353
+ <h1>${projectName}</h1>
354
+ <div class="subtitle">
355
+ <span class="status-badge" style="background: ${statusColors[status]}20; color: ${statusColors[status]}">${status.toUpperCase()}</span>
356
+ ${environment.node ? `<span class="tag">Node ${environment.node.version}</span>` : ""}
357
+ ${environment.python ? `<span class="tag">Python ${environment.python}</span>` : ""}
358
+ ${endProfile ? `<span class="tag">${Math.round((endProfile.rssKb || 0) / 1024)}MB RSS</span>` : ""}
359
+ </div>
360
+
361
+ <div class="grid">
362
+ <div class="card">
363
+ <div class="card-label">Functions</div>
364
+ <div class="card-value">${observations.length}</div>
365
+ </div>
366
+ <div class="card">
367
+ <div class="card-label">Variables</div>
368
+ <div class="card-value">${variables.length}</div>
369
+ </div>
370
+ <div class="card">
371
+ <div class="card-label">DB Queries</div>
372
+ <div class="card-value">${queries.length}</div>
373
+ </div>
374
+ <div class="card">
375
+ <div class="card-label">Call Trace</div>
376
+ <div class="card-value">${calltrace.length}</div>
377
+ </div>
378
+ <div class="card">
379
+ <div class="card-label">Errors</div>
380
+ <div class="card-value" style="color: ${errors.length > 0 ? "#ef4444" : "#22c55e"}">${errors.length}</div>
381
+ </div>
382
+ <div class="card">
383
+ <div class="card-label">Alerts</div>
384
+ <div class="card-value" style="color: ${criticalAlerts.length > 0 ? "#ef4444" : warningAlerts.length > 0 ? "#eab308" : "#22c55e"}">${alerts.length}</div>
385
+ </div>
386
+ </div>
387
+
388
+ ${alerts.length > 0 ? `
389
+ <h2>Alerts</h2>
390
+ <div class="section">
391
+ ${alerts.map((a) => `
392
+ <div class="card ${a.severity === "critical" ? "alert-critical" : "alert-warning"}" style="margin-bottom: 8px;">
393
+ <strong>${a.severity === "critical" ? "CRITICAL" : "WARNING"}</strong>: ${escapeHtml(a.message || "")}
394
+ ${a.suggestion ? `<div style="color: #9ca3af; font-size: 13px; margin-top: 4px;">Fix: ${escapeHtml(a.suggestion)}</div>` : ""}
395
+ </div>
396
+ `).join("")}
397
+ </div>` : ""}
398
+
399
+ ${errors.length > 0 ? `
400
+ <h2>Errors</h2>
401
+ <div class="section">
402
+ ${errors.slice(0, 10).map((e) => `
403
+ <div class="error-card">
404
+ <strong class="mono">${escapeHtml(e.type || "Error")}</strong>: ${escapeHtml((e.message || "").substring(0, 200))}
405
+ ${e.function ? `<div style="color: #6b7280; font-size: 12px;">in ${escapeHtml(e.function)}</div>` : ""}
406
+ </div>
407
+ `).join("")}
408
+ </div>` : ""}
409
+
410
+ ${slowFuncs.length > 0 ? `
411
+ <h2>Performance Hotspots</h2>
412
+ <div class="section">
413
+ <table>
414
+ <thead><tr><th>Function</th><th>Module</th><th>Duration</th><th></th></tr></thead>
415
+ <tbody>
416
+ ${slowFuncs.map((f) => {
417
+ const pct = Math.round((f.durationMs / slowFuncs[0].durationMs) * 100);
418
+ return `<tr>
419
+ <td class="mono">${escapeHtml(f.functionName || "?")}</td>
420
+ <td>${escapeHtml(f.module || "?")}</td>
421
+ <td>${f.durationMs?.toFixed(0)}ms</td>
422
+ <td><div class="bar" style="width: ${pct}%"></div></td>
423
+ </tr>`;
424
+ }).join("")}
425
+ </tbody>
426
+ </table>
427
+ </div>` : ""}
428
+
429
+ ${observations.length > 0 ? `
430
+ <h2>Observed Functions</h2>
431
+ <div class="section">
432
+ <table>
433
+ <thead><tr><th>Function</th><th>Module</th><th>Duration</th></tr></thead>
434
+ <tbody>
435
+ ${observations.slice(0, 30).map((o) => `<tr>
436
+ <td class="mono">${escapeHtml(o.functionName || "?")}</td>
437
+ <td>${escapeHtml(o.module || "?")}</td>
438
+ <td>${o.durationMs ? o.durationMs.toFixed(0) + "ms" : "-"}</td>
439
+ </tr>`).join("")}
440
+ </tbody>
441
+ </table>
442
+ </div>` : ""}
443
+
444
+ ${queries.length > 0 ? `
445
+ <h2>Database Queries</h2>
446
+ <div class="section">
447
+ <table>
448
+ <thead><tr><th>Query</th><th>Duration</th></tr></thead>
449
+ <tbody>
450
+ ${queries.slice(0, 20).map((q) => `<tr>
451
+ <td class="mono" style="max-width: 600px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${escapeHtml((q.query || q.sql || "?").substring(0, 100))}</td>
452
+ <td>${q.durationMs ? q.durationMs.toFixed(1) + "ms" : "-"}</td>
453
+ </tr>`).join("")}
454
+ </tbody>
455
+ </table>
456
+ </div>` : ""}
457
+ </div>
458
+ <footer>Powered by <a href="https://github.com/yiheinchai/trickle">trickle</a> — runtime observability</footer>
459
+ </body>
460
+ </html>`;
461
+ }
462
+ function escapeHtml(s) {
463
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
464
+ }
465
+ exports.default = router;
package/dist/server.js CHANGED
@@ -18,6 +18,7 @@ const dashboard_1 = __importDefault(require("./routes/dashboard"));
18
18
  const coverage_1 = __importDefault(require("./routes/coverage"));
19
19
  const audit_1 = __importDefault(require("./routes/audit"));
20
20
  const search_1 = __importDefault(require("./routes/search"));
21
+ const cloud_1 = __importDefault(require("./routes/cloud"));
21
22
  const app = (0, express_1.default)();
22
23
  exports.app = app;
23
24
  app.use((0, cors_1.default)());
@@ -34,6 +35,7 @@ app.use("/dashboard", dashboard_1.default);
34
35
  app.use("/api/coverage", coverage_1.default);
35
36
  app.use("/api/audit", audit_1.default);
36
37
  app.use("/api/search", search_1.default);
38
+ app.use("/api/v1", cloud_1.default);
37
39
  // Health check
38
40
  app.get("/api/health", (_req, res) => {
39
41
  res.json({ ok: true, timestamp: new Date().toISOString() });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trickle-backend",
3
- "version": "0.1.63",
3
+ "version": "0.1.65",
4
4
  "main": "dist/index.js",
5
5
  "scripts": {
6
6
  "build": "tsc",
@@ -0,0 +1,61 @@
1
+ import type Database from "better-sqlite3";
2
+
3
+ export function runCloudMigrations(db: Database.Database): void {
4
+ db.exec(`
5
+ -- Projects table: each project is an isolated workspace
6
+ CREATE TABLE IF NOT EXISTS projects (
7
+ id TEXT PRIMARY KEY,
8
+ name TEXT NOT NULL,
9
+ owner_key_id TEXT NOT NULL,
10
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
11
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
12
+ settings TEXT DEFAULT '{}'
13
+ );
14
+
15
+ -- API keys for authentication
16
+ CREATE TABLE IF NOT EXISTS api_keys (
17
+ id TEXT PRIMARY KEY,
18
+ key_hash TEXT NOT NULL UNIQUE,
19
+ key_prefix TEXT NOT NULL,
20
+ name TEXT NOT NULL DEFAULT 'default',
21
+ owner_email TEXT,
22
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
23
+ last_used_at TEXT,
24
+ revoked INTEGER NOT NULL DEFAULT 0
25
+ );
26
+
27
+ -- Project data: stores all JSONL/JSON files per project
28
+ CREATE TABLE IF NOT EXISTS project_data (
29
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
30
+ project_id TEXT NOT NULL REFERENCES projects(id),
31
+ filename TEXT NOT NULL,
32
+ content TEXT NOT NULL,
33
+ size_bytes INTEGER NOT NULL DEFAULT 0,
34
+ pushed_at TEXT NOT NULL DEFAULT (datetime('now')),
35
+ UNIQUE(project_id, filename)
36
+ );
37
+
38
+ -- Push history for audit trail
39
+ CREATE TABLE IF NOT EXISTS push_history (
40
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
41
+ project_id TEXT NOT NULL REFERENCES projects(id),
42
+ key_id TEXT NOT NULL,
43
+ file_count INTEGER NOT NULL,
44
+ total_bytes INTEGER NOT NULL,
45
+ pushed_at TEXT NOT NULL DEFAULT (datetime('now'))
46
+ );
47
+
48
+ -- Shared dashboards (public read-only links)
49
+ CREATE TABLE IF NOT EXISTS share_links (
50
+ id TEXT PRIMARY KEY,
51
+ project_id TEXT NOT NULL REFERENCES projects(id),
52
+ created_by TEXT NOT NULL,
53
+ expires_at TEXT,
54
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
55
+ );
56
+
57
+ CREATE INDEX IF NOT EXISTS idx_project_data_project ON project_data(project_id);
58
+ CREATE INDEX IF NOT EXISTS idx_push_history_project ON push_history(project_id);
59
+ CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash);
60
+ `);
61
+ }
@@ -2,12 +2,11 @@ import path from "path";
2
2
  import fs from "fs";
3
3
  import Database, { Database as DatabaseType } from "better-sqlite3";
4
4
 
5
- const trickleDir = path.join(process.env.HOME || "~", ".trickle");
5
+ const dbPath = process.env.TRICKLE_DB_PATH || path.join(process.env.HOME || "~", ".trickle", "trickle.db");
6
+ const trickleDir = path.dirname(dbPath);
6
7
 
7
8
  fs.mkdirSync(trickleDir, { recursive: true });
8
9
 
9
- const dbPath = path.join(trickleDir, "trickle.db");
10
-
11
10
  const db: DatabaseType = new Database(dbPath);
12
11
 
13
12
  db.pragma("journal_mode = WAL");
package/src/index.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import { app } from "./server";
2
2
  import { db } from "./db/connection";
3
3
  import { runMigrations } from "./db/migrations";
4
+ import { runCloudMigrations } from "./db/cloud-migrations";
4
5
 
5
6
  runMigrations(db);
7
+ runCloudMigrations(db);
6
8
 
7
9
  const PORT = parseInt(process.env.PORT || "4888", 10);
8
10
 
@@ -0,0 +1,562 @@
1
+ /**
2
+ * Cloud API routes — /api/v1/*
3
+ *
4
+ * Provides multi-tenant cloud observability:
5
+ * POST /api/v1/push — Upload .trickle/ data for a project
6
+ * GET /api/v1/pull — Download project data
7
+ * GET /api/v1/projects — List projects for authenticated user
8
+ * POST /api/v1/projects — Create a new project
9
+ * POST /api/v1/keys — Generate a new API key
10
+ * POST /api/v1/share — Create a shareable dashboard link
11
+ * GET /api/v1/shared/:id — View shared dashboard data (no auth required)
12
+ * GET /api/v1/dashboard/:projectId — Get dashboard data for a project
13
+ */
14
+
15
+ import { Router, Request, Response, NextFunction } from "express";
16
+ import crypto from "crypto";
17
+ import { db } from "../db/connection";
18
+
19
+ const router = Router();
20
+
21
+ // ── Helpers ──
22
+
23
+ function generateId(): string {
24
+ return crypto.randomBytes(12).toString("hex");
25
+ }
26
+
27
+ function generateApiKey(): { key: string; hash: string; prefix: string } {
28
+ const key = `tk_${crypto.randomBytes(24).toString("hex")}`;
29
+ const hash = crypto.createHash("sha256").update(key).digest("hex");
30
+ const prefix = key.slice(0, 7);
31
+ return { key, hash, prefix };
32
+ }
33
+
34
+ function hashKey(key: string): string {
35
+ return crypto.createHash("sha256").update(key).digest("hex");
36
+ }
37
+
38
+ // ── Auth middleware ──
39
+
40
+ interface AuthedRequest extends Request {
41
+ keyId?: string;
42
+ keyName?: string;
43
+ }
44
+
45
+ function requireAuth(req: AuthedRequest, res: Response, next: NextFunction): void {
46
+ const authHeader = req.headers.authorization;
47
+ if (!authHeader?.startsWith("Bearer ")) {
48
+ res.status(401).json({ error: "Missing or invalid Authorization header. Use: Bearer <api-key>" });
49
+ return;
50
+ }
51
+
52
+ const token = authHeader.slice(7);
53
+ const tokenHash = hashKey(token);
54
+
55
+ const key = db.prepare(
56
+ "SELECT id, name, revoked FROM api_keys WHERE key_hash = ?"
57
+ ).get(tokenHash) as any;
58
+
59
+ if (!key) {
60
+ res.status(401).json({ error: "Invalid API key" });
61
+ return;
62
+ }
63
+
64
+ if (key.revoked) {
65
+ res.status(403).json({ error: "API key has been revoked" });
66
+ return;
67
+ }
68
+
69
+ // Update last_used_at
70
+ db.prepare("UPDATE api_keys SET last_used_at = datetime('now') WHERE id = ?").run(key.id);
71
+
72
+ req.keyId = key.id;
73
+ req.keyName = key.name;
74
+ next();
75
+ }
76
+
77
+ // ── POST /api/v1/keys — Generate a new API key ──
78
+
79
+ router.post("/keys", (req: Request, res: Response) => {
80
+ const { name, email } = req.body || {};
81
+ const { key, hash, prefix } = generateApiKey();
82
+ const id = generateId();
83
+
84
+ db.prepare(
85
+ "INSERT INTO api_keys (id, key_hash, key_prefix, name, owner_email) VALUES (?, ?, ?, ?, ?)"
86
+ ).run(id, hash, prefix, name || "default", email || null);
87
+
88
+ res.status(201).json({
89
+ id,
90
+ key, // Only returned once — user must save it
91
+ prefix,
92
+ name: name || "default",
93
+ message: "Save this key — it cannot be retrieved later.",
94
+ });
95
+ });
96
+
97
+ // ── POST /api/v1/ingest — Real-time streaming ingest ──
98
+ // Accepts batched observations and appends to project data files.
99
+ // This enables `trickle run` to stream data to the cloud in real-time.
100
+
101
+ router.post("/ingest", requireAuth, (req: AuthedRequest, res: Response) => {
102
+ const { project, file, lines } = req.body;
103
+
104
+ if (!project || !file || !lines) {
105
+ res.status(400).json({ error: "project, file, and lines required" });
106
+ return;
107
+ }
108
+
109
+ const projectId = `${req.keyId}:${project}`;
110
+
111
+ // Auto-create project
112
+ db.prepare(`
113
+ INSERT INTO projects (id, name, owner_key_id, updated_at)
114
+ VALUES (?, ?, ?, datetime('now'))
115
+ ON CONFLICT(id) DO UPDATE SET updated_at = datetime('now')
116
+ `).run(projectId, project, req.keyId);
117
+
118
+ // Append to existing content (or create new)
119
+ const existing = db.prepare(
120
+ "SELECT content FROM project_data WHERE project_id = ? AND filename = ?"
121
+ ).get(projectId, file) as any;
122
+
123
+ const newContent = typeof lines === "string" ? lines : (lines as string[]).join("\n") + "\n";
124
+ const content = existing ? existing.content + newContent : newContent;
125
+ const bytes = Buffer.byteLength(content, "utf-8");
126
+
127
+ db.prepare(`
128
+ INSERT INTO project_data (project_id, filename, content, size_bytes, pushed_at)
129
+ VALUES (?, ?, ?, ?, datetime('now'))
130
+ ON CONFLICT(project_id, filename) DO UPDATE SET
131
+ content = excluded.content,
132
+ size_bytes = excluded.size_bytes,
133
+ pushed_at = datetime('now')
134
+ `).run(projectId, file, content, bytes);
135
+
136
+ res.json({ ok: true, file, bytes });
137
+ });
138
+
139
+ // ── POST /api/v1/push — Upload project data (full replace) ──
140
+
141
+ router.post("/push", requireAuth, (req: AuthedRequest, res: Response) => {
142
+ const { project, files, timestamp } = req.body;
143
+
144
+ if (!project || typeof project !== "string") {
145
+ res.status(400).json({ error: "project name required" });
146
+ return;
147
+ }
148
+ if (!files || typeof files !== "object") {
149
+ res.status(400).json({ error: "files object required" });
150
+ return;
151
+ }
152
+
153
+ const projectId = `${req.keyId}:${project}`;
154
+
155
+ // Upsert project
156
+ db.prepare(`
157
+ INSERT INTO projects (id, name, owner_key_id, updated_at)
158
+ VALUES (?, ?, ?, datetime('now'))
159
+ ON CONFLICT(id) DO UPDATE SET updated_at = datetime('now')
160
+ `).run(projectId, project, req.keyId);
161
+
162
+ // Upsert each file
163
+ const upsertFile = db.prepare(`
164
+ INSERT INTO project_data (project_id, filename, content, size_bytes, pushed_at)
165
+ VALUES (?, ?, ?, ?, datetime('now'))
166
+ ON CONFLICT(project_id, filename) DO UPDATE SET
167
+ content = excluded.content,
168
+ size_bytes = excluded.size_bytes,
169
+ pushed_at = datetime('now')
170
+ `);
171
+
172
+ let totalBytes = 0;
173
+ let fileCount = 0;
174
+
175
+ const insertMany = db.transaction(() => {
176
+ for (const [filename, content] of Object.entries(files)) {
177
+ if (typeof content !== "string") continue;
178
+ const bytes = Buffer.byteLength(content, "utf-8");
179
+ upsertFile.run(projectId, filename, content, bytes);
180
+ totalBytes += bytes;
181
+ fileCount++;
182
+ }
183
+ });
184
+ insertMany();
185
+
186
+ // Record push history
187
+ db.prepare(
188
+ "INSERT INTO push_history (project_id, key_id, file_count, total_bytes) VALUES (?, ?, ?, ?)"
189
+ ).run(projectId, req.keyId, fileCount, totalBytes);
190
+
191
+ const dashboardUrl = `${req.protocol}://${req.get("host")}/api/v1/dashboard/${encodeURIComponent(projectId)}`;
192
+
193
+ res.json({
194
+ ok: true,
195
+ project: projectId,
196
+ files: fileCount,
197
+ bytes: totalBytes,
198
+ url: dashboardUrl,
199
+ });
200
+ });
201
+
202
+ // ── GET /api/v1/pull — Download project data ──
203
+
204
+ router.get("/pull", requireAuth, (req: AuthedRequest, res: Response) => {
205
+ const project = req.query.project as string;
206
+ if (!project) {
207
+ res.status(400).json({ error: "project query parameter required" });
208
+ return;
209
+ }
210
+
211
+ const projectId = `${req.keyId}:${project}`;
212
+
213
+ const rows = db.prepare(
214
+ "SELECT filename, content FROM project_data WHERE project_id = ?"
215
+ ).all(projectId) as any[];
216
+
217
+ if (rows.length === 0) {
218
+ res.status(404).json({ error: "No data found for this project" });
219
+ return;
220
+ }
221
+
222
+ const files: Record<string, string> = {};
223
+ for (const row of rows) {
224
+ files[row.filename] = row.content;
225
+ }
226
+
227
+ res.json({ project, files, fileCount: rows.length });
228
+ });
229
+
230
+ // ── GET /api/v1/projects — List projects ──
231
+
232
+ router.get("/projects", requireAuth, (req: AuthedRequest, res: Response) => {
233
+ const rows = db.prepare(`
234
+ SELECT p.id, p.name, p.created_at, p.updated_at,
235
+ (SELECT COUNT(*) FROM project_data pd WHERE pd.project_id = p.id) as file_count,
236
+ (SELECT SUM(pd.size_bytes) FROM project_data pd WHERE pd.project_id = p.id) as total_bytes
237
+ FROM projects p
238
+ WHERE p.owner_key_id = ?
239
+ ORDER BY p.updated_at DESC
240
+ `).all(req.keyId) as any[];
241
+
242
+ res.json({
243
+ projects: rows.map((r: any) => ({
244
+ id: r.id,
245
+ name: r.name,
246
+ files: r.file_count || 0,
247
+ size: r.total_bytes || 0,
248
+ createdAt: r.created_at,
249
+ updatedAt: r.updated_at,
250
+ })),
251
+ });
252
+ });
253
+
254
+ // ── POST /api/v1/projects — Create project ──
255
+
256
+ router.post("/projects", requireAuth, (req: AuthedRequest, res: Response) => {
257
+ const { name } = req.body;
258
+ if (!name) {
259
+ res.status(400).json({ error: "name required" });
260
+ return;
261
+ }
262
+
263
+ const projectId = `${req.keyId}:${name}`;
264
+
265
+ db.prepare(`
266
+ INSERT INTO projects (id, name, owner_key_id)
267
+ VALUES (?, ?, ?)
268
+ ON CONFLICT(id) DO NOTHING
269
+ `).run(projectId, name, req.keyId);
270
+
271
+ res.status(201).json({ id: projectId, name });
272
+ });
273
+
274
+ // ── POST /api/v1/share — Create shareable link ──
275
+
276
+ router.post("/share", requireAuth, (req: AuthedRequest, res: Response) => {
277
+ const { project, expiresInHours } = req.body;
278
+ if (!project) {
279
+ res.status(400).json({ error: "project name required" });
280
+ return;
281
+ }
282
+
283
+ const projectId = `${req.keyId}:${project}`;
284
+
285
+ // Verify project exists
286
+ const proj = db.prepare("SELECT id FROM projects WHERE id = ?").get(projectId);
287
+ if (!proj) {
288
+ res.status(404).json({ error: "Project not found" });
289
+ return;
290
+ }
291
+
292
+ const shareId = generateId();
293
+ const expiresAt = expiresInHours
294
+ ? new Date(Date.now() + expiresInHours * 3600000).toISOString()
295
+ : null;
296
+
297
+ db.prepare(
298
+ "INSERT INTO share_links (id, project_id, created_by, expires_at) VALUES (?, ?, ?, ?)"
299
+ ).run(shareId, projectId, req.keyId!, expiresAt);
300
+
301
+ const shareUrl = `${req.protocol}://${req.get("host")}/api/v1/shared/${shareId}`;
302
+
303
+ res.status(201).json({
304
+ shareId,
305
+ url: shareUrl,
306
+ expiresAt,
307
+ });
308
+ });
309
+
310
+ // ── GET /api/v1/shared/:id — View shared data (no auth) ──
311
+
312
+ router.get("/shared/:id", (req: Request, res: Response) => {
313
+ const link = db.prepare(`
314
+ SELECT sl.project_id, sl.expires_at, p.name as project_name
315
+ FROM share_links sl
316
+ JOIN projects p ON p.id = sl.project_id
317
+ WHERE sl.id = ?
318
+ `).get(req.params.id) as any;
319
+
320
+ if (!link) {
321
+ res.status(404).json({ error: "Share link not found" });
322
+ return;
323
+ }
324
+
325
+ if (link.expires_at && new Date(link.expires_at) < new Date()) {
326
+ res.status(410).json({ error: "Share link has expired" });
327
+ return;
328
+ }
329
+
330
+ const rows = db.prepare(
331
+ "SELECT filename, content FROM project_data WHERE project_id = ?"
332
+ ).all(link.project_id) as any[];
333
+
334
+ const files: Record<string, string> = {};
335
+ for (const row of rows) {
336
+ files[row.filename] = row.content;
337
+ }
338
+
339
+ // If request accepts HTML, serve dashboard
340
+ if (req.accepts("html")) {
341
+ res.send(generateDashboardHtml(link.project_name, files));
342
+ return;
343
+ }
344
+
345
+ res.json({ project: link.project_name, files, fileCount: rows.length });
346
+ });
347
+
348
+ // ── GET /api/v1/dashboard/:projectId — Authenticated dashboard ──
349
+
350
+ router.get("/dashboard/:projectId", requireAuth, (req: AuthedRequest, res: Response) => {
351
+ const projectId = decodeURIComponent(req.params.projectId);
352
+
353
+ // Verify ownership
354
+ const proj = db.prepare(
355
+ "SELECT name FROM projects WHERE id = ? AND owner_key_id = ?"
356
+ ).get(projectId, req.keyId) as any;
357
+
358
+ if (!proj) {
359
+ res.status(404).json({ error: "Project not found" });
360
+ return;
361
+ }
362
+
363
+ const rows = db.prepare(
364
+ "SELECT filename, content FROM project_data WHERE project_id = ?"
365
+ ).all(projectId) as any[];
366
+
367
+ const files: Record<string, string> = {};
368
+ for (const row of rows) {
369
+ files[row.filename] = row.content;
370
+ }
371
+
372
+ if (req.accepts("html")) {
373
+ res.send(generateDashboardHtml(proj.name, files));
374
+ return;
375
+ }
376
+
377
+ res.json({ project: proj.name, files, fileCount: rows.length });
378
+ });
379
+
380
+ // ── Dashboard HTML generator ──
381
+
382
+ function generateDashboardHtml(projectName: string, files: Record<string, string>): string {
383
+ // Parse data files
384
+ const parseJsonl = (content: string) =>
385
+ content.split("\n").filter(Boolean).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
386
+
387
+ const observations = files["observations.jsonl"] ? parseJsonl(files["observations.jsonl"]) : [];
388
+ const variables = files["variables.jsonl"] ? parseJsonl(files["variables.jsonl"]) : [];
389
+ const calltrace = files["calltrace.jsonl"] ? parseJsonl(files["calltrace.jsonl"]) : [];
390
+ const queries = files["queries.jsonl"] ? parseJsonl(files["queries.jsonl"]) : [];
391
+ const errors = files["errors.jsonl"] ? parseJsonl(files["errors.jsonl"]) : [];
392
+ const alerts = files["alerts.jsonl"] ? parseJsonl(files["alerts.jsonl"]) : [];
393
+ const profile = files["profile.jsonl"] ? parseJsonl(files["profile.jsonl"]) : [];
394
+ let environment: any = {};
395
+ try { if (files["environment.json"]) environment = JSON.parse(files["environment.json"]); } catch {}
396
+
397
+ const criticalAlerts = alerts.filter((a: any) => a.severity === "critical");
398
+ const warningAlerts = alerts.filter((a: any) => a.severity === "warning");
399
+ const endProfile = profile.find((p: any) => p.event === "end");
400
+
401
+ const status = criticalAlerts.length > 0 ? "critical" :
402
+ errors.length > 0 ? "error" :
403
+ warningAlerts.length > 0 ? "warning" : "healthy";
404
+
405
+ const statusColors: Record<string, string> = {
406
+ healthy: "#22c55e", warning: "#eab308", error: "#ef4444", critical: "#dc2626",
407
+ };
408
+
409
+ const slowFuncs = observations
410
+ .filter((o: any) => o.durationMs && o.durationMs > 10)
411
+ .sort((a: any, b: any) => (b.durationMs || 0) - (a.durationMs || 0))
412
+ .slice(0, 10);
413
+
414
+ return `<!DOCTYPE html>
415
+ <html lang="en">
416
+ <head>
417
+ <meta charset="utf-8">
418
+ <meta name="viewport" content="width=device-width, initial-scale=1">
419
+ <title>${projectName} — trickle dashboard</title>
420
+ <style>
421
+ * { margin: 0; padding: 0; box-sizing: border-box; }
422
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f1117; color: #e5e7eb; line-height: 1.5; }
423
+ .container { max-width: 1200px; margin: 0 auto; padding: 24px; }
424
+ h1 { font-size: 24px; font-weight: 600; margin-bottom: 8px; }
425
+ h2 { font-size: 18px; font-weight: 600; margin: 24px 0 12px; color: #9ca3af; }
426
+ .subtitle { color: #6b7280; font-size: 14px; margin-bottom: 24px; }
427
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }
428
+ .card { background: #1f2937; border-radius: 8px; padding: 16px; border: 1px solid #374151; }
429
+ .card-label { font-size: 12px; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.05em; }
430
+ .card-value { font-size: 28px; font-weight: 700; margin-top: 4px; }
431
+ .status-badge { display: inline-block; padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 600; text-transform: uppercase; }
432
+ table { width: 100%; border-collapse: collapse; }
433
+ th { text-align: left; padding: 8px 12px; font-size: 12px; color: #9ca3af; text-transform: uppercase; border-bottom: 1px solid #374151; }
434
+ td { padding: 8px 12px; border-bottom: 1px solid #1f2937; font-size: 14px; }
435
+ .mono { font-family: 'SF Mono', Monaco, Consolas, monospace; font-size: 13px; }
436
+ .bar { height: 8px; border-radius: 4px; background: #3b82f6; display: inline-block; min-width: 4px; }
437
+ .alert-critical { border-left: 3px solid #ef4444; }
438
+ .alert-warning { border-left: 3px solid #eab308; }
439
+ .error-card { background: #1f2937; border-left: 3px solid #ef4444; padding: 12px; margin-bottom: 8px; border-radius: 4px; }
440
+ .section { background: #1f2937; border-radius: 8px; padding: 16px; border: 1px solid #374151; margin-bottom: 16px; }
441
+ .tag { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; background: #374151; color: #9ca3af; margin-right: 4px; }
442
+ footer { text-align: center; padding: 24px; color: #4b5563; font-size: 12px; }
443
+ a { color: #3b82f6; text-decoration: none; }
444
+ </style>
445
+ </head>
446
+ <body>
447
+ <div class="container">
448
+ <h1>${projectName}</h1>
449
+ <div class="subtitle">
450
+ <span class="status-badge" style="background: ${statusColors[status]}20; color: ${statusColors[status]}">${status.toUpperCase()}</span>
451
+ ${environment.node ? `<span class="tag">Node ${environment.node.version}</span>` : ""}
452
+ ${environment.python ? `<span class="tag">Python ${environment.python}</span>` : ""}
453
+ ${endProfile ? `<span class="tag">${Math.round((endProfile.rssKb || 0) / 1024)}MB RSS</span>` : ""}
454
+ </div>
455
+
456
+ <div class="grid">
457
+ <div class="card">
458
+ <div class="card-label">Functions</div>
459
+ <div class="card-value">${observations.length}</div>
460
+ </div>
461
+ <div class="card">
462
+ <div class="card-label">Variables</div>
463
+ <div class="card-value">${variables.length}</div>
464
+ </div>
465
+ <div class="card">
466
+ <div class="card-label">DB Queries</div>
467
+ <div class="card-value">${queries.length}</div>
468
+ </div>
469
+ <div class="card">
470
+ <div class="card-label">Call Trace</div>
471
+ <div class="card-value">${calltrace.length}</div>
472
+ </div>
473
+ <div class="card">
474
+ <div class="card-label">Errors</div>
475
+ <div class="card-value" style="color: ${errors.length > 0 ? "#ef4444" : "#22c55e"}">${errors.length}</div>
476
+ </div>
477
+ <div class="card">
478
+ <div class="card-label">Alerts</div>
479
+ <div class="card-value" style="color: ${criticalAlerts.length > 0 ? "#ef4444" : warningAlerts.length > 0 ? "#eab308" : "#22c55e"}">${alerts.length}</div>
480
+ </div>
481
+ </div>
482
+
483
+ ${alerts.length > 0 ? `
484
+ <h2>Alerts</h2>
485
+ <div class="section">
486
+ ${alerts.map((a: any) => `
487
+ <div class="card ${a.severity === "critical" ? "alert-critical" : "alert-warning"}" style="margin-bottom: 8px;">
488
+ <strong>${a.severity === "critical" ? "CRITICAL" : "WARNING"}</strong>: ${escapeHtml(a.message || "")}
489
+ ${a.suggestion ? `<div style="color: #9ca3af; font-size: 13px; margin-top: 4px;">Fix: ${escapeHtml(a.suggestion)}</div>` : ""}
490
+ </div>
491
+ `).join("")}
492
+ </div>` : ""}
493
+
494
+ ${errors.length > 0 ? `
495
+ <h2>Errors</h2>
496
+ <div class="section">
497
+ ${errors.slice(0, 10).map((e: any) => `
498
+ <div class="error-card">
499
+ <strong class="mono">${escapeHtml(e.type || "Error")}</strong>: ${escapeHtml((e.message || "").substring(0, 200))}
500
+ ${e.function ? `<div style="color: #6b7280; font-size: 12px;">in ${escapeHtml(e.function)}</div>` : ""}
501
+ </div>
502
+ `).join("")}
503
+ </div>` : ""}
504
+
505
+ ${slowFuncs.length > 0 ? `
506
+ <h2>Performance Hotspots</h2>
507
+ <div class="section">
508
+ <table>
509
+ <thead><tr><th>Function</th><th>Module</th><th>Duration</th><th></th></tr></thead>
510
+ <tbody>
511
+ ${slowFuncs.map((f: any) => {
512
+ const pct = Math.round((f.durationMs / slowFuncs[0].durationMs) * 100);
513
+ return `<tr>
514
+ <td class="mono">${escapeHtml(f.functionName || "?")}</td>
515
+ <td>${escapeHtml(f.module || "?")}</td>
516
+ <td>${f.durationMs?.toFixed(0)}ms</td>
517
+ <td><div class="bar" style="width: ${pct}%"></div></td>
518
+ </tr>`;
519
+ }).join("")}
520
+ </tbody>
521
+ </table>
522
+ </div>` : ""}
523
+
524
+ ${observations.length > 0 ? `
525
+ <h2>Observed Functions</h2>
526
+ <div class="section">
527
+ <table>
528
+ <thead><tr><th>Function</th><th>Module</th><th>Duration</th></tr></thead>
529
+ <tbody>
530
+ ${observations.slice(0, 30).map((o: any) => `<tr>
531
+ <td class="mono">${escapeHtml(o.functionName || "?")}</td>
532
+ <td>${escapeHtml(o.module || "?")}</td>
533
+ <td>${o.durationMs ? o.durationMs.toFixed(0) + "ms" : "-"}</td>
534
+ </tr>`).join("")}
535
+ </tbody>
536
+ </table>
537
+ </div>` : ""}
538
+
539
+ ${queries.length > 0 ? `
540
+ <h2>Database Queries</h2>
541
+ <div class="section">
542
+ <table>
543
+ <thead><tr><th>Query</th><th>Duration</th></tr></thead>
544
+ <tbody>
545
+ ${queries.slice(0, 20).map((q: any) => `<tr>
546
+ <td class="mono" style="max-width: 600px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${escapeHtml((q.query || q.sql || "?").substring(0, 100))}</td>
547
+ <td>${q.durationMs ? q.durationMs.toFixed(1) + "ms" : "-"}</td>
548
+ </tr>`).join("")}
549
+ </tbody>
550
+ </table>
551
+ </div>` : ""}
552
+ </div>
553
+ <footer>Powered by <a href="https://github.com/yiheinchai/trickle">trickle</a> — runtime observability</footer>
554
+ </body>
555
+ </html>`;
556
+ }
557
+
558
+ function escapeHtml(s: string): string {
559
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
560
+ }
561
+
562
+ export default router;
package/src/server.ts CHANGED
@@ -13,6 +13,7 @@ import dashboardRouter from "./routes/dashboard";
13
13
  import coverageRouter from "./routes/coverage";
14
14
  import auditRouter from "./routes/audit";
15
15
  import searchRouter from "./routes/search";
16
+ import cloudRouter from "./routes/cloud";
16
17
 
17
18
  const app = express();
18
19
 
@@ -31,6 +32,7 @@ app.use("/dashboard", dashboardRouter);
31
32
  app.use("/api/coverage", coverageRouter);
32
33
  app.use("/api/audit", auditRouter);
33
34
  app.use("/api/search", searchRouter);
35
+ app.use("/api/v1", cloudRouter);
34
36
 
35
37
  // Health check
36
38
  app.get("/api/health", (_req, res) => {