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
@@ -450,6 +450,44 @@ function isEmptyOrderBy(orderBy) {
450
450
  }
451
451
  return orderBy === undefined || orderBy === null;
452
452
  }
453
+ /** The two accepted {@link JsonEncoding} values, frozen so the check is total. */
454
+ const JSON_ENCODINGS = Object.freeze(['object', 'positional']);
455
+ /**
456
+ * The relation JSON encoding a client on `dialect` gets when it names none.
457
+ *
458
+ * `'positional'` (`json_build_array`) on PostgreSQL, `'object'`
459
+ * (`json_build_object`) everywhere else. The positional form drops the repeated
460
+ * key names from every relation row: measured on a 50-parent / ~10-child read
461
+ * against local PostgreSQL 17, server time 0.685 ms → 0.350 ms and 152 KB →
462
+ * 100 KB on the wire, returning byte-identical parsed rows.
463
+ *
464
+ * ## Why the test is `dialect.name`, not "does the dialect have buildJsonArray"
465
+ *
466
+ * Because a presence test does not distinguish engines HERE. `buildJsonArray`
467
+ * is declared on `postgresDialect`, and sqlite.ts / mysql.ts / mssql.ts /
468
+ * powdb.ts each build their dialect by SPREADING it, so every engine inherits
469
+ * the hook and an "is it absent" fallback never fires. That is the documented
470
+ * inheritance trap (see `buildPartitionLimit` in {@link
471
+ * QueryInterface.batchedContext} and the `distinct` gate in
472
+ * {@link QueryInterface.buildFindMany}), and it is exactly the shape that
473
+ * silently turned the partition-limit window on for SQLite.
474
+ *
475
+ * A new capability flag would work, but only if every engine set it
476
+ * explicitly, and it would be a SECOND authority on the same question:
477
+ * `buildSelectWithRelations` (relations.ts) already refuses `'positional'` with
478
+ * E017 on `dialect.name !== 'postgresql'`. Deriving the default from the
479
+ * identical predicate is what makes it impossible for the default to select an
480
+ * encoding the builder then refuses. A flag could drift from that refusal; this
481
+ * cannot.
482
+ *
483
+ * A wire-compatible engine that reaches this on `postgresDialect` itself
484
+ * (CockroachDB, YugabyteDB, AlloyDB, Timescale, all of which are ADAPTERS over
485
+ * the Postgres dialect rather than dialects of their own) gets `'positional'`,
486
+ * which is correct: they speak `json_build_array`.
487
+ */
488
+ function defaultJsonEncoding(dialect) {
489
+ return dialect.name === 'postgresql' ? 'positional' : 'object';
490
+ }
453
491
  // biome-ignore lint/complexity/noBannedTypes: {} means "no relations known", intentional for untyped table access
