turbine-orm 0.19.1 → 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 (53) hide show
  1. package/README.md +156 -24
  2. package/dist/adapters/cockroachdb.js +4 -9
  3. package/dist/cjs/adapters/cockroachdb.js +4 -9
  4. package/dist/cjs/cli/index.js +23 -12
  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-ui.generated.js +1 -1
  8. package/dist/cjs/cli/studio.js +6 -37
  9. package/dist/cjs/client.js +45 -33
  10. package/dist/cjs/dialect.js +116 -0
  11. package/dist/cjs/errors.js +19 -1
  12. package/dist/cjs/generate.js +4 -0
  13. package/dist/cjs/index.js +3 -2
  14. package/dist/cjs/introspect.js +20 -0
  15. package/dist/cjs/mssql.js +1338 -0
  16. package/dist/cjs/mysql.js +1052 -0
  17. package/dist/cjs/query/builder.js +408 -122
  18. package/dist/cjs/query/utils.js +1 -0
  19. package/dist/cjs/sqlite.js +849 -0
  20. package/dist/cjs/typed-sql.js +9 -7
  21. package/dist/cli/index.js +23 -12
  22. package/dist/cli/migrate.d.ts +2 -2
  23. package/dist/cli/observe-ui.d.ts +1 -1
  24. package/dist/cli/observe-ui.js +12 -2
  25. package/dist/cli/observe.js +7 -8
  26. package/dist/cli/studio-ui.generated.js +1 -1
  27. package/dist/cli/studio.d.ts +0 -10
  28. package/dist/cli/studio.js +7 -37
  29. package/dist/client.d.ts +35 -14
  30. package/dist/client.js +46 -34
  31. package/dist/dialect.d.ts +258 -1
  32. package/dist/dialect.js +83 -0
  33. package/dist/errors.d.ts +12 -0
  34. package/dist/errors.js +17 -0
  35. package/dist/generate.js +4 -0
  36. package/dist/index.d.ts +4 -4
  37. package/dist/index.js +1 -1
  38. package/dist/introspect.d.ts +18 -0
  39. package/dist/introspect.js +19 -0
  40. package/dist/mssql.d.ts +233 -0
  41. package/dist/mssql.js +1298 -0
  42. package/dist/mysql.d.ts +174 -0
  43. package/dist/mysql.js +1012 -0
  44. package/dist/query/builder.d.ts +105 -6
  45. package/dist/query/builder.js +409 -123
  46. package/dist/query/index.d.ts +2 -2
  47. package/dist/query/types.d.ts +62 -12
  48. package/dist/query/utils.js +1 -0
  49. package/dist/sqlite.d.ts +144 -0
  50. package/dist/sqlite.js +842 -0
  51. package/dist/typed-sql.d.ts +7 -5
  52. package/dist/typed-sql.js +9 -7
  53. package/package.json +32 -3
