turbine-orm 0.14.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.
Files changed (42) hide show
  1. package/dist/adapters/cockroachdb.js +1 -1
  2. package/dist/adapters/index.d.ts +7 -4
  3. package/dist/adapters/index.js +1 -1
  4. package/dist/adapters/yugabytedb.js +1 -1
  5. package/dist/cjs/adapters/cockroachdb.js +1 -1
  6. package/dist/cjs/adapters/index.js +1 -1
  7. package/dist/cjs/adapters/yugabytedb.js +1 -1
  8. package/dist/cjs/cli/index.js +64 -0
  9. package/dist/cjs/cli/observe-ui.js +182 -0
  10. package/dist/cjs/cli/observe.js +242 -0
  11. package/dist/cjs/cli/studio.js +45 -7
  12. package/dist/cjs/client.js +102 -1
  13. package/dist/cjs/errors.js +44 -1
  14. package/dist/cjs/generate.js +86 -0
  15. package/dist/cjs/index.js +10 -1
  16. package/dist/cjs/nested-write.js +557 -0
  17. package/dist/cjs/observe.js +145 -0
  18. package/dist/cjs/query/builder.js +271 -23
  19. package/dist/cli/index.d.ts +1 -0
  20. package/dist/cli/index.js +64 -0
  21. package/dist/cli/observe-ui.d.ts +2 -0
  22. package/dist/cli/observe-ui.js +180 -0
  23. package/dist/cli/observe.d.ts +20 -0
  24. package/dist/cli/observe.js +237 -0
  25. package/dist/cli/studio.d.ts +10 -2
  26. package/dist/cli/studio.js +45 -7
  27. package/dist/client.d.ts +32 -2
  28. package/dist/client.js +102 -2
  29. package/dist/errors.d.ts +23 -0
  30. package/dist/errors.js +41 -0
  31. package/dist/generate.js +86 -0
  32. package/dist/index.d.ts +5 -3
  33. package/dist/index.js +4 -2
  34. package/dist/nested-write.d.ts +95 -0
  35. package/dist/nested-write.js +551 -0
  36. package/dist/observe.d.ts +36 -0
  37. package/dist/observe.js +141 -0
  38. package/dist/query/builder.d.ts +45 -12
  39. package/dist/query/builder.js +239 -24
  40. package/dist/query/index.d.ts +2 -2
  41. package/dist/query/types.d.ts +76 -8
  42. package/package.json +2 -2
@@ -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;
@@ -11,10 +11,44 @@
11
11
  * Schema-driven: all column names, types, and relations come from introspected
12
12
  * metadata — nothing is hardcoded.
13
13
  */
14
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
15
+ if (k2 === undefined) k2 = k;
16
+ var desc = Object.getOwnPropertyDescriptor(m, k);
17
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
18
+ desc = { enumerable: true, get: function() { return m[k]; } };
19
+ }
20
+ Object.defineProperty(o, k2, desc);
21
+ }) : (function(o, m, k, k2) {
22
+ if (k2 === undefined) k2 = k;
23
+ o[k2] = m[k];
24
+ }));
25
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
26
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
27
+ }) : function(o, v) {
28
+ o["default"] = v;
29
+ });
30
+ var __importStar = (this && this.__importStar) || (function () {
31
+ var ownKeys = function(o) {
32
+ ownKeys = Object.getOwnPropertyNames || function (o) {
33
+ var ar = [];
34
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
35
+ return ar;
36
+ };
37
+ return ownKeys(o);
38
+ };
39
+ return function (mod) {
40
+ if (mod && mod.__esModule) return mod;
41
+ var result = {};
42
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
43
+ __setModuleDefault(result, mod);
44
+ return result;
45
+ };
46
+ })();
14
47
  Object.defineProperty(exports, "__esModule", { value: true });
15
48
  exports.QueryInterface = void 0;
16
49
  const dialect_js_1 = require("../dialect.js");
17
50
  const errors_js_1 = require("../errors.js");
51
+ const nested_write_js_1 = require("../nested-write.js");
18
52
  const schema_js_1 = require("../schema.js");
