turbine-orm 0.71.0 → 0.72.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.
Files changed (44) hide show
  1. package/README.md +2 -2
  2. package/dist/cjs/client.d.ts +0 -18
  3. package/dist/cjs/client.js +2 -29
  4. package/dist/cjs/connection-url.d.ts +30 -0
  5. package/dist/cjs/connection-url.js +15 -17
  6. package/dist/cjs/powql.d.ts +38 -1
  7. package/dist/cjs/powql.js +106 -18
  8. package/dist/cjs/query/aggregates.d.ts +0 -13
  9. package/dist/cjs/query/aggregates.js +81 -33
  10. package/dist/cjs/query/batched-loader.d.ts +13 -1
  11. package/dist/cjs/query/batched-loader.js +46 -11
  12. package/dist/cjs/query/builder.d.ts +13 -0
  13. package/dist/cjs/query/builder.js +104 -14
  14. package/dist/cjs/query/compound-unique.js +29 -5
  15. package/dist/cjs/query/relation-names.d.ts +52 -0
  16. package/dist/cjs/query/relation-names.js +120 -0
  17. package/dist/cjs/query/relations.d.ts +11 -6
  18. package/dist/cjs/query/relations.js +45 -27
  19. package/dist/cjs/query/utils.d.ts +107 -3
  20. package/dist/cjs/query/utils.js +408 -7
  21. package/dist/cjs/query/where-compile.js +9 -4
  22. package/dist/cjs/query/where.js +9 -5
  23. package/dist/client.d.ts +0 -18
  24. package/dist/client.js +2 -29
  25. package/dist/connection-url.d.ts +30 -0
  26. package/dist/connection-url.js +15 -18
  27. package/dist/powql.d.ts +38 -1
  28. package/dist/powql.js +107 -19
  29. package/dist/query/aggregates.d.ts +0 -13
  30. package/dist/query/aggregates.js +82 -34
  31. package/dist/query/batched-loader.d.ts +13 -1
  32. package/dist/query/batched-loader.js +47 -12
  33. package/dist/query/builder.d.ts +13 -0
  34. package/dist/query/builder.js +105 -15
  35. package/dist/query/compound-unique.js +30 -6
  36. package/dist/query/relation-names.d.ts +52 -0
  37. package/dist/query/relation-names.js +117 -0
  38. package/dist/query/relations.d.ts +11 -6
  39. package/dist/query/relations.js +47 -29
  40. package/dist/query/utils.d.ts +107 -3
  41. package/dist/query/utils.js +404 -8
  42. package/dist/query/where-compile.js +10 -5
  43. package/dist/query/where.js +10 -6
  44. package/package.json +5 -3
@@ -12,6 +12,8 @@ exports.JSON_WIRE_COERCION_OIDS = exports.OPERATOR_KEYS = exports.LRUCache = exp
12
12
  exports.quoteIdent = quoteIdent;
13
13
  exports.ownLookup = ownLookup;
14
14
  exports.resolveColumnName = resolveColumnName;
15
+ exports.resolveRelation = resolveRelation;
16
+ exports.resolveRelationDef = resolveRelationDef;
15
17
  exports.canonicalColumnOrder = canonicalColumnOrder;
16
18
  exports.canonicalWriteEntries = canonicalWriteEntries;
17
19
  exports.markInternalCombinator = markInternalCombinator;
@@ -28,7 +30,10 @@ exports.coerceTemporalValue = coerceTemporalValue;
28
30
  exports.parseDbDate = parseDbDate;
29
31
  exports.isTemporalInfinity = isTemporalInfinity;
30
32
  exports.createUtcDateParser = createUtcDateParser;
33
+ exports.createUtcDateParserGeneral = createUtcDateParserGeneral;
31
34
  exports.createUtcTimestampParser = createUtcTimestampParser;
35
+ exports.createUtcTimestampParserGeneral = createUtcTimestampParserGeneral;
36
+ exports.createFastTimestamptzParser = createFastTimestamptzParser;
32
37
  exports.parseUtcTimestampText = parseUtcTimestampText;
33
38
  exports.createPgArrayParser = createPgArrayParser;
34
39
  exports.markTurbineParser = markTurbineParser;
@@ -108,6 +113,55 @@ function resolveColumnName(meta, key) {
108
113
  return snake;
109
114
  return undefined;
110
115
  }
