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.
@@ -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 delete on related records at
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
- return await exec;
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
- throw (0, errors_js_1.wrapPgError)(err);
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
- return await Promise.race([exec, timeoutPromise]);
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
- throw (0, errors_js_1.wrapPgError)(err);
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 || 'every' in filterObj || 'none' 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 || 'every' in filterObj || 'none' 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 || 'every' in filterObj || 'none' 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
  /**
@@ -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