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.
package/dist/client.js CHANGED
@@ -27,7 +27,7 @@ import { setErrorMessageMode, TimeoutError, UnsupportedFeatureError, ValidationE
27
27
  import { ObserveEngine } from './observe.js';
28
28
  import { executePipeline, pipelineSupported } from './pipeline.js';
29
29
  import { QueryInterface, } from './query/index.js';
30
- import { closestName, quoteIdent, registerUtcTemporalParsers } from './query/utils.js';
30
+ import { closestName, markTurbineParser, quoteIdent, registerUtcTemporalParsers, warnParserOverwrite, } from './query/utils.js';
31
31
  import { shouldWarnOnce, WARN_NS } from './query/warn-registry.js';
32
32
  import { createSubscription, validateChannel, } from './realtime.js';
33
33
  import { buildTypedSql, TypedSqlQuery } from './typed-sql.js';
@@ -102,6 +102,7 @@ const TURBINE_CONFIG_KEYS = {
102
102
  defaultLimit: true,
103
103
  warnOnUnlimited: true,
104
104
  utcTimestamps: true,
105
+ temporalInfinity: true,
105
106
  planCacheMode: true,
106
107
  scopedConnect: true,
107
108
  relationLoadStrategy: true,
@@ -591,10 +592,11 @@ export class TurbineClient {
591
592
  // constructor-gated by the static flags, so it happens at most once.
592
593
  const ownsAnyPool = !config.pool;
593
594
  if (ownsAnyPool && !TurbineClient.int8ParserRegistered) {
594
- pg.types.setTypeParser(20, (val) => {
595
+ warnParserOverwrite(20, 'int8');
596
+ pg.types.setTypeParser(20, markTurbineParser((val) => {
595
597
  const n = Number(val);
596
598
  return Number.isSafeInteger(n) ? n : val;
597
- });
599
+ }));
598
600
  TurbineClient.int8ParserRegistered = true;
599
601
  }
600
602
  // Parse the zone-less temporal types (`timestamp` OID 1114, `date` OID
@@ -638,6 +640,7 @@ export class TurbineClient {
638
640
  defaultLimit: config.defaultLimit,
639
641
  warnOnUnlimited: config.warnOnUnlimited,
640
642
  utcTimestamps: config.utcTimestamps,
643
+ temporalInfinity: TurbineClient.resolveTemporalInfinity(config.temporalInfinity),
641
644
  scopedConnect: config.scopedConnect,
642
645
  relationLoadStrategy: config.relationLoadStrategy,
643
646
  stableRelationOrder: config.stableRelationOrder,
@@ -809,6 +812,23 @@ export class TurbineClient {
809
812
  * `UnsupportedFeatureError` (E017), in the same style as the other
810
813
  * capability refusals.
811
814
  */
815
+ /**
816
+ * Validate the `temporalInfinity` reading. A closed two-value enum, checked
817
+ * at construction so a typo (`'preserved'`, `'raw'`) fails loudly rather than
818
+ * silently falling back to the default reading the caller was trying to
819
+ * change.
820
+ */
821
+ static resolveTemporalInfinity(value) {
822
+ if (value === undefined)
823
+ return undefined;
824
+ if (value !== 'null' && value !== 'preserve') {
825
+ throw new ValidationError(`Invalid temporalInfinity: ${JSON.stringify(value)}. Expected 'preserve' (default: read a Postgres ` +
826
+ 'temporal `infinity` as the JS number `Infinity` / `-Infinity`, which round-trips through a write ' +
827
+ "but breaks the declared `Date` type) or 'null' (read it as null, which serializes cleanly but " +
828
+ 'makes it indistinguishable from a stored NULL, so a read-modify-write destroys the value).');
829
+ }
830
+ return value;
831
+ }
812
832
  static resolvePlanCacheMode(mode, dialect) {
813
833
  if (mode === undefined || mode === null)
814
834
  return undefined;
package/dist/index.d.ts CHANGED
@@ -43,7 +43,7 @@ export { type IntrospectOptions, introspect } from './introspect.js';
43
43
  export { executeNestedCreate, executeNestedUpdate, hasRelationFields, type NestedWriteContext, } from './nested-write.js';
44
44
  export { HttpJsonSink, type HttpJsonSinkOptions, type MetricsFlushBatch, type MetricsFlushRow, type ObserveConfig, type ObserveHandle, type ObserveSink, PgMetricsSink, type PgMetricsSinkOptions, } from './observe.js';
45
45
  export { executePipeline, type PipelineOptions, type PipelineResults, pipelineSupported } from './pipeline.js';
46
- export { type AggregateArgs, type AggregateResult, type ArrayFilter, 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, type ColumnRef, type ConnectOrCreateOp, type CountArgs, type CreateArgs, type CreateDataInput, type CreateManyArgs, type DeferredQuery, type DeleteArgs, type DeleteManyArgs, type FieldResult, type FindManyArgs, type FindManyStreamArgs, type FindUniqueArgs, type GlobalFilters, type GroupByAggregateSpec, type GroupByArgs, type GroupByDistinctOn, type GroupByResult, type HavingClause, type JsonFilter, type JsonPathAggregateTarget, type JsonPathGroupKey, type JsonPathOrderBy, type MiddlewareFn, type NestedCreateOp, type NestedUpdateOp, type NestedUpdateOpItem, type NestedUpsertOpItem, type OmitResult, type OrderByClause, type OrderByObject, type OrderDirection, type QueryEvent, type QueryEventListener, QueryInterface, type QueryResult, type RelationDescriptor, type RelationFilter, type RelationLoadStrategy, type RelationPickBy, type RelationPickOrderBy, type SelectResult, type SkipGlobalFilters, type TextSearchFilter, type TypedWithClause, type UpdateArgs, type UpdateDataInput, type UpdateInput, type UpdateManyArgs, type UpdateOperatorInput, type UpsertArgs, type VectorDistanceFilter, type VectorFilter, type VectorMetric, type VectorOrderBy, type VectorOrderByDistance, type WhereClause, type WhereOperator, type WhereValue, type WithClause, type WithOptions, type WithOrderByObject, type WithResult, } from './query/index.js';
46
+ export { type AggregateArgs, type AggregateResult, type ArrayFilter, 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, type ColumnRef, type ConnectOrCreateOp, type CountArgs, type CreateArgs, type CreateDataInput, type CreateManyArgs, type DeferredQuery, type DeleteArgs, type DeleteManyArgs, type FieldResult, type FindManyArgs, type FindManyStreamArgs, type FindUniqueArgs, type GlobalFilters, type GroupByAggregateSpec, type GroupByArgs, type GroupByDistinctOn, type GroupByResult, type HavingClause, type JsonFilter, type JsonPathAggregateTarget, type JsonPathGroupKey, type JsonPathOrderBy, type MiddlewareFn, type NestedCreateOp, type NestedUpdateOp, type NestedUpdateOpItem, type NestedUpsertOpItem, type OmitResult, type OrderByClause, type OrderByObject, type OrderDirection, type QueryEvent, type QueryEventListener, QueryInterface, type QueryResult, type RelationDescriptor, type RelationFilter, type RelationLoadStrategy, type RelationPickBy, type RelationPickOrderBy, type SelectResult, type SkipGlobalFilters, type TemporalInfinityReading, type TextSearchFilter, type TypedWithClause, type UpdateArgs, type UpdateDataInput, type UpdateInput, type UpdateManyArgs, type UpdateOperatorInput, type UpsertArgs, type VectorDistanceFilter, type VectorFilter, type VectorMetric, type VectorOrderBy, type VectorOrderByDistance, type WhereClause, type WhereOperator, type WhereValue, type WithClause, type WithOptions, type WithOrderByObject, type WithResult, } from './query/index.js';
47
47
  export { type ActiveSubscription, type NotificationHandler, type Subscription, validateChannel } from './realtime.js';
48
48
  export type { CheckMetadata, ColumnMetadata, IndexMetadata, PrismaCompatMap, PrismaModelMap, PrismaRelationMap, ReferentialAction, RelationDef, SchemaMetadata, TableMetadata, } from './schema.js';
49
49
  export { camelToSnake, isDateType, normalizeKeyColumns, pgArrayType, pgTypeToTs, singularize, snakeToCamel, snakeToPascal, withDbFieldNames, } from './schema.js';
@@ -11,7 +11,7 @@
11
11
  import { UnsupportedFeatureError, ValidationError } from '../errors.js';
12
12
  import { snakeToCamel } from '../schema.js';
13
13
  import { isJsonPathOrderBy, isUnmatchedPlainObject, isVectorOrderBy, isWhereOperator, normalizeOrderBy, orderByEntries, } from './filters.js';
14
- import { ownLookup } from './utils.js';
14
+ import { isTemporalInfinity, ownLookup } from './utils.js';
15
15
  import * as whereMod from './where.js';
16
16
  /**
17
17
  * Enforce the PII contract on the aggregate surface. A PII-tagged
@@ -310,12 +310,14 @@ export function buildGroupBy(qi, args) {
310
310
  }
311
311
  else if (rawKey.startsWith('_min_')) {
312
312
  const j = jsonAgg(rawKey);
313
- minObj[fieldFor(rawKey, rawKey.slice(5))] = j?.numeric && rawValue !== null ? Number(rawValue) : rawValue;
313
+ minObj[fieldFor(rawKey, rawKey.slice(5))] =
314
+ j?.numeric && rawValue !== null ? Number(rawValue) : temporalAggValue(qi, rawKey.slice(5), rawValue);
314
315
  hasMins = true;
315
316
  }
316
317
  else if (rawKey.startsWith('_max_')) {
317
318
  const j = jsonAgg(rawKey);
318
- maxObj[fieldFor(rawKey, rawKey.slice(5))] = j?.numeric && rawValue !== null ? Number(rawValue) : rawValue;
319
+ maxObj[fieldFor(rawKey, rawKey.slice(5))] =
320
+ j?.numeric && rawValue !== null ? Number(rawValue) : temporalAggValue(qi, rawKey.slice(5), rawValue);
319
321
  hasMaxs = true;
320
322
  }
321
323
  }
@@ -725,6 +727,38 @@ export function buildHavingNumericClauses(qi, expr, filter, params) {
725
727
  }
726
728
  return clauses;
727
729
  }
730
+ /**
731
+ * `_min` / `_max` over a TEMPORAL column, aligned with the row parser.
732
+ *
733
+ * These two are the only aggregates that hand back a row's stored cell rather
734
+ * than something computed across rows, so they are the only ones that can carry
735
+ * a Postgres `infinity`. They are assembled from the RAW row (a Date must stay
736
+ * a Date, and `parseRow`'s snake→camel mapping would collide with the `_min_`
737
+ * alias), so the infinity mapping has to be applied here too. Without it
738
+ * `aggregate({ _max: { ts: true } })` would return the driver's `Infinity`
739
+ * while `findMany` returned `null` for the very same value.
740
+ *
741
+ * Gated on the column being temporal, so a numeric `_max` is untouched, and on
742
+ * the client's `temporalInfinity` reading, so it cannot disagree with the rows
743
+ * `findMany` returns for the same column. An absent reading resolves to the
744
+ * default, `'preserve'`, exactly as it does in the row parser.
745
+ *
746
+ * Note what the opt-in `'null'` reading then means for `_max` on a table that
747
+ * plainly has rows: `null` is the same value an empty table and an all-NULL
748
+ * column return, so `_min` can report a real date while `_max` reports
749
+ * "nothing" (documented, and one of the reasons `'null'` is not the default).
750
+ */
751
+ function temporalAggValue(qi, col, value) {
752
+ if (!qi.tableMeta.dateColumns.has(col))
753
+ return value;
754
+ if (!isTemporalInfinity(value))
755
+ return value;
756
+ if (qi.temporalInfinity === 'null')
757
+ return null;
758
+ if (typeof value === 'number')
759
+ return value;
760
+ return value === '-infinity' ? Number.NEGATIVE_INFINITY : Number.POSITIVE_INFINITY;
761
+ }
728
762
  export function buildAggregate(qi, args) {
729
763
  qi.currentSkip = args.skipGlobalFilters;
730
764
  const aggWhere = whereMod.mergeGlobalFilter(qi, args.where);
@@ -869,13 +903,13 @@ export function buildAggregate(qi, args) {
869
903
  else if (key.startsWith('_min_')) {
870
904
  const col = key.slice(5);
871
905
  const field = qi.tableMeta.reverseColumnMap[col] ?? snakeToCamel(col);
872
- minObj[field] = val;
906
+ minObj[field] = temporalAggValue(qi, col, val);
873
907
  hasMins = true;
874
908
  }
875
909
  else if (key.startsWith('_max_')) {
876
910
  const col = key.slice(5);
877
911
  const field = qi.tableMeta.reverseColumnMap[col] ?? snakeToCamel(col);
878
- maxObj[field] = val;
912
+ maxObj[field] = temporalAggValue(qi, col, val);
879
913
  hasMaxs = true;
880
914
  }
881
915
  }
@@ -154,7 +154,7 @@ export declare const AUTO_TO_ONE_JOIN_ROWS_MAX = 100000;
154
154
  * it.
155
155
  */
156
156
  export declare const AUTO_COUNT_BATCH_MIN_PARENT_ROWS = 2;
157
- export type { DeferredQuery, MiddlewareFn, QueryEvent, QueryEventListener, QueryInterfaceOptions, ReselectExecutor, } from './deferred.js';
157
+ export type { DeferredQuery, MiddlewareFn, QueryEvent, QueryEventListener, QueryInterfaceOptions, ReselectExecutor, TemporalInfinityReading, } from './deferred.js';
158
158
  import type { DeferredQuery, MiddlewareFn, QueryInterfaceOptions } from './deferred.js';
159
159
  export declare class QueryInterface<T extends object, R extends object = {}> {
160
160
  private readonly pool;
@@ -181,6 +181,14 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
181
181
  private readonly warnOnUnlimited;
182
182
  private readonly scopedConnect;
183
183
  private readonly utcTimestamps;
184
+ /**
185
+ * How a Postgres temporal `infinity` / `-infinity` is handed back:
186
+ * `'preserve'` (default) or `'null'`. See {@link TemporalInfinityReading}
187
+ * for the trade, and {@link parseRow} for where it is applied.
188
+ */
189
+ private readonly temporalInfinity;
190
+ /** Whether `temporalInfinity` was left unset, i.e. the warning still applies. */
191
+ private readonly warnTemporalInfinityUnset;
184
192
  private readonly preparedStatementsEnabled;
185
193
  /**
186
194
  * Whether the SQL template cache is active. Set once in the constructor.
@@ -1010,5 +1018,52 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
1010
1018
  * memoized per table. Used so nested relation rows (camelCase keys) coerce
1011
1019
  * dates the same way top-level rows do.
1012
1020
  */
1021
+ /**
1022
+ * Say ONCE per `table.field` that a stored temporal `infinity` was actually
1023
+ * read, and describe the reading the caller is getting. JavaScript has no
1024
+ * `Date` for either infinity, so BOTH readings cost something and a caller
1025
+ * whose rows carry the value needs to know which cost they are paying.
1026
+ *
1027
+ * Under the default `'preserve'` the value comes back as the JS number
1028
+ * `Infinity` / `-Infinity` on a field the generated types declare as `Date`,
1029
+ * so `.toISOString()` / `.getTime()` throw a TypeError on exactly those rows
1030
+ * and `JSON.stringify` still renders them `null` (JSON has no infinity
1031
+ * literal). That is the price of the reading being LOSSLESS: the number binds
1032
+ * straight back, so a read-modify-write stores `infinity` again. The
1033
+ * alternative, `'null'`, is silently destructive, which is why it is not the
1034
+ * default and why the warning names it as a deliberate choice rather than a
1035
+ * recommendation.
1036
+ *
1037
+ * ONLY WHEN THE OPTION WAS LEFT UNSET. Naming a reading in the config, either
1038
+ * one, is the acknowledgement, and the warning exists to surface an
1039
+ * unacknowledged trade rather than to nag.
1040
+ *
1041
+ * NOT DEV-ONLY, unlike every other warning in this codebase, and deliberately
1042
+ * so. Production is exactly where a destructive write commits and where the
1043
+ * row cannot be recovered afterwards, so a warning that goes quiet under
1044
+ * `NODE_ENV=production` is silent in the only place it matters. The cost is
1045
+ * bounded to the point of irrelevance: once per process per field, and only
1046
+ * on a row that actually held an infinity.
1047
+ *
1048
+ * KEYED ON THE FIELD, not the row key: top-level rows arrive snake_case and
1049
+ * nested `json_build_object` rows arrive camelCase, so keying on the raw key
1050
+ * warned twice for one column.
1051
+ */
1052
+ private warnTemporalInfinity;
1053
+ /**
1054
+ * Apply the configured reading to the infinity elements of a temporal ARRAY
1055
+ * value, returning the original array by identity when there are none (the
1056
+ * overwhelmingly common case, so no per-row allocation on ordinary data).
1057
+ */
1058
+ private mapArrayTemporalInfinity;
1059
+ /**
1060
+ * The configured reading of one infinity value: the JS number (`'preserve'`,
1061
+ * the default) or `null`. Under `'preserve'` the JSON-wire STRING form
1062
+ * (`"infinity"`, what the join and positional strategies see) is normalized
1063
+ * to the number too, so the reading is identical on every strategy; 0.54
1064
+ * shipped the number on some paths and an Invalid Date on others, which is
1065
+ * the bug that made a single reading necessary in the first place.
1066
+ */
1067
+ private readTemporalInfinity;
1013
1068
  private parseRow;
1014
1069
  }
@@ -20,7 +20,7 @@ import { defaultProjectionFields, includeKeysForBatching, loadRelationsBatched,
20
20
  import { expandCompoundUniqueWhere } from './compound-unique.js';
21
21
  import { isJsonPathOrderBy, isOrderBySpec, isRelationPickOrderBy, isVectorOrderBy, isWhereOperator, orderByEntries, sortedEntries, } from './filters.js';
22
22
  import * as relationsMod from './relations.js';
23
- import { LRUCache, ownLookup, parseDbDate, resolveColumnName, sqlToPreparedName, unknownFieldMessage, } from './utils.js';
23
+ import { isTemporalInfinity, LRUCache, ownLookup, parseDbDate, resolveColumnName, sqlToPreparedName, unknownFieldMessage, } from './utils.js';
24
24
  import { shouldWarnOnce, WARN_NS } from './warn-registry.js';
25
25
  import * as whereMod from './where.js';
26
26
  import * as writesMod from './writes.js';
@@ -341,6 +341,14 @@ export class QueryInterface {
341
341
  warnOnUnlimited;
342
342
  scopedConnect;
343
343
  utcTimestamps;
344
+ /**
345
+ * How a Postgres temporal `infinity` / `-infinity` is handed back:
346
+ * `'preserve'` (default) or `'null'`. See {@link TemporalInfinityReading}
347
+ * for the trade, and {@link parseRow} for where it is applied.
348
+ */
349
+ temporalInfinity;
350
+ /** Whether `temporalInfinity` was left unset, i.e. the warning still applies. */
351
+ warnTemporalInfinityUnset;
344
352
  preparedStatementsEnabled;
345
353
  /**
346
354
  * Whether the SQL template cache is active. Set once in the constructor.
@@ -487,6 +495,17 @@ export class QueryInterface {
487
495
  : warnOpt !== false;
488
496
  this.scopedConnect = options?.scopedConnect === true;
489
497
  this.utcTimestamps = options?.utcTimestamps !== false;
498
+ // Unset resolves to `'preserve'`. `'null'` destroys data: it makes a stored
499
+ // infinity indistinguishable from a stored NULL, so the ordinary
500
+ // read-modify-write (`update({ data: { ...row } })`) stores SQL NULL over a
501
+ // nullable temporal column and the infinity is gone with no error, measured
502
+ // on a live server. A lossy default is the wrong price for a nicer
503
+ // `JSON.stringify`, so the lossless reading is the default and `'null'`
504
+ // stays available as an explicit opt-in.
505
+ this.temporalInfinity = options?.temporalInfinity === 'null' ? 'null' : 'preserve';
506
+ // The warning exists to surface an UNACKNOWLEDGED trade. Naming either
507
+ // reading in the config is the acknowledgement, so it stops.
508
+ this.warnTemporalInfinityUnset = options?.temporalInfinity === undefined;
490
509
  this.preparedStatementsEnabled = options?.preparedStatements ?? true;
491
510
  // SQL template cache capacity. `sqlCacheSize: 0` disables caching entirely
492
511
  // (mirrors `sqlCache: false`); any positive integer sets the LRU bound;
@@ -583,6 +602,9 @@ export class QueryInterface {
583
602
  // building a client whose writes and reads disagree (see
584
603
  // `assertUtcTimestampsAgree` in client.ts).
585
604
  utcTimestamps: this.utcTimestamps,
605
+ // Reaches aggregates.ts, where `_min` / `_max` are assembled from the raw
606
+ // row and so need the same infinity reading as `parseRow`.
607
+ temporalInfinity: this.temporalInfinity,
586
608
  crossSchemaTypeColumns: this.crossSchemaTypeColumns,
587
609
  get currentSkip() {
588
610
  return self.currentSkip;
@@ -3019,6 +3041,87 @@ export class QueryInterface {
3019
3041
  * memoized per table. Used so nested relation rows (camelCase keys) coerce
3020
3042
  * dates the same way top-level rows do.
3021
3043
  */
3044
+ /**
3045
+ * Say ONCE per `table.field` that a stored temporal `infinity` was actually
3046
+ * read, and describe the reading the caller is getting. JavaScript has no
3047
+ * `Date` for either infinity, so BOTH readings cost something and a caller
3048
+ * whose rows carry the value needs to know which cost they are paying.
3049
+ *
3050
+ * Under the default `'preserve'` the value comes back as the JS number
3051
+ * `Infinity` / `-Infinity` on a field the generated types declare as `Date`,
3052
+ * so `.toISOString()` / `.getTime()` throw a TypeError on exactly those rows
3053
+ * and `JSON.stringify` still renders them `null` (JSON has no infinity
3054
+ * literal). That is the price of the reading being LOSSLESS: the number binds
3055
+ * straight back, so a read-modify-write stores `infinity` again. The
3056
+ * alternative, `'null'`, is silently destructive, which is why it is not the
3057
+ * default and why the warning names it as a deliberate choice rather than a
3058
+ * recommendation.
3059
+ *
3060
+ * ONLY WHEN THE OPTION WAS LEFT UNSET. Naming a reading in the config, either
3061
+ * one, is the acknowledgement, and the warning exists to surface an
3062
+ * unacknowledged trade rather than to nag.
3063
+ *
3064
+ * NOT DEV-ONLY, unlike every other warning in this codebase, and deliberately
3065
+ * so. Production is exactly where a destructive write commits and where the
3066
+ * row cannot be recovered afterwards, so a warning that goes quiet under
3067
+ * `NODE_ENV=production` is silent in the only place it matters. The cost is
3068
+ * bounded to the point of irrelevance: once per process per field, and only
3069
+ * on a row that actually held an infinity.
3070
+ *
3071
+ * KEYED ON THE FIELD, not the row key: top-level rows arrive snake_case and
3072
+ * nested `json_build_object` rows arrive camelCase, so keying on the raw key
3073
+ * warned twice for one column.
3074
+ */
3075
+ warnTemporalInfinity(table, field) {
3076
+ if (!this.warnTemporalInfinityUnset)
3077
+ return;
3078
+ if (!shouldWarnOnce(WARN_NS.temporalInfinity, `${table}.${field}`))
3079
+ return;
3080
+ console.warn(`[turbine] ${table}.${field} holds the Postgres value \`infinity\` (or \`-infinity\`). JavaScript has ` +
3081
+ 'no Date for it, so it reads as the JS number `Infinity` / `-Infinity` on a field the generated ' +
3082
+ 'types declare as `Date`: `.toISOString()` and `.getTime()` throw a TypeError on these rows, and ' +
3083
+ '`JSON.stringify` still renders them null because JSON has no infinity literal. The number is the ' +
3084
+ 'lossless reading, though: it binds straight back, so writing a row you just read stores `infinity` ' +
3085
+ 'again. Two more things this column can no longer do: `where: { col: null }` still means IS NULL and ' +
3086
+ "does NOT match these rows (filter them with `{ col: 'infinity' }`), and `groupBy` / `distinct` " +
3087
+ "cannot tell infinity, -infinity and NULL apart. Set `temporalInfinity: 'preserve'` to confirm this " +
3088
+ "reading and silence the warning, or `'null'` to read null instead, accepting that a stored " +
3089
+ 'infinity then looks exactly like a stored NULL and a read-modify-write over a nullable column ' +
3090
+ 'stores SQL NULL, destroying the value with no error.');
3091
+ }
3092
+ /**
3093
+ * Apply the configured reading to the infinity elements of a temporal ARRAY
3094
+ * value, returning the original array by identity when there are none (the
3095
+ * overwhelmingly common case, so no per-row allocation on ordinary data).
3096
+ */
3097
+ mapArrayTemporalInfinity(value, table, field) {
3098
+ let hit = false;
3099
+ for (let i = 0; i < value.length; i++) {
3100
+ if (isTemporalInfinity(value[i])) {
3101
+ hit = true;
3102
+ break;
3103
+ }
3104
+ }
3105
+ if (!hit)
3106
+ return value;
3107
+ this.warnTemporalInfinity(table, field);
3108
+ return value.map((el) => (isTemporalInfinity(el) ? this.readTemporalInfinity(el) : el));
3109
+ }
3110
+ /**
3111
+ * The configured reading of one infinity value: the JS number (`'preserve'`,
3112
+ * the default) or `null`. Under `'preserve'` the JSON-wire STRING form
3113
+ * (`"infinity"`, what the join and positional strategies see) is normalized
3114
+ * to the number too, so the reading is identical on every strategy; 0.54
3115
+ * shipped the number on some paths and an Invalid Date on others, which is
3116
+ * the bug that made a single reading necessary in the first place.
3117
+ */
3118
+ readTemporalInfinity(value) {
3119
+ if (this.temporalInfinity === 'null')
3120
+ return null;
3121
+ if (typeof value === 'number')
3122
+ return value;
3123
+ return value === '-infinity' ? Number.NEGATIVE_INFINITY : Number.POSITIVE_INFINITY;
3124
+ }
3022
3125
  parseRow(row, table) {
3023
3126
  const parsed = {};
3024
3127
  const meta = this.schema.tables[table];
@@ -3035,29 +3138,48 @@ export class QueryInterface {
3035
3138
  const value = row[col];
3036
3139
  const field = reverseMap[col] ?? col; // fall back to raw col name, not regex
3037
3140
  // Top-level rows are snake_case (dateCols); nested rows are camelCase (camelDateFields).
3038
- //
3039
- // An ARRAY value is excluded: `dateColumns` includes array-of-date
3040
- // columns (`date[]`, `timestamp[]`, `timestamptz[]`), for which the
3041
- // driver already hands back a `Date[]`. Coercing it ran
3042
- // `new Date(String(theArray))` and replaced the whole array with a
3043
- // single Invalid Date, the column was unreadable on every strategy.
3044
- // The join strategy's string arrays are handled upstream instead, by
3045
- // the JSON-wire decode in relations.ts.
3046
- //
3047
- // A NUMBER is excluded for the same reason: the driver returns the
3048
- // JS numbers `Infinity` / `-Infinity` for the Postgres `infinity` /
3049
- // `-infinity` timestamp values, and re-coercing one ran
3050
- // `parseDbDate(String(Infinity))` = `parseDbDate('Infinity')`, which
3051
- // is an Invalid Date that JSON-encodes as null. The array form escaped
3052
- // this only because it took the branch above.
3053
- if ((dateCols.has(col) || camelDateFields.has(field)) &&
3054
- value !== null &&
3055
- !(value instanceof Date) &&
3056
- !Array.isArray(value) &&
3057
- typeof value !== 'number') {
3058
- // Offset-less strings (Postgres `timestamp`, json_agg output) are
3059
- // pinned to UTC so results don't depend on the server's time zone.
3060
- parsed[field] = this.utcTimestamps ? parseDbDate(String(value)) : new Date(value);
3141
+ if ((dateCols.has(col) || camelDateFields.has(field)) && value !== null && !(value instanceof Date)) {
3142
+ if (isTemporalInfinity(value)) {
3143
+ // Postgres `infinity` / `-infinity`. No JS Date means either, so
3144
+ // both readings cost something and the default is the one that is
3145
+ // not lossy. `'preserve'` hands back the JS number, which breaks
3146
+ // the declared `Date` type at runtime (`.toISOString()` throws) and
3147
+ // still serializes as null because JSON has no infinity literal,
3148
+ // but binds straight back, so a read-modify-write stores `infinity`
3149
+ // again. `'null'` reads nicer and DESTROYS the value on that same
3150
+ // write, because a stored infinity and a stored NULL become
3151
+ // indistinguishable. Whichever is configured, it is the SAME on
3152
+ // every read strategy: the driver hands back the number,
3153
+ // `json_build_object` hands back the string "infinity", and both
3154
+ // land here (see `isTemporalInfinity`).
3155
+ //
3156
+ // The warning below fires once per column when the option was left
3157
+ // unset, on a row that actually held an infinity, and describes the
3158
+ // reading in force rather than gating on which one it is.
3159
+ this.warnTemporalInfinity(table, field);
3160
+ parsed[field] = this.readTemporalInfinity(value);
3161
+ }
3162
+ else if (Array.isArray(value)) {
3163
+ // `dateColumns` includes array-of-date columns (`date[]`,
3164
+ // `timestamp[]`, `timestamptz[]`), for which the driver already
3165
+ // hands back a `Date[]`. Coercing the array itself ran
3166
+ // `new Date(String(theArray))` and replaced the whole column with
3167
+ // one Invalid Date. Its ELEMENTS get the same infinity mapping as a
3168
+ // scalar (same declared element type, same JSON rendering);
3169
+ // everything else is passed through by identity.
3170
+ parsed[field] = this.mapArrayTemporalInfinity(value, table, field);
3171
+ }
3172
+ else if (typeof value === 'number') {
3173
+ // Any other number on a date column is left alone rather than run
3174
+ // through `parseDbDate(String(n))`, which would produce an Invalid
3175
+ // Date.
3176
+ parsed[field] = value;
3177
+ }
3178
+ else {
3179
+ // Offset-less strings (Postgres `timestamp`, json_agg output) are
3180
+ // pinned to UTC so results don't depend on the server's time zone.
3181
+ parsed[field] = this.utcTimestamps ? parseDbDate(String(value)) : new Date(value);
3182
+ }
3061
3183
  }
3062
3184
  else {
3063
3185
  parsed[field] = value;
@@ -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
  /**