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
@@ -11,7 +11,8 @@
11
11
  * metadata — nothing is hardcoded.
12
12
  */
13
13
  import { postgresDialect } from '../dialect.js';
14
- import { CircularRelationError, NotFoundError, RelationError, TimeoutError, ValidationError, wrapPgError, } from '../errors.js';
14
+ import { CircularRelationError, NotFoundError, OptimisticLockError, RelationError, TimeoutError, ValidationError, wrapPgError, } from '../errors.js';
15
+ import { executeNestedCreate, executeNestedUpdate, hasRelationFields, } from '../nested-write.js';
15
16
  import { camelToSnake, snakeToCamel } from '../schema.js';
16
17
  import { escapeLike, LRUCache, OPERATOR_KEYS, sqlToPreparedName } from './utils.js';
17
18
  // ---------------------------------------------------------------------------
@@ -97,6 +98,28 @@ function findArrayUniqueKey(value) {
97
98
  }
98
99
  return null;
99
100
  }
101
+ /** Known text search operator keys */
102
+ const TEXT_SEARCH_KEYS = new Set(['search', 'config']);
103
+ /** Check if a value is a TextSearchFilter object */
104
+ function isTextSearchFilter(value) {
105
+ if (value === null ||
106
+ value === undefined ||
107
+ typeof value !== 'object' ||
108
+ Array.isArray(value) ||
109
+ value instanceof Date) {
110
+ return false;
111
+ }
112
+ const keys = Object.keys(value);
113
+ // Must have 'search' key and only known text search keys
114
+ return keys.includes('search') && keys.every((k) => TEXT_SEARCH_KEYS.has(k));
115
+ }
116
+ /**
117
+ * Validate a text search config name. Only alphanumeric characters and
118
+ * underscores are allowed to prevent SQL injection via the config parameter.
119
+ */
120
+ function validateTextSearchConfig(config) {
121
+ return /^[a-zA-Z0-9_]+$/.test(config);
122
+ }
100
123
  // biome-ignore lint/complexity/noBannedTypes: {} means "no relations known" — intentional for untyped table access
