OriginChainDB docs
reference · tds

The SQL Server wire protocol

OriginChainDB implements TDS, the wire protocol used by Microsoft SQL Server. Point a SQL Server driver at an instance and it connects, authenticates, prepares statements and reads rows against the same store your HTTP API calls see. Whether a session may also write depends on its credential model: a shared-credential listener reads and writes; a per-user listener is read-only, and refuses every write. This page is the dialect reference: what the protocol carries, what the SQL translator accepts, and — the part people get wrong — what it refuses.

read this before you plan a migration

The wire is compatible. T-SQL is not the language. A batch that arrives over TDS is parsed with a SQL Server grammar and rewritten, statement by statement, into OriginChainDB's own SQL. That translation covers the query language. It does not cover the procedural one.

There are no T-SQL variables, no @@ functions, no GO, no #temp tables, and no T-SQL stored procedures. Procedures on OriginChainDB are a PL/pgSQL-shaped subset, which is a different language with different syntax and different error semantics.

So a stored procedure written for SQL Server has to be rewritten, not ported. If your application's logic lives in procedures, triggers or scripted batches, budget for that first — it is the largest single cost of moving, and it is not reduced by anything else on this page. The exact boundary is below.

preview · off by default · enabled per instance

The adapter is built into the shipping engine and is inert until it is configured. No instance gets a TDS listener by default, enabling one is not self-serve, and on production instances today /v1/capabilities reports available: false for it. Enabling requires an address, a tenant, a credential and TLS material, and it is a preview surface, not a generally available one. Everything described here also runs today over the SQL endpoint and the PostgreSQL wire, neither of which needs any of this. Talk to us if you want a listener on your instance.

Check what is bound, not what is configured

The available flag under mssql_tds_dialect means configuration is complete. It is a precondition for starting a listener, not an observation that one is listening — and, as the next section explains, this adapter deliberately refuses to bind in states where it could not be safe. So check the flag, then the socket, then a real login.

# 1. What does this instance say about the adapter?
curl -s "https://$OC_HOST/v1/capabilities" | jq '.families.mssql_tds_dialect'

# 2. "available" means CONFIGURED, not "a socket is listening".
#    Check the socket too.
nc -vz $OC_SQL_HOST 1433

# 3. Then prove it end to end with a real login, not a port scan.
#    (sqlcmd, or the JDBC / Go snippets further down this page.)

1433 is the port SQL Server clients expect and the one used throughout this page, but the host and port your instance is published on are shown in the console once a listener exists. Use those. The limitations your own instance reports beside available are derived from its configuration, and are authoritative for it.

TLS is mandatory, and it fails closed

There is no plaintext path to this listener — not a discouraged one, not a flag, none. That is worth stating plainly because it changes what a misconfiguration looks like: a broken certificate does not produce a degraded connection, it produces no port at all. If nothing answers on 1433, check the certificate before you check the firewall.

It is enforced at four places, each of which fails closed:

  • At startup. If the certificate and key are missing, empty or unloadable the listener does not bind, and the reason is logged. The check runs before the bind, so no port is ever presented as up.
  • At PRELOGIN. A client that offers no encryption, or asks for it to be off, is answered with the protocol byte meaning encryption is required by the server and the connection is closed cleanly, before the login packet is read. It is not downgraded and not accepted-then-ignored.
  • Against login-only encryption. Encrypting the login and then reverting to plaintext — which a real SQL Server will do — is rejected by design, not merely unimplemented. It protects the credential while streaming every query and every result row in clear, which for a database leaks exactly what the credential would have unlocked.
  • In the type system. The command loop takes an encrypted stream that only a completed handshake can produce, so "no session runs unencrypted" is a compile-time property of the adapter rather than a rule someone has to remember.

Set encrypt=true and leave trustServerCertificate at false so your client actually validates the chain. The handshake itself is the TDS-framed variant that commercial SQL Server drivers require during negotiation; the stream switches to ordinary TLS records from the login packet onward.

