feat: compile one writer per parameter or column, and read a row's max cells from a source - #1780
feat: compile one writer per parameter or column, and read a row's max cells from a source#1780arthurschreiber wants to merge 48 commits into
Conversation
…contract Splits parameter handling into two phases. `resolveParameter` validates the value and determines the declaration facts (length, precision, scale, collation) once, before a request is sent; `writeTypeInfo` and `writeValue` serialize the resolved parameter into a `WritableTrackingBuffer`. Types can implement `resolve`, `writeTypeInfo` and `writeValue` natively; the helpers adapt everything else from the existing `validate` / `resolve*` / `generate*` methods, so types can be migrated one at a time. Int, NVarChar and VarBinary are migrated. `Request.validateParameters` now resolves the request's parameters and keeps the result; the RPC payload takes resolved parameters and only serializes. `Connection.execSql`, `callProcedure`, `prepare`, `unprepare`, `execute` and the Always Encrypted metadata request build their payloads from resolved parameters. Bulk load writes column metadata and row values through the same helpers. Two behaviour changes come with the shared resolution: - Lengths are resolved for every type that can resolve one, not only for type ids matching the legacy variable-length bit pattern (the fix proposed in #1771). - Errors thrown while writing a parameter's TYPE_INFO are wrapped in the same `InputError` as errors from writing its value (the RPC half of #1772). A new unit test serializes 40 parameter cases across every type, on TDS 7.4 and 7.2, with and without a collation, through the new payload and through an inline copy of the previous serialization, and asserts the bytes are identical. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
Constraint names on temporary tables are unique per database, so the
named constraint collided when two CI jobs ran this test against the
same Azure database at the same time ("There is already an object named
'chk_id' in the database").
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
- `BulkLoad.addColumn` no longer gates length resolution on the legacy variable-length type id bit pattern, so the RPC and bulk load paths agree and #1771 is covered in full. - `resolveParameter` treats an explicitly specified length, precision or scale of 0 as specified instead of falling through to the type's resolver. Every existing resolver re-checked for an explicit value itself, so this changes no bytes for existing types; it removes the trap for a future type whose resolver does not. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
`resolveParameter` calls `type.validate(value, collation)` without the connection options, as every caller did before. Passing the options would activate the `useUTC`-dependent range checks in the date and time types, which have never run; that is a behaviour change to make on its own. A unit test pins the call shape. `Connection.resolveParameter` is renamed `resolveRequestParameter` so it is not misread as recursion into the free function it wraps. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
…quest The Always Encrypted metadata request is built from raw parameters rather than a `Request` that went through `validateParameters`, so the parameters are resolved inline. A comment says so. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
`RpcRequestPayload` wrote a small buffer per parameter and yielded each in turn. It now writes the whole request — header, and every parameter's header, TYPE_INFO and value — into a single `WritableTrackingBuffer` and yields its chunks once. The bytes are unchanged, and a large value written by reference is still referenced rather than copied (the tracking buffer references buffers of 8 KB or more), so this adds no copy; the request just reaches the packetizer as a few large chunks instead of a small buffer per parameter. Serializing a 20-scalar-parameter request is about 1.7x faster (benchmarks/parameters/scalar-params.js); large binary values, dominated by the value itself, are unchanged. The existing byte-equivalence and by-reference tests cover it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
A `varbinary(max)`, `varchar(max)` or `nvarchar(max)` parameter can now be given an async iterable of chunks instead of a whole buffer or string, and a table-valued parameter an async iterable of rows instead of a rows array. The value is read while the request is written, so a value larger than memory can be sent, and a large in-memory TVP is no longer copied in full before it is sent. The write contract gains `writeValueStream`: a type whose value is streamed (`ParameterData.streamed`) yields its length prefix and data as buffers, in chunks of its own choosing, reading the source as it goes. `resolve` detects an async source and marks the parameter streamed and `max`. `RpcRequestPayload` writes the request into one buffer as before; when a parameter is streamed it flushes what it has, delegates the value to `writeValueStream`, then continues. It exposes an async iterator only when a value is actually streamed, so a request of in-memory values keeps the fully synchronous serialization path unchanged. `max` values stream as PLP; TVP rows are validated and written as they arrive, flushing at packet-sized boundaries so memory stays bounded regardless of row count. Serializing a 1,000,000-row TVP given as an array is about 2x faster and uses far less peak memory (it is no longer buffered whole); the same TVP from an async iterable serializes at a similar rate with bounded memory. A failing source aborts the request with the `InputError` that names the parameter. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
An async `varchar(max)` source may yield the two halves of a UTF-16 surrogate pair in separate chunks. Under a UTF-8 collation, encoding each chunk on its own turned each half into a replacement character, so a streamed value could differ from the equivalent in-memory value. The source is now re-chunked so a surrogate pair is never split across an encode boundary: a trailing lone high surrogate is carried into the next chunk. The result is byte-identical to encoding the whole string at once, for every codepage. nvarchar is unaffected — UCS-2 encodes each code unit independently. Reported by Codex review on #1777. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
Adds the unit tests suggested in review: - `declaration()` returns the `max` form for an async-iterable value, for the three `max` types. This is what `execSql` hands to `sp_executesql` as the parameter's type, and was previously only exercised by the `callProcedure` path. - A TVP whose async row source fails validation partway through surfaces the `InputError` naming the parameter, mirroring the `varbinary` case. Also notes, in `varbinary.resolve` and `TVP.resolve`, that a streamed value is always sent as `max` (overriding an explicit length) and that a TVP is always streamed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
The streamed max types and TVP flush their buffer once it reaches CHUNK_SIZE (8 KB), which is the memory-bounded core of the change. The existing tests only used small values, so the flush/consume bookkeeping was not exercised in CI. Adds: - a varbinary and an nvarchar source of 25/30 KB in several chunks, whose reassembled PLP data must equal the concatenated input; - a ~2000-row TVP (well past the flush size) serialized from an array and from an async source, asserted byte-for-byte equal. Also documents why `stitchSurrogates` only holds back a trailing high surrogate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
Adds a CP932 (non-UTF-8, double-byte) case to the surrogate-splitting test, so the "byte-identical to encoding the whole string, for every codepage" guarantee is self-verified in CI rather than resting on manual checks. Also documents why `RpcRequestPayload`'s `[Symbol.asyncIterator]` field is a conditionally-assigned own property rather than a `declare`d field. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
A TVP row's metadata covers every declared column, so a row with fewer (or more) values than columns desyncs the server's parse of the following rows. The row loop only iterated over the values present, so a short row was serialized against the wrong column metadata. `validateRow` now rejects a row whose length does not match the column count, on both the array and async paths, with an `InputError` naming the row. Valid rows are unaffected and serialize byte-for-byte as before. Reported by Codex review on #1777. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
Addresses review feedback on the streaming path: - `writeValueStream`'s doc notes that a large buffer (CHUNK_SIZE or more) is referenced rather than copied, so a source must not reuse or mutate a buffer it has yielded until the request is sent. - `RpcRequestPayload.generateData` (the synchronous iterator) now throws if the payload contains a streamed parameter, turning a misuse (iterating a streamed payload synchronously instead of via `Readable.from`) into a loud error rather than corrupted bytes. Not reachable through the current call sites, which all use `Readable.from`. - Adds a test for a single streamed chunk at or above CHUNK_SIZE, exercising the zero-copy `writeBuffer` branch directly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
Round-trip a streamed varbinary(max), nvarchar(max) and varchar(max) value and a TVP fed from an async iterable against a real server, and check that a source or row that fails mid-stream surfaces as the parameter's InputError and leaves the connection usable for the next request. This was previously only verified by hand. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
…d contract A streamed varchar(max) had its collation checked only inside writeValueStream, once the request was already being written, while an in-memory value fails the same check in validate before the request starts. Check it in resolve so both fail the same way. The payload also now rejects, when it is constructed, a parameter that resolved as streamed from a type that does not implement writeValueStream, instead of failing with an unattributed TypeError mid-request. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
max types, and PLP with them, do not exist before TDS 7.2, and the server rejects the MAX length in the TYPE_INFO; the in-memory large value tests already skip there. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
Takes the bulk load serializer from #1779. The transform this branch had routed cells through writeValue is gone; the payload's loop routes them through writeValue instead, and COLMETADATA keeps going through writeTypeInfo. The wide-row unit test spies on writeValue now that VarBinary implements it natively. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
…ters Brings in the merge of master (#1779's bulk load serializer) from the parameter-contract branch. No conflicts: this branch does not touch the bulk load. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
Takes #1771, which removed the legacy-id gate on length resolution that this branch had already removed in resolveParameter and BulkLoad.addColumn; only its comment is new. Its three unit tests drove the old payload constructor and are covered by parameter-contract-test.ts and bulk-load-test.ts here. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
…ters Brings in the merge of master (#1771) from the parameter-contract branch. No conflicts. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
The fireTriggers integration test randomizes its table name to survive
parallel runs against one database, but created its trigger under a
fixed name. Trigger names are scoped to the schema, so two Azure CI
jobs running the test at the same time collided on it ("There is
already an object named 'bulkLoadTest' in the database"). The trigger
is now named after the table, like the CHECK constraint fix before it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
…ters Takes the trigger-name fix for the fireTriggers integration test. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
execute() resolves its parameters from the values handed to it, so a streamed value reaches a prepared statement the way it reaches execSql; nothing exercised that path. A TVP fed from an async iterable that yields no rows serializes exactly as one from an empty array. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
A streamed varchar(max) source re-chunked its strings so that a UTF-16 surrogate pair split across two chunks was encoded whole. Node.js core does not do this anywhere on the write side: `Writable.write`, `fs.createWriteStream` and `crypto.Hash.update` each encode a string chunk independently, and a split pair becomes two replacement characters. The producer of string chunks is responsible for not splitting a pair, and Node's own UTF-8 decoding never does. The type now encodes each chunk on its own, like nvarchar already did, and `Request.addParameter` documents the async-iterable value form and the chunking rule that comes with it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
The Azure CI jobs share one database and run this suite at the same
time. With a fixed name, one job's teardown dropped the procedure while
another job was calling it ("Could not find stored procedure
'__tediousStreamedTvpTest'"). The type and procedure are now named with
a random suffix per test, like the bulk load tests' tables.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
The payload kept a synchronous iterator for requests without a streamed value and installed an async one only when a value was streamed, with a flag, two generators and a guard to keep the two apart. Every consumer goes through `Readable.from`, which drives a synchronous iterator through its own asynchronous read loop anyway, so the fast path was not faster: 20 scalar parameters serialize at the same ~50k req/s either way. The payload is now an async iterable only, the same shape as BulkLoadPayload, with one generator that writes each parameter into one buffer and yields the buffer's contents whenever it holds a chunk's worth (CHUNK_SIZE), so the first packet can leave before the last parameter is written. A streamed value is one more case in that loop; the check that its type implements writeValueStream moves there and rejects on iteration instead of throwing from the constructor. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
Only tedious's own types resolve a value as streamed, and they all implement writeValueStream. A type that did not would still fail, as a TypeError wrapped in the parameter's InputError like any other serialization error, so the guard only reworded a failure that cannot happen. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
The test only compared bytes; the by-reference claim is now checked by identity on the yielded buffers. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
…the live path generateParameterLength, generateParameterData, generateTypeInfo and validate are no longer reached when a TVP is serialized, since resolve always marks it streamed and writeTypeInfo/writeValueStream are native, but the DataType interface still requires them. They carried their own copy of the column metadata, row and type-info serialization, and validate repeated resolve's table check. They now delegate to the helpers the live path uses, so the bytes cannot drift, and resolve and validate share one table check. The legacy row path also gets the row-length check the live path has. Deleting the methods waits for the interface to drop the legacy trio, once every type has migrated. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
The streamed branch consumed the type's writeValueStream with a `for await` inside the `try` whose catch relabels errors as the parameter's, so the yields to the consumer sat inside it too: an error a consumer threw into the suspended generator would have come back as "Input parameter 'x' could not be validated". The type's generator is now driven by hand, with only its next() wrapped, the yields outside, and a finally that closes it, and with it the value's source, when the consumer stops early, as `for await` would. Tests cover both the early stop and a consumer-thrown error keeping its identity. Throughput is unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
An array iterator has no throw method, so an error a consumer throws into the generator while it delegates to one with yield* surfaces as "TypeError: The iterator does not provide a 'throw' method" on Node 24 and later, which follow the spec here; Node 22 still forwarded the consumer's error, which is why the test added with the previous commit passed locally and failed in CI. The chunks are now yielded from a loop, as BulkLoadPayload does, so a thrown error resumes the generator at a plain yield and keeps its identity on every Node version. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
The other streamed types return a generator without doing any work first; VarChar read the codepage before returning one, the only synchronous step a streamed write took outside the payload's wrapped next(). It is an async generator now, so nothing runs before the first next() for any type. A test covers a TVP whose rows are neither an array nor an async iterable. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
Every TVP row allocated a validated copy of itself, and every cell a fresh ParameterData object. Validation and writing now happen cell by cell in one pass, and the cell objects are created once per TVP and reused for every row, with only the value reassigned: a type writes a cell's bytes as soon as it is handed the cell and keeps no reference to it. A cell that fails validation leaves the row half written in the buffer, which does not matter, since the request is abandoned with the error and that buffer never reaches the wire. The row-length check still runs before any byte is written. Rows serialize 10-15% faster, from an array and from an async source alike. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
… fails The payload closed the type's generator in a finally, so a source whose cleanup rejected when it was closed early replaced the error that was propagating, e.g. the one the consumer had thrown into the generator. `for await` keeps the original error in that case, and BulkLoadPayload already does. The close now happens in the catch with its own failure swallowed, and the finally only covers the consumer returning early, where nothing else is propagating. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
Review: unify
|
A value read from a source needed a second method, writeValueStream, and a streamed flag on the resolved parameter to pick it, and the loop that drives such a write, with its close-on-early-stop and error identity rules, lived in RpcRequestPayload alone. writeValue now covers both: a value fully in memory is written before it returns, a value read from a source returns the rest of the write as an async iterable that yields whenever the buffer holds a chunk's worth. writeValueStream and ParameterData.streamed are gone; resolve still declares an async source as a max type. The driving loop moves into writeRest, shared by everything that writes a value. VarChar gets its in-memory writeValue, ported from its generate* methods, to host its streamed branch. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
218572b to
1db0668
Compare
writeValueStream no longer exists. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
…source DataType gains compileWriter(column, options): the writer for the cells of a column whose declaration facts are known before any of its values, validating and writing one value per call with the facts resolved once rather than per cell, and returning the rest of the write for a cell read from a source, as writeValue does. Types without one are adapted from validate and writeValue. Int, NVarChar, VarBinary and VarChar implement it. TVP rows and bulk load rows are written through compiled writers, and a cell of a max column in either may now be an async iterable of chunks, read while the row is written, under the rules that already apply to such a parameter value. Rows serialize 15-40% faster on this run: an int, nvarchar, bit TVP from ~1.9M to ~2.35M rows/s, the same with all-native columns from ~3.4M to ~4.7M, bulk load rows from ~1.3M to ~1.5M narrow and ~0.5M to ~0.7M wide. Scalar requests are unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
1db0668 to
a0d7a31
Compare
ReviewI read through the full diff plus the surrounding (unchanged) code in OverallThis is a clean, well-scoped perf change. The The TVP row-continuation logic ( Minor points
Test coverageGood — the unit suite checks byte parity and error parity per type (including the adapter path for SecurityNo concerns — this is purely wire-serialization logic; no new string interpolation into SQL, no new deserialization of untrusted input. PerformanceThe measurements in the description are consistent with what the code does: one |
ReviewI read through the full diff ( Correctness spot-checks I ran mentally
Minor, non-blocking nits
Test coverageThe new SecurityNo new attack surface: values still go through the same Nice work — this is a solid, low-risk perf change given how carefully the byte-parity and streaming-edge-case tests pin down behavior. |
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
Review: compile a writer per column, and read a row's max cells from a sourceNice piece of work — the Code quality / best practices
Potential bugs
Performance
Security
Test coverage
Overall this looks solid and well-tested; I'd be comfortable with this merging as-is modulo the stray comment cleanup. |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #1780 +/- ##
==========================================
+ Coverage 82.49% 82.88% +0.39%
==========================================
Files 95 95
Lines 4862 4915 +53
Branches 913 911 -2
==========================================
+ Hits 4011 4074 +63
+ Misses 575 570 -5
+ Partials 276 271 -5 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
The bulk load's per-cell InputError from #1772 now wraps the compiled writer call and the rest of a cell read from a source. A cell that fails validation is wrapped too, since a compiled writer validates and writes in one call, which matches how a parameter's InputError already covers both. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
… InputError A compiled writer validates and writes a cell in one step, so the validation error reaches the bulk load's callback as the cause of the column's InputError, as a parameter's does, rather than raw. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
ReviewI read through the diff ( Code quality / design
Potential bugs
PerformanceThe benchmarks in the PR description are convincing and the approach (resolve declaration facts once per column instead of per cell, monomorphic call site per column) is the right lever for this workload. No concerns. SecurityNo new attack surface — this is internal wire-serialization code operating on already-validated/typed values; no injection or memory-safety concerns spotted. Test coverageThis is the strongest part of the PR. Nice work — nothing blocking, just the two small nits above (DRY on the TVP error construction, and the stray comment) if you want to tidy before merge. |
ReviewNice piece of work: the compileWriter/writeRest split is a clean way to get per-column monomorphism without special-casing streamed cells at every call site, and the new test/unit/compiled-writers-test.ts (byte-parity and error-parity vs validate + writeValue, plus streaming round-trips with uneven/boundary-crossing chunks and source-cleanup-on-failure) gives solid confidence that "bytes are unchanged for every existing input" actually holds. I traced through writeRow/finishRow in tvp.ts and the loop in bulk-load-payload.ts, and the resumable-cell bookkeeping (index + rest, drained before moving on) looks correct, including the rowIndex/from handling across chunk-yield boundaries. A few things worth a look: 1. Bulk load validation errors now surface as InputError, not the raw error (behavior change) This brings bulk load in line with how TVP/parameter validation errors are already wrapped, which looks like a genuine improvement, but it is a visible error-type change for any consumer catching a specific error class out of a bulk load. Given the PR own follow-up note ("#1772 wraps the bulk load per-cell write in an InputError naming the column; once it lands, that wrap belongs around the compiled writer call...") it would be worth double-checking whether this wrapping is meant to land here now or is expected to arrive via #1772, and either way calling it out explicitly in the changelog/release notes as a behavior change, not just a refactor. 2. Duplicated validation/serialization logic (maintainability) 3. Minor nits in tvp.ts
Other areas checked, no issues found:
Overall: solid refactor with good test coverage; the main thing worth confirming before merge is whether the InputError-wrapping behavior change for bulk load validation errors (point 1) is intended to ship in this PR. |
…ters # Conflicts: # src/bulk-load-payload.ts # src/data-type.ts # src/data-types/tvp.ts
ReviewI read through the full diff ( Code quality / best practices
Potential bugs
Performance
Security
Test coverage
No blocking issues found from this read-through; the DRY nit above is optional polish. |
…alue `compileWriter` is now a type's single write path: the writer it returns validates one value through `validate` and writes it, with the declaration facts captured once. `writeValue` leaves the interface, and with it the second copy of every type's write logic and the validation that the first compiled writers had inlined. The RPC payload writes each parameter through a writer compiled for it; TVP and bulk load rows compile one per column, as before. The generic adapter in data-type.ts goes, since every type compiles its own. The writers of VarChar, Char and Text take an already encoded buffer as well, for a parameter whose value `resolve` encoded before the request is written. The per-type unit tests exercise the compiled writer. The NChar, NVarChar and VarBinary cases that fed values `validate` rejects use valid ones now. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
|
Review This is the last of a multi-PR series (#1777, #1781, this one) that replaces Bug: leftover duplicated/misindented comment in
return (buffer, raw) => {
// A buffer is the value as `validate` encoded it, for a parameter
// resolved before the request is written.
// A buffer is the value as `validate` encoded it, for a parameter
// resolved before the request is written.
const value = Buffer.isBuffer(raw) ? raw : VarChar.validate(raw, collation);
if (value == null) {The comment is duplicated verbatim, and the indentation of Design notes (not blocking) Deliberate double-validation on the RPC scalar path: TVP and bulk-load cells skip
Test coverage Coverage looks thorough: Minor
Overall: solid refactor; the one concrete issue is the duplicated comment/indentation in |
Builds on #1777 and #1781. Targets
master.Problem
After #1781 every type writes a value through
writeValue, and TVP and bulk load rows write cell by cell throughvalidatefollowed bywriteValue, with the column's declaration facts re-read per cell and every cell going through call sites that see a different type at each column. That is where the time goes now. And amaxvalue can be read from a source as a parameter (#1777), but not as a cell of a TVP row or a bulk load row.Change
compileWriter(facts, options)is a type's single write path. It returns(buffer, value) => void | AsyncIterable<void>: the writer validates one value through the type'svalidateand writes it (length prefix and data), with the fact-dependent choices (maxor not, the collation, the scale,useUTC) made once when the writer is built.writeValueleaves the interface, and with it the second copy of every type's write logic. Nothing is inlined thatvalidatealready does. Callers:RpcRequestPayloadwrites each parameter throughtype.compileWriter(data, options)(buffer, data.value).resolvevalidated the value already; the writer validates it again, which every type'svalidatetolerates (VarChar,CharandTexttake the buffervalidateencoded as well as a string).tvp.tscompiles one writer per column per TVP and writes each row in a synchronous loop that returns as soon as a cell hands back a rest;finishRowdrives that rest throughwriteRestand writes the cells after it, so a row without such a cell costs one call.BulkLoadPayloaddoes the same per column.compileWriteradapter indata-type.tsgoes: every type compiles its own.Streamed cells. A cell of a
varbinary(max),nvarchar(max)orvarchar(max)column in a TVP row or a bulk load row may now be an async iterable of chunks, read while the row is written, under the rulesRequest.addParameteralready documents for such a parameter value. A column that is notmaxrejects a source the way it rejects any other non-value.Behaviour
maxcell in a TVP row or a bulk load row may be an async iterable. Documented onaddParameterand in the bulk load row-source section.VarChar,CharandTextaccept an already encoded buffer for a cell, the wayvalidateproduces one.NChar,NVarCharandVarBinaryno longer have unreachable branches for valuesvalidaterejects, and their unit tests feed valid values.Validation
test/unit/compiled-writers-test.ts: error parity withvalidate, a source rejected for a non-maxcolumn, the rest of a write returned for amaxcolumn, a TVP row and a bulk load row with streamed cells serializing exactly as with the cells in memory, chunks handed on while a cell is still being read, a failing cell source surfacing as the column'sInputErrorfor a TVP and failing the bulk load while closing its row source.test/integration/streaming-parameters-test.tsandtest/integration/bulk-load-test.ts: a 100 KBvarbinary(max)cell and a 70 KBnvarchar(max)cell, streamed in uneven chunks including empty ones across packet and flush boundaries, round-trip unchanged through a TVP procedure and through a bulk load, next to in-memory and null cells.Measurements
Serialization only, consumed through
Readable.fromasmakeRequestdoes, same machine, interleaved runs, Node 22, median per run with the range across runs.masteris current master (with #1781).mastervarbinary(max)parameterThe gain is per cell: no separate
validateand write calls through the type object, no cell object, and one monomorphic call per column instead of call sites that see every type. The two-type wide bulk load row was already served well by a polymorphic call site and sits within noise of master, slightly below in these runs. Scalar requests pay for a closure per parameter and a second validation, about a microsecond per 100 parameters, which the round trip dwarfs; the shared-closure variant for fact-free types recovers half of that and costs the same again on rows, so the writers stay per column.Not in this PR
🤖 Generated with Claude Code
https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug