turbine-orm 0.15.0 → 0.16.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/dist/cjs/cli/index.js +64 -0
- package/dist/cjs/cli/observe-ui.js +182 -0
- package/dist/cjs/cli/observe.js +242 -0
- package/dist/cjs/client.js +54 -0
- package/dist/cjs/nested-write.js +94 -4
- package/dist/cjs/observe.js +145 -0
- package/dist/cjs/query/builder.js +66 -13
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +64 -0
- package/dist/cli/observe-ui.d.ts +2 -0
- package/dist/cli/observe-ui.js +180 -0
- package/dist/cli/observe.d.ts +20 -0
- package/dist/cli/observe.js +237 -0
- package/dist/client.d.ts +9 -2
- package/dist/client.js +55 -1
- package/dist/index.d.ts +2 -1
- package/dist/nested-write.d.ts +2 -2
- package/dist/nested-write.js +94 -4
- package/dist/observe.d.ts +36 -0
- package/dist/observe.js +141 -0
- package/dist/query/builder.d.ts +17 -0
- package/dist/query/builder.js +66 -13
- package/dist/query/index.d.ts +1 -1
- package/package.json +2 -2
package/dist/cjs/nested-write.js
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Tree-walking create/update that resolves relation fields in `data` into
|
|
6
6
|
* batched SQL operations within a transaction. Supports create, connect,
|
|
7
|
-
* connectOrCreate, disconnect, set, and
|
|
8
|
-
* arbitrary depth (capped at 10).
|
|
7
|
+
* connectOrCreate, disconnect, set, delete, update, and upsert on related
|
|
8
|
+
* records at arbitrary depth (capped at 10).
|
|
9
9
|
*
|
|
10
10
|
* This module is imported by `query/builder.ts` when the `data` argument
|
|
11
11
|
* of `create()` or `update()` contains relation fields. It never imports
|
|
@@ -22,7 +22,7 @@ const errors_js_1 = require("./errors.js");
|
|
|
22
22
|
const schema_js_1 = require("./schema.js");
|
|
23
23
|
const MAX_DEPTH = 10;
|
|
24
24
|
const CREATE_ONLY_OPS = new Set(['create', 'connect', 'connectOrCreate']);
|
|
25
|
-
const UPDATE_ONLY_OPS = new Set(['disconnect', 'set', 'delete']);
|
|
25
|
+
const UPDATE_ONLY_OPS = new Set(['disconnect', 'set', 'delete', 'update', 'upsert']);
|
|
26
26
|
// ---------------------------------------------------------------------------
|
|
27
27
|
// Pure helpers (exported for testing)
|
|
28
28
|
// ---------------------------------------------------------------------------
|
|
@@ -100,7 +100,7 @@ function validateOps(relationName, ops, isUpdate) {
|
|
|
100
100
|
for (const opName of Object.keys(ops)) {
|
|
101
101
|
if (!CREATE_ONLY_OPS.has(opName) && !UPDATE_ONLY_OPS.has(opName)) {
|
|
102
102
|
throw new errors_js_1.ValidationError(`[turbine] Unknown nested write operation "${opName}" on relation "${relationName}". ` +
|
|
103
|
-
`Valid operations: create, connect, connectOrCreate${isUpdate ? ', disconnect, set, delete' : ''}.`);
|
|
103
|
+
`Valid operations: create, connect, connectOrCreate${isUpdate ? ', disconnect, set, delete, update, upsert' : ''}.`);
|
|
104
104
|
}
|
|
105
105
|
if (!isUpdate && UPDATE_ONLY_OPS.has(opName)) {
|
|
106
106
|
throw new errors_js_1.ValidationError(`[turbine] Operation "${opName}" on relation "${relationName}" is only valid inside update(), not create().`);
|
|
@@ -223,9 +223,25 @@ async function executeNestedUpdate(ctx, tableName, where, data, depth = 0, path
|
|
|
223
223
|
if (ops.delete !== undefined) {
|
|
224
224
|
await processDelete(ctx, rel, ops.delete);
|
|
225
225
|
}
|
|
226
|
+
// update
|
|
227
|
+
if (ops.update !== undefined) {
|
|
228
|
+
await processNestedUpdate(ctx, rel, ops.update);
|
|
229
|
+
}
|
|
230
|
+
// upsert
|
|
231
|
+
if (ops.upsert !== undefined) {
|
|
232
|
+
await processNestedUpsert(ctx, rel, ops.upsert, parentRow);
|
|
233
|
+
}
|
|
226
234
|
}
|
|
227
235
|
else if (rel.type === 'belongsTo') {
|
|
228
236
|
await processBelongsToCreate(ctx, rel, ops, parentRow, tableName, depth, path, relName);
|
|
237
|
+
// update (belongsTo — derive where from parent FK)
|
|
238
|
+
if (ops.update !== undefined) {
|
|
239
|
+
await processBelongsToUpdate(ctx, rel, ops.update, parentRow, tableName);
|
|
240
|
+
}
|
|
241
|
+
// upsert (belongsTo)
|
|
242
|
+
if (ops.upsert !== undefined) {
|
|
243
|
+
await processBelongsToUpsert(ctx, rel, ops.upsert, parentRow, tableName);
|
|
244
|
+
}
|
|
229
245
|
if (ops.disconnect !== undefined) {
|
|
230
246
|
// For belongsTo disconnect, null out the FK on the parent
|
|
231
247
|
const fks = (0, schema_js_1.normalizeKeyColumns)(rel.foreignKey);
|
|
@@ -459,6 +475,80 @@ async function processSet(ctx, rel, setItems, parentRow) {
|
|
|
459
475
|
await ctx.tx.table(rel.to).update({ where: target, data: updateData });
|
|
460
476
|
}
|
|
461
477
|
}
|
|
478
|
+
// ---------------------------------------------------------------------------
|
|
479
|
+
// update / upsert operations (update-context only)
|
|
480
|
+
// ---------------------------------------------------------------------------
|
|
481
|
+
async function processNestedUpdate(ctx, rel, updateArg) {
|
|
482
|
+
const items = toArray(updateArg);
|
|
483
|
+
for (const item of items) {
|
|
484
|
+
if (!item.where || !item.data) {
|
|
485
|
+
throw new errors_js_1.ValidationError(`[turbine] Nested update on "${rel.name}" requires both "where" and "data" fields.`);
|
|
486
|
+
}
|
|
487
|
+
await ctx.tx.table(rel.to).update({ where: item.where, data: item.data });
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
async function processNestedUpsert(ctx, rel, upsertArg, parentRow) {
|
|
491
|
+
const items = toArray(upsertArg);
|
|
492
|
+
for (const item of items) {
|
|
493
|
+
if (!item.where || !item.create || !item.update) {
|
|
494
|
+
throw new errors_js_1.ValidationError(`[turbine] Nested upsert on "${rel.name}" requires "where", "create", and "update" fields.`);
|
|
495
|
+
}
|
|
496
|
+
const existing = await ctx.tx.table(rel.to).findUnique({ where: item.where });
|
|
497
|
+
if (existing) {
|
|
498
|
+
await ctx.tx.table(rel.to).update({ where: item.where, data: item.update });
|
|
499
|
+
}
|
|
500
|
+
else {
|
|
501
|
+
const injected = injectForeignKey(item.create, rel, parentRow, ctx.schema);
|
|
502
|
+
await ctx.tx.table(rel.to).create({ data: injected });
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
async function processBelongsToUpdate(ctx, rel, updateArg, parentRow, parentTable) {
|
|
507
|
+
const item = updateArg;
|
|
508
|
+
if (!item.data) {
|
|
509
|
+
throw new errors_js_1.ValidationError(`[turbine] Nested update on belongsTo "${rel.name}" requires a "data" field.`);
|
|
510
|
+
}
|
|
511
|
+
// Derive where from parent's FK values
|
|
512
|
+
const fks = (0, schema_js_1.normalizeKeyColumns)(rel.foreignKey);
|
|
513
|
+
const refs = (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey);
|
|
514
|
+
const parentMeta = ctx.schema.tables[parentTable];
|
|
515
|
+
const relatedTable = ctx.schema.tables[rel.to];
|
|
516
|
+
const where = {};
|
|
517
|
+
for (let i = 0; i < fks.length; i++) {
|
|
518
|
+
const fkField = parentMeta?.reverseColumnMap[fks[i]] ?? fks[i];
|
|
519
|
+
const refField = relatedTable?.reverseColumnMap[refs[i]] ?? refs[i];
|
|
520
|
+
where[refField] = parentRow[fkField];
|
|
521
|
+
}
|
|
522
|
+
await ctx.tx.table(rel.to).update({ where, data: item.data });
|
|
523
|
+
}
|
|
524
|
+
async function processBelongsToUpsert(ctx, rel, upsertArg, parentRow, parentTable) {
|
|
525
|
+
const item = upsertArg;
|
|
526
|
+
if (!item.where || !item.create || !item.update) {
|
|
527
|
+
throw new errors_js_1.ValidationError(`[turbine] Nested upsert on belongsTo "${rel.name}" requires "where", "create", and "update" fields.`);
|
|
528
|
+
}
|
|
529
|
+
const existing = await ctx.tx.table(rel.to).findUnique({ where: item.where });
|
|
530
|
+
if (existing) {
|
|
531
|
+
await ctx.tx.table(rel.to).update({ where: item.where, data: item.update });
|
|
532
|
+
}
|
|
533
|
+
else {
|
|
534
|
+
// Create the related row, then update parent's FK to point at it
|
|
535
|
+
const createdRow = (await ctx.tx.table(rel.to).create({ data: item.create }));
|
|
536
|
+
const fks = (0, schema_js_1.normalizeKeyColumns)(rel.foreignKey);
|
|
537
|
+
const refs = (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey);
|
|
538
|
+
const parentMeta = ctx.schema.tables[parentTable];
|
|
539
|
+
const relatedTable = ctx.schema.tables[rel.to];
|
|
540
|
+
const updateData = {};
|
|
541
|
+
for (let i = 0; i < fks.length; i++) {
|
|
542
|
+
const fkField = parentMeta.reverseColumnMap[fks[i]] ?? fks[i];
|
|
543
|
+
const refField = relatedTable?.reverseColumnMap[refs[i]] ?? refs[i];
|
|
544
|
+
updateData[fkField] = createdRow[refField];
|
|
545
|
+
}
|
|
546
|
+
await ctx.tx.table(parentTable).update({
|
|
547
|
+
where: pkWhere(parentMeta, parentRow),
|
|
548
|
+
data: updateData,
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
}
|
|
462
552
|
async function processDelete(ctx, rel, deleteArg) {
|
|
463
553
|
const items = toArray(deleteArg);
|
|
464
554
|
for (const target of items) {
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* turbine-orm — Observability module
|
|
4
|
+
*
|
|
5
|
+
* Buffers query metrics in memory (keyed by model:action per minute bucket),
|
|
6
|
+
* then periodically flushes aggregates (count, avg, p50, p95, p99, errors)
|
|
7
|
+
* to a dedicated _turbine_metrics table. Uses a separate 1-connection pool
|
|
8
|
+
* so metrics writes never contend with the application pool.
|
|
9
|
+
*/
|
|
10
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
11
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
12
|
+
};
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.ObserveEngine = void 0;
|
|
15
|
+
exports.floorToMinute = floorToMinute;
|
|
16
|
+
exports.percentile = percentile;
|
|
17
|
+
const pg_1 = __importDefault(require("pg"));
|
|
18
|
+
function floorToMinute(date) {
|
|
19
|
+
const d = new Date(date);
|
|
20
|
+
d.setSeconds(0, 0);
|
|
21
|
+
return d;
|
|
22
|
+
}
|
|
23
|
+
function percentile(sorted, p) {
|
|
24
|
+
if (sorted.length === 0)
|
|
25
|
+
return 0;
|
|
26
|
+
const idx = Math.ceil(p * sorted.length) - 1;
|
|
27
|
+
return sorted[Math.max(0, idx)];
|
|
28
|
+
}
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Schema DDL
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
const SCHEMA_DDL = `
|
|
33
|
+
CREATE TABLE IF NOT EXISTS _turbine_metrics (
|
|
34
|
+
id BIGSERIAL PRIMARY KEY,
|
|
35
|
+
bucket TIMESTAMPTZ NOT NULL,
|
|
36
|
+
model TEXT NOT NULL,
|
|
37
|
+
action TEXT NOT NULL,
|
|
38
|
+
count INTEGER NOT NULL DEFAULT 0,
|
|
39
|
+
avg_ms REAL NOT NULL DEFAULT 0,
|
|
40
|
+
p50_ms REAL NOT NULL DEFAULT 0,
|
|
41
|
+
p95_ms REAL NOT NULL DEFAULT 0,
|
|
42
|
+
p99_ms REAL NOT NULL DEFAULT 0,
|
|
43
|
+
error_count INTEGER NOT NULL DEFAULT 0,
|
|
44
|
+
UNIQUE(bucket, model, action)
|
|
45
|
+
);
|
|
46
|
+
CREATE INDEX IF NOT EXISTS idx_turbine_metrics_bucket ON _turbine_metrics(bucket);
|
|
47
|
+
`;
|
|
48
|
+
const UPSERT_SQL = `
|
|
49
|
+
INSERT INTO _turbine_metrics (bucket, model, action, count, avg_ms, p50_ms, p95_ms, p99_ms, error_count)
|
|
50
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
51
|
+
ON CONFLICT (bucket, model, action) DO UPDATE SET
|
|
52
|
+
count = _turbine_metrics.count + EXCLUDED.count,
|
|
53
|
+
avg_ms = (_turbine_metrics.avg_ms * _turbine_metrics.count + EXCLUDED.avg_ms * EXCLUDED.count)
|
|
54
|
+
/ (_turbine_metrics.count + EXCLUDED.count),
|
|
55
|
+
p50_ms = EXCLUDED.p50_ms,
|
|
56
|
+
p95_ms = EXCLUDED.p95_ms,
|
|
57
|
+
p99_ms = EXCLUDED.p99_ms,
|
|
58
|
+
error_count = _turbine_metrics.error_count + EXCLUDED.error_count
|
|
59
|
+
`;
|
|
60
|
+
const RETENTION_SQL = `DELETE FROM _turbine_metrics WHERE bucket < NOW() - INTERVAL '1 day' * $1`;
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Observe engine
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
class ObserveEngine {
|
|
65
|
+
pool;
|
|
66
|
+
buffer = new Map();
|
|
67
|
+
currentBucket;
|
|
68
|
+
flushIntervalMs;
|
|
69
|
+
retentionDays;
|
|
70
|
+
timer;
|
|
71
|
+
listener;
|
|
72
|
+
stopped = false;
|
|
73
|
+
constructor(config) {
|
|
74
|
+
this.pool = new pg_1.default.Pool({ connectionString: config.connectionString, max: 1 });
|
|
75
|
+
this.flushIntervalMs = config.flushIntervalMs ?? 60_000;
|
|
76
|
+
this.retentionDays = config.retentionDays ?? 30;
|
|
77
|
+
this.currentBucket = floorToMinute(new Date());
|
|
78
|
+
this.listener = (event) => {
|
|
79
|
+
if (this.stopped)
|
|
80
|
+
return;
|
|
81
|
+
const nowBucket = floorToMinute(new Date());
|
|
82
|
+
if (nowBucket.getTime() !== this.currentBucket.getTime()) {
|
|
83
|
+
this.currentBucket = nowBucket;
|
|
84
|
+
}
|
|
85
|
+
const key = `${event.model}:${event.action}`;
|
|
86
|
+
let entry = this.buffer.get(key);
|
|
87
|
+
if (!entry) {
|
|
88
|
+
entry = { durations: [], errors: 0 };
|
|
89
|
+
this.buffer.set(key, entry);
|
|
90
|
+
}
|
|
91
|
+
entry.durations.push(event.duration);
|
|
92
|
+
if (event.error)
|
|
93
|
+
entry.errors++;
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
getListener() {
|
|
97
|
+
return this.listener;
|
|
98
|
+
}
|
|
99
|
+
async init() {
|
|
100
|
+
await this.pool.query(SCHEMA_DDL);
|
|
101
|
+
this.timer = setInterval(() => {
|
|
102
|
+
this.flush().catch(() => { });
|
|
103
|
+
}, this.flushIntervalMs);
|
|
104
|
+
// Unref so it doesn't keep the process alive
|
|
105
|
+
if (this.timer && typeof this.timer === 'object' && 'unref' in this.timer) {
|
|
106
|
+
this.timer.unref();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async flush() {
|
|
110
|
+
if (this.buffer.size === 0)
|
|
111
|
+
return;
|
|
112
|
+
const bucket = this.currentBucket;
|
|
113
|
+
const entries = new Map(this.buffer);
|
|
114
|
+
this.buffer.clear();
|
|
115
|
+
for (const [key, entry] of entries) {
|
|
116
|
+
const [model, action] = key.split(':');
|
|
117
|
+
const sorted = entry.durations.slice().sort((a, b) => a - b);
|
|
118
|
+
const count = sorted.length;
|
|
119
|
+
const avg = sorted.reduce((s, v) => s + v, 0) / count;
|
|
120
|
+
const p50 = percentile(sorted, 0.5);
|
|
121
|
+
const p95 = percentile(sorted, 0.95);
|
|
122
|
+
const p99 = percentile(sorted, 0.99);
|
|
123
|
+
try {
|
|
124
|
+
await this.pool.query(UPSERT_SQL, [bucket, model, action, count, avg, p50, p95, p99, entry.errors]);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// Fire-and-forget — never throw from flush
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
await this.pool.query(RETENTION_SQL, [this.retentionDays]);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// Best effort
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
async stop() {
|
|
138
|
+
this.stopped = true;
|
|
139
|
+
if (this.timer)
|
|
140
|
+
clearInterval(this.timer);
|
|
141
|
+
await this.flush();
|
|
142
|
+
await this.pool.end();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
exports.ObserveEngine = ObserveEngine;
|
|
@@ -191,6 +191,8 @@ class QueryInterface {
|
|
|
191
191
|
txScoped;
|
|
192
192
|
/** Original options reference — forwarded to child QIs in nested writes. */
|
|
193
193
|
options;
|
|
194
|
+
/** Set by executeWithMiddleware so queryWithTimeout can include it in events. */
|
|
195
|
+
currentAction = 'raw';
|
|
194
196
|
constructor(pool, table, schema, middlewares, options) {
|
|
195
197
|
this.pool = pool;
|
|
196
198
|
this.table = table;
|
|
@@ -272,12 +274,25 @@ class QueryInterface {
|
|
|
272
274
|
resetUnlimitedWarnings() {
|
|
273
275
|
this.warnedTables.clear();
|
|
274
276
|
}
|
|
277
|
+
emitQueryEvent(sql, params, duration, action, rows, error) {
|
|
278
|
+
const onQuery = this.options?._onQuery;
|
|
279
|
+
if (!onQuery)
|
|
280
|
+
return;
|
|
281
|
+
try {
|
|
282
|
+
onQuery({ sql, params, duration, model: this.table, action, rows, timestamp: new Date(), error });
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
// Listener errors must never crash a query
|
|
286
|
+
}
|
|
287
|
+
}
|
|
275
288
|
/**
|
|
276
289
|
* Execute a pool.query with an optional timeout.
|
|
277
290
|
* If timeout is set, races the query against a timer and rejects on expiry.
|
|
278
291
|
* pg driver errors are translated to typed Turbine errors via wrapPgError.
|
|
279
292
|
*/
|
|
280
293
|
async queryWithTimeout(sql, params, timeout, preparedName) {
|
|
294
|
+
const start = performance.now();
|
|
295
|
+
const action = this.currentAction;
|
|
281
296
|
// Build the query argument — use object form with `name` for prepared
|
|
282
297
|
// statements, or the plain (text, values) form otherwise.
|
|
283
298
|
const usePrepared = preparedName && this.preparedStatementsEnabled;
|
|
@@ -286,10 +301,14 @@ class QueryInterface {
|
|
|
286
301
|
: this.pool.query(sql, params);
|
|
287
302
|
if (!timeout) {
|
|
288
303
|
try {
|
|
289
|
-
|
|
304
|
+
const result = await exec;
|
|
305
|
+
this.emitQueryEvent(sql, params, performance.now() - start, action, result.rowCount ?? 0);
|
|
306
|
+
return result;
|
|
290
307
|
}
|
|
291
308
|
catch (err) {
|
|
292
|
-
|
|
309
|
+
const wrapped = (0, errors_js_1.wrapPgError)(err);
|
|
310
|
+
this.emitQueryEvent(sql, params, performance.now() - start, action, 0, wrapped instanceof Error ? wrapped : undefined);
|
|
311
|
+
throw wrapped;
|
|
293
312
|
}
|
|
294
313
|
}
|
|
295
314
|
let timer;
|
|
@@ -297,10 +316,14 @@ class QueryInterface {
|
|
|
297
316
|
timer = setTimeout(() => reject(new errors_js_1.TimeoutError(timeout)), timeout);
|
|
298
317
|
});
|
|
299
318
|
try {
|
|
300
|
-
|
|
319
|
+
const result = await Promise.race([exec, timeoutPromise]);
|
|
320
|
+
this.emitQueryEvent(sql, params, performance.now() - start, action, result.rowCount ?? 0);
|
|
321
|
+
return result;
|
|
301
322
|
}
|
|
302
323
|
catch (err) {
|
|
303
|
-
|
|
324
|
+
const wrapped = (0, errors_js_1.wrapPgError)(err);
|
|
325
|
+
this.emitQueryEvent(sql, params, performance.now() - start, action, 0, wrapped instanceof Error ? wrapped : undefined);
|
|
326
|
+
throw wrapped;
|
|
304
327
|
}
|
|
305
328
|
finally {
|
|
306
329
|
clearTimeout(timer);
|
|
@@ -316,6 +339,7 @@ class QueryInterface {
|
|
|
316
339
|
* To intercept queries before SQL generation, use the raw() method instead.
|
|
317
340
|
*/
|
|
318
341
|
async executeWithMiddleware(action, args, executor) {
|
|
342
|
+
this.currentAction = action;
|
|
319
343
|
if (this.middlewares.length === 0) {
|
|
320
344
|
return executor();
|
|
321
345
|
}
|
|
@@ -335,7 +359,6 @@ class QueryInterface {
|
|
|
335
359
|
// -------------------------------------------------------------------------
|
|
336
360
|
// findUnique
|
|
337
361
|
// -------------------------------------------------------------------------
|
|
338
|
-
// biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
|
|
339
362
|
async findUnique(args) {
|
|
340
363
|
return this.executeWithMiddleware('findUnique', args, async () => {
|
|
341
364
|
const deferred = this.buildFindUnique(args);
|
|
@@ -439,7 +462,6 @@ class QueryInterface {
|
|
|
439
462
|
// -------------------------------------------------------------------------
|
|
440
463
|
// findMany
|
|
441
464
|
// -------------------------------------------------------------------------
|
|
442
|
-
// biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
|
|
443
465
|
async findMany(args) {
|
|
444
466
|
this.maybeWarnUnlimited(args);
|
|
445
467
|
// Dev-only: warn on deeply nested with clauses
|
|
@@ -641,7 +663,6 @@ class QueryInterface {
|
|
|
641
663
|
* }
|
|
642
664
|
* ```
|
|
643
665
|
*/
|
|
644
|
-
// biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
|
|
645
666
|
async *findManyStream(args) {
|
|
646
667
|
const batchSize = Math.max(1, Math.floor(Number(args?.batchSize ?? 1000)));
|
|
647
668
|
const hasRelations = !!args?.with;
|
|
@@ -650,6 +671,7 @@ class QueryInterface {
|
|
|
650
671
|
...args,
|
|
651
672
|
limit: batchSize + 1,
|
|
652
673
|
});
|
|
674
|
+
this.currentAction = 'findManyStream';
|
|
653
675
|
const speculativeResult = await this.queryWithTimeout(speculativeDeferred.sql, speculativeDeferred.params, args?.timeout);
|
|
654
676
|
if (speculativeResult.rows.length <= batchSize) {
|
|
655
677
|
// Small drain — yield all rows and return, no cursor needed
|
|
@@ -698,7 +720,6 @@ class QueryInterface {
|
|
|
698
720
|
// -------------------------------------------------------------------------
|
|
699
721
|
// findFirst — like findMany but returns a single row or null
|
|
700
722
|
// -------------------------------------------------------------------------
|
|
701
|
-
// biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
|
|
702
723
|
async findFirst(args) {
|
|
703
724
|
return this.executeWithMiddleware('findFirst', (args ?? {}), async () => {
|
|
704
725
|
const deferred = this.buildFindFirst(args);
|
|
@@ -724,7 +745,6 @@ class QueryInterface {
|
|
|
724
745
|
// -------------------------------------------------------------------------
|
|
725
746
|
// findFirstOrThrow — like findFirst but throws if no record found
|
|
726
747
|
// -------------------------------------------------------------------------
|
|
727
|
-
// biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
|
|
728
748
|
async findFirstOrThrow(args) {
|
|
729
749
|
return this.executeWithMiddleware('findFirstOrThrow', (args ?? {}), async () => {
|
|
730
750
|
const deferred = this.buildFindFirstOrThrow(args);
|
|
@@ -755,7 +775,6 @@ class QueryInterface {
|
|
|
755
775
|
// -------------------------------------------------------------------------
|
|
756
776
|
// findUniqueOrThrow — like findUnique but throws if no record found
|
|
757
777
|
// -------------------------------------------------------------------------
|
|
758
|
-
// biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
|
|
759
778
|
async findUniqueOrThrow(args) {
|
|
760
779
|
return this.executeWithMiddleware('findUniqueOrThrow', args, async () => {
|
|
761
780
|
const deferred = this.buildFindUniqueOrThrow(args);
|
|
@@ -1669,7 +1688,11 @@ class QueryInterface {
|
|
|
1669
1688
|
const relDef = this.tableMeta.relations[key];
|
|
1670
1689
|
if (relDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
1671
1690
|
const filterObj = value;
|
|
1672
|
-
if ('some' in filterObj ||
|
|
1691
|
+
if ('some' in filterObj ||
|
|
1692
|
+
'every' in filterObj ||
|
|
1693
|
+
'none' in filterObj ||
|
|
1694
|
+
'is' in filterObj ||
|
|
1695
|
+
'isNot' in filterObj) {
|
|
1673
1696
|
const relParts = [];
|
|
1674
1697
|
if (filterObj.some !== undefined)
|
|
1675
1698
|
relParts.push(`some(${this.fingerprintRelFilter(relDef.to, filterObj.some)})`);
|
|
@@ -1677,6 +1700,10 @@ class QueryInterface {
|
|
|
1677
1700
|
relParts.push(`every(${this.fingerprintRelFilter(relDef.to, filterObj.every)})`);
|
|
1678
1701
|
if (filterObj.none !== undefined)
|
|
1679
1702
|
relParts.push(`none(${this.fingerprintRelFilter(relDef.to, filterObj.none)})`);
|
|
1703
|
+
if (filterObj.is !== undefined)
|
|
1704
|
+
relParts.push(`is(${this.fingerprintRelFilter(relDef.to, filterObj.is)})`);
|
|
1705
|
+
if (filterObj.isNot !== undefined)
|
|
1706
|
+
relParts.push(`isNot(${this.fingerprintRelFilter(relDef.to, filterObj.isNot)})`);
|
|
1680
1707
|
parts.push(`${key}:{${relParts.join(',')}}`);
|
|
1681
1708
|
continue;
|
|
1682
1709
|
}
|
|
@@ -1798,13 +1825,21 @@ class QueryInterface {
|
|
|
1798
1825
|
const relationDef = this.tableMeta.relations[key];
|
|
1799
1826
|
if (relationDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
1800
1827
|
const filterObj = value;
|
|
1801
|
-
if ('some' in filterObj ||
|
|
1828
|
+
if ('some' in filterObj ||
|
|
1829
|
+
'every' in filterObj ||
|
|
1830
|
+
'none' in filterObj ||
|
|
1831
|
+
'is' in filterObj ||
|
|
1832
|
+
'isNot' in filterObj) {
|
|
1802
1833
|
if (filterObj.some !== undefined)
|
|
1803
1834
|
this.collectRelFilterParams(relationDef.to, filterObj.some, params);
|
|
1804
1835
|
if (filterObj.none !== undefined)
|
|
1805
1836
|
this.collectRelFilterParams(relationDef.to, filterObj.none, params);
|
|
1806
1837
|
if (filterObj.every !== undefined)
|
|
1807
1838
|
this.collectRelFilterParams(relationDef.to, filterObj.every, params);
|
|
1839
|
+
if (filterObj.is !== undefined)
|
|
1840
|
+
this.collectRelFilterParams(relationDef.to, filterObj.is, params);
|
|
1841
|
+
if (filterObj.isNot !== undefined)
|
|
1842
|
+
this.collectRelFilterParams(relationDef.to, filterObj.isNot, params);
|
|
1808
1843
|
continue;
|
|
1809
1844
|
}
|
|
1810
1845
|
}
|
|
@@ -2157,7 +2192,11 @@ class QueryInterface {
|
|
|
2157
2192
|
if (relationDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
2158
2193
|
const filterObj = value;
|
|
2159
2194
|
// Check if this is a relation filter (has some/every/none keys)
|
|
2160
|
-
if ('some' in filterObj ||
|
|
2195
|
+
if ('some' in filterObj ||
|
|
2196
|
+
'every' in filterObj ||
|
|
2197
|
+
'none' in filterObj ||
|
|
2198
|
+
'is' in filterObj ||
|
|
2199
|
+
'isNot' in filterObj) {
|
|
2161
2200
|
const relClause = this.buildRelationFilter(key, relationDef, filterObj, params);
|
|
2162
2201
|
if (relClause)
|
|
2163
2202
|
andClauses.push(relClause);
|
|
@@ -2274,6 +2313,20 @@ class QueryInterface {
|
|
|
2274
2313
|
// "every" with empty filter = true (all match trivially)
|
|
2275
2314
|
}
|
|
2276
2315
|
}
|
|
2316
|
+
// "is": EXISTS — for to-one relations (same SQL as "some")
|
|
2317
|
+
if (filterObj.is !== undefined) {
|
|
2318
|
+
const subWhere = filterObj.is;
|
|
2319
|
+
const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
|
|
2320
|
+
const fullWhere = filterClause ? `${correlation} AND ${filterClause}` : correlation;
|
|
2321
|
+
clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${fullWhere})`);
|
|
2322
|
+
}
|
|
2323
|
+
// "isNot": NOT EXISTS — for to-one relations (same SQL as "none")
|
|
2324
|
+
if (filterObj.isNot !== undefined) {
|
|
2325
|
+
const subWhere = filterObj.isNot;
|
|
2326
|
+
const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
|
|
2327
|
+
const fullWhere = filterClause ? `${correlation} AND ${filterClause}` : correlation;
|
|
2328
|
+
clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${fullWhere})`);
|
|
2329
|
+
}
|
|
2277
2330
|
return clauses.length > 0 ? clauses.join(' AND ') : null;
|
|
2278
2331
|
}
|
|
2279
2332
|
/**
|
package/dist/cli/index.d.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* turbine seed — Run seed file
|
|
14
14
|
* turbine status — Show schema summary
|
|
15
15
|
* turbine studio — Launch local read-only web UI
|
|
16
|
+
* turbine observe — Launch metrics dashboard (requires TURBINE_OBSERVE_URL)
|
|
16
17
|
*
|
|
17
18
|
* Usage:
|
|
18
19
|
* DATABASE_URL=postgres://... npx turbine generate
|
package/dist/cli/index.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* turbine seed — Run seed file
|
|
14
14
|
* turbine status — Show schema summary
|
|
15
15
|
* turbine studio — Launch local read-only web UI
|
|
16
|
+
* turbine observe — Launch metrics dashboard (requires TURBINE_OBSERVE_URL)
|
|
16
17
|
*
|
|
17
18
|
* Usage:
|
|
18
19
|
* DATABASE_URL=postgres://... npx turbine generate
|
|
@@ -28,6 +29,7 @@ import { schemaDiff, schemaPush } from '../schema-sql.js';
|
|
|
28
29
|
import { configTemplate, findConfigFile, loadConfig, resolveConfig } from './config.js';
|
|
29
30
|
import { needsTsLoader, registerTsLoader } from './loader.js';
|
|
30
31
|
import { createMigration, listMigrationFiles, migrateDown, migrateStatus, migrateUp } from './migrate.js';
|
|
32
|
+
import { startObserve } from './observe.js';
|
|
31
33
|
import { startStudio } from './studio.js';
|
|
32
34
|
import { banner, blue, bold, box, cyan, dim, divider, elapsed, error, table as formatTable, gray, green, header, info, label, magenta, newline, red, redactUrl, Spinner, success, symbols, warn, yellow, } from './ui.js';
|
|
33
35
|
function parseArgs() {
|
|
@@ -970,6 +972,65 @@ async function cmdStudio(args, config) {
|
|
|
970
972
|
});
|
|
971
973
|
}
|
|
972
974
|
// ---------------------------------------------------------------------------
|
|
975
|
+
// Command: observe
|
|
976
|
+
// ---------------------------------------------------------------------------
|
|
977
|
+
async function cmdObserve(args) {
|
|
978
|
+
banner();
|
|
979
|
+
const url = process.env.TURBINE_OBSERVE_URL;
|
|
980
|
+
if (!url) {
|
|
981
|
+
error('TURBINE_OBSERVE_URL environment variable is required for the observe command.');
|
|
982
|
+
newline();
|
|
983
|
+
console.log(` ${dim('Set it to the Postgres connection string where metrics are stored.')}`);
|
|
984
|
+
console.log(` ${dim('Example:')} ${cyan('TURBINE_OBSERVE_URL=postgres://... npx turbine observe')}`);
|
|
985
|
+
newline();
|
|
986
|
+
process.exit(1);
|
|
987
|
+
}
|
|
988
|
+
const port = args.port ?? 4984;
|
|
989
|
+
const host = args.host ?? '127.0.0.1';
|
|
990
|
+
const openBrowser = !args.noOpen;
|
|
991
|
+
if (!Number.isFinite(port) || port <= 0 || port > 65535) {
|
|
992
|
+
console.log(red(`✗ invalid port: ${args.port}`));
|
|
993
|
+
process.exit(1);
|
|
994
|
+
}
|
|
995
|
+
if (host !== '127.0.0.1' && host !== 'localhost' && host !== '::1') {
|
|
996
|
+
console.log(warn(`Observe is binding to ${yellow(host)} — this is NOT loopback. ` +
|
|
997
|
+
`Anyone on your network who can reach this port + guess the session token can read your metrics.`));
|
|
998
|
+
}
|
|
999
|
+
const spinner = new Spinner('Connecting to metrics database').start();
|
|
1000
|
+
let handle;
|
|
1001
|
+
try {
|
|
1002
|
+
handle = await startObserve({ url, port, host, openBrowser });
|
|
1003
|
+
spinner.succeed('Observe dashboard is running');
|
|
1004
|
+
}
|
|
1005
|
+
catch (err) {
|
|
1006
|
+
spinner.fail(`Failed to start Observe: ${err instanceof Error ? err.message : String(err)}`);
|
|
1007
|
+
process.exit(1);
|
|
1008
|
+
}
|
|
1009
|
+
newline();
|
|
1010
|
+
console.log(box([
|
|
1011
|
+
`${bold('Turbine Observe')} ${dim('— query metrics dashboard')}`,
|
|
1012
|
+
'',
|
|
1013
|
+
` ${cyan('URL:')} ${bold(handle.url)}`,
|
|
1014
|
+
'',
|
|
1015
|
+
dim('Open the URL above in your browser. Press Ctrl+C to stop.'),
|
|
1016
|
+
].join('\n'), { title: bold(cyan('Observe')), padding: 1 }));
|
|
1017
|
+
newline();
|
|
1018
|
+
await new Promise((resolve) => {
|
|
1019
|
+
const shutdown = async () => {
|
|
1020
|
+
console.log(dim('\n shutting down…'));
|
|
1021
|
+
try {
|
|
1022
|
+
await handle.dispose();
|
|
1023
|
+
}
|
|
1024
|
+
catch {
|
|
1025
|
+
/* ignore */
|
|
1026
|
+
}
|
|
1027
|
+
resolve();
|
|
1028
|
+
};
|
|
1029
|
+
process.once('SIGINT', shutdown);
|
|
1030
|
+
process.once('SIGTERM', shutdown);
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
// ---------------------------------------------------------------------------
|
|
973
1034
|
// Subcommand help
|
|
974
1035
|
// ---------------------------------------------------------------------------
|
|
975
1036
|
function showSubcommandHelp(command) {
|
|
@@ -1253,6 +1314,9 @@ async function main() {
|
|
|
1253
1314
|
case 'studio':
|
|
1254
1315
|
await cmdStudio(args, config);
|
|
1255
1316
|
break;
|
|
1317
|
+
case 'observe':
|
|
1318
|
+
await cmdObserve(args);
|
|
1319
|
+
break;
|
|
1256
1320
|
default:
|
|
1257
1321
|
error(`Unknown command: ${bold(args.command)}`);
|
|
1258
1322
|
newline();
|
|
@@ -0,0 +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>";
|
|
2
|
+
//# sourceMappingURL=observe-ui.d.ts.map
|