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.
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Tree-walking create/update that resolves relation fields in `data` into
5
5
  * batched SQL operations within a transaction. Supports create, connect,
6
- * connectOrCreate, disconnect, set, and delete on related records at
7
- * arbitrary depth (capped at 10).
6
+ * connectOrCreate, disconnect, set, delete, update, and upsert on related
7
+ * records at arbitrary depth (capped at 10).
8
8
  *
9
9
  * This module is imported by `query/builder.ts` when the `data` argument
10
10
  * of `create()` or `update()` contains relation fields. It never imports
@@ -15,7 +15,7 @@ import { CircularRelationError, RelationError, ValidationError } from './errors.
15
15
  import { normalizeKeyColumns } from './schema.js';
16
16
  const MAX_DEPTH = 10;
17
17
  const CREATE_ONLY_OPS = new Set(['create', 'connect', 'connectOrCreate']);
18
- const UPDATE_ONLY_OPS = new Set(['disconnect', 'set', 'delete']);
18
+ const UPDATE_ONLY_OPS = new Set(['disconnect', 'set', 'delete', 'update', 'upsert']);
19
19
  // ---------------------------------------------------------------------------
20
20
  // Pure helpers (exported for testing)
21
21
  // ---------------------------------------------------------------------------
@@ -93,7 +93,7 @@ function validateOps(relationName, ops, isUpdate) {
93
93
  for (const opName of Object.keys(ops)) {
94
94
  if (!CREATE_ONLY_OPS.has(opName) && !UPDATE_ONLY_OPS.has(opName)) {
95
95
  throw new ValidationError(`[turbine] Unknown nested write operation "${opName}" on relation "${relationName}". ` +
96
- `Valid operations: create, connect, connectOrCreate${isUpdate ? ', disconnect, set, delete' : ''}.`);
96
+ `Valid operations: create, connect, connectOrCreate${isUpdate ? ', disconnect, set, delete, update, upsert' : ''}.`);
97
97
  }
98
98
  if (!isUpdate && UPDATE_ONLY_OPS.has(opName)) {
99
99
  throw new ValidationError(`[turbine] Operation "${opName}" on relation "${relationName}" is only valid inside update(), not create().`);
@@ -216,9 +216,25 @@ export async function executeNestedUpdate(ctx, tableName, where, data, depth = 0
216
216
  if (ops.delete !== undefined) {
217
217
  await processDelete(ctx, rel, ops.delete);
218
218
  }
219
+ // update
220
+ if (ops.update !== undefined) {
221
+ await processNestedUpdate(ctx, rel, ops.update);
222
+ }
223
+ // upsert
224
+ if (ops.upsert !== undefined) {
225
+ await processNestedUpsert(ctx, rel, ops.upsert, parentRow);
226
+ }
219
227
  }
220
228
  else if (rel.type === 'belongsTo') {
221
229
  await processBelongsToCreate(ctx, rel, ops, parentRow, tableName, depth, path, relName);
230
+ // update (belongsTo — derive where from parent FK)
231
+ if (ops.update !== undefined) {
232
+ await processBelongsToUpdate(ctx, rel, ops.update, parentRow, tableName);
233
+ }
234
+ // upsert (belongsTo)
235
+ if (ops.upsert !== undefined) {
236
+ await processBelongsToUpsert(ctx, rel, ops.upsert, parentRow, tableName);
237
+ }
222
238
  if (ops.disconnect !== undefined) {
223
239
  // For belongsTo disconnect, null out the FK on the parent
224
240
  const fks = normalizeKeyColumns(rel.foreignKey);
@@ -452,6 +468,80 @@ async function processSet(ctx, rel, setItems, parentRow) {
452
468
  await ctx.tx.table(rel.to).update({ where: target, data: updateData });
453
469
  }
454
470
  }
471
+ // ---------------------------------------------------------------------------
472
+ // update / upsert operations (update-context only)
473
+ // ---------------------------------------------------------------------------
474
+ async function processNestedUpdate(ctx, rel, updateArg) {
475
+ const items = toArray(updateArg);
476
+ for (const item of items) {
477
+ if (!item.where || !item.data) {
478
+ throw new ValidationError(`[turbine] Nested update on "${rel.name}" requires both "where" and "data" fields.`);
479
+ }
480
+ await ctx.tx.table(rel.to).update({ where: item.where, data: item.data });
481
+ }
482
+ }
483
+ async function processNestedUpsert(ctx, rel, upsertArg, parentRow) {
484
+ const items = toArray(upsertArg);
485
+ for (const item of items) {
486
+ if (!item.where || !item.create || !item.update) {
487
+ throw new ValidationError(`[turbine] Nested upsert on "${rel.name}" requires "where", "create", and "update" fields.`);
488
+ }
489
+ const existing = await ctx.tx.table(rel.to).findUnique({ where: item.where });
490
+ if (existing) {
491
+ await ctx.tx.table(rel.to).update({ where: item.where, data: item.update });
492
+ }
493
+ else {
494
+ const injected = injectForeignKey(item.create, rel, parentRow, ctx.schema);
495
+ await ctx.tx.table(rel.to).create({ data: injected });
496
+ }
497
+ }
498
+ }
499
+ async function processBelongsToUpdate(ctx, rel, updateArg, parentRow, parentTable) {
500
+ const item = updateArg;
501
+ if (!item.data) {
502
+ throw new ValidationError(`[turbine] Nested update on belongsTo "${rel.name}" requires a "data" field.`);
503
+ }
504
+ // Derive where from parent's FK values
505
+ const fks = normalizeKeyColumns(rel.foreignKey);
506
+ const refs = normalizeKeyColumns(rel.referenceKey);
507
+ const parentMeta = ctx.schema.tables[parentTable];
508
+ const relatedTable = ctx.schema.tables[rel.to];
509
+ const where = {};
510
+ for (let i = 0; i < fks.length; i++) {
511
+ const fkField = parentMeta?.reverseColumnMap[fks[i]] ?? fks[i];
512
+ const refField = relatedTable?.reverseColumnMap[refs[i]] ?? refs[i];
513
+ where[refField] = parentRow[fkField];
514
+ }
515
+ await ctx.tx.table(rel.to).update({ where, data: item.data });
516
+ }
517
+ async function processBelongsToUpsert(ctx, rel, upsertArg, parentRow, parentTable) {
518
+ const item = upsertArg;
519
+ if (!item.where || !item.create || !item.update) {
520
+ throw new ValidationError(`[turbine] Nested upsert on belongsTo "${rel.name}" requires "where", "create", and "update" fields.`);
521
+ }
522
+ const existing = await ctx.tx.table(rel.to).findUnique({ where: item.where });
523
+ if (existing) {
524
+ await ctx.tx.table(rel.to).update({ where: item.where, data: item.update });
525
+ }
526
+ else {
527
+ // Create the related row, then update parent's FK to point at it
528
+ const createdRow = (await ctx.tx.table(rel.to).create({ data: item.create }));
529
+ const fks = normalizeKeyColumns(rel.foreignKey);
530
+ const refs = normalizeKeyColumns(rel.referenceKey);
531
+ const parentMeta = ctx.schema.tables[parentTable];
532
+ const relatedTable = ctx.schema.tables[rel.to];
533
+ const updateData = {};
534
+ for (let i = 0; i < fks.length; i++) {
535
+ const fkField = parentMeta.reverseColumnMap[fks[i]] ?? fks[i];
536
+ const refField = relatedTable?.reverseColumnMap[refs[i]] ?? refs[i];
537
+ updateData[fkField] = createdRow[refField];
538
+ }
539
+ await ctx.tx.table(parentTable).update({
540
+ where: pkWhere(parentMeta, parentRow),
541
+ data: updateData,
542
+ });
543
+ }
544
+ }
455
545
  async function processDelete(ctx, rel, deleteArg) {
456
546
  const items = toArray(deleteArg);
457
547
  for (const target of items) {
@@ -0,0 +1,36 @@
1
+ /**
2
+ * turbine-orm — Observability module
3
+ *
4
+ * Buffers query metrics in memory (keyed by model:action per minute bucket),
5
+ * then periodically flushes aggregates (count, avg, p50, p95, p99, errors)
6
+ * to a dedicated _turbine_metrics table. Uses a separate 1-connection pool
7
+ * so metrics writes never contend with the application pool.
8
+ */
9
+ import type { QueryEventListener } from './query/index.js';
10
+ export interface ObserveConfig {
11
+ connectionString: string;
12
+ flushIntervalMs?: number;
13
+ retentionDays?: number;
14
+ }
15
+ export interface ObserveHandle {
16
+ stop(): Promise<void>;
17
+ }
18
+ declare function floorToMinute(date: Date): Date;
19
+ declare function percentile(sorted: number[], p: number): number;
20
+ export declare class ObserveEngine {
21
+ private readonly pool;
22
+ private readonly buffer;
23
+ private currentBucket;
24
+ private readonly flushIntervalMs;
25
+ private readonly retentionDays;
26
+ private timer;
27
+ private readonly listener;
28
+ private stopped;
29
+ constructor(config: ObserveConfig);
30
+ getListener(): QueryEventListener;
31
+ init(): Promise<void>;
32
+ flush(): Promise<void>;
33
+ stop(): Promise<void>;
34
+ }
35
+ export { floorToMinute, percentile };
36
+ //# sourceMappingURL=observe.d.ts.map
@@ -0,0 +1,141 @@
1
+ /**
2
+ * turbine-orm — Observability module
3
+ *
4
+ * Buffers query metrics in memory (keyed by model:action per minute bucket),
5
+ * then periodically flushes aggregates (count, avg, p50, p95, p99, errors)
6
+ * to a dedicated _turbine_metrics table. Uses a separate 1-connection pool
7
+ * so metrics writes never contend with the application pool.
8
+ */
9
+ import pg from 'pg';
10
+ function floorToMinute(date) {
11
+ const d = new Date(date);
12
+ d.setSeconds(0, 0);
13
+ return d;
14
+ }
15
+ function percentile(sorted, p) {
16
+ if (sorted.length === 0)
17
+ return 0;
18
+ const idx = Math.ceil(p * sorted.length) - 1;
19
+ return sorted[Math.max(0, idx)];
20
+ }
21
+ // ---------------------------------------------------------------------------
22
+ // Schema DDL
23
+ // ---------------------------------------------------------------------------
24
+ const SCHEMA_DDL = `
25
+ CREATE TABLE IF NOT EXISTS _turbine_metrics (
26
+ id BIGSERIAL PRIMARY KEY,
27
+ bucket TIMESTAMPTZ NOT NULL,
28
+ model TEXT NOT NULL,
29
+ action TEXT NOT NULL,
30
+ count INTEGER NOT NULL DEFAULT 0,
31
+ avg_ms REAL NOT NULL DEFAULT 0,
32
+ p50_ms REAL NOT NULL DEFAULT 0,
33
+ p95_ms REAL NOT NULL DEFAULT 0,
34
+ p99_ms REAL NOT NULL DEFAULT 0,
35
+ error_count INTEGER NOT NULL DEFAULT 0,
36
+ UNIQUE(bucket, model, action)
37
+ );
38
+ CREATE INDEX IF NOT EXISTS idx_turbine_metrics_bucket ON _turbine_metrics(bucket);
39
+ `;
40
+ const UPSERT_SQL = `
41
+ INSERT INTO _turbine_metrics (bucket, model, action, count, avg_ms, p50_ms, p95_ms, p99_ms, error_count)
42
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
43
+ ON CONFLICT (bucket, model, action) DO UPDATE SET
44
+ count = _turbine_metrics.count + EXCLUDED.count,
45
+ avg_ms = (_turbine_metrics.avg_ms * _turbine_metrics.count + EXCLUDED.avg_ms * EXCLUDED.count)
46
+ / (_turbine_metrics.count + EXCLUDED.count),
47
+ p50_ms = EXCLUDED.p50_ms,
48
+ p95_ms = EXCLUDED.p95_ms,
49
+ p99_ms = EXCLUDED.p99_ms,
50
+ error_count = _turbine_metrics.error_count + EXCLUDED.error_count
51
+ `;
52
+ const RETENTION_SQL = `DELETE FROM _turbine_metrics WHERE bucket < NOW() - INTERVAL '1 day' * $1`;
53
+ // ---------------------------------------------------------------------------
54
+ // Observe engine
55
+ // ---------------------------------------------------------------------------
56
+ export class ObserveEngine {
57
+ pool;
58
+ buffer = new Map();
59
+ currentBucket;
60
+ flushIntervalMs;
61
+ retentionDays;
62
+ timer;
63
+ listener;
64
+ stopped = false;
65
+ constructor(config) {
66
+ this.pool = new pg.Pool({ connectionString: config.connectionString, max: 1 });
67
+ this.flushIntervalMs = config.flushIntervalMs ?? 60_000;
68
+ this.retentionDays = config.retentionDays ?? 30;
69
+ this.currentBucket = floorToMinute(new Date());
70
+ this.listener = (event) => {
71
+ if (this.stopped)
72
+ return;
73
+ const nowBucket = floorToMinute(new Date());
74
+ if (nowBucket.getTime() !== this.currentBucket.getTime()) {
75
+ this.currentBucket = nowBucket;
76
+ }
77
+ const key = `${event.model}:${event.action}`;
78
+ let entry = this.buffer.get(key);
79
+ if (!entry) {
80
+ entry = { durations: [], errors: 0 };
81
+ this.buffer.set(key, entry);
82
+ }
83
+ entry.durations.push(event.duration);
84
+ if (event.error)
85
+ entry.errors++;
86
+ };
87
+ }
88
+ getListener() {
89
+ return this.listener;
90
+ }
91
+ async init() {
92
+ await this.pool.query(SCHEMA_DDL);
93
+ this.timer = setInterval(() => {
94
+ this.flush().catch(() => { });
95
+ }, this.flushIntervalMs);
96
+ // Unref so it doesn't keep the process alive
97
+ if (this.timer && typeof this.timer === 'object' && 'unref' in this.timer) {
98
+ this.timer.unref();
99
+ }
100
+ }
101
+ async flush() {
102
+ if (this.buffer.size === 0)
103
+ return;
104
+ const bucket = this.currentBucket;
105
+ const entries = new Map(this.buffer);
106
+ this.buffer.clear();
107
+ for (const [key, entry] of entries) {
108
+ const [model, action] = key.split(':');
109
+ const sorted = entry.durations.slice().sort((a, b) => a - b);
110
+ const count = sorted.length;
111
+ const avg = sorted.reduce((s, v) => s + v, 0) / count;
112
+ const p50 = percentile(sorted, 0.5);
113
+ const p95 = percentile(sorted, 0.95);
114
+ const p99 = percentile(sorted, 0.99);
115
+ try {
116
+ await this.pool.query(UPSERT_SQL, [bucket, model, action, count, avg, p50, p95, p99, entry.errors]);
117
+ }
118
+ catch {
119
+ // Fire-and-forget — never throw from flush
120
+ }
121
+ }
122
+ try {
123
+ await this.pool.query(RETENTION_SQL, [this.retentionDays]);
124
+ }
125
+ catch {
126
+ // Best effort
127
+ }
128
+ }
129
+ async stop() {
130
+ this.stopped = true;
131
+ if (this.timer)
132
+ clearInterval(this.timer);
133
+ await this.flush();
134
+ await this.pool.end();
135
+ }
136
+ }
137
+ // ---------------------------------------------------------------------------
138
+ // Exported helpers for testing
139
+ // ---------------------------------------------------------------------------
140
+ export { floorToMinute, percentile };
141
+ //# sourceMappingURL=observe.js.map
@@ -36,6 +36,18 @@ export type MiddlewareFn = (params: {
36
36
  action: string;
37
37
  args: Record<string, unknown>;
38
38
  }) => Promise<unknown>) => Promise<unknown>;
39
+ /** Emitted after every query execution (success or failure). */
40
+ export interface QueryEvent {
41
+ sql: string;
42
+ params: unknown[];
43
+ duration: number;
44
+ model: string;
45
+ action: string;
46
+ rows: number;
47
+ timestamp: Date;
48
+ error?: Error;
49
+ }
50
+ export type QueryEventListener = (event: QueryEvent) => void;
39
51
  /** Options passed from TurbineClient to QueryInterface */
40
52
  export interface QueryInterfaceOptions {
41
53
  /** Default LIMIT applied to findMany() when no limit is specified */
@@ -69,6 +81,8 @@ export interface QueryInterfaceOptions {
69
81
  dialect?: Dialect;
70
82
  /** @internal Set by TransactionClient — signals that this QI runs inside an active transaction. */
71
83
  _txScoped?: boolean;
84
+ /** @internal Callback from TurbineClient for query event emission. */
85
+ _onQuery?: (event: QueryEvent) => void;
72
86
  }
73
87
  export declare class QueryInterface<T extends object, R extends object = {}> {
74
88
  private readonly pool;
@@ -104,6 +118,8 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
104
118
  private readonly txScoped;
105
119
  /** Original options reference — forwarded to child QIs in nested writes. */
106
120
  private readonly options?;
121
+ /** Set by executeWithMiddleware so queryWithTimeout can include it in events. */
122
+ private currentAction;
107
123
  constructor(pool: pg.Pool, table: string, schema: SchemaMetadata, middlewares?: MiddlewareFn[], options?: QueryInterfaceOptions);
108
124
  /** Quote an identifier through the active SQL dialect. */
109
125
  private q;
@@ -133,6 +149,7 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
133
149
  * exactly once per table without bleeding state between assertions.
134
150
  */
135
151
  resetUnlimitedWarnings(): void;
152
+ private emitQueryEvent;
136
153
  /**
137
154
  * Execute a pool.query with an optional timeout.
138
155
  * If timeout is set, races the query against a timer and rejects on expiry.
@@ -155,6 +155,8 @@ export class QueryInterface {
155
155
  txScoped;
156
156
  /** Original options reference — forwarded to child QIs in nested writes. */
157
157
  options;
158
+ /** Set by executeWithMiddleware so queryWithTimeout can include it in events. */
159
+ currentAction = 'raw';
158
160
  constructor(pool, table, schema, middlewares, options) {
159
161
  this.pool = pool;
160
162
  this.table = table;
@@ -236,12 +238,25 @@ export class QueryInterface {
236
238
  resetUnlimitedWarnings() {
237
239
  this.warnedTables.clear();
238
240
  }
241
+ emitQueryEvent(sql, params, duration, action, rows, error) {
242
+ const onQuery = this.options?._onQuery;
243
+ if (!onQuery)
244
+ return;
245
+ try {
246
+ onQuery({ sql, params, duration, model: this.table, action, rows, timestamp: new Date(), error });
247
+ }
248
+ catch {
249
+ // Listener errors must never crash a query
250
+ }
251
+ }
239
252
  /**
240
253
  * Execute a pool.query with an optional timeout.
241
254
  * If timeout is set, races the query against a timer and rejects on expiry.
242
255
  * pg driver errors are translated to typed Turbine errors via wrapPgError.
243
256
  */
244
257
  async queryWithTimeout(sql, params, timeout, preparedName) {
258
+ const start = performance.now();
259
+ const action = this.currentAction;
245
260
  // Build the query argument — use object form with `name` for prepared
246
261
  // statements, or the plain (text, values) form otherwise.
247
262
  const usePrepared = preparedName && this.preparedStatementsEnabled;
@@ -250,10 +265,14 @@ export class QueryInterface {
250
265
  : this.pool.query(sql, params);
251
266
  if (!timeout) {
252
267
  try {
253
- return await exec;
268
+ const result = await exec;
269
+ this.emitQueryEvent(sql, params, performance.now() - start, action, result.rowCount ?? 0);
270
+ return result;
254
271
  }
255
272
  catch (err) {
256
- throw wrapPgError(err);
273
+ const wrapped = wrapPgError(err);
274
+ this.emitQueryEvent(sql, params, performance.now() - start, action, 0, wrapped instanceof Error ? wrapped : undefined);
275
+ throw wrapped;
257
276
  }
258
277
  }
259
278
  let timer;
@@ -261,10 +280,14 @@ export class QueryInterface {
261
280
  timer = setTimeout(() => reject(new TimeoutError(timeout)), timeout);
262
281
  });
263
282
  try {
264
- return await Promise.race([exec, timeoutPromise]);
283
+ const result = await Promise.race([exec, timeoutPromise]);
284
+ this.emitQueryEvent(sql, params, performance.now() - start, action, result.rowCount ?? 0);
285
+ return result;
265
286
  }
266
287
  catch (err) {
267
- throw wrapPgError(err);
288
+ const wrapped = wrapPgError(err);
289
+ this.emitQueryEvent(sql, params, performance.now() - start, action, 0, wrapped instanceof Error ? wrapped : undefined);
290
+ throw wrapped;
268
291
  }
269
292
  finally {
270
293
  clearTimeout(timer);
@@ -280,6 +303,7 @@ export class QueryInterface {
280
303
  * To intercept queries before SQL generation, use the raw() method instead.
281
304
  */
282
305
  async executeWithMiddleware(action, args, executor) {
306
+ this.currentAction = action;
283
307
  if (this.middlewares.length === 0) {
284
308
  return executor();
285
309
  }
@@ -299,7 +323,6 @@ export class QueryInterface {
299
323
  // -------------------------------------------------------------------------
300
324
  // findUnique
301
325
  // -------------------------------------------------------------------------
302
- // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
303
326
  async findUnique(args) {
304
327
  return this.executeWithMiddleware('findUnique', args, async () => {
305
328
  const deferred = this.buildFindUnique(args);
@@ -403,7 +426,6 @@ export class QueryInterface {
403
426
  // -------------------------------------------------------------------------
404
427
  // findMany
405
428
  // -------------------------------------------------------------------------
406
- // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
407
429
  async findMany(args) {
408
430
  this.maybeWarnUnlimited(args);
409
431
  // Dev-only: warn on deeply nested with clauses
@@ -605,7 +627,6 @@ export class QueryInterface {
605
627
  * }
606
628
  * ```
607
629
  */
608
- // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
609
630
  async *findManyStream(args) {
610
631
  const batchSize = Math.max(1, Math.floor(Number(args?.batchSize ?? 1000)));
611
632
  const hasRelations = !!args?.with;
@@ -614,6 +635,7 @@ export class QueryInterface {
614
635
  ...args,
615
636
  limit: batchSize + 1,
616
637
  });
638
+ this.currentAction = 'findManyStream';
617
639
  const speculativeResult = await this.queryWithTimeout(speculativeDeferred.sql, speculativeDeferred.params, args?.timeout);
618
640
  if (speculativeResult.rows.length <= batchSize) {
619
641
  // Small drain — yield all rows and return, no cursor needed
@@ -662,7 +684,6 @@ export class QueryInterface {
662
684
  // -------------------------------------------------------------------------
663
685
  // findFirst — like findMany but returns a single row or null
664
686
  // -------------------------------------------------------------------------
665
- // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
666
687
  async findFirst(args) {
667
688
  return this.executeWithMiddleware('findFirst', (args ?? {}), async () => {
668
689
  const deferred = this.buildFindFirst(args);
@@ -688,7 +709,6 @@ export class QueryInterface {
688
709
  // -------------------------------------------------------------------------
689
710
  // findFirstOrThrow — like findFirst but throws if no record found
690
711
  // -------------------------------------------------------------------------
691
- // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
692
712
  async findFirstOrThrow(args) {
693
713
  return this.executeWithMiddleware('findFirstOrThrow', (args ?? {}), async () => {
694
714
  const deferred = this.buildFindFirstOrThrow(args);
@@ -719,7 +739,6 @@ export class QueryInterface {
719
739
  // -------------------------------------------------------------------------
720
740
  // findUniqueOrThrow — like findUnique but throws if no record found
721
741
  // -------------------------------------------------------------------------
722
- // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
723
742
  async findUniqueOrThrow(args) {
724
743
  return this.executeWithMiddleware('findUniqueOrThrow', args, async () => {
725
744
  const deferred = this.buildFindUniqueOrThrow(args);
@@ -1633,7 +1652,11 @@ export class QueryInterface {
1633
1652
  const relDef = this.tableMeta.relations[key];
1634
1653
  if (relDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
1635
1654
  const filterObj = value;
1636
- if ('some' in filterObj || 'every' in filterObj || 'none' in filterObj) {
1655
+ if ('some' in filterObj ||
1656
+ 'every' in filterObj ||
1657
+ 'none' in filterObj ||
1658
+ 'is' in filterObj ||
1659
+ 'isNot' in filterObj) {
1637
1660
  const relParts = [];
1638
1661
  if (filterObj.some !== undefined)
1639
1662
  relParts.push(`some(${this.fingerprintRelFilter(relDef.to, filterObj.some)})`);
@@ -1641,6 +1664,10 @@ export class QueryInterface {
1641
1664
  relParts.push(`every(${this.fingerprintRelFilter(relDef.to, filterObj.every)})`);
1642
1665
  if (filterObj.none !== undefined)
1643
1666
  relParts.push(`none(${this.fingerprintRelFilter(relDef.to, filterObj.none)})`);
1667
+ if (filterObj.is !== undefined)
1668
+ relParts.push(`is(${this.fingerprintRelFilter(relDef.to, filterObj.is)})`);
1669
+ if (filterObj.isNot !== undefined)
1670
+ relParts.push(`isNot(${this.fingerprintRelFilter(relDef.to, filterObj.isNot)})`);
1644
1671
  parts.push(`${key}:{${relParts.join(',')}}`);
1645
1672
  continue;
1646
1673
  }
@@ -1762,13 +1789,21 @@ export class QueryInterface {
1762
1789
  const relationDef = this.tableMeta.relations[key];
1763
1790
  if (relationDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
1764
1791
  const filterObj = value;
1765
- if ('some' in filterObj || 'every' in filterObj || 'none' in filterObj) {
1792
+ if ('some' in filterObj ||
1793
+ 'every' in filterObj ||
1794
+ 'none' in filterObj ||
1795
+ 'is' in filterObj ||
1796
+ 'isNot' in filterObj) {
1766
1797
  if (filterObj.some !== undefined)
1767
1798
  this.collectRelFilterParams(relationDef.to, filterObj.some, params);
1768
1799
  if (filterObj.none !== undefined)
1769
1800
  this.collectRelFilterParams(relationDef.to, filterObj.none, params);
1770
1801
  if (filterObj.every !== undefined)
1771
1802
  this.collectRelFilterParams(relationDef.to, filterObj.every, params);
1803
+ if (filterObj.is !== undefined)
1804
+ this.collectRelFilterParams(relationDef.to, filterObj.is, params);
1805
+ if (filterObj.isNot !== undefined)
1806
+ this.collectRelFilterParams(relationDef.to, filterObj.isNot, params);
1772
1807
  continue;
1773
1808
  }
1774
1809
  }
@@ -2121,7 +2156,11 @@ export class QueryInterface {
2121
2156
  if (relationDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
2122
2157
  const filterObj = value;
2123
2158
  // Check if this is a relation filter (has some/every/none keys)
2124
- if ('some' in filterObj || 'every' in filterObj || 'none' in filterObj) {
2159
+ if ('some' in filterObj ||
2160
+ 'every' in filterObj ||
2161
+ 'none' in filterObj ||
2162
+ 'is' in filterObj ||
2163
+ 'isNot' in filterObj) {
2125
2164
  const relClause = this.buildRelationFilter(key, relationDef, filterObj, params);
2126
2165
  if (relClause)
2127
2166
  andClauses.push(relClause);
@@ -2238,6 +2277,20 @@ export class QueryInterface {
2238
2277
  // "every" with empty filter = true (all match trivially)
2239
2278
  }
2240
2279
  }
2280
+ // "is": EXISTS — for to-one relations (same SQL as "some")
2281
+ if (filterObj.is !== undefined) {
2282
+ const subWhere = filterObj.is;
2283
+ const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
2284
+ const fullWhere = filterClause ? `${correlation} AND ${filterClause}` : correlation;
2285
+ clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${fullWhere})`);
2286
+ }
2287
+ // "isNot": NOT EXISTS — for to-one relations (same SQL as "none")
2288
+ if (filterObj.isNot !== undefined) {
2289
+ const subWhere = filterObj.isNot;
2290
+ const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
2291
+ const fullWhere = filterClause ? `${correlation} AND ${filterClause}` : correlation;
2292
+ clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${fullWhere})`);
2293
+ }
2241
2294
  return clauses.length > 0 ? clauses.join(' AND ') : null;
2242
2295
  }
2243
2296
  /**
@@ -10,6 +10,6 @@ export type { BuiltStatement, BulkInsertStatementInput, ColumnDefinitionInput, C
10
10
  export { postgresDialect } from '../dialect.js';
11
11
  export type { SqlCacheEntry } from './utils.js';
12
12
  export { buildCorrelation, escapeLike, escSingleQuote, fnv1a64Hex, LRUCache, OPERATOR_KEYS, quoteIdent, sqlToPreparedName, } from './utils.js';
13
- export type { DeferredQuery, MiddlewareFn, QueryInterfaceOptions } from './builder.js';
13
+ export type { DeferredQuery, MiddlewareFn, QueryEvent, QueryEventListener, QueryInterfaceOptions } from './builder.js';
14
14
  export { QueryInterface } from './builder.js';
15
15
  //# sourceMappingURL=index.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turbine-orm",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Postgres-native TypeScript ORM — runs on Neon, Vercel Postgres, Cloudflare, Supabase. Streaming cursors, typed errors, single-query nested relations. 1 dependency, ~110KB",
5
5
  "type": "module",
6
6
  "exports": {
@@ -49,7 +49,7 @@
49
49
  "status": "tsx src/cli/index.ts status",
50
50
  "examples": "tsx examples/examples.ts",
51
51
  "test": "tsx --test src/test/*.test.ts",
52
- "test:unit": "tsx --test src/test/schema-builder.test.ts src/test/errors.test.ts src/test/stress.test.ts src/test/migrate.test.ts src/test/update-operators.test.ts src/test/empty-where-guard.test.ts src/test/cli.test.ts src/test/serverless.test.ts src/test/pipeline.test.ts src/test/pipeline-submittable.test.ts src/test/with-inference.test.ts src/test/operator-validation.test.ts src/test/unlimited-warning.test.ts src/test/generate-relations.test.ts src/test/stream-and-parse.test.ts src/test/sql-cache.test.ts src/test/dialect.test.ts src/test/studio.test.ts src/test/sql-injection.test.ts src/test/cli-flags.test.ts src/test/cockroachdb-adapter.test.ts src/test/yugabytedb-adapter.test.ts src/test/pg-compat.test.ts src/test/relation-filter-validation.test.ts src/test/client-coverage.test.ts src/test/schema-diff.test.ts src/test/composite-fk.test.ts src/test/retry.test.ts src/test/text-search.test.ts src/test/optimistic-lock.test.ts src/test/sql-safety-property.test.ts",
52
+ "test:unit": "tsx --test src/test/schema-builder.test.ts src/test/errors.test.ts src/test/stress.test.ts src/test/migrate.test.ts src/test/update-operators.test.ts src/test/empty-where-guard.test.ts src/test/cli.test.ts src/test/serverless.test.ts src/test/pipeline.test.ts src/test/pipeline-submittable.test.ts src/test/with-inference.test.ts src/test/operator-validation.test.ts src/test/unlimited-warning.test.ts src/test/generate-relations.test.ts src/test/stream-and-parse.test.ts src/test/sql-cache.test.ts src/test/dialect.test.ts src/test/studio.test.ts src/test/sql-injection.test.ts src/test/cli-flags.test.ts src/test/cockroachdb-adapter.test.ts src/test/yugabytedb-adapter.test.ts src/test/pg-compat.test.ts src/test/relation-filter-validation.test.ts src/test/client-coverage.test.ts src/test/schema-diff.test.ts src/test/composite-fk.test.ts src/test/retry.test.ts src/test/text-search.test.ts src/test/optimistic-lock.test.ts src/test/sql-safety-property.test.ts src/test/nested-write.test.ts src/test/nested-write-update-upsert.test.ts src/test/cursor-pagination.test.ts src/test/client-branches.test.ts src/test/is-isNot-filter.test.ts src/test/event-emitter.test.ts src/test/observe.test.ts",
53
53
  "test:coverage": "c8 tsx --test src/test/schema-builder.test.ts src/test/errors.test.ts src/test/stress.test.ts src/test/migrate.test.ts src/test/update-operators.test.ts src/test/empty-where-guard.test.ts src/test/cli.test.ts src/test/serverless.test.ts src/test/pipeline.test.ts src/test/pipeline-submittable.test.ts src/test/with-inference.test.ts src/test/operator-validation.test.ts src/test/unlimited-warning.test.ts src/test/generate-relations.test.ts src/test/stream-and-parse.test.ts src/test/sql-cache.test.ts src/test/dialect.test.ts src/test/studio.test.ts src/test/sql-injection.test.ts src/test/cli-flags.test.ts",
54
54
  "lint": "biome check src/",
55
55
  "lint:fix": "biome check --write src/",