19
53
  const utils_js_1 = require("./utils.js");
20
54
  // ---------------------------------------------------------------------------
@@ -100,6 +134,28 @@ function findArrayUniqueKey(value) {
100
134
  }
101
135
  return null;
102
136
  }
137
+ /** Known text search operator keys */
138
+ const TEXT_SEARCH_KEYS = new Set(['search', 'config']);
139
+ /** Check if a value is a TextSearchFilter object */
140
+ function isTextSearchFilter(value) {
141
+ if (value === null ||
142
+ value === undefined ||
143
+ typeof value !== 'object' ||
144
+ Array.isArray(value) ||
145
+ value instanceof Date) {
146
+ return false;
147
+ }
148
+ const keys = Object.keys(value);
149
+ // Must have 'search' key and only known text search keys
150
+ return keys.includes('search') && keys.every((k) => TEXT_SEARCH_KEYS.has(k));
151
+ }
152
+ /**
153
+ * Validate a text search config name. Only alphanumeric characters and
154
+ * underscores are allowed to prevent SQL injection via the config parameter.
155
+ */
156
+ function validateTextSearchConfig(config) {
157
+ return /^[a-zA-Z0-9_]+$/.test(config);
158
+ }
103
159
  // biome-ignore lint/complexity/noBannedTypes: {} means "no relations known" — intentional for untyped table access
