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.
- package/dist/adapters/cockroachdb.js +1 -1
- package/dist/adapters/index.d.ts +7 -4
- package/dist/adapters/index.js +1 -1
- package/dist/adapters/yugabytedb.js +1 -1
- package/dist/cjs/adapters/cockroachdb.js +1 -1
- package/dist/cjs/adapters/index.js +1 -1
- package/dist/cjs/adapters/yugabytedb.js +1 -1
- package/dist/cjs/cli/index.js +64 -0
- package/dist/cjs/cli/observe-ui.js +182 -0
- package/dist/cjs/cli/observe.js +242 -0
- package/dist/cjs/cli/studio.js +45 -7
- package/dist/cjs/client.js +102 -1
- package/dist/cjs/errors.js +44 -1
- package/dist/cjs/generate.js +86 -0
- package/dist/cjs/index.js +10 -1
- package/dist/cjs/nested-write.js +557 -0
- package/dist/cjs/observe.js +145 -0
- package/dist/cjs/query/builder.js +271 -23
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +64 -0
- package/dist/cli/observe-ui.d.ts +2 -0
- package/dist/cli/observe-ui.js +180 -0
- package/dist/cli/observe.d.ts +20 -0
- package/dist/cli/observe.js +237 -0
- package/dist/cli/studio.d.ts +10 -2
- package/dist/cli/studio.js +45 -7
- package/dist/client.d.ts +32 -2
- package/dist/client.js +102 -2
- package/dist/errors.d.ts +23 -0
- package/dist/errors.js +41 -0
- package/dist/generate.js +86 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.js +4 -2
- package/dist/nested-write.d.ts +95 -0
- package/dist/nested-write.js +551 -0
- package/dist/observe.d.ts +36 -0
- package/dist/observe.js +141 -0
- package/dist/query/builder.d.ts +45 -12
- package/dist/query/builder.js +239 -24
- package/dist/query/index.d.ts +2 -2
- package/dist/query/types.d.ts +76 -8
- package/package.json +2 -2
package/dist/cli/studio.js
CHANGED
|
@@ -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
|
|
63
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
@@ -24,9 +24,17 @@
|
|
|
24
24
|
import pg from 'pg';
|
|
25
25
|
import type { Dialect } from './dialect.js';
|
|
26
26
|
import { type ErrorMessageMode } from './errors.js';
|
|
27
|
+
import { type ObserveConfig, type ObserveHandle } from './observe.js';
|
|
27
28
|
import { type PipelineOptions, type PipelineResults } from './pipeline.js';
|
|
28
|
-
import { type DeferredQuery, QueryInterface, type QueryInterfaceOptions } from './query/index.js';
|
|
29
|
+
import { type DeferredQuery, type QueryEventListener, QueryInterface, type QueryInterfaceOptions } from './query/index.js';
|
|
29
30
|
import type { SchemaMetadata } from './schema.js';
|
|
31
|
+
export interface RetryOptions {
|
|
32
|
+
maxAttempts?: number;
|
|
33
|
+
baseDelay?: number;
|
|
34
|
+
maxDelay?: number;
|
|
35
|
+
onRetry?: (error: unknown, attempt: number) => void;
|
|
36
|
+
}
|
|
37
|
+
export declare function withRetry<T>(fn: () => Promise<T>, options?: RetryOptions): Promise<T>;
|
|
30
38
|
/**
|
|
31
39
|
* Minimal pg-compatible query result.
|
|
32
40
|
* `pg.Pool`, `@neondatabase/serverless` Pool, `@vercel/postgres` Pool and
|
|
@@ -212,7 +220,9 @@ export declare class TurbineClient {
|
|
|
212
220
|
private readonly logging;
|
|
213
221
|
private readonly tableCache;
|
|
214
222
|
private readonly middlewares;
|
|
215
|
-
private readonly
|
|
223
|
+
private readonly queryListeners;
|
|
224
|
+
private queryOptions;
|
|
225
|
+
private readonly errorMessagesSafe;
|
|
216
226
|
/** True when Turbine created the pool and is responsible for tearing it down */
|
|
217
227
|
private readonly ownsPool;
|
|
218
228
|
constructor(config: TurbineConfig | undefined, schema: SchemaMetadata);
|
|
@@ -248,6 +258,10 @@ export declare class TurbineClient {
|
|
|
248
258
|
* ```
|
|
249
259
|
*/
|
|
250
260
|
$use(middleware: Middleware): void;
|
|
261
|
+
$on(_event: 'query', listener: QueryEventListener): void;
|
|
262
|
+
$off(_event: 'query', listener: QueryEventListener): void;
|
|
263
|
+
private observeEngine?;
|
|
264
|
+
$observe(config: ObserveConfig): Promise<ObserveHandle>;
|
|
251
265
|
/**
|
|
252
266
|
* Get a QueryInterface for a table.
|
|
253
267
|
* Results are cached — calling `table('users')` twice returns the same instance.
|
|
@@ -316,6 +330,22 @@ export declare class TurbineClient {
|
|
|
316
330
|
* ```
|
|
317
331
|
*/
|
|
318
332
|
$transaction<R>(fn: (tx: TransactionClient) => Promise<R>, options?: TransactionOptions): Promise<R>;
|
|
333
|
+
/**
|
|
334
|
+
* Execute an async function with automatic retry on retryable errors.
|
|
335
|
+
*
|
|
336
|
+
* Only errors with `isRetryable === true` (DeadlockError, SerializationFailureError)
|
|
337
|
+
* are retried. Uses exponential backoff with jitter.
|
|
338
|
+
*
|
|
339
|
+
* @example
|
|
340
|
+
* ```ts
|
|
341
|
+
* const result = await db.$retry(() =>
|
|
342
|
+
* db.$transaction(async (tx) => {
|
|
343
|
+
* // ... serializable transaction logic
|
|
344
|
+
* }, { isolationLevel: 'Serializable' })
|
|
345
|
+
* );
|
|
346
|
+
* ```
|
|
347
|
+
*/
|
|
348
|
+
$retry<T>(fn: () => Promise<T>, options?: RetryOptions): Promise<T>;
|
|
319
349
|
/**
|
|
320
350
|
* Test the database connection.
|
|
321
351
|
* Throws if the connection fails.
|
package/dist/client.js
CHANGED
|
@@ -23,8 +23,33 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import pg from 'pg';
|
|
25
25
|
import { setErrorMessageMode, TimeoutError, wrapPgError } from './errors.js';
|
|
26
|
+
import { ObserveEngine } from './observe.js';
|
|
26
27
|
import { executePipeline, pipelineSupported } from './pipeline.js';
|
|
27
|
-
import { QueryInterface } from './query/index.js';
|
|
28
|
+
import { QueryInterface, } from './query/index.js';
|
|
29
|
+
export async function withRetry(fn, options) {
|
|
30
|
+
const maxAttempts = options?.maxAttempts ?? 3;
|
|
31
|
+
const baseDelay = options?.baseDelay ?? 50;
|
|
32
|
+
const maxDelay = options?.maxDelay ?? 5000;
|
|
33
|
+
let lastError;
|
|
34
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
35
|
+
try {
|
|
36
|
+
return await fn();
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
lastError = err;
|
|
40
|
+
const isRetryable = err &&
|
|
41
|
+
typeof err === 'object' &&
|
|
42
|
+
'isRetryable' in err &&
|
|
43
|
+
err.isRetryable === true;
|
|
44
|
+
if (!isRetryable || attempt === maxAttempts - 1)
|
|
45
|
+
throw err;
|
|
46
|
+
options?.onRetry?.(err, attempt + 1);
|
|
47
|
+
const delay = Math.min(baseDelay * 2 ** attempt + Math.random() * baseDelay, maxDelay);
|
|
48
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
throw lastError;
|
|
52
|
+
}
|
|
28
53
|
/** Maps isolation level names to SQL */
|
|
29
54
|
const ISOLATION_LEVELS = {
|
|
30
55
|
ReadUncommitted: 'READ UNCOMMITTED',
|
|
@@ -73,7 +98,8 @@ export class TransactionClient {
|
|
|
73
98
|
// Create a QueryInterface that uses the transaction client as its "pool"
|
|
74
99
|
// We use a proxy pool that routes queries through the transaction client
|
|
75
100
|
const txPool = this.createTxPool();
|
|
76
|
-
|
|
101
|
+
const txOpts = { ...this.queryOptions, _txScoped: true };
|
|
102
|
+
qi = new QueryInterface(txPool, name, this.schema, this.middlewares, txOpts);
|
|
77
103
|
this.tableCache.set(name, qi);
|
|
78
104
|
}
|
|
79
105
|
return qi;
|
|
@@ -158,7 +184,9 @@ export class TurbineClient {
|
|
|
158
184
|
logging;
|
|
159
185
|
tableCache = new Map();
|
|
160
186
|
middlewares = [];
|
|
187
|
+
queryListeners = new Set();
|
|
161
188
|
queryOptions;
|
|
189
|
+
errorMessagesSafe;
|
|
162
190
|
/** True when Turbine created the pool and is responsible for tearing it down */
|
|
163
191
|
ownsPool = true;
|
|
164
192
|
constructor(config = {}, schema) {
|
|
@@ -192,12 +220,27 @@ export class TurbineClient {
|
|
|
192
220
|
this.schema = schema;
|
|
193
221
|
// Respect env var kill switch
|
|
194
222
|
const envDisablePrepared = typeof process !== 'undefined' && process.env?.TURBINE_DISABLE_PREPARED === '1';
|
|
223
|
+
this.errorMessagesSafe = (config.errorMessages ?? 'safe') === 'safe';
|
|
195
224
|
this.queryOptions = {
|
|
196
225
|
defaultLimit: config.defaultLimit,
|
|
197
226
|
warnOnUnlimited: config.warnOnUnlimited,
|
|
198
227
|
preparedStatements: envDisablePrepared ? false : (config.preparedStatements ?? !config.pool),
|
|
199
228
|
sqlCache: config.sqlCache ?? true,
|
|
200
229
|
dialect: config.dialect,
|
|
230
|
+
_onQuery: (event) => {
|
|
231
|
+
if (this.queryListeners.size === 0)
|
|
232
|
+
return;
|
|
233
|
+
const emitted = this.errorMessagesSafe ? { ...event, params: event.params.map(() => '[REDACTED]') } : event;
|
|
234
|
+
for (const listener of this.queryListeners) {
|
|
235
|
+
try {
|
|
236
|
+
listener(emitted);
|
|
237
|
+
}
|
|
238
|
+
catch (e) {
|
|
239
|
+
if (this.logging)
|
|
240
|
+
console.error('[turbine] Query listener error:', e);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
},
|
|
201
244
|
};
|
|
202
245
|
// Apply NotFoundError message redaction mode (default: safe — values are
|
|
203
246
|
// stripped from messages to avoid leaking PII into error logs).
|
|
@@ -250,6 +293,11 @@ export class TurbineClient {
|
|
|
250
293
|
});
|
|
251
294
|
}
|
|
252
295
|
}
|
|
296
|
+
// Auto-start observability from env var
|
|
297
|
+
const observeUrl = typeof process !== 'undefined' ? process.env?.TURBINE_OBSERVE_URL : undefined;
|
|
298
|
+
if (observeUrl) {
|
|
299
|
+
this.$observe({ connectionString: observeUrl }).catch(() => { });
|
|
300
|
+
}
|
|
253
301
|
}
|
|
254
302
|
// -------------------------------------------------------------------------
|
|
255
303
|
// Middleware — intercept all queries
|
|
@@ -291,6 +339,37 @@ export class TurbineClient {
|
|
|
291
339
|
this.tableCache.clear();
|
|
292
340
|
}
|
|
293
341
|
// -------------------------------------------------------------------------
|
|
342
|
+
// Event emitter — subscribe to query lifecycle events
|
|
343
|
+
// -------------------------------------------------------------------------
|
|
344
|
+
$on(_event, listener) {
|
|
345
|
+
this.queryListeners.add(listener);
|
|
346
|
+
}
|
|
347
|
+
$off(_event, listener) {
|
|
348
|
+
this.queryListeners.delete(listener);
|
|
349
|
+
}
|
|
350
|
+
// -------------------------------------------------------------------------
|
|
351
|
+
// Observability — automatic metrics collection
|
|
352
|
+
// -------------------------------------------------------------------------
|
|
353
|
+
observeEngine;
|
|
354
|
+
async $observe(config) {
|
|
355
|
+
if (this.observeEngine) {
|
|
356
|
+
await this.observeEngine.stop();
|
|
357
|
+
this.$off('query', this.observeEngine.getListener());
|
|
358
|
+
}
|
|
359
|
+
const engine = new ObserveEngine(config);
|
|
360
|
+
this.observeEngine = engine;
|
|
361
|
+
await engine.init();
|
|
362
|
+
this.$on('query', engine.getListener());
|
|
363
|
+
return {
|
|
364
|
+
stop: async () => {
|
|
365
|
+
this.$off('query', engine.getListener());
|
|
366
|
+
await engine.stop();
|
|
367
|
+
if (this.observeEngine === engine)
|
|
368
|
+
this.observeEngine = undefined;
|
|
369
|
+
},
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
// -------------------------------------------------------------------------
|
|
294
373
|
// Table accessor — creates QueryInterface for any table
|
|
295
374
|
// -------------------------------------------------------------------------
|
|
296
375
|
/**
|
|
@@ -535,6 +614,27 @@ export class TurbineClient {
|
|
|
535
614
|
}
|
|
536
615
|
}
|
|
537
616
|
// -------------------------------------------------------------------------
|
|
617
|
+
// Retry — automatic retry for retryable errors (deadlock, serialization)
|
|
618
|
+
// -------------------------------------------------------------------------
|
|
619
|
+
/**
|
|
620
|
+
* Execute an async function with automatic retry on retryable errors.
|
|
621
|
+
*
|
|
622
|
+
* Only errors with `isRetryable === true` (DeadlockError, SerializationFailureError)
|
|
623
|
+
* are retried. Uses exponential backoff with jitter.
|
|
624
|
+
*
|
|
625
|
+
* @example
|
|
626
|
+
* ```ts
|
|
627
|
+
* const result = await db.$retry(() =>
|
|
628
|
+
* db.$transaction(async (tx) => {
|
|
629
|
+
* // ... serializable transaction logic
|
|
630
|
+
* }, { isolationLevel: 'Serializable' })
|
|
631
|
+
* );
|
|
632
|
+
* ```
|
|
633
|
+
*/
|
|
634
|
+
async $retry(fn, options) {
|
|
635
|
+
return withRetry(fn, options);
|
|
636
|
+
}
|
|
637
|
+
// -------------------------------------------------------------------------
|
|
538
638
|
// Connection lifecycle
|
|
539
639
|
// -------------------------------------------------------------------------
|
|
540
640
|
/**
|
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
|
*
|
package/dist/errors.js
CHANGED
|
@@ -20,6 +20,8 @@ export const TurbineErrorCode = {
|
|
|
20
20
|
DEADLOCK_DETECTED: 'TURBINE_E012',
|
|
21
21
|
SERIALIZATION_FAILURE: 'TURBINE_E013',
|
|
22
22
|
PIPELINE: 'TURBINE_E014',
|
|
23
|
+
OPTIMISTIC_LOCK: 'TURBINE_E015',
|
|
24
|
+
EXCLUSION_VIOLATION: 'TURBINE_E016',
|
|
23
25
|
};
|
|
24
26
|
/** Base error class for all Turbine errors */
|
|
25
27
|
export class TurbineError extends Error {
|
|
@@ -331,6 +333,25 @@ export class CheckConstraintError extends TurbineError {
|
|
|
331
333
|
this.table = table;
|
|
332
334
|
}
|
|
333
335
|
}
|
|
336
|
+
export class ExclusionConstraintError extends TurbineError {
|
|
337
|
+
constraint;
|
|
338
|
+
table;
|
|
339
|
+
constructor(opts = {}) {
|
|
340
|
+
const { constraint, table, cause } = opts;
|
|
341
|
+
let message = opts.message;
|
|
342
|
+
if (!message) {
|
|
343
|
+
const constraintPart = constraint ? ` on ${constraint}` : '';
|
|
344
|
+
message = `[turbine] Exclusion constraint violation${constraintPart}`;
|
|
345
|
+
const detail = detailFromCause(cause);
|
|
346
|
+
if (detail)
|
|
347
|
+
message += `: ${detail}`;
|
|
348
|
+
}
|
|
349
|
+
super(TurbineErrorCode.EXCLUSION_VIOLATION, message, { cause });
|
|
350
|
+
this.name = 'ExclusionConstraintError';
|
|
351
|
+
this.constraint = constraint;
|
|
352
|
+
this.table = table;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
334
355
|
/**
|
|
335
356
|
* Thrown when a non-transactional pipeline has partial failures.
|
|
336
357
|
*
|
|
@@ -371,6 +392,19 @@ export class PipelineError extends TurbineError {
|
|
|
371
392
|
this.failedTag = failedTag;
|
|
372
393
|
}
|
|
373
394
|
}
|
|
395
|
+
export class OptimisticLockError extends TurbineError {
|
|
396
|
+
table;
|
|
397
|
+
versionField;
|
|
398
|
+
expectedVersion;
|
|
399
|
+
constructor(opts) {
|
|
400
|
+
super(TurbineErrorCode.OPTIMISTIC_LOCK, `[turbine] Optimistic lock failed on "${opts.table}" — ` +
|
|
401
|
+
`expected ${opts.versionField} = ${opts.expectedVersion} but row was modified by another transaction`);
|
|
402
|
+
this.name = 'OptimisticLockError';
|
|
403
|
+
this.table = opts.table;
|
|
404
|
+
this.versionField = opts.versionField;
|
|
405
|
+
this.expectedVersion = opts.expectedVersion;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
374
408
|
/**
|
|
375
409
|
* Parse column names out of a pg `detail` string like:
|
|
376
410
|
* "Key (email)=(foo@bar) already exists."
|
|
@@ -391,6 +425,7 @@ function parseColumnsFromDetail(detail) {
|
|
|
391
425
|
* 23503 (foreign_key_violation) -> ForeignKeyError
|
|
392
426
|
* 23502 (not_null_violation) -> NotNullViolationError
|
|
393
427
|
* 23514 (check_violation) -> CheckConstraintError
|
|
428
|
+
* 23P01 (exclusion_violation) -> ExclusionConstraintError
|
|
394
429
|
* 40P01 (deadlock_detected) -> DeadlockError (retryable)
|
|
395
430
|
* 40001 (serialization_failure) -> SerializationFailureError (retryable)
|
|
396
431
|
*
|
|
@@ -430,6 +465,12 @@ export function wrapPgError(err) {
|
|
|
430
465
|
table: e.table,
|
|
431
466
|
cause: err,
|
|
432
467
|
});
|
|
468
|
+
case '23P01':
|
|
469
|
+
return new ExclusionConstraintError({
|
|
470
|
+
constraint: e.constraint,
|
|
471
|
+
table: e.table,
|
|
472
|
+
cause: err,
|
|
473
|
+
});
|
|
433
474
|
case '40P01':
|
|
434
475
|
return new DeadlockError({
|
|
435
476
|
constraint: e.constraint,
|
package/dist/generate.js
CHANGED
|
@@ -177,6 +177,92 @@ export function generateTypes(schema) {
|
|
|
177
177
|
}
|
|
178
178
|
}
|
|
179
179
|
}
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// Nested write types (WhereUnique, NestedCreateInput, NestedUpdateInput,
|
|
182
|
+
// ConnectOrCreate, CreateInput, UpdateInput)
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
for (const table of Object.values(schema.tables)) {
|
|
185
|
+
const typeName = entityName(table.name);
|
|
186
|
+
const hasRels = Object.keys(table.relations).length > 0;
|
|
187
|
+
// WhereUnique — union of unique constraint shapes, deduplicating PK
|
|
188
|
+
const seen = new Set();
|
|
189
|
+
const uniqueSets = [];
|
|
190
|
+
// Always include the primary key first
|
|
191
|
+
const pkKey = table.primaryKey.join(',');
|
|
192
|
+
seen.add(pkKey);
|
|
193
|
+
uniqueSets.push(table.primaryKey);
|
|
194
|
+
// Add unique indexes that aren't duplicates of the PK
|
|
195
|
+
for (const uc of table.uniqueColumns) {
|
|
196
|
+
const ucKey = uc.join(',');
|
|
197
|
+
if (!seen.has(ucKey)) {
|
|
198
|
+
seen.add(ucKey);
|
|
199
|
+
uniqueSets.push(uc);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (uniqueSets.length > 0) {
|
|
203
|
+
const branches = uniqueSets.map((cols) => {
|
|
204
|
+
const fields = cols.map((colName) => {
|
|
205
|
+
const col = table.columns.find((c) => c.name === colName);
|
|
206
|
+
const field = col?.field ?? colName;
|
|
207
|
+
const tsType = col?.tsType ?? 'unknown';
|
|
208
|
+
return `${field}: ${tsType}`;
|
|
209
|
+
});
|
|
210
|
+
return `{ ${fields.join('; ')} }`;
|
|
211
|
+
});
|
|
212
|
+
lines.push(`export type ${typeName}WhereUnique = ${branches.join(' | ')};`);
|
|
213
|
+
lines.push('');
|
|
214
|
+
}
|
|
215
|
+
// CreateInput / UpdateInput — extends base type with optional relation fields
|
|
216
|
+
if (hasRels) {
|
|
217
|
+
lines.push(`export type ${typeName}CreateInput = ${typeName}Create & {`);
|
|
218
|
+
for (const [relName, rel] of Object.entries(table.relations)) {
|
|
219
|
+
const targetType = entityName(rel.to);
|
|
220
|
+
lines.push(` ${relName}?: ${targetType}NestedCreateInput;`);
|
|
221
|
+
}
|
|
222
|
+
lines.push('};');
|
|
223
|
+
lines.push('');
|
|
224
|
+
lines.push(`export type ${typeName}UpdateInput = ${typeName}Update & {`);
|
|
225
|
+
for (const [relName, rel] of Object.entries(table.relations)) {
|
|
226
|
+
const targetType = entityName(rel.to);
|
|
227
|
+
if (rel.type === 'hasMany') {
|
|
228
|
+
lines.push(` ${relName}?: ${targetType}NestedUpdateInput;`);
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
lines.push(` ${relName}?: ${targetType}NestedCreateInput;`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
lines.push('};');
|
|
235
|
+
lines.push('');
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
// Emit NestedCreateInput, NestedUpdateInput, ConnectOrCreate for every table
|
|
239
|
+
for (const table of Object.values(schema.tables)) {
|
|
240
|
+
const typeName = entityName(table.name);
|
|
241
|
+
const hasRels = Object.keys(table.relations).length > 0;
|
|
242
|
+
// NestedCreateInput uses *CreateInput (which includes relation fields) when
|
|
243
|
+
// the table has relations, otherwise falls back to the plain *Create type.
|
|
244
|
+
const createRefType = hasRels ? `${typeName}CreateInput` : `${typeName}Create`;
|
|
245
|
+
lines.push(`export interface ${typeName}NestedCreateInput {`);
|
|
246
|
+
lines.push(` create?: ${createRefType} | ${createRefType}[];`);
|
|
247
|
+
lines.push(` connect?: ${typeName}WhereUnique | ${typeName}WhereUnique[];`);
|
|
248
|
+
lines.push(` connectOrCreate?: ${typeName}ConnectOrCreate | ${typeName}ConnectOrCreate[];`);
|
|
249
|
+
lines.push('}');
|
|
250
|
+
lines.push('');
|
|
251
|
+
lines.push(`export interface ${typeName}NestedUpdateInput {`);
|
|
252
|
+
lines.push(` create?: ${createRefType} | ${createRefType}[];`);
|
|
253
|
+
lines.push(` connect?: ${typeName}WhereUnique | ${typeName}WhereUnique[];`);
|
|
254
|
+
lines.push(` connectOrCreate?: ${typeName}ConnectOrCreate | ${typeName}ConnectOrCreate[];`);
|
|
255
|
+
lines.push(` disconnect?: ${typeName}WhereUnique | ${typeName}WhereUnique[];`);
|
|
256
|
+
lines.push(` set?: ${typeName}WhereUnique[];`);
|
|
257
|
+
lines.push(` delete?: ${typeName}WhereUnique | ${typeName}WhereUnique[];`);
|
|
258
|
+
lines.push('}');
|
|
259
|
+
lines.push('');
|
|
260
|
+
lines.push(`export interface ${typeName}ConnectOrCreate {`);
|
|
261
|
+
lines.push(` where: ${typeName}WhereUnique;`);
|
|
262
|
+
lines.push(` create: ${createRefType};`);
|
|
263
|
+
lines.push('}');
|
|
264
|
+
lines.push('');
|
|
265
|
+
}
|
|
180
266
|
return lines.join('\n');
|
|
181
267
|
}
|
|
182
268
|
// ---------------------------------------------------------------------------
|
package/dist/index.d.ts
CHANGED
|
@@ -34,14 +34,16 @@
|
|
|
34
34
|
*/
|
|
35
35
|
export type { DatabaseAdapter, IntrospectionOverrides } from './adapters/index.js';
|
|
36
36
|
export { alloydb, cockroachdb, postgresql, timescale, yugabytedb } from './adapters/index.js';
|
|
37
|
-
export { type Middleware, type MiddlewareNext, type MiddlewareParams, type PgCompatPool, type PgCompatPoolClient, type PgCompatQueryResult, TransactionClient, type TransactionOptions, TurbineClient, type TurbineConfig, } from './client.js';
|
|
37
|
+
export { type Middleware, type MiddlewareNext, type MiddlewareParams, type PgCompatPool, type PgCompatPoolClient, type PgCompatQueryResult, type RetryOptions, TransactionClient, type TransactionOptions, TurbineClient, type TurbineConfig, withRetry, } from './client.js';
|
|
38
38
|
export type { BuiltStatement, BulkInsertStatementInput, ColumnDefinitionInput, ColumnTypeInput, CreateIndexStatementInput, CreateTableStatementInput, Dialect, DialectIntrospector, DialectMigrator, DialectName, InsertStatementInput, IntrospectOptions as DialectIntrospectOptions, UpsertStatementInput, } from './dialect.js';
|
|
39
39
|
export { postgresDialect } from './dialect.js';
|
|
40
|
-
export { CheckConstraintError, CircularRelationError, ConnectionError, DeadlockError, type ErrorMessageMode, ForeignKeyError, getErrorMessageMode, MigrationError, NotFoundError, NotNullViolationError, PipelineError, type PipelineResultSlot, RelationError, SerializationFailureError, setErrorMessageMode, TimeoutError, TurbineError, TurbineErrorCode, UniqueConstraintError, ValidationError, wrapPgError, } from './errors.js';
|
|
40
|
+
export { CheckConstraintError, CircularRelationError, ConnectionError, DeadlockError, type ErrorMessageMode, ExclusionConstraintError, ForeignKeyError, getErrorMessageMode, MigrationError, NotFoundError, NotNullViolationError, OptimisticLockError, PipelineError, type PipelineResultSlot, RelationError, SerializationFailureError, setErrorMessageMode, TimeoutError, TurbineError, TurbineErrorCode, UniqueConstraintError, ValidationError, wrapPgError, } from './errors.js';
|
|
41
41
|
export { type GenerateOptions, generate } from './generate.js';
|
|
42
42
|
export { type IntrospectOptions, introspect } from './introspect.js';
|
|
43
|
+
export { executeNestedCreate, executeNestedUpdate, hasRelationFields, type NestedWriteContext, } from './nested-write.js';
|
|
44
|
+
export type { ObserveConfig, ObserveHandle } from './observe.js';
|
|
43
45
|
export { executePipeline, type PipelineOptions, type PipelineResults, pipelineSupported } from './pipeline.js';
|
|
44
|
-
export { type AggregateArgs, type AggregateResult, type ArrayFilter, type CountArgs, type CreateArgs, type CreateManyArgs, type DeferredQuery, type DeleteArgs, type DeleteManyArgs, type FindManyArgs, type FindManyStreamArgs, type FindUniqueArgs, type GroupByArgs, type JsonFilter, type OrderDirection, QueryInterface, type RelationDescriptor, type RelationFilter, type TypedWithClause, type UpdateArgs, type UpdateInput, type UpdateManyArgs, type UpdateOperatorInput, type UpsertArgs, type WithClause, type WithOptions, type WithResult, } from './query/index.js';
|
|
46
|
+
export { type AggregateArgs, type AggregateResult, type ArrayFilter, type ConnectOrCreateOp, type CountArgs, type CreateArgs, type CreateManyArgs, type DeferredQuery, type DeleteArgs, type DeleteManyArgs, type FieldResult, type FindManyArgs, type FindManyStreamArgs, type FindUniqueArgs, type GroupByArgs, type JsonFilter, type NestedCreateOp, type NestedUpdateOp, type OmitResult, type OrderDirection, type QueryEvent, type QueryEventListener, QueryInterface, type QueryResult, type RelationDescriptor, type RelationFilter, type SelectResult, type TextSearchFilter, type TypedWithClause, type UpdateArgs, type UpdateInput, type UpdateManyArgs, type UpdateOperatorInput, type UpsertArgs, type WithClause, type WithOptions, type WithResult, } from './query/index.js';
|
|
45
47
|
export type { ColumnMetadata, IndexMetadata, RelationDef, SchemaMetadata, TableMetadata, } from './schema.js';
|
|
46
48
|
export { camelToSnake, isDateType, normalizeKeyColumns, pgArrayType, pgTypeToTs, singularize, snakeToCamel, snakeToPascal, } from './schema.js';
|
|
47
49
|
export { ColumnBuilder, type ColumnConfig, type ColumnDef, type ColumnType, type ColumnTypeName, column, defineSchema, type SchemaDef, type TableDef, table, } from './schema-builder.js';
|
package/dist/index.js
CHANGED
|
@@ -34,14 +34,16 @@
|
|
|
34
34
|
*/
|
|
35
35
|
export { alloydb, cockroachdb, postgresql, timescale, yugabytedb } from './adapters/index.js';
|
|
36
36
|
// Client
|
|
37
|
-
export { TransactionClient, TurbineClient, } from './client.js';
|
|
37
|
+
export { TransactionClient, TurbineClient, withRetry, } from './client.js';
|
|
38
38
|
export { postgresDialect } from './dialect.js';
|
|
39
39
|
// Error types
|
|
40
|
-
export { CheckConstraintError, CircularRelationError, ConnectionError, DeadlockError, ForeignKeyError, getErrorMessageMode, MigrationError, NotFoundError, NotNullViolationError, PipelineError, RelationError, SerializationFailureError, setErrorMessageMode, TimeoutError, TurbineError, TurbineErrorCode, UniqueConstraintError, ValidationError, wrapPgError, } from './errors.js';
|
|
40
|
+
export { CheckConstraintError, CircularRelationError, ConnectionError, DeadlockError, ExclusionConstraintError, ForeignKeyError, getErrorMessageMode, MigrationError, NotFoundError, NotNullViolationError, OptimisticLockError, PipelineError, RelationError, SerializationFailureError, setErrorMessageMode, TimeoutError, TurbineError, TurbineErrorCode, UniqueConstraintError, ValidationError, wrapPgError, } from './errors.js';
|
|
41
41
|
// Code generation
|
|
42
42
|
export { generate } from './generate.js';
|
|
43
43
|
// Introspection
|
|
44
44
|
export { introspect } from './introspect.js';
|
|
45
|
+
// Nested writes
|
|
46
|
+
export { executeNestedCreate, executeNestedUpdate, hasRelationFields, } from './nested-write.js';
|
|
45
47
|
// Pipeline
|
|
46
48
|
export { executePipeline, pipelineSupported } from './pipeline.js';
|
|
47
49
|
// Query builder
|