turbine-orm 0.15.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.
- package/dist/cjs/cli/index.js +64 -0
- package/dist/cjs/cli/observe-ui.js +182 -0
- package/dist/cjs/cli/observe.js +242 -0
- package/dist/cjs/client.js +54 -0
- package/dist/cjs/nested-write.js +94 -4
- package/dist/cjs/observe.js +145 -0
- package/dist/cjs/query/builder.js +66 -13
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +64 -0
- package/dist/cli/observe-ui.d.ts +2 -0
- package/dist/cli/observe-ui.js +180 -0
- package/dist/cli/observe.d.ts +20 -0
- package/dist/cli/observe.js +237 -0
- package/dist/client.d.ts +9 -2
- package/dist/client.js +55 -1
- package/dist/index.d.ts +2 -1
- package/dist/nested-write.d.ts +2 -2
- package/dist/nested-write.js +94 -4
- package/dist/observe.d.ts +36 -0
- package/dist/observe.js +141 -0
- package/dist/query/builder.d.ts +17 -0
- package/dist/query/builder.js +66 -13
- package/dist/query/index.d.ts +1 -1
- package/package.json +2 -2
package/dist/cjs/cli/index.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* turbine seed — Run seed file
|
|
15
15
|
* turbine status — Show schema summary
|
|
16
16
|
* turbine studio — Launch local read-only web UI
|
|
17
|
+
* turbine observe — Launch metrics dashboard (requires TURBINE_OBSERVE_URL)
|
|
17
18
|
*
|
|
18
19
|
* Usage:
|
|
19
20
|
* DATABASE_URL=postgres://... npx turbine generate
|
|
@@ -63,6 +64,7 @@ const schema_sql_js_1 = require("../schema-sql.js");
|
|
|
63
64
|
const config_js_1 = require("./config.js");
|
|
64
65
|
const loader_js_1 = require("./loader.js");
|
|
65
66
|
const migrate_js_1 = require("./migrate.js");
|
|
67
|
+
const observe_js_1 = require("./observe.js");
|
|
66
68
|
const studio_js_1 = require("./studio.js");
|
|
67
69
|
const ui_js_1 = require("./ui.js");
|
|
68
70
|
function parseArgs() {
|
|
@@ -1005,6 +1007,65 @@ async function cmdStudio(args, config) {
|
|
|
1005
1007
|
});
|
|
1006
1008
|
}
|
|
1007
1009
|
// ---------------------------------------------------------------------------
|
|
1010
|
+
// Command: observe
|
|
1011
|
+
// ---------------------------------------------------------------------------
|
|
1012
|
+
async function cmdObserve(args) {
|
|
1013
|
+
(0, ui_js_1.banner)();
|
|
1014
|
+
const url = process.env.TURBINE_OBSERVE_URL;
|
|
1015
|
+
if (!url) {
|
|
1016
|
+
(0, ui_js_1.error)('TURBINE_OBSERVE_URL environment variable is required for the observe command.');
|
|
1017
|
+
(0, ui_js_1.newline)();
|
|
1018
|
+
console.log(` ${(0, ui_js_1.dim)('Set it to the Postgres connection string where metrics are stored.')}`);
|
|
1019
|
+
console.log(` ${(0, ui_js_1.dim)('Example:')} ${(0, ui_js_1.cyan)('TURBINE_OBSERVE_URL=postgres://... npx turbine observe')}`);
|
|
1020
|
+
(0, ui_js_1.newline)();
|
|
1021
|
+
process.exit(1);
|
|
1022
|
+
}
|
|
1023
|
+
const port = args.port ?? 4984;
|
|
1024
|
+
const host = args.host ?? '127.0.0.1';
|
|
1025
|
+
const openBrowser = !args.noOpen;
|
|
1026
|
+
if (!Number.isFinite(port) || port <= 0 || port > 65535) {
|
|
1027
|
+
console.log((0, ui_js_1.red)(`✗ invalid port: ${args.port}`));
|
|
1028
|
+
process.exit(1);
|
|
1029
|
+
}
|
|
1030
|
+
if (host !== '127.0.0.1' && host !== 'localhost' && host !== '::1') {
|
|
1031
|
+
console.log((0, ui_js_1.warn)(`Observe is binding to ${(0, ui_js_1.yellow)(host)} — this is NOT loopback. ` +
|
|
1032
|
+
`Anyone on your network who can reach this port + guess the session token can read your metrics.`));
|
|
1033
|
+
}
|
|
1034
|
+
const spinner = new ui_js_1.Spinner('Connecting to metrics database').start();
|
|
1035
|
+
let handle;
|
|
1036
|
+
try {
|
|
1037
|
+
handle = await (0, observe_js_1.startObserve)({ url, port, host, openBrowser });
|
|
1038
|
+
spinner.succeed('Observe dashboard is running');
|
|
1039
|
+
}
|
|
1040
|
+
catch (err) {
|
|
1041
|
+
spinner.fail(`Failed to start Observe: ${err instanceof Error ? err.message : String(err)}`);
|
|
1042
|
+
process.exit(1);
|
|
1043
|
+
}
|
|
1044
|
+
(0, ui_js_1.newline)();
|
|
1045
|
+
console.log((0, ui_js_1.box)([
|
|
1046
|
+
`${(0, ui_js_1.bold)('Turbine Observe')} ${(0, ui_js_1.dim)('— query metrics dashboard')}`,
|
|
1047
|
+
'',
|
|
1048
|
+
` ${(0, ui_js_1.cyan)('URL:')} ${(0, ui_js_1.bold)(handle.url)}`,
|
|
1049
|
+
'',
|
|
1050
|
+
(0, ui_js_1.dim)('Open the URL above in your browser. Press Ctrl+C to stop.'),
|
|
1051
|
+
].join('\n'), { title: (0, ui_js_1.bold)((0, ui_js_1.cyan)('Observe')), padding: 1 }));
|
|
1052
|
+
(0, ui_js_1.newline)();
|
|
1053
|
+
await new Promise((resolve) => {
|
|
1054
|
+
const shutdown = async () => {
|
|
1055
|
+
console.log((0, ui_js_1.dim)('\n shutting down…'));
|
|
1056
|
+
try {
|
|
1057
|
+
await handle.dispose();
|
|
1058
|
+
}
|
|
1059
|
+
catch {
|
|
1060
|
+
/* ignore */
|
|
1061
|
+
}
|
|
1062
|
+
resolve();
|
|
1063
|
+
};
|
|
1064
|
+
process.once('SIGINT', shutdown);
|
|
1065
|
+
process.once('SIGTERM', shutdown);
|
|
1066
|
+
});
|
|
1067
|
+
}
|
|
1068
|
+
// ---------------------------------------------------------------------------
|
|
1008
1069
|
// Subcommand help
|
|
1009
1070
|
// ---------------------------------------------------------------------------
|
|
1010
1071
|
function showSubcommandHelp(command) {
|
|
@@ -1288,6 +1349,9 @@ async function main() {
|
|
|
1288
1349
|
case 'studio':
|
|
1289
1350
|
await cmdStudio(args, config);
|
|
1290
1351
|
break;
|
|
1352
|
+
case 'observe':
|
|
1353
|
+
await cmdObserve(args);
|
|
1354
|
+
break;
|
|
1291
1355
|
default:
|
|
1292
1356
|
(0, ui_js_1.error)(`Unknown command: ${(0, ui_js_1.bold)(args.command)}`);
|
|
1293
1357
|
(0, ui_js_1.newline)();
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Embedded HTML for the Turbine Observe dashboard.
|
|
3
|
+
// Same pattern as studio-ui.generated.ts but hand-authored (no build step needed).
|
|
4
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5
|
+
exports.OBSERVE_HTML = void 0;
|
|
6
|
+
exports.OBSERVE_HTML = `<!doctype html>
|
|
7
|
+
<html lang="en">
|
|
8
|
+
<head>
|
|
9
|
+
<meta charset="utf-8" />
|
|
10
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
11
|
+
<meta name="color-scheme" content="dark" />
|
|
12
|
+
<title>Turbine Observe</title>
|
|
13
|
+
<style>
|
|
14
|
+
:root {
|
|
15
|
+
--bg: #0a0a0b;
|
|
16
|
+
--bg-elev: #111113;
|
|
17
|
+
--bg-hover: #1a1a1d;
|
|
18
|
+
--border: #26262b;
|
|
19
|
+
--text: #e6e6ea;
|
|
20
|
+
--text-dim: #8a8a93;
|
|
21
|
+
--accent: #60a5fa;
|
|
22
|
+
--green: #4ade80;
|
|
23
|
+
--red: #f87171;
|
|
24
|
+
--orange: #fb923c;
|
|
25
|
+
--purple: #a78bfa;
|
|
26
|
+
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
27
|
+
--sans: system-ui, -apple-system, sans-serif;
|
|
28
|
+
--radius: 6px;
|
|
29
|
+
}
|
|
30
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
31
|
+
body { background: var(--bg); color: var(--text); font-family: var(--sans); font-size: 14px; padding: 24px; }
|
|
32
|
+
h1 { font-size: 20px; margin-bottom: 4px; }
|
|
33
|
+
.subtitle { color: var(--text-dim); margin-bottom: 24px; }
|
|
34
|
+
.controls { display: flex; gap: 8px; margin-bottom: 24px; }
|
|
35
|
+
.controls button {
|
|
36
|
+
background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius);
|
|
37
|
+
color: var(--text); padding: 6px 12px; cursor: pointer; font-size: 13px;
|
|
38
|
+
}
|
|
39
|
+
.controls button.active { border-color: var(--accent); color: var(--accent); }
|
|
40
|
+
.card {
|
|
41
|
+
background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius);
|
|
42
|
+
padding: 16px; margin-bottom: 16px;
|
|
43
|
+
}
|
|
44
|
+
.card h2 { font-size: 14px; color: var(--text-dim); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
|
|
45
|
+
table { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 12px; }
|
|
46
|
+
th { text-align: left; padding: 6px 8px; color: var(--text-dim); border-bottom: 1px solid var(--border); }
|
|
47
|
+
td { padding: 6px 8px; border-bottom: 1px solid var(--border); }
|
|
48
|
+
.num { text-align: right; }
|
|
49
|
+
.error-rate { color: var(--red); }
|
|
50
|
+
.low-error { color: var(--green); }
|
|
51
|
+
svg { width: 100%; height: 200px; }
|
|
52
|
+
.chart-line { fill: none; stroke-width: 1.5; }
|
|
53
|
+
.line-avg { stroke: var(--accent); }
|
|
54
|
+
.line-p95 { stroke: var(--orange); }
|
|
55
|
+
.line-p99 { stroke: var(--red); }
|
|
56
|
+
.legend { display: flex; gap: 16px; margin-top: 8px; font-size: 12px; color: var(--text-dim); }
|
|
57
|
+
.legend span::before { content: ''; display: inline-block; width: 12px; height: 2px; margin-right: 4px; vertical-align: middle; }
|
|
58
|
+
.legend .l-avg::before { background: var(--accent); }
|
|
59
|
+
.legend .l-p95::before { background: var(--orange); }
|
|
60
|
+
.legend .l-p99::before { background: var(--red); }
|
|
61
|
+
.empty { color: var(--text-dim); text-align: center; padding: 40px; }
|
|
62
|
+
</style>
|
|
63
|
+
</head>
|
|
64
|
+
<body>
|
|
65
|
+
<h1>Turbine Observe</h1>
|
|
66
|
+
<p class="subtitle">Query performance metrics</p>
|
|
67
|
+
<div class="controls">
|
|
68
|
+
<button data-range="1h" class="active">1h</button>
|
|
69
|
+
<button data-range="6h">6h</button>
|
|
70
|
+
<button data-range="24h">24h</button>
|
|
71
|
+
<button data-range="7d">7d</button>
|
|
72
|
+
</div>
|
|
73
|
+
<div class="card" id="latency-card">
|
|
74
|
+
<h2>Latency over time</h2>
|
|
75
|
+
<div id="chart"></div>
|
|
76
|
+
<div class="legend">
|
|
77
|
+
<span class="l-avg">avg</span>
|
|
78
|
+
<span class="l-p95">p95</span>
|
|
79
|
+
<span class="l-p99">p99</span>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
<div class="card" id="models-card">
|
|
83
|
+
<h2>Top models</h2>
|
|
84
|
+
<div id="models-table"></div>
|
|
85
|
+
</div>
|
|
86
|
+
<div class="card" id="errors-card">
|
|
87
|
+
<h2>Error rates</h2>
|
|
88
|
+
<div id="errors-table"></div>
|
|
89
|
+
</div>
|
|
90
|
+
<script>
|
|
91
|
+
let currentRange = '1h';
|
|
92
|
+
const token = document.cookie.match(/turbine_observe_token=([a-f0-9]+)/)?.[1] || '';
|
|
93
|
+
const headers = { 'x-turbine-token': token };
|
|
94
|
+
|
|
95
|
+
document.querySelector('.controls').addEventListener('click', e => {
|
|
96
|
+
if (e.target.tagName !== 'BUTTON') return;
|
|
97
|
+
document.querySelectorAll('.controls button').forEach(b => b.classList.remove('active'));
|
|
98
|
+
e.target.classList.add('active');
|
|
99
|
+
currentRange = e.target.dataset.range;
|
|
100
|
+
refresh();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
async function fetchJson(path) {
|
|
104
|
+
const res = await fetch(path, { headers });
|
|
105
|
+
if (!res.ok) return null;
|
|
106
|
+
return res.json();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function buildSvgPath(points, width, height, maxY) {
|
|
110
|
+
if (points.length === 0) return '';
|
|
111
|
+
const xStep = width / Math.max(points.length - 1, 1);
|
|
112
|
+
return points.map((y, i) => {
|
|
113
|
+
const px = i * xStep;
|
|
114
|
+
const py = height - (y / maxY) * height;
|
|
115
|
+
return (i === 0 ? 'M' : 'L') + px.toFixed(1) + ',' + py.toFixed(1);
|
|
116
|
+
}).join(' ');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function renderChart(data) {
|
|
120
|
+
const el = document.getElementById('chart');
|
|
121
|
+
if (!data || data.length === 0) { el.innerHTML = '<p class="empty">No data yet</p>'; return; }
|
|
122
|
+
const width = 800; const height = 180;
|
|
123
|
+
const allVals = data.flatMap(d => [d.avg_ms, d.p95_ms, d.p99_ms]);
|
|
124
|
+
const maxY = Math.max(...allVals, 1) * 1.1;
|
|
125
|
+
const avgPath = buildSvgPath(data.map(d => d.avg_ms), width, height, maxY);
|
|
126
|
+
const p95Path = buildSvgPath(data.map(d => d.p95_ms), width, height, maxY);
|
|
127
|
+
const p99Path = buildSvgPath(data.map(d => d.p99_ms), width, height, maxY);
|
|
128
|
+
el.innerHTML = '<svg viewBox="0 0 ' + width + ' ' + height + '" preserveAspectRatio="none">'
|
|
129
|
+
+ '<path class="chart-line line-avg" d="' + avgPath + '"/>'
|
|
130
|
+
+ '<path class="chart-line line-p95" d="' + p95Path + '"/>'
|
|
131
|
+
+ '<path class="chart-line line-p99" d="' + p99Path + '"/>'
|
|
132
|
+
+ '</svg>';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function renderModels(data) {
|
|
136
|
+
const el = document.getElementById('models-table');
|
|
137
|
+
if (!data || data.length === 0) { el.innerHTML = '<p class="empty">No data yet</p>'; return; }
|
|
138
|
+
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>';
|
|
139
|
+
for (const row of data) {
|
|
140
|
+
html += '<tr><td>' + row.model + '</td><td>' + row.action + '</td>'
|
|
141
|
+
+ '<td class="num">' + row.count + '</td>'
|
|
142
|
+
+ '<td class="num">' + row.avg_ms.toFixed(1) + '</td>'
|
|
143
|
+
+ '<td class="num">' + row.p95_ms.toFixed(1) + '</td>'
|
|
144
|
+
+ '<td class="num">' + row.p99_ms.toFixed(1) + '</td></tr>';
|
|
145
|
+
}
|
|
146
|
+
html += '</tbody></table>';
|
|
147
|
+
el.innerHTML = html;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function renderErrors(data) {
|
|
151
|
+
const el = document.getElementById('errors-table');
|
|
152
|
+
if (!data || data.length === 0) { el.innerHTML = '<p class="empty">No errors</p>'; return; }
|
|
153
|
+
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>';
|
|
154
|
+
for (const row of data) {
|
|
155
|
+
const rate = row.count > 0 ? (row.error_count / row.count * 100).toFixed(1) : '0.0';
|
|
156
|
+
const cls = parseFloat(rate) > 5 ? 'error-rate' : 'low-error';
|
|
157
|
+
html += '<tr><td>' + row.model + '</td><td>' + row.action + '</td>'
|
|
158
|
+
+ '<td class="num">' + row.count + '</td>'
|
|
159
|
+
+ '<td class="num">' + row.error_count + '</td>'
|
|
160
|
+
+ '<td class="num ' + cls + '">' + rate + '%</td></tr>';
|
|
161
|
+
}
|
|
162
|
+
html += '</tbody></table>';
|
|
163
|
+
el.innerHTML = html;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function refresh() {
|
|
167
|
+
const [latency, models] = await Promise.all([
|
|
168
|
+
fetchJson('/api/latency?range=' + currentRange),
|
|
169
|
+
fetchJson('/api/models?range=' + currentRange),
|
|
170
|
+
]);
|
|
171
|
+
renderChart(latency);
|
|
172
|
+
renderModels(models);
|
|
173
|
+
// Derive errors from models data
|
|
174
|
+
const withErrors = (models || []).filter(m => m.error_count > 0);
|
|
175
|
+
renderErrors(withErrors);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
refresh();
|
|
179
|
+
setInterval(refresh, 60000);
|
|
180
|
+
</script>
|
|
181
|
+
</body>
|
|
182
|
+
</html>`;
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* turbine-orm CLI — Observe
|
|
4
|
+
*
|
|
5
|
+
* A local, read-only dashboard for viewing query metrics stored in
|
|
6
|
+
* _turbine_metrics. Same security model as Studio: loopback binding,
|
|
7
|
+
* random token, HttpOnly cookie, CSP headers, read-only transactions.
|
|
8
|
+
*/
|
|
9
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
10
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
11
|
+
};
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.startObserve = startObserve;
|
|
14
|
+
const node_crypto_1 = require("node:crypto");
|
|
15
|
+
const node_http_1 = require("node:http");
|
|
16
|
+
const pg_1 = __importDefault(require("pg"));
|
|
17
|
+
const observe_ui_js_1 = require("./observe-ui.js");
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Main entry point
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
async function startObserve(options) {
|
|
22
|
+
const pool = new pg_1.default.Pool({
|
|
23
|
+
connectionString: options.url,
|
|
24
|
+
max: 2,
|
|
25
|
+
idleTimeoutMillis: 10_000,
|
|
26
|
+
});
|
|
27
|
+
const probe = await pool.connect();
|
|
28
|
+
try {
|
|
29
|
+
await probe.query('SELECT 1');
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
probe.release();
|
|
33
|
+
}
|
|
34
|
+
const authToken = (0, node_crypto_1.randomBytes)(24).toString('hex');
|
|
35
|
+
const server = (0, node_http_1.createServer)((req, res) => {
|
|
36
|
+
handleRequest(req, res, pool, options, authToken).catch((err) => {
|
|
37
|
+
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
await new Promise((resolve, reject) => {
|
|
41
|
+
server.once('error', reject);
|
|
42
|
+
server.listen(options.port, options.host, () => {
|
|
43
|
+
server.off('error', reject);
|
|
44
|
+
resolve();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
const hostPart = options.host.includes(':') && !options.host.startsWith('[') ? `[${options.host}]` : options.host;
|
|
48
|
+
const url = `http://${hostPart}:${options.port}/?token=${authToken}`;
|
|
49
|
+
if (options.openBrowser) {
|
|
50
|
+
openUrl(url);
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
authToken,
|
|
54
|
+
url,
|
|
55
|
+
dispose: async () => {
|
|
56
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
57
|
+
await pool.end();
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Request routing
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
async function handleRequest(req, res, pool, options, authToken) {
|
|
65
|
+
const hostPart = options.host.includes(':') && !options.host.startsWith('[') ? `[${options.host}]` : options.host;
|
|
66
|
+
const expectedOrigin = `http://${hostPart}:${options.port}`;
|
|
67
|
+
const origin = req.headers.origin;
|
|
68
|
+
if (origin && origin !== expectedOrigin) {
|
|
69
|
+
sendJson(res, 403, { error: 'cross-origin requests not allowed' });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const url = new URL(req.url ?? '/', expectedOrigin);
|
|
73
|
+
const pathname = url.pathname;
|
|
74
|
+
if (pathname === '/' || pathname === '/index.html') {
|
|
75
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
76
|
+
sendText(res, 405, 'Method Not Allowed');
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const queryToken = url.searchParams.get('token');
|
|
80
|
+
if (queryToken && constantTimeEqual(queryToken, authToken)) {
|
|
81
|
+
res.writeHead(302, {
|
|
82
|
+
Location: '/',
|
|
83
|
+
'Set-Cookie': `turbine_observe_token=${authToken}; Path=/; HttpOnly; SameSite=Strict`,
|
|
84
|
+
});
|
|
85
|
+
res.end();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
sendHtml(res, 200, observe_ui_js_1.OBSERVE_HTML);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (!isAuthorized(req, authToken)) {
|
|
92
|
+
sendJson(res, 401, { error: 'unauthorized' });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (pathname === '/api/latency' && req.method === 'GET') {
|
|
96
|
+
return apiLatency(res, pool, url.searchParams);
|
|
97
|
+
}
|
|
98
|
+
if (pathname === '/api/models' && req.method === 'GET') {
|
|
99
|
+
return apiModels(res, pool, url.searchParams);
|
|
100
|
+
}
|
|
101
|
+
sendJson(res, 404, { error: 'not found' });
|
|
102
|
+
}
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Auth
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
function isAuthorized(req, expectedToken) {
|
|
107
|
+
const headerToken = req.headers['x-turbine-token'];
|
|
108
|
+
if (typeof headerToken === 'string' && constantTimeEqual(headerToken, expectedToken)) {
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
const cookieHeader = req.headers.cookie ?? '';
|
|
112
|
+
const match = /turbine_observe_token=([a-f0-9]+)/.exec(cookieHeader);
|
|
113
|
+
if (match?.[1] && constantTimeEqual(match[1], expectedToken)) {
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
function constantTimeEqual(a, b) {
|
|
119
|
+
if (a.length !== b.length)
|
|
120
|
+
return false;
|
|
121
|
+
let result = 0;
|
|
122
|
+
for (let i = 0; i < a.length; i++) {
|
|
123
|
+
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
124
|
+
}
|
|
125
|
+
return result === 0;
|
|
126
|
+
}
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// API handlers
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
function rangeToInterval(range) {
|
|
131
|
+
switch (range) {
|
|
132
|
+
case '6h':
|
|
133
|
+
return '6 hours';
|
|
134
|
+
case '24h':
|
|
135
|
+
return '24 hours';
|
|
136
|
+
case '7d':
|
|
137
|
+
return '7 days';
|
|
138
|
+
default:
|
|
139
|
+
return '1 hour';
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
async function apiLatency(res, pool, params) {
|
|
143
|
+
const range = params.get('range') ?? '1h';
|
|
144
|
+
const interval = rangeToInterval(range);
|
|
145
|
+
const client = await pool.connect();
|
|
146
|
+
try {
|
|
147
|
+
await client.query('BEGIN READ ONLY');
|
|
148
|
+
await client.query(`SET LOCAL statement_timeout = '30s'`);
|
|
149
|
+
const result = await client.query(`SELECT bucket, SUM(count) as count,
|
|
150
|
+
SUM(avg_ms * count) / NULLIF(SUM(count), 0) as avg_ms,
|
|
151
|
+
MAX(p95_ms) as p95_ms,
|
|
152
|
+
MAX(p99_ms) as p99_ms
|
|
153
|
+
FROM _turbine_metrics
|
|
154
|
+
WHERE bucket >= NOW() - $1::interval
|
|
155
|
+
GROUP BY bucket
|
|
156
|
+
ORDER BY bucket`, [interval]);
|
|
157
|
+
await client.query('COMMIT');
|
|
158
|
+
sendJson(res, 200, result.rows);
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
try {
|
|
162
|
+
await client.query('ROLLBACK');
|
|
163
|
+
}
|
|
164
|
+
catch { }
|
|
165
|
+
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
client.release();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
async function apiModels(res, pool, params) {
|
|
172
|
+
const range = params.get('range') ?? '1h';
|
|
173
|
+
const interval = rangeToInterval(range);
|
|
174
|
+
const client = await pool.connect();
|
|
175
|
+
try {
|
|
176
|
+
await client.query('BEGIN READ ONLY');
|
|
177
|
+
await client.query(`SET LOCAL statement_timeout = '30s'`);
|
|
178
|
+
const result = await client.query(`SELECT model, action,
|
|
179
|
+
SUM(count)::int as count,
|
|
180
|
+
SUM(avg_ms * count) / NULLIF(SUM(count), 0) as avg_ms,
|
|
181
|
+
MAX(p95_ms) as p95_ms,
|
|
182
|
+
MAX(p99_ms) as p99_ms,
|
|
183
|
+
SUM(error_count)::int as error_count
|
|
184
|
+
FROM _turbine_metrics
|
|
185
|
+
WHERE bucket >= NOW() - $1::interval
|
|
186
|
+
GROUP BY model, action
|
|
187
|
+
ORDER BY MAX(p95_ms) DESC
|
|
188
|
+
LIMIT 50`, [interval]);
|
|
189
|
+
await client.query('COMMIT');
|
|
190
|
+
sendJson(res, 200, result.rows);
|
|
191
|
+
}
|
|
192
|
+
catch (err) {
|
|
193
|
+
try {
|
|
194
|
+
await client.query('ROLLBACK');
|
|
195
|
+
}
|
|
196
|
+
catch { }
|
|
197
|
+
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
client.release();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// Response helpers
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
const SECURITY_HEADERS = {
|
|
207
|
+
'X-Content-Type-Options': 'nosniff',
|
|
208
|
+
'X-Frame-Options': 'DENY',
|
|
209
|
+
'Referrer-Policy': 'no-referrer',
|
|
210
|
+
'Content-Security-Policy': "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'",
|
|
211
|
+
};
|
|
212
|
+
function sendJson(res, status, body) {
|
|
213
|
+
const payload = JSON.stringify(body);
|
|
214
|
+
res.writeHead(status, { ...SECURITY_HEADERS, 'Content-Type': 'application/json' });
|
|
215
|
+
res.end(payload);
|
|
216
|
+
}
|
|
217
|
+
function sendHtml(res, status, html) {
|
|
218
|
+
res.writeHead(status, { ...SECURITY_HEADERS, 'Content-Type': 'text/html; charset=utf-8' });
|
|
219
|
+
res.end(html);
|
|
220
|
+
}
|
|
221
|
+
function sendText(res, status, text) {
|
|
222
|
+
res.writeHead(status, { ...SECURITY_HEADERS, 'Content-Type': 'text/plain' });
|
|
223
|
+
res.end(text);
|
|
224
|
+
}
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
// Browser open
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
function openUrl(url) {
|
|
229
|
+
const { platform: os } = process;
|
|
230
|
+
const { spawn } = require('node:child_process');
|
|
231
|
+
try {
|
|
232
|
+
if (os === 'darwin')
|
|
233
|
+
spawn('open', [url], { stdio: 'ignore', detached: true }).unref();
|
|
234
|
+
else if (os === 'win32')
|
|
235
|
+
spawn('cmd', ['/c', 'start', '', url], { stdio: 'ignore', detached: true }).unref();
|
|
236
|
+
else
|
|
237
|
+
spawn('xdg-open', [url], { stdio: 'ignore', detached: true }).unref();
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
// Best effort
|
|
241
|
+
}
|
|
242
|
+
}
|
package/dist/cjs/client.js
CHANGED
|
@@ -30,6 +30,7 @@ exports.TurbineClient = exports.TransactionClient = void 0;
|
|
|
30
30
|
exports.withRetry = withRetry;
|
|
31
31
|
const pg_1 = __importDefault(require("pg"));
|
|
32
32
|
const errors_js_1 = require("./errors.js");
|
|
33
|
+
const observe_js_1 = require("./observe.js");
|
|
33
34
|
const pipeline_js_1 = require("./pipeline.js");
|
|
34
35
|
const index_js_1 = require("./query/index.js");
|
|
35
36
|
async function withRetry(fn, options) {
|
|
@@ -191,7 +192,9 @@ class TurbineClient {
|
|
|
191
192
|
logging;
|
|
192
193
|
tableCache = new Map();
|
|
193
194
|
middlewares = [];
|
|
195
|
+
queryListeners = new Set();
|
|
194
196
|
queryOptions;
|
|
197
|
+
errorMessagesSafe;
|
|
195
198
|
/** True when Turbine created the pool and is responsible for tearing it down */
|
|
196
199
|
ownsPool = true;
|
|
197
200
|
constructor(config = {}, schema) {
|
|
@@ -225,12 +228,27 @@ class TurbineClient {
|
|
|
225
228
|
this.schema = schema;
|
|
226
229
|
// Respect env var kill switch
|
|
227
230
|
const envDisablePrepared = typeof process !== 'undefined' && process.env?.TURBINE_DISABLE_PREPARED === '1';
|
|
231
|
+
this.errorMessagesSafe = (config.errorMessages ?? 'safe') === 'safe';
|
|
228
232
|
this.queryOptions = {
|
|
229
233
|
defaultLimit: config.defaultLimit,
|
|
230
234
|
warnOnUnlimited: config.warnOnUnlimited,
|
|
231
235
|
preparedStatements: envDisablePrepared ? false : (config.preparedStatements ?? !config.pool),
|
|
232
236
|
sqlCache: config.sqlCache ?? true,
|
|
233
237
|
dialect: config.dialect,
|
|
238
|
+
_onQuery: (event) => {
|
|
239
|
+
if (this.queryListeners.size === 0)
|
|
240
|
+
return;
|
|
241
|
+
const emitted = this.errorMessagesSafe ? { ...event, params: event.params.map(() => '[REDACTED]') } : event;
|
|
242
|
+
for (const listener of this.queryListeners) {
|
|
243
|
+
try {
|
|
244
|
+
listener(emitted);
|
|
245
|
+
}
|
|
246
|
+
catch (e) {
|
|
247
|
+
if (this.logging)
|
|
248
|
+
console.error('[turbine] Query listener error:', e);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
},
|
|
234
252
|
};
|
|
235
253
|
// Apply NotFoundError message redaction mode (default: safe — values are
|
|
236
254
|
// stripped from messages to avoid leaking PII into error logs).
|
|
@@ -283,6 +301,11 @@ class TurbineClient {
|
|
|
283
301
|
});
|
|
284
302
|
}
|
|
285
303
|
}
|
|
304
|
+
// Auto-start observability from env var
|
|
305
|
+
const observeUrl = typeof process !== 'undefined' ? process.env?.TURBINE_OBSERVE_URL : undefined;
|
|
306
|
+
if (observeUrl) {
|
|
307
|
+
this.$observe({ connectionString: observeUrl }).catch(() => { });
|
|
308
|
+
}
|
|
286
309
|
}
|
|
287
310
|
// -------------------------------------------------------------------------
|
|
288
311
|
// Middleware — intercept all queries
|
|
@@ -324,6 +347,37 @@ class TurbineClient {
|
|
|
324
347
|
this.tableCache.clear();
|
|
325
348
|
}
|
|
326
349
|
// -------------------------------------------------------------------------
|
|
350
|
+
// Event emitter — subscribe to query lifecycle events
|
|
351
|
+
// -------------------------------------------------------------------------
|
|
352
|
+
$on(_event, listener) {
|
|
353
|
+
this.queryListeners.add(listener);
|
|
354
|
+
}
|
|
355
|
+
$off(_event, listener) {
|
|
356
|
+
this.queryListeners.delete(listener);
|
|
357
|
+
}
|
|
358
|
+
// -------------------------------------------------------------------------
|
|
359
|
+
// Observability — automatic metrics collection
|
|
360
|
+
// -------------------------------------------------------------------------
|
|
361
|
+
observeEngine;
|
|
362
|
+
async $observe(config) {
|
|
363
|
+
if (this.observeEngine) {
|
|
364
|
+
await this.observeEngine.stop();
|
|
365
|
+
this.$off('query', this.observeEngine.getListener());
|
|
366
|
+
}
|
|
367
|
+
const engine = new observe_js_1.ObserveEngine(config);
|
|
368
|
+
this.observeEngine = engine;
|
|
369
|
+
await engine.init();
|
|
370
|
+
this.$on('query', engine.getListener());
|
|
371
|
+
return {
|
|
372
|
+
stop: async () => {
|
|
373
|
+
this.$off('query', engine.getListener());
|
|
374
|
+
await engine.stop();
|
|
375
|
+
if (this.observeEngine === engine)
|
|
376
|
+
this.observeEngine = undefined;
|
|
377
|
+
},
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
// -------------------------------------------------------------------------
|
|
327
381
|
// Table accessor — creates QueryInterface for any table
|
|
328
382
|
// -------------------------------------------------------------------------
|
|
329
383
|
/**
|