OriginChainDB docs
reference · wire protocols

MySQL and SQL Server wire protocols

Alongside the PostgreSQL wire protocol, the engine carries two more wire listeners: one compatible with the MySQL wire protocol and one compatible with the TDS protocol used by Microsoft SQL Server. Both read and write the same store your HTTP API calls see. This page is written for someone holding a client and a connection string.

off by default · enabled per tenant

Both adapters are built into the engine and are inert until configured. No tenant gets a MySQL or TDS listener by default, and enabling one is not self-serve today - talk to us and we will tell you whether your tenant can be served at all, which the credential models section explains. Everything described here also works today over the SQL endpoint of the HTTP API and over the PostgreSQL wire.

Check what is bound, not what is configured

The available flag in /v1/capabilities means configuration is complete. It is a spawn precondition, not an observed bound port. A listener can report available: true and still have refused to bind - which is exactly what the two refusals below do. So check the socket, then check a real login.

# 1. Does the tenant report the listener as CONFIGURED?
#    The families sit at the TOP level of the payload, not under a wrapper.
curl -s "https://$OC_HOST/v1/capabilities" \
  | jq '.mysql_wire_dialect.available, .mssql_tds_dialect.available'

# 2. Is a socket actually LISTENING?
#    (1) can say "configured" while (2) refused to bind, so run both -
#    and give the port scan a control, or a silent "no" proves nothing.
nc -vz $OC_SQL_HOST 443      # control: this MUST answer
nc -vz $OC_SQL_HOST 3306     # MySQL wire
nc -vz $OC_SQL_HOST 1433     # TDS
# If the control line fails too, your path is blocked and you have
# measured your own network, not our listener.

# 3. Prove it end to end with a real login, not a port scan.
mysql --host=$OC_SQL_HOST --port=3306 --user=$OC_DB_USER \
      --ssl-mode=REQUIRED -e "SELECT 1"

There is no default port. Each listener binds whatever address it is configured with, and we follow the convention each client expects - 3306 for the MySQL wire, 1433 for TDS - so a client's own defaults usually need no override. The host and port your tenant is actually published on are the ones we give you when the listener is enabled. Use those, not the examples on this page.

Credential models, and which one your tenant is on

Both listeners support two models, and the choice is not free - it is decided by whether your tenant uses database RBAC.

Model What the client sends Use it when
shared One listener-wide password. Every session arrives with the same identity. Your tenant has no database RBAC grants at all. A shared credential cannot enforce per-user grants, so the engine will not let you combine the two.
per-user Each database user authenticates with its own password verifier, and its own grants apply. Your tenant has any database RBAC grant. This is the only model that can serve it.

Two refusals you should expect, and what they mean

These are designed behaviour, not faults. The engine refuses to bind a listener whose rules it could not enforce, rather than binding one that quietly serves more than it should.

1. A shared credential against a tenant that has RBAC:

DENIED: this tenant has database RBAC configured on N object(s) (...).
ANY grant counts

Remedy: switch the listener to per-user login. Any grant counts - one grant on one object is enough to make a shared credential inadmissible, and so does any row-level security policy; a row policy is not the lighter case. Note what that buys and what it costs: the session becomes enforced, and read-only.

2. Per-user login against a tenant with no usable database user:

DENIED: per-user login is configured but this tenant has NO enabled
database user with a password verifier, so no session could ever be
admitted. Refusing to bind.

Remedy: create at least one enabled database user that has a password verifier.

-- Give the tenant at least one ENABLED database user WITH a password
-- verifier, and the per-user listener has something it can admit.
CREATE USER app_service WITH PASSWORD 'generate-a-real-secret-here';
GRANT SELECT, INSERT, UPDATE ON invoices TO app_service;
per-user login is read-only - on both listeners

A per-user session refuses every write, on the MySQL listener and on the TDS listener alike. 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 either adapter. INSERT, UPDATE, DELETE and DDL all come back refused, and no configuration turns this on.

Read that together with the rule above - any grant at all forces per-user - 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 either wire protocol at all. It must be on per-user login, and per-user login does not write. There is no combination of settings that yields an authorizing, writing session on these two doors today.

