@atscript/db-postgres 0.1.149 → 0.1.151

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -110,10 +110,30 @@ function quoteTableName(name) {
110
110
  if (dot >= 0) return `${qi(name.slice(0, dot))}.${qi(name.slice(dot + 1))}`;
111
111
  return qi(name);
112
112
  }
113
+ /**
114
+ * A `db.geoPoint` value written or filtered on before the adapter knows the
115
+ * physical type of its column — a PostGIS `geography` / `geometry` column
116
+ * (EWKT) or the JSONB fallback (`raw`, what the JSONB path binds). Whichever
117
+ * adapter executes the statement resolves it right before it runs (the
118
+ * marker may come from another table's formatter, e.g. a relational filter):
119
+ * `native` answers for the column the marker was formatted for, probing the
120
+ * catalog on `exec` when not known yet.
121
+ */
122
+ var PendingGeoPoint = class {
123
+ point;
124
+ raw;
125
+ native;
126
+ constructor(point, raw, native) {
127
+ this.point = point;
128
+ this.raw = raw;
129
+ this.native = native;
130
+ }
131
+ };
113
132
  /** Converts JS values to SQL-bindable params, keeping booleans native. */
114
133
  function toPgValue(value) {
115
134
  if (value === void 0) return null;
116
135
  if (value === null) return null;
136
+ if (value instanceof PendingGeoPoint) return new PendingGeoPoint(value.point, toPgValue(value.raw), value.native);
117
137
  if (value instanceof Date) return value.toISOString();
118
138
  if (typeof value === "object") return JSON.stringify(value);
119
139
  return value;
@@ -138,6 +158,7 @@ const pgDialect = {
138
158
  params: [pattern]
139
159
  };
140
160
  },
