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/dist/generate.js CHANGED
@@ -60,8 +60,33 @@ function generatedFileHeader() {
60
60
  '',
61
61
  ];
62
62
  }
63
- function generateTypes(schema) {
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
- if (rel.type === 'hasMany') {
122
- lines.push(` ${relName}: ${targetType}[];`);
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
- export interface WithOptions<_T = unknown> {
80
- with?: WithClause;
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
- * - When R is the default ({}) or W is empty, resolves to plain T.
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
- * @typeParam T - Base entity type (e.g. User)
97
- * @typeParam R - Relations map (e.g. { posts: Post[]; profile: Profile | null })
98
- * @typeParam W - The with clause object (e.g. { posts: true })
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 & Pick<R, keyof W & keyof R>;
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
- /** Log a warning when findMany() is called without a limit */
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
- this.warnOnUnlimited = options?.warnOnUnlimited ?? false;
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
- // Warn if no limit specified and warnOnUnlimited is enabled
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)) {
@@ -56,17 +56,50 @@ export interface ColumnConfig {
56
56
  maxLength: number | null;
57
57
  }
58
58
  export interface TableDef {
59
- /** Table name (set during defineSchema) */
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
- /** All tables keyed by table name */
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
  *