116
+ /**
117
+ * Resolve a user-supplied key to a relation's CANONICAL name and definition, or
118
+ * `undefined` when the key names no relation on the table.
119
+ *
120
+ * The relation-name half of the rule {@link resolveColumnName} states for
121
+ * columns, and deliberately the same shape: the declared name first, else
122
+ * `snakeToCamel(key)` accepted ONLY when that names a real relation.
123
+ * `snakeToCamel` is idempotent on an already-camel string, so a canonical key
124
+ * takes the first branch and this is a no-op for every existing caller.
125
+ *
126
+ * WHY IT EXISTS. A relation has one declared name, `ripeningChecks`, while the
127
+ * DDL anyone reads has only the TABLE name, `ripening_checks`. Writing back
128
+ * what the schema shows therefore failed with E005 in `with`, E003 in a
129
+ * relation filter, and E005 in `orderBy`, on names the error text was already
130
+ * computing correctly ("Did you mean ...?"). A system that can name the
131
+ * intended relation can accept it.
132
+ *
133
+ * NOT A GUESS, for the same reason the column rule is not: the transformed name
134
+ * is accepted only when it is a real declared relation, so an unknown key still
135
+ * fails and a typo is still a typo. Exact match wins first, so a schema that
136
+ * literally declares `ripening_checks` keeps it, even alongside a
137
+ * `ripeningChecks`.
138
+ *
139
+ * The RESULT KEY is the canonical name, not the caller's spelling, matching the
140
+ * column side (`select: { ledger_handle: true }` already returns
141
+ * `{ ledgerHandle }`). Resolving here rather than normalizing the args up front
142
+ * also means both spellings share one SQL-cache entry instead of minting two
143
+ * templates for one query.
144
+ *
145
+ * Prototype-safe via {@link ownLookup}, so `__proto__` cannot name a relation.
146
+ */
147
+ function resolveRelation(relations, key) {
148
+ const direct = ownLookup(relations, key);
149
+ if (direct !== undefined)
150
+ return { name: key, def: direct };
151
+ const camel = (0, schema_js_1.snakeToCamel)(key);
152
+ if (camel === key)
153
+ return undefined;
154
+ const mapped = ownLookup(relations, camel);
155
+ return mapped === undefined ? undefined : { name: camel, def: mapped };
156
+ }
157
+ /**
158
+ * {@link resolveRelation} when only the definition is wanted: a drop-in for the
159
+ * `ownLookup(meta.relations, key)` it replaces, with the same signature and the
160
+ * same `undefined` on a miss.
161
+ */
162
+ function resolveRelationDef(relations, key) {
163
+ return resolveRelation(relations, key)?.def;
164
+ }
111
165
  // ---------------------------------------------------------------------------
112
166
  // Caller-controlled key ORDER, canonicalized
113
167
  // ---------------------------------------------------------------------------
