turbine-orm 0.54.0 → 0.55.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.
@@ -35,6 +35,36 @@ export interface DeferredQuery<T> {
35
35
  */
36
36
  reselect?: (exec: ReselectExecutor) => Promise<pg.QueryResult>;
37
37
  }
38
+ /**
39
+ * How the ORM hands back a Postgres temporal `infinity` / `-infinity`.
40
+ *
41
+ * JavaScript has no `Date` for either value, so every available reading is
42
+ * wrong in some way and the choice is which way:
43
+ *
44
+ * `'preserve'` the default. The JS numbers `Infinity` / `-Infinity`,
45
+ * normalized to the same value on EVERY read strategy (the
46
+ * join and positional paths see the JSON string `"infinity"`
47
+ * and are mapped to the number, which is what 0.54 got wrong).
48
+ * LOSSLESS: binding the number back to a temporal column
49
+ * stores `infinity` again, so `update({ data: { ...row } })`
50
+ * round-trips. THE COST: the field's declared type is `Date`,
51
+ * so `row.validUntil.toISOString()` throws a TypeError on
52
+ * those rows, and `JSON.stringify` still renders the value
53
+ * `null`.
54
+ * `'null'` opt-in. Matches what a caller serializing the row already
55
+ * saw (`JSON.stringify` renders every other candidate as null
56
+ * too) and is permitted by the declared type of a nullable
57
+ * column, so no method call throws. THE COST is data loss: a
58
+ * stored `infinity` and a stored NULL become
59
+ * indistinguishable, so a read-modify-write over a nullable
60
+ * column (`update({ data: { ...row } })`) writes SQL NULL and
61
+ * the infinity is gone, with no error.
62
+ *
63
+ * The default is the reading that cannot lose a value. Pick `'null'` when the
64
+ * declared type contract matters more than the stored value, knowing that rows
65
+ * read under it must not be written back.
66
+ */
67
+ export type TemporalInfinityReading = 'null' | 'preserve';
38
68
  /** Middleware function type, imported from client to avoid circular deps */
