Skip to content

feat: add a pull-based API for consuming responses - #1797

Draft
arthurschreiber wants to merge 21 commits into
masterfrom
claude/token-to-push-parser-2wfq7f
Draft

arthurschreiber wants to merge 21 commits into
masterfrom
claude/token-to-push-parser-2wfq7f

Conversation

@arthurschreiber

@arthurschreiber arthurschreiber commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator

This adds a second way to consume the response of a request: pulling results, rows, values and output parameters with for await and promises, instead of listening to events. It sits alongside the existing API. A request with a completion callback works exactly as before, and a request without one returns a Response to pull from.

const request = new Request('SELECT id, name, document FROM docs');
await using response = connection.execSql(request);

for await (const row of response.rows()) {
  console.log(row.get('id'), row.get('name'));

  // `max` values can be streamed without buffering them
  await pipeline(await row.stream('document'), createWriteStream(`${row.get('id')}.bin`));
}
// One result per statement that returns rows or reports a row count
await using response = connection.execSqlBatch(new Request("INSERT ...; UPDATE ...; PRINT 'done'; SELECT ..."));

for await (const result of response.results()) {
  console.log(`${result.rowCount} rows`, result.messages.map((m) => m.message));
  for await (const row of result) { /* rows of the SELECT; none for INSERT/UPDATE */ }
}

API

  • Executing a request without a callback returns a Response. This covers execSql, execSqlBatch, callProcedure and execute. prepare returns a Promise<PreparedStatement>. With a callback, all of these return void as before; the TypeScript overloads use the PulledRequest and CallbackRequest types.
  • Response
    • results() iterates the results in order. There is one Result for each result set, and one for each statement without a result set that reports a row count or an error (e.g. INSERT, UPDATE, DELETE). A Result is an async iterable of its rows; there are none if it has no result set.
    • rows() iterates the rows of the single result set of a request. Results without a result set are skipped, and it throws if there is more than one result set.
    • messages holds all info messages read so far (PRINT, RAISERROR with a low severity, warnings).
    • outputParameters() resolves to the output parameters and the return status once the results were read.
    • finish() reads the rest of the response and resolves to a RequestSummary: rowCount (total), returnStatus, results (the row count, messages and errors of each result) and messages. Output parameters are only available via outputParameters(), so finish() does not buffer max values nobody asked for.
  • Result
    • columns is empty if the result has no result set.
    • rowCount is undefined if the server did not report it (with SET NOCOUNT ON).
    • messages and errors hold those of the result's statement. messages also includes the messages of statements before it that have no result of their own, like PRINT.
  • Row
    • get(key) returns a value that was already read, by column name or index.
    • read(key) reads a value in full; stream(key) returns the chunks of a max value as an async iterable, or null for a null value.
    • length(key), values() and readValues() round this out.
    • Rows are plain arrays of their values; the column metadata is kept once per result.
  • OutputParameters have the same get, read, stream and length, keyed by parameter name, plus returnStatus.
  • PreparedStatement has execute(parameters, options) and unprepare(), and can be disposed with await using.
  • ExecutionOptions take { signal } on every execution. Aborting the signal cancels the execution, which then fails with the signal's reason. This part is also proposed on its own in feat: experimental support for abort signals when executing requests and bulk loads #1798.

Which statements produce a result

SQL Server does not send a token for every statement. DECLARE sends nothing, IF … PRINT sends two DONE tokens, and with SET NOCOUNT ON statements inside sp_executesql send nothing at all. A result is therefore a result set, or a DONE/DONEINPROC token that reports a row count or an error. Other DONE tokens are skipped, and so is the DONEPROC that ends sp_executesql or a procedure. As a result:

  • A batch and the same statements executed via execSql (sp_executesql) return the same results.
  • With SET NOCOUNT ON, results() returns exactly the result sets.
  • A statement in a loop returns a result per iteration, just like the done events of the event API.

Behavior

  • A pulled response must be finished before the connection can run the next request, via finish() or await using. Disposal reads the rest of the response and raises errors the consumer has not seen yet.
  • max values are streamed piece by piece, straight from the incoming data without copying, and never buffered unless read with read() or readValues(). Values that are not read are skipped.
  • Stopping a loop early (with break) skips the remaining rows. The rest of the response is drained by finish(), so the execution can still be canceled until then.
  • Request errors are thrown when the response ends, after the results that came before them. They are also attached to the result of the statement that failed.
  • Operations only return a promise once they have to wait for more data, so reading rows that were already received does not cost a promise per step inside the driver.

