turbine-orm 0.19.2 → 0.22.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 +99 -18
- package/dist/adapters/cockroachdb.js +4 -9
- package/dist/cjs/adapters/cockroachdb.js +4 -9
- package/dist/cjs/cli/index.js +6 -3
- package/dist/cjs/cli/observe-ui.js +12 -2
- package/dist/cjs/cli/observe.js +6 -7
- package/dist/cjs/cli/studio.js +6 -7
- package/dist/cjs/client.js +44 -22
- package/dist/cjs/dialect.js +116 -0
- package/dist/cjs/errors.js +19 -1
- package/dist/cjs/generate.js +4 -0
- package/dist/cjs/index.js +3 -2
- package/dist/cjs/introspect.js +20 -0
- package/dist/cjs/mssql.js +1338 -0
- package/dist/cjs/mysql.js +1052 -0
- package/dist/cjs/powdb.js +851 -0
- package/dist/cjs/powql.js +903 -0
- package/dist/cjs/query/builder.js +293 -73
- package/dist/cjs/sqlite.js +849 -0
- package/dist/cjs/typed-sql.js +9 -7
- package/dist/cli/index.js +6 -3
- package/dist/cli/observe-ui.d.ts +1 -1
- package/dist/cli/observe-ui.js +12 -2
- package/dist/cli/observe.js +7 -8
- package/dist/cli/studio.js +7 -8
- package/dist/cli/ui.d.ts +1 -1
- package/dist/client.d.ts +23 -1
- package/dist/client.js +45 -23
- package/dist/dialect.d.ts +258 -1
- package/dist/dialect.js +83 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +17 -0
- package/dist/generate.js +4 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/introspect.d.ts +18 -0
- package/dist/introspect.js +19 -0
- package/dist/mssql.d.ts +233 -0
- package/dist/mssql.js +1298 -0
- package/dist/mysql.d.ts +174 -0
- package/dist/mysql.js +1012 -0
- package/dist/powdb.d.ts +338 -0
- package/dist/powdb.js +802 -0
- package/dist/powql.d.ts +153 -0
- package/dist/powql.js +900 -0
- package/dist/query/builder.d.ts +105 -0
- package/dist/query/builder.js +294 -74
- package/dist/query/index.d.ts +1 -1
- package/dist/sqlite.d.ts +144 -0
- package/dist/sqlite.js +842 -0
- package/dist/typed-sql.d.ts +7 -5
- package/dist/typed-sql.js +9 -7
- package/package.json +45 -1
package/dist/cjs/typed-sql.js
CHANGED
|
@@ -51,21 +51,23 @@
|
|
|
51
51
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
52
52
|
exports.TypedSqlQuery = void 0;
|
|
53
53
|
exports.buildTypedSql = buildTypedSql;
|
|
54
|
+
const dialect_js_1 = require("./dialect.js");
|
|
54
55
|
const errors_js_1 = require("./errors.js");
|
|
55
56
|
/**
|
|
56
57
|
* Build a `(sql, params)` pair from a tagged-template invocation.
|
|
57
58
|
*
|
|
58
|
-
* Each interpolated value is replaced by a positional
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
59
|
+
* Each interpolated value is replaced by a positional placeholder (the active
|
|
60
|
+
* dialect's `paramPlaceholder`, `$N` for PostgreSQL) and pushed to the params
|
|
61
|
+
* array in order. The static string segments are the only thing concatenated
|
|
62
|
+
* into the SQL text. This is the single point that guarantees parameterization
|
|
63
|
+
* for the entire typed-SQL surface.
|
|
62
64
|
*
|
|
63
65
|
* Exported for unit testing the parameterization invariant without a database.
|
|
64
66
|
*/
|
|
65
|
-
function buildTypedSql(strings, values) {
|
|
67
|
+
function buildTypedSql(strings, values, dialect = dialect_js_1.postgresDialect) {
|
|
66
68
|
// The tagged-template API guarantees `strings.length === values.length + 1`.
|
|
67
69
|
// Guard the directly-callable (exported) surface so a mismatched call can't
|
|
68
|
-
// silently desync
|
|
70
|
+
// silently desync placeholders from params (pg would reject it, but fail
|
|
69
71
|
// loudly here instead).
|
|
70
72
|
if (strings.length !== values.length + 1) {
|
|
71
73
|
throw new errors_js_1.ValidationError(`[turbine] sql template segment/value count mismatch: ${strings.length} segments, ${values.length} values.`);
|
|
@@ -74,7 +76,7 @@ function buildTypedSql(strings, values) {
|
|
|
74
76
|
for (let i = 0; i < strings.length; i++) {
|
|
75
77
|
sql += strings[i];
|
|
76
78
|
if (i < values.length) {
|
|
77
|
-
sql +=
|
|
79
|
+
sql += dialect.paramPlaceholder(i + 1);
|
|
78
80
|
}
|
|
79
81
|
}
|
|
80
82
|
return { sql, params: values.slice() };
|
package/dist/cli/index.js
CHANGED
|
@@ -929,9 +929,10 @@ async function cmdStudio(args, config) {
|
|
|
929
929
|
console.log(red(`✗ invalid port: ${args.port}`));
|
|
930
930
|
process.exit(1);
|
|
931
931
|
}
|
|
932
|
-
//
|
|
933
|
-
//
|
|
934
|
-
// session token, so exposing it on a LAN
|
|
932
|
+
// Warn loudly when an explicit non-loopback --host is used — the user is
|
|
933
|
+
// opting in, so we proceed rather than refuse. Studio has no real
|
|
934
|
+
// authentication beyond a random session token, so exposing it on a LAN
|
|
935
|
+
// interface is foot-gun territory.
|
|
935
936
|
if (host !== '127.0.0.1' && host !== 'localhost' && host !== '::1') {
|
|
936
937
|
console.log(warn(`Studio is binding to ${yellow(host)} — this is NOT loopback. ` +
|
|
937
938
|
`Anyone on your network who can reach this port + guess the session token can read your database.`));
|
|
@@ -1004,6 +1005,8 @@ async function cmdObserve(args) {
|
|
|
1004
1005
|
console.log(red(`✗ invalid port: ${args.port}`));
|
|
1005
1006
|
process.exit(1);
|
|
1006
1007
|
}
|
|
1008
|
+
// Warn loudly when an explicit non-loopback --host is used — the user is
|
|
1009
|
+
// opting in, so we proceed rather than refuse.
|
|
1007
1010
|
if (host !== '127.0.0.1' && host !== 'localhost' && host !== '::1') {
|
|
1008
1011
|
console.log(warn(`Observe is binding to ${yellow(host)} — this is NOT loopback. ` +
|
|
1009
1012
|
`Anyone on your network who can reach this port + guess the session token can read your metrics.`));
|
package/dist/cli/observe-ui.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const OBSERVE_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <meta name=\"color-scheme\" content=\"dark\" />\n <title>Turbine Observe</title>\n <style>\n :root {\n --bg: #0a0a0b;\n --bg-elev: #111113;\n --bg-hover: #1a1a1d;\n --border: #26262b;\n --text: #e6e6ea;\n --text-dim: #8a8a93;\n --accent: #60a5fa;\n --green: #4ade80;\n --red: #f87171;\n --orange: #fb923c;\n --purple: #a78bfa;\n --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;\n --sans: system-ui, -apple-system, sans-serif;\n --radius: 6px;\n }\n * { margin: 0; padding: 0; box-sizing: border-box; }\n body { background: var(--bg); color: var(--text); font-family: var(--sans); font-size: 14px; padding: 24px; }\n h1 { font-size: 20px; margin-bottom: 4px; }\n .subtitle { color: var(--text-dim); margin-bottom: 24px; }\n .controls { display: flex; gap: 8px; margin-bottom: 24px; }\n .controls button {\n background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius);\n color: var(--text); padding: 6px 12px; cursor: pointer; font-size: 13px;\n }\n .controls button.active { border-color: var(--accent); color: var(--accent); }\n .card {\n background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius);\n padding: 16px; margin-bottom: 16px;\n }\n .card h2 { font-size: 14px; color: var(--text-dim); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.5px; }\n table { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 12px; }\n th { text-align: left; padding: 6px 8px; color: var(--text-dim); border-bottom: 1px solid var(--border); }\n td { padding: 6px 8px; border-bottom: 1px solid var(--border); }\n .num { text-align: right; }\n .error-rate { color: var(--red); }\n .low-error { color: var(--green); }\n svg { width: 100%; height: 200px; }\n .chart-line { fill: none; stroke-width: 1.5; }\n .line-avg { stroke: var(--accent); }\n .line-p95 { stroke: var(--orange); }\n .line-p99 { stroke: var(--red); }\n .legend { display: flex; gap: 16px; margin-top: 8px; font-size: 12px; color: var(--text-dim); }\n .legend span::before { content: ''; display: inline-block; width: 12px; height: 2px; margin-right: 4px; vertical-align: middle; }\n .legend .l-avg::before { background: var(--accent); }\n .legend .l-p95::before { background: var(--orange); }\n .legend .l-p99::before { background: var(--red); }\n .empty { color: var(--text-dim); text-align: center; padding: 40px; }\n </style>\n</head>\n<body>\n <h1>Turbine Observe</h1>\n <p class=\"subtitle\">Query performance metrics</p>\n <div class=\"controls\">\n <button data-range=\"1h\" class=\"active\">1h</button>\n <button data-range=\"6h\">6h</button>\n <button data-range=\"24h\">24h</button>\n <button data-range=\"7d\">7d</button>\n </div>\n <div class=\"card\" id=\"latency-card\">\n <h2>Latency over time</h2>\n <div id=\"chart\"></div>\n <div class=\"legend\">\n <span class=\"l-avg\">avg</span>\n <span class=\"l-p95\">p95</span>\n <span class=\"l-p99\">p99</span>\n </div>\n </div>\n <div class=\"card\" id=\"models-card\">\n <h2>Top models</h2>\n <div id=\"models-table\"></div>\n </div>\n <div class=\"card\" id=\"errors-card\">\n <h2>Error rates</h2>\n <div id=\"errors-table\"></div>\n </div>\n <script>\n let currentRange = '1h';\n const token = document.cookie.match(/turbine_observe_token=([a-f0-9]+)/)?.[1] || '';\n const headers = { 'x-turbine-token': token };\n\n document.querySelector('.controls').addEventListener('click', e => {\n if (e.target.tagName !== 'BUTTON') return;\n document.querySelectorAll('.controls button').forEach(b => b.classList.remove('active'));\n e.target.classList.add('active');\n currentRange = e.target.dataset.range;\n refresh();\n });\n\n async function fetchJson(path) {\n const res = await fetch(path, { headers });\n if (!res.ok) return null;\n return res.json();\n }\n\n function buildSvgPath(points, width, height, maxY) {\n if (points.length === 0) return '';\n const xStep = width / Math.max(points.length - 1, 1);\n return points.map((y, i) => {\n const px = i * xStep;\n const py = height - (y / maxY) * height;\n return (i === 0 ? 'M' : 'L') + px.toFixed(1) + ',' + py.toFixed(1);\n }).join(' ');\n }\n\n function renderChart(data) {\n const el = document.getElementById('chart');\n if (!data || data.length === 0) { el.innerHTML = '<p class=\"empty\">No data yet</p>'; return; }\n const width = 800; const height = 180;\n const allVals = data.flatMap(d => [d.avg_ms, d.p95_ms, d.p99_ms]);\n const maxY = Math.max(...allVals, 1) * 1.1;\n const avgPath = buildSvgPath(data.map(d => d.avg_ms), width, height, maxY);\n const p95Path = buildSvgPath(data.map(d => d.p95_ms), width, height, maxY);\n const p99Path = buildSvgPath(data.map(d => d.p99_ms), width, height, maxY);\n el.innerHTML = '<svg viewBox=\"0 0 ' + width + ' ' + height + '\" preserveAspectRatio=\"none\">'\n + '<path class=\"chart-line line-avg\" d=\"' + avgPath + '\"/>'\n + '<path class=\"chart-line line-p95\" d=\"' + p95Path + '\"/>'\n + '<path class=\"chart-line line-p99\" d=\"' + p99Path + '\"/>'\n + '</svg>';\n }\n\n function renderModels(data) {\n const el = document.getElementById('models-table');\n if (!data || data.length === 0) { el.innerHTML = '<p class=\"empty\">No data yet</p>'; return; }\n let html = '<table><thead><tr><th>Model</th><th>Action</th><th class=\"num\">Count</th><th class=\"num\">Avg (ms)</th><th class=\"num\">P95 (ms)</th><th class=\"num\">P99 (ms)</th></tr></thead><tbody>';\n for (const row of data) {\n html += '<tr><td>' + row.model + '</td><td>' + row.action + '</td>'\n + '<td class=\"num\">' + row.count + '</td>'\n + '<td class=\"num\">' + row.avg_ms.toFixed(1) + '</td>'\n + '<td class=\"num\">' + row.p95_ms.toFixed(1) + '</td>'\n + '<td class=\"num\">' + row.p99_ms.toFixed(1) + '</td></tr>';\n }\n html += '</tbody></table>';\n el.innerHTML = html;\n }\n\n function renderErrors(data) {\n const el = document.getElementById('errors-table');\n if (!data || data.length === 0) { el.innerHTML = '<p class=\"empty\">No errors</p>'; return; }\n let html = '<table><thead><tr><th>Model</th><th>Action</th><th class=\"num\">Total</th><th class=\"num\">Errors</th><th class=\"num\">Rate</th></tr></thead><tbody>';\n for (const row of data) {\n const rate = row.count > 0 ? (row.error_count / row.count * 100).toFixed(1) : '0.0';\n const cls = parseFloat(rate) > 5 ? 'error-rate' : 'low-error';\n html += '<tr><td>' + row.model + '</td><td>' + row.action + '</td>'\n + '<td class=\"num\">' + row.count + '</td>'\n + '<td class=\"num\">' + row.error_count + '</td>'\n + '<td class=\"num ' + cls + '\">' + rate + '%</td></tr>';\n }\n html += '</tbody></table>';\n el.innerHTML = html;\n }\n\n async function refresh() {\n const [latency, models] = await Promise.all([\n fetchJson('/api/latency?range=' + currentRange),\n fetchJson('/api/models?range=' + currentRange),\n ]);\n renderChart(latency);\n renderModels(models);\n // Derive errors from models data\n const withErrors = (models || []).filter(m => m.error_count > 0);\n renderErrors(withErrors);\n }\n\n refresh();\n setInterval(refresh, 60000);\n </script>\n</body>\n</html>";
|
|
1
|
+
export declare const OBSERVE_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <meta name=\"color-scheme\" content=\"dark\" />\n <title>Turbine Observe</title>\n <style>\n :root {\n --bg: #0a0a0b;\n --bg-elev: #111113;\n --bg-hover: #1a1a1d;\n --border: #26262b;\n --text: #e6e6ea;\n --text-dim: #8a8a93;\n --accent: #60a5fa;\n --green: #4ade80;\n --red: #f87171;\n --orange: #fb923c;\n --purple: #a78bfa;\n --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;\n --sans: system-ui, -apple-system, sans-serif;\n --radius: 6px;\n }\n * { margin: 0; padding: 0; box-sizing: border-box; }\n body { background: var(--bg); color: var(--text); font-family: var(--sans); font-size: 14px; padding: 24px; }\n h1 { font-size: 20px; margin-bottom: 4px; }\n .subtitle { color: var(--text-dim); margin-bottom: 24px; }\n .controls { display: flex; gap: 8px; margin-bottom: 24px; }\n .controls button {\n background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius);\n color: var(--text); padding: 6px 12px; cursor: pointer; font-size: 13px;\n }\n .controls button.active { border-color: var(--accent); color: var(--accent); }\n .card {\n background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius);\n padding: 16px; margin-bottom: 16px;\n }\n .card h2 { font-size: 14px; color: var(--text-dim); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.5px; }\n table { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 12px; }\n th { text-align: left; padding: 6px 8px; color: var(--text-dim); border-bottom: 1px solid var(--border); }\n td { padding: 6px 8px; border-bottom: 1px solid var(--border); }\n .num { text-align: right; }\n .error-rate { color: var(--red); }\n .low-error { color: var(--green); }\n svg { width: 100%; height: 200px; }\n .chart-line { fill: none; stroke-width: 1.5; }\n .line-avg { stroke: var(--accent); }\n .line-p95 { stroke: var(--orange); }\n .line-p99 { stroke: var(--red); }\n .legend { display: flex; gap: 16px; margin-top: 8px; font-size: 12px; color: var(--text-dim); }\n .legend span::before { content: ''; display: inline-block; width: 12px; height: 2px; margin-right: 4px; vertical-align: middle; }\n .legend .l-avg::before { background: var(--accent); }\n .legend .l-p95::before { background: var(--orange); }\n .legend .l-p99::before { background: var(--red); }\n .empty { color: var(--text-dim); text-align: center; padding: 40px; }\n </style>\n</head>\n<body>\n <h1>Turbine Observe</h1>\n <p class=\"subtitle\">Query performance metrics</p>\n <div class=\"controls\">\n <button data-range=\"1h\" class=\"active\">1h</button>\n <button data-range=\"6h\">6h</button>\n <button data-range=\"24h\">24h</button>\n <button data-range=\"7d\">7d</button>\n </div>\n <div class=\"card\" id=\"latency-card\">\n <h2>Latency over time</h2>\n <div id=\"chart\"></div>\n <div class=\"legend\">\n <span class=\"l-avg\">avg</span>\n <span class=\"l-p95\">p95</span>\n <span class=\"l-p99\">p99</span>\n </div>\n </div>\n <div class=\"card\" id=\"models-card\">\n <h2>Top models</h2>\n <div id=\"models-table\"></div>\n </div>\n <div class=\"card\" id=\"errors-card\">\n <h2>Error rates</h2>\n <div id=\"errors-table\"></div>\n </div>\n <script>\n let currentRange = '1h';\n const token = document.cookie.match(/turbine_observe_token=([a-f0-9]+)/)?.[1] || '';\n const headers = { 'x-turbine-token': token };\n\n document.querySelector('.controls').addEventListener('click', e => {\n if (e.target.tagName !== 'BUTTON') return;\n document.querySelectorAll('.controls button').forEach(b => b.classList.remove('active'));\n e.target.classList.add('active');\n currentRange = e.target.dataset.range;\n refresh();\n });\n\n async function fetchJson(path) {\n const res = await fetch(path, { headers });\n if (!res.ok) return null;\n return res.json();\n }\n\n function buildSvgPath(points, width, height, maxY) {\n if (points.length === 0) return '';\n const xStep = width / Math.max(points.length - 1, 1);\n return points.map((y, i) => {\n const px = i * xStep;\n const py = height - (y / maxY) * height;\n return (i === 0 ? 'M' : 'L') + px.toFixed(1) + ',' + py.toFixed(1);\n }).join(' ');\n }\n\n function renderChart(data) {\n const el = document.getElementById('chart');\n if (!data || data.length === 0) { el.innerHTML = '<p class=\"empty\">No data yet</p>'; return; }\n const width = 800; const height = 180;\n const allVals = data.flatMap(d => [d.avg_ms, d.p95_ms, d.p99_ms]);\n const maxY = Math.max(...allVals, 1) * 1.1;\n const avgPath = buildSvgPath(data.map(d => d.avg_ms), width, height, maxY);\n const p95Path = buildSvgPath(data.map(d => d.p95_ms), width, height, maxY);\n const p99Path = buildSvgPath(data.map(d => d.p99_ms), width, height, maxY);\n el.innerHTML = '<svg viewBox=\"0 0 ' + width + ' ' + height + '\" preserveAspectRatio=\"none\">'\n + '<path class=\"chart-line line-avg\" d=\"' + avgPath + '\"/>'\n + '<path class=\"chart-line line-p95\" d=\"' + p95Path + '\"/>'\n + '<path class=\"chart-line line-p99\" d=\"' + p99Path + '\"/>'\n + '</svg>';\n }\n\n function escapeHtml(s) {\n if (s == null) return '';\n return String(s)\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\n }\n\n function renderModels(data) {\n const el = document.getElementById('models-table');\n if (!data || data.length === 0) { el.innerHTML = '<p class=\"empty\">No data yet</p>'; return; }\n let html = '<table><thead><tr><th>Model</th><th>Action</th><th class=\"num\">Count</th><th class=\"num\">Avg (ms)</th><th class=\"num\">P95 (ms)</th><th class=\"num\">P99 (ms)</th></tr></thead><tbody>';\n for (const row of data) {\n html += '<tr><td>' + escapeHtml(row.model) + '</td><td>' + escapeHtml(row.action) + '</td>'\n + '<td class=\"num\">' + row.count + '</td>'\n + '<td class=\"num\">' + row.avg_ms.toFixed(1) + '</td>'\n + '<td class=\"num\">' + row.p95_ms.toFixed(1) + '</td>'\n + '<td class=\"num\">' + row.p99_ms.toFixed(1) + '</td></tr>';\n }\n html += '</tbody></table>';\n el.innerHTML = html;\n }\n\n function renderErrors(data) {\n const el = document.getElementById('errors-table');\n if (!data || data.length === 0) { el.innerHTML = '<p class=\"empty\">No errors</p>'; return; }\n let html = '<table><thead><tr><th>Model</th><th>Action</th><th class=\"num\">Total</th><th class=\"num\">Errors</th><th class=\"num\">Rate</th></tr></thead><tbody>';\n for (const row of data) {\n const rate = row.count > 0 ? (row.error_count / row.count * 100).toFixed(1) : '0.0';\n const cls = parseFloat(rate) > 5 ? 'error-rate' : 'low-error';\n html += '<tr><td>' + escapeHtml(row.model) + '</td><td>' + escapeHtml(row.action) + '</td>'\n + '<td class=\"num\">' + row.count + '</td>'\n + '<td class=\"num\">' + row.error_count + '</td>'\n + '<td class=\"num ' + cls + '\">' + rate + '%</td></tr>';\n }\n html += '</tbody></table>';\n el.innerHTML = html;\n }\n\n async function refresh() {\n const [latency, models] = await Promise.all([\n fetchJson('/api/latency?range=' + currentRange),\n fetchJson('/api/models?range=' + currentRange),\n ]);\n renderChart(latency);\n renderModels(models);\n // Derive errors from models data\n const withErrors = (models || []).filter(m => m.error_count > 0);\n renderErrors(withErrors);\n }\n\n refresh();\n setInterval(refresh, 60000);\n </script>\n</body>\n</html>";
|
|
2
2
|
//# sourceMappingURL=observe-ui.d.ts.map
|
package/dist/cli/observe-ui.js
CHANGED
|
@@ -129,12 +129,22 @@ export const OBSERVE_HTML = `<!doctype html>
|
|
|
129
129
|
+ '</svg>';
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
function escapeHtml(s) {
|
|
133
|
+
if (s == null) return '';
|
|
134
|
+
return String(s)
|
|
135
|
+
.replace(/&/g, '&')
|
|
136
|
+
.replace(/</g, '<')
|
|
137
|
+
.replace(/>/g, '>')
|
|
138
|
+
.replace(/"/g, '"')
|
|
139
|
+
.replace(/'/g, ''');
|
|
140
|
+
}
|
|
141
|
+
|
|
132
142
|
function renderModels(data) {
|
|
133
143
|
const el = document.getElementById('models-table');
|
|
134
144
|
if (!data || data.length === 0) { el.innerHTML = '<p class="empty">No data yet</p>'; return; }
|
|
135
145
|
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
146
|
for (const row of data) {
|
|
137
|
-
html += '<tr><td>' + row.model + '</td><td>' + row.action + '</td>'
|
|
147
|
+
html += '<tr><td>' + escapeHtml(row.model) + '</td><td>' + escapeHtml(row.action) + '</td>'
|
|
138
148
|
+ '<td class="num">' + row.count + '</td>'
|
|
139
149
|
+ '<td class="num">' + row.avg_ms.toFixed(1) + '</td>'
|
|
140
150
|
+ '<td class="num">' + row.p95_ms.toFixed(1) + '</td>'
|
|
@@ -151,7 +161,7 @@ export const OBSERVE_HTML = `<!doctype html>
|
|
|
151
161
|
for (const row of data) {
|
|
152
162
|
const rate = row.count > 0 ? (row.error_count / row.count * 100).toFixed(1) : '0.0';
|
|
153
163
|
const cls = parseFloat(rate) > 5 ? 'error-rate' : 'low-error';
|
|
154
|
-
html += '<tr><td>' + row.model + '</td><td>' + row.action + '</td>'
|
|
164
|
+
html += '<tr><td>' + escapeHtml(row.model) + '</td><td>' + escapeHtml(row.action) + '</td>'
|
|
155
165
|
+ '<td class="num">' + row.count + '</td>'
|
|
156
166
|
+ '<td class="num">' + row.error_count + '</td>'
|
|
157
167
|
+ '<td class="num ' + cls + '">' + rate + '%</td></tr>';
|
package/dist/cli/observe.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* _turbine_metrics. Same security model as Studio: loopback binding,
|
|
6
6
|
* random token, HttpOnly cookie, CSP headers, read-only transactions.
|
|
7
7
|
*/
|
|
8
|
-
import { randomBytes } from 'node:crypto';
|
|
8
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
9
9
|
import { createServer } from 'node:http';
|
|
10
10
|
import pg from 'pg';
|
|
11
11
|
import { OBSERVE_HTML } from './observe-ui.js';
|
|
@@ -110,13 +110,12 @@ function isAuthorized(req, expectedToken) {
|
|
|
110
110
|
return false;
|
|
111
111
|
}
|
|
112
112
|
function constantTimeEqual(a, b) {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
return result === 0;
|
|
113
|
+
// Hash both inputs to fixed-length 32-byte SHA-256 digests before comparing.
|
|
114
|
+
// This makes the comparison constant-length (timingSafeEqual never throws on a
|
|
115
|
+
// length mismatch) and leaks neither length nor content via timing.
|
|
116
|
+
const ah = createHash('sha256').update(a).digest();
|
|
117
|
+
const bh = createHash('sha256').update(b).digest();
|
|
118
|
+
return timingSafeEqual(ah, bh);
|
|
120
119
|
}
|
|
121
120
|
// ---------------------------------------------------------------------------
|
|
122
121
|
// API handlers
|
package/dist/cli/studio.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* Studio is for inspection. Use the CLI, migrate, or raw SQL for writes.
|
|
22
22
|
*/
|
|
23
23
|
import { spawn } from 'node:child_process';
|
|
24
|
-
import { randomBytes, randomUUID } from 'node:crypto';
|
|
24
|
+
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
25
25
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
26
26
|
import { createServer } from 'node:http';
|
|
27
27
|
import { platform } from 'node:os';
|
|
@@ -223,13 +223,12 @@ function checkRateLimit(limiter, token) {
|
|
|
223
223
|
return { allowed: true, resetAt: entry.resetAt };
|
|
224
224
|
}
|
|
225
225
|
function constantTimeEqual(a, b) {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
return result === 0;
|
|
226
|
+
// Hash both inputs to fixed-length 32-byte SHA-256 digests before comparing.
|
|
227
|
+
// This makes the comparison constant-length (timingSafeEqual never throws on a
|
|
228
|
+
// length mismatch) and leaks neither length nor content via timing.
|
|
229
|
+
const ah = createHash('sha256').update(a).digest();
|
|
230
|
+
const bh = createHash('sha256').update(b).digest();
|
|
231
|
+
return timingSafeEqual(ah, bh);
|
|
233
232
|
}
|
|
234
233
|
/**
|
|
235
234
|
* Build a helpful "unknown table" error that lists the available tables so the
|
package/dist/cli/ui.d.ts
CHANGED
|
@@ -40,7 +40,7 @@ export declare const symbols: {
|
|
|
40
40
|
readonly bottomLeft: "+" | "╰";
|
|
41
41
|
readonly bottomRight: "+" | "╯";
|
|
42
42
|
readonly tee: "|" | "├";
|
|
43
|
-
readonly teeEnd: "
|
|
43
|
+
readonly teeEnd: "\\" | "└";
|
|
44
44
|
};
|
|
45
45
|
export declare function box(content: string, options?: {
|
|
46
46
|
title?: string;
|
package/dist/client.d.ts
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* ```
|
|
23
23
|
*/
|
|
24
24
|
import pg from 'pg';
|
|
25
|
-
import type
|
|
25
|
+
import { type Dialect } from './dialect.js';
|
|
26
26
|
import { type ErrorMessageMode } from './errors.js';
|
|
27
27
|
import { type ObserveConfig, type ObserveHandle } from './observe.js';
|
|
28
28
|
import { type PipelineOptions, type PipelineResults } from './pipeline.js';
|
|
@@ -83,6 +83,24 @@ export interface PgCompatPool {
|
|
|
83
83
|
/** Optional — pg.Pool supports 'error' event; HTTP drivers typically do not */
|
|
84
84
|
on?(event: 'error', listener: (err: Error) => void): this;
|
|
85
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Driver-neutral seam. Bundles a pg-compatible connection pool with the SQL
|
|
88
|
+
* {@link Dialect} that owns every piece of SQL text varying across engines —
|
|
89
|
+
* parameter placeholders, transaction-control keywords (BEGIN/COMMIT/ROLLBACK/
|
|
90
|
+
* SAVEPOINT/isolation/set_config), streaming, and capability flags.
|
|
91
|
+
*
|
|
92
|
+
* This is the structural boundary that keeps hard-coded Postgres SQL out of
|
|
93
|
+
* `client.ts`: the pool provides connect/query/transaction/close, the dialect
|
|
94
|
+
* provides every literal keyword and the placeholder syntax. A future MySQL /
|
|
95
|
+
* SQLite engine ships a `{ pool, dialect }` pair instead of a raw `pg.Pool`,
|
|
96
|
+
* exactly as `turbineHttp` ships a serverless pool today.
|
|
97
|
+
*/
|
|
98
|
+
export interface TurbineDriver {
|
|
99
|
+
/** The underlying pg-compatible connection pool (connect/query/transaction/close). */
|
|
100
|
+
readonly pool: PgCompatPool;
|
|
101
|
+
/** SQL dialect: placeholders, transaction keywords, session-config, capability flags. */
|
|
102
|
+
readonly dialect: Dialect;
|
|
103
|
+
}
|
|
86
104
|
export interface TurbineConfig {
|
|
87
105
|
/**
|
|
88
106
|
* An external pg-compatible pool. Use this to plug in serverless drivers
|
|
@@ -211,6 +229,8 @@ export declare class TransactionClient {
|
|
|
211
229
|
private readonly queryOptions?;
|
|
212
230
|
private readonly tableCache;
|
|
213
231
|
private savepointCounter;
|
|
232
|
+
/** Active SQL dialect — owns savepoint keywords and raw-SQL placeholders. */
|
|
233
|
+
private readonly dialect;
|
|
214
234
|
constructor(client: pg.PoolClient, schema: SchemaMetadata, middlewares: Middleware[], queryOptions?: QueryInterfaceOptions | undefined);
|
|
215
235
|
/**
|
|
216
236
|
* Get a QueryInterface for a table within this transaction.
|
|
@@ -244,6 +264,8 @@ export declare class TurbineClient {
|
|
|
244
264
|
readonly schema: SchemaMetadata;
|
|
245
265
|
private static int8ParserRegistered;
|
|
246
266
|
private readonly logging;
|
|
267
|
+
/** Active SQL dialect — owns transaction keywords, set_config, raw-SQL placeholders, capability flags. */
|
|
268
|
+
private readonly dialect;
|
|
247
269
|
private readonly tableCache;
|
|
248
270
|
private readonly middlewares;
|
|
249
271
|
private readonly queryListeners;
|
package/dist/client.js
CHANGED
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
* ```
|
|
23
23
|
*/
|
|
24
24
|
import pg from 'pg';
|
|
25
|
-
import {
|
|
25
|
+
import { postgresDialect } from './dialect.js';
|
|
26
|
+
import { setErrorMessageMode, TimeoutError, UnsupportedFeatureError, ValidationError, wrapPgError, } from './errors.js';
|
|
26
27
|
import { ObserveEngine } from './observe.js';
|
|
27
28
|
import { executePipeline, pipelineSupported } from './pipeline.js';
|
|
28
29
|
import { QueryInterface, } from './query/index.js';
|
|
@@ -82,11 +83,14 @@ export class TransactionClient {
|
|
|
82
83
|
queryOptions;
|
|
83
84
|
tableCache = new Map();
|
|
84
85
|
savepointCounter = 0;
|
|
86
|
+
/** Active SQL dialect — owns savepoint keywords and raw-SQL placeholders. */
|
|
87
|
+
dialect;
|
|
85
88
|
constructor(client, schema, middlewares, queryOptions) {
|
|
86
89
|
this.client = client;
|
|
87
90
|
this.schema = schema;
|
|
88
91
|
this.middlewares = middlewares;
|
|
89
92
|
this.queryOptions = queryOptions;
|
|
93
|
+
this.dialect = queryOptions?.dialect ?? postgresDialect;
|
|
90
94
|
// Auto-create typed table accessors for all tables in the schema
|
|
91
95
|
for (const tableName of Object.keys(schema.tables)) {
|
|
92
96
|
const camelName = tableName.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
@@ -109,7 +113,9 @@ export class TransactionClient {
|
|
|
109
113
|
// We use a proxy pool that routes queries through the transaction client
|
|
110
114
|
const txPool = this.createTxPool();
|
|
111
115
|
const txOpts = { ...this.queryOptions, _txScoped: true };
|
|
112
|
-
qi =
|
|
116
|
+
qi = txOpts.queryInterfaceFactory
|
|
117
|
+
? txOpts.queryInterfaceFactory(txPool, name, this.schema, this.middlewares, txOpts)
|
|
118
|
+
: new QueryInterface(txPool, name, this.schema, this.middlewares, txOpts);
|
|
113
119
|
this.tableCache.set(name, qi);
|
|
114
120
|
}
|
|
115
121
|
return qi;
|
|
@@ -120,14 +126,14 @@ export class TransactionClient {
|
|
|
120
126
|
*/
|
|
121
127
|
async $transaction(fn) {
|
|
122
128
|
const savepointName = `sp_${++this.savepointCounter}`;
|
|
123
|
-
await this.client.query(
|
|
129
|
+
await this.client.query(this.dialect.savepointStatement(savepointName));
|
|
124
130
|
try {
|
|
125
131
|
const result = await fn(this);
|
|
126
|
-
await this.client.query(
|
|
132
|
+
await this.client.query(this.dialect.releaseSavepointStatement(savepointName));
|
|
127
133
|
return result;
|
|
128
134
|
}
|
|
129
135
|
catch (err) {
|
|
130
|
-
await this.client.query(
|
|
136
|
+
await this.client.query(this.dialect.rollbackToSavepointStatement(savepointName));
|
|
131
137
|
throw err;
|
|
132
138
|
}
|
|
133
139
|
}
|
|
@@ -139,7 +145,7 @@ export class TransactionClient {
|
|
|
139
145
|
strings.forEach((str, i) => {
|
|
140
146
|
sql += str;
|
|
141
147
|
if (i < values.length) {
|
|
142
|
-
sql +=
|
|
148
|
+
sql += this.dialect.paramPlaceholder(i + 1);
|
|
143
149
|
}
|
|
144
150
|
});
|
|
145
151
|
try {
|
|
@@ -192,6 +198,8 @@ export class TurbineClient {
|
|
|
192
198
|
schema;
|
|
193
199
|
static int8ParserRegistered = false;
|
|
194
200
|
logging;
|
|
201
|
+
/** Active SQL dialect — owns transaction keywords, set_config, raw-SQL placeholders, capability flags. */
|
|
202
|
+
dialect;
|
|
195
203
|
tableCache = new Map();
|
|
196
204
|
middlewares = [];
|
|
197
205
|
queryListeners = new Set();
|
|
@@ -237,6 +245,7 @@ export class TurbineClient {
|
|
|
237
245
|
TurbineClient.int8ParserRegistered = true;
|
|
238
246
|
}
|
|
239
247
|
this.logging = config.logging ?? false;
|
|
248
|
+
this.dialect = config.dialect ?? postgresDialect;
|
|
240
249
|
this.schema = schema;
|
|
241
250
|
// Respect env var kill switch
|
|
242
251
|
const envDisablePrepared = typeof process !== 'undefined' && process.env?.TURBINE_DISABLE_PREPARED === '1';
|
|
@@ -247,6 +256,11 @@ export class TurbineClient {
|
|
|
247
256
|
preparedStatements: envDisablePrepared ? false : (config.preparedStatements ?? !config.pool),
|
|
248
257
|
sqlCache: config.sqlCache ?? true,
|
|
249
258
|
dialect: config.dialect,
|
|
259
|
+
// Non-SQL backends (PowDB) inject a factory that builds their own query
|
|
260
|
+
// interface (PowqlInterface) instead of the SQL QueryInterface. SQL engines
|
|
261
|
+
// never set this, so `table()` keeps constructing `new QueryInterface`.
|
|
262
|
+
queryInterfaceFactory: config
|
|
263
|
+
.queryInterfaceFactory,
|
|
250
264
|
_onQuery: (event) => {
|
|
251
265
|
if (this.queryListeners.size === 0)
|
|
252
266
|
return;
|
|
@@ -398,7 +412,9 @@ export class TurbineClient {
|
|
|
398
412
|
table(name) {
|
|
399
413
|
let qi = this.tableCache.get(name);
|
|
400
414
|
if (!qi) {
|
|
401
|
-
qi =
|
|
415
|
+
qi = this.queryOptions?.queryInterfaceFactory
|
|
416
|
+
? this.queryOptions.queryInterfaceFactory(this.pool, name, this.schema, this.middlewares, this.queryOptions)
|
|
417
|
+
: new QueryInterface(this.pool, name, this.schema, this.middlewares, this.queryOptions);
|
|
402
418
|
this.tableCache.set(name, qi);
|
|
403
419
|
}
|
|
404
420
|
return qi;
|
|
@@ -465,7 +481,7 @@ export class TurbineClient {
|
|
|
465
481
|
strings.forEach((str, i) => {
|
|
466
482
|
sql += str;
|
|
467
483
|
if (i < values.length) {
|
|
468
|
-
sql +=
|
|
484
|
+
sql += this.dialect.paramPlaceholder(i + 1);
|
|
469
485
|
}
|
|
470
486
|
});
|
|
471
487
|
if (this.logging) {
|
|
@@ -510,7 +526,7 @@ export class TurbineClient {
|
|
|
510
526
|
* ```
|
|
511
527
|
*/
|
|
512
528
|
sql(strings, ...values) {
|
|
513
|
-
const { sql, params } = buildTypedSql(strings, values);
|
|
529
|
+
const { sql, params } = buildTypedSql(strings, values, this.dialect);
|
|
514
530
|
return new TypedSqlQuery(this.pool, sql, params, this.logging);
|
|
515
531
|
}
|
|
516
532
|
// -------------------------------------------------------------------------
|
|
@@ -530,13 +546,13 @@ export class TurbineClient {
|
|
|
530
546
|
async transaction(fn) {
|
|
531
547
|
const client = await this.pool.connect();
|
|
532
548
|
try {
|
|
533
|
-
await client.query(
|
|
549
|
+
await client.query(this.dialect.beginStatement());
|
|
534
550
|
const result = await fn(client);
|
|
535
|
-
await client.query(
|
|
551
|
+
await client.query(this.dialect.commitStatement());
|
|
536
552
|
return result;
|
|
537
553
|
}
|
|
538
554
|
catch (err) {
|
|
539
|
-
await client.query(
|
|
555
|
+
await client.query(this.dialect.rollbackStatement());
|
|
540
556
|
throw err;
|
|
541
557
|
}
|
|
542
558
|
finally {
|
|
@@ -587,14 +603,10 @@ export class TurbineClient {
|
|
|
587
603
|
};
|
|
588
604
|
let timedOut = false;
|
|
589
605
|
try {
|
|
590
|
-
// BEGIN with optional isolation level
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
if (level)
|
|
595
|
-
beginSQL += ` ISOLATION LEVEL ${level}`;
|
|
596
|
-
}
|
|
597
|
-
await client.query(beginSQL);
|
|
606
|
+
// BEGIN with optional isolation level — the dialect owns the keyword and
|
|
607
|
+
// BEGIN+isolation composition (Postgres appends ` ISOLATION LEVEL …`).
|
|
608
|
+
const isolationSql = options?.isolationLevel ? ISOLATION_LEVELS[options.isolationLevel] : undefined;
|
|
609
|
+
await client.query(this.dialect.beginStatement(isolationSql));
|
|
598
610
|
// Apply transaction-local session context (RLS / multi-tenant GUCs).
|
|
599
611
|
// Order matters: BEGIN -> isolation level (above) -> set_config loop ->
|
|
600
612
|
// user fn. Any error here propagates to the catch below and rolls back
|
|
@@ -602,12 +614,16 @@ export class TurbineClient {
|
|
|
602
614
|
// is_local=true) — the parameterizable, transaction-scoped equivalent of
|
|
603
615
|
// SET LOCAL — so both name and value are BOUND params, never interpolated.
|
|
604
616
|
if (options?.sessionContext) {
|
|
617
|
+
if (!this.dialect.supportsRLS) {
|
|
618
|
+
throw new UnsupportedFeatureError('sessionContext (RLS session GUCs)', this.dialect.name, 'set_config-based row-level-security context requires PostgreSQL.');
|
|
619
|
+
}
|
|
605
620
|
for (const [name, value] of Object.entries(options.sessionContext)) {
|
|
606
621
|
if (!GUC_NAME_REGEX.test(name)) {
|
|
607
622
|
throw new ValidationError(`[turbine] Invalid session-context GUC name "${name}" — must match ` +
|
|
608
623
|
'/^[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)?$/ (optionally namespaced, e.g. "app.current_tenant")');
|
|
609
624
|
}
|
|
610
|
-
|
|
625
|
+
const cfg = this.dialect.buildSetSessionConfig(name, String(value));
|
|
626
|
+
await client.query(cfg.sql, cfg.params);
|
|
611
627
|
}
|
|
612
628
|
}
|
|
613
629
|
// Create the transaction client with typed table accessors
|
|
@@ -653,7 +669,7 @@ export class TurbineClient {
|
|
|
653
669
|
else {
|
|
654
670
|
result = await fn(tx);
|
|
655
671
|
}
|
|
656
|
-
await client.query(
|
|
672
|
+
await client.query(this.dialect.commitStatement());
|
|
657
673
|
if (this.logging) {
|
|
658
674
|
console.log('[turbine] Transaction committed');
|
|
659
675
|
}
|
|
@@ -666,7 +682,7 @@ export class TurbineClient {
|
|
|
666
682
|
// when its socket was closed).
|
|
667
683
|
if (!timedOut && !released) {
|
|
668
684
|
try {
|
|
669
|
-
await client.query(
|
|
685
|
+
await client.query(this.dialect.rollbackStatement());
|
|
670
686
|
}
|
|
671
687
|
catch {
|
|
672
688
|
// Best-effort rollback — the connection may have died mid-query.
|
|
@@ -733,6 +749,9 @@ export class TurbineClient {
|
|
|
733
749
|
* ```
|
|
734
750
|
*/
|
|
735
751
|
async $listen(channel, handler) {
|
|
752
|
+
if (!this.dialect.supportsListenNotify) {
|
|
753
|
+
throw new UnsupportedFeatureError('$listen (LISTEN/NOTIFY realtime)', this.dialect.name, 'Realtime pub/sub requires PostgreSQL.');
|
|
754
|
+
}
|
|
736
755
|
validateChannel(channel);
|
|
737
756
|
const quoted = quoteIdent(channel);
|
|
738
757
|
if (this.logging) {
|
|
@@ -758,6 +777,9 @@ export class TurbineClient {
|
|
|
758
777
|
* ```
|
|
759
778
|
*/
|
|
760
779
|
async $notify(channel, payload) {
|
|
780
|
+
if (!this.dialect.supportsListenNotify) {
|
|
781
|
+
throw new UnsupportedFeatureError('$notify (LISTEN/NOTIFY realtime)', this.dialect.name, 'Realtime pub/sub requires PostgreSQL.');
|
|
782
|
+
}
|
|
761
783
|
validateChannel(channel);
|
|
762
784
|
if (this.logging) {
|
|
763
785
|
console.log(`[turbine] NOTIFY ${channel}`);
|