Skip to main content

sql implemented read profiles/v1alpha1

Reads and catalogue reads implemented on PostgreSQL and MySQL

Rendered from adapters/sql/contracts/reads/v1alpha1/semantics.md by connectors-docs; the source owns every rule on this page.

This adapter owns the native behavior of its existing local implementation. Shared wire envelopes and admission remain in service v1alpha1. This relocation changes no runtime or selected codec.

SQL: PostgreSQL (and, below, MySQL) schema discovery and parameterized, bounded single-statement reads through a configured role/database. Database-enforced read-only transactions and an adapter-owned execution deadline are mandatory. Each transaction installs statement/lock timeouts as additional server-side guards. Caller-accessible functions can change those session settings, including between portal executions; they cannot extend the adapter's deadline. Schema scope is enforced by database grants, not a string-prefix SQL filter. Preserve column type metadata, NULL, arrays/JSON and numeric values without silently converting unsupported values into strings. The first postgresql-native-text profile encodes non-NULL cells as PostgreSQL's lossless text representation alongside each column's native type name; NULL is JSON null. Numeric precision, arrays, timestamps and JSON are preserved as native text rather than coerced to JavaScript numbers. Parameters are text or null with server-side type resolution. This representation is explicit in the descriptor. Report truncation explicitly; no invented resumable cursor for a fresh SQL query (a read profile's keyset cursor follows a column the operator declared, and is a new read after a value, not a resumed one). Each request uses a fresh connection and read-only transaction, initialized with search_path = public, pg_catalog; qualify names in other schemas. Session settings are not a substitute for the configured role's schema and function grants. No connection pool is claimed in this local profile. A configured CA bundle replaces public trust roots for SQL; empty bundles are refused. Do not advertise writes, transactions across requests, or dialect portability. PostgreSQL SQLSTATE 0A000 (feature not supported) returns unsupported, not unavailable. The statement runs as a derived table that closes on its own line; a syntax error (42601) answered to that wrapper, after the statement itself prepared, means the statement is not a query (DELETE … RETURNING, EXPLAIN, SHOW) and returns unsupported (see how a statement that is not a read is refused). Source facts: https://www.postgresql.org/docs/current/sql-set-transaction.html and https://www.postgresql.org/docs/current/runtime-config-client.html. Source access: 2026-09-08.

Native deadlines and bounds​

Within the shared 15 s provider budget (connect <=5 s), execution is at most 10 s after connection, with up to 2 s reserved for cancellation/cleanup. Slow connection reduces remaining execution time. Initial statement and lock timeouts are 10 s and 2 s. Native limits are 1,000 rows and 8 KiB query text; callers may request smaller row limits, but configuration exposes no ceiling overrides.

An independent execution deadline triggers backend-keyed cancellation on failure, deadline or dropped invocation, using the established endpoint and captured TLS trust. The supervisor drives cleanup for at most 2 s if the caller disappears, then closes the connection. Cancellation has no acknowledgement; an unreachable database, process crash or shutdown may prevent backend termination. Finite local wait does not guarantee zero query cost or remote termination.

MySQL engine​

A connection whose configuration names "engine": "mysql" serves the same operations, including the catalogue reads below, with the same input bounds, deadlines and output shape over the MySQL wire protocol; a configuration without engine is PostgreSQL, unchanged, and its effective configuration (and so its revision) carries no engine member. The instance descriptor names the profile mysql-native-text for every operation.

Each request uses a fresh session made READ ONLY (SET SESSION TRANSACTION READ ONLY) and bounded (max_execution_time 10 s, lock_wait_timeout and innodb_lock_wait_timeout 2 s), with its time_zone set to +00:00, before the caller's statement is prepared. A statement must return between one and 256 columns, so a statement that returns no rows is refused as unsupported before execution; it then runs as a derived table with positional column aliases and LIMIT <limit+1>, so only one result-returning statement runs and truncation is reported without draining the result. A statement that prepares with columns but cannot be a derived table (SHOW, EXPLAIN), which the server refuses with error 1064 on the wrapper, is unsupported, as on PostgreSQL. A table write the read-only session refuses (error 1792), including one inside a stored function, is forbidden. The guarantee is that read-only session plus one result-returning statement; it does not prevent the side effects of a routine the statement calls. A function the configured user may execute runs with its own rights: on a live MySQL 8.0.46 a definer-rights function called through query.read ran SET PERSIST and SET GLOBAL, and the change persisted. The configured user's grants are therefore the boundary for routines: grant SELECT only, and no EXECUTE on a routine with side effects. Parameters are text or null, bound positionally.

