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.
- package/dist/cjs/query/builder.d.ts +39 -0
- package/dist/cjs/query/builder.js +185 -70
- package/dist/query/builder.d.ts +39 -0
- package/dist/query/builder.js +185 -70
- package/package.json +1 -1
|
@@ -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
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
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
|
|
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
|
-
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
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
|
|
3578
|
-
|
|
3579
|
-
|
|
3580
|
-
|
|
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
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
|
|
3600
|
-
|
|
3601
|
-
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
3613
|
-
|
|
3614
|
-
|
|
3615
|
-
|
|
3616
|
-
|
|
3617
|
-
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
|
|
3623
|
-
|
|
3624
|
-
|
|
3625
|
-
|
|
3626
|
-
|
|
3627
|
-
|
|
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 {
|
package/dist/query/builder.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/query/builder.js
CHANGED
|
@@ -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
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
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
|
|
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
|
-
|
|
3530
|
-
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
|
|
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
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
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
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
3557
|
-
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
|
|
3580
|
-
|
|
3581
|
-
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
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.
|
|
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).",
|