@@ -572,6 +626,211 @@ function isTemporalInfinity(value) {
572
626
  // ---------------------------------------------------------------------------
573
627
  // Driver type parsers for the zone-less temporal OIDs
574
628
  // ---------------------------------------------------------------------------
629
+ // ---------------------------------------------------------------------------
630
+ // The fast temporal scan
631
+ //
632
+ // Every temporal parser below is a two-stage function: a hand-written
633
+ // character scan that claims the ONE wire shape a busy application actually
634
+ // produces, and behind it the general parser, which is where every other shape
635
+ // (and every shape a future server adds) still goes.
636
+ //
637
+ // WHY IT EXISTS, stated as a measurement rather than an intuition. Draining
638
+ // 50,000 rows of the benchmark `comments` fixture spends ~23.5 ms in
639
+ // client-side type decoding, and `timestamptz` alone is 20.7 ms of it (88%).
640
+ // The remaining types are not worth touching: `text`, `numeric`, `uuid` and
641
+ // `bool` cost nothing at all, because pg registers no parser for them. Two
642
+ // hard bounds were verified in node-postgres' source before any of this was
643
+ // written, and they say what a decoder rewrite can and cannot reach: every
644
+ // cell is materialised as a JS string by `reader.string(len)` before any
645
+ // parser is consulted, so the `utf8Slice` half is unreachable; and the binary
646
+ // protocol is not an escape hatch, the parser constructor throws
647
+ // `Binary mode not supported yet`. The type-PARSING half is the whole budget,
648
+ // and this is a claim on it.
649
+ //
650
+ // THE RULE, and it is the only thing that makes a fast path acceptable here:
651
+ // **the scan must return exactly what the parser it replaces returned, or
652
+ // return `null` and not claim the value at all.** It never guesses, never
653
+ // "handles" a shape approximately, and never widens what it accepts to cover
654
+ // one more case. Every refusal costs a few charCode compares and is repaid by
655
+ // the general parser being correct.
656
+ //
657
+ // Two traps are baked into the refusals rather than into corrections, because
658
+ // a throwaway version of this decoder wrote during the ceiling measurement got
659
+ // 3 of 15 edge values wrong while believing it had delegated the hard ones:
660
+ //
661
+ // 1. `Date.UTC` maps years 0-99 onto 1900-1999, so `0044-03-15` decodes as
662
+ // 1944 and `0001-01-01` as 1901. The obvious repair, build the Date then
663
+ // `setUTCFullYear`, is ALSO wrong: year 0 is a leap year in the proleptic
664
+ // Gregorian calendar and 1900 is not, so `0000-02-29` built that way
665
+ // lands on March 1st. The scan refuses `year < 100` and lets the general
666
+ // parser's `new Date(0)` + `setUTCFullYear(y, m, d)` assembly (which sets
667
+ // all three fields against the right year's calendar) answer it.
668
+ // 2. An offset parse that runs to end-of-string silently EATS a trailing
669
+ // ` BC`: `4713-01-01 00:00:00+00 BC` decodes as AD 4713, off by 9,424
670
+ // years with no error. The scan requires end-of-string after the offset.
671
+ //
672
+ // Differential coverage: src/test/fast-temporal-decode.test.ts compares the
673
+ // scan against the parser it replaces value for value, and
674
+ // src/test/fast-temporal-decode.integration.test.ts does the same against a
675
+ // live server with `DateStyle` and `TimeZone` varied, because the wire shape
676
+ // is a server setting and a decoder tuned to one `DateStyle` is a latent
677
+ // corruption bug.
678
+ // ---------------------------------------------------------------------------
679
+ // Wire shape the scan is reading. Plain module constants rather than an enum:
680
+ // this repo's lint config rejects `const enum` (it does not survive
681
+ // `isolatedModules`), and a plain `enum` emits a runtime object, so every
682
+ // `Shape.Date` in the scan below would become a property load in the hottest
683
+ // loop in the library. These are values, never serialized, never persisted.
684
+ /** `YYYY-MM-DD` (OID 1082). */
685
+ const SHAPE_DATE = 0;
686
+ /** `YYYY-MM-DD[ T]HH:MM:SS[.f…]` (OID 1114), never an offset. */
687
+ const SHAPE_TIMESTAMP = 1;
688
+ /** `YYYY-MM-DD HH:MM:SS[.f…](Z|±HH[:MM[:SS]])` (OID 1184), offset REQUIRED. */
689
+ const SHAPE_TIMESTAMPTZ = 2;
690
+ const CH_HYPHEN = 45;
691
+ const CH_COLON = 58;
692
+ const CH_DOT = 46;
693
+ const CH_SPACE = 32;
694
+ const CH_T = 84;
695
+ const CH_Z = 90;
696
+ const CH_PLUS = 43;
697
+ /** Same code point as {@link CH_HYPHEN}; named separately because the two read
698
+ * as different things (a date separator, an offset sign) at their use sites. */
699
+ const CH_MINUS = 45;
700
+ /**
701
+ * Two ASCII digits at `i` as a number, or `-1` if either character is not a
702
+ * digit. `charCodeAt` past the end returns `NaN`, and `NaN - 48` is `NaN`,
703
+ * which fails both range tests, so this needs no separate length check.
704
+ */
705
+ function twoDigitsAt(text, i) {
706
+ const hi = text.charCodeAt(i) - 48;
707
+ const lo = text.charCodeAt(i + 1) - 48;
708
+ return hi >= 0 && hi <= 9 && lo >= 0 && lo <= 9 ? hi * 10 + lo : -1;
709
+ }
710
+ /**
711
+ * Decode `text` if it is exactly the canonical ISO wire shape for `shape`, or
712
+ * return `null` to say "not mine" (see the rule in the block comment above).
713
+ *
714
+ * Deliberately NOT accepted, each because the parser it replaces would answer
715
+ * differently:
716
+ *
717
+ * - a year that is not exactly four digits. A 5-digit year is a real
718
+ * PostgreSQL output and the general parser handles it; a fast path that
719
+ * matched a variable-width year would have to re-find every later field.
720
+ * - a year below 100 (trap 1 above).
721
+ * - a `T` separator for `SHAPE_TIMESTAMPTZ`. `postgres-date`'s
722
+ * own date-time regex requires a literal SPACE, so it returns `null` for
723
+ * `2024-01-01T00:00:00+00`; a scan that accepted `T` there would invent a
724
+ * Date where the driver hands back null. `timestamp` (1114) does accept it
725
+ * because the general parser it replaces does.
726
+ * - a missing offset for `SHAPE_TIMESTAMPTZ`. `postgres-date` reads an
727
+ * offset-less value in the process's LOCAL zone, which is not what this
728
+ * scan computes.
729
+ * - a colon-less offset (`+0530`), an alphabetic zone (` UTC`), or anything
730
+ * at all after the offset, including ` BC` (trap 2 above).
731
+ * - a bare `.` with no fractional digits.
732
+ *
733
+ * Field-value overflow (`2024-13-45`, `25:70:99`) IS accepted, because
734
+ * `Date.UTC` rolls those over exactly as the general parser's `setUTCFullYear`
735
+ * / `setUTCHours` do. PostgreSQL never emits them; agreeing on them is free.
736
+ */
737
+ function scanIsoTemporal(text, shape) {
738
+ const len = text.length;
739
+ if (len < 10)
740
+ return null;
741
+ if (text.charCodeAt(4) !== CH_HYPHEN || text.charCodeAt(7) !== CH_HYPHEN)
742
+ return null;
743
+ const yearHi = twoDigitsAt(text, 0);
744
+ const yearLo = twoDigitsAt(text, 2);
745
+ if (yearHi < 0 || yearLo < 0)
746
+ return null;
747
+ const year = yearHi * 100 + yearLo;
748
+ if (year < 100)
749
+ return null;
750
+ const month = twoDigitsAt(text, 5);
751
+ const day = twoDigitsAt(text, 8);
752
+ if (month < 0 || day < 0)
753
+ return null;
754
+ if (shape === SHAPE_DATE) {
755
+ return len === 10 ? new Date(Date.UTC(year, month - 1, day)) : null;
756
+ }
757
+ if (len < 19)
758
+ return null;
759
+ const sep = text.charCodeAt(10);
760
+ if (shape === SHAPE_TIMESTAMP ? sep !== CH_SPACE && sep !== CH_T : sep !== CH_SPACE)
761
+ return null;
762
+ if (text.charCodeAt(13) !== CH_COLON || text.charCodeAt(16) !== CH_COLON)
763
+ return null;
764
+ const hour = twoDigitsAt(text, 11);
765
+ const minute = twoDigitsAt(text, 14);
766
+ const second = twoDigitsAt(text, 17);
767
+ if (hour < 0 || minute < 0 || second < 0)
768
+ return null;
769
+ let i = 19;
770
+ let ms = 0;
771
+ if (text.charCodeAt(i) === CH_DOT) {
772
+ i++;
773
+ let digits = 0;
774
+ for (;;) {
775
+ const d = text.charCodeAt(i) - 48;
776
+ if (!(d >= 0 && d <= 9))
777
+ break;
778
+ // Only the first three digits survive: PostgreSQL emits up to six and a
779
+ // JS Date holds milliseconds. `postgres-date` gets the same answer by a
780
+ // different route (`1000 * parseFloat('.476603')`, truncated by
781
+ // `Date.UTC`), and the two agree on every 1-to-6-digit fraction.
782
+ if (digits < 3)
783
+ ms = ms * 10 + d;
784
+ digits++;
785
+ i++;
786
+ }
787
+ if (digits === 0)
788
+ return null;
789
+ if (digits === 1)
790
+ ms *= 100;
791
+ else if (digits === 2)
792
+ ms *= 10;
793
+ }
794
+ if (shape === SHAPE_TIMESTAMP) {
795
+ return i === len ? new Date(Date.UTC(year, month - 1, day, hour, minute, second, ms)) : null;
796
+ }
797
+ let offsetMs = 0;
798
+ const signCode = text.charCodeAt(i);
799
+ if (signCode === CH_Z) {
800
+ i++;
801
+ }
802
+ else if (signCode === CH_PLUS || signCode === CH_MINUS) {
803
+ i++;
804
+ const offsetHours = twoDigitsAt(text, i);
805
+ if (offsetHours < 0)
806
+ return null;
807
+ i += 2;
808
+ let offsetMinutes = 0;
809
+ let offsetSeconds = 0;
810
+ if (text.charCodeAt(i) === CH_COLON) {
811
+ offsetMinutes = twoDigitsAt(text, i + 1);
812
+ if (offsetMinutes < 0)
813
+ return null;
814
+ i += 3;
815
+ // A zone whose historical LMT offset was not a whole minute emits
816
+ // seconds too (`1850-01-01 00:00:00+00:19:32`).
817
+ if (text.charCodeAt(i) === CH_COLON) {
818
+ offsetSeconds = twoDigitsAt(text, i + 1);
819
+ if (offsetSeconds < 0)
820
+ return null;
821
+ i += 3;
822
+ }
823
+ }
824
+ offsetMs =
825
+ (offsetHours * 3_600_000 + offsetMinutes * 60_000 + offsetSeconds * 1000) * (signCode === CH_MINUS ? -1 : 1);
826
+ }
827
+ else {
828
+ return null;
829
+ }
830
+ if (i !== len)
831
+ return null;
832
+ return new Date(Date.UTC(year, month - 1, day, hour, minute, second, ms) - offsetMs);
833
+ }
575
834
  /**
576
835
  * A Postgres `date` wire value: `YYYY-MM-DD`, optionally with more than four
577
836
  * year digits, optionally suffixed ` BC`. Anything else (`infinity`,
@@ -605,6 +864,22 @@ const PG_DATE_TEXT_RE = /^(\d{4,})-(\d{2})-(\d{2})( BC)?$/;
605
864
  * astronomical year (`0044 BC` → -43) the way the driver's own parser does.
606
865
  */
607
866
  function createUtcDateParser(fallback) {
867
+ const general = createUtcDateParserGeneral(fallback);
868
+ return (text) => scanIsoTemporal(text, SHAPE_DATE) ?? general(text);
869
+ }
870
+ /**
871
+ * The `date` (OID 1082) parser WITHOUT the fast scan in front of it: the regex
872
+ * implementation described by {@link createUtcDateParser}, and the reference
873
+ * side of the differential test.
874
+ *
875
+ * Exported so "what the fast path must agree with" is the running code rather
876
+ * than a transcription of it in a test file. Two hand-synced copies of a
877
+ * parser is the drift class this repo has been bitten by before; there is one
878
+ * copy, and the fast path delegates to it.
879
+ *
880
+ * @internal
881
+ */
882
+ function createUtcDateParserGeneral(fallback) {
608
883
  return (text) => {
609
884
  const m = PG_DATE_TEXT_RE.exec(text);
610
885
  if (!m)
@@ -644,6 +919,18 @@ const PG_TIMESTAMP_TEXT_RE = /^(\d{4,})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2
644
919
  * `Date`-string parsing did too.
645
920
  */
646
921
  function createUtcTimestampParser(fallback) {
922
+ const general = createUtcTimestampParserGeneral(fallback);
923
+ return (text) => scanIsoTemporal(text, SHAPE_TIMESTAMP) ?? general(text);
924
+ }
925
+ /**
926
+ * The `timestamp` (OID 1114) parser WITHOUT the fast scan in front of it: the
927
+ * regex implementation described by {@link createUtcTimestampParser}, and the
928
+ * reference side of the differential test. Same reasoning as
929
+ * {@link createUtcDateParserGeneral}.
930
+ *
931
+ * @internal
932
+ */
933
+ function createUtcTimestampParserGeneral(fallback) {
647
934
  return (text) => {
648
935
  const m = PG_TIMESTAMP_TEXT_RE.exec(text);
649
936
  if (!m)
@@ -656,6 +943,27 @@ function createUtcTimestampParser(fallback) {
656
943
  return date;
657
944
  };
658
945
  }
946
+ /**
947
+ * Build the driver parser for Postgres `timestamptz` (OID 1184): the ISO wire
948
+ * shape decoded by {@link scanIsoTemporal}, everything else handed straight to
949
+ * `fallback`.
950
+ *
951
+ * UNLIKE the `date` and `timestamp` parsers beside it, this one changes NO
952
+ * READING. A `timestamptz` arrives with an explicit offset, so its instant is
953
+ * unambiguous and both this and `postgres-date` produce the same `Date`; the
954
+ * only difference is how long it takes. That is also why it is not governed by
955
+ * a semantic decision the way `utcTimestamps` governs the zone-less types: there
956
+ * is no second interpretation to choose between.
957
+ *
958
+ * `fallback` must be captured with `pg.types.getTypeParser(1184, 'text')`
959
+ * BEFORE registration, for the same reason as the parsers above: reading it
960
+ * afterwards hands back this function and recurses forever. It is what keeps
961
+ * `infinity` / `-infinity`, ` BC`, wide and low years, and every non-ISO
962
+ * `DateStyle` on `postgres-date`, which already handles them.
963
+ */
964
+ function createFastTimestamptzParser(fallback) {
965
+ return (text) => scanIsoTemporal(text, SHAPE_TIMESTAMPTZ) ?? fallback(text);
966
+ }
659
967
  /**
660
968
  * The offset-less-timestamp-as-UTC reading, with no fallback: `text` must be a
661
969
  * plain `YYYY-MM-DD HH:MM:SS[.ffffff]`. Used where the input shape is already
@@ -677,10 +985,21 @@ function parseUtcTimestampText(text) {
677
985
  * `pg.types.arrayParser` is a public member of the `pg` module (it is what the
678
986
  * driver's own `_text` / `_date` parsers are built from), so this adds no
679
987
  * dependency. NULL elements stay `null` and are never handed to `element`.
988
+ *
989
+ * The empty-string guard mirrors pg's own `parseDateArray`, which opens
990
+ * `if (!value) return null`. Turbine's copy did not, and answered `[]` where
991
+ * the driver answers `null` for the same input. No column produces it (a SQL
992
+ * NULL never reaches a parser, and an empty array is `{}`), so this is parity
993
+ * for its own sake rather than a bug report; it matters because these parsers
994
+ * are registered process-globally over pg's, and a shape where Turbine's
995
+ * answer differs from the driver's is a difference somebody eventually finds
996
+ * the hard way.
680
997
  */
681
998
  function createPgArrayParser(element) {
682
999
  const arrayParser = pg_1.default.types.arrayParser;
683
- return (text) => arrayParser.create(text, (entry) => (entry === null || entry === undefined ? null : element(entry))).parse();
1000
+ return (text) => text
1001
+ ? arrayParser.create(text, (entry) => (entry === null || entry === undefined ? null : element(entry))).parse()
1002
+ : null;
684
1003
  }
685
1004
  /**
686
1005
  * A canonical wire value per OID, plus the JS value pg's OWN default text
@@ -699,6 +1018,13 @@ const DEFAULT_PARSER_PROBES = {
699
1018
  1114: { text: '2020-01-02 03:04:05', expected: () => new Date(2020, 0, 2, 3, 4, 5) },
700
1019
  1115: { text: '{"2020-01-02 03:04:05"}', expected: () => [new Date(2020, 0, 2, 3, 4, 5)] },
701
1020
  1182: { text: '{2020-01-02}', expected: () => [new Date(2020, 0, 2)] },
1021
+ // The `timestamptz` pair carries an explicit offset, so unlike the four
1022
+ // above its expectation is NOT computed from local components: pg's default
1023
+ // and Turbine's fast scan produce the same instant in every zone. These two
1024
+ // probes are consulted by {@link registerFastTemporalParserIfDefault}, which
1025
+ // DECLINES to install rather than warning-and-overwriting.
1026
+ 1184: { text: '2020-01-02 03:04:05+00', expected: () => new Date(Date.UTC(2020, 0, 2, 3, 4, 5)) },
1027
+ 1185: { text: '{"2020-01-02 03:04:05+00"}', expected: () => [new Date(Date.UTC(2020, 0, 2, 3, 4, 5))] },
702
1028
  };
703
1029
  /**
704
1030
  * Marks a text parser as one TURBINE installed, so a later registration in the
@@ -708,8 +1034,15 @@ const DEFAULT_PARSER_PROBES = {
708
1034
  * uses one.
709
1035
  */
710
1036
  const TURBINE_PARSER = Symbol.for('turbine.typeParser');
711
- /** The OIDs the `utcTimestamps` flag governs, and so the ones it can opt out of. */
712
- const TEMPORAL_PARSER_OIDS = new Set([1114, 1082, 1115, 1182]);
1037
+ /**
1038
+ * The OIDs the `utcTimestamps` flag governs, and so the ones it can opt out of.
1039
+ *
1040
+ * 1184 / 1185 are in the set because the flag gates their registration too,
1041
+ * but they are there for a different reason from the other four: those four
1042
+ * change a READING (local zone to UTC), while the `timestamptz` pair changes
1043
+ * only decode SPEED. See {@link registerUtcTemporalParsers}.
1044
+ */
1045
+ const TEMPORAL_PARSER_OIDS = new Set([1114, 1082, 1115, 1182, 1184, 1185]);
713
1046
  /** Tag `parser` as Turbine's own and return it (see {@link TURBINE_PARSER}). */
714
1047
  function markTurbineParser(parser) {
715
1048
  parser[TURBINE_PARSER] = true;
@@ -802,11 +1135,14 @@ function warnParserOverwrite(oid, typeName) {
802
1135
  return;
803
1136
  if (!(0, warn_registry_js_1.shouldWarnOnce)(warn_registry_js_1.WARN_NS.parserOverwrite, String(oid)))
804
1137
  return;
805
- // The `utcTimestamps: false` opt-out only governs the four TEMPORAL OIDs.
1138
+ // The `utcTimestamps: false` opt-out only governs the six TEMPORAL OIDs.
806
1139
  // Offering it as the remedy for int8 (20) would name a setting that does
807
1140
  // nothing for the OID being warned about; that registration has no opt-out.
1141
+ // (1184 / 1185 never reach this warning at all: they DECLINE over a
1142
+ // non-default parser instead of overwriting it. They are named in the
1143
+ // sentence because it describes what the flag leaves alone.)
808
1144
  const remedy = TEMPORAL_PARSER_OIDS.has(oid)
809
- ? ' `utcTimestamps: false` leaves the four temporal OIDs (1114, 1082, 1115, 1182) alone entirely.'
1145
+ ? ' `utcTimestamps: false` leaves the six temporal OIDs (1114, 1082, 1115, 1182, 1184, 1185) alone entirely.'
810
1146
  : ' There is no opt-out for this OID: Turbine registers it so bigint values come back as numbers.';
811
1147
  console.warn(`[turbine] pg type parser for OID ${oid} (${typeName}) was already customized by something else in this ` +
812
1148
  'process, and Turbine is replacing it. `pg.types.setTypeParser` is process-global and takes effect ' +
@@ -817,8 +1153,21 @@ function warnParserOverwrite(oid, typeName) {
817
1153
  'in production from import order alone.');
818
1154
  }
819
1155
  /**
820
- * Register the UTC readings of the four zone-less temporal OIDs on the pg
821
- * module: `timestamp` (1114), `date` (1082) and their array forms (1115, 1182).
1156
+ * Register Turbine's temporal text parsers on the pg module. SIX OIDs, doing
1157
+ * two different jobs:
1158
+ *
1159
+ * 1114 / 1082 / 1115 / 1182 the UTC READING of the zone-less types,
1160
+ * `timestamp`, `date` and their array forms.
1161
+ * This changes what a column means and is what
1162
+ * `utcTimestamps` is named for.
1163
+ * 1184 / 1185 the fast decode path for `timestamptz` and
1164
+ * `timestamptz[]`. This changes NOTHING about
1165
+ * what a column means: an offset-carrying value
1166
+ * has one instant and this reads the same one.
1167
+ * It is here for speed, `timestamptz` being ~88%
1168
+ * of the client-side decode cost of a wide row
1169
+ * drain, and it DECLINES rather than overwrites
1170
+ * (see the comment at the call site).
822
1171
  *
823
1172
  * ONE place, because `pg.types.setTypeParser` is process-global and the pairing
824
1173
  * matters: registering a scalar without its array form, or a `date` without the
@@ -856,6 +1205,58 @@ function registerUtcTemporalParsers() {
856
1205
  // beside them returned UTC ones.
857
1206
  setParser(1182, markTurbineParser(createPgArrayParser(parseDate)));
858
1207
  setParser(1115, markTurbineParser(createPgArrayParser(parseTimestamp)));
1208
+ // `timestamptz` (1184) and `timestamptz[]` (1185). SPEED ONLY: an offset-
1209
+ // carrying value has exactly one instant, and this reads it as the same
1210
+ // instant `postgres-date` does, so nothing here changes what a column means.
1211
+ //
1212
+ // Two things about it are deliberate and neither is obvious.
1213
+ //
1214
+ // It is gated behind `utcTimestamps` along with the other four, even though
1215
+ // that flag is about a READING and this is not. The alternative was a second
1216
+ // registration site outside this function, and one process-global parser
1217
+ // table with two places that write to it is the exact shape the "ONE place"
1218
+ // rule above exists to prevent. So the flag reads as "leave pg's temporal
1219
+ // parser table alone", and `utcTimestamps: false` costs the optimisation as
1220
+ // well as the UTC reading. That is a documented cost, not an oversight.
1221
+ //
1222
+ // And it DECLINES rather than overwrites (see
1223
+ // {@link registerFastTemporalParserIfDefault}), which is the opposite of what
1224
+ // the four above do. They MUST overwrite: they exist to replace a reading,
1225
+ // and a process where half the temporal columns read local and half read UTC
1226
+ // is broken. This one exists only to be faster, so a caller who installed
1227
+ // their own `timestamptz` parser (to get strings, or Luxon objects, or a
1228
+ // Temporal instant) keeps it. Overwriting them would trade their correctness
1229
+ // for our speed, which is never the right trade, and Turbine has never
1230
+ // touched 1184 before now, so declining is also what preserves that.
1231
+ const parseTimestamptz = registerFastTemporalParserIfDefault(1184, createFastTimestamptzParser);
1232
+ if (parseTimestamptz) {
1233
+ registerFastTemporalParserIfDefault(1185, () => createPgArrayParser(parseTimestamptz));
1234
+ }
1235
+ }
1236
+ /**
1237
+ * Install a SPEED-ONLY parser for `oid`, but only over pg's own default (or
1238
+ * over a parser Turbine itself installed earlier in this process).
1239
+ *
1240
+ * Returns the installed parser, or `undefined` when it declined, so a caller
1241
+ * can hold a scalar and its array form to the same decision: registering the
1242
+ * array half over a caller's customized scalar half would make the two
1243
+ * disagree, which is worse than leaving both slow.
1244
+ *
1245
+ * `build` receives the parser being replaced, which becomes the fast path's
1246
+ * fallback. That is only sound because the parser being replaced is known to
1247
+ * be pg's default (or Turbine's own wrapper around it); the check below is
1248
+ * what makes it so, and is not an optimisation of it.
1249
+ */
1250
+ function registerFastTemporalParserIfDefault(oid, build) {
1251
+ const getParser = pg_1.default.types.getTypeParser;
1252
+ const setParser = pg_1.default.types.setTypeParser;
1253
+ const current = getParser(oid, 'text');
1254
+ const isTurbines = current[TURBINE_PARSER] === true;
1255
+ if (!isTurbines && !isDefaultTextParser(oid, current))
1256
+ return undefined;
1257
+ const parser = markTurbineParser(build(current));
1258
+ setParser(oid, parser);
1259
+ return parser;
859
1260
  }
860
1261
  // ---------------------------------------------------------------------------
861
1262
  // JSON-wire value coercion (relationLoadStrategy: 'join')
@@ -120,11 +120,16 @@ function walkWhere(host, where) {
120
120
  events.push({ kind: 'not', condition: value });
121
121
  continue;
122
122
  }
123
- const relDef = (0, utils_js_1.ownLookup)(host.tableMeta.relations, key);
124
- if (relDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
125
- const filterObj = host.normalizeRelationFilter(relDef, value);
123
+ // Resolved rather than looked up, so a relation filter accepts the
124
+ // snake_case spelling of the relation exactly as `with` does. This is the
125
+ // ONE branch authority (build, fingerprint and param-collect all consume
126
+ // these events), so resolving here cannot drift between them; the emitted
127
+ // event carries the DECLARED name, which is what reaches the fingerprint.
128
+ const resolvedRel = (0, utils_js_1.resolveRelation)(host.tableMeta.relations, key);
129
+ if (resolvedRel && typeof value === 'object' && value !== null && !Array.isArray(value)) {
130
+ const filterObj = host.normalizeRelationFilter(resolvedRel.def, value);
126
131
  if (isRelationFilterObj(filterObj)) {
127
- events.push({ kind: 'relation', key, relDef, filterObj });
132
+ events.push({ kind: 'relation', key: resolvedRel.name, relDef: resolvedRel.def, filterObj });
128
133
  continue;
129
134
  }
130
135
  }
@@ -812,8 +812,8 @@ function buildScopedWhere(qi, scope, where, params, depth = 0) {
812
812
  */
813
813
  function buildScopedScalarClause(qi, scope, field, value, params, clauses) {
814
814
  const meta = scope.meta;
815
- const col = (0, utils_js_1.ownLookup)(meta.columnMap, field) ?? (0, schema_js_1.camelToSnake)(field);
816
- if (!meta.allColumns.includes(col))
815
+ const col = (0, utils_js_1.resolveColumnName)(meta, field);
816
+ if (col === undefined)
817
817
  throw scope.unknownColumn(field);
818
818
  const qCol = `${scope.qualifier}${qi.q(col)}`;
819
819
  if (value === null) {
@@ -891,7 +891,11 @@ function collectScopedScalarParams(qi, scope, field, value, params) {
891
891
  if (value === null)
892
892
  return;
893
893
  const meta = scope.meta;
894
- const col = (0, utils_js_1.ownLookup)(meta.columnMap, field) ?? (0, schema_js_1.camelToSnake)(field);
894
+ // Unvalidated on purpose: this is the cache-HIT mirror, and a key that does
895
+ // not resolve could never have produced the entry being served. It still
896
+ // goes through the one authority, so the column it binds against cannot
897
+ // differ from the one the build path emitted.
898
+ const col = (0, utils_js_1.resolveColumnName)(meta, field) ?? (0, schema_js_1.camelToSnake)(field);
895
899
  if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
896
900
  const colType = pgTypeForColumn(qi, meta, col);
897
901
  if (isJsonColumnType(qi, colType)) {
@@ -1231,8 +1235,8 @@ function resolveColumnRef(_qi, ref, ctx, mode) {
1231
1235
  `Case-insensitive column-to-column comparison is not supported: use client.sql\`...\` ` +
1232
1236
  `for lower(a) = lower(b).`);
1233
1237
  }
1234
- const col = (0, utils_js_1.ownLookup)(ctx.meta.columnMap, ref.col) ?? (0, schema_js_1.camelToSnake)(ref.col);
1235
- if (!ctx.meta.allColumns.includes(col)) {
1238
+ const col = (0, utils_js_1.resolveColumnName)(ctx.meta, ref.col);
1239
+ if (col === undefined) {
1236
1240
  throw new errors_js_1.ValidationError(`[turbine] Unknown field "${ref.col}" referenced by { col } in where on table "${ctx.table}". ` +
1237
1241
  `Known fields: ${Object.keys(ctx.meta.columnMap).join(', ') || '(none)'}.`);
1238
1242
  }
package/dist/client.d.ts CHANGED
@@ -800,24 +800,6 @@ export declare class TurbineClient {
800
800
  * (`ALTER ROLE ... SET plan_cache_mode = ...`).
801
801
  */
802
802
  private static withPlanCacheMode;
803
- /**
804
- * `connectionString` with `setting` appended to its existing `options` query
805
- * parameter, or `null` when it carries no `options` (in which case the caller
806
- * should use the `options` pool field, which is not overridden).
807
- *
808
- * Only the query string is rewritten, never the userinfo or host, so a
809
- * percent-encoded password cannot be mangled by a round trip through `URL`.
810
- * The split is on the first `?`, which is also where pg's own parser puts the
811
- * query-string boundary: a connection string with an unencoded `?` inside the
812
- * password is not parseable by pg either, so there is no shape this handles
813
- * differently from the driver.
814
- *
815
- * A twin of this lives in `src/connection-url.ts`, which `turbine doctor`
816
- * uses for `statement_timeout`. Unifying them is the obvious refactor and it
817
- * is deliberately NOT done; the reason (a c8 merge artifact that costs almost
818
- * all of the coverage gate's headroom) is written up over there.
819
- */
820
- private static mergeConnectionStringOptions;
821
803
  /**
822
804
  * Refuse a `utcTimestamps` value that contradicts the one an earlier client
823
805
  * in this process settled the zone-less temporal read parsers (OIDs 1114,
package/dist/client.js CHANGED
@@ -22,6 +22,7 @@
22
22
  * ```
23
23
  */
24
24
  import pg from 'pg';
25
+ import { mergeConnectionStringOptions } from './connection-url.js';
25
26
  import { postgresDialect } from './dialect.js';
26
27
  import { ConnectionError, errorMessageModesDiverged, registerClientErrorMessageMode, runWithErrorMessageMode, setErrorMessageMode, TimeoutError, UnsupportedFeatureError, ValidationError, wrapPgError, } from './errors.js';
27
28
  import { ObserveEngine } from './observe.js';
@@ -1138,7 +1139,7 @@ export class TurbineClient {
1138
1139
  // cannot be a bind parameter.
1139
1140
  const setting = `-c plan_cache_mode=${mode}`;
1140
1141
  const merged = poolConfig.connectionString
1141
- ? TurbineClient.mergeConnectionStringOptions(poolConfig.connectionString, setting)
1142
+ ? mergeConnectionStringOptions(poolConfig.connectionString, setting)
1142
1143
  : null;
1143
1144
  if (merged)
1144
1145
  return { ...poolConfig, connectionString: merged };
@@ -1148,34 +1149,6 @@ export class TurbineClient {
1148
1149
  const existing = poolConfig.options || (typeof process !== 'undefined' ? process.env?.PGOPTIONS : undefined);
1149
1150
  return { ...poolConfig, options: existing ? `${existing} ${setting}` : setting };
1150
1151
  }
1151
- /**
1152
- * `connectionString` with `setting` appended to its existing `options` query
1153
- * parameter, or `null` when it carries no `options` (in which case the caller
1154
- * should use the `options` pool field, which is not overridden).
1155
- *
1156
- * Only the query string is rewritten, never the userinfo or host, so a
1157
- * percent-encoded password cannot be mangled by a round trip through `URL`.
1158
- * The split is on the first `?`, which is also where pg's own parser puts the
1159
- * query-string boundary: a connection string with an unencoded `?` inside the
1160
- * password is not parseable by pg either, so there is no shape this handles
1161
- * differently from the driver.
1162
- *
1163
- * A twin of this lives in `src/connection-url.ts`, which `turbine doctor`
1164
- * uses for `statement_timeout`. Unifying them is the obvious refactor and it
1165
- * is deliberately NOT done; the reason (a c8 merge artifact that costs almost
1166
- * all of the coverage gate's headroom) is written up over there.
1167
- */
1168
- static mergeConnectionStringOptions(connectionString, setting) {
1169
- const q = connectionString.indexOf('?');
1170
- if (q === -1)
1171
- return null;
1172
- const params = new URLSearchParams(connectionString.slice(q + 1));
1173
- const existing = params.get('options');
1174
- if (existing === null)
1175
- return null;
1176
- params.set('options', `${existing} ${setting}`);
1177
- return connectionString.slice(0, q + 1) + params.toString();
1178
- }
1179
1152
  /**
1180
1153
  * Refuse a `utcTimestamps` value that contradicts the one an earlier client
1181
1154
  * in this process settled the zone-less temporal read parsers (OIDs 1114,