mysql-native-text writes every non-NULL cell as a JSON string by its column's family, never coercing a database value to a JSON number: integers (signed, unsigned, YEAR) as decimal text; DECIMAL as the server's exact text; floating values as the shortest text that reads back as the same value (positional on a tie, otherwise the exponent form 1e300, 1e-4; PostgreSQL writes 1e+300); DATE as YYYY-MM-DD; DATETIME, which records no time zone, as the naive local date-time YYYY-MM-DDTHH:MM:SS with as many fractional digits as the column declares and no offset; TIMESTAMP, an instant, as that instant in UTC in the same form followed by Z (the session time zone is UTC, so the server converts it before sending it, whatever its own time_zone; two distinct instants are never written alike across a daylight-saving change); TIME as [-]HH:MM:SS[.ffffff] with unbounded hours (an elapsed time, not a time of day); binary-charset strings, BIT and geometry as padded standard base64; other strings, ENUM, SET and JSON as their text. A text cell that is not valid UTF-8 is refused as unsupported, not replaced. NULL is JSON null. Column metadata carries MySQL's lower-case type name, with unsigned for unsigned integers. The adapter's ESS model (adapters/sql/spec/ess, connectors_sql.reads) fixes these rules.

A connection is bound to one database on both engines, and on MySQL a schema is a database, so schema.list on MySQL reads only the connected database: schema may be omitted, which reads it, or name it exactly; any other value is refused as invalid_input before a session is opened or any query sent. It reads information_schema.COLUMNS for that database, returning the PostgreSQL path's seven columns, and the provenance resource names it; udt_name carries MySQL's COLUMN_TYPE. On PostgreSQL schema remains required (an input without it is invalid_input) and names a schema inside the connected database.

The connection uses only its configured endpoint (never a server-named Unix socket), installs no local-file handler, and without allow_plaintext requires TLS before any credential is sent; a configured CA bundle replaces the public roots and is refused when empty. The adapter's own deadline or a dropped invocation kills the statement with KILL QUERY <connection id> from a second session within the 2 s cleanup budget, then closes the session; as on PostgreSQL this does not guarantee remote termination. MySQL errors are classified by number where the SQLSTATE class is too coarse (access refusals 1044, 1142, 1143, 1227 and 1370 are forbidden, 1045 unauthorized, 1235 unsupported, 3024 timeout and 1040/1203 capacity, the last two as the server's answer), and otherwise by SQLSTATE as above. MySQL 8.0 or later is assumed; MariaDB is not claimed. Source facts: https://dev.mysql.com/doc/refman/8.4/en/set-transaction.html, https://dev.mysql.com/doc/refman/8.4/en/server-system-variables.html, https://dev.mysql.com/doc/refman/8.4/en/derived-tables.html, https://dev.mysql.com/doc/refman/8.4/en/information-schema-columns-table.html, https://dev.mysql.com/doc/refman/8.4/en/kill.html and https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_binary_resultset.html. These MySQL pages were cited, not fetched, when this section was written (2026-10-09); the wire behavior is checked against a scripted fixture. The ignored live cases in adapters/sql/tests/ ran against disposable MySQL 8.0.46 and PostgreSQL 17.11 servers on 2026-10-09; they are not part of the default suite.

Catalogue reads​

Four further reads describe the catalogue on both engines: database.list, table.list, table.describe and index.list. Each is a declared read (effect: read in the local bootstrap) and runs one fixed statement per engine through the query.read path: the same fresh read-only transaction (PostgreSQL) or READ ONLY session (MySQL), deadlines, limit of 1 to 1,000 rows, byte limit and explicit truncated flag. The caller's schema and table are bound as text parameters and never written into the statement. Each answer has the relational result shape of query.read under the engine's profile, so every cell is native text or JSON null; integers are decimal text, and flags are YES or NO on both engines. The columns are fixed, in this order:

operationinputcolumns
database.listlimitdatabase_name
table.listschema, limittable_name, table_kind (table or view), row_estimate
table.describeschema, table, limitcolumn_name, native_type, is_nullable, column_default, ordinal_position, primary_key_position, foreign_key, referenced_schema, referenced_table, referenced_column
index.listschema, optional table, limitindex_name, table_name, column_position, column_name, is_unique, is_primary

