turbine-orm 0.19.2 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +82 -18
  2. package/dist/adapters/cockroachdb.js +4 -9
  3. package/dist/cjs/adapters/cockroachdb.js +4 -9
  4. package/dist/cjs/cli/index.js +6 -3
  5. package/dist/cjs/cli/observe-ui.js +12 -2
  6. package/dist/cjs/cli/observe.js +6 -7
  7. package/dist/cjs/cli/studio.js +6 -7
  8. package/dist/cjs/client.js +33 -20
  9. package/dist/cjs/dialect.js +116 -0
  10. package/dist/cjs/errors.js +19 -1
  11. package/dist/cjs/generate.js +4 -0
  12. package/dist/cjs/index.js +3 -2
  13. package/dist/cjs/introspect.js +20 -0
  14. package/dist/cjs/mssql.js +1338 -0
  15. package/dist/cjs/mysql.js +1052 -0
  16. package/dist/cjs/query/builder.js +293 -73
  17. package/dist/cjs/sqlite.js +849 -0
  18. package/dist/cjs/typed-sql.js +9 -7
  19. package/dist/cli/index.js +6 -3
  20. package/dist/cli/observe-ui.d.ts +1 -1
  21. package/dist/cli/observe-ui.js +12 -2
  22. package/dist/cli/observe.js +7 -8
  23. package/dist/cli/studio.js +7 -8
  24. package/dist/client.d.ts +23 -1
  25. package/dist/client.js +34 -21
  26. package/dist/dialect.d.ts +258 -1
  27. package/dist/dialect.js +83 -0
  28. package/dist/errors.d.ts +12 -0
  29. package/dist/errors.js +17 -0
  30. package/dist/generate.js +4 -0
  31. package/dist/index.d.ts +3 -3
  32. package/dist/index.js +1 -1
  33. package/dist/introspect.d.ts +18 -0
  34. package/dist/introspect.js +19 -0
  35. package/dist/mssql.d.ts +233 -0
  36. package/dist/mssql.js +1298 -0
  37. package/dist/mysql.d.ts +174 -0
  38. package/dist/mysql.js +1012 -0
  39. package/dist/query/builder.d.ts +97 -0
  40. package/dist/query/builder.js +294 -74
  41. package/dist/query/index.d.ts +1 -1
  42. package/dist/sqlite.d.ts +144 -0
  43. package/dist/sqlite.js +842 -0
  44. package/dist/typed-sql.d.ts +7 -5
  45. package/dist/typed-sql.js +9 -7
  46. package/package.json +30 -1
