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.
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import pg from 'pg';
7
7
  import { camelToSnake, localDateTimeKind, timeOfDayKind } from '../schema.js';
8
+ import { shouldWarnOnce, WARN_NS } from './warn-registry.js';
8
9
  // ---------------------------------------------------------------------------
9
10
  // Identifier quoting, prevents SQL injection via table/column names
10
11
  // ---------------------------------------------------------------------------
@@ -320,6 +321,42 @@ export function parseDbDate(value) {
320
321
  // normalize `YYYY-MM-DD HH:MM:SS` (driver form) to ISO before pinning UTC
321
322
  return new Date(`${value.replace(' ', 'T')}Z`);
322
323
  }
324
+ /**
325
+ * Is `value` one of the two representations a Postgres temporal `infinity` /
326
+ * `-infinity` reaches the ORM row parser in?
327
+ *
328
+ * TWO representations, because a temporal column is read two different ways
329
+ * and they disagree on the wire:
330
+ *
331
+ * driver the pg text parser for `timestamp` / `date` does not
332
+ * recognise the word, so it falls through to the driver's own
333
+ * parser, which returns the JS NUMBERS `Infinity` /
334
+ * `-Infinity`. This is what a top-level row, the batched and
335
+ * flatten strategies, a write `RETURNING` projection and a
336
+ * `groupBy` key all see.
337
+ * JSON wire `json_build_object` renders the same value as the STRING
338
+ * `"infinity"`, and scalar `timestamp` / `timestamptz` are
339
+ * deliberately absent from {@link JSON_WIRE_COERCION_OIDS},
340
+ * so no driver parser runs over it. This is what the `'join'`
341
+ * strategy and the positional encoding see.
342
+ *
343
+ * Both are normalized in one place ({@link QueryInterface}'s row parser), to
344
+ * whichever reading `temporalInfinity` selects (the JS number by default, or
345
+ * `null`), so the same stored value cannot read differently depending on which
346
+ * plan the query happened to take. The string form is only ever consulted for a column
347
+ * the schema says is temporal, so a `text` column holding the word "infinity"
348
+ * is untouched.
349
+ *
350
+ * Not dialect-gated. Postgres is the only engine with an infinite temporal
351
+ * value, but the row parser is engine-shared and the alternative reading on the
352
+ * other engines (a stray `'infinity'` string becoming an Invalid Date) is not
353
+ * one worth preserving.
354
+ */
355
+ export function isTemporalInfinity(value) {
356
+ if (typeof value === 'number')
357
+ return value === Number.POSITIVE_INFINITY || value === Number.NEGATIVE_INFINITY;
358
+ return value === 'infinity' || value === '-infinity';
359
+ }
323
360
  // ---------------------------------------------------------------------------
324
361
  // Driver type parsers for the zone-less temporal OIDs
325
362
  // ---------------------------------------------------------------------------
@@ -433,6 +470,126 @@ export function createPgArrayParser(element) {
433
470
  const arrayParser = pg.types.arrayParser;
434
471
  return (text) => arrayParser.create(text, (entry) => (entry === null || entry === undefined ? null : element(entry))).parse();
435
472
  }
