turbine-orm 0.5.0 → 0.7.1
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 +292 -26
- package/dist/cjs/cli/config.js +5 -15
- package/dist/cjs/cli/index.js +311 -43
- package/dist/cjs/cli/loader.js +129 -0
- package/dist/cjs/cli/migrate.js +96 -47
- package/dist/cjs/cli/ui.js +5 -9
- package/dist/cjs/client.js +158 -49
- package/dist/cjs/errors.js +424 -0
- package/dist/cjs/generate.js +145 -14
- package/dist/cjs/index.js +43 -20
- package/dist/cjs/introspect.js +3 -5
- package/dist/cjs/pipeline.js +9 -2
- package/dist/cjs/query.js +544 -115
- package/dist/cjs/schema-builder.js +150 -30
- package/dist/cjs/schema-sql.js +241 -37
- package/dist/cjs/schema.js +5 -2
- package/dist/cjs/serverless.js +88 -176
- package/dist/cli/config.js +6 -16
- package/dist/cli/index.js +316 -48
- package/dist/cli/loader.d.ts +45 -0
- package/dist/cli/loader.js +91 -0
- package/dist/cli/migrate.d.ts +13 -2
- package/dist/cli/migrate.js +97 -48
- package/dist/cli/ui.d.ts +1 -1
- package/dist/cli/ui.js +5 -9
- package/dist/client.d.ts +92 -4
- package/dist/client.js +158 -49
- package/dist/errors.d.ts +225 -0
- package/dist/errors.js +405 -0
- package/dist/generate.d.ts +7 -1
- package/dist/generate.js +148 -18
- package/dist/index.d.ts +11 -9
- package/dist/index.js +16 -12
- package/dist/introspect.d.ts +1 -1
- package/dist/introspect.js +4 -6
- package/dist/pipeline.d.ts +1 -1
- package/dist/pipeline.js +9 -2
- package/dist/query.d.ts +374 -38
- package/dist/query.js +545 -116
- package/dist/schema-builder.d.ts +38 -5
- package/dist/schema-builder.js +150 -31
- package/dist/schema-sql.d.ts +7 -3
- package/dist/schema-sql.js +241 -37
- package/dist/schema.d.ts +1 -1
- package/dist/schema.js +5 -2
- package/dist/serverless.d.ts +92 -139
- package/dist/serverless.js +87 -173
- package/package.json +33 -16
- package/dist/types.d.ts +0 -93
- package/dist/types.js +0 -126
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* turbine-orm — Error types
|
|
4
|
+
*
|
|
5
|
+
* Typed errors with error codes for programmatic handling.
|
|
6
|
+
* All Turbine errors extend TurbineError which includes a `code` property.
|
|
7
|
+
*/
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
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;
|
|
12
|
+
exports.wrapPgError = wrapPgError;
|
|
13
|
+
/** Error codes for all Turbine errors */
|
|
14
|
+
exports.TurbineErrorCode = {
|
|
15
|
+
NOT_FOUND: 'TURBINE_E001',
|
|
16
|
+
TIMEOUT: 'TURBINE_E002',
|
|
17
|
+
VALIDATION: 'TURBINE_E003',
|
|
18
|
+
CONNECTION: 'TURBINE_E004',
|
|
19
|
+
RELATION: 'TURBINE_E005',
|
|
20
|
+
MIGRATION: 'TURBINE_E006',
|
|
21
|
+
CIRCULAR_RELATION: 'TURBINE_E007',
|
|
22
|
+
UNIQUE_VIOLATION: 'TURBINE_E008',
|
|
23
|
+
FOREIGN_KEY_VIOLATION: 'TURBINE_E009',
|
|
24
|
+
NOT_NULL_VIOLATION: 'TURBINE_E010',
|
|
25
|
+
CHECK_VIOLATION: 'TURBINE_E011',
|
|
26
|
+
DEADLOCK_DETECTED: 'TURBINE_E012',
|
|
27
|
+
SERIALIZATION_FAILURE: 'TURBINE_E013',
|
|
28
|
+
};
|
|
29
|
+
/** Base error class for all Turbine errors */
|
|
30
|
+
class TurbineError extends Error {
|
|
31
|
+
code;
|
|
32
|
+
constructor(code, message, options) {
|
|
33
|
+
super(message, options);
|
|
34
|
+
this.name = 'TurbineError';
|
|
35
|
+
this.code = code;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
exports.TurbineError = TurbineError;
|
|
39
|
+
let errorMessageMode = 'safe';
|
|
40
|
+
/**
|
|
41
|
+
* Set the global NotFoundError message mode. Called from the TurbineClient
|
|
42
|
+
* constructor when `TurbineConfig.errorMessages` is provided.
|
|
43
|
+
*
|
|
44
|
+
* - `'safe'` (default): the message includes only the keys of the where
|
|
45
|
+
* clause (e.g. `where: { id, email }`). Values are redacted.
|
|
46
|
+
* - `'verbose'`: the message includes the full JSON-serialized where
|
|
47
|
+
* clause (e.g. `where: {"id":1,"email":"alice@x.com"}`).
|
|
48
|
+
*/
|
|
49
|
+
function setErrorMessageMode(mode) {
|
|
50
|
+
errorMessageMode = mode;
|
|
51
|
+
}
|
|
52
|
+
/** Returns the current NotFoundError message mode. Exported for tests. */
|
|
53
|
+
function getErrorMessageMode() {
|
|
54
|
+
return errorMessageMode;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Render a `where` clause for error messages. In 'safe' mode (the default),
|
|
58
|
+
* only the keys are shown; values are stripped to avoid leaking PII into logs.
|
|
59
|
+
* Nested AND/OR/NOT combinators are recursively rendered.
|
|
60
|
+
*/
|
|
61
|
+
function renderWhereForMessage(where, mode) {
|
|
62
|
+
if (mode === 'verbose') {
|
|
63
|
+
try {
|
|
64
|
+
return JSON.stringify(where);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return '[unserializable]';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// safe mode: keys only
|
|
71
|
+
if (where === null || where === undefined)
|
|
72
|
+
return '';
|
|
73
|
+
if (typeof where !== 'object')
|
|
74
|
+
return '';
|
|
75
|
+
const keys = Object.keys(where);
|
|
76
|
+
if (keys.length === 0)
|
|
77
|
+
return '{}';
|
|
78
|
+
return `{ ${keys.join(', ')} }`;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Thrown when a record is not found (findUniqueOrThrow, findFirstOrThrow,
|
|
82
|
+
* update/delete against a non-matching row, etc.)
|
|
83
|
+
*
|
|
84
|
+
* Supports two call styles for back-compat:
|
|
85
|
+
* - `new NotFoundError()` / `new NotFoundError('custom message')`
|
|
86
|
+
* - `new NotFoundError({ table, where, operation, cause, message })`
|
|
87
|
+
*
|
|
88
|
+
* When called with an options object and no explicit `message`, a Prisma-style
|
|
89
|
+
* message is built automatically. By default, only the where-clause keys are
|
|
90
|
+
* shown to avoid leaking PII into logs:
|
|
91
|
+
* `[turbine] findUniqueOrThrow on "users" found no record matching where: { id }`
|
|
92
|
+
*
|
|
93
|
+
* Set `setErrorMessageMode('verbose')` (or pass `errorMessages: 'verbose'` to
|
|
94
|
+
* the TurbineClient constructor) to include the full where values:
|
|
95
|
+
* `[turbine] findUniqueOrThrow on "users" found no record matching where: {"id":1}`
|
|
96
|
+
*
|
|
97
|
+
* The full `where` object, `table`, and `operation` are always available as
|
|
98
|
+
* structured properties on the error instance regardless of mode.
|
|
99
|
+
*/
|
|
100
|
+
class NotFoundError extends TurbineError {
|
|
101
|
+
table;
|
|
102
|
+
where;
|
|
103
|
+
operation;
|
|
104
|
+
constructor(input) {
|
|
105
|
+
// Back-compat: string argument (or undefined) — replicate legacy behavior.
|
|
106
|
+
if (typeof input === 'string' || input === undefined) {
|
|
107
|
+
super(exports.TurbineErrorCode.NOT_FOUND, input ?? 'Record not found');
|
|
108
|
+
this.name = 'NotFoundError';
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const { table, where, operation, cause } = input;
|
|
112
|
+
let message = input.message;
|
|
113
|
+
if (!message) {
|
|
114
|
+
if (operation && table) {
|
|
115
|
+
const wherePart = where !== undefined ? ` matching where: ${renderWhereForMessage(where, errorMessageMode)}` : '';
|
|
116
|
+
message = `[turbine] ${operation} on "${table}" found no record${wherePart}`;
|
|
117
|
+
}
|
|
118
|
+
else if (table) {
|
|
119
|
+
const wherePart = where !== undefined ? ` matching where ${renderWhereForMessage(where, errorMessageMode)}` : '';
|
|
120
|
+
message = `[turbine] No record found in "${table}"${wherePart}`;
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
message = '[turbine] Record not found';
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
super(exports.TurbineErrorCode.NOT_FOUND, message, { cause });
|
|
127
|
+
this.name = 'NotFoundError';
|
|
128
|
+
this.table = table;
|
|
129
|
+
this.where = where;
|
|
130
|
+
this.operation = operation;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
exports.NotFoundError = NotFoundError;
|
|
134
|
+
/** Thrown when a query or transaction exceeds the configured timeout */
|
|
135
|
+
class TimeoutError extends TurbineError {
|
|
136
|
+
timeoutMs;
|
|
137
|
+
constructor(timeoutMs, context = 'Query') {
|
|
138
|
+
super(exports.TurbineErrorCode.TIMEOUT, `[turbine] ${context} timed out after ${timeoutMs}ms`);
|
|
139
|
+
this.name = 'TimeoutError';
|
|
140
|
+
this.timeoutMs = timeoutMs;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
exports.TimeoutError = TimeoutError;
|
|
144
|
+
/** Thrown when query arguments fail validation (unknown column, invalid operator, etc.) */
|
|
145
|
+
class ValidationError extends TurbineError {
|
|
146
|
+
constructor(message) {
|
|
147
|
+
super(exports.TurbineErrorCode.VALIDATION, message);
|
|
148
|
+
this.name = 'ValidationError';
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
exports.ValidationError = ValidationError;
|
|
152
|
+
/** Thrown when a database connection fails */
|
|
153
|
+
class ConnectionError extends TurbineError {
|
|
154
|
+
constructor(message) {
|
|
155
|
+
super(exports.TurbineErrorCode.CONNECTION, message);
|
|
156
|
+
this.name = 'ConnectionError';
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
exports.ConnectionError = ConnectionError;
|
|
160
|
+
/** Thrown when a relation reference is invalid */
|
|
161
|
+
class RelationError extends TurbineError {
|
|
162
|
+
constructor(message) {
|
|
163
|
+
super(exports.TurbineErrorCode.RELATION, message);
|
|
164
|
+
this.name = 'RelationError';
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
exports.RelationError = RelationError;
|
|
168
|
+
/** Thrown when a migration operation fails */
|
|
169
|
+
class MigrationError extends TurbineError {
|
|
170
|
+
constructor(message) {
|
|
171
|
+
super(exports.TurbineErrorCode.MIGRATION, message);
|
|
172
|
+
this.name = 'MigrationError';
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
exports.MigrationError = MigrationError;
|
|
176
|
+
/** Thrown when circular relation nesting is detected */
|
|
177
|
+
class CircularRelationError extends TurbineError {
|
|
178
|
+
path;
|
|
179
|
+
constructor(path) {
|
|
180
|
+
super(exports.TurbineErrorCode.CIRCULAR_RELATION, `[turbine] Circular or too-deep relation nesting detected: ${path.join(' → ')}. Maximum nesting depth is 10.`);
|
|
181
|
+
this.name = 'CircularRelationError';
|
|
182
|
+
this.path = path;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
exports.CircularRelationError = CircularRelationError;
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// Database constraint violation errors
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
/**
|
|
190
|
+
* Extract the `detail` string from a pg-style error stored as `cause`.
|
|
191
|
+
* Returns undefined if the cause is not an object or has no detail.
|
|
192
|
+
*/
|
|
193
|
+
function detailFromCause(cause) {
|
|
194
|
+
if (!cause || typeof cause !== 'object')
|
|
195
|
+
return undefined;
|
|
196
|
+
const d = cause.detail;
|
|
197
|
+
return typeof d === 'string' && d.length > 0 ? d : undefined;
|
|
198
|
+
}
|
|
199
|
+
/** Thrown when a UNIQUE constraint is violated (pg code 23505) */
|
|
200
|
+
class UniqueConstraintError extends TurbineError {
|
|
201
|
+
constraint;
|
|
202
|
+
columns;
|
|
203
|
+
table;
|
|
204
|
+
constructor(opts = {}) {
|
|
205
|
+
const { constraint, columns, table, cause } = opts;
|
|
206
|
+
let message = opts.message;
|
|
207
|
+
if (!message) {
|
|
208
|
+
const constraintPart = constraint ? ` on ${constraint}` : '';
|
|
209
|
+
const columnsPart = columns && columns.length > 0 ? ` (${columns.join(', ')})` : '';
|
|
210
|
+
message = `[turbine] Unique constraint violation${constraintPart}${columnsPart}`;
|
|
211
|
+
const detail = detailFromCause(cause);
|
|
212
|
+
if (detail)
|
|
213
|
+
message += `: ${detail}`;
|
|
214
|
+
}
|
|
215
|
+
super(exports.TurbineErrorCode.UNIQUE_VIOLATION, message, { cause });
|
|
216
|
+
this.name = 'UniqueConstraintError';
|
|
217
|
+
this.constraint = constraint;
|
|
218
|
+
this.columns = columns;
|
|
219
|
+
this.table = table;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
exports.UniqueConstraintError = UniqueConstraintError;
|
|
223
|
+
/** Thrown when a FOREIGN KEY constraint is violated (pg code 23503) */
|
|
224
|
+
class ForeignKeyError extends TurbineError {
|
|
225
|
+
constraint;
|
|
226
|
+
table;
|
|
227
|
+
constructor(opts = {}) {
|
|
228
|
+
const { constraint, table, cause } = opts;
|
|
229
|
+
let message = opts.message;
|
|
230
|
+
if (!message) {
|
|
231
|
+
const constraintPart = constraint ? ` on ${constraint}` : '';
|
|
232
|
+
message = `[turbine] Foreign key constraint violation${constraintPart}`;
|
|
233
|
+
const detail = detailFromCause(cause);
|
|
234
|
+
if (detail)
|
|
235
|
+
message += `: ${detail}`;
|
|
236
|
+
}
|
|
237
|
+
super(exports.TurbineErrorCode.FOREIGN_KEY_VIOLATION, message, { cause });
|
|
238
|
+
this.name = 'ForeignKeyError';
|
|
239
|
+
this.constraint = constraint;
|
|
240
|
+
this.table = table;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
exports.ForeignKeyError = ForeignKeyError;
|
|
244
|
+
/** Thrown when a NOT NULL constraint is violated (pg code 23502) */
|
|
245
|
+
class NotNullViolationError extends TurbineError {
|
|
246
|
+
column;
|
|
247
|
+
table;
|
|
248
|
+
constructor(opts = {}) {
|
|
249
|
+
const { column, table, cause } = opts;
|
|
250
|
+
let message = opts.message;
|
|
251
|
+
if (!message) {
|
|
252
|
+
const columnPart = column ? ` on column "${column}"` : '';
|
|
253
|
+
message = `[turbine] NOT NULL constraint violation${columnPart}`;
|
|
254
|
+
const detail = detailFromCause(cause);
|
|
255
|
+
if (detail)
|
|
256
|
+
message += `: ${detail}`;
|
|
257
|
+
}
|
|
258
|
+
super(exports.TurbineErrorCode.NOT_NULL_VIOLATION, message, { cause });
|
|
259
|
+
this.name = 'NotNullViolationError';
|
|
260
|
+
this.column = column;
|
|
261
|
+
this.table = table;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
exports.NotNullViolationError = NotNullViolationError;
|
|
265
|
+
/**
|
|
266
|
+
* Thrown when Postgres detects a deadlock (pg code 40P01).
|
|
267
|
+
*
|
|
268
|
+
* This error is **retryable** — when caught, callers can safely retry the
|
|
269
|
+
* transaction (typically with backoff). Catch it explicitly:
|
|
270
|
+
*
|
|
271
|
+
* ```ts
|
|
272
|
+
* try {
|
|
273
|
+
* await db.$transaction(async (tx) => { ... });
|
|
274
|
+
* } catch (err) {
|
|
275
|
+
* if (err instanceof DeadlockError) {
|
|
276
|
+
* // safe to retry
|
|
277
|
+
* }
|
|
278
|
+
* }
|
|
279
|
+
* ```
|
|
280
|
+
*/
|
|
281
|
+
class DeadlockError extends TurbineError {
|
|
282
|
+
/** Marks this error as safe to retry */
|
|
283
|
+
isRetryable = true;
|
|
284
|
+
constraint;
|
|
285
|
+
constructor(opts = {}) {
|
|
286
|
+
const { constraint, cause } = opts;
|
|
287
|
+
let message = opts.message;
|
|
288
|
+
if (!message) {
|
|
289
|
+
const pgMessage = cause?.message;
|
|
290
|
+
message = pgMessage ? `[turbine] Deadlock detected: ${pgMessage}` : '[turbine] Deadlock detected';
|
|
291
|
+
}
|
|
292
|
+
super(exports.TurbineErrorCode.DEADLOCK_DETECTED, message, { cause });
|
|
293
|
+
this.name = 'DeadlockError';
|
|
294
|
+
this.constraint = constraint;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
exports.DeadlockError = DeadlockError;
|
|
298
|
+
/**
|
|
299
|
+
* Thrown when a Serializable transaction fails due to a serialization
|
|
300
|
+
* conflict (pg code 40001 — `could not serialize access due to ...`).
|
|
301
|
+
*
|
|
302
|
+
* This error is **retryable** — by Postgres documentation, the recommended
|
|
303
|
+
* response is to re-run the entire transaction. Catch it explicitly:
|
|
304
|
+
*
|
|
305
|
+
* ```ts
|
|
306
|
+
* try {
|
|
307
|
+
* await db.$transaction(async (tx) => { ... }, { isolationLevel: 'Serializable' });
|
|
308
|
+
* } catch (err) {
|
|
309
|
+
* if (err instanceof SerializationFailureError) {
|
|
310
|
+
* // safe to retry the whole transaction
|
|
311
|
+
* }
|
|
312
|
+
* }
|
|
313
|
+
* ```
|
|
314
|
+
*/
|
|
315
|
+
class SerializationFailureError extends TurbineError {
|
|
316
|
+
/** Marks this error as safe to retry */
|
|
317
|
+
isRetryable = true;
|
|
318
|
+
constructor(opts = {}) {
|
|
319
|
+
const { cause } = opts;
|
|
320
|
+
let message = opts.message;
|
|
321
|
+
if (!message) {
|
|
322
|
+
const pgMessage = cause?.message;
|
|
323
|
+
message = pgMessage
|
|
324
|
+
? `[turbine] Serializable transaction conflict: ${pgMessage}`
|
|
325
|
+
: '[turbine] Serializable transaction conflict';
|
|
326
|
+
}
|
|
327
|
+
super(exports.TurbineErrorCode.SERIALIZATION_FAILURE, message, { cause });
|
|
328
|
+
this.name = 'SerializationFailureError';
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
exports.SerializationFailureError = SerializationFailureError;
|
|
332
|
+
/** Thrown when a CHECK constraint is violated (pg code 23514) */
|
|
333
|
+
class CheckConstraintError extends TurbineError {
|
|
334
|
+
constraint;
|
|
335
|
+
table;
|
|
336
|
+
constructor(opts = {}) {
|
|
337
|
+
const { constraint, table, cause } = opts;
|
|
338
|
+
let message = opts.message;
|
|
339
|
+
if (!message) {
|
|
340
|
+
const constraintPart = constraint ? ` on ${constraint}` : '';
|
|
341
|
+
message = `[turbine] Check constraint violation${constraintPart}`;
|
|
342
|
+
const detail = detailFromCause(cause);
|
|
343
|
+
if (detail)
|
|
344
|
+
message += `: ${detail}`;
|
|
345
|
+
}
|
|
346
|
+
super(exports.TurbineErrorCode.CHECK_VIOLATION, message, { cause });
|
|
347
|
+
this.name = 'CheckConstraintError';
|
|
348
|
+
this.constraint = constraint;
|
|
349
|
+
this.table = table;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
exports.CheckConstraintError = CheckConstraintError;
|
|
353
|
+
/**
|
|
354
|
+
* Parse column names out of a pg `detail` string like:
|
|
355
|
+
* "Key (email)=(foo@bar) already exists."
|
|
356
|
+
* "Key (col1, col2)=(v1, v2) already exists."
|
|
357
|
+
*/
|
|
358
|
+
function parseColumnsFromDetail(detail) {
|
|
359
|
+
const m = detail.match(/^Key \(([^)]+)\)/);
|
|
360
|
+
if (!m)
|
|
361
|
+
return undefined;
|
|
362
|
+
return m[1].split(',').map((s) => s.trim());
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Translate a pg driver error into a typed Turbine error.
|
|
366
|
+
* If the error doesn't match a known constraint code, returns it unchanged.
|
|
367
|
+
*
|
|
368
|
+
* Maps:
|
|
369
|
+
* 23505 (unique_violation) -> UniqueConstraintError
|
|
370
|
+
* 23503 (foreign_key_violation) -> ForeignKeyError
|
|
371
|
+
* 23502 (not_null_violation) -> NotNullViolationError
|
|
372
|
+
* 23514 (check_violation) -> CheckConstraintError
|
|
373
|
+
* 40P01 (deadlock_detected) -> DeadlockError (retryable)
|
|
374
|
+
* 40001 (serialization_failure) -> SerializationFailureError (retryable)
|
|
375
|
+
*
|
|
376
|
+
* The original pg error is preserved as `.cause` on the wrapped error.
|
|
377
|
+
*/
|
|
378
|
+
function wrapPgError(err) {
|
|
379
|
+
if (!err || typeof err !== 'object')
|
|
380
|
+
return err;
|
|
381
|
+
const e = err;
|
|
382
|
+
if (!e.code)
|
|
383
|
+
return err;
|
|
384
|
+
switch (e.code) {
|
|
385
|
+
case '23505': {
|
|
386
|
+
const cols = e.detail ? parseColumnsFromDetail(e.detail) : undefined;
|
|
387
|
+
return new UniqueConstraintError({
|
|
388
|
+
constraint: e.constraint,
|
|
389
|
+
columns: cols,
|
|
390
|
+
table: e.table,
|
|
391
|
+
cause: err,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
case '23503':
|
|
395
|
+
return new ForeignKeyError({
|
|
396
|
+
constraint: e.constraint,
|
|
397
|
+
table: e.table,
|
|
398
|
+
cause: err,
|
|
399
|
+
});
|
|
400
|
+
case '23502':
|
|
401
|
+
return new NotNullViolationError({
|
|
402
|
+
column: e.column,
|
|
403
|
+
table: e.table,
|
|
404
|
+
cause: err,
|
|
405
|
+
});
|
|
406
|
+
case '23514':
|
|
407
|
+
return new CheckConstraintError({
|
|
408
|
+
constraint: e.constraint,
|
|
409
|
+
table: e.table,
|
|
410
|
+
cause: err,
|
|
411
|
+
});
|
|
412
|
+
case '40P01':
|
|
413
|
+
return new DeadlockError({
|
|
414
|
+
constraint: e.constraint,
|
|
415
|
+
cause: err,
|
|
416
|
+
});
|
|
417
|
+
case '40001':
|
|
418
|
+
return new SerializationFailureError({
|
|
419
|
+
cause: err,
|
|
420
|
+
});
|
|
421
|
+
default:
|
|
422
|
+
return err;
|
|
423
|
+
}
|
|
424
|
+
}
|
package/dist/cjs/generate.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* turbine-orm — Code generator
|
|
4
4
|
*
|
|
5
5
|
* Takes an IntrospectedSchema and emits TypeScript files:
|
|
6
6
|
* - types.ts — Entity interfaces, Create/Update input types
|
|
@@ -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");
|
|
@@ -55,18 +56,41 @@ function generate(options) {
|
|
|
55
56
|
function generatedFileHeader() {
|
|
56
57
|
return [
|
|
57
58
|
'/**',
|
|
58
|
-
' * Auto-generated by
|
|
59
|
+
' * Auto-generated by turbine-orm — DO NOT EDIT',
|
|
59
60
|
' *',
|
|
60
61
|
` * Generated at: ${new Date().toISOString()}`,
|
|
61
|
-
' * @see https://
|
|
62
|
+
' * @see https://turbineorm.dev',
|
|
62
63
|
' */',
|
|
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
|
-
const lines = [
|
|
68
|
-
|
|
69
|
-
|
|
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
|
+
}
|
|
70
94
|
// Generate enum types
|
|
71
95
|
for (const [enumName, labels] of Object.entries(schema.enums)) {
|
|
72
96
|
const typeName = (0, schema_js_1.snakeToPascal)(enumName);
|
|
@@ -108,17 +132,37 @@ function generateTypes(schema) {
|
|
|
108
132
|
lines.push('};');
|
|
109
133
|
lines.push('');
|
|
110
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`.
|
|
111
137
|
const nonPkCols = table.columns.filter((c) => !table.primaryKey.includes(c.name));
|
|
112
138
|
lines.push(`/** Input type for updating a row in \`${table.name}\` */`);
|
|
113
139
|
lines.push(`export type ${typeName}Update = {`);
|
|
114
140
|
for (const col of nonPkCols) {
|
|
115
|
-
lines.push(` ${col.field}?: ${col.tsType};`);
|
|
141
|
+
lines.push(` ${col.field}?: ${updateFieldType(col.tsType)};`);
|
|
116
142
|
}
|
|
117
143
|
lines.push('};');
|
|
118
144
|
lines.push('');
|
|
119
|
-
// ---
|
|
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).
|
|
120
153
|
const hasRelations = Object.keys(table.relations).length > 0;
|
|
121
154
|
if (hasRelations) {
|
|
155
|
+
lines.push(`/** Available relations for the \`${table.name}\` table */`);
|
|
156
|
+
lines.push(`export interface ${typeName}Relations {`);
|
|
157
|
+
for (const [relName, rel] of Object.entries(table.relations)) {
|
|
158
|
+
const targetType = entityName(rel.to);
|
|
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}>;`);
|
|
162
|
+
}
|
|
163
|
+
lines.push('}');
|
|
164
|
+
lines.push('');
|
|
165
|
+
// --- Legacy per-relation interfaces (kept for backward compatibility) ---
|
|
122
166
|
for (const [relName, rel] of Object.entries(table.relations)) {
|
|
123
167
|
const targetType = entityName(rel.to);
|
|
124
168
|
if (rel.type === 'hasMany') {
|
|
@@ -145,7 +189,7 @@ function generateTypes(schema) {
|
|
|
145
189
|
function generateMetadata(schema) {
|
|
146
190
|
const lines = [
|
|
147
191
|
...generatedFileHeader(),
|
|
148
|
-
"import type { SchemaMetadata } from '
|
|
192
|
+
"import type { SchemaMetadata } from 'turbine-orm';",
|
|
149
193
|
'',
|
|
150
194
|
'export const SCHEMA: SchemaMetadata = {',
|
|
151
195
|
' tables: {',
|
|
@@ -218,14 +262,55 @@ function generateIndex(schema) {
|
|
|
218
262
|
const tableEntries = Object.values(schema.tables);
|
|
219
263
|
const lines = [
|
|
220
264
|
...generatedFileHeader(),
|
|
221
|
-
"import { TurbineClient as BaseTurbineClient, QueryInterface } from '
|
|
222
|
-
"import type { TurbineConfig } from '
|
|
265
|
+
"import { TurbineClient as BaseTurbineClient, TransactionClient as BaseTransactionClient, QueryInterface } from 'turbine-orm';",
|
|
266
|
+
"import type { TurbineConfig, TransactionOptions } from 'turbine-orm';",
|
|
223
267
|
"import { SCHEMA } from './metadata.js';",
|
|
224
268
|
];
|
|
225
|
-
// Import all entity types
|
|
226
|
-
const typeImports =
|
|
269
|
+
// Import all entity types and relations maps
|
|
270
|
+
const typeImports = [];
|
|
271
|
+
for (const t of tableEntries) {
|
|
272
|
+
typeImports.push(entityName(t.name));
|
|
273
|
+
if (Object.keys(t.relations).length > 0) {
|
|
274
|
+
typeImports.push(`${entityName(t.name)}Relations`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
227
277
|
lines.push(`import type { ${typeImports.join(', ')} } from './types.js';`);
|
|
228
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('');
|
|
229
314
|
// Generate the client class with JSDoc
|
|
230
315
|
lines.push('/**');
|
|
231
316
|
lines.push(' * Generated Turbine client with typed table accessors.');
|
|
@@ -249,8 +334,10 @@ function generateIndex(schema) {
|
|
|
249
334
|
for (const table of tableEntries) {
|
|
250
335
|
const typeName = entityName(table.name);
|
|
251
336
|
const accessor = snakeToCamelStr(table.name);
|
|
337
|
+
const hasRelations = Object.keys(table.relations).length > 0;
|
|
338
|
+
const genericArgs = hasRelations ? `${typeName}, ${typeName}Relations` : typeName;
|
|
252
339
|
lines.push(` /** Query interface for the \`${table.name}\` table */`);
|
|
253
|
-
lines.push(` declare readonly ${accessor}: QueryInterface<${
|
|
340
|
+
lines.push(` declare readonly ${accessor}: QueryInterface<${genericArgs}>;`);
|
|
254
341
|
}
|
|
255
342
|
lines.push('');
|
|
256
343
|
lines.push(' constructor(config?: TurbineConfig) {');
|
|
@@ -258,6 +345,22 @@ function generateIndex(schema) {
|
|
|
258
345
|
lines.push(' }');
|
|
259
346
|
lines.push('}');
|
|
260
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('');
|
|
261
364
|
// Factory function with JSDoc
|
|
262
365
|
lines.push('/**');
|
|
263
366
|
lines.push(' * Create a new Turbine client instance.');
|
|
@@ -299,3 +402,31 @@ function quoteIfNeeded(s) {
|
|
|
299
402
|
function snakeToCamelStr(s) {
|
|
300
403
|
return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
301
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
|
+
}
|