Every write shown on this page therefore assumes a shared-credential listener - the one case the shared model is admitted in, which is also the only case in which these doors write. If your tenant has any authorization to enforce, keep these listeners for reads and write through the HTTP API or the PostgreSQL wire, where writes under an enforced identity are served.

Put the two together and there is a real state that neither model can serve: a tenant that has database RBAC or a row-level security policy but has no database user with a password verifier. Shared login is refused because that authorization is present; per-user login is refused because there is no account to admit. This is not an exotic corner - it is the ordinary state of a tenant whose access has only ever been through API tokens. Adding one database user with a password is the whole fix.

MySQL wire protocol

Point any client that speaks the MySQL wire protocol at $OC_SQL_HOST:3306, authenticate with your tenant's shared or per-user credential, and issue SQL.

TLS is not optional here

The MySQL adapter fails open on TLS - but only on the shared credential model. Configured with a shared credential and no certificate it still binds, with TLS not required - and then every query and every result row crosses the network in cleartext, including columns your masking policy rewrites and rows your row-level security filters. A per-user listener is the opposite: it refuses to start at all without a certificate, because that model reads the cleartext password off the session and will not do so unencrypted. So the fail-open hazard below is a shared-credential hazard; per-user login has TLS settled for you. Those protections are applied inside the engine; they do nothing about an observer on the wire. So a MySQL listener must never be enabled without a certificate, and your client should demand TLS rather than merely accept it - --ssl-mode=REQUIRED for the CLI, or ssl with rejectUnauthorized: true for mysql2. The TDS listener behaves the opposite way and fails closed.

Worked example - Sequelize 6.37 over mysql2 3.24

// Verified against the engine with Sequelize 6.37 on mysql2 3.24.
import { Sequelize, DataTypes } from "sequelize";

const sequelize = new Sequelize(
  process.env.OC_DATABASE,
  process.env.OC_DB_USER,
  process.env.OC_DB_PASSWORD,
  {
    host: process.env.OC_SQL_HOST,
    port: 3306,
    dialect: "mysql",
    dialectModule: require("mysql2"),
    dialectOptions: {
      // Required in practice. See "TLS is not optional here" below.
      ssl: { minVersion: "TLSv1.2", rejectUnauthorized: true },
    },
    // The engine cannot reflect a schema yet, so DEFINE every model
    // explicitly and never call sequelize.sync() to discover one.
    define: { timestamps: false, freezeTableName: true },
  },
);

const Invoice = sequelize.define("invoices", {
  id: { type: DataTypes.INTEGER, primaryKey: true },
  customer_ref: DataTypes.STRING,
  amount_cents: DataTypes.INTEGER,
  status: DataTypes.STRING,
});

await sequelize.authenticate();

// Single-table reads and writes work, transactions included.
const tx = await sequelize.transaction();
try {
  await Invoice.create(
    { id: 4101, customer_ref: "ACME-77", amount_cents: 129900, status: "open" },
    { transaction: tx },
  );
  await Invoice.update({ status: "settled" }, { where: { id: 4101 }, transaction: tx });
  await tx.commit();
} catch (err) {
  await tx.rollback(); // Rollback is honoured; the row does not appear.
  throw err;
}

const open = await Invoice.findAll({
  where: { status: "open" },
  order: [["amount_cents", "DESC"]],
  limit: 50,
});

What this does not do

Measured against the real engine, on a listener using the shared credential model: a full ORM connects, authenticates, and runs a complete single-table workload - reads, writes, and transactions that both commit and roll back. On a per-user listener the writes in that sentence do not happen at all - see below. Two further things it cannot do on either model:

  • Schema reflection. The ORM cannot read table shapes back from the listener, so anything that discovers a schema - describeTable, sync, model autoloading, and the migration tooling built on them - does not work. Declare your models by hand.
  • Associations. Related models do not load. Eager loading with include and the lazy accessors that go with it do not resolve.
do not call sync()