Changes to the existing API

  • new Request(sql) accepts no callback; that makes it a pulled request.
  • execSql, execSqlBatch, callProcedure, execute and prepare accept an optional ExecutionOptions argument (see above).
  • The private rows field of Request and BulkLoad is renamed to collectedRows.
  • A failed prepare of a request without a callback only emits error if someone listens for it; otherwise the error rejects the promise that connection.prepare() returns.

Implementation

  • The token parser can stream max values (streamValues). With it enabled, it returns rows as arrays of their values, split into several tokens around each streamed value.
  • src/pull-response.ts reads the response's tokens on demand. Tokens that the consumer does not pull (errors, info messages, environment changes, DONE row counts) still go to the request's token handler. The handler keeps the connection's state (transactions, row counts, errors) in sync exactly as for event-based requests, and still fires the connection's infoMessage and errorMessage events.

Status

This is a draft to review the API as a whole. It is too large to merge as one PR, and would be split up for merging. Not covered yet: documentation beyond the doc comments, and bulk loads, which keep their existing API.

Testing

  • The unit tests pass (554 passing).
  • test/integration/pull-api-test.ts has 65 tests, run against SQL Server 2022 with TDS 7.1 (the tests that use max types are skipped there), 7.2 and 7.4. The tests for results() cover:
    • batches and sp_executesql returning the same results;
    • SET NOCOUNT ON;
    • messages of statements without a result of their own;
    • errors attached to the failing statement;
    • rows() next to results without a result set.
  • The full integration suite passes, except should not leave any dangling sockets after connection timeout, which fails the same way on master in my environment (the connection is refused instead of timing out).

🤖 Generated with Claude Code

https://claude.ai/code/session_01HGJsu1u44CHnt4nkkQ5ias

The token parser used to pull data from the message via an async iterator,
turning every token into promises and generator yields, and wrapping the
result in a `Readable` stream for backpressure.

Data is now pushed into the parser via `write(chunk)`, and tokens are pulled
out synchronously via `read()`, which returns `undefined` once more data is
needed. Tokens are still parsed lazily, one at a time, so effects of handling
a token (like the TDS version negotiated by `LOGINACK`) apply to all tokens
that follow it.

Short tokens are simply re-parsed from their start once enough data has
arrived. The parser tracks how many bytes are needed and buffers incoming
chunks until then, so a large token spanning many chunks is not re-parsed
(and re-copied) for every chunk.

Tokens that can grow arbitrarily large (`COLMETADATA`, `ROW`, `NBCROW` and
`RETURNVALUE`) are parsed incrementally by `TokenReader`s, which keep their
progress column by column across chunks. PLP values are read by a
`PLPReader`, which consumes whatever part of the value is available without
waiting for complete PLP chunks. Reading a large PLP chunk is now linear
instead of quadratic in its size.

`token-stream-parser` drives the push parser from the message and dispatches
tokens to the handler, pausing between tokens when requested.
When `parser.streamValues` is set, rows are no longer delivered as single
`ROW`/`NBCROW` tokens holding every value in full. Instead, each row is
delivered as a sequence of tokens:

  RowStartToken
  for each column, either:
    ColumnValueToken                                  (a value read as a whole)
    ValueStartToken, ValueChunkToken*, ValueEndToken  (a streamed PLP value)
  RowEndToken