454
492
  class QueryInterface {
455
493
  pool;
@@ -529,8 +567,31 @@ class QueryInterface {
529
567
  * is derived. See {@link autoToOneThreshold}.
530
568
  */
531
569
  autoRoundTripMs;
532
- /** Nested-relation JSON encoding: 'object' (default) or 'positional'. */
570
+ /**
571
+ * The CLIENT-level nested-relation JSON encoding: what a query that names no
572
+ * `jsonEncoding` of its own gets. `'positional'` on PostgreSQL, `'object'`
573
+ * everywhere else (see {@link defaultJsonEncoding}).
574
+ *
575
+ * Read through {@link QueryInterface.currentJsonEncoding}, never directly, so
576
+ * a per-query override cannot be missed by one reader.
577
+ */
533
578
  jsonEncoding;
579
+ /**
580
+ * The encoding the query BEING BUILT resolved to, i.e. its own
581
+ * `jsonEncoding` or {@link QueryInterface.jsonEncoding}.
582
+ *
583
+ * Reassigned per `build*` call and exposed on the {@link BuilderCtx} as a live
584
+ * getter, exactly like {@link QueryInterface.currentSkip} and for the same
585
+ * reason: relations.ts reads the encoding from four places deep inside the
586
+ * SELECT walk, and threading it through every one of them as a parameter
587
+ * would be four chances to forget it.
588
+ *
589
+ * Safe because a build is SYNCHRONOUS from the assignment to the last read:
590
+ * `buildFindMany` / `buildFindUnique` return a fully-formed DeferredQuery
591
+ * whose parser closure already captured the shapes, so nothing reads this
592
+ * field after the build returns and no two builds can interleave on it.
593
+ */
594
+ currentJsonEncoding;
534
595
  /**
535
596
  * `parseRow` decode plans, keyed by table plus the exact column list. Bounded
536
597
  * like the SQL template cache and for the same reason: the shapes come from
@@ -699,7 +760,8 @@ class QueryInterface {
699
760
  autoToOne !== undefined && Number.isFinite(autoToOne) && autoToOne >= 0 ? Math.floor(autoToOne) : undefined;
700
761
  const rtt = options?.autoRoundTripMs;
701
762
  this.autoRoundTripMs = rtt !== undefined && Number.isFinite(rtt) && rtt > 0 ? rtt : undefined;
702
- this.jsonEncoding = options?.jsonEncoding ?? 'object';
763
+ this.jsonEncoding = options?.jsonEncoding ?? defaultJsonEncoding(this.dialect);
764
+ this.currentJsonEncoding = this.jsonEncoding;
703
765
  // Only retain the map when it has at least one entry, so `globalFilters`
704
766
  // stays `undefined` (and every merge path a no-op) for the common case.
705
767
  this.globalFilters =
@@ -806,7 +868,13 @@ class QueryInterface {
806
868
  mutationInsertId: (result) => this.mutationInsertId(result),
807
869
  acquireSql: (cacheKey, build) => this.acquireSql(cacheKey, build),
808
870
  crossCheckCache: (op, cacheKey, entry, build, collectedParams) => this.crossCheckCache(op, cacheKey, entry, build, collectedParams),
809
- jsonEncoding: this.jsonEncoding,
871
+ // LIVE getter, not a copied value: `jsonEncoding` is now a per-query
872
+ // option, so the module-facing view must see the encoding of the query
873
+ // being built rather than the one the client was constructed with. Same
874
+ // shape and same reason as `currentSkip` above.
875
+ get jsonEncoding() {
876
+ return self.currentJsonEncoding;
877
+ },
810
878
  camelDateFieldCache: this.camelDateFieldCache,
811
879
  relationEntryCache: this.relationEntryCache,
812
880
  limitOneClause: () => this.limitOneClause(),
@@ -1005,6 +1073,35 @@ class QueryInterface {
1005
1073
  resolveStableOrder(argFlag) {
1006
1074
  return argFlag ?? this.stableRelationOrder;
1007
1075
  }
1076
+ /**
1077
+ * Resolve one query's relation JSON encoding and PIN it for the build, so
1078
+ * every reader inside relations.ts sees the same answer.
1079
+ *
1080
+ * Called at the TOP of each entry point that can emit or decode relation JSON
1081
+ * (`buildFindMany`, `buildFindUnique`, `makeStreamRowParser`), before the
1082
+ * cache key is assembled and before the flatten plan is consulted, because
1083
+ * both of those depend on the answer.
1084
+ *
1085
+ * An unrecognized value THROWS (E003) rather than falling back. A per-query
1086
+ * option that is silently ignored when misspelled is the exact failure this
1087
+ * package has been bitten by before (see query/option-surface.ts), and here it
1088
+ * would be invisible: the wrong encoding still returns correct rows, just
1089
+ * without the saving the caller asked for, or with the flatten plan they were
1090
+ * trying to re-enable still refused. Thrown before the SQL cache is consulted
1091
+ * so a warm template can never serve a call the cold path would refuse.
1092
+ */
1093
+ resolveJsonEncoding(argEncoding) {
1094
+ if (argEncoding === undefined) {
1095
+ this.currentJsonEncoding = this.jsonEncoding;
1096
+ return this.currentJsonEncoding;
1097
+ }
1098
+ if (!JSON_ENCODINGS.includes(argEncoding)) {
1099
+ throw new errors_js_1.ValidationError(`[turbine] Invalid \`jsonEncoding\` on "${this.table}": ${JSON.stringify(argEncoding)}. ` +
1100
+ `Expected ${JSON_ENCODINGS.map((e) => `'${e}'`).join(' or ')}.`);
1101
+ }
1102
+ this.currentJsonEncoding = argEncoding;
1103
+ return argEncoding;
1104
+ }
1008
1105
  /**
1009
1106
  * Fill a PK-ascending `orderBy` into every to-many `with` relation that has no
1010
1107
  * explicit one, recursing into nested `with`. Returns a CLONED clause (user
@@ -2117,6 +2214,8 @@ class QueryInterface {
2117
2214
  }
2118
2215
  buildFindUnique(args) {
2119
2216
  this.currentSkip = (0, types_js_1.resolveSkipGlobalFilters)(args.skipGlobalFilters);
2217
+ // Pinned before the cache key, which carries it (see buildFindMany).
2218
+ const jsonEncoding = this.resolveJsonEncoding(args.jsonEncoding);
2120
2219
  // Prisma compound-unique selector expansion (before global-filter merge and
2121
2220
  // fingerprinting, so the cache only ever sees the canonical expanded where).
2122
2221
  args = maybeExpandCompoundUnique(this.tableMeta, args);
@@ -2170,7 +2269,10 @@ class QueryInterface {
2170
2269
  // See buildFindMany: `includePii` is its own cache-key segment so a no-PII
2171
2270
  // statement can never serve an `includePii` call (relation projections that
2172
2271
  // `withFp` does not capture also flip on it).
2173
- const ck = `fu:${whereFingerprint}|c=${colKey}|w=${withFp}|pii=${includePii ? 1 : 0}${this.globalFilterCacheSegment()}`;
2272
+ // `|je=`: same rule and same hazard as in buildFindMany. findUnique is never
2273
+ // flatten-planned, so this is the only plan-shape segment it needs.
2274
+ const encodingFp = `|je=${jsonEncoding === 'positional' ? 'p' : 'o'}`;
2275
+ const ck = `fu:${whereFingerprint}|c=${colKey}|w=${withFp}|pii=${includePii ? 1 : 0}${encodingFp}${this.globalFilterCacheSegment()}`;
2174
2276
  const params = [];
2175
2277
  // Check if all where values are simple (plain equality, no operators/null/OR).
2176
2278
  // Keys are sorted to match fingerprintWhere, insertion order here would let
@@ -2455,6 +2557,8 @@ class QueryInterface {
2455
2557
  }
2456
2558
  buildFindMany(args) {
2457
2559
  this.currentSkip = (0, types_js_1.resolveSkipGlobalFilters)(args?.skipGlobalFilters);
2560
+ // Pinned before the flatten plan and the cache key, both of which read it.
2561
+ const jsonEncoding = this.resolveJsonEncoding(args?.jsonEncoding);
2458
2562
  // Stable relation order (opt-in): fill PK-asc orderBy into unordered to-many
2459
2563
  // relations BEFORE fingerprinting, so the two orderings get distinct cache
2460
2564
  // entries and every downstream path (SQL build, collect, parser) inherits it.
@@ -2603,7 +2707,19 @@ class QueryInterface {
2603
2707
  // key is byte-identical to before.
2604
2708
  const flattenPlan = this.planFlatten(args, includePii);
2605
2709
  const flattenFp = flattenPlan ? `|fl=${flattenPlan.signature}` : '';
2606
- 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()}`;
2710
+ // `jsonEncoding` decides whether every relation subquery emits
2711
+ // `json_build_object` or `json_build_array`, and the parser built alongside
2712
+ // the statement decodes exactly one of those two. It is invisible to
2713
+ // `withFp` (which fingerprints the `with` SHAPE, not its rendering), so it
2714
+ // needs its own segment for the same reason `pii=` and `fl=` do, and the
2715
+ // consequence of getting it wrong is worse than either: a positional
2716
+ // template served to an object-planned call hands `parseNestedRow` bare
2717
+ // arrays where it expects keyed objects, which is silent data corruption
2718
+ // rather than an error. Emitted for BOTH values rather than only the
2719
+ // non-default one, so the key never depends on what the client default
2720
+ // happens to be.
2721
+ const encodingFp = `|je=${jsonEncoding === 'positional' ? 'p' : 'o'}`;
2722
+ 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()}`;
2607
2723
  const params = [];
2608
2724
  const buildSql = (freshParams) => {
2609
2725
  // Fresh build: generates SQL and populates freshParams
@@ -2780,58 +2896,66 @@ class QueryInterface {
2780
2896
  };
2781
2897
  }
2782
2898
  // -------------------------------------------------------------------------
2783
- // findManyStream, async iterable using PostgreSQL cursors
2899
+ // findManyStream / findManyStreamBatches, async iterables using PostgreSQL cursors
2784
2900
  // -------------------------------------------------------------------------
2785
2901
  /**
2786
- * Stream rows from a findMany query using PostgreSQL cursors.
2787
- * Returns an AsyncIterable that yields individual rows, fetching in batches internally.
2902
+ * Build the one row parser a whole drain uses.
2788
2903
  *
2789
- * **Speculative fast-path:** Before opening a cursor, issues a single
2790
- * `SELECT ... LIMIT batchSize+1`. If the result fits within `batchSize`,
2791
- * all rows are yielded immediately with zero cursor overhead (no BEGIN /
2792
- * DECLARE / CLOSE / COMMIT). Only when the result overflows does the
2793
- * method fall back to the full cursor path.
2904
+ * Shared by {@link findManyStream} and {@link findManyStreamBatches} so the
2905
+ * two can never disagree about a row's SHAPE while agreeing about its
2906
+ * contents: the flatten plan, the positional/object relation decode and the
2907
+ * PII projection are all decided here, once, before a statement is issued.
2794
2908
  *
2795
- * **Cursor path:** Uses DECLARE CURSOR within a dedicated transaction on a
2796
- * single pooled connection. The cursor is CLOSEd (in the dialect's `finally`)
2797
- * and the connection released both when iteration completes normally and when
2798
- * it ends early (`break` from `for await`). An error mid-stream skips the
2799
- * CLOSE and rolls back instead, which drops the cursor with the transaction.
2909
+ * The plan is a pure function of the schema, the `with` shape and
2910
+ * `includePii`, never of `limit`, so the batch-size override the speculative
2911
+ * fetch applies cannot change it and the parser always matches the emitted
2912
+ * SQL. Reading the raw `includePii` sentinel with `=== true` here would have
2913
+ * quietly planned a no-PII parser over a with-PII statement, which is why it
2914
+ * goes through `resolveUnsafeFlag` on every path, `with` clause or not.
2915
+ */
2916
+ makeStreamRowParser(args) {
2917
+ const streamPii = (0, types_js_1.resolveUnsafeFlag)(args?.includePii, 'includePii');
2918
+ // THIS CALL IS ORDER-CRITICAL, and it is why the resolution is a method
2919
+ // rather than an expression inlined into the two build sites. The parser is
2920
+ // built HERE, before `streamRaw` reaches `buildFindMany` and pins the
2921
+ // encoding itself, so without this the parser would be planned against the
2922
+ // CLIENT default while the statement was built against the query's
2923
+ // override: positional SQL decoded as objects, or the reverse. Both build
2924
+ // from the same `args`, so both resolve to the same value; the invalid-value
2925
+ // throw also lands here first, before a connection is taken.
2926
+ this.resolveJsonEncoding(args?.jsonEncoding);
2927
+ if (!args?.with) {
2928
+ const table = this.table;
2929
+ return (row) => this.parseRow(row, table);
2930
+ }
2931
+ const streamFlattenPlan = this.planFlatten(args, streamPii);
2932
+ return this.makeNestedParser(args.with, streamPii, streamFlattenPlan);
2933
+ }
2934
+ /**
2935
+ * Everything a stream does to the DATABASE, and nothing it does to a row.
2800
2936
  *
2801
- * **Snapshot semantics note:** Outside a transaction the speculative
2802
- * fast-path runs unwrapped, and an overflow opens the cursor in its own
2803
- * transaction, so the two fetches span two separate snapshots. Wrapping the
2804
- * call in `$transaction` gives strict single-snapshot semantics: both the
2805
- * speculative fetch and the cursor then run on the caller's connection
2806
- * inside the caller's transaction (the cursor path issues no BEGIN/COMMIT of
2807
- * its own and releases nothing, so the caller's transaction is intact when
2808
- * iteration finishes).
2937
+ * {@link findManyStream} and {@link findManyStreamBatches} are the same drain
2938
+ * handed out at two granularities, so the statement they issue, when the
2939
+ * cursor opens, and how the connection is released all live here once. Only
2940
+ * the yielding differs, which is the entire point of having both.
2809
2941
  *
2810
- * @example
2811
- * ```ts
2812
- * for await (const user of db.users.findManyStream({ where: { orgId: 1 }, batchSize: 500 })) {
2813
- * process.stdout.write(`${user.email}\n`);
2814
- * }
2815
- * ```
2942
+ * Parsing deliberately stays in the callers: they parse at different
2943
+ * granularities, and the per-row method must keep parsing LAZILY, one row at
2944
+ * a time, exactly as it always has. Hoisting the parse in here would make a
2945
+ * consumer that breaks after the first row pay for the rest of its batch.
2946
+ *
2947
+ * `action` is the tag query events carry. It is a parameter rather than a
2948
+ * constant so each public method reports its own name instead of the name of
2949
+ * whichever one happens to be implemented over the other.
2950
+ *
2951
+ * An EMPTY batch is never yielded: the dialect's cursor loop breaks on a
2952
+ * zero-row FETCH, and the speculative path below yields nothing at all when
2953
+ * the result set is empty. Callers may therefore treat a yielded batch as
2954
+ * non-empty.
2816
2955
  */
2817
- async *findManyStream(args) {
2956
+ async *streamRaw(args, action) {
2818
2957
  const batchSize = Math.max(1, Math.floor(Number(args?.batchSize ?? 1000)));
2819
- const hasRelations = !!args?.with;
2820
- // Build the positional-aware relation parser once for the whole stream.
2821
- // Same flatten plan buildFindMany compiles below. The plan is a pure
2822
- // function of the schema, the `with` shape and `includePii`, never of
2823
- // `limit`, so the batch-size override the speculative fetch applies cannot
2824
- // change it, and the stream's parser matches the emitted SQL.
2825
- // Resolved once for the whole stream: the flatten plan and the row parser
2826
- // MUST agree with the SQL buildFindMany emits below, and reading the raw
2827
- // sentinel with `=== true` here would have quietly planned a no-PII parser
2828
- // over a with-PII statement.
2829
- const streamPii = (0, types_js_1.resolveUnsafeFlag)(args?.includePii, 'includePii');
2830
- const streamFlattenPlan = hasRelations ? this.planFlatten(args, streamPii) : null;
2831
- const parseWith = hasRelations
2832
- ? this.makeNestedParser(args.with, streamPii, streamFlattenPlan)
2833
- : null;
2834
- this.currentAction = 'findManyStream';
2958
+ this.currentAction = action;
2835
2959
  // Streaming is ALREADY immune to the generic-plan cliff: the speculative
2836
2960
  // fetch has never passed a prepared name, and the cursor path runs through
2837
2961
  // DECLARE, so neither statement enters the plan cache. `preparedNameFor` is
@@ -2874,10 +2998,9 @@ class QueryInterface {
2874
2998
  });
2875
2999
  const speculativeResult = await this.queryWithTimeout(speculativeDeferred.sql, speculativeDeferred.params, args?.timeout, streamPreparedName);
2876
3000
  if (speculativeResult.rows.length <= batchSize) {
2877
- // Small drain, yield all rows and return, no cursor needed
2878
- for (const row of speculativeResult.rows) {
2879
- yield (parseWith ? parseWith(row) : this.parseRow(row, this.table));
2880
- }
3001
+ // Small drain, hand over the whole result and return, no cursor needed.
3002
+ if (speculativeResult.rows.length > 0)
3003
+ yield speculativeResult.rows;
2881
3004
  return;
2882
3005
  }
2883
3006
  }
@@ -2886,7 +3009,7 @@ class QueryInterface {
2886
3009
  // Acquire a dedicated connection: cursors require a single connection in a
2887
3010
  // transaction. The dialect owns the streaming SQL (Postgres: BEGIN → DECLARE
2888
3011
  // … NO SCROLL CURSOR FOR → FETCH n → CLOSE → COMMIT, ROLLBACK on error); we
2889
- // just parse + yield the row batches it produces.
3012
+ // just yield the row batches it produces.
2890
3013
  //
2891
3014
  // Inside a caller-owned transaction there is nothing to check out: the
2892
3015
  // transaction-scoped pool pins every query to the transaction's own
@@ -2898,13 +3021,9 @@ class QueryInterface {
2898
3021
  query: async (text, values) => (await this.pool.query(text, values)),
2899
3022
  };
2900
3023
  try {
2901
- for await (const batch of this.dialect.openStream(conn, deferred.sql, deferred.params, batchSize, {
3024
+ yield* this.dialect.openStream(conn, deferred.sql, deferred.params, batchSize, {
2902
3025
  ambientTransaction: this.txScoped,
2903
- })) {
2904
- for (const row of batch) {
2905
- yield (parseWith ? parseWith(row) : this.parseRow(row, this.table));
2906
- }
2907
- }
3026
+ });
2908
3027
  }
2909
3028
  catch (err) {
2910
3029
  // Wrap pg constraint errors so streaming surfaces typed errors like the rest of the API
@@ -2914,6 +3033,97 @@ class QueryInterface {
2914
3033
  client?.release();
2915
3034
  }
2916
3035
  }
3036
+ /**
3037
+ * Stream rows from a findMany query using PostgreSQL cursors, one BATCH of
3038
+ * rows at a time.
3039
+ *
3040
+ * The same drain {@link findManyStream} performs, the same statements, the
3041
+ * same cursor, the same rows in the same order, handed out as arrays instead
3042
+ * of one row at a time. That is worth having because the per-row form costs
3043
+ * one promise resolution and one microtask turn PER ROW: measured over 50,000
3044
+ * rows on a local PostgreSQL, per-row yielding costs ~7 ms (~140 ns/row) that
3045
+ * batch yielding does not, which is roughly half of the streaming overhead
3046
+ * over a hand-written cursor loop. Nothing else about the two paths differs,
3047
+ * so the entire saving is the yielding.
3048
+ *
3049
+ * Prefer this whenever the consumer can work on an array; keep
3050
+ * {@link findManyStream} when a row at a time is what the code actually wants,
3051
+ * since flattening a batch by hand costs exactly what it saves.
3052
+ *
3053
+ * A yielded batch is never empty, and its length is NOT a contract: it is at
3054
+ * most `batchSize`, the final batch is usually shorter, and a result set that
3055
+ * fits within one batch arrives as a single array from the speculative fetch
3056
+ * with no cursor involved. Do not use batch boundaries to infer anything
3057
+ * about the data.
3058
+ *
3059
+ * Every other streaming behaviour is shared and documented on
3060
+ * {@link findManyStream}: the speculative fast path, the cursor path and its
3061
+ * cleanup, the snapshot semantics, and early `break`.
3062
+ *
3063
+ * @example
3064
+ * ```ts
3065
+ * for await (const batch of db.users.findManyStreamBatches({ where: { orgId: 1 }, batchSize: 500 })) {
3066
+ * await sink.writeAll(batch);
3067
+ * }
3068
+ * ```
3069
+ */
3070
+ async *findManyStreamBatches(args) {
3071
+ const parse = this.makeStreamRowParser(args);
3072
+ for await (const batch of this.streamRaw(args, 'findManyStreamBatches')) {
3073
+ const parsed = new Array(batch.length);
3074
+ for (let i = 0; i < batch.length; i++) {
3075
+ parsed[i] = parse(batch[i]);
3076
+ }
3077
+ yield parsed;
3078
+ }
3079
+ }
3080
+ /**
3081
+ * Stream rows from a findMany query using PostgreSQL cursors.
3082
+ * Returns an AsyncIterable that yields individual rows, fetching in batches internally.
3083
+ *
3084
+ * See {@link findManyStreamBatches} for the same drain yielded a batch at a
3085
+ * time, which is measurably cheaper when the consumer can take an array.
3086
+ *
3087
+ * **Speculative fast-path:** Before opening a cursor, issues a single
3088
+ * `SELECT ... LIMIT batchSize+1`. If the result fits within `batchSize`,
3089
+ * all rows are yielded immediately with zero cursor overhead (no BEGIN /
3090
+ * DECLARE / CLOSE / COMMIT). Only when the result overflows does the
3091
+ * method fall back to the full cursor path.
3092
+ *
3093
+ * **Cursor path:** Uses DECLARE CURSOR within a dedicated transaction on a
3094
+ * single pooled connection. The cursor is CLOSEd (in the dialect's `finally`)
3095
+ * and the connection released both when iteration completes normally and when
3096
+ * it ends early (`break` from `for await`). An error mid-stream skips the
3097
+ * CLOSE and rolls back instead, which drops the cursor with the transaction.
3098
+ *
3099
+ * **Snapshot semantics note:** Outside a transaction the speculative
3100
+ * fast-path runs unwrapped, and an overflow opens the cursor in its own
3101
+ * transaction, so the two fetches span two separate snapshots. Wrapping the
3102
+ * call in `$transaction` gives strict single-snapshot semantics: both the
3103
+ * speculative fetch and the cursor then run on the caller's connection
3104
+ * inside the caller's transaction (the cursor path issues no BEGIN/COMMIT of
3105
+ * its own and releases nothing, so the caller's transaction is intact when
3106
+ * iteration finishes).
3107
+ *
3108
+ * @example
3109
+ * ```ts
3110
+ * for await (const user of db.users.findManyStream({ where: { orgId: 1 }, batchSize: 500 })) {
3111
+ * process.stdout.write(`${user.email}\n`);
3112
+ * }
3113
+ * ```
3114
+ */
3115
+ async *findManyStream(args) {
3116
+ const parse = this.makeStreamRowParser(args);
3117
+ // Parsed one row at a time, INSIDE the yield loop rather than a batch ahead
3118
+ // of it, so a consumer that breaks early still pays only for the rows it
3119
+ // took. That laziness is the reason this loop is not written over
3120
+ // `findManyStreamBatches`.
3121
+ for await (const batch of this.streamRaw(args, 'findManyStream')) {
3122
+ for (let i = 0; i < batch.length; i++) {
3123
+ yield parse(batch[i]);
3124
+ }
3125
+ }
3126
+ }
2917
3127
  // -------------------------------------------------------------------------
2918
3128
  // findFirst, like findMany but returns a single row or null
2919
3129
  // -------------------------------------------------------------------------
@@ -3337,7 +3547,11 @@ class QueryInterface {
3337
3547
  * strategy, silently and byte-identically):
3338
3548
  * - the resolved strategy is not `'flatten'`;
3339
3549
  * - `jsonEncoding: 'positional'` (a flattened relation emits no JSON at all,
3340
- * so the two encodings are not composed in this version);
3550
+ * so the two are not composed in this version). NOTE that this is the
3551
+ * PostgreSQL DEFAULT, so on Postgres `'flatten'` engages only when the
3552
+ * caller ALSO passes `jsonEncoding: 'object'`. The fallback is silent and
3553
+ * byte-identical, and `warnFlattenBlocked` names the encoding and the
3554
+ * escape hatch in dev;
3341
3555
  * - the dialect owns relation-subquery generation
3342
3556
  * (`dialect.buildRelationSubquery`, i.e. SQL Server's `FOR JSON PATH`);
3343
3557
  * - `distinct` (the `DISTINCT ON` rewrite re-orders in an outer wrapper, and
@@ -3362,8 +3576,9 @@ class QueryInterface {
3362
3576
  // Query-level refusals: the whole plan is off, so name the reason once for
3363
3577
  // the query rather than once per relation (relations.ts warns per relation
3364
3578
  // for the eligibility rules it owns).
3365
- const queryLevelBlock = this.jsonEncoding === 'positional'
3366
- ? "`jsonEncoding: 'positional'` is active, and a flattened relation emits no JSON to encode"
3579
+ const queryLevelBlock = this.currentJsonEncoding === 'positional'
3580
+ ? "`jsonEncoding: 'positional'` is active (the PostgreSQL default), and a flattened relation emits no " +
3581
+ "JSON to encode. Pass `jsonEncoding: 'object'` alongside the strategy to get the flatten plan"
3367
3582
  : this.dialect.buildRelationSubquery
3368
3583
  ? `the ${this.dialect.name} dialect generates relation subqueries itself`
3369
3584
  : args?.distinct && args.distinct.length > 0
@@ -3682,9 +3897,18 @@ class QueryInterface {
3682
3897
  * Build the decode plan for one exact column list, and remember it as this
3683
3898
  * table's most recent shape so the next row of the same result set hits the
3684
3899
  * fast path in {@link parseRow}.
3900
+ *
3901
+ * The shape key's delimiter is NUL because it is the one byte a Postgres
3902
+ * identifier cannot contain, even quoted, so no table or column name can
3903
+ * forge a collision. It MUST be written as the six-character escape, never
3904
+ * as a literal NUL byte: a raw NUL makes byte-oriented tools classify this
3905
+ * file as binary, and `grep` then reports ZERO matches for a term that is
3906
+ * present rather than saying it declined to look. This is the largest file
3907
+ * in the repo, and that silent empty result has already sent more than one
3908
+ * search down the wrong path.
3685
3909
  */
3686
3910
  buildRowDecodePlan(table, meta, keys) {
3687
- const shapeKey = `${table}${keys.join('')}`;
3911
+ const shapeKey = `${table}\u0000${keys.join('\u0000')}`;
3688
3912
  let plan = this.rowPlanCache.get(shapeKey);
3689
3913
  if (plan === undefined) {
3690
3914
  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';
@@ -73,6 +73,10 @@ exports.FIND_UNIQUE_OPTIONS = {
73
73
  skipGlobalFilters: 'native',
74
74
  includePii: 'native',
75
75
  forceCustomPlan: 'native',
76
+ // `'object' | 'positional'`: a wire ENCODING, not a name. It cannot reach the
77
+ // schema's naming space, so THE ONE RULE puts it here rather than in the
78
+ // hand-translated set.
79
+ jsonEncoding: 'native',
76
80
  };
77
81
  exports.FIND_MANY_OPTIONS = {
78
82
  where: 'prisma',
@@ -92,6 +96,8 @@ exports.FIND_MANY_OPTIONS = {
92
96
  warnOnUnlimited: 'native',
93
97
  includePii: 'native',
94
98
  forceCustomPlan: 'native',
99
+ // See FIND_UNIQUE_OPTIONS: an encoding name carries no schema name.
100
+ jsonEncoding: 'native',
95
101
  };
96
102
  exports.FIND_MANY_STREAM_OPTIONS = {
97
103
  ...exports.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