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/generate.js
CHANGED
|
@@ -60,8 +60,33 @@ function generatedFileHeader() {
|
|
|
60
60
|
'',
|
|
61
61
|
];
|
|
62
62
|
}
|
|
63
|
-
|
|
63
|
+
/**
|
|
64
|
+
* Generate the contents of `types.ts` (entity interfaces, *Create / *Update,
|
|
65
|
+
* and *Relations brand-field interfaces). Exported so tests can pin the
|
|
66
|
+
* generator output without writing files to disk.
|
|
67
|
+
*/
|
|
68
|
+
export function generateTypes(schema) {
|
|
64
69
|
const lines = [...generatedFileHeader()];
|
|
70
|
+
// We import UpdateOperatorInput so generated *Update types can express
|
|
71
|
+
// atomic increment / decrement / multiply / divide / set operators on
|
|
72
|
+
// numeric columns (TASK-3.4).
|
|
73
|
+
//
|
|
74
|
+
// RelationDescriptor is the brand-field interface that lets `WithResult`
|
|
75
|
+
// recurse through nested `with` clauses at any depth. The generator emits
|
|
76
|
+
// each `*Relations` member as a `RelationDescriptor<Target, Cardinality,
|
|
77
|
+
// TargetRelations>` so users get full deep `with`-clause type inference
|
|
78
|
+
// out of the box (TASK-2.1).
|
|
79
|
+
lines.push("import type { RelationDescriptor, UpdateOperatorInput } from 'turbine-orm';");
|
|
80
|
+
lines.push('');
|
|
81
|
+
// Pre-compute which tables have relations so we know whether to thread
|
|
82
|
+
// `${TargetType}Relations` (for deep inference) or `{}` (the no-relations
|
|
83
|
+
// default) into each `RelationDescriptor`. Built once up-front because
|
|
84
|
+
// relations can point at tables we haven't iterated to yet.
|
|
85
|
+
const tablesWithRelations = new Set();
|
|
86
|
+
for (const t of Object.values(schema.tables)) {
|
|
87
|
+
if (Object.keys(t.relations).length > 0)
|
|
88
|
+
tablesWithRelations.add(t.name);
|
|
89
|
+
}
|
|
65
90
|
// Generate enum types
|
|
66
91
|
for (const [enumName, labels] of Object.entries(schema.enums)) {
|
|
67
92
|
const typeName = snakeToPascal(enumName);
|
|
@@ -103,27 +128,33 @@ function generateTypes(schema) {
|
|
|
103
128
|
lines.push('};');
|
|
104
129
|
lines.push('');
|
|
105
130
|
// --- Update input type (all fields optional except PK) ---
|
|
131
|
+
// Numeric columns additionally accept `UpdateOperatorInput<number>` so
|
|
132
|
+
// users can write `{ viewCount: { increment: 1 } }` without an `as any`.
|
|
106
133
|
const nonPkCols = table.columns.filter((c) => !table.primaryKey.includes(c.name));
|
|
107
134
|
lines.push(`/** Input type for updating a row in \`${table.name}\` */`);
|
|
108
135
|
lines.push(`export type ${typeName}Update = {`);
|
|
109
136
|
for (const col of nonPkCols) {
|
|
110
|
-
lines.push(` ${col.field}?: ${col.tsType};`);
|
|
137
|
+
lines.push(` ${col.field}?: ${updateFieldType(col.tsType)};`);
|
|
111
138
|
}
|
|
112
139
|
lines.push('};');
|
|
113
140
|
lines.push('');
|
|
114
141
|
// --- Relations map (for type-safe `with` clauses) ---
|
|
142
|
+
//
|
|
143
|
+
// Each relation is emitted as a `RelationDescriptor<Target, Cardinality,
|
|
144
|
+
// TargetRelations>` brand-field interface. This is what enables the
|
|
145
|
+
// recursive `WithResult` type to walk through nested `with` clauses at
|
|
146
|
+
// any depth — `RelationRelations<R[K]>` reads the third type parameter
|
|
147
|
+
// and threads it into the next recursion step. If the target table has
|
|
148
|
+
// no relations of its own, the descriptor uses `{}` (the default).
|
|
115
149
|
const hasRelations = Object.keys(table.relations).length > 0;
|
|
116
150
|
if (hasRelations) {
|
|
117
151
|
lines.push(`/** Available relations for the \`${table.name}\` table */`);
|
|
118
152
|
lines.push(`export interface ${typeName}Relations {`);
|
|
119
153
|
for (const [relName, rel] of Object.entries(table.relations)) {
|
|
120
154
|
const targetType = entityName(rel.to);
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
else {
|
|
125
|
-
lines.push(` ${relName}: ${targetType} | null;`);
|
|
126
|
-
}
|
|
155
|
+
const cardinality = rel.type === 'hasMany' ? "'many'" : "'one'";
|
|
156
|
+
const targetRelations = tablesWithRelations.has(rel.to) ? `${targetType}Relations` : '{}';
|
|
157
|
+
lines.push(` ${relName}: RelationDescriptor<${targetType}, ${cardinality}, ${targetRelations}>;`);
|
|
127
158
|
}
|
|
128
159
|
lines.push('}');
|
|
129
160
|
lines.push('');
|
|
@@ -227,8 +258,8 @@ function generateIndex(schema) {
|
|
|
227
258
|
const tableEntries = Object.values(schema.tables);
|
|
228
259
|
const lines = [
|
|
229
260
|
...generatedFileHeader(),
|
|
230
|
-
"import { TurbineClient as BaseTurbineClient, QueryInterface } from 'turbine-orm';",
|
|
231
|
-
"import type { TurbineConfig } from 'turbine-orm';",
|
|
261
|
+
"import { TurbineClient as BaseTurbineClient, TransactionClient as BaseTransactionClient, QueryInterface } from 'turbine-orm';",
|
|
262
|
+
"import type { TurbineConfig, TransactionOptions } from 'turbine-orm';",
|
|
232
263
|
"import { SCHEMA } from './metadata.js';",
|
|
233
264
|
];
|
|
234
265
|
// Import all entity types and relations maps
|
|
@@ -241,6 +272,41 @@ function generateIndex(schema) {
|
|
|
241
272
|
}
|
|
242
273
|
lines.push(`import type { ${typeImports.join(', ')} } from './types.js';`);
|
|
243
274
|
lines.push('');
|
|
275
|
+
// -------------------------------------------------------------------------
|
|
276
|
+
// TypedTransactionClient — same typed table accessors as TurbineClient,
|
|
277
|
+
// but scoped to a single transaction connection. The runtime instance is
|
|
278
|
+
// an ordinary `TransactionClient` from turbine-orm; this declaration just
|
|
279
|
+
// teaches TypeScript about the auto-attached accessors so users get
|
|
280
|
+
// autocomplete inside `db.$transaction(async (tx) => tx.users.create(...))`.
|
|
281
|
+
// -------------------------------------------------------------------------
|
|
282
|
+
lines.push('/**');
|
|
283
|
+
lines.push(' * Transaction-scoped client with the same typed table accessors as TurbineClient.');
|
|
284
|
+
lines.push(' * Created automatically by `db.$transaction(async (tx) => ...)` — never instantiate');
|
|
285
|
+
lines.push(' * directly. All queries run on a dedicated connection within a BEGIN/COMMIT block.');
|
|
286
|
+
lines.push(' */');
|
|
287
|
+
lines.push('export class TypedTransactionClient extends BaseTransactionClient {');
|
|
288
|
+
for (const table of tableEntries) {
|
|
289
|
+
const typeName = entityName(table.name);
|
|
290
|
+
const accessor = snakeToCamelStr(table.name);
|
|
291
|
+
const hasRelations = Object.keys(table.relations).length > 0;
|
|
292
|
+
const genericArgs = hasRelations ? `${typeName}, ${typeName}Relations` : typeName;
|
|
293
|
+
lines.push(` /** Query interface for the \`${table.name}\` table (transaction-scoped) */`);
|
|
294
|
+
lines.push(` declare readonly ${accessor}: QueryInterface<${genericArgs}>;`);
|
|
295
|
+
}
|
|
296
|
+
lines.push('}');
|
|
297
|
+
lines.push('');
|
|
298
|
+
// Augment the class with a typed `$transaction` overload via interface
|
|
299
|
+
// merging. This adds an additional callable signature whose callback
|
|
300
|
+
// parameter is narrowed to `TypedTransactionClient`, while the base
|
|
301
|
+
// signature (callback parameter `BaseTransactionClient`) remains valid.
|
|
302
|
+
lines.push('export interface TypedTransactionClient {');
|
|
303
|
+
lines.push(' /**');
|
|
304
|
+
lines.push(' * Nested transaction via SAVEPOINT. The callback receives a typed');
|
|
305
|
+
lines.push(' * `TypedTransactionClient` so all table accessors auto-complete.');
|
|
306
|
+
lines.push(' */');
|
|
307
|
+
lines.push(' $transaction<R>(fn: (tx: TypedTransactionClient) => Promise<R>): Promise<R>;');
|
|
308
|
+
lines.push('}');
|
|
309
|
+
lines.push('');
|
|
244
310
|
// Generate the client class with JSDoc
|
|
245
311
|
lines.push('/**');
|
|
246
312
|
lines.push(' * Generated Turbine client with typed table accessors.');
|
|
@@ -275,6 +341,22 @@ function generateIndex(schema) {
|
|
|
275
341
|
lines.push(' }');
|
|
276
342
|
lines.push('}');
|
|
277
343
|
lines.push('');
|
|
344
|
+
// Augment TurbineClient via interface merging with a typed $transaction
|
|
345
|
+
// overload. The callback parameter is narrowed to `TypedTransactionClient`
|
|
346
|
+
// so users get autocomplete on `tx.users`, `tx.posts`, etc. The base
|
|
347
|
+
// signature (callback parameter `BaseTransactionClient`) remains valid as
|
|
348
|
+
// an overload, so prior usage continues to typecheck.
|
|
349
|
+
lines.push('export interface TurbineClient {');
|
|
350
|
+
lines.push(' /**');
|
|
351
|
+
lines.push(' * Run a callback inside a transaction. The callback receives a typed');
|
|
352
|
+
lines.push(' * `TypedTransactionClient` with autocompletion for every table accessor.');
|
|
353
|
+
lines.push(' */');
|
|
354
|
+
lines.push(' $transaction<R>(');
|
|
355
|
+
lines.push(' fn: (tx: TypedTransactionClient) => Promise<R>,');
|
|
356
|
+
lines.push(' options?: TransactionOptions,');
|
|
357
|
+
lines.push(' ): Promise<R>;');
|
|
358
|
+
lines.push('}');
|
|
359
|
+
lines.push('');
|
|
278
360
|
// Factory function with JSDoc
|
|
279
361
|
lines.push('/**');
|
|
280
362
|
lines.push(' * Create a new Turbine client instance.');
|
|
@@ -316,4 +398,32 @@ function quoteIfNeeded(s) {
|
|
|
316
398
|
function snakeToCamelStr(s) {
|
|
317
399
|
return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
318
400
|
}
|
|
401
|
+
/**
|
|
402
|
+
* Build the update-input field type for a column. Numeric columns become
|
|
403
|
+
* `T | UpdateOperatorInput<number> | null?` so users can write atomic
|
|
404
|
+
* operators (`{ increment: 1 }`, `{ multiply: 2 }`, etc.) without casts.
|
|
405
|
+
*
|
|
406
|
+
* The check is purely structural — if the column's TS type contains
|
|
407
|
+
* `'number'` (e.g. `number`, `number | null`), it's eligible. Other
|
|
408
|
+
* scalar types (`string`, `boolean`, `Date`, `unknown`, `Buffer`,
|
|
409
|
+
* `Date | null`, etc.) pass through unchanged.
|
|
410
|
+
*/
|
|
411
|
+
function updateFieldType(tsType) {
|
|
412
|
+
// Strip parens for the regex check; preserve the original string in the output.
|
|
413
|
+
if (containsNumberType(tsType)) {
|
|
414
|
+
return `${tsType} | UpdateOperatorInput<number>`;
|
|
415
|
+
}
|
|
416
|
+
return tsType;
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Detect whether a TypeScript type expression contains the `number` primitive
|
|
420
|
+
* as a top-level union member. Conservative on purpose — only matches
|
|
421
|
+
* `number`, `number | null`, `null | number`, etc., not `number[]` or
|
|
422
|
+
* `Record<string, number>`.
|
|
423
|
+
*/
|
|
424
|
+
function containsNumberType(tsType) {
|
|
425
|
+
// Tokenize on `|` and check each member.
|
|
426
|
+
const parts = tsType.split('|').map((p) => p.trim());
|
|
427
|
+
return parts.some((p) => p === 'number');
|
|
428
|
+
}
|
|
319
429
|
//# sourceMappingURL=generate.js.map
|
package/dist/index.d.ts
CHANGED
|
@@ -33,11 +33,11 @@
|
|
|
33
33
|
* ```
|
|
34
34
|
*/
|
|
35
35
|
export { type Middleware, type MiddlewareNext, type MiddlewareParams, type PgCompatPool, type PgCompatPoolClient, type PgCompatQueryResult, TransactionClient, type TransactionOptions, TurbineClient, type TurbineConfig, } from './client.js';
|
|
36
|
-
export { CheckConstraintError, CircularRelationError, ConnectionError, ForeignKeyError, MigrationError, NotFoundError, NotNullViolationError, RelationError, TimeoutError, TurbineError, TurbineErrorCode, UniqueConstraintError, ValidationError, wrapPgError, } from './errors.js';
|
|
36
|
+
export { CheckConstraintError, CircularRelationError, ConnectionError, DeadlockError, type ErrorMessageMode, ForeignKeyError, getErrorMessageMode, MigrationError, NotFoundError, NotNullViolationError, RelationError, SerializationFailureError, setErrorMessageMode, TimeoutError, TurbineError, TurbineErrorCode, UniqueConstraintError, ValidationError, wrapPgError, } from './errors.js';
|
|
37
37
|
export { type GenerateOptions, generate } from './generate.js';
|
|
38
38
|
export { type IntrospectOptions, introspect } from './introspect.js';
|
|
39
39
|
export { executePipeline, type PipelineResults } from './pipeline.js';
|
|
40
|
-
export { type AggregateArgs, type AggregateResult, type ArrayFilter, type CountArgs, type CreateArgs, type CreateManyArgs, type DeferredQuery, type DeleteArgs, type DeleteManyArgs, type FindManyArgs, type FindManyStreamArgs, type FindUniqueArgs, type GroupByArgs, type JsonFilter, type OrderDirection, QueryInterface, type RelationFilter, type TypedWithClause, type UpdateArgs, type UpdateInput, type UpdateManyArgs, type UpdateOperatorInput, type UpsertArgs, type WithClause, type WithOptions, type WithResult, } from './query.js';
|
|
40
|
+
export { type AggregateArgs, type AggregateResult, type ArrayFilter, type CountArgs, type CreateArgs, type CreateManyArgs, type DeferredQuery, type DeleteArgs, type DeleteManyArgs, type FindManyArgs, type FindManyStreamArgs, type FindUniqueArgs, type GroupByArgs, type JsonFilter, type OrderDirection, QueryInterface, type RelationDescriptor, type RelationFilter, type TypedWithClause, type UpdateArgs, type UpdateInput, type UpdateManyArgs, type UpdateOperatorInput, type UpsertArgs, type WithClause, type WithOptions, type WithResult, } from './query.js';
|
|
41
41
|
export type { ColumnMetadata, IndexMetadata, RelationDef, SchemaMetadata, TableMetadata, } from './schema.js';
|
|
42
42
|
export { camelToSnake, isDateType, pgArrayType, pgTypeToTs, singularize, snakeToCamel, snakeToPascal, } from './schema.js';
|
|
43
43
|
export { ColumnBuilder, type ColumnConfig, type ColumnDef, type ColumnType, type ColumnTypeName, column, defineSchema, type SchemaDef, type TableDef, table, } from './schema-builder.js';
|
package/dist/index.js
CHANGED
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
// Client
|
|
36
36
|
export { TransactionClient, TurbineClient, } from './client.js';
|
|
37
37
|
// Error types
|
|
38
|
-
export { CheckConstraintError, CircularRelationError, ConnectionError, ForeignKeyError, MigrationError, NotFoundError, NotNullViolationError, RelationError, TimeoutError, TurbineError, TurbineErrorCode, UniqueConstraintError, ValidationError, wrapPgError, } from './errors.js';
|
|
38
|
+
export { CheckConstraintError, CircularRelationError, ConnectionError, DeadlockError, ForeignKeyError, getErrorMessageMode, MigrationError, NotFoundError, NotNullViolationError, RelationError, SerializationFailureError, setErrorMessageMode, TimeoutError, TurbineError, TurbineErrorCode, UniqueConstraintError, ValidationError, wrapPgError, } from './errors.js';
|
|
39
39
|
// Code generation
|
|
40
40
|
export { generate } from './generate.js';
|
|
41
41
|
// Introspection
|
package/dist/query.d.ts
CHANGED
|
@@ -72,12 +72,26 @@ export interface WithClause {
|
|
|
72
72
|
* Relation-aware with clause. When R (the relations map) is provided,
|
|
73
73
|
* only keys from R are autocompleted. Used in public method signatures
|
|
74
74
|
* so the compiler can narrow the return type.
|
|
75
|
+
*
|
|
76
|
+
* For typed maps, each relation accepts either `true` (default include) or a
|
|
77
|
+
* {@link WithOptions} object whose nested `with` is keyed against the relation
|
|
78
|
+
* target's own relations interface — this is what enables deep
|
|
79
|
+
* `WithResult` inference.
|
|
75
80
|
*/
|
|
76
81
|
export type TypedWithClause<R extends object = {}> = [keyof R] extends [never] ? WithClause : {
|
|
77
|
-
[K in keyof R]?: true | WithOptions
|
|
82
|
+
[K in keyof R]?: true | WithOptions<RelationRelations<R[K]> & object>;
|
|
78
83
|
};
|
|
79
|
-
|
|
80
|
-
|
|
84
|
+
/**
|
|
85
|
+
* Options for an included relation.
|
|
86
|
+
*
|
|
87
|
+
* Generic over `NestedR` — the relations interface of the *target* entity —
|
|
88
|
+
* so the nested `with` clause is autocompleted with the correct relation keys
|
|
89
|
+
* and so {@link WithResult} can recursively infer the return type. Defaults to
|
|
90
|
+
* `{}` (no relation suggestions) for callers that use the unparameterized
|
|
91
|
+
* {@link WithClause}.
|
|
92
|
+
*/
|
|
93
|
+
export interface WithOptions<NestedR extends object = {}> {
|
|
94
|
+
with?: TypedWithClause<NestedR>;
|
|
81
95
|
where?: Record<string, unknown>;
|
|
82
96
|
orderBy?: Record<string, OrderDirection>;
|
|
83
97
|
limit?: number;
|
|
@@ -86,18 +100,87 @@ export interface WithOptions<_T = unknown> {
|
|
|
86
100
|
/** Exclude these fields from the relation */
|
|
87
101
|
omit?: Record<string, boolean>;
|
|
88
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* A relation descriptor used by generated `*Relations` interfaces to make deep
|
|
105
|
+
* `with` clause inference work. It bundles three pieces of information that
|
|
106
|
+
* `WithResult` needs to recurse through nested relations:
|
|
107
|
+
*
|
|
108
|
+
* - `__target` — the target entity type (e.g. `Post`)
|
|
109
|
+
* - `__cardinality`— `'many'` for hasMany, `'one'` for belongsTo / hasOne
|
|
110
|
+
* - `__relations` — the target entity's relations interface (for further recursion)
|
|
111
|
+
*
|
|
112
|
+
* **Generator contract (Track 3):** the code generator emits `*Relations`
|
|
113
|
+
* interfaces in the following shape so that `WithResult` can walk arbitrary
|
|
114
|
+
* nesting depth:
|
|
115
|
+
*
|
|
116
|
+
* ```ts
|
|
117
|
+
* export interface UserRelations {
|
|
118
|
+
* posts: RelationDescriptor<Post, 'many', PostRelations>;
|
|
119
|
+
* profile: RelationDescriptor<Profile, 'one', ProfileRelations>;
|
|
120
|
+
* }
|
|
121
|
+
* ```
|
|
122
|
+
*
|
|
123
|
+
* The brand fields are phantom — they exist only for type inference and have
|
|
124
|
+
* no runtime representation. The runtime always sees the parsed entity values
|
|
125
|
+
* (arrays for hasMany, single object or null for belongsTo / hasOne) — see the
|
|
126
|
+
* cardinality projection inside {@link WithResult}.
|
|
127
|
+
*
|
|
128
|
+
* **Backward compatibility:** legacy generated code emitted bare types
|
|
129
|
+
* (`posts: Post[]`, `profile: Profile | null`). `WithResult` still accepts that
|
|
130
|
+
* shape via a fallback branch — it just cannot recurse into nested `with` for
|
|
131
|
+
* those relations until the generator is updated.
|
|
132
|
+
*
|
|
133
|
+
* @typeParam Target - The entity type the relation points at.
|
|
134
|
+
* @typeParam Cardinality - `'many'` (array) or `'one'` (single object | null).
|
|
135
|
+
* @typeParam Relations - The target entity's own `*Relations` interface, or
|
|
136
|
+
* `{}` if the target has no relations of its own.
|
|
137
|
+
*/
|
|
138
|
+
export interface RelationDescriptor<Target, Cardinality extends 'one' | 'many', Relations extends object = {}> {
|
|
139
|
+
readonly __target?: Target;
|
|
140
|
+
readonly __cardinality?: Cardinality;
|
|
141
|
+
readonly __relations?: Relations;
|
|
142
|
+
}
|
|
143
|
+
/** Extract the target entity from a relation descriptor or bare relation type. */
|
|
144
|
+
type RelationTarget<Rel> = Rel extends RelationDescriptor<infer Target, infer _C, infer _R> ? Target : Rel extends Array<infer Item> ? Item : Rel extends infer One | null ? One : Rel;
|
|
145
|
+
/** Extract the target's relations map from a relation descriptor (or `{}` for bare types). */
|
|
146
|
+
type RelationRelations<Rel> = Rel extends RelationDescriptor<infer _T, infer _C, infer R> ? R : {};
|
|
147
|
+
/** Project the target type into its runtime shape (array for many, single for one). */
|
|
148
|
+
type ApplyCardinality<Rel, Resolved> = Rel extends RelationDescriptor<infer _T, infer Cardinality, infer _R> ? Cardinality extends 'many' ? Resolved[] : Resolved | null : Rel extends Array<infer _Item> ? Resolved[] : Resolved | null;
|
|
89
149
|
/**
|
|
90
150
|
* Compute the result type when relations are included via `with`.
|
|
91
151
|
*
|
|
92
|
-
*
|
|
93
|
-
* - When R is a real relations map and W specifies relation keys, merges matching
|
|
94
|
-
* relation types from R onto T.
|
|
152
|
+
* Recursively walks the `with` clause, looking up each relation in `R` and:
|
|
95
153
|
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
154
|
+
* 1. If the relation is included with `true` (or no nested `with`), the
|
|
155
|
+
* relation's bare resolved type is grafted onto `T` (e.g. `posts: Post[]`).
|
|
156
|
+
* 2. If the relation is included with a nested `with: {...}`, the recursion
|
|
157
|
+
* looks up the target entity's relations interface (via the
|
|
158
|
+
* {@link RelationDescriptor} brand fields the generator emits) and
|
|
159
|
+
* recursively applies `WithResult` to the nested target. Cardinality is
|
|
160
|
+
* re-applied at each level so a hasMany relation stays an array even after
|
|
161
|
+
* deep nesting.
|
|
162
|
+
*
|
|
163
|
+
* **When `R` is `{}` (the default):** the recursion short-circuits and the
|
|
164
|
+
* function returns plain `T` — preserving the existing untyped escape hatch
|
|
165
|
+
* for callers that have not generated typed clients.
|
|
166
|
+
*
|
|
167
|
+
* **When `R` does not contain the requested relations:** the unknown keys are
|
|
168
|
+
* ignored (the runtime will throw a `RelationError`, but the type system stays
|
|
169
|
+
* permissive so the legacy `WithClause` index signature still typechecks).
|
|
170
|
+
*
|
|
171
|
+
* @typeParam T - Base entity type (e.g. `User`).
|
|
172
|
+
* @typeParam R - Relations map for `T` (e.g.
|
|
173
|
+
* `{ posts: RelationDescriptor<Post, 'many', PostRelations>; ... }`).
|
|
174
|
+
* Legacy bare shapes (`{ posts: Post[]; profile: Profile | null }`)
|
|
175
|
+
* are also accepted, but cannot recurse beyond one level.
|
|
176
|
+
* @typeParam W - The `with` clause the user passed (e.g. `{ posts: true }` or
|
|
177
|
+
* `{ posts: { with: { comments: true } } }`).
|
|
99
178
|
*/
|
|
100
|
-
export type WithResult<T, R extends object, W> = [keyof R] extends [never] ? T : [keyof W & keyof R] extends [never] ? T : T &
|
|
179
|
+
export type WithResult<T, R extends object, W> = [keyof R] extends [never] ? T : W extends object ? [keyof W & keyof R] extends [never] ? T : T & {
|
|
180
|
+
[K in keyof W & keyof R]: W[K] extends true ? ApplyCardinality<R[K], RelationTarget<R[K]>> : W[K] extends {
|
|
181
|
+
with?: infer NestedW;
|
|
182
|
+
} ? NestedW extends object ? ApplyCardinality<R[K], WithResult<RelationTarget<R[K]>, RelationRelations<R[K]> & object, NestedW>> : ApplyCardinality<R[K], RelationTarget<R[K]>> : ApplyCardinality<R[K], RelationTarget<R[K]>>;
|
|
183
|
+
} : T;
|
|
101
184
|
export interface FindUniqueArgs<T, R extends object = {}, W extends TypedWithClause<R> = TypedWithClause<R>> {
|
|
102
185
|
where: WhereClause<T>;
|
|
103
186
|
select?: Record<string, boolean>;
|
|
@@ -311,7 +394,13 @@ type MiddlewareFn = (params: {
|
|
|
311
394
|
export interface QueryInterfaceOptions {
|
|
312
395
|
/** Default LIMIT applied to findMany() when no limit is specified */
|
|
313
396
|
defaultLimit?: number;
|
|
314
|
-
/**
|
|
397
|
+
/**
|
|
398
|
+
* Log a one-time warning when {@link QueryInterface.findMany} is called
|
|
399
|
+
* without a `limit`. Defaults to `true` so that accidental unbounded
|
|
400
|
+
* queries are surfaced loudly during development. Pass `false` to silence
|
|
401
|
+
* the warning entirely (e.g. for CLI tooling that intentionally streams
|
|
402
|
+
* full tables).
|
|
403
|
+
*/
|
|
315
404
|
warnOnUnlimited?: boolean;
|
|
316
405
|
}
|
|
317
406
|
export declare class QueryInterface<T extends object, R extends object = {}> {
|
|
@@ -324,10 +413,25 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
|
|
|
324
413
|
private readonly middlewares;
|
|
325
414
|
private readonly defaultLimit?;
|
|
326
415
|
private readonly warnOnUnlimited;
|
|
416
|
+
/**
|
|
417
|
+
* Tracks tables that have already triggered an unlimited-query warning so
|
|
418
|
+
* the user is not spammed once per row. Per-instance state — each
|
|
419
|
+
* QueryInterface is bound to a single table, so this set will only ever
|
|
420
|
+
* contain at most one entry, but using a Set keeps the API consistent with
|
|
421
|
+
* the audit's "Set<string>" guidance and leaves room for future
|
|
422
|
+
* cross-table sharing.
|
|
423
|
+
*/
|
|
424
|
+
private readonly warnedTables;
|
|
327
425
|
/** Pre-computed column type lookups (avoids linear scans per query) */
|
|
328
426
|
private readonly columnPgTypeMap;
|
|
329
427
|
private readonly columnArrayTypeMap;
|
|
330
428
|
constructor(pool: pg.Pool, table: string, schema: SchemaMetadata, middlewares?: MiddlewareFn[], options?: QueryInterfaceOptions);
|
|
429
|
+
/**
|
|
430
|
+
* Reset the per-instance unlimited-query warning dedupe set.
|
|
431
|
+
* Exposed for tests so a single test process can verify the warning fires
|
|
432
|
+
* exactly once per table without bleeding state between assertions.
|
|
433
|
+
*/
|
|
434
|
+
resetUnlimitedWarnings(): void;
|
|
331
435
|
/**
|
|
332
436
|
* Execute a pool.query with an optional timeout.
|
|
333
437
|
* If timeout is set, races the query against a timer and rejects on expiry.
|
|
@@ -347,6 +451,17 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
|
|
|
347
451
|
findUnique<W extends TypedWithClause<R> = {}>(args: FindUniqueArgs<T, R, W>): Promise<WithResult<T, R, W> | null>;
|
|
348
452
|
buildFindUnique<W extends TypedWithClause<R> = {}>(args: FindUniqueArgs<T, R, W>): DeferredQuery<T | null>;
|
|
349
453
|
findMany<W extends TypedWithClause<R> = {}>(args?: FindManyArgs<T, R, W>): Promise<WithResult<T, R, W>[]>;
|
|
454
|
+
/**
|
|
455
|
+
* Emit a one-time `console.warn` when {@link findMany} is called without an
|
|
456
|
+
* explicit `limit`/`take` and `warnOnUnlimited` has not been disabled.
|
|
457
|
+
*
|
|
458
|
+
* Deduped per QueryInterface instance via {@link warnedTables} so a busy
|
|
459
|
+
* loop calling `db.users.findMany()` thousands of times only logs once.
|
|
460
|
+
* Suppressed when `defaultLimit` is configured (the caller has already
|
|
461
|
+
* opted in to a bounded query) and when the user passed an explicit
|
|
462
|
+
* `limit`, `take`, or `cursor`.
|
|
463
|
+
*/
|
|
464
|
+
private maybeWarnUnlimited;
|
|
350
465
|
buildFindMany<W extends TypedWithClause<R> = {}>(args?: FindManyArgs<T, R, W>): DeferredQuery<T[]>;
|
|
351
466
|
/**
|
|
352
467
|
* Stream rows from a findMany query using PostgreSQL cursors.
|
package/dist/query.js
CHANGED
|
@@ -71,6 +71,14 @@ function isWhereOperator(value) {
|
|
|
71
71
|
const UPDATE_OPERATOR_KEYS = new Set(['set', 'increment', 'decrement', 'multiply', 'divide']);
|
|
72
72
|
/** Known JSONB operator keys */
|
|
73
73
|
const JSONB_OPERATOR_KEYS = new Set(['path', 'equals', 'contains', 'hasKey']);
|
|
74
|
+
/**
|
|
75
|
+
* JSONB operator keys that are *unique* to {@link JsonFilter} — they cannot
|
|
76
|
+
* appear in any other where-filter shape, so the presence of one of these is
|
|
77
|
+
* an unambiguous signal that the user meant a JSON filter. Used by the
|
|
78
|
+
* strict-validation path so that `{ contains: 'foo' }` (which is also a valid
|
|
79
|
+
* `WhereOperator` for LIKE) is not misclassified.
|
|
80
|
+
*/
|
|
81
|
+
const JSONB_UNIQUE_KEYS = new Set(['path', 'equals', 'hasKey']);
|
|
74
82
|
/** Check if a value is a JSONB filter object */
|
|
75
83
|
function isJsonFilter(value) {
|
|
76
84
|
if (value === null ||
|
|
@@ -83,8 +91,27 @@ function isJsonFilter(value) {
|
|
|
83
91
|
const keys = Object.keys(value);
|
|
84
92
|
return keys.length > 0 && keys.some((k) => JSONB_OPERATOR_KEYS.has(k));
|
|
85
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* Returns the first JSON-unique key found in `value`, or `null` if none.
|
|
96
|
+
* Used to drive the strict-validation error message.
|
|
97
|
+
*/
|
|
98
|
+
function findJsonUniqueKey(value) {
|
|
99
|
+
for (const k of Object.keys(value)) {
|
|
100
|
+
if (JSONB_UNIQUE_KEYS.has(k))
|
|
101
|
+
return k;
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
86
105
|
/** Known Array operator keys */
|
|
87
106
|
const ARRAY_OPERATOR_KEYS = new Set(['has', 'hasEvery', 'hasSome', 'isEmpty']);
|
|
107
|
+
/**
|
|
108
|
+
* Array operator keys that are *unique* to {@link ArrayFilter}. None of the
|
|
109
|
+
* array operators currently overlap with `WhereOperator` or `JsonFilter`, so
|
|
110
|
+
* this set equals {@link ARRAY_OPERATOR_KEYS}; it is kept as a separate
|
|
111
|
+
* constant so a future overlap (e.g. a `contains` for arrays) is easy to
|
|
112
|
+
* carve out.
|
|
113
|
+
*/
|
|
114
|
+
const ARRAY_UNIQUE_KEYS = new Set(['has', 'hasEvery', 'hasSome', 'isEmpty']);
|
|
88
115
|
/** Check if a value is an Array filter object */
|
|
89
116
|
function isArrayFilter(value) {
|
|
90
117
|
if (value === null ||
|
|
@@ -97,6 +124,17 @@ function isArrayFilter(value) {
|
|
|
97
124
|
const keys = Object.keys(value);
|
|
98
125
|
return keys.length > 0 && keys.some((k) => ARRAY_OPERATOR_KEYS.has(k));
|
|
99
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* Returns the first array-unique key found in `value`, or `null` if none.
|
|
129
|
+
* Used to drive the strict-validation error message.
|
|
130
|
+
*/
|
|
131
|
+
function findArrayUniqueKey(value) {
|
|
132
|
+
for (const k of Object.keys(value)) {
|
|
133
|
+
if (ARRAY_UNIQUE_KEYS.has(k))
|
|
134
|
+
return k;
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
100
138
|
// ---------------------------------------------------------------------------
|
|
101
139
|
// LRU cache — bounded SQL template cache to prevent memory leaks
|
|
102
140
|
// ---------------------------------------------------------------------------
|
|
@@ -146,6 +184,15 @@ export class QueryInterface {
|
|
|
146
184
|
middlewares;
|
|
147
185
|
defaultLimit;
|
|
148
186
|
warnOnUnlimited;
|
|
187
|
+
/**
|
|
188
|
+
* Tracks tables that have already triggered an unlimited-query warning so
|
|
189
|
+
* the user is not spammed once per row. Per-instance state — each
|
|
190
|
+
* QueryInterface is bound to a single table, so this set will only ever
|
|
191
|
+
* contain at most one entry, but using a Set keeps the API consistent with
|
|
192
|
+
* the audit's "Set<string>" guidance and leaves room for future
|
|
193
|
+
* cross-table sharing.
|
|
194
|
+
*/
|
|
195
|
+
warnedTables = new Set();
|
|
149
196
|
/** Pre-computed column type lookups (avoids linear scans per query) */
|
|
150
197
|
columnPgTypeMap;
|
|
151
198
|
columnArrayTypeMap;
|
|
@@ -160,7 +207,10 @@ export class QueryInterface {
|
|
|
160
207
|
this.tableMeta = meta;
|
|
161
208
|
this.middlewares = middlewares ?? [];
|
|
162
209
|
this.defaultLimit = options?.defaultLimit;
|
|
163
|
-
|
|
210
|
+
// Default to ON: surfacing accidental full-table scans is more valuable
|
|
211
|
+
// than the (small) risk of noisy logs. Callers explicitly opt out with
|
|
212
|
+
// `warnOnUnlimited: false`.
|
|
213
|
+
this.warnOnUnlimited = options?.warnOnUnlimited !== false;
|
|
164
214
|
// Pre-compute column type lookup maps (TASK-26)
|
|
165
215
|
this.columnPgTypeMap = new Map();
|
|
166
216
|
this.columnArrayTypeMap = new Map();
|
|
@@ -169,6 +219,14 @@ export class QueryInterface {
|
|
|
169
219
|
this.columnArrayTypeMap.set(col.name, col.pgArrayType);
|
|
170
220
|
}
|
|
171
221
|
}
|
|
222
|
+
/**
|
|
223
|
+
* Reset the per-instance unlimited-query warning dedupe set.
|
|
224
|
+
* Exposed for tests so a single test process can verify the warning fires
|
|
225
|
+
* exactly once per table without bleeding state between assertions.
|
|
226
|
+
*/
|
|
227
|
+
resetUnlimitedWarnings() {
|
|
228
|
+
this.warnedTables.clear();
|
|
229
|
+
}
|
|
172
230
|
/**
|
|
173
231
|
* Execute a pool.query with an optional timeout.
|
|
174
232
|
* If timeout is set, races the query against a timer and rejects on expiry.
|
|
@@ -300,17 +358,37 @@ export class QueryInterface {
|
|
|
300
358
|
// findMany
|
|
301
359
|
// -------------------------------------------------------------------------
|
|
302
360
|
async findMany(args) {
|
|
303
|
-
|
|
304
|
-
const hasExplicitLimit = args?.limit !== undefined || args?.take !== undefined;
|
|
305
|
-
if (this.warnOnUnlimited && !hasExplicitLimit) {
|
|
306
|
-
console.warn(`[turbine] findMany() called without limit on table "${this.table}". Set defaultLimit in config to prevent unbounded queries.`);
|
|
307
|
-
}
|
|
361
|
+
this.maybeWarnUnlimited(args);
|
|
308
362
|
return this.executeWithMiddleware('findMany', (args ?? {}), async () => {
|
|
309
363
|
const deferred = this.buildFindMany(args);
|
|
310
364
|
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout);
|
|
311
365
|
return deferred.transform(result);
|
|
312
366
|
});
|
|
313
367
|
}
|
|
368
|
+
/**
|
|
369
|
+
* Emit a one-time `console.warn` when {@link findMany} is called without an
|
|
370
|
+
* explicit `limit`/`take` and `warnOnUnlimited` has not been disabled.
|
|
371
|
+
*
|
|
372
|
+
* Deduped per QueryInterface instance via {@link warnedTables} so a busy
|
|
373
|
+
* loop calling `db.users.findMany()` thousands of times only logs once.
|
|
374
|
+
* Suppressed when `defaultLimit` is configured (the caller has already
|
|
375
|
+
* opted in to a bounded query) and when the user passed an explicit
|
|
376
|
+
* `limit`, `take`, or `cursor`.
|
|
377
|
+
*/
|
|
378
|
+
maybeWarnUnlimited(args) {
|
|
379
|
+
if (!this.warnOnUnlimited)
|
|
380
|
+
return;
|
|
381
|
+
if (this.defaultLimit !== undefined)
|
|
382
|
+
return;
|
|
383
|
+
const hasExplicitLimit = args?.limit !== undefined || args?.take !== undefined || args?.cursor !== undefined;
|
|
384
|
+
if (hasExplicitLimit)
|
|
385
|
+
return;
|
|
386
|
+
if (this.warnedTables.has(this.table))
|
|
387
|
+
return;
|
|
388
|
+
this.warnedTables.add(this.table);
|
|
389
|
+
console.warn(`[turbine] warning: findMany on "${this.table}" has no limit — this will fetch every row. ` +
|
|
390
|
+
'Pass `limit` or set `warnOnUnlimited: false` in config to silence.');
|
|
391
|
+
}
|
|
314
392
|
buildFindMany(args) {
|
|
315
393
|
const { sql: whereSql, params } = args?.where ? this.buildWhere(args.where) : { sql: '', params: [] };
|
|
316
394
|
const columnsList = this.resolveColumns(args?.select, args?.omit);
|
|
@@ -1261,6 +1339,16 @@ export class QueryInterface {
|
|
|
1261
1339
|
andClauses.push(...jsonClauses);
|
|
1262
1340
|
continue;
|
|
1263
1341
|
}
|
|
1342
|
+
// Strict validation: a JSON-only operator on a non-JSON column was almost
|
|
1343
|
+
// certainly a typo or schema mismatch. Silently falling through to plain
|
|
1344
|
+
// equality (the previous behaviour) wasted hours of debugging time. Only
|
|
1345
|
+
// throw when the operator is unambiguously JSON-specific — `contains` is
|
|
1346
|
+
// shared with WhereOperator's LIKE so it must continue to fall through.
|
|
1347
|
+
const jsonKey = findJsonUniqueKey(value);
|
|
1348
|
+
if (jsonKey) {
|
|
1349
|
+
throw new ValidationError(`[turbine] Column "${rawColumn}" on table "${this.table}" is not a JSON column ` +
|
|
1350
|
+
`(actual type: ${colType}); cannot apply JSON operator '${jsonKey}'.`);
|
|
1351
|
+
}
|
|
1264
1352
|
}
|
|
1265
1353
|
// Handle Array filter operators (for array columns)
|
|
1266
1354
|
if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
|
|
@@ -1270,6 +1358,14 @@ export class QueryInterface {
|
|
|
1270
1358
|
andClauses.push(...arrayClauses);
|
|
1271
1359
|
continue;
|
|
1272
1360
|
}
|
|
1361
|
+
// Strict validation: array operators (`has`, `hasEvery`, ...) on a
|
|
1362
|
+
// non-array column always indicate a mistake. None of these keys
|
|
1363
|
+
// overlap with other filter shapes so we can throw unconditionally.
|
|
1364
|
+
const arrayKey = findArrayUniqueKey(value);
|
|
1365
|
+
if (arrayKey) {
|
|
1366
|
+
throw new ValidationError(`[turbine] Column "${rawColumn}" on table "${this.table}" is not an array column ` +
|
|
1367
|
+
`(actual type: ${colType}); cannot apply array operator '${arrayKey}'.`);
|
|
1368
|
+
}
|
|
1273
1369
|
}
|
|
1274
1370
|
// Handle operator objects
|
|
1275
1371
|
if (isWhereOperator(value)) {
|
package/dist/schema-builder.d.ts
CHANGED
|
@@ -56,17 +56,50 @@ export interface ColumnConfig {
|
|
|
56
56
|
maxLength: number | null;
|
|
57
57
|
}
|
|
58
58
|
export interface TableDef {
|
|
59
|
-
/**
|
|
59
|
+
/**
|
|
60
|
+
* DDL-facing table name (snake_case). This is the name used when generating
|
|
61
|
+
* `CREATE TABLE` and other DDL statements. Set automatically during
|
|
62
|
+
* `defineSchema()` by converting the JS-facing accessor key from camelCase
|
|
63
|
+
* to snake_case (e.g. `postTags` → `post_tags`).
|
|
64
|
+
*/
|
|
60
65
|
name: string;
|
|
66
|
+
/**
|
|
67
|
+
* JS-facing accessor name (camelCase). This is the original key the user
|
|
68
|
+
* supplied to `defineSchema({ ... })` and is used as the property name on
|
|
69
|
+
* the generated `TurbineClient` (e.g. `db.postTags`). For schemas that
|
|
70
|
+
* already use snake_case keys, this matches `name`.
|
|
71
|
+
*/
|
|
72
|
+
accessor: string;
|
|
61
73
|
/** Column definitions keyed by camelCase field name */
|
|
62
74
|
columns: Record<string, ColumnConfig>;
|
|
75
|
+
/**
|
|
76
|
+
* Optional composite primary key. When present, takes precedence over any
|
|
77
|
+
* column-level `primaryKey: true` flags. Column names listed here are the
|
|
78
|
+
* camelCase JS-facing field names — they will be converted to snake_case
|
|
79
|
+
* when emitted as a `PRIMARY KEY (...)` table constraint.
|
|
80
|
+
*/
|
|
81
|
+
primaryKey?: readonly string[];
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* User-facing input shape for a single table when using the object format.
|
|
85
|
+
* The optional `primaryKey` field declares a composite primary key.
|
|
86
|
+
*/
|
|
87
|
+
export interface TableInput {
|
|
88
|
+
/** Optional composite primary key (camelCase field names) */
|
|
89
|
+
primaryKey?: readonly string[];
|
|
90
|
+
/** Column definitions keyed by camelCase field name */
|
|
91
|
+
[columnName: string]: ColumnDef | readonly string[] | undefined;
|
|
63
92
|
}
|
|
64
93
|
export interface SchemaDef {
|
|
65
|
-
/**
|
|
94
|
+
/**
|
|
95
|
+
* All tables keyed by their JS-facing accessor name (camelCase, exactly as
|
|
96
|
+
* the user wrote them in `defineSchema({ ... })`). The DDL-facing snake_case
|
|
97
|
+
* name is available as `tables[key].name`.
|
|
98
|
+
*/
|
|
66
99
|
tables: Record<string, TableDef>;
|
|
67
100
|
}
|
|
68
101
|
/** Input format: table name -> column defs (object format) or TableDef (legacy builder) */
|
|
69
|
-
type SchemaInput = Record<string, Record<string, ColumnDef> | TableDef>;
|
|
102
|
+
type SchemaInput = Record<string, Record<string, ColumnDef> | TableDef | TableInput>;
|
|
70
103
|
/**
|
|
71
104
|
* Define the full database schema using plain objects.
|
|
72
105
|
*
|