trickle-backend 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/db/connection.d.ts +3 -0
  2. package/dist/db/connection.js +16 -0
  3. package/dist/db/migrations.d.ts +2 -0
  4. package/dist/db/migrations.js +51 -0
  5. package/dist/db/queries.d.ts +70 -0
  6. package/dist/db/queries.js +186 -0
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.js +10 -0
  9. package/dist/routes/audit.d.ts +2 -0
  10. package/dist/routes/audit.js +251 -0
  11. package/dist/routes/codegen.d.ts +2 -0
  12. package/dist/routes/codegen.js +224 -0
  13. package/dist/routes/coverage.d.ts +2 -0
  14. package/dist/routes/coverage.js +98 -0
  15. package/dist/routes/dashboard.d.ts +2 -0
  16. package/dist/routes/dashboard.js +433 -0
  17. package/dist/routes/diff.d.ts +2 -0
  18. package/dist/routes/diff.js +181 -0
  19. package/dist/routes/errors.d.ts +2 -0
  20. package/dist/routes/errors.js +86 -0
  21. package/dist/routes/functions.d.ts +2 -0
  22. package/dist/routes/functions.js +69 -0
  23. package/dist/routes/ingest.d.ts +2 -0
  24. package/dist/routes/ingest.js +111 -0
  25. package/dist/routes/mock.d.ts +2 -0
  26. package/dist/routes/mock.js +57 -0
  27. package/dist/routes/search.d.ts +2 -0
  28. package/dist/routes/search.js +136 -0
  29. package/dist/routes/tail.d.ts +2 -0
  30. package/dist/routes/tail.js +11 -0
  31. package/dist/routes/types.d.ts +2 -0
  32. package/dist/routes/types.js +97 -0
  33. package/dist/server.d.ts +2 -0
  34. package/dist/server.js +40 -0
  35. package/dist/services/sse-broker.d.ts +10 -0
  36. package/dist/services/sse-broker.js +39 -0
  37. package/dist/services/type-differ.d.ts +2 -0
  38. package/dist/services/type-differ.js +126 -0
  39. package/dist/services/type-generator.d.ts +319 -0
  40. package/dist/services/type-generator.js +3207 -0
  41. package/dist/types.d.ts +56 -0
  42. package/dist/types.js +2 -0
  43. package/package.json +22 -0
  44. package/src/db/connection.ts +16 -0
  45. package/src/db/migrations.ts +50 -0
  46. package/src/db/queries.ts +260 -0
  47. package/src/index.ts +11 -0
  48. package/src/routes/audit.ts +283 -0
  49. package/src/routes/codegen.ts +237 -0
  50. package/src/routes/coverage.ts +120 -0
  51. package/src/routes/dashboard.ts +435 -0
  52. package/src/routes/diff.ts +215 -0
  53. package/src/routes/errors.ts +91 -0
  54. package/src/routes/functions.ts +75 -0
  55. package/src/routes/ingest.ts +139 -0
  56. package/src/routes/mock.ts +66 -0
  57. package/src/routes/search.ts +169 -0
  58. package/src/routes/tail.ts +12 -0
  59. package/src/routes/types.ts +106 -0
  60. package/src/server.ts +40 -0
  61. package/src/services/sse-broker.ts +51 -0
  62. package/src/services/type-differ.ts +141 -0
  63. package/src/services/type-generator.ts +3853 -0
  64. package/src/types.ts +37 -0
  65. package/tsconfig.json +8 -0
