turbine-orm 0.7.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 +99 -1
- 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 +49 -3
- package/dist/cjs/errors.js +135 -4
- package/dist/cjs/generate.js +120 -9
- package/dist/cjs/index.js +5 -1
- package/dist/cjs/query.js +102 -6
- 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 +15 -0
- package/dist/client.js +50 -4
- package/dist/errors.d.ts +88 -1
- package/dist/errors.js +130 -3
- package/dist/generate.d.ts +6 -0
- package/dist/generate.js +120 -10
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/query.d.ts +126 -11
- package/dist/query.js +102 -6
- 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/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.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,8 @@ 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',
|
|
24
28
|
};
|
|
25
29
|
/** Base error class for all Turbine errors */
|
|
26
30
|
class TurbineError extends Error {
|
|
@@ -32,6 +36,47 @@ class TurbineError extends Error {
|
|
|
32
36
|
}
|
|
33
37
|
}
|
|
34
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
|
+
}
|
|
35
80
|
/**
|
|
36
81
|
* Thrown when a record is not found (findUniqueOrThrow, findFirstOrThrow,
|
|
37
82
|
* update/delete against a non-matching row, etc.)
|
|
@@ -41,8 +86,16 @@ exports.TurbineError = TurbineError;
|
|
|
41
86
|
* - `new NotFoundError({ table, where, operation, cause, message })`
|
|
42
87
|
*
|
|
43
88
|
* When called with an options object and no explicit `message`, a Prisma-style
|
|
44
|
-
* message is built automatically,
|
|
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:
|
|
45
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.
|
|
46
99
|
*/
|
|
47
100
|
class NotFoundError extends TurbineError {
|
|
48
101
|
table;
|
|
@@ -59,11 +112,11 @@ class NotFoundError extends TurbineError {
|
|
|
59
112
|
let message = input.message;
|
|
60
113
|
if (!message) {
|
|
61
114
|
if (operation && table) {
|
|
62
|
-
const wherePart = where !== undefined ? ` matching where: ${
|
|
115
|
+
const wherePart = where !== undefined ? ` matching where: ${renderWhereForMessage(where, errorMessageMode)}` : '';
|
|
63
116
|
message = `[turbine] ${operation} on "${table}" found no record${wherePart}`;
|
|
64
117
|
}
|
|
65
118
|
else if (table) {
|
|
66
|
-
const wherePart = where !== undefined ? ` matching where ${
|
|
119
|
+
const wherePart = where !== undefined ? ` matching where ${renderWhereForMessage(where, errorMessageMode)}` : '';
|
|
67
120
|
message = `[turbine] No record found in "${table}"${wherePart}`;
|
|
68
121
|
}
|
|
69
122
|
else {
|
|
@@ -209,6 +262,73 @@ class NotNullViolationError extends TurbineError {
|
|
|
209
262
|
}
|
|
210
263
|
}
|
|
211
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;
|
|
212
332
|
/** Thrown when a CHECK constraint is violated (pg code 23514) */
|
|
213
333
|
class CheckConstraintError extends TurbineError {
|
|
214
334
|
constraint;
|
|
@@ -250,6 +370,8 @@ function parseColumnsFromDetail(detail) {
|
|
|
250
370
|
* 23503 (foreign_key_violation) -> ForeignKeyError
|
|
251
371
|
* 23502 (not_null_violation) -> NotNullViolationError
|
|
252
372
|
* 23514 (check_violation) -> CheckConstraintError
|
|
373
|
+
* 40P01 (deadlock_detected) -> DeadlockError (retryable)
|
|
374
|
+
* 40001 (serialization_failure) -> SerializationFailureError (retryable)
|
|
253
375
|
*
|
|
254
376
|
* The original pg error is preserved as `.cause` on the wrapped error.
|
|
255
377
|
*/
|
|
@@ -287,6 +409,15 @@ function wrapPgError(err) {
|
|
|
287
409
|
table: e.table,
|
|
288
410
|
cause: err,
|
|
289
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
|
+
});
|
|
290
421
|
default:
|
|
291
422
|
return err;
|
|
292
423
|
}
|
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.executePipeline = exports.introspect = exports.generate = exports.wrapPgError = exports.ValidationError = exports.UniqueConstraintError = exports.TurbineErrorCode = exports.TurbineError = exports.TimeoutError = exports.setErrorMessageMode = exports.SerializationFailureError = exports.RelationError = 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,15 @@ 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; } });
|
|
51
53
|
Object.defineProperty(exports, "RelationError", { enumerable: true, get: function () { return errors_js_1.RelationError; } });
|
|
54
|
+
Object.defineProperty(exports, "SerializationFailureError", { enumerable: true, get: function () { return errors_js_1.SerializationFailureError; } });
|
|
55
|
+
Object.defineProperty(exports, "setErrorMessageMode", { enumerable: true, get: function () { return errors_js_1.setErrorMessageMode; } });
|
|
52
56
|
Object.defineProperty(exports, "TimeoutError", { enumerable: true, get: function () { return errors_js_1.TimeoutError; } });
|
|
53
57
|
Object.defineProperty(exports, "TurbineError", { enumerable: true, get: function () { return errors_js_1.TurbineError; } });
|
|
54
58
|
Object.defineProperty(exports, "TurbineErrorCode", { enumerable: true, get: function () { return errors_js_1.TurbineErrorCode; } });
|
package/dist/cjs/query.js
CHANGED
|
@@ -75,6 +75,14 @@ function isWhereOperator(value) {
|
|
|
75
75
|
const UPDATE_OPERATOR_KEYS = new Set(['set', 'increment', 'decrement', 'multiply', 'divide']);
|
|
76
76
|
/** Known JSONB operator keys */
|
|
77
77
|
const JSONB_OPERATOR_KEYS = new Set(['path', 'equals', 'contains', 'hasKey']);
|
|
78
|
+
/**
|
|
79
|
+
* JSONB operator keys that are *unique* to {@link JsonFilter} — they cannot
|
|
80
|
+
* appear in any other where-filter shape, so the presence of one of these is
|
|
81
|
+
* an unambiguous signal that the user meant a JSON filter. Used by the
|
|
82
|
+
* strict-validation path so that `{ contains: 'foo' }` (which is also a valid
|
|
83
|
+
* `WhereOperator` for LIKE) is not misclassified.
|
|
84
|
+
*/
|
|
85
|
+
const JSONB_UNIQUE_KEYS = new Set(['path', 'equals', 'hasKey']);
|
|
78
86
|
/** Check if a value is a JSONB filter object */
|
|
79
87
|
function isJsonFilter(value) {
|
|
80
88
|
if (value === null ||
|
|
@@ -87,8 +95,27 @@ function isJsonFilter(value) {
|
|
|
87
95
|
const keys = Object.keys(value);
|
|
88
96
|
return keys.length > 0 && keys.some((k) => JSONB_OPERATOR_KEYS.has(k));
|
|
89
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* Returns the first JSON-unique key found in `value`, or `null` if none.
|
|
100
|
+
* Used to drive the strict-validation error message.
|
|
101
|
+
*/
|
|
102
|
+
function findJsonUniqueKey(value) {
|
|
103
|
+
for (const k of Object.keys(value)) {
|
|
104
|
+
if (JSONB_UNIQUE_KEYS.has(k))
|
|
105
|
+
return k;
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
90
109
|
/** Known Array operator keys */
|
|
91
110
|
const ARRAY_OPERATOR_KEYS = new Set(['has', 'hasEvery', 'hasSome', 'isEmpty']);
|
|
111
|
+
/**
|
|
112
|
+
* Array operator keys that are *unique* to {@link ArrayFilter}. None of the
|
|
113
|
+
* array operators currently overlap with `WhereOperator` or `JsonFilter`, so
|
|
114
|
+
* this set equals {@link ARRAY_OPERATOR_KEYS}; it is kept as a separate
|
|
115
|
+
* constant so a future overlap (e.g. a `contains` for arrays) is easy to
|
|
116
|
+
* carve out.
|
|
117
|
+
*/
|
|
118
|
+
const ARRAY_UNIQUE_KEYS = new Set(['has', 'hasEvery', 'hasSome', 'isEmpty']);
|
|
92
119
|
/** Check if a value is an Array filter object */
|
|
93
120
|
function isArrayFilter(value) {
|
|
94
121
|
if (value === null ||
|
|
@@ -101,6 +128,17 @@ function isArrayFilter(value) {
|
|
|
101
128
|
const keys = Object.keys(value);
|
|
102
129
|
return keys.length > 0 && keys.some((k) => ARRAY_OPERATOR_KEYS.has(k));
|
|
103
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* Returns the first array-unique key found in `value`, or `null` if none.
|
|
133
|
+
* Used to drive the strict-validation error message.
|
|
134
|
+
*/
|
|
135
|
+
function findArrayUniqueKey(value) {
|
|
136
|
+
for (const k of Object.keys(value)) {
|
|
137
|
+
if (ARRAY_UNIQUE_KEYS.has(k))
|
|
138
|
+
return k;
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
104
142
|
// ---------------------------------------------------------------------------
|
|
105
143
|
// LRU cache — bounded SQL template cache to prevent memory leaks
|
|
106
144
|
// ---------------------------------------------------------------------------
|
|
@@ -150,6 +188,15 @@ class QueryInterface {
|
|
|
150
188
|
middlewares;
|
|
151
189
|
defaultLimit;
|
|
152
190
|
warnOnUnlimited;
|
|
191
|
+
/**
|
|
192
|
+
* Tracks tables that have already triggered an unlimited-query warning so
|
|
193
|
+
* the user is not spammed once per row. Per-instance state — each
|
|
194
|
+
* QueryInterface is bound to a single table, so this set will only ever
|
|
195
|
+
* contain at most one entry, but using a Set keeps the API consistent with
|
|
196
|
+
* the audit's "Set<string>" guidance and leaves room for future
|
|
197
|
+
* cross-table sharing.
|
|
198
|
+
*/
|
|
199
|
+
warnedTables = new Set();
|
|
153
200
|
/** Pre-computed column type lookups (avoids linear scans per query) */
|
|
154
201
|
columnPgTypeMap;
|
|
155
202
|
columnArrayTypeMap;
|
|
@@ -164,7 +211,10 @@ class QueryInterface {
|
|
|
164
211
|
this.tableMeta = meta;
|
|
165
212
|
this.middlewares = middlewares ?? [];
|
|
166
213
|
this.defaultLimit = options?.defaultLimit;
|
|
167
|
-
|
|
214
|
+
// Default to ON: surfacing accidental full-table scans is more valuable
|
|
215
|
+
// than the (small) risk of noisy logs. Callers explicitly opt out with
|
|
216
|
+
// `warnOnUnlimited: false`.
|
|
217
|
+
this.warnOnUnlimited = options?.warnOnUnlimited !== false;
|
|
168
218
|
// Pre-compute column type lookup maps (TASK-26)
|
|
169
219
|
this.columnPgTypeMap = new Map();
|
|
170
220
|
this.columnArrayTypeMap = new Map();
|
|
@@ -173,6 +223,14 @@ class QueryInterface {
|
|
|
173
223
|
this.columnArrayTypeMap.set(col.name, col.pgArrayType);
|
|
174
224
|
}
|
|
175
225
|
}
|
|
226
|
+
/**
|
|
227
|
+
* Reset the per-instance unlimited-query warning dedupe set.
|
|
228
|
+
* Exposed for tests so a single test process can verify the warning fires
|
|
229
|
+
* exactly once per table without bleeding state between assertions.
|
|
230
|
+
*/
|
|
231
|
+
resetUnlimitedWarnings() {
|
|
232
|
+
this.warnedTables.clear();
|
|
233
|
+
}
|
|
176
234
|
/**
|
|
177
235
|
* Execute a pool.query with an optional timeout.
|
|
178
236
|
* If timeout is set, races the query against a timer and rejects on expiry.
|
|
@@ -304,17 +362,37 @@ class QueryInterface {
|
|
|
304
362
|
// findMany
|
|
305
363
|
// -------------------------------------------------------------------------
|
|
306
364
|
async findMany(args) {
|
|
307
|
-
|
|
308
|
-
const hasExplicitLimit = args?.limit !== undefined || args?.take !== undefined;
|
|
309
|
-
if (this.warnOnUnlimited && !hasExplicitLimit) {
|
|
310
|
-
console.warn(`[turbine] findMany() called without limit on table "${this.table}". Set defaultLimit in config to prevent unbounded queries.`);
|
|
311
|
-
}
|
|
365
|
+
this.maybeWarnUnlimited(args);
|
|
312
366
|
return this.executeWithMiddleware('findMany', (args ?? {}), async () => {
|
|
313
367
|
const deferred = this.buildFindMany(args);
|
|
314
368
|
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout);
|
|
315
369
|
return deferred.transform(result);
|
|
316
370
|
});
|
|
317
371
|
}
|
|
372
|
+
/**
|
|
373
|
+
* Emit a one-time `console.warn` when {@link findMany} is called without an
|
|
374
|
+
* explicit `limit`/`take` and `warnOnUnlimited` has not been disabled.
|
|
375
|
+
*
|
|
376
|
+
* Deduped per QueryInterface instance via {@link warnedTables} so a busy
|
|
377
|
+
* loop calling `db.users.findMany()` thousands of times only logs once.
|
|
378
|
+
* Suppressed when `defaultLimit` is configured (the caller has already
|
|
379
|
+
* opted in to a bounded query) and when the user passed an explicit
|
|
380
|
+
* `limit`, `take`, or `cursor`.
|
|
381
|
+
*/
|
|
382
|
+
maybeWarnUnlimited(args) {
|
|
383
|
+
if (!this.warnOnUnlimited)
|
|
384
|
+
return;
|
|
385
|
+
if (this.defaultLimit !== undefined)
|
|
386
|
+
return;
|
|
387
|
+
const hasExplicitLimit = args?.limit !== undefined || args?.take !== undefined || args?.cursor !== undefined;
|
|
388
|
+
if (hasExplicitLimit)
|
|
389
|
+
return;
|
|
390
|
+
if (this.warnedTables.has(this.table))
|
|
391
|
+
return;
|
|
392
|
+
this.warnedTables.add(this.table);
|
|
393
|
+
console.warn(`[turbine] warning: findMany on "${this.table}" has no limit — this will fetch every row. ` +
|
|
394
|
+
'Pass `limit` or set `warnOnUnlimited: false` in config to silence.');
|
|
395
|
+
}
|
|
318
396
|
buildFindMany(args) {
|
|
319
397
|
const { sql: whereSql, params } = args?.where ? this.buildWhere(args.where) : { sql: '', params: [] };
|
|
320
398
|
const columnsList = this.resolveColumns(args?.select, args?.omit);
|
|
@@ -1265,6 +1343,16 @@ class QueryInterface {
|
|
|
1265
1343
|
andClauses.push(...jsonClauses);
|
|
1266
1344
|
continue;
|
|
1267
1345
|
}
|
|
1346
|
+
// Strict validation: a JSON-only operator on a non-JSON column was almost
|
|
1347
|
+
// certainly a typo or schema mismatch. Silently falling through to plain
|
|
1348
|
+
// equality (the previous behaviour) wasted hours of debugging time. Only
|
|
1349
|
+
// throw when the operator is unambiguously JSON-specific — `contains` is
|
|
1350
|
+
// shared with WhereOperator's LIKE so it must continue to fall through.
|
|
1351
|
+
const jsonKey = findJsonUniqueKey(value);
|
|
1352
|
+
if (jsonKey) {
|
|
1353
|
+
throw new errors_js_1.ValidationError(`[turbine] Column "${rawColumn}" on table "${this.table}" is not a JSON column ` +
|
|
1354
|
+
`(actual type: ${colType}); cannot apply JSON operator '${jsonKey}'.`);
|
|
1355
|
+
}
|
|
1268
1356
|
}
|
|
1269
1357
|
// Handle Array filter operators (for array columns)
|
|
1270
1358
|
if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
|
|
@@ -1274,6 +1362,14 @@ class QueryInterface {
|
|
|
1274
1362
|
andClauses.push(...arrayClauses);
|
|
1275
1363
|
continue;
|
|
1276
1364
|
}
|
|
1365
|
+
// Strict validation: array operators (`has`, `hasEvery`, ...) on a
|
|
1366
|
+
// non-array column always indicate a mistake. None of these keys
|
|
1367
|
+
// overlap with other filter shapes so we can throw unconditionally.
|
|
1368
|
+
const arrayKey = findArrayUniqueKey(value);
|
|
1369
|
+
if (arrayKey) {
|
|
1370
|
+
throw new errors_js_1.ValidationError(`[turbine] Column "${rawColumn}" on table "${this.table}" is not an array column ` +
|
|
1371
|
+
`(actual type: ${colType}); cannot apply array operator '${arrayKey}'.`);
|
|
1372
|
+
}
|
|
1277
1373
|
}
|
|
1278
1374
|
// Handle operator objects
|
|
1279
1375
|
if (isWhereOperator(value)) {
|