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
@@ -4,7 +4,7 @@
4
4
  * Standalone utility functions and classes used by the query builder.
5
5
  */
6
6
  import pg from 'pg';
7
- import { camelToSnake, localDateTimeKind, timeOfDayKind } from '../schema.js';
7
+ import { camelToSnake, localDateTimeKind, snakeToCamel, timeOfDayKind } from '../schema.js';
8
8
  import { shouldWarnOnce, WARN_NS } from './warn-registry.js';
9
9
  // ---------------------------------------------------------------------------
10
10
  // Identifier quoting, prevents SQL injection via table/column names
@@ -67,6 +67,55 @@ export function resolveColumnName(meta, key) {
67
67
  return snake;
68
68
  return undefined;
69
69
  }
70
+ /**
71
+ * Resolve a user-supplied key to a relation's CANONICAL name and definition, or
72
+ * `undefined` when the key names no relation on the table.
73
+ *
74
+ * The relation-name half of the rule {@link resolveColumnName} states for
75
+ * columns, and deliberately the same shape: the declared name first, else
76
+ * `snakeToCamel(key)` accepted ONLY when that names a real relation.
77
+ * `snakeToCamel` is idempotent on an already-camel string, so a canonical key
78
+ * takes the first branch and this is a no-op for every existing caller.
79
+ *
80
+ * WHY IT EXISTS. A relation has one declared name, `ripeningChecks`, while the
81
+ * DDL anyone reads has only the TABLE name, `ripening_checks`. Writing back
82
+ * what the schema shows therefore failed with E005 in `with`, E003 in a
83
+ * relation filter, and E005 in `orderBy`, on names the error text was already
84
+ * computing correctly ("Did you mean ...?"). A system that can name the
85
+ * intended relation can accept it.
86
+ *
87
+ * NOT A GUESS, for the same reason the column rule is not: the transformed name
88
+ * is accepted only when it is a real declared relation, so an unknown key still
89
+ * fails and a typo is still a typo. Exact match wins first, so a schema that
90
+ * literally declares `ripening_checks` keeps it, even alongside a
91
+ * `ripeningChecks`.
92
+ *
93
+ * The RESULT KEY is the canonical name, not the caller's spelling, matching the
94
+ * column side (`select: { ledger_handle: true }` already returns
95
+ * `{ ledgerHandle }`). Resolving here rather than normalizing the args up front
96
+ * also means both spellings share one SQL-cache entry instead of minting two
97
+ * templates for one query.
98
+ *
99
+ * Prototype-safe via {@link ownLookup}, so `__proto__` cannot name a relation.
100
+ */
101
+ export function resolveRelation(relations, key) {
102
+ const direct = ownLookup(relations, key);
103
+ if (direct !== undefined)
104
+ return { name: key, def: direct };
105
+ const camel = snakeToCamel(key);
106
+ if (camel === key)
107
+ return undefined;
108
+ const mapped = ownLookup(relations, camel);
109
+ return mapped === undefined ? undefined : { name: camel, def: mapped };
110
+ }
111
+ /**
112
+ * {@link resolveRelation} when only the definition is wanted: a drop-in for the
113
+ * `ownLookup(meta.relations, key)` it replaces, with the same signature and the
114
+ * same `undefined` on a miss.
115
+ */
116
+ export function resolveRelationDef(relations, key) {
117
+ return resolveRelation(relations, key)?.def;
118
+ }
70
119
  // ---------------------------------------------------------------------------
71
120
  // Caller-controlled key ORDER, canonicalized
72
121
  // ---------------------------------------------------------------------------