@@ -0,0 +1,435 @@
1
+ import { Router, Request, Response } from "express";
2
+
3
+ const router = Router();
4
+
5
+ router.get("/", (_req: Request, res: Response) => {
6
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
7
+ res.send(dashboardHtml());
8
+ });
9
+
10
+ function dashboardHtml(): string {
11
+ return `<!DOCTYPE html>
12
+ <html lang="en">
13
+ <head>
14
+ <meta charset="UTF-8">
15
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
16
+ <title>trickle dashboard</title>
17
+ <style>
18
+ :root {
19
+ --bg: #0d1117; --surface: #161b22; --border: #30363d;
20
+ --text: #e6edf3; --muted: #8b949e; --accent: #58a6ff;
21
+ --green: #3fb950; --red: #f85149; --yellow: #d29922; --purple: #bc8cff;
22
+ }
23
+ * { margin: 0; padding: 0; box-sizing: border-box; }
24
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
25
+ background: var(--bg); color: var(--text); line-height: 1.5; }
26
+ .container { max-width: 1100px; margin: 0 auto; padding: 24px; }
27
+
28
+ header { display: flex; align-items: center; justify-content: space-between;
29
+ padding: 16px 0; border-bottom: 1px solid var(--border); margin-bottom: 24px; }
30
+ header h1 { font-size: 20px; font-weight: 600; }
31
+ header h1 span { color: var(--accent); }
32
+ .status { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--muted); }
33
+ .dot { width: 8px; height: 8px; border-radius: 50%; }
34
+ .dot.live { background: var(--green); animation: pulse 2s infinite; }
35
+ .dot.off { background: var(--red); }
36
+ @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
37
+
38
+ .stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
39
+ gap: 12px; margin-bottom: 24px; }
40
+ .stat { background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
41
+ padding: 16px; }
42
+ .stat-value { font-size: 28px; font-weight: 700; color: var(--accent); }
43
+ .stat-label { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; }
44
+
45
+ .search { margin-bottom: 16px; }
46
+ .search input { width: 100%; padding: 10px 14px; background: var(--surface);
47
+ border: 1px solid var(--border); border-radius: 6px; color: var(--text);
48
+ font-size: 14px; outline: none; }
49
+ .search input:focus { border-color: var(--accent); }
50
+ .search input::placeholder { color: var(--muted); }
51
+
52
+ .route-list { display: flex; flex-direction: column; gap: 8px; }
53
+ .route { background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
54
+ overflow: hidden; transition: border-color 0.15s; }
55
+ .route:hover { border-color: var(--accent); }
56
+ .route.new { animation: highlight 1.5s; }
57
+ @keyframes highlight { 0% { border-color: var(--green); box-shadow: 0 0 12px rgba(63,185,80,0.15); }
58
+ 100% { border-color: var(--border); box-shadow: none; } }
59
+
60
+ .route-header { display: flex; align-items: center; gap: 10px; padding: 12px 16px;
61
+ cursor: pointer; user-select: none; }
62
+ .method { font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 4px;
63
+ text-transform: uppercase; letter-spacing: 0.5px; }
64
+ .method.get { background: rgba(63,185,80,0.15); color: var(--green); }
65
+ .method.post { background: rgba(88,166,255,0.15); color: var(--accent); }
66
+ .method.put { background: rgba(210,153,34,0.15); color: var(--yellow); }
67
+ .method.delete { background: rgba(248,81,73,0.15); color: var(--red); }
68
+ .method.patch { background: rgba(188,140,255,0.15); color: var(--purple); }
69
+ .route-path { font-size: 14px; font-weight: 500; font-family: 'SF Mono', Menlo, monospace; }
70
+ .route-meta { margin-left: auto; font-size: 12px; color: var(--muted); }
71
+ .chevron { color: var(--muted); transition: transform 0.15s; font-size: 12px; }
72
+ .route.open .chevron { transform: rotate(90deg); }
73
+
74
+ .route-detail { display: none; padding: 0 16px 16px; border-top: 1px solid var(--border); }
75
+ .route.open .route-detail { display: block; }
76
+
77
+ .type-section { margin-top: 12px; }
78
+ .type-label { font-size: 11px; font-weight: 600; color: var(--muted); text-transform: uppercase;
79
+ letter-spacing: 0.5px; margin-bottom: 6px; }
80
+ .type-block { background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
81
+ padding: 12px; font-family: 'SF Mono', Menlo, monospace; font-size: 13px;
82
+ line-height: 1.6; overflow-x: auto; white-space: pre-wrap; word-break: break-word; }
83
+ .type-block .key { color: var(--accent); }
84
+ .type-block .type { color: var(--green); }
85
+ .type-block .punct { color: var(--muted); }
86
+
87
+ .sample-section { margin-top: 12px; }
88
+ .sample-block { background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
89
+ padding: 12px; font-family: 'SF Mono', Menlo, monospace; font-size: 12px;
90
+ line-height: 1.5; overflow-x: auto; white-space: pre-wrap; color: var(--muted); max-height: 200px; overflow-y: auto; }
91
+
92
+ .empty { text-align: center; padding: 48px; color: var(--muted); }
93
+ .empty p { margin-top: 8px; font-size: 14px; }
94
+
95
+ .live-banner { display: none; padding: 8px 16px; background: rgba(63,185,80,0.1);
96
+ border: 1px solid rgba(63,185,80,0.3); border-radius: 6px; margin-bottom: 16px;
97
+ font-size: 13px; color: var(--green); text-align: center; }
98
+ .live-banner.show { display: block; animation: fadeIn 0.3s; }
99
+ @keyframes fadeIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
100
+
101
+ .tabs { display: flex; gap: 4px; margin-bottom: 16px; }
102
+ .tab { padding: 6px 14px; border-radius: 6px; font-size: 13px; cursor: pointer;
103
+ color: var(--muted); background: transparent; border: 1px solid transparent; }
104
+ .tab:hover { color: var(--text); }
105
+ .tab.active { color: var(--text); background: var(--surface); border-color: var(--border); }
106
+ </style>
107
+ </head>
108
+ <body>
109
+ <div class="container">
110
+ <header>
111
+ <h1><span>trickle</span> dashboard</h1>
112
+ <div class="status">
113
+ <div class="dot" id="statusDot"></div>
114
+ <span id="statusText">connecting...</span>
115
+ </div>
116
+ </header>
117
+
118
+ <div class="stats" id="stats"></div>
119
+
120
+ <div class="live-banner" id="liveBanner">New types observed — view updated</div>
121
+
122
+ <div class="search">
123
+ <input type="text" id="searchInput" placeholder="Search routes... (e.g. /api/users, GET, POST)">
124
+ </div>
125
+
126
+ <div class="tabs" id="tabs">
127
+ <div class="tab active" data-filter="all">All</div>
128
+ <div class="tab" data-filter="GET">GET</div>
129
+ <div class="tab" data-filter="POST">POST</div>
130
+ <div class="tab" data-filter="PUT">PUT</div>
131
+ <div class="tab" data-filter="DELETE">DELETE</div>
132
+ </div>
133
+
134
+ <div class="route-list" id="routeList"></div>
135
+ </div>
136
+
137
+ <script>
138
+ const API = window.location.origin;
139
+ let allRoutes = [];
140
+ let currentFilter = 'all';
141
+ let searchQuery = '';
142
+
143
+ async function fetchRoutes() {
144
+ try {
145
+ const res = await fetch(API + '/api/functions?limit=500');
146
+ const data = await res.json();
147
+ return data.functions || [];
148
+ } catch { return []; }
149
+ }
150
+
151
+ async function fetchSnapshot(funcId, env) {
152
+ try {
153
+ const url = API + '/api/types/' + funcId + (env ? '?env=' + env : '');
154
+ const res = await fetch(url);
155
+ const data = await res.json();
156
+ return data.snapshots?.[0] || null;
157
+ } catch { return null; }
158
+ }
159
+
160
+ async function fetchMockConfig() {
161
+ try {
162
+ const res = await fetch(API + '/api/mock-config');
163
+ const data = await res.json();
164
+ return data.routes || [];
165
+ } catch { return []; }
166
+ }
167
+
168
+ function parseRoute(name) {
169
+ const m = name.match(/^(GET|POST|PUT|DELETE|PATCH)\\s+(.+)$/i);
170
+ if (!m) return null;
171
+ return { method: m[1].toUpperCase(), path: m[2] };
172
+ }
173
+
174
+ function timeAgo(iso) {
175
+ const d = new Date(iso);
176
+ const s = Math.floor((Date.now() - d.getTime()) / 1000);
177
+ if (s < 60) return s + 's ago';
178
+ const m = Math.floor(s / 60);
179
+ if (m < 60) return m + 'm ago';
180
+ const h = Math.floor(m / 60);
181
+ if (h < 24) return h + 'h ago';
182
+ return Math.floor(h / 24) + 'd ago';
183
+ }
184
+
185
+ function renderTypeNode(node, indent) {
186
+ indent = indent || 0;
187
+ const pad = ' '.repeat(indent);
188
+ if (!node || !node.kind) return '<span class="type">unknown</span>';
189
+
190
+ switch (node.kind) {
191
+ case 'primitive':
192
+ return '<span class="type">' + node.name + '</span>';
193
+ case 'unknown':
194
+ return '<span class="type">unknown</span>';
195
+ case 'array':
196
+ return renderTypeNode(node.element, indent) + '<span class="punct">[]</span>';
197
+ case 'object': {
198
+ const keys = Object.keys(node.properties || {});
199
+ if (keys.length === 0) return '<span class="punct">{}</span>';
200
+ let s = '<span class="punct">{</span>\\n';
201
+ for (const k of keys) {
202
+ s += pad + ' <span class="key">' + k + '</span><span class="punct">: </span>';
203
+ s += renderTypeNode(node.properties[k], indent + 1);
204
+ s += '<span class="punct">;</span>\\n';
205
+ }
206
+ s += pad + '<span class="punct">}</span>';
207
+ return s;
208
+ }
209
+ case 'union': {
210
+ return (node.members || []).map(m => renderTypeNode(m, indent)).join(' <span class="punct">|</span> ');
211
+ }
212
+ case 'tuple': {
213
+ const els = (node.elements || []).map(e => renderTypeNode(e, indent));
214
+ return '<span class="punct">[</span>' + els.join(', ') + '<span class="punct">]</span>';
215
+ }
216
+ case 'map':
217
+ return '<span class="type">Map</span><span class="punct">&lt;</span>' +
218
+ renderTypeNode(node.key, indent) + ', ' + renderTypeNode(node.value, indent) +
219
+ '<span class="punct">&gt;</span>';
220
+ case 'set':
221
+ return '<span class="type">Set</span><span class="punct">&lt;</span>' +
222
+ renderTypeNode(node.element, indent) + '<span class="punct">&gt;</span>';
223
+ case 'promise':
224
+ return '<span class="type">Promise</span><span class="punct">&lt;</span>' +
225
+ renderTypeNode(node.resolved, indent) + '<span class="punct">&gt;</span>';
226
+ case 'function':
227
+ return '<span class="type">Function</span>';
228
+ default:
229
+ return '<span class="type">unknown</span>';
230
+ }
231
+ }
232
+
233
+ function renderRoute(r) {
234
+ const parsed = parseRoute(r.function_name);
235
+ if (!parsed) return '';
236
+ const method = parsed.method.toLowerCase();
237
+ const ago = timeAgo(r.last_seen_at);
238
+
239
+ return '<div class="route" data-method="' + parsed.method + '" data-id="' + r.id + '">' +
240
+ '<div class="route-header" onclick="toggleRoute(this.parentElement)">' +
241
+ '<span class="chevron">&#9654;</span>' +
242
+ '<span class="method ' + method + '">' + parsed.method + '</span>' +
243
+ '<span class="route-path">' + escHtml(parsed.path) + '</span>' +
244
+ '<span class="route-meta">' + ago + '</span>' +
245
+ '</div>' +
246
+ '<div class="route-detail" id="detail-' + r.id + '"><div class="loading" style="color:var(--muted);font-size:13px;padding:8px 0">Loading types...</div></div>' +
247
+ '</div>';
248
+ }
249
+
250
+ function escHtml(s) {
251
+ return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
252
+ }
253
+
254
+ async function toggleRoute(el) {
255
+ el.classList.toggle('open');
256
+ if (el.classList.contains('open')) {
257
+ const id = el.dataset.id;
258
+ const detail = document.getElementById('detail-' + id);
259
+ if (detail.querySelector('.loading')) {
260
+ const snap = await fetchSnapshot(id);
261
+ const mockRoutes = await fetchMockConfig();
262
+ const fn = allRoutes.find(r => r.id == id);
263
+ const mockRoute = fn ? mockRoutes.find(m => m.functionName === fn.function_name) : null;
264
+
265
+ let html = '';
266
+
267
+ if (snap) {
268
+ let argsType, returnType;
269
+ try { argsType = typeof snap.args_type === 'string' ? JSON.parse(snap.args_type) : snap.args_type; } catch { argsType = null; }
270
+ try { returnType = typeof snap.return_type === 'string' ? JSON.parse(snap.return_type) : snap.return_type; } catch { returnType = null; }
271
+
272
+ if (returnType) {
273
+ html += '<div class="type-section"><div class="type-label">Response Type</div>';
274
+ html += '<div class="type-block">' + renderTypeNode(returnType, 0) + '</div></div>';
275
+ }
276
+
277
+ if (argsType) {
278
+ // For routes, show body/params/query separately
279
+ if (argsType.kind === 'object' && argsType.properties) {
280
+ if (argsType.properties.body && argsType.properties.body.kind === 'object' &&
281
+ Object.keys(argsType.properties.body.properties || {}).length > 0) {
282
+ html += '<div class="type-section"><div class="type-label">Request Body</div>';
283
+ html += '<div class="type-block">' + renderTypeNode(argsType.properties.body, 0) + '</div></div>';
284
+ }
285
+ if (argsType.properties.params && argsType.properties.params.kind === 'object' &&
286
+ Object.keys(argsType.properties.params.properties || {}).length > 0) {
287
+ html += '<div class="type-section"><div class="type-label">Path Params</div>';
288
+ html += '<div class="type-block">' + renderTypeNode(argsType.properties.params, 0) + '</div></div>';
289
+ }
290
+ if (argsType.properties.query && argsType.properties.query.kind === 'object' &&
291
+ Object.keys(argsType.properties.query.properties || {}).length > 0) {
292
+ html += '<div class="type-section"><div class="type-label">Query Params</div>';
293
+ html += '<div class="type-block">' + renderTypeNode(argsType.properties.query, 0) + '</div></div>';
294
+ }
295
+ }
296
+ }
297
+ }
298
+
299
+ if (mockRoute && mockRoute.sampleOutput) {
300
+ html += '<div class="sample-section"><div class="type-label">Sample Response</div>';
301
+ html += '<div class="sample-block">' + escHtml(JSON.stringify(mockRoute.sampleOutput, null, 2)) + '</div></div>';
302
+ }
303
+
304
+ if (!html) html = '<div style="color:var(--muted);font-size:13px;padding:8px 0">No type data available yet.</div>';
305
+ detail.innerHTML = html;
306
+ }
307
+ }
308
+ }
309
+
310
+ function renderStats(routes) {
311
+ const methods = {};
312
+ let routeCount = 0;
313
+ for (const r of routes) {
314
+ const p = parseRoute(r.function_name);
315
+ if (p) {
316
+ routeCount++;
317
+ methods[p.method] = (methods[p.method] || 0) + 1;
318
+ }
319
+ }
320
+
321
+ let html = '<div class="stat"><div class="stat-value">' + routes.length + '</div><div class="stat-label">Functions</div></div>';
322
+ html += '<div class="stat"><div class="stat-value">' + routeCount + '</div><div class="stat-label">API Routes</div></div>';
323
+ for (const [m, c] of Object.entries(methods)) {
324
+ html += '<div class="stat"><div class="stat-value">' + c + '</div><div class="stat-label">' + m + ' routes</div></div>';
325
+ }
326
+ document.getElementById('stats').innerHTML = html;
327
+ }
328
+
329
+ function applyFilters() {
330
+ const cards = document.querySelectorAll('.route');
331
+ cards.forEach(card => {
332
+ const method = card.dataset.method;
333
+ const text = card.textContent.toLowerCase();
334
+ const matchFilter = currentFilter === 'all' || method === currentFilter;
335
+ const matchSearch = !searchQuery || text.includes(searchQuery.toLowerCase());
336
+ card.style.display = matchFilter && matchSearch ? '' : 'none';
337
+ });
338
+ }
339
+
340
+ async function loadData() {
341
+ allRoutes = await fetchRoutes();
342
+ renderStats(allRoutes);
343
+
344
+ if (allRoutes.length === 0) {
345
+ document.getElementById('routeList').innerHTML =
346
+ '<div class="empty"><h3>No observations yet</h3><p>Instrument your app and make some requests to see types here.</p></div>';
347
+ return;
348
+ }
349
+
350
+ // Sort: routes first, then by last_seen_at descending
351
+ const sorted = [...allRoutes].sort((a, b) => {
352
+ const aRoute = parseRoute(a.function_name);
353
+ const bRoute = parseRoute(b.function_name);
354
+ if (aRoute && !bRoute) return -1;
355
+ if (!aRoute && bRoute) return 1;
356
+ return new Date(b.last_seen_at).getTime() - new Date(a.last_seen_at).getTime();
357
+ });
358
+
359
+ document.getElementById('routeList').innerHTML = sorted.map(renderRoute).join('');
360
+ applyFilters();
361
+ }
362
+
363
+ // Search
364
+ document.getElementById('searchInput').addEventListener('input', e => {
365
+ searchQuery = e.target.value;
366
+ applyFilters();
367
+ });
368
+
369
+ // Tabs
370
+ document.querySelectorAll('.tab').forEach(tab => {
371
+ tab.addEventListener('click', () => {
372
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
373
+ tab.classList.add('active');
374
+ currentFilter = tab.dataset.filter;
375
+ applyFilters();
376
+ });
377
+ });
378
+
379
+ // SSE for live updates
380
+ function connectSSE() {
381
+ const dot = document.getElementById('statusDot');
382
+ const text = document.getElementById('statusText');
383
+ const banner = document.getElementById('liveBanner');
384
+
385
+ try {
386
+ const es = new EventSource(API + '/api/tail');
387
+ es.onopen = () => {
388
+ dot.className = 'dot live';
389
+ text.textContent = 'live';
390
+ };
391
+ es.addEventListener('type_snapshot', () => {
392
+ banner.classList.add('show');
393
+ setTimeout(() => banner.classList.remove('show'), 3000);
394
+ // Reload data
395
+ loadData();
396
+ });
397
+ es.addEventListener('function_seen', () => {
398
+ loadData();
399
+ });
400
+ es.onerror = () => {
401
+ dot.className = 'dot off';
402
+ text.textContent = 'disconnected';
403
+ // Retry
404
+ setTimeout(connectSSE, 5000);
405
+ };
406
+ } catch {
407
+ dot.className = 'dot off';
408
+ text.textContent = 'disconnected';
409
+ }
410
+ }
411
+
412
+ // Health check
413
+ async function checkHealth() {
414
+ try {
415
+ const res = await fetch(API + '/api/health');
416
+ if (res.ok) {
417
+ document.getElementById('statusDot').className = 'dot live';
418
+ document.getElementById('statusText').textContent = 'connected';
419
+ }
420
+ } catch {
421
+ document.getElementById('statusDot').className = 'dot off';
422
+ document.getElementById('statusText').textContent = 'disconnected';
423
+ }
424
+ }
425
+
426
+ // Init
427
+ checkHealth();
428
+ loadData();
429
+ connectSSE();
430
+ </script>
431
+ </body>
432
+ </html>`;
433
+ }
434
+
435
+ export default router;
@@ -0,0 +1,215 @@
1
+ import { Router, Request, Response } from "express";
2
+ import { db } from "../db/connection";
3
+ import { diffTypes } from "../services/type-differ";
4
+ import { TypeNode } from "../types";
5
+
6
+ const router = Router();
7
+
8
+ interface DriftEntry {
9
+ functionName: string;
10
+ module: string;
11
+ language: string;
12
+ from: { id: number; env: string; observed_at: string; type_hash: string };
13
+ to: { id: number; env: string; observed_at: string; type_hash: string };
14
+ diffs: Array<{ kind: string; path: string; from?: unknown; to?: unknown; type?: unknown }>;
15
+ }
16
+
17
+ // GET / — cross-function type drift report
18
+ // Query params:
19
+ // since: ISO datetime string — only show changes after this time
20
+ // env1, env2: compare latest snapshots across environments
21
+ // env: filter functions by environment
22
+ router.get("/", (req: Request, res: Response) => {
23
+ try {
24
+ const { since, env1, env2, env } = req.query;
25
+
26
+ if (env1 && env2) {
27
+ // Cross-environment diff: for each function, compare latest snapshot in env1 vs env2
28
+ const functionsStmt = db.prepare(`
29
+ SELECT DISTINCT f.id, f.function_name, f.module, f.language
30
+ FROM functions f
31
+ JOIN type_snapshots s ON s.function_id = f.id
32
+ WHERE s.env IN (?, ?)
33
+ ORDER BY f.function_name
34
+ `);
35
+ const functions = functionsStmt.all(env1 as string, env2 as string) as Array<{
36
+ id: number;
37
+ function_name: string;
38
+ module: string;
39
+ language: string;
40
+ }>;
41
+
42
+ const snapshotStmt = db.prepare(`
43
+ SELECT * FROM type_snapshots
44
+ WHERE function_id = ? AND env = ?
45
+ ORDER BY observed_at DESC
46
+ LIMIT 1
47
+ `);
48
+
49
+ const entries: DriftEntry[] = [];
50
+
51
+ for (const fn of functions) {
52
+ const fromSnap = snapshotStmt.get(fn.id, env1 as string) as Record<string, unknown> | undefined;
53
+ const toSnap = snapshotStmt.get(fn.id, env2 as string) as Record<string, unknown> | undefined;
54
+
55
+ if (!fromSnap || !toSnap) continue;
56
+ if (fromSnap.type_hash === toSnap.type_hash) continue;
57
+
58
+ const fromArgs = JSON.parse(fromSnap.args_type as string) as TypeNode;
59
+ const toArgs = JSON.parse(toSnap.args_type as string) as TypeNode;
60
+ const fromReturn = JSON.parse(fromSnap.return_type as string) as TypeNode;
61
+ const toReturn = JSON.parse(toSnap.return_type as string) as TypeNode;
62
+
63
+ const diffs = [
64
+ ...diffTypes(fromArgs, toArgs, "args"),
65
+ ...diffTypes(fromReturn, toReturn, "return"),
66
+ ];
67
+
68
+ if (diffs.length > 0) {
69
+ entries.push({
70
+ functionName: fn.function_name,
71
+ module: fn.module,
72
+ language: fn.language,
73
+ from: {
74
+ id: fromSnap.id as number,
75
+ env: fromSnap.env as string,
76
+ observed_at: fromSnap.observed_at as string,
77
+ type_hash: fromSnap.type_hash as string,
78
+ },
79
+ to: {
80
+ id: toSnap.id as number,
81
+ env: toSnap.env as string,
82
+ observed_at: toSnap.observed_at as string,
83
+ type_hash: toSnap.type_hash as string,
84
+ },
85
+ diffs,
86
+ });
87
+ }
88
+ }
89
+
90
+ res.json({
91
+ mode: "cross-env",
92
+ env1,
93
+ env2,
94
+ entries,
95
+ total: entries.length,
96
+ });
97
+ return;
98
+ }
99
+
100
+ // Time-based diff: for each function, compare the two most recent snapshots
101
+ // If `since` is provided, only consider functions with snapshots after that time
102
+ let functionsQuery: string;
103
+ const bindings: unknown[] = [];
104
+
105
+ if (since) {
106
+ if (env) {
107
+ functionsQuery = `
108
+ SELECT DISTINCT f.id, f.function_name, f.module, f.language
109
+ FROM functions f
110
+ JOIN type_snapshots s ON s.function_id = f.id
111
+ WHERE s.observed_at >= ? AND s.env = ?
112
+ ORDER BY f.function_name
113
+ `;
114
+ bindings.push(since as string, env as string);
115
+ } else {
116
+ functionsQuery = `
117
+ SELECT DISTINCT f.id, f.function_name, f.module, f.language
118
+ FROM functions f
119
+ JOIN type_snapshots s ON s.function_id = f.id
120
+ WHERE s.observed_at >= ?
121
+ ORDER BY f.function_name
122
+ `;
123
+ bindings.push(since as string);
124
+ }
125
+ } else {
126
+ if (env) {
127
+ functionsQuery = `
128
+ SELECT DISTINCT f.id, f.function_name, f.module, f.language
129
+ FROM functions f
130
+ JOIN type_snapshots s ON s.function_id = f.id
131
+ WHERE s.env = ?
132
+ ORDER BY f.function_name
133
+ `;
134
+ bindings.push(env as string);
135
+ } else {
136
+ functionsQuery = `
137
+ SELECT DISTINCT f.id, f.function_name, f.module, f.language
138
+ FROM functions f
139
+ JOIN type_snapshots s ON s.function_id = f.id
140
+ ORDER BY f.function_name
141
+ `;
142
+ }
143
+ }
144
+
145
+ const functions = db.prepare(functionsQuery).all(...bindings) as Array<{
146
+ id: number;
147
+ function_name: string;
148
+ module: string;
149
+ language: string;
150
+ }>;
151
+
152
+ // For each function, get the two most recent snapshots and diff them
153
+ const latestTwoStmt = db.prepare(`
154
+ SELECT * FROM type_snapshots
155
+ WHERE function_id = ?
156
+ ORDER BY observed_at DESC
157
+ LIMIT 2
158
+ `);
159
+
160
+ const entries: DriftEntry[] = [];
161
+
162
+ for (const fn of functions) {
163
+ const snapshots = latestTwoStmt.all(fn.id) as Array<Record<string, unknown>>;
164
+
165
+ if (snapshots.length < 2) continue;
166
+
167
+ const [newer, older] = snapshots;
168
+ if (newer.type_hash === older.type_hash) continue;
169
+
170
+ const fromArgs = JSON.parse(older.args_type as string) as TypeNode;
171
+ const toArgs = JSON.parse(newer.args_type as string) as TypeNode;
172
+ const fromReturn = JSON.parse(older.return_type as string) as TypeNode;
173
+ const toReturn = JSON.parse(newer.return_type as string) as TypeNode;
174
+
175
+ const diffs = [
176
+ ...diffTypes(fromArgs, toArgs, "args"),
177
+ ...diffTypes(fromReturn, toReturn, "return"),
178
+ ];
179
+
180
+ if (diffs.length > 0) {
181
+ entries.push({
182
+ functionName: fn.function_name,
183
+ module: fn.module,
184
+ language: fn.language,
185
+ from: {
186
+ id: older.id as number,
187
+ env: older.env as string,
188
+ observed_at: older.observed_at as string,
189
+ type_hash: older.type_hash as string,
190
+ },
191
+ to: {
192
+ id: newer.id as number,
193
+ env: newer.env as string,
194
+ observed_at: newer.observed_at as string,
195
+ type_hash: newer.type_hash as string,
196
+ },
197
+ diffs,
198
+ });
199
+ }
200
+ }
201
+
202
+ res.json({
203
+ mode: "temporal",
204
+ since: since || null,
205
+ env: env || null,
206
+ entries,
207
+ total: entries.length,
208
+ });
209
+ } catch (err) {
210
+ console.error("Diff report error:", err);
211
+ res.status(500).json({ error: "Internal server error" });
212
+ }
213
+ });
214
+
215
+ export default router;