turbine-orm 0.70.0 → 0.71.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.
Files changed (46) hide show
  1. package/README.md +164 -1041
  2. package/dist/cjs/cli/compile-query.d.ts +198 -0
  3. package/dist/cjs/cli/compile-query.js +529 -0
  4. package/dist/cjs/cli/index.d.ts +25 -1
  5. package/dist/cjs/cli/index.js +49 -1
  6. package/dist/cjs/cli/mcp.js +198 -16
  7. package/dist/cjs/client.d.ts +45 -10
  8. package/dist/cjs/client.js +21 -3
  9. package/dist/cjs/connection-url.d.ts +160 -0
  10. package/dist/cjs/connection-url.js +296 -0
  11. package/dist/cjs/index-stats.d.ts +4 -1
  12. package/dist/cjs/index-stats.js +27 -11
  13. package/dist/cjs/index.d.ts +1 -1
  14. package/dist/cjs/plan-flip-probe.js +17 -1
  15. package/dist/cjs/powql.d.ts +1 -0
  16. package/dist/cjs/powql.js +9 -0
  17. package/dist/cjs/query/builder.d.ts +133 -2
  18. package/dist/cjs/query/builder.js +288 -64
  19. package/dist/cjs/query/deferred.d.ts +12 -6
  20. package/dist/cjs/query/index.d.ts +1 -1
  21. package/dist/cjs/query/option-surface.js +6 -0
  22. package/dist/cjs/query/types.d.ts +47 -0
  23. package/dist/cjs/query/where.d.ts +11 -2
  24. package/dist/cli/compile-query.d.ts +198 -0
  25. package/dist/cli/compile-query.js +522 -0
  26. package/dist/cli/index.d.ts +25 -1
  27. package/dist/cli/index.js +48 -1
  28. package/dist/cli/mcp.js +198 -16
  29. package/dist/client.d.ts +45 -10
  30. package/dist/client.js +19 -1
  31. package/dist/connection-url.d.ts +160 -0
  32. package/dist/connection-url.js +289 -0
  33. package/dist/index-stats.d.ts +4 -1
  34. package/dist/index-stats.js +27 -11
  35. package/dist/index.d.ts +1 -1
  36. package/dist/plan-flip-probe.js +17 -1
  37. package/dist/powql.d.ts +1 -0
  38. package/dist/powql.js +9 -0
  39. package/dist/query/builder.d.ts +133 -2
  40. package/dist/query/builder.js +288 -64
  41. package/dist/query/deferred.d.ts +12 -6
  42. package/dist/query/index.d.ts +1 -1
  43. package/dist/query/option-surface.js +6 -0
  44. package/dist/query/types.d.ts +47 -0
  45. package/dist/query/where.d.ts +11 -2
  46. package/package.json +8 -6
@@ -412,6 +412,44 @@ function isEmptyOrderBy(orderBy) {
412
412
  }
413
413
  return orderBy === undefined || orderBy === null;
414
414
  }
415
+ /** The two accepted {@link JsonEncoding} values, frozen so the check is total. */
416
+ const JSON_ENCODINGS = Object.freeze(['object', 'positional']);
417
+ /**
418
+ * The relation JSON encoding a client on `dialect` gets when it names none.
419
+ *
420
+ * `'positional'` (`json_build_array`) on PostgreSQL, `'object'`
421
+ * (`json_build_object`) everywhere else. The positional form drops the repeated
422
+ * key names from every relation row: measured on a 50-parent / ~10-child read
423
+ * against local PostgreSQL 17, server time 0.685 ms → 0.350 ms and 152 KB →
424
+ * 100 KB on the wire, returning byte-identical parsed rows.
425
+ *
426
+ * ## Why the test is `dialect.name`, not "does the dialect have buildJsonArray"
427
+ *
428
+ * Because a presence test does not distinguish engines HERE. `buildJsonArray`
429
+ * is declared on `postgresDialect`, and sqlite.ts / mysql.ts / mssql.ts /
430
+ * powdb.ts each build their dialect by SPREADING it, so every engine inherits
431
+ * the hook and an "is it absent" fallback never fires. That is the documented
432
+ * inheritance trap (see `buildPartitionLimit` in {@link
433
+ * QueryInterface.batchedContext} and the `distinct` gate in
434
+ * {@link QueryInterface.buildFindMany}), and it is exactly the shape that
435
+ * silently turned the partition-limit window on for SQLite.
436
+ *
437
+ * A new capability flag would work, but only if every engine set it
438
+ * explicitly, and it would be a SECOND authority on the same question:
439
+ * `buildSelectWithRelations` (relations.ts) already refuses `'positional'` with
440
+ * E017 on `dialect.name !== 'postgresql'`. Deriving the default from the
441
+ * identical predicate is what makes it impossible for the default to select an
442
+ * encoding the builder then refuses. A flag could drift from that refusal; this
443
+ * cannot.
444
+ *
445
+ * A wire-compatible engine that reaches this on `postgresDialect` itself
446
+ * (CockroachDB, YugabyteDB, AlloyDB, Timescale, all of which are ADAPTERS over
447
+ * the Postgres dialect rather than dialects of their own) gets `'positional'`,
448
+ * which is correct: they speak `json_build_array`.
449
+ */
450
+ function defaultJsonEncoding(dialect) {
451
+ return dialect.name === 'postgresql' ? 'positional' : 'object';
452
+ }
415
453
  // biome-ignore lint/complexity/noBannedTypes: {} means "no relations known", intentional for untyped table access
