turbine-orm 0.66.0 → 0.67.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.
@@ -268,6 +268,20 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
268
268
  private readonly autoRoundTripMs;
269
269
  /** Nested-relation JSON encoding: 'object' (default) or 'positional'. */
270
270
  private readonly jsonEncoding;
271
+ /**
272
+ * `parseRow` decode plans, keyed by table plus the exact column list. Bounded
273
+ * like the SQL template cache and for the same reason: the shapes come from
274
+ * `select` / `omit`, which is usually a handful of literals in the caller's
275
+ * source but can be assembled per request from user input, so the key space
276
+ * is not provably finite. Eviction only ever costs a rebuild.
277
+ */
278
+ private readonly rowPlanCache;
279
+ /**
280
+ * The most recent plan per table, which is the entry `parseRow` actually
281
+ * probes. One result set is one shape, so this hits for every row after the
282
+ * first without hashing a cache key per row.
283
+ */
284
+ private readonly rowPlanLast;
271
285
  /**
272
286
  * Client-level automatic WHERE filters keyed by table accessor (soft-delete /
273
287
  * multi-tenancy). AND-merged into every query on the keyed table and every
@@ -1230,5 +1244,30 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
1230
1244
  * the bug that made a single reading necessary in the first place.
1231
1245
  */
1232
1246
  private readTemporalInfinity;
1247
+ /**
1248
+ * The decode work for ONE cell of a date-bearing column, extracted so the
1249
+ * planned and unplanned row paths cannot drift apart. `null` and an
1250
+ * already-parsed `Date` are returned untouched, which is the same guard the
1251
+ * pre-plan code spelled inline.
1252
+ */
1253
+ private decodeTemporalCell;
1254
+ /**
1255
+ * Build the decode plan for one exact column list, and remember it as this
1256
+ * table's most recent shape so the next row of the same result set hits the
1257
+ * fast path in {@link parseRow}.
1258
+ */
1259
+ private buildRowDecodePlan;
1260
+ /**
1261
+ * Whether a cached plan describes exactly this row's column list, in order.
1262
+ * A pointer compare per column: both sides are the driver's own interned
1263
+ * column-name strings, so this is far cheaper than the reverse-map lookup and
1264
+ * two Set probes per column that it replaces, and unlike a length check or a
1265
+ * fingerprint it CANNOT accept a different projection that happens to look
1266
+ * similar. That matters more than the speed: a positional plan applied to the
1267
+ * wrong column list would silently write each value under a neighbouring
1268
+ * field's name, which is the one failure mode worth paying a full comparison
1269
+ * to make impossible.
1270
+ */
1271
+ private static rowPlanMatches;
1233
1272
  private parseRow;
1234
1273
  }
@@ -286,6 +286,22 @@ exports.AUTO_TO_ONE_JOIN_ROWS_MAX = 100_000;
286
286
  * it.
287
287
  */
288
288
  exports.AUTO_COUNT_BATCH_MIN_PARENT_ROWS = 2;
289
+ /**
290
+ * How many distinct `parseRow` column shapes one QueryInterface remembers.
291
+ * Deliberately far smaller than the 1,000-entry SQL template cache: a shape is
292
+ * a `select` / `omit` projection rather than a where-clause fingerprint, so the
293
+ * realistic population is single digits per table, and the cost of a miss is
294
+ * one rebuild rather than a re-parse on the server.
295
+ */
296
+ const ROW_PLAN_CACHE_SIZE = 128;
297
+ /**
298
+ * The largest `findManyStream` batch size for which the speculative first fetch
299
+ * is still worth attempting. Equal to the default batch size, so the default
300
+ * and everything below it behaves exactly as it always has. See the comment at
301
+ * the speculation itself for why the bound is on `batchSize` rather than on the
302
+ * probe's own limit.
303
+ */
304
+ const STREAM_SPECULATION_MAX_BATCH = 1000;
289
305
  /**
290
306
  * Strict structural equality for a single SQL parameter value. Handles the
291
307
  * value shapes Turbine binds: primitives (incl. `NaN` and `bigint`), `null`/
@@ -515,6 +531,20 @@ class QueryInterface {
515
531
  autoRoundTripMs;
516
532
  /** Nested-relation JSON encoding: 'object' (default) or 'positional'. */
517
533
  jsonEncoding;
