turbine-orm 0.14.0 → 0.16.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 (42) hide show
  1. package/dist/adapters/cockroachdb.js +1 -1
  2. package/dist/adapters/index.d.ts +7 -4
  3. package/dist/adapters/index.js +1 -1
  4. package/dist/adapters/yugabytedb.js +1 -1
  5. package/dist/cjs/adapters/cockroachdb.js +1 -1
  6. package/dist/cjs/adapters/index.js +1 -1
  7. package/dist/cjs/adapters/yugabytedb.js +1 -1
  8. package/dist/cjs/cli/index.js +64 -0
  9. package/dist/cjs/cli/observe-ui.js +182 -0
  10. package/dist/cjs/cli/observe.js +242 -0
  11. package/dist/cjs/cli/studio.js +45 -7
  12. package/dist/cjs/client.js +102 -1
  13. package/dist/cjs/errors.js +44 -1
  14. package/dist/cjs/generate.js +86 -0
  15. package/dist/cjs/index.js +10 -1
  16. package/dist/cjs/nested-write.js +557 -0
  17. package/dist/cjs/observe.js +145 -0
  18. package/dist/cjs/query/builder.js +271 -23
  19. package/dist/cli/index.d.ts +1 -0
  20. package/dist/cli/index.js +64 -0
  21. package/dist/cli/observe-ui.d.ts +2 -0
  22. package/dist/cli/observe-ui.js +180 -0
  23. package/dist/cli/observe.d.ts +20 -0
  24. package/dist/cli/observe.js +237 -0
  25. package/dist/cli/studio.d.ts +10 -2
  26. package/dist/cli/studio.js +45 -7
  27. package/dist/client.d.ts +32 -2
  28. package/dist/client.js +102 -2
  29. package/dist/errors.d.ts +23 -0
  30. package/dist/errors.js +41 -0
  31. package/dist/generate.js +86 -0
  32. package/dist/index.d.ts +5 -3
  33. package/dist/index.js +4 -2
  34. package/dist/nested-write.d.ts +95 -0
  35. package/dist/nested-write.js +551 -0
  36. package/dist/observe.d.ts +36 -0
  37. package/dist/observe.js +141 -0
  38. package/dist/query/builder.d.ts +45 -12
  39. package/dist/query/builder.js +239 -24
  40. package/dist/query/index.d.ts +2 -2
  41. package/dist/query/types.d.ts +76 -8
  42. package/package.json +2 -2
package/dist/cli/index.js CHANGED
@@ -13,6 +13,7 @@
13
13
  * turbine seed — Run seed file
14
14
  * turbine status — Show schema summary
15
15
  * turbine studio — Launch local read-only web UI
16
+ * turbine observe — Launch metrics dashboard (requires TURBINE_OBSERVE_URL)
16
17
  *
17
18
  * Usage:
18
19
  * DATABASE_URL=postgres://... npx turbine generate
@@ -28,6 +29,7 @@ import { schemaDiff, schemaPush } from '../schema-sql.js';
28
29
  import { configTemplate, findConfigFile, loadConfig, resolveConfig } from './config.js';
29
30
  import { needsTsLoader, registerTsLoader } from './loader.js';
30
31
  import { createMigration, listMigrationFiles, migrateDown, migrateStatus, migrateUp } from './migrate.js';
32
+ import { startObserve } from './observe.js';
31
33
  import { startStudio } from './studio.js';
32
34
  import { banner, blue, bold, box, cyan, dim, divider, elapsed, error, table as formatTable, gray, green, header, info, label, magenta, newline, red, redactUrl, Spinner, success, symbols, warn, yellow, } from './ui.js';
33
35
  function parseArgs() {
@@ -970,6 +972,65 @@ async function cmdStudio(args, config) {
970
972
  });
971
973
  }
972
974
  // ---------------------------------------------------------------------------
