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