A failed sync() is not a no-op. DDL is never buffered on this listener - CREATE, DROP, ALTER, TRUNCATE and RENAME go straight to the store instead of through the transaction buffer, and an implicit block that was open is force-committed first so it cannot be discarded behind them. That is the mechanism, not an implicit commit arriving afterwards: the CREATE TABLE statements a sync has already issued stay applied when the reflection step it runs next fails, and a ROLLBACK will not take them back. You are then left with a half-built schema and an exception that does not say so. Create tables deliberately - through the schema API or explicit DDL - and keep the ORM to reads and writes.

The practical consequence is worth stating plainly: this suits a single-table application, not a modelled domain. If your application's value is in its object graph, the MySQL listener is not the right door - use the SQL endpoint or the PostgreSQL wire, where joins are served. Fixes for both gaps are in flight. They are not shipped, and you should not plan against them.

// NOT served today. Both of these fail against the MySQL listener.

// 1. Schema reflection - there is nothing to read a table shape back from.
await sequelize.getQueryInterface().describeTable("invoices");   // fails
await sequelize.sync();                                          // fails

// 2. Associations - a modelled domain does not load.
Invoice.hasMany(LineItem, { foreignKey: "invoice_id" });
await Invoice.findAll({ include: [LineItem] });                  // fails

// Do this instead: declare every model by hand (as above) and issue the
// second query yourself, joining in your own application code.
const invoices = await Invoice.findAll({ where: { status: "open" } });
const items = await LineItem.findAll({
  where: { invoice_id: invoices.map((i) => i.id) },
});

TDS - the SQL Server wire protocol

Point a TDS client at $OC_SQL_HOST:1433. The same two credential models apply.

TLS is mandatory, and it fails closed

A certificate and key are required. Without loadable PEMs the port is not bound at all - there is no cleartext fallback and no degraded mode to ship by accident. If the port does not answer, an unloadable certificate is the first thing to check. Set encrypt=true and leave trustServerCertificate at false so your client actually validates the chain.

Worked example - mssql-jdbc 13.4.0 and 12.10.1

// Verified with 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 - the listener is TLS-only
    + ";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"))) {

    // Prepared statements, including server-side handles and reuse.
    try (PreparedStatement ps = conn.prepareStatement(
            "SELECT 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"));
            }
        }
    }

    // Batch execution.
    conn.setAutoCommit(false);
    try (PreparedStatement ins = conn.prepareStatement(
            "INSERT INTO invoices (id, customer_ref, amount_cents, status)"
          + " VALUES (?, ?, ?, ?)")) {
        for (Object[] row : rows) {
            ins.setInt(1, (Integer) row[0]);
            ins.setString(2, (String) row[1]);
            ins.setInt(3, (Integer) row[2]);
            ins.setString(4, (String) row[3]);
            ins.addBatch();
        }
        ins.executeBatch();
    }
    conn.commit();
}

Worked example - microsoft/go-mssqldb 1.11.0

// Verified with microsoft/go-mssqldb 1.11.0 - 21 of 21 checks passed.
package main

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

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

func main() {
    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: url.Values{
            "database": {os.Getenv("OC_DATABASE")},
            // Mandatory - the listener is TLS-only and validates.
            "encrypt":                {"true"},
            "TrustServerCertificate": {"false"},
        }.Encode(),
    }).String()

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

    rows, err := db.QueryContext(context.Background(),
        "SELECT id, customer_ref, amount_cents FROM invoices"+
            " WHERE status = @p1 ORDER BY amount_cents DESC",
        sql.Named("p1", "open"))
    if err != nil {
        panic(err)
    }
    defer rows.Close()

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

How far this has been proven

The driver-level defects that previously blocked both commercial drivers are fixed, and interop is proven end to end against the real engine - the framed TLS handshake, prepared-statement handles and batch execution included. The measured results were 34 of 34 for mssql-jdbc (13.4.0 and 12.10.1) and 21 of 21 for microsoft/go-mssqldb 1.11.0.

One qualification we would rather state than have you discover: that interop battery runs in no automated job. It was executed deliberately and it passed, so the accurate word is verified, not continuously verified. A regression introduced by a future engine build would not be caught by a scheduled run today, so re-run the battery against the build you intend to deploy rather than trusting the figures above to still hold.

