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
|
@@ -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
|
package/dist/client.d.ts
CHANGED
|
@@ -24,8 +24,9 @@
|
|
|
24
24
|
import pg from 'pg';
|
|
25
25
|
import type { Dialect } from './dialect.js';
|
|
26
26
|
import { type ErrorMessageMode } from './errors.js';
|
|
27
|
+
import { type ObserveConfig, type ObserveHandle } from './observe.js';
|
|
27
28
|
import { type PipelineOptions, type PipelineResults } from './pipeline.js';
|
|
28
|
-
import { type DeferredQuery, QueryInterface, type QueryInterfaceOptions } from './query/index.js';
|
|
29
|
+
import { type DeferredQuery, type QueryEventListener, QueryInterface, type QueryInterfaceOptions } from './query/index.js';
|
|
29
30
|
import type { SchemaMetadata } from './schema.js';
|
|
30
31
|
export interface RetryOptions {
|
|
31
32
|
maxAttempts?: number;
|
|
@@ -219,7 +220,9 @@ export declare class TurbineClient {
|
|
|
219
220
|
private readonly logging;
|
|
220
221
|
private readonly tableCache;
|
|
221
222
|
private readonly middlewares;
|
|
222
|
-
private readonly
|
|
223
|
+
private readonly queryListeners;
|
|
224
|
+
private queryOptions;
|
|
225
|
+
private readonly errorMessagesSafe;
|
|
223
226
|
/** True when Turbine created the pool and is responsible for tearing it down */
|
|
224
227
|
private readonly ownsPool;
|
|
225
228
|
constructor(config: TurbineConfig | undefined, schema: SchemaMetadata);
|
|
@@ -255,6 +258,10 @@ export declare class TurbineClient {
|
|
|
255
258
|
* ```
|
|
256
259
|
*/
|
|
257
260
|
$use(middleware: Middleware): void;
|
|
261
|
+
$on(_event: 'query', listener: QueryEventListener): void;
|
|
262
|
+
$off(_event: 'query', listener: QueryEventListener): void;
|
|
263
|
+
private observeEngine?;
|
|
264
|
+
$observe(config: ObserveConfig): Promise<ObserveHandle>;
|
|
258
265
|
/**
|
|
259
266
|
* Get a QueryInterface for a table.
|
|
260
267
|
* Results are cached — calling `table('users')` twice returns the same instance.
|
package/dist/client.js
CHANGED
|
@@ -23,8 +23,9 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import pg from 'pg';
|
|
25
25
|
import { setErrorMessageMode, TimeoutError, wrapPgError } from './errors.js';
|
|
26
|
+
import { ObserveEngine } from './observe.js';
|
|
26
27
|
import { executePipeline, pipelineSupported } from './pipeline.js';
|
|
27
|
-
import { QueryInterface } from './query/index.js';
|
|
28
|
+
import { QueryInterface, } from './query/index.js';
|
|
28
29
|
export async function withRetry(fn, options) {
|
|
29
30
|
const maxAttempts = options?.maxAttempts ?? 3;
|
|
30
31
|
const baseDelay = options?.baseDelay ?? 50;
|
|
@@ -183,7 +184,9 @@ export class TurbineClient {
|
|
|
183
184
|
logging;
|
|
184
185
|
tableCache = new Map();
|
|
185
186
|
middlewares = [];
|
|
187
|
+
queryListeners = new Set();
|
|
186
188
|
queryOptions;
|
|
189
|
+
errorMessagesSafe;
|
|
187
190
|
/** True when Turbine created the pool and is responsible for tearing it down */
|
|
188
191
|
ownsPool = true;
|
|
189
192
|
constructor(config = {}, schema) {
|
|
@@ -217,12 +220,27 @@ export class TurbineClient {
|
|
|
217
220
|
this.schema = schema;
|
|
218
221
|
// Respect env var kill switch
|
|
219
222
|
const envDisablePrepared = typeof process !== 'undefined' && process.env?.TURBINE_DISABLE_PREPARED === '1';
|
|
223
|
+
this.errorMessagesSafe = (config.errorMessages ?? 'safe') === 'safe';
|
|
220
224
|
this.queryOptions = {
|
|
221
225
|
defaultLimit: config.defaultLimit,
|
|
222
226
|
warnOnUnlimited: config.warnOnUnlimited,
|
|
223
227
|
preparedStatements: envDisablePrepared ? false : (config.preparedStatements ?? !config.pool),
|
|
224
228
|
sqlCache: config.sqlCache ?? true,
|
|
225
229
|
dialect: config.dialect,
|
|
230
|
+
_onQuery: (event) => {
|
|
231
|
+
if (this.queryListeners.size === 0)
|
|
232
|
+
return;
|
|
233
|
+
const emitted = this.errorMessagesSafe ? { ...event, params: event.params.map(() => '[REDACTED]') } : event;
|
|
234
|
+
for (const listener of this.queryListeners) {
|
|
235
|
+
try {
|
|
236
|
+
listener(emitted);
|
|
237
|
+
}
|
|
238
|
+
catch (e) {
|
|
239
|
+
if (this.logging)
|
|
240
|
+
console.error('[turbine] Query listener error:', e);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
},
|
|
226
244
|
};
|
|
227
245
|
// Apply NotFoundError message redaction mode (default: safe — values are
|
|
228
246
|
// stripped from messages to avoid leaking PII into error logs).
|
|
@@ -275,6 +293,11 @@ export class TurbineClient {
|
|
|
275
293
|
});
|
|
276
294
|
}
|
|
277
295
|
}
|
|
296
|
+
// Auto-start observability from env var
|
|
297
|
+
const observeUrl = typeof process !== 'undefined' ? process.env?.TURBINE_OBSERVE_URL : undefined;
|
|
298
|
+
if (observeUrl) {
|
|
299
|
+
this.$observe({ connectionString: observeUrl }).catch(() => { });
|
|
300
|
+
}
|
|
278
301
|
}
|
|
279
302
|
// -------------------------------------------------------------------------
|
|
280
303
|
// Middleware — intercept all queries
|
|
@@ -316,6 +339,37 @@ export class TurbineClient {
|
|
|
316
339
|
this.tableCache.clear();
|
|
317
340
|
}
|
|
318
341
|
// -------------------------------------------------------------------------
|
|
342
|
+
// Event emitter — subscribe to query lifecycle events
|
|
343
|
+
// -------------------------------------------------------------------------
|
|
344
|
+
$on(_event, listener) {
|
|
345
|
+
this.queryListeners.add(listener);
|
|
346
|
+
}
|
|
347
|
+
$off(_event, listener) {
|
|
348
|
+
this.queryListeners.delete(listener);
|
|
349
|
+
}
|
|
350
|
+
// -------------------------------------------------------------------------
|
|
351
|
+
// Observability — automatic metrics collection
|
|
352
|
+
// -------------------------------------------------------------------------
|
|
353
|
+
observeEngine;
|
|
354
|
+
async $observe(config) {
|
|
355
|
+
if (this.observeEngine) {
|
|
356
|
+
await this.observeEngine.stop();
|
|
357
|
+
this.$off('query', this.observeEngine.getListener());
|
|
358
|
+
}
|
|
359
|
+
const engine = new ObserveEngine(config);
|
|
360
|
+
this.observeEngine = engine;
|
|
361
|
+
await engine.init();
|
|
362
|
+
this.$on('query', engine.getListener());
|
|
363
|
+
return {
|
|
364
|
+
stop: async () => {
|
|
365
|
+
this.$off('query', engine.getListener());
|
|
366
|
+
await engine.stop();
|
|
367
|
+
if (this.observeEngine === engine)
|
|
368
|
+
this.observeEngine = undefined;
|
|
369
|
+
},
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
// -------------------------------------------------------------------------
|
|
319
373
|
// Table accessor — creates QueryInterface for any table
|
|
320
374
|
// -------------------------------------------------------------------------
|
|
321
375
|
/**
|
package/dist/index.d.ts
CHANGED
|
@@ -41,8 +41,9 @@ export { CheckConstraintError, CircularRelationError, ConnectionError, DeadlockE
|
|
|
41
41
|
export { type GenerateOptions, generate } from './generate.js';
|
|
42
42
|
export { type IntrospectOptions, introspect } from './introspect.js';
|
|
43
43
|
export { executeNestedCreate, executeNestedUpdate, hasRelationFields, type NestedWriteContext, } from './nested-write.js';
|
|
44
|
+
export type { ObserveConfig, ObserveHandle } from './observe.js';
|
|
44
45
|
export { executePipeline, type PipelineOptions, type PipelineResults, pipelineSupported } from './pipeline.js';
|
|
45
|
-
export { type AggregateArgs, type AggregateResult, type ArrayFilter, type ConnectOrCreateOp, type CountArgs, type CreateArgs, type CreateManyArgs, type DeferredQuery, type DeleteArgs, type DeleteManyArgs, type FieldResult, type FindManyArgs, type FindManyStreamArgs, type FindUniqueArgs, type GroupByArgs, type JsonFilter, type NestedCreateOp, type NestedUpdateOp, type OmitResult, type OrderDirection, QueryInterface, type QueryResult, type RelationDescriptor, type RelationFilter, type SelectResult, type TextSearchFilter, type TypedWithClause, type UpdateArgs, type UpdateInput, type UpdateManyArgs, type UpdateOperatorInput, type UpsertArgs, type WithClause, type WithOptions, type WithResult, } from './query/index.js';
|
|
46
|
+
export { type AggregateArgs, type AggregateResult, type ArrayFilter, type ConnectOrCreateOp, type CountArgs, type CreateArgs, type CreateManyArgs, type DeferredQuery, type DeleteArgs, type DeleteManyArgs, type FieldResult, type FindManyArgs, type FindManyStreamArgs, type FindUniqueArgs, type GroupByArgs, type JsonFilter, type NestedCreateOp, type NestedUpdateOp, type OmitResult, type OrderDirection, type QueryEvent, type QueryEventListener, QueryInterface, type QueryResult, type RelationDescriptor, type RelationFilter, type SelectResult, type TextSearchFilter, type TypedWithClause, type UpdateArgs, type UpdateInput, type UpdateManyArgs, type UpdateOperatorInput, type UpsertArgs, type WithClause, type WithOptions, type WithResult, } from './query/index.js';
|
|
46
47
|
export type { ColumnMetadata, IndexMetadata, RelationDef, SchemaMetadata, TableMetadata, } from './schema.js';
|
|
47
48
|
export { camelToSnake, isDateType, normalizeKeyColumns, pgArrayType, pgTypeToTs, singularize, snakeToCamel, snakeToPascal, } from './schema.js';
|
|
48
49
|
export { ColumnBuilder, type ColumnConfig, type ColumnDef, type ColumnType, type ColumnTypeName, column, defineSchema, type SchemaDef, type TableDef, table, } from './schema-builder.js';
|
package/dist/nested-write.d.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Tree-walking create/update that resolves relation fields in `data` into
|
|
5
5
|
* batched SQL operations within a transaction. Supports create, connect,
|
|
6
|
-
* connectOrCreate, disconnect, set, and
|
|
7
|
-
* arbitrary depth (capped at 10).
|
|
6
|
+
* connectOrCreate, disconnect, set, delete, update, and upsert on related
|
|
7
|
+
* records at arbitrary depth (capped at 10).
|
|
8
8
|
*
|
|
9
9
|
* This module is imported by `query/builder.ts` when the `data` argument
|
|
10
10
|
* of `create()` or `update()` contains relation fields. It never imports
|