turbine-orm 0.14.0 → 0.15.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.
@@ -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,10 @@ 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;
134
194
  constructor(pool, table, schema, middlewares, options) {
135
195
  this.pool = pool;
136
196
  this.table = table;
@@ -149,6 +209,8 @@ class QueryInterface {
149
209
  this.preparedStatementsEnabled = options?.preparedStatements ?? true;
150
210
  this.sqlCacheEnabled = options?.sqlCache !== false;
151
211
  this.dialect = options?.dialect ?? dialect_js_1.postgresDialect;
212
+ this.txScoped = options?._txScoped ?? false;
213
+ this.options = options;
152
214
  // Pre-compute column type lookup maps (TASK-26)
153
215
  this.columnPgTypeMap = new Map();
154
216
  this.columnArrayTypeMap = new Map();
@@ -726,6 +788,9 @@ class QueryInterface {
726
788
  // -------------------------------------------------------------------------
727
789
  async create(args) {
728
790
  return this.executeWithMiddleware('create', args, async () => {
791
+ if ((0, nested_write_js_1.hasRelationFields)(args.data, this.tableMeta)) {
792
+ return this.nestedCreate(args);
793
+ }
729
794
  const deferred = this.buildCreate(args);
730
795
  const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
731
796
  return deferred.transform(result);
@@ -808,6 +873,9 @@ class QueryInterface {
808
873
  // -------------------------------------------------------------------------
809
874
  async update(args) {
810
875
  return this.executeWithMiddleware('update', args, async () => {
876
+ if ((0, nested_write_js_1.hasRelationFields)(args.data, this.tableMeta)) {
877
+ return this.nestedUpdate(args);
878
+ }
811
879
  const deferred = this.buildUpdate(args);
812
880
  const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
813
881
  return deferred.transform(result);
@@ -816,32 +884,62 @@ class QueryInterface {
816
884
  buildUpdate(args) {
817
885
  const dataObj = args.data;
818
886
  const whereObj = args.where;
887
+ const lock = args.optimisticLock;
819
888
  const setFp = this.fingerprintSet(dataObj);
820
889
  const whereFp = this.fingerprintWhere(whereObj);
821
- const ck = `u:${setFp}|${whereFp}`;
890
+ const ck = lock ? null : `u:${setFp}|${whereFp}`;
822
891
  const params = [];
823
- const entry = this.acquireSql(ck, () => {
892
+ const buildSql = () => {
824
893
  const freshParams = [];
825
894
  const setEntries = Object.entries(dataObj).filter(([, v]) => v !== undefined);
826
895
  const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, freshParams));
896
+ if (lock) {
897
+ const versionCol = this.toSqlColumn(lock.field);
898
+ setClauses.push(`${versionCol} = ${versionCol} + 1`);
899
+ }
827
900
  const whereClause = this.buildWhereClause(whereObj, freshParams);
828
- const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
901
+ let whereSql = whereClause ? ` WHERE ${whereClause}` : '';
902
+ if (lock) {
903
+ const versionCol = this.toSqlColumn(lock.field);
904
+ freshParams.push(lock.expected);
905
+ const versionCheck = `${versionCol} = ${this.p(freshParams.length)}`;
906
+ whereSql = whereSql ? `${whereSql} AND ${versionCheck}` : ` WHERE ${versionCheck}`;
907
+ }
829
908
  this.assertMutationHasPredicate('update', whereSql, args.allowFullTableScan);
830
909
  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);
910
+ };
911
+ let sql;
912
+ let preparedName;
913
+ if (ck) {
914
+ const entry = this.acquireSql(ck, buildSql);
915
+ sql = entry.sql;
916
+ preparedName = entry.name;
917
+ if (whereFp === '') {
918
+ this.assertMutationHasPredicate('update', '', args.allowFullTableScan);
919
+ }
835
920
  }
836
- // Collect params: SET first, then WHERE (same order as fresh build)
921
+ else {
922
+ sql = buildSql();
923
+ }
924
+ // Collect params: SET first, then WHERE, then version check (same order as fresh build)
837
925
  this.collectSetParams(dataObj, params);
838
926
  this.collectWhereParams(whereObj, params);
927
+ if (lock) {
928
+ params.push(lock.expected);
929
+ }
839
930
  return {
840
- sql: entry.sql,
931
+ sql,
841
932
  params,
842
933
  transform: (result) => {
843
934
  const row = result.rows[0];
844
935
  if (!row) {
936
+ if (lock) {
937
+ throw new errors_js_1.OptimisticLockError({
938
+ table: this.table,
939
+ versionField: lock.field,
940
+ expectedVersion: lock.expected,
941
+ });
942
+ }
845
943
  throw new errors_js_1.NotFoundError({
846
944
  table: this.table,
847
945
  where: args.where,
@@ -851,7 +949,75 @@ class QueryInterface {
851
949
  return this.parseRow(row, this.table);
852
950
  },
853
951
  tag: `${this.table}.update`,
854
- preparedName: entry.name,
952
+ preparedName,
953
+ };
954
+ }
955
+ // -------------------------------------------------------------------------
956
+ // Nested write helpers (shared by create + update)
957
+ // -------------------------------------------------------------------------
958
+ async nestedCreate(args) {
959
+ const data = args.data;
960
+ if (this.txScoped) {
961
+ const ctx = this.buildNestedCtx();
962
+ return (0, nested_write_js_1.executeNestedCreate)(ctx, this.table, data);
963
+ }
964
+ return this.runInImplicitTx(async (ctx) => {
965
+ const result = await (0, nested_write_js_1.executeNestedCreate)(ctx, this.table, data);
966
+ return result;
967
+ });
968
+ }
969
+ async nestedUpdate(args) {
970
+ const data = args.data;
971
+ const where = args.where;
972
+ if (this.txScoped) {
973
+ const ctx = this.buildNestedCtx();
974
+ return (0, nested_write_js_1.executeNestedUpdate)(ctx, this.table, where, data);
975
+ }
976
+ return this.runInImplicitTx(async (ctx) => {
977
+ const result = await (0, nested_write_js_1.executeNestedUpdate)(ctx, this.table, where, data);
978
+ return result;
979
+ });
980
+ }
981
+ async runInImplicitTx(fn) {
982
+ const client = await this.pool.connect();
983
+ try {
984
+ await client.query('BEGIN');
985
+ const { TransactionClient } = await Promise.resolve().then(() => __importStar(require('../client.js')));
986
+ // biome-ignore lint/suspicious/noExplicitAny: MiddlewareFn and Middleware are structurally identical
987
+ const tx = new TransactionClient(client, this.schema, this.middlewares, this.options);
988
+ // biome-ignore lint/suspicious/noExplicitAny: TransactionClient satisfies NestedWriteContext['tx'] at runtime
989
+ const ctx = { schema: this.schema, tx: tx };
990
+ const result = await fn(ctx);
991
+ await client.query('COMMIT');
992
+ return result;
993
+ }
994
+ catch (err) {
995
+ try {
996
+ await client.query('ROLLBACK');
997
+ }
998
+ catch {
999
+ // Best-effort rollback — connection may have died.
1000
+ }
1001
+ throw err;
1002
+ }
1003
+ finally {
1004
+ client.release();
1005
+ }
1006
+ }
1007
+ buildNestedCtx() {
1008
+ const pool = this.pool;
1009
+ const schema = this.schema;
1010
+ const middlewares = this.middlewares;
1011
+ const opts = { ...this.options, _txScoped: true };
1012
+ return {
1013
+ schema,
1014
+ tx: this.makeTxProxy(pool, schema, middlewares, opts),
1015
+ };
1016
+ }
1017
+ // biome-ignore lint/suspicious/noExplicitAny: bridges MiddlewareFn[] ↔ Middleware[] and QI ↔ NestedWriteContext type gap
1018
+ makeTxProxy(pool, schema, middlewares, opts) {
1019
+ return {
1020
+ table: (name) => new QueryInterface(pool, name, schema, middlewares, opts),
855
1021
  };
856
1022
  }
857
1023
  // -------------------------------------------------------------------------
@@ -1541,6 +1707,12 @@ class QueryInterface {
1541
1707
  parts.push(`${key}:arr(${this.fingerprintArrayFilter(value)})`);
1542
1708
  continue;
1543
1709
  }
1710
+ // Text search filter
1711
+ if (typeof value === 'object' && !Array.isArray(value) && isTextSearchFilter(value)) {
1712
+ const cfg = value.config ?? 'english';
1713
+ parts.push(`${key}:fts(${cfg})`);
1714
+ continue;
1715
+ }
1544
1716
  // Plain equality
1545
1717
  parts.push(`${key}:eq`);
1546
1718
  }
@@ -1656,6 +1828,11 @@ class QueryInterface {
1656
1828
  continue;
1657
1829
  }
1658
1830
  }
1831
+ // Text search filter
1832
+ if (typeof value === 'object' && !Array.isArray(value) && isTextSearchFilter(value)) {
1833
+ params.push(value.search);
1834
+ continue;
1835
+ }
1659
1836
  // Operator objects
1660
1837
  if (isWhereOperator(value)) {
1661
1838
  this.collectOperatorParams(value, params);
@@ -2030,6 +2207,12 @@ class QueryInterface {
2030
2207
  `(actual type: ${colType}); cannot apply array operator '${arrayKey}'.`);
2031
2208
  }
2032
2209
  }
2210
+ // Handle full-text search filter
2211
+ if (typeof value === 'object' && !Array.isArray(value) && isTextSearchFilter(value)) {
2212
+ const tsClause = this.buildTextSearchClause(column, value, params);
2213
+ andClauses.push(tsClause);
2214
+ continue;
2215
+ }
2033
2216
  // Handle operator objects
2034
2217
  if (isWhereOperator(value)) {
2035
2218
  const opClauses = this.buildOperatorClauses(column, value, params);
@@ -2672,6 +2855,18 @@ class QueryInterface {
2672
2855
  }
2673
2856
  return clauses;
2674
2857
  }
2858
+ /**
2859
+ * Build SQL clause for full-text search using to_tsvector @@ to_tsquery.
2860
+ * The config name is validated to prevent injection (only alphanumeric + underscore).
2861
+ */
2862
+ buildTextSearchClause(column, filter, params) {
2863
+ const config = filter.config ?? 'english';
2864
+ if (!validateTextSearchConfig(config)) {
2865
+ throw new errors_js_1.ValidationError(`[turbine] Invalid text search config "${config}": only alphanumeric characters and underscores are allowed.`);
2866
+ }
2867
+ params.push(filter.search);
2868
+ return `to_tsvector('${config}', ${column}) @@ to_tsquery('${config}', ${this.p(params.length)})`;
2869
+ }
2675
2870
  /**
2676
2871
  * Get the Postgres array type for a column (used by UNNEST in createMany).
2677
2872
  * Uses pre-computed Map for O(1) lookup instead of linear scan.
@@ -45,8 +45,16 @@ export interface StudioContext {
45
45
  options: StudioOptions;
46
46
  authToken: string;
47
47
  stateDir: string;
48
- /** Resolved statement timeout SQL string (adapter-aware). */
49
- statementTimeoutSQL: string;
48
+ /** Resolved statement timeout (adapter-aware) — parameterized SQL + values. */
49
+ statementTimeout: {
50
+ sql: string;
51
+ params: unknown[];
52
+ };
53
+ /** Rate limiter state — tracks requests per authenticated session. */
54
+ rateLimiter: Map<string, {
55
+ count: number;
56
+ resetAt: number;
57
+ }>;
50
58
  }
51
59
  /**
52
60
  * Start the Studio server. Returns a handle with the session token, a pre-built
@@ -59,8 +59,12 @@ export async function startStudio(options) {
59
59
  });
60
60
  const authToken = randomBytes(24).toString('hex');
61
61
  const stateDir = pathResolve(options.stateDir ?? '.turbine');
62
- const statementTimeoutSQL = options.adapter?.statementTimeout?.(30) ?? `SET LOCAL statement_timeout = '30s'`;
63
- const ctx = { pool, metadata, options, authToken, stateDir, statementTimeoutSQL };
62
+ const statementTimeout = options.adapter?.statementTimeout?.(30) ?? {
63
+ sql: `SET LOCAL statement_timeout = $1`,
64
+ params: ['30s'],
65
+ };
66
+ const rateLimiter = new Map();
67
+ const ctx = { pool, metadata, options, authToken, stateDir, statementTimeout, rateLimiter };
64
68
  const server = createServer((req, res) => {
65
69
  handleRequest(req, res, ctx).catch((err) => {
66
70
  sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
@@ -137,6 +141,14 @@ async function handleRequest(req, res, ctx) {
137
141
  sendJson(res, 401, { error: 'unauthorized — use the URL printed in the terminal' });
138
142
  return;
139
143
  }
144
+ // Rate limiting — 100 requests per 60 seconds per authenticated session.
145
+ const rateLimitResult = checkRateLimit(ctx.rateLimiter, ctx.authToken);
146
+ if (!rateLimitResult.allowed) {
147
+ const retryAfter = Math.ceil((rateLimitResult.resetAt - Date.now()) / 1000);
148
+ res.setHeader('Retry-After', String(retryAfter));
149
+ sendJson(res, 429, { error: 'Rate limit exceeded', retryAfter });
150
+ return;
151
+ }
140
152
  if (pathname === '/api/schema' && req.method === 'GET') {
141
153
  return apiSchema(res, ctx);
142
154
  }
@@ -177,6 +189,26 @@ function isAuthorized(req, expectedToken) {
177
189
  }
178
190
  return false;
179
191
  }
192
+ // ---------------------------------------------------------------------------
193
+ // Rate limiting
194
+ // ---------------------------------------------------------------------------
195
+ const RATE_LIMIT_WINDOW_MS = 60_000; // 60 seconds
196
+ const RATE_LIMIT_MAX_REQUESTS = 100;
197
+ function checkRateLimit(limiter, token) {
198
+ const now = Date.now();
199
+ const entry = limiter.get(token);
200
+ if (!entry || now >= entry.resetAt) {
201
+ // Start a new window
202
+ const resetAt = now + RATE_LIMIT_WINDOW_MS;
203
+ limiter.set(token, { count: 1, resetAt });
204
+ return { allowed: true, resetAt };
205
+ }
206
+ entry.count++;
207
+ if (entry.count > RATE_LIMIT_MAX_REQUESTS) {
208
+ return { allowed: false, resetAt: entry.resetAt };
209
+ }
210
+ return { allowed: true, resetAt: entry.resetAt };
211
+ }
180
212
  function constantTimeEqual(a, b) {
181
213
  if (a.length !== b.length)
182
214
  return false;
@@ -262,7 +294,7 @@ export async function apiTableRows(res, ctx, rawTableName, params) {
262
294
  let mainWhere = '';
263
295
  if (hasSearch && pattern !== null) {
264
296
  mainValues.push(pattern);
265
- const conds = textColumns.map((c) => `${quoteIdent(c)} ILIKE $3`);
297
+ const conds = textColumns.map((c) => `${quoteIdent(c)} ILIKE $3 ESCAPE '\\'`);
266
298
  mainWhere = `WHERE (${conds.join(' OR ')})`;
267
299
  }
268
300
  // Count query: $1 = pattern (if search)
@@ -270,7 +302,7 @@ export async function apiTableRows(res, ctx, rawTableName, params) {
270
302
  let countWhere = '';
271
303
  if (hasSearch && pattern !== null) {
272
304
  countValues.push(pattern);
273
- const conds = textColumns.map((c) => `${quoteIdent(c)} ILIKE $1`);
305
+ const conds = textColumns.map((c) => `${quoteIdent(c)} ILIKE $1 ESCAPE '\\'`);
274
306
  countWhere = `WHERE (${conds.join(' OR ')})`;
275
307
  }
276
308
  const qualifiedTable = `${quoteIdent(ctx.options.schema)}.${quoteIdent(table.name)}`;
@@ -279,7 +311,7 @@ export async function apiTableRows(res, ctx, rawTableName, params) {
279
311
  const client = await ctx.pool.connect();
280
312
  try {
281
313
  await client.query('BEGIN READ ONLY');
282
- await client.query(ctx.statementTimeoutSQL);
314
+ await client.query(ctx.statementTimeout.sql, ctx.statementTimeout.params);
283
315
  const result = await client.query(sql, mainValues);
284
316
  const countResult = await client.query(countSql, countValues);
285
317
  await client.query('COMMIT');
@@ -336,6 +368,10 @@ async function apiQuery(req, res, ctx) {
336
368
  sendJson(res, 400, { error: 'missing sql' });
337
369
  return;
338
370
  }
371
+ if (rawSql.length > 10_000) {
372
+ sendJson(res, 400, { error: 'query too long — maximum 10,000 characters allowed' });
373
+ return;
374
+ }
339
375
  if (!isReadOnlyStatement(rawSql)) {
340
376
  sendJson(res, 400, {
341
377
  error: 'only SELECT / WITH statements are allowed in Studio — use the CLI for writes',
@@ -345,7 +381,7 @@ async function apiQuery(req, res, ctx) {
345
381
  const client = await ctx.pool.connect();
346
382
  try {
347
383
  await client.query('BEGIN READ ONLY');
348
- await client.query(ctx.statementTimeoutSQL);
384
+ await client.query(ctx.statementTimeout.sql, ctx.statementTimeout.params);
349
385
  const started = Date.now();
350
386
  const result = await client.query(rawSql);
351
387
  const elapsedMs = Date.now() - started;
@@ -397,7 +433,7 @@ export async function apiBuilder(req, res, ctx) {
397
433
  const client = await ctx.pool.connect();
398
434
  try {
399
435
  await client.query('BEGIN READ ONLY');
400
- await client.query(ctx.statementTimeoutSQL);
436
+ await client.query(ctx.statementTimeout.sql, ctx.statementTimeout.params);
401
437
  const started = Date.now();
402
438
  const result = await client.query(deferred.sql, deferred.params);
403
439
  const elapsedMs = Date.now() - started;
@@ -593,6 +629,7 @@ function sendJson(res, status, body) {
593
629
  'Cache-Control': 'no-store',
594
630
  'X-Content-Type-Options': 'nosniff',
595
631
  'Referrer-Policy': 'no-referrer',
632
+ 'Content-Security-Policy': "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-ancestors 'none'",
596
633
  });
597
634
  res.end(payload);
598
635
  }
@@ -611,6 +648,7 @@ function sendHtml(res, status, body) {
611
648
  'X-Content-Type-Options': 'nosniff',
612
649
  'X-Frame-Options': 'DENY',
613
650
  'Referrer-Policy': 'no-referrer',
651
+ 'Content-Security-Policy': "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-ancestors 'none'",
614
652
  });
615
653
  res.end(body);
616
654
  }
package/dist/client.d.ts CHANGED
@@ -27,6 +27,13 @@ import { type ErrorMessageMode } from './errors.js';
27
27
  import { type PipelineOptions, type PipelineResults } from './pipeline.js';
28
28
  import { type DeferredQuery, QueryInterface, type QueryInterfaceOptions } from './query/index.js';
29
29
  import type { SchemaMetadata } from './schema.js';
30
+ export interface RetryOptions {
31
+ maxAttempts?: number;
32
+ baseDelay?: number;
33
+ maxDelay?: number;
34
+ onRetry?: (error: unknown, attempt: number) => void;
35
+ }
36
+ export declare function withRetry<T>(fn: () => Promise<T>, options?: RetryOptions): Promise<T>;
30
37
  /**
31
38
  * Minimal pg-compatible query result.
32
39
  * `pg.Pool`, `@neondatabase/serverless` Pool, `@vercel/postgres` Pool and
@@ -316,6 +323,22 @@ export declare class TurbineClient {
316
323
  * ```
317
324
  */
318
325
  $transaction<R>(fn: (tx: TransactionClient) => Promise<R>, options?: TransactionOptions): Promise<R>;
326
+ /**
327
+ * Execute an async function with automatic retry on retryable errors.
328
+ *
329
+ * Only errors with `isRetryable === true` (DeadlockError, SerializationFailureError)
330
+ * are retried. Uses exponential backoff with jitter.
331
+ *
332
+ * @example
333
+ * ```ts
334
+ * const result = await db.$retry(() =>
335
+ * db.$transaction(async (tx) => {
336
+ * // ... serializable transaction logic
337
+ * }, { isolationLevel: 'Serializable' })
338
+ * );
339
+ * ```
340
+ */
341
+ $retry<T>(fn: () => Promise<T>, options?: RetryOptions): Promise<T>;
319
342
  /**
320
343
  * Test the database connection.
321
344
  * Throws if the connection fails.
package/dist/client.js CHANGED
@@ -25,6 +25,30 @@ import pg from 'pg';
25
25
  import { setErrorMessageMode, TimeoutError, wrapPgError } from './errors.js';
26
26
  import { executePipeline, pipelineSupported } from './pipeline.js';
27
27
  import { QueryInterface } from './query/index.js';
28
+ export async function withRetry(fn, options) {
29
+ const maxAttempts = options?.maxAttempts ?? 3;
30
+ const baseDelay = options?.baseDelay ?? 50;
31
+ const maxDelay = options?.maxDelay ?? 5000;
32
+ let lastError;
33
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
34
+ try {
35
+ return await fn();
36
+ }
37
+ catch (err) {
38
+ lastError = err;
39
+ const isRetryable = err &&
40
+ typeof err === 'object' &&
41
+ 'isRetryable' in err &&
42
+ err.isRetryable === true;
43
+ if (!isRetryable || attempt === maxAttempts - 1)
44
+ throw err;
45
+ options?.onRetry?.(err, attempt + 1);
46
+ const delay = Math.min(baseDelay * 2 ** attempt + Math.random() * baseDelay, maxDelay);
47
+ await new Promise((resolve) => setTimeout(resolve, delay));
48
+ }
49
+ }
50
+ throw lastError;
51
+ }
28
52
  /** Maps isolation level names to SQL */
29
53
  const ISOLATION_LEVELS = {
30
54
  ReadUncommitted: 'READ UNCOMMITTED',
@@ -73,7 +97,8 @@ export class TransactionClient {
73
97
  // Create a QueryInterface that uses the transaction client as its "pool"
74
98
  // We use a proxy pool that routes queries through the transaction client
75
99
  const txPool = this.createTxPool();
76
- qi = new QueryInterface(txPool, name, this.schema, this.middlewares, this.queryOptions);
100
+ const txOpts = { ...this.queryOptions, _txScoped: true };
101
+ qi = new QueryInterface(txPool, name, this.schema, this.middlewares, txOpts);
77
102
  this.tableCache.set(name, qi);
78
103
  }
79
104
  return qi;
@@ -535,6 +560,27 @@ export class TurbineClient {
535
560
  }
536
561
  }
537
562
  // -------------------------------------------------------------------------
563
+ // Retry — automatic retry for retryable errors (deadlock, serialization)
564
+ // -------------------------------------------------------------------------
565
+ /**
566
+ * Execute an async function with automatic retry on retryable errors.
567
+ *
568
+ * Only errors with `isRetryable === true` (DeadlockError, SerializationFailureError)
569
+ * are retried. Uses exponential backoff with jitter.
570
+ *
571
+ * @example
572
+ * ```ts
573
+ * const result = await db.$retry(() =>
574
+ * db.$transaction(async (tx) => {
575
+ * // ... serializable transaction logic
576
+ * }, { isolationLevel: 'Serializable' })
577
+ * );
578
+ * ```
579
+ */
580
+ async $retry(fn, options) {
581
+ return withRetry(fn, options);
582
+ }
583
+ // -------------------------------------------------------------------------
538
584
  // Connection lifecycle
539
585
  // -------------------------------------------------------------------------
540
586
  /**
package/dist/errors.d.ts CHANGED
@@ -20,6 +20,8 @@ export declare const TurbineErrorCode: {
20
20
  readonly DEADLOCK_DETECTED: "TURBINE_E012";
21
21
  readonly SERIALIZATION_FAILURE: "TURBINE_E013";
22
22
  readonly PIPELINE: "TURBINE_E014";
23
+ readonly OPTIMISTIC_LOCK: "TURBINE_E015";
24
+ readonly EXCLUSION_VIOLATION: "TURBINE_E016";
23
25
  };
24
26
  export type TurbineErrorCode = (typeof TurbineErrorCode)[keyof typeof TurbineErrorCode];
25
27
  /** Base error class for all Turbine errors */
@@ -208,6 +210,16 @@ export declare class CheckConstraintError extends TurbineError {
208
210
  cause?: unknown;
209
211
  });
210
212
  }
213
+ export declare class ExclusionConstraintError extends TurbineError {
214
+ readonly constraint?: string;
215
+ readonly table?: string;
216
+ constructor(opts?: {
217
+ constraint?: string;
218
+ table?: string;
219
+ message?: string;
220
+ cause?: unknown;
221
+ });
222
+ }
211
223
  /** Result slot for a single query in a non-transactional pipeline */
212
224
  export type PipelineResultSlot = {
213
225
  status: 'ok';
@@ -251,6 +263,16 @@ export declare class PipelineError extends TurbineError {
251
263
  cause?: unknown;
252
264
  });
253
265
  }
266
+ export declare class OptimisticLockError extends TurbineError {
267
+ readonly table: string;
268
+ readonly versionField: string;
269
+ readonly expectedVersion: unknown;
270
+ constructor(opts: {
271
+ table: string;
272
+ versionField: string;
273
+ expectedVersion: unknown;
274
+ });
275
+ }
254
276
  /**
255
277
  * Translate a pg driver error into a typed Turbine error.
256
278
  * If the error doesn't match a known constraint code, returns it unchanged.
@@ -260,6 +282,7 @@ export declare class PipelineError extends TurbineError {
260
282
  * 23503 (foreign_key_violation) -> ForeignKeyError
261
283
  * 23502 (not_null_violation) -> NotNullViolationError
262
284
  * 23514 (check_violation) -> CheckConstraintError
285
+ * 23P01 (exclusion_violation) -> ExclusionConstraintError
263
286
  * 40P01 (deadlock_detected) -> DeadlockError (retryable)
264
287
  * 40001 (serialization_failure) -> SerializationFailureError (retryable)
265
288
  *