416
454
  export class QueryInterface {
417
455
  pool;
@@ -491,8 +529,31 @@ export class QueryInterface {
491
529
  * is derived. See {@link autoToOneThreshold}.
492
530
  */
493
531
  autoRoundTripMs;
494
- /** Nested-relation JSON encoding: 'object' (default) or 'positional'. */
532
+ /**
533
+ * The CLIENT-level nested-relation JSON encoding: what a query that names no
534
+ * `jsonEncoding` of its own gets. `'positional'` on PostgreSQL, `'object'`
535
+ * everywhere else (see {@link defaultJsonEncoding}).
536
+ *
537
+ * Read through {@link QueryInterface.currentJsonEncoding}, never directly, so
538
+ * a per-query override cannot be missed by one reader.
539
+ */
495
540
  jsonEncoding;
541
+ /**
542
+ * The encoding the query BEING BUILT resolved to, i.e. its own
543
+ * `jsonEncoding` or {@link QueryInterface.jsonEncoding}.
544
+ *
545
+ * Reassigned per `build*` call and exposed on the {@link BuilderCtx} as a live
546
+ * getter, exactly like {@link QueryInterface.currentSkip} and for the same
547
+ * reason: relations.ts reads the encoding from four places deep inside the
548
+ * SELECT walk, and threading it through every one of them as a parameter
549
+ * would be four chances to forget it.
550
+ *
551
+ * Safe because a build is SYNCHRONOUS from the assignment to the last read:
552
+ * `buildFindMany` / `buildFindUnique` return a fully-formed DeferredQuery
553
+ * whose parser closure already captured the shapes, so nothing reads this
554
+ * field after the build returns and no two builds can interleave on it.
555
+ */
556
+ currentJsonEncoding;
496
557
  /**
497
558
  * `parseRow` decode plans, keyed by table plus the exact column list. Bounded
498
559
  * like the SQL template cache and for the same reason: the shapes come from
@@ -661,7 +722,8 @@ export class QueryInterface {
661
722
  autoToOne !== undefined && Number.isFinite(autoToOne) && autoToOne >= 0 ? Math.floor(autoToOne) : undefined;
662
723
  const rtt = options?.autoRoundTripMs;
663
724
  this.autoRoundTripMs = rtt !== undefined && Number.isFinite(rtt) && rtt > 0 ? rtt : undefined;
664
- this.jsonEncoding = options?.jsonEncoding ?? 'object';
725
+ this.jsonEncoding = options?.jsonEncoding ?? defaultJsonEncoding(this.dialect);
726
+ this.currentJsonEncoding = this.jsonEncoding;
665
727
  // Only retain the map when it has at least one entry, so `globalFilters`
666
728
  // stays `undefined` (and every merge path a no-op) for the common case.
667
729
  this.globalFilters =
@@ -768,7 +830,13 @@ export class QueryInterface {
768
830
  mutationInsertId: (result) => this.mutationInsertId(result),
769
831
  acquireSql: (cacheKey, build) => this.acquireSql(cacheKey, build),
770
832
  crossCheckCache: (op, cacheKey, entry, build, collectedParams) => this.crossCheckCache(op, cacheKey, entry, build, collectedParams),
771
- jsonEncoding: this.jsonEncoding,
833
+ // LIVE getter, not a copied value: `jsonEncoding` is now a per-query
834
+ // option, so the module-facing view must see the encoding of the query
835
+ // being built rather than the one the client was constructed with. Same
836
+ // shape and same reason as `currentSkip` above.
837
+ get jsonEncoding() {
838
+ return self.currentJsonEncoding;
839
+ },
772
840
  camelDateFieldCache: this.camelDateFieldCache,
773
841
  relationEntryCache: this.relationEntryCache,
774
842
  limitOneClause: () => this.limitOneClause(),
@@ -967,6 +1035,35 @@ export class QueryInterface {
967
1035
  resolveStableOrder(argFlag) {
968
1036
  return argFlag ?? this.stableRelationOrder;
969
1037
  }
1038
+ /**
1039
+ * Resolve one query's relation JSON encoding and PIN it for the build, so
1040
+ * every reader inside relations.ts sees the same answer.
1041
+ *
1042
+ * Called at the TOP of each entry point that can emit or decode relation JSON
1043
+ * (`buildFindMany`, `buildFindUnique`, `makeStreamRowParser`), before the
1044
+ * cache key is assembled and before the flatten plan is consulted, because
1045
+ * both of those depend on the answer.
1046
+ *
1047
+ * An unrecognized value THROWS (E003) rather than falling back. A per-query
1048
+ * option that is silently ignored when misspelled is the exact failure this
1049
+ * package has been bitten by before (see query/option-surface.ts), and here it
1050
+ * would be invisible: the wrong encoding still returns correct rows, just
1051
+ * without the saving the caller asked for, or with the flatten plan they were
1052
+ * trying to re-enable still refused. Thrown before the SQL cache is consulted
1053
+ * so a warm template can never serve a call the cold path would refuse.
1054
+ */
1055
+ resolveJsonEncoding(argEncoding) {
1056
+ if (argEncoding === undefined) {
1057
+ this.currentJsonEncoding = this.jsonEncoding;
1058
+ return this.currentJsonEncoding;
1059
+ }
1060
+ if (!JSON_ENCODINGS.includes(argEncoding)) {
1061
+ throw new ValidationError(`[turbine] Invalid \`jsonEncoding\` on "${this.table}": ${JSON.stringify(argEncoding)}. ` +
1062
+ `Expected ${JSON_ENCODINGS.map((e) => `'${e}'`).join(' or ')}.`);
1063
+ }
1064
+ this.currentJsonEncoding = argEncoding;
1065
+ return argEncoding;
1066
+ }
970
1067
  /**
971
1068
  * Fill a PK-ascending `orderBy` into every to-many `with` relation that has no
972
1069
  * explicit one, recursing into nested `with`. Returns a CLONED clause (user
@@ -2079,6 +2176,8 @@ export class QueryInterface {
2079
2176
  }
2080
2177
  buildFindUnique(args) {
2081
2178
  this.currentSkip = resolveSkipGlobalFilters(args.skipGlobalFilters);
2179
+ // Pinned before the cache key, which carries it (see buildFindMany).
2180
+ const jsonEncoding = this.resolveJsonEncoding(args.jsonEncoding);
2082
2181
  // Prisma compound-unique selector expansion (before global-filter merge and
2083
2182
  // fingerprinting, so the cache only ever sees the canonical expanded where).
2084
2183
  args = maybeExpandCompoundUnique(this.tableMeta, args);
@@ -2132,7 +2231,10 @@ export class QueryInterface {
2132
2231
  // See buildFindMany: `includePii` is its own cache-key segment so a no-PII
2133
2232
  // statement can never serve an `includePii` call (relation projections that
2134
2233
  // `withFp` does not capture also flip on it).
2135
- const ck = `fu:${whereFingerprint}|c=${colKey}|w=${withFp}|pii=${includePii ? 1 : 0}${this.globalFilterCacheSegment()}`;
2234
+ // `|je=`: same rule and same hazard as in buildFindMany. findUnique is never
2235
+ // flatten-planned, so this is the only plan-shape segment it needs.
2236
+ const encodingFp = `|je=${jsonEncoding === 'positional' ? 'p' : 'o'}`;
2237
+ const ck = `fu:${whereFingerprint}|c=${colKey}|w=${withFp}|pii=${includePii ? 1 : 0}${encodingFp}${this.globalFilterCacheSegment()}`;
2136
2238
  const params = [];
2137
2239
  // Check if all where values are simple (plain equality, no operators/null/OR).
2138
2240
  // Keys are sorted to match fingerprintWhere, insertion order here would let
@@ -2417,6 +2519,8 @@ export class QueryInterface {
2417
2519
  }
2418
2520
  buildFindMany(args) {
2419
2521
  this.currentSkip = resolveSkipGlobalFilters(args?.skipGlobalFilters);
2522
+ // Pinned before the flatten plan and the cache key, both of which read it.
2523
+ const jsonEncoding = this.resolveJsonEncoding(args?.jsonEncoding);
2420
2524
  // Stable relation order (opt-in): fill PK-asc orderBy into unordered to-many
2421
2525
  // relations BEFORE fingerprinting, so the two orderings get distinct cache
2422
2526
  // entries and every downstream path (SQL build, collect, parser) inherits it.
@@ -2565,7 +2669,19 @@ export class QueryInterface {
2565
2669
  // key is byte-identical to before.
2566
2670
  const flattenPlan = this.planFlatten(args, includePii);
2567
2671
  const flattenFp = flattenPlan ? `|fl=${flattenPlan.signature}` : '';
2568
- const ck = `fm:${whereFp}|c=${colKey}|o=${orderFp}|l=${limitFp}|off=${offsetFp}|cur=${cursorFp}|d=${distinctFp}|w=${withFp}|pii=${includePii ? 1 : 0}${flattenFp}${this.globalFilterCacheSegment()}`;
2672
+ // `jsonEncoding` decides whether every relation subquery emits
2673
+ // `json_build_object` or `json_build_array`, and the parser built alongside
2674
+ // the statement decodes exactly one of those two. It is invisible to
2675
+ // `withFp` (which fingerprints the `with` SHAPE, not its rendering), so it
2676
+ // needs its own segment for the same reason `pii=` and `fl=` do, and the
2677
+ // consequence of getting it wrong is worse than either: a positional
2678
+ // template served to an object-planned call hands `parseNestedRow` bare
2679
+ // arrays where it expects keyed objects, which is silent data corruption
2680
+ // rather than an error. Emitted for BOTH values rather than only the
2681
+ // non-default one, so the key never depends on what the client default
2682
+ // happens to be.
2683
+ const encodingFp = `|je=${jsonEncoding === 'positional' ? 'p' : 'o'}`;
2684
+ const ck = `fm:${whereFp}|c=${colKey}|o=${orderFp}|l=${limitFp}|off=${offsetFp}|cur=${cursorFp}|d=${distinctFp}|w=${withFp}|pii=${includePii ? 1 : 0}${flattenFp}${encodingFp}${this.globalFilterCacheSegment()}`;
2569
2685
  const params = [];
2570
2686
  const buildSql = (freshParams) => {
2571
2687
  // Fresh build: generates SQL and populates freshParams
@@ -2742,58 +2858,66 @@ export class QueryInterface {
2742
2858
  };
2743
2859
  }
2744
2860
  // -------------------------------------------------------------------------
2745
- // findManyStream, async iterable using PostgreSQL cursors
2861
+ // findManyStream / findManyStreamBatches, async iterables using PostgreSQL cursors
2746
2862
  // -------------------------------------------------------------------------
2747
2863
  /**
2748
- * Stream rows from a findMany query using PostgreSQL cursors.
2749
- * Returns an AsyncIterable that yields individual rows, fetching in batches internally.
2864
+ * Build the one row parser a whole drain uses.
2750
2865
  *
2751
- * **Speculative fast-path:** Before opening a cursor, issues a single
2752
- * `SELECT ... LIMIT batchSize+1`. If the result fits within `batchSize`,
2753
- * all rows are yielded immediately with zero cursor overhead (no BEGIN /
2754
- * DECLARE / CLOSE / COMMIT). Only when the result overflows does the
2755
- * method fall back to the full cursor path.
2866
+ * Shared by {@link findManyStream} and {@link findManyStreamBatches} so the
2867
+ * two can never disagree about a row's SHAPE while agreeing about its
2868
+ * contents: the flatten plan, the positional/object relation decode and the
2869
+ * PII projection are all decided here, once, before a statement is issued.
2756
2870
  *
2757
- * **Cursor path:** Uses DECLARE CURSOR within a dedicated transaction on a
2758
- * single pooled connection. The cursor is CLOSEd (in the dialect's `finally`)
2759
- * and the connection released both when iteration completes normally and when
2760
- * it ends early (`break` from `for await`). An error mid-stream skips the
2761
- * CLOSE and rolls back instead, which drops the cursor with the transaction.
2871
+ * The plan is a pure function of the schema, the `with` shape and
2872
+ * `includePii`, never of `limit`, so the batch-size override the speculative
2873
+ * fetch applies cannot change it and the parser always matches the emitted
2874
+ * SQL. Reading the raw `includePii` sentinel with `=== true` here would have
2875
+ * quietly planned a no-PII parser over a with-PII statement, which is why it
2876
+ * goes through `resolveUnsafeFlag` on every path, `with` clause or not.
2877
+ */
2878
+ makeStreamRowParser(args) {
2879
+ const streamPii = resolveUnsafeFlag(args?.includePii, 'includePii');
2880
+ // THIS CALL IS ORDER-CRITICAL, and it is why the resolution is a method
2881
+ // rather than an expression inlined into the two build sites. The parser is
2882
+ // built HERE, before `streamRaw` reaches `buildFindMany` and pins the
2883
+ // encoding itself, so without this the parser would be planned against the
2884
+ // CLIENT default while the statement was built against the query's
2885
+ // override: positional SQL decoded as objects, or the reverse. Both build
2886
+ // from the same `args`, so both resolve to the same value; the invalid-value
2887
+ // throw also lands here first, before a connection is taken.
2888
+ this.resolveJsonEncoding(args?.jsonEncoding);
2889
+ if (!args?.with) {
2890
+ const table = this.table;
2891
+ return (row) => this.parseRow(row, table);
2892
+ }
2893
+ const streamFlattenPlan = this.planFlatten(args, streamPii);
2894
+ return this.makeNestedParser(args.with, streamPii, streamFlattenPlan);
2895
+ }
2896
+ /**
2897
+ * Everything a stream does to the DATABASE, and nothing it does to a row.
2762
2898
  *
2763
- * **Snapshot semantics note:** Outside a transaction the speculative
2764
- * fast-path runs unwrapped, and an overflow opens the cursor in its own
2765
- * transaction, so the two fetches span two separate snapshots. Wrapping the
2766
- * call in `$transaction` gives strict single-snapshot semantics: both the
2767
- * speculative fetch and the cursor then run on the caller's connection
2768
- * inside the caller's transaction (the cursor path issues no BEGIN/COMMIT of
2769
- * its own and releases nothing, so the caller's transaction is intact when
2770
- * iteration finishes).
2899
+ * {@link findManyStream} and {@link findManyStreamBatches} are the same drain
2900
+ * handed out at two granularities, so the statement they issue, when the
2901
+ * cursor opens, and how the connection is released all live here once. Only
2902
+ * the yielding differs, which is the entire point of having both.
2771
2903
  *
2772
- * @example
2773
- * ```ts
2774
- * for await (const user of db.users.findManyStream({ where: { orgId: 1 }, batchSize: 500 })) {
2775
- * process.stdout.write(`${user.email}\n`);
2776
- * }
2777
- * ```
2904
+ * Parsing deliberately stays in the callers: they parse at different
2905
+ * granularities, and the per-row method must keep parsing LAZILY, one row at
2906
+ * a time, exactly as it always has. Hoisting the parse in here would make a
2907
+ * consumer that breaks after the first row pay for the rest of its batch.
2908
+ *
2909
+ * `action` is the tag query events carry. It is a parameter rather than a
2910
+ * constant so each public method reports its own name instead of the name of
2911
+ * whichever one happens to be implemented over the other.
2912
+ *
2913
+ * An EMPTY batch is never yielded: the dialect's cursor loop breaks on a
2914
+ * zero-row FETCH, and the speculative path below yields nothing at all when
2915
+ * the result set is empty. Callers may therefore treat a yielded batch as
2916
+ * non-empty.
2778
2917
  */
2779
- async *findManyStream(args) {
2918
+ async *streamRaw(args, action) {
2780
2919
  const batchSize = Math.max(1, Math.floor(Number(args?.batchSize ?? 1000)));
2781
- const hasRelations = !!args?.with;
2782
- // Build the positional-aware relation parser once for the whole stream.
2783
- // Same flatten plan buildFindMany compiles below. The plan is a pure
2784
- // function of the schema, the `with` shape and `includePii`, never of
2785
- // `limit`, so the batch-size override the speculative fetch applies cannot
2786
- // change it, and the stream's parser matches the emitted SQL.
2787
- // Resolved once for the whole stream: the flatten plan and the row parser
2788
- // MUST agree with the SQL buildFindMany emits below, and reading the raw
2789
- // sentinel with `=== true` here would have quietly planned a no-PII parser
2790
- // over a with-PII statement.
2791
- const streamPii = resolveUnsafeFlag(args?.includePii, 'includePii');
2792
- const streamFlattenPlan = hasRelations ? this.planFlatten(args, streamPii) : null;
2793
- const parseWith = hasRelations
2794
- ? this.makeNestedParser(args.with, streamPii, streamFlattenPlan)
2795
- : null;
2796
- this.currentAction = 'findManyStream';
2920
+ this.currentAction = action;
2797
2921
  // Streaming is ALREADY immune to the generic-plan cliff: the speculative
2798
2922
  // fetch has never passed a prepared name, and the cursor path runs through
2799
2923
  // DECLARE, so neither statement enters the plan cache. `preparedNameFor` is
@@ -2836,10 +2960,9 @@ export class QueryInterface {
2836
2960
  });
2837
2961
  const speculativeResult = await this.queryWithTimeout(speculativeDeferred.sql, speculativeDeferred.params, args?.timeout, streamPreparedName);
2838
2962
  if (speculativeResult.rows.length <= batchSize) {
2839
- // Small drain, yield all rows and return, no cursor needed
2840
- for (const row of speculativeResult.rows) {
2841
- yield (parseWith ? parseWith(row) : this.parseRow(row, this.table));
2842
- }
2963
+ // Small drain, hand over the whole result and return, no cursor needed.
2964
+ if (speculativeResult.rows.length > 0)
2965
+ yield speculativeResult.rows;
2843
2966
  return;
2844
2967
  }
2845
2968
  }
@@ -2848,7 +2971,7 @@ export class QueryInterface {
2848
2971
  // Acquire a dedicated connection: cursors require a single connection in a
2849
2972
  // transaction. The dialect owns the streaming SQL (Postgres: BEGIN → DECLARE
2850
2973
  // … NO SCROLL CURSOR FOR → FETCH n → CLOSE → COMMIT, ROLLBACK on error); we
2851
- // just parse + yield the row batches it produces.
2974
+ // just yield the row batches it produces.
2852
2975
  //
2853
2976
  // Inside a caller-owned transaction there is nothing to check out: the
2854
2977
  // transaction-scoped pool pins every query to the transaction's own
@@ -2860,13 +2983,9 @@ export class QueryInterface {
2860
2983
  query: async (text, values) => (await this.pool.query(text, values)),
2861
2984
  };
2862
2985
  try {
2863
- for await (const batch of this.dialect.openStream(conn, deferred.sql, deferred.params, batchSize, {
2986
+ yield* this.dialect.openStream(conn, deferred.sql, deferred.params, batchSize, {
2864
2987
  ambientTransaction: this.txScoped,
2865
- })) {
2866
- for (const row of batch) {
2867
- yield (parseWith ? parseWith(row) : this.parseRow(row, this.table));
2868
- }
2869
- }
2988
+ });
2870
2989
  }
2871
2990
  catch (err) {
2872
2991
  // Wrap pg constraint errors so streaming surfaces typed errors like the rest of the API
@@ -2876,6 +2995,97 @@ export class QueryInterface {
2876
2995
  client?.release();
2877
2996
  }
2878
2997
  }
2998
+ /**
2999
+ * Stream rows from a findMany query using PostgreSQL cursors, one BATCH of
3000
+ * rows at a time.
3001
+ *
3002
+ * The same drain {@link findManyStream} performs, the same statements, the
3003
+ * same cursor, the same rows in the same order, handed out as arrays instead
3004
+ * of one row at a time. That is worth having because the per-row form costs
3005
+ * one promise resolution and one microtask turn PER ROW: measured over 50,000
3006
+ * rows on a local PostgreSQL, per-row yielding costs ~7 ms (~140 ns/row) that
3007
+ * batch yielding does not, which is roughly half of the streaming overhead
3008
+ * over a hand-written cursor loop. Nothing else about the two paths differs,
3009
+ * so the entire saving is the yielding.
3010
+ *
3011
+ * Prefer this whenever the consumer can work on an array; keep
3012
+ * {@link findManyStream} when a row at a time is what the code actually wants,
3013
+ * since flattening a batch by hand costs exactly what it saves.
3014
+ *
3015
+ * A yielded batch is never empty, and its length is NOT a contract: it is at
3016
+ * most `batchSize`, the final batch is usually shorter, and a result set that
3017
+ * fits within one batch arrives as a single array from the speculative fetch
3018
+ * with no cursor involved. Do not use batch boundaries to infer anything
3019
+ * about the data.
3020
+ *
3021
+ * Every other streaming behaviour is shared and documented on
3022
+ * {@link findManyStream}: the speculative fast path, the cursor path and its
3023
+ * cleanup, the snapshot semantics, and early `break`.
3024
+ *
3025
+ * @example
3026
+ * ```ts
3027
+ * for await (const batch of db.users.findManyStreamBatches({ where: { orgId: 1 }, batchSize: 500 })) {
3028
+ * await sink.writeAll(batch);
3029
+ * }
3030
+ * ```
3031
+ */
3032
+ async *findManyStreamBatches(args) {
3033
+ const parse = this.makeStreamRowParser(args);
3034
+ for await (const batch of this.streamRaw(args, 'findManyStreamBatches')) {
3035
+ const parsed = new Array(batch.length);
3036
+ for (let i = 0; i < batch.length; i++) {
3037
+ parsed[i] = parse(batch[i]);
3038
+ }
3039
+ yield parsed;
3040
+ }
3041
+ }
3042
+ /**
3043
+ * Stream rows from a findMany query using PostgreSQL cursors.
3044
+ * Returns an AsyncIterable that yields individual rows, fetching in batches internally.
3045
+ *
3046
+ * See {@link findManyStreamBatches} for the same drain yielded a batch at a
3047
+ * time, which is measurably cheaper when the consumer can take an array.
3048
+ *
3049
+ * **Speculative fast-path:** Before opening a cursor, issues a single
3050
+ * `SELECT ... LIMIT batchSize+1`. If the result fits within `batchSize`,
3051
+ * all rows are yielded immediately with zero cursor overhead (no BEGIN /
3052
+ * DECLARE / CLOSE / COMMIT). Only when the result overflows does the
3053
+ * method fall back to the full cursor path.
3054
+ *
3055
+ * **Cursor path:** Uses DECLARE CURSOR within a dedicated transaction on a
3056
+ * single pooled connection. The cursor is CLOSEd (in the dialect's `finally`)
3057
+ * and the connection released both when iteration completes normally and when
3058
+ * it ends early (`break` from `for await`). An error mid-stream skips the
3059
+ * CLOSE and rolls back instead, which drops the cursor with the transaction.
3060
+ *
3061
+ * **Snapshot semantics note:** Outside a transaction the speculative
3062
+ * fast-path runs unwrapped, and an overflow opens the cursor in its own
3063
+ * transaction, so the two fetches span two separate snapshots. Wrapping the
3064
+ * call in `$transaction` gives strict single-snapshot semantics: both the
3065
+ * speculative fetch and the cursor then run on the caller's connection
3066
+ * inside the caller's transaction (the cursor path issues no BEGIN/COMMIT of
3067
+ * its own and releases nothing, so the caller's transaction is intact when
3068
+ * iteration finishes).
3069
+ *
3070
+ * @example
3071
+ * ```ts
3072
+ * for await (const user of db.users.findManyStream({ where: { orgId: 1 }, batchSize: 500 })) {
3073
+ * process.stdout.write(`${user.email}\n`);
3074
+ * }
3075
+ * ```
3076
+ */
3077
+ async *findManyStream(args) {
3078
+ const parse = this.makeStreamRowParser(args);
3079
+ // Parsed one row at a time, INSIDE the yield loop rather than a batch ahead
3080
+ // of it, so a consumer that breaks early still pays only for the rows it
3081
+ // took. That laziness is the reason this loop is not written over
3082
+ // `findManyStreamBatches`.
3083
+ for await (const batch of this.streamRaw(args, 'findManyStream')) {
3084
+ for (let i = 0; i < batch.length; i++) {
3085
+ yield parse(batch[i]);
3086
+ }
3087
+ }
3088
+ }
2879
3089
  // -------------------------------------------------------------------------
2880
3090
  // findFirst, like findMany but returns a single row or null
2881
3091
  // -------------------------------------------------------------------------
@@ -3299,7 +3509,11 @@ export class QueryInterface {
3299
3509
  * strategy, silently and byte-identically):
3300
3510
  * - the resolved strategy is not `'flatten'`;
3301
3511
  * - `jsonEncoding: 'positional'` (a flattened relation emits no JSON at all,
3302
- * so the two encodings are not composed in this version);
3512
+ * so the two are not composed in this version). NOTE that this is the
3513
+ * PostgreSQL DEFAULT, so on Postgres `'flatten'` engages only when the
3514
+ * caller ALSO passes `jsonEncoding: 'object'`. The fallback is silent and
3515
+ * byte-identical, and `warnFlattenBlocked` names the encoding and the
3516
+ * escape hatch in dev;
3303
3517
  * - the dialect owns relation-subquery generation
3304
3518
  * (`dialect.buildRelationSubquery`, i.e. SQL Server's `FOR JSON PATH`);
3305
3519
  * - `distinct` (the `DISTINCT ON` rewrite re-orders in an outer wrapper, and
@@ -3324,8 +3538,9 @@ export class QueryInterface {
3324
3538
  // Query-level refusals: the whole plan is off, so name the reason once for
3325
3539
  // the query rather than once per relation (relations.ts warns per relation
3326
3540
  // for the eligibility rules it owns).
3327
- const queryLevelBlock = this.jsonEncoding === 'positional'
3328
- ? "`jsonEncoding: 'positional'` is active, and a flattened relation emits no JSON to encode"
3541
+ const queryLevelBlock = this.currentJsonEncoding === 'positional'
3542
+ ? "`jsonEncoding: 'positional'` is active (the PostgreSQL default), and a flattened relation emits no " +
3543
+ "JSON to encode. Pass `jsonEncoding: 'object'` alongside the strategy to get the flatten plan"
3329
3544
  : this.dialect.buildRelationSubquery
3330
3545
  ? `the ${this.dialect.name} dialect generates relation subqueries itself`
3331
3546
  : args?.distinct && args.distinct.length > 0
@@ -3644,9 +3859,18 @@ export class QueryInterface {
3644
3859
  * Build the decode plan for one exact column list, and remember it as this
3645
3860
  * table's most recent shape so the next row of the same result set hits the
3646
3861
  * fast path in {@link parseRow}.
3862
+ *
3863
+ * The shape key's delimiter is NUL because it is the one byte a Postgres
3864
+ * identifier cannot contain, even quoted, so no table or column name can
3865
+ * forge a collision. It MUST be written as the six-character escape, never
3866
+ * as a literal NUL byte: a raw NUL makes byte-oriented tools classify this
3867
+ * file as binary, and `grep` then reports ZERO matches for a term that is
3868
+ * present rather than saying it declined to look. This is the largest file
3869
+ * in the repo, and that silent empty result has already sent more than one
3870
+ * search down the wrong path.
3647
3871
  */
3648
3872
  buildRowDecodePlan(table, meta, keys) {
3649
- const shapeKey = `${table}${keys.join('')}`;
3873
+ const shapeKey = `${table}\u0000${keys.join('\u0000')}`;
3650
3874
  let plan = this.rowPlanCache.get(shapeKey);
3651
3875
  if (plan === undefined) {
3652
3876
  const reverseMap = meta.reverseColumnMap;
@@ -7,7 +7,7 @@ import type { Dialect } from '../dialect.js';
7
7
  import type { PgCompatPool, PgCompatQueryResult } from '../pg-types.js';
8
8
  import type { SchemaMetadata } from '../schema.js';
9
9
  import type { QueryInterface } from './builder.js';
10
- import type { GlobalFilters, RelationLoadStrategy } from './types.js';
10
+ import type { GlobalFilters, JsonEncoding, RelationLoadStrategy } from './types.js';
11
11
  /**
12
12
  * Runs a SQL statement and resolves its raw result. Passed to a
13
13
  * {@link DeferredQuery.reselect} plan so it can run the write and the follow-up
@@ -245,12 +245,18 @@ export interface QueryInterfaceOptions {
245
245
  */
246
246
  autoRoundTripMs?: number;
247
247
  /**
248
- * How nested-relation subqueries encode each row's JSON: `'object'` (default,
249
- * `json_build_object`) or `'positional'` (`json_build_array`, key-less, see
250
- * {@link Dialect.buildJsonArray}). Positional is Postgres-only in v1; a
251
- * `with` clause on any other dialect throws `UnsupportedFeatureError` (E017).
248
+ * How nested-relation subqueries encode each row's JSON: `'object'`
249
+ * (`json_build_object`) or `'positional'` (`json_build_array`, key-less, see
250
+ * {@link Dialect.buildJsonArray}).
251
+ *
252
+ * DEFAULTS BY ENGINE: `'positional'` on PostgreSQL, `'object'` on every other
253
+ * dialect. Positional is Postgres-only; setting it elsewhere makes a `with`
254
+ * clause throw `UnsupportedFeatureError` (E017).
255
+ *
256
+ * Overridable per query (`findMany({ jsonEncoding })`), which is also how a
257
+ * caller re-enables `relationLoadStrategy: 'flatten'` on PostgreSQL.
252
258
  */
253
- jsonEncoding?: 'object' | 'positional';
259
+ jsonEncoding?: JsonEncoding;
254
260
  /**
255
261
  * Automatic WHERE filters keyed by table accessor, AND-merged into every
256
262
  * query on that table and every relation subquery targeting it (soft-delete /
@@ -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, GroupByResult, HavingClause, JsonFilter, JsonPathAggregateTarget, JsonPathGroupKey, JsonPathOrderBy, NestedCreateOp, NestedUpdateOp, NestedUpdateOpItem, NestedUpsertOpItem, OmitResult, OrderByClause, OrderByObject, OrderDirection, QueryResult, RelationDescriptor, RelationFilter, RelationLoadStrategy, RelationPickBy, RelationPickOrderBy, ResolvedSkipGlobalFilters, 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';
8
+ export type { AggregateArgs, AggregateResult, ArrayFilter, ColumnRef, ConnectOrCreateOp, CountArgs, CreateArgs, CreateDataInput, CreateManyArgs, DeleteArgs, DeleteManyArgs, FieldResult, FindManyArgs, FindManyStreamArgs, FindUniqueArgs, GlobalFilters, GroupByAggregateSpec, GroupByArgs, GroupByDistinctOn, GroupByResult, HavingClause, JsonEncoding, JsonFilter, JsonPathAggregateTarget, JsonPathGroupKey, JsonPathOrderBy, NestedCreateOp, NestedUpdateOp, NestedUpdateOpItem, NestedUpsertOpItem, OmitResult, OrderByClause, OrderByObject, OrderDirection, QueryResult, RelationDescriptor, RelationFilter, RelationLoadStrategy, RelationPickBy, RelationPickOrderBy, ResolvedSkipGlobalFilters, 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 { OptionKind, OptionTable } from './option-surface.js';
@@ -68,6 +68,10 @@ export const FIND_UNIQUE_OPTIONS = {
68
68
  skipGlobalFilters: 'native',
69
69
  includePii: 'native',
70
70
  forceCustomPlan: 'native',
71
+ // `'object' | 'positional'`: a wire ENCODING, not a name. It cannot reach the
72
+ // schema's naming space, so THE ONE RULE puts it here rather than in the
73
+ // hand-translated set.
74
+ jsonEncoding: 'native',
71
75
  };
72
76
  export const FIND_MANY_OPTIONS = {
73
77
  where: 'prisma',
@@ -87,6 +91,8 @@ export const FIND_MANY_OPTIONS = {
87
91
  warnOnUnlimited: 'native',
88
92
  includePii: 'native',
89
93
  forceCustomPlan: 'native',
94
+ // See FIND_UNIQUE_OPTIONS: an encoding name carries no schema name.
95
+ jsonEncoding: 'native',
90
96
  };
91
97
  export const FIND_MANY_STREAM_OPTIONS = {
92
98
  ...FIND_MANY_OPTIONS,
@@ -599,7 +599,23 @@ export interface FindUniqueArgs<T, R extends object = {}, W extends TypedWithCla
599
599
  includePii?: Unsafe;
600
600
  /** Plan this query with its real parameter values. See {@link FindManyArgs.forceCustomPlan}. */
601
601
  forceCustomPlan?: boolean;
602
+ /** Override the relation JSON wire encoding for this query. See {@link FindManyArgs.jsonEncoding}. */
603
+ jsonEncoding?: JsonEncoding;
602
604
  }
605
+ /**
606
+ * How a nested relation's rows are encoded on the wire.
607
+ *
608
+ * - `'object'`, `json_build_object('id', …, 'title', …)`: every key name is
609
+ * repeated on every row. Readable in a query log, and the only shape the
610
+ * non-PostgreSQL engines emit.
611
+ * - `'positional'`, `json_build_array(…)`: the keys are dropped and each row is
612
+ * a bare array whose positions are mapped back client-side. Fewer bytes and
613
+ * materially less server CPU, and the parsed rows are identical.
614
+ *
615
+ * PostgreSQL defaults to `'positional'`; every other engine is `'object'` and
616
+ * refuses `'positional'` with {@link UnsupportedFeatureError} (E017).
617
+ */
618
+ export type JsonEncoding = 'object' | 'positional';
603
619
  export interface FindManyArgs<T, R extends object = {}, W extends TypedWithClause<R> = TypedWithClause<R>, S extends Record<string, boolean> | undefined = undefined, O extends Record<string, boolean> | undefined = undefined> {
604
620
  /** Row filter. Keys are checked against `T` and `R` (see {@link WhereClause}). */
605
621
  where?: WhereClause<T, R>;
@@ -788,6 +804,37 @@ export interface FindManyArgs<T, R extends object = {}, W extends TypedWithClaus
788
804
  * made. Omitting it (or `false`) is accepted everywhere.
789
805
  */
790
806
  forceCustomPlan?: boolean;
807
+ /**
808
+ * Override {@link TurbineConfig.jsonEncoding} for this query: how a `with`
809
+ * clause's relation rows are encoded on the wire.
810
+ *
811
+ * No effect on a query without a `with` clause, which emits no relation JSON
812
+ * at all, and none on `relationLoadStrategy: 'batched'`, whose follow-up
813
+ * queries are flat.
814
+ *
815
+ * PostgreSQL defaults to `'positional'`, which drops the repeated key names
816
+ * from every relation row. Measured on a 50-parent, ~10-child-per-parent read
817
+ * against local PostgreSQL 17: server time 0.685 ms → 0.350 ms and 152 KB →
818
+ * 100 KB on the wire, for byte-identical parsed rows. Every other engine
819
+ * defaults to `'object'` and refuses `'positional'` with
820
+ * {@link UnsupportedFeatureError} (E017).
821
+ *
822
+ * Two reasons to ask for `'object'` on PostgreSQL:
823
+ *
824
+ * 1. READABILITY. A logged positional statement carries no key names, so a
825
+ * query log or a Studio SQL pane shows `json_build_array(t0."id", …)`
826
+ * where the object form names each field.
827
+ * 2. `relationLoadStrategy: 'flatten'`. A flattened relation emits no JSON
828
+ * to encode, so the two are not composed: with `'positional'` active the
829
+ * flatten plan is refused for the whole query and every relation falls
830
+ * back to the correlated subquery (same rows, different plan). Setting
831
+ * `jsonEncoding: 'object'` alongside `'flatten'` is what makes the
832
+ * flatten plan run.
833
+ *
834
+ * A value that is neither `'object'` nor `'positional'` throws
835
+ * {@link ValidationError} (E003) rather than being ignored.
836
+ */
837
+ jsonEncoding?: JsonEncoding;
791
838
  }
792
839
  export interface FindManyStreamArgs<T, R extends object = {}, W extends TypedWithClause<R> = TypedWithClause<R>, S extends Record<string, boolean> | undefined = undefined, O extends Record<string, boolean> | undefined = undefined> extends FindManyArgs<T, R, W, S, O> {
793
840
  /**
@@ -15,7 +15,7 @@ import { ValidationError } from '../errors.js';
15
15
  import type { PgCompatQueryResult } from '../pg-types.js';
16
16
  import type { RelationDef, SchemaMetadata, TableMetadata } from '../schema.js';
17
17
  import type { TemporalInfinityReading } from './deferred.js';
18
- import type { ArrayFilter, ColumnRef, GlobalFilters, JsonFilter, JsonPathOrderBy, ResolvedSkipGlobalFilters, TextSearchFilter, VectorFilter, WhereClause, WhereOperator } from './types.js';
18
+ import type { ArrayFilter, ColumnRef, GlobalFilters, JsonEncoding, JsonFilter, JsonPathOrderBy, ResolvedSkipGlobalFilters, TextSearchFilter, VectorFilter, WhereClause, WhereOperator } from './types.js';
19
19
  import { type SqlCacheEntry } from './utils.js';
20
20
  import { type WhereHost, type WhereRecord } from './where-compile.js';
21
21
  /**
@@ -130,7 +130,16 @@ export interface BuilderCtx {
130
130
  mutationInsertId(result: PgCompatQueryResult): unknown;
131
131
  acquireSql(cacheKey: string, build: (params: unknown[]) => string): SqlCacheEntry;
132
132
  crossCheckCache(op: string, cacheKey: string, entry: SqlCacheEntry, build: (params: unknown[]) => string, collectedParams: unknown[]): void;
133
- readonly jsonEncoding: 'object' | 'positional';
133
+ /**
134
+ * The relation JSON encoding of the query BEING BUILT: its own `jsonEncoding`
135
+ * arg, else the client's, which is `'positional'` on PostgreSQL and
136
+ * `'object'` on every other engine.
137
+ *
138
+ * A live getter on the concrete ctx (like `currentSkip`), not a value copied
139
+ * at construction, because it is now a per-query option. `readonly` here says
140
+ * the modules may not write it, not that it cannot change between builds.
141
+ */
142
+ readonly jsonEncoding: JsonEncoding;
134
143
  readonly camelDateFieldCache: Map<string, Set<string>>;
135
144
  /**
136
145
  * Per-table memo of `Object.entries(meta.relations)`. See