Skip to content

Dev - #63

Merged
erayhanoglu merged 108 commits into
mainfrom
dev
Sep 7, 2026
Merged

Dev#63
erayhanoglu merged 108 commits into
mainfrom
dev

Conversation

@erayhanoglu

Copy link
Copy Markdown
Member

No description provided.

erayhanoglu and others added 30 commits August 13, 2026 08:55
…nd in audit

- connection-config.ts: an explicit requireSSL:true was silently clobbered
  back to false when the host was re-parsed as a connection string; a prior
  fix attempt wrote to a discarded variable instead of cfg, so it never took
  effect. Now the explicit value is preserved across the merge.
- connection-config.ts: username/password from a connection URI used
  decodeURI, which does not decode reserved characters like %40 (@) and
  %3A (:) that commonly appear in passwords, causing auth failures with
  otherwise-correct credentials. Switched to decodeURIComponent.
- json-type.ts / jsonb-type.ts: fetchAsString OID checks were swapped
  between the two types, so the option had no effect on either.
- buffer-reader.ts: readCString silently returned an empty string and
  reset the read offset to 0 when no NUL terminator was found (e.g. a
  fragmented/malformed message), corrupting the parse instead of failing.
  Now throws.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Documents when to use graphify query/path/explain over raw grep or the
full GRAPH_REPORT.md, and to run graphify update after code changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- float4-type.ts: parseBinary rounded every decoded value to 2 decimal
  places (Math.round(...*100)/100), silently truncating float4 precision
  on every query. Confirmed against PostgreSQL's float4send/float4recv
  and pq_sendfloat4/pq_getmsgfloat4 (raw IEEE754, no rounding) and
  node-pg-types' parseFloat32 (plain readFloatBE). Now matches float8-type.ts.
  Updated float4.spec.ts and 04-query.spec.ts expectations to use
  Math.fround() for values not exactly representable in 32-bit float,
  since the prior rounding was masking float4's real precision.
- connection-config.ts: sslmode=prefer was mapped to requireSSL:true,
  making pg-socket.ts treat a server's SSL refusal as fatal. Per libpq
  semantics, prefer should attempt SSL and silently fall back to a plain
  connection when refused; only require/verify-ca/verify-full should
  hard-fail. Removed 'prefer' from the requireSSL list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
PostgreSQL's array text format only uses double quotes for quoting
elements; a literal apostrophe has no special meaning and is never
escaped. parsePostgresArray toggled its quote state on both " and ',
so an apostrophe inside an already-quoted element (e.g. from a
text[]/varchar[] column) would flip parsing into a false "quoted"
state, dropping the apostrophe and merging it with the next element.

Verified against a live server: ARRAY['O''Brien, Jr.', 'Smith']::text[]
renders as {"O'Brien, Jr.",Smith}, and previously parsed to a single
corrupted element instead of two.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Int4Type.isType() had no lower bound and used Number.MAX_SAFE_INTEGER
(~9x10^15) as its upper bound instead of the real int4 range
(-2147483648..2147483647). Int8Type.isType() only matched numbers
strictly greater than MAX_SAFE_INTEGER, so any plain number between
2^31 and MAX_SAFE_INTEGER (or below -2^31) fell through and was wrongly
auto-detected as int4.

Concretely, connection.query(sql, { params: [5000000000] }) with no
explicit param type would auto-detect int4, then crash with a Node
RangeError from writeInt32BE instead of being sent as int8. Verified
live: after the fix, GlobalTypeMap.determine() correctly maps values
outside the 32-bit range to int8, and the same query now succeeds.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…bind

CharType.isType() checked JS string .length (UTF-16 code units) instead
of UTF-8 byte length, so any single non-ASCII character (e.g. 'é', '中')
was wrongly auto-detected as PostgreSQL's 1-byte "char" type. encodeBinary
then wrote the character's full UTF-8 encoding (2-3 bytes) into a
parameter the server expects to be exactly 1 byte, so the server rejected
the bind with "incorrect binary data format in bind parameter". A query
as ordinary as connection.query('select $1', { params: ['é'] }) would
crash.