39
69
  export type MiddlewareFn = (params: {
40
70
  model: string;
@@ -142,6 +172,11 @@ export interface QueryInterfaceOptions {
142
172
  * the pre-0.26 behavior (JS local-time interpretation).
143
173
  */
144
174
  utcTimestamps?: boolean;
175
+ /**
176
+ * How a Postgres temporal `infinity` / `-infinity` is handed back. See
177
+ * {@link TemporalInfinityReading}. Default `'preserve'`.
178
+ */
179
+ temporalInfinity?: TemporalInfinityReading;
145
180
  /**
146
181
  * Client-level default relation-loading strategy for `with` clauses; a
147
182
  * per-query `relationLoadStrategy` arg overrides it. On SQL engines the default
@@ -10,5 +10,5 @@ export type { BuiltStatement, BulkInsertStatementInput, ColumnDefinitionInput, C
10
10
  export { postgresDialect } from '../dialect.js';
11
11
  export type { SqlCacheEntry } from './utils.js';
12
12
  export { buildCorrelation, escapeLike, escSingleQuote, fnv1a64Hex, LRUCache, OPERATOR_KEYS, quoteIdent, sqlToPreparedName, } from './utils.js';
13
- export type { DeferredQuery, MiddlewareFn, QueryEvent, QueryEventListener, QueryInterfaceOptions, ReselectExecutor, } from './builder.js';
13
+ export type { DeferredQuery, MiddlewareFn, QueryEvent, QueryEventListener, QueryInterfaceOptions, ReselectExecutor, TemporalInfinityReading, } from './builder.js';
14
14
  export { AUTO_ASSUMED_ROUND_TRIP_MS, AUTO_COUNT_BATCH_MIN_PARENT_ROWS, AUTO_JOIN_PENALTY_MS_PER_ROW, AUTO_TO_ONE_JOIN_MAX_ROWS, AUTO_TO_ONE_JOIN_ROWS_MAX, AUTO_TO_ONE_JOIN_ROWS_MIN, QueryInterface, } from './builder.js';
@@ -176,6 +176,38 @@ export declare function coerceTemporalValue(dbType: string | undefined, value: u
176
176
  * as-is.
177
177
  */
178
178
  export declare function parseDbDate(value: string): Date;
179
+ /**
180
+ * Is `value` one of the two representations a Postgres temporal `infinity` /
181
+ * `-infinity` reaches the ORM row parser in?
182
+ *
183
+ * TWO representations, because a temporal column is read two different ways
184
+ * and they disagree on the wire:
185
+ *
186
+ * driver the pg text parser for `timestamp` / `date` does not
187
+ * recognise the word, so it falls through to the driver's own
188
+ * parser, which returns the JS NUMBERS `Infinity` /
189
+ * `-Infinity`. This is what a top-level row, the batched and
190
+ * flatten strategies, a write `RETURNING` projection and a
191
+ * `groupBy` key all see.
192
+ * JSON wire `json_build_object` renders the same value as the STRING
193
+ * `"infinity"`, and scalar `timestamp` / `timestamptz` are
194
+ * deliberately absent from {@link JSON_WIRE_COERCION_OIDS},
195
+ * so no driver parser runs over it. This is what the `'join'`
196
+ * strategy and the positional encoding see.
197
+ *
198
+ * Both are normalized in one place ({@link QueryInterface}'s row parser), to
199
+ * whichever reading `temporalInfinity` selects (the JS number by default, or
200
+ * `null`), so the same stored value cannot read differently depending on which
201
+ * plan the query happened to take. The string form is only ever consulted for a column
202
+ * the schema says is temporal, so a `text` column holding the word "infinity"
203
+ * is untouched.
204
+ *
205
+ * Not dialect-gated. Postgres is the only engine with an infinite temporal
206
+ * value, but the row parser is engine-shared and the alternative reading on the
207
+ * other engines (a stray `'infinity'` string becoming an Invalid Date) is not
208
+ * one worth preserving.
209
+ */
210
+ export declare function isTemporalInfinity(value: unknown): boolean;
179
211
  /**
180
212
  * Build the driver parser for Postgres `date` (OID 1082) that reads a
181
213
  * zone-less calendar day as **UTC midnight**.
@@ -243,6 +275,52 @@ export declare function parseUtcTimestampText(text: string): Date;
243
275
  * dependency. NULL elements stay `null` and are never handed to `element`.
244
276
  */
245
277
  export declare function createPgArrayParser(element: (text: string) => unknown): (text: string) => unknown[];
278
+ /** Tag `parser` as Turbine's own and return it (see {@link TURBINE_PARSER}). */
279
+ export declare function markTurbineParser<F extends (text: string) => unknown>(parser: F): F;
280
+ /**
281
+ * Is the parser currently registered for `oid` still pg's own default?
282
+ *
283
+ * DETECTED BY BEHAVIOUR, NOT BY IDENTITY, and deliberately so. `pg-types` keeps
284
+ * its default parser table private: `getTypeParser` hands back whatever is
285
+ * registered NOW, and there is no exported way to ask what the default WAS, so
286
+ * a function-identity comparison would need a deep import of a file the package
287
+ * does not publish as an entry point. Instead this runs the registered parser
288
+ * over a canonical wire value and compares the result with what pg's default
289
+ * produces for it.
290
+ *
291
+ * What that buys and what it costs, stated honestly:
292
+ * - Every parser that behaves OBSERVABLY differently on the probe value is
293
+ * detected, which is the case worth warning about (someone else's reading
294
+ * is about to be replaced by Turbine's).
295
+ * - A replacement that is observably EQUIVALENT on the probe is reported as
296
+ * the default and draws no warning. That is a false negative, and an
297
+ * acceptable one: if it agrees with the default here it is not a reading
298
+ * anybody would notice Turbine overwriting.
299
+ * - A parser that THROWS on the probe is reported as non-default; pg's own
300
+ * never throws on a valid value of its type.
301
+ * - An OID with no probe entry is reported as default (never warn on a guess).
302
+ *
303
+ * The registered parser is invoked once, on a synthetic value, at client
304
+ * construction. A decode parser with side effects would be surprising, and pg's
305
+ * own have none.
306
+ */
307
+ export declare function isDefaultTextParser(oid: number, parser: (text: string) => unknown): boolean;
308
+ /**
309
+ * Warn ONCE per OID when Turbine is about to replace a text parser that is not
310
+ * pg's default, i.e. when some other module in the process has already
311
+ * customized it.
312
+ *
313
+ * `pg.types.setTypeParser` is process-global and retroactive: it changes how
314
+ * every `pg.Pool` in the process decodes that OID, including pools that were
315
+ * constructed and were already querying before the Turbine client existed. When
316
+ * the OID was still on pg's default that is the documented, intended trade (the
317
+ * whole point of `utcTimestamps`). When somebody else had already installed
318
+ * their own reading, Turbine is silently rewriting an expectation it cannot see
319
+ * the origin of, and the resulting bug is order-dependent: which reading wins
320
+ * depends on module evaluation order, which lazy route imports make unstable
321
+ * between requests. So say it out loud, once.
322
+ */
323
+ export declare function warnParserOverwrite(oid: number, typeName: string): void;
246
324
  /**
247
325
  * Register the UTC readings of the four zone-less temporal OIDs on the pg
248
326
  * module: `timestamp` (1114), `date` (1082) and their array forms (1115, 1182).
@@ -258,6 +336,11 @@ export declare function createPgArrayParser(element: (text: string) => unknown):
258
336
  * Each fallback is read BEFORE its parser is installed, so an unrecognised wire
259
337
  * value (`infinity`, and whatever a future server adds) still reaches the
260
338
  * driver's own parser.
339
+ *
340
+ * Registration is RETROACTIVE for the whole process, pools included that were
341
+ * created and are already querying (there is one parser table, and it is read
342
+ * per row at decode time, not captured per pool). If any of the four OIDs is
343
+ * already on a NON-default parser, {@link warnParserOverwrite} says so once.
261
344
  */
262
345
  export declare function registerUtcTemporalParsers(): void;
263
346
  /**
@@ -22,10 +22,14 @@ exports.toLocalDateTimeLiteral = toLocalDateTimeLiteral;
22
22
  exports.temporalBindKind = temporalBindKind;
23
23
  exports.coerceTemporalValue = coerceTemporalValue;
24
24
  exports.parseDbDate = parseDbDate;
25
+ exports.isTemporalInfinity = isTemporalInfinity;
25
26
  exports.createUtcDateParser = createUtcDateParser;
26
27
  exports.createUtcTimestampParser = createUtcTimestampParser;
27
28
  exports.parseUtcTimestampText = parseUtcTimestampText;
28
29
  exports.createPgArrayParser = createPgArrayParser;
30
+ exports.markTurbineParser = markTurbineParser;
31
+ exports.isDefaultTextParser = isDefaultTextParser;
32
+ exports.warnParserOverwrite = warnParserOverwrite;
29
33
  exports.registerUtcTemporalParsers = registerUtcTemporalParsers;
30
34
  exports.jsonWireCoercionOid = jsonWireCoercionOid;
31
35
  exports.coerceJsonWireValue = coerceJsonWireValue;
@@ -33,6 +37,7 @@ exports.closestName = closestName;
33
37
  exports.unknownFieldMessage = unknownFieldMessage;
34
38
  const pg_1 = __importDefault(require("pg"));
35
39
  const schema_js_1 = require("../schema.js");
40
+ const warn_registry_js_1 = require("./warn-registry.js");
36
41
  // ---------------------------------------------------------------------------
37
42
  // Identifier quoting, prevents SQL injection via table/column names
38
43
  // ---------------------------------------------------------------------------
@@ -349,6 +354,42 @@ function parseDbDate(value) {
349
354
  // normalize `YYYY-MM-DD HH:MM:SS` (driver form) to ISO before pinning UTC
350
355
  return new Date(`${value.replace(' ', 'T')}Z`);
351
356
  }
357
+ /**
358
+ * Is `value` one of the two representations a Postgres temporal `infinity` /
359
+ * `-infinity` reaches the ORM row parser in?
360
+ *
361
+ * TWO representations, because a temporal column is read two different ways
362
+ * and they disagree on the wire:
363
+ *
364
+ * driver the pg text parser for `timestamp` / `date` does not
365
+ * recognise the word, so it falls through to the driver's own
366
+ * parser, which returns the JS NUMBERS `Infinity` /
367
+ * `-Infinity`. This is what a top-level row, the batched and
368
+ * flatten strategies, a write `RETURNING` projection and a
369
+ * `groupBy` key all see.
370
+ * JSON wire `json_build_object` renders the same value as the STRING
371
+ * `"infinity"`, and scalar `timestamp` / `timestamptz` are
372
+ * deliberately absent from {@link JSON_WIRE_COERCION_OIDS},
373
+ * so no driver parser runs over it. This is what the `'join'`
374
+ * strategy and the positional encoding see.
375
+ *
376
+ * Both are normalized in one place ({@link QueryInterface}'s row parser), to
377
+ * whichever reading `temporalInfinity` selects (the JS number by default, or
378
+ * `null`), so the same stored value cannot read differently depending on which
379
+ * plan the query happened to take. The string form is only ever consulted for a column
380
+ * the schema says is temporal, so a `text` column holding the word "infinity"
381
+ * is untouched.
382
+ *
383
+ * Not dialect-gated. Postgres is the only engine with an infinite temporal
384
+ * value, but the row parser is engine-shared and the alternative reading on the
385
+ * other engines (a stray `'infinity'` string becoming an Invalid Date) is not
386
+ * one worth preserving.
387
+ */
388
+ function isTemporalInfinity(value) {
389
+ if (typeof value === 'number')
390
+ return value === Number.POSITIVE_INFINITY || value === Number.NEGATIVE_INFINITY;
391
+ return value === 'infinity' || value === '-infinity';
392
+ }
352
393
  // ---------------------------------------------------------------------------
353
394
  // Driver type parsers for the zone-less temporal OIDs
354
395
  // ---------------------------------------------------------------------------
@@ -462,6 +503,126 @@ function createPgArrayParser(element) {
462
503
  const arrayParser = pg_1.default.types.arrayParser;
463
504
  return (text) => arrayParser.create(text, (entry) => (entry === null || entry === undefined ? null : element(entry))).parse();
464
505
  }
506
+ /**
507
+ * A canonical wire value per OID, plus the JS value pg's OWN default text
508
+ * parser produces from it, used to tell "still on the driver default" from
509
+ * "somebody else already customized this OID" (see
510
+ * {@link isDefaultTextParser}).
511
+ *
512
+ * The expected values are computed from LOCAL date components on purpose: pg's
513
+ * default for the zone-less temporal OIDs builds its `Date` in the process's
514
+ * zone (that is the reading `utcTimestamps` exists to replace), so the
515
+ * expectation has to be computed the same way in whatever zone the process runs.
516
+ */
517
+ const DEFAULT_PARSER_PROBES = {
518
+ 20: { text: '9007199254740993', expected: () => '9007199254740993' },
519
+ 1082: { text: '2020-01-02', expected: () => new Date(2020, 0, 2) },
520
+ 1114: { text: '2020-01-02 03:04:05', expected: () => new Date(2020, 0, 2, 3, 4, 5) },
521
+ 1115: { text: '{"2020-01-02 03:04:05"}', expected: () => [new Date(2020, 0, 2, 3, 4, 5)] },
522
+ 1182: { text: '{2020-01-02}', expected: () => [new Date(2020, 0, 2)] },
523
+ };
524
+ /**
525
+ * Marks a text parser as one TURBINE installed, so a later registration in the
526
+ * same process (a client plus `turbine studio`, ESM plus CJS copies of this
527
+ * module) does not report Turbine's own parser as "somebody else's". A
528
+ * `Symbol.for` key, for the same cross-copy-identity reason the warn registry
529
+ * uses one.
530
+ */
531
+ const TURBINE_PARSER = Symbol.for('turbine.typeParser');
532
+ /** The OIDs the `utcTimestamps` flag governs, and so the ones it can opt out of. */
533
+ const TEMPORAL_PARSER_OIDS = new Set([1114, 1082, 1115, 1182]);
534
+ /** Tag `parser` as Turbine's own and return it (see {@link TURBINE_PARSER}). */
535
+ function markTurbineParser(parser) {
536
+ parser[TURBINE_PARSER] = true;
537
+ return parser;
538
+ }
539
+ /** Comparable rendering of a parser result (Date by instant, array by element). */
540
+ function parserResultSignature(value) {
541
+ if (value === null || value === undefined)
542
+ return String(value);
543
+ if (value instanceof Date)
544
+ return `date:${value.getTime()}`;
545
+ if (Array.isArray(value))
546
+ return `[${value.map(parserResultSignature).join(',')}]`;
547
+ return `${typeof value}:${String(value)}`;
548
+ }
549
+ /**
550
+ * Is the parser currently registered for `oid` still pg's own default?
551
+ *
552
+ * DETECTED BY BEHAVIOUR, NOT BY IDENTITY, and deliberately so. `pg-types` keeps
553
+ * its default parser table private: `getTypeParser` hands back whatever is
554
+ * registered NOW, and there is no exported way to ask what the default WAS, so
555
+ * a function-identity comparison would need a deep import of a file the package
556
+ * does not publish as an entry point. Instead this runs the registered parser
557
+ * over a canonical wire value and compares the result with what pg's default
558
+ * produces for it.
559
+ *
560
+ * What that buys and what it costs, stated honestly:
561
+ * - Every parser that behaves OBSERVABLY differently on the probe value is
562
+ * detected, which is the case worth warning about (someone else's reading
563
+ * is about to be replaced by Turbine's).
564
+ * - A replacement that is observably EQUIVALENT on the probe is reported as
565
+ * the default and draws no warning. That is a false negative, and an
566
+ * acceptable one: if it agrees with the default here it is not a reading
567
+ * anybody would notice Turbine overwriting.
568
+ * - A parser that THROWS on the probe is reported as non-default; pg's own
569
+ * never throws on a valid value of its type.
570
+ * - An OID with no probe entry is reported as default (never warn on a guess).
571
+ *
572
+ * The registered parser is invoked once, on a synthetic value, at client
573
+ * construction. A decode parser with side effects would be surprising, and pg's
574
+ * own have none.
575
+ */
576
+ function isDefaultTextParser(oid, parser) {
577
+ const probe = DEFAULT_PARSER_PROBES[oid];
578
+ if (!probe)
579
+ return true;
580
+ try {
581
+ return parserResultSignature(parser(probe.text)) === parserResultSignature(probe.expected());
582
+ }
583
+ catch {
584
+ return false;
585
+ }
586
+ }
587
+ /**
588
+ * Warn ONCE per OID when Turbine is about to replace a text parser that is not
589
+ * pg's default, i.e. when some other module in the process has already
590
+ * customized it.
591
+ *
592
+ * `pg.types.setTypeParser` is process-global and retroactive: it changes how
593
+ * every `pg.Pool` in the process decodes that OID, including pools that were
594
+ * constructed and were already querying before the Turbine client existed. When
595
+ * the OID was still on pg's default that is the documented, intended trade (the
596
+ * whole point of `utcTimestamps`). When somebody else had already installed
597
+ * their own reading, Turbine is silently rewriting an expectation it cannot see
598
+ * the origin of, and the resulting bug is order-dependent: which reading wins
599
+ * depends on module evaluation order, which lazy route imports make unstable
600
+ * between requests. So say it out loud, once.
601
+ */
602
+ function warnParserOverwrite(oid, typeName) {
603
+ if (process.env.NODE_ENV === 'production')
604
+ return;
605
+ const getParser = pg_1.default.types.getTypeParser;
606
+ const current = getParser(oid, 'text');
607
+ // Turbine's own earlier registration is not a third party's expectation.
608
+ if (current[TURBINE_PARSER])
609
+ return;
610
+ if (isDefaultTextParser(oid, current))
611
+ return;
612
+ if (!(0, warn_registry_js_1.shouldWarnOnce)(warn_registry_js_1.WARN_NS.parserOverwrite, String(oid)))
613
+ return;
614
+ // The `utcTimestamps: false` opt-out only governs the four TEMPORAL OIDs.
615
+ // Offering it as the remedy for int8 (20) would name a setting that does
616
+ // nothing for the OID being warned about; that registration has no opt-out.
617
+ const remedy = TEMPORAL_PARSER_OIDS.has(oid)
618
+ ? ' `utcTimestamps: false` leaves the four temporal OIDs (1114, 1082, 1115, 1182) alone entirely.'
619
+ : ' There is no opt-out for this OID: Turbine registers it so bigint values come back as numbers.';
620
+ console.warn(`[turbine] pg type parser for OID ${oid} (${typeName}) was already customized by something else in this ` +
621
+ 'process, and Turbine is replacing it. `pg.types.setTypeParser` is process-global and takes effect ' +
622
+ 'immediately for EVERY pg.Pool in the process, including pools that already exist and are already ' +
623
+ 'querying, so whatever set that parser will now read this column differently. If yours should win, ' +
624
+ `register it AFTER constructing the client.${remedy} Dev-only: silent under \`NODE_ENV=production\`.`);
625
+ }
465
626
  /**
466
627
  * Register the UTC readings of the four zone-less temporal OIDs on the pg
467
628
  * module: `timestamp` (1114), `date` (1082) and their array forms (1115, 1182).
@@ -477,6 +638,11 @@ function createPgArrayParser(element) {
477
638
  * Each fallback is read BEFORE its parser is installed, so an unrecognised wire
478
639
  * value (`infinity`, and whatever a future server adds) still reaches the
479
640
  * driver's own parser.
641
+ *
642
+ * Registration is RETROACTIVE for the whole process, pools included that were
643
+ * created and are already querying (there is one parser table, and it is read
644
+ * per row at decode time, not captured per pool). If any of the four OIDs is
645
+ * already on a NON-default parser, {@link warnParserOverwrite} says so once.
480
646
  */
481
647
  function registerUtcTemporalParsers() {
482
648
  // pg-types declares get/setTypeParser over its own OID enum, which lists the
@@ -484,15 +650,19 @@ function registerUtcTemporalParsers() {
484
650
  // retyped over a plain number rather than the incomplete enum.
485
651
  const getParser = pg_1.default.types.getTypeParser;
486
652
  const setParser = pg_1.default.types.setTypeParser;
653
+ warnParserOverwrite(1114, 'timestamp');
654
+ warnParserOverwrite(1082, 'date');
655
+ warnParserOverwrite(1115, 'timestamp[]');
656
+ warnParserOverwrite(1182, 'date[]');
487
657
  const parseDate = createUtcDateParser(getParser(1082, 'text'));
488
658
  const parseTimestamp = createUtcTimestampParser(getParser(1114, 'text'));
489
- setParser(1114, parseTimestamp);
490
- setParser(1082, parseDate);
659
+ setParser(1114, markTurbineParser(parseTimestamp));
660
+ setParser(1082, markTurbineParser(parseDate));
491
661
  // Array OIDs do not inherit their element parser, so `date[]` / `timestamp[]`
492
662
  // would otherwise keep returning local-zone Dates while the scalar columns
493
663
  // beside them returned UTC ones.
494
- setParser(1182, createPgArrayParser(parseDate));
495
- setParser(1115, createPgArrayParser(parseTimestamp));
664
+ setParser(1182, markTurbineParser(createPgArrayParser(parseDate)));
665
+ setParser(1115, markTurbineParser(createPgArrayParser(parseTimestamp)));
496
666
  }
497
667
  // ---------------------------------------------------------------------------
498
668
  // JSON-wire value coercion (relationLoadStrategy: 'join')
@@ -82,6 +82,20 @@ export declare const WARN_NS: {
82
82
  * the offenders, not one per column. The namespace name is historical.
83
83
  */
84
84
  readonly untypedDateColumn: "untypedDateColumn";
85
+ /**
86
+ * A stored temporal `infinity` / `-infinity` was actually read, and
87
+ * `temporalInfinity` was left unset, so the row parser describes the reading
88
+ * in force (builder.ts `warnTemporalInfinity`). Keyed on `table.column`, so a
89
+ * table with two such columns says it once for each and a million rows say it
90
+ * once in total.
91
+ */
92
+ readonly temporalInfinity: "temporalInfinity";
93
+ /**
94
+ * Turbine replaced a pg text parser that was NOT on the driver default, i.e.
95
+ * something else in the process had already customized that OID (utils.ts
96
+ * `warnParserOverwrite`). Keyed on the OID.
97
+ */
98
+ readonly parserOverwrite: "parserOverwrite";
85
99
  /**
86
100
  * `planCacheMode` was set on a client given an EXTERNAL pool, where Turbine
87
101
  * runs no connection setup, so the option is a no-op (client.ts constructor).
@@ -123,6 +123,20 @@ exports.WARN_NS = {
123
123
  * the offenders, not one per column. The namespace name is historical.
124
124
  */
125
125
  untypedDateColumn: 'untypedDateColumn',
126
+ /**
127
+ * A stored temporal `infinity` / `-infinity` was actually read, and
128
+ * `temporalInfinity` was left unset, so the row parser describes the reading
129
+ * in force (builder.ts `warnTemporalInfinity`). Keyed on `table.column`, so a
130
+ * table with two such columns says it once for each and a million rows say it
131
+ * once in total.
132
+ */
133
+ temporalInfinity: 'temporalInfinity',
134
+ /**
135
+ * Turbine replaced a pg text parser that was NOT on the driver default, i.e.
136
+ * something else in the process had already customized that OID (utils.ts
137
+ * `warnParserOverwrite`). Keyed on the OID.
138
+ */
139
+ parserOverwrite: 'parserOverwrite',
126
140
  /**
127
141
  * `planCacheMode` was set on a client given an EXTERNAL pool, where Turbine
128
142
  * runs no connection setup, so the option is a no-op (client.ts constructor).
@@ -14,6 +14,7 @@ import type pg from 'pg';
14
14
  import type { Dialect } from '../dialect.js';
15
15
  import { ValidationError } from '../errors.js';
16
16
  import type { RelationDef, SchemaMetadata, TableMetadata } from '../schema.js';
17
+ import type { TemporalInfinityReading } from './deferred.js';
17
18
  import type { ArrayFilter, ColumnRef, GlobalFilters, JsonFilter, JsonPathOrderBy, SkipGlobalFilters, TextSearchFilter, VectorFilter, WhereClause, WhereOperator } from './types.js';
18
19
  import { type SqlCacheEntry } from './utils.js';
19
20
  import { type WhereHost, type WhereRecord } from './where-compile.js';
@@ -43,6 +44,13 @@ export interface BuilderCtx {
43
44
  * `timestamp` columns (see `coerceWriteValue` in writes.ts).
44
45
  */
45
46
  readonly utcTimestamps?: boolean;
47
+ /**
48
+ * The client's `temporalInfinity` reading (`'preserve'` default, `'null'`).
49
+ * Read by aggregates.ts, where `_min` / `_max` are assembled from the raw row
50
+ * and so cannot go through `parseRow`. Optional so a hand-built ctx keeps the
51
+ * default.
52
+ */
53
+ readonly temporalInfinity?: TemporalInfinityReading;
46
54
  readonly crossSchemaTypeColumns: Set<string>;
47
55
  /**
48
56
  * The active query's `skipGlobalFilters` opt-out. A live getter/setter over
package/dist/client.d.ts CHANGED
@@ -26,7 +26,7 @@ import { type Dialect } from './dialect.js';
26
26
  import { type ErrorMessageMode } from './errors.js';
27
27
  import { type ObserveConfig, type ObserveHandle } from './observe.js';
28
28
  import { type PipelineOptions, type PipelineResults } from './pipeline.js';
29
- import { type DeferredQuery, type GlobalFilters, type QueryEventListener, QueryInterface, type QueryInterfaceOptions, type RelationLoadStrategy } from './query/index.js';
29
+ import { type DeferredQuery, type GlobalFilters, type QueryEventListener, QueryInterface, type QueryInterfaceOptions, type RelationLoadStrategy, type TemporalInfinityReading } from './query/index.js';
30
30
  import { type NotificationHandler, type Subscription } from './realtime.js';
31
31
  import type { SchemaMetadata } from './schema.js';
32
32
  import { TypedSqlQuery } from './typed-sql.js';
@@ -192,11 +192,21 @@ export interface TurbineConfig {
192
192
  * rewrite (a bound `Date` on a zone-less `date` / `timestamp` column is then
193
193
  * serialized by the driver in the process's zone).
194
194
  *
195
- * PER PROCESS, NOT PER CLIENT. The read half is a pg type parser, and
196
- * `pg.types.setTypeParser` installs one parser per OID for the whole
197
- * process. The first client settles it for every later one, so constructing
198
- * a second client with the OPPOSITE value throws a `ValidationError` rather
199
- * than handing back a client whose writes and reads disagree. Give every
195
+ * PER PROCESS, NOT PER CLIENT, AND RETROACTIVE. The read half is a pg type
196
+ * parser, and `pg.types.setTypeParser` installs one parser per OID for the
197
+ * whole process. There is ONE parser table and it is consulted per row at
198
+ * decode time, so registration also changes POOLS THAT ALREADY EXIST AND ARE
199
+ * ALREADY QUERYING: the same `pg.Pool` running the same query returns
200
+ * different values before and after a Turbine client is constructed
201
+ * somewhere else in the process. With lazy route imports that ordering is
202
+ * not stable between requests, so a reporting job on its own pool can return
203
+ * different days depending on what has been imported yet. When the OID was
204
+ * still on the driver's default that is the intended trade; when something
205
+ * else had already customized it, Turbine says so once (dev-only, see
206
+ * `warnParserOverwrite`). The first client settles it for every later one, so
207
+ * constructing a second client with the OPPOSITE value throws a
208
+ * `ValidationError` rather than handing back a client whose writes and reads
209
+ * disagree. Give every
200
210
  * client in the process the same value, or isolate the odd one in its own
201
211
  * process. A client on an EXTERNAL pool never REGISTERS a parser (it inherits
202
212
  * whatever configuration the caller's driver has, so a process containing
@@ -207,27 +217,80 @@ export interface TurbineConfig {
207
217
  * UTC.
208
218
  */
209
219
  utcTimestamps?: boolean;
220
+ /**
221
+ * How a Postgres temporal `infinity` / `-infinity` is handed back:
222
+ * `'preserve'` (the default, the JS numbers `Infinity` / `-Infinity`) or
223
+ * `'null'`. See {@link TemporalInfinityReading} for the full trade.
224
+ *
225
+ * There is no JS `Date` for either value, so both readings are wrong in some
226
+ * way and the option is which way. `'preserve'` is LOSSLESS: binding the
227
+ * number back stores `infinity` again, so a read-modify-write
228
+ * (`update({ data: { ...row } })`) round-trips. Its cost is a number on a
229
+ * `Date`-typed field, so `row.validUntil.toISOString()` throws a TypeError on
230
+ * exactly those rows, and `JSON.stringify` still renders the value `null`.
231
+ * `'null'` matches what `JSON.stringify` already produced and keeps the
232
+ * field's declared `Date | null` type honest, at the cost of DATA LOSS: a
233
+ * stored `infinity` and a stored NULL become indistinguishable, so that same
234
+ * read-modify-write writes SQL NULL and destroys the value with no error.
235
+ * The default is the reading that cannot lose a value.
236
+ *
237
+ * Unlike 0.54, which returned the number on some read strategies and an
238
+ * Invalid Date on others, BOTH readings here are identical on every
239
+ * strategy, every write projection, `groupBy` keys and `_min` / `_max`.
240
+ *
241
+ * Leaving the option unset selects `'preserve'` AND enables a one-time
242
+ * warning (per process, per field, not silenced by `NODE_ENV=production`) the
243
+ * first time a stored infinity is actually read, describing that reading and
244
+ * both escapes. Naming either reading explicitly silences it.
245
+ */
246
+ temporalInfinity?: TemporalInfinityReading;
210
247
  /**
211
248
  * Pin `plan_cache_mode` on every connection this client opens, fixing how the
212
249
  * PostgreSQL backend chooses between a custom plan (re-planned per parameter
213
250
  * set) and a generic plan (planned once, blind to the values).
214
251
  *
215
252
  * Why it exists: Turbine sends NAMED prepared statements by default on a pool
216
- * it owns, and PostgreSQL promotes a named statement to a generic plan after
217
- * five executions. A generic plan is planned for the AVERAGE parameter, so a
253
+ * it owns, and PostgreSQL MAY promote a named statement to a generic plan
254
+ * after five executions (see the ceiling note below: it promotes only when
255
+ * the generic plan's estimated cost is not worse than the average custom
256
+ * cost). A generic plan is planned for the AVERAGE parameter, so a
218
257
  * predicate whose selectivity varies wildly per value (the canonical case is a
219
258
  * `tenant_id` / `user_id` equality on a shared table, where one value matches
220
259
  * a handful of rows and another matches most of them) is planned blind to the
221
260
  * value it will actually get, and it never reverts. `'force_custom_plan'`
222
261
  * removes that, at the cost of re-planning each execution.
223
262
  *
224
- * Scope, measured rather than assumed: it applies to the statements Turbine
225
- * itself promotes. `count()` on such a predicate is promoted after five
226
- * executions and is controlled by this option; `findMany` / `findFirst` bind
227
- * `LIMIT $n`, and a parameterized limit denies the planner the limit fraction
228
- * that makes a skewed plan look cheap, so those are much less exposed. Treat
229
- * the option as a targeted remedy for a plan that is measurably worse after
230
- * its fifth execution, not a general speed-up.
263
+ * Scope. It applies to the statements Turbine itself promotes, which is any
264
+ * of them: `count()`, `findMany` and `findFirst` alike. CORRECTION TO THE
265
+ * 0.54.0 TEXT, which claimed `findMany` / `findFirst` were "much less
266
+ * exposed" because they bind `LIMIT $n`: that was false. PostgreSQL does not
267
+ * deny the planner a limit fraction for a bound limit, it SUBSTITUTES a
268
+ * default of 10% of the child node's own row estimate (clamped at one row),
269
+ * which is simply a different wrong number. An unknown `OFFSET` triggers the
270
+ * same substitution on its own even when the limit is a constant, and a
271
+ * paginated Turbine read binds both.
272
+ *
273
+ * Two things the sentence above this one glosses over. The sixth execution
274
+ * is a CEILING, not a trigger: `auto` promotes only when the generic plan's
275
+ * ESTIMATED cost is not worse than the average custom cost, so plenty of
276
+ * statements are never promoted at all (`pg_prepared_statements.generic_plans`
277
+ * is how you tell). And the shape that gets promoted unprompted is the one
278
+ * with NO limit: measured on a skewed join predicate, the unlimited statement
279
+ * promoted under the default `auto` and ran a nested loop at 430x the buffers
280
+ * of the custom plan, while the same predicate under `LIMIT $n` was never
281
+ * promoted across eight executions, because its substituted row count made
282
+ * the generic plan look MORE expensive. A limited `findMany` gives the
283
+ * planner two unknowns instead of one, which is not the same thing as more
284
+ * damage.
285
+ *
286
+ * `implicitPkOrdering` is OFF by default in core, so a default `findMany`
287
+ * emits no `ORDER BY` at all; switching it on adds an ordering a generic plan
288
+ * can walk the whole table in.
289
+ *
290
+ * Treat the option as a targeted remedy for a plan that is measurably worse
291
+ * after its fifth execution, not a general speed-up, and measure with
292
+ * `plan_cache_mode = force_generic_plan` versus `force_custom_plan` rather
293
+ * than reasoning about which query shapes "should" be safe.
231
294
  *
232
295
  * Default `undefined`: Turbine issues NOTHING and the backend keeps its own
233
296
  * default (`auto`), byte-identical to not setting the option.
@@ -689,6 +752,13 @@ export declare class TurbineClient {
689
752
  * `UnsupportedFeatureError` (E017), in the same style as the other
690
753
  * capability refusals.
691
754
  */
755
+ /**
756
+ * Validate the `temporalInfinity` reading. A closed two-value enum, checked
757
+ * at construction so a typo (`'preserved'`, `'raw'`) fails loudly rather than
758
+ * silently falling back to the default reading the caller was trying to
759
+ * change.
760
+ */
761
+ private static resolveTemporalInfinity;
692
762
  private static resolvePlanCacheMode;
693
763
  /**
694
764
  * Pin `plan_cache_mode` on every connection an OWNED pool opens, by putting