turbine-orm 0.72.0 → 0.73.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.
@@ -51,6 +51,12 @@
51
51
  *
52
52
  * @module
53
53
  */
54
+ // Runtime imports, and the only ones in this file: the unknown-key warning at
55
+ // the bottom needs the once-per-process registry and the name suggester. Both
56
+ // are leaves that do not import this module, so the type-only shape of
57
+ // everything above is unaffected.
58
+ import { suggestKey } from './utils.js';
59
+ import { shouldWarnOnce, WARN_NS } from './warn-registry.js';
54
60
  export const FIND_UNIQUE_OPTIONS = {
55
61
  where: 'prisma',
56
62
  select: 'prisma',
@@ -79,7 +85,13 @@ export const FIND_MANY_OPTIONS = {
79
85
  omit: 'prisma',
80
86
  orderBy: 'prisma',
81
87
  cursor: 'prisma',
88
+ // `take` and `skip` are Prisma's own spellings and core accepts them as
89
+ // aliases, but they stay hand-translated because Prisma gives them meanings
90
+ // core does not have: a NEGATIVE `take` pages from the end, and `skip: 1`
91
+ // beside a `cursor` is the exclusive-pagination idiom. Forwarding either
92
+ // verbatim would hand core a number that means something else.
82
93
  take: 'prisma',
94
+ skip: 'prisma',
83
95
  distinct: 'prisma',
84
96
  relationLoadStrategy: 'prisma',
85
97
  with: 'nativeAlias',
@@ -226,3 +238,81 @@ export function applyNativeOptions(table, src, dst) {
226
238
  export function optionKeysOfKind(table, ...kinds) {
227
239
  return Object.keys(table).filter((k) => kinds.includes(table[k]));
228
240
  }
241
+ // ---------------------------------------------------------------------------
242
+ // Unknown-key diagnostic
243
+ // ---------------------------------------------------------------------------
244
+ /**
245
+ * The operations a caller can invoke, mapped to the table that describes their
246
+ * legal keys. The `*OrThrow` and `findFirst` variants take the same args as the
247
+ * method they are built on, so they share its table rather than getting a copy
248
+ * that could drift from it.
249
+ */
250
+ const OPERATION_TABLE = {
251
+ ...ALL_OPTION_TABLES,
252
+ findFirst: FIND_MANY_OPTIONS,
253
+ findFirstOrThrow: FIND_MANY_OPTIONS,
254
+ findUniqueOrThrow: FIND_UNIQUE_OPTIONS,
255
+ findManyStreamBatches: FIND_MANY_STREAM_OPTIONS,
256
+ };
257
+ /**
258
+ * Prisma spellings that name a real Turbine option under a different word.
259
+ *
260
+ * Only spellings whose Turbine equivalent EXISTS belong here: the message tells
261
+ * the caller what to write instead, so a key with no equivalent would produce
262
+ * advice that does not work. `take` / `skip` were once on this list and are now
263
+ * accepted outright.
264
+ */
265
+ const PRISMA_SPELLING = {
266
+ include: 'with',
267
+ };
268
+ /**
269
+ * Dev-mode warning for a key that is not part of the operation's option
270
+ * surface, and is therefore doing nothing.
271
+ *
272
+ * The motivating case is `include`. It is Prisma's word for `with`, it is what
273
+ * a model or a developer coming from Prisma reaches for first, and an
274
+ * unrecognized key is simply ignored: the query runs, returns rows, and the
275
+ * relation the caller asked for is absent. No error, no empty array, just a
276
+ * missing key on every row. A cross-model eval measured this as the single
277
+ * largest source of confidently-wrong queries against Turbine, and every one of
278
+ * them looked like a success from inside the process.
279
+ *
280
+ * A WARNING and never an error, deliberately. Refusing an unknown key would
281
+ * break `findMany({ ...someOptionsBag })`, which is ordinary code, and the
282
+ * option surface grows: a caller pinned to an older minor would have their
283
+ * working query start throwing. A warning costs a correct program nothing and
284
+ * tells an incorrect one exactly what happened.
285
+ *
286
+ * Dev-only, once per `table.operation.key` per process, and total: the whole
287
+ * body is wrapped, because a diagnostic must never be the reason a query fails.
288
+ */
289
+ export function warnUnknownQueryOptions(table, operation, args) {
290
+ if (process.env.NODE_ENV === 'production')
291
+ return;
292
+ if (args === null || typeof args !== 'object' || Array.isArray(args))
293
+ return;
294
+ const known = OPERATION_TABLE[operation];
295
+ if (!known)
296
+ return;
297
+ try {
298
+ for (const key of Object.keys(args)) {
299
+ // `{ ...maybeOptions }` routinely materializes keys with no value.
300
+ // Nothing is being ignored when the value is undefined.
301
+ if (args[key] === undefined)
302
+ continue;
303
+ if (Object.hasOwn(known, key))
304
+ continue;
305
+ if (!shouldWarnOnce(WARN_NS.unknownQueryOption, `${table}.${operation}.${key}`))
306
+ continue;
307
+ const prismaSpelling = PRISMA_SPELLING[key];
308
+ const suggestion = prismaSpelling ?? suggestKey(key, Object.keys(known));
309
+ const because = prismaSpelling ? ` Turbine spells this "${prismaSpelling}".` : '';
310
+ console.warn(`[turbine] unknown option "${key}" in ${table}.${operation}(), it is ignored.${because}` +
311
+ (!prismaSpelling && suggestion ? ` Did you mean "${suggestion}"?` : ''));
312
+ }
313
+ }
314
+ catch {
315
+ // Key enumeration is the only thing that can fail here (a Proxy whose
316
+ // ownKeys throws), and it must not take the query with it.
317
+ }
318
+ }
@@ -629,8 +629,22 @@ export interface FindManyArgs<T, R extends object = {}, W extends TypedWithClaus
629
629
  with?: W;
630
630
  /** Cursor-based pagination: start after this row */
631
631
  cursor?: Partial<T>;
632
- /** Number of records to take (used with cursor) */
632
+ /**
633
+ * Prisma's spelling of {@link FindManyArgs.limit}. Folded into `limit` before
634
+ * anything reads it; passing both with different values is a
635
+ * `ValidationError`.
636
+ */
633
637
  take?: number;
638
+ /**
639
+ * Prisma's spelling of {@link FindManyArgs.offset}. Folded into `offset`
640
+ * before anything reads it; passing both with different values is a
641
+ * `ValidationError`.
642
+ *
643
+ * Accepted since 0.73.0. Before that `take` was recognized and `skip` was
644
+ * not, so the Prisma pair `{ take, skip }` silently returned the first page
645
+ * however far the caller thought they had paged.
646
+ */
647
+ skip?: number;
634
648
  /** De-duplicate results by specified fields */
635
649
  distinct?: (keyof T & string)[];
636
650
  /** Query timeout in milliseconds. Rejects with an error if exceeded. */
@@ -728,3 +728,26 @@ export declare function warnRedundantSortTerm(table: string, clause: string, dro
728
728
  */
729
729
  export declare function selectNamesNothingMessage(table: string): string;
730
730
  export declare function selectOmitExclusiveMessage(table: string): string;
731
+ /**
732
+ * The Prisma pagination aliases, folded into Turbine's own spelling ONCE,
733
+ * before anything reads them.
734
+ *
735
+ * Turbine's names are `limit` / `offset`; Prisma's are `take` / `skip`. `take`
736
+ * was accepted and `skip` was not, which is the worst of the three possible
737
+ * states: `{ take: 20, skip: 40 }` is what a Prisma habit writes, it looks
738
+ * accepted because half of it is, and the query silently returns page one
739
+ * forever. An unknown key is at least inert on its own; a HALF-recognized pair
740
+ * changes the answer.
741
+ *
742
+ * Folded here rather than read at each site deliberately. `take` used to be
743
+ * handled by six separate `args?.take ?? args?.limit` reads, one of which is
744
+ * the SQL-cache FINGERPRINT, so adding `skip` the same way would have meant
745
+ * teaching six places about it and a miss in the fingerprint is not a missing
746
+ * feature, it is two different pages sharing one cached statement. Normalizing
747
+ * up front leaves `limit` / `offset` as the single authority and the aliases
748
+ * cease to exist below this line.
749
+ *
750
+ * Returns the SAME object when neither alias is present, so the common path
751
+ * allocates nothing.
752
+ */
753
+ export declare function normalizePagination<A extends object | undefined>(args: A): A;
@@ -4,6 +4,7 @@
4
4
  * Standalone utility functions and classes used by the query builder.
5
5
  */
6
6
  import pg from 'pg';
7
+ import { ValidationError } from '../errors.js';
7
8
  import { camelToSnake, localDateTimeKind, snakeToCamel, timeOfDayKind } from '../schema.js';
8
9
  import { shouldWarnOnce, WARN_NS } from './warn-registry.js';
9
10
  // ---------------------------------------------------------------------------
@@ -1492,3 +1493,58 @@ export function selectOmitExclusiveMessage(table) {
1492
1493
  return (`[turbine] "select" and "omit" are mutually exclusive (on table "${table}"). ` +
1493
1494
  `A select already lists exactly the fields you want.`);
1494
1495
  }
1496
+ /**
1497
+ * The Prisma pagination aliases, folded into Turbine's own spelling ONCE,
1498
+ * before anything reads them.
1499
+ *
1500
+ * Turbine's names are `limit` / `offset`; Prisma's are `take` / `skip`. `take`
1501
+ * was accepted and `skip` was not, which is the worst of the three possible
1502
+ * states: `{ take: 20, skip: 40 }` is what a Prisma habit writes, it looks
1503
+ * accepted because half of it is, and the query silently returns page one
1504
+ * forever. An unknown key is at least inert on its own; a HALF-recognized pair
1505
+ * changes the answer.
1506
+ *
1507
+ * Folded here rather than read at each site deliberately. `take` used to be
1508
+ * handled by six separate `args?.take ?? args?.limit` reads, one of which is
1509
+ * the SQL-cache FINGERPRINT, so adding `skip` the same way would have meant
1510
+ * teaching six places about it and a miss in the fingerprint is not a missing
1511
+ * feature, it is two different pages sharing one cached statement. Normalizing
1512
+ * up front leaves `limit` / `offset` as the single authority and the aliases
1513
+ * cease to exist below this line.
1514
+ *
1515
+ * Returns the SAME object when neither alias is present, so the common path
1516
+ * allocates nothing.
1517
+ */
1518
+ export function normalizePagination(args) {
1519
+ if (!args)
1520
+ return args;
1521
+ const a = args;
1522
+ if (a.take === undefined && a.skip === undefined)
1523
+ return args;
1524
+ const out = { ...args };
1525
+ if (a.take !== undefined) {
1526
+ assertAliasAgrees('take', a.take, 'limit', a.limit);
1527
+ out.limit = a.take;
1528
+ delete out.take;
1529
+ }
1530
+ if (a.skip !== undefined) {
1531
+ assertAliasAgrees('skip', a.skip, 'offset', a.offset);
1532
+ out.offset = a.skip;
1533
+ delete out.skip;
1534
+ }
1535
+ return out;
1536
+ }
1537
+ /**
1538
+ * Both spellings of one bound, disagreeing. Refused rather than resolved: the
1539
+ * old `take ?? limit` silently preferred one of the two numbers the caller
1540
+ * wrote, and there is no reading of `{ limit: 10, take: 5 }` that makes one of
1541
+ * them the intended answer. Equal values are accepted, since there is nothing
1542
+ * to choose between.
1543
+ */
1544
+ function assertAliasAgrees(alias, aliasValue, native, nativeValue) {
1545
+ if (nativeValue === undefined || nativeValue === aliasValue)
1546
+ return;
1547
+ throw new ValidationError(`[turbine] "${alias}" and "${native}" are the same option and were given different values ` +
1548
+ `(${alias}: ${String(aliasValue)}, ${native}: ${String(nativeValue)}). ` +
1549
+ `"${alias}" is Prisma's spelling of "${native}"; pass one of them.`);
1550
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turbine-orm",
3
- "version": "0.72.0",
3
+ "version": "0.73.0",
4
4
  "description": "Postgres-native TypeScript ORM, runs on Neon, Vercel Postgres, Cloudflare, Supabase. Streaming cursors, typed errors, single-query nested relations. One dependency, no WASM engine",
5
5
  "type": "module",
6
6
  "//exports": "Each subpath declares its types PER CONDITION. A single shared top-level \"types\" resolves to the ESM declarations for `require` too, which is TS1479 (\"is an ES module ... cannot be require()d\") for any CJS consumer on moduleResolution node16/nodenext. The require condition points at dist/cjs, which ships its own {\"type\":\"commonjs\"} package.json, so those declarations are CJS declarations. Gated in CI by publint + @arethetypeswrong/cli + a real .cts consumer typecheck (see the package-types job in ci.yml).",
@@ -107,6 +107,7 @@
107
107
  "!dist/**/*.js.map",
108
108
  "!dist/**/examples.*",
109
109
  "!dist/**/*.d.ts.map",
110
+ "skills",
110
111
  "LICENSE",
111
112
  "README.md"
112
113
  ],
@@ -0,0 +1,247 @@
1
+ ---
2
+ name: turbine-orm
3
+ description: Use when writing or debugging Turbine ORM queries in TypeScript - covers the with clause, WHERE operators, relation filters, aggregates, groupBy having, pagination, JSON paths, and the errors each one throws
4
+ ---
5
+
6
+ # Writing Turbine queries
7
+
8
+ Turbine is a Postgres-first TypeScript ORM with a Prisma-shaped API. If you know
9
+ Prisma, four differences account for most first-try failures, and they are the
10
+ first four sections here.
11
+
12
+ Every construct below is executed against a live database by
13
+ `evals/src/verify-skill.ts` in the turbine-orm repository, on every release. A
14
+ claim in this file that stops being true fails that check by name.
15
+
16
+ ## 1. Relations are `with`, never `include`
17
+
18
+ ```ts
19
+ const orders = await db.orders.findMany({
20
+ select: { id: true, total: true },
21
+ with: { customer: { select: { email: true } } },
22
+ });
23
+ ```
24
+
25
+ `include` is Prisma's word. Turbine ignores an unrecognized option, so an
26
+ `include` runs, returns rows, and the relation is simply absent from every one
27
+ of them. Since 0.73.0 it also prints a dev-mode warning naming `with`; in
28
+ production it is silent.
29
+
30
+ ## 2. Relation names are derived, and not from the column
31
+
32
+ A relation name is not the foreign-key column, and not always the table name:
33
+
34
+ - **belongsTo** (the side holding the FK) is the target table, singularised and
35
+ camelCased. `guild_id` on `cheese_wheels` gives the relation `guild`.
36
+ - **hasMany** (the side pointed at) is the child table, camelCased and left
37
+ plural: `cheese_wheels` gives `cheeseWheels`.
38
+ - **manyToMany** across a junction is named for the FAR table, not the junction.
39
+ With `wheel_cultures` joining `cheese_wheels` and `cultures`, the relation on
40
+ `cheese_wheels` is `cultures`. The junction is *also* exposed as a plain
41
+ hasMany (`wheelCultures`), and picking that one gives junction rows rather
42
+ than the entities you wanted.
43
+
44
+ If a `turbine mcp` server is connected, `relation_graph` lists these names
45
+ exactly, and `find_join_path` returns the `with` clause to write. Read them
46
+ rather than deriving them.
47
+
48
+ ## 3. Either spelling of a name works, and results are always camelCase
49
+
50
+ A column or relation may be written in the schema's `snake_case` or in the
51
+ generated `camelCase`, in every argument position, and the two produce identical
52
+ SQL. Results are always camelCase: `cave_humidity_pct` comes back as
53
+ `caveHumidityPct`.
54
+
55
+ Prefer camelCase, because that is what the generated types autocomplete and what
56
+ you will read back. A name that is neither spelling is a `ValidationError`
57
+ (`TURBINE_E003`) for a column, `RelationError` (`TURBINE_E005`) for a relation,
58
+ and the message suggests the closest real name.
59
+
60
+ ## 4. `select` is columns only
61
+
62
+ `select` and `omit` name columns. Naming a relation in `select` throws
63
+ `TURBINE_E003` pointing at `with`; relations carry their own nested `select`.
64
+ `select` and `omit` together throw, and so does a `select` that names no field.
65
+
66
+ ## Reads
67
+
68
+ | method | returns |
69
+ |---|---|
70
+ | `findMany` | `T[]` |
71
+ | `findFirst` / `findFirstOrThrow` | the first row matching an optional filter |
72
+ | `findUnique` / `findUniqueOrThrow` | one row addressed by a unique key |
73
+ | `count` | a `number` |
74
+ | `aggregate` | one row of aggregates |
75
+ | `groupBy` | one row per group |
76
+
77
+ `findUnique` requires a `where` that identifies a single row: a primary key, a
78
+ single-column unique, or every column of a compound unique. Anything else is
79
+ `TURBINE_E003` naming the keys that would work. Use `findFirst` for "any row
80
+ matching a filter", and give it an `orderBy` if you care which one.
81
+
82
+ ## WHERE
83
+
84
+ A bare value means equality; `null` means `IS NULL`.
85
+
86
+ ```ts
87
+ where: {
88
+ status: 'graded',
89
+ retiredAt: null,
90
+ caveHumidityPct: { gte: 90, lte: 94 },
91
+ rindStyle: { in: ['washed', 'waxed'] },
92
+ givenName: { contains: 'Roux', mode: 'insensitive' },
93
+ batchRef: { startsWith: 'WB-' },
94
+ wheelCount: { not: 0 },
95
+ }
96
+ ```
97
+
98
+ Operators: `equals`, `not`, `gt`, `gte`, `lt`, `lte`, `in`, `notIn`, `contains`,
99
+ `startsWith`, `endsWith`, plus `mode: 'insensitive'`. `AND`, `OR` and `NOT` nest
100
+ at any depth; `AND` and `OR` take arrays.
101
+
102
+ Values are always bound as parameters, and `contains` / `startsWith` /
103
+ `endsWith` escape LIKE wildcards for you. There is no code path in the typed API
104
+ that concatenates a value into SQL.
105
+
106
+ ## Relation filters: `some` / `none` / `every`
107
+
108
+ Filter parents by their children without returning the children:
109
+
110
+ ```ts
111
+ where: { cheeseWheels: { some: { status: 'quarantined' } } }
112
+ where: { ripeningChecks: { none: {} } } // has no rows at all
113
+ where: { cheeseWheels: { every: { caveHumidityPct: { gte: 85 } } } }
114
+ ```
115
+
116
+ These are filters, not projections: they add nothing to the result. To get the
117
+ children too, add a `with`.
118
+
119
+ ## Per-relation options
120
+
121
+ Everything inside a `with` applies PER PARENT ROW, so `limit: 3` means three
122
+ children each, not three overall.
123
+
124
+ ```ts
125
+ with: {
126
+ affineurs: {
127
+ select: { id: true },
128
+ where: { isJourneyman: true },
129
+ orderBy: { id: 'desc' },
130
+ limit: 3,
131
+ with: { cheeseWheels: { select: { id: true } } }, // nests to any depth
132
+ },
133
+ }
134
+ ```
135
+
136
+ ## Ordering
137
+
138
+ `orderBy` takes an object or an array of objects; an array keeps its order as
139
+ tie breakers. Two special forms:
140
+
141
+ ```ts
142
+ orderBy: [{ pressedOn: 'desc' }, { id: 'desc' }]
143
+ orderBy: { affineurs: { _count: 'desc' } }
144
+ orderBy: { tastingNotes: { path: ['panel', 'score'], direction: 'desc' } }
145
+ ```
146
+
147
+ Without an `orderBy`, row order is undefined. A paginated query with no
148
+ `orderBy` is not just unordered, it is unstable: the same row can appear on two
149
+ pages or on none. Turbine warns about it in development.
150
+
151
+ ## Pagination
152
+
153
+ `limit` and `offset`, with Prisma's `take` and `skip` accepted as aliases for
154
+ them. Passing both spellings of one bound with different values throws.
155
+
156
+ ```ts
157
+ { limit: 20, offset: 40 } // the same query, written two ways
158
+ { take: 20, skip: 40 }
159
+ ```
160
+
161
+ For deep pages, prefer a `cursor` over a large `offset`.
162
+
163
+ ## JSON columns
164
+
165
+ A `path` array walks the document, and combines with the normal operators:
166
+
167
+ ```ts
168
+ where: { tastingNotes: { path: ['panel', 'score'], gte: 9 } }
169
+ where: { credentialBlob: { path: ['tier'], equals: 'master' } }
170
+ ```
171
+
172
+ A path takes a NARROWER operator set than a plain column: `equals`, `gt`, `gte`,
173
+ `lt`, `lte`, `contains`, `startsWith`, `endsWith`, plus `mode`. `not`, `in` and
174
+ `notIn` are refused with `TURBINE_E003` listing what is accepted; express them
175
+ with `NOT` / `OR` around the path filter instead.
176
+
177
+ `_count` accepts a JSON path, and `groupBy` accepts one as a grouping key via
178
+ `{ field, path }`. **`_avg`, `_sum`, `_min` and `_max` do not work on a JSON
179
+ path**: they reach the database as `avg(jsonb)` and fail there. Cast the value
180
+ into a real column, or aggregate in application code.
181
+
182
+ ## Aggregates
183
+
184
+ ```ts
185
+ await db.ripeningChecks.aggregate({
186
+ where: { rindScore: { gte: 8 } },
187
+ _avg: { aromaScore: true },
188
+ _max: { aromaScore: true },
189
+ _count: { id: true },
190
+ });
191
+ ```
192
+
193
+ ## groupBy and having
194
+
195
+ `by` lists the grouping columns. The `having` shape is **column first, aggregate
196
+ second**, which is the opposite of how it reads aloud:
197
+
198
+ ```ts
199
+ await db.cheeseWheels.groupBy({
200
+ by: ['rindStyle'],
201
+ _count: { id: true },
202
+ having: { id: { _count: { gt: 55 } } },
203
+ orderBy: { rindStyle: 'asc' },
204
+ });
205
+ ```
206
+
207
+ `having: { wheelCount: { _sum: { gt: 400 } } }` filters on a summed column. The
208
+ column named in `having` must be one you grouped by or aggregated.
209
+
210
+ ## Unique lookups
211
+
212
+ For a single-column unique, name the column. For a compound unique, use the
213
+ joined selector whose value is an object of the parts, or pass the columns flat:
214
+
215
+ ```ts
216
+ where: { guildId_batchRef: { guildId: 4, batchRef: 'WB-0007' } }
217
+ where: { guildId: 4, batchRef: 'WB-0007' } // equivalent
218
+ ```
219
+
220
+ ## `distinct`
221
+
222
+ `distinct: ['status']` de-duplicates on those columns.
223
+
224
+ ## Errors worth branching on
225
+
226
+ Every error extends `TurbineError` and carries a stable `code`. The ones a query
227
+ produces:
228
+
229
+ | code | class | means |
230
+ |---|---|---|
231
+ | `TURBINE_E003` | `ValidationError` | unknown column, bad operator, refused shape |
232
+ | `TURBINE_E005` | `RelationError` | unknown relation name in `with` |
233
+ | `TURBINE_E001` | `NotFoundError` | an `*OrThrow` matched nothing |
234
+ | `TURBINE_E008` | `UniqueConstraintError` | a write hit a unique constraint |
235
+
236
+ Branch on the class or the code, never on the message text: messages are not
237
+ covered by semver, and every message carries a link to its docs page.
238
+
239
+ ## Before you answer
240
+
241
+ - Did the task ask for a relation? Then it is `with`, not `include`, and not
242
+ `select`.
243
+ - Did it name exact columns? Then `select` exactly those and no more.
244
+ - Did it ask for an order? Then `orderBy` it explicitly.
245
+ - Did it ask for a count rather than rows? Then `count`, not `findMany`.
246
+ - Are you looking a row up by something that is not a unique key? Then
247
+ `findFirst`, not `findUnique`.