Now isType() checks Buffer.byteLength(v, 'utf8') === 1, so only true
single-byte (ASCII) strings match; multi-byte characters correctly fall
through to VarcharType. Verified live: 'é' and '中' now auto-detect as
varchar and round-trip successfully; ASCII single chars are unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
TimestampType.isType() excluded any Date at exactly midnight, so a
genuine (non-epoch) timestamp that happens to land on 00:00:00.000 was
auto-detected as DateType instead - e.g. connection.query('select $1',
{ params: [new Date('2026-06-01T00:00:00')] }) reported field oid 1082
(date) instead of 1114 (timestamp), losing type information for a value
that was never meant to be date-only.

A bare Date carries no signal distinguishing "date-only" from "a real
timestamp that happens to fall on midnight" - both look identical. Since
a timestamp/timestamptz column represents midnight without any loss,
while a date column can never hold a non-midnight time, timestamp is the
safer default. Removed the midnight exclusion from TimestampType.isType();
DateType auto-detection now only fires for the one genuinely unambiguous
case - epoch (1970-01-01) at midnight, which carries no information
beyond "date-only".

Verified live: a non-epoch midnight Date now reports oid 1114 (timestamp);
epoch-midnight still reports 1082 (date); ordinary timestamps unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The sign field of PostgreSQL's binary numeric wire format is a bitmask
(0x0000/0x4000/0xC000, plus 0xD000/0xF000 for +/-Infinity since PG14),
not a two's complement quantity. It was read with readInt16BE (signed),
so any sign value with the top bit set (0xC000, 0xD000, 0xF000) came
back as a negative number and never matched the positive hex-literal
constants - the sign === NUMERIC_NAN check had been dead code all along.
NUMERIC_NEG (0x4000) happened to work because it doesn't set the sign
bit, which is why ordinary negative numbers were unaffected and this
went unnoticed.

Concretely, selecting 'NaN'::numeric, 'Infinity'::numeric or
'-Infinity'::numeric with columnFormat: binary silently returned 0
instead of the correct value (text format was already correct, since
JS's parseFloat natively understands these strings).

Fixed by reading sign with readUInt16BE, and added explicit handling
for the two new PG14+ Infinity sign values. Verified live against
PostgreSQL 18.4.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
convertRowToObject built each row with out[fields[i].fieldName] = value
on a plain {} object. A result column literally named "__proto__" (e.g.
select x as "__proto__", reachable whenever a column list or alias is
built dynamically) doesn't create an own property through bracket
assignment - it reassigns the object's prototype via the inherited
Object.prototype.__proto__ accessor, corrupting every row object built
this way and anything that later trusts Object.prototype invariants.

Switched to Object.defineProperty, which always creates a genuine own
data property regardless of the key, matching normal assignment
semantics for every other column name. Verified live: a jsonb column
aliased "__proto__" now round-trips as a real own property; the row's
own prototype and Object.prototype are both left untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
escapeLiteral() escaped quotes and backslashes but let \0 bytes through
untouched. stringifyValueForSQL()/stringifyArrayForSQL() call escapeLiteral
internally and inherit the same gap.

The result is used to build script-mode SQL for connection.execute(),
which frames the query as a C-string (writeCString in frontend.ts). An
embedded \0 in the middle of that string creates a second null terminator
before the message's declared length is reached, which the server rejects
with a low-level "invalid message format" DatabaseError - confusing, but
verified live that it does NOT kill the connection or crash the process
(a subsequent query on the same connection still succeeds), correcting an
earlier overstated "connection can be terminated" claim from the audit.

PostgreSQL's text-literal syntax has no way to represent a NUL byte at
all, so escapeLiteral now throws a clear, actionable error immediately
instead of producing a string that fails downstream with an opaque
protocol error.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
close() and the internal _close() both decremented _refCount
independently, with no floor or "already closed" guard. Every call to
close() after the statement was already closed re-entered the queue and
sent a real CLOSE+SYNC round-trip to the server for a statement name
that no longer exists, and _refCount drifted further negative each time
(-1, -3, -5, ...) instead of settling at 0.

Verified live: three consecutive close() calls on the same statement
used to each hit the network; PostgreSQL doesn't error on closing a
nonexistent statement so the connection survived, but every redundant
call cost a real round-trip, and the corrupted ref-count could interact
badly with the cursor path in _execute(), which increments _refCount
to share a statement with a live cursor.

_close() no longer decrements on its own (it's only ever invoked from
close() after the count already hit zero, so the second decrement was
pure double-counting), and a _closed flag makes close() itself a no-op
once the statement is genuinely closed. The ref-counted case (a cursor
holding an extra reference) still decrements-then-closes correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The benchmark suite writes per-run JSON into benchmark/results, which is
regenerated on every run and should never be committed. The workflow's
tsconfig filter matched nested tsconfig files too, so unrelated changes
triggered the test job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Class members go properties -> constructor -> accessors -> public ->
protected -> private, with non-public ones at the bottom rather than
beside their callers, and protected is preferred over private since
Connection, Pool and IntlConnection extend and wrap each other.

Loops read length into a local first: V8 can only hoist the property
read when the value is a plain array field and the body calls nothing
opaque, which rules out most of this codebase's hot loops (getters like
BufferReader.length, bodies that call a parser).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…enating every chunk