schema follows schema.list: on PostgreSQL it is required and names a schema of the connected database; on MySQL it may be omitted or name the connected database, and any other value is refused as invalid_input before a session is opened. database.list takes no schema: it lists names only and opens no other database. On PostgreSQL it reads pg_database, excluding templates, databases that refuse connections and those the role lacks CONNECT on; on MySQL it reads information_schema.SCHEMATA, which shows the databases the user holds a privilege on (all of them with SHOW DATABASES).

table.list reports PostgreSQL ordinary and partitioned tables as table and views and materialized views as view, and MySQL base tables and views. row_estimate is the engine's own estimate, not a count: pg_class.reltuples (null when the table was never analysed) or information_schema.TABLES.TABLE_ROWS (cached statistics, an approximation for InnoDB); it is null for every view.

table.describe answers one row per column in ordinal order. native_type is the full type (format_type on PostgreSQL, COLUMN_TYPE on MySQL), column_default the engine's own text of the default or null, and primary_key_position the column's 1-based place in the primary key or null. A generated column has no default: on PostgreSQL its generation expression (attgenerated) is not reported as column_default, as information_schema.columns does not report it, and MySQL's COLUMN_DEFAULT is null for it. A column in a foreign key carries the constraint name and the referenced schema, table and column paired with it, so a composite key appears on each of its columns; a column in several foreign keys has one row per key, ordered by constraint name. A row is one declared key: on PostgreSQL a key into a partitioned table is reported once, not once more per referenced partition (the internal clone constraints PostgreSQL keeps on the referencing table, whose parent constraint is on that same table, are left out), and a partition reports the key it inherits from its partitioned parent as its own. A table with no row is refused as not_found, whether it does not exist or the configured role or user can see none of its columns: the two are not told apart.

Foreign keys into what the caller cannot see differ by engine. On PostgreSQL a foreign key is reported only when the role holds USAGE on the referenced schema and some privilege on the referenced table (or is a member of its owner), as information_schema decides, and a column privilege on every referenced column, so no row names a column that table.describe of the referenced table hides; otherwise each column of the key is still described, without that key. On MySQL information_schema.KEY_COLUMN_USAGE decides: it lists a foreign key of a table the user can see and names its referenced schema, table and column even when the user holds no privilege on them, so table.describe can name a referenced database that database.list does not list, and a referenced column the user cannot read.

index.list answers one row per key column of each index, in index order, for one table or, without table, for every table of the schema; included (non-key) columns are not listed. An expression key part carries the engine's text of the expression in column_name. A table with no index, and a table that does not exist, are both an empty answer. On PostgreSQL an index follows column visibility: it is left out when any column it is built on is one table.describe hides (one the role holds no column privilege on and does not own through the table's owner). That covers a key column, an INCLUDE column and a column used in a key expression; a column used only in a partial index's predicate counts too, because PostgreSQL records expression and predicate columns alike (pg_depend). A whole-row reference (such as (t IS NOT NULL), in a key expression or the predicate) is built on every column of the table, so it counts as every column; PostgreSQL records no dependency for it, and the read finds it in the stored expression and predicate. PostgreSQL gives an index with no plain key column a dependency on the whole table (refobjsubid = 0), which counts as every column as well: an index on expressions alone, such as ((pub + 1)), is listed only to a role that owns the table or holds a column privilege on every column, even when the expression uses only visible columns.

MySQL cannot hold a table name that ends in a space, and its metadata collation (utf8mb3_bin) pads, so a lookup of orders would match orders. On MySQL, table.describe and index.list answer such a name not_found before any session is opened. A name the utf8mb3 metadata cannot represent (a supplementary character) makes the server answer error 3988 to the bound lookup; these reads answer it not_found, as no table can carry that name. query.read keeps the shared classification, under which 3988 (HY000) is unavailable. On PostgreSQL both kinds of name are ordinary names and are looked up as given.