@@ -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.`));
@@ -1129,13 +1132,15 @@ function showMigrateHelp() {
1129
1132
  newline();
1130
1133
  console.log(` ${bold('Options:')}`);
1131
1134
  console.log(` ${cyan('--url, -u')} ${dim('<url>')} Postgres connection string`);
1135
+ console.log(` ${cyan('--auto')} Auto-generate UP/DOWN SQL from schema diff ${dim('(create only)')}`);
1132
1136
  console.log(` ${cyan('--step, -n')} ${dim('<N>')} Number of migrations to apply/rollback`);
1133
- console.log(` ${cyan('--dry-run')} Show SQL without executing`);
1134
- console.log(` ${cyan('--allow-drift')} Bypass checksum validation ${dim('(migrate up only — advanced)')}`);
1135
- console.log(` ${cyan('--verbose, -v')} Show detailed output`);
1137
+ console.log(` ${cyan('--dry-run')} Show SQL without executing`);
1138
+ console.log(` ${cyan('--allow-drift')} Bypass checksum validation ${dim('(migrate up only — advanced)')}`);
1139
+ console.log(` ${cyan('--verbose, -v')} Show detailed output`);
1136
1140
  newline();
1137
1141
  console.log(` ${bold('Examples:')}`);
1138
1142
  console.log(` ${dim('$')} npx turbine migrate create add_users_table`);
1143
+ console.log(` ${dim('$')} npx turbine migrate create add_email_index --auto`);
1139
1144
  console.log(` ${dim('$')} npx turbine migrate up`);
1140
1145
  console.log(` ${dim('$')} npx turbine migrate down --step 2`);
1141
1146
  console.log(` ${dim('$')} npx turbine migrate status`);
@@ -1180,16 +1185,17 @@ function showHelp() {
1180
1185
  newline();
1181
1186
  console.log(` ${bold('Commands:')}`);
1182
1187
  console.log(` ${cyan('init')} Initialize a Turbine project`);
1183
- console.log(` ${cyan('generate')} ${dim('| pull')} Introspect database ${symbols.arrow} generate types`);
1188
+ console.log(` ${cyan('generate')} ${dim('| pull')} Introspect database ${symbols.arrow} generate types`);
1184
1189
  console.log(` ${cyan('push')} Apply schema definitions to database`);
1185
- console.log(` ${cyan('migrate')} ${dim('<sub>')} SQL migration management`);
1186
- console.log(` ${dim('create <name>')} Create a new migration file`);
1190
+ console.log(` ${cyan('migrate')} ${dim('<sub>')} SQL migration management`);
1191
+ console.log(` ${dim('create <name>')} Create a new migration file`);
1187
1192
  console.log(` ${dim('up')} Apply pending migrations`);
1188
1193
  console.log(` ${dim('down')} Rollback last migration`);
1189
1194
  console.log(` ${dim('status')} Show applied/pending migrations`);
1190
1195
  console.log(` ${cyan('seed')} Run seed file`);
1191
- console.log(` ${cyan('status')} ${dim('| info')} Show schema summary`);
1196
+ console.log(` ${cyan('status')} ${dim('| info')} Show schema summary`);
1192
1197
  console.log(` ${cyan('studio')} Launch local read-only web UI`);
1198
+ console.log(` ${cyan('observe')} Launch metrics dashboard ${dim('(requires TURBINE_OBSERVE_URL)')}`);
1193
1199
  newline();
1194
1200
  console.log(` ${bold('Options:')}`);
1195
1201
  console.log(` ${cyan('--url, -u')} ${dim('<url>')} Postgres connection string`);
@@ -1201,8 +1207,13 @@ function showHelp() {
1201
1207
  console.log(` ${cyan('--verbose, -v')} Show detailed output`);
1202
1208
  console.log(` ${cyan('--force, -f')} Overwrite existing files`);
1203
1209
  newline();
1204
- console.log(` ${bold('Studio options:')}`);
1205
- console.log(` ${cyan('--port')} ${dim('<n>')} HTTP port ${dim('(default: 4983)')}`);
1210
+ console.log(` ${bold('Migrate options:')}`);
1211
+ console.log(` ${cyan('--auto')} Auto-generate UP/DOWN SQL from schema diff ${dim('(create)')}`);
1212
+ console.log(` ${cyan('--step, -n')} ${dim('<N>')} Number of migrations to apply/rollback`);
1213
+ console.log(` ${cyan('--allow-drift')} Bypass checksum validation on ${cyan('migrate up')} ${dim('(advanced)')}`);
1214
+ newline();
1215
+ console.log(` ${bold('Studio / observe options:')}`);
1216
+ console.log(` ${cyan('--port')} ${dim('<n>')} HTTP port ${dim('(default: 4983 studio, 4984 observe)')}`);
1206
1217
  console.log(` ${cyan('--host')} ${dim('<addr>')} Bind address ${dim('(default: 127.0.0.1)')}`);
1207
1218
  console.log(` ${cyan('--no-open')} Don't auto-open the browser`);
1208
1219
  newline();
@@ -113,8 +113,8 @@ export declare function deriveLockId(databaseName: string): number;
113
113
  */
114
114
  export declare function migrateUp(connectionString: string, migrationsDir: string, options?: {
115
115
  step?: number;
116
- allowDrift?: boolean /** @deprecated use allowDrift */;
117
- force?: boolean;
116
+ allowDrift?: boolean;
117
+ force?: boolean /** @deprecated use allowDrift */;
118
118
  adapter?: DatabaseAdapter;
119
119
  dialect?: Dialect;
120
120
  }): Promise<{
@@ -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