@@ -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 `$N` placeholder and
59
- * pushed to the params array in order. The static string segments are the only
60
- * thing concatenated into the SQL text. This is the single point that
61
- * guarantees parameterization for the entire typed-SQL surface.
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 `$N` placeholders from params (pg would reject it, but fail
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 += `$${i + 1}`;
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
- // Refuse to bind anything other than loopback unless explicitly overridden.
933
- // This is deliberate: Studio has no real authentication beyond a random
934
- // session token, so exposing it on a LAN interface is foot-gun territory.
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.`));
@@ -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, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n .replace(/'/g, '&#39;');\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
@@ -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, '&amp;')
136
+ .replace(/</g, '&lt;')
137
+ .replace(/>/g, '&gt;')
138
+ .replace(/"/g, '&quot;')
139
+ .replace(/'/g, '&#39;');
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>';
@@ -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
- 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;
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
@@ -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
- if (a.length !== b.length)
227
- return false;
228
- let result = 0;
229
- for (let i = 0; i < a.length; i++) {
230
- result |= a.charCodeAt(i) ^ b.charCodeAt(i);
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/client.d.ts CHANGED
@@ -22,7 +22,7 @@
22
22
  * ```
23
23
  */
24
24
  import pg from 'pg';
25
- import type { Dialect } from './dialect.js';
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 { setErrorMessageMode, TimeoutError, ValidationError, wrapPgError } from './errors.js';
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());
@@ -120,14 +124,14 @@ export class TransactionClient {
120
124
  */
121
125
  async $transaction(fn) {
122
126
  const savepointName = `sp_${++this.savepointCounter}`;
123
- await this.client.query(`SAVEPOINT ${savepointName}`);
127
+ await this.client.query(this.dialect.savepointStatement(savepointName));
124
128
  try {
125
129
  const result = await fn(this);
126
- await this.client.query(`RELEASE SAVEPOINT ${savepointName}`);
130
+ await this.client.query(this.dialect.releaseSavepointStatement(savepointName));
127
131
  return result;
128
132
  }
129
133
  catch (err) {
130
- await this.client.query(`ROLLBACK TO SAVEPOINT ${savepointName}`);
134
+ await this.client.query(this.dialect.rollbackToSavepointStatement(savepointName));
131
135
  throw err;
132
136
  }
133
137
  }
@@ -139,7 +143,7 @@ export class TransactionClient {
139
143
  strings.forEach((str, i) => {
140
144
  sql += str;
141
145
  if (i < values.length) {
142
- sql += `$${i + 1}`;
146
+ sql += this.dialect.paramPlaceholder(i + 1);
143
147
  }
144
148
  });
145
149
  try {
@@ -192,6 +196,8 @@ export class TurbineClient {
192
196
  schema;
193
197
  static int8ParserRegistered = false;
194
198
  logging;
199
+ /** Active SQL dialect — owns transaction keywords, set_config, raw-SQL placeholders, capability flags. */
200
+ dialect;
195
201
  tableCache = new Map();
196
202
  middlewares = [];
197
203
  queryListeners = new Set();
@@ -237,6 +243,7 @@ export class TurbineClient {
237
243
  TurbineClient.int8ParserRegistered = true;
238
244
  }
239
245
  this.logging = config.logging ?? false;
246
+ this.dialect = config.dialect ?? postgresDialect;
240
247
  this.schema = schema;
241
248
  // Respect env var kill switch
242
249
  const envDisablePrepared = typeof process !== 'undefined' && process.env?.TURBINE_DISABLE_PREPARED === '1';
@@ -465,7 +472,7 @@ export class TurbineClient {
465
472
  strings.forEach((str, i) => {
466
473
  sql += str;
467
474
  if (i < values.length) {
468
- sql += `$${i + 1}`;
475
+ sql += this.dialect.paramPlaceholder(i + 1);
469
476
  }
470
477
  });
471
478
  if (this.logging) {
@@ -510,7 +517,7 @@ export class TurbineClient {
510
517
  * ```
511
518
  */
512
519
  sql(strings, ...values) {
513
- const { sql, params } = buildTypedSql(strings, values);
520
+ const { sql, params } = buildTypedSql(strings, values, this.dialect);
514
521
  return new TypedSqlQuery(this.pool, sql, params, this.logging);
515
522
  }
516
523
  // -------------------------------------------------------------------------
@@ -530,13 +537,13 @@ export class TurbineClient {
530
537
  async transaction(fn) {
531
538
  const client = await this.pool.connect();
532
539
  try {
533
- await client.query('BEGIN');
540
+ await client.query(this.dialect.beginStatement());
534
541
  const result = await fn(client);
535
- await client.query('COMMIT');
542
+ await client.query(this.dialect.commitStatement());
536
543
  return result;
537
544
  }
538
545
  catch (err) {
539
- await client.query('ROLLBACK');
546
+ await client.query(this.dialect.rollbackStatement());
540
547
  throw err;
541
548
  }
542
549
  finally {
@@ -587,14 +594,10 @@ export class TurbineClient {
587
594
  };
588
595
  let timedOut = false;
589
596
  try {
590
- // BEGIN with optional isolation level
591
- let beginSQL = 'BEGIN';
592
- if (options?.isolationLevel) {
593
- const level = ISOLATION_LEVELS[options.isolationLevel];
594
- if (level)
595
- beginSQL += ` ISOLATION LEVEL ${level}`;
596
- }
597
- await client.query(beginSQL);
597
+ // BEGIN with optional isolation level — the dialect owns the keyword and
598
+ // BEGIN+isolation composition (Postgres appends ` ISOLATION LEVEL …`).
599
+ const isolationSql = options?.isolationLevel ? ISOLATION_LEVELS[options.isolationLevel] : undefined;
600
+ await client.query(this.dialect.beginStatement(isolationSql));
598
601
  // Apply transaction-local session context (RLS / multi-tenant GUCs).
599
602
  // Order matters: BEGIN -> isolation level (above) -> set_config loop ->
600
603
  // user fn. Any error here propagates to the catch below and rolls back
@@ -602,12 +605,16 @@ export class TurbineClient {
602
605
  // is_local=true) — the parameterizable, transaction-scoped equivalent of
603
606
  // SET LOCAL — so both name and value are BOUND params, never interpolated.
604
607
  if (options?.sessionContext) {
608
+ if (!this.dialect.supportsRLS) {
609
+ throw new UnsupportedFeatureError('sessionContext (RLS session GUCs)', this.dialect.name, 'set_config-based row-level-security context requires PostgreSQL.');
610
+ }
605
611
  for (const [name, value] of Object.entries(options.sessionContext)) {
606
612
  if (!GUC_NAME_REGEX.test(name)) {
607
613
  throw new ValidationError(`[turbine] Invalid session-context GUC name "${name}" — must match ` +
608
614
  '/^[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)?$/ (optionally namespaced, e.g. "app.current_tenant")');
609
615
  }
610
- await client.query('SELECT set_config($1, $2, true)', [name, String(value)]);
616
+ const cfg = this.dialect.buildSetSessionConfig(name, String(value));
617
+ await client.query(cfg.sql, cfg.params);
611
618
  }
612
619
  }
613
620
  // Create the transaction client with typed table accessors
@@ -653,7 +660,7 @@ export class TurbineClient {
653
660
  else {
654
661
  result = await fn(tx);
655
662
  }
656
- await client.query('COMMIT');
663
+ await client.query(this.dialect.commitStatement());
657
664
  if (this.logging) {
658
665
  console.log('[turbine] Transaction committed');
659
666
  }
@@ -666,7 +673,7 @@ export class TurbineClient {
666
673
  // when its socket was closed).
667
674
  if (!timedOut && !released) {
668
675
  try {
669
- await client.query('ROLLBACK');
676
+ await client.query(this.dialect.rollbackStatement());
670
677
  }
671
678
  catch {
672
679
  // Best-effort rollback — the connection may have died mid-query.
@@ -733,6 +740,9 @@ export class TurbineClient {
733
740
  * ```
734
741
  */
735
742
  async $listen(channel, handler) {
743
+ if (!this.dialect.supportsListenNotify) {
744
+ throw new UnsupportedFeatureError('$listen (LISTEN/NOTIFY realtime)', this.dialect.name, 'Realtime pub/sub requires PostgreSQL.');
745
+ }
736
746
  validateChannel(channel);
737
747
  const quoted = quoteIdent(channel);
738
748
  if (this.logging) {
@@ -758,6 +768,9 @@ export class TurbineClient {
758
768
  * ```
759
769
  */
760
770
  async $notify(channel, payload) {
771
+ if (!this.dialect.supportsListenNotify) {
772
+ throw new UnsupportedFeatureError('$notify (LISTEN/NOTIFY realtime)', this.dialect.name, 'Realtime pub/sub requires PostgreSQL.');
773
+ }
761
774
  validateChannel(channel);
762
775
  if (this.logging) {
763
776
  console.log(`[turbine] NOTIFY ${channel}`);