parse() rebuilt its pending buffer with Buffer.concat([this._buf, data])
on every socket chunk, so a message spanning N chunks was copied N times
- measured at 105.5MB copied and 179 concat calls to deliver a single
10.5MB payload, all of it short-lived garbage the collector then had to
walk.

The wire tells us the length up front, so use it: when a whole message
already sits in one chunk, take a zero-copy subarray of it; otherwise
allocate the exact final size once and fill it as chunks arrive. Same
10.5MB payload now needs one allocation, and connect/close drops from
18 allocations to 2.

readBuffer() switches slice() to its non-deprecated subarray() alias
(identical semantics, both views) for the same reason it is used above.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The wire format declares that count as Int16 (unlike RowDescription's
field count or this same message's own per-parameter Int32 OIDs), so
reading it as UInt32BE shifted every subsequent read two bytes off and
eventually ran past the buffer with "Eof in buffer detected".

Never exercised until now: postgrejs only ever described already-bound
portals (type 'P'), whose response carries no ParameterDescription at
all. The Describe(type:'S') path that PreparedStatement.prepare() is
about to take is its first real caller.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…er-column slices

Three changes that turned out to be one: they share the same signatures
and could not be landed apart.

Socket writes. Every protocol message was written on its own, so a burst
of 50 concurrent prepared executes produced 50 separate write() calls.
_send() now queues into _pendingWrites and flushes on process.nextTick
under cork()/uncork(), but only when more than one request is already in
flight - a lone sequential query still writes immediately rather than
paying a tick of latency. The same burst now leaves as a single _writev.
sendExtendedQueryMessages() passes its five messages as an array instead
of Buffer.concat-ing them first.

Response correlation. Requests and responses are matched through an
explicit FIFO capture queue rather than ad-hoc listeners, which is what
makes multiple in-flight statements on one connection safe. It also
fixes a real bug on the cursor error path: closing a portal sent Close
and Sync under two separate captures, so the orphaned Sync consumed the
next response and the caller saw "unexpected response message (Z)"
instead of the DatabaseError that actually happened. Close and Sync now
share one capture, and PreparedStatement._execute() swallows a teardown
failure so it cannot mask the original error either.

Row decoding. DataRow carried one Buffer.subarray() per column - 550,000
throwaway views for a 50,000-row, 11-column result, allocated before any
value was even looked at. The message now carries the row's whole
payload as one buffer and each column's bounds are walked lazily during
decode. DecodeBinaryFunction gains an `offset` so fixed-width types
(declared via the new DataType.fixedBinarySize) read straight out of the
shared buffer; variable-width ones (bytea, json, jsonb, numeric, varchar)
still get a properly bounded slice, since their parseBinary has no way
to find its own end otherwise. Text columns skip the subarray entirely
via Buffer.toString(enc, start, end).

Also here because they are part of the same call chain: parseTextBuffer
as a fast path that parses int2/int4/bytea straight from the wire bytes,
a rewritten parse-datetime, a local parse-bytea replacing the
postgres-bytea dependency, and convert-row-to-object folded into
parse-row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PostgreSQL correlates responses to requests by order, so several queries
can be in flight on one connection at once, each with its own Sync and
therefore its own error boundary. Passing `pipeline: true` dispatches
that way instead of holding a connection for the duration of the call,
which means the pool's `max` stops being a ceiling on concurrent queries
and becomes only a ceiling on connections.

Connections are picked synchronously, and that matters: everything in a
Promise.all() burst chooses its connection before any of them reaches
execute(), so runningQueryCount still reads zero for all of them at that
moment and cannot be used to balance. Each borrowed connection carries
its own `load`, incremented at selection time and decremented when the
query settles, and the least loaded one wins - which spreads a burst
evenly and favours whichever connection is draining fastest. Selection
never awaits either: opening another connection is started but not
waited on, because a caller that waits lands in a later event loop tick
and loses the per-tick write batching that makes the burst one write per
connection. Measured on 1000 queries against a pool of 10: 901 socket
writes and 91ms on one shared connection, 20 writes and 16ms spread
across ten.

Opt-in rather than default because the trade is real - the server runs a
connection's statements serially, so sharing speeds up bursts of short
queries but lets one slow query delay whatever is queued behind it.
Cursors, transactions and `autoCommit: false` never share: the last of
those prepares, executes and closes as separate steps, and the
connection reports itself idle between them, which would hand it back
mid-query. startsTransaction() rejects SQL that opens a transaction
before it is dispatched (a two-tier scan that skips comments, string
literals and dollar-quoted bodies), and a connection found in a
transaction afterwards - one opened inside a stored procedure, say - is
dropped from the shared set.

Connections go back to the pool on their own 'idle' event, so they are
borrowed for the length of a burst rather than owned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fifteen scenarios covering connection setup, simple and extended
queries, prepared statement reuse, mixed-type and binary decoding, large
blob and array fetches, cursor streaming and pool concurrency, each run
in its own child process against the repo's own docker-compose Postgres.

Every library is driven through its own idiomatic fast path rather than
a shared lowest-common-denominator API: postgres.js through tagged
templates and its implicit auto-pipelined pool, pg through named
statements and its opt-in wire pipelining, postgrejs through
Connection/Pool/Cursor/PreparedStatement. Where that is not possible the
asymmetry is disclosed in the report rather than normalized away - which
result format each library requests, why pg's binary bytea and array
decoders are not used, why postgres.js is excluded from the binary
decode scenario instead of being given a misleading number.

Alongside latency the runner records GC count and pause time, peak heap
growth (sampled during the run, not a before/after snapshot, which
cannot tell a scenario that peaks at 200MB and frees it from one that
never allocates), and bytes received on the wire - counted at
Readable.push() so all three libraries are measured identically, which
is what makes the binary-vs-text transfer comparison in the blob and
array scenarios meaningful.

npm run bench runs it, npm run bench:report regenerates BENCHMARKS.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CopyInResponse is 'G' (0x47) on the wire; the constant read 0x67, which
is 'g'. Its own comment already said // G. Nothing had ever used COPY,
so no code path looked for that message and the typo stayed invisible -
a COPY FROM STDIN simply waited forever for a response it could not
recognise.

Bind's comment said // R while its value was right; corrected to // B.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
connection.copyTo() returns a Readable of the bytes the server sends and
connection.copyFrom() returns a Writable to feed it - text, CSV or
binary, whichever the statement asked for. Nothing is decoded or copied
on the way through, so the format is entirely the caller's business.

```ts
const out = await connection.copyTo(`copy users to stdout (format csv)`);
await pipeline(out, fs.createWriteStream('users.csv'));

const inp = await connection.copyFrom(`copy users from stdin (format csv)`);
await pipeline(fs.createReadStream('users.csv'), inp);
console.log(inp.rowCount);
```

Both resolve as soon as the server accepts the copy rather than when it
finishes, which is the whole point - an export is never held in memory.
Backpressure runs in both directions: a consumer that falls behind
pauses the socket (once per stall, not once per row - a single socket
chunk carries many CopyData messages and every one of them reports the
buffer as full), and CopyData is written with the socket's own write
callback so a slow server pauses the source. Two million rows import at
a 36MB peak heap and export, against a deliberately slow consumer, at
63MB.

'end' and 'finish' mean the server is done, not merely that the last
byte moved: the streams wait for ReadyForQuery, so rowCount is set by
the time a pipeline() over them resolves and the next query on that
connection cannot race the copy. If the source fails, pipeline()
destroys the stream and CopyFail is sent - without it the server would
sit waiting for data that is never coming, and the connection would be
unusable rather than merely failed.

Around that:

- PgSocket gains sendCopyData/sendCopyDone/sendCopyFail, which write
  without pushing onto the capture queue: while a copy is running the
  server answers no individual message, so the response still belongs to
  the Query that opened it. CopyData's header and the caller's payload
  go out as two buffers under one cork rather than being concatenated,
  so a bulk import never copies its own bytes.
- execute() used to ignore CopyInResponse, leaving the server waiting
  for data it had no way to send. It now sends CopyFail and reports what
  to use instead; COPY TO STDOUT likewise says so rather than silently
  returning an empty result.
- Pooled pipelining refuses COPY outright. A copy puts the connection
  into a mode where the next pipelined query's Query message is a
  protocol error, which would take down every caller sharing it. The
  comment/quote-skipping scan startsTransaction() already used is now
  shared with startsCopy().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parseBinary/parseText/parseTextBuffer become decodeBinary/decodeText/
decodeTextBuffer, so every DataType member reads as one half of an
encode/decode pair instead of the two directions being named after
different verbs. The function type aliases follow (ParseTextFunction ->
DecodeTextFunction, ParseTextBufferFunction -> DecodeTextBufferFunction);
DecodeBinaryFunction was already named this way.

Untouched on purpose: parseRow, parseObjectRow, parsePostgresArray,
parseDateTime, Backend.parse and getParsers are not DataType members and
mean something else.

Breaking: DataType is public and custom types can be registered through
GlobalTypeMap, so anyone who has written one has to rename these three.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every call now takes a `signal`, so cancellation composes with everything
else that speaks AbortSignal:

    await connection.query(sql, { signal: AbortSignal.timeout(5000) });

    const ac = new AbortController();
    await Promise.all(rows.map(r => pool.query(sql, { params: [r], signal: ac.signal })));
    ac.abort();

That also settles per-call timeouts without inventing an option for them.

Cancelling a PostgreSQL query means opening a second connection and
sending a CancelRequest on it - a backend busy with a query is not
reading its own socket, which is why it cannot travel down the
connection it is meant to interrupt. It is a request, not a guarantee:
the statement may well finish first. So the call does not settle early.
It lets the server end the query and only then reports the abort, which
is what keeps the connection usable, since its response has to be read
either way.

The rejection is the signal's own reason, so `AbortSignal.timeout()`
still reports itself as a TimeoutError, with the database error (SQLSTATE
57014) attached as `cause` rather than replacing it. An already-aborted
signal rejects before anything is sent. The abort listener is always
removed again, so one long-lived signal shared by a thousand queries does
not accumulate a thousand listeners.

Pooled pipelining refuses a signal outright and takes an exclusive
connection instead. Cancelling targets a backend rather than a statement,
so on a shared connection it would kill whichever query happens to be
running, which is rarely the one the caller aborted. pg has the same
problem and answers it by silently discarding the result while the query
keeps running on the server.

PreparedStatement.cancel() was a stub that threw; it now does this too,
as does the new Connection.cancel().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
    await connection.query(sql`select * from t where id = any(${[1, 3]})`);

The tag builds a QueryRequest rather than executing anything - the thing
that runs a query is still query() or execute(). Interpolated values
never reach the text, so this is not string building with extra steps:
there is no way for a value to be read as SQL.

A nested QueryRequest is spliced in as a fragment with its parameters
renumbered, which is what makes a statement assemblable from pieces:

    const filter = city ? sql`where city = ${city}` : sql``;
    await connection.query(sql`select * from t ${filter} order by id`);

One object serves both protocols. query() takes `sql` and `params` and
lets the server receive the values out of band. execute() cannot - the
Simple Query protocol carries no parameters at all - so it takes
stringify(), which writes them in as literals.

Those two are not equally strong, and the difference is why stringify()
is careful. It resolves each value through the same type map query()
uses, encodes it with that type's own encodeText, and gives it an
explicit cast: a parameter carries its OID in the Bind message but a
literal carries nothing, so without the cast the server would infer a
type from context and the same statement could mean different things
through the two paths. Verified across twelve value types that they
agree. A value whose type has no text encoding throws rather than
falling back to a generic conversion, which is how wrong data gets
written quietly.

Making that work needed encodeText on nineteen data types that only had
an encoder for the binary format. The date and time ones mirror their own
encodeBinary exactly, including how it reads a JS Date's UTC or local
components depending on `utcDates` - choosing differently there would
have made query() and execute() disagree about which instant was meant.

Three helpers build the parts that cannot be parameters at all:

    sql.ident(name)         -> "name", quoted like PostgreSQL's quote_ident()
    sql.values(user)        -> ("id","name") values ($1,$2)
    sql.set({ city })       -> "city" = $1

`$1` is always a value, so a dynamic column or table name has to be
written into the text - quoting is the only thing standing between it and
an injection. sql.values() and sql.set() take an optional column list;
pass it when the object comes from outside, since without it the columns
are whatever keys the input happens to carry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
erayhanoglu and others added 29 commits September 6, 2026 23:15
Direct unit tests against fake DataTypeMap/RowDescription objects (no
live server needed - the function is pure): both default parsers for
an unregistered dataTypeId, the fixed-size-mismatch fallback slice, the
matching-size direct-read path, and a scalar text type with no
decodeTextBuffer fast path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
arrayCalculateDim() does a full DFS that visits every array node in the
input and deepens `dim` to match, so by construction a value can never
actually be an array once the walk reaches what dim considers the leaf
level - if it were, dim would already have gone one level deeper there.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same gaps as LsegType: encodeText(), 3 of decodeText()'s 4 accepted
text forms (a live round trip only ever gets the server's own
canonical form back), and isType()'s rejection branches.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
JSON.stringify() cannot serialize a BigInt itself - it throws
"Do not know how to serialize a BigInt" - so the bigint branch that
routed through it never actually worked. Writes it as a bare numeric
literal instead, which is what a JSON number already looks like on the
wire.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
encodeText(), the fixed-size types' string-to-number encodeBinary()
fallback, and each isType()/decodeBinary()/decodeText() edge case had
no direct coverage for BoxType, ByteaType, Float4Type, Float8Type,
Int8Type, PointType, UuidType, OidType, and Int2Type - only round-
tripped through a live connection, which never exercises the literal-
encoding path or most out-of-range/fallback branches.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
cancel() sends the CancelRequest over a brand-new, separately-connected
socket - if the server was slow to accept and process it, it could
still arrive after the *next* query in the same test had already
started, canceling that one instead of landing as the intended no-op.
Gives it a moment to fully land server-side before moving on.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
DateType/TimeType/TimestampType/TimestamptzType's encodeText() just
delegates to format-datetime.ts's already-tested formatters, but had no
direct call of its own. Documents (without forcing) one remaining
unreachable branch in TimestamptzType.decodeText(): parseDateTimeTz()
only ever returns a Date, Infinity or -Infinity.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Number.prototype.toFixed() reverts to exponential notation once |x|
reaches 1e21 (per spec) - the code relied on it to always produce plain
decimal notation, so past that threshold the 'e' stayed in the string
and the digit-grouping logic that followed silently produced the wrong
value instead of throwing (1e21 round-tripped back as 10000).

Replaces it with expandExponential(), which shifts the decimal point
across the mantissa's own digits via string manipulation instead of
floating-point formatting, so it has no magnitude limit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Direct unit tests against hand-built wire-format row buffers: null
columns in both parseRow() and parseObjectRow() (confirming the parser
itself is never called for a null value), and the __proto__ own-
property fix for both a real value and a null one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Covers the readable() stream surfacing an underlying read() failure as
its own 'error' (instead of hanging or throwing unhandled),
writable()'s default chunk size, and close() being safe to call twice.
Documents (without forcing) one remaining defensive branch: loread()
always returns a bytea, never SQL NULL.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Covers getBindMessage()'s scalar-wrapped-into-array-for-binary-type
and scalar-encodeText-with-no-elementsOID branches, plus every
missing-argument default across getParseMessage()/getExecuteMessage()/
getCloseMessage()/getQueryMessage()/getCopyFailMessage() and
getParseMessage()'s explicit paramTypes list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
fill() had no callers left. Adds direct tests for the remaining gaps:
growSize()'s buffer-limit-exceeded throw, flush()'s pending-timer clear
and zero-length path, _houseKeep() sizing itself by whatever's
currently pending, writeLString()'s null/empty-string cases, and
writeBigInt64BE()'s number-to-bigint coercion and its no-native-method
fallback.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Covers rowType, fetch() returning fewer rows than asked for once the
result runs out, and next()/fetch() after close(). Documents (without
forcing) one remaining defensive branch: _fetchRows()'s own _closed
check can't currently observe a change its callers didn't already gate
on, since there's no await between their check and this one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Direct unit tests against a fake PgSocket (pause/resume/sendCopyData/
sendCopyDone/sendCopyFail stubs) rather than racing real server timing
for backpressure, mid-copy errors, or a dead socket - the two stream
classes only ever touch those five methods. Covers every capture()
message case, waitStarted()'s three outcomes, fail()'s three-way
completion routing, and the "copy ended before it started"/"nothing
left waiting" fallbacks in both _finish() implementations.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… it created