161
+ integerText: (expr) => `CAST(CAST(${expr} AS BIGINT) AS TEXT)`,
141
162
  geoWithin(quotedCol, circle) {
142
163
  return {
143
164
  sql: `ST_DWithin(${quotedCol}, ST_SetSRID(ST_MakePoint(?, ?), 4326)::geography, ?)`,
@@ -478,6 +499,53 @@ function buildWhere(filter, opts) {
478
499
  }
479
500
  //#endregion
480
501
  //#region src/postgres-adapter.ts
502
+ /**
503
+ * Read-only geo facts of one table, in one round trip: PostGIS presence and
504
+ * the physical type of the named columns (`{ column: type }`, a domain
505
+ * reported as its base type; `null` when the relation does not exist). Catalog
506
+ * reads only — no privileges needed, never aborts a transaction.
507
+ */
508
+ const GEO_PROBE_SQL = `SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'postgis') AS "postgis",
509
+ (SELECT json_object_agg(a.attname, COALESCE(b.typname, t.typname))
510
+ FROM pg_attribute a
511
+ JOIN pg_type t ON t.oid = a.atttypid
512
+ LEFT JOIN pg_type b ON b.oid = NULLIF(t.typbasetype, 0)
513
+ WHERE a.attrelid = to_regclass($1::text) AND a.attnum > 0 AND NOT a.attisdropped
514
+ AND a.attname = ANY($2::text[])) AS "columns"`;
515
+ /** A PostGIS column type — a geo value binds as EWKT. Anything else (JSONB, TEXT) takes the JSON form. */
516
+ const POSTGIS_TYPES = new Set(["geography", "geometry"]);
517
+ /** The SQL `$geoWithin` renders (`pgDialect.geoWithin`) — PostGIS only. */
518
+ const GEO_WITHIN_SQL = "ST_DWithin(";
519
+ /** Index of the first {@link PendingGeoPoint} in `params`, or -1. */
520
+ function firstPendingGeo(params) {
521
+ if (params) {
522
+ for (let i = 0; i < params.length; i++) if (params[i] instanceof PendingGeoPoint) return i;
523
+ }
524
+ return -1;
525
+ }
526
+ /**
527
+ * A copy of `params` with every {@link PendingGeoPoint} (from `from` on)
528
+ * resolved for its own column — EWKT for a PostGIS column, the JSON form
529
+ * otherwise — each marker's column probe running on `exec`, the statement's.
530
+ */
531
+ async function resolvePendingGeo(params, from, exec) {
532
+ const out = params.slice();
533
+ for (let i = from; i < out.length; i++) {
534
+ const value = out[i];
535
+ if (value instanceof PendingGeoPoint) {
536
+ const native = value.native(exec);
537
+ out[i] = (typeof native === "boolean" ? native : await native) ? geoPointToEwkt(value.point) : value.raw;
538
+ }
539
+ }
540
+ return out;
541
+ }
542
+ /** `GEO_NOT_SUPPORTED` — geo search / `$geoWithin` without PostGIS. */
543
+ function geoNotSupported(path, message) {
544
+ return new _atscript_db.DbError("GEO_NOT_SUPPORTED", [{
545
+ path,
546
+ message
547
+ }]);
548
+ }
481
549
  /** PostgreSQL COUNT() may return string (bigint) — parse to number. */
482
550
  function parseCount(value) {
483
551
  if (typeof value === "string") return Number.parseInt(value, 10);
@@ -549,8 +617,42 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
549
617
  _nocaseColumns = /* @__PURE__ */ new Set();
550
618
  /** Whether citext extension has been provisioned (avoids redundant round-trips). */
551
619
  _citextProvisioned = false;
552
- /** Whether the connected PostgreSQL instance has the PostGIS extension. */
620
+ /**
621
+ * Whether the connected PostgreSQL instance has the PostGIS extension —
622
+ * `undefined` until known: set by schema sync ({@link prepareTypeMapper},
623
+ * which installs it), or by the read-only geo probe ({@link _probeGeo}).
624
+ * Decides DDL and geo search; how a geo VALUE binds is decided per column
625
+ * ({@link _geoNative}).
626
+ */
553
627
  _supportsGeo;
628
+ /** Whether {@link _detectGeoSupport} (the installing detection) already ran. */
629
+ _geoInstallTried = false;
630
+ /** Memo of {@link _hasGeoPointFields}. */
631
+ _geoFields;
632
+ /** Physical names of the unencrypted `db.geoPoint` columns (set with the metadata). */
633
+ _geoColumnNames = [];
634
+ /**
635
+ * Physical geo column → whether it is a PostGIS column (`geography` /
636
+ * `geometry`: EWKT) or not (JSONB: the JSON form). Learned from the catalog
637
+ * — after schema sync ({@link afterSyncTable}) or before the first statement
638
+ * binding a geo value — never from the extension alone: a JSONB column
639
+ * created before PostGIS was installed stays JSONB until a sync migrates
640
+ * it. Cleared by every DDL path ({@link _geoSchemaChanged}).
641
+ */
642
+ _geoNative = /* @__PURE__ */ new Map();
643
+ /** The in-flight shared geo probe and the executor it runs on. */
644
+ _geoProbe;
645
+ /** Bumped by {@link _geoSchemaChanged}: a probe started before a DDL caches nothing. */
646
+ _geoSchemaGen = 0;
647
+ /** The pool executor with geo-marker resolution (built once). */
648
+ _poolExec;
649
+ /** The last transaction connection's wrapped executor. */
650
+ _txExec;
651
+ _pkColumnsMemo;
652
+ _replaceColumnsMemo;
653
+ _ignorePlanMemo;
654
+ _fulltextMemo;
655
+ _searchIndexesMemo;
554
656
  /** Whether the connected PostgreSQL instance has the pgvector extension. */
555
657
  _supportsVector;
556
658
  /** Vector fields: physical field name → { dimensions, similarity, indexName }. */
@@ -613,7 +715,41 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
613
715
  * otherwise the pool-based driver.
614
716
  */
615
717
  _exec() {
616
- return this._txConnection() ?? this.driver;
718
+ const conn = this._txConnection();
719
+ if (!conn) return this._poolExec ??= this._geoResolvingExec(this.driver);
720
+ if (this._txExec?.conn !== conn) this._txExec = {
721
+ conn,
722
+ exec: this._geoResolvingExec(conn)
723
+ };
724
+ return this._txExec.exec;
725
+ }
726
+ /**
727
+ * `exec` resolving `PendingGeoPoint` params before the statement runs. Every
728
+ * adapter wraps — not only geo tables: a relational filter on another
729
+ * table's geo column carries that table's markers into this one's
730
+ * statement. A statement without markers runs as is (one param scan).
731
+ */
732
+ _geoResolvingExec(exec) {
733
+ const prep = (sql, params, run) => {
734
+ if (this._geoFields && this._supportsGeo !== true && sql.includes(GEO_WITHIN_SQL)) return this._assertGeoWithin(exec).then(() => prep(sql, params, run));
735
+ const at = firstPendingGeo(params);
736
+ return at < 0 ? run(params) : resolvePendingGeo(params, at, exec).then(run);
737
+ };
738
+ return {
739
+ run: (sql, params) => prep(sql, params, (p) => exec.run(sql, p)),
740
+ all: (sql, params) => prep(sql, params, (p) => exec.all(sql, p)),
741
+ get: (sql, params) => prep(sql, params, (p) => exec.get(sql, p)),
742
+ exec: (sql) => exec.exec(sql).finally(() => this._geoSchemaChanged())
743
+ };
744
+ }
745
+ /**
746
+ * `$geoWithin` passed the core guard while PostGIS presence was unknown
747
+ * ({@link isGeoSearchable} answers optimistically then): learn it, and
748
+ * refuse like the guard would have when it is absent.
749
+ */
750
+ async _assertGeoWithin(exec) {
751
+ if (this._supportsGeo === void 0) await this._probeGeo(exec);
752
+ if (this._supportsGeo !== true) throw geoNotSupported("", "$geoWithin requires the PostGIS extension");
617
753
  }
618
754
  /**
619
755
  * Relational predicates (`$some` / `$none`) render as correlated
@@ -658,7 +794,13 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
658
794
  }
659
795
  onBeforeFlatten(_type) {}
660
796
  onAfterFlatten() {
661
- for (const fd of this._table.fieldDescriptors) if (fd.collate === "nocase") this._nocaseColumns.add(fd.physicalName);
797
+ const geo = [];
798
+ for (const fd of this._table.fieldDescriptors) {
799
+ if (fd.collate === "nocase") this._nocaseColumns.add(fd.physicalName);
800
+ if (fd.isGeoPoint === true && !fd.encrypted) geo.push(fd.physicalName);
801
+ }
802
+ this._geoColumnNames = geo;
803
+ this._geoFields = geo.length > 0;
662
804
  }
663
805
  onFieldScanned(field, _type, metadata) {
664
806
  if (metadata.has("db.default.increment")) {
@@ -694,20 +836,27 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
694
836
  toStorage: (value) => Array.isArray(value) ? `[${value.join(",")}]` : value,
695
837
  fromStorage: (value) => typeof value === "string" ? JSON.parse(value) : value
696
838
  };
697
- if (field.isGeoPoint && !field.encrypted) return {
698
- toStorage: (value) => {
699
- if (!this._supportsGeo) return value;
700
- const point = (0, _atscript_db_sql_tools.normalizeGeoPointValue)(value);
701
- return point ? geoPointToEwkt(point) : value;
702
- },
703
- fromStorage: (value) => {
704
- if (typeof value === "string") {
705
- const point = parseEwkbPointHex(value);
706
- if (point) return point;
839
+ if (field.isGeoPoint && !field.encrypted) {
840
+ const column = field.physicalName;
841
+ const native = (exec) => this._geoColumnNative(column, exec);
842
+ return {
843
+ toStorage: (value) => {
844
+ if (this._supportsGeo === false) return value;
845
+ const point = (0, _atscript_db_sql_tools.normalizeGeoPointValue)(value);
846
+ if (!point) return value;
847
+ const known = this._geoNative.get(column);
848
+ if (known === void 0) return new PendingGeoPoint(point, value, native);
849
+ return known ? geoPointToEwkt(point) : value;
850
+ },
851
+ fromStorage: (value) => {
852
+ if (typeof value === "string") {
853
+ const point = parseEwkbPointHex(value);
854
+ if (point) return point;
855
+ }
856
+ return value;
707
857
  }
708
- return value;
709
- }
710
- };
858
+ };
859
+ }
711
860
  }
712
861
  /**
713
862
  * Wraps an async write operation to catch PostgreSQL constraint errors
@@ -761,9 +910,9 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
761
910
  }];
762
911
  }
763
912
  async insertOne(data) {
764
- let { sql, params } = buildInsert(this.resolveTableName(), data);
765
- const pkCols = this._pkColumns().map((pk) => qi(pk));
766
- if (pkCols.length > 0) sql += ` RETURNING ${pkCols.join(", ")}`;
913
+ const insert = buildInsert(this.resolveTableName(), data);
914
+ const sql = insert.sql + this._pk().returning;
915
+ const params = insert.params;
767
916
  this._log(sql, params);
768
917
  const returned = (await this._wrapConstraintError(() => this._exec().run(sql, params))).rows?.[0];
769
918
  return { insertedId: this._resolveInsertedId(data, returned ? Object.values(returned)[0] : void 0) };
@@ -773,11 +922,10 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
773
922
  insertedCount: 0,
774
923
  insertedIds: []
775
924
  };
776
- return this.withTransaction(async () => {
925
+ const { columns, batches } = (0, _atscript_db_sql_tools.chunkInsertRows)(data);
926
+ const run = async () => {
777
927
  const tableName = this.resolveTableName();
778
- const pkCols = this._pkColumns();
779
- const returningSuffix = pkCols.length > 0 ? ` RETURNING ${pkCols.map((pk) => qi(pk)).join(", ")}` : "";
780
- const { columns, batches } = (0, _atscript_db_sql_tools.chunkInsertRows)(data);
928
+ const returningSuffix = this._pk().returning;
781
929
  const allIds = [];
782
930
  for (const batch of batches) {
783
931
  const insert = buildInsertMany(tableName, batch, columns);
@@ -794,7 +942,8 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
794
942
  insertedCount: allIds.length,
795
943
  insertedIds: allIds
796
944
  };
797
- });
945
+ };
946
+ return batches.length === 1 ? run() : this.withTransaction(run);
798
947
  }
799
948
  supportsInsertIgnore() {
800
949
  return true;
@@ -804,33 +953,34 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
804
953
  * unique key columns>`: one statement per chunk (a conflict never raises, so
805
954
  * the surrounding transaction survives). Skipped rows are the ones missing
806
955
  * from RETURNING — mapped back by key values, see {@link mapIgnoredBatch}.
807
- * Each chunk runs inside a SAVEPOINT: when the mapping is ambiguous (a key
808
- * the server returns in another form than it was sent) the chunk is rolled
809
- * back to the savepoint and redone row by row with the same
810
- * `ON CONFLICT DO NOTHING`, so the result is always exact.
956
+ * A multi-row chunk of a keyed table runs inside a SAVEPOINT: when the
957
+ * mapping is ambiguous (a key the server returns in another form than it
958
+ * was sent) the chunk is rolled back to the savepoint and redone row by row
959
+ * with the same `ON CONFLICT DO NOTHING`, so the result is always exact. A
960
+ * one-row chunk (or a table without keys) maps exactly by construction, so
961
+ * it needs no savepoint — and a single such chunk no transaction either.
811
962
  */
812
963
  async insertManyIgnore(data) {
813
964
  if (data.length === 0) return [];
814
- return this.withTransaction(async () => {
965
+ const plan = this._ignorePlan();
966
+ const { columns, batches } = (0, _atscript_db_sql_tools.chunkInsertRows)(data);
967
+ const needsSavepoint = (batch) => batch.length > 1 && plan.returning.length > 0;
968
+ const run = async () => {
815
969
  const tableName = this.resolveTableName();
816
- const pkCols = this._pkColumns();
817
- const keySets = this._table.uniqueKeySets;
818
- const returning = [...new Set(keySets.flat())];
819
- const returningCols = new Set(returning);
820
- const nonTextKeyCols = new Set(this._table.fieldDescriptors.filter((f) => returningCols.has(f.physicalName) && !isTextKeyType(this.typeMapper(f))).map((f) => f.physicalName));
821
- const returningSuffix = returning.length > 0 ? ` RETURNING ${returning.map((c) => qi(c)).join(", ")}` : "";
970
+ const { keySets, returning, nonTextKeyCols, returningSuffix } = plan;
971
+ const pkCols = this._pk().cols;
822
972
  const insertedId = (row, returned) => ({ insertedId: this._resolveInsertedId(row, pkCols.length > 0 ? returned?.[pkCols[0]] : void 0) });
823
- const { columns, batches } = (0, _atscript_db_sql_tools.chunkInsertRows)(data);
824
973
  const slots = [];
825
974
  for (const batch of batches) {
826
975
  const insert = buildInsertMany(tableName, batch, columns);
827
976
  const sql = `${insert.sql} ON CONFLICT DO NOTHING${returningSuffix}`;
828
- await this._exec().run(`SAVEPOINT ${IGNORE_SAVEPOINT}`);
977
+ const savepoint = needsSavepoint(batch);
978
+ if (savepoint) await this._exec().run(`SAVEPOINT ${IGNORE_SAVEPOINT}`);
829
979
  this._log(sql, insert.params);
830
980
  const returned = (await this._wrapConstraintError(() => this._exec().run(sql, insert.params))).rows ?? [];
831
981
  const mapping = returning.length === 0 ? batch.map((_, i) => i) : mapIgnoredBatch(batch, returned, keySets, nonTextKeyCols);
832
982
  if (mapping) {
833
- await this._exec().run(`RELEASE SAVEPOINT ${IGNORE_SAVEPOINT}`);
983
+ if (savepoint) await this._exec().run(`RELEASE SAVEPOINT ${IGNORE_SAVEPOINT}`);
834
984
  mapping.forEach((hit, i) => slots.push(hit < 0 ? null : insertedId(batch[i], returned[hit])));
835
985
  continue;
836
986
  }
@@ -844,7 +994,32 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
844
994
  }
845
995
  }
846
996
  return slots;
847
- });
997
+ };
998
+ return batches.length === 1 && !needsSavepoint(batches[0]) ? run() : this.withTransaction(run);
999
+ }
1000
+ /**
1001
+ * The key columns of a conflict-ignoring insert (built once per table):
1002
+ * every primary / unique key set, their RETURNING list, and the key columns
1003
+ * whose physical type is not text (see {@link mapIgnoredBatch}). Keyed on the
1004
+ * detected extension state the type mapper reads.
1005
+ */
1006
+ _ignorePlan() {
1007
+ const key = `${this._supportsGeo}|${this._supportsVector}`;
1008
+ const memo = this._ignorePlanMemo;
1009
+ if (memo?.key === key) return memo;
1010
+ const keySets = this._table.uniqueKeySets;
1011
+ const returning = [...new Set(keySets.flat())];
1012
+ const returningCols = new Set(returning);
1013
+ const nonTextKeyCols = new Set(this._table.fieldDescriptors.filter((f) => returningCols.has(f.physicalName) && !isTextKeyType(this.typeMapper(f))).map((f) => f.physicalName));
1014
+ const returningSuffix = returning.length > 0 ? ` RETURNING ${returning.map((c) => qi(c)).join(", ")}` : "";
1015
+ this._ignorePlanMemo = {
1016
+ key,
1017
+ keySets,
1018
+ returning,
1019
+ nonTextKeyCols,
1020
+ returningSuffix
1021
+ };
1022
+ return this._ignorePlanMemo;
848
1023
  }
849
1024
  async findOne(query) {
850
1025
  const where = buildWhere(query.filter);
@@ -894,9 +1069,48 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
894
1069
  this._log(sql, params);
895
1070
  return (0, _atscript_db_sql_tools.mapQueryErrors)(pgDialect, () => this._exec().all(sql, params), query.controls);
896
1071
  }
897
- /** Physical primary-key columns (`@db.column` renames applied). */
898
- _pkColumns() {
899
- return this._table.primaryKeys.map((key) => this._table.physicalPath(key));
1072
+ /**
1073
+ * Physical primary-key columns (`@db.column` renames applied) and their
1074
+ * ` RETURNING …` clause (empty without a PK) — built once per table.
1075
+ */
1076
+ _pk() {
1077
+ const src = this._table.primaryKeys;
1078
+ let memo = this._pkColumnsMemo;
1079
+ if (memo?.src !== src) {
1080
+ const cols = src.map((key) => this._table.physicalPath(key));
1081
+ const returning = cols.length > 0 ? ` RETURNING ${cols.map((c) => qi(c)).join(", ")}` : "";
1082
+ memo = this._pkColumnsMemo = {
1083
+ src,
1084
+ cols,
1085
+ returning
1086
+ };
1087
+ }
1088
+ return memo;
1089
+ }
1090
+ /**
1091
+ * Whether `filter` pins at most one row by itself: a plain object holding
1092
+ * exactly the primary-key columns, each equal to a scalar (`{ id: 5 }` or
1093
+ * `{ id: { $eq: 5 } }`) — what the core sends for a by-id write. Such a
1094
+ * filter needs no `LIMIT 1` re-keying subquery: the outer predicate is the
1095
+ * PK equality either way.
1096
+ */
1097
+ _isExactPkFilter(filter) {
1098
+ const pk = this._pk().cols;
1099
+ if (pk.length === 0 || typeof filter !== "object" || filter === null || Array.isArray(filter)) return false;
1100
+ const f = filter;
1101
+ if (Object.keys(f).length !== pk.length) return false;
1102
+ for (const col of pk) {
1103
+ if (!Object.hasOwn(f, col)) return false;
1104
+ let value = f[col];
1105
+ if (typeof value === "object" && value !== null && Object.getPrototypeOf(value) === Object.prototype) {
1106
+ const ops = Object.keys(value);
1107
+ if (ops.length !== 1 || ops[0] !== "$eq") return false;
1108
+ value = value.$eq;
1109
+ }
1110
+ const t = typeof value;
1111
+ if (t !== "string" && t !== "number" && t !== "bigint" && t !== "boolean") return false;
1112
+ }
1113
+ return true;
900
1114
  }
901
1115
  /**
902
1116
  * The predicate that narrows a single-row UPDATE/DELETE to the first row
@@ -907,21 +1121,18 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
907
1121
  * existing behavior rather than guess.
908
1122
  */
909
1123
  _limitOnePredicate(quotedTable, whereSql) {
910
- const pkCols = this._pkColumns();
1124
+ const pkCols = this._pk().cols;
911
1125
  const quotedKeys = pkCols.length > 0 ? pkCols.map((c) => qi(c)) : ["ctid"];
912
1126
  const colList = quotedKeys.join(", ");
913
1127
  return `${quotedKeys.length === 1 ? `${colList} =` : `(${colList}) IN`} (SELECT ${colList} FROM ${quotedTable} WHERE ${whereSql} LIMIT 1)`;
914
1128
  }
915
- async updateOne(filter, data, ops, expectedVersion) {
1129
+ async updateOne(filter, data, ops, expectedVersion, opts) {
916
1130
  const where = buildWhere(filter);
917
1131
  const tableName = this.resolveTableName();
918
- const quotedTable = quoteTableName(tableName);
919
- const limitedWhere = {
920
- sql: this._limitOnePredicate(quotedTable, where.sql),
1132
+ const { sql, params } = buildUpdate(tableName, data, this._isExactPkFilter(filter) ? where : {
1133
+ sql: this._limitOnePredicate(quoteTableName(tableName), where.sql),
921
1134
  params: where.params
922
- };
923
- const versionColumn = this._table.versionColumnPhysical;
924
- const { sql, params } = buildUpdate(tableName, data, limitedWhere, void 0, ops, versionColumn, expectedVersion);
1135
+ }, void 0, ops, this._versionColumnFor(opts, expectedVersion), expectedVersion);
925
1136
  this._log(sql, params);
926
1137
  const result = await this._wrapConstraintError(() => this._exec().run(sql, params));
927
1138
  return {
@@ -929,9 +1140,9 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
929
1140
  modifiedCount: result.affectedRows
930
1141
  };
931
1142
  }
932
- async updateMany(filter, data, ops) {
1143
+ async updateMany(filter, data, ops, opts) {
933
1144
  const where = buildWhere(filter);
934
- const versionColumn = this._table.versionColumnPhysical;
1145
+ const versionColumn = this._versionColumnFor(opts);
935
1146
  const { sql, params } = buildUpdate(this.resolveTableName(), data, where, void 0, ops, versionColumn);
936
1147
  this._log(sql, params);
937
1148
  const result = await this._wrapConstraintError(() => this._exec().run(sql, params));
@@ -941,14 +1152,25 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
941
1152
  };
942
1153
  }
943
1154
  async replaceOne(filter, data, expectedVersion) {
944
- const full = (0, _atscript_db_sql_tools.fillReplacePayload)(data, (0, _atscript_db_sql_tools.replaceColumnsFor)(this._table.fieldDescriptors, this.nativeDefaultFns()), this._table.versionColumnPhysical);
1155
+ const full = (0, _atscript_db_sql_tools.fillReplacePayload)(data, this._replaceColumns(), this._table.versionColumnPhysical);
945
1156
  return this.updateOne(filter, full, void 0, expectedVersion);
946
1157
  }
1158
+ /** The columns a full replace assigns (`replaceColumnsFor`), built once per table. */
1159
+ _replaceColumns() {
1160
+ const src = this._table.fieldDescriptors;
1161
+ let memo = this._replaceColumnsMemo;
1162
+ if (memo?.src !== src) memo = this._replaceColumnsMemo = {
1163
+ src,
1164
+ cols: (0, _atscript_db_sql_tools.replaceColumnsFor)(src, this.nativeDefaultFns())
1165
+ };
1166
+ return memo.cols;
1167
+ }
947
1168
  async replaceMany(filter, data) {
948
1169
  return this.updateMany(filter, data);
949
1170
  }
950
1171
  async deleteOne(filter) {
951
1172
  const where = buildWhere(filter);
1173
+ if (this._isExactPkFilter(filter)) return this._deleteWhere(where);
952
1174
  const quotedTable = quoteTableName(this.resolveTableName());
953
1175
  const { sql, params } = (0, _atscript_db_sql_tools.finalizeParams)(pgDialect, {
954
1176
  sql: `DELETE FROM ${quotedTable} WHERE ${where.sql} AND ${this._limitOnePredicate(quotedTable, where.sql)}`,
@@ -958,18 +1180,24 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
958
1180
  return { deletedCount: (await this._wrapConstraintError(() => this._exec().run(sql, params))).affectedRows };
959
1181
  }
960
1182
  async deleteMany(filter) {
961
- const where = buildWhere(filter);
1183
+ return this._deleteWhere(buildWhere(filter));
1184
+ }
1185
+ async _deleteWhere(where) {
962
1186
  const { sql, params } = buildDelete(this.resolveTableName(), where);
963
1187
  this._log(sql, params);
964
1188
  return { deletedCount: (await this._wrapConstraintError(() => this._exec().run(sql, params))).affectedRows };
965
1189
  }
966
1190
  async prepareTypeMapper() {
967
1191
  if (this._supportsVector === void 0 && this._vectorFields.size > 0) await this._detectVectorSupport();
968
- if (this._supportsGeo === void 0 && this._hasGeoPointFields()) await this._detectGeoSupport();
1192
+ if (!this._geoInstallTried && this._hasGeoPointFields()) await this._detectGeoSupport();
969
1193
  }
970
- /** Whether the table declares any `db.geoPoint` fields (unencrypted). */
1194
+ /**
1195
+ * Whether the table declares any `db.geoPoint` fields (unencrypted) — set
1196
+ * when the metadata is built (`false` before, and on an administrative
1197
+ * adapter: no geo value can be formatted without built metadata).
1198
+ */
971
1199
  _hasGeoPointFields() {
972
- return this._table.fieldDescriptors.some((fd) => fd.isGeoPoint && !fd.encrypted);
1200
+ return this._geoFields === true;
973
1201
  }
974
1202
  async ensureTable(opts) {
975
1203
  if (this._nocaseColumns.size > 0 && !this._citextProvisioned) try {
@@ -1276,9 +1504,11 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
1276
1504
  await conn.exec(ddl);
1277
1505
  }
1278
1506
  await conn.exec("COMMIT");
1507
+ this._geoSchemaChanged();
1279
1508
  await this._resetIdentitySequences();
1280
1509
  } catch (err) {
1281
1510
  await conn.exec("ROLLBACK").catch(() => {});
1511
+ this._geoSchemaChanged();
1282
1512
  const detail = err.detail;
1283
1513
  if (err instanceof Error && typeof detail === "string" && detail.length > 0) err.message = `${err.message} — ${detail}`;
1284
1514
  throw err;
@@ -1334,6 +1564,7 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
1334
1564
  }
1335
1565
  async afterSyncTable() {
1336
1566
  await this._resetIdentitySequences();
1567
+ if (this._geoFields && this._supportsGeo === true && this._geoNative.size === 0) await this._probeGeo(this._txConnection() ?? this.driver).catch(() => void 0);
1337
1568
  }
1338
1569
  /**
1339
1570
  * Resets IDENTITY sequences to MAX(column) so that the next auto-generated
@@ -1441,7 +1672,9 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
1441
1672
  },
1442
1673
  createIndex: async (index) => {
1443
1674
  if (index.type === "fulltext") {
1444
- const tsvectorExpr = this._buildTsvectorExpr(index.fields);
1675
+ const textFields = (0, _atscript_db.splitFulltextFields)(index).text;
1676
+ if (textFields.length === 0) return;
1677
+ const tsvectorExpr = this._buildTsvectorExpr(textFields);
1445
1678
  const sql = `CREATE INDEX IF NOT EXISTS ${qi(index.key)} ON ${quoteTableName(this.resolveTableName())} USING gin(to_tsvector('english', ${tsvectorExpr}))`;
1446
1679
  this._log(sql);
1447
1680
  await this._exec().exec(sql);
@@ -1534,13 +1767,23 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
1534
1767
  return byName;
1535
1768
  }
1536
1769
  getSearchIndexes() {
1770
+ const src = this._table.indexes;
1771
+ let memo = this._searchIndexesMemo;
1772
+ if (memo?.src !== src) memo = this._searchIndexesMemo = {
1773
+ src,
1774
+ list: this._buildSearchIndexes()
1775
+ };
1776
+ return [...memo.list];
1777
+ }
1778
+ _buildSearchIndexes() {
1537
1779
  const indexes = [];
1538
- for (const index of this._table.indexes.values()) if (index.type === "fulltext") indexes.push({
1780
+ const { all: ftAll, def: ftDefault } = this._fulltextIndexes();
1781
+ for (const index of ftAll) indexes.push({
1539
1782
  name: index.key,
1540
- description: `GIN tsvector index on ${index.fields.map((f) => f.name).join(", ")}`,
1783
+ description: (0, _atscript_db.describeFulltext)(index, (names) => `GIN tsvector index on ${names}`),
1541
1784
  type: "text",
1542
1785
  fields: this._indexLogicalPaths(index),
1543
- isDefault: indexes.length === 0
1786
+ isDefault: index === ftDefault
1544
1787
  });
1545
1788
  let firstVector = true;
1546
1789
  for (const [field, vec] of this._vectorFields) {
@@ -1591,12 +1834,24 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
1591
1834
  _buildSearchWhere(text, query, indexName) {
1592
1835
  const fulltextIndex = this._getFulltextIndex(indexName);
1593
1836
  if (!fulltextIndex) throw new Error("No fulltext index found for search");
1594
- const tsvectorExpr = this._buildTsvectorExpr(fulltextIndex.fields);
1837
+ const { text: textFields, integer: integerFields } = (0, _atscript_db.splitFulltextFields)(fulltextIndex);
1595
1838
  const where = buildWhere(query.filter);
1596
- const tsqueryClause = `to_tsvector('english', ${tsvectorExpr}) @@ plainto_tsquery('english', ?)`;
1839
+ const n = integerFields.length > 0 ? (0, _atscript_db.searchTermInteger)(text) : void 0;
1840
+ const parts = [];
1841
+ const params = [];
1842
+ if (textFields.length > 0) {
1843
+ const tsvectorExpr = this._buildTsvectorExpr(textFields);
1844
+ parts.push(`to_tsvector('english', ${tsvectorExpr}) @@ plainto_tsquery('english', ?)`);
1845
+ params.push(text);
1846
+ }
1847
+ if (n !== void 0) for (const f of integerFields) {
1848
+ parts.push(`${qi(f.name)} = CAST(? AS BIGINT)`);
1849
+ params.push(n);
1850
+ }
1851
+ const clause = (0, _atscript_db_sql_tools.orFragment)(parts, params);
1597
1852
  return {
1598
- sql: where.sql === "1=1" ? tsqueryClause : `${where.sql} AND ${tsqueryClause}`,
1599
- params: [...where.params, text]
1853
+ sql: where.sql === "1=1" ? clause.sql : `${where.sql} AND ${clause.sql}`,
1854
+ params: [...where.params, ...clause.params]
1600
1855
  };
1601
1856
  }
1602
1857
  /** Builds the tsvector SQL expression for a fulltext index's fields. Must match between index DDL and queries. */
@@ -1604,9 +1859,23 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
1604
1859
  return fields.map((f) => `coalesce(${qi(f.name)}, '')`).join(" || ' ' || ");
1605
1860
  }
1606
1861
  _getFulltextIndex(indexName) {
1607
- for (const index of this._table.indexes.values()) if (index.type === "fulltext") {
1608
- if (!indexName || index.key === indexName) return index;
1862
+ const { all, def } = this._fulltextIndexes();
1863
+ if (!indexName) return def;
1864
+ return all.find((index) => index.key === indexName);
1865
+ }
1866
+ /** The table's fulltext indexes and the default one, built once per table. */
1867
+ _fulltextIndexes() {
1868
+ const src = this._table.indexes;
1869
+ let memo = this._fulltextMemo;
1870
+ if (memo?.src !== src) {
1871
+ const all = [...src.values()].filter((i) => i.type === "fulltext");
1872
+ memo = this._fulltextMemo = {
1873
+ src,
1874
+ all,
1875
+ def: (0, _atscript_db.defaultFulltextIndex)(all)
1876
+ };
1609
1877
  }
1878
+ return memo;
1610
1879
  }
1611
1880
  /**
1612
1881
  * Detects pgvector support by attempting to enable the extension.
@@ -1628,15 +1897,16 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
1628
1897
  async vectorSearch(vector, query, indexName) {
1629
1898
  await this._detectVectorSupport();
1630
1899
  if (!this._supportsVector) throw new Error("Vector search requires the pgvector extension");
1631
- const { sql, params } = this._buildVectorSearchQuery(vector, query, indexName);
1900
+ const { sql, params } = this._buildVectorSearchQuery(this._prepareVectorSearch(vector, query, indexName));
1632
1901
  this._log(sql, params);
1633
1902
  return this._exec().all(sql, params);
1634
1903
  }
1635
1904
  async vectorSearchWithCount(vector, query, indexName) {
1636
1905
  await this._detectVectorSupport();
1637
1906
  if (!this._supportsVector) throw new Error("Vector search requires the pgvector extension");
1638
- const { sql, params } = this._buildVectorSearchQuery(vector, query, indexName);
1639
- const { sql: countSql, params: countParams } = this._buildVectorSearchCountQuery(vector, query, indexName);
1907
+ const ctx = this._prepareVectorSearch(vector, query, indexName);
1908
+ const { sql, params } = this._buildVectorSearchQuery(ctx);
1909
+ const { sql: countSql, params: countParams } = this._buildVectorSearchCountQuery(ctx);
1640
1910
  this._log(sql, params);
1641
1911
  this._log(countSql, countParams);
1642
1912
  const [data, countRow] = await Promise.all([this._exec().all(sql, params), this._exec().get(countSql, countParams)]);
@@ -1690,8 +1960,7 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
1690
1960
  maxDistance: ctx.threshold === void 0 ? void 0 : thresholdToDistance(ctx.threshold, ctx.vec.similarity)
1691
1961
  };
1692
1962
  }
1693
- _buildVectorSearchQuery(vector, query, indexName) {
1694
- const ctx = this._prepareVectorSearch(vector, query, indexName);
1963
+ _buildVectorSearchQuery(ctx) {
1695
1964
  const { source, maxDistance } = this._vectorSearchSource(ctx, true);
1696
1965
  const skip = Number(ctx.controls.$skip) || 0;
1697
1966
  return (0, _atscript_db_sql_tools.buildVectorSearchSelect)(pgDialect, source, {
@@ -1701,8 +1970,8 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
1701
1970
  maxDistance
1702
1971
  });
1703
1972
  }
1704
- _buildVectorSearchCountQuery(vector, query, indexName) {
1705
- const { source, maxDistance } = this._vectorSearchSource(this._prepareVectorSearch(vector, query, indexName), false);
1973
+ _buildVectorSearchCountQuery(ctx) {
1974
+ const { source, maxDistance } = this._vectorSearchSource(ctx, false);
1706
1975
  return (0, _atscript_db_sql_tools.buildVectorSearchCount)(pgDialect, source, { maxDistance });
1707
1976
  }
1708
1977
  /** Resolves threshold: query-time $threshold > schema-level @db.search.vector.threshold. */
@@ -1712,11 +1981,13 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
1712
1981
  return this._vectorThresholds.get(indexName);
1713
1982
  }
1714
1983
  /**
1715
- * Detects PostGIS support by attempting to enable the extension.
1716
- * Idempotent — safe to call multiple times.
1984
+ * Detects PostGIS support by attempting to enable the extension — schema
1985
+ * sync's detection ({@link prepareTypeMapper}). Runs once; a probe that
1986
+ * already found PostGIS skips it.
1717
1987
  */
1718
1988
  async _detectGeoSupport() {
1719
- if (this._supportsGeo !== void 0) return this._supportsGeo;
1989
+ if (this._geoInstallTried || this._supportsGeo === true) return this._supportsGeo === true;
1990
+ this._geoInstallTried = true;
1720
1991
  try {
1721
1992
  await this._exec().exec("CREATE EXTENSION IF NOT EXISTS postgis");
1722
1993
  this._supportsGeo = true;
@@ -1726,17 +1997,92 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
1726
1997
  }
1727
1998
  return this._supportsGeo;
1728
1999
  }
2000
+ /**
2001
+ * Whether geo column `column` is a PostGIS column (EWKT) — synchronously
2002
+ * when known, else after {@link _probeGeo} on `exec` (the statement's own
2003
+ * executor). A column the catalog does not show (table not created yet)
2004
+ * follows PostGIS presence — what sync would create — and is probed again
2005
+ * next time.
2006
+ */
2007
+ _geoColumnNative(column, exec) {
2008
+ if (this._supportsGeo === false) return false;
2009
+ const known = this._geoNative.get(column);
2010
+ if (known !== void 0) return known;
2011
+ return this._probeGeo(exec).then((probe) => probe.columns.get(column) ?? this._supportsGeo ?? probe.postgis);
2012
+ }
2013
+ /**
2014
+ * The read-only geo probe ({@link GEO_PROBE_SQL}) of this table on `exec`:
2015
+ * records PostGIS presence when not known yet, and caches the type of every
2016
+ * geo column the catalog shows (unless a DDL path ran meanwhile).
2017
+ *
2018
+ * Concurrent callers on the SAME executor share one probe; a caller on
2019
+ * another executor runs its own — waiting on a probe queued for a pool
2020
+ * connection while holding a transaction connection could deadlock a
2021
+ * small pool, and a transaction's failure (an aborted one) never reaches
2022
+ * callers outside it. A failed probe is not cached, and a caller that
2023
+ * joined it retries once (a pool probe may have hit a broken connection).
2024
+ */
2025
+ _probeGeo(exec) {
2026
+ const shared = this._geoProbe;
2027
+ if (shared?.exec === exec) return shared.promise.catch(() => this._runGeoProbe(exec));
2028
+ const promise = this._runGeoProbe(exec);
2029
+ const entry = {
2030
+ exec,
2031
+ promise
2032
+ };
2033
+ this._geoProbe = entry;
2034
+ const clear = () => {
2035
+ if (this._geoProbe === entry) this._geoProbe = void 0;
2036
+ };
2037
+ promise.then(clear, clear);
2038
+ return promise;
2039
+ }
2040
+ async _runGeoProbe(exec) {
2041
+ const gen = this._geoSchemaGen;
2042
+ const row = await exec.get(GEO_PROBE_SQL, [quoteTableName(this.resolveTableName()), this._geoColumnNames]);
2043
+ const postgis = row?.postgis === true;
2044
+ const raw = typeof row?.columns === "string" ? JSON.parse(row.columns) : row?.columns;
2045
+ const columns = /* @__PURE__ */ new Map();
2046
+ if (raw && typeof raw === "object") for (const [name, type] of Object.entries(raw)) columns.set(name, POSTGIS_TYPES.has(String(type)));
2047
+ this._supportsGeo ??= postgis;
2048
+ if (gen === this._geoSchemaGen) for (const [name, native] of columns) this._geoNative.set(name, native);
2049
+ return {
2050
+ postgis,
2051
+ columns
2052
+ };
2053
+ }
2054
+ /**
2055
+ * A DDL path ran (create / alter / recreate / rename / drop): the geo
2056
+ * column types are learned anew, and a probe in flight caches nothing.
2057
+ */
2058
+ _geoSchemaChanged() {
2059
+ if (this._geoFields) {
2060
+ this._geoNative.clear();
2061
+ this._geoSchemaGen++;
2062
+ this._geoProbe = void 0;
2063
+ }
2064
+ }
2065
+ /**
2066
+ * PostGIS presence when known; while unknown (no sync, no statement yet),
2067
+ * a table with geo columns answers `true` — the geo paths learn presence
2068
+ * before they run and refuse with `GEO_NOT_SUPPORTED` then, so the first
2069
+ * geo search of a process that never syncs is not refused up front.
2070
+ */
1729
2071
  isGeoSearchable() {
1730
- return this._supportsGeo === true;
2072
+ return this._supportsGeo ?? this._hasGeoPointFields();
2073
+ }
2074
+ /** PostGIS presence, probed when not known yet (geo search entry points). */
2075
+ async _ensureGeoKnown() {
2076
+ if (this._supportsGeo === void 0) await this._probeGeo(this._txConnection() ?? this.driver);
1731
2077
  }
1732
2078
  async geoSearch(point, query, indexName) {
1733
- await this._detectGeoSupport();
2079
+ await this._ensureGeoKnown();
1734
2080
  const { sql, params } = this._buildGeoSearchSelect(this._prepareGeoSearch(point, query, indexName));
1735
2081
  this._log(sql, params);
1736
2082
  return (await this._exec().all(sql, params)).map((row) => (0, _atscript_db_sql_tools.renameGeoDistance)(row));
1737
2083
  }
1738
2084
  async geoSearchWithCount(point, query, indexName) {
1739
- await this._detectGeoSupport();
2085
+ await this._ensureGeoKnown();
1740
2086
  const ctx = this._prepareGeoSearch(point, query, indexName);
1741
2087
  const { sql, params } = this._buildGeoSearchSelect(ctx);
1742
2088
  const countFrag = (0, _atscript_db_sql_tools.buildGeoSearchCount)(pgDialect, ctx.tableName, ctx.where, ctx.dist, ctx.window);
@@ -1750,10 +2096,7 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
1750
2096
  }
1751
2097
  /** Resolves the shared parts of a geo search once (column, filter, distance, window). */
1752
2098
  _prepareGeoSearch(point, query, indexName) {
1753
- if (!this._supportsGeo) throw new _atscript_db.DbError("GEO_NOT_SUPPORTED", [{
1754
- path: "",
1755
- message: "Geo search requires the PostGIS extension"
1756
- }]);
2099
+ if (!this._supportsGeo) throw geoNotSupported("", "Geo search requires the PostGIS extension");
1757
2100
  const column = this._resolveGeoColumn(indexName);
1758
2101
  const controls = query.controls ?? {};
1759
2102
  return {
@@ -1871,16 +2214,39 @@ function vectorToString(vector) {
1871
2214
  }
1872
2215
  //#endregion
1873
2216
  //#region src/pg-driver.ts
1874
- /** pg rejects `undefined` in bind arrays — coerce to `null`. */
2217
+ const NO_PARAMS = [];
2218
+ /**
2219
+ * pg rejects `undefined` in bind arrays — coerce to `null`. Copies only when
2220
+ * there is one (an array hole counts): the common case binds `params` as is
2221
+ * (pg never mutates the bind array).
2222
+ */
1875
2223
  function sanitizeParams(params) {
1876
- if (!params) return [];
1877
- return params.map((v) => v === void 0 ? null : v);
2224
+ if (!params) return NO_PARAMS;
2225
+ return params.includes(void 0) ? Array.from(params, (v) => v === void 0 ? null : v) : params;
1878
2226
  }
1879
- /** Parses TIMESTAMP/TIMESTAMPTZ to epoch milliseconds. */
2227
+ /** Parses TIMESTAMPTZ (its text carries the UTC offset) to epoch milliseconds. */
1880
2228
  function parseTimestamp(val) {
1881
2229
  const ms = new Date(val).getTime();
1882
2230
  return Number.isNaN(ms) ? val : ms;
1883
2231
  }
2232
+ /** PostgreSQL's text form of a `timestamp without time zone` (AD, finite): `YYYY-MM-DD HH:MM:SS[.ffffff]`. */
2233
+ const TIMESTAMP_TEXT = /^(\d{4,})-(\d\d)-(\d\d) (\d\d):(\d\d):(\d\d)(?:\.(\d{1,6}))?$/;
2234
+ /**
2235
+ * Parses TIMESTAMP (without time zone) to epoch milliseconds, reading the
2236
+ * wall time as UTC — the same instant whatever the process time zone (since
2237
+ * 0.1.151; it used to be the process's local time). Matches the MySQL
2238
+ * adapter's DATETIME reading. Sub-millisecond digits are truncated; other
2239
+ * forms (`infinity`, BC dates) keep the previous parse.
2240
+ */
2241
+ function parseTimestampUtc(val) {
2242
+ const m = TIMESTAMP_TEXT.exec(val);
2243
+ if (!m) return parseTimestamp(val);
2244
+ const d = /* @__PURE__ */ new Date(0);
2245
+ d.setUTCFullYear(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
2246
+ d.setUTCHours(Number(m[4]), Number(m[5]), Number(m[6]), m[7] ? Number(m[7].padEnd(3, "0").slice(0, 3)) : 0);
2247
+ const ms = d.getTime();
2248
+ return Number.isNaN(ms) ? val : ms;
2249
+ }
1884
2250
  /** Parses NUMERIC to number. */
1885
2251
  function parseNumeric(val) {
1886
2252
  const n = Number.parseFloat(val);
@@ -1900,13 +2266,14 @@ const INT8_OID = 20;
1900
2266
  * Creates a per-pool custom types config that overrides specific parsers
1901
2267
  * without mutating the global `pg.types`.
1902
2268
  *
1903
- * - TIMESTAMP/TIMESTAMPTZ → epoch milliseconds (number)
2269
+ * - TIMESTAMPTZ → epoch milliseconds (number); TIMESTAMP → epoch ms of its
2270
+ * wall time read as UTC (independent of the process time zone)
1904
2271
  * - NUMERIC → number (not string)
1905
2272
  * - INT8/BIGINT → number (for JS-safe range)
1906
2273
  */
1907
2274
  function createCustomTypes(pgTypes) {
1908
2275
  const overrides = new Map([
1909
- [TIMESTAMP_OID, parseTimestamp],
2276
+ [TIMESTAMP_OID, parseTimestampUtc],
1910
2277
  [TIMESTAMPTZ_OID, parseTimestamp],
1911
2278
  [NUMERIC_OID, parseNumeric],
1912
2279
  [INT8_OID, parseBigInt]