@@ -530,6 +579,211 @@ export function isTemporalInfinity(value) {
530
579
  // ---------------------------------------------------------------------------
531
580
  // Driver type parsers for the zone-less temporal OIDs
532
581
  // ---------------------------------------------------------------------------
582
+ // ---------------------------------------------------------------------------
583
+ // The fast temporal scan
584
+ //
585
+ // Every temporal parser below is a two-stage function: a hand-written
586
+ // character scan that claims the ONE wire shape a busy application actually
587
+ // produces, and behind it the general parser, which is where every other shape
588
+ // (and every shape a future server adds) still goes.
589
+ //
590
+ // WHY IT EXISTS, stated as a measurement rather than an intuition. Draining
591
+ // 50,000 rows of the benchmark `comments` fixture spends ~23.5 ms in
592
+ // client-side type decoding, and `timestamptz` alone is 20.7 ms of it (88%).
593
+ // The remaining types are not worth touching: `text`, `numeric`, `uuid` and
594
+ // `bool` cost nothing at all, because pg registers no parser for them. Two
595
+ // hard bounds were verified in node-postgres' source before any of this was
596
+ // written, and they say what a decoder rewrite can and cannot reach: every
597
+ // cell is materialised as a JS string by `reader.string(len)` before any
598
+ // parser is consulted, so the `utf8Slice` half is unreachable; and the binary
599
+ // protocol is not an escape hatch, the parser constructor throws
600
+ // `Binary mode not supported yet`. The type-PARSING half is the whole budget,
601
+ // and this is a claim on it.
602
+ //
603
+ // THE RULE, and it is the only thing that makes a fast path acceptable here:
604
+ // **the scan must return exactly what the parser it replaces returned, or
605
+ // return `null` and not claim the value at all.** It never guesses, never
606
+ // "handles" a shape approximately, and never widens what it accepts to cover
607
+ // one more case. Every refusal costs a few charCode compares and is repaid by
608
+ // the general parser being correct.
609
+ //
610
+ // Two traps are baked into the refusals rather than into corrections, because
611
+ // a throwaway version of this decoder wrote during the ceiling measurement got
612
+ // 3 of 15 edge values wrong while believing it had delegated the hard ones:
613
+ //
614
+ // 1. `Date.UTC` maps years 0-99 onto 1900-1999, so `0044-03-15` decodes as
615
+ // 1944 and `0001-01-01` as 1901. The obvious repair, build the Date then
616
+ // `setUTCFullYear`, is ALSO wrong: year 0 is a leap year in the proleptic
617
+ // Gregorian calendar and 1900 is not, so `0000-02-29` built that way
618
+ // lands on March 1st. The scan refuses `year < 100` and lets the general
619
+ // parser's `new Date(0)` + `setUTCFullYear(y, m, d)` assembly (which sets
620
+ // all three fields against the right year's calendar) answer it.
621
+ // 2. An offset parse that runs to end-of-string silently EATS a trailing
622
+ // ` BC`: `4713-01-01 00:00:00+00 BC` decodes as AD 4713, off by 9,424
623
+ // years with no error. The scan requires end-of-string after the offset.
624
+ //
625
+ // Differential coverage: src/test/fast-temporal-decode.test.ts compares the
626
+ // scan against the parser it replaces value for value, and
627
+ // src/test/fast-temporal-decode.integration.test.ts does the same against a
628
+ // live server with `DateStyle` and `TimeZone` varied, because the wire shape
629
+ // is a server setting and a decoder tuned to one `DateStyle` is a latent
630
+ // corruption bug.
631
+ // ---------------------------------------------------------------------------
632
+ // Wire shape the scan is reading. Plain module constants rather than an enum:
633
+ // this repo's lint config rejects `const enum` (it does not survive
634
+ // `isolatedModules`), and a plain `enum` emits a runtime object, so every
635
+ // `Shape.Date` in the scan below would become a property load in the hottest
636
+ // loop in the library. These are values, never serialized, never persisted.
637
+ /** `YYYY-MM-DD` (OID 1082). */
638
+ const SHAPE_DATE = 0;
639
+ /** `YYYY-MM-DD[ T]HH:MM:SS[.f…]` (OID 1114), never an offset. */
640
+ const SHAPE_TIMESTAMP = 1;
641
+ /** `YYYY-MM-DD HH:MM:SS[.f…](Z|±HH[:MM[:SS]])` (OID 1184), offset REQUIRED. */
642
+ const SHAPE_TIMESTAMPTZ = 2;
643
+ const CH_HYPHEN = 45;
644
+ const CH_COLON = 58;
645
+ const CH_DOT = 46;
646
+ const CH_SPACE = 32;
647
+ const CH_T = 84;
648
+ const CH_Z = 90;
649
+ const CH_PLUS = 43;
650
+ /** Same code point as {@link CH_HYPHEN}; named separately because the two read
651
+ * as different things (a date separator, an offset sign) at their use sites. */
652
+ const CH_MINUS = 45;
653
+ /**
654
+ * Two ASCII digits at `i` as a number, or `-1` if either character is not a
655
+ * digit. `charCodeAt` past the end returns `NaN`, and `NaN - 48` is `NaN`,
656
+ * which fails both range tests, so this needs no separate length check.
657
+ */
658
+ function twoDigitsAt(text, i) {
659
+ const hi = text.charCodeAt(i) - 48;
660
+ const lo = text.charCodeAt(i + 1) - 48;
661
+ return hi >= 0 && hi <= 9 && lo >= 0 && lo <= 9 ? hi * 10 + lo : -1;
662
+ }
663
+ /**
664
+ * Decode `text` if it is exactly the canonical ISO wire shape for `shape`, or
665
+ * return `null` to say "not mine" (see the rule in the block comment above).
666
+ *
667
+ * Deliberately NOT accepted, each because the parser it replaces would answer
668
+ * differently:
669
+ *
670
+ * - a year that is not exactly four digits. A 5-digit year is a real
671
+ * PostgreSQL output and the general parser handles it; a fast path that
672
+ * matched a variable-width year would have to re-find every later field.
673
+ * - a year below 100 (trap 1 above).
674
+ * - a `T` separator for `SHAPE_TIMESTAMPTZ`. `postgres-date`'s
675
+ * own date-time regex requires a literal SPACE, so it returns `null` for
676
+ * `2024-01-01T00:00:00+00`; a scan that accepted `T` there would invent a
677
+ * Date where the driver hands back null. `timestamp` (1114) does accept it
678
+ * because the general parser it replaces does.
679
+ * - a missing offset for `SHAPE_TIMESTAMPTZ`. `postgres-date` reads an
680
+ * offset-less value in the process's LOCAL zone, which is not what this
681
+ * scan computes.
682
+ * - a colon-less offset (`+0530`), an alphabetic zone (` UTC`), or anything
683
+ * at all after the offset, including ` BC` (trap 2 above).
684
+ * - a bare `.` with no fractional digits.
685
+ *
686
+ * Field-value overflow (`2024-13-45`, `25:70:99`) IS accepted, because
687
+ * `Date.UTC` rolls those over exactly as the general parser's `setUTCFullYear`
688
+ * / `setUTCHours` do. PostgreSQL never emits them; agreeing on them is free.
689
+ */
690
+ function scanIsoTemporal(text, shape) {
691
+ const len = text.length;
692
+ if (len < 10)
693
+ return null;
694
+ if (text.charCodeAt(4) !== CH_HYPHEN || text.charCodeAt(7) !== CH_HYPHEN)
695
+ return null;
696
+ const yearHi = twoDigitsAt(text, 0);
697
+ const yearLo = twoDigitsAt(text, 2);
698
+ if (yearHi < 0 || yearLo < 0)
699
+ return null;
700
+ const year = yearHi * 100 + yearLo;
701
+ if (year < 100)
702
+ return null;
703
+ const month = twoDigitsAt(text, 5);
704
+ const day = twoDigitsAt(text, 8);
705
+ if (month < 0 || day < 0)
706
+ return null;
707
+ if (shape === SHAPE_DATE) {
708
+ return len === 10 ? new Date(Date.UTC(year, month - 1, day)) : null;
709
+ }
710
+ if (len < 19)
711
+ return null;
712
+ const sep = text.charCodeAt(10);
713
+ if (shape === SHAPE_TIMESTAMP ? sep !== CH_SPACE && sep !== CH_T : sep !== CH_SPACE)
714
+ return null;
715
+ if (text.charCodeAt(13) !== CH_COLON || text.charCodeAt(16) !== CH_COLON)
716
+ return null;
717
+ const hour = twoDigitsAt(text, 11);
718
+ const minute = twoDigitsAt(text, 14);
719
+ const second = twoDigitsAt(text, 17);
720
+ if (hour < 0 || minute < 0 || second < 0)
721
+ return null;
722
+ let i = 19;
723
+ let ms = 0;
724
+ if (text.charCodeAt(i) === CH_DOT) {
725
+ i++;
726
+ let digits = 0;
727
+ for (;;) {
728
+ const d = text.charCodeAt(i) - 48;
729
+ if (!(d >= 0 && d <= 9))
730
+ break;
731
+ // Only the first three digits survive: PostgreSQL emits up to six and a
732
+ // JS Date holds milliseconds. `postgres-date` gets the same answer by a
733
+ // different route (`1000 * parseFloat('.476603')`, truncated by
734
+ // `Date.UTC`), and the two agree on every 1-to-6-digit fraction.
735
+ if (digits < 3)
736
+ ms = ms * 10 + d;
737
+ digits++;
738
+ i++;
739
+ }
740
+ if (digits === 0)
741
+ return null;
742
+ if (digits === 1)
743
+ ms *= 100;
744
+ else if (digits === 2)
745
+ ms *= 10;
746
+ }
747
+ if (shape === SHAPE_TIMESTAMP) {
748
+ return i === len ? new Date(Date.UTC(year, month - 1, day, hour, minute, second, ms)) : null;
749
+ }
750
+ let offsetMs = 0;
751
+ const signCode = text.charCodeAt(i);
752
+ if (signCode === CH_Z) {
753
+ i++;
754
+ }
755
+ else if (signCode === CH_PLUS || signCode === CH_MINUS) {
756
+ i++;
757
+ const offsetHours = twoDigitsAt(text, i);
758
+ if (offsetHours < 0)
759
+ return null;
760
+ i += 2;
761
+ let offsetMinutes = 0;
762
+ let offsetSeconds = 0;
763
+ if (text.charCodeAt(i) === CH_COLON) {
764
+ offsetMinutes = twoDigitsAt(text, i + 1);
765
+ if (offsetMinutes < 0)
766
+ return null;
767
+ i += 3;
768
+ // A zone whose historical LMT offset was not a whole minute emits
769
+ // seconds too (`1850-01-01 00:00:00+00:19:32`).
770
+ if (text.charCodeAt(i) === CH_COLON) {
771
+ offsetSeconds = twoDigitsAt(text, i + 1);
772
+ if (offsetSeconds < 0)
773
+ return null;
774
+ i += 3;
775
+ }
776
+ }
777
+ offsetMs =
778
+ (offsetHours * 3_600_000 + offsetMinutes * 60_000 + offsetSeconds * 1000) * (signCode === CH_MINUS ? -1 : 1);
779
+ }
780
+ else {
781
+ return null;
782
+ }
783
+ if (i !== len)
784
+ return null;
785
+ return new Date(Date.UTC(year, month - 1, day, hour, minute, second, ms) - offsetMs);
786
+ }
533
787
  /**
534
788
  * A Postgres `date` wire value: `YYYY-MM-DD`, optionally with more than four
535
789
  * year digits, optionally suffixed ` BC`. Anything else (`infinity`,
@@ -563,6 +817,22 @@ const PG_DATE_TEXT_RE = /^(\d{4,})-(\d{2})-(\d{2})( BC)?$/;
563
817
  * astronomical year (`0044 BC` → -43) the way the driver's own parser does.
564
818
  */
565
819
  export function createUtcDateParser(fallback) {
820
+ const general = createUtcDateParserGeneral(fallback);
821
+ return (text) => scanIsoTemporal(text, SHAPE_DATE) ?? general(text);
822
+ }
823
+ /**
824
+ * The `date` (OID 1082) parser WITHOUT the fast scan in front of it: the regex
825
+ * implementation described by {@link createUtcDateParser}, and the reference
826
+ * side of the differential test.
827
+ *
828
+ * Exported so "what the fast path must agree with" is the running code rather
829
+ * than a transcription of it in a test file. Two hand-synced copies of a
830
+ * parser is the drift class this repo has been bitten by before; there is one
831
+ * copy, and the fast path delegates to it.
832
+ *
833
+ * @internal
834
+ */
835
+ export function createUtcDateParserGeneral(fallback) {
566
836
  return (text) => {
567
837
  const m = PG_DATE_TEXT_RE.exec(text);
568
838
  if (!m)
@@ -602,6 +872,18 @@ const PG_TIMESTAMP_TEXT_RE = /^(\d{4,})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2
602
872
  * `Date`-string parsing did too.
603
873
  */
604
874
  export function createUtcTimestampParser(fallback) {
875
+ const general = createUtcTimestampParserGeneral(fallback);
876
+ return (text) => scanIsoTemporal(text, SHAPE_TIMESTAMP) ?? general(text);
877
+ }
878
+ /**
879
+ * The `timestamp` (OID 1114) parser WITHOUT the fast scan in front of it: the
880
+ * regex implementation described by {@link createUtcTimestampParser}, and the
881
+ * reference side of the differential test. Same reasoning as
882
+ * {@link createUtcDateParserGeneral}.
883
+ *
884
+ * @internal
885
+ */
886
+ export function createUtcTimestampParserGeneral(fallback) {
605
887
  return (text) => {
606
888
  const m = PG_TIMESTAMP_TEXT_RE.exec(text);
607
889
  if (!m)
@@ -614,6 +896,27 @@ export function createUtcTimestampParser(fallback) {
614
896
  return date;
615
897
  };
616
898
  }
899
+ /**
900
+ * Build the driver parser for Postgres `timestamptz` (OID 1184): the ISO wire
901
+ * shape decoded by {@link scanIsoTemporal}, everything else handed straight to
902
+ * `fallback`.
903
+ *
904
+ * UNLIKE the `date` and `timestamp` parsers beside it, this one changes NO
905
+ * READING. A `timestamptz` arrives with an explicit offset, so its instant is
906
+ * unambiguous and both this and `postgres-date` produce the same `Date`; the
907
+ * only difference is how long it takes. That is also why it is not governed by
908
+ * a semantic decision the way `utcTimestamps` governs the zone-less types: there
909
+ * is no second interpretation to choose between.
910
+ *
911
+ * `fallback` must be captured with `pg.types.getTypeParser(1184, 'text')`
912
+ * BEFORE registration, for the same reason as the parsers above: reading it
913
+ * afterwards hands back this function and recurses forever. It is what keeps
914
+ * `infinity` / `-infinity`, ` BC`, wide and low years, and every non-ISO
915
+ * `DateStyle` on `postgres-date`, which already handles them.
916
+ */
917
+ export function createFastTimestamptzParser(fallback) {
918
+ return (text) => scanIsoTemporal(text, SHAPE_TIMESTAMPTZ) ?? fallback(text);
919
+ }
617
920
  /**
618
921
  * The offset-less-timestamp-as-UTC reading, with no fallback: `text` must be a
619
922
  * plain `YYYY-MM-DD HH:MM:SS[.ffffff]`. Used where the input shape is already
@@ -635,10 +938,21 @@ export function parseUtcTimestampText(text) {
635
938
  * `pg.types.arrayParser` is a public member of the `pg` module (it is what the
636
939
  * driver's own `_text` / `_date` parsers are built from), so this adds no
637
940
  * dependency. NULL elements stay `null` and are never handed to `element`.
941
+ *
942
+ * The empty-string guard mirrors pg's own `parseDateArray`, which opens
943
+ * `if (!value) return null`. Turbine's copy did not, and answered `[]` where
944
+ * the driver answers `null` for the same input. No column produces it (a SQL
945
+ * NULL never reaches a parser, and an empty array is `{}`), so this is parity
946
+ * for its own sake rather than a bug report; it matters because these parsers
947
+ * are registered process-globally over pg's, and a shape where Turbine's
948
+ * answer differs from the driver's is a difference somebody eventually finds
949
+ * the hard way.
638
950
  */
639
951
  export function createPgArrayParser(element) {
640
952
  const arrayParser = pg.types.arrayParser;
641
- return (text) => arrayParser.create(text, (entry) => (entry === null || entry === undefined ? null : element(entry))).parse();
953
+ return (text) => text
954
+ ? arrayParser.create(text, (entry) => (entry === null || entry === undefined ? null : element(entry))).parse()
955
+ : null;
642
956
  }
643
957
  /**
644
958
  * A canonical wire value per OID, plus the JS value pg's OWN default text
@@ -657,6 +971,13 @@ const DEFAULT_PARSER_PROBES = {
657
971
  1114: { text: '2020-01-02 03:04:05', expected: () => new Date(2020, 0, 2, 3, 4, 5) },
658
972
  1115: { text: '{"2020-01-02 03:04:05"}', expected: () => [new Date(2020, 0, 2, 3, 4, 5)] },
659
973
  1182: { text: '{2020-01-02}', expected: () => [new Date(2020, 0, 2)] },
974
+ // The `timestamptz` pair carries an explicit offset, so unlike the four
975
+ // above its expectation is NOT computed from local components: pg's default
976
+ // and Turbine's fast scan produce the same instant in every zone. These two
977
+ // probes are consulted by {@link registerFastTemporalParserIfDefault}, which
978
+ // DECLINES to install rather than warning-and-overwriting.
979
+ 1184: { text: '2020-01-02 03:04:05+00', expected: () => new Date(Date.UTC(2020, 0, 2, 3, 4, 5)) },
980
+ 1185: { text: '{"2020-01-02 03:04:05+00"}', expected: () => [new Date(Date.UTC(2020, 0, 2, 3, 4, 5))] },
660
981
  };
661
982
  /**
662
983
  * Marks a text parser as one TURBINE installed, so a later registration in the
@@ -666,8 +987,15 @@ const DEFAULT_PARSER_PROBES = {
666
987
  * uses one.
667
988
  */
668
989
  const TURBINE_PARSER = Symbol.for('turbine.typeParser');
669
- /** The OIDs the `utcTimestamps` flag governs, and so the ones it can opt out of. */
670
- const TEMPORAL_PARSER_OIDS = new Set([1114, 1082, 1115, 1182]);
990
+ /**
991
+ * The OIDs the `utcTimestamps` flag governs, and so the ones it can opt out of.
992
+ *
993
+ * 1184 / 1185 are in the set because the flag gates their registration too,
994
+ * but they are there for a different reason from the other four: those four
995
+ * change a READING (local zone to UTC), while the `timestamptz` pair changes
996
+ * only decode SPEED. See {@link registerUtcTemporalParsers}.
997
+ */
998
+ const TEMPORAL_PARSER_OIDS = new Set([1114, 1082, 1115, 1182, 1184, 1185]);
671
999
  /** Tag `parser` as Turbine's own and return it (see {@link TURBINE_PARSER}). */
672
1000
  export function markTurbineParser(parser) {
673
1001
  parser[TURBINE_PARSER] = true;
@@ -760,11 +1088,14 @@ export function warnParserOverwrite(oid, typeName) {
760
1088
  return;
761
1089
  if (!shouldWarnOnce(WARN_NS.parserOverwrite, String(oid)))
762
1090
  return;
763
- // The `utcTimestamps: false` opt-out only governs the four TEMPORAL OIDs.
1091
+ // The `utcTimestamps: false` opt-out only governs the six TEMPORAL OIDs.
764
1092
  // Offering it as the remedy for int8 (20) would name a setting that does
765
1093
  // nothing for the OID being warned about; that registration has no opt-out.
1094
+ // (1184 / 1185 never reach this warning at all: they DECLINE over a
1095
+ // non-default parser instead of overwriting it. They are named in the
1096
+ // sentence because it describes what the flag leaves alone.)
766
1097
  const remedy = TEMPORAL_PARSER_OIDS.has(oid)
767
- ? ' `utcTimestamps: false` leaves the four temporal OIDs (1114, 1082, 1115, 1182) alone entirely.'
1098
+ ? ' `utcTimestamps: false` leaves the six temporal OIDs (1114, 1082, 1115, 1182, 1184, 1185) alone entirely.'
768
1099
  : ' There is no opt-out for this OID: Turbine registers it so bigint values come back as numbers.';
769
1100
  console.warn(`[turbine] pg type parser for OID ${oid} (${typeName}) was already customized by something else in this ` +
770
1101
  'process, and Turbine is replacing it. `pg.types.setTypeParser` is process-global and takes effect ' +
@@ -775,8 +1106,21 @@ export function warnParserOverwrite(oid, typeName) {
775
1106
  'in production from import order alone.');
776
1107
  }
777
1108
  /**
778
- * Register the UTC readings of the four zone-less temporal OIDs on the pg
779
- * module: `timestamp` (1114), `date` (1082) and their array forms (1115, 1182).
1109
+ * Register Turbine's temporal text parsers on the pg module. SIX OIDs, doing
1110
+ * two different jobs:
1111
+ *
1112
+ * 1114 / 1082 / 1115 / 1182 the UTC READING of the zone-less types,
1113
+ * `timestamp`, `date` and their array forms.
1114
+ * This changes what a column means and is what
1115
+ * `utcTimestamps` is named for.
1116
+ * 1184 / 1185 the fast decode path for `timestamptz` and
1117
+ * `timestamptz[]`. This changes NOTHING about
1118
+ * what a column means: an offset-carrying value
1119
+ * has one instant and this reads the same one.
1120
+ * It is here for speed, `timestamptz` being ~88%
1121
+ * of the client-side decode cost of a wide row
1122
+ * drain, and it DECLINES rather than overwrites
1123
+ * (see the comment at the call site).
780
1124
  *
781
1125
  * ONE place, because `pg.types.setTypeParser` is process-global and the pairing
782
1126
  * matters: registering a scalar without its array form, or a `date` without the
@@ -814,6 +1158,58 @@ export function registerUtcTemporalParsers() {
814
1158
  // beside them returned UTC ones.
815
1159
  setParser(1182, markTurbineParser(createPgArrayParser(parseDate)));
816
1160
  setParser(1115, markTurbineParser(createPgArrayParser(parseTimestamp)));
1161
+ // `timestamptz` (1184) and `timestamptz[]` (1185). SPEED ONLY: an offset-
1162
+ // carrying value has exactly one instant, and this reads it as the same
1163
+ // instant `postgres-date` does, so nothing here changes what a column means.
1164
+ //
1165
+ // Two things about it are deliberate and neither is obvious.
1166
+ //
1167
+ // It is gated behind `utcTimestamps` along with the other four, even though
1168
+ // that flag is about a READING and this is not. The alternative was a second
1169
+ // registration site outside this function, and one process-global parser
1170
+ // table with two places that write to it is the exact shape the "ONE place"
1171
+ // rule above exists to prevent. So the flag reads as "leave pg's temporal
1172
+ // parser table alone", and `utcTimestamps: false` costs the optimisation as
1173
+ // well as the UTC reading. That is a documented cost, not an oversight.
1174
+ //
1175
+ // And it DECLINES rather than overwrites (see
1176
+ // {@link registerFastTemporalParserIfDefault}), which is the opposite of what
1177
+ // the four above do. They MUST overwrite: they exist to replace a reading,
1178
+ // and a process where half the temporal columns read local and half read UTC
1179
+ // is broken. This one exists only to be faster, so a caller who installed
1180
+ // their own `timestamptz` parser (to get strings, or Luxon objects, or a
1181
+ // Temporal instant) keeps it. Overwriting them would trade their correctness
1182
+ // for our speed, which is never the right trade, and Turbine has never
1183
+ // touched 1184 before now, so declining is also what preserves that.
1184
+ const parseTimestamptz = registerFastTemporalParserIfDefault(1184, createFastTimestamptzParser);
1185
+ if (parseTimestamptz) {
1186
+ registerFastTemporalParserIfDefault(1185, () => createPgArrayParser(parseTimestamptz));
1187
+ }
1188
+ }
1189
+ /**
1190
+ * Install a SPEED-ONLY parser for `oid`, but only over pg's own default (or
1191
+ * over a parser Turbine itself installed earlier in this process).
1192
+ *
1193
+ * Returns the installed parser, or `undefined` when it declined, so a caller
1194
+ * can hold a scalar and its array form to the same decision: registering the
1195
+ * array half over a caller's customized scalar half would make the two
1196
+ * disagree, which is worse than leaving both slow.
1197
+ *
1198
+ * `build` receives the parser being replaced, which becomes the fast path's
1199
+ * fallback. That is only sound because the parser being replaced is known to
1200
+ * be pg's default (or Turbine's own wrapper around it); the check below is
1201
+ * what makes it so, and is not an optimisation of it.
1202
+ */
1203
+ function registerFastTemporalParserIfDefault(oid, build) {
1204
+ const getParser = pg.types.getTypeParser;
1205
+ const setParser = pg.types.setTypeParser;
1206
+ const current = getParser(oid, 'text');
1207
+ const isTurbines = current[TURBINE_PARSER] === true;
1208
+ if (!isTurbines && !isDefaultTextParser(oid, current))
1209
+ return undefined;
1210
+ const parser = markTurbineParser(build(current));
1211
+ setParser(oid, parser);
1212
+ return parser;
817
1213
  }
818
1214
  // ---------------------------------------------------------------------------
819
1215
  // JSON-wire value coercion (relationLoadStrategy: 'join')
@@ -35,7 +35,7 @@
35
35
  */
36
36
  import { ValidationError } from '../errors.js';
37
37
  import { findArrayUniqueKey, findJsonUniqueKey, fingerprintArrayFilterShape, fingerprintJsonFilterShape, fingerprintOperatorShape, isArrayFilter, isJsonFilter, isTextSearchFilter, isUnmatchedPlainObject, isVectorFilter, isWhereOperator, sortedKeys, VECTOR_DISTANCE_COMPARATORS, } from './filters.js';
38
- import { ownLookup } from './utils.js';
38
+ import { resolveRelation } from './utils.js';
39
39
  /**
40
40
  * Maximum nesting of `OR` / `AND` / `NOT` combinators and relation-filter
41
41
  * descents in one WHERE clause (and in one groupBy `HAVING`).
@@ -113,11 +113,16 @@ export function walkWhere(host, where) {
113
113
  events.push({ kind: 'not', condition: value });
114
114
  continue;
115
115
  }
116
- const relDef = ownLookup(host.tableMeta.relations, key);
117
- if (relDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
118
- const filterObj = host.normalizeRelationFilter(relDef, value);
116
+ // Resolved rather than looked up, so a relation filter accepts the
117
+ // snake_case spelling of the relation exactly as `with` does. This is the
118
+ // ONE branch authority (build, fingerprint and param-collect all consume
119
+ // these events), so resolving here cannot drift between them; the emitted
120
+ // event carries the DECLARED name, which is what reaches the fingerprint.
121
+ const resolvedRel = resolveRelation(host.tableMeta.relations, key);
122
+ if (resolvedRel && typeof value === 'object' && value !== null && !Array.isArray(value)) {
123
+ const filterObj = host.normalizeRelationFilter(resolvedRel.def, value);
119
124
  if (isRelationFilterObj(filterObj)) {
120
- events.push({ kind: 'relation', key, relDef, filterObj });
125
+ events.push({ kind: 'relation', key: resolvedRel.name, relDef: resolvedRel.def, filterObj });
121
126
  continue;
122
127
  }
123
128
  }
@@ -13,7 +13,7 @@
13
13
  import { getErrorMessageMode, UnsupportedFeatureError, ValidationError } from '../errors.js';
14
14
  import { camelToSnake, normalizeKeyColumns } from '../schema.js';
15
15
  import { assertBindableEqualsOperand, findArrayUniqueKey, findJsonUniqueKey, isArrayFilter, isColumnRef, isJsonFilter, isUnmatchedPlainObject, isWhereOperator, JSON_FILTER_KEYS, JSON_RANGE_OPERATORS, JSON_STRING_OPERATORS, VECTOR_DISTANCE_COMPARATORS, VECTOR_METRIC_OPERATORS, validateTextSearchConfig, } from './filters.js';
16
- import { coerceTemporalValue, escapeLike, isInternalCombinator, markInternalCombinator, OPERATOR_KEYS, ownLookup, } from './utils.js';
16
+ import { coerceTemporalValue, escapeLike, isInternalCombinator, markInternalCombinator, OPERATOR_KEYS, ownLookup, resolveColumnName, } from './utils.js';
17
17
  import { assertWhereDepth, classifyScalarForSql, fingerprintScalarToken, walkWhere, } from './where-compile.js';
18
18
  /**
19
19
  * LIKE-escape one bound operand through the ACTIVE dialect, falling back to the
@@ -745,8 +745,8 @@ export function buildScopedWhere(qi, scope, where, params, depth = 0) {
745
745
  */
746
746
  export function buildScopedScalarClause(qi, scope, field, value, params, clauses) {
747
747
  const meta = scope.meta;
748
- const col = ownLookup(meta.columnMap, field) ?? camelToSnake(field);
749
- if (!meta.allColumns.includes(col))
748
+ const col = resolveColumnName(meta, field);
749
+ if (col === undefined)
750
750
  throw scope.unknownColumn(field);
751
751
  const qCol = `${scope.qualifier}${qi.q(col)}`;
752
752
  if (value === null) {
@@ -824,7 +824,11 @@ export function collectScopedScalarParams(qi, scope, field, value, params) {
824
824
  if (value === null)
825
825
  return;
826
826
  const meta = scope.meta;
827
- const col = ownLookup(meta.columnMap, field) ?? camelToSnake(field);
827
+ // Unvalidated on purpose: this is the cache-HIT mirror, and a key that does
828
+ // not resolve could never have produced the entry being served. It still
829
+ // goes through the one authority, so the column it binds against cannot
830
+ // differ from the one the build path emitted.
831
+ const col = resolveColumnName(meta, field) ?? camelToSnake(field);
828
832
  if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
829
833
  const colType = pgTypeForColumn(qi, meta, col);
830
834
  if (isJsonColumnType(qi, colType)) {
@@ -1164,8 +1168,8 @@ export function resolveColumnRef(_qi, ref, ctx, mode) {
1164
1168
  `Case-insensitive column-to-column comparison is not supported: use client.sql\`...\` ` +
1165
1169
  `for lower(a) = lower(b).`);
1166
1170
  }
1167
- const col = ownLookup(ctx.meta.columnMap, ref.col) ?? camelToSnake(ref.col);
1168
- if (!ctx.meta.allColumns.includes(col)) {
1171
+ const col = resolveColumnName(ctx.meta, ref.col);
1172
+ if (col === undefined) {
1169
1173
  throw new ValidationError(`[turbine] Unknown field "${ref.col}" referenced by { col } in where on table "${ctx.table}". ` +
1170
1174
  `Known fields: ${Object.keys(ctx.meta.columnMap).join(', ') || '(none)'}.`);
1171
1175
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turbine-orm",
3
- "version": "0.71.0",
3
+ "version": "0.72.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).",
@@ -122,10 +122,12 @@
122
122
  "dogfood": "tsx examples/dogfood.ts",
123
123
  "test": "tsx --test --test-concurrency=1 src/test/*.test.ts",
124
124
  "test:unit": "DATABASE_URL= tsx --test src/test/*.test.ts",
125
- "test:coverage": "c8 tsx --test --test-concurrency=1 src/test/*.test.ts",
125
+ "//test:coverage": "Collection and reporting are SEPARATE, and the report is not c8's. c8 merges every process's V8 coverage into one ProcessCov before converting to istanbul, and that merge is not monotonic on a run this size: measured on this suite, query/relations.ts read 3043 covered lines at 240 process files and 1333 at 359, and a 448-test SUBSET read 91.88% where the full 5,819-test superset read 41.78%. scripts/coverage-report.mjs keeps every other c8 behaviour (same .c8rc.json, same reporters, same checkCoverages gate) and only moves the merge to the istanbul level, where it is additive and cannot go backwards. Collection is a bare NODE_V8_COVERAGE, which is exactly what the c8 wrapper set; dropping the wrapper also drops the broken merge from the critical path instead of running it and discarding the result.",
126
+ "test:coverage": "rm -rf coverage/tmp && mkdir -p coverage/tmp && NODE_V8_COVERAGE=coverage/tmp tsx --test --test-concurrency=1 src/test/*.test.ts && node scripts/coverage-report.mjs",
126
127
  "//coverage:cli": "The CLI coverage gate, split into ONE collection run plus a per-file threshold check for every file in it, and an aggregate. c8 enforces a single threshold set per invocation and its --per-file applies the SAME numbers to every file, neither of which can express 'destructive.ts holds 100 while migrate.ts holds 70'. An aggregate-only floor lets the least-covered file spend the whole slack the best-covered file earned: at the measured 3623/2951 lines, migrate.ts could fall from 70.3% to 66.6% with the aggregate still green. So each file gets its OWN floor, checked by re-reporting the coverage already on disk (c8 report re-reads ./coverage/tmp, so this costs no extra test run). The aggregate check is kept as well: it catches all three sagging together inside their individual margins. Per-file gates run FIRST because their failure names the file. DATABASE_URL is neutralized on the collection run: these test files include live migration tests that create and drop tables, and this script runs from prepublishOnly.",
127
128
  "test:coverage:cli": "npm run coverage:cli:collect && npm run coverage:cli:gate:destructive && npm run coverage:cli:gate:sql-statements && npm run coverage:cli:gate:pii-guard && npm run coverage:cli:gate:error-catalog && npm run coverage:cli:gate:mcp && npm run coverage:cli:gate:compile-query && npm run coverage:cli:gate:studio && npm run coverage:cli:gate:migrate && npm run coverage:cli:gate:aggregate",
128
- "coverage:cli:collect": "DATABASE_URL= c8 --all --reporter text --exclude 'src/test/**' --include src/cli/studio.ts --include src/cli/migrate.ts --include src/cli/destructive.ts --include src/cli/sql-statements.ts --include src/cli/pii-predicate-guard.ts --include src/cli/mcp.ts --include src/cli/compile-query.ts --include src/cli/error-catalog.ts tsx --test src/test/studio-write.test.ts src/test/studio-demo.test.ts src/test/studio.test.ts src/test/studio-security.test.ts src/test/migrate.test.ts src/test/migrate-deploy.test.ts src/test/migrate-smoke-fixes.test.ts src/test/destructive-migrations.test.ts src/test/backfill-recipe.test.ts src/test/cli.test.ts src/test/cli-diff-migration.test.ts src/test/cli-flags.test.ts src/test/cli-first-run.test.ts src/test/mcp.test.ts src/test/mcp-relations.test.ts src/test/mcp-pii.test.ts src/test/mcp-pii-round2.test.ts src/test/mcp-agent-tools.test.ts src/test/mcp-compile-query.test.ts",
129
+ "//coverage:cli:collect": "--check-coverage=false is load-bearing, not tidying. c8 reads .c8rc.json for defaults, so this COLLECTION step was silently enforcing the MAIN gate's global thresholds against a src/cli-only file set. That is a gate nobody wrote and nobody wanted, and it went unnoticed only because the main floor was low enough (75) for the CLI aggregate (89.29) to clear it by accident. When the main floors were re-baselined to 93/89/78 (see //merge-bug in .c8rc.json) it started failing here, several steps before the per-file gates that are supposed to decide. Thresholds for these files belong to the coverage:cli:gate:* scripts, which pass their own explicitly.",
130
+ "coverage:cli:collect": "DATABASE_URL= c8 --all --check-coverage=false --reporter text --exclude 'src/test/**' --include src/cli/studio.ts --include src/cli/migrate.ts --include src/cli/destructive.ts --include src/cli/sql-statements.ts --include src/cli/pii-predicate-guard.ts --include src/cli/mcp.ts --include src/cli/compile-query.ts --include src/cli/error-catalog.ts tsx --test src/test/studio-write.test.ts src/test/studio-demo.test.ts src/test/studio.test.ts src/test/studio-security.test.ts src/test/migrate.test.ts src/test/migrate-deploy.test.ts src/test/migrate-smoke-fixes.test.ts src/test/destructive-migrations.test.ts src/test/backfill-recipe.test.ts src/test/cli.test.ts src/test/cli-diff-migration.test.ts src/test/cli-flags.test.ts src/test/cli-first-run.test.ts src/test/mcp.test.ts src/test/mcp-relations.test.ts src/test/mcp-pii.test.ts src/test/mcp-pii-round2.test.ts src/test/mcp-agent-tools.test.ts src/test/mcp-compile-query.test.ts",
129
131
  "coverage:cli:gate": "c8 report --all --exclude 'src/test/**' --reporter text --check-coverage",
130
132
  "coverage:cli:gate:destructive": "npm run coverage:cli:gate -- --include src/cli/destructive.ts --lines 98 --statements 98 --branches 84 --functions 98",
131
133
  "coverage:cli:gate:sql-statements": "npm run coverage:cli:gate -- --include src/cli/sql-statements.ts --lines 100 --statements 100 --branches 98 --functions 100",