postgrejs 3.5.0 → 3.6.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.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,31 @@
1
1
  ## Changelog
2
2
 
3
- ### [v3.5.0](https://github.com/panates/postgrejs/compare/v3.4.0...v3.5.0) -
3
+ ### [v3.6.0](https://github.com/panates/postgrejs/compare/v3.5.0...v3.6.0) -
4
+
5
+ #### 🚀 New Features
6
+
7
+ - feat: make fetchAsString request text from the server @Eray Hanoğlu
8
+
9
+ #### 🪲 Fixes
10
+
11
+ - fix: decode float4 as the number the server would print @Eray Hanoğlu
12
+ - fix: report rowsAffected for MERGE @Eray Hanoğlu
13
+ - fix: quote LISTEN channel names instead of restricting them @Eray Hanoğlu
14
+ - fix: stop a pooled cursor's connection going back into the pool @Eray Hanoğlu
15
+ - fix: stop inferring "char" for one-character strings @Eray Hanoğlu
16
+ - fix: copy both indexes when a DataTypeMap is built from another @Eray Hanoğlu
17
+ - fix: emit 'notification' once per NOTIFY @Eray Hanoğlu
18
+ - fix: accept single-character savepoint names @Eray Hanoğlu
19
+
20
+ #### 📖 Documentation Changes
21
+
22
+ - docs: record what 3.6 changes for existing code @Eray Hanoğlu
23
+
24
+ #### 💬 General Changes
25
+
26
+ - feat!: fetch every row by default instead of the first 100 @Eray Hanoğlu
27
+
28
+ ### [v3.5.0](https://github.com/panates/postgrejs/compare/v3.4.0...v3.5.0) - 16 September 2026
4
29
 
5
30
  #### 🚀 New Features
6
31
 
