turbine-orm 0.39.0 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import type { DatabaseAdapter } from '../adapters/index.js';
15
15
  import { type Dialect } from '../dialect.js';
16
+ import { type DestructiveStatement } from './destructive.js';
16
17
  export interface MigrationFile {
17
18
  /** Full filename (e.g. "20260325120000_create_users.sql") */
18
19
  filename: string;
@@ -91,6 +92,38 @@ export interface MigrationRecipe {
91
92
  * in here without touching {@link createMigration} or the CLI handler.
92
93
  */
93
94
  export declare const MIGRATION_RECIPES: Record<string, MigrationRecipe>;
95
+ /**
96
+ * The UP/DOWN body produced by {@link buildDiffMigrationBody}, plus the
97
+ * destructive statements found in each direction so the CLI can warn loudly.
98
+ */
99
+ export interface DiffMigrationBody {
100
+ /** Annotated UP SQL (destructive statements flagged with loud comments). */
101
+ up: string;
102
+ /** Annotated DOWN SQL, or an irreversible placeholder when none is derivable. */
103
+ down: string;
104
+ /** Destructive statements detected in the UP direction. */
105
+ destructiveUp: DestructiveStatement[];
106
+ /** Destructive statements detected in the DOWN direction. */
107
+ destructiveDown: DestructiveStatement[];
108
+ }
109
+ /**
110
+ * Build a migration UP/DOWN body from a `schemaDiff()` result.
111
+ *
112
+ * - UP is the diff's forward statements. DOWN is the diff's reverse statements
113
+ * when derivable, otherwise a clearly-commented "irreversible" placeholder.
114
+ * - Destructive statements in EITHER direction (a lossy `ALTER COLUMN ... TYPE`
115
+ * in UP, a `DROP TABLE`/`DROP COLUMN` reverse in DOWN) are flagged inline and,
116
+ * when any exist, a loud file-level banner is prepended to UP.
117
+ * - Any diff `warnings` (changes the diff refuses to apply automatically, e.g.
118
+ * enum value removals) are surfaced as `-- NOTE:` comments in UP.
119
+ *
120
+ * Pure and DB-free, so it is unit-testable from a synthesized diff.
121
+ */
122
+ export declare function buildDiffMigrationBody(diff: {
123
+ statements: string[];
124
+ reverseStatements: string[];
125
+ warnings?: string[];
126
+ }): DiffMigrationBody;
94
127
  /**
95
128
  * Create a new migration file.
96
129
  *
@@ -202,6 +202,84 @@ export const MIGRATION_RECIPES = {
202
202
  build: buildBackfillRecipe,
203
203
  },
204
204
  };
205
+ /** Loud file-level banner prepended when a diff migration contains destructive statements. */
206
+ const DESTRUCTIVE_MIGRATION_BANNER = [
207
+ '-- ============================================================',
208
+ '-- WARNING: this migration contains DESTRUCTIVE statement(s).',
209
+ '-- Each one is flagged inline below. `turbine migrate up` refuses',
210
+ '-- destructive statements by default: it asks you to confirm',
211
+ '-- interactively, or you must pass --allow-destructive. Review',
212
+ '-- every flagged statement carefully before running.',
213
+ '-- ============================================================',
214
+ ];
215
+ /**
216
+ * Annotate a list of SQL statements: scan each for data-destroying operations
217
+ * (via {@link scanDestructiveSql}) and prefix any offender with loud, commented
218
+ * warnings. Statements are left intact so the existing `migrate up` gate still
219
+ * refuses them by default; the comments just make the danger visible on review.
220
+ */
221
+ function annotateDiffStatements(statements) {
222
+ const destructive = [];
223
+ const out = [];
224
+ for (const raw of statements) {
225
+ const stmt = raw.trim();
226
+ if (!stmt)
227
+ continue;
228
+ const hits = scanDestructiveSql(stmt);
229
+ if (hits.length > 0) {
230
+ destructive.push(...hits);
231
+ for (const h of hits) {
232
+ out.push(`-- !! DESTRUCTIVE [${h.kind}] ${h.target}: ${DESTRUCTIVE_KIND_LABEL[h.kind]}`);
233
+ }
234
+ out.push('-- !! Refused by default. Confirm interactively or pass --allow-destructive to run it.');
235
+ }
236
+ out.push(stmt.endsWith(';') ? stmt : `${stmt};`);
237
+ }
238
+ return { text: out.join('\n'), destructive };
239
+ }
240
+ /**
241
+ * Build a migration UP/DOWN body from a `schemaDiff()` result.
242
+ *
243
+ * - UP is the diff's forward statements. DOWN is the diff's reverse statements
244
+ * when derivable, otherwise a clearly-commented "irreversible" placeholder.
245
+ * - Destructive statements in EITHER direction (a lossy `ALTER COLUMN ... TYPE`
246
+ * in UP, a `DROP TABLE`/`DROP COLUMN` reverse in DOWN) are flagged inline and,
247
+ * when any exist, a loud file-level banner is prepended to UP.
248
+ * - Any diff `warnings` (changes the diff refuses to apply automatically, e.g.
249
+ * enum value removals) are surfaced as `-- NOTE:` comments in UP.
250
+ *
251
+ * Pure and DB-free, so it is unit-testable from a synthesized diff.
252
+ */
253
+ export function buildDiffMigrationBody(diff) {
254
+ const up = annotateDiffStatements(diff.statements);
255
+ const hasReverse = diff.reverseStatements.length > 0;
256
+ const down = hasReverse
257
+ ? annotateDiffStatements(diff.reverseStatements)
258
+ : { text: '', destructive: [] };
259
+ const upParts = [];
260
+ if (up.destructive.length > 0 || down.destructive.length > 0) {
261
+ upParts.push(...DESTRUCTIVE_MIGRATION_BANNER, '');
262
+ }
263
+ if (diff.warnings && diff.warnings.length > 0) {
264
+ for (const w of diff.warnings)
265
+ upParts.push(`-- NOTE: ${w}`);
266
+ upParts.push('');
267
+ }
268
+ upParts.push(up.text || '-- (no statements: schema already matches the database)');
269
+ const downText = hasReverse
270
+ ? down.text
271
+ : [
272
+ '-- irreversible, write manually',
273
+ '-- The diff produced no reversible statements for this change. Write the',
274
+ '-- rollback SQL by hand, or leave this section empty for a one-way migration.',
275
+ ].join('\n');
276
+ return {
277
+ up: upParts.join('\n'),
278
+ down: downText,
279
+ destructiveUp: up.destructive,
280
+ destructiveDown: down.destructive,
281
+ };
282
+ }
205
283
  // ---------------------------------------------------------------------------
206
284
  // Commands
207
285
  // ---------------------------------------------------------------------------
package/dist/client.d.ts CHANGED
@@ -241,6 +241,16 @@ export interface TurbineConfig {
241
241
  * Default: `true`. Set to `false` as a nuclear kill switch.
242
242
  */
243
243
  sqlCache?: boolean;
244
+ /**
245
+ * Maximum number of distinct SQL templates each per-table LRU cache retains.
246
+ *
247
+ * Default: `1000`. Values are parameterized (`$1, $2, …`) and never fragment
248
+ * the cache, so this bounds distinct query SHAPES. Raise it for apps with a
249
+ * very large surface of query shapes to lift the hit rate at the cost of
250
+ * memory; lower it to cap memory. `0` disables caching entirely (identical to
251
+ * `sqlCache: false`); a negative value is ignored (treated as the default).
252
+ */
253
+ sqlCacheSize?: number;
244
254
  /** SQL dialect implementation. Defaults to PostgreSQL. Internal Phase-1 seam for dialect packages. */
245
255
  dialect?: Dialect;
246
256
  /**
package/dist/client.js CHANGED
@@ -366,6 +366,7 @@ export class TurbineClient {
366
366
  globalFilters: config.globalFilters,
367
367
  preparedStatements: envDisablePrepared ? false : (config.preparedStatements ?? !config.pool),
368
368
  sqlCache: config.sqlCache ?? true,
369
+ sqlCacheSize: config.sqlCacheSize,
369
370
  dialect: config.dialect,
370
371
  // Non-SQL backends (PowDB) inject a factory that builds their own query
371
372
  // interface (PowqlInterface) instead of the SQL QueryInterface. SQL engines
package/dist/index.d.ts CHANGED
@@ -43,7 +43,7 @@ export { type IntrospectOptions, introspect } from './introspect.js';
43
43
  export { executeNestedCreate, executeNestedUpdate, hasRelationFields, type NestedWriteContext, } from './nested-write.js';
44
44
  export type { ObserveConfig, ObserveHandle } from './observe.js';
45
45
  export { executePipeline, type PipelineOptions, type PipelineResults, pipelineSupported } from './pipeline.js';
46
- export { type AggregateArgs, type AggregateResult, type ArrayFilter, type ColumnRef, type ConnectOrCreateOp, type CountArgs, type CreateArgs, type CreateDataInput, type CreateManyArgs, type DeferredQuery, type DeleteArgs, type DeleteManyArgs, type FieldResult, type FindManyArgs, type FindManyStreamArgs, type FindUniqueArgs, type GlobalFilters, type GroupByAggregateSpec, type GroupByArgs, type GroupByDistinctOn, type HavingClause, type JsonFilter, type JsonPathAggregateTarget, type JsonPathGroupKey, type JsonPathOrderBy, type MiddlewareFn, type NestedCreateOp, type NestedUpdateOp, type NestedUpdateOpItem, type NestedUpsertOpItem, type OmitResult, type OrderByClause, type OrderDirection, type QueryEvent, type QueryEventListener, QueryInterface, type QueryResult, type RelationDescriptor, type RelationFilter, type RelationLoadStrategy, type RelationPickBy, type RelationPickOrderBy, type SelectResult, type SkipGlobalFilters, type TextSearchFilter, type TypedWithClause, type UpdateArgs, type UpdateDataInput, type UpdateInput, type UpdateManyArgs, type UpdateOperatorInput, type UpsertArgs, type VectorDistanceFilter, type VectorFilter, type VectorMetric, type VectorOrderBy, type VectorOrderByDistance, type WhereClause, type WhereOperator, type WhereValue, type WithClause, type WithOptions, type WithResult, } from './query/index.js';
46
+ export { type AggregateArgs, type AggregateResult, type ArrayFilter, type ColumnRef, type ConnectOrCreateOp, type CountArgs, type CreateArgs, type CreateDataInput, type CreateManyArgs, type DeferredQuery, type DeleteArgs, type DeleteManyArgs, type FieldResult, type FindManyArgs, type FindManyStreamArgs, type FindUniqueArgs, type GlobalFilters, type GroupByAggregateSpec, type GroupByArgs, type GroupByDistinctOn, type GroupByResult, type HavingClause, type JsonFilter, type JsonPathAggregateTarget, type JsonPathGroupKey, type JsonPathOrderBy, type MiddlewareFn, type NestedCreateOp, type NestedUpdateOp, type NestedUpdateOpItem, type NestedUpsertOpItem, type OmitResult, type OrderByClause, type OrderByObject, type OrderDirection, type QueryEvent, type QueryEventListener, QueryInterface, type QueryResult, type RelationDescriptor, type RelationFilter, type RelationLoadStrategy, type RelationPickBy, type RelationPickOrderBy, type SelectResult, type SkipGlobalFilters, type TextSearchFilter, type TypedWithClause, type UpdateArgs, type UpdateDataInput, type UpdateInput, type UpdateManyArgs, type UpdateOperatorInput, type UpsertArgs, type VectorDistanceFilter, type VectorFilter, type VectorMetric, type VectorOrderBy, type VectorOrderByDistance, type WhereClause, type WhereOperator, type WhereValue, type WithClause, type WithOptions, type WithOrderByObject, type WithResult, } from './query/index.js';
47
47
  export { type ActiveSubscription, type NotificationHandler, type Subscription, validateChannel } from './realtime.js';
48
48
  export type { CheckMetadata, ColumnMetadata, IndexMetadata, ReferentialAction, RelationDef, SchemaMetadata, TableMetadata, } from './schema.js';
49
49
  export { camelToSnake, isDateType, normalizeKeyColumns, pgArrayType, pgTypeToTs, singularize, snakeToCamel, snakeToPascal, } from './schema.js';
package/dist/powql.js CHANGED
@@ -38,7 +38,7 @@ import { randomUUID } from 'node:crypto';
38
38
  import { NotFoundError, ReadOnlyError, TimeoutError, UnsupportedFeatureError, ValidationError } from './errors.js';
39
39
  import { executeNestedCreate, executeNestedUpdate, hasRelationFields, } from './nested-write.js';
40
40
  import { ALL_POWDB_CAPABILITIES, coerceNativeValue, isJsonColumn, isStaleFramePowdbError, PowdbFloatParam, PowdbJsonParam, powqlColumnType, quotePowqlIdent, requireCapability, rowToEntity, } from './powdb.js';
41
- import { isJsonFilter, isRelationPickOrderBy } from './query/filters.js';
41
+ import { isJsonFilter, isRelationPickOrderBy, orderByEntries } from './query/filters.js';
42
42
  import { escapeLike } from './query/utils.js';
43
43
  import { normalizeKeyColumns, snakeToCamel, } from './schema.js';
44
44
  /**
@@ -713,7 +713,7 @@ export class PowqlInterface {
713
713
  buildOrder(orderBy, params, alias) {
714
714
  if (!orderBy)
715
715
  return '';
716
- const keys = Object.entries(orderBy).filter(([, dir]) => dir !== undefined);
716
+ const keys = orderByEntries(orderBy).filter(([, dir]) => dir !== undefined);
717
717
  if (!keys.length)
718
718
  return '';
719
719
  const parts = keys.map(([field, dir]) => {
@@ -2168,7 +2168,11 @@ export class PowqlInterface {
2168
2168
  }
2169
2169
  const having = this.buildHaving(args.having, params, aggInner);
2170
2170
  const order = this.buildGroupOrder(args.orderBy, byOrderExprs, aggOrderExprs);
2171
- const powql = `${this.qt}${filter} group ${groupExprs.join(', ')}${having}${order} { ${proj.join(', ')} }`;
2171
+ // LIMIT / OFFSET over the result groups, applied after ORDER BY (mirrors
2172
+ // the SQL groupBy). offset 0 is a no-op, matching findMany.
2173
+ const limitClause = args.limit !== undefined ? ` limit ${this.param(args.limit, params)}` : '';
2174
+ const offsetClause = args.offset ? ` offset ${this.param(args.offset, params)}` : '';
2175
+ const powql = `${this.qt}${filter} group ${groupExprs.join(', ')}${having}${order}${limitClause}${offsetClause} { ${proj.join(', ')} }`;
2172
2176
  const { rows, native: resultNative } = await this.exec(powql, params, args.timeout, 'groupBy');
2173
2177
  // Reshape: group keys → user fields (coerced / null-disambiguated),
2174
2178
  // aggregates → nested `{ _sum: { field } }`; discriminators are stripped.
@@ -2274,7 +2278,7 @@ export class PowqlInterface {
2274
2278
  return keys.join(', ') || '(none)';
2275
2279
  };
2276
2280
  const parts = [];
2277
- for (const [key, value] of Object.entries(orderBy)) {
2281
+ for (const [key, value] of orderByEntries(orderBy)) {
2278
2282
  if (value === undefined)
2279
2283
  continue;
2280
2284
  if (aggBlocks.has(key)) {
@@ -25,7 +25,7 @@ export declare function buildGroupBy<T extends object>(qi: BuilderCtx, args: Gro
25
25
  * no `$n` renumbering). An aggregate key that was not requested, or an unknown
26
26
  * by-key, throws {@link ValidationError} E003 listing the valid keys.
27
27
  */
28
- export declare function buildGroupByOrderBy(qi: BuilderCtx, orderBy: GroupByOrderBy, byOrderExprs: Map<string, string>, aggOrderExprs: Map<string, string>): string;
28
+ export declare function buildGroupByOrderBy(qi: BuilderCtx, orderBy: GroupByOrderBy | GroupByOrderBy[], byOrderExprs: Map<string, string>, aggOrderExprs: Map<string, string>): string;
29
29
  /**
30
30
  * Validate a JSON-path target (group key or aggregate target) in groupBy:
31
31
  * the field must resolve to a real json/jsonb column and the path must be a
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { UnsupportedFeatureError, ValidationError } from '../errors.js';
12
12
  import { snakeToCamel } from '../schema.js';
13
- import { isJsonPathOrderBy, isVectorOrderBy, normalizeOrderBy } from './filters.js';
13
+ import { isJsonPathOrderBy, isVectorOrderBy, normalizeOrderBy, orderByEntries } from './filters.js';
14
14
  import * as whereMod from './where.js';
15
15
  export function buildGroupBy(qi, args) {
16
16
  const meta = qi.schema.tables[qi.table];
@@ -168,6 +168,16 @@ export function buildGroupBy(qi, args) {
168
168
  if (orderSql)
169
169
  sql += ` ORDER BY ${orderSql}`;
170
170
  }
171
+ // LIMIT / OFFSET over the result groups, applied AFTER ORDER BY. Routed
172
+ // through the dialect pagination hook (parameterized on PG/SQLite/SQL Server,
173
+ // inlined on MySQL); params append after the WHERE/HAVING/ORDER BY params, so
174
+ // no `$n` renumbering. `offset` without a deterministic `orderBy` yields an
175
+ // arbitrary window (same caveat as findMany).
176
+ if (args.limit !== undefined || args.offset !== undefined) {
177
+ const limitPh = args.limit !== undefined ? qi.paginationRef(args.limit, params) : undefined;
178
+ const offsetPh = args.offset !== undefined ? qi.paginationRef(args.offset, params) : undefined;
179
+ sql += qi.buildPagination(limitPh, offsetPh, args.orderBy !== undefined);
180
+ }
171
181
  return {
172
182
  sql,
173
183
  params,
@@ -255,7 +265,7 @@ export function buildGroupByOrderBy(qi, orderBy, byOrderExprs, aggOrderExprs) {
255
265
  return keys.join(', ') || '(none)';
256
266
  };
257
267
  const parts = [];
258
- for (const [key, value] of Object.entries(orderBy)) {
268
+ for (const [key, value] of orderByEntries(orderBy)) {
259
269
  if (value === undefined)
260
270
  continue;
261
271
  // Aggregate ordering blocks.
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import type pg from 'pg';
14
14
  import type { SchemaMetadata } from '../schema.js';
15
- import type { AggregateArgs, AggregateResult, CountArgs, CreateArgs, CreateManyArgs, DeleteArgs, DeleteManyArgs, FindManyArgs, FindManyStreamArgs, FindUniqueArgs, GroupByArgs, QueryResult, TypedWithClause, UpdateArgs, UpdateManyArgs, UpsertArgs, WithClause } from './types.js';
15
+ import type { AggregateArgs, AggregateResult, CountArgs, CreateArgs, CreateManyArgs, DeleteArgs, DeleteManyArgs, FindManyArgs, FindManyStreamArgs, FindUniqueArgs, GroupByArgs, GroupByResult, QueryResult, TypedWithClause, UpdateArgs, UpdateManyArgs, UpsertArgs, WithClause } from './types.js';
16
16
  export type { DeferredQuery, MiddlewareFn, QueryEvent, QueryEventListener, QueryInterfaceOptions, ReselectExecutor, } from './deferred.js';
17
17
  import type { DeferredQuery, MiddlewareFn, QueryInterfaceOptions } from './deferred.js';
18
18
  export declare class QueryInterface<T extends object, R extends object = {}> {
@@ -20,7 +20,11 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
20
20
  private readonly table;
21
21
  private readonly schema;
22
22
  private readonly tableMeta;
23
- /** SQL template cache: cacheKey → SqlCacheEntry (sql + prepared statement name) */
23
+ /**
24
+ * SQL template cache: cacheKey → SqlCacheEntry (sql + prepared statement name).
25
+ * Capacity is set once in the constructor from `options.sqlCacheSize`
26
+ * (default 1000). See {@link QueryInterfaceOptions.sqlCacheSize}.
27
+ */
24
28
  private readonly sqlTemplateCache;
25
29
  /**
26
30
  * Whether the most recent {@link acquireSql} call was a cache HIT. Read by
@@ -423,7 +427,15 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
423
427
  }>;
424
428
  count(args?: CountArgs<T>): Promise<number>;
425
429
  buildCount(args?: CountArgs<T>): DeferredQuery<number>;
426
- groupBy(args: GroupByArgs<T>): Promise<Record<string, unknown>[]>;
430
+ /**
431
+ * Group rows and compute per-group aggregates (Prisma-style). The result row
432
+ * type is INFERRED from the args: each `by` field carries its entity field
433
+ * type, `_count` is always a number, and each requested `_sum` / `_avg` /
434
+ * `_min` / `_max` block maps its fields to properly typed values (see
435
+ * {@link GroupByResult}). Grouping by a JSON-path key yields a runtime alias
436
+ * that cannot be typed, so those columns are not projected onto the row type.
437
+ */
438
+ groupBy<A extends GroupByArgs<T>>(args: A): Promise<GroupByResult<T, A>[]>;
427
439
  buildGroupBy(args: GroupByArgs<T>): DeferredQuery<Record<string, unknown>[]>;
428
440
  buildAggregate(args: AggregateArgs<T>): DeferredQuery<AggregateResult<T>>;
429
441
  aggregate(args: AggregateArgs<T>): Promise<AggregateResult<T>>;
@@ -16,7 +16,7 @@ import { executeNestedCreate, executeNestedUpdate, hasRelationFields, } from '..
16
16
  import { camelToSnake, snakeToCamel } from '../schema.js';
17
17
  import * as aggMod from './aggregates.js';
18
18
  import { includeKeysForBatching, loadRelationsBatched, neededParentKeyFields, rejectNestedPickOrder, stripFields, } from './batched-loader.js';
19
- import { isJsonPathOrderBy, isOrderBySpec, isRelationPickOrderBy, isVectorOrderBy, isWhereOperator, sortedEntries, } from './filters.js';
19
+ import { isJsonPathOrderBy, isOrderBySpec, isRelationPickOrderBy, isVectorOrderBy, isWhereOperator, orderByEntries, sortedEntries, } from './filters.js';
20
20
  import * as relationsMod from './relations.js';
21
21
  import { LRUCache, ownLookup, parseDbDate, sqlToPreparedName } from './utils.js';
22
22
  import * as whereMod from './where.js';
@@ -149,8 +149,12 @@ export class QueryInterface {
149
149
  table;
150
150
  schema;
151
151
  tableMeta;
152
- /** SQL template cache: cacheKey → SqlCacheEntry (sql + prepared statement name) */
153
- sqlTemplateCache = new LRUCache(1000);
152
+ /**
153
+ * SQL template cache: cacheKey → SqlCacheEntry (sql + prepared statement name).
154
+ * Capacity is set once in the constructor from `options.sqlCacheSize`
155
+ * (default 1000). See {@link QueryInterfaceOptions.sqlCacheSize}.
156
+ */
157
+ sqlTemplateCache;
154
158
  /**
155
159
  * Whether the most recent {@link acquireSql} call was a cache HIT. Read by
156
160
  * {@link crossCheckCache} to decide whether to run the dev-mode lockstep
@@ -278,7 +282,13 @@ export class QueryInterface {
278
282
  : warnOpt !== false;
279
283
  this.utcTimestamps = options?.utcTimestamps !== false;
280
284
  this.preparedStatementsEnabled = options?.preparedStatements ?? true;
281
- this.sqlCacheEnabled = options?.sqlCache !== false;
285
+ // SQL template cache capacity. `sqlCacheSize: 0` disables caching entirely
286
+ // (mirrors `sqlCache: false`); any positive integer sets the LRU bound;
287
+ // undefined keeps the historical 1000-entry default. A negative value is
288
+ // treated as the default rather than throwing.
289
+ const sqlCacheSize = options?.sqlCacheSize;
290
+ this.sqlCacheEnabled = options?.sqlCache !== false && sqlCacheSize !== 0;
291
+ this.sqlTemplateCache = new LRUCache(sqlCacheSize !== undefined && sqlCacheSize > 0 ? Math.floor(sqlCacheSize) : 1000);
282
292
  this.dialect = options?.dialect ?? postgresDialect;
283
293
  this.relationLoadStrategy = options?.relationLoadStrategy ?? 'join';
284
294
  this.jsonEncoding = options?.jsonEncoding ?? 'object';
@@ -1074,7 +1084,7 @@ export class QueryInterface {
1074
1084
  // Checked BEFORE the SQL cache so build and warm-cache paths throw
1075
1085
  // identically (same rule as the vector guard inside the distinct branch).
1076
1086
  if (args?.distinct && args.distinct.length > 0 && args.orderBy) {
1077
- for (const d of Object.values(args.orderBy)) {
1087
+ for (const [, d] of orderByEntries(args.orderBy)) {
1078
1088
  if (this.isRelationOrderByValue(d)) {
1079
1089
  throw new ValidationError('[turbine] `distinct` cannot be combined with relation orderBy (pick-row, `_count`, or ' +
1080
1090
  'to-one relation ordering): the outer re-order cannot reference the parent table.');
@@ -1093,8 +1103,13 @@ export class QueryInterface {
1093
1103
  // Build fingerprint for cache lookup
1094
1104
  const whereFp = hasWhere ? this.fingerprintWhere(whereObj) : '';
1095
1105
  const withFp = args?.with ? this.withFingerprint(args.with) : '';
1106
+ // Flatten via orderByEntries so the object form (`{ a, b }`) and the
1107
+ // Prisma-style array form (`[{ a }, { b }]`) fingerprint from the SAME
1108
+ // ordered entry list the build/collect paths consume. An array's element
1109
+ // order is authoritative and preserved here, so a permuted array is a
1110
+ // distinct key (correct, it emits a different ORDER BY).
1096
1111
  const orderFp = args?.orderBy
1097
- ? Object.entries(args.orderBy)
1112
+ ? orderByEntries(args.orderBy)
1098
1113
  .map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d, ownLookup(this.tableMeta.relations, k)?.to)}`)
1099
1114
  .join(',')
1100
1115
  : '';
@@ -1163,11 +1178,15 @@ export class QueryInterface {
1163
1178
  // Sorted (canonical) order — MUST match cursorFp and the cache-hit collect below.
1164
1179
  const cursorEntries = sortedEntries(args.cursor).filter(([, v]) => v !== undefined);
1165
1180
  if (cursorEntries.length > 0) {
1181
+ // Resolve the seek direction per cursor field from the flattened
1182
+ // orderBy entries (last wins, matching object-key semantics), so both
1183
+ // the object and array orderBy forms drive the cursor comparison.
1184
+ const orderDirByKey = new Map(orderByEntries(args.orderBy));
1166
1185
  const cursorConditions = cursorEntries.map(([k, v]) => {
1167
1186
  const col = this.toSqlColumn(k);
1168
1187
  // orderBy values can be the { sort, nulls } spec form: normalize
1169
1188
  // before comparing, or a desc spec would seek the ascending side.
1170
- const dir = args.orderBy?.[k];
1189
+ const dir = orderDirByKey.get(k);
1171
1190
  const desc = isOrderBySpec(dir) ? dir.sort === 'desc' : dir === 'desc';
1172
1191
  const op = desc ? '<' : '>';
1173
1192
  freshParams.push(v);
@@ -1188,7 +1207,7 @@ export class QueryInterface {
1188
1207
  // need two levels: inner DISTINCT ON ordered by the distinct columns then
1189
1208
  // the user's order (picks the right representative row), outer re-ordered
1190
1209
  // by the user's order alone.
1191
- if (Object.values(args.orderBy).some((d) => isVectorOrderBy(d))) {
1210
+ if (orderByEntries(args.orderBy).some(([, d]) => isVectorOrderBy(d))) {
1192
1211
  throw new ValidationError('[turbine] `distinct` cannot be combined with vector distance ordering.');
1193
1212
  }
1194
1213
  const userOrder = this.buildOrderBy(args.orderBy, freshParams);
@@ -1602,6 +1621,14 @@ export class QueryInterface {
1602
1621
  // -------------------------------------------------------------------------
1603
1622
  // groupBy (with aggregate functions)
1604
1623
  // -------------------------------------------------------------------------
1624
+ /**
1625
+ * Group rows and compute per-group aggregates (Prisma-style). The result row
1626
+ * type is INFERRED from the args: each `by` field carries its entity field
1627
+ * type, `_count` is always a number, and each requested `_sum` / `_avg` /
1628
+ * `_min` / `_max` block maps its fields to properly typed values (see
1629
+ * {@link GroupByResult}). Grouping by a JSON-path key yields a runtime alias
1630
+ * that cannot be typed, so those columns are not projected onto the row type.
1631
+ */
1605
1632
  async groupBy(args) {
1606
1633
  return this.executeWithMiddleware('groupBy', args, async () => {
1607
1634
  const deferred = this.buildGroupBy(args);
@@ -1768,7 +1795,7 @@ export class QueryInterface {
1768
1795
  * order exactly so the cached-SQL param re-collection stays in lockstep.
1769
1796
  */
1770
1797
  collectOrderByParams(orderBy, params) {
1771
- for (const [key, dir] of Object.entries(orderBy)) {
1798
+ for (const [key, dir] of orderByEntries(orderBy)) {
1772
1799
  if (isVectorOrderBy(dir)) {
1773
1800
  const rawColumn = this.toColumn(key);
1774
1801
  // Re-run the same validation as buildOrderBy so the collect path can
@@ -107,6 +107,17 @@ export interface QueryInterfaceOptions {
107
107
  * unparseable value keeps the check fully off.
108
108
  */
109
109
  sqlCache?: boolean;
110
+ /**
111
+ * Maximum number of distinct SQL templates the per-table LRU cache retains.
112
+ *
113
+ * Default: `1000`. Raise it for apps with a very large number of distinct
114
+ * query SHAPES (not values, since values are parameterized and never fragment the
115
+ * cache) to lift the hit rate at the cost of memory; lower it to cap memory.
116
+ * `0` disables the cache entirely, exactly like `sqlCache: false`. A negative
117
+ * value is ignored (treated as the default). Applied per QueryInterface (one
118
+ * per table).
119
+ */
120
+ sqlCacheSize?: number;
110
121
  /** SQL dialect implementation. Defaults to PostgreSQL. */
111
122
  dialect?: Dialect;
112
123
  /**
@@ -173,6 +173,23 @@ export declare function isJsonPathOrderBy(value: unknown): value is JsonPathOrde
173
173
  * ordering) are excluded up front.
174
174
  */
175
175
  export declare function isRelationPickOrderBy(value: unknown): value is RelationPickOrderBy;
176
+ /**
177
+ * Flatten an orderBy input into an ordered list of `[field, value]` entries.
178
+ *
179
+ * Accepts BOTH the classic single-object form (`{ a: 'asc', b: 'desc' }`,
180
+ * whose insertion order is authoritative) and the Prisma-style array form
181
+ * (`[{ a: 'asc' }, { b: 'desc' }]`, whose array order is authoritative). The
182
+ * array form removes the reliance on JS object key iteration order for
183
+ * multi-key sorts. Each array element may carry one or more keys; they expand
184
+ * left-to-right. `undefined`/non-object elements are skipped.
185
+ *
186
+ * Undefined-VALUED entries are preserved (mirroring `Object.entries`) so each
187
+ * consumer keeps its own `dir !== undefined` filtering exactly as before. This
188
+ * is THE single flattening authority: every ORDER BY compile / collect /
189
+ * fingerprint path routes through it so the array and object forms stay in
190
+ * lockstep across build, param-collect, and cache-key fingerprint.
191
+ */
192
+ export declare function orderByEntries(orderBy: unknown): [string, unknown][];
176
193
  /**
177
194
  * Normalize an orderBy value into `{ direction, nulls }`. Accepts a plain
178
195
  * direction string or an {@link OrderBySpec}. Used by every ORDER BY compile
@@ -332,6 +332,38 @@ export function isRelationPickOrderBy(value) {
332
332
  }
333
333
  return true;
334
334
  }
335
+ /**
336
+ * Flatten an orderBy input into an ordered list of `[field, value]` entries.
337
+ *
338
+ * Accepts BOTH the classic single-object form (`{ a: 'asc', b: 'desc' }`,
339
+ * whose insertion order is authoritative) and the Prisma-style array form
340
+ * (`[{ a: 'asc' }, { b: 'desc' }]`, whose array order is authoritative). The
341
+ * array form removes the reliance on JS object key iteration order for
342
+ * multi-key sorts. Each array element may carry one or more keys; they expand
343
+ * left-to-right. `undefined`/non-object elements are skipped.
344
+ *
345
+ * Undefined-VALUED entries are preserved (mirroring `Object.entries`) so each
346
+ * consumer keeps its own `dir !== undefined` filtering exactly as before. This
347
+ * is THE single flattening authority: every ORDER BY compile / collect /
348
+ * fingerprint path routes through it so the array and object forms stay in
349
+ * lockstep across build, param-collect, and cache-key fingerprint.
350
+ */
351
+ export function orderByEntries(orderBy) {
352
+ if (Array.isArray(orderBy)) {
353
+ const entries = [];
354
+ for (const element of orderBy) {
355
+ if (element !== null && typeof element === 'object' && !Array.isArray(element)) {
356
+ for (const kv of Object.entries(element))
357
+ entries.push(kv);
358
+ }
359
+ }
360
+ return entries;
361
+ }
362
+ if (orderBy !== null && typeof orderBy === 'object') {
363
+ return Object.entries(orderBy);
364
+ }
365
+ return [];
366
+ }
335
367
  /**
336
368
  * Normalize an orderBy value into `{ direction, nulls }`. Accepts a plain
337
369
  * direction string or an {@link OrderBySpec}. Used by every ORDER BY compile
@@ -5,7 +5,7 @@
5
5
  * `import { … } from './query/index.js'` is a drop-in replacement for the
6
6
  * former monolithic `import { … } from './query.js'`.
7
7
  */
8
- export type { AggregateArgs, AggregateResult, ArrayFilter, ColumnRef, ConnectOrCreateOp, CountArgs, CreateArgs, CreateDataInput, CreateManyArgs, DeleteArgs, DeleteManyArgs, FieldResult, FindManyArgs, FindManyStreamArgs, FindUniqueArgs, GlobalFilters, GroupByAggregateSpec, GroupByArgs, GroupByDistinctOn, HavingClause, JsonFilter, JsonPathAggregateTarget, JsonPathGroupKey, JsonPathOrderBy, NestedCreateOp, NestedUpdateOp, NestedUpdateOpItem, NestedUpsertOpItem, OmitResult, OrderByClause, OrderDirection, QueryResult, RelationDescriptor, RelationFilter, RelationLoadStrategy, RelationPickBy, RelationPickOrderBy, SelectResult, SkipGlobalFilters, TextSearchFilter, TypedWithClause, UpdateArgs, UpdateDataInput, UpdateInput, UpdateManyArgs, UpdateOperatorInput, UpsertArgs, VectorDistanceFilter, VectorFilter, VectorMetric, VectorOrderBy, VectorOrderByDistance, WhereClause, WhereOperator, WhereValue, WithClause, WithOptions, WithResult, } from './types.js';
8
+ export type { AggregateArgs, AggregateResult, ArrayFilter, ColumnRef, ConnectOrCreateOp, CountArgs, CreateArgs, CreateDataInput, CreateManyArgs, DeleteArgs, DeleteManyArgs, FieldResult, FindManyArgs, FindManyStreamArgs, FindUniqueArgs, GlobalFilters, GroupByAggregateSpec, GroupByArgs, GroupByDistinctOn, GroupByResult, HavingClause, JsonFilter, JsonPathAggregateTarget, JsonPathGroupKey, JsonPathOrderBy, NestedCreateOp, NestedUpdateOp, NestedUpdateOpItem, NestedUpsertOpItem, OmitResult, OrderByClause, OrderByObject, OrderDirection, QueryResult, RelationDescriptor, RelationFilter, RelationLoadStrategy, RelationPickBy, RelationPickOrderBy, SelectResult, SkipGlobalFilters, TextSearchFilter, TypedWithClause, UpdateArgs, UpdateDataInput, UpdateInput, UpdateManyArgs, UpdateOperatorInput, UpsertArgs, VectorDistanceFilter, VectorFilter, VectorMetric, VectorOrderBy, VectorOrderByDistance, WhereClause, WhereOperator, WhereValue, WithClause, WithOptions, WithOrderByObject, WithResult, } from './types.js';
9
9
  export type { BuiltStatement, BulkInsertStatementInput, ColumnDefinitionInput, ColumnTypeInput, CreateIndexStatementInput, CreateTableStatementInput, Dialect, InsertStatementInput, UpsertStatementInput, } from '../dialect.js';
10
10
  export { postgresDialect } from '../dialect.js';
11
11
  export type { SqlCacheEntry } from './utils.js';
@@ -16,7 +16,7 @@ import { CircularRelationError, RelationError, UnsupportedFeatureError, Validati
16
16
  import { missingIndexForRelation } from '../index-advisor.js';
17
17
  import { camelToSnake, normalizeKeyColumns, snakeToCamel } from '../schema.js';
18
18
  import { resolveCountRelations } from './batched-loader.js';
19
- import { isJsonPathOrderBy, isOrderBySpec, isRelationPickOrderBy, isVectorOrderBy, normalizeOrderBy, sortedEntries, } from './filters.js';
19
+ import { isJsonPathOrderBy, isOrderBySpec, isRelationPickOrderBy, isVectorOrderBy, normalizeOrderBy, orderByEntries, sortedEntries, } from './filters.js';
20
20
  import { ownLookup } from './utils.js';
21
21
  import * as whereMod from './where.js';
22
22
  import * as writesMod from './writes.js';
@@ -128,7 +128,7 @@ export function withFingerprint(qi, withClause, table, depth = 0) {
128
128
  // orderBy shape (OrderBySpec nulls placement changes the SQL, so fingerprint it)
129
129
  if (opts.orderBy) {
130
130
  const targetRels = qi.schema.tables[relDef.to]?.relations;
131
- const oEntries = Object.entries(opts.orderBy).map(([k, d]) => `${k}:${orderByEntryFingerprint(qi, d, targetRels?.[k]?.to)}`);
131
+ const oEntries = orderByEntries(opts.orderBy).map(([k, d]) => `${k}:${orderByEntryFingerprint(qi, d, targetRels?.[k]?.to)}`);
132
132
  subParts.push(`o=${oEntries.join(',')}`);
133
133
  }
134
134
  // limit presence, but on inline-pagination engines (MySQL) the literal
@@ -191,7 +191,7 @@ export function collectRelationSubqueryParams(qi, relDef, spec, params, _parentR
191
191
  // orderBy params → where params → limit param → nested-with params
192
192
  // (always, both paths).
193
193
  if (relDef.type === 'manyToMany') {
194
- const m2mOrderEntries = spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
194
+ const m2mOrderEntries = spec.orderBy ? orderByEntries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
195
195
  if (nativeOrderPath && m2mOrderEntries.length > 0) {
196
196
  collectRelationOrderParams(qi, targetTable, targetMeta, m2mOrderEntries, params);
197
197
  }
@@ -213,7 +213,7 @@ export function collectRelationSubqueryParams(qi, relDef, spec, params, _parentR
213
213
  return;
214
214
  }
215
215
  // Mirrors buildRelationSubquery's willWrap: `orderBy: {}` is treated as absent.
216
- const relOrderEntries = spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
216
+ const relOrderEntries = spec.orderBy ? orderByEntries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
217
217
  const hasOrder = relOrderEntries.length > 0;
218
218
  const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || hasOrder);
219
219
  // Non-wrapped path: nested relations BEFORE where/limit
@@ -317,7 +317,7 @@ export function buildOrderBy(qi, orderBy, params, lateralSink) {
317
317
  // orderBy keys (object values that are neither a vector nor an OrderBySpec)
318
318
  // are validated in the relation branch below, so skip them here.
319
319
  if (process.env.NODE_ENV !== 'production') {
320
- for (const [key, value] of Object.entries(orderBy)) {
320
+ for (const [key, value] of orderByEntries(orderBy)) {
321
321
  if (isRelationOrderByValue(qi, value) && ownLookup(qi.tableMeta.relations, key))
322
322
  continue;
323
323
  const snakeKey = camelToSnake(key);
@@ -329,7 +329,7 @@ export function buildOrderBy(qi, orderBy, params, lateralSink) {
329
329
  }
330
330
  const meta = qi.schema.tables[qi.table];
331
331
  let relOrdCounter = 0;
332
- return Object.entries(orderBy)
332
+ return orderByEntries(orderBy)
333
333
  .map(([key, value]) => {
334
334
  // Vector KNN ordering: { distance: { to, metric, direction? } }
335
335
  if (isVectorOrderBy(value)) {
@@ -1381,7 +1381,7 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
1381
1381
  // An orderBy with no defined entries (`orderBy: {}`) is treated as absent —
1382
1382
  // it must neither trigger the wrap (dropping nested relations) nor render a
1383
1383
  // dangling `ORDER BY `. `limit: 0` is meaningful (LIMIT 0) and DOES wrap.
1384
- const relOrderEntries = spec !== true && spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
1384
+ const relOrderEntries = spec !== true && spec.orderBy ? orderByEntries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
1385
1385
  const willWrap = relDef.type === 'hasMany' && spec !== true && (spec.limit !== undefined || relOrderEntries.length > 0);
1386
1386
  // manyToMany takes a dedicated JOIN-through-junction path. Nested relations,
1387
1387
  // where, orderBy, and select/omit are handled there (the target alias is the
@@ -1552,7 +1552,7 @@ export function buildManyToManySubquery(qi, relDef, spec, params, parentRef, ali
1552
1552
  // `orderBy: {}` (no defined entries) is treated as absent: it must not
1553
1553
  // render a dangling `ORDER BY `. Param pushes here land BEFORE the
1554
1554
  // spec.where params, mirrored by collectRelationSubqueryParams' m2m branch.
1555
- const relOrderEntries = spec !== true && spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
1555
+ const relOrderEntries = spec !== true && spec.orderBy ? orderByEntries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
1556
1556
  let orderClause = '';
1557
1557
  if (relOrderEntries.length > 0) {
1558
1558
  orderClause = buildRelationOrderClause(qi, targetTable, targetMeta, talias, relOrderEntries, params);