975
+ // Command: observe
976
+ // ---------------------------------------------------------------------------
977
+ async function cmdObserve(args) {
978
+ banner();
979
+ const url = process.env.TURBINE_OBSERVE_URL;
980
+ if (!url) {
981
+ error('TURBINE_OBSERVE_URL environment variable is required for the observe command.');
982
+ newline();
983
+ console.log(` ${dim('Set it to the Postgres connection string where metrics are stored.')}`);
984
+ console.log(` ${dim('Example:')} ${cyan('TURBINE_OBSERVE_URL=postgres://... npx turbine observe')}`);
985
+ newline();
986
+ process.exit(1);
987
+ }
988
+ const port = args.port ?? 4984;
989
+ const host = args.host ?? '127.0.0.1';
990
+ const openBrowser = !args.noOpen;
991
+ if (!Number.isFinite(port) || port <= 0 || port > 65535) {
992
+ console.log(red(`✗ invalid port: ${args.port}`));
993
+ process.exit(1);
994
+ }
995
+ if (host !== '127.0.0.1' && host !== 'localhost' && host !== '::1') {
996
+ console.log(warn(`Observe is binding to ${yellow(host)} — this is NOT loopback. ` +
997
+ `Anyone on your network who can reach this port + guess the session token can read your metrics.`));
998
+ }
999
+ const spinner = new Spinner('Connecting to metrics database').start();
1000
+ let handle;
1001
+ try {
1002
+ handle = await startObserve({ url, port, host, openBrowser });
1003
+ spinner.succeed('Observe dashboard is running');
1004
+ }
1005
+ catch (err) {
1006
+ spinner.fail(`Failed to start Observe: ${err instanceof Error ? err.message : String(err)}`);
1007
+ process.exit(1);
1008
+ }
1009
+ newline();
1010
+ console.log(box([
1011
+ `${bold('Turbine Observe')} ${dim('— query metrics dashboard')}`,
1012
+ '',
1013
+ ` ${cyan('URL:')} ${bold(handle.url)}`,
1014
+ '',
1015
+ dim('Open the URL above in your browser. Press Ctrl+C to stop.'),
1016
+ ].join('\n'), { title: bold(cyan('Observe')), padding: 1 }));
1017
+ newline();
1018
+ await new Promise((resolve) => {
1019
+ const shutdown = async () => {
1020
+ console.log(dim('\n shutting down…'));
1021
+ try {
1022
+ await handle.dispose();
1023
+ }
1024
+ catch {
1025
+ /* ignore */
1026
+ }
1027
+ resolve();
1028
+ };
1029
+ process.once('SIGINT', shutdown);
1030
+ process.once('SIGTERM', shutdown);
1031
+ });
1032
+ }
1033
+ // ---------------------------------------------------------------------------
973
1034
  // Subcommand help
974
1035
  // ---------------------------------------------------------------------------
