Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/native-mssql-tds.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@effect/sql-mssql": minor
---

Replace the tedious runtime dependency with an Effect-native TDS 7.4 driver,
including TLS, SQL, NTLMv2 and access-token FedAuth authentication, parameter and result codecs,
stored procedures, table-valued parameters, transaction-aware calls and safe
cancellation. Export native MssqlTypes descriptors and add requestTimeout.

Automatic Azure credential flows are not yet supported; applications can provide
an Effect-based access-token provider. Native parameter descriptors
replace tedious descriptors; see the package README for compatibility limits.
51 changes: 50 additions & 1 deletion packages/sql/mssql/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# @effect/sql-mssql

An Effect SQL client for Microsoft SQL Server, built on the [`tedious`](https://tediousjs.github.io/tedious/) library.
An Effect SQL client for Microsoft SQL Server with a native TDS 7.4 implementation.
Connections, pooling, transactions and cancellation are managed through Effect;
the runtime does not depend on `tedious`.

## Installation

Expand All @@ -12,3 +14,50 @@ npm install effect@rc @effect/sql-mssql@rc

- [Effect website](https://effect.website)
- [API reference](https://effect.website/docs/v4/api/sql-mssql)

## Native driver

The driver supports encrypted SQL authentication, NTLMv2, access-token FedAuth, named-instance discovery,
server-directed routing, parameterized queries, stored procedures with output
parameters, table-valued parameters, and nested transactions using savepoints.
Interrupted or timed-out requests send ATTENTION and drain its acknowledgement
before the connection can be reused. Failed connections are removed from the pool.

TLS is enabled by default and certificates are verified. `trustServer: true` is
intended for explicitly trusted self-signed servers, such as local test containers.
`encrypt: false` disables transport protection, including protection of credentials.
The request timeout defaults to 15 seconds; `requestTimeout: 0` disables it.

Use `MssqlTypes` from `@effect/sql-mssql` for procedure parameter descriptors and
`parameterTypes`. These are native descriptors, not the objects exported by tedious.
SQL `bigint` results remain strings; decimal and numeric results remain JavaScript
numbers, which can lose precision. Exact decimal input can be supplied as a string.
`time`, `datetime2`, and `datetimeoffset` results retain sub-millisecond precision
in a non-enumerable `nanosecondsDelta` property, compatible with tedious. Despite
the property's name, it is measured in seconds. Passing the Date back as a native
temporal parameter preserves the fraction at the requested scale. Lower scales
round values, including rollover at midnight.

For Azure SQL, provide `accessToken` as an Effect returning a redacted access
token for the SQL service. It runs for each new pooled connection, allowing the
application's credential provider to refresh expired tokens. Token acquisition
is bounded by `connectTimeout`, and requires TLS. The driver does not acquire
credentials from Azure CLI, managed identity, or environment variables itself.
`authType: "azure-active-directory-access-token"` is optional when `accessToken`
is provided. The token must target the Azure SQL service, not Azure management APIs.

Compatibility limits of this implementation:

- Security Token FedAuth has encrypted protocol tests, but has not been verified
against live Azure SQL. Automatic Azure credential flows and ADAL/FEDAUTHINFO
negotiation are not implemented; other Azure `authType` values fail explicitly.
- NTLMv2 has protocol/vector tests, but has not been verified against a live Windows
domain. Extended Protection/channel binding is not implemented.
- Streaming queries remain unsupported. Results are buffered, with a 16 MiB per-token
safety limit; a single larger row or value is rejected and closes the connection.
- Table-valued parameters use arrays of rows. Async row producers are not supported.
- UDT and SQL_VARIANT result values are decoded, but these types cannot be used
as input or output parameter descriptors. UDT results are returned as binary data.

See [the benchmark guide](./benchmark/README.md) for live Docker tests and comparisons
with tedious. Tedious is retained only as a development benchmark dependency.
87 changes: 87 additions & 0 deletions packages/sql/mssql/benchmark/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Native TDS verification and benchmarks

Run from the repository root after `pnpm install`. The benchmark-only tedious
dependency does not appear in the runtime dependency graph.

## Live SQL Server

Use a dedicated SQL Server 2022 Docker container. On ARM hosts the official image
runs under amd64 emulation. This example exposes SQL Server only on localhost and
accepts Microsoft's container EULA. The password below is for disposable local
tests, not deployments.

```sh
docker run --detach --name effect-native-mssql-test --platform linux/amd64 \
-e ACCEPT_EULA=Y -e 'MSSQL_SA_PASSWORD=Effect_Tds_Test_7426!' \
-p 127.0.0.1:14339:1433 mcr.microsoft.com/mssql/server:2022-latest
docker logs effect-native-mssql-test
```

After the server reports that it is ready for client connections:

```sh
EFFECT_INTEGRATION_TESTS=1 MSSQL_PORT=14339 pnpm test --run packages/sql/mssql/test
node packages/sql/mssql/benchmark/TdsClient.ts
node packages/sql/mssql/benchmark/TdsCodec.ts
FRAGMENT_BYTES=4096 node packages/sql/mssql/benchmark/TdsCodec.ts
```

`MSSQL_HOST`, `MSSQL_PORT`, and `MSSQL_PASSWORD` override the local connection.
Live benchmarks use SQL authentication as `sa` with TLS and explicitly trust the
disposable container's certificate. Tests create and remove their own objects;
do not point them at a production database.

## Method

`TdsClient.ts` compares a native session and tedious 20.0.0 in the same process.
Both use Effect callbacks, TLS, identical session settings and row-object
conversion. Results and session defaults must match before timing. Each workload
warms both drivers for 300 ms, then measures five alternating pairs of 750 ms.
`BENCH_ROUNDS` and `BENCH_DURATION_MS` control those values. Transactions count
one begin/insert/rollback cycle as an operation; the native driver uses SQL
batches for transaction control and tedious uses its transaction API.

`TdsCodec.ts` reuses the DONEPROC payload/workload from
`tedious/benchmarks/token-parser/done-token.js`, with identical chunks and a
callback per token. It verifies token counts and alternates seven pairs after
warmup. `TOKEN_COUNT`, `REPEATS`, `BENCH_ROUNDS`, and `FRAGMENT_BYTES` control it.
This is a narrow parser microbenchmark, not a complete codec performance claim.

## Local results, 2026-09-08

Node 24.20.0, SQL Server 2022 CU26 (16.0.4265.3), Linux amd64 container under
Docker on an ARM Mac. This was not an isolated performance host. Raw samples
are in [results.jsonl](./results.jsonl); differences are median paired throughput
changes, not the ratio of independent medians.

| Workload | Native operations/s | Tedious operations/s | Paired change |
| ------------------------- | ------------------: | -------------------: | ------------: |
| Parameterized SELECT | 2,202 | 1,963 | +12.2% |
| 100 rows × 3 columns | 1,114 | 968 | +19.6% |
| 100 rows × 20 columns | 1,065 | 966 | +10.2% |
| Large Unicode result | 1,107 | 1,031 | +7.2% |
| Large Unicode parameter | 1,085 | 982 | +10.4% |
| Begin / insert / rollback | 358 | 316 | +11.3% |
| DONEPROC tokens | 9,540,062 | 5,086,195 | +87.2% |

These samples were rerun after the TLS write-queue changes, with the live and
codec benchmarks run sequentially. The large parameter sends and returns 10,000
Unicode characters, exercising multi-packet requests. Unrelated host activity
was not controlled, and samples vary materially. Treat these
numbers as directional evidence and rerun longer, isolated trials before making
release claims. No latency percentiles, memory/GC measurements, remote-server
results, or concurrent pool load are established by this harness.

## Coverage and remaining validation

Tests cover packet/token fragmentation and bounds, NTLMv2 published vectors and
a simulated exchange, SSRP discovery, routing, retries, cancellation races and
timeouts. Live SQL tests cover TLS, SQL authentication, scalar/LOB/TVP codecs,
procedures, output parameters, errors, transactions and public adapter behavior.
Existing persistence/cache/queue integration tests also run against the container.

Security Token FedAuth also has TLS peer tests, including required acknowledgements,
echo flags and per-connection token acquisition. These do not establish live Azure
interoperability. Windows-domain NTLM, Extended Protection, live Azure SQL,
automatic Azure credential flows, and other SQL Server versions remain outside
verified coverage.
182 changes: 182 additions & 0 deletions packages/sql/mssql/benchmark/TdsClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import * as Native from "#tds/tdsConnection"
import { TYPES } from "#tds/tdsRequest"
import { Effect } from "effect"
import { strict as assert } from "node:assert"
import { performance } from "node:perf_hooks"
import * as Tedious from "tedious"

const config = {
server: process.env.MSSQL_HOST ?? "127.0.0.1",
port: Number(process.env.MSSQL_PORT ?? 14339),
username: "sa",
password: process.env.MSSQL_PASSWORD ?? "Effect_Tds_Test_7426!",
encrypt: true,
trustServer: true
}
const rounds = Number(process.env.BENCH_ROUNDS ?? 5)
const duration = Number(process.env.BENCH_DURATION_MS ?? 750)
if (!Number.isSafeInteger(rounds) || rounds < 1 || !Number.isFinite(duration) || duration <= 0) {
throw new Error("BENCH_ROUNDS must be a positive integer and BENCH_DURATION_MS must be positive")
}

const baseline = Effect.acquireRelease(
Effect.callback<Tedious.Connection, Error>((resume) => {
const conn = new Tedious.Connection({
server: config.server,
authentication: { type: "default", options: { userName: config.username, password: config.password } },
options: {
port: config.port,
encrypt: true,
trustServerCertificate: true,
rowCollectionOnRequestCompletion: true
}
})
conn.on("error", () => {})
conn.connect((error) => resume(error ? Effect.fail(error) : Effect.succeed(conn)))
return Effect.sync(() => conn.close())
}),
(conn) => Effect.sync(() => conn.close())
)

const tediousQuery = (conn: Tedious.Connection, query: string, parameter: boolean | string) =>
Effect.callback<ReadonlyArray<any>, Error>((resume) => {
const request = new Tedious.Request(query, (error, _count, rows) => {
if (error) {
resume(Effect.fail(error))
return
}
resume(Effect.succeed(rows.map((columns: Array<any>) => {
const row: Record<string, unknown> = {}
for (const column of columns) {
if (column.metadata.colName === "__proto__") {
Object.defineProperty(row, column.metadata.colName, {
value: column.value,
enumerable: true,
configurable: true,
writable: true
})
} else {
row[column.metadata.colName] = column.value
}
}
return row
})))
})
if (typeof parameter === "string") request.addParameter("value", Tedious.TYPES.NVarChar, parameter)
else if (parameter) request.addParameter("value", Tedious.TYPES.Float, 42)
conn.execSql(request)
return Effect.sync(() => {
conn.cancel()
})
})

const tediousControl = (conn: Tedious.Connection, method: "beginTransaction" | "rollbackTransaction") =>
Effect.callback<void, Error>((resume) => {
conn[method]((error) => resume(error ? Effect.fail(error) : Effect.void))
})

const measure = (query: Effect.Effect<unknown, unknown>, milliseconds: number) =>
Effect.gen(function*() {
const start = performance.now()
let count = 0
while (performance.now() - start < milliseconds) {
yield* query
count++
}
return count * 1000 / (performance.now() - start)
})

const median = (values: ReadonlyArray<number>) => {
const sorted = [...values].sort((a, b) => a - b)
return sorted[Math.floor(sorted.length / 2)]
}

const program = Effect.scoped(Effect.gen(function*() {
const native = yield* Native.make(config)
const tedious = yield* baseline
const sessionSettings = "SELECT @@OPTIONS AS flags, @@DATEFIRST AS firstDay, @@TEXTSIZE AS [textSize]"
assert.deepEqual(
(yield* native.query(sessionSettings)).rows,
yield* tediousQuery(tedious, sessionSettings, false),
"session defaults differ"
)
const version = yield* native.query("SELECT @@VERSION AS version")
console.log(JSON.stringify({ node: process.version, server: version.rows[0].version, rounds, duration, tls: true }))
const workloads = [
{ name: "parameterized-select", sql: "SELECT @value AS value", parameter: true },
{
name: "100-rows-3-columns",
sql:
"SELECT TOP (100) ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS n, N'λ hello' AS text, CAST(1.5 AS float) AS value FROM sys.all_objects",
parameter: false
},
{
name: "100-rows-20-columns",
sql: `SELECT TOP (100) ${Array.from({ length: 20 }, (_, i) => `${i} AS c${i}`).join(",")} FROM sys.all_objects`,
parameter: false
},
{ name: "large-unicode", sql: "SELECT REPLICATE(CAST(N'λ' AS nvarchar(max)), 10000) AS text", parameter: false },
{ name: "large-unicode-parameter", sql: "SELECT @value AS text", parameter: "λ".repeat(10000) },
{
name: "transaction-insert-rollback",
sql: "DECLARE @t TABLE(value float); INSERT INTO @t VALUES(@value)",
parameter: true
}
]
for (const workload of workloads) {
const nativeQuery = native.query(
workload.sql,
typeof workload.parameter === "string"
? [{ name: "value", type: TYPES.NVarChar, value: workload.parameter }]
: workload.parameter
? [{ name: "value", type: TYPES.Float, value: 42 }]
: []
)
.pipe(Effect.map((result) => result.rows))
const baselineQuery = tediousQuery(tedious, workload.sql, workload.parameter)
const a = workload.name === "transaction-insert-rollback"
? native.batch("BEGIN TRAN").pipe(Effect.andThen(nativeQuery), Effect.tap(() => native.batch("ROLLBACK TRAN")))
: nativeQuery
const b = workload.name === "transaction-insert-rollback"
? tediousControl(tedious, "beginTransaction").pipe(
Effect.andThen(baselineQuery),
Effect.tap(() => tediousControl(tedious, "rollbackTransaction"))
)
: baselineQuery
assert.deepEqual(yield* a, yield* b, `${workload.name}: native and tedious results differ`)
yield* measure(a, 300)
yield* measure(b, 300)
const nativeRates: Array<number> = []
const tediousRates: Array<number> = []
const deltas: Array<number> = []
for (let i = 0; i < rounds; i++) {
let n: number
let t: number
if (i % 2 === 0) {
n = yield* measure(a, duration)
t = yield* measure(b, duration)
} else {
t = yield* measure(b, duration)
n = yield* measure(a, duration)
}
nativeRates.push(n)
tediousRates.push(t)
deltas.push((n / t - 1) * 100)
}
console.log(
JSON.stringify({
workload: workload.name,
nativeQueriesPerSecond: median(nativeRates),
tediousQueriesPerSecond: median(tediousRates),
medianPairedDeltaPercent: median(deltas),
nativeRates,
tediousRates
})
)
}
}))

Effect.runPromise(program).catch((error) => {
console.error(error)
process.exitCode = 1
})
Loading