101
124
  export class QueryInterface {
102
125
  pool;
@@ -128,6 +151,12 @@ export class QueryInterface {
128
151
  columnArrayTypeMap;
129
152
  /** Tracks tables that have already triggered a deep-with warning (one-time) */
130
153
  deepWithWarned = new Set();
154
+ /** True when this QI runs inside an active transaction (set via _txScoped option). */
155
+ txScoped;
156
+ /** Original options reference — forwarded to child QIs in nested writes. */
157
+ options;
158
+ /** Set by executeWithMiddleware so queryWithTimeout can include it in events. */
159
+ currentAction = 'raw';
131
160
  constructor(pool, table, schema, middlewares, options) {
132
161
  this.pool = pool;
133
162
  this.table = table;
@@ -146,6 +175,8 @@ export class QueryInterface {
146
175
  this.preparedStatementsEnabled = options?.preparedStatements ?? true;
147
176
  this.sqlCacheEnabled = options?.sqlCache !== false;
148
177
  this.dialect = options?.dialect ?? postgresDialect;
178
+ this.txScoped = options?._txScoped ?? false;
179
+ this.options = options;
149
180
  // Pre-compute column type lookup maps (TASK-26)
150
181
  this.columnPgTypeMap = new Map();
151
182
  this.columnArrayTypeMap = new Map();
@@ -207,12 +238,25 @@ export class QueryInterface {
207
238
  resetUnlimitedWarnings() {
208
239
  this.warnedTables.clear();
209
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
+ }
210
252
  /**
211
253
  * Execute a pool.query with an optional timeout.
212
254
  * If timeout is set, races the query against a timer and rejects on expiry.
213
255
  * pg driver errors are translated to typed Turbine errors via wrapPgError.
214
256
  */
215
257
  async queryWithTimeout(sql, params, timeout, preparedName) {
258
+ const start = performance.now();
259
+ const action = this.currentAction;
216
260
  // Build the query argument — use object form with `name` for prepared
217
261
  // statements, or the plain (text, values) form otherwise.
218
262
  const usePrepared = preparedName && this.preparedStatementsEnabled;
@@ -221,10 +265,14 @@ export class QueryInterface {
221
265
  : this.pool.query(sql, params);
222
266
  if (!timeout) {
223
267
  try {
224
- return await exec;
268
+ const result = await exec;
269
+ this.emitQueryEvent(sql, params, performance.now() - start, action, result.rowCount ?? 0);
270
+ return result;
225
271
  }
226
272
  catch (err) {
227
- 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;
228
276
  }
229
277
  }
230
278
  let timer;
@@ -232,10 +280,14 @@ export class QueryInterface {
232
280
  timer = setTimeout(() => reject(new TimeoutError(timeout)), timeout);
233
281
  });
234
282
  try {
235
- 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;
236
286
  }
237
287
  catch (err) {
238
- 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;
239
291
  }
240
292
  finally {
241
293
  clearTimeout(timer);
@@ -251,6 +303,7 @@ export class QueryInterface {
251
303
  * To intercept queries before SQL generation, use the raw() method instead.
252
304
  */
253
305
  async executeWithMiddleware(action, args, executor) {
306
+ this.currentAction = action;
254
307
  if (this.middlewares.length === 0) {
255
308
  return executor();
256
309
  }
@@ -270,7 +323,6 @@ export class QueryInterface {
270
323
  // -------------------------------------------------------------------------
271
324
  // findUnique
272
325
  // -------------------------------------------------------------------------
273
- // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
274
326
  async findUnique(args) {
275
327
  return this.executeWithMiddleware('findUnique', args, async () => {
276
328
  const deferred = this.buildFindUnique(args);
@@ -374,7 +426,6 @@ export class QueryInterface {
374
426
  // -------------------------------------------------------------------------
375
427
  // findMany
376
428
  // -------------------------------------------------------------------------
377
- // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
378
429
  async findMany(args) {
379
430
  this.maybeWarnUnlimited(args);
380
431
  // Dev-only: warn on deeply nested with clauses
@@ -576,7 +627,6 @@ export class QueryInterface {
576
627
  * }
577
628
  * ```
578
629
  */
579
- // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
580
630
  async *findManyStream(args) {
581
631
  const batchSize = Math.max(1, Math.floor(Number(args?.batchSize ?? 1000)));
582
632
  const hasRelations = !!args?.with;
@@ -585,6 +635,7 @@ export class QueryInterface {
585
635
  ...args,
586
636
  limit: batchSize + 1,
587
637
  });
638
+ this.currentAction = 'findManyStream';
588
639
  const speculativeResult = await this.queryWithTimeout(speculativeDeferred.sql, speculativeDeferred.params, args?.timeout);
589
640
  if (speculativeResult.rows.length <= batchSize) {
590
641
  // Small drain — yield all rows and return, no cursor needed
@@ -633,7 +684,6 @@ export class QueryInterface {
633
684
  // -------------------------------------------------------------------------
634
685
  // findFirst — like findMany but returns a single row or null
635
686
  // -------------------------------------------------------------------------
636
- // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
637
687
  async findFirst(args) {
638
688
  return this.executeWithMiddleware('findFirst', (args ?? {}), async () => {
639
689
  const deferred = this.buildFindFirst(args);
@@ -659,7 +709,6 @@ export class QueryInterface {
659
709
  // -------------------------------------------------------------------------
660
710
  // findFirstOrThrow — like findFirst but throws if no record found
661
711
  // -------------------------------------------------------------------------
662
- // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
663
712
  async findFirstOrThrow(args) {
664
713
  return this.executeWithMiddleware('findFirstOrThrow', (args ?? {}), async () => {
665
714
  const deferred = this.buildFindFirstOrThrow(args);
@@ -690,7 +739,6 @@ export class QueryInterface {
690
739
  // -------------------------------------------------------------------------
691
740
  // findUniqueOrThrow — like findUnique but throws if no record found
692
741
  // -------------------------------------------------------------------------
693
- // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
694
742
  async findUniqueOrThrow(args) {
695
743
  return this.executeWithMiddleware('findUniqueOrThrow', args, async () => {
696
744
  const deferred = this.buildFindUniqueOrThrow(args);
@@ -723,6 +771,9 @@ export class QueryInterface {
723
771
  // -------------------------------------------------------------------------
724
772
  async create(args) {
725
773
  return this.executeWithMiddleware('create', args, async () => {
774
+ if (hasRelationFields(args.data, this.tableMeta)) {
775
+ return this.nestedCreate(args);
776
+ }
726
777
  const deferred = this.buildCreate(args);
727
778
  const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
728
779
  return deferred.transform(result);
@@ -805,6 +856,9 @@ export class QueryInterface {
805
856
  // -------------------------------------------------------------------------
806
857
  async update(args) {
807
858
  return this.executeWithMiddleware('update', args, async () => {
859
+ if (hasRelationFields(args.data, this.tableMeta)) {
860
+ return this.nestedUpdate(args);
861
+ }
808
862
  const deferred = this.buildUpdate(args);
809
863
  const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
810
864
  return deferred.transform(result);
@@ -813,32 +867,62 @@ export class QueryInterface {
813
867
  buildUpdate(args) {
814
868
  const dataObj = args.data;
815
869
  const whereObj = args.where;
870
+ const lock = args.optimisticLock;
816
871
  const setFp = this.fingerprintSet(dataObj);
817
872
  const whereFp = this.fingerprintWhere(whereObj);
818
- const ck = `u:${setFp}|${whereFp}`;
873
+ const ck = lock ? null : `u:${setFp}|${whereFp}`;
819
874
  const params = [];
820
- const entry = this.acquireSql(ck, () => {
875
+ const buildSql = () => {
821
876
  const freshParams = [];
822
877
  const setEntries = Object.entries(dataObj).filter(([, v]) => v !== undefined);
823
878
  const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, freshParams));
879
+ if (lock) {
880
+ const versionCol = this.toSqlColumn(lock.field);
881
+ setClauses.push(`${versionCol} = ${versionCol} + 1`);
882
+ }
824
883
  const whereClause = this.buildWhereClause(whereObj, freshParams);
825
- const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
884
+ let whereSql = whereClause ? ` WHERE ${whereClause}` : '';
885
+ if (lock) {
886
+ const versionCol = this.toSqlColumn(lock.field);
887
+ freshParams.push(lock.expected);
888
+ const versionCheck = `${versionCol} = ${this.p(freshParams.length)}`;
889
+ whereSql = whereSql ? `${whereSql} AND ${versionCheck}` : ` WHERE ${versionCheck}`;
890
+ }
826
891
  this.assertMutationHasPredicate('update', whereSql, args.allowFullTableScan);
827
892
  return `UPDATE ${this.q(this.table)} SET ${setClauses.join(', ')}${whereSql}${this.dialect.buildReturningClause('*')}`;
828
- });
829
- // On cache hit, validate predicate
830
- if (whereFp === '') {
831
- this.assertMutationHasPredicate('update', '', args.allowFullTableScan);
893
+ };
894
+ let sql;
895
+ let preparedName;
896
+ if (ck) {
897
+ const entry = this.acquireSql(ck, buildSql);
898
+ sql = entry.sql;
899
+ preparedName = entry.name;
900
+ if (whereFp === '') {
901
+ this.assertMutationHasPredicate('update', '', args.allowFullTableScan);
902
+ }
903
+ }
904
+ else {
905
+ sql = buildSql();
832
906
  }
833
- // Collect params: SET first, then WHERE (same order as fresh build)
907
+ // Collect params: SET first, then WHERE, then version check (same order as fresh build)
834
908
  this.collectSetParams(dataObj, params);
835
909
  this.collectWhereParams(whereObj, params);
910
+ if (lock) {
911
+ params.push(lock.expected);
912
+ }
836
913
  return {
837
- sql: entry.sql,
914
+ sql,
838
915
  params,
839
916
  transform: (result) => {
840
917
  const row = result.rows[0];
841
918
  if (!row) {
919
+ if (lock) {
920
+ throw new OptimisticLockError({
921
+ table: this.table,
922
+ versionField: lock.field,
923
+ expectedVersion: lock.expected,
924
+ });
925
+ }
842
926
  throw new NotFoundError({
843
927
  table: this.table,
844
928
  where: args.where,
@@ -848,7 +932,75 @@ export class QueryInterface {
848
932
  return this.parseRow(row, this.table);
849
933
  },
850
934
  tag: `${this.table}.update`,
851
- preparedName: entry.name,
935
+ preparedName,
936
+ };
937
+ }
938
+ // -------------------------------------------------------------------------
939
+ // Nested write helpers (shared by create + update)
940
+ // -------------------------------------------------------------------------
941
+ async nestedCreate(args) {
942
+ const data = args.data;
943
+ if (this.txScoped) {
944
+ const ctx = this.buildNestedCtx();
945
+ return executeNestedCreate(ctx, this.table, data);
946
+ }
947
+ return this.runInImplicitTx(async (ctx) => {
948
+ const result = await executeNestedCreate(ctx, this.table, data);
949
+ return result;
950
+ });
951
+ }
952
+ async nestedUpdate(args) {
953
+ const data = args.data;
954
+ const where = args.where;
955
+ if (this.txScoped) {
956
+ const ctx = this.buildNestedCtx();
957
+ return executeNestedUpdate(ctx, this.table, where, data);
958
+ }
959
+ return this.runInImplicitTx(async (ctx) => {
960
+ const result = await executeNestedUpdate(ctx, this.table, where, data);
961
+ return result;
962
+ });
963
+ }
964
+ async runInImplicitTx(fn) {
965
+ const client = await this.pool.connect();
966
+ try {
967
+ await client.query('BEGIN');
968
+ const { TransactionClient } = await import('../client.js');
969
+ // biome-ignore lint/suspicious/noExplicitAny: MiddlewareFn and Middleware are structurally identical
970
+ const tx = new TransactionClient(client, this.schema, this.middlewares, this.options);
971
+ // biome-ignore lint/suspicious/noExplicitAny: TransactionClient satisfies NestedWriteContext['tx'] at runtime
972
+ const ctx = { schema: this.schema, tx: tx };
973
+ const result = await fn(ctx);
974
+ await client.query('COMMIT');
975
+ return result;
976
+ }
977
+ catch (err) {
978
+ try {
979
+ await client.query('ROLLBACK');
980
+ }
981
+ catch {
982
+ // Best-effort rollback — connection may have died.
983
+ }
984
+ throw err;
985
+ }
986
+ finally {
987
+ client.release();
988
+ }
989
+ }
990
+ buildNestedCtx() {
991
+ const pool = this.pool;
992
+ const schema = this.schema;
993
+ const middlewares = this.middlewares;
994
+ const opts = { ...this.options, _txScoped: true };
995
+ return {
996
+ schema,
997
+ tx: this.makeTxProxy(pool, schema, middlewares, opts),
998
+ };
999
+ }
1000
+ // biome-ignore lint/suspicious/noExplicitAny: bridges MiddlewareFn[] ↔ Middleware[] and QI ↔ NestedWriteContext type gap
1001
+ makeTxProxy(pool, schema, middlewares, opts) {
1002
+ return {
1003
+ table: (name) => new QueryInterface(pool, name, schema, middlewares, opts),
852
1004
  };
853
1005
  }
854
1006
  // -------------------------------------------------------------------------
@@ -1500,7 +1652,11 @@ export class QueryInterface {
1500
1652
  const relDef = this.tableMeta.relations[key];
1501
1653
  if (relDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
1502
1654
  const filterObj = value;
1503
- 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) {
1504
1660
  const relParts = [];
1505
1661
  if (filterObj.some !== undefined)
1506
1662
  relParts.push(`some(${this.fingerprintRelFilter(relDef.to, filterObj.some)})`);
@@ -1508,6 +1664,10 @@ export class QueryInterface {
1508
1664
  relParts.push(`every(${this.fingerprintRelFilter(relDef.to, filterObj.every)})`);
1509
1665
  if (filterObj.none !== undefined)
1510
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)})`);
1511
1671
  parts.push(`${key}:{${relParts.join(',')}}`);
1512
1672
  continue;
1513
1673
  }
@@ -1538,6 +1698,12 @@ export class QueryInterface {
1538
1698
  parts.push(`${key}:arr(${this.fingerprintArrayFilter(value)})`);
1539
1699
  continue;
1540
1700
  }
1701
+ // Text search filter
1702
+ if (typeof value === 'object' && !Array.isArray(value) && isTextSearchFilter(value)) {
1703
+ const cfg = value.config ?? 'english';
1704
+ parts.push(`${key}:fts(${cfg})`);
1705
+ continue;
1706
+ }
1541
1707
  // Plain equality
1542
1708
  parts.push(`${key}:eq`);
1543
1709
  }
@@ -1623,13 +1789,21 @@ export class QueryInterface {
1623
1789
  const relationDef = this.tableMeta.relations[key];
1624
1790
  if (relationDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
1625
1791
  const filterObj = value;
1626
- 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) {
1627
1797
  if (filterObj.some !== undefined)
1628
1798
  this.collectRelFilterParams(relationDef.to, filterObj.some, params);
1629
1799
  if (filterObj.none !== undefined)
1630
1800
  this.collectRelFilterParams(relationDef.to, filterObj.none, params);
1631
1801
  if (filterObj.every !== undefined)
1632
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);
1633
1807
  continue;
1634
1808
  }
1635
1809
  }
@@ -1653,6 +1827,11 @@ export class QueryInterface {
1653
1827
  continue;
1654
1828
  }
1655
1829
  }
1830
+ // Text search filter
1831
+ if (typeof value === 'object' && !Array.isArray(value) && isTextSearchFilter(value)) {
1832
+ params.push(value.search);
1833
+ continue;
1834
+ }
1656
1835
  // Operator objects
1657
1836
  if (isWhereOperator(value)) {
1658
1837
  this.collectOperatorParams(value, params);
@@ -1977,7 +2156,11 @@ export class QueryInterface {
1977
2156
  if (relationDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
1978
2157
  const filterObj = value;
1979
2158
  // Check if this is a relation filter (has some/every/none keys)
1980
- 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) {
1981
2164
  const relClause = this.buildRelationFilter(key, relationDef, filterObj, params);
1982
2165
  if (relClause)
1983
2166
  andClauses.push(relClause);
@@ -2027,6 +2210,12 @@ export class QueryInterface {
2027
2210
  `(actual type: ${colType}); cannot apply array operator '${arrayKey}'.`);
2028
2211
  }
2029
2212
  }
2213
+ // Handle full-text search filter
2214
+ if (typeof value === 'object' && !Array.isArray(value) && isTextSearchFilter(value)) {
2215
+ const tsClause = this.buildTextSearchClause(column, value, params);
2216
+ andClauses.push(tsClause);
2217
+ continue;
2218
+ }
2030
2219
  // Handle operator objects
2031
2220
  if (isWhereOperator(value)) {
2032
2221
  const opClauses = this.buildOperatorClauses(column, value, params);
@@ -2088,6 +2277,20 @@ export class QueryInterface {
2088
2277
  // "every" with empty filter = true (all match trivially)
2089
2278
  }
2090
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
+ }
2091
2294
  return clauses.length > 0 ? clauses.join(' AND ') : null;
2092
2295
  }
2093
2296
  /**
@@ -2669,6 +2872,18 @@ export class QueryInterface {
2669
2872
  }
2670
2873
  return clauses;
2671
2874
  }
2875
+ /**
2876
+ * Build SQL clause for full-text search using to_tsvector @@ to_tsquery.
2877
+ * The config name is validated to prevent injection (only alphanumeric + underscore).
2878
+ */
2879
+ buildTextSearchClause(column, filter, params) {
2880
+ const config = filter.config ?? 'english';
2881
+ if (!validateTextSearchConfig(config)) {
2882
+ throw new ValidationError(`[turbine] Invalid text search config "${config}": only alphanumeric characters and underscores are allowed.`);
2883
+ }
2884
+ params.push(filter.search);
2885
+ return `to_tsvector('${config}', ${column}) @@ to_tsquery('${config}', ${this.p(params.length)})`;
2886
+ }
2672
2887
  /**
2673
2888
  * Get the Postgres array type for a column (used by UNNEST in createMany).
2674
2889
  * Uses pre-computed Map for O(1) lookup instead of linear scan.
@@ -5,11 +5,11 @@
5
5
  * `import { … } from './query/index.js'` is a drop-in replacement for the
6
6
  * former monolithic `import { … } from './query.js'`.
7
7
  */
8
- export type { AggregateArgs, AggregateResult, ArrayFilter, CountArgs, CreateArgs, CreateManyArgs, DeleteArgs, DeleteManyArgs, FindManyArgs, FindManyStreamArgs, FindUniqueArgs, GroupByArgs, JsonFilter, OrderDirection, RelationDescriptor, RelationFilter, TypedWithClause, UpdateArgs, UpdateInput, UpdateManyArgs, UpdateOperatorInput, UpsertArgs, WhereClause, WhereOperator, WhereValue, WithClause, WithOptions, WithResult, } from './types.js';
8
+ export type { AggregateArgs, AggregateResult, ArrayFilter, ConnectOrCreateOp, CountArgs, CreateArgs, CreateManyArgs, DeleteArgs, DeleteManyArgs, FieldResult, FindManyArgs, FindManyStreamArgs, FindUniqueArgs, GroupByArgs, JsonFilter, NestedCreateOp, NestedUpdateOp, OmitResult, OrderDirection, QueryResult, RelationDescriptor, RelationFilter, SelectResult, TextSearchFilter, TypedWithClause, UpdateArgs, UpdateInput, UpdateManyArgs, UpdateOperatorInput, UpsertArgs, WhereClause, WhereOperator, WhereValue, WithClause, WithOptions, WithResult, } from './types.js';
9
9
  export type { BuiltStatement, BulkInsertStatementInput, ColumnDefinitionInput, ColumnTypeInput, CreateIndexStatementInput, CreateTableStatementInput, Dialect, InsertStatementInput, UpsertStatementInput, } from '../dialect.js';
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
@@ -26,8 +26,9 @@ export interface WhereOperator<V = unknown> {
26
26
  * - An operator object ({ gt: 5, lte: 10 })
27
27
  * - A JSONB filter object ({ contains, equals, path, hasKey })
28
28
  * - An array filter object ({ has, hasEvery, hasSome, isEmpty })
29
+ * - A text search filter object ({ search, config? })
29
30
  */
30
- export type WhereValue<V = unknown> = V | WhereOperator<V> | JsonFilter | ArrayFilter | null;
31
+ export type WhereValue<V = unknown> = V | WhereOperator<V> | JsonFilter | ArrayFilter | TextSearchFilter | null;
31
32
  /**
32
33
  * Where clause type: each field can be a plain value, null, or operator object.
33
34
  * Special keys: OR for disjunctive conditions.
@@ -162,18 +163,40 @@ export type WithResult<T, R extends object, W> = [keyof R] extends [never] ? T :
162
163
  with?: infer NestedW;
163
164
  } ? NestedW extends object ? ApplyCardinality<R[K], WithResult<RelationTarget<R[K]>, RelationRelations<R[K]> & object, NestedW>> : ApplyCardinality<R[K], RelationTarget<R[K]>> : ApplyCardinality<R[K], RelationTarget<R[K]>>;
164
165
  } : T;
165
- export interface FindUniqueArgs<T, R extends object = {}, W extends TypedWithClause<R> = TypedWithClause<R>> {
166
+ /** Extract keys from a boolean record where the value is `true`. */
167
+ type TrueKeys<S extends Record<string, boolean>> = {
168
+ [K in keyof S]: S[K] extends true ? K : never;
169
+ }[keyof S];
170
+ /** Pick only the fields from T that are selected (value = true) in S. */
171
+ export type SelectResult<T, S extends Record<string, boolean> | undefined> = S extends Record<string, boolean> ? Pick<T, Extract<keyof T, TrueKeys<S>>> : T;
172
+ /** Omit the fields from T that are marked (value = true) in O. */
173
+ export type OmitResult<T, O extends Record<string, boolean> | undefined> = O extends Record<string, boolean> ? Omit<T, Extract<keyof T, TrueKeys<O>>> : T;
174
+ /**
175
+ * Apply select or omit field narrowing to a base type. Select takes priority —
176
+ * when both are provided, only select is applied (matching runtime behavior).
177
+ */
178
+ export type FieldResult<T, S extends Record<string, boolean> | undefined, O extends Record<string, boolean> | undefined> = S extends Record<string, boolean> ? SelectResult<T, S> : OmitResult<T, O>;
179
+ /**
180
+ * Compute the full query result type: apply field narrowing to the base entity,
181
+ * then add relation additions from the `with` clause. Relations are unaffected
182
+ * by select/omit (they are separate JSON subqueries at the SQL level).
183
+ *
184
+ * Short-circuits to plain WithResult when neither select nor omit is provided,
185
+ * preserving exact type equality with the pre-narrowing era.
186
+ */
187
+ export type QueryResult<T, R extends object, W, S extends Record<string, boolean> | undefined, O extends Record<string, boolean> | undefined> = S extends undefined ? O extends undefined ? WithResult<T, R, W> : O extends Record<string, boolean> ? Omit<WithResult<T, R, W>, Extract<keyof T, TrueKeys<O>>> : WithResult<T, R, W> : S extends Record<string, boolean> ? Pick<WithResult<T, R, W>, Extract<keyof T, TrueKeys<S>> | Exclude<keyof WithResult<T, R, W>, keyof T>> : WithResult<T, R, W>;
188
+ export interface FindUniqueArgs<T, R extends object = {}, W extends TypedWithClause<R> = TypedWithClause<R>, S extends Record<string, boolean> | undefined = undefined, O extends Record<string, boolean> | undefined = undefined> {
166
189
  where: WhereClause<T>;
167
- select?: Record<string, boolean>;
168
- omit?: Record<string, boolean>;
190
+ select?: S;
191
+ omit?: O;
169
192
  with?: W;
170
193
  /** Query timeout in milliseconds. Rejects with an error if exceeded. */
171
194
  timeout?: number;
172
195
  }
173
- export interface FindManyArgs<T, R extends object = {}, W extends TypedWithClause<R> = TypedWithClause<R>> {
196
+ export interface FindManyArgs<T, R extends object = {}, W extends TypedWithClause<R> = TypedWithClause<R>, S extends Record<string, boolean> | undefined = undefined, O extends Record<string, boolean> | undefined = undefined> {
174
197
  where?: WhereClause<T>;
175
- select?: Record<string, boolean>;
176
- omit?: Record<string, boolean>;
198
+ select?: S;
199
+ omit?: O;
177
200
  orderBy?: Record<string, OrderDirection>;
178
201
  limit?: number;
179
202
  offset?: number;
@@ -187,7 +210,7 @@ export interface FindManyArgs<T, R extends object = {}, W extends TypedWithClaus
187
210
  /** Query timeout in milliseconds. Rejects with an error if exceeded. */
188
211
  timeout?: number;
189
212
  }
190
- export interface FindManyStreamArgs<T, R extends object = {}, W extends TypedWithClause<R> = TypedWithClause<R>> extends FindManyArgs<T, R, W> {
213
+ export interface FindManyStreamArgs<T, R extends object = {}, W extends TypedWithClause<R> = TypedWithClause<R>, S extends Record<string, boolean> | undefined = undefined, O extends Record<string, boolean> | undefined = undefined> extends FindManyArgs<T, R, W, S, O> {
191
214
  /**
192
215
  * Number of rows to fetch per internal FETCH batch (default: 1000).
193
216
  *
@@ -256,6 +279,27 @@ export interface UpdateArgs<T> {
256
279
  * when an unconditional mutation is the intended behaviour.
257
280
  */
258
281
  allowFullTableScan?: boolean;
282
+ /**
283
+ * Optimistic locking — prevents lost updates in concurrent scenarios.
284
+ * Specify the version field and its expected value. The update adds a
285
+ * WHERE check on the version and auto-increments it. If the row was
286
+ * modified by another transaction, throws `OptimisticLockError`.
287
+ *
288
+ * @example
289
+ * ```ts
290
+ * await db.posts.update({
291
+ * where: { id: 1 },
292
+ * data: { title: 'new title' },
293
+ * optimisticLock: { field: 'version', expected: 3 },
294
+ * });
295
+ * // Generates: UPDATE posts SET title=$1, version=version+1
296
+ * // WHERE id=$2 AND version=$3 RETURNING *
297
+ * ```
298
+ */
299
+ optimisticLock?: {
300
+ field: keyof T & string;
301
+ expected: number;
302
+ };
259
303
  }
260
304
  export interface UpdateManyArgs<T> {
261
305
  where: WhereClause<T>;
@@ -286,6 +330,23 @@ export interface UpsertArgs<T> {
286
330
  /** Query timeout in milliseconds. Rejects with an error if exceeded. */
287
331
  timeout?: number;
288
332
  }
333
+ export interface ConnectOrCreateOp<T> {
334
+ where: Partial<T>;
335
+ create: Partial<T>;
336
+ }
337
+ export interface NestedCreateOp<T> {
338
+ create?: Partial<T> | Partial<T>[];
339
+ connect?: Partial<T> | Partial<T>[];
340
+ connectOrCreate?: ConnectOrCreateOp<T> | ConnectOrCreateOp<T>[];
341
+ }
342
+ export interface NestedUpdateOp<T> {
343
+ create?: Partial<T> | Partial<T>[];
344
+ connect?: Partial<T> | Partial<T>[];
345
+ connectOrCreate?: ConnectOrCreateOp<T> | ConnectOrCreateOp<T>[];
346
+ disconnect?: Partial<T> | Partial<T>[];
347
+ set?: Partial<T>[];
348
+ delete?: Partial<T> | Partial<T>[];
349
+ }
289
350
  export interface CountArgs<T> {
290
351
  where?: WhereClause<T>;
291
352
  /** Query timeout in milliseconds. Rejects with an error if exceeded. */
@@ -361,5 +422,12 @@ export interface ArrayFilter {
361
422
  /** Check if array is empty: array_length(column, 1) IS NULL */
362
423
  isEmpty?: boolean;
363
424
  }
425
+ /** Full-text search filter using PostgreSQL to_tsvector / to_tsquery */
426
+ export interface TextSearchFilter {
427
+ /** The search query string passed to to_tsquery */
428
+ search: string;
429
+ /** PostgreSQL text search configuration name (defaults to 'english') */
430
+ config?: string;
431
+ }
364
432
  export {};
365
433
  //# sourceMappingURL=types.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turbine-orm",
3
- "version": "0.14.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",
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/",