turbine-orm 0.15.0 → 0.18.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/README.md +180 -12
- package/dist/adapters/cockroachdb.js +4 -2
- package/dist/adapters/index.js +4 -1
- package/dist/adapters/yugabytedb.js +4 -2
- package/dist/cjs/adapters/cockroachdb.js +4 -2
- package/dist/cjs/adapters/index.js +4 -1
- package/dist/cjs/adapters/yugabytedb.js +4 -2
- 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/cli/studio.js +5 -1
- package/dist/cjs/client.js +218 -0
- package/dist/cjs/errors.js +35 -5
- package/dist/cjs/generate.js +14 -3
- package/dist/cjs/index.js +10 -2
- package/dist/cjs/introspect.js +81 -0
- package/dist/cjs/nested-write.js +164 -10
- package/dist/cjs/observe.js +145 -0
- package/dist/cjs/query/builder.js +604 -25
- package/dist/cjs/realtime.js +147 -0
- package/dist/cjs/schema-builder.js +86 -0
- package/dist/cjs/schema.js +10 -0
- package/dist/cjs/typed-sql.js +149 -0
- 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/cli/studio.js +5 -1
- package/dist/client.d.ts +129 -2
- package/dist/client.js +220 -2
- package/dist/errors.js +35 -5
- package/dist/generate.js +14 -3
- package/dist/index.d.ts +5 -2
- package/dist/index.js +5 -1
- package/dist/introspect.js +81 -0
- package/dist/nested-write.d.ts +2 -2
- package/dist/nested-write.js +164 -10
- package/dist/observe.d.ts +36 -0
- package/dist/observe.js +141 -0
- package/dist/query/builder.d.ts +121 -1
- package/dist/query/builder.js +605 -26
- package/dist/query/index.d.ts +2 -2
- package/dist/query/types.d.ts +126 -2
- package/dist/realtime.d.ts +71 -0
- package/dist/realtime.js +144 -0
- package/dist/schema-builder.d.ts +68 -1
- package/dist/schema-builder.js +85 -0
- package/dist/schema.d.ts +18 -1
- package/dist/schema.js +10 -0
- package/dist/typed-sql.d.ts +101 -0
- package/dist/typed-sql.js +145 -0
- package/package.json +18 -16
|
@@ -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/cli/studio.js
CHANGED
|
@@ -60,7 +60,11 @@ export async function startStudio(options) {
|
|
|
60
60
|
const authToken = randomBytes(24).toString('hex');
|
|
61
61
|
const stateDir = pathResolve(options.stateDir ?? '.turbine');
|
|
62
62
|
const statementTimeout = options.adapter?.statementTimeout?.(30) ?? {
|
|
63
|
-
|
|
63
|
+
// Postgres rejects parameters in `SET LOCAL` (`SET LOCAL ... = $1` is a
|
|
64
|
+
// syntax error). `set_config(name, value, is_local=true)` is the
|
|
65
|
+
// parameterizable, transaction-local equivalent and works on every
|
|
66
|
+
// Postgres-compatible engine.
|
|
67
|
+
sql: `SELECT set_config('statement_timeout', $1, true)`,
|
|
64
68
|
params: ['30s'],
|
|
65
69
|
};
|
|
66
70
|
const rateLimiter = new Map();
|
package/dist/client.d.ts
CHANGED
|
@@ -24,9 +24,12 @@
|
|
|
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';
|
|
30
|
+
import { type NotificationHandler, type Subscription } from './realtime.js';
|
|
29
31
|
import type { SchemaMetadata } from './schema.js';
|
|
32
|
+
import { TypedSqlQuery } from './typed-sql.js';
|
|
30
33
|
export interface RetryOptions {
|
|
31
34
|
maxAttempts?: number;
|
|
32
35
|
baseDelay?: number;
|
|
@@ -171,6 +174,30 @@ export interface TransactionOptions {
|
|
|
171
174
|
timeout?: number;
|
|
172
175
|
/** Isolation level for the transaction */
|
|
173
176
|
isolationLevel?: 'ReadUncommitted' | 'ReadCommitted' | 'RepeatableRead' | 'Serializable';
|
|
177
|
+
/**
|
|
178
|
+
* Transaction-local session GUCs to set after BEGIN. The canonical use case
|
|
179
|
+
* is multi-tenant Postgres row-level security (RLS): your policies filter on
|
|
180
|
+
* `current_setting('app.current_tenant')`, and you set that value here so
|
|
181
|
+
* every query inside the transaction sees it.
|
|
182
|
+
*
|
|
183
|
+
* Each entry is applied via `SELECT set_config($1, $2, true)` — `is_local=true`
|
|
184
|
+
* scopes the value to this transaction, so it auto-resets on COMMIT/ROLLBACK
|
|
185
|
+
* and never leaks onto the pooled connection. Both the name and value are
|
|
186
|
+
* bound parameters (never interpolated); the GUC name is additionally
|
|
187
|
+
* validated against a strict identifier regex.
|
|
188
|
+
*
|
|
189
|
+
* @example
|
|
190
|
+
* ```ts
|
|
191
|
+
* await db.$transaction(
|
|
192
|
+
* async (tx) => {
|
|
193
|
+
* // every query here sees current_setting('app.current_tenant') = '42'
|
|
194
|
+
* return tx.invoices.findMany();
|
|
195
|
+
* },
|
|
196
|
+
* { sessionContext: { 'app.current_tenant': '42', 'app.current_user': userId } },
|
|
197
|
+
* );
|
|
198
|
+
* ```
|
|
199
|
+
*/
|
|
200
|
+
sessionContext?: Record<string, string | number | boolean>;
|
|
174
201
|
}
|
|
175
202
|
/**
|
|
176
203
|
* A transaction-scoped client that provides the same table accessor API as TurbineClient.
|
|
@@ -219,9 +246,13 @@ export declare class TurbineClient {
|
|
|
219
246
|
private readonly logging;
|
|
220
247
|
private readonly tableCache;
|
|
221
248
|
private readonly middlewares;
|
|
222
|
-
private readonly
|
|
249
|
+
private readonly queryListeners;
|
|
250
|
+
private queryOptions;
|
|
251
|
+
private readonly errorMessagesSafe;
|
|
223
252
|
/** True when Turbine created the pool and is responsible for tearing it down */
|
|
224
253
|
private readonly ownsPool;
|
|
254
|
+
/** Active LISTEN subscriptions — torn down on disconnect() so it never hangs */
|
|
255
|
+
private readonly activeSubscriptions;
|
|
225
256
|
constructor(config: TurbineConfig | undefined, schema: SchemaMetadata);
|
|
226
257
|
/**
|
|
227
258
|
* Register a middleware function that runs before/after every query.
|
|
@@ -255,6 +286,10 @@ export declare class TurbineClient {
|
|
|
255
286
|
* ```
|
|
256
287
|
*/
|
|
257
288
|
$use(middleware: Middleware): void;
|
|
289
|
+
$on(_event: 'query', listener: QueryEventListener): void;
|
|
290
|
+
$off(_event: 'query', listener: QueryEventListener): void;
|
|
291
|
+
private observeEngine?;
|
|
292
|
+
$observe(config: ObserveConfig): Promise<ObserveHandle>;
|
|
258
293
|
/**
|
|
259
294
|
* Get a QueryInterface for a table.
|
|
260
295
|
* Results are cached — calling `table('users')` twice returns the same instance.
|
|
@@ -291,6 +326,37 @@ export declare class TurbineClient {
|
|
|
291
326
|
* ```
|
|
292
327
|
*/
|
|
293
328
|
raw<T extends Record<string, unknown> = Record<string, unknown>>(strings: TemplateStringsArray, ...values: unknown[]): Promise<T[]>;
|
|
329
|
+
/**
|
|
330
|
+
* Execute a **typed** raw SQL query — Turbine's answer to Prisma's TypedSQL.
|
|
331
|
+
*
|
|
332
|
+
* Like {@link raw}, every interpolated `${value}` becomes a `$N` parameter
|
|
333
|
+
* (never string-concatenated), so it is injection-safe by construction. The
|
|
334
|
+
* difference is the caller-supplied row type and the chainable result: the
|
|
335
|
+
* returned {@link TypedSqlQuery} can be `await`ed directly for `T[]`, or
|
|
336
|
+
* refined with `.one()` (→ `T | null`) or `.scalar<V>()` (→ `V | null`).
|
|
337
|
+
*
|
|
338
|
+
* Rows are returned as-is — no snake→camel mapping (matching `raw()`). Alias
|
|
339
|
+
* columns in SQL if you want camelCase keys.
|
|
340
|
+
*
|
|
341
|
+
* @example
|
|
342
|
+
* ```ts
|
|
343
|
+
* // rows
|
|
344
|
+
* const rows = await db.sql<{ id: number; name: string }>`
|
|
345
|
+
* SELECT id, name FROM users WHERE org_id = ${orgId}
|
|
346
|
+
* `;
|
|
347
|
+
*
|
|
348
|
+
* // single row or null
|
|
349
|
+
* const user = await db.sql<{ id: number; name: string }>`
|
|
350
|
+
* SELECT id, name FROM users WHERE id = ${userId}
|
|
351
|
+
* `.one();
|
|
352
|
+
*
|
|
353
|
+
* // scalar
|
|
354
|
+
* const total = await db.sql<{ count: number }>`
|
|
355
|
+
* SELECT COUNT(*)::int AS count FROM users
|
|
356
|
+
* `.scalar();
|
|
357
|
+
* ```
|
|
358
|
+
*/
|
|
359
|
+
sql<T extends Record<string, unknown> = Record<string, unknown>>(strings: TemplateStringsArray, ...values: unknown[]): TypedSqlQuery<T>;
|
|
294
360
|
/**
|
|
295
361
|
* Execute a function within a database transaction (raw pg.PoolClient).
|
|
296
362
|
* For the typed API, use `$transaction()` instead.
|
|
@@ -323,6 +389,67 @@ export declare class TurbineClient {
|
|
|
323
389
|
* ```
|
|
324
390
|
*/
|
|
325
391
|
$transaction<R>(fn: (tx: TransactionClient) => Promise<R>, options?: TransactionOptions): Promise<R>;
|
|
392
|
+
/**
|
|
393
|
+
* Convenience wrapper around `$transaction` for the multi-tenant / RLS case:
|
|
394
|
+
* runs `fn` inside a transaction with the given session GUCs applied via
|
|
395
|
+
* `set_config(..., is_local=true)`. Equivalent to
|
|
396
|
+
* `$transaction(fn, { sessionContext: context })`.
|
|
397
|
+
*
|
|
398
|
+
* @example
|
|
399
|
+
* ```ts
|
|
400
|
+
* const invoices = await db.$withSession(
|
|
401
|
+
* { 'app.current_tenant': tenantId },
|
|
402
|
+
* (tx) => tx.invoices.findMany(),
|
|
403
|
+
* );
|
|
404
|
+
* ```
|
|
405
|
+
*/
|
|
406
|
+
$withSession<R>(context: Record<string, string | number | boolean>, fn: (tx: TransactionClient) => Promise<R>): Promise<R>;
|
|
407
|
+
/**
|
|
408
|
+
* Subscribe to a Postgres NOTIFY channel. The handler fires with each
|
|
409
|
+
* notification's payload string (the empty string when a payload-less
|
|
410
|
+
* NOTIFY is sent) for as long as the subscription is active.
|
|
411
|
+
*
|
|
412
|
+
* Each `$listen` checks out its OWN dedicated long-lived connection from the
|
|
413
|
+
* pool and runs `LISTEN "channel"` on it; `subscription.unsubscribe()`
|
|
414
|
+
* UNLISTENs, detaches the handler, and releases that connection. Active
|
|
415
|
+
* subscriptions are tracked and force-released on `disconnect()` so shutdown
|
|
416
|
+
* never hangs.
|
|
417
|
+
*
|
|
418
|
+
* The channel name CANNOT be a bound parameter (`LISTEN $1` is a syntax
|
|
419
|
+
* error), so it is validated against a strict identifier regex AND quoted via
|
|
420
|
+
* `quoteIdent` before interpolation — it is the only identifier this method
|
|
421
|
+
* places into SQL text.
|
|
422
|
+
*
|
|
423
|
+
* **Serverless caveat:** LISTEN needs a persistent connection that can push
|
|
424
|
+
* async notifications. Stateless HTTP drivers (Neon HTTP, Vercel Postgres)
|
|
425
|
+
* cannot do this — `$listen` throws a `ConnectionError` rather than hang.
|
|
426
|
+
* `$notify` works on every driver.
|
|
427
|
+
*
|
|
428
|
+
* @example
|
|
429
|
+
* ```ts
|
|
430
|
+
* const sub = await db.$listen('order_created', (payload) => {
|
|
431
|
+
* const order = JSON.parse(payload);
|
|
432
|
+
* console.log('new order', order.id);
|
|
433
|
+
* });
|
|
434
|
+
* // ...later
|
|
435
|
+
* await sub.unsubscribe();
|
|
436
|
+
* ```
|
|
437
|
+
*/
|
|
438
|
+
$listen(channel: string, handler: NotificationHandler): Promise<Subscription>;
|
|
439
|
+
/**
|
|
440
|
+
* Send a Postgres NOTIFY on `channel` with an optional payload string.
|
|
441
|
+
*
|
|
442
|
+
* Issued as `SELECT pg_notify($1, $2)` — both the channel and payload are
|
|
443
|
+
* BOUND parameters (no quoting/injection concern). The channel is still
|
|
444
|
+
* validated against the identifier regex for parity with `$listen` and to
|
|
445
|
+
* catch typos loudly. Works on every driver, including serverless HTTP pools.
|
|
446
|
+
*
|
|
447
|
+
* @example
|
|
448
|
+
* ```ts
|
|
449
|
+
* await db.$notify('order_created', JSON.stringify({ id: 7 }));
|
|
450
|
+
* ```
|
|
451
|
+
*/
|
|
452
|
+
$notify(channel: string, payload?: string): Promise<void>;
|
|
326
453
|
/**
|
|
327
454
|
* Execute an async function with automatic retry on retryable errors.
|
|
328
455
|
*
|