This is the opposite of OriginChainDB's MySQL-wire adapter, which fails open — it binds without a certificate and then requires nothing. The two adapters behave differently on purpose, and a habit learned on one is the wrong habit on the other.

Connecting with a real client

Only drivers that have actually been driven against the engine appear here, at the versions they were driven at.

Java — mssql-jdbc 13.4.0 and 12.10.1

// Measured against mssql-jdbc 13.4.0 and 12.10.1 - 34 of 34 checks passed.
String url = "jdbc:sqlserver://" + System.getenv("OC_SQL_HOST") + ":1433"
           + ";databaseName="          + System.getenv("OC_DATABASE")
           + ";encrypt=true"            // mandatory - there is no other way in
           + ";trustServerCertificate=false"
           + ";hostNameInCertificate="  + System.getenv("OC_SQL_HOST")
           + ";loginTimeout=30";

try (Connection conn = DriverManager.getConnection(
        url, System.getenv("OC_DB_USER"), System.getenv("OC_DB_PASSWORD"))) {

    try (PreparedStatement ps = conn.prepareStatement(
            "SELECT TOP 50 id, customer_ref, amount_cents"
          + "  FROM invoices WHERE status = ? ORDER BY amount_cents DESC")) {
        ps.setString(1, "open");
        try (ResultSet rs = ps.executeQuery()) {
            while (rs.next()) {
                System.out.println(rs.getInt("id") + " " + rs.getString("customer_ref"));
            }
        }
    }
}

Go — microsoft/go-mssqldb 1.11.0

// Measured against microsoft/go-mssqldb 1.11.0 - 21 of 21 checks passed.
package main

import (
	"database/sql"
	"fmt"
	"net/url"
	"os"

	_ "github.com/microsoft/go-mssqldb"
)

func main() {
	q := url.Values{}
	q.Set("database", os.Getenv("OC_DATABASE"))
	q.Set("encrypt", "true")                  // mandatory
	q.Set("TrustServerCertificate", "false")  // validate the chain

	dsn := url.URL{
		Scheme:   "sqlserver",
		User:     url.UserPassword(os.Getenv("OC_DB_USER"), os.Getenv("OC_DB_PASSWORD")),
		Host:     os.Getenv("OC_SQL_HOST") + ":1433",
		RawQuery: q.Encode(),
	}

	db, err := sql.Open("sqlserver", dsn.String())
	if err != nil {
		panic(err)
	}
	defer db.Close()

	rows, err := db.Query(
		"SELECT TOP 50 id, customer_ref FROM invoices WHERE status = @p1", "open")
	if err != nil {
		panic(err)
	}
	defer rows.Close()

	for rows.Next() {
		var id int
		var ref string
		if err := rows.Scan(&id, &ref); err != nil {
			panic(err)
		}
		fmt.Println(id, ref)
	}
}

Worked, page-sized versions of both, plus the prepared-statement path, are in the TDS examples.

Credential models

A listener runs in one of two models, and the choice is not yours to make freely — it follows from whether the tenant has any database authorization to enforce: a database RBAC grant or a row-level security policy. Either one is enough; a row-security policy is not the lighter case.

Model What the login carries Applies when
shared One listener-wide credential. Every session arrives as the same identity. The tenant has no database RBAC grants and no row-level security policies at all. A shared credential cannot enforce a per-user grant or evaluate a per-user row policy, so the engine will not let the two be combined.
per-user Each database user logs in with its own password, under the mandatory TLS, and its own grants apply. The tenant has any database RBAC grant, or any row-level security policy. This is the only model that can serve it, and the session is read-only.
a per-user listener is read-only

A per-user TDS session refuses every write. Not only the writes a grant disallows — the listener marks the session read-only the moment it admits an authenticated identity, before any grant is consulted, because there is no write-parity bridge on this adapter. INSERT, UPDATE, DELETE and DDL all come back refused, and no configuration turns this on.