Value chunks reference the incoming data without copying it, so values of
any size can be consumed without holding them in memory. As tokens are
parsed lazily, `streamValues` can be switched at any time (e.g. after
looking at a result set's `COLMETADATA`) and applies to the following rows.

`PLPReader` now exposes `readLength` and `readChunk`, which are shared by
the buffered and the streaming paths. Token readers can return a sequence
of tokens by setting `hasMore`. `TokenHandler` gains default handlers for
the new tokens, so a request level streaming API can be built on top.

Return values are still read in full.
A request created without a completion callback is consumed by pulling
its response, instead of via events:

  const request = new Request('SELECT id, content FROM documents');
  connection.execSql(request);

  for await (const row of request.rows()) {
    await pipeline(row.stream('content'), createWriteStream(`${row.get('id')}`));
  }

- `rows()` iterates the rows of a single result set, `results()` iterates
  multiple result sets. The loop ending means the request completed, and
  errors of the request are thrown by the loop. Stopping a loop early
  cancels the request, and waits for the cancellation to complete.
- Values are read in order. Values before a row's first `max` value are
  available synchronously via `get()`. `max` values are streamed via
  `stream()`, or read in full via `await read()`, which skip any values in
  between.
- `outputParameters()` reads the return status and output parameters,
  streaming `max` ones the same way. The server returns output parameters
  of `max` types after all others, so parameters are located by name.
  Output parameters nobody asked for are discarded before the next request.
- `finish()` reads the rest of a response and returns the row count,
  return status and (buffered) output parameters.

The response is read by a `ResponseReader`, which passes all tokens the
consumer does not pull to the request's token handler, so errors, row
counts and transaction state are tracked just like for event consumers.
Operations run synchronously while the data they need is buffered, and
only return a promise once they need to wait for more data.

In streamed mode, the parser now returns rows without (non-`null`) PLP
values as single `ROW`/`NBCROW` tokens, and rows with PLP values as a
`RowStartToken` holding the values before the first PLP value. Return
values with PLP values are streamed as well.

The private `rows` property of `Request` and `BulkLoad` (rows collected
for `rowCollectionOnRequestCompletion`) is renamed to `collectedRows`, as
it clashes with the new `rows()` method.
`Row.values()` only works for rows that were read completely, which is
not the case for rows with `max` values, as those are read on demand.
`readValues()` reads all remaining values of a row in full (including
`max` values) and returns all of the row's values.
…t using`

A request without a completion callback is now in progress until it is
finished, and the connection refuses further requests until then:

  {
    await using request = new Request('SELECT id FROM users');
    connection.execSql(request);

    for await (const row of request.rows()) {
      // ...
    }
  } // the request is finished here

`Request` implements `Symbol.asyncDispose`, which calls `finish()`.
Making a request while a pulled one is unfinished fails with an
`EINVALIDSTATE` error pointing to `finish()` and `await using`. This
replaces discarding a response's unread output parameters when the next
request is made.

`finish()` can be called any number of times and raises an error at most
once. It only raises the request's error if the response was not read via
`rows()`, `results()` or `outputParameters()`, which raise it themselves,
so disposing a request never raises an error a second time.
Executing a request without a completion callback now returns the
`Response` of that execution, which is what gets pulled and finished:

  const request = new Request('SELECT id, name FROM users');

  await using response = connection.execSql(request);
  for await (const row of response.rows()) {
    // ...
  }

A `Request` goes back to being a reusable description of a statement.
`rows()`, `results()`, `outputParameters()`, `finish()` and
`Symbol.asyncDispose` move from `Request` to `Response`, and every
execution gets its own response. For requests with a completion callback,
the executors return a response too, but it can not be pulled.

`connection.prepare()` now returns a promise of a `PreparedStatement`,
which is executed via `statement.execute(parameters)` and unprepared via
`statement.unprepare()` - or `await using`, which disposes of the
responses of its executions first:

  await using statement = await connection.prepare(request);
  for (const id of ids) {
    await using response = statement.execute({ id });
    // ...
  }

This fixes `unprepare()` of a pulled request being treated as an
unfinished pulled request, which blocked the connection for good.
`sp_prepare` and `sp_unprepare` responses are now always consumed
internally.

Also reset `Request.canceled` once an execution completed. A canceled
execution of a prepared request used to fail all later executions, and
its unpreparing, with `ECANCEL`.
`execSql`, `execSqlBatch`, `callProcedure`, `execute` and `prepare`
return a `Response` (or a `PreparedStatement`) only for requests without
a completion callback. For requests with a callback, they return nothing,
as before this branch.

To tell the two apart at the type level, the `Request` constructor is
typed with overloads: `new Request(sql)` creates a `PulledRequest`, and
`new Request(sql, callback)` a `CallbackRequest`. The executors are
overloaded on those, so `connection.execSql(new Request(sql))` is typed
as returning a `Response`, and `connection.execSql(new Request(sql, cb))`
as returning `void`. A value typed as plain `Request` gets
`Response | void`. (Making `Request` generic over its callback does not
work for this: TypeScript infers the type parameter of an inline
`new Request(...)` from the overload being tried, not from the callback.)

`prepare()` of a request with a callback no longer returns a promise,
which also removes the need to mark that promise's rejection as handled.
`connection.prepare()` attached a `returnValue` listener to the request to
pick up the prepared statement's handle from `sp_prepare`, and never
removed it. Besides adding another listener on every `prepare()`, the
listener treated the output parameters of every later execution as
unexpected output parameters of `sp_prepare`, and failed the execution
with "Unexpected output parameter ... from sp_prepare" - so prepared
statements with output parameters did not work at all.

The `RequestTokenHandler` now stores the handle while the request is
being prepared, so no listener is needed. The `returnValue` event for
the handle is still emitted, as before.
`stream()` used to return a `Readable` synchronously, which ended without
any data for a `null` value - indistinguishable from an empty value.
Whether a value is `null` is only known once the value is reached, which
for values after an unread one requires reading ahead, so `stream()` now
returns a promise: of `null` for a `null` value, or of a `Readable` of the
value's data otherwise.

  const content = await row.stream('content');
  if (content !== null) {
    await pipeline(content, destination);
  }

This lines up `stream()` with `read()`: both may read ahead (and skip
unread values) to reach a value, while `get()` returns an already read
value synchronously.

`stream()` of a value that is not of a `max` type now throws, pointing to
`get()`, instead of returning a stream that ends right away.
…lready

`finish()` (and so disposing a response via `await using`) did not raise
the request's error if the response had been pulled via `rows()`,
`results()` or `outputParameters()`, assuming the consumer had received
the error from there. A response whose `rows()` was never iterated thus
lost its error.

The response now remembers which errors were actually raised to the
consumer - by a loop, `read()`, `stream()`, `readValues()` or
`outputParameters()` - and `finish()` raises the request's error unless
it is one of them. The error of a cancellation the consumer caused by
stopping a loop early is not raised either.

This plays well with `await using`: a scope left normally raises the
error from disposal; a scope left via an exception while the request's
error was not delivered raises a `SuppressedError` carrying both, and an
error that the loop already raised is never raised twice.
`stream()` now resolves to an `AsyncIterable<Buffer>` of the value's
chunks (or `null`), instead of a `Readable`:

  const content = await row.stream('content');
  if (content !== null) {
    for await (const chunk of content) {
      // ...
    }
  }

The iterator pulls each chunk from the response on demand, resolving
right away when the chunk was received already, which replaces pumping
chunks into a `Readable` in the background - and with it, destroying the
stream and waiting for its pump when a value is skipped. Stopping the
iteration early just stops it: the rest of the value is skipped once the
next value or row is read, like any other unread value. An iteration of a
value that was skipped fails.

Async iterables work with `pipeline()` and `Readable.from()` directly,
and mirror streamed `max` parameters, which are given as async iterables
as well.
Breaking out of `rows()` or `results()` no longer cancels the request.
The remaining result sets are skipped so the request runs to completion,
output parameters stay readable, and any error of the request is raised
by `finish()`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HGJsu1u44CHnt4nkkQ5ias
…n finish

Executors, `prepare()` and `PreparedStatement.execute()` accept an
`ExecutionOptions` object with a `signal`. Aborting it cancels the
execution, which then fails with the signal's reason. Disposing an
aborted response does not raise that reason again, while `finish()`
still does.

Stopping a `rows()` or `results()` loop early no longer drains the rest
of the response right away. `finish()` reads it, so an abort in between
avoids the drain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HGJsu1u44CHnt4nkkQ5ias
Rows read via the pull API used to be arrays of `{ value, metadata }`
objects, like the rows of the `row` event, although the metadata is the
same for every row (it is the result set's `columns`).

In streaming mode, the parser now reads the values of a row into a plain
array: a row without streamed values becomes a single `RowValuesToken`,
and `RowStartToken` and `ColumnValueToken` carry values only. `Row` and
`OutputParameters` keep plain arrays of their values, and look up the
metadata of a value by its position. `Row#values()` returns the row's
array instead of copying it.

Parsing a streamed row (int, nvarchar, datetime2, decimal, varchar and
bit columns) takes ~5% less time, with ~15% fewer minor GCs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HGJsu1u44CHnt4nkkQ5ias
`Response#rowBatches(size?)` and `ResultSet#batches(size?)` iterate rows
in batches, as the "easy" way to consume a response: all values of the
rows are read in full (including values of `max` types, which are
buffered), so all rows of a batch stay valid.

A batch has `size` rows (only the last batch of a result set can have
fewer). Without a `size`, a batch has all rows that can be read from the
data received so far, but at least one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HGJsu1u44CHnt4nkkQ5ias
This reverts commit b81b743. Reading rows in batches made no measurable
difference in performance compared to `rows()`, so the extra API is not
worth keeping.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HGJsu1u44CHnt4nkkQ5ias
Master now contains the push parser (#1794) that this branch was built
on, and the fixes for output parameters of prepared statements (#1789)
and for executing a request again after it was canceled (#1790), which
this branch had earlier versions of. The branch's versions of the parser
files are kept (they extend the merged parser), with the `process.nextTick`
fix from #1794. `Request.callback` only resets `canceled` before calling
back, like on master: resetting it afterwards would also clear the
cancellation of an execution that was started from the callback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HGJsu1u44CHnt4nkkQ5ias
`max` types, and with them streamed values, only exist from TDS 7.2 on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HGJsu1u44CHnt4nkkQ5ias
@codecov

codecov Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.99752% with 113 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.60%. Comparing base (7c3b004) to head (6980b69).

Files with missing lines Patch % Lines
src/pull-response.ts 84.13% 52 Missing and 40 partials ⚠️
src/connection.ts 83.05% 5 Missing and 5 partials ⚠️
src/token/handler.ts 11.11% 8 Missing ⚠️
src/request.ts 84.61% 1 Missing and 1 partial ⚠️
src/value-parser.ts 96.77% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1797      +/-   ##
==========================================
+ Coverage   84.38%   84.60%   +0.21%     
==========================================
  Files          95       96       +1     
  Lines        4702     5462     +760     
  Branches      880     1053     +173     
==========================================
+ Hits         3968     4621     +653     
- Misses        473      534      +61     
- Partials      261      307      +46     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

`results()` now returns, in order, a `Result` for each result set, and for
each statement without a result set that reports a row count or an error
(e.g. an `INSERT`, `UPDATE` or `DELETE`). `DONE` tokens that report
nothing (e.g. of `DECLARE`, `PRINT` or `DROP` statements, or of all
statements with `SET NOCOUNT ON`) are skipped, so statements executed via
`sp_executesql` return the same results as a batch. A `Result` is an
async iterator of its rows - there are none if it has no result set.
`rows()` skips results without a result set.

Each result has the info messages and errors of its statement (and the
messages of statements before it without a result of their own). All
info messages are also collected in `Response#messages`. Errors are
still raised when the response ends.

`finish()` returns the row count, messages and errors of each result
instead of the output parameters, which are read via
`outputParameters()` - so `finish()` no longer buffers values of `max`
types that nobody asked for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HGJsu1u44CHnt4nkkQ5ias
…uard re-executions

- A read that waits for more data now fails as soon as the request
  completes with an error (e.g. the connection was closed or lost), instead
  of waiting forever for data that never arrives. `finish()` and
  `await using` no longer hang in that case either.
- Operations on a response (`next()`, `read()`, `stream()`, `readValues()`,
  `outputParameters()`, `finish()`) now run one after the other, in the
  order they were called. Calling `next()` again before the previous call
  settled returned rows out of order, or ended the iteration early. Draining
  a canceled response also waits for the consumer's pending read now.
  Operations on data that was received already stay synchronous.
- Executing a request without a callback while its previous execution (or
  its preparation) is still in progress now fails that execution with
  `EINVALIDSTATE`, without touching the one in progress. Previously, the
  earlier response never completed, and the connection could not be used
  anymore.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FFfrSh4ZKQbv3zDLhrTZTC
The server ends the response of a canceled batch with a `DONE` token that
has the error flag set, but without sending an error first. `results()`
returned an empty result for it before raising the cancellation. The error
flag now only makes a result if an error was read for the statement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FFfrSh4ZKQbv3zDLhrTZTC

This branch has not been deployed

No deployments
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.

2 participants