975
1036
  function showSubcommandHelp(command) {
@@ -1253,6 +1314,9 @@ async function main() {
1253
1314
  case 'studio':
1254
1315
  await cmdStudio(args, config);
1255
1316
  break;
1317
+ case 'observe':
1318
+ await cmdObserve(args);
1319
+ break;
1256
1320
  default:
1257
1321
  error(`Unknown command: ${bold(args.command)}`);
1258
1322
  newline();
@@ -0,0 +1,2 @@
1
+ export declare const OBSERVE_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <meta name=\"color-scheme\" content=\"dark\" />\n <title>Turbine Observe</title>\n <style>\n :root {\n --bg: #0a0a0b;\n --bg-elev: #111113;\n --bg-hover: #1a1a1d;\n --border: #26262b;\n --text: #e6e6ea;\n --text-dim: #8a8a93;\n --accent: #60a5fa;\n --green: #4ade80;\n --red: #f87171;\n --orange: #fb923c;\n --purple: #a78bfa;\n --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;\n --sans: system-ui, -apple-system, sans-serif;\n --radius: 6px;\n }\n * { margin: 0; padding: 0; box-sizing: border-box; }\n body { background: var(--bg); color: var(--text); font-family: var(--sans); font-size: 14px; padding: 24px; }\n h1 { font-size: 20px; margin-bottom: 4px; }\n .subtitle { color: var(--text-dim); margin-bottom: 24px; }\n .controls { display: flex; gap: 8px; margin-bottom: 24px; }\n .controls button {\n background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius);\n color: var(--text); padding: 6px 12px; cursor: pointer; font-size: 13px;\n }\n .controls button.active { border-color: var(--accent); color: var(--accent); }\n .card {\n background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius);\n padding: 16px; margin-bottom: 16px;\n }\n .card h2 { font-size: 14px; color: var(--text-dim); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.5px; }\n table { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 12px; }\n th { text-align: left; padding: 6px 8px; color: var(--text-dim); border-bottom: 1px solid var(--border); }\n td { padding: 6px 8px; border-bottom: 1px solid var(--border); }\n .num { text-align: right; }\n .error-rate { color: var(--red); }\n .low-error { color: var(--green); }\n svg { width: 100%; height: 200px; }\n .chart-line { fill: none; stroke-width: 1.5; }\n .line-avg { stroke: var(--accent); }\n .line-p95 { stroke: var(--orange); }\n .line-p99 { stroke: var(--red); }\n .legend { display: flex; gap: 16px; margin-top: 8px; font-size: 12px; color: var(--text-dim); }\n .legend span::before { content: ''; display: inline-block; width: 12px; height: 2px; margin-right: 4px; vertical-align: middle; }\n .legend .l-avg::before { background: var(--accent); }\n .legend .l-p95::before { background: var(--orange); }\n .legend .l-p99::before { background: var(--red); }\n .empty { color: var(--text-dim); text-align: center; padding: 40px; }\n </style>\n</head>\n<body>\n <h1>Turbine Observe</h1>\n <p class=\"subtitle\">Query performance metrics</p>\n <div class=\"controls\">\n <button data-range=\"1h\" class=\"active\">1h</button>\n <button data-range=\"6h\">6h</button>\n <button data-range=\"24h\">24h</button>\n <button data-range=\"7d\">7d</button>\n </div>\n <div class=\"card\" id=\"latency-card\">\n <h2>Latency over time</h2>\n <div id=\"chart\"></div>\n <div class=\"legend\">\n <span class=\"l-avg\">avg</span>\n <span class=\"l-p95\">p95</span>\n <span class=\"l-p99\">p99</span>\n </div>\n </div>\n <div class=\"card\" id=\"models-card\">\n <h2>Top models</h2>\n <div id=\"models-table\"></div>\n </div>\n <div class=\"card\" id=\"errors-card\">\n <h2>Error rates</h2>\n <div id=\"errors-table\"></div>\n </div>\n <script>\n let currentRange = '1h';\n const token = document.cookie.match(/turbine_observe_token=([a-f0-9]+)/)?.[1] || '';\n const headers = { 'x-turbine-token': token };\n\n document.querySelector('.controls').addEventListener('click', e => {\n if (e.target.tagName !== 'BUTTON') return;\n document.querySelectorAll('.controls button').forEach(b => b.classList.remove('active'));\n e.target.classList.add('active');\n currentRange = e.target.dataset.range;\n refresh();\n });\n\n async function fetchJson(path) {\n const res = await fetch(path, { headers });\n if (!res.ok) return null;\n return res.json();\n }\n\n function buildSvgPath(points, width, height, maxY) {\n if (points.length === 0) return '';\n const xStep = width / Math.max(points.length - 1, 1);\n return points.map((y, i) => {\n const px = i * xStep;\n const py = height - (y / maxY) * height;\n return (i === 0 ? 'M' : 'L') + px.toFixed(1) + ',' + py.toFixed(1);\n }).join(' ');\n }\n\n function renderChart(data) {\n const el = document.getElementById('chart');\n if (!data || data.length === 0) { el.innerHTML = '<p class=\"empty\">No data yet</p>'; return; }\n const width = 800; const height = 180;\n const allVals = data.flatMap(d => [d.avg_ms, d.p95_ms, d.p99_ms]);\n const maxY = Math.max(...allVals, 1) * 1.1;\n const avgPath = buildSvgPath(data.map(d => d.avg_ms), width, height, maxY);\n const p95Path = buildSvgPath(data.map(d => d.p95_ms), width, height, maxY);\n const p99Path = buildSvgPath(data.map(d => d.p99_ms), width, height, maxY);\n el.innerHTML = '<svg viewBox=\"0 0 ' + width + ' ' + height + '\" preserveAspectRatio=\"none\">'\n + '<path class=\"chart-line line-avg\" d=\"' + avgPath + '\"/>'\n + '<path class=\"chart-line line-p95\" d=\"' + p95Path + '\"/>'\n + '<path class=\"chart-line line-p99\" d=\"' + p99Path + '\"/>'\n + '</svg>';\n }\n\n function renderModels(data) {\n const el = document.getElementById('models-table');\n if (!data || data.length === 0) { el.innerHTML = '<p class=\"empty\">No data yet</p>'; return; }\n let html = '<table><thead><tr><th>Model</th><th>Action</th><th class=\"num\">Count</th><th class=\"num\">Avg (ms)</th><th class=\"num\">P95 (ms)</th><th class=\"num\">P99 (ms)</th></tr></thead><tbody>';\n for (const row of data) {\n html += '<tr><td>' + row.model + '</td><td>' + row.action + '</td>'\n + '<td class=\"num\">' + row.count + '</td>'\n + '<td class=\"num\">' + row.avg_ms.toFixed(1) + '</td>'\n + '<td class=\"num\">' + row.p95_ms.toFixed(1) + '</td>'\n + '<td class=\"num\">' + row.p99_ms.toFixed(1) + '</td></tr>';\n }\n html += '</tbody></table>';\n el.innerHTML = html;\n }\n\n function renderErrors(data) {\n const el = document.getElementById('errors-table');\n if (!data || data.length === 0) { el.innerHTML = '<p class=\"empty\">No errors</p>'; return; }\n let html = '<table><thead><tr><th>Model</th><th>Action</th><th class=\"num\">Total</th><th class=\"num\">Errors</th><th class=\"num\">Rate</th></tr></thead><tbody>';\n for (const row of data) {\n const rate = row.count > 0 ? (row.error_count / row.count * 100).toFixed(1) : '0.0';\n const cls = parseFloat(rate) > 5 ? 'error-rate' : 'low-error';\n html += '<tr><td>' + row.model + '</td><td>' + row.action + '</td>'\n + '<td class=\"num\">' + row.count + '</td>'\n + '<td class=\"num\">' + row.error_count + '</td>'\n + '<td class=\"num ' + cls + '\">' + rate + '%</td></tr>';\n }\n html += '</tbody></table>';\n el.innerHTML = html;\n }\n\n async function refresh() {\n const [latency, models] = await Promise.all([\n fetchJson('/api/latency?range=' + currentRange),\n fetchJson('/api/models?range=' + currentRange),\n ]);\n renderChart(latency);\n renderModels(models);\n // Derive errors from models data\n const withErrors = (models || []).filter(m => m.error_count > 0);\n renderErrors(withErrors);\n }\n\n refresh();\n setInterval(refresh, 60000);\n </script>\n</body>\n</html>";
2
+ //# sourceMappingURL=observe-ui.d.ts.map
@@ -0,0 +1,180 @@
1
+ // Embedded HTML for the Turbine Observe dashboard.
2
+ // Same pattern as studio-ui.generated.ts but hand-authored (no build step needed).
3
+ export const OBSERVE_HTML = `<!doctype html>
4
+ <html lang="en">
5
+ <head>
6
+ <meta charset="utf-8" />
7
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
8
+ <meta name="color-scheme" content="dark" />
9
+ <title>Turbine Observe</title>
10
+ <style>
11
+ :root {
12
+ --bg: #0a0a0b;
13
+ --bg-elev: #111113;
14
+ --bg-hover: #1a1a1d;
15
+ --border: #26262b;
16
+ --text: #e6e6ea;
17
+ --text-dim: #8a8a93;
18
+ --accent: #60a5fa;
19
+ --green: #4ade80;
20
+ --red: #f87171;
21
+ --orange: #fb923c;
22
+ --purple: #a78bfa;
23
+ --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
24
+ --sans: system-ui, -apple-system, sans-serif;
25
+ --radius: 6px;
26
+ }
27
+ * { margin: 0; padding: 0; box-sizing: border-box; }
28
+ body { background: var(--bg); color: var(--text); font-family: var(--sans); font-size: 14px; padding: 24px; }
29
+ h1 { font-size: 20px; margin-bottom: 4px; }
30
+ .subtitle { color: var(--text-dim); margin-bottom: 24px; }
31
+ .controls { display: flex; gap: 8px; margin-bottom: 24px; }
32
+ .controls button {
33
+ background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius);
34
+ color: var(--text); padding: 6px 12px; cursor: pointer; font-size: 13px;
35
+ }
36
+ .controls button.active { border-color: var(--accent); color: var(--accent); }
37
+ .card {
38
+ background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius);
39
+ padding: 16px; margin-bottom: 16px;
40
+ }
41
+ .card h2 { font-size: 14px; color: var(--text-dim); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
42
+ table { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 12px; }
43
+ th { text-align: left; padding: 6px 8px; color: var(--text-dim); border-bottom: 1px solid var(--border); }
44
+ td { padding: 6px 8px; border-bottom: 1px solid var(--border); }
45
+ .num { text-align: right; }
46
+ .error-rate { color: var(--red); }
47
+ .low-error { color: var(--green); }
48
+ svg { width: 100%; height: 200px; }
49
+ .chart-line { fill: none; stroke-width: 1.5; }
50
+ .line-avg { stroke: var(--accent); }
51
+ .line-p95 { stroke: var(--orange); }
52
+ .line-p99 { stroke: var(--red); }
53
+ .legend { display: flex; gap: 16px; margin-top: 8px; font-size: 12px; color: var(--text-dim); }
54
+ .legend span::before { content: ''; display: inline-block; width: 12px; height: 2px; margin-right: 4px; vertical-align: middle; }
55
+ .legend .l-avg::before { background: var(--accent); }
56
+ .legend .l-p95::before { background: var(--orange); }
57
+ .legend .l-p99::before { background: var(--red); }
58
+ .empty { color: var(--text-dim); text-align: center; padding: 40px; }
59
+ </style>
60
+ </head>
61
+ <body>
62
+ <h1>Turbine Observe</h1>
63
+ <p class="subtitle">Query performance metrics</p>
64
+ <div class="controls">
65
+ <button data-range="1h" class="active">1h</button>
66
+ <button data-range="6h">6h</button>
67
+ <button data-range="24h">24h</button>
68
+ <button data-range="7d">7d</button>
69
+ </div>
70
+ <div class="card" id="latency-card">
71
+ <h2>Latency over time</h2>
72
+ <div id="chart"></div>
73
+ <div class="legend">
74
+ <span class="l-avg">avg</span>
75
+ <span class="l-p95">p95</span>
76
+ <span class="l-p99">p99</span>
77
+ </div>
78
+ </div>
79
+ <div class="card" id="models-card">
80
+ <h2>Top models</h2>
81
+ <div id="models-table"></div>
82
+ </div>
83
+ <div class="card" id="errors-card">
84
+ <h2>Error rates</h2>
85
+ <div id="errors-table"></div>
86
+ </div>
87
+ <script>
88
+ let currentRange = '1h';
89
+ const token = document.cookie.match(/turbine_observe_token=([a-f0-9]+)/)?.[1] || '';
90
+ const headers = { 'x-turbine-token': token };
91
+
92
+ document.querySelector('.controls').addEventListener('click', e => {
93
+ if (e.target.tagName !== 'BUTTON') return;
94
+ document.querySelectorAll('.controls button').forEach(b => b.classList.remove('active'));
95
+ e.target.classList.add('active');
96
+ currentRange = e.target.dataset.range;
97
+ refresh();
98
+ });
99
+
100
+ async function fetchJson(path) {
101
+ const res = await fetch(path, { headers });
102
+ if (!res.ok) return null;
103
+ return res.json();
104
+ }
105
+
106
+ function buildSvgPath(points, width, height, maxY) {
107
+ if (points.length === 0) return '';
108
+ const xStep = width / Math.max(points.length - 1, 1);
109
+ return points.map((y, i) => {
110
+ const px = i * xStep;
111
+ const py = height - (y / maxY) * height;
112
+ return (i === 0 ? 'M' : 'L') + px.toFixed(1) + ',' + py.toFixed(1);
113
+ }).join(' ');
114
+ }
115
+
116
+ function renderChart(data) {
117
+ const el = document.getElementById('chart');
118
+ if (!data || data.length === 0) { el.innerHTML = '<p class="empty">No data yet</p>'; return; }
119
+ const width = 800; const height = 180;
120
+ const allVals = data.flatMap(d => [d.avg_ms, d.p95_ms, d.p99_ms]);
121
+ const maxY = Math.max(...allVals, 1) * 1.1;
122
+ const avgPath = buildSvgPath(data.map(d => d.avg_ms), width, height, maxY);
123
+ const p95Path = buildSvgPath(data.map(d => d.p95_ms), width, height, maxY);
124
+ const p99Path = buildSvgPath(data.map(d => d.p99_ms), width, height, maxY);
125
+ el.innerHTML = '<svg viewBox="0 0 ' + width + ' ' + height + '" preserveAspectRatio="none">'
126
+ + '<path class="chart-line line-avg" d="' + avgPath + '"/>'
127
+ + '<path class="chart-line line-p95" d="' + p95Path + '"/>'
128
+ + '<path class="chart-line line-p99" d="' + p99Path + '"/>'
129
+ + '</svg>';
130
+ }
131
+
132
+ function renderModels(data) {
133
+ const el = document.getElementById('models-table');
134
+ if (!data || data.length === 0) { el.innerHTML = '<p class="empty">No data yet</p>'; return; }
135
+ let html = '<table><thead><tr><th>Model</th><th>Action</th><th class="num">Count</th><th class="num">Avg (ms)</th><th class="num">P95 (ms)</th><th class="num">P99 (ms)</th></tr></thead><tbody>';
136
+ for (const row of data) {
137
+ html += '<tr><td>' + row.model + '</td><td>' + row.action + '</td>'
138
+ + '<td class="num">' + row.count + '</td>'
139
+ + '<td class="num">' + row.avg_ms.toFixed(1) + '</td>'
140
+ + '<td class="num">' + row.p95_ms.toFixed(1) + '</td>'
141
+ + '<td class="num">' + row.p99_ms.toFixed(1) + '</td></tr>';
142
+ }
143
+ html += '</tbody></table>';
144
+ el.innerHTML = html;
145
+ }
146
+
147
+ function renderErrors(data) {
148
+ const el = document.getElementById('errors-table');
149
+ if (!data || data.length === 0) { el.innerHTML = '<p class="empty">No errors</p>'; return; }
150
+ let html = '<table><thead><tr><th>Model</th><th>Action</th><th class="num">Total</th><th class="num">Errors</th><th class="num">Rate</th></tr></thead><tbody>';
151
+ for (const row of data) {
152
+ const rate = row.count > 0 ? (row.error_count / row.count * 100).toFixed(1) : '0.0';
153
+ const cls = parseFloat(rate) > 5 ? 'error-rate' : 'low-error';
154
+ html += '<tr><td>' + row.model + '</td><td>' + row.action + '</td>'
155
+ + '<td class="num">' + row.count + '</td>'
156
+ + '<td class="num">' + row.error_count + '</td>'
157
+ + '<td class="num ' + cls + '">' + rate + '%</td></tr>';
158
+ }
159
+ html += '</tbody></table>';
160
+ el.innerHTML = html;
161
+ }
162
+
163
+ async function refresh() {
164
+ const [latency, models] = await Promise.all([
165
+ fetchJson('/api/latency?range=' + currentRange),
166
+ fetchJson('/api/models?range=' + currentRange),
167
+ ]);
168
+ renderChart(latency);
169
+ renderModels(models);
170
+ // Derive errors from models data
171
+ const withErrors = (models || []).filter(m => m.error_count > 0);
172
+ renderErrors(withErrors);
173
+ }
174
+
175
+ refresh();
176
+ setInterval(refresh, 60000);
177
+ </script>
178
+ </body>
179
+ </html>`;
180
+ //# sourceMappingURL=observe-ui.js.map
@@ -0,0 +1,20 @@
1
+ /**
2
+ * turbine-orm CLI — Observe
3
+ *
4
+ * A local, read-only dashboard for viewing query metrics stored in
5
+ * _turbine_metrics. Same security model as Studio: loopback binding,
6
+ * random token, HttpOnly cookie, CSP headers, read-only transactions.
7
+ */
8
+ export interface ObserveOptions {
9
+ url: string;
10
+ port: number;
11
+ host: string;
12
+ openBrowser: boolean;
13
+ }
14
+ export interface ObserveServerHandle {
15
+ dispose: () => Promise<void>;
16
+ authToken: string;
17
+ url: string;
18
+ }
19
+ export declare function startObserve(options: ObserveOptions): Promise<ObserveServerHandle>;
20
+ //# sourceMappingURL=observe.d.ts.map
@@ -0,0 +1,237 @@
1
+ /**
2
+ * turbine-orm CLI — Observe
3
+ *
4
+ * A local, read-only dashboard for viewing query metrics stored in
5
+ * _turbine_metrics. Same security model as Studio: loopback binding,
6
+ * random token, HttpOnly cookie, CSP headers, read-only transactions.
7
+ */
8
+ import { randomBytes } from 'node:crypto';
9
+ import { createServer } from 'node:http';
10
+ import pg from 'pg';
11
+ import { OBSERVE_HTML } from './observe-ui.js';
12
+ // ---------------------------------------------------------------------------
13
+ // Main entry point
14
+ // ---------------------------------------------------------------------------
15
+ export async function startObserve(options) {
16
+ const pool = new pg.Pool({
17
+ connectionString: options.url,
18
+ max: 2,
19
+ idleTimeoutMillis: 10_000,
20
+ });
21
+ const probe = await pool.connect();
22
+ try {
23
+ await probe.query('SELECT 1');
24
+ }
25
+ finally {
26
+ probe.release();
27
+ }
28
+ const authToken = randomBytes(24).toString('hex');
29
+ const server = createServer((req, res) => {
30
+ handleRequest(req, res, pool, options, authToken).catch((err) => {
31
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
32
+ });
33
+ });
34
+ await new Promise((resolve, reject) => {
35
+ server.once('error', reject);
36
+ server.listen(options.port, options.host, () => {
37
+ server.off('error', reject);
38
+ resolve();
39
+ });
40
+ });
41
+ const hostPart = options.host.includes(':') && !options.host.startsWith('[') ? `[${options.host}]` : options.host;
42
+ const url = `http://${hostPart}:${options.port}/?token=${authToken}`;
43
+ if (options.openBrowser) {
44
+ openUrl(url);
45
+ }
46
+ return {
47
+ authToken,
48
+ url,
49
+ dispose: async () => {
50
+ await new Promise((resolve) => server.close(() => resolve()));
51
+ await pool.end();
52
+ },
53
+ };
54
+ }
55
+ // ---------------------------------------------------------------------------
56
+ // Request routing
57
+ // ---------------------------------------------------------------------------
58
+ async function handleRequest(req, res, pool, options, authToken) {
59
+ const hostPart = options.host.includes(':') && !options.host.startsWith('[') ? `[${options.host}]` : options.host;
60
+ const expectedOrigin = `http://${hostPart}:${options.port}`;
61
+ const origin = req.headers.origin;
62
+ if (origin && origin !== expectedOrigin) {
63
+ sendJson(res, 403, { error: 'cross-origin requests not allowed' });
64
+ return;
65
+ }
66
+ const url = new URL(req.url ?? '/', expectedOrigin);
67
+ const pathname = url.pathname;
68
+ if (pathname === '/' || pathname === '/index.html') {
69
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
70
+ sendText(res, 405, 'Method Not Allowed');
71
+ return;
72
+ }
73
+ const queryToken = url.searchParams.get('token');
74
+ if (queryToken && constantTimeEqual(queryToken, authToken)) {
75
+ res.writeHead(302, {
76
+ Location: '/',
77
+ 'Set-Cookie': `turbine_observe_token=${authToken}; Path=/; HttpOnly; SameSite=Strict`,
78
+ });
79
+ res.end();
80
+ return;
81
+ }
82
+ sendHtml(res, 200, OBSERVE_HTML);
83
+ return;
84
+ }
85
+ if (!isAuthorized(req, authToken)) {
86
+ sendJson(res, 401, { error: 'unauthorized' });
87
+ return;
88
+ }
89
+ if (pathname === '/api/latency' && req.method === 'GET') {
90
+ return apiLatency(res, pool, url.searchParams);
91
+ }
92
+ if (pathname === '/api/models' && req.method === 'GET') {
93
+ return apiModels(res, pool, url.searchParams);
94
+ }
95
+ sendJson(res, 404, { error: 'not found' });
96
+ }
97
+ // ---------------------------------------------------------------------------
98
+ // Auth
99
+ // ---------------------------------------------------------------------------
100
+ function isAuthorized(req, expectedToken) {
101
+ const headerToken = req.headers['x-turbine-token'];
102
+ if (typeof headerToken === 'string' && constantTimeEqual(headerToken, expectedToken)) {
103
+ return true;
104
+ }
105
+ const cookieHeader = req.headers.cookie ?? '';
106
+ const match = /turbine_observe_token=([a-f0-9]+)/.exec(cookieHeader);
107
+ if (match?.[1] && constantTimeEqual(match[1], expectedToken)) {
108
+ return true;
109
+ }
110
+ return false;
111
+ }
112
+ function constantTimeEqual(a, b) {
113
+ if (a.length !== b.length)
114
+ return false;
115
+ let result = 0;
116
+ for (let i = 0; i < a.length; i++) {
117
+ result |= a.charCodeAt(i) ^ b.charCodeAt(i);
118
+ }
119
+ return result === 0;
120
+ }
121
+ // ---------------------------------------------------------------------------
122
+ // API handlers
123
+ // ---------------------------------------------------------------------------
124
+ function rangeToInterval(range) {
125
+ switch (range) {
126
+ case '6h':
127
+ return '6 hours';
128
+ case '24h':
129
+ return '24 hours';
130
+ case '7d':
131
+ return '7 days';
132
+ default:
133
+ return '1 hour';
134
+ }
135
+ }
136
+ async function apiLatency(res, pool, params) {
137
+ const range = params.get('range') ?? '1h';
138
+ const interval = rangeToInterval(range);
139
+ const client = await pool.connect();
140
+ try {
141
+ await client.query('BEGIN READ ONLY');
142
+ await client.query(`SET LOCAL statement_timeout = '30s'`);
143
+ const result = await client.query(`SELECT bucket, SUM(count) as count,
144
+ SUM(avg_ms * count) / NULLIF(SUM(count), 0) as avg_ms,
145
+ MAX(p95_ms) as p95_ms,
146
+ MAX(p99_ms) as p99_ms
147
+ FROM _turbine_metrics
148
+ WHERE bucket >= NOW() - $1::interval
149
+ GROUP BY bucket
150
+ ORDER BY bucket`, [interval]);
151
+ await client.query('COMMIT');
152
+ sendJson(res, 200, result.rows);
153
+ }
154
+ catch (err) {
155
+ try {
156
+ await client.query('ROLLBACK');
157
+ }
158
+ catch { }
159
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
160
+ }
161
+ finally {
162
+ client.release();
163
+ }
164
+ }
165
+ async function apiModels(res, pool, params) {
166
+ const range = params.get('range') ?? '1h';
167
+ const interval = rangeToInterval(range);
168
+ const client = await pool.connect();
169
+ try {
170
+ await client.query('BEGIN READ ONLY');
171
+ await client.query(`SET LOCAL statement_timeout = '30s'`);
172
+ const result = await client.query(`SELECT model, action,
173
+ SUM(count)::int as count,
174
+ SUM(avg_ms * count) / NULLIF(SUM(count), 0) as avg_ms,
175
+ MAX(p95_ms) as p95_ms,
176
+ MAX(p99_ms) as p99_ms,
177
+ SUM(error_count)::int as error_count
178
+ FROM _turbine_metrics
179
+ WHERE bucket >= NOW() - $1::interval
180
+ GROUP BY model, action
181
+ ORDER BY MAX(p95_ms) DESC
182
+ LIMIT 50`, [interval]);
183
+ await client.query('COMMIT');
184
+ sendJson(res, 200, result.rows);
185
+ }
186
+ catch (err) {
187
+ try {
188
+ await client.query('ROLLBACK');
189
+ }
190
+ catch { }
191
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
192
+ }
193
+ finally {
194
+ client.release();
195
+ }
196
+ }
197
+ // ---------------------------------------------------------------------------
198
+ // Response helpers
199
+ // ---------------------------------------------------------------------------
200
+ const SECURITY_HEADERS = {
201
+ 'X-Content-Type-Options': 'nosniff',
202
+ 'X-Frame-Options': 'DENY',
203
+ 'Referrer-Policy': 'no-referrer',
204
+ 'Content-Security-Policy': "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'",
205
+ };
206
+ function sendJson(res, status, body) {
207
+ const payload = JSON.stringify(body);
208
+ res.writeHead(status, { ...SECURITY_HEADERS, 'Content-Type': 'application/json' });
209
+ res.end(payload);
210
+ }
211
+ function sendHtml(res, status, html) {
212
+ res.writeHead(status, { ...SECURITY_HEADERS, 'Content-Type': 'text/html; charset=utf-8' });
213
+ res.end(html);
214
+ }
215
+ function sendText(res, status, text) {
216
+ res.writeHead(status, { ...SECURITY_HEADERS, 'Content-Type': 'text/plain' });
217
+ res.end(text);
218
+ }
219
+ // ---------------------------------------------------------------------------
220
+ // Browser open
221
+ // ---------------------------------------------------------------------------
222
+ function openUrl(url) {
223
+ const { platform: os } = process;
224
+ const { spawn } = require('node:child_process');
225
+ try {
226
+ if (os === 'darwin')
227
+ spawn('open', [url], { stdio: 'ignore', detached: true }).unref();
228
+ else if (os === 'win32')
229
+ spawn('cmd', ['/c', 'start', '', url], { stdio: 'ignore', detached: true }).unref();
230
+ else
231
+ spawn('xdg-open', [url], { stdio: 'ignore', detached: true }).unref();
232
+ }
233
+ catch {
234
+ // Best effort
235
+ }
236
+ }
237
+ //# sourceMappingURL=observe.js.map
@@ -45,8 +45,16 @@ export interface StudioContext {
45
45
  options: StudioOptions;
46
46
  authToken: string;
47
47
  stateDir: string;
48
- /** Resolved statement timeout SQL string (adapter-aware). */
49
- statementTimeoutSQL: string;
48
+ /** Resolved statement timeout (adapter-aware) — parameterized SQL + values. */
49
+ statementTimeout: {
50
+ sql: string;
51
+ params: unknown[];
52
+ };
53
+ /** Rate limiter state — tracks requests per authenticated session. */
54
+ rateLimiter: Map<string, {
55
+ count: number;
56
+ resetAt: number;
57
+ }>;
50
58
  }
51
59
  /**
52
60
  * Start the Studio server. Returns a handle with the session token, a pre-built