Put that beside the table above and the consequence is the one to plan around: a tenant with a single RBAC grant or a single row-level security policy cannot write over TDS at all. That tenant must be on per-user login, and per-user login does not write. No combination of settings yields an authorizing, writing TDS session today.

So every write shown on this page and under TDS examples — including statements in the translated set, which translate correctly and are still refused on a per-user session — applies to a shared-credential listener only. If your tenant has any authorization to enforce, write through the HTTP API or the PostgreSQL wire, and keep the TDS listener for reads.

Put those two rows together and there is a real state that neither model can serve: a tenant that carries database RBAC or a row-level security policy but has no database user with a password verifier. Shared login is inadmissible because that authorization is present; per-user login has no account to admit, so the listener refuses to bind — by design, rather than binding something it could not enforce. This is not an exotic corner: it is the ordinary state of a tenant whose access has only ever been through API tokens. Creating one enabled database user with a password is the whole fix.

The T-SQL boundary, exactly

A batch is parsed with a SQL Server grammar, the syntax tree is rewritten into OriginChainDB's SQL, and each statement is re-rendered and executed through the same engine every other surface uses. The rule the translator follows is soundness over coverage: anything without an exact equivalent is refused with a clear error and nothing executes. It never guesses, and it never returns a plausible wrong answer.

That makes the boundary easy to test and unpleasant to discover late. Here it is.

Translated

T-SQL you write What it becomes
[bracket] identifiers A plain name unwraps; a name with spaces or punctuation, or a reserved word such as [order], becomes a double-quoted identifier. Every position a statement can put one in.
SELECT TOP n · TOP (n) LIMIT n
OFFSET n ROWS FETCH NEXT m ROWS ONLY LIMIT m OFFSET n. Always lowered — a FETCH left alone would be ignored and silently return every row.
ISNULL · LEN · CHARINDEX · IIF COALESCE, LENGTH, POSITION, CASE
GETDATE · SYSDATETIME · SYSUTCDATETIME One statement-time UTC value, folded so every call in a statement agrees.
CONVERT(type, expr) CAST(expr AS type). The UTF-16 character types (NVARCHAR, NCHAR, NTEXT) collapse onto the engine's string types and UNIQUEIDENTIFIER onto its UUID type, in casts and in DDL column definitions alike. Only mappings that change no value representation are made: BIT, MONEY and DATETIME2 are deliberately left alone, so the engine refuses them with a clear error rather than accept mis-typed data.
a + b (string concatenation) CONCAT(a, b), but only when both operands are provably string. Two numbers stay arithmetic; one string and one unknown is ambiguous in T-SQL itself and is refused.
SUBSTRING · REPLACE · LTRIM · RTRIM · UPPER · LOWER Pass through unchanged — they are engine natives.
-- Every line below is rewritten and executed. Send it as an ordinary
-- SQL_BATCH, or as the statement text of a parameterized RPC.

SELECT TOP 20 [order].[id], [customer name]
  FROM [order]
 WHERE ISNULL([status], 'open') = 'open'
 ORDER BY [amount cents] DESC;

SELECT id,
       LEN(customer_ref)                         AS ref_len,
       CHARINDEX('-', customer_ref)              AS dash_at,
       IIF(amount_cents > 100000, 'large', 'small') AS bucket,
       CONVERT(NVARCHAR(32), amount_cents)       AS amount_text
  FROM invoices
 ORDER BY id
OFFSET 40 ROWS FETCH NEXT 20 ROWS ONLY;

Refused, cleanly

Each of these comes back as an error token naming the construct and, where one exists, the portable rewrite. Nothing runs, nothing is half-applied, and the connection survives.