Visibility is the engine's: on PostgreSQL a relation or column is listed when the role owns it (or is a member of its owner) or holds a privilege on it, as information_schema decides; on MySQL information_schema shows only what the user holds a privilege on. The adapter's ESS model (connectors_sql.reads.CatalogScope and the row types DatabaseRow, TableRow, ColumnRow and IndexRow) fixes these rules. The statements are checked on scripted wire fixtures of both engines. On 2026-10-09 the generated-column, foreign-key, index-visibility and MySQL name rules were also run against disposable PostgreSQL 17 and MySQL 8.0.46 servers (adapters/sql/tests/catalogue_adversary.rs and catalogue_adversary_2.rs, ignored by default). Source facts: https://www.postgresql.org/docs/current/catalog-pg-database.html, https://www.postgresql.org/docs/current/catalog-pg-class.html, https://www.postgresql.org/docs/current/catalog-pg-attribute.html, https://www.postgresql.org/docs/current/catalog-pg-constraint.html, https://www.postgresql.org/docs/current/catalog-pg-index.html, https://dev.mysql.com/doc/refman/8.4/en/information-schema-schemata-table.html, https://dev.mysql.com/doc/refman/8.4/en/information-schema-tables-table.html, https://dev.mysql.com/doc/refman/8.4/en/information-schema-key-column-usage-table.html and https://dev.mysql.com/doc/refman/8.4/en/information-schema-statistics-table.html, cited, not fetched, on 2026-10-09.

Read profiles​

A connection may declare read profiles in its owner-only native file (read_profiles, at most 32, distinct ids). A read profile is the operator's: one read statement with the engine's placeholders ($1… on PostgreSQL, ? on MySQL), the names of its parameters in placeholder order, its output columns in statement order, each with a declared type (text, integer, timestamp or phone_number) and optional masking, the column that carries the call identifier and the column the keyset cursor follows. The repository holds only this shape and its validation, which the adapter's ESS model (connectors_sql.profiles, and the profile rules of connectors_sql.connection.LocalConfiguration) fixes; the composition admits a profile exactly when the model does. Profiles are part of the binding: the effective configuration carries them, so a changed profile is a changed configuration revision, and a file without profiles (or with an empty list) keeps the revision it had before profiles existed.

profile.read serves them. It is advertised only by a connection that declares a profile, is a declared read (effect: read) and needs no mutation profile. The instance descriptor publishes each profile's exact input and output as one alternative of the operation's input and output schemas, so operations describe shows the parameters a caller may pass and the typed columns it gets back. The caller names the profile, gives a value (text or null) for each declared parameter and nothing else, and may pass a cursor and a limit of 1 to 1,000. An unknown profile, an undeclared or missing parameter, or any other member is invalid_input; a cursor the checks below do not admit is stale_cursor. Both are refused before any session is opened. Values are bound as text parameters, never written into a statement.

A cursor is not signed, so the adapter cannot tell a cursor it issued from one built to look like it. What it checks is shape, context and type, and a cursor that fails any of them is stale_cursor. Shape: the token is base64url without padding of exactly the object {"profile", "context", "after"} or {"profile", "context", "after_base64"}, in the one form the adapter issues for its value (plain when JSON writes the value without an escape, otherwise base64url of its UTF-8 bytes), with a value of at most 8,192 bytes. Context: profile names this profile, and context is the digest of the instance's revision (which covers the connection's endpoint, database and user and the full definition of every profile), the profile's definition and the parameter values, so a cursor issued before any change to the profile, on another connection or for other values is stale. Type: the ordering column's declared type admits the value, as it must admit a returned one, so an integer cursor that is not an integer never reaches a cast that would read it as 0. A cursor that passes all three but was never issued carries a value of the right type for this read: the ordering column is never masked, so it only moves where a read the caller may make anyway starts. The longest token the adapter issues (a 64-character id and an 8,192-byte value that JSON must escape throughout) is 14,795 characters, inside the 16,384 the published schemas admit; an ordering value longer than 8,192 bytes refuses the page as unsupported rather than issuing a cursor the schemas would refuse.

A page runs the statement through the query.read path: the same fresh read-only transaction (PostgreSQL) or READ ONLY session (MySQL), deadlines and byte bounds, and the same refusals of a statement that is not a read (below) before it runs. A statement that returns other than its declared number of columns is unsupported, also before it runs. The statement runs inside the derived table with positional aliases, which closes on its own line on both engines (so a statement ending in a -- comment cannot reach the aliases), ordered by one expression E of the ordering column and asking for one row past the limit; a continuation compares the cursor by the same expression: … \n) AS result(c0,…) [WHERE E(result.c<k>) > E($<n+1>)] ORDER BY E(result.c<k>) LIMIT <limit+1> on PostgreSQL and … \n) AS result (c0,…) [WHERE E(c<k>) > E(?)] ORDER BY E(c<k>) LIMIT <limit+1> on MySQL, where the cursor's value is bound after the statement's own parameters. E is chosen by the declared type and the column's native type when the statement is prepared, before any row is read (connectors_sql.profiles.KeysetOrdering):