473
+ /**
474
+ * A canonical wire value per OID, plus the JS value pg's OWN default text
475
+ * parser produces from it, used to tell "still on the driver default" from
476
+ * "somebody else already customized this OID" (see
477
+ * {@link isDefaultTextParser}).
478
+ *
479
+ * The expected values are computed from LOCAL date components on purpose: pg's
480
+ * default for the zone-less temporal OIDs builds its `Date` in the process's
481
+ * zone (that is the reading `utcTimestamps` exists to replace), so the
482
+ * expectation has to be computed the same way in whatever zone the process runs.
483
+ */
484
+ const DEFAULT_PARSER_PROBES = {
485
+ 20: { text: '9007199254740993', expected: () => '9007199254740993' },
486
+ 1082: { text: '2020-01-02', expected: () => new Date(2020, 0, 2) },
487
+ 1114: { text: '2020-01-02 03:04:05', expected: () => new Date(2020, 0, 2, 3, 4, 5) },
488
+ 1115: { text: '{"2020-01-02 03:04:05"}', expected: () => [new Date(2020, 0, 2, 3, 4, 5)] },
489
+ 1182: { text: '{2020-01-02}', expected: () => [new Date(2020, 0, 2)] },
490
+ };
491
+ /**
492
+ * Marks a text parser as one TURBINE installed, so a later registration in the
493
+ * same process (a client plus `turbine studio`, ESM plus CJS copies of this
494
+ * module) does not report Turbine's own parser as "somebody else's". A
495
+ * `Symbol.for` key, for the same cross-copy-identity reason the warn registry
496
+ * uses one.
497
+ */
498
+ const TURBINE_PARSER = Symbol.for('turbine.typeParser');
499
+ /** The OIDs the `utcTimestamps` flag governs, and so the ones it can opt out of. */
500
+ const TEMPORAL_PARSER_OIDS = new Set([1114, 1082, 1115, 1182]);
501
+ /** Tag `parser` as Turbine's own and return it (see {@link TURBINE_PARSER}). */
502
+ export function markTurbineParser(parser) {
503
+ parser[TURBINE_PARSER] = true;
504
+ return parser;
505
+ }
506
+ /** Comparable rendering of a parser result (Date by instant, array by element). */
507
+ function parserResultSignature(value) {
508
+ if (value === null || value === undefined)
509
+ return String(value);
510
+ if (value instanceof Date)
511
+ return `date:${value.getTime()}`;
512
+ if (Array.isArray(value))
513
+ return `[${value.map(parserResultSignature).join(',')}]`;
514
+ return `${typeof value}:${String(value)}`;
515
+ }
516
+ /**
517
+ * Is the parser currently registered for `oid` still pg's own default?
518
+ *
519
+ * DETECTED BY BEHAVIOUR, NOT BY IDENTITY, and deliberately so. `pg-types` keeps
520
+ * its default parser table private: `getTypeParser` hands back whatever is
521
+ * registered NOW, and there is no exported way to ask what the default WAS, so
522
+ * a function-identity comparison would need a deep import of a file the package
523
+ * does not publish as an entry point. Instead this runs the registered parser
524
+ * over a canonical wire value and compares the result with what pg's default
525
+ * produces for it.
526
+ *
527
+ * What that buys and what it costs, stated honestly:
528
+ * - Every parser that behaves OBSERVABLY differently on the probe value is
529
+ * detected, which is the case worth warning about (someone else's reading
530
+ * is about to be replaced by Turbine's).
531
+ * - A replacement that is observably EQUIVALENT on the probe is reported as
532
+ * the default and draws no warning. That is a false negative, and an
533
+ * acceptable one: if it agrees with the default here it is not a reading
534
+ * anybody would notice Turbine overwriting.
535
+ * - A parser that THROWS on the probe is reported as non-default; pg's own
536
+ * never throws on a valid value of its type.
537
+ * - An OID with no probe entry is reported as default (never warn on a guess).
538
+ *
539
+ * The registered parser is invoked once, on a synthetic value, at client
540
+ * construction. A decode parser with side effects would be surprising, and pg's
541
+ * own have none.
542
+ */
543
+ export function isDefaultTextParser(oid, parser) {
544
+ const probe = DEFAULT_PARSER_PROBES[oid];
545
+ if (!probe)
546
+ return true;
547
+ try {
548
+ return parserResultSignature(parser(probe.text)) === parserResultSignature(probe.expected());
549
+ }
550
+ catch {
551
+ return false;
552
+ }
553
+ }
554
+ /**
555
+ * Warn ONCE per OID when Turbine is about to replace a text parser that is not
556
+ * pg's default, i.e. when some other module in the process has already
557
+ * customized it.
558
+ *
559
+ * `pg.types.setTypeParser` is process-global and retroactive: it changes how
560
+ * every `pg.Pool` in the process decodes that OID, including pools that were
561
+ * constructed and were already querying before the Turbine client existed. When
562
+ * the OID was still on pg's default that is the documented, intended trade (the
563
+ * whole point of `utcTimestamps`). When somebody else had already installed
564
+ * their own reading, Turbine is silently rewriting an expectation it cannot see
565
+ * the origin of, and the resulting bug is order-dependent: which reading wins
566
+ * depends on module evaluation order, which lazy route imports make unstable
567
+ * between requests. So say it out loud, once.
568
+ */
569
+ export function warnParserOverwrite(oid, typeName) {
570
+ if (process.env.NODE_ENV === 'production')
571
+ return;
572
+ const getParser = pg.types.getTypeParser;
573
+ const current = getParser(oid, 'text');
574
+ // Turbine's own earlier registration is not a third party's expectation.
575
+ if (current[TURBINE_PARSER])
576
+ return;
577
+ if (isDefaultTextParser(oid, current))
578
+ return;
579
+ if (!shouldWarnOnce(WARN_NS.parserOverwrite, String(oid)))
580
+ return;
581
+ // The `utcTimestamps: false` opt-out only governs the four TEMPORAL OIDs.
582
+ // Offering it as the remedy for int8 (20) would name a setting that does
583
+ // nothing for the OID being warned about; that registration has no opt-out.
584
+ const remedy = TEMPORAL_PARSER_OIDS.has(oid)
585
+ ? ' `utcTimestamps: false` leaves the four temporal OIDs (1114, 1082, 1115, 1182) alone entirely.'
586
+ : ' There is no opt-out for this OID: Turbine registers it so bigint values come back as numbers.';
587
+ console.warn(`[turbine] pg type parser for OID ${oid} (${typeName}) was already customized by something else in this ` +
588
+ 'process, and Turbine is replacing it. `pg.types.setTypeParser` is process-global and takes effect ' +
589
+ 'immediately for EVERY pg.Pool in the process, including pools that already exist and are already ' +
590
+ 'querying, so whatever set that parser will now read this column differently. If yours should win, ' +
591
+ `register it AFTER constructing the client.${remedy} Dev-only: silent under \`NODE_ENV=production\`.`);
592
+ }
436
593
  /**
437
594
  * Register the UTC readings of the four zone-less temporal OIDs on the pg
438
595
  * module: `timestamp` (1114), `date` (1082) and their array forms (1115, 1182).
@@ -448,6 +605,11 @@ export function createPgArrayParser(element) {
448
605
  * Each fallback is read BEFORE its parser is installed, so an unrecognised wire
449
606
  * value (`infinity`, and whatever a future server adds) still reaches the
450
607
  * driver's own parser.
608
+ *
609
+ * Registration is RETROACTIVE for the whole process, pools included that were
610
+ * created and are already querying (there is one parser table, and it is read
611
+ * per row at decode time, not captured per pool). If any of the four OIDs is
612
+ * already on a NON-default parser, {@link warnParserOverwrite} says so once.
451
613
  */