close() tried to run DROP_REPLICATION_SLOT while the connection was
still mid-COPY-BOTH from START_REPLICATION - a new Query message is
invalid there, so the server rejected it and closed the connection,
silently swallowed by close()'s own .catch(() => undefined). The slot
was leaked every time, quietly piling up WAL on the server.

Fixed by sending CopyDone and waiting for the server to acknowledge the
end of streaming (resuming the socket first, in case backpressure had
paused it) before issuing DROP_REPLICATION_SLOT or closing the
connection.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Fake IntlConnection/socket stubs (pause/resume/sendCopyData/execute/
close) rather than a live replication slot for everything that doesn't
need one: the capture callback's every message case, _handleCopyData()'s
keepalive/XLogData/unknown-kind handling, _accepts()'s filter
combinations, _sendStatus(), ack(), and _fail().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds Getting Started, Development Workflow, Code Style, a stronger
Testing requirement (every change must come with a test and be
covered), Commit Messages, Pull Requests, and Benchmarks sections
around the existing test-setup instructions. Updates the stale
docker-compose.yaml reference to docker/docker-compose.yml.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replaces the default GitHub web-app template fields (Browser,
Smartphone, Screenshots) with ones relevant here: postgrejs/Node.js/
PostgreSQL versions, a minimal reproduction snippet, and the actual
error/stack trace.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
v3 is almost entirely additive; documents the one real breaking change
(DataType's parse* methods renamed to decode* to pair with encode*,
affecting only custom-registered data types) and the license change,
then tours what's new for anyone upgrading.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replaces the generic "enterprise-level client" opener with a "Why
PostgreJS?" section backed by real numbers from doc/BENCHMARKS.md,
broken into Blazing Fast / Small Footprint / Batteries Included
subsections. Trims the Library Overview to quick facts and drops the
generic closing paragraph after the Features list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
target="blank" opened a single reusable named window instead of a new
tab each time - missing the underscore for the "_blank" keyword. Adds
rel="noopener noreferrer" (standard practice for an external
target="_blank" link) and the image's actual height (800x446) so the
browser can reserve its space before it loads.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CODE_OF_CONDUCT.md's enforcement contact was still the Contributor
Covenant template's own placeholder emails, unrelated to this project -
replaced with info@panates.com. LICENSE's copyright line now names
Panates alone, matching how the project is attributed elsewhere.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Direct TLS negotiation is a PostgreSQL 17+ feature - an older server
has no idea what a raw TLS handshake byte means where it expects a
StartupMessage, and just closes the connection instead of erroring.
The test only checked whether SSL was enabled at all, so it failed on
CI's PostgreSQL 12/16 matrix entries instead of skipping. Now checks
server_version too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
numeric only gained Infinity/-Infinity support in PostgreSQL 14 - NaN
alone was valid before that. These tests assumed every server in CI's
matrix supported all three, failing outright ("invalid input syntax"/
"invalid sign in external numeric value") on PostgreSQL 12 instead of
skipping.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds a map of the codebase (src/protocol, src/connection, src/data-types,
src/util and their test/A-common, test/B-connection, test/C-data-types
counterparts), the c8-vs-istanbul ignore-comment gotcha, the fake-socket
unit-testing pattern used for stream/connection-wrapping classes, the
PostgreSQL-version self-skip pattern for CI's 12/16/18 matrix, and a
reminder to check git status before committing since the user works in
the repo concurrently.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@erayhanoglu
erayhanoglu merged commit ea38445 into main Sep 7, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant