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/README.md CHANGED
@@ -429,7 +429,7 @@ const db = turbine({
429
429
  });
430
430
  ```
431
431
 
432
- > **`utcTimestamps` is a process-wide decision, not a per-client one.** The two halves settle differently. The WRITE half is per client: a `Date` bound to a zone-less `date` / `timestamp` column is rewritten to a UTC literal unless that client opted out. The READ half is a set of pg type parsers on OIDs 1114 (`timestamp`), 1082 (`date`) and their array forms 1115 / 1182, and `pg.types.setTypeParser` installs one parser per OID for the whole pg module, shared by every pool, every raw query, and any other library on the same `pg`. The first client in the process settles it for all the rest. Constructing a second client with the **opposite** value therefore throws `ValidationError` (`TURBINE_E003`) at construction, rather than handing back a client that writes UTC and reads local (or the reverse) and so does not round-trip its own values. The message names both values and the two ways out: give every client in the process the same `utcTimestamps`, or run the odd one out in its own process. Clients built on an external pool (`pool: ...`, `turbineHttp()`) never REGISTER a parser and settle nothing on their own, so a process holding only those keeps whatever parser configuration the caller set up. They are not exempt from the check, though: registration is process-global, so once a Turbine-owned client has installed the parsers an external-pool client reads through them too, and a disagreeing one would write local `date` literals while reading UTC.
432
+ > **`utcTimestamps` is a process-wide decision, not a per-client one.** The two halves settle differently. The WRITE half is per client: a `Date` bound to a zone-less `date` / `timestamp` column is rewritten to a UTC literal unless that client opted out. The READ half is a set of pg type parsers on OIDs 1114 (`timestamp`), 1082 (`date`) and their array forms 1115 / 1182, and `pg.types.setTypeParser` installs one parser per OID for the whole pg module, shared by every pool, every raw query, and any other library on the same `pg`. It is also **retroactive**: there is one parser table and it is read per row at decode time, so registering changes pools **that already exist and are already querying**, not just pools created afterwards. The same `pg.Pool` running the same query returns different values before and after some unrelated module constructs a Turbine client, and with lazy route imports that ordering is not stable between requests. If the OID had already been customized by something else, Turbine emits a one-time dev warning rather than replacing it silently. The first client in the process settles it for all the rest. Constructing a second client with the **opposite** value therefore throws `ValidationError` (`TURBINE_E003`) at construction, rather than handing back a client that writes UTC and reads local (or the reverse) and so does not round-trip its own values. The message names both values and the two ways out: give every client in the process the same `utcTimestamps`, or run the odd one out in its own process. Clients built on an external pool (`pool: ...`, `turbineHttp()`) never REGISTER a parser and settle nothing on their own, so a process holding only those keeps whatever parser configuration the caller set up. They are not exempt from the check, though: registration is process-global, so once a Turbine-owned client has installed the parsers an external-pool client reads through them too, and a disagreeing one would write local `date` literals while reading UTC.
433
433
 
434
434
  > **Upgrading with `utcTimestamps: false`.** If you already set `false`, this release changes the **stored text** of your writes: the write path now honors the flag where it previously ignored it, so zone-less columns receive local-calendar literals instead of UTC ones. Rows written before the upgrade and rows written after carry two conventions in the same column until you backfill.
435
435
 
@@ -454,7 +454,7 @@ const db = turbine({
454
454
 
455
455
  Where a pg-style alias exists (`max`, `idleTimeoutMillis`, `connectionTimeoutMillis`), the explicit Turbine field wins when both are set.
456
456
 
457
- > **`planCacheMode` (Postgres only, opt-in).** PostgreSQL promotes a **named** prepared statement to a generic plan on its sixth execution, and a generic plan is costed blind to the bound values. On a predicate whose selectivity swings per value (a `tenant_id` equality on a shared table, where one value matches a handful of rows and another matches most of them), the statement can be locked onto a plan chosen for the average value, and it never reverts. `planCacheMode: 'auto' | 'force_custom_plan' | 'force_generic_plan'` pins the backend's choice; `'force_custom_plan'` re-plans every execution and removes the cliff. It is applied as a connection parameter (`options=-c plan_cache_mode=...`) when Turbine opens a connection, so it is in force for that connection's first statement and for every checkout, `$transaction`, stream and pipeline on it, and it cannot race your first query. Leave it unset (the default) and Turbine sends nothing at all. Reach for it when you have measured a statement getting slower after its fifth execution: `count()` on a skewed predicate is the shape that promotes through Turbine, while `findMany` / `findFirst` bind `LIMIT $n` and are much less exposed. Three scope limits: it does nothing on an **external pool** (Turbine never opens those connections, so set the GUC in the driver's own setup; Turbine-owned string `replicas` on that same client DO get it); a Postgres wire-compatible engine without the setting (CockroachDB, YugabyteDB, pre-12 PostgreSQL) refuses the connection parameter itself; and a **connection pooler** may filter startup parameters (PgBouncer's `ignore_startup_parameters`), where `ALTER ROLE ... SET plan_cache_mode = ...` is the way in. Any value outside the three throws `ValidationError` at construction, and a non-Postgres engine throws `UnsupportedFeatureError` (`TURBINE_E017`).
457
+ > **`planCacheMode` (Postgres only, opt-in).** PostgreSQL may promote a **named** prepared statement to a generic plan from its sixth execution onward, and a generic plan is costed blind to the bound values. On a predicate whose selectivity swings per value (a `tenant_id` equality on a shared table, where one value matches a handful of rows and another matches most of them), the statement can be locked onto a plan chosen for the average value, and it never reverts. `planCacheMode: 'auto' | 'force_custom_plan' | 'force_generic_plan'` pins the backend's choice; `'force_custom_plan'` re-plans every execution and removes the cliff. It is applied as a connection parameter (`options=-c plan_cache_mode=...`) when Turbine opens a connection, so it is in force for that connection's first statement and for every checkout, `$transaction`, stream and pipeline on it, and it cannot race your first query. Leave it unset (the default) and Turbine sends nothing at all. Reach for it when you have measured a statement getting slower after its fifth execution. **Correction to the 0.54 text, which said `findMany` / `findFirst` bind `LIMIT $n` and are "much less exposed":** that was false. PostgreSQL does not deny the planner a limit fraction for a bound limit, it substitutes a default of 10% of the child node's own row estimate (clamped at one row), and an unknown `OFFSET` triggers the same substitution even when the limit is a constant, which a paginated Turbine read always has. Two things also need saying about the sentence that opens this note. The sixth execution is a ceiling, not a trigger: `auto` promotes only when the generic plan's **estimated** cost is not worse than the average custom cost, so many statements are never promoted at all, and `pg_prepared_statements.generic_plans` is how you tell. And the shape that gets promoted unprompted is the one with **no limit**, not the limited one: measured on a skewed join predicate, an unlimited `count()`-shaped statement promoted under the default `auto` and ran a nested loop at 430x the buffers of the custom plan, while the same predicate under `LIMIT $n` was never promoted across eight executions (its substituted row count made the generic plan look more expensive). A limited `findMany` gives the planner two unknowns instead of one, which is not the same as more damage. `implicitPkOrdering` is **off by default in core**, so a default `findMany` emits no `ORDER BY`; switching it on adds an ordering a generic plan can walk the whole table in. Measure with `plan_cache_mode = force_generic_plan` against `force_custom_plan` rather than reasoning about which shapes ought to be safe; the fixtures and numbers are on the [relations page](https://turbineorm.dev/relations) and in the 0.55.0 changelog. Three scope limits: it does nothing on an **external pool** (Turbine never opens those connections, so set the GUC in the driver's own setup; Turbine-owned string `replicas` on that same client DO get it); a Postgres wire-compatible engine without the setting (CockroachDB, YugabyteDB, pre-12 PostgreSQL) refuses the connection parameter itself; and a **connection pooler** may filter startup parameters (PgBouncer's `ignore_startup_parameters`), where `ALTER ROLE ... SET plan_cache_mode = ...` is the way in. Any value outside the three throws `ValidationError` at construction, and a non-Postgres engine throws `UnsupportedFeatureError` (`TURBINE_E017`).
458
458
 
459
459
  > **`preparedStatements` and connection poolers.** With prepared statements on, Turbine submits queries as `{ name, text, values }` so Postgres caches the parse and plan **per backend connection**. That is a real win against a database you connect to directly, and a hazard behind a transaction-pooling proxy (PgBouncer in `transaction` mode, Supabase's pooler port, some serverless poolers): the named statement is prepared on one backend and your next query may land on another, which fails with `prepared statement "..." does not exist`. Turbine defaults it to `true` only for pools it creates itself and `false` for external pools passed via `pool` / `turbineHttp()`, because serverless drivers are the common case there. If you are pointing a Turbine-owned pool at a transaction pooler, set `preparedStatements: false`. The environment variable `TURBINE_DISABLE_PREPARED=1` turns it off globally without a code change.
460
460
 
@@ -1097,7 +1097,7 @@ Turbine maps Postgres types to TypeScript:
1097
1097
  | `int8` / `bigint` | `number` | Values > `Number.MAX_SAFE_INTEGER` (2^53 - 1) are returned as `string` at runtime to avoid precision loss. This affects < 0.01% of use cases (auto-increment IDs, counts, etc. are all safe). |
1098
1098
  | `numeric`, `money` | `string` | Arbitrary precision, kept as string to avoid JS float issues |
1099
1099
  | `text`, `varchar`, `uuid`, `citext` | `string` | |
1100
- | `timestamptz`, `timestamp`, `date` | `Date` | `timestamp` (without time zone) **and `date`** are parsed as UTC by default (Prisma/Rails/Django convention), so the same row yields the same instant in every region. Opt out with `utcTimestamps: false`. Since v0.52 the flag also reaches the WRITE side on Postgres: it governs the `Date` values that `create` / `update` / `upsert` and `where` clauses bind to zone-less `date` / `timestamp` columns. Before v0.52 it reached the read path only, so a client that had set `false` was still binding UTC, and those statements (and the text they store) change on upgrade. The two halves settle at different scopes: the write half is per client, the read half is a process-global pg type parser, so every client in one process must agree or construction throws `ValidationError`. Since v0.54 `date` reads at UTC midnight rather than the process's local midnight (its array form too), which moves the **epoch value** of a `date` by your process's UTC offset even though the calendar day it denotes is unchanged: format with `toISOString().slice(0, 10)`, not with local-component helpers like `toLocaleDateString()`. See [Relation loading and wire encoding](#relation-loading-and-wire-encoding). |
1100
+ | `timestamptz`, `timestamp`, `date` | `Date` | `timestamp` (without time zone) **and `date`** are parsed as UTC by default (Prisma/Rails/Django convention), so the same row yields the same instant in every region. Opt out with `utcTimestamps: false`. Since v0.52 the flag also reaches the WRITE side on Postgres: it governs the `Date` values that `create` / `update` / `upsert` and `where` clauses bind to zone-less `date` / `timestamp` columns. Before v0.52 it reached the read path only, so a client that had set `false` was still binding UTC, and those statements (and the text they store) change on upgrade. The two halves settle at different scopes: the write half is per client, the read half is a process-global pg type parser, so every client in one process must agree or construction throws `ValidationError`. Since v0.54 `date` reads at UTC midnight rather than the process's local midnight (its array form too), which moves the **epoch value** of a `date` by your process's UTC offset even though the calendar day it denotes is unchanged: format with `toISOString().slice(0, 10)`, not with local-component helpers like `toLocaleDateString()`. Since v0.55 a stored `infinity` / `-infinity` reads as the JS number `Infinity` / `-Infinity` on **every** read strategy (v0.54 gave the number on the top-level and batched paths and an `Invalid Date` through a `with` clause, because `json_build_object` renders the value as the string `"infinity"` that no driver parser sees). There is no JS `Date` for either value, so the reading costs something either way and the default is the one that cannot **lose** a value: the number binds straight back, so `update({ data: { ...row } })` stores `infinity` again, whereas reading it as `null` makes a stored infinity indistinguishable from a stored NULL and that same write silently stores SQL NULL. The price of the default is that the field is declared `Date` and hands back a number, so `.toISOString()` / `.getTime()` throw a `TypeError` on those rows and `JSON.stringify` still renders them `null`. Set `temporalInfinity: 'null'` to read `null` instead, accepting the data loss, the collapse of `groupBy` / `distinct` keys, and `_max` returning `null` on a table with rows. Either way the value stays writable as `'infinity'` / `'-infinity'`, a one-time warning names the field the first time one is read (this one is NOT silenced by `NODE_ENV=production`, and naming either reading silences it), and `where: { col: null }` still means `IS NULL` and does not match those rows. See [Relation loading and wire encoding](#relation-loading-and-wire-encoding). |
1101
1101
  | `boolean` | `boolean` | |
1102
1102
  | `json`, `jsonb` | `unknown` | |
1103
1103
  | `bytea` | `Buffer` | |
@@ -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
@@ -109,6 +109,7 @@ const TURBINE_CONFIG_KEYS = {
109
109
  defaultLimit: true,
110
110
  warnOnUnlimited: true,
111
111
  utcTimestamps: true,
112
+ temporalInfinity: true,
112
113
  planCacheMode: true,
113
114
  scopedConnect: true,
114
115
  relationLoadStrategy: true,
@@ -599,10 +600,11 @@ class TurbineClient {
599
600
  // constructor-gated by the static flags, so it happens at most once.
600
601
  const ownsAnyPool = !config.pool;
601
602
  if (ownsAnyPool && !TurbineClient.int8ParserRegistered) {
602
- pg_1.default.types.setTypeParser(20, (val) => {
603
+ (0, utils_js_1.warnParserOverwrite)(20, 'int8');
604
+ pg_1.default.types.setTypeParser(20, (0, utils_js_1.markTurbineParser)((val) => {
603
605
  const n = Number(val);
604
606
  return Number.isSafeInteger(n) ? n : val;
605
- });
607
+ }));
606
608
  TurbineClient.int8ParserRegistered = true;
607
609
  }
608
610
  // Parse the zone-less temporal types (`timestamp` OID 1114, `date` OID
@@ -646,6 +648,7 @@ class TurbineClient {
646
648
  defaultLimit: config.defaultLimit,
647
649
  warnOnUnlimited: config.warnOnUnlimited,
648
650
  utcTimestamps: config.utcTimestamps,
651
+ temporalInfinity: TurbineClient.resolveTemporalInfinity(config.temporalInfinity),
649
652
  scopedConnect: config.scopedConnect,
650
653
  relationLoadStrategy: config.relationLoadStrategy,
651
654
  stableRelationOrder: config.stableRelationOrder,
@@ -817,6 +820,23 @@ class TurbineClient {
817
820
  * `UnsupportedFeatureError` (E017), in the same style as the other
818
821
  * capability refusals.
819
822
  */
823
+ /**
824
+ * Validate the `temporalInfinity` reading. A closed two-value enum, checked
825
+ * at construction so a typo (`'preserved'`, `'raw'`) fails loudly rather than
826
+ * silently falling back to the default reading the caller was trying to
827
+ * change.
828
+ */
829
+ static resolveTemporalInfinity(value) {
830
+ if (value === undefined)
831
+ return undefined;
832
+ if (value !== 'null' && value !== 'preserve') {
833
+ throw new errors_js_1.ValidationError(`Invalid temporalInfinity: ${JSON.stringify(value)}. Expected 'preserve' (default: read a Postgres ` +
834
+ 'temporal `infinity` as the JS number `Infinity` / `-Infinity`, which round-trips through a write ' +
835
+ "but breaks the declared `Date` type) or 'null' (read it as null, which serializes cleanly but " +
836
+ 'makes it indistinguishable from a stored NULL, so a read-modify-write destroys the value).');
837
+ }
838
+ return value;
839
+ }
820
840
  static resolvePlanCacheMode(mode, dialect) {
821
841
  if (mode === undefined || mode === null)
822
842
  return undefined;
@@ -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';
@@ -353,12 +353,14 @@ function buildGroupBy(qi, args) {
353
353
  }
354
354
  else if (rawKey.startsWith('_min_')) {
355
355
  const j = jsonAgg(rawKey);
356
- minObj[fieldFor(rawKey, rawKey.slice(5))] = j?.numeric && rawValue !== null ? Number(rawValue) : rawValue;
356
+ minObj[fieldFor(rawKey, rawKey.slice(5))] =
357
+ j?.numeric && rawValue !== null ? Number(rawValue) : temporalAggValue(qi, rawKey.slice(5), rawValue);
357
358
  hasMins = true;
358
359
  }
359
360
  else if (rawKey.startsWith('_max_')) {
360
361
  const j = jsonAgg(rawKey);
361
- maxObj[fieldFor(rawKey, rawKey.slice(5))] = j?.numeric && rawValue !== null ? Number(rawValue) : rawValue;
362
+ maxObj[fieldFor(rawKey, rawKey.slice(5))] =
363
+ j?.numeric && rawValue !== null ? Number(rawValue) : temporalAggValue(qi, rawKey.slice(5), rawValue);
362
364
  hasMaxs = true;
363
365
  }
364
366
  }
@@ -768,6 +770,38 @@ function buildHavingNumericClauses(qi, expr, filter, params) {
768
770
  }
769
771
  return clauses;
770
772
  }
773
+ /**
774
+ * `_min` / `_max` over a TEMPORAL column, aligned with the row parser.
775
+ *
776
+ * These two are the only aggregates that hand back a row's stored cell rather
777
+ * than something computed across rows, so they are the only ones that can carry
778
+ * a Postgres `infinity`. They are assembled from the RAW row (a Date must stay
779
+ * a Date, and `parseRow`'s snake→camel mapping would collide with the `_min_`
780
+ * alias), so the infinity mapping has to be applied here too. Without it
781
+ * `aggregate({ _max: { ts: true } })` would return the driver's `Infinity`
782
+ * while `findMany` returned `null` for the very same value.
783
+ *
784
+ * Gated on the column being temporal, so a numeric `_max` is untouched, and on
785
+ * the client's `temporalInfinity` reading, so it cannot disagree with the rows
786
+ * `findMany` returns for the same column. An absent reading resolves to the
787
+ * default, `'preserve'`, exactly as it does in the row parser.
788
+ *
789
+ * Note what the opt-in `'null'` reading then means for `_max` on a table that
790
+ * plainly has rows: `null` is the same value an empty table and an all-NULL
791
+ * column return, so `_min` can report a real date while `_max` reports
792
+ * "nothing" (documented, and one of the reasons `'null'` is not the default).
793
+ */
794
+ function temporalAggValue(qi, col, value) {
795
+ if (!qi.tableMeta.dateColumns.has(col))
796
+ return value;
797
+ if (!(0, utils_js_1.isTemporalInfinity)(value))
798
+ return value;
799
+ if (qi.temporalInfinity === 'null')
800
+ return null;
801
+ if (typeof value === 'number')
802
+ return value;
803
+ return value === '-infinity' ? Number.NEGATIVE_INFINITY : Number.POSITIVE_INFINITY;
804
+ }
771
805
  function buildAggregate(qi, args) {
772
806
  qi.currentSkip = args.skipGlobalFilters;
773
807
  const aggWhere = whereMod.mergeGlobalFilter(qi, args.where);
@@ -912,13 +946,13 @@ function buildAggregate(qi, args) {
912
946
  else if (key.startsWith('_min_')) {
913
947
  const col = key.slice(5);
914
948
  const field = qi.tableMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col);
915
- minObj[field] = val;
949
+ minObj[field] = temporalAggValue(qi, col, val);
916
950
  hasMins = true;
917
951
  }
918
952
  else if (key.startsWith('_max_')) {
919
953
  const col = key.slice(5);
920
954
  const field = qi.tableMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col);
921
- maxObj[field] = val;
955
+ maxObj[field] = temporalAggValue(qi, col, val);
922
956
  hasMaxs = true;
923
957
  }
924
958
  }
@@ -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
  }
@@ -377,6 +377,14 @@ class QueryInterface {
377
377
  warnOnUnlimited;
378
378
  scopedConnect;
379
379
  utcTimestamps;
380
+ /**
381
+ * How a Postgres temporal `infinity` / `-infinity` is handed back:
382
+ * `'preserve'` (default) or `'null'`. See {@link TemporalInfinityReading}
383
+ * for the trade, and {@link parseRow} for where it is applied.
384
+ */
385
+ temporalInfinity;
386
+ /** Whether `temporalInfinity` was left unset, i.e. the warning still applies. */
387
+ warnTemporalInfinityUnset;
380
388
  preparedStatementsEnabled;
381
389
  /**
382
390
  * Whether the SQL template cache is active. Set once in the constructor.
@@ -523,6 +531,17 @@ class QueryInterface {
523
531
  : warnOpt !== false;
524
532
  this.scopedConnect = options?.scopedConnect === true;
525
533
  this.utcTimestamps = options?.utcTimestamps !== false;
534
+ // Unset resolves to `'preserve'`. `'null'` destroys data: it makes a stored
535
+ // infinity indistinguishable from a stored NULL, so the ordinary
536
+ // read-modify-write (`update({ data: { ...row } })`) stores SQL NULL over a
537
+ // nullable temporal column and the infinity is gone with no error, measured
538
+ // on a live server. A lossy default is the wrong price for a nicer
539
+ // `JSON.stringify`, so the lossless reading is the default and `'null'`
540
+ // stays available as an explicit opt-in.
541
+ this.temporalInfinity = options?.temporalInfinity === 'null' ? 'null' : 'preserve';
542
+ // The warning exists to surface an UNACKNOWLEDGED trade. Naming either
543
+ // reading in the config is the acknowledgement, so it stops.
544
+ this.warnTemporalInfinityUnset = options?.temporalInfinity === undefined;
526
545
  this.preparedStatementsEnabled = options?.preparedStatements ?? true;
527
546
  // SQL template cache capacity. `sqlCacheSize: 0` disables caching entirely
528
547
  // (mirrors `sqlCache: false`); any positive integer sets the LRU bound;
@@ -619,6 +638,9 @@ class QueryInterface {
619
638
  // building a client whose writes and reads disagree (see
620
639
  // `assertUtcTimestampsAgree` in client.ts).
621
640
  utcTimestamps: this.utcTimestamps,
641
+ // Reaches aggregates.ts, where `_min` / `_max` are assembled from the raw
642
+ // row and so need the same infinity reading as `parseRow`.
643
+ temporalInfinity: this.temporalInfinity,
622
644
  crossSchemaTypeColumns: this.crossSchemaTypeColumns,
623
645
  get currentSkip() {
624
646
  return self.currentSkip;
@@ -3055,6 +3077,87 @@ class QueryInterface {
3055
3077
  * memoized per table. Used so nested relation rows (camelCase keys) coerce
3056
3078
  * dates the same way top-level rows do.
3057
3079
  */
3080
+ /**
3081
+ * Say ONCE per `table.field` that a stored temporal `infinity` was actually
3082
+ * read, and describe the reading the caller is getting. JavaScript has no
3083
+ * `Date` for either infinity, so BOTH readings cost something and a caller
3084
+ * whose rows carry the value needs to know which cost they are paying.
3085
+ *
3086
+ * Under the default `'preserve'` the value comes back as the JS number
3087
+ * `Infinity` / `-Infinity` on a field the generated types declare as `Date`,
3088
+ * so `.toISOString()` / `.getTime()` throw a TypeError on exactly those rows
3089
+ * and `JSON.stringify` still renders them `null` (JSON has no infinity
3090
+ * literal). That is the price of the reading being LOSSLESS: the number binds
3091
+ * straight back, so a read-modify-write stores `infinity` again. The
3092
+ * alternative, `'null'`, is silently destructive, which is why it is not the
3093
+ * default and why the warning names it as a deliberate choice rather than a
3094
+ * recommendation.
3095
+ *
3096
+ * ONLY WHEN THE OPTION WAS LEFT UNSET. Naming a reading in the config, either
3097
+ * one, is the acknowledgement, and the warning exists to surface an
3098
+ * unacknowledged trade rather than to nag.
3099
+ *
3100
+ * NOT DEV-ONLY, unlike every other warning in this codebase, and deliberately
3101
+ * so. Production is exactly where a destructive write commits and where the
3102
+ * row cannot be recovered afterwards, so a warning that goes quiet under
3103
+ * `NODE_ENV=production` is silent in the only place it matters. The cost is
3104
+ * bounded to the point of irrelevance: once per process per field, and only
3105
+ * on a row that actually held an infinity.
3106
+ *
3107
+ * KEYED ON THE FIELD, not the row key: top-level rows arrive snake_case and
3108
+ * nested `json_build_object` rows arrive camelCase, so keying on the raw key
3109
+ * warned twice for one column.
3110
+ */
3111
+ warnTemporalInfinity(table, field) {
3112
+ if (!this.warnTemporalInfinityUnset)
3113
+ return;
3114
+ if (!(0, warn_registry_js_1.shouldWarnOnce)(warn_registry_js_1.WARN_NS.temporalInfinity, `${table}.${field}`))
3115
+ return;
3116
+ console.warn(`[turbine] ${table}.${field} holds the Postgres value \`infinity\` (or \`-infinity\`). JavaScript has ` +
3117
+ 'no Date for it, so it reads as the JS number `Infinity` / `-Infinity` on a field the generated ' +
3118
+ 'types declare as `Date`: `.toISOString()` and `.getTime()` throw a TypeError on these rows, and ' +
3119
+ '`JSON.stringify` still renders them null because JSON has no infinity literal. The number is the ' +
3120
+ 'lossless reading, though: it binds straight back, so writing a row you just read stores `infinity` ' +
3121
+ 'again. Two more things this column can no longer do: `where: { col: null }` still means IS NULL and ' +
3122
+ "does NOT match these rows (filter them with `{ col: 'infinity' }`), and `groupBy` / `distinct` " +
3123
+ "cannot tell infinity, -infinity and NULL apart. Set `temporalInfinity: 'preserve'` to confirm this " +
3124
+ "reading and silence the warning, or `'null'` to read null instead, accepting that a stored " +
3125
+ 'infinity then looks exactly like a stored NULL and a read-modify-write over a nullable column ' +
3126
+ 'stores SQL NULL, destroying the value with no error.');
3127
+ }
3128
+ /**
3129
+ * Apply the configured reading to the infinity elements of a temporal ARRAY
3130
+ * value, returning the original array by identity when there are none (the
3131
+ * overwhelmingly common case, so no per-row allocation on ordinary data).
3132
+ */
3133
+ mapArrayTemporalInfinity(value, table, field) {
3134
+ let hit = false;
3135
+ for (let i = 0; i < value.length; i++) {
3136
+ if ((0, utils_js_1.isTemporalInfinity)(value[i])) {
3137
+ hit = true;
3138
+ break;
3139
+ }
3140
+ }
3141
+ if (!hit)
3142
+ return value;
3143
+ this.warnTemporalInfinity(table, field);
3144
+ return value.map((el) => ((0, utils_js_1.isTemporalInfinity)(el) ? this.readTemporalInfinity(el) : el));
3145
+ }
3146
+ /**
3147
+ * The configured reading of one infinity value: the JS number (`'preserve'`,
3148
+ * the default) or `null`. Under `'preserve'` the JSON-wire STRING form
3149
+ * (`"infinity"`, what the join and positional strategies see) is normalized
3150
+ * to the number too, so the reading is identical on every strategy; 0.54
3151
+ * shipped the number on some paths and an Invalid Date on others, which is
3152
+ * the bug that made a single reading necessary in the first place.
3153
+ */
3154
+ readTemporalInfinity(value) {
3155
+ if (this.temporalInfinity === 'null')
3156
+ return null;
3157
+ if (typeof value === 'number')
3158
+ return value;
3159
+ return value === '-infinity' ? Number.NEGATIVE_INFINITY : Number.POSITIVE_INFINITY;
3160
+ }
3058
3161
  parseRow(row, table) {
3059
3162
  const parsed = {};
3060
3163
  const meta = this.schema.tables[table];
@@ -3071,29 +3174,48 @@ class QueryInterface {
3071
3174
  const value = row[col];
3072
3175
  const field = reverseMap[col] ?? col; // fall back to raw col name, not regex
3073
3176
  // Top-level rows are snake_case (dateCols); nested rows are camelCase (camelDateFields).
3074
- //
3075
- // An ARRAY value is excluded: `dateColumns` includes array-of-date
3076
- // columns (`date[]`, `timestamp[]`, `timestamptz[]`), for which the
3077
- // driver already hands back a `Date[]`. Coercing it ran
3078
- // `new Date(String(theArray))` and replaced the whole array with a
3079
- // single Invalid Date, the column was unreadable on every strategy.
3080
- // The join strategy's string arrays are handled upstream instead, by
3081
- // the JSON-wire decode in relations.ts.
3082
- //
3083
- // A NUMBER is excluded for the same reason: the driver returns the
3084
- // JS numbers `Infinity` / `-Infinity` for the Postgres `infinity` /
3085
- // `-infinity` timestamp values, and re-coercing one ran
3086
- // `parseDbDate(String(Infinity))` = `parseDbDate('Infinity')`, which
3087
- // is an Invalid Date that JSON-encodes as null. The array form escaped
3088
- // this only because it took the branch above.
3089
- if ((dateCols.has(col) || camelDateFields.has(field)) &&
3090
- value !== null &&
3091
- !(value instanceof Date) &&
3092
- !Array.isArray(value) &&
3093
- typeof value !== 'number') {
3094
- // Offset-less strings (Postgres `timestamp`, json_agg output) are
3095
- // pinned to UTC so results don't depend on the server's time zone.
3096
- parsed[field] = this.utcTimestamps ? (0, utils_js_1.parseDbDate)(String(value)) : new Date(value);
3177
+ if ((dateCols.has(col) || camelDateFields.has(field)) && value !== null && !(value instanceof Date)) {
3178
+ if ((0, utils_js_1.isTemporalInfinity)(value)) {
3179
+ // Postgres `infinity` / `-infinity`. No JS Date means either, so
3180
+ // both readings cost something and the default is the one that is
3181
+ // not lossy. `'preserve'` hands back the JS number, which breaks
3182
+ // the declared `Date` type at runtime (`.toISOString()` throws) and
3183
+ // still serializes as null because JSON has no infinity literal,
3184
+ // but binds straight back, so a read-modify-write stores `infinity`
3185
+ // again. `'null'` reads nicer and DESTROYS the value on that same
3186
+ // write, because a stored infinity and a stored NULL become
3187
+ // indistinguishable. Whichever is configured, it is the SAME on
3188
+ // every read strategy: the driver hands back the number,
3189
+ // `json_build_object` hands back the string "infinity", and both
3190
+ // land here (see `isTemporalInfinity`).
3191
+ //
3192
+ // The warning below fires once per column when the option was left
3193
+ // unset, on a row that actually held an infinity, and describes the
3194
+ // reading in force rather than gating on which one it is.
3195
+ this.warnTemporalInfinity(table, field);
3196
+ parsed[field] = this.readTemporalInfinity(value);
3197
+ }
3198
+ else if (Array.isArray(value)) {
3199
+ // `dateColumns` includes array-of-date columns (`date[]`,
3200
+ // `timestamp[]`, `timestamptz[]`), for which the driver already
3201
+ // hands back a `Date[]`. Coercing the array itself ran
3202
+ // `new Date(String(theArray))` and replaced the whole column with
3203
+ // one Invalid Date. Its ELEMENTS get the same infinity mapping as a
3204
+ // scalar (same declared element type, same JSON rendering);
3205
+ // everything else is passed through by identity.
3206
+ parsed[field] = this.mapArrayTemporalInfinity(value, table, field);
3207
+ }
3208
+ else if (typeof value === 'number') {
3209
+ // Any other number on a date column is left alone rather than run
3210
+ // through `parseDbDate(String(n))`, which would produce an Invalid
3211
+ // Date.
3212
+ parsed[field] = value;
3213
+ }
3214
+ else {
3215
+ // Offset-less strings (Postgres `timestamp`, json_agg output) are
3216
+ // pinned to UTC so results don't depend on the server's time zone.
3217
+ parsed[field] = this.utcTimestamps ? (0, utils_js_1.parseDbDate)(String(value)) : new Date(value);
3218
+ }
3097
3219
  }
3098
3220
  else {
3099
3221
  parsed[field] = value;