Dev - #63
Merged
Merged
Conversation
…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>
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>
# Conflicts: # package.json
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.