534
+ /**
535
+ * `parseRow` decode plans, keyed by table plus the exact column list. Bounded
536
+ * like the SQL template cache and for the same reason: the shapes come from
537
+ * `select` / `omit`, which is usually a handful of literals in the caller's
538
+ * source but can be assembled per request from user input, so the key space
539
+ * is not provably finite. Eviction only ever costs a rebuild.
540
+ */
541
+ rowPlanCache = new utils_js_1.LRUCache(ROW_PLAN_CACHE_SIZE);
542
+ /**
543
+ * The most recent plan per table, which is the entry `parseRow` actually
544
+ * probes. One result set is one shape, so this hits for every row after the
545
+ * first without hashing a cache key per row.
546
+ */
547
+ rowPlanLast = new Map();
518
548
  /**
519
549
  * Client-level automatic WHERE filters keyed by table accessor (soft-delete /
520
550
  * multi-tenancy). AND-merged into every query on the keyed table and every
@@ -2792,27 +2822,57 @@ class QueryInterface {
2792
2822
  const parseWith = hasRelations
2793
2823
  ? this.makeNestedParser(args.with, streamPii, streamFlattenPlan)
2794
2824
  : null;
2795
- // --- Speculative first fetch: try to satisfy the entire drain in one RTT ---
2796
- const speculativeDeferred = this.buildFindMany({
2797
- ...args,
2798
- limit: batchSize + 1,
2799
- });
2800
2825
  this.currentAction = 'findManyStream';
2801
2826
  // Streaming is ALREADY immune to the generic-plan cliff: the speculative
2802
2827
  // fetch has never passed a prepared name, and the cursor path runs through
2803
2828
  // DECLARE, so neither statement enters the plan cache. `preparedNameFor` is
2804
- // still called with no name so that `forceCustomPlan: true` is VALIDATED on
2829
+ // still called, with no name, so that `forceCustomPlan: true` is VALIDATED on
2805
2830
  // an engine that cannot honour it here either, rather than being quietly
2806
- // satisfied by an accident of this code path.
2807
- const speculativeResult = await this.queryWithTimeout(speculativeDeferred.sql, speculativeDeferred.params, args?.timeout, this.preparedNameFor(args, undefined));
2808
- if (speculativeResult.rows.length <= batchSize) {
2809
- // Small drain, yield all rows and return, no cursor needed
2810
- for (const row of speculativeResult.rows) {
2811
- yield (parseWith ? parseWith(row) : this.parseRow(row, this.table));
2831
+ // satisfied by an accident of this code path. It is called OUTSIDE the
2832
+ // speculation branch below: it used to be the speculative fetch's own
2833
+ // argument, which would have made that validation conditional on a batch
2834
+ // size the moment the speculation became conditional.
2835
+ const streamPreparedName = this.preparedNameFor(args, undefined);
2836
+ // --- Speculative first fetch: try to satisfy the entire drain in one RTT ---
2837
+ //
2838
+ // A drain that fits in one batch needs no cursor, and skipping it saves the
2839
+ // four extra round trips BEGIN / DECLARE / CLOSE / COMMIT cost. That is the
2840
+ // whole point of this fetch, and it is a good trade while the statement is
2841
+ // cheap to throw away.
2842
+ //
2843
+ // On OVERFLOW it is thrown away: these rows were read outside the cursor's
2844
+ // transaction, so yielding them and then continuing from the cursor would
2845
+ // splice two snapshots together, and resuming past them would need an
2846
+ // ORDER BY the caller never asked for. Both are unsound, so the cursor
2847
+ // re-reads from row one and this fetch cost `batchSize + 1` rows for
2848
+ // nothing.
2849
+ //
2850
+ // That waste is proportional to `batchSize`, which is the one number the
2851
+ // caller raises when they expect MORE rows, so the optimization used to get
2852
+ // most expensive exactly where it was least likely to pay off: raising
2853
+ // `batchSize` for throughput made every large drain slower, and the stream
2854
+ // was the only thing here that got slower as `batchSize` went up. So the
2855
+ // speculation is now bounded BY `batchSize` rather than scaled by it. At or
2856
+ // below the default it is unchanged; above it, a caller asking for large
2857
+ // batches has told us not to expect a one-batch result, and we go straight
2858
+ // to the cursor and waste nothing. The cost of being wrong about that is
2859
+ // four round trips on a drain that would have fit, never any transferred
2860
+ // rows.
2861
+ if (batchSize <= STREAM_SPECULATION_MAX_BATCH) {
2862
+ const speculativeDeferred = this.buildFindMany({
2863
+ ...args,
2864
+ limit: batchSize + 1,
2865
+ });
2866
+ const speculativeResult = await this.queryWithTimeout(speculativeDeferred.sql, speculativeDeferred.params, args?.timeout, streamPreparedName);
2867
+ if (speculativeResult.rows.length <= batchSize) {
2868
+ // Small drain, yield all rows and return, no cursor needed
2869
+ for (const row of speculativeResult.rows) {
2870
+ yield (parseWith ? parseWith(row) : this.parseRow(row, this.table));
2871
+ }
2872
+ return;
2812
2873
  }
2813
- return;
2814
2874
  }
2815
- // --- Overflow: fall back to cursor path from scratch ---
2875
+ // --- Overflow, or speculation declined: cursor path from scratch ---
2816
2876
  const deferred = this.buildFindMany(args);
2817
2877
  // Acquire a dedicated connection: cursors require a single connection in a
2818
2878
  // transaction. The dialect owns the streaming SQL (Postgres: BEGIN → DECLARE
@@ -3564,68 +3624,123 @@ class QueryInterface {
3564
3624
  return value;
3565
3625
  return value === '-infinity' ? Number.NEGATIVE_INFINITY : Number.POSITIVE_INFINITY;
3566
3626
  }
3567
- parseRow(row, table) {
3568
- const parsed = {};
3569
- const meta = this.schema.tables[table];
3570
- if (meta) {
3571
- // Fast path: use pre-computed maps (avoids regex per column per row)
3627
+ /**
3628
+ * The decode work for ONE cell of a date-bearing column, extracted so the
3629
+ * planned and unplanned row paths cannot drift apart. `null` and an
3630
+ * already-parsed `Date` are returned untouched, which is the same guard the
3631
+ * pre-plan code spelled inline.
3632
+ */
3633
+ decodeTemporalCell(value, table, field) {
3634
+ if (value === null || value instanceof Date)
3635
+ return value;
3636
+ if ((0, utils_js_1.isTemporalInfinity)(value)) {
3637
+ // Postgres `infinity` / `-infinity`. No JS Date means either, so both
3638
+ // readings cost something and the default is the one that is not lossy.
3639
+ // `'preserve'` hands back the JS number, which breaks the declared `Date`
3640
+ // type at runtime (`.toISOString()` throws) and still serializes as null
3641
+ // because JSON has no infinity literal, but binds straight back, so a
3642
+ // read-modify-write stores `infinity` again. `'null'` reads nicer and
3643
+ // DESTROYS the value on that same write, because a stored infinity and a
3644
+ // stored NULL become indistinguishable. Whichever is configured, it is
3645
+ // the SAME on every read strategy: the driver hands back the number,
3646
+ // `json_build_object` hands back the string "infinity", and both land
3647
+ // here (see `isTemporalInfinity`).
3648
+ //
3649
+ // The warning fires once per column when the option was left unset, on a
3650
+ // row that actually held an infinity, and describes the reading in force
3651
+ // rather than gating on which one it is.
3652
+ this.warnTemporalInfinity(table, field);
3653
+ return this.readTemporalInfinity(value);
3654
+ }
3655
+ if (Array.isArray(value)) {
3656
+ // `dateColumns` includes array-of-date columns (`date[]`, `timestamp[]`,
3657
+ // `timestamptz[]`), for which the driver already hands back a `Date[]`.
3658
+ // Coercing the array itself ran `new Date(String(theArray))` and replaced
3659
+ // the whole column with one Invalid Date. Its ELEMENTS get the same
3660
+ // infinity mapping as a scalar (same declared element type, same JSON
3661
+ // rendering); everything else is passed through by identity.
3662
+ return this.mapArrayTemporalInfinity(value, table, field);
3663
+ }
3664
+ // Any other number on a date column is left alone rather than run through
3665
+ // `parseDbDate(String(n))`, which would produce an Invalid Date.
3666
+ if (typeof value === 'number')
3667
+ return value;
3668
+ // Offset-less strings (Postgres `timestamp`, json_agg output) are pinned to
3669
+ // UTC so results don't depend on the server's time zone.
3670
+ return this.utcTimestamps ? (0, utils_js_1.parseDbDate)(String(value)) : new Date(value);
3671
+ }
3672
+ /**
3673
+ * Build the decode plan for one exact column list, and remember it as this
3674
+ * table's most recent shape so the next row of the same result set hits the
3675
+ * fast path in {@link parseRow}.
3676
+ */
3677
+ buildRowDecodePlan(table, meta, keys) {
3678
+ const shapeKey = `${table}${keys.join('')}`;
3679
+ let plan = this.rowPlanCache.get(shapeKey);
3680
+ if (plan === undefined) {
3572
3681
  const reverseMap = meta.reverseColumnMap;
3573
3682
  const dateCols = meta.dateColumns;
3574
3683
  // camelCase-keyed date fields, so nested json_build_object rows (whose
3575
3684
  // keys are already camelCase) get the same Date coercion as top-level rows.
3576
3685
  const camelDateFields = this.getCamelDateFields(table, meta);
3577
- const keys = Object.keys(row);
3578
- for (let i = 0; i < keys.length; i++) {
3579
- const col = keys[i];
3580
- const value = row[col];
3686
+ const cols = keys.slice();
3687
+ const fields = [];
3688
+ const dates = [];
3689
+ for (const col of cols) {
3581
3690
  const field = reverseMap[col] ?? col; // fall back to raw col name, not regex
3582
- // Top-level rows are snake_case (dateCols); nested rows are camelCase (camelDateFields).
3583
- if ((dateCols.has(col) || camelDateFields.has(field)) && value !== null && !(value instanceof Date)) {
3584
- if ((0, utils_js_1.isTemporalInfinity)(value)) {
3585
- // Postgres `infinity` / `-infinity`. No JS Date means either, so
3586
- // both readings cost something and the default is the one that is
3587
- // not lossy. `'preserve'` hands back the JS number, which breaks
3588
- // the declared `Date` type at runtime (`.toISOString()` throws) and
3589
- // still serializes as null because JSON has no infinity literal,
3590
- // but binds straight back, so a read-modify-write stores `infinity`
3591
- // again. `'null'` reads nicer and DESTROYS the value on that same
3592
- // write, because a stored infinity and a stored NULL become
3593
- // indistinguishable. Whichever is configured, it is the SAME on
3594
- // every read strategy: the driver hands back the number,
3595
- // `json_build_object` hands back the string "infinity", and both
3596
- // land here (see `isTemporalInfinity`).
3597
- //
3598
- // The warning below fires once per column when the option was left
3599
- // unset, on a row that actually held an infinity, and describes the
3600
- // reading in force rather than gating on which one it is.
3601
- this.warnTemporalInfinity(table, field);
3602
- parsed[field] = this.readTemporalInfinity(value);
3603
- }
3604
- else if (Array.isArray(value)) {
3605
- // `dateColumns` includes array-of-date columns (`date[]`,
3606
- // `timestamp[]`, `timestamptz[]`), for which the driver already
3607
- // hands back a `Date[]`. Coercing the array itself ran
3608
- // `new Date(String(theArray))` and replaced the whole column with
3609
- // one Invalid Date. Its ELEMENTS get the same infinity mapping as a
3610
- // scalar (same declared element type, same JSON rendering);
3611
- // everything else is passed through by identity.
3612
- parsed[field] = this.mapArrayTemporalInfinity(value, table, field);
3613
- }
3614
- else if (typeof value === 'number') {
3615
- // Any other number on a date column is left alone rather than run
3616
- // through `parseDbDate(String(n))`, which would produce an Invalid
3617
- // Date.
3618
- parsed[field] = value;
3619
- }
3620
- else {
3621
- // Offset-less strings (Postgres `timestamp`, json_agg output) are
3622
- // pinned to UTC so results don't depend on the server's time zone.
3623
- parsed[field] = this.utcTimestamps ? (0, utils_js_1.parseDbDate)(String(value)) : new Date(value);
3624
- }
3625
- }
3626
- else {
3627
- parsed[field] = value;
3628
- }
3691
+ fields.push(field);
3692
+ // Top-level rows are snake_case (dateCols); nested rows are camelCase.
3693
+ dates.push(dateCols.has(col) || camelDateFields.has(field));
3694
+ }
3695
+ plan = { cols, fields, dates };
3696
+ this.rowPlanCache.set(shapeKey, plan);
3697
+ }
3698
+ this.rowPlanLast.set(table, plan);
3699
+ return plan;
3700
+ }
3701
+ /**
3702
+ * Whether a cached plan describes exactly this row's column list, in order.
3703
+ * A pointer compare per column: both sides are the driver's own interned
3704
+ * column-name strings, so this is far cheaper than the reverse-map lookup and
3705
+ * two Set probes per column that it replaces, and unlike a length check or a
3706
+ * fingerprint it CANNOT accept a different projection that happens to look
3707
+ * similar. That matters more than the speed: a positional plan applied to the
3708
+ * wrong column list would silently write each value under a neighbouring
3709
+ * field's name, which is the one failure mode worth paying a full comparison
3710
+ * to make impossible.
3711
+ */
3712
+ static rowPlanMatches(plan, keys) {
3713
+ const cols = plan.cols;
3714
+ if (cols.length !== keys.length)
3715
+ return false;
3716
+ for (let i = 0; i < keys.length; i++) {
3717
+ if (cols[i] !== keys[i])
3718
+ return false;
3719
+ }
3720
+ return true;
3721
+ }
3722
+ parseRow(row, table) {
3723
+ const parsed = {};
3724
+ const meta = this.schema.tables[table];
3725
+ if (meta) {
3726
+ // Every row of one result set has the same columns in the same order (a
3727
+ // SQL result set has fixed field descriptors, and a nested row decoded
3728
+ // from `json_build_object` has a fixed key list), so the per-column
3729
+ // name resolution and date-column membership tests are the same answer
3730
+ // recomputed for every row. Resolve them ONCE per column shape and keep
3731
+ // the plan; the shape is verified against each row rather than assumed,
3732
+ // so a caller that does hand this function heterogeneous rows gets a
3733
+ // rebuilt plan instead of a mis-mapped one.
3734
+ const keys = Object.keys(row);
3735
+ const last = this.rowPlanLast.get(table);
3736
+ const plan = last !== undefined && QueryInterface.rowPlanMatches(last, keys)
3737
+ ? last
3738
+ : this.buildRowDecodePlan(table, meta, keys);
3739
+ const { cols, fields, dates } = plan;
3740
+ for (let i = 0; i < cols.length; i++) {
3741
+ const field = fields[i];
3742
+ const value = row[cols[i]];
3743
+ parsed[field] = dates[i] ? this.decodeTemporalCell(value, table, field) : value;
3629
3744
  }
3630
3745
  }
3631
3746
  else {
@@ -268,6 +268,20 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
268
268
  private readonly autoRoundTripMs;
269
269
  /** Nested-relation JSON encoding: 'object' (default) or 'positional'. */
270
270
  private readonly jsonEncoding;
271
+ /**
272
+ * `parseRow` decode plans, keyed by table plus the exact column list. Bounded
273
+ * like the SQL template cache and for the same reason: the shapes come from
274
+ * `select` / `omit`, which is usually a handful of literals in the caller's
275
+ * source but can be assembled per request from user input, so the key space
276
+ * is not provably finite. Eviction only ever costs a rebuild.
277
+ */
278
+ private readonly rowPlanCache;
279
+ /**
280
+ * The most recent plan per table, which is the entry `parseRow` actually
281
+ * probes. One result set is one shape, so this hits for every row after the
282
+ * first without hashing a cache key per row.
283
+ */
284
+ private readonly rowPlanLast;
271
285
  /**
272
286
  * Client-level automatic WHERE filters keyed by table accessor (soft-delete /
273
287
  * multi-tenancy). AND-merged into every query on the keyed table and every
@@ -1230,5 +1244,30 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
1230
1244
  * the bug that made a single reading necessary in the first place.
1231
1245
  */
1232
1246
  private readTemporalInfinity;
1247
+ /**
1248
+ * The decode work for ONE cell of a date-bearing column, extracted so the
1249
+ * planned and unplanned row paths cannot drift apart. `null` and an
1250
+ * already-parsed `Date` are returned untouched, which is the same guard the
1251
+ * pre-plan code spelled inline.
1252
+ */
1253
+ private decodeTemporalCell;
1254
+ /**
1255
+ * Build the decode plan for one exact column list, and remember it as this
1256
+ * table's most recent shape so the next row of the same result set hits the
1257
+ * fast path in {@link parseRow}.
1258
+ */
1259
+ private buildRowDecodePlan;
1260
+ /**
1261
+ * Whether a cached plan describes exactly this row's column list, in order.
1262
+ * A pointer compare per column: both sides are the driver's own interned
1263
+ * column-name strings, so this is far cheaper than the reverse-map lookup and
1264
+ * two Set probes per column that it replaces, and unlike a length check or a
1265
+ * fingerprint it CANNOT accept a different projection that happens to look
1266
+ * similar. That matters more than the speed: a positional plan applied to the
1267
+ * wrong column list would silently write each value under a neighbouring
1268
+ * field's name, which is the one failure mode worth paying a full comparison
1269
+ * to make impossible.
1270
+ */
1271
+ private static rowPlanMatches;
1233
1272
  private parseRow;
1234
1273
  }
@@ -248,6 +248,22 @@ export const AUTO_TO_ONE_JOIN_ROWS_MAX = 100_000;
248
248
  * it.
249
249
  */
250
250
  export const AUTO_COUNT_BATCH_MIN_PARENT_ROWS = 2;
251
+ /**
252
+ * How many distinct `parseRow` column shapes one QueryInterface remembers.
253
+ * Deliberately far smaller than the 1,000-entry SQL template cache: a shape is
254
+ * a `select` / `omit` projection rather than a where-clause fingerprint, so the
255
+ * realistic population is single digits per table, and the cost of a miss is
256
+ * one rebuild rather than a re-parse on the server.
257
+ */
258
+ const ROW_PLAN_CACHE_SIZE = 128;
259
+ /**
260
+ * The largest `findManyStream` batch size for which the speculative first fetch
261
+ * is still worth attempting. Equal to the default batch size, so the default
262
+ * and everything below it behaves exactly as it always has. See the comment at
263
+ * the speculation itself for why the bound is on `batchSize` rather than on the
264
+ * probe's own limit.
265
+ */
266
+ const STREAM_SPECULATION_MAX_BATCH = 1000;
251
267
  /**
252
268
  * Strict structural equality for a single SQL parameter value. Handles the
253
269
  * value shapes Turbine binds: primitives (incl. `NaN` and `bigint`), `null`/
@@ -477,6 +493,20 @@ export class QueryInterface {
477
493
  autoRoundTripMs;
478
494
  /** Nested-relation JSON encoding: 'object' (default) or 'positional'. */
479
495
  jsonEncoding;
496
+ /**
497
+ * `parseRow` decode plans, keyed by table plus the exact column list. Bounded
498
+ * like the SQL template cache and for the same reason: the shapes come from
499
+ * `select` / `omit`, which is usually a handful of literals in the caller's
500
+ * source but can be assembled per request from user input, so the key space
501
+ * is not provably finite. Eviction only ever costs a rebuild.
502
+ */
503
+ rowPlanCache = new LRUCache(ROW_PLAN_CACHE_SIZE);
504
+ /**
505
+ * The most recent plan per table, which is the entry `parseRow` actually
506
+ * probes. One result set is one shape, so this hits for every row after the
507
+ * first without hashing a cache key per row.
508
+ */
509
+ rowPlanLast = new Map();
480
510
  /**
481
511
  * Client-level automatic WHERE filters keyed by table accessor (soft-delete /
482
512
  * multi-tenancy). AND-merged into every query on the keyed table and every
@@ -2754,27 +2784,57 @@ export class QueryInterface {
2754
2784
  const parseWith = hasRelations
2755
2785
  ? this.makeNestedParser(args.with, streamPii, streamFlattenPlan)
2756
2786
  : null;
2757
- // --- Speculative first fetch: try to satisfy the entire drain in one RTT ---
2758
- const speculativeDeferred = this.buildFindMany({
2759
- ...args,
2760
- limit: batchSize + 1,
2761
- });
2762
2787
  this.currentAction = 'findManyStream';
2763
2788
  // Streaming is ALREADY immune to the generic-plan cliff: the speculative
2764
2789
  // fetch has never passed a prepared name, and the cursor path runs through
2765
2790
  // DECLARE, so neither statement enters the plan cache. `preparedNameFor` is
2766
- // still called with no name so that `forceCustomPlan: true` is VALIDATED on
2791
+ // still called, with no name, so that `forceCustomPlan: true` is VALIDATED on
2767
2792
  // an engine that cannot honour it here either, rather than being quietly
2768
- // satisfied by an accident of this code path.
2769
- const speculativeResult = await this.queryWithTimeout(speculativeDeferred.sql, speculativeDeferred.params, args?.timeout, this.preparedNameFor(args, undefined));
2770
- if (speculativeResult.rows.length <= batchSize) {
2771
- // Small drain, yield all rows and return, no cursor needed
2772
- for (const row of speculativeResult.rows) {
2773
- yield (parseWith ? parseWith(row) : this.parseRow(row, this.table));
2793
+ // satisfied by an accident of this code path. It is called OUTSIDE the
2794
+ // speculation branch below: it used to be the speculative fetch's own
2795
+ // argument, which would have made that validation conditional on a batch
2796
+ // size the moment the speculation became conditional.
2797
+ const streamPreparedName = this.preparedNameFor(args, undefined);
2798
+ // --- Speculative first fetch: try to satisfy the entire drain in one RTT ---
2799
+ //
2800
+ // A drain that fits in one batch needs no cursor, and skipping it saves the
2801
+ // four extra round trips BEGIN / DECLARE / CLOSE / COMMIT cost. That is the
2802
+ // whole point of this fetch, and it is a good trade while the statement is
2803
+ // cheap to throw away.
2804
+ //
2805
+ // On OVERFLOW it is thrown away: these rows were read outside the cursor's
2806
+ // transaction, so yielding them and then continuing from the cursor would
2807
+ // splice two snapshots together, and resuming past them would need an
2808
+ // ORDER BY the caller never asked for. Both are unsound, so the cursor
2809
+ // re-reads from row one and this fetch cost `batchSize + 1` rows for
2810
+ // nothing.
2811
+ //
2812
+ // That waste is proportional to `batchSize`, which is the one number the
2813
+ // caller raises when they expect MORE rows, so the optimization used to get
2814
+ // most expensive exactly where it was least likely to pay off: raising
2815
+ // `batchSize` for throughput made every large drain slower, and the stream
2816
+ // was the only thing here that got slower as `batchSize` went up. So the
2817
+ // speculation is now bounded BY `batchSize` rather than scaled by it. At or
2818
+ // below the default it is unchanged; above it, a caller asking for large
2819
+ // batches has told us not to expect a one-batch result, and we go straight
2820
+ // to the cursor and waste nothing. The cost of being wrong about that is
2821
+ // four round trips on a drain that would have fit, never any transferred
2822
+ // rows.
2823
+ if (batchSize <= STREAM_SPECULATION_MAX_BATCH) {
2824
+ const speculativeDeferred = this.buildFindMany({
2825
+ ...args,
2826
+ limit: batchSize + 1,
2827
+ });
2828
+ const speculativeResult = await this.queryWithTimeout(speculativeDeferred.sql, speculativeDeferred.params, args?.timeout, streamPreparedName);
2829
+ if (speculativeResult.rows.length <= batchSize) {
2830
+ // Small drain, yield all rows and return, no cursor needed
2831
+ for (const row of speculativeResult.rows) {
2832
+ yield (parseWith ? parseWith(row) : this.parseRow(row, this.table));
2833
+ }
2834
+ return;
2774
2835
  }
2775
- return;
2776
2836
  }
2777
- // --- Overflow: fall back to cursor path from scratch ---
2837
+ // --- Overflow, or speculation declined: cursor path from scratch ---
2778
2838
  const deferred = this.buildFindMany(args);
2779
2839
  // Acquire a dedicated connection: cursors require a single connection in a
2780
2840
  // transaction. The dialect owns the streaming SQL (Postgres: BEGIN → DECLARE
@@ -3526,68 +3586,123 @@ export class QueryInterface {
3526
3586
  return value;
3527
3587
  return value === '-infinity' ? Number.NEGATIVE_INFINITY : Number.POSITIVE_INFINITY;
3528
3588
  }
3529
- parseRow(row, table) {
3530
- const parsed = {};
3531
- const meta = this.schema.tables[table];
3532
- if (meta) {
3533
- // Fast path: use pre-computed maps (avoids regex per column per row)
3589
+ /**
3590
+ * The decode work for ONE cell of a date-bearing column, extracted so the
3591
+ * planned and unplanned row paths cannot drift apart. `null` and an
3592
+ * already-parsed `Date` are returned untouched, which is the same guard the
3593
+ * pre-plan code spelled inline.
3594
+ */
3595
+ decodeTemporalCell(value, table, field) {
3596
+ if (value === null || value instanceof Date)
3597
+ return value;
3598
+ if (isTemporalInfinity(value)) {
3599
+ // Postgres `infinity` / `-infinity`. No JS Date means either, so both
3600
+ // readings cost something and the default is the one that is not lossy.
3601
+ // `'preserve'` hands back the JS number, which breaks the declared `Date`
3602
+ // type at runtime (`.toISOString()` throws) and still serializes as null
3603
+ // because JSON has no infinity literal, but binds straight back, so a
3604
+ // read-modify-write stores `infinity` again. `'null'` reads nicer and
3605
+ // DESTROYS the value on that same write, because a stored infinity and a
3606
+ // stored NULL become indistinguishable. Whichever is configured, it is
3607
+ // the SAME on every read strategy: the driver hands back the number,
3608
+ // `json_build_object` hands back the string "infinity", and both land
3609
+ // here (see `isTemporalInfinity`).
3610
+ //
3611
+ // The warning fires once per column when the option was left unset, on a
3612
+ // row that actually held an infinity, and describes the reading in force
3613
+ // rather than gating on which one it is.
3614
+ this.warnTemporalInfinity(table, field);
3615
+ return this.readTemporalInfinity(value);
3616
+ }
3617
+ if (Array.isArray(value)) {
3618
+ // `dateColumns` includes array-of-date columns (`date[]`, `timestamp[]`,
3619
+ // `timestamptz[]`), for which the driver already hands back a `Date[]`.
3620
+ // Coercing the array itself ran `new Date(String(theArray))` and replaced
3621
+ // the whole column with one Invalid Date. Its ELEMENTS get the same
3622
+ // infinity mapping as a scalar (same declared element type, same JSON
3623
+ // rendering); everything else is passed through by identity.
3624
+ return this.mapArrayTemporalInfinity(value, table, field);
3625
+ }
3626
+ // Any other number on a date column is left alone rather than run through
3627
+ // `parseDbDate(String(n))`, which would produce an Invalid Date.
3628
+ if (typeof value === 'number')
3629
+ return value;
3630
+ // Offset-less strings (Postgres `timestamp`, json_agg output) are pinned to
3631
+ // UTC so results don't depend on the server's time zone.
3632
+ return this.utcTimestamps ? parseDbDate(String(value)) : new Date(value);
3633
+ }
3634
+ /**
3635
+ * Build the decode plan for one exact column list, and remember it as this
3636
+ * table's most recent shape so the next row of the same result set hits the
3637
+ * fast path in {@link parseRow}.
3638
+ */
3639
+ buildRowDecodePlan(table, meta, keys) {
3640
+ const shapeKey = `${table}${keys.join('')}`;
3641
+ let plan = this.rowPlanCache.get(shapeKey);
3642
+ if (plan === undefined) {
3534
3643
  const reverseMap = meta.reverseColumnMap;
3535
3644
  const dateCols = meta.dateColumns;
3536
3645
  // camelCase-keyed date fields, so nested json_build_object rows (whose
3537
3646
  // keys are already camelCase) get the same Date coercion as top-level rows.
3538
3647
  const camelDateFields = this.getCamelDateFields(table, meta);
3539
- const keys = Object.keys(row);
3540
- for (let i = 0; i < keys.length; i++) {
3541
- const col = keys[i];
3542
- const value = row[col];
3648
+ const cols = keys.slice();
3649
+ const fields = [];
3650
+ const dates = [];
3651
+ for (const col of cols) {
3543
3652
  const field = reverseMap[col] ?? col; // fall back to raw col name, not regex
3544
- // Top-level rows are snake_case (dateCols); nested rows are camelCase (camelDateFields).
3545
- if ((dateCols.has(col) || camelDateFields.has(field)) && value !== null && !(value instanceof Date)) {
3546
- if (isTemporalInfinity(value)) {
3547
- // Postgres `infinity` / `-infinity`. No JS Date means either, so
3548
- // both readings cost something and the default is the one that is
3549
- // not lossy. `'preserve'` hands back the JS number, which breaks
3550
- // the declared `Date` type at runtime (`.toISOString()` throws) and
3551
- // still serializes as null because JSON has no infinity literal,
3552
- // but binds straight back, so a read-modify-write stores `infinity`
3553
- // again. `'null'` reads nicer and DESTROYS the value on that same
3554
- // write, because a stored infinity and a stored NULL become
3555
- // indistinguishable. Whichever is configured, it is the SAME on
3556
- // every read strategy: the driver hands back the number,
3557
- // `json_build_object` hands back the string "infinity", and both
3558
- // land here (see `isTemporalInfinity`).
3559
- //
3560
- // The warning below fires once per column when the option was left
3561
- // unset, on a row that actually held an infinity, and describes the
3562
- // reading in force rather than gating on which one it is.
3563
- this.warnTemporalInfinity(table, field);
3564
- parsed[field] = this.readTemporalInfinity(value);
3565
- }
3566
- else if (Array.isArray(value)) {
3567
- // `dateColumns` includes array-of-date columns (`date[]`,
3568
- // `timestamp[]`, `timestamptz[]`), for which the driver already
3569
- // hands back a `Date[]`. Coercing the array itself ran
3570
- // `new Date(String(theArray))` and replaced the whole column with
3571
- // one Invalid Date. Its ELEMENTS get the same infinity mapping as a
3572
- // scalar (same declared element type, same JSON rendering);
3573
- // everything else is passed through by identity.
3574
- parsed[field] = this.mapArrayTemporalInfinity(value, table, field);
3575
- }
3576
- else if (typeof value === 'number') {
3577
- // Any other number on a date column is left alone rather than run
3578
- // through `parseDbDate(String(n))`, which would produce an Invalid
3579
- // Date.
3580
- parsed[field] = value;
3581
- }
3582
- else {
3583
- // Offset-less strings (Postgres `timestamp`, json_agg output) are
3584
- // pinned to UTC so results don't depend on the server's time zone.
3585
- parsed[field] = this.utcTimestamps ? parseDbDate(String(value)) : new Date(value);
3586
- }
3587
- }
3588
- else {
3589
- parsed[field] = value;
3590
- }
3653
+ fields.push(field);
3654
+ // Top-level rows are snake_case (dateCols); nested rows are camelCase.
3655
+ dates.push(dateCols.has(col) || camelDateFields.has(field));
3656
+ }
3657
+ plan = { cols, fields, dates };
3658
+ this.rowPlanCache.set(shapeKey, plan);
3659
+ }
3660
+ this.rowPlanLast.set(table, plan);
3661
+ return plan;
3662
+ }
3663
+ /**
3664
+ * Whether a cached plan describes exactly this row's column list, in order.
3665
+ * A pointer compare per column: both sides are the driver's own interned
3666
+ * column-name strings, so this is far cheaper than the reverse-map lookup and
3667
+ * two Set probes per column that it replaces, and unlike a length check or a
3668
+ * fingerprint it CANNOT accept a different projection that happens to look
3669
+ * similar. That matters more than the speed: a positional plan applied to the
3670
+ * wrong column list would silently write each value under a neighbouring
3671
+ * field's name, which is the one failure mode worth paying a full comparison
3672
+ * to make impossible.
3673
+ */
3674
+ static rowPlanMatches(plan, keys) {
3675
+ const cols = plan.cols;
3676
+ if (cols.length !== keys.length)
3677
+ return false;
3678
+ for (let i = 0; i < keys.length; i++) {
3679
+ if (cols[i] !== keys[i])
3680
+ return false;
3681
+ }
3682
+ return true;
3683
+ }
3684
+ parseRow(row, table) {
3685
+ const parsed = {};
3686
+ const meta = this.schema.tables[table];
3687
+ if (meta) {
3688
+ // Every row of one result set has the same columns in the same order (a
3689
+ // SQL result set has fixed field descriptors, and a nested row decoded
3690
+ // from `json_build_object` has a fixed key list), so the per-column
3691
+ // name resolution and date-column membership tests are the same answer
3692
+ // recomputed for every row. Resolve them ONCE per column shape and keep
3693
+ // the plan; the shape is verified against each row rather than assumed,
3694
+ // so a caller that does hand this function heterogeneous rows gets a
3695
+ // rebuilt plan instead of a mis-mapped one.
3696
+ const keys = Object.keys(row);
3697
+ const last = this.rowPlanLast.get(table);
3698
+ const plan = last !== undefined && QueryInterface.rowPlanMatches(last, keys)
3699
+ ? last
3700
+ : this.buildRowDecodePlan(table, meta, keys);
3701
+ const { cols, fields, dates } = plan;
3702
+ for (let i = 0; i < cols.length; i++) {
3703
+ const field = fields[i];
3704
+ const value = row[cols[i]];
3705
+ parsed[field] = dates[i] ? this.decodeTemporalCell(value, table, field) : value;
3591
3706
  }
3592
3707
  }
3593
3708
  else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turbine-orm",
3
- "version": "0.66.0",
3
+ "version": "0.67.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).",