enginedeclared typenative columnE(x)
PostgreSQLtext, phone_numberanyx::text COLLATE "C": the returned text, by its bytes
PostgreSQLintegerint2, int4, int8x
PostgreSQLtimestamptimestamp, timestamptzx
MySQLtext, phone_numbera text string (CHAR, VARCHAR, TEXT, ENUM, SET)CAST(CONVERT(x USING utf8mb4) AS BINARY)
MySQLintegera signed or unsigned integer, YEARCAST(x AS DECIMAL(65,0))
MySQLtimestampDATETIME, TIMESTAMPCAST(x AS DATETIME(6)), the Z of a TIMESTAMP cursor dropped (the session is UTC)

Every other pairing is refused as unsupported, naming the profile's column, before the page is read. Each admitted pairing orders exactly as the cursor compares, and two returned values are equal under E exactly when their texts are equal. That is why the rest are refused: MySQL writes a binary string, BIT and geometry as base64, whose order is not the order of their bytes; it writes floating, decimal, JSON, date and time values otherwise than its own text conversion; and an integer or timestamp declared over a text column orders as text, not as the declared type. ENUM and SET, ordered by their bytes rather than their index, are consistent. The text expressions also keep a case- or pad-insensitive collation from tying two distinct values.

A server, or a proxy in front of it, may return fewer rows than any LIMIT asks for, so the adapter never takes a short page for the end. A page that received more than limit rows returns limit of them; every page that received a row carries next_cursor, after the last row it returns; only a page that received none is complete with a null cursor. The ordering column must hold distinct values for the rows the statement returns. When a page received the row past the limit and that row shares the last returned row's ordering value, the page is refused as unsupported, naming the ordering column as not unique: continuing after that value would drop the row (connectors_sql.profiles.PageBoundary). A shared value inside a page is not a boundary and is served. On PostgreSQL a row past the limit whose cells exceed the row byte bound cannot be compared, and refuses the page as capacity, as the next page would. The adapter cannot see a row a server cap withheld: when a server returns fewer rows than asked and a withheld row shares the last returned value, the next page continues after that value and the withheld row is not returned. Uniqueness of the ordering column is therefore the operator's obligation; the check above catches it only where the adapter received the next row.

A statement that is not a read is refused before it runs, the same way on query.read and profile.read, and the classes match across the engines. A statement that returns no columns (an INSERT, UPDATE or DELETE without RETURNING, a SET, a CALL) is unsupported when it is prepared, on both engines. A statement that prepares with columns but cannot be a derived table is refused by the server's syntax check of the wrapper, which the adapter answers unsupported too: on PostgreSQL that is DELETE, UPDATE or INSERT with RETURNING, EXPLAIN and SHOW (SQLSTATE 42601 on the wrapper); on MySQL, which has no RETURNING, SHOW and EXPLAIN (error 1064 on the wrapper). A PostgreSQL data-modifying WITH is refused by the server as not at the top level (0A000), also unsupported. The caller's own statement refused at its own preparation keeps the server's class (a syntax error in it is invalid_input). A write reached at run time through a function the statement calls is refused by the read-only transaction (PostgreSQL 25006) or session (MySQL 1792), and is forbidden on both.

Each item carries the declared columns under their declared names, never the database's own. Every non-null value is a JSON string: text the engine's native text; integer decimal digits with an optional -; timestamp the engine's native text, which starts YYYY-MM-DD, T or a space, then HH:MM:SS; phone_number the number. A value its declared type does not admit refuses the page as unsupported; it is never coerced. A column is masked when the profile says so, and otherwise exactly when it is a phone_number; a masked value keeps its last four characters and writes * for each earlier one, and a value of four characters or fewer is all *. The call identifier and the ordering column are never masked, are not phone_number columns, and are never null: a null in either refuses the page as unsupported. The answer names the profile, its call identifier and ordering columns and each column's type and masking, so a workflow joins on the call identifier without a model. A page whose answer would exceed the response limit is capacity; ask for a smaller limit.

The scripted wire fixtures of both engines check these rules (adapters/sql/tests/profiles.rs, profiles_adversary.rs, protocol.rs and mysql_local.rs), against a synthetic call table (tests/mysql/calls.rs), including the SQL text of each keyset expression; tests/ess_model.rs holds the executable's admission, masking, pages, page boundaries, keyset orderings, issued cursors, cursor admission and refusals to the model. No live database has run a read profile yet.