And a second, narrower one, because it lands exactly where the credential models do: those driver runs were made against a listener on the shared credential model, on a tenant with no database RBAC - the one case the shared model is admitted in. Per-user login has not yet been driven by either commercial driver; it is exercised only by our own test client, and we are not going to tell you it is proven when the drivers that matter to you have not been pointed at it. It also does not behave the same way, and in one respect the difference is decisive rather than incidental: a per-user session is read-only, on both the MySQL and the TDS listener. So the driver figures above were measured on a model that writes, against a model that does not. If your tenant has RBAC or a row-level security policy - and so must be on per-user - do not read those numbers across; pilot the login leg first, and plan your writes onto another door.

Applications written for Oracle Database

dialect, not transport

This distinction decides whether you can adopt us, so it is worth being blunt about. We support a slice of the Oracle SQL dialect, and we serve it over the PostgreSQL wire. We do not implement Oracle Database's TNS network protocol, there is no listener on 1521, and we do not intend to build one. That is a decision, not a backlog item - the compatibility matrix answers no, not later.

The consequence: an Oracle client library cannot connect to us at all. An Oracle-shaped application connects through a PostgreSQL driver, and what has to port is the SQL, not the transport. If your application can be repointed at a PostgreSQL driver, a large slice of its SQL already works unchanged. If it is welded to an Oracle client library, it cannot connect, and no amount of dialect coverage changes that.

# An application written for Oracle Database connects with a PostgreSQL
# driver, on the PostgreSQL wire port. There is no TNS listener to point
# an Oracle client library at.

psql "host=$OC_SQL_HOST port=5432 dbname=$OC_DATABASE user=$OC_DB_USER sslmode=require"

Served, and equivalent within scope

Construct Scope
DUAL The one-row dual table.
NVL Two-argument form; the operands must share one type. An empty string is not NULL here.
NVL2 Three-argument form; the branches must share one type. An empty string is not NULL here.
Sequences CREATE SEQUENCE and the sequence object itself.
NEXTVAL pseudocolumn seq.NEXTVAL as a projection - the Oracle spelling.
MINUS A pure spelling alias for EXCEPT: the same operator, whole-word rewritten.

Served, but narrower than Oracle Database

Each of these ships and is useful, and each is narrower than the construct you know. Where a shape falls outside the served scope it is refused by name rather than executed with the clause quietly dropped - a dropped clause would return a confident, wrong answer.

Construct Where it narrows
CONNECT BY START WITH ... CONNECT BY [PRIOR] col = col over a single relation, rewritten to the equivalent WITH RECURSIVE. The hierarchy walk is served, and so are the pseudocolumns LEVEL, CONNECT_BY_ROOT col and SYS_CONNECT_BY_PATH(col, sep) - each carried down the recursion, so WHERE LEVEL <= 3 post-filters the walked hierarchy exactly as Oracle Database does. Refused by name: CONNECT_BY_ISLEAF, ORDER SIBLINGS BY, a PRIOR outside the CONNECT BY condition, more than one relationship, anything but a single PRIOR col = col equality, joins or multiple tables, a subquery in the FROM, and GROUP BY / HAVING / DISTINCT. A pseudocolumn is also refused as a sort key in every spelling - sort on a plain column instead. Nothing is executed with the clause dropped: that would return the unwalked base table with a 200.
ROWNUM The row-cap idioms port, and Oracle Database's counter semantics are preserved rather than approximated. WHERE ROWNUM <= n becomes a row cap; a projected ROWNUM rn numbers the returned rows from 1; the nested pagination idiom (... WHERE ROWNUM <= 20) WHERE rn > 10 returns the window Oracle returns; and WHERE ROWNUM > 1 matches nothing, as in Oracle, rather than being lowered to an offset. Refused by name, because ROWNUM is assigned before the sort and before grouping: a cap in the same block as ORDER BY, or alongside DISTINCT / GROUP BY / HAVING / an aggregate / a window function; a cap on a level that already carries LIMIT / OFFSET / FETCH; and ROWNUM under an OR, compared to a column, or used in GROUP BY / HAVING / ORDER BY / a join condition. For top-n write ORDER BY x FETCH FIRST n ROWS ONLY. One inconsistency to know about: the capability payload still folds ROWNUM into its rowid entry and reports it as roadmap, because that entry covers both pseudocolumns and has not been split yet. The behaviour above is what the engine does.
DECODE NULL equals NULL, and there is no implicit conversion of the search terms.
INSTR Two-argument form only; the position form is refused.
SUBSTR PostgreSQL semantics, positive offsets only.
MERGE Ships, and is narrower than Oracle Database's full MERGE grammar.
(+) outer join One provably equivalent shape: a single SELECT over exactly two comma-joined tables, all marks on one side, each marked predicate a plain col = col. Every other shape is refused rather than answered as an inner join, which would silently lose the unmatched rows the query was written to keep.
Recursive queries WITH RECURSIVE, within a documented narrower scope.
SYSDATE Ships, narrower than Oracle Database.
TO_DATE Ships, narrower than Oracle Database.

