turbine-orm 0.7.0 → 0.8.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/README.md +134 -40
- package/dist/cjs/cli/index.js +72 -3
- package/dist/cjs/cli/loader.js +129 -0
- package/dist/cjs/cli/migrate.js +33 -9
- package/dist/cjs/client.js +92 -8
- package/dist/cjs/errors.js +177 -4
- package/dist/cjs/generate.js +120 -9
- package/dist/cjs/index.js +7 -1
- package/dist/cjs/pipeline-submittable.js +403 -0
- package/dist/cjs/pipeline.js +90 -37
- package/dist/cjs/query.js +943 -137
- package/dist/cjs/schema-builder.js +57 -6
- package/dist/cjs/schema-sql.js +85 -19
- package/dist/cjs/serverless.js +8 -7
- package/dist/cli/index.js +72 -3
- package/dist/cli/loader.d.ts +45 -0
- package/dist/cli/loader.js +91 -0
- package/dist/cli/migrate.d.ts +7 -1
- package/dist/cli/migrate.js +33 -9
- package/dist/cli/ui.d.ts +1 -1
- package/dist/client.d.ts +47 -3
- package/dist/client.js +94 -10
- package/dist/errors.d.ts +132 -1
- package/dist/errors.js +171 -3
- package/dist/generate.d.ts +6 -0
- package/dist/generate.js +120 -10
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/pipeline-submittable.d.ts +94 -0
- package/dist/pipeline-submittable.js +397 -0
- package/dist/pipeline.d.ts +37 -9
- package/dist/pipeline.js +89 -37
- package/dist/query.d.ts +268 -17
- package/dist/query.js +941 -137
- package/dist/schema-builder.d.ts +36 -3
- package/dist/schema-builder.js +57 -6
- package/dist/schema-sql.js +85 -19
- package/dist/serverless.d.ts +8 -7
- package/dist/serverless.js +8 -7
- package/package.json +3 -3
package/dist/cjs/client.js
CHANGED
|
@@ -134,9 +134,15 @@ class TransactionClient {
|
|
|
134
134
|
// Return a minimal pool-compatible object that routes queries
|
|
135
135
|
// through the transaction client
|
|
136
136
|
return {
|
|
137
|
-
query: async (
|
|
137
|
+
query: async (textOrConfig, values) => {
|
|
138
138
|
try {
|
|
139
|
-
|
|
139
|
+
if (typeof textOrConfig === 'string') {
|
|
140
|
+
return await client.query(textOrConfig, values);
|
|
141
|
+
}
|
|
142
|
+
// Object form for prepared statements: { name, text, values }
|
|
143
|
+
// pg.PoolClient.query accepts QueryConfig but the overloads make TS
|
|
144
|
+
// unhappy with the union, so we cast through unknown.
|
|
145
|
+
return await client.query(textOrConfig);
|
|
140
146
|
}
|
|
141
147
|
catch (err) {
|
|
142
148
|
throw (0, errors_js_1.wrapPgError)(err);
|
|
@@ -191,10 +197,19 @@ class TurbineClient {
|
|
|
191
197
|
}
|
|
192
198
|
this.logging = config.logging ?? false;
|
|
193
199
|
this.schema = schema;
|
|
200
|
+
// Respect env var kill switch
|
|
201
|
+
const envDisablePrepared = typeof process !== 'undefined' && process.env?.TURBINE_DISABLE_PREPARED === '1';
|
|
194
202
|
this.queryOptions = {
|
|
195
203
|
defaultLimit: config.defaultLimit,
|
|
196
204
|
warnOnUnlimited: config.warnOnUnlimited,
|
|
205
|
+
preparedStatements: envDisablePrepared ? false : (config.preparedStatements ?? !config.pool),
|
|
206
|
+
sqlCache: config.sqlCache ?? true,
|
|
197
207
|
};
|
|
208
|
+
// Apply NotFoundError message redaction mode (default: safe — values are
|
|
209
|
+
// stripped from messages to avoid leaking PII into error logs).
|
|
210
|
+
if (config.errorMessages) {
|
|
211
|
+
(0, errors_js_1.setErrorMessageMode)(config.errorMessages);
|
|
212
|
+
}
|
|
198
213
|
if (config.pool) {
|
|
199
214
|
// External pool — use directly. Turbine doesn't manage its lifecycle.
|
|
200
215
|
this.pool = config.pool;
|
|
@@ -302,13 +317,41 @@ class TurbineClient {
|
|
|
302
317
|
/**
|
|
303
318
|
* Execute multiple queries in a single database round-trip.
|
|
304
319
|
*
|
|
305
|
-
*
|
|
320
|
+
* Two call styles:
|
|
321
|
+
* - `db.pipeline(q1, q2, q3)` — rest params (backward-compatible)
|
|
322
|
+
* - `db.pipeline([q1, q2, q3], { transactional: false })` — array + options
|
|
323
|
+
*
|
|
324
|
+
* On pg.Pool-backed connections with TCP, this uses the real Postgres
|
|
325
|
+
* extended-query pipeline protocol (one TCP flush, one round-trip).
|
|
326
|
+
* On HTTP-based drivers it falls back to sequential execution.
|
|
306
327
|
*/
|
|
307
|
-
async pipeline(...
|
|
328
|
+
async pipeline(...args) {
|
|
329
|
+
let queries;
|
|
330
|
+
let options;
|
|
331
|
+
// Detect which overload was used
|
|
332
|
+
if (args.length > 0 &&
|
|
333
|
+
Array.isArray(args[0]) &&
|
|
334
|
+
args[0].every((item) => item && typeof item === 'object' && 'sql' in item)) {
|
|
335
|
+
// Array form: pipeline([q1, q2], opts?)
|
|
336
|
+
queries = args[0];
|
|
337
|
+
options = args[1];
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
// Rest-param form: pipeline(q1, q2, q3)
|
|
341
|
+
queries = args;
|
|
342
|
+
}
|
|
308
343
|
if (this.logging) {
|
|
309
344
|
console.log(`[turbine] Pipeline: ${queries.length} queries — ${queries.map((q) => q.tag).join(', ')}`);
|
|
310
345
|
}
|
|
311
|
-
return (0, pipeline_js_1.executePipeline)(this.pool, queries);
|
|
346
|
+
return (0, pipeline_js_1.executePipeline)(this.pool, queries, options);
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Check whether the underlying pool supports the real pipeline protocol.
|
|
350
|
+
* Returns `true` for standard pg.Pool TCP connections, `false` for HTTP
|
|
351
|
+
* drivers (Neon HTTP, Vercel Postgres, etc.) and mock pools.
|
|
352
|
+
*/
|
|
353
|
+
async pipelineSupported() {
|
|
354
|
+
return (0, pipeline_js_1.pipelineSupported)(this.pool);
|
|
312
355
|
}
|
|
313
356
|
// -------------------------------------------------------------------------
|
|
314
357
|
// Raw SQL — tagged template literal escape hatch
|
|
@@ -399,6 +442,24 @@ class TurbineClient {
|
|
|
399
442
|
async $transaction(fn, options) {
|
|
400
443
|
const client = await this.pool.connect();
|
|
401
444
|
const timeout = options?.timeout;
|
|
445
|
+
/**
|
|
446
|
+
* Track whether the connection has already been released so the finally
|
|
447
|
+
* block doesn't double-release. When a timeout fires we destroy the
|
|
448
|
+
* connection eagerly to abort the in-flight backend query.
|
|
449
|
+
*/
|
|
450
|
+
let released = false;
|
|
451
|
+
const releaseOnce = (err) => {
|
|
452
|
+
if (released)
|
|
453
|
+
return;
|
|
454
|
+
released = true;
|
|
455
|
+
try {
|
|
456
|
+
client.release(err);
|
|
457
|
+
}
|
|
458
|
+
catch {
|
|
459
|
+
// pg may throw if the client is already released — swallow.
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
let timedOut = false;
|
|
402
463
|
try {
|
|
403
464
|
// BEGIN with optional isolation level
|
|
404
465
|
let beginSQL = 'BEGIN';
|
|
@@ -422,10 +483,22 @@ class TurbineClient {
|
|
|
422
483
|
}
|
|
423
484
|
let result;
|
|
424
485
|
if (timeout) {
|
|
425
|
-
// Race between the function and a timeout
|
|
486
|
+
// Race between the function and a timeout. If the timeout fires we
|
|
487
|
+
// need to actually abort the in-flight query — otherwise the backend
|
|
488
|
+
// keeps running until pg's own timeout, holding a pool slot the whole
|
|
489
|
+
// time. The simplest reliable cancellation is to destroy the
|
|
490
|
+
// connection: passing a truthy argument to client.release() tells the
|
|
491
|
+
// pg pool to discard the client (its socket is closed, which causes
|
|
492
|
+
// Postgres to abort the active query and roll back the transaction).
|
|
493
|
+
// The pool will spin up a fresh connection on the next checkout.
|
|
426
494
|
let timer;
|
|
427
495
|
const timeoutPromise = new Promise((_, reject) => {
|
|
428
496
|
timer = setTimeout(() => {
|
|
497
|
+
timedOut = true;
|
|
498
|
+
// Destroy the connection to abort the in-flight backend query.
|
|
499
|
+
// We do this BEFORE rejecting so the socket is gone by the time
|
|
500
|
+
// the caller's catch block runs.
|
|
501
|
+
releaseOnce(new Error('[turbine] Transaction timeout — connection destroyed'));
|
|
429
502
|
reject(new errors_js_1.TimeoutError(timeout, 'Transaction'));
|
|
430
503
|
}, timeout);
|
|
431
504
|
});
|
|
@@ -446,14 +519,25 @@ class TurbineClient {
|
|
|
446
519
|
return result;
|
|
447
520
|
}
|
|
448
521
|
catch (err) {
|
|
449
|
-
|
|
522
|
+
// If the timeout fired we already destroyed the connection — issuing a
|
|
523
|
+
// ROLLBACK on a released client would throw "Client has already been
|
|
524
|
+
// released". Skip the rollback in that case (the backend rolled back
|
|
525
|
+
// when its socket was closed).
|
|
526
|
+
if (!timedOut && !released) {
|
|
527
|
+
try {
|
|
528
|
+
await client.query('ROLLBACK');
|
|
529
|
+
}
|
|
530
|
+
catch {
|
|
531
|
+
// Best-effort rollback — the connection may have died mid-query.
|
|
532
|
+
}
|
|
533
|
+
}
|
|
450
534
|
if (this.logging) {
|
|
451
535
|
console.log('[turbine] Transaction rolled back');
|
|
452
536
|
}
|
|
453
537
|
throw err;
|
|
454
538
|
}
|
|
455
539
|
finally {
|
|
456
|
-
|
|
540
|
+
releaseOnce();
|
|
457
541
|
}
|
|
458
542
|
}
|
|
459
543
|
// -------------------------------------------------------------------------
|
package/dist/cjs/errors.js
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
* All Turbine errors extend TurbineError which includes a `code` property.
|
|
7
7
|
*/
|
|
8
8
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
-
exports.CheckConstraintError = exports.NotNullViolationError = exports.ForeignKeyError = exports.UniqueConstraintError = exports.CircularRelationError = exports.MigrationError = exports.RelationError = exports.ConnectionError = exports.ValidationError = exports.TimeoutError = exports.NotFoundError = exports.TurbineError = exports.TurbineErrorCode = void 0;
|
|
9
|
+
exports.PipelineError = exports.CheckConstraintError = exports.SerializationFailureError = exports.DeadlockError = exports.NotNullViolationError = exports.ForeignKeyError = exports.UniqueConstraintError = exports.CircularRelationError = exports.MigrationError = exports.RelationError = exports.ConnectionError = exports.ValidationError = exports.TimeoutError = exports.NotFoundError = exports.TurbineError = exports.TurbineErrorCode = void 0;
|
|
10
|
+
exports.setErrorMessageMode = setErrorMessageMode;
|
|
11
|
+
exports.getErrorMessageMode = getErrorMessageMode;
|
|
10
12
|
exports.wrapPgError = wrapPgError;
|
|
11
13
|
/** Error codes for all Turbine errors */
|
|
12
14
|
exports.TurbineErrorCode = {
|
|
@@ -21,6 +23,9 @@ exports.TurbineErrorCode = {
|
|
|
21
23
|
FOREIGN_KEY_VIOLATION: 'TURBINE_E009',
|
|
22
24
|
NOT_NULL_VIOLATION: 'TURBINE_E010',
|
|
23
25
|
CHECK_VIOLATION: 'TURBINE_E011',
|
|
26
|
+
DEADLOCK_DETECTED: 'TURBINE_E012',
|
|
27
|
+
SERIALIZATION_FAILURE: 'TURBINE_E013',
|
|
28
|
+
PIPELINE: 'TURBINE_E014',
|
|
24
29
|
};
|
|
25
30
|
/** Base error class for all Turbine errors */
|
|
26
31
|
class TurbineError extends Error {
|
|
@@ -32,6 +37,47 @@ class TurbineError extends Error {
|
|
|
32
37
|
}
|
|
33
38
|
}
|
|
34
39
|
exports.TurbineError = TurbineError;
|
|
40
|
+
let errorMessageMode = 'safe';
|
|
41
|
+
/**
|
|
42
|
+
* Set the global NotFoundError message mode. Called from the TurbineClient
|
|
43
|
+
* constructor when `TurbineConfig.errorMessages` is provided.
|
|
44
|
+
*
|
|
45
|
+
* - `'safe'` (default): the message includes only the keys of the where
|
|
46
|
+
* clause (e.g. `where: { id, email }`). Values are redacted.
|
|
47
|
+
* - `'verbose'`: the message includes the full JSON-serialized where
|
|
48
|
+
* clause (e.g. `where: {"id":1,"email":"alice@x.com"}`).
|
|
49
|
+
*/
|
|
50
|
+
function setErrorMessageMode(mode) {
|
|
51
|
+
errorMessageMode = mode;
|
|
52
|
+
}
|
|
53
|
+
/** Returns the current NotFoundError message mode. Exported for tests. */
|
|
54
|
+
function getErrorMessageMode() {
|
|
55
|
+
return errorMessageMode;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Render a `where` clause for error messages. In 'safe' mode (the default),
|
|
59
|
+
* only the keys are shown; values are stripped to avoid leaking PII into logs.
|
|
60
|
+
* Nested AND/OR/NOT combinators are recursively rendered.
|
|
61
|
+
*/
|
|
62
|
+
function renderWhereForMessage(where, mode) {
|
|
63
|
+
if (mode === 'verbose') {
|
|
64
|
+
try {
|
|
65
|
+
return JSON.stringify(where);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return '[unserializable]';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// safe mode: keys only
|
|
72
|
+
if (where === null || where === undefined)
|
|
73
|
+
return '';
|
|
74
|
+
if (typeof where !== 'object')
|
|
75
|
+
return '';
|
|
76
|
+
const keys = Object.keys(where);
|
|
77
|
+
if (keys.length === 0)
|
|
78
|
+
return '{}';
|
|
79
|
+
return `{ ${keys.join(', ')} }`;
|
|
80
|
+
}
|
|
35
81
|
/**
|
|
36
82
|
* Thrown when a record is not found (findUniqueOrThrow, findFirstOrThrow,
|
|
37
83
|
* update/delete against a non-matching row, etc.)
|
|
@@ -41,8 +87,16 @@ exports.TurbineError = TurbineError;
|
|
|
41
87
|
* - `new NotFoundError({ table, where, operation, cause, message })`
|
|
42
88
|
*
|
|
43
89
|
* When called with an options object and no explicit `message`, a Prisma-style
|
|
44
|
-
* message is built automatically,
|
|
90
|
+
* message is built automatically. By default, only the where-clause keys are
|
|
91
|
+
* shown to avoid leaking PII into logs:
|
|
92
|
+
* `[turbine] findUniqueOrThrow on "users" found no record matching where: { id }`
|
|
93
|
+
*
|
|
94
|
+
* Set `setErrorMessageMode('verbose')` (or pass `errorMessages: 'verbose'` to
|
|
95
|
+
* the TurbineClient constructor) to include the full where values:
|
|
45
96
|
* `[turbine] findUniqueOrThrow on "users" found no record matching where: {"id":1}`
|
|
97
|
+
*
|
|
98
|
+
* The full `where` object, `table`, and `operation` are always available as
|
|
99
|
+
* structured properties on the error instance regardless of mode.
|
|
46
100
|
*/
|
|
47
101
|
class NotFoundError extends TurbineError {
|
|
48
102
|
table;
|
|
@@ -59,11 +113,11 @@ class NotFoundError extends TurbineError {
|
|
|
59
113
|
let message = input.message;
|
|
60
114
|
if (!message) {
|
|
61
115
|
if (operation && table) {
|
|
62
|
-
const wherePart = where !== undefined ? ` matching where: ${
|
|
116
|
+
const wherePart = where !== undefined ? ` matching where: ${renderWhereForMessage(where, errorMessageMode)}` : '';
|
|
63
117
|
message = `[turbine] ${operation} on "${table}" found no record${wherePart}`;
|
|
64
118
|
}
|
|
65
119
|
else if (table) {
|
|
66
|
-
const wherePart = where !== undefined ? ` matching where ${
|
|
120
|
+
const wherePart = where !== undefined ? ` matching where ${renderWhereForMessage(where, errorMessageMode)}` : '';
|
|
67
121
|
message = `[turbine] No record found in "${table}"${wherePart}`;
|
|
68
122
|
}
|
|
69
123
|
else {
|
|
@@ -209,6 +263,73 @@ class NotNullViolationError extends TurbineError {
|
|
|
209
263
|
}
|
|
210
264
|
}
|
|
211
265
|
exports.NotNullViolationError = NotNullViolationError;
|
|
266
|
+
/**
|
|
267
|
+
* Thrown when Postgres detects a deadlock (pg code 40P01).
|
|
268
|
+
*
|
|
269
|
+
* This error is **retryable** — when caught, callers can safely retry the
|
|
270
|
+
* transaction (typically with backoff). Catch it explicitly:
|
|
271
|
+
*
|
|
272
|
+
* ```ts
|
|
273
|
+
* try {
|
|
274
|
+
* await db.$transaction(async (tx) => { ... });
|
|
275
|
+
* } catch (err) {
|
|
276
|
+
* if (err instanceof DeadlockError) {
|
|
277
|
+
* // safe to retry
|
|
278
|
+
* }
|
|
279
|
+
* }
|
|
280
|
+
* ```
|
|
281
|
+
*/
|
|
282
|
+
class DeadlockError extends TurbineError {
|
|
283
|
+
/** Marks this error as safe to retry */
|
|
284
|
+
isRetryable = true;
|
|
285
|
+
constraint;
|
|
286
|
+
constructor(opts = {}) {
|
|
287
|
+
const { constraint, cause } = opts;
|
|
288
|
+
let message = opts.message;
|
|
289
|
+
if (!message) {
|
|
290
|
+
const pgMessage = cause?.message;
|
|
291
|
+
message = pgMessage ? `[turbine] Deadlock detected: ${pgMessage}` : '[turbine] Deadlock detected';
|
|
292
|
+
}
|
|
293
|
+
super(exports.TurbineErrorCode.DEADLOCK_DETECTED, message, { cause });
|
|
294
|
+
this.name = 'DeadlockError';
|
|
295
|
+
this.constraint = constraint;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
exports.DeadlockError = DeadlockError;
|
|
299
|
+
/**
|
|
300
|
+
* Thrown when a Serializable transaction fails due to a serialization
|
|
301
|
+
* conflict (pg code 40001 — `could not serialize access due to ...`).
|
|
302
|
+
*
|
|
303
|
+
* This error is **retryable** — by Postgres documentation, the recommended
|
|
304
|
+
* response is to re-run the entire transaction. Catch it explicitly:
|
|
305
|
+
*
|
|
306
|
+
* ```ts
|
|
307
|
+
* try {
|
|
308
|
+
* await db.$transaction(async (tx) => { ... }, { isolationLevel: 'Serializable' });
|
|
309
|
+
* } catch (err) {
|
|
310
|
+
* if (err instanceof SerializationFailureError) {
|
|
311
|
+
* // safe to retry the whole transaction
|
|
312
|
+
* }
|
|
313
|
+
* }
|
|
314
|
+
* ```
|
|
315
|
+
*/
|
|
316
|
+
class SerializationFailureError extends TurbineError {
|
|
317
|
+
/** Marks this error as safe to retry */
|
|
318
|
+
isRetryable = true;
|
|
319
|
+
constructor(opts = {}) {
|
|
320
|
+
const { cause } = opts;
|
|
321
|
+
let message = opts.message;
|
|
322
|
+
if (!message) {
|
|
323
|
+
const pgMessage = cause?.message;
|
|
324
|
+
message = pgMessage
|
|
325
|
+
? `[turbine] Serializable transaction conflict: ${pgMessage}`
|
|
326
|
+
: '[turbine] Serializable transaction conflict';
|
|
327
|
+
}
|
|
328
|
+
super(exports.TurbineErrorCode.SERIALIZATION_FAILURE, message, { cause });
|
|
329
|
+
this.name = 'SerializationFailureError';
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
exports.SerializationFailureError = SerializationFailureError;
|
|
212
333
|
/** Thrown when a CHECK constraint is violated (pg code 23514) */
|
|
213
334
|
class CheckConstraintError extends TurbineError {
|
|
214
335
|
constraint;
|
|
@@ -230,6 +351,47 @@ class CheckConstraintError extends TurbineError {
|
|
|
230
351
|
}
|
|
231
352
|
}
|
|
232
353
|
exports.CheckConstraintError = CheckConstraintError;
|
|
354
|
+
/**
|
|
355
|
+
* Thrown when a non-transactional pipeline has partial failures.
|
|
356
|
+
*
|
|
357
|
+
* In non-transactional mode (`{ transactional: false }`), each query executes
|
|
358
|
+
* independently. If one or more queries fail, the pipeline rejects with a
|
|
359
|
+
* `PipelineError` that carries per-query results so callers can inspect which
|
|
360
|
+
* succeeded and which failed.
|
|
361
|
+
*
|
|
362
|
+
* ```ts
|
|
363
|
+
* try {
|
|
364
|
+
* await db.pipeline([q1, q2, q3], { transactional: false });
|
|
365
|
+
* } catch (err) {
|
|
366
|
+
* if (err instanceof PipelineError) {
|
|
367
|
+
* for (const slot of err.results) {
|
|
368
|
+
* if (slot.status === 'error') console.error(slot.error);
|
|
369
|
+
* }
|
|
370
|
+
* }
|
|
371
|
+
* }
|
|
372
|
+
* ```
|
|
373
|
+
*/
|
|
374
|
+
class PipelineError extends TurbineError {
|
|
375
|
+
/** Per-query results: each slot is either `{status:'ok', value}` or `{status:'error', error}` */
|
|
376
|
+
results;
|
|
377
|
+
/** Zero-based index of the first query that failed */
|
|
378
|
+
failedIndex;
|
|
379
|
+
/** Tag of the first query that failed (from DeferredQuery.tag) */
|
|
380
|
+
failedTag;
|
|
381
|
+
constructor(opts) {
|
|
382
|
+
const { results, failedIndex, failedTag, cause } = opts;
|
|
383
|
+
const failedCount = results.filter((r) => r.status === 'error').length;
|
|
384
|
+
const message = opts.message ??
|
|
385
|
+
`[turbine] Pipeline completed with ${failedCount} error(s) out of ${results.length} queries` +
|
|
386
|
+
(failedTag ? ` (first failure: ${failedTag} at index ${failedIndex})` : '');
|
|
387
|
+
super(exports.TurbineErrorCode.PIPELINE, message, { cause });
|
|
388
|
+
this.name = 'PipelineError';
|
|
389
|
+
this.results = results;
|
|
390
|
+
this.failedIndex = failedIndex;
|
|
391
|
+
this.failedTag = failedTag;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
exports.PipelineError = PipelineError;
|
|
233
395
|
/**
|
|
234
396
|
* Parse column names out of a pg `detail` string like:
|
|
235
397
|
* "Key (email)=(foo@bar) already exists."
|
|
@@ -250,6 +412,8 @@ function parseColumnsFromDetail(detail) {
|
|
|
250
412
|
* 23503 (foreign_key_violation) -> ForeignKeyError
|
|
251
413
|
* 23502 (not_null_violation) -> NotNullViolationError
|
|
252
414
|
* 23514 (check_violation) -> CheckConstraintError
|
|
415
|
+
* 40P01 (deadlock_detected) -> DeadlockError (retryable)
|
|
416
|
+
* 40001 (serialization_failure) -> SerializationFailureError (retryable)
|
|
253
417
|
*
|
|
254
418
|
* The original pg error is preserved as `.cause` on the wrapped error.
|
|
255
419
|
*/
|
|
@@ -287,6 +451,15 @@ function wrapPgError(err) {
|
|
|
287
451
|
table: e.table,
|
|
288
452
|
cause: err,
|
|
289
453
|
});
|
|
454
|
+
case '40P01':
|
|
455
|
+
return new DeadlockError({
|
|
456
|
+
constraint: e.constraint,
|
|
457
|
+
cause: err,
|
|
458
|
+
});
|
|
459
|
+
case '40001':
|
|
460
|
+
return new SerializationFailureError({
|
|
461
|
+
cause: err,
|
|
462
|
+
});
|
|
290
463
|
default:
|
|
291
464
|
return err;
|
|
292
465
|
}
|
package/dist/cjs/generate.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
13
|
exports.generate = generate;
|
|
14
|
+
exports.generateTypes = generateTypes;
|
|
14
15
|
const node_fs_1 = require("node:fs");
|
|
15
16
|
const node_path_1 = require("node:path");
|
|
16
17
|
const schema_js_1 = require("./schema.js");
|
|
@@ -63,8 +64,33 @@ function generatedFileHeader() {
|
|
|
63
64
|
'',
|
|
64
65
|
];
|
|
65
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* Generate the contents of `types.ts` (entity interfaces, *Create / *Update,
|
|
69
|
+
* and *Relations brand-field interfaces). Exported so tests can pin the
|
|
70
|
+
* generator output without writing files to disk.
|
|
71
|
+
*/
|
|
66
72
|
function generateTypes(schema) {
|
|
67
73
|
const lines = [...generatedFileHeader()];
|
|
74
|
+
// We import UpdateOperatorInput so generated *Update types can express
|
|
75
|
+
// atomic increment / decrement / multiply / divide / set operators on
|
|
76
|
+
// numeric columns (TASK-3.4).
|
|
77
|
+
//
|
|
78
|
+
// RelationDescriptor is the brand-field interface that lets `WithResult`
|
|
79
|
+
// recurse through nested `with` clauses at any depth. The generator emits
|
|
80
|
+
// each `*Relations` member as a `RelationDescriptor<Target, Cardinality,
|
|
81
|
+
// TargetRelations>` so users get full deep `with`-clause type inference
|
|
82
|
+
// out of the box (TASK-2.1).
|
|
83
|
+
lines.push("import type { RelationDescriptor, UpdateOperatorInput } from 'turbine-orm';");
|
|
84
|
+
lines.push('');
|
|
85
|
+
// Pre-compute which tables have relations so we know whether to thread
|
|
86
|
+
// `${TargetType}Relations` (for deep inference) or `{}` (the no-relations
|
|
87
|
+
// default) into each `RelationDescriptor`. Built once up-front because
|
|
88
|
+
// relations can point at tables we haven't iterated to yet.
|
|
89
|
+
const tablesWithRelations = new Set();
|
|
90
|
+
for (const t of Object.values(schema.tables)) {
|
|
91
|
+
if (Object.keys(t.relations).length > 0)
|
|
92
|
+
tablesWithRelations.add(t.name);
|
|
93
|
+
}
|
|
68
94
|
// Generate enum types
|
|
69
95
|
for (const [enumName, labels] of Object.entries(schema.enums)) {
|
|
70
96
|
const typeName = (0, schema_js_1.snakeToPascal)(enumName);
|
|
@@ -106,27 +132,33 @@ function generateTypes(schema) {
|
|
|
106
132
|
lines.push('};');
|
|
107
133
|
lines.push('');
|
|
108
134
|
// --- Update input type (all fields optional except PK) ---
|
|
135
|
+
// Numeric columns additionally accept `UpdateOperatorInput<number>` so
|
|
136
|
+
// users can write `{ viewCount: { increment: 1 } }` without an `as any`.
|
|
109
137
|
const nonPkCols = table.columns.filter((c) => !table.primaryKey.includes(c.name));
|
|
110
138
|
lines.push(`/** Input type for updating a row in \`${table.name}\` */`);
|
|
111
139
|
lines.push(`export type ${typeName}Update = {`);
|
|
112
140
|
for (const col of nonPkCols) {
|
|
113
|
-
lines.push(` ${col.field}?: ${col.tsType};`);
|
|
141
|
+
lines.push(` ${col.field}?: ${updateFieldType(col.tsType)};`);
|
|
114
142
|
}
|
|
115
143
|
lines.push('};');
|
|
116
144
|
lines.push('');
|
|
117
145
|
// --- Relations map (for type-safe `with` clauses) ---
|
|
146
|
+
//
|
|
147
|
+
// Each relation is emitted as a `RelationDescriptor<Target, Cardinality,
|
|
148
|
+
// TargetRelations>` brand-field interface. This is what enables the
|
|
149
|
+
// recursive `WithResult` type to walk through nested `with` clauses at
|
|
150
|
+
// any depth — `RelationRelations<R[K]>` reads the third type parameter
|
|
151
|
+
// and threads it into the next recursion step. If the target table has
|
|
152
|
+
// no relations of its own, the descriptor uses `{}` (the default).
|
|
118
153
|
const hasRelations = Object.keys(table.relations).length > 0;
|
|
119
154
|
if (hasRelations) {
|
|
120
155
|
lines.push(`/** Available relations for the \`${table.name}\` table */`);
|
|
121
156
|
lines.push(`export interface ${typeName}Relations {`);
|
|
122
157
|
for (const [relName, rel] of Object.entries(table.relations)) {
|
|
123
158
|
const targetType = entityName(rel.to);
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
127
|
-
else {
|
|
128
|
-
lines.push(` ${relName}: ${targetType} | null;`);
|
|
129
|
-
}
|
|
159
|
+
const cardinality = rel.type === 'hasMany' ? "'many'" : "'one'";
|
|
160
|
+
const targetRelations = tablesWithRelations.has(rel.to) ? `${targetType}Relations` : '{}';
|
|
161
|
+
lines.push(` ${relName}: RelationDescriptor<${targetType}, ${cardinality}, ${targetRelations}>;`);
|
|
130
162
|
}
|
|
131
163
|
lines.push('}');
|
|
132
164
|
lines.push('');
|
|
@@ -230,8 +262,8 @@ function generateIndex(schema) {
|
|
|
230
262
|
const tableEntries = Object.values(schema.tables);
|
|
231
263
|
const lines = [
|
|
232
264
|
...generatedFileHeader(),
|
|
233
|
-
"import { TurbineClient as BaseTurbineClient, QueryInterface } from 'turbine-orm';",
|
|
234
|
-
"import type { TurbineConfig } from 'turbine-orm';",
|
|
265
|
+
"import { TurbineClient as BaseTurbineClient, TransactionClient as BaseTransactionClient, QueryInterface } from 'turbine-orm';",
|
|
266
|
+
"import type { TurbineConfig, TransactionOptions } from 'turbine-orm';",
|
|
235
267
|
"import { SCHEMA } from './metadata.js';",
|
|
236
268
|
];
|
|
237
269
|
// Import all entity types and relations maps
|
|
@@ -244,6 +276,41 @@ function generateIndex(schema) {
|
|
|
244
276
|
}
|
|
245
277
|
lines.push(`import type { ${typeImports.join(', ')} } from './types.js';`);
|
|
246
278
|
lines.push('');
|
|
279
|
+
// -------------------------------------------------------------------------
|
|
280
|
+
// TypedTransactionClient — same typed table accessors as TurbineClient,
|
|
281
|
+
// but scoped to a single transaction connection. The runtime instance is
|
|
282
|
+
// an ordinary `TransactionClient` from turbine-orm; this declaration just
|
|
283
|
+
// teaches TypeScript about the auto-attached accessors so users get
|
|
284
|
+
// autocomplete inside `db.$transaction(async (tx) => tx.users.create(...))`.
|
|
285
|
+
// -------------------------------------------------------------------------
|
|
286
|
+
lines.push('/**');
|
|
287
|
+
lines.push(' * Transaction-scoped client with the same typed table accessors as TurbineClient.');
|
|
288
|
+
lines.push(' * Created automatically by `db.$transaction(async (tx) => ...)` — never instantiate');
|
|
289
|
+
lines.push(' * directly. All queries run on a dedicated connection within a BEGIN/COMMIT block.');
|
|
290
|
+
lines.push(' */');
|
|
291
|
+
lines.push('export class TypedTransactionClient extends BaseTransactionClient {');
|
|
292
|
+
for (const table of tableEntries) {
|
|
293
|
+
const typeName = entityName(table.name);
|
|
294
|
+
const accessor = snakeToCamelStr(table.name);
|
|
295
|
+
const hasRelations = Object.keys(table.relations).length > 0;
|
|
296
|
+
const genericArgs = hasRelations ? `${typeName}, ${typeName}Relations` : typeName;
|
|
297
|
+
lines.push(` /** Query interface for the \`${table.name}\` table (transaction-scoped) */`);
|
|
298
|
+
lines.push(` declare readonly ${accessor}: QueryInterface<${genericArgs}>;`);
|
|
299
|
+
}
|
|
300
|
+
lines.push('}');
|
|
301
|
+
lines.push('');
|
|
302
|
+
// Augment the class with a typed `$transaction` overload via interface
|
|
303
|
+
// merging. This adds an additional callable signature whose callback
|
|
304
|
+
// parameter is narrowed to `TypedTransactionClient`, while the base
|
|
305
|
+
// signature (callback parameter `BaseTransactionClient`) remains valid.
|
|
306
|
+
lines.push('export interface TypedTransactionClient {');
|
|
307
|
+
lines.push(' /**');
|
|
308
|
+
lines.push(' * Nested transaction via SAVEPOINT. The callback receives a typed');
|
|
309
|
+
lines.push(' * `TypedTransactionClient` so all table accessors auto-complete.');
|
|
310
|
+
lines.push(' */');
|
|
311
|
+
lines.push(' $transaction<R>(fn: (tx: TypedTransactionClient) => Promise<R>): Promise<R>;');
|
|
312
|
+
lines.push('}');
|
|
313
|
+
lines.push('');
|
|
247
314
|
// Generate the client class with JSDoc
|
|
248
315
|
lines.push('/**');
|
|
249
316
|
lines.push(' * Generated Turbine client with typed table accessors.');
|
|
@@ -278,6 +345,22 @@ function generateIndex(schema) {
|
|
|
278
345
|
lines.push(' }');
|
|
279
346
|
lines.push('}');
|
|
280
347
|
lines.push('');
|
|
348
|
+
// Augment TurbineClient via interface merging with a typed $transaction
|
|
349
|
+
// overload. The callback parameter is narrowed to `TypedTransactionClient`
|
|
350
|
+
// so users get autocomplete on `tx.users`, `tx.posts`, etc. The base
|
|
351
|
+
// signature (callback parameter `BaseTransactionClient`) remains valid as
|
|
352
|
+
// an overload, so prior usage continues to typecheck.
|
|
353
|
+
lines.push('export interface TurbineClient {');
|
|
354
|
+
lines.push(' /**');
|
|
355
|
+
lines.push(' * Run a callback inside a transaction. The callback receives a typed');
|
|
356
|
+
lines.push(' * `TypedTransactionClient` with autocompletion for every table accessor.');
|
|
357
|
+
lines.push(' */');
|
|
358
|
+
lines.push(' $transaction<R>(');
|
|
359
|
+
lines.push(' fn: (tx: TypedTransactionClient) => Promise<R>,');
|
|
360
|
+
lines.push(' options?: TransactionOptions,');
|
|
361
|
+
lines.push(' ): Promise<R>;');
|
|
362
|
+
lines.push('}');
|
|
363
|
+
lines.push('');
|
|
281
364
|
// Factory function with JSDoc
|
|
282
365
|
lines.push('/**');
|
|
283
366
|
lines.push(' * Create a new Turbine client instance.');
|
|
@@ -319,3 +402,31 @@ function quoteIfNeeded(s) {
|
|
|
319
402
|
function snakeToCamelStr(s) {
|
|
320
403
|
return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
321
404
|
}
|
|
405
|
+
/**
|
|
406
|
+
* Build the update-input field type for a column. Numeric columns become
|
|
407
|
+
* `T | UpdateOperatorInput<number> | null?` so users can write atomic
|
|
408
|
+
* operators (`{ increment: 1 }`, `{ multiply: 2 }`, etc.) without casts.
|
|
409
|
+
*
|
|
410
|
+
* The check is purely structural — if the column's TS type contains
|
|
411
|
+
* `'number'` (e.g. `number`, `number | null`), it's eligible. Other
|
|
412
|
+
* scalar types (`string`, `boolean`, `Date`, `unknown`, `Buffer`,
|
|
413
|
+
* `Date | null`, etc.) pass through unchanged.
|
|
414
|
+
*/
|
|
415
|
+
function updateFieldType(tsType) {
|
|
416
|
+
// Strip parens for the regex check; preserve the original string in the output.
|
|
417
|
+
if (containsNumberType(tsType)) {
|
|
418
|
+
return `${tsType} | UpdateOperatorInput<number>`;
|
|
419
|
+
}
|
|
420
|
+
return tsType;
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Detect whether a TypeScript type expression contains the `number` primitive
|
|
424
|
+
* as a top-level union member. Conservative on purpose — only matches
|
|
425
|
+
* `number`, `number | null`, `null | number`, etc., not `number[]` or
|
|
426
|
+
* `Record<string, number>`.
|
|
427
|
+
*/
|
|
428
|
+
function containsNumberType(tsType) {
|
|
429
|
+
// Tokenize on `|` and check each member.
|
|
430
|
+
const parts = tsType.split('|').map((p) => p.trim());
|
|
431
|
+
return parts.some((p) => p === 'number');
|
|
432
|
+
}
|
package/dist/cjs/index.js
CHANGED
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
* ```
|
|
35
35
|
*/
|
|
36
36
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
|
-
exports.turbineHttp = exports.schemaToSQLString = exports.schemaToSQL = exports.schemaPush = exports.schemaDiff = exports.table = exports.defineSchema = exports.column = exports.ColumnBuilder = exports.snakeToPascal = exports.snakeToCamel = exports.singularize = exports.pgTypeToTs = exports.pgArrayType = exports.isDateType = exports.camelToSnake = exports.QueryInterface = exports.executePipeline = exports.introspect = exports.generate = exports.wrapPgError = exports.ValidationError = exports.UniqueConstraintError = exports.TurbineErrorCode = exports.TurbineError = exports.TimeoutError = exports.RelationError = exports.NotNullViolationError = exports.NotFoundError = exports.MigrationError = exports.ForeignKeyError = exports.ConnectionError = exports.CircularRelationError = exports.CheckConstraintError = exports.TurbineClient = exports.TransactionClient = void 0;
|
|
37
|
+
exports.turbineHttp = exports.schemaToSQLString = exports.schemaToSQL = exports.schemaPush = exports.schemaDiff = exports.table = exports.defineSchema = exports.column = exports.ColumnBuilder = exports.snakeToPascal = exports.snakeToCamel = exports.singularize = exports.pgTypeToTs = exports.pgArrayType = exports.isDateType = exports.camelToSnake = exports.QueryInterface = exports.pipelineSupported = exports.executePipeline = exports.introspect = exports.generate = exports.wrapPgError = exports.ValidationError = exports.UniqueConstraintError = exports.TurbineErrorCode = exports.TurbineError = exports.TimeoutError = exports.setErrorMessageMode = exports.SerializationFailureError = exports.RelationError = exports.PipelineError = exports.NotNullViolationError = exports.NotFoundError = exports.MigrationError = exports.getErrorMessageMode = exports.ForeignKeyError = exports.DeadlockError = exports.ConnectionError = exports.CircularRelationError = exports.CheckConstraintError = exports.TurbineClient = exports.TransactionClient = void 0;
|
|
38
38
|
// Client
|
|
39
39
|
var client_js_1 = require("./client.js");
|
|
40
40
|
Object.defineProperty(exports, "TransactionClient", { enumerable: true, get: function () { return client_js_1.TransactionClient; } });
|
|
@@ -44,11 +44,16 @@ var errors_js_1 = require("./errors.js");
|
|
|
44
44
|
Object.defineProperty(exports, "CheckConstraintError", { enumerable: true, get: function () { return errors_js_1.CheckConstraintError; } });
|
|
45
45
|
Object.defineProperty(exports, "CircularRelationError", { enumerable: true, get: function () { return errors_js_1.CircularRelationError; } });
|
|
46
46
|
Object.defineProperty(exports, "ConnectionError", { enumerable: true, get: function () { return errors_js_1.ConnectionError; } });
|
|
47
|
+
Object.defineProperty(exports, "DeadlockError", { enumerable: true, get: function () { return errors_js_1.DeadlockError; } });
|
|
47
48
|
Object.defineProperty(exports, "ForeignKeyError", { enumerable: true, get: function () { return errors_js_1.ForeignKeyError; } });
|
|
49
|
+
Object.defineProperty(exports, "getErrorMessageMode", { enumerable: true, get: function () { return errors_js_1.getErrorMessageMode; } });
|
|
48
50
|
Object.defineProperty(exports, "MigrationError", { enumerable: true, get: function () { return errors_js_1.MigrationError; } });
|
|
49
51
|
Object.defineProperty(exports, "NotFoundError", { enumerable: true, get: function () { return errors_js_1.NotFoundError; } });
|
|
50
52
|
Object.defineProperty(exports, "NotNullViolationError", { enumerable: true, get: function () { return errors_js_1.NotNullViolationError; } });
|
|
53
|
+
Object.defineProperty(exports, "PipelineError", { enumerable: true, get: function () { return errors_js_1.PipelineError; } });
|
|
51
54
|
Object.defineProperty(exports, "RelationError", { enumerable: true, get: function () { return errors_js_1.RelationError; } });
|
|
55
|
+
Object.defineProperty(exports, "SerializationFailureError", { enumerable: true, get: function () { return errors_js_1.SerializationFailureError; } });
|
|
56
|
+
Object.defineProperty(exports, "setErrorMessageMode", { enumerable: true, get: function () { return errors_js_1.setErrorMessageMode; } });
|
|
52
57
|
Object.defineProperty(exports, "TimeoutError", { enumerable: true, get: function () { return errors_js_1.TimeoutError; } });
|
|
53
58
|
Object.defineProperty(exports, "TurbineError", { enumerable: true, get: function () { return errors_js_1.TurbineError; } });
|
|
54
59
|
Object.defineProperty(exports, "TurbineErrorCode", { enumerable: true, get: function () { return errors_js_1.TurbineErrorCode; } });
|
|
@@ -64,6 +69,7 @@ Object.defineProperty(exports, "introspect", { enumerable: true, get: function (
|
|
|
64
69
|
// Pipeline
|
|
65
70
|
var pipeline_js_1 = require("./pipeline.js");
|
|
66
71
|
Object.defineProperty(exports, "executePipeline", { enumerable: true, get: function () { return pipeline_js_1.executePipeline; } });
|
|
72
|
+
Object.defineProperty(exports, "pipelineSupported", { enumerable: true, get: function () { return pipeline_js_1.pipelineSupported; } });
|
|
67
73
|
// Query builder
|
|
68
74
|
var query_js_1 = require("./query.js");
|
|
69
75
|
Object.defineProperty(exports, "QueryInterface", { enumerable: true, get: function () { return query_js_1.QueryInterface; } });
|