package/README.md CHANGED
@@ -59,6 +59,9 @@ $ npm install postgrejs --save
59
59
  Please read :small_orange_diamond: [DOCUMENTATION](https://www.postgrejs.com/) :small_orange_diamond: for detailed
60
60
  usage.
61
61
 
62
+ Upgrading from 3.5? See [doc/MIGRATION-v3.5-to-v3.6.md](doc/MIGRATION-v3.5-to-v3.6.md) - `query()` no longer stops at
63
+ 100 rows, and `fetchAsString` now returns the server's own text.
64
+
62
65
  ## Library Overview
63
66
 
64
67
  - **Language:** Pure JavaScript, with no native/binary dependencies to compile or ship.
@@ -82,16 +85,23 @@ usage.
82
85
  statement leaves the transaction usable instead of aborting the whole block. The `SAVEPOINT`/`RELEASE` pair travels
83
86
  in the statement's own round trip rather than costing one each - 36µs for the pair, against 507µs sent separately.
84
87
  `rollbackOnError: false` to opt out.
88
+ - **Complete Results:** `query()` returns every row the statement produced. A `fetchCount` limit is a limit rather
89
+ than a silent cap: the result carries `suspended: true` when the server stopped at it, so a prefix can never be
90
+ mistaken for the whole answer. `0` means unlimited, as it does in the protocol.
85
91
  - **Cursors:** Features fast double-link cache cursors for efficient data retrieval, iterable with `for await` a row
86
- at a time and closed when the loop ends - by exhaustion, a `break`, or a throw.
92
+ at a time and closed when the loop ends - by exhaustion, a `break`, or a throw. A cursor reads through a portal, and
93
+ a portal lives as long as the transaction that created it - `Pool.query()` keeps its connection out of the pool
94
+ until the cursor closes, and on a bare `Connection` open the cursor inside a transaction if anything else will run
95
+ on that connection meanwhile.
87
96
  - **Scoped Transactions:** `transaction(fn)` commits when the callback returns and rolls back when it throws, on a
88
97
  connection or straight from the pool. A call made while a transaction is already open takes a savepoint rather than
89
98
  a second `BEGIN`, so a nested scope that fails rolls back its own work and leaves the outer transaction standing.
90
99
  - **Batch Execution:** `executeBatch()` runs one prepared statement over many parameter sets under a single `Sync`,
91
100
  reporting each set's row count - 1000 updates in 20ms where the same calls pipelined individually take 188ms.
92
101
  - **Notifications:** High-level implementation for PostgreSQL notifications (LISTEN/NOTIFY), enabling real-time data
93
- updates.
94
- - **Extensibility:** Extensible data-types and type mapping to accommodate custom requirements.
102
+ updates. Channel names are quoted rather than pattern-matched, so any name PostgreSQL accepts works.
103
+ - **Extensibility:** Extensible data-types and type mapping to accommodate custom requirements. A type can set
104
+ `inferrable: false` to stay out of parameter type inference while still decoding its own columns.
95
105
  - **Parameter Binding:** Bind parameters with OID mappings for precise and efficient query execution.
96
106
  - **Array Handling:** Supports multidimensional arrays with fast binary encoding/decoding.
97
107
  - **Performance Optimization:** Low memory utilization and boosted performance through the use of shared buffers.
@@ -119,6 +129,10 @@ usage.
119
129
  `rollbackPrepared()`, from any connection.
120
130
  - **Cancellation:** Any call takes an `AbortSignal`, which also gives per-query timeouts via `AbortSignal.timeout()`.
121
131
  - **Flexible Data Retrieval:** Can return both array and object rows to suit different data processing needs.
132
+ - **Values as Text:** `fetchAsString: [DataTypeOIDs.int8]` asks the server for those columns in its own text format
133
+ and hands the bytes back unparsed - so `count(*)` arrives as `'3'`, one consistent type where a decoded `int8` is a
134
+ `number` or a `BigInt` depending on magnitude. Any OID, arrays included. The string is PostgreSQL's own rendering
135
+ rather than one reconstructed from the binary value, so it cannot drift from what the server would print.
122
136
  - **Resource Management:** Auto disposal of resources with the "using" syntax
123
137
  ([TC39 Explicit Resource Management](https://github.com/tc39/proposal-explicit-resource-management)), ensuring
124
138
  efficient resource cleanup.
@@ -2,6 +2,8 @@ import { ConnectionState, DataTypeOIDs } from '../constants.js';
2
2
  import { GlobalTypeMap } from '../data-type-map.js';
3
3
  import { SafeEventEmitter } from '../safe-event-emitter.js';
4
4
  import { withAbortSignal } from '../util/abort-signal.js';
5
+ import { normalizeChannelName } from '../util/channel-name.js';
6
+ import { escapeIdentifier } from '../util/escape-identifier.js';
5
7
  import { QueryRequest } from '../util/sql-tag.js';
6
8
  import { BindParam } from './bind-param.js';
7
9
  import { IntlConnection } from './intl-connection.js';
@@ -599,8 +601,7 @@ export class Connection extends SafeEventEmitter {
599
601
  return this._captureErrorStack(this._intlCon.releaseSavepoint(name, immediate));
600
602
  }
601
603
  async listen(channel, callback) {
602
- if (!/^[A-Z]\w+$/i.test(channel))
603
- throw new TypeError(`Invalid channel name`);
604
+ channel = normalizeChannelName(channel);
604
605
  if (!this._notificationListeners) {
605
606
  this._notificationListeners = new SafeEventEmitter();
606
607
  this._intlCon.on('notification', (msg) => this._handleNotification(msg));
@@ -613,14 +614,13 @@ export class Connection extends SafeEventEmitter {
613
614
  const alreadyListening = !!this._notificationListeners.listenerCount(channel);
614
615
  this._notificationListeners.on(channel, callback);
615
616
  if (!alreadyListening)
616
- await this._captureErrorStack(this.query('LISTEN ' + channel));
617
+ await this._captureErrorStack(this.query('LISTEN ' + escapeIdentifier(channel)));
617
618
  }
618
619
  async unListen(channel) {
619
- if (!/^[A-Z]\w+$/i.test(channel))
620
- throw new TypeError(`Invalid channel name`);
620
+ channel = normalizeChannelName(channel);
621
621
  if (this._notificationListeners?.listenerCount(channel)) {
622
622
  this._notificationListeners?.removeAllListeners(channel);
623
- await this._captureErrorStack(this.query('UNLISTEN ' + channel));
623
+ await this._captureErrorStack(this.query('UNLISTEN ' + escapeIdentifier(channel)));
624
624
  }
625
625
  }
626
626
  async unListenAll() {
@@ -682,7 +682,13 @@ export class Connection extends SafeEventEmitter {
682
682
  return new LargeObject(this, oid, Number(opened.rows?.[0][0]), ownsTransaction);
683
683
  }
684
684
  _handleNotification(msg) {
685
- this.emit('notification', msg);
685
+ // Deliberately no `this.emit('notification', msg)` here:
686
+ // IntlConnection.emit() re-emits every event on its owner, which is
687
+ // this Connection, so the raw event has already been delivered by the
688
+ // time this runs. Emitting it again fired 'notification' twice for one
689
+ // NOTIFY - but only once listen() had been called, since that is what
690
+ // installs this handler, so the same listener saw one event or two
691
+ // depending on whether anything else had subscribed.
686
692
  this._notificationListeners?.emit(msg.channel, msg);
687
693
  }
688
694
  async _close() {
@@ -69,7 +69,28 @@ export class Cursor extends SafeEventEmitter {
69
69
  await this._taskQueue
70
70
  .enqueue(async () => {
71
71
  const queryOptions = this._queryOptions;
72
- const r = await portal.execute(queryOptions.fetchCount || 100);
72
+ let r;
73
+ try {
74
+ r = await portal.execute(queryOptions.fetchCount ?? 100);
75
+ }
76
+ catch (e) {
77
+ // 34000 here means the portal is gone, and the only thing that
78
+ // takes a portal away mid-cursor is another statement on this
79
+ // connection: its Sync ends the implicit transaction the portal
80
+ // lives in. The server's own message says nothing about why, and
81
+ // a caller cannot act on it without knowing. Closing first keeps
82
+ // the statement from leaking and lets whoever is holding the
83
+ // connection for this cursor (Pool.query()) have it back.
84
+ if (e?.code === '34000') {
85
+ await this.close().catch(() => undefined);
86
+ e.message +=
87
+ ' - the portal was destroyed by another statement running on' +
88
+ ' the same connection, which ends the implicit transaction it' +
89
+ ' lives in. Open the cursor inside an explicit transaction, or' +
90
+ ' give it a connection of its own.';
91
+ }
92
+ throw e;
93
+ }
73
94
  if (r && r.rows && r.rows.length) {
74
95
  const rows = r.rows;
75
96
  if (this._parsers) {
@@ -245,7 +245,7 @@ export declare class IntlConnection extends SafeEventEmitter {
245
245
  * so repeated executes of the same statement don't rebuild them.
246
246
  * Shared by executeReused() and executeBatchReused().
247
247
  */
248
- protected _resolveReusedParsers(cachedFields: Protocol.RowDescription[], typeMap: DataTypeMap, options: QueryOptions): {
248
+ protected _resolveReusedParsers(cachedFields: Protocol.RowDescription[], typeMap: DataTypeMap, options: QueryOptions, columnFormat: Protocol.DataFormat | Protocol.DataFormat[]): {
249
249
  parsers: AnyParseFunction[];
250
250
  resultFields: FieldInfo[];
251
251
  };
@@ -9,6 +9,7 @@ import { getConnectionConfig } from '../util/connection-config.js';
9
9
  import { buildCopySql, buildProbeSql, writeCopyBinaryRows, } from '../util/copy-from-rows.js';
10
10
  import { escapeLiteral } from '../util/escape-literal.js';
11
11
  import { getParsers } from '../util/get-parsers.js';
12
+ import { fetchAsStringEqual, resolveColumnFormats, } from '../util/resolve-column-formats.js';
12
13
  import { resolveRowDecoder, resolveRowType } from '../util/row-decoder.js';
13
14
  import { wrapRowDescription } from '../util/wrap-row-description.js';
14
15
  import { CopyFromStream, CopyToStream } from './copy-stream.js';
@@ -27,6 +28,21 @@ function columnFormatsEqual(a, b) {
27
28
  return false;
28
29
  return true;
29
30
  }
31
+ /**
32
+ * Whether a command tag's row count describes rows the statement changed,
33
+ * rather than rows it returned or moved. SELECT, FETCH, MOVE and COPY all
34
+ * carry a count of their own that would be misleading as `rowsAffected`.
35
+ *
36
+ * MERGE has reported its own total (inserted + updated + deleted) since
37
+ * PostgreSQL 15; _handleCommandComplete() has always parsed it, and this
38
+ * is what stops it being dropped on the floor.
39
+ */
40
+ function reportsRowsAffected(command) {
41
+ return (command === 'INSERT' ||
42
+ command === 'UPDATE' ||
43
+ command === 'DELETE' ||
44
+ command === 'MERGE');
45
+ }
30
46
  /**
31
47
  * Uses of the same SQL before it is worth preparing.
32
48
  *
@@ -267,7 +283,7 @@ export class IntlConnection extends SafeEventEmitter {
267
283
  * nesting the existing one.
268
284
  */
269
285
  async savepoint(name) {
270
- if (!(name && name.match(/^[a-zA-Z]\w+$/)))
286
+ if (!(name && name.match(/^[a-zA-Z]\w*$/)))
271
287
  throw new Error(`Invalid savepoint "${name}"`);
272
288
  const depth = (this._savepointDepths.get(name) || 0) + 1;
273
289
  this._savepointDepths.set(name, depth);
@@ -299,7 +315,7 @@ export class IntlConnection extends SafeEventEmitter {
299
315
  * @param immediate - Ignores the tracked depth and releases right away.
300
316
  */
301
317
  async releaseSavepoint(name, immediate) {
302
- if (!(name && name.match(/^[a-zA-Z]\w+$/)))
318
+ if (!(name && name.match(/^[a-zA-Z]\w*$/)))
303
319
  throw new Error(`Invalid savepoint "${name}"`);
304
320
  const depth = this._savepointDepths.get(name) || 0;
305
321
  if (!immediate && depth > 1) {
@@ -359,7 +375,7 @@ export class IntlConnection extends SafeEventEmitter {
359
375
  * unwind.
360
376
  */
361
377
  async rollbackToSavepoint(name) {
362
- if (!(name && name.match(/^[a-zA-Z]\w+$/)))
378
+ if (!(name && name.match(/^[a-zA-Z]\w*$/)))
363
379
  throw new Error(`Invalid savepoint "${name}"`);
364
380
  this._savepointDepths.delete(name);
365
381
  await this.execute('ROLLBACK TO SAVEPOINT ' + name, { autoCommit: false });
@@ -523,8 +539,11 @@ export class IntlConnection extends SafeEventEmitter {
523
539
  break;
524
540
  case Protocol.BackendMessageCode.RowDescription:
525
541
  fields = msg.fields;
526
- parsers = getParsers(typeMap, fields);
527
- current.fields = wrapRowDescription(typeMap, fields, DataFormat.text);
542
+ // Simple Query has no Bind at all, so every column is
543
+ // already text - a fetchAsString column simply comes back
544
+ // unparsed.
545
+ parsers = getParsers(typeMap, fields, options);
546
+ current.fields = wrapRowDescription(typeMap, fields, DataFormat.text, options);
528
547
  current.rows = [];
529
548
  break;
530
549
  case Protocol.BackendMessageCode.DataRow:
@@ -539,11 +558,8 @@ export class IntlConnection extends SafeEventEmitter {
539
558
  case Protocol.BackendMessageCode.CommandComplete:
540
559
  // Ignore BEGIN command that we added to sql
541
560
  current.command = msg.command;
542
- if (current.command === 'DELETE' ||
543
- current.command === 'INSERT' ||
544
- current.command === 'UPDATE') {
561
+ if (reportsRowsAffected(current.command))
545
562
  current.rowsAffected = msg.rowCount;
546
- }
547
563
  if (timingEnabled)
548
564
  current.executeTime = performance.now() - currentStart;
549
565
  if (current.rows)
@@ -659,7 +675,13 @@ export class IntlConnection extends SafeEventEmitter {
659
675
  return result;
660
676
  }
661
677
  }
662
- if (!this._earnsAName(key))
678
+ // A fetchAsString query needs its RowDescription before the Bind that
679
+ // asks for those columns as text, and only a named statement has one
680
+ // in hand ahead of time. Preparing on first sight pays a single
681
+ // Parse+Describe round trip per connection instead of leaving every
682
+ // call until the statement earns a name asking for the whole row as
683
+ // text (what queryOnce() falls back to).
684
+ if (!options.fetchAsString?.length && !this._earnsAName(key))
663
685
  return this.queryOnce(sql, paramTypes, params, options, savepoint);
664
686
  const name = 'C_' + ++this._preparedCounter;
665
687
  // A Parse that fails caches nothing and reports itself; the statement
@@ -734,7 +756,24 @@ export class IntlConnection extends SafeEventEmitter {
734
756
  let resultFields;
735
757
  let commandTag;
736
758
  let error;
759
+ let suspended = false;
737
760
  const rowDecoder = resolveRowDecoder(options);
761
+ // fetchAsString names OIDs, but Bind's result format codes are
762
+ // positional - and this path's Describe answers only after the Bind
763
+ // that would have to carry them. With no column types to map the
764
+ // list onto, the whole row is asked for as text instead: the listed
765
+ // columns then arrive exactly as the server renders them, which is
766
+ // what was asked for, and the rest cost a text decode.
767
+ //
768
+ // Only `prepare: false` normally reaches here with a list set -
769
+ // _queryCached() prepares a fetchAsString query on first sight
770
+ // precisely so it can send per-column codes from the second call on
771
+ // (from the first, for every connection that has seen the SQL).
772
+ const columnFormat = options.fetchAsString?.length
773
+ ? DataFormat.text
774
+ : options.columnFormat != null
775
+ ? options.columnFormat
776
+ : DEFAULT_COLUMN_FORMAT;
738
777
  // See executeReused() for what a savepoint riding along does to the
739
778
  // CommandComplete stream.
740
779
  let leadingCommandTags = savepoint ? 1 : 0;
@@ -742,9 +781,15 @@ export class IntlConnection extends SafeEventEmitter {
742
781
  await this.socket
743
782
  .sendExtendedQueryMessages({
744
783
  parse: { sql, paramTypes },
745
- bind: { typeMap, paramTypes, params, queryOptions: options },
784
+ bind: {
785
+ typeMap,
786
+ paramTypes,
787
+ params,
788
+ queryOptions: options,
789
+ columnFormat,
790
+ },
746
791
  describe: { type: 'P' },
747
- execute: { fetchCount: options.fetchCount || 100 },
792
+ execute: { fetchCount: options.fetchCount ?? 0 },
748
793
  before: savepoint ? 'SAVEPOINT ' + savepoint : undefined,
749
794
  after: savepoint ? 'RELEASE ' + savepoint : undefined,
750
795
  }, (code, msg, done) => {
@@ -753,11 +798,17 @@ export class IntlConnection extends SafeEventEmitter {
753
798
  case Protocol.BackendMessageCode.BindComplete:
754
799
  case Protocol.BackendMessageCode.NoData:
755
800
  case Protocol.BackendMessageCode.NoticeResponse:
801
+ break;
802
+ // The server stopped at the fetchCount limit rather than
803
+ // running out of rows. The Sync that closes this call
804
+ // discards the portal, so the rest is gone - all that can be
805
+ // done is say the result is a prefix.
756
806
  case Protocol.BackendMessageCode.PortalSuspended:
807
+ suspended = true;
757
808
  break;
758
809
  case Protocol.BackendMessageCode.RowDescription:
759
- parsers = getParsers(typeMap, msg.fields);
760
- resultFields = wrapRowDescription(typeMap, msg.fields, options.columnFormat || DEFAULT_COLUMN_FORMAT);
810
+ parsers = getParsers(typeMap, msg.fields, options);
811
+ resultFields = wrapRowDescription(typeMap, msg.fields, columnFormat, options);
761
812
  result.fields = resultFields;
762
813
  result.rowType = resolveRowType(options);
763
814
  break;
@@ -796,11 +847,10 @@ export class IntlConnection extends SafeEventEmitter {
796
847
  rows[i] = rowDecoder.decode(parsers, rows[i].data, rows[i].columnCount, options, resultFields);
797
848
  }
798
849
  }
799
- if (result.command === 'DELETE' ||
800
- result.command === 'INSERT' ||
801
- result.command === 'UPDATE') {
850
+ if (suspended)
851
+ result.suspended = true;
852
+ if (reportsRowsAffected(result.command))
802
853
  result.rowsAffected = commandTag?.rowCount;
803
- }
804
854
  if (timingEnabled)
805
855
  result.executeTime = performance.now() - startTime;
806
856
  return result;
@@ -873,25 +923,24 @@ export class IntlConnection extends SafeEventEmitter {
873
923
  * so repeated executes of the same statement don't rebuild them.
874
924
  * Shared by executeReused() and executeBatchReused().
875
925
  */
876
- _resolveReusedParsers(cachedFields, typeMap, options) {
877
- const columnFormat = options.columnFormat != null
878
- ? options.columnFormat
879
- : DEFAULT_COLUMN_FORMAT;
926
+ _resolveReusedParsers(cachedFields, typeMap, options, columnFormat) {
880
927
  const cached = this._executeReusedParserCache.get(cachedFields);
881
928
  if (cached &&
882
929
  cached.typeMap === typeMap &&
883
- columnFormatsEqual(cached.columnFormat, columnFormat)) {
930
+ columnFormatsEqual(cached.columnFormat, columnFormat) &&
931
+ fetchAsStringEqual(cached.fetchAsString, options.fetchAsString)) {
884
932
  return { parsers: cached.parsers, resultFields: cached.resultFields };
885
933
  }
886
934
  const fields = cachedFields.map((f, i) => ({
887
935
  ...f,
888
936
  format: Array.isArray(columnFormat) ? columnFormat[i] : columnFormat,
889
937
  }));
890
- const parsers = getParsers(typeMap, fields);
891
- const resultFields = wrapRowDescription(typeMap, fields, columnFormat);
938
+ const parsers = getParsers(typeMap, fields, options);
939
+ const resultFields = wrapRowDescription(typeMap, fields, columnFormat, options);
892
940
  this._executeReusedParserCache.set(cachedFields, {
893
941
  typeMap,
894
942
  columnFormat,
943
+ fetchAsString: options.fetchAsString,
895
944
  parsers,
896
945
  resultFields,
897
946
  });
@@ -974,6 +1023,16 @@ export class IntlConnection extends SafeEventEmitter {
974
1023
  if (key != null && plan.name)
975
1024
  named.set(key, plan);
976
1025
  }
1026
+ // Only a statement whose columns are already known can carry
1027
+ // per-column codes; the rest fall back to asking for the whole row
1028
+ // as text, exactly as queryOnce() does and for the same reason.
1029
+ plan.columnFormat = plan.fields
1030
+ ? resolveColumnFormats(plan.fields, options)
1031
+ : options.fetchAsString?.length
1032
+ ? DataFormat.text
1033
+ : options.columnFormat != null
1034
+ ? options.columnFormat
1035
+ : DEFAULT_COLUMN_FORMAT;
977
1036
  plans.push(plan);
978
1037
  statements.push({
979
1038
  // Per statement, not per pipeline: the statements differ, so a
@@ -988,6 +1047,7 @@ export class IntlConnection extends SafeEventEmitter {
988
1047
  paramTypes: r.paramTypes,
989
1048
  params: r.params,
990
1049
  queryOptions: options,
1050
+ columnFormat: plan.columnFormat,
991
1051
  },
992
1052
  describe: sendDescribe ? { type: 'P' } : undefined,
993
1053
  // Unlimited, never options.fetchCount: a suspended portal would
@@ -1025,7 +1085,7 @@ export class IntlConnection extends SafeEventEmitter {
1025
1085
  const fields = pendingFields || plan.fields;
1026
1086
  const result = { command: msg?.command };
1027
1087
  if (fields) {
1028
- const { parsers, resultFields } = this._resolveReusedParsers(fields, typeMap, options);
1088
+ const { parsers, resultFields } = this._resolveReusedParsers(fields, typeMap, options, plan.columnFormat ?? DEFAULT_COLUMN_FORMAT);
1029
1089
  result.fields = resultFields;
1030
1090
  result.rowType = resolveRowType(options);
1031
1091
  if (!result.command)
@@ -1038,9 +1098,7 @@ export class IntlConnection extends SafeEventEmitter {
1038
1098
  }
1039
1099
  result.rows = rows;
1040
1100
  }
1041
- if (result.command === 'DELETE' ||
1042
- result.command === 'INSERT' ||
1043
- result.command === 'UPDATE')
1101
+ if (reportsRowsAffected(result.command))
1044
1102
  result.rowsAffected = msg.rowCount;
1045
1103
  pendingFields = undefined;
1046
1104
  pendingRows = undefined;
@@ -1120,8 +1178,9 @@ export class IntlConnection extends SafeEventEmitter {
1120
1178
  const rowDecoder = resolveRowDecoder(options);
1121
1179
  let parsers;
1122
1180
  let resultFields;
1181
+ const columnFormat = resolveColumnFormats(cachedFields, options);
1123
1182
  if (cachedFields) {
1124
- const resolved = this._resolveReusedParsers(cachedFields, typeMap, options);
1183
+ const resolved = this._resolveReusedParsers(cachedFields, typeMap, options, columnFormat);
1125
1184
  parsers = resolved.parsers;
1126
1185
  resultFields = resolved.resultFields;
1127
1186
  }
@@ -1134,6 +1193,7 @@ export class IntlConnection extends SafeEventEmitter {
1134
1193
  paramTypes,
1135
1194
  params,
1136
1195
  queryOptions: options,
1196
+ columnFormat,
1137
1197
  }));
1138
1198
  this.runningQueryCount++;
1139
1199
  await this.socket
@@ -1155,9 +1215,7 @@ export class IntlConnection extends SafeEventEmitter {
1155
1215
  const item = {};
1156
1216
  if (msg?.command)
1157
1217
  item.command = msg.command;
1158
- if (item.command === 'DELETE' ||
1159
- item.command === 'INSERT' ||
1160
- item.command === 'UPDATE')
1218
+ if (reportsRowsAffected(item.command))
1161
1219
  item.rowsAffected = msg.rowCount;
1162
1220
  if (pendingRows && parsers && resultFields) {
1163
1221
  const l = pendingRows.length;
@@ -1225,9 +1283,15 @@ export class IntlConnection extends SafeEventEmitter {
1225
1283
  let resultFields;
1226
1284
  let commandTag;
1227
1285
  let error;
1286
+ let suspended = false;
1228
1287
  const rowDecoder = resolveRowDecoder(options);
1288
+ // The whole point of reusing a prepared statement here: its
1289
+ // RowDescription is already in hand, so fetchAsString's OID list can
1290
+ // become the per-column result format codes this Bind carries, with
1291
+ // no extra round trip to find out what the columns are.
1292
+ const columnFormat = resolveColumnFormats(cachedFields, options);
1229
1293
  if (cachedFields) {
1230
- const resolved = this._resolveReusedParsers(cachedFields, typeMap, options);
1294
+ const resolved = this._resolveReusedParsers(cachedFields, typeMap, options, columnFormat);
1231
1295
  parsers = resolved.parsers;
1232
1296
  resultFields = resolved.resultFields;
1233
1297
  result.fields = resultFields;
@@ -1247,8 +1311,9 @@ export class IntlConnection extends SafeEventEmitter {
1247
1311
  paramTypes,
1248
1312
  params,
1249
1313
  queryOptions: options,
1314
+ columnFormat,
1250
1315
  },
1251
- execute: { fetchCount: options.fetchCount || 100 },
1316
+ execute: { fetchCount: options.fetchCount ?? 0 },
1252
1317
  before: savepoint ? 'SAVEPOINT ' + savepoint : undefined,
1253
1318
  after: savepoint ? 'RELEASE ' + savepoint : undefined,
1254
1319
  }, (code, msg, done) => {
@@ -1256,7 +1321,10 @@ export class IntlConnection extends SafeEventEmitter {
1256
1321
  case Protocol.BackendMessageCode.ParseComplete:
1257
1322
  case Protocol.BackendMessageCode.BindComplete:
1258
1323
  case Protocol.BackendMessageCode.NoticeResponse:
1324
+ break;
1325
+ // See queryOnce() for what a suspended portal means here.
1259
1326
  case Protocol.BackendMessageCode.PortalSuspended:
1327
+ suspended = true;
1260
1328
  break;
1261
1329
  case Protocol.BackendMessageCode.DataRow:
1262
1330
  rows.push(msg);
@@ -1297,11 +1365,10 @@ export class IntlConnection extends SafeEventEmitter {
1297
1365
  rows[i] = rowDecoder.decode(parsers, rows[i].data, rows[i].columnCount, options, resultFields);
1298
1366
  }
1299
1367
  }
1300
- if (result.command === 'DELETE' ||
1301
- result.command === 'INSERT' ||
1302
- result.command === 'UPDATE') {
1368
+ if (suspended)
1369
+ result.suspended = true;
1370
+ if (reportsRowsAffected(result.command))
1303
1371
  result.rowsAffected = commandTag?.rowCount;
1304
- }
1305
1372
  if (timingEnabled)
1306
1373
  result.executeTime = performance.now() - startTime;
1307
1374
  return result;
@@ -2,6 +2,7 @@ import { Pool as LightningPool, } from 'lightning-pool';
2
2
  import { coerceToBoolean, coerceToInt } from 'putil-varhelpers';
3
3
  import { ConnectionState } from '../constants.js';
4
4
  import { SafeEventEmitter } from '../safe-event-emitter.js';
5
+ import { normalizeChannelName } from '../util/channel-name.js';
5
6
  import { getConnectionConfig } from '../util/connection-config.js';
6
7
  import { QueryRequest } from '../util/sql-tag.js';
7
8
  import { startsCopy, startsTransaction } from '../util/starts-transaction.js';
@@ -205,12 +206,32 @@ export class Pool extends SafeEventEmitter {
205
206
  : undefined;
206
207
  if (!slot) {
207
208
  const connection = await this.acquire();
209
+ let result;
208
210
  try {
209
- return await connection.query(sql, options);
211
+ result = await connection.query(sql, options);
210
212
  }
211
- finally {
213
+ catch (e) {
212
214
  await this.release(connection);
215
+ throw e;
216
+ }
217
+ // A portal lives only as long as the transaction it was created in,
218
+ // and outside an explicit one that is the implicit transaction any
219
+ // other statement's Sync ends. So a connection carrying an open
220
+ // cursor cannot go back in the pool when this call returns - handing
221
+ // it to the next caller would destroy the cursor's portal, and did
222
+ // ("portal ... does not exist", nondeterministically, depending on
223
+ // which connection that caller happened to get). It goes back when
224
+ // the cursor closes instead, which for-await and `await using` both
225
+ // do on their own; a cursor that is never closed holds its
226
+ // connection until the pool itself is closed.
227
+ if (result.cursor) {
228
+ result.cursor.once('close', () => {
229
+ this.release(connection).catch(e => this.emit('error', e));
230
+ });
231
+ return result;
213
232
  }
233
+ await this.release(connection);
234
+ return result;
214
235
  }
215
236
  try {
216
237
  const shared = slot.connection || (await slot.promise);
@@ -256,8 +277,10 @@ export class Pool extends SafeEventEmitter {
256
277
  return this._pool.releaseAsync(getIntlConnection(connection));
257
278
  }
258
279
  async listen(channel, callback) {
259
- if (!/^[A-Z]\w+$/i.test(channel))
260
- throw new TypeError(`Invalid channel name`);
280
+ // Folded here as well as in Connection.listen(): this emitter's keys
281
+ // have to be the same strings the inner connection registers, or
282
+ // unListen() would miss them.
283
+ channel = normalizeChannelName(channel);
261
284
  // Bug: _initNotificationConnection() only ever bootstraps the shared
262
285
  // connection and registers every channel known at that moment - it
263
286
  // returns immediately once that connection already exists, so a second,
@@ -274,8 +297,7 @@ export class Pool extends SafeEventEmitter {
274
297
  }
275
298
  }
276
299
  async unListen(channel) {
277
- if (!/^[A-Z]\w+$/i.test(channel))
278
- throw new TypeError(`Invalid channel name`);
300
+ channel = normalizeChannelName(channel);
279
301
  this._notificationListeners.removeAllListeners(channel);
280
302
  if (!this._notificationListeners.eventNames().length) {
281
303
  await this.unListenAll();
@@ -23,7 +23,7 @@ export declare class Portal {
23
23
  * no explicit BEGIN left transactionStatus 'I' and a later Execute on the
24
24
  * same named portal failed with "portal ... does not exist").
25
25
  */
26
- bindAndRetrieveFields(params: Maybe<any[]>, queryOptions: QueryOptions): Promise<Protocol.RowDescription[]>;
26
+ bindAndRetrieveFields(params: Maybe<any[]>, queryOptions: QueryOptions, columnFormat?: Protocol.DataFormat | Protocol.DataFormat[]): Promise<Protocol.RowDescription[]>;
27
27
  execute(fetchCount?: number): Promise<PortalExecuteResult>;
28
28
  /**
29
29
  * Close + Sync as one request (see PgSocket.sendCloseAndSyncMessages()
@@ -22,7 +22,7 @@ export class Portal {
22
22
  * no explicit BEGIN left transactionStatus 'I' and a later Execute on the
23
23
  * same named portal failed with "portal ... does not exist").
24
24
  */
25
- async bindAndRetrieveFields(params, queryOptions) {
25
+ async bindAndRetrieveFields(params, queryOptions, columnFormat) {
26
26
  const intoCon = getIntlConnection(this.connection);
27
27
  intoCon.ref();
28
28
  try {
@@ -35,6 +35,7 @@ export class Portal {
35
35
  paramTypes: this._statement.paramTypes,
36
36
  params,
37
37
  queryOptions,
38
+ columnFormat,
38
39
  },
39
40
  describe: { type: 'P', name: this.name },
40
41
  }, (code, msg, done) => {
@@ -68,7 +69,7 @@ export class Portal {
68
69
  const rows = [];
69
70
  const executePromise = socket.sendExecuteMessage({
70
71
  portal: this.name,
71
- fetchCount: fetchCount || 100,
72
+ fetchCount: fetchCount ?? 100,
72
73
  }, (code, msg, done) => {
73
74
  switch (code) {
74
75
  case Protocol.BackendMessageCode.NoticeResponse:
@@ -1,9 +1,9 @@
1
- import { DEFAULT_COLUMN_FORMAT } from '../constants.js';
2
1
  import { GlobalTypeMap } from '../data-type-map.js';
3
2
  import { Protocol } from '../protocol/protocol.js';
4
3
  import { SafeEventEmitter } from '../safe-event-emitter.js';
5
4
  import { withAbortSignal } from '../util/abort-signal.js';
6
5
  import { getParsers } from '../util/get-parsers.js';
6
+ import { resolveColumnFormats } from '../util/resolve-column-formats.js';
7
7
  import { resolveRowType } from '../util/row-decoder.js';
8
8
  import { wrapRowDescription } from '../util/wrap-row-description.js';
9
9
  import { Cursor } from './cursor.js';
@@ -208,10 +208,14 @@ export class PreparedStatement extends SafeEventEmitter {
208
208
  intlCon.ref();
209
209
  let portal = new Portal(this, 'P_' + ++portalCounter);
210
210
  try {
211
- const fields = await portal.bindAndRetrieveFields(options.params, options);
211
+ // prepare() already fetched this statement's RowDescription, so
212
+ // fetchAsString's OID list can become per-column result format
213
+ // codes on the Bind below with no round trip of its own.
214
+ const columnFormat = resolveColumnFormats(this._fields, options);
215
+ const fields = await portal.bindAndRetrieveFields(options.params, options, columnFormat);
212
216
  const typeMap = options.typeMap || GlobalTypeMap;
213
- const parsers = getParsers(typeMap, fields);
214
- const resultFields = wrapRowDescription(typeMap, fields, options.columnFormat || DEFAULT_COLUMN_FORMAT);
217
+ const parsers = getParsers(typeMap, fields, options);
218
+ const resultFields = wrapRowDescription(typeMap, fields, columnFormat, options);
215
219
  const result = {
216
220
  command: undefined,
217
221
  fields: resultFields,