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.
- package/README.md +156 -24
- package/dist/adapters/cockroachdb.js +4 -9
- package/dist/cjs/adapters/cockroachdb.js +4 -9
- package/dist/cjs/cli/index.js +23 -12
- package/dist/cjs/cli/observe-ui.js +12 -2
- package/dist/cjs/cli/observe.js +6 -7
- package/dist/cjs/cli/studio-ui.generated.js +1 -1
- package/dist/cjs/cli/studio.js +6 -37
- package/dist/cjs/client.js +45 -33
- 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/query/builder.js +408 -122
- package/dist/cjs/query/utils.js +1 -0
- package/dist/cjs/sqlite.js +849 -0
- package/dist/cjs/typed-sql.js +9 -7
- package/dist/cli/index.js +23 -12
- package/dist/cli/migrate.d.ts +2 -2
- 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-ui.generated.js +1 -1
- package/dist/cli/studio.d.ts +0 -10
- package/dist/cli/studio.js +7 -37
- package/dist/client.d.ts +35 -14
- package/dist/client.js +46 -34
- 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 +4 -4
- 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/query/builder.d.ts +105 -6
- package/dist/query/builder.js +409 -123
- package/dist/query/index.d.ts +2 -2
- package/dist/query/types.d.ts +62 -12
- package/dist/query/utils.js +1 -0
- 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 +32 -3
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.`));
|
|
@@ -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')}
|
|
1134
|
-
console.log(` ${cyan('--allow-drift')}
|
|
1135
|
-
console.log(` ${cyan('--verbose, -v')}
|
|
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')}
|
|
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>')}
|
|
1186
|
-
console.log(` ${dim('create <name>')}
|
|
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')}
|
|
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('
|
|
1205
|
-
console.log(` ${cyan('--
|
|
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();
|
package/dist/cli/migrate.d.ts
CHANGED
|
@@ -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
|
|
117
|
-
force?: boolean
|
|
116
|
+
allowDrift?: boolean;
|
|
117
|
+
force?: boolean /** @deprecated use allowDrift */;
|
|
118
118
|
adapter?: DatabaseAdapter;
|
|
119
119
|
dialect?: Dialect;
|
|
120
120
|
}): Promise<{
|
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
|