Refused today, with the portable rewrite

Construct What to write instead
ROWID Refused. Row identity here is the declared PRIMARY KEY, which every registered table has. A ROWID in Oracle Database is a physical address and a primary key is not one, so code that persisted ROWIDs was relying on something a primary key does not provide - that logic needs revisiting rather than translating. The row-cap uses of ROWNUM are a different construct and do port; see the preview table above.
PIVOT / UNPIVOT Refused. Build the cross-tab with conditional aggregates - SUM(CASE WHEN ... THEN ... END) with a GROUP BY - which is served over a single table and over a join.
Flashback query Refused: AS OF TIMESTAMP, AS OF SCN, VERSIONS BETWEEN. A historical read here is an operator-driven point-in-time restore at the backup surface, not a clause inside a SELECT.
PL/SQL Refused. Stored program units are not accepted.
TNS network protocol Not implemented, and not planned - the compatibility matrix answers no, not later. See the callout above.
-- Served, and equivalent within the documented scope:
SELECT NVL(discount_pct, 0), NVL2(shipped_at, 'shipped', 'pending') FROM orders;
SELECT seq_invoice.NEXTVAL FROM DUAL;
SELECT sku FROM catalogue MINUS SELECT sku FROM discontinued;

-- Served, but narrower than Oracle Database (see the scope column):
SELECT LEVEL, id, manager_id FROM staff
  START WITH manager_id IS NULL
  CONNECT BY PRIOR id = manager_id;
SELECT DECODE(status, 'O', 'open', 'C', 'closed', 'other') FROM invoices;
MERGE INTO invoices t USING staging s ON (t.id = s.id)
  WHEN MATCHED THEN UPDATE SET t.status = s.status;

-- ROWNUM row caps port, with Oracle's counter semantics kept:
SELECT id FROM invoices WHERE ROWNUM <= 10;          -- becomes a row cap
SELECT id FROM invoices WHERE ROWNUM > 1;            -- returns NOTHING, as in Oracle
SELECT * FROM (                                      -- the pagination idiom, verbatim
  SELECT a.*, ROWNUM rn FROM ( SELECT id, ref FROM invoices ORDER BY id ) a
  WHERE ROWNUM <= 20
) WHERE rn > 10;

-- REFUSED by name, because ROWNUM is assigned BEFORE the sort: this is
-- ten arbitrary rows THEN sorted in Oracle, not the top ten by date.
-- SELECT * FROM invoices WHERE ROWNUM <= 10 ORDER BY created_at;
SELECT * FROM invoices ORDER BY created_at FETCH FIRST 10 ROWS ONLY;

-- NOT served. ROWID has no equivalent; row identity is the PRIMARY KEY.
-- SELECT ROWID, id FROM invoices;                   -- refused

A note on the names used on this page

Every third-party name here is used to describe protocol or dialect compatibility, and for no other purpose. MySQL and Oracle are registered trademarks of Oracle Corporation and/or its affiliates. Microsoft and SQL Server are trademarks of the Microsoft group of companies. PostgreSQL is a registered trademark of the PostgreSQL Community Association of Canada. Sequelize, mysql2, mssql-jdbc and go-mssqldb are the property of their respective owners. OriginChainDB is not affiliated with, endorsed by, or sponsored by any of them. OriginChainDB is not any of these products - it is an independent database that speaks their wire protocols and accepts parts of their SQL.