104
160
  class QueryInterface {
105
161
  pool;
@@ -131,6 +187,12 @@ class QueryInterface {
131
187
  columnArrayTypeMap;
132
188
  /** Tracks tables that have already triggered a deep-with warning (one-time) */
133
189
  deepWithWarned = new Set();
190
+ /** True when this QI runs inside an active transaction (set via _txScoped option). */
191
+ txScoped;
192
+ /** Original options reference — forwarded to child QIs in nested writes. */
193
+ options;
194
+ /** Set by executeWithMiddleware so queryWithTimeout can include it in events. */
195
+ currentAction = 'raw';
134
196
  constructor(pool, table, schema, middlewares, options) {
135
197
  this.pool = pool;
136
198
  this.table = table;
@@ -149,6 +211,8 @@ class QueryInterface {
149
211
  this.preparedStatementsEnabled = options?.preparedStatements ?? true;
150
212
  this.sqlCacheEnabled = options?.sqlCache !== false;
151
213
  this.dialect = options?.dialect ?? dialect_js_1.postgresDialect;
214
+ this.txScoped = options?._txScoped ?? false;
215
+ this.options = options;
152
216
  // Pre-compute column type lookup maps (TASK-26)
153
217
  this.columnPgTypeMap = new Map();
154
218
  this.columnArrayTypeMap = new Map();
@@ -210,12 +274,25 @@ class QueryInterface {
210
274
  resetUnlimitedWarnings() {
211
275
  this.warnedTables.clear();
212
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
+ }
213
288
  /**
214
289
  * Execute a pool.query with an optional timeout.
215
290
  * If timeout is set, races the query against a timer and rejects on expiry.
216
291
  * pg driver errors are translated to typed Turbine errors via wrapPgError.
217
292
  */
218
293
  async queryWithTimeout(sql, params, timeout, preparedName) {
294
+ const start = performance.now();
295
+ const action = this.currentAction;
219
296
  // Build the query argument — use object form with `name` for prepared
220
297
  // statements, or the plain (text, values) form otherwise.
221
298
  const usePrepared = preparedName && this.preparedStatementsEnabled;
@@ -224,10 +301,14 @@ class QueryInterface {
224
301
  : this.pool.query(sql, params);
225
302
  if (!timeout) {
226
303
  try {
227
- return await exec;
304
+ const result = await exec;
305
+ this.emitQueryEvent(sql, params, performance.now() - start, action, result.rowCount ?? 0);
306
+ return result;
228
307
  }
229
308
  catch (err) {
230
- 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;
231
312
  }
232
313
  }
233
314
  let timer;
@@ -235,10 +316,14 @@ class QueryInterface {
235
316
  timer = setTimeout(() => reject(new errors_js_1.TimeoutError(timeout)), timeout);
236
317
  });
237
318
  try {
238
- 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;
239
322
  }
240
323
  catch (err) {
241
- 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;
242
327
  }
243
328
  finally {
244
329
  clearTimeout(timer);
@@ -254,6 +339,7 @@ class QueryInterface {
254
339
  * To intercept queries before SQL generation, use the raw() method instead.
255
340
  */
256
341
  async executeWithMiddleware(action, args, executor) {
342
+ this.currentAction = action;
257
343
  if (this.middlewares.length === 0) {
258
344
  return executor();
259
345
  }
@@ -273,7 +359,6 @@ class QueryInterface {
273
359
  // -------------------------------------------------------------------------
274
360
  // findUnique
275
361
  // -------------------------------------------------------------------------
276
- // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
277
362
  async findUnique(args) {
278
363
  return this.executeWithMiddleware('findUnique', args, async () => {
279
364
  const deferred = this.buildFindUnique(args);
@@ -377,7 +462,6 @@ class QueryInterface {
377
462
  // -------------------------------------------------------------------------
378
463
  // findMany
379
464
  // -------------------------------------------------------------------------
380
- // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
381
465
  async findMany(args) {
382
466
  this.maybeWarnUnlimited(args);
383
467
  // Dev-only: warn on deeply nested with clauses
@@ -579,7 +663,6 @@ class QueryInterface {
579
663
  * }
580
664
  * ```
581
665
  */
582
- // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
583
666
  async *findManyStream(args) {
584
667
  const batchSize = Math.max(1, Math.floor(Number(args?.batchSize ?? 1000)));
585
668
  const hasRelations = !!args?.with;
@@ -588,6 +671,7 @@ class QueryInterface {
588
671
  ...args,
589
672
  limit: batchSize + 1,
590
673
  });
674
+ this.currentAction = 'findManyStream';
591
675
  const speculativeResult = await this.queryWithTimeout(speculativeDeferred.sql, speculativeDeferred.params, args?.timeout);
592
676
  if (speculativeResult.rows.length <= batchSize) {
593
677
  // Small drain — yield all rows and return, no cursor needed
@@ -636,7 +720,6 @@ class QueryInterface {
636
720
  // -------------------------------------------------------------------------
637
721
  // findFirst — like findMany but returns a single row or null
638
722
  // -------------------------------------------------------------------------
639
- // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
640
723
  async findFirst(args) {
641
724
  return this.executeWithMiddleware('findFirst', (args ?? {}), async () => {
642
725
  const deferred = this.buildFindFirst(args);
@@ -662,7 +745,6 @@ class QueryInterface {
662
745
  // -------------------------------------------------------------------------
663
746
  // findFirstOrThrow — like findFirst but throws if no record found
664
747
  // -------------------------------------------------------------------------
665
- // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
666
748
  async findFirstOrThrow(args) {
667
749
  return this.executeWithMiddleware('findFirstOrThrow', (args ?? {}), async () => {
668
750
  const deferred = this.buildFindFirstOrThrow(args);
@@ -693,7 +775,6 @@ class QueryInterface {
693
775
  // -------------------------------------------------------------------------
694
776
  // findUniqueOrThrow — like findUnique but throws if no record found
695
777
  // -------------------------------------------------------------------------
696
- // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
697
778
  async findUniqueOrThrow(args) {
698
779
  return this.executeWithMiddleware('findUniqueOrThrow', args, async () => {
699
780
  const deferred = this.buildFindUniqueOrThrow(args);
@@ -726,6 +807,9 @@ class QueryInterface {
726
807
  // -------------------------------------------------------------------------
727
808
  async create(args) {
728
809
  return this.executeWithMiddleware('create', args, async () => {
810
+ if ((0, nested_write_js_1.hasRelationFields)(args.data, this.tableMeta)) {
811
+ return this.nestedCreate(args);
812
+ }
729
813
  const deferred = this.buildCreate(args);
730
814
  const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
731
815
  return deferred.transform(result);
@@ -808,6 +892,9 @@ class QueryInterface {
808
892
  // -------------------------------------------------------------------------
809
893
  async update(args) {
810
894
  return this.executeWithMiddleware('update', args, async () => {
895
+ if ((0, nested_write_js_1.hasRelationFields)(args.data, this.tableMeta)) {
896
+ return this.nestedUpdate(args);
897
+ }
811
898
  const deferred = this.buildUpdate(args);
812
899
  const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
813
900
  return deferred.transform(result);
@@ -816,32 +903,62 @@ class QueryInterface {
816
903
  buildUpdate(args) {
817
904
  const dataObj = args.data;
818
905
  const whereObj = args.where;
906
+ const lock = args.optimisticLock;
819
907
  const setFp = this.fingerprintSet(dataObj);
820
908
  const whereFp = this.fingerprintWhere(whereObj);
821
- const ck = `u:${setFp}|${whereFp}`;
909
+ const ck = lock ? null : `u:${setFp}|${whereFp}`;
822
910
  const params = [];
823
- const entry = this.acquireSql(ck, () => {
911
+ const buildSql = () => {
824
912
  const freshParams = [];
825
913
  const setEntries = Object.entries(dataObj).filter(([, v]) => v !== undefined);
826
914
  const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, freshParams));
915
+ if (lock) {
916
+ const versionCol = this.toSqlColumn(lock.field);
917
+ setClauses.push(`${versionCol} = ${versionCol} + 1`);
918
+ }
827
919
  const whereClause = this.buildWhereClause(whereObj, freshParams);
828
- const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
920
+ let whereSql = whereClause ? ` WHERE ${whereClause}` : '';
921
+ if (lock) {
922
+ const versionCol = this.toSqlColumn(lock.field);
923
+ freshParams.push(lock.expected);
924
+ const versionCheck = `${versionCol} = ${this.p(freshParams.length)}`;
925
+ whereSql = whereSql ? `${whereSql} AND ${versionCheck}` : ` WHERE ${versionCheck}`;
926
+ }
829
927
  this.assertMutationHasPredicate('update', whereSql, args.allowFullTableScan);
830
928
  return `UPDATE ${this.q(this.table)} SET ${setClauses.join(', ')}${whereSql}${this.dialect.buildReturningClause('*')}`;
831
- });
832
- // On cache hit, validate predicate
833
- if (whereFp === '') {
834
- this.assertMutationHasPredicate('update', '', args.allowFullTableScan);
929
+ };
930
+ let sql;
931
+ let preparedName;
932
+ if (ck) {
933
+ const entry = this.acquireSql(ck, buildSql);
934
+ sql = entry.sql;
935
+ preparedName = entry.name;
936
+ if (whereFp === '') {
937
+ this.assertMutationHasPredicate('update', '', args.allowFullTableScan);
938
+ }
939
+ }
940
+ else {
941
+ sql = buildSql();
835
942
  }
836
- // Collect params: SET first, then WHERE (same order as fresh build)
943
+ // Collect params: SET first, then WHERE, then version check (same order as fresh build)
837
944
  this.collectSetParams(dataObj, params);
838
945
  this.collectWhereParams(whereObj, params);
946
+ if (lock) {
947
+ params.push(lock.expected);
948
+ }
839
949
  return {
840
- sql: entry.sql,
950
+ sql,
841
951
  params,
842
952
  transform: (result) => {
843
953
  const row = result.rows[0];
844
954
  if (!row) {
955
+ if (lock) {
956
+ throw new errors_js_1.OptimisticLockError({
957
+ table: this.table,
958
+ versionField: lock.field,
959
+ expectedVersion: lock.expected,
960
+ });
961
+ }
845
962
  throw new errors_js_1.NotFoundError({
846
963
  table: this.table,
847
964
  where: args.where,
@@ -851,7 +968,75 @@ class QueryInterface {
851
968
  return this.parseRow(row, this.table);
852
969
  },
853
970
  tag: `${this.table}.update`,
854
- preparedName: entry.name,
971
+ preparedName,
972
+ };
973
+ }
974
+ // -------------------------------------------------------------------------
975
+ // Nested write helpers (shared by create + update)
976
+ // -------------------------------------------------------------------------
977
+ async nestedCreate(args) {
978
+ const data = args.data;
979
+ if (this.txScoped) {
980
+ const ctx = this.buildNestedCtx();
981
+ return (0, nested_write_js_1.executeNestedCreate)(ctx, this.table, data);
982
+ }
983
+ return this.runInImplicitTx(async (ctx) => {
984
+ const result = await (0, nested_write_js_1.executeNestedCreate)(ctx, this.table, data);
985
+ return result;
986
+ });
987
+ }
988
+ async nestedUpdate(args) {
989
+ const data = args.data;
990
+ const where = args.where;
991
+ if (this.txScoped) {
992
+ const ctx = this.buildNestedCtx();
993
+ return (0, nested_write_js_1.executeNestedUpdate)(ctx, this.table, where, data);
994
+ }
995
+ return this.runInImplicitTx(async (ctx) => {
996
+ const result = await (0, nested_write_js_1.executeNestedUpdate)(ctx, this.table, where, data);
997
+ return result;
998
+ });
999
+ }
1000
+ async runInImplicitTx(fn) {
1001
+ const client = await this.pool.connect();
1002
+ try {
1003
+ await client.query('BEGIN');
1004
+ const { TransactionClient } = await Promise.resolve().then(() => __importStar(require('../client.js')));
1005
+ // biome-ignore lint/suspicious/noExplicitAny: MiddlewareFn and Middleware are structurally identical
1006
+ const tx = new TransactionClient(client, this.schema, this.middlewares, this.options);
1007
+ // biome-ignore lint/suspicious/noExplicitAny: TransactionClient satisfies NestedWriteContext['tx'] at runtime
1008
+ const ctx = { schema: this.schema, tx: tx };
1009
+ const result = await fn(ctx);
1010
+ await client.query('COMMIT');
1011
+ return result;
1012
+ }
1013
+ catch (err) {
1014
+ try {
1015
+ await client.query('ROLLBACK');
1016
+ }
1017
+ catch {
1018
+ // Best-effort rollback — connection may have died.
1019
+ }
1020
+ throw err;
1021
+ }
1022
+ finally {
1023
+ client.release();
1024
+ }
1025
+ }
1026
+ buildNestedCtx() {
1027
+ const pool = this.pool;
1028
+ const schema = this.schema;
1029
+ const middlewares = this.middlewares;
1030
+ const opts = { ...this.options, _txScoped: true };
1031
+ return {
1032
+ schema,
1033
+ tx: this.makeTxProxy(pool, schema, middlewares, opts),
1034
+ };
1035
+ }
1036
+ // biome-ignore lint/suspicious/noExplicitAny: bridges MiddlewareFn[] ↔ Middleware[] and QI ↔ NestedWriteContext type gap
1037
+ makeTxProxy(pool, schema, middlewares, opts) {
1038
+ return {
1039
+ table: (name) => new QueryInterface(pool, name, schema, middlewares, opts),
855
1040
  };
856
1041
  }
857
1042
  // -------------------------------------------------------------------------
@@ -1503,7 +1688,11 @@ class QueryInterface {
1503
1688
  const relDef = this.tableMeta.relations[key];
1504
1689
  if (relDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
1505
1690
  const filterObj = value;
1506
- 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) {
1507
1696
  const relParts = [];
1508
1697
  if (filterObj.some !== undefined)
1509
1698
  relParts.push(`some(${this.fingerprintRelFilter(relDef.to, filterObj.some)})`);
@@ -1511,6 +1700,10 @@ class QueryInterface {
1511
1700
  relParts.push(`every(${this.fingerprintRelFilter(relDef.to, filterObj.every)})`);
1512
1701
  if (filterObj.none !== undefined)
1513
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)})`);
1514
1707
  parts.push(`${key}:{${relParts.join(',')}}`);
1515
1708
  continue;
1516
1709
  }
@@ -1541,6 +1734,12 @@ class QueryInterface {
1541
1734
  parts.push(`${key}:arr(${this.fingerprintArrayFilter(value)})`);
1542
1735
  continue;
1543
1736
  }
1737
+ // Text search filter
1738
+ if (typeof value === 'object' && !Array.isArray(value) && isTextSearchFilter(value)) {
1739
+ const cfg = value.config ?? 'english';
1740
+ parts.push(`${key}:fts(${cfg})`);
1741
+ continue;
1742
+ }
1544
1743
  // Plain equality
1545
1744
  parts.push(`${key}:eq`);
1546
1745
  }
@@ -1626,13 +1825,21 @@ class QueryInterface {
1626
1825
  const relationDef = this.tableMeta.relations[key];
1627
1826
  if (relationDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
1628
1827
  const filterObj = value;
1629
- 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) {
1630
1833
  if (filterObj.some !== undefined)
1631
1834
  this.collectRelFilterParams(relationDef.to, filterObj.some, params);
1632
1835
  if (filterObj.none !== undefined)
1633
1836
  this.collectRelFilterParams(relationDef.to, filterObj.none, params);
1634
1837
  if (filterObj.every !== undefined)
1635
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);
1636
1843
  continue;
1637
1844
  }
1638
1845
  }
@@ -1656,6 +1863,11 @@ class QueryInterface {
1656
1863
  continue;
1657
1864
  }
1658
1865
  }
1866
+ // Text search filter
1867
+ if (typeof value === 'object' && !Array.isArray(value) && isTextSearchFilter(value)) {
1868
+ params.push(value.search);
1869
+ continue;
1870
+ }
1659
1871
  // Operator objects
1660
1872
  if (isWhereOperator(value)) {
1661
1873
  this.collectOperatorParams(value, params);
@@ -1980,7 +2192,11 @@ class QueryInterface {
1980
2192
  if (relationDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
1981
2193
  const filterObj = value;
1982
2194
  // Check if this is a relation filter (has some/every/none keys)
1983
- 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) {
1984
2200
  const relClause = this.buildRelationFilter(key, relationDef, filterObj, params);
1985
2201
  if (relClause)
1986
2202
  andClauses.push(relClause);
@@ -2030,6 +2246,12 @@ class QueryInterface {
2030
2246
  `(actual type: ${colType}); cannot apply array operator '${arrayKey}'.`);
2031
2247
  }
2032
2248
  }
2249
+ // Handle full-text search filter
2250
+ if (typeof value === 'object' && !Array.isArray(value) && isTextSearchFilter(value)) {
2251
+ const tsClause = this.buildTextSearchClause(column, value, params);
2252
+ andClauses.push(tsClause);
2253
+ continue;
2254
+ }
2033
2255
  // Handle operator objects
2034
2256
  if (isWhereOperator(value)) {
2035
2257
  const opClauses = this.buildOperatorClauses(column, value, params);
@@ -2091,6 +2313,20 @@ class QueryInterface {
2091
2313
  // "every" with empty filter = true (all match trivially)
2092
2314
  }
2093
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
+ }
2094
2330
  return clauses.length > 0 ? clauses.join(' AND ') : null;
2095
2331
  }
2096
2332
  /**
@@ -2672,6 +2908,18 @@ class QueryInterface {
2672
2908
  }
2673
2909
  return clauses;
2674
2910
  }
2911
+ /**
2912
+ * Build SQL clause for full-text search using to_tsvector @@ to_tsquery.
2913
+ * The config name is validated to prevent injection (only alphanumeric + underscore).
2914
+ */
2915
+ buildTextSearchClause(column, filter, params) {
2916
+ const config = filter.config ?? 'english';
2917
+ if (!validateTextSearchConfig(config)) {
2918
+ throw new errors_js_1.ValidationError(`[turbine] Invalid text search config "${config}": only alphanumeric characters and underscores are allowed.`);
2919
+ }
2920
+ params.push(filter.search);
2921
+ return `to_tsvector('${config}', ${column}) @@ to_tsquery('${config}', ${this.p(params.length)})`;
2922
+ }
2675
2923
  /**
2676
2924
  * Get the Postgres array type for a column (used by UNNEST in createMany).
2677
2925
  * Uses pre-computed Map for O(1) lookup instead of linear scan.
@@ -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