452
614
  export function registerUtcTemporalParsers() {
453
615
  // pg-types declares get/setTypeParser over its own OID enum, which lists the
@@ -455,15 +617,19 @@ export function registerUtcTemporalParsers() {
455
617
  // retyped over a plain number rather than the incomplete enum.
456
618
  const getParser = pg.types.getTypeParser;
457
619
  const setParser = pg.types.setTypeParser;
620
+ warnParserOverwrite(1114, 'timestamp');
621
+ warnParserOverwrite(1082, 'date');
622
+ warnParserOverwrite(1115, 'timestamp[]');
623
+ warnParserOverwrite(1182, 'date[]');
458
624
  const parseDate = createUtcDateParser(getParser(1082, 'text'));
459
625
  const parseTimestamp = createUtcTimestampParser(getParser(1114, 'text'));
460
- setParser(1114, parseTimestamp);
461
- setParser(1082, parseDate);
626
+ setParser(1114, markTurbineParser(parseTimestamp));
627
+ setParser(1082, markTurbineParser(parseDate));
462
628
  // Array OIDs do not inherit their element parser, so `date[]` / `timestamp[]`
463
629
  // would otherwise keep returning local-zone Dates while the scalar columns
464
630
  // beside them returned UTC ones.
465
- setParser(1182, createPgArrayParser(parseDate));
466
- setParser(1115, createPgArrayParser(parseTimestamp));
631
+ setParser(1182, markTurbineParser(createPgArrayParser(parseDate)));
632
+ setParser(1115, markTurbineParser(createPgArrayParser(parseTimestamp)));
467
633
  }
468
634
  // ---------------------------------------------------------------------------
469
635
  // 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).
@@ -117,6 +117,20 @@ export const WARN_NS = {
117
117
  * the offenders, not one per column. The namespace name is historical.
118
118
  */
119
119
  untypedDateColumn: 'untypedDateColumn',
120
+ /**
121
+ * A stored temporal `infinity` / `-infinity` was actually read, and
122
+ * `temporalInfinity` was left unset, so the row parser describes the reading
123
+ * in force (builder.ts `warnTemporalInfinity`). Keyed on `table.column`, so a
124
+ * table with two such columns says it once for each and a million rows say it
125
+ * once in total.
126
+ */
127
+ temporalInfinity: 'temporalInfinity',
128
+ /**
129
+ * Turbine replaced a pg text parser that was NOT on the driver default, i.e.
130
+ * something else in the process had already customized that OID (utils.ts
131
+ * `warnParserOverwrite`). Keyed on the OID.
132
+ */
133
+ parserOverwrite: 'parserOverwrite',
120
134
  /**
121
135
  * `planCacheMode` was set on a client given an EXTERNAL pool, where Turbine
122
136
  * 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turbine-orm",
3
- "version": "0.54.0",
3
+ "version": "0.55.0",
4
4
  "description": "Postgres-native TypeScript ORM, runs on Neon, Vercel Postgres, Cloudflare, Supabase. Streaming cursors, typed errors, single-query nested relations. One dependency, no WASM engine",
5
5
  "type": "module",
6
6
  "//exports": "Each subpath declares its types PER CONDITION. A single shared top-level \"types\" resolves to the ESM declarations for `require` too, which is TS1479 (\"is an ES module ... cannot be require()d\") for any CJS consumer on moduleResolution node16/nodenext. The require condition points at dist/cjs, which ships its own {\"type\":\"commonjs\"} package.json, so those declarations are CJS declarations. Gated in CI by publint + @arethetypeswrong/cli + a real .cts consumer typecheck (see the package-types job in ci.yml).",