Construct Why, and what to write instead
@var · @@FUNCTION Batch-level variables and built-in globals are not modelled. Bind a parameter, or compute the value in your application.
#temp objects Per-session temporary objects are not modelled. Create an ordinary table.
GO A client-tool batch separator, not a server statement. Send one statement per batch. It is caught before parsing, because the grammar would otherwise read a trailing GO as a column alias.
MERGE Use INSERT ... ON CONFLICT.
SELECT ... INTO newtable The engine parses the INTO clause and drops it, so passing it through would report a successful table copy that created nothing. Use CREATE TABLE then INSERT ... SELECT.
DATEADD · DATEDIFF The substrate has no calendar timestamp type — timestamps are ISO-8601 strings — so there is no sound mapping. Do date arithmetic in your application.
CONVERT with a style code Style codes and USING charsets are formatting semantics that are not reproduced. Format in your application.
TOP ... PERCENT · WITH TIES No LIMIT equivalent. The same applies to FETCH ... PERCENT / WITH TIES.
TOP in UPDATE / DELETE Constrain the statement with a WHERE predicate.
Anything the SQL Server grammar cannot parse Reported with the parse failure rather than attempted. A final safety net also refuses any translated output still carrying an un-rewritten bracket identifier, rather than letting it reach the engine as a wrong name.
-- Each of these is REFUSED with an explicit error. Nothing executes,
-- nothing is half-applied, and the connection stays open.

DECLARE @cutoff INT = 100000;          -- @variables are not modelled
SELECT @@VERSION;                      -- @@functions are not modelled
SELECT * INTO #recent FROM invoices;   -- # temp objects are per-session
GO                                     -- a client-tool directive, not a statement

MERGE invoices AS t USING staged AS s  -- use INSERT ... ON CONFLICT instead
   ON t.id = s.id WHEN MATCHED THEN UPDATE SET t.status = s.status;

SELECT DATEADD(day, -7, GETDATE());    -- no calendar timestamp type
SELECT CONVERT(VARCHAR(10), created_at, 112);  -- style codes are formatting
SELECT TOP 10 PERCENT * FROM invoices; -- no LIMIT equivalent
DELETE TOP (100) FROM invoices;        -- constrain with WHERE instead
SELECT * INTO archive FROM invoices;   -- SELECT ... INTO would create nothing

Three approximations to know about

These translate, and for ordinary inputs they agree with SQL Server. They are listed because for unusual inputs they do not:

  • LEN does not reproduce T-SQL's trailing-space trim, so LEN('a ') counts the spaces.
  • GETDATE() yields an ISO-8601 UTC string, because there is no datetime type to yield instead.
  • CONCAT treats NULL as empty where T-SQL's + propagates it. Only reachable where both operands are provably string, which is the only case that translates at all.

Stored procedures: the distinction that costs money

TDS carries a remote-procedure-call path, and OriginChainDB serves the part of it every driver depends on: sp_executesql, sp_prepare, sp_execute, sp_prepexec and sp_unprepare. Seeing those names work is what makes people assume stored procedures work.

They are not your procedures. That family is the protocol's own machinery — it is how a driver ships a parameterized query and reuses its plan handle. Serving it is what makes a PreparedStatement work on its second execution and what makes ODBC work on its first. It says nothing about running procedures you wrote. Calling a user stored procedure over the wire is refused, and so is every other procedure, by numeric id or by name.

Also refused on this path, cleanly rather than mis-served: the server-side cursor family, OUTPUT value parameters (there is no variable-assignment surface that could produce one), bulk load, and multiple active result sets. Temporal parameter values decode best-effort in this preview — integer, float, bit, string, numeric and unique-identifier parameters are exact.

OriginChainDB does have procedures. They are written in a PL/pgSQL-shaped subset, created and called over the SQL endpoint or the PostgreSQL wire, and they are a genuinely different language. Here is what a small one looks like on both sides — note that nothing about the T-SQL version survives except its intent:

-- SQL Server (T-SQL). This does NOT run on OriginChainDB.
CREATE PROCEDURE dbo.settle_invoice @id INT, @settled INT OUTPUT
AS
BEGIN
    DECLARE @amount INT;
    SELECT @amount = amount_cents FROM invoices WHERE id = @id;
    IF @amount IS NULL
        THROW 50001, 'no such invoice', 1;
    UPDATE invoices SET status = 'settled' WHERE id = @id;
    SET @settled = @@ROWCOUNT;
END;

-- OriginChainDB. A different procedural language, so this is a rewrite -
-- not a port. Issue it over the SQL endpoint or the PostgreSQL wire.
CREATE PROCEDURE settle_invoice(IN p_id INT, INOUT p_settled INT)
LANGUAGE plpgsql AS $$
DECLARE
    v_amount INT;
BEGIN
    SELECT amount_cents INTO v_amount FROM invoices WHERE id = p_id;
    IF v_amount IS NULL THEN
        RAISE EXCEPTION 'no such invoice';
    END IF;
    UPDATE invoices SET status = 'settled' WHERE id = p_id;
    GET DIAGNOSTICS p_settled = ROW_COUNT;
END;
$$;

For anything larger, estimate the rewrite the way you would estimate a rewrite: by counting the procedures and reading them, not by assuming a compatibility layer will absorb them.

How far this has been proven

Interop is measured by driving Microsoft's own shipped drivers against the real accept loop — not a client written against our own encoder, which is how earlier claims about this adapter turned out to be wrong while every in-house test was green.

Driver Version Result
mssql-jdbc 13.4.0 34 / 34
mssql-jdbc 12.10.1 34 / 34
microsoft/go-mssqldb 1.11.0 21 / 21

Thirteen consecutive rounds, all green, measured on 2026-09-07. What those rounds cover: connecting and logging in over the framed TLS handshake; DDL; insert; select with rows decoded and compared; the whole prepare / execute / unprepare family asserted through the driver's own prepared-statement handle rather than through our encoder; batch execution; and a wrong credential being refused. Both drivers are required — each one exercises framing, flush and row-count behaviour the other tolerates, and a single-driver run has already been shown to miss half of a defect set.

verified, not continuously verified

That battery runs in no automated job. It was executed deliberately and it passed, so the accurate word is verified — as of that date, against those driver versions, against that engine build. A regression introduced by a later build would not be caught by a scheduled run, because there is not one. Treat the figures above as a measurement with a date on it, and re-run the battery against the build you intend to deploy rather than assuming they still hold.

Limits, in one place

  • The T-SQL procedural language is not implemented. No variables, no built-in globals, no control-flow batches, no GO, no temp tables, no T-SQL stored procedures, triggers or functions. Procedures are a PL/pgSQL-shaped subset and are a rewrite.
  • This is a preview surface. It is not generally available, it is off on every instance unless someone turned it on, and it is not self-serve.
  • TLS is required absolutely. No certificate means no listener, and a client that will not encrypt is refused before it can log in.
  • Server-side cursors, bulk load and multiple active result sets are refused — cleanly, but refused. So is calling a user stored procedure, and so are OUTPUT value parameters.
  • Date and time arithmetic does not translate. There is no calendar timestamp type behind this surface.
  • Interop is verified, not continuously verified, against the three driver versions named above and no others. Other TDS clients may work; they have not been measured, so we do not claim them.

If any of that is disqualifying, the same data is reachable today over the SQL endpoint and the PostgreSQL wire, both of which are ordinary supported surfaces with none of the caveats on this page.

Every third-party name on this page is used to describe protocol or dialect compatibility, and for no other purpose. Microsoft, SQL Server and T-SQL are trademarks of the Microsoft group of companies. PostgreSQL is a registered trademark of the PostgreSQL Community Association of Canada. MySQL is a registered trademark of Oracle Corporation and/or its affiliates. mssql-jdbc and go-mssqldb are Microsoft's own drivers and are named only to identify the versions this compatibility was measured against. OriginChainDB is not affiliated with, endorsed by, or sponsored by any of them, and does not distribute their software. OriginChainDB is not SQL Server: it is an independent database that implements the TDS wire protocol so that existing SQL Server